mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 14:24:31 +00:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f8197b710 | |||
| e9dd706fa6 | |||
| f8ac7f9594 | |||
| 3f5e0bc03f | |||
| 92384b96f0 | |||
| 516e69f820 | |||
| 2a6c5ef1f9 | |||
| 01a5befd87 | |||
| ae164d4fe7 | |||
| e7dc0e051e | |||
| e1478ec8b0 | |||
| 363e0719ab | |||
| 35ab0e337d | |||
| b67415bcc2 | |||
| 5df95bea29 | |||
| 7412448f9c | |||
| 6dd9dcb5d4 | |||
| 311222d478 | |||
| 72918a6dd6 | |||
| eec59f7e12 | |||
| c1ae5f0f28 | |||
| 4873d6d6dd | |||
| 1f6085acdc | |||
| d5dfed5f1a | |||
| 7923dff8b2 | |||
| 8c7423a6ab | |||
| 8f3e9f4bd8 | |||
| fca60135a6 | |||
| d66602b646 | |||
| 333b6f45ce | |||
| 1a070ce8b9 | |||
| 415d9d9a9c | |||
| 6a5e007a1b | |||
| d81b83ac91 | |||
| 6b6ca37f10 | |||
| 028984155b | |||
| 391d15221b | |||
| 269551da2f | |||
| ebd45d2991 | |||
| 733d138fcc | |||
| 503baa9a94 | |||
| d4ac44f698 | |||
| 3aedd619dc | |||
| bae2e1b400 | |||
| 0a71e6c508 | |||
| fb0b82e429 | |||
| 3c60bf5e5a | |||
| da2eb37a10 | |||
| 3156160778 | |||
| 8163ed1a9d | |||
| f37a4ab985 | |||
| dfe7bb4140 | |||
| 327ca35190 | |||
| 6e4d4da740 | |||
| ab8d463d54 | |||
| b55cd00205 | |||
| 25d70a77ed | |||
| c7b6c6c933 | |||
| 2d1a119768 | |||
| 902b692dcb | |||
| 0af5ce5935 | |||
| b07b4670fb | |||
| 581886938b | |||
| 949796df17 | |||
| 75ee76b60e | |||
| 3e14a01c92 |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
-20
@@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
branches: [ "**" ]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
branches: [ "**" ]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -14,34 +14,36 @@ jobs:
|
||||
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"
|
||||
|
||||
- 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
|
||||
|
||||
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 are not supported by some dependencies yet
|
||||
|
||||
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 }}
|
||||
|
||||
- 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
|
||||
|
||||
@@ -2,9 +2,9 @@ name: Publish
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
branches: [master]
|
||||
workflows: [ "CI" ]
|
||||
types: [ completed ]
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
@@ -18,24 +18,24 @@ jobs:
|
||||
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
|
||||
@@ -46,24 +46,24 @@ jobs:
|
||||
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]
|
||||
needs: [ version-check, build ]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
@@ -74,16 +74,16 @@ 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]
|
||||
needs: [ version-check, build ]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
@@ -91,19 +91,27 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/download-artifact@v8.0.1
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
- uses: actions/download-artifact@v8
|
||||
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
|
||||
- name: Extract latest changelog entry
|
||||
id: changelog
|
||||
run: |
|
||||
body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "body<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$body" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: v${{ needs.version-check.outputs.version }}
|
||||
name: v${{ needs.version-check.outputs.version }}
|
||||
files: dist/*
|
||||
body: ${{ steps.changelog.outputs.body }}
|
||||
make_latest: true
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ Thumbs.db
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
demo.run.py
|
||||
systests/
|
||||
|
||||
# Build & distribution
|
||||
dist/
|
||||
|
||||
+160
@@ -7,18 +7,171 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
||||
|
||||
---
|
||||
|
||||
## [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; 1–5 winners, 500–1 000 000 stars each
|
||||
- `giveaway_premium(channel, winners, months)` — Premium giveaway; 1–24 000 winners, 3/6/12 months each
|
||||
- `StarsGiveawayResult`, `PremiumGiveawayResult` result types
|
||||
|
||||
**Telegram Ads**
|
||||
|
||||
- `recharge_ads(account, amount)` — top up a Telegram Ads account; 1–1 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
|
||||
- All page URL constants built from `FRAGMENT_BASE_URL`;
|
||||
- `TransactionError` includes an SSL hint; `DUPLICATE_SEQNO` variant auto-retried up to 2 times (2 s apart)
|
||||
- Error messages rewritten: "what happened → why → what to do"
|
||||
|
||||
---
|
||||
|
||||
## [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__`
|
||||
|
||||
@@ -27,6 +180,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)
|
||||
@@ -38,5 +192,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
|
||||
|
||||
@@ -4,17 +4,16 @@
|
||||
<h1 style="margin-top: 24px;">Fragment API</h1>
|
||||
|
||||
<p style="font-size: 18px; margin-bottom: 24px;">
|
||||
<b>Python library for the Fragment.com API — purchase Telegram Stars, Premium, and top up TON Ads balance.</b>
|
||||
<b>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.</b>
|
||||
</p>
|
||||
|
||||
[](https://pypi.org/project/pyfragment/)
|
||||
[](https://pypi.org/project/pyfragment/)
|
||||
[](https://python.org)
|
||||
[](LICENSE)
|
||||
[](https://github.com/bohd4nx/pyfragment/stargazers)
|
||||
[](https://github.com/bohd4nx/pyfragment/actions)
|
||||
[](https://pepy.tech/projects/pyfragment)
|
||||
[](https://python.org)
|
||||
[](https://github.com/bohd4nx/pyfragment/actions)
|
||||
[](https://github.com/bohd4nx/pyfragment/blob/master/LICENSE)
|
||||
|
||||
[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/) · [Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -34,7 +33,7 @@ To install the latest unreleased changes from the `dev` branch:
|
||||
pip install git+https://github.com/bohd4nx/pyfragment.git@dev
|
||||
```
|
||||
|
||||
Requires Python 3.12+.
|
||||
Requires Python 3.10+.
|
||||
|
||||
---
|
||||
|
||||
@@ -52,7 +51,25 @@ Requires Python 3.12+.
|
||||
|
||||
## 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.
|
||||
**Fragment cookies** — log in to [fragment.com](https://fragment.com) and connect your TON wallet. You can get cookies in two ways:
|
||||
|
||||
- **Automatically** (recommended) — install the optional browser extra and use `get_cookies_from_browser()`, which reads them directly from your browser's on-disk store. No extension needed:
|
||||
|
||||
```bash
|
||||
pip install "pyfragment[browser]"
|
||||
```
|
||||
|
||||
```python
|
||||
from pyfragment import get_cookies_from_browser
|
||||
|
||||
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
|
||||
# result.cookies — dict[str, str] to pass to FragmentClient
|
||||
# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies
|
||||
```
|
||||
|
||||
- **Manually** — install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export these four keys: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass them as a `dict` or JSON string.
|
||||
|
||||
Refresh when you get authentication errors.
|
||||
|
||||
**Tonapi key** — generate at [tonconsole.com](https://tonconsole.com).
|
||||
|
||||
@@ -64,22 +81,13 @@ Requires Python 3.12+.
|
||||
|
||||
```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,40 +95,29 @@ 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.months} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||
wallet = await client.get_wallet()
|
||||
print(f"Wallet: {wallet.address} | TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}")
|
||||
|
||||
# Purchase 500 Stars
|
||||
result = await client.purchase_stars("@username", amount=500)
|
||||
print(f"{result.stars} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||
recipient = "https://t.me/username" # also supports: @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"Stars sent: {stars.amount} 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"Premium sent: {premium.amount} months to {premium.username} | tx: {premium.transaction_id}")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
Full runnable examples:
|
||||
|
||||
- https://github.com/bohd4nx/pyfragment/tree/master/examples
|
||||
|
||||
Payload debug/decode helper (thanks):
|
||||
|
||||
- https://ton-cell-abi-viewer.vercel.app/
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Example: search the Fragment gifts marketplace.
|
||||
|
||||
collection filters by gift type slug (e.g. "plushpepe", "swisswatch").
|
||||
sort can be "price_desc", "price_asc", "listed", or "ending".
|
||||
filter can be "", "auction", "sale", or "sold".
|
||||
Use next_offset for pagination.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
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",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
QUERY = "" # search text — or omit for all
|
||||
COLLECTION = "plushpepe" # gift collection slug — or omit for all
|
||||
SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit
|
||||
FILTER = "" # "", "auction", "sale", "sold" — or omit
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER)
|
||||
|
||||
print(f"Found {len(result.items)} result(s):")
|
||||
print(json.dumps(result.items, indent=2))
|
||||
|
||||
if result.next_offset:
|
||||
print(f"\nMore results available — next page offset: {result.next_offset}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Example: search the Fragment marketplace for anonymous Telegram numbers.
|
||||
|
||||
sort can be "price_desc", "price_asc", "listed", or "ending".
|
||||
filter can be "", "auction", "sale", or "sold".
|
||||
Use next_offset_id for pagination.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
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",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
QUERY = "888" # search term — or omit for all
|
||||
SORT = "price_asc" # "price_desc", "price_asc", "listed", "ending" — or omit
|
||||
FILTER = "" # "", "auction", "sale", "sold" — or omit
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER)
|
||||
|
||||
print(f"Found {len(result.items)} result(s):")
|
||||
print(json.dumps(result.items, indent=2))
|
||||
|
||||
if result.next_offset_id:
|
||||
print(f"\nMore results available — next page offset: {result.next_offset_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Example: search the Fragment marketplace for Telegram usernames.
|
||||
|
||||
sort can be "price_desc", "price_asc", "listed", or "ending".
|
||||
filter can be "", "auction", "sale", or "sold".
|
||||
Use next_offset_id for pagination.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
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",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
QUERY = "durov" # search term
|
||||
SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit
|
||||
FILTER = "auction" # "", "auction", "sale", "sold" — or omit
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER)
|
||||
|
||||
print(f"Found {len(result.items)} result(s):")
|
||||
print(json.dumps(result.items, indent=2))
|
||||
|
||||
if result.next_offset_id:
|
||||
print(f"\nMore results available — next page offset: {result.next_offset_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Example: send a raw request to any Fragment API method.
|
||||
|
||||
Use client.call() when you need to access a method that is not yet
|
||||
wrapped by the library, or to inspect raw API responses directly.
|
||||
|
||||
page_url is optional — only set it when the target method belongs to a
|
||||
specific Fragment page (Fragment derives the API hash per page).
|
||||
Defaults to the Fragment base URL.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
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",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
METHOD = "anyFragmentMethod" # replace with the actual method name
|
||||
DATA = {"key": "value"} # replace with the actual request payload
|
||||
PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragment page (optional)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
result = await client.call(METHOD, DATA, page_url=PAGE_URL)
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -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__":
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Example: manage an anonymous Telegram number — read login code and terminate sessions.
|
||||
|
||||
Use get_login_code() to fetch the current pending login code for your number.
|
||||
Use toggle_login_codes() to enable or disable receiving codes.
|
||||
Use terminate_sessions() to forcefully end all active Telegram sessions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import AnonymousNumberError, FragmentClient
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
|
||||
# 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",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
NUMBER = "+88888888888"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
# Fetch the latest login code
|
||||
result = await client.get_login_code(NUMBER)
|
||||
if result.code:
|
||||
print(f"Login code for {result.number}: {result.code} ({result.active_sessions} active session(s))")
|
||||
else:
|
||||
print(f"No pending login code for {result.number} ({result.active_sessions} active session(s))")
|
||||
|
||||
# Terminate all active sessions
|
||||
try:
|
||||
terminated = await client.terminate_sessions(NUMBER)
|
||||
print(f"Sessions terminated for {terminated.number}" + (f": {terminated.message}" if terminated.message else ""))
|
||||
except AnonymousNumberError as e:
|
||||
print(f"Could not terminate sessions: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
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 satisfy the current minimum TON threshold and transaction cost.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import (
|
||||
AdsRechargeResult,
|
||||
ConfigurationError,
|
||||
FragmentClient,
|
||||
WalletError,
|
||||
)
|
||||
|
||||
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",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account
|
||||
AMOUNT = 10 # 1–1 000 000 000 TON
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
try:
|
||||
result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT)
|
||||
except WalletError as e:
|
||||
print(f"Wallet error — insufficient balance or misconfiguration: {e}")
|
||||
return
|
||||
except ConfigurationError as e:
|
||||
print(f"Invalid argument: {e}")
|
||||
return
|
||||
|
||||
print(f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
CHANNEL = "https://t.me/channel"
|
||||
WINNERS = 10 # 1–24 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,
|
||||
payment_method=PAYMENT_METHOD,
|
||||
)
|
||||
except UserNotFoundError:
|
||||
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
|
||||
return
|
||||
except ConfigurationError as e:
|
||||
print(f"Invalid argument: {e}")
|
||||
return
|
||||
|
||||
print(
|
||||
f"Premium giveaway created for {result.channel} — {result.winners} winner(s) × {result.amount} months each | tx: {result.transaction_id}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
CHANNEL = "https://t.me/channel"
|
||||
WINNERS = 3 # 1–5
|
||||
AMOUNT = 1000 # 500–1 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,
|
||||
payment_method=PAYMENT_METHOD,
|
||||
)
|
||||
except UserNotFoundError:
|
||||
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
|
||||
return
|
||||
except ConfigurationError as e:
|
||||
print(f"Invalid argument: {e}")
|
||||
return
|
||||
|
||||
print(
|
||||
f"Stars giveaway created for {result.channel} — {result.winners} winner(s) × {result.amount} stars each | tx: {result.transaction_id}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -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"
|
||||
MONTHS = 3 # 3, 6, or 12
|
||||
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
|
||||
@@ -33,7 +46,7 @@ async def main() -> None:
|
||||
print(f"Invalid argument: {e}")
|
||||
return
|
||||
|
||||
print(f"{result.months} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||
print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -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"
|
||||
AMOUNT = 500 # 50–1 000 000
|
||||
USERNAME = "https://t.me/username"
|
||||
AMOUNT = 500 # 50–1 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
|
||||
@@ -33,7 +46,7 @@ async def main() -> None:
|
||||
print(f"Invalid argument: {e}")
|
||||
return
|
||||
|
||||
print(f"{result.stars} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||
print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -1,16 +1,28 @@
|
||||
"""
|
||||
Example: top up a Telegram Ads account with TON.
|
||||
Example: top up TON to a recipient's Telegram balance.
|
||||
|
||||
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
|
||||
|
||||
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError, WalletError
|
||||
from pyfragment import (
|
||||
ConfigurationError,
|
||||
FragmentClient,
|
||||
UserNotFoundError,
|
||||
WalletError,
|
||||
)
|
||||
|
||||
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",
|
||||
@@ -19,7 +31,7 @@ COOKIES = {
|
||||
}
|
||||
|
||||
USERNAME = "@username"
|
||||
AMOUNT = 10 # TON, integer — 1–1 000 000 000
|
||||
AMOUNT = 10 # 1–1 000 000 000 TON
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -36,7 +48,7 @@ async def main() -> None:
|
||||
print(f"Invalid argument: {e}")
|
||||
return
|
||||
|
||||
print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||
print(f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512" height="512" style="width: 100%; height: 100%; transform: translate3d(0,0,0); content-visibility: visible;" preserveAspectRatio="xMidYMid meet"><defs><clipPath id="__lottie_element_2"><rect width="512" height="512" x="0" y="0"></rect></clipPath><clipPath id="__lottie_element_4"><path d="M0,0 L100,0 L100,100 L0,100z"></path></clipPath></defs><g clip-path="url(#__lottie_element_2)"><g clip-path="url(#__lottie_element_4)" style="display: block;" transform="matrix(5.119999885559082,0,0,5.119999885559082,0,0)" opacity="1"><g style="display: block;" transform="matrix(1.333299994468689,0,0,1.333299994468689,0,0)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill="rgb(30,40,51)" fill-opacity="1" d=" M47.31999969482422,5.619999885559082 C47.31999969482422,5.619999885559082 27.68000030517578,5.619999885559082 27.68000030517578,5.619999885559082 C15.5,5.619999885559082 5.619999885559082,15.5 5.619999885559082,27.68000030517578 C5.619999885559082,27.68000030517578 5.619999885559082,47.31999969482422 5.619999885559082,47.31999969482422 C5.619999885559082,59.5 15.5,69.37999725341797 27.68000030517578,69.37999725341797 C27.68000030517578,69.37999725341797 47.31999969482422,69.37999725341797 47.31999969482422,69.37999725341797 C59.5,69.37999725341797 69.37999725341797,59.5 69.37999725341797,47.31999969482422 C69.37999725341797,47.31999969482422 69.37999725341797,27.68000030517578 69.37999725341797,27.68000030517578 C69.37999725341797,15.5 59.5,5.619999885559082 47.31999969482422,5.619999885559082 C47.31999969482422,5.619999885559082 47.31999969482422,5.619999885559082 47.31999969482422,5.619999885559082z"></path></g><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill="rgb(255,255,255)" fill-opacity="1" d=" M36.349998474121094,32.79999923706055 C36.349998474121094,32.79999923706055 21.1299991607666,25.940000534057617 21.1299991607666,25.940000534057617 C20.020000457763672,25.450000762939453 20.389999389648438,23.790000915527344 21.600000381469727,23.790000915527344 C21.600000381469727,23.790000915527344 53.41999816894531,23.790000915527344 53.41999816894531,23.790000915527344 C54.630001068115234,23.790000915527344 54.9900016784668,25.440000534057617 53.880001068115234,25.940000534057617 C53.880001068115234,25.940000534057617 38.66999816894531,32.79999923706055 38.66999816894531,32.79999923706055 C37.939998626708984,33.130001068115234 37.09000015258789,33.130001068115234 36.36000061035156,32.79999923706055 C36.36000061035156,32.79999923706055 36.349998474121094,32.79999923706055 36.349998474121094,32.79999923706055z M56.81999969482422,30.06999969482422 C57.43000030517578,29.1200008392334 56.43000030517578,27.979999542236328 55.400001525878906,28.440000534057617 C55.400001525878906,28.440000534057617 40.72999954223633,35.13999938964844 40.72999954223633,35.13999938964844 C39.72999954223633,35.599998474121094 39.09000015258789,36.61000061035156 39.09000015258789,37.70000076293945 C39.09000015258789,37.70000076293945 39.09000015258789,53.810001373291016 39.09000015258789,53.810001373291016 C39.09000015258789,54.93000030517578 40.54999923706055,55.36000061035156 41.15999984741211,54.41999816894531 C41.15999984741211,54.41999816894531 56.810001373291016,30.06999969482422 56.810001373291016,30.06999969482422 C56.810001373291016,30.06999969482422 56.81999969482422,30.06999969482422 56.81999969482422,30.06999969482422z M19.600000381469727,28.440000534057617 C18.579999923706055,27.979999542236328 17.56999969482422,29.1200008392334 18.18000030517578,30.06999969482422 C18.18000030517578,30.06999969482422 33.84000015258789,54.43000030517578 33.84000015258789,54.43000030517578 C34.45000076293945,55.380001068115234 35.90999984741211,54.939998626708984 35.90999984741211,53.81999969482422 C35.90999984741211,53.81999969482422 35.90999984741211,37.70000076293945 35.90999984741211,37.70000076293945 C35.90999984741211,36.599998474121094 35.27000045776367,35.599998474121094 34.27000045776367,35.13999938964844 C34.27000045776367,35.13999938964844 19.59000015258789,28.450000762939453 19.59000015258789,28.450000762939453 C19.59000015258789,28.450000762939453 19.600000381469727,28.440000534057617 19.600000381469727,28.440000534057617z"></path></g></g></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 4.3 KiB |
+35
-20
@@ -1,13 +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.
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
from pyfragment.client import FragmentClient
|
||||
from pyfragment.types import (
|
||||
AdsTopupResult,
|
||||
from pyfragment.core.cookies import get_cookies_from_browser
|
||||
from pyfragment.exceptions import (
|
||||
AnonymousNumberError,
|
||||
ClientError,
|
||||
ConfigurationError,
|
||||
CookieError,
|
||||
@@ -16,36 +12,55 @@ from pyfragment.types import (
|
||||
FragmentPageError,
|
||||
OperationError,
|
||||
ParseError,
|
||||
PremiumResult,
|
||||
StarsResult,
|
||||
TransactionError,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
WalletError,
|
||||
WalletInfo,
|
||||
)
|
||||
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
|
||||
from pyfragment.models.cookies import CookieResult
|
||||
from pyfragment.models.enums import PaymentMethod
|
||||
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
|
||||
|
||||
__version__: str = version("pyfragment")
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"FragmentClient",
|
||||
"AdsTopupResult",
|
||||
"PremiumResult",
|
||||
# results
|
||||
"StarsResult",
|
||||
"StarsGiveawayResult",
|
||||
"PremiumResult",
|
||||
"PremiumGiveawayResult",
|
||||
"WalletInfo",
|
||||
"ClientError",
|
||||
"ConfigurationError",
|
||||
"CookieError",
|
||||
"FragmentAPIError",
|
||||
"AdsTopupResult",
|
||||
"AdsRechargeResult",
|
||||
"CookieResult",
|
||||
"GiftsResult",
|
||||
"LoginCodeResult",
|
||||
"NumbersResult",
|
||||
"TerminateSessionsResult",
|
||||
"UsernamesResult",
|
||||
# exceptions
|
||||
"FragmentError",
|
||||
"FragmentAPIError",
|
||||
"FragmentPageError",
|
||||
"ConfigurationError",
|
||||
"UserNotFoundError",
|
||||
"WalletError",
|
||||
"VerificationError",
|
||||
"TransactionError",
|
||||
"AnonymousNumberError",
|
||||
"ClientError",
|
||||
"CookieError",
|
||||
"OperationError",
|
||||
"ParseError",
|
||||
"TransactionError",
|
||||
"UnexpectedError",
|
||||
"UserNotFoundError",
|
||||
"VerificationError",
|
||||
"WalletError",
|
||||
# literal types
|
||||
"PaymentMethod",
|
||||
"get_cookies_from_browser",
|
||||
]
|
||||
|
||||
+275
-46
@@ -1,19 +1,25 @@
|
||||
import json
|
||||
from typing import cast
|
||||
from __future__ import annotations
|
||||
|
||||
from pyfragment.methods.premium import purchase_premium
|
||||
from pyfragment.methods.stars import purchase_stars
|
||||
from pyfragment.methods.ton import topup_ton
|
||||
from pyfragment.types import (
|
||||
AdsTopupResult,
|
||||
ConfigurationError,
|
||||
CookieError,
|
||||
PremiumResult,
|
||||
StarsResult,
|
||||
WalletInfo,
|
||||
)
|
||||
from pyfragment.types.constants import DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS, SUPPORTED_WALLET_VERSIONS, WalletVersion
|
||||
from pyfragment.utils.wallet import get_wallet_info
|
||||
import json
|
||||
from typing import Any, cast, get_args
|
||||
|
||||
import httpx
|
||||
|
||||
from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS
|
||||
from pyfragment.core.transport import fragment_request, get_fragment_hash
|
||||
from pyfragment.domains.ads.service import AdsService
|
||||
from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
|
||||
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:
|
||||
@@ -51,7 +57,7 @@ class FragmentClient:
|
||||
self,
|
||||
seed: str,
|
||||
api_key: str,
|
||||
cookies: dict | str,
|
||||
cookies: dict[str, Any] | str,
|
||||
wallet_version: str = "V5R1",
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> None:
|
||||
@@ -72,25 +78,31 @@ class FragmentClient:
|
||||
except Exception as exc:
|
||||
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
|
||||
|
||||
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict, cookies).get(k, "")).strip()]
|
||||
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict[str, Any], cookies).get(k, "")).strip()]
|
||||
if missing_keys:
|
||||
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
|
||||
|
||||
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)))
|
||||
)
|
||||
)
|
||||
|
||||
self.seed: str = seed.strip()
|
||||
self.api_key: str = api_key.strip()
|
||||
self.cookies: dict = cast(dict, cookies)
|
||||
self.cookies: dict[str, Any] = cast(dict[str, Any], cookies)
|
||||
self.wallet_version: WalletVersion = version # type: ignore[assignment]
|
||||
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:
|
||||
@@ -99,50 +111,267 @@ 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``, ``months``, ``timestamp``.
|
||||
: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 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 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:
|
||||
"""Top up TON to a recipient's Telegram balance.
|
||||
|
||||
Args:
|
||||
username: Recipient's Telegram username (with or without ``@``).
|
||||
amount: Number of stars — 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``, ``stars``, ``timestamp``.
|
||||
"""
|
||||
return await purchase_stars(self, username, amount, show_sender)
|
||||
|
||||
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||
"""Top up Telegram Ads balance with TON.
|
||||
|
||||
Args:
|
||||
username: Ads account username (with or without ``@``).
|
||||
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
:class:`AdsTopupResult` with ``transaction_id``, ``username``, ``amount``, ``timestamp``.
|
||||
: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.
|
||||
|
||||
Args:
|
||||
account: Your Fragment Ads account identifier — the channel or bot username
|
||||
the Ads account is linked to (e.g. ``"@mychannel"``).
|
||||
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||
|
||||
Returns:
|
||||
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
|
||||
"""
|
||||
return await 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"``, 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 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 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 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 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.
|
||||
|
||||
Args:
|
||||
number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
|
||||
|
||||
Returns:
|
||||
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
|
||||
and ``active_sessions`` count.
|
||||
"""
|
||||
return await 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.
|
||||
|
||||
Args:
|
||||
number: Phone number with or without leading ``+``.
|
||||
can_receive: ``True`` to allow receiving codes, ``False`` to block them.
|
||||
"""
|
||||
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.
|
||||
|
||||
Args:
|
||||
number: Phone number with or without leading ``+``.
|
||||
|
||||
Returns:
|
||||
:class:`TerminateSessionsResult` with ``number`` and ``message``.
|
||||
|
||||
Raises:
|
||||
AnonymousNumberError: If the number is not owned by this account or has no active sessions.
|
||||
"""
|
||||
return await self.anonymous_numbers.terminate_sessions(number)
|
||||
|
||||
async def search_usernames(
|
||||
self,
|
||||
query: str = "",
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
offset_id: str | None = None,
|
||||
) -> UsernamesResult:
|
||||
"""Search the Fragment marketplace for Telegram usernames.
|
||||
|
||||
Args:
|
||||
query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
|
||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||
``"ending"``. Omit to use Fragment's default ordering.
|
||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
|
||||
``""`` (available items). Omit to return all.
|
||||
offset_id: Pagination cursor — pass :attr:`UsernamesResult.next_offset_id`
|
||||
from a previous result to fetch the next page.
|
||||
|
||||
Returns:
|
||||
:class:`UsernamesResult` with ``items`` (parsed list of item dicts)
|
||||
and ``next_offset_id`` (``None`` on the last page).
|
||||
"""
|
||||
return await self.marketplace.search_usernames(query, sort=sort, filter=filter, offset_id=offset_id)
|
||||
|
||||
async def search_numbers(
|
||||
self,
|
||||
query: str = "",
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
offset_id: str | None = None,
|
||||
) -> NumbersResult:
|
||||
"""Search the Fragment marketplace for anonymous Telegram numbers.
|
||||
|
||||
Args:
|
||||
query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all.
|
||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||
``"ending"``. Omit to use Fragment's default ordering.
|
||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
|
||||
``""`` (available items). Omit to return all.
|
||||
offset_id: Pagination cursor — pass :attr:`NumbersResult.next_offset_id`
|
||||
from a previous result to fetch the next page.
|
||||
|
||||
Returns:
|
||||
:class:`NumbersResult` with ``items`` (parsed list of item dicts)
|
||||
and ``next_offset_id`` (``None`` on the last page).
|
||||
"""
|
||||
return await self.marketplace.search_numbers(query, sort=sort, filter=filter, offset_id=offset_id)
|
||||
|
||||
async def search_gifts(
|
||||
self,
|
||||
query: str = "",
|
||||
collection: str | None = None,
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
view: str | None = None,
|
||||
attr: dict[str, list[str]] | None = None,
|
||||
offset: int | None = None,
|
||||
) -> GiftsResult:
|
||||
"""Search the Fragment gifts marketplace.
|
||||
|
||||
Args:
|
||||
query: Search text. Omit or pass ``""`` to browse without filtering by name.
|
||||
collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all.
|
||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||
``"ending"``. Omit to use Fragment's default ordering.
|
||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
|
||||
``""`` (available items). Omit to return all.
|
||||
view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default.
|
||||
attr: Attribute filters — mapping of trait name to accepted values, e.g.
|
||||
``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``.
|
||||
Each key is sent as ``attr[Key]`` with its list of values.
|
||||
offset: Integer page offset from a previous :class:`GiftsResult`.
|
||||
Pass ``next_offset`` to fetch the next page.
|
||||
|
||||
Returns:
|
||||
:class:`GiftsResult` with ``items`` (parsed list of item dicts)
|
||||
and ``next_offset`` (``None`` on the last page).
|
||||
"""
|
||||
return await self.marketplace.search_gifts(
|
||||
query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL
|
||||
) -> dict[str, Any]:
|
||||
"""Send a raw request to the Fragment API.
|
||||
|
||||
Useful for accessing undocumented or future Fragment API methods
|
||||
without waiting for a library update.
|
||||
|
||||
Args:
|
||||
method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``.
|
||||
data: Additional form-data fields to include in the request body.
|
||||
page_url: Fragment page URL used to derive the API hash and headers.
|
||||
Defaults to ``FRAGMENT_BASE_URL`` (``"https://fragment.com"``).
|
||||
|
||||
Returns:
|
||||
Raw parsed JSON response as a dict.
|
||||
|
||||
Example::
|
||||
|
||||
result = await client.call(
|
||||
"searchPremiumGiftRecipient",
|
||||
{"query": "@username", "months": 3},
|
||||
page_url="https://fragment.com/premium/gift",
|
||||
)
|
||||
"""
|
||||
headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
|
||||
async with httpx.AsyncClient(cookies=self.cookies, timeout=self.timeout) as session:
|
||||
fragment_hash = await get_fragment_hash(self.cookies, headers, page_url, self.timeout)
|
||||
return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})})
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
|
||||
|
||||
WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
|
||||
|
||||
MIN_TON_BALANCE: float = 0.33
|
||||
USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
|
||||
MIN_USDT_BALANCE: float = 0.75
|
||||
|
||||
DEFAULT_TIMEOUT: float = 30.0
|
||||
|
||||
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
|
||||
|
||||
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"
|
||||
PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway"
|
||||
ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup"
|
||||
NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers"
|
||||
GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts"
|
||||
|
||||
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": "26.04.0",
|
||||
"maxProtocolVersion": 2,
|
||||
"features": [
|
||||
"SendTransaction",
|
||||
{"name": "SendTransaction", "maxMessages": 255},
|
||||
{"name": "SignData", "types": ["text", "binary", "cell"]},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
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 (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",
|
||||
}
|
||||
@@ -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 # type: ignore[import-not-found]
|
||||
except Exception:
|
||||
rookiepy = None
|
||||
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain-level helpers for Fragment operations."""
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -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)
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
raise
|
||||
except Exception as exc:
|
||||
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,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
raise
|
||||
except Exception as exc:
|
||||
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:
|
||||
raise
|
||||
except Exception as exc:
|
||||
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)
|
||||
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
class BaseService:
|
||||
def __init__(self, client: FragmentClient) -> None:
|
||||
self._client = client
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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
|
||||
|
||||
|
||||
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))
|
||||
|
||||
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:
|
||||
raise
|
||||
except Exception as exc:
|
||||
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))
|
||||
|
||||
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:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
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"[^>]*>')
|
||||
NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$")
|
||||
|
||||
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">([^<]+)<')
|
||||
GRID_NUM_RE = re.compile(r'class="item-num">[^#]*#(\w+)<')
|
||||
GRID_PRICE_RE = re.compile(r'class="[^"]*tm-grid-item-value[^"]*icon-ton[^"]*"[^>]*>\s*([0-9][^<]*?)\s*<')
|
||||
GRID_STATUS_RE = re.compile(r'class="[^"]*tm-grid-item-status[^"]*"[^>]*>\s*([^<]+?)\s*<')
|
||||
GRID_DATETIME_RE = re.compile(r'<time[^>]+datetime="([^"]+)"')
|
||||
|
||||
|
||||
def parse_auction_rows(html: str) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
for row_match in ROW_BLOCK_RE.finditer(html):
|
||||
row = row_match.group(1)
|
||||
|
||||
href_m = HREF_RE.search(row)
|
||||
if not href_m:
|
||||
continue
|
||||
slug = href_m.group(1).lstrip("/")
|
||||
|
||||
values = [m.group(1).strip() for m in VALUE_RE.finditer(row)]
|
||||
name = values[0] if values else slug
|
||||
|
||||
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_m = PRICE_RE.search(row)
|
||||
price: str | None = None
|
||||
if price_m:
|
||||
raw_price = price_m.group(1).strip().replace(",", "")
|
||||
try:
|
||||
price = f"{float(raw_price):.2f}"
|
||||
except ValueError:
|
||||
price = raw_price
|
||||
|
||||
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})
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]:
|
||||
items: list[dict[str, Any]] = []
|
||||
for item_match in GRID_ITEM_RE.finditer(html):
|
||||
block = item_match.group(0)
|
||||
|
||||
href_m = GRID_HREF_RE.search(block)
|
||||
if not href_m:
|
||||
continue
|
||||
slug = href_m.group(1).lstrip("/")
|
||||
|
||||
name_m = GRID_NAME_RE.search(block)
|
||||
num_m = GRID_NUM_RE.search(block)
|
||||
item_name = name_m.group(1).strip() if name_m else slug
|
||||
item_num = f" #{num_m.group(1)}" if num_m else ""
|
||||
name = f"{item_name}{item_num}"
|
||||
|
||||
status_m = GRID_STATUS_RE.search(block)
|
||||
status: str | None = status_m.group(1).strip() if status_m else None
|
||||
|
||||
price_m = GRID_PRICE_RE.search(block)
|
||||
price: str | None = None
|
||||
if price_m:
|
||||
raw_price = price_m.group(1).strip().replace(",", "")
|
||||
try:
|
||||
price = f"{float(raw_price):.2f}"
|
||||
except ValueError:
|
||||
price = raw_price
|
||||
|
||||
time_m = GRID_DATETIME_RE.search(block)
|
||||
date: str | None = time_m.group(1) if time_m else None
|
||||
|
||||
items.append({"slug": slug, "name": name, "status": status, "price": price, "date": date})
|
||||
|
||||
next_offset_m = re.search(r'data-next-offset="(\d+)"', html)
|
||||
next_offset = int(next_offset_m.group(1)) if next_offset_m else None
|
||||
|
||||
return items, next_offset
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
raise
|
||||
except Exception as exc:
|
||||
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:
|
||||
raise
|
||||
except Exception as exc:
|
||||
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:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
raise
|
||||
except Exception as exc:
|
||||
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:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -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)
|
||||
@@ -0,0 +1,23 @@
|
||||
from pyfragment.domains.tonapi.account import (
|
||||
check_ton_payment_balance,
|
||||
check_usdt_payment_balance,
|
||||
get_account_info,
|
||||
get_usdt_balance,
|
||||
get_wallet_info,
|
||||
)
|
||||
from pyfragment.domains.tonapi.service import TonapiService
|
||||
from pyfragment.domains.tonapi.transaction import clean_decode, process_transaction
|
||||
from pyfragment.domains.tonapi.transfer import send_ton_transfer, send_usdt_transfer
|
||||
|
||||
__all__ = [
|
||||
"TonapiService",
|
||||
"clean_decode",
|
||||
"check_ton_payment_balance",
|
||||
"check_usdt_payment_balance",
|
||||
"get_account_info",
|
||||
"get_usdt_balance",
|
||||
"get_wallet_info",
|
||||
"process_transaction",
|
||||
"send_ton_transfer",
|
||||
"send_usdt_transfer",
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
return 0.0
|
||||
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
|
||||
except Exception as exc:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
|
||||
@@ -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)
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
|
||||
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 first so we fail before trying to broadcast on-chain.
|
||||
try:
|
||||
await wallet.refresh()
|
||||
balance_ton = wallet.balance / 1_000_000_000
|
||||
if payment_method == "ton":
|
||||
wallet.address.to_str(False, False)
|
||||
await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount)
|
||||
else:
|
||||
# USDT is paid from the Fragment-linked wallet, not the signing wallet.
|
||||
fragment_wallet_address = transaction_data["transaction"].get("from", "")
|
||||
await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address)
|
||||
except WalletError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
|
||||
|
||||
try:
|
||||
raw_payload = str(message.get("payload", ""))
|
||||
payload = clean_decode(raw_payload)
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
result = await wallet.transfer(
|
||||
destination=message["address"],
|
||||
amount=int(message["amount"]), # nanotons, not TON
|
||||
body=payload,
|
||||
)
|
||||
return str(result.normalized_hash)
|
||||
except ProviderResponseError as exc:
|
||||
if exc.code == 429 and attempt == 0:
|
||||
await asyncio.sleep(1 + random.uniform(0, 0.5))
|
||||
continue
|
||||
if exc.code == 406 and "seqno" in str(exc).lower():
|
||||
if attempt < 2:
|
||||
await asyncio.sleep(2 + random.uniform(0, 1))
|
||||
continue
|
||||
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
|
||||
raise
|
||||
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"))
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ton_core import Address, NetworkGlobalID
|
||||
from tonutils.clients import ToncenterClient
|
||||
from tonutils.contracts import JettonTransferBuilder, TONTransferBuilder
|
||||
|
||||
from pyfragment.core.constants import USDT_TON_MASTER_ADDRESS, WALLET_CLASSES
|
||||
from pyfragment.exceptions import TransactionError, WalletError
|
||||
from pyfragment.models.wallet import TonTransferResult, UsdtTransferResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def send_ton_transfer(
|
||||
client: FragmentClient,
|
||||
destination: str,
|
||||
amount: int,
|
||||
body: str | None = None,
|
||||
) -> TonTransferResult:
|
||||
"""Send a direct TON transfer from the seeded wallet.
|
||||
|
||||
Args:
|
||||
client: Authenticated `FragmentClient` instance.
|
||||
destination: Recipient address in any TON-compatible format.
|
||||
amount: Amount in nanotons.
|
||||
body: Optional on-chain comment.
|
||||
"""
|
||||
try:
|
||||
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
|
||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||
result = await wallet.transfer_message(
|
||||
TONTransferBuilder(
|
||||
destination=Address(destination),
|
||||
amount=amount,
|
||||
body=body,
|
||||
)
|
||||
)
|
||||
return TonTransferResult(
|
||||
transaction_id=str(result.normalized_hash),
|
||||
destination=destination,
|
||||
amount=amount,
|
||||
)
|
||||
except (TransactionError, WalletError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
|
||||
|
||||
|
||||
async def send_usdt_transfer(
|
||||
client: FragmentClient,
|
||||
destination: str,
|
||||
usdt_amount: int,
|
||||
forward_payload: str | None = None,
|
||||
ton_for_gas: int = 50_000_000,
|
||||
) -> UsdtTransferResult:
|
||||
"""Send a direct USDT transfer from the seeded wallet.
|
||||
|
||||
Args:
|
||||
client: Authenticated `FragmentClient` instance.
|
||||
destination: Recipient address in any TON-compatible format.
|
||||
usdt_amount: Amount in USDT base units (6 decimals).
|
||||
forward_payload: Optional comment passed through to the recipient.
|
||||
ton_for_gas: TON attached for gas in nanotons.
|
||||
"""
|
||||
try:
|
||||
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
|
||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||
result = await wallet.transfer_message(
|
||||
JettonTransferBuilder(
|
||||
destination=Address(destination),
|
||||
jetton_amount=usdt_amount,
|
||||
jetton_master_address=Address(USDT_TON_MASTER_ADDRESS),
|
||||
forward_payload=forward_payload,
|
||||
forward_amount=1,
|
||||
amount=ton_for_gas,
|
||||
)
|
||||
)
|
||||
return UsdtTransferResult(
|
||||
transaction_id=str(result.normalized_hash),
|
||||
destination=destination,
|
||||
amount=usdt_amount,
|
||||
)
|
||||
except (TransactionError, WalletError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class FragmentError(Exception):
|
||||
"""Base exception for all pyfragment errors."""
|
||||
|
||||
|
||||
class ClientError(FragmentError):
|
||||
"""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}'. 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}. 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_USERNAME = (
|
||||
"Invalid username '{username}'. "
|
||||
"Must be 5-32 characters and contain only letters (A-Z, a-z), digits (0-9), or underscores (_)."
|
||||
)
|
||||
INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5."
|
||||
INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24,000."
|
||||
INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1,000,000."
|
||||
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}."
|
||||
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. 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. 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."
|
||||
)
|
||||
|
||||
|
||||
class AnonymousNumberError(FragmentAPIError):
|
||||
"""Raised for Fragment anonymous number API failures."""
|
||||
|
||||
NOT_OWNED = "Number '{number}' is not associated with your Fragment account or has no active sessions to terminate."
|
||||
TERMINATE_FAILED = "Failed to terminate sessions for '{number}': {error}"
|
||||
|
||||
|
||||
class TransactionError(FragmentAPIError):
|
||||
"""Raised when a TON transaction fails to build or broadcast."""
|
||||
|
||||
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"
|
||||
"This usually means your system's CA bundle is missing or outdated.\n"
|
||||
"Fix: run `pip install --upgrade certifi` and retry. "
|
||||
"On macOS you may also need to run the 'Install Certificates.command' "
|
||||
"located in your Python installation folder."
|
||||
)
|
||||
DUPLICATE_SEQNO = (
|
||||
"Transaction broadcast failed: the TON wallet rejected the message "
|
||||
"because a previous transaction with the same sequence number (seqno) "
|
||||
"is still pending confirmation on-chain.\n"
|
||||
"Wait a few seconds for the previous transaction to confirm, then retry."
|
||||
)
|
||||
|
||||
|
||||
class ParseError(FragmentAPIError):
|
||||
"""Raised when a Fragment API response or payload cannot be parsed."""
|
||||
|
||||
UNPARSEABLE = "Failed to parse the Fragment API response for '{context}': {exc}"
|
||||
|
||||
|
||||
class VerificationError(FragmentAPIError):
|
||||
"""Raised when Fragment requires KYC verification before proceeding."""
|
||||
|
||||
KYC_REQUIRED = (
|
||||
"Fragment requires identity verification (KYC) before this action can be completed. "
|
||||
"Complete verification at https://fragment.com/my/profile and retry."
|
||||
)
|
||||
|
||||
|
||||
class OperationError(FragmentError):
|
||||
"""Raised for runtime operation failures unrelated to Fragment's API."""
|
||||
|
||||
|
||||
class WalletError(OperationError):
|
||||
"""Raised for TON wallet issues (connection, balance, account info)."""
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
class UnexpectedError(OperationError):
|
||||
"""Raised when an unexpected error occurs during an API call."""
|
||||
|
||||
UNEXPECTED = "An unexpected error occurred during the operation: {exc}"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FragmentError",
|
||||
"ClientError",
|
||||
"ConfigurationError",
|
||||
"CookieError",
|
||||
"FragmentAPIError",
|
||||
"FragmentPageError",
|
||||
"AnonymousNumberError",
|
||||
"UserNotFoundError",
|
||||
"TransactionError",
|
||||
"ParseError",
|
||||
"VerificationError",
|
||||
"OperationError",
|
||||
"WalletError",
|
||||
"UnexpectedError",
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
from pyfragment.methods.premium import purchase_premium
|
||||
from pyfragment.methods.stars import purchase_stars
|
||||
from pyfragment.methods.ton import topup_ton
|
||||
|
||||
__all__ = ["purchase_premium", "purchase_stars", "topup_ton"]
|
||||
@@ -1,118 +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 BASE_HEADERS, DEVICE, PREMIUM_PAGE
|
||||
from pyfragment.utils import (
|
||||
execute_transaction_request,
|
||||
fragment_post,
|
||||
get_account_info,
|
||||
get_fragment_hash,
|
||||
process_transaction,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
# Page-specific headers
|
||||
HEADERS: dict[str, str] = {
|
||||
**BASE_HEADERS,
|
||||
"referer": PREMIUM_PAGE,
|
||||
"x-aj-referer": PREMIUM_PAGE,
|
||||
}
|
||||
|
||||
|
||||
async def _search_recipient(
|
||||
session: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
username: str,
|
||||
months: int,
|
||||
) -> str:
|
||||
result = await fragment_post(
|
||||
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_post(
|
||||
session,
|
||||
fragment_hash,
|
||||
HEADERS,
|
||||
{
|
||||
"mode": "new",
|
||||
"lv": "false",
|
||||
"dh": str(int(time.time())),
|
||||
"method": "updatePremiumState",
|
||||
},
|
||||
)
|
||||
result = await fragment_post(
|
||||
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:
|
||||
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, months=months)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -1,105 +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 BASE_HEADERS, DEVICE, STARS_PAGE
|
||||
from pyfragment.utils import (
|
||||
execute_transaction_request,
|
||||
fragment_post,
|
||||
get_account_info,
|
||||
get_fragment_hash,
|
||||
process_transaction,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
# Page-specific headers
|
||||
HEADERS: dict[str, str] = {
|
||||
**BASE_HEADERS,
|
||||
"referer": STARS_PAGE,
|
||||
"x-aj-referer": STARS_PAGE,
|
||||
}
|
||||
|
||||
|
||||
async def _search_recipient(
|
||||
session: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
username: str,
|
||||
) -> str:
|
||||
result = await fragment_post(
|
||||
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_post(
|
||||
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:
|
||||
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, stars=amount)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -1,105 +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 BASE_HEADERS, DEVICE, TON_PAGE
|
||||
from pyfragment.utils import (
|
||||
execute_transaction_request,
|
||||
fragment_post,
|
||||
get_account_info,
|
||||
get_fragment_hash,
|
||||
process_transaction,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
# Page-specific headers
|
||||
HEADERS: dict[str, str] = {
|
||||
**BASE_HEADERS,
|
||||
"referer": TON_PAGE,
|
||||
"x-aj-referer": TON_PAGE,
|
||||
}
|
||||
|
||||
|
||||
async def _search_recipient(
|
||||
session: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
username: str,
|
||||
) -> str:
|
||||
await fragment_post(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"})
|
||||
result = await fragment_post(
|
||||
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_post(
|
||||
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:
|
||||
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, TON_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
|
||||
@@ -0,0 +1,27 @@
|
||||
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 TonTransferResult, UsdtTransferResult, WalletInfo
|
||||
|
||||
__all__ = [
|
||||
"AdsRechargeResult",
|
||||
"AdsTopupResult",
|
||||
"CookieResult",
|
||||
"GiftsResult",
|
||||
"LoginCodeResult",
|
||||
"NumbersResult",
|
||||
"PaymentMethod",
|
||||
"PremiumGiveawayResult",
|
||||
"PremiumResult",
|
||||
"StarsGiveawayResult",
|
||||
"StarsResult",
|
||||
"TerminateSessionsResult",
|
||||
"TonTransferResult",
|
||||
"UsernamesResult",
|
||||
"UsdtTransferResult",
|
||||
"WalletInfo",
|
||||
"WalletVersion",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
PaymentMethod = Literal["ton", "usdt_ton"]
|
||||
WalletVersion = Literal["V4R2", "V5R1"]
|
||||
|
||||
__all__ = ["PaymentMethod", "WalletVersion"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,40 @@
|
||||
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)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TonTransferResult:
|
||||
transaction_id: str
|
||||
destination: str
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"TonTransferResult(destination='{self.destination}', amount={self.amount} TON, tx='{self.transaction_id}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsdtTransferResult:
|
||||
transaction_id: str
|
||||
destination: str
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"UsdtTransferResult(destination='{self.destination}', amount={self.amount} USDT, tx='{self.transaction_id}')"
|
||||
|
||||
|
||||
__all__ = ["TonTransferResult", "UsdtTransferResult", "WalletInfo"]
|
||||
@@ -1,39 +0,0 @@
|
||||
from pyfragment.types.exceptions import (
|
||||
ClientError,
|
||||
ConfigurationError,
|
||||
CookieError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
FragmentPageError,
|
||||
OperationError,
|
||||
ParseError,
|
||||
TransactionError,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
WalletError,
|
||||
)
|
||||
from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult, WalletInfo
|
||||
|
||||
__all__ = [
|
||||
# client exceptions
|
||||
"ClientError",
|
||||
"ConfigurationError",
|
||||
"CookieError",
|
||||
# fragment exceptions
|
||||
"FragmentAPIError",
|
||||
"FragmentError",
|
||||
"FragmentPageError",
|
||||
"OperationError",
|
||||
"ParseError",
|
||||
"TransactionError",
|
||||
"UnexpectedError",
|
||||
"UserNotFoundError",
|
||||
"VerificationError",
|
||||
"WalletError",
|
||||
# result types
|
||||
"AdsTopupResult",
|
||||
"PremiumResult",
|
||||
"StarsResult",
|
||||
"WalletInfo",
|
||||
]
|
||||
@@ -1,58 +0,0 @@
|
||||
import json
|
||||
from typing import Any, Literal, get_args
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
STARS_PAGE: str = "https://fragment.com/stars/buy"
|
||||
PREMIUM_PAGE: str = "https://fragment.com/premium/gift"
|
||||
TON_PAGE: str = "https://fragment.com/ads/topup"
|
||||
|
||||
# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload.
|
||||
DEVICE: str = json.dumps(
|
||||
{
|
||||
"platform": "iphone",
|
||||
"appName": "Tonkeeper",
|
||||
"appVersion": "5.5.2",
|
||||
"maxProtocolVersion": 2,
|
||||
"features": [
|
||||
"SendTransaction",
|
||||
{"name": "SendTransaction", "maxMessages": 255},
|
||||
{"name": "SignData", "types": ["text", "binary", "cell"]},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# 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": "https://fragment.com",
|
||||
"priority": "u=1, i",
|
||||
"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"
|
||||
),
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
class FragmentError(Exception):
|
||||
"""Base exception for all pyfragment library errors."""
|
||||
|
||||
|
||||
class ClientError(FragmentError):
|
||||
"""Raised for client configuration and setup issues (bad params, invalid cookies)."""
|
||||
|
||||
|
||||
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: got {count} words, expected 12, 18, or 24."
|
||||
INVALID_API_KEY = "Invalid Tonapi key: got {length} characters, expected at least 68. Get one at https://tonconsole.com."
|
||||
INVALID_MONTHS = "Invalid duration. Choose 3, 6, or 12 months."
|
||||
INVALID_STARS_AMOUNT = "Amount must be an integer between 50 and 1 000 000 stars."
|
||||
INVALID_TON_AMOUNT = "Amount must be an integer between 1 and 1 000 000 000 TON."
|
||||
INVALID_USERNAME = (
|
||||
"Invalid username '{username}'. Must be 5–32 characters: letters (A–Z, a–z), digits (0–9), or underscores (_)."
|
||||
)
|
||||
|
||||
|
||||
class CookieError(ClientError):
|
||||
"""Raised when cookies are unreadable or missing required fields."""
|
||||
|
||||
READ_FAILED = "Failed to parse cookies: {exc}"
|
||||
MISSING_KEYS = (
|
||||
"Cookies are missing or have empty values for: {keys}. " "Open Fragment.com in your browser and copy 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}'. " "The session may have expired — refresh your cookies."
|
||||
)
|
||||
|
||||
|
||||
class FragmentPageError(FragmentAPIError):
|
||||
"""Raised when the Fragment page cannot be fetched or the API hash is not found."""
|
||||
|
||||
BAD_STATUS = "Fragment returned HTTP {status} for {url}. " "Check that your cookies are valid and not expired."
|
||||
NOT_FOUND = (
|
||||
"Fragment hash not found in the page source of {url}. " "The page structure may have changed or you are not logged in."
|
||||
)
|
||||
|
||||
|
||||
class UserNotFoundError(FragmentAPIError):
|
||||
"""Raised when the target Telegram user is not found on Fragment."""
|
||||
|
||||
NOT_FOUND = (
|
||||
"Telegram user '{username}' was not found on Fragment. " "Make sure the username is correct and the account exists."
|
||||
)
|
||||
|
||||
|
||||
class TransactionError(FragmentAPIError):
|
||||
"""Raised when a TON transaction fails to build or broadcast."""
|
||||
|
||||
INVALID_PAYLOAD = (
|
||||
"Fragment returned an invalid transaction payload. " "The API response is missing expected 'transaction.messages' data."
|
||||
)
|
||||
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
|
||||
|
||||
|
||||
class ParseError(FragmentAPIError):
|
||||
"""Raised when a Fragment API response or payload cannot be parsed."""
|
||||
|
||||
UNPARSEABLE = "Fragment API returned an unparseable response for '{context}': {exc}"
|
||||
|
||||
|
||||
class VerificationError(FragmentAPIError):
|
||||
"""Raised when Fragment requires KYC verification before proceeding."""
|
||||
|
||||
KYC_REQUIRED = "Fragment requires identity (KYC) verification. " "Complete it at https://fragment.com/my/profile and retry."
|
||||
|
||||
|
||||
class OperationError(FragmentError):
|
||||
"""Raised for runtime operation failures unrelated to Fragment's API."""
|
||||
|
||||
|
||||
class WalletError(OperationError):
|
||||
"""Raised for TON wallet issues (connection, balance, account info)."""
|
||||
|
||||
LOW_BALANCE = "TON wallet balance is too low: {balance:.4f} TON available, {required:.4f} TON required."
|
||||
BALANCE_CHECK_FAILED = "Wallet balance check failed: {exc}"
|
||||
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info: {exc}"
|
||||
WALLET_INFO_FAILED = "Failed to retrieve wallet info: {exc}"
|
||||
|
||||
|
||||
class UnexpectedError(OperationError):
|
||||
"""Raised when an unexpected error occurs during an API call."""
|
||||
|
||||
UNEXPECTED = "An unexpected error occurred: {exc}"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FragmentError",
|
||||
"ClientError",
|
||||
"ConfigurationError",
|
||||
"CookieError",
|
||||
"FragmentAPIError",
|
||||
"FragmentPageError",
|
||||
"UserNotFoundError",
|
||||
"TransactionError",
|
||||
"ParseError",
|
||||
"VerificationError",
|
||||
"OperationError",
|
||||
"WalletError",
|
||||
"UnexpectedError",
|
||||
]
|
||||
@@ -1,55 +0,0 @@
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
__all__ = ["AdsTopupResult", "PremiumResult", "StarsResult", "WalletInfo"]
|
||||
|
||||
|
||||
@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
|
||||
months: int
|
||||
timestamp: int = field(default_factory=lambda: int(time.time()))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"PremiumResult(username='{self.username}', months={self.months}, tx='{self.transaction_id}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StarsResult:
|
||||
"""Result of a successful Telegram Stars purchase."""
|
||||
|
||||
transaction_id: str
|
||||
username: str
|
||||
stars: int
|
||||
timestamp: int = field(default_factory=lambda: int(time.time()))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"StarsResult(username='{self.username}', stars={self.stars}, tx='{self.transaction_id}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdsTopupResult:
|
||||
"""Result of a successful Telegram Ads balance top-up."""
|
||||
|
||||
transaction_id: str
|
||||
username: str
|
||||
amount: int
|
||||
timestamp: int = field(default_factory=lambda: int(time.time()))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AdsTopupResult(username='{self.username}', amount={self.amount} TON, tx='{self.transaction_id}')"
|
||||
@@ -1,18 +0,0 @@
|
||||
from pyfragment.utils.decoder import clean_decode
|
||||
from pyfragment.utils.http import (
|
||||
execute_transaction_request,
|
||||
fragment_post,
|
||||
get_fragment_hash,
|
||||
parse_json_response,
|
||||
)
|
||||
from pyfragment.utils.wallet import get_account_info, process_transaction
|
||||
|
||||
__all__ = [
|
||||
"clean_decode",
|
||||
"execute_transaction_request",
|
||||
"fragment_post",
|
||||
"get_account_info",
|
||||
"get_fragment_hash",
|
||||
"parse_json_response",
|
||||
"process_transaction",
|
||||
]
|
||||
@@ -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
|
||||
@@ -1,137 +0,0 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from pyfragment.types import FragmentPageError, ParseError, VerificationError
|
||||
from pyfragment.types.constants import DEFAULT_TIMEOUT
|
||||
|
||||
|
||||
async def get_fragment_hash(
|
||||
cookies: dict[str, Any],
|
||||
headers: dict[str, str],
|
||||
page_url: str,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> str:
|
||||
"""Fetch the API hash from a Fragment page.
|
||||
|
||||
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": "https://fragment.com/",
|
||||
"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_post(
|
||||
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"https://fragment.com/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_post(session, fragment_hash, headers, tx_data)
|
||||
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
return transaction
|
||||
@@ -1,138 +0,0 @@
|
||||
import asyncio
|
||||
import base64
|
||||
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
|
||||
|
||||
# TODO: Investigate 406 'inbound external message rejected before smart-contract execution'.
|
||||
# This happens when the previous transaction's seqno hasn't been confirmed on-chain yet,
|
||||
# causing the wallet contract to reject the new message.
|
||||
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||
|
||||
# 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))
|
||||
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(2):
|
||||
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
|
||||
raise
|
||||
except (WalletError, TransactionError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
|
||||
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
|
||||
|
||||
|
||||
async def get_account_info(client: "FragmentClient") -> dict[str, Any]:
|
||||
"""Fetch wallet address, public key, and state-init for the Fragment API.
|
||||
|
||||
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
|
||||
+51
-26
@@ -4,45 +4,66 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "pyfragment"
|
||||
version = "2026.0.2"
|
||||
description = "Python library for the Fragment.com API — purchase Telegram Stars, Premium, and top up TON Ads balance."
|
||||
version = "2026.3.0"
|
||||
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",
|
||||
"tonkeeper",
|
||||
"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",
|
||||
"Intended Audience :: Financial and Insurance Industry",
|
||||
"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",
|
||||
"Framework :: AsyncIO",
|
||||
"Topic :: Software Development :: Libraries",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Topic :: Internet",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
"Topic :: Office/Business :: Financial",
|
||||
"Topic :: Office/Business :: Financial :: Investment",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"httpx==0.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 +71,27 @@ 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"
|
||||
|
||||
[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"]
|
||||
"systests/*" = ["E402"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
strict = true
|
||||
exclude = ["^systests/", "^examples/"]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
-e .[dev]
|
||||
@@ -1 +0,0 @@
|
||||
-e .
|
||||
+101
-9
@@ -1,34 +1,65 @@
|
||||
"""Tests for clean_decode() — BOC-encoded Fragment payloads decode to UTF-8."""
|
||||
"""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",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", PAYLOADS)
|
||||
def test_decode_payload(payload: str) -> None:
|
||||
# Decode valid payload tests
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
def test_empty_payload_returns_empty_string() -> None:
|
||||
@@ -38,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
|
||||
|
||||
+36
-31
@@ -1,20 +1,13 @@
|
||||
"""Unit tests for FragmentClient — init validation and cookie parsing (no network calls)."""
|
||||
"""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
|
||||
|
||||
VALID_SEED = "abandon " * 23 + "about"
|
||||
VALID_API_KEY = "A" * 68
|
||||
VALID_COOKIES = {
|
||||
"stel_ssid": "x",
|
||||
"stel_dt": "x",
|
||||
"stel_token": "x",
|
||||
"stel_ton_token": "x",
|
||||
}
|
||||
# Client init tests
|
||||
|
||||
|
||||
def test_valid_init() -> None:
|
||||
@@ -24,6 +17,9 @@ def test_valid_init() -> None:
|
||||
assert client.wallet_version == "V5R1"
|
||||
|
||||
|
||||
# Wallet version tests
|
||||
|
||||
|
||||
def test_wallet_version_v4r2() -> None:
|
||||
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V4R2")
|
||||
assert client.wallet_version == "V4R2"
|
||||
@@ -34,6 +30,14 @@ def test_wallet_version_is_case_insensitive() -> None:
|
||||
assert client.wallet_version == "V5R1"
|
||||
|
||||
|
||||
def test_unsupported_wallet_version_raises() -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V3R2")
|
||||
|
||||
|
||||
# Seed and mnemonic validation tests
|
||||
|
||||
|
||||
def test_missing_seed_raises() -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
@@ -44,14 +48,33 @@ def test_whitespace_only_seed_raises() -> None:
|
||||
FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
def test_invalid_mnemonic_length_raises() -> None:
|
||||
bad_seed = " ".join(["word"] * 23)
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed=bad_seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
def test_valid_mnemonic_lengths() -> None:
|
||||
for length in (12, 18, 24):
|
||||
seed = " ".join(["abandon"] * (length - 1) + ["about"])
|
||||
client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
assert len(client.seed.split()) == length
|
||||
|
||||
|
||||
# API key validation tests
|
||||
|
||||
|
||||
def test_missing_api_key_raises() -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
def test_unsupported_wallet_version_raises() -> None:
|
||||
def test_short_api_key_raises() -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V3R2")
|
||||
FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
# Cookie validation tests
|
||||
|
||||
|
||||
def test_cookies_as_json_string() -> None:
|
||||
@@ -81,24 +104,6 @@ def test_whitespace_cookie_value_raises() -> None:
|
||||
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad)
|
||||
|
||||
|
||||
def test_invalid_mnemonic_length_raises() -> None:
|
||||
bad_seed = " ".join(["word"] * 23)
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed=bad_seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
def test_valid_mnemonic_lengths() -> None:
|
||||
for length in (12, 18, 24):
|
||||
seed = " ".join(["abandon"] * (length - 1) + ["about"])
|
||||
client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
assert len(client.seed.split()) == length
|
||||
|
||||
|
||||
def test_short_api_key_raises() -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
def test_repr() -> None:
|
||||
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
r = repr(client)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""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 import TransactionError, WalletError
|
||||
from pyfragment.domains.tonapi.transaction import process_transaction
|
||||
from tests.shared import VALID_SEED
|
||||
|
||||
|
||||
def _provider_error(code: int, message: str = "error") -> ProviderResponseError:
|
||||
return ProviderResponseError(code=code, message=message, endpoint="api.tonapi.io")
|
||||
|
||||
|
||||
TRANSACTION_DATA = {
|
||||
"transaction": {
|
||||
"messages": [
|
||||
{
|
||||
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
|
||||
"amount": "500000000", # 0.5 TON
|
||||
"payload": "",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _make_client() -> MagicMock:
|
||||
client = MagicMock()
|
||||
client.api_key = "test_key"
|
||||
client.seed = VALID_SEED.split()
|
||||
client.wallet_version = "V5R1"
|
||||
return client
|
||||
|
||||
|
||||
def _make_wallet(balance_nanotons: int) -> MagicMock:
|
||||
wallet = MagicMock()
|
||||
wallet.refresh = AsyncMock()
|
||||
wallet.balance = balance_nanotons
|
||||
wallet.transfer = AsyncMock(return_value=MagicMock(normalized_hash="abc123"))
|
||||
return wallet
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
||||
with (
|
||||
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)
|
||||
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
|
||||
yield
|
||||
|
||||
|
||||
# Balance threshold tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sufficient_balance_broadcasts() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, 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()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insufficient_balance_raises() -> None:
|
||||
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)
|
||||
wallet.transfer.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_minimum_balance_broadcasts() -> None:
|
||||
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=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)
|
||||
|
||||
|
||||
# Error handling tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_payload_raises() -> None:
|
||||
with pytest.raises(TransactionError):
|
||||
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)
|
||||
wallet.refresh = AsyncMock(side_effect=RuntimeError("network timeout"))
|
||||
with _patch_wallet(wallet):
|
||||
with pytest.raises(WalletError, match="balance"):
|
||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
wallet.transfer.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_retries_and_succeeds() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
|
||||
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""):
|
||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
assert result == "abc123"
|
||||
assert wallet.transfer.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_seqno_raises_after_retries() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||
err = _provider_error(406, "Duplicate msg_seqno")
|
||||
wallet.transfer = AsyncMock(side_effect=[err, err, err])
|
||||
with _patch_wallet(wallet), patch("pyfragment.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,
|
||||
)
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Tests for get_fragment_hash() — fetches a valid lowercase hex hash
|
||||
from the fragment.com/stars/buy 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}"
|
||||
@@ -1,91 +0,0 @@
|
||||
"""Unit tests for process_transaction() — balance checks before broadcast."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment.types import TransactionError, WalletError
|
||||
from pyfragment.utils.wallet import process_transaction
|
||||
|
||||
VALID_SEED = "abandon " * 23 + "about"
|
||||
|
||||
TRANSACTION_DATA = {
|
||||
"transaction": {
|
||||
"messages": [
|
||||
{
|
||||
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
|
||||
"amount": "500000000", # 0.5 TON
|
||||
"payload": "",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _make_client() -> MagicMock:
|
||||
client = MagicMock()
|
||||
client.api_key = "test_key"
|
||||
client.seed = VALID_SEED.split()
|
||||
client.wallet_version = "V5R1"
|
||||
return client
|
||||
|
||||
|
||||
def _make_wallet(balance_nanotons: int) -> MagicMock:
|
||||
wallet = MagicMock()
|
||||
wallet.refresh = AsyncMock()
|
||||
wallet.balance = balance_nanotons
|
||||
wallet.transfer = AsyncMock(return_value=MagicMock(normalized_hash="abc123"))
|
||||
return wallet
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch_wallet(wallet: MagicMock):
|
||||
with (
|
||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
||||
):
|
||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sufficient_balance_broadcasts() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, needs 0.556 TON
|
||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"):
|
||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
assert result == "abc123"
|
||||
wallet.transfer.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insufficient_balance_raises() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, needs 0.556 TON
|
||||
with _patch_wallet(wallet):
|
||||
with pytest.raises(WalletError, match="required"):
|
||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
wallet.transfer.assert_not_called()
|
||||
|
||||
|
||||
@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"):
|
||||
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
|
||||
with _patch_wallet(wallet):
|
||||
with pytest.raises(WalletError, match="required"):
|
||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_payload_raises() -> None:
|
||||
with pytest.raises(TransactionError):
|
||||
await process_transaction(_make_client(), {"transaction": {}})
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Cover stars purchase and giveaway flows, including validation and request wiring."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_purchase_stars_mod = importlib.import_module("pyfragment.domains.purchases.purchase")
|
||||
_giveaway_stars_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway")
|
||||
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[1]
|
||||
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)
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Unit tests for method-level input validation — no network calls."""
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import ConfigurationError
|
||||
|
||||
VALID_SEED = "abandon " * 23 + "about"
|
||||
VALID_API_KEY = "A" * 68
|
||||
VALID_COOKIES = {
|
||||
"stel_ssid": "x",
|
||||
"stel_dt": "x",
|
||||
"stel_token": "x",
|
||||
"stel_ton_token": "x",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> FragmentClient:
|
||||
return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_premium_invalid_months(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.purchase_premium("@user", months=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.purchase_stars("@user", amount=49)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.purchase_stars("@user", amount=1_000_001)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_stars_float_amount(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_amount_zero(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.topup_ton("@user", amount=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_amount_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.topup_ton("@user", amount=1_000_000_001)
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Cover premium purchase and giveaway flows, including validation and request wiring."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_purchase_premium_mod = importlib.import_module("pyfragment.domains.purchases.purchase")
|
||||
_giveaway_premium_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway")
|
||||
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[1]
|
||||
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)
|
||||
@@ -1,146 +0,0 @@
|
||||
"""Tests for purchase methods with all network calls mocked."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import AdsTopupResult, PremiumResult, StarsResult, UserNotFoundError
|
||||
|
||||
VALID_SEED = "abandon " * 23 + "about"
|
||||
VALID_API_KEY = "A" * 68
|
||||
VALID_COOKIES = {
|
||||
"stel_ssid": "x",
|
||||
"stel_dt": "x",
|
||||
"stel_token": "x",
|
||||
"stel_ton_token": "x",
|
||||
}
|
||||
FAKE_HASH = "abc123"
|
||||
FAKE_RECIPIENT = "recipient_token"
|
||||
FAKE_REQ_ID = "req_42"
|
||||
FAKE_TX_HASH = "deadbeef" * 8
|
||||
FAKE_ACCOUNT = {"address": "0:abc", "publicKey": "pub", "chain": "-239", "walletStateInit": "base64=="}
|
||||
FAKE_TRANSACTION = {"transaction": {"messages": [{"address": "0:abc", "amount": "100000000", "payload": ""}]}}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> FragmentClient:
|
||||
return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_stars_success(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch("pyfragment.methods.stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.methods.stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch(
|
||||
"pyfragment.methods.stars.fragment_post",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}}, # searchStarsRecipient
|
||||
{"req_id": FAKE_REQ_ID}, # initBuyStarsRequest
|
||||
]
|
||||
),
|
||||
),
|
||||
patch("pyfragment.methods.stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
|
||||
patch("pyfragment.methods.stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||
):
|
||||
result = await client.purchase_stars("testuser", amount=100)
|
||||
|
||||
assert isinstance(result, StarsResult)
|
||||
assert result.transaction_id == FAKE_TX_HASH
|
||||
assert result.username == "testuser"
|
||||
assert result.stars == 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch("pyfragment.methods.stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.methods.stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch("pyfragment.methods.stars.fragment_post", AsyncMock(return_value={"found": {}})),
|
||||
):
|
||||
with pytest.raises(UserNotFoundError):
|
||||
await client.purchase_stars("ghost", amount=100)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_premium_success(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch("pyfragment.methods.premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.methods.premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch(
|
||||
"pyfragment.methods.premium.fragment_post",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}}, # searchPremiumGiftRecipient
|
||||
{}, # updatePremiumState
|
||||
{"req_id": FAKE_REQ_ID}, # initGiftPremiumRequest
|
||||
]
|
||||
),
|
||||
),
|
||||
patch("pyfragment.methods.premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
|
||||
patch("pyfragment.methods.premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||
):
|
||||
result = await client.purchase_premium("testuser", months=6)
|
||||
|
||||
assert isinstance(result, PremiumResult)
|
||||
assert result.transaction_id == FAKE_TX_HASH
|
||||
assert result.username == "testuser"
|
||||
assert result.months == 6
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch("pyfragment.methods.premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.methods.premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch("pyfragment.methods.premium.fragment_post", AsyncMock(return_value={"found": {}})),
|
||||
):
|
||||
with pytest.raises(UserNotFoundError):
|
||||
await client.purchase_premium("ghost", months=3)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_success(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch("pyfragment.methods.ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.methods.ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch(
|
||||
"pyfragment.methods.ton.fragment_post",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{}, # updateAdsTopupState
|
||||
{"found": {"recipient": FAKE_RECIPIENT}}, # searchAdsTopupRecipient
|
||||
{"req_id": FAKE_REQ_ID}, # initAdsTopupRequest
|
||||
]
|
||||
),
|
||||
),
|
||||
patch("pyfragment.methods.ton.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
|
||||
patch("pyfragment.methods.ton.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||
):
|
||||
result = await client.topup_ton("testuser", amount=10)
|
||||
|
||||
assert isinstance(result, AdsTopupResult)
|
||||
assert result.transaction_id == FAKE_TX_HASH
|
||||
assert result.username == "testuser"
|
||||
assert result.amount == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch("pyfragment.methods.ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.methods.ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch(
|
||||
"pyfragment.methods.ton.fragment_post",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{}, # updateAdsTopupState
|
||||
{"found": {}}, # searchAdsTopupRecipient → not found
|
||||
]
|
||||
),
|
||||
),
|
||||
):
|
||||
with pytest.raises(UserNotFoundError):
|
||||
await client.topup_ton("ghost", amount=10)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Cover TON top-up through Telegram Ads, including recipient lookup and transaction building."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_topup_ton_mod = importlib.import_module("pyfragment.domains.ads.tonup")
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_amount_zero(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.topup_ton("@user", amount=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_amount_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.topup_ton("@user", amount=1_000_000_001)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_float_amount(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.topup_ton("@user", amount=1.5) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# Topup TON mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_success(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{}, # updateAdsTopupState
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
FAKE_TRANSACTION,
|
||||
]
|
||||
),
|
||||
),
|
||||
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)
|
||||
|
||||
assert isinstance(result, AdsTopupResult)
|
||||
assert result.transaction_id == FAKE_TX_HASH
|
||||
assert result.username == "@user"
|
||||
assert result.amount == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
|
||||
with patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{}, # updateAdsTopupState
|
||||
{"found": {}},
|
||||
]
|
||||
),
|
||||
):
|
||||
with pytest.raises(UserNotFoundError):
|
||||
await client.topup_ton("@ghost", amount=10)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Verify wallet inspection returns friendly TON and USDT balances from Tonapi."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient, WalletInfo
|
||||
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON
|
||||
|
||||
# Wallet mocked tests (TON and USDT balances are returned separately)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
|
||||
mock_wallet = MagicMock()
|
||||
mock_wallet.refresh = AsyncMock()
|
||||
mock_wallet.balance = FAKE_BALANCE_NANOTON
|
||||
mock_wallet.state = MagicMock(value="active")
|
||||
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||
|
||||
with (
|
||||
patch("pyfragment.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)
|
||||
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
|
||||
|
||||
result = await client.get_wallet()
|
||||
|
||||
assert isinstance(result, WalletInfo)
|
||||
assert result.address == FAKE_ADDRESS
|
||||
assert result.state == "active"
|
||||
assert result.ton_balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4)
|
||||
assert result.usdt_balance == 12.3456
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
|
||||
mock_wallet = MagicMock()
|
||||
mock_wallet.refresh = AsyncMock()
|
||||
mock_wallet.balance = 0
|
||||
mock_wallet.state = MagicMock(value="uninit")
|
||||
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||
|
||||
with (
|
||||
patch("pyfragment.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)
|
||||
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
|
||||
|
||||
result = await client.get_wallet()
|
||||
|
||||
assert result.ton_balance == 0.0
|
||||
assert result.usdt_balance == 0.0
|
||||
assert result.state == "uninit"
|
||||
@@ -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.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:
|
||||
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:
|
||||
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:
|
||||
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}
|
||||
|
||||
|
||||
# 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"})
|
||||
@@ -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"
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Cover Telegram Ads recharge flow, including request preparation and KYC handling."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_recharge_ads_mod = importlib.import_module("pyfragment.domains.ads.recharge")
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_amount_zero(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=1_000_000_001)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_float_amount(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=5.5) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# recharge_ads mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_success(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{}, # updateAdsState
|
||||
{"req_id": FAKE_REQ_ID}, # initAdsRechargeRequest
|
||||
FAKE_TRANSACTION, # getAdsRechargeLink
|
||||
]
|
||||
),
|
||||
),
|
||||
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)
|
||||
|
||||
assert isinstance(result, AdsRechargeResult)
|
||||
assert result.transaction_id == FAKE_TX_HASH
|
||||
assert result.amount == 10
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Verify username search parsing and query forwarding."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient, UsernamesResult
|
||||
|
||||
FAKE_HTML = """
|
||||
<tr class="tm-row-selectable">
|
||||
<td><a href="/username/coolname" class="table-cell">
|
||||
<div class="table-cell-value tm-value">@coolname</div>
|
||||
<div class="table-cell-status-thin thin-only tm-status-avail">On auction</div>
|
||||
</a></td>
|
||||
<td><div class="table-cell-value tm-value icon-before icon-ton">5</div>
|
||||
<time datetime="2026-06-01T12:00:00+00:00" data-relative="text">2 days</time>
|
||||
</td>
|
||||
<td><div class="table-cell-value tm-value tm-status-avail">On auction</div></td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
|
||||
# search_usernames result parsing tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_usernames_basic(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})):
|
||||
result = await client.search_usernames("coolname")
|
||||
|
||||
assert isinstance(result, UsernamesResult)
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0]["slug"] == "username/coolname"
|
||||
assert result.items[0]["name"] == "@coolname"
|
||||
assert result.items[0]["date"] == "2026-06-01T12:00:00+00:00"
|
||||
assert result.next_offset_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_usernames_empty_html(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
|
||||
result = await client.search_usernames("zzz_no_results")
|
||||
|
||||
assert isinstance(result, UsernamesResult)
|
||||
assert result.items == []
|
||||
assert result.next_offset_id is None
|
||||
|
||||
|
||||
# search_usernames parameter forwarding tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_usernames_with_sort_and_filter(client: FragmentClient) -> None:
|
||||
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_call.call_args[0][1]
|
||||
assert call_data["type"] == "usernames"
|
||||
assert call_data["sort"] == "price_desc"
|
||||
assert call_data["filter"] == "auction"
|
||||
assert call_data["query"] == "durov"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_usernames_with_offset_id(client: FragmentClient) -> None:
|
||||
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_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:
|
||||
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_call.call_args[0][1]
|
||||
assert call_data["query"] == ""
|
||||
assert call_data["type"] == "usernames"
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Verify anonymous number search parsing and query forwarding."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient, NumbersResult
|
||||
|
||||
FAKE_HTML = """
|
||||
<tr class="tm-row-selectable">
|
||||
<td><a href="/number/8880000888" class="table-cell">
|
||||
<div class="table-cell-value tm-value">+888 0000 888</div>
|
||||
<div class="table-cell-status-thin thin-only tm-status-avail">For sale</div>
|
||||
</a></td>
|
||||
<td><div class="table-cell-value tm-value icon-before icon-ton">150</div>
|
||||
<time datetime="2026-05-15T10:00:00+00:00" data-relative="short-text">May 15</time>
|
||||
</td>
|
||||
<td><div class="table-cell-value tm-value tm-status-avail">For sale</div></td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
|
||||
# search_numbers result parsing tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_numbers_basic(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})):
|
||||
result = await client.search_numbers("888")
|
||||
|
||||
assert isinstance(result, NumbersResult)
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0]["slug"] == "number/8880000888"
|
||||
assert result.items[0]["name"] == "+888 0000 888"
|
||||
assert result.items[0]["date"] == "2026-05-15T10:00:00+00:00"
|
||||
assert result.next_offset_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_numbers_empty_html(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
|
||||
result = await client.search_numbers("zzz_no_results")
|
||||
|
||||
assert isinstance(result, NumbersResult)
|
||||
assert result.items == []
|
||||
assert result.next_offset_id is None
|
||||
|
||||
|
||||
# search_numbers parameter forwarding tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_numbers_with_sort_and_filter(client: FragmentClient) -> None:
|
||||
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_call.call_args[0][1]
|
||||
assert call_data["type"] == "numbers"
|
||||
assert call_data["sort"] == "price_asc"
|
||||
assert call_data["filter"] == "sale"
|
||||
assert call_data["query"] == "888"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_numbers_with_offset_id(client: FragmentClient) -> None:
|
||||
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_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:
|
||||
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_call.call_args[0][1]
|
||||
assert call_data["query"] == ""
|
||||
assert call_data["type"] == "numbers"
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Verify gift search parsing and pagination handling."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient, GiftsResult
|
||||
|
||||
FAKE_GIFTS_HTML = """
|
||||
<div class="tm-catalog-grid">
|
||||
<a href="/gift/plushpepe-1821?collection=all" class="tm-grid-item">
|
||||
<div class="tm-grid-item-thumb">
|
||||
<img src="https://nft.fragment.com/gift/plushpepe-1821.medium.jpg" class="tm-grid-thumb"/>
|
||||
</div>
|
||||
<div class="tm-grid-item-content">
|
||||
<div class="tm-grid-item-name wide-only">
|
||||
<span class="item-name">Plush Pepe</span>
|
||||
<span class="item-num"> #1821</span>
|
||||
</div>
|
||||
<div class="tm-grid-item-desc wide-only">
|
||||
<time datetime="2026-02-05T14:41:27+00:00" class="short">Feb 5 at 16:41</time>
|
||||
</div>
|
||||
<div class="tm-grid-item-values">
|
||||
<div class="tm-grid-item-value tm-value icon-before icon-ton">88,888</div>
|
||||
<div class="tm-grid-item-status tm-status-unavail">Sold</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/gift/swisswatch-7799?collection=all" class="tm-grid-item">
|
||||
<div class="tm-grid-item-content">
|
||||
<div class="tm-grid-item-name wide-only">
|
||||
<span class="item-name">Swiss Watch</span>
|
||||
<span class="item-num"> #7799</span>
|
||||
</div>
|
||||
<div class="tm-grid-item-desc wide-only">
|
||||
<time datetime="2026-01-10T04:52:59+00:00" class="short">Jan 10 at 06:52</time>
|
||||
</div>
|
||||
<div class="tm-grid-item-values">
|
||||
<div class="tm-grid-item-value tm-value icon-before icon-ton">13,588</div>
|
||||
<div class="tm-grid-item-status tm-status-unavail">Sold</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="tm-catalog-grid-more js-load-more" data-next-offset="60">Show more</a>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
# search_gifts result parsing tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_gifts_basic(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})):
|
||||
result = await client.search_gifts()
|
||||
|
||||
assert isinstance(result, GiftsResult)
|
||||
assert len(result.items) == 2
|
||||
assert result.items[0]["slug"] == "gift/plushpepe-1821"
|
||||
assert result.items[0]["name"] == "Plush Pepe #1821"
|
||||
assert result.items[0]["status"] == "Sold"
|
||||
assert result.items[0]["price"] == "88888.00"
|
||||
assert result.items[0]["date"] == "2026-02-05T14:41:27+00:00"
|
||||
assert result.items[1]["slug"] == "gift/swisswatch-7799"
|
||||
assert result.items[1]["price"] == "13588.00"
|
||||
assert result.next_offset == 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_gifts_empty(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
|
||||
result = await client.search_gifts(query="zzz_no_results")
|
||||
|
||||
assert isinstance(result, GiftsResult)
|
||||
assert result.items == []
|
||||
assert result.next_offset is None
|
||||
|
||||
|
||||
# search_gifts parameter forwarding tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_gifts_with_collection_and_sort(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_gifts(collection="plushpepe", sort="price_desc", filter="sold")
|
||||
|
||||
assert isinstance(result, GiftsResult)
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["collection"] == "plushpepe"
|
||||
assert call_data["sort"] == "price_desc"
|
||||
assert call_data["filter"] == "sold"
|
||||
assert call_data["type"] == "gifts"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_gifts_with_offset(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_gifts(offset=60)
|
||||
|
||||
assert isinstance(result, GiftsResult)
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["offset"] == 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_gifts_with_view(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_gifts(collection="artisanbrick", view="Model")
|
||||
|
||||
assert isinstance(result, GiftsResult)
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["view"] == "Model"
|
||||
assert call_data["collection"] == "artisanbrick"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_gifts_with_attr(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_gifts(
|
||||
collection="artisanbrick",
|
||||
sort="listed",
|
||||
filter="auction",
|
||||
attr={
|
||||
"Model": ["Delicate Wash", "Foosball", "Chocolate"],
|
||||
"Backdrop": ["Celtic Blue", "Carrot Juice", "Orange"],
|
||||
"Symbol": ["Crystal Ball", "Tetsubin", "Acorn"],
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(result, GiftsResult)
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["attr[Model]"] == ["Delicate Wash", "Foosball", "Chocolate"]
|
||||
assert call_data["attr[Backdrop]"] == ["Celtic Blue", "Carrot Juice", "Orange"]
|
||||
assert call_data["attr[Symbol]"] == ["Crystal Ball", "Tetsubin", "Acorn"]
|
||||
assert call_data["collection"] == "artisanbrick"
|
||||
assert call_data["sort"] == "listed"
|
||||
assert call_data["filter"] == "auction"
|
||||
assert call_data["type"] == "gifts"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_gifts_attr_not_in_data_when_none(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_gifts()
|
||||
|
||||
assert isinstance(result, GiftsResult)
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert "view" not in call_data
|
||||
assert not any(k.startswith("attr[") for k in call_data)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Extract Fragment cookies from browser stores and validate required keys."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import CookieError, get_cookies_from_browser
|
||||
from pyfragment.core.constants import REQUIRED_COOKIE_KEYS
|
||||
|
||||
FAKE_JAR = [
|
||||
{"name": "stel_ssid", "value": "abc123", "domain": "fragment.com", "expires": "2027-04-03T20:52:16.375Z"},
|
||||
{"name": "stel_dt", "value": "-120", "domain": "fragment.com"},
|
||||
{"name": "stel_token", "value": "tok_xyz", "domain": "fragment.com"},
|
||||
{"name": "stel_ton_token", "value": "ton_xyz", "domain": "fragment.com"},
|
||||
{"name": "unrelated", "value": "noise", "domain": "fragment.com"},
|
||||
]
|
||||
|
||||
|
||||
def _mock_rookiepy(jar: list[dict[str, str]] | None = None) -> MagicMock:
|
||||
mock = MagicMock()
|
||||
mock.chrome.return_value = jar if jar is not None else FAKE_JAR
|
||||
return mock
|
||||
|
||||
|
||||
PATCH = "pyfragment.core.cookies.rookiepy"
|
||||
|
||||
|
||||
# unsupported browser tests
|
||||
|
||||
|
||||
def test_unsupported_browser_raises() -> None:
|
||||
with pytest.raises(CookieError, match="Unsupported browser"):
|
||||
get_cookies_from_browser("internet_explorer")
|
||||
|
||||
|
||||
# successful extraction tests
|
||||
|
||||
|
||||
def test_returns_required_keys_only() -> None:
|
||||
with patch(PATCH, _mock_rookiepy()):
|
||||
result = get_cookies_from_browser("chrome")
|
||||
|
||||
assert set(result.cookies.keys()) == set(REQUIRED_COOKIE_KEYS)
|
||||
assert result.cookies["stel_ssid"] == "abc123"
|
||||
assert result.cookies["stel_dt"] == "-120"
|
||||
assert result.cookies["stel_token"] == "tok_xyz"
|
||||
assert result.cookies["stel_ton_token"] == "ton_xyz"
|
||||
assert "unrelated" not in result.cookies
|
||||
|
||||
|
||||
def test_returns_expiry() -> None:
|
||||
with patch(PATCH, _mock_rookiepy()):
|
||||
result = get_cookies_from_browser("chrome")
|
||||
|
||||
assert result.expires == "2027-04-03T20:52:16.375000+00:00"
|
||||
|
||||
|
||||
def test_returns_none_expiry_when_missing() -> None:
|
||||
jar = [{"name": k, "value": "v"} for k in REQUIRED_COOKIE_KEYS]
|
||||
with patch(PATCH, _mock_rookiepy(jar)):
|
||||
result = get_cookies_from_browser("chrome")
|
||||
|
||||
assert result.expires is None
|
||||
|
||||
|
||||
def test_default_browser_is_chrome() -> None:
|
||||
mock_rp = _mock_rookiepy()
|
||||
with patch(PATCH, mock_rp):
|
||||
get_cookies_from_browser()
|
||||
|
||||
mock_rp.chrome.assert_called_once_with(["fragment.com"])
|
||||
|
||||
|
||||
def test_browser_name_is_case_insensitive() -> None:
|
||||
mock_rp = _mock_rookiepy()
|
||||
with patch(PATCH, mock_rp):
|
||||
result = get_cookies_from_browser("Chrome")
|
||||
|
||||
assert result.cookies["stel_ssid"] == "abc123"
|
||||
|
||||
|
||||
# missing cookies tests
|
||||
|
||||
|
||||
def test_missing_cookies_raises() -> None:
|
||||
partial_jar = [
|
||||
{"name": "stel_ssid", "value": "abc123"},
|
||||
{"name": "stel_dt", "value": "-120"},
|
||||
# stel_token and stel_ton_token missing
|
||||
]
|
||||
with patch(PATCH, _mock_rookiepy(partial_jar)):
|
||||
with pytest.raises(CookieError, match="Fragment cookies not found in chrome"):
|
||||
get_cookies_from_browser("chrome")
|
||||
|
||||
|
||||
def test_empty_cookie_value_treated_as_missing() -> None:
|
||||
jar_with_empty = [
|
||||
{"name": "stel_ssid", "value": "abc123"},
|
||||
{"name": "stel_dt", "value": ""}, # empty — should be treated as missing
|
||||
{"name": "stel_token", "value": "tok_xyz"},
|
||||
{"name": "stel_ton_token", "value": "ton_xyz"},
|
||||
]
|
||||
with patch(PATCH, _mock_rookiepy(jar_with_empty)):
|
||||
with pytest.raises(CookieError, match="Fragment cookies not found in chrome"):
|
||||
get_cookies_from_browser("chrome")
|
||||
|
||||
|
||||
# expired cookie tests
|
||||
|
||||
|
||||
def test_expired_cookie_raises() -> None:
|
||||
expired_jar = [
|
||||
{"name": "stel_ssid", "value": "abc123", "expires": "2020-01-01T00:00:00.000Z"},
|
||||
{"name": "stel_dt", "value": "-120"},
|
||||
{"name": "stel_token", "value": "tok_xyz"},
|
||||
{"name": "stel_ton_token", "value": "ton_xyz"},
|
||||
]
|
||||
with patch(PATCH, _mock_rookiepy(expired_jar)):
|
||||
with pytest.raises(CookieError, match="expired"):
|
||||
get_cookies_from_browser("chrome")
|
||||
|
||||
|
||||
# read failure tests
|
||||
|
||||
|
||||
def test_browser_read_error_raises() -> None:
|
||||
mock_rp = MagicMock()
|
||||
mock_rp.chrome.side_effect = PermissionError("locked")
|
||||
with patch(PATCH, mock_rp):
|
||||
with pytest.raises(CookieError, match="Failed to read chrome cookies"):
|
||||
get_cookies_from_browser("chrome")
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Parse Fragment init responses to the payment amount the transaction should cover."""
|
||||
|
||||
from pyfragment.domains.payments import parse_required_payment_amount
|
||||
|
||||
|
||||
def test_parse_required_payment_amount_ton_uses_amount() -> None:
|
||||
init_response = {"amount": "0.326"}
|
||||
assert parse_required_payment_amount(init_response) == 0.326
|
||||
|
||||
|
||||
def test_parse_required_payment_amount_usdt_uses_amount() -> None:
|
||||
init_response = {
|
||||
"amount": "0.00075",
|
||||
"content": '<span class="icon-before icon-usd">0.75</span>',
|
||||
}
|
||||
assert parse_required_payment_amount(init_response) == 0.00075
|
||||
|
||||
|
||||
def test_parse_required_payment_amount_usdt_falls_back_to_amount() -> None:
|
||||
init_response = {"amount": "1.25", "content": "<p>no usd icon</p>"}
|
||||
assert parse_required_payment_amount(init_response) == 1.25
|
||||
+19
-3
@@ -1,16 +1,32 @@
|
||||
"""Shared pytest fixtures for Fragment client tests."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
import pyfragment.domains.ads.recharge # noqa: F401
|
||||
import pyfragment.domains.ads.tonup # noqa: F401
|
||||
import pyfragment.domains.giveaways.giveaway # noqa: F401
|
||||
import pyfragment.domains.purchases.purchase # noqa: F401
|
||||
import pyfragment.domains.tonapi.account # noqa: F401
|
||||
import pyfragment.domains.tonapi.transaction # noqa: F401
|
||||
from pyfragment import FragmentClient
|
||||
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cookies():
|
||||
"""Load Fragment cookies from COOKIES_JSON env var; skip if unavailable."""
|
||||
def cookies() -> dict[str, str]:
|
||||
raw = os.environ.get("COOKIES_JSON")
|
||||
if not raw:
|
||||
pytest.skip("COOKIES_JSON env var not set")
|
||||
try:
|
||||
return json.loads(raw)
|
||||
return cast(dict[str, str], json.loads(raw))
|
||||
except Exception as exc:
|
||||
pytest.skip(f"Cookies unavailable — {exc}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> FragmentClient:
|
||||
return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Shared test constants for the pyfragment test suite.
|
||||
|
||||
pyfragment is an async Python client for the Fragment API — a unified toolkit
|
||||
to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances,
|
||||
run giveaways, manage anonymous numbers, and explore the marketplace for usernames,
|
||||
numbers, and gifts.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Credentials and config
|
||||
VALID_SEED: str = "abandon " * 23 + "about"
|
||||
VALID_API_KEY: str = "A" * 68
|
||||
VALID_COOKIES: dict[str, str] = {
|
||||
"stel_ssid": "x",
|
||||
"stel_dt": "x",
|
||||
"stel_token": "x",
|
||||
"stel_ton_token": "x",
|
||||
}
|
||||
|
||||
# Generic test data
|
||||
FAKE_HASH: str = "abc123"
|
||||
FAKE_RECIPIENT: str = "recipient_token"
|
||||
FAKE_REQ_ID: str = "req_42"
|
||||
FAKE_TX_HASH: str = "deadbeef" * 8
|
||||
FAKE_ACCOUNT: dict[str, Any] = {"address": "0:abc", "publicKey": "pub", "chain": "-239", "walletStateInit": "base64=="}
|
||||
FAKE_TRANSACTION: dict[str, Any] = {"transaction": {"messages": [{"address": "0:abc", "amount": "100000000", "payload": ""}]}}
|
||||
|
||||
# client.call()
|
||||
FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}}
|
||||
|
||||
# get_wallet()
|
||||
FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5"
|
||||
FAKE_BALANCE_NANOTON: int = 1_500_000_000 # 1.5 TON
|
||||
|
||||
# recharge_ads
|
||||
FAKE_ADS_ACCOUNT: str = "@mychannel"
|
||||
|
||||
# Revenue withdrawals
|
||||
FAKE_WITHDRAWAL_WALLET: str = "EQDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
FAKE_REVENUE_TX: str = "revenue_tx_abc123"
|
||||
|
||||
# Anonymous number
|
||||
FAKE_HTML_WITH_CODE: str = """
|
||||
<table>
|
||||
<tr>
|
||||
<td class="table-cell-value">12345</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>session data</td>
|
||||
</tr>
|
||||
</table>
|
||||
"""
|
||||
FAKE_HTML_NO_CODE: str = "<table><tr><td>no code here</td></tr></table>"
|
||||
FAKE_TERMINATE_HASH: str = "terminate_hash_abc123"
|
||||
Reference in New Issue
Block a user