mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-30 16:45:35 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1478ec8b0 | |||
| 363e0719ab | |||
| 35ab0e337d | |||
| b67415bcc2 | |||
| 5df95bea29 | |||
| 7412448f9c | |||
| 6dd9dcb5d4 | |||
| 311222d478 | |||
| 72918a6dd6 | |||
| eec59f7e12 | |||
| c1ae5f0f28 | |||
| 4873d6d6dd | |||
| 1f6085acdc | |||
| d5dfed5f1a | |||
| 7923dff8b2 | |||
| 8c7423a6ab | |||
| 8f3e9f4bd8 | |||
| fca60135a6 | |||
| d66602b646 | |||
| 333b6f45ce |
@@ -1,101 +1,101 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
description: Report an issue or unexpected behavior in pyfragment.
|
description: Report an issue or unexpected behavior in pyfragment.
|
||||||
labels:
|
labels:
|
||||||
- bug
|
- bug
|
||||||
body:
|
body:
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: Checklist
|
label: Checklist
|
||||||
options:
|
options:
|
||||||
- label: I am sure the error is coming from pyfragment code
|
- label: I am sure the error is coming from pyfragment code
|
||||||
required: true
|
required: true
|
||||||
- label: I have searched the issue tracker for similar bug reports, including closed ones
|
- 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:
|
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
label: Python version
|
value: |
|
||||||
description: Run `python --version` inside your virtualenv
|
## Context
|
||||||
placeholder: e.g. 3.12.3
|
Please provide as much detail as possible to help us reproduce and fix the issue.
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: pyfragment version
|
label: Operating system
|
||||||
description: Run `pip show pyfragment` inside your virtualenv
|
placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11
|
||||||
placeholder: e.g. 2026.1.0
|
validations:
|
||||||
validations:
|
required: true
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: Expected behavior
|
label: Python version
|
||||||
description: Describe what you expected to happen.
|
description: Run `python --version` inside your virtualenv
|
||||||
placeholder: e.g. Stars should be purchased and StarsResult returned.
|
placeholder: e.g. 3.12.3
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: Current behavior
|
label: pyfragment version
|
||||||
description: Describe what is actually happening.
|
description: Run `pip show pyfragment` inside your virtualenv
|
||||||
placeholder: e.g. ParseError is raised with status 400.
|
placeholder: e.g. 2026.1.0
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to reproduce
|
label: Expected behavior
|
||||||
description: Minimal steps that reproduce the issue.
|
description: Describe what you expected to happen.
|
||||||
placeholder: |
|
placeholder: e.g. Stars should be purchased and StarsResult returned.
|
||||||
1. Create FragmentClient with valid credentials
|
validations:
|
||||||
2. Call purchase_stars("@username", amount=100)
|
required: true
|
||||||
3. See error
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Code example
|
label: Current behavior
|
||||||
description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable.
|
description: Describe what is actually happening.
|
||||||
placeholder: |
|
placeholder: e.g. ParseError is raised with status 400.
|
||||||
import asyncio
|
validations:
|
||||||
from pyfragment import FragmentClient
|
required: true
|
||||||
|
|
||||||
async def main():
|
- type: textarea
|
||||||
client = FragmentClient(...)
|
attributes:
|
||||||
result = await client.purchase_stars("@username", amount=100)
|
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())
|
- type: textarea
|
||||||
render: python
|
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
|
async def main():
|
||||||
attributes:
|
client = FragmentClient(...)
|
||||||
label: Traceback / logs
|
result = await client.purchase_stars("@username", amount=100)
|
||||||
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
|
asyncio.run(main())
|
||||||
attributes:
|
render: python
|
||||||
label: Additional information
|
|
||||||
description: Anything else that might help us diagnose the problem.
|
- type: textarea
|
||||||
placeholder: e.g. Only happens with V5R1 wallet version.
|
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
|
blank_issues_enabled: true
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Ask a question or start a discussion
|
- name: Ask a question or start a discussion
|
||||||
url: https://github.com/bohd4nx/pyfragment/discussions
|
url: https://github.com/bohd4nx/pyfragment/discussions
|
||||||
about: General questions, ideas, and community help go here — not in the issue tracker.
|
about: General questions, ideas, and community help go here — not in the issue tracker.
|
||||||
|
|||||||
@@ -1,51 +1,51 @@
|
|||||||
name: Feature request
|
name: Feature request
|
||||||
description: Suggest an improvement or new feature for pyfragment.
|
description: Suggest an improvement or new feature for pyfragment.
|
||||||
labels:
|
labels:
|
||||||
- enhancement
|
- enhancement
|
||||||
body:
|
body:
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
attributes:
|
attributes:
|
||||||
label: pyfragment version
|
label: pyfragment version
|
||||||
description: Which version are you running?
|
description: Which version are you running?
|
||||||
options:
|
options:
|
||||||
- latest
|
- latest
|
||||||
- older
|
- older
|
||||||
- n/a
|
- n/a
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Problem
|
label: Problem
|
||||||
description: Is your request related to a specific problem? Describe it.
|
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.
|
placeholder: e.g. There is no way to check my current TON balance before sending.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Proposed solution
|
label: Proposed solution
|
||||||
description: Describe what you would like to see added or changed.
|
description: Describe what you would like to see added or changed.
|
||||||
placeholder: e.g. Add a get_balance() method to FragmentClient.
|
placeholder: e.g. Add a get_balance() method to FragmentClient.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Alternatives considered
|
label: Alternatives considered
|
||||||
description: Any workarounds or alternative approaches you have thought of.
|
description: Any workarounds or alternative approaches you have thought of.
|
||||||
placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
|
placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Code example
|
label: Code example
|
||||||
description: A short example demonstrating the desired API, if applicable.
|
description: A short example demonstrating the desired API, if applicable.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
balance = await client.get_balance()
|
balance = await client.get_balance()
|
||||||
print(balance.ton)
|
print(balance.ton)
|
||||||
render: python
|
render: python
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional information
|
label: Additional information
|
||||||
description: Any other context, screenshots, or references.
|
description: Any other context, screenshots, or references.
|
||||||
|
|||||||
+16
-16
@@ -1,19 +1,19 @@
|
|||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "pip"
|
- package-ecosystem: "pip"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
day: "monday"
|
day: "monday"
|
||||||
open-pull-requests-limit: 5
|
open-pull-requests-limit: 5
|
||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
|
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
day: "monday"
|
day: "monday"
|
||||||
open-pull-requests-limit: 5
|
open-pull-requests-limit: 5
|
||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
|
|||||||
+17
-23
@@ -2,9 +2,9 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["**"]
|
branches: [ "**" ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["**"]
|
branches: [ "**" ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
@@ -14,15 +14,15 @@ jobs:
|
|||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6.0.2
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/setup-python@v6.2.0
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.10"
|
||||||
|
|
||||||
- run: pip install ".[dev]"
|
- run: pip install ".[dev]"
|
||||||
|
|
||||||
- run: ruff check . && black --check . --target-version py312 && mypy pyfragment
|
- run: ruff check . && ruff format --check . && mypy pyfragment
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Tests (Python ${{ matrix.python-version }})
|
name: Tests (Python ${{ matrix.python-version }})
|
||||||
@@ -33,23 +33,17 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12"] # 3.13, 3.14 are not supported by some dependencies yet
|
python-version: [ "3.10", "3.11", "3.12" ] # 3.13, 3.14 are not supported by some dependencies yet
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6.0.2
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up conda (Miniconda)
|
- uses: actions/setup-python@v6
|
||||||
uses: conda-incubator/setup-miniconda@v3
|
with:
|
||||||
with:
|
python-version: ${{ matrix.python-version }}
|
||||||
auto-update-conda: true
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
activate-environment: pyfragment-test
|
|
||||||
auto-activate-base: false
|
|
||||||
|
|
||||||
- name: Install package and dev dependencies
|
- name: Install package and dev dependencies
|
||||||
shell: bash -el {0}
|
run: pip install ".[dev]"
|
||||||
run: pip install ".[dev]"
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
shell: bash -el {0}
|
run: pytest
|
||||||
run: pytest
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ name: Publish
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["CI"]
|
workflows: [ "CI" ]
|
||||||
types: [completed]
|
types: [ completed ]
|
||||||
branches: [master]
|
branches: [ master ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
version-check:
|
version-check:
|
||||||
@@ -18,24 +18,24 @@ jobs:
|
|||||||
is-new: ${{ steps.tag.outputs.is-new }}
|
is-new: ${{ steps.tag.outputs.is-new }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6.0.2
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Read version
|
- name: Read version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
|
value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
|
||||||
echo "value=$value" >> $GITHUB_OUTPUT
|
echo "value=$value" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Check tag
|
- name: Check tag
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: |
|
||||||
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
|
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
|
||||||
echo "is-new=false" >> $GITHUB_OUTPUT
|
echo "is-new=false" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo "is-new=true" >> $GITHUB_OUTPUT
|
echo "is-new=true" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
@@ -46,24 +46,24 @@ jobs:
|
|||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6.0.2
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/setup-python@v6.2.0
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
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
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist/*
|
path: dist/*
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Publish to PyPI
|
name: Publish to PyPI
|
||||||
needs: [version-check, build]
|
needs: [ version-check, build ]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
@@ -74,16 +74,16 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v8.0.1
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
- uses: pypa/gh-action-pypi-publish@v1.13.0
|
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: GitHub Release
|
name: GitHub Release
|
||||||
needs: [version-check, build]
|
needs: [ version-check, build ]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
@@ -91,27 +91,27 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6.0.2
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/download-artifact@v8.0.1
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
- name: Extract latest changelog entry
|
- name: Extract latest changelog entry
|
||||||
id: changelog
|
id: changelog
|
||||||
run: |
|
run: |
|
||||||
body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||||
echo "body<<EOF" >> $GITHUB_OUTPUT
|
echo "body<<EOF" >> $GITHUB_OUTPUT
|
||||||
echo "$body" >> $GITHUB_OUTPUT
|
echo "$body" >> $GITHUB_OUTPUT
|
||||||
echo "EOF" >> $GITHUB_OUTPUT
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- uses: softprops/action-gh-release@v2.6.1
|
- uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
tag_name: v${{ needs.version-check.outputs.version }}
|
tag_name: v${{ needs.version-check.outputs.version }}
|
||||||
name: v${{ needs.version-check.outputs.version }}
|
name: v${{ needs.version-check.outputs.version }}
|
||||||
files: dist/*
|
files: dist/*
|
||||||
body: ${{ steps.changelog.outputs.body }}
|
body: ${{ steps.changelog.outputs.body }}
|
||||||
make_latest: true
|
make_latest: true
|
||||||
|
|||||||
@@ -7,6 +7,68 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
## [2026.2.0] — 2026-04-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -33,37 +95,44 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
**Giveaways**
|
**Giveaways**
|
||||||
|
|
||||||
- `giveaway_stars(channel, winners, amount)` — Stars giveaway; 1–5 winners, 500–1 000 000 stars each
|
- `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
|
- `giveaway_premium(channel, winners, months)` — Premium giveaway; 1–24 000 winners, 3/6/12 months each
|
||||||
- `StarsGiveawayResult`, `PremiumGiveawayResult` result types
|
- `StarsGiveawayResult`, `PremiumGiveawayResult` result types
|
||||||
|
|
||||||
**Telegram Ads**
|
**Telegram Ads**
|
||||||
|
|
||||||
- `recharge_ads(account, amount)` — top up a Telegram Ads account; 1–1 000 000 000 TON
|
- `recharge_ads(account, amount)` — top up a Telegram Ads account; 1–1 000 000 000 TON
|
||||||
- `AdsRechargeResult` result type
|
- `AdsRechargeResult` result type
|
||||||
|
|
||||||
**Marketplace**
|
**Marketplace**
|
||||||
|
|
||||||
- `search_usernames(query?, sort?, filter?, offset_id?)` — search Fragment usernames; `sort`: `price_desc / price_asc / listed / ending`, `filter`: `auction / sale / sold`
|
- `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_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"]}`
|
- `search_gifts(query?, collection?, sort?, filter?, view?, attr?, offset?)` — search Fragment gifts; `attr` accepts `{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}`
|
||||||
- `UsernamesResult`, `NumbersResult`, `GiftsResult` result types
|
- `UsernamesResult`, `NumbersResult`, `GiftsResult` result types
|
||||||
|
|
||||||
**Anonymous numbers**
|
**Anonymous numbers**
|
||||||
|
|
||||||
- `get_login_code(number)` — fetch the current pending login code
|
- `get_login_code(number)` — fetch the current pending login code
|
||||||
- `toggle_login_codes(number, can_receive)` — enable or disable login code delivery
|
- `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)
|
- `terminate_sessions(number)` — terminate all active Telegram sessions (two-step flow handled internally)
|
||||||
- `LoginCodeResult`, `TerminateSessionsResult` result types; `AnonymousNumberError` exception
|
- `LoginCodeResult`, `TerminateSessionsResult` result types; `AnonymousNumberError` exception
|
||||||
|
|
||||||
**Raw API**
|
**Raw API**
|
||||||
|
|
||||||
- `FragmentClient.call(method, data, *, page_url)` — raw request to any Fragment API method
|
- `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
|
- `FRAGMENT_BASE_URL` constant — base URL shared across all page constants and headers
|
||||||
|
|
||||||
**Examples**
|
**Examples**
|
||||||
|
|
||||||
- `examples/client/` — `wallet_info.py` (wallet info), `raw_api_call.py` (raw API call)
|
- `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/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/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`
|
- `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
|
### Changed
|
||||||
|
|
||||||
- All result types now expose a unified `amount` field (`months` and `stars` removed)
|
- All result types now expose a unified `amount` field (`months` and `stars` removed)
|
||||||
- `__repr__` includes the unit — `3 months`, `500 stars`, etc.
|
- `__repr__` includes the unit — `3 months`, `500 stars`, etc.
|
||||||
- `timestamp` removed from all result dataclasses
|
- `timestamp` removed from all result dataclasses
|
||||||
@@ -76,15 +145,18 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
|||||||
## [2026.0.2] — 2026-03-20
|
## [2026.0.2] — 2026-03-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- `timeout` parameter on `FragmentClient` (default `30.0` s) — passed through to every HTTP request
|
- `timeout` parameter on `FragmentClient` (default `30.0` s) — passed through to every HTTP request
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Cookie validation: narrowed type internally so no `# type: ignore` is needed in `FragmentClient.__init__`
|
- 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
|
- `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
|
- 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
|
- README usage section rewritten with a single comprehensive `async with` example
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- mypy: missing return path in `process_transaction` after retry loop
|
- mypy: missing return path in `process_transaction` after retry loop
|
||||||
- mypy: `cookies` union-attr error in `FragmentClient.__init__`
|
- mypy: `cookies` union-attr error in `FragmentClient.__init__`
|
||||||
|
|
||||||
@@ -93,6 +165,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
|||||||
## [2026.0.1] — 2026-03-16
|
## [2026.0.1] — 2026-03-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Initial stable release of `pyfragment`
|
- Initial stable release of `pyfragment`
|
||||||
- `FragmentClient` — async client for the Fragment.com API with context manager support (`async with`)
|
- `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)
|
- `purchase_premium(username, months)` — purchase Telegram Premium for any user (3, 6, or 12 months)
|
||||||
@@ -104,6 +177,9 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
|||||||
- `py.typed` marker — full PEP 561 typing support for type-checkers
|
- `py.typed` marker — full PEP 561 typing support for type-checkers
|
||||||
- `__repr__` on all result types for readable debug output
|
- `__repr__` on all result types for readable debug output
|
||||||
|
|
||||||
|
[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.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.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.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.2
|
||||||
|
|||||||
@@ -4,15 +4,14 @@
|
|||||||
<h1 style="margin-top: 24px;">Fragment API</h1>
|
<h1 style="margin-top: 24px;">Fragment API</h1>
|
||||||
|
|
||||||
<p style="font-size: 18px; margin-bottom: 24px;">
|
<p style="font-size: 18px; margin-bottom: 24px;">
|
||||||
<b>Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts.</b>
|
<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>
|
</p>
|
||||||
|
|
||||||
[](https://pypi.org/project/pyfragment/)
|
[](https://pypi.org/project/pyfragment/)
|
||||||
[](https://pypi.org/project/pyfragment/)
|
[](https://pepy.tech/projects/pyfragment)
|
||||||
[](https://python.org)
|
[](https://python.org)
|
||||||
[](LICENSE)
|
[](https://github.com/bohd4nx/pyfragment/actions)
|
||||||
[](https://github.com/bohd4nx/pyfragment/stargazers)
|
[](https://github.com/bohd4nx/pyfragment/blob/master/LICENSE)
|
||||||
[](https://github.com/bohd4nx/pyfragment/actions)
|
|
||||||
|
|
||||||
[Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
|
[Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
|
||||||
|
|
||||||
@@ -75,22 +74,13 @@ Refresh when you get authentication errors.
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
import asyncio
|
import asyncio
|
||||||
from pyfragment import (
|
from pyfragment import FragmentClient
|
||||||
FragmentClient,
|
|
||||||
FragmentError, # base — catches everything below
|
|
||||||
UserNotFoundError, # username doesn't exist on Fragment
|
|
||||||
WalletError, # insufficient balance or misconfiguration
|
|
||||||
CookieError, # cookies are missing or expired
|
|
||||||
TransactionError, # on-chain broadcast failed
|
|
||||||
ConfigurationError, # invalid argument (months, amount, etc.)
|
|
||||||
FragmentAPIError, # unexpected Fragment API response
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
async with FragmentClient(
|
async with FragmentClient(
|
||||||
seed="word1 word2 ... word24", # 24-word TON wallet mnemonic
|
seed="word1 word2 ... word24",
|
||||||
api_key="YOUR_TONAPI_KEY", # from tonconsole.com
|
api_key="YOUR_TONAPI_KEY",
|
||||||
cookies={
|
cookies={
|
||||||
"stel_ssid": "...",
|
"stel_ssid": "...",
|
||||||
"stel_dt": "...",
|
"stel_dt": "...",
|
||||||
@@ -98,40 +88,29 @@ async def main() -> None:
|
|||||||
"stel_ton_token": "...",
|
"stel_ton_token": "...",
|
||||||
},
|
},
|
||||||
) as client:
|
) as client:
|
||||||
try:
|
wallet = await client.get_wallet()
|
||||||
# Purchase 6 months of Telegram Premium
|
print(f"Wallet: {wallet.address} | TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}")
|
||||||
result = await client.purchase_premium("@username", months=6)
|
|
||||||
print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
|
|
||||||
|
|
||||||
# Purchase 500 Stars
|
recipient = "https://t.me/username" # also supports: @username, username
|
||||||
result = await client.purchase_stars("@username", amount=500)
|
|
||||||
print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
|
|
||||||
|
|
||||||
# Top up 10 TON to Telegram balance
|
stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton")
|
||||||
# wallet must hold at least amount + ~0.056 TON for gas
|
print(f"Stars sent: {stars.amount} to {stars.username} | tx: {stars.transaction_id}")
|
||||||
result = await client.topup_ton("@username", amount=10)
|
|
||||||
print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}")
|
|
||||||
|
|
||||||
except UserNotFoundError:
|
premium = await client.purchase_premium(recipient, months=6, payment_method="ton")
|
||||||
print(f"User was not found on fragment.com — check the username and try again.")
|
print(f"Premium sent: {premium.amount} months to {premium.username} | tx: {premium.transaction_id}")
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
asyncio.run(main())
|
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">
|
<div align="center">
|
||||||
|
|||||||
@@ -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.
|
Cookies can be passed as a dict or as a JSON string.
|
||||||
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
|
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
|
||||||
@@ -35,7 +35,8 @@ async def main() -> None:
|
|||||||
wallet = await client.get_wallet()
|
wallet = await client.get_wallet()
|
||||||
print(f"Address: {wallet.address}")
|
print(f"Address: {wallet.address}")
|
||||||
print(f"State: {wallet.state}")
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Example: recharge your own Telegram Ads account with TON.
|
Example: recharge your own Telegram Ads account with TON.
|
||||||
|
|
||||||
Amount must be an integer between 1 and 1 000 000 000 TON.
|
Amount must be an integer between 1 and 1 000 000 000 TON.
|
||||||
Your wallet must hold at least the recharge amount + ~0.056 TON for gas.
|
Your wallet must satisfy the current minimum TON threshold and transaction cost.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ Example: run a Telegram Premium giveaway for a channel.
|
|||||||
|
|
||||||
winners must be an integer between 1 and 24 000.
|
winners must be an integer between 1 and 24 000.
|
||||||
months (Premium duration per winner) must be 3, 6, or 12.
|
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
|
import asyncio
|
||||||
@@ -24,15 +26,21 @@ COOKIES = {
|
|||||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANNEL = "@channel"
|
CHANNEL = "https://t.me/channel"
|
||||||
WINNERS = 10 # 1–24 000
|
WINNERS = 10 # 1–24 000
|
||||||
MONTHS = 3 # 3, 6 or 12
|
MONTHS = 3 # 3, 6 or 12
|
||||||
|
PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
try:
|
try:
|
||||||
result = await client.giveaway_premium(CHANNEL, winners=WINNERS, months=MONTHS)
|
result = await client.giveaway_premium(
|
||||||
|
CHANNEL,
|
||||||
|
winners=WINNERS,
|
||||||
|
months=MONTHS,
|
||||||
|
payment_method=PAYMENT_METHOD,
|
||||||
|
)
|
||||||
except UserNotFoundError:
|
except UserNotFoundError:
|
||||||
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
|
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ Example: run a Telegram Stars giveaway for a channel.
|
|||||||
|
|
||||||
winners must be an integer between 1 and 5.
|
winners must be an integer between 1 and 5.
|
||||||
amount (stars per winner) must be an integer between 500 and 1 000 000.
|
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
|
import asyncio
|
||||||
@@ -24,15 +26,21 @@ COOKIES = {
|
|||||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANNEL = "@channel"
|
CHANNEL = "https://t.me/channel"
|
||||||
WINNERS = 3 # 1–5
|
WINNERS = 3 # 1–5
|
||||||
AMOUNT = 1000 # 500–1 000 000 stars per winner
|
AMOUNT = 1000 # 500–1 000 000 stars per winner
|
||||||
|
PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
try:
|
try:
|
||||||
result = await client.giveaway_stars(CHANNEL, winners=WINNERS, amount=AMOUNT)
|
result = await client.giveaway_stars(
|
||||||
|
CHANNEL,
|
||||||
|
winners=WINNERS,
|
||||||
|
amount=AMOUNT,
|
||||||
|
payment_method=PAYMENT_METHOD,
|
||||||
|
)
|
||||||
except UserNotFoundError:
|
except UserNotFoundError:
|
||||||
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
|
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ Example: purchase Telegram Premium for a user.
|
|||||||
|
|
||||||
Supported durations: 3, 6, or 12 months.
|
Supported durations: 3, 6, or 12 months.
|
||||||
Set show_sender=False to send anonymously.
|
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
|
import asyncio
|
||||||
@@ -24,14 +26,20 @@ COOKIES = {
|
|||||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
}
|
}
|
||||||
|
|
||||||
USERNAME = "@username"
|
USERNAME = "https://t.me/username"
|
||||||
MONTHS = 3 # 3, 6 or 12
|
MONTHS = 3 # 3, 6 or 12
|
||||||
|
PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
try:
|
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:
|
except UserNotFoundError:
|
||||||
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ Example: purchase Telegram Stars for a user.
|
|||||||
|
|
||||||
Amount must be an integer between 50 and 1 000 000.
|
Amount must be an integer between 50 and 1 000 000.
|
||||||
Set show_sender=False to send anonymously.
|
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
|
import asyncio
|
||||||
@@ -24,14 +26,20 @@ COOKIES = {
|
|||||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
}
|
}
|
||||||
|
|
||||||
USERNAME = "@username"
|
USERNAME = "https://t.me/username"
|
||||||
AMOUNT = 500 # 50–1 000 000 stars
|
AMOUNT = 500 # 50–1 000 000 stars
|
||||||
|
PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
try:
|
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:
|
except UserNotFoundError:
|
||||||
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Example: top up TON to a recipient's Telegram balance.
|
|||||||
For adding TON to a Telegram Ads account, use recharge_ads() instead.
|
For adding TON to a Telegram Ads account, use recharge_ads() instead.
|
||||||
|
|
||||||
Amount must be an integer between 1 and 1 000 000 000 TON.
|
Amount must be an integer between 1 and 1 000 000 000 TON.
|
||||||
Your wallet must hold at least the top-up amount + ~0.056 TON for gas.
|
Your wallet must satisfy the current minimum TON threshold and transaction cost.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|||||||
+23
-15
@@ -15,6 +15,7 @@ from pyfragment.types import (
|
|||||||
CookieError,
|
CookieError,
|
||||||
CookieResult,
|
CookieResult,
|
||||||
FragmentAPIError,
|
FragmentAPIError,
|
||||||
|
# exceptions
|
||||||
FragmentError,
|
FragmentError,
|
||||||
FragmentPageError,
|
FragmentPageError,
|
||||||
GiftsResult,
|
GiftsResult,
|
||||||
@@ -22,9 +23,12 @@ from pyfragment.types import (
|
|||||||
NumbersResult,
|
NumbersResult,
|
||||||
OperationError,
|
OperationError,
|
||||||
ParseError,
|
ParseError,
|
||||||
|
# literal types
|
||||||
|
PaymentMethod,
|
||||||
PremiumGiveawayResult,
|
PremiumGiveawayResult,
|
||||||
PremiumResult,
|
PremiumResult,
|
||||||
StarsGiveawayResult,
|
StarsGiveawayResult,
|
||||||
|
# results
|
||||||
StarsResult,
|
StarsResult,
|
||||||
TerminateSessionsResult,
|
TerminateSessionsResult,
|
||||||
TransactionError,
|
TransactionError,
|
||||||
@@ -41,31 +45,35 @@ __version__: str = version("pyfragment")
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"__version__",
|
"__version__",
|
||||||
"FragmentClient",
|
"FragmentClient",
|
||||||
"AdsRechargeResult",
|
# results
|
||||||
|
"StarsResult",
|
||||||
|
"StarsGiveawayResult",
|
||||||
|
"PremiumResult",
|
||||||
|
"PremiumGiveawayResult",
|
||||||
|
"WalletInfo",
|
||||||
"AdsTopupResult",
|
"AdsTopupResult",
|
||||||
|
"AdsRechargeResult",
|
||||||
|
"CookieResult",
|
||||||
"GiftsResult",
|
"GiftsResult",
|
||||||
"LoginCodeResult",
|
"LoginCodeResult",
|
||||||
"NumbersResult",
|
"NumbersResult",
|
||||||
"PremiumGiveawayResult",
|
|
||||||
"PremiumResult",
|
|
||||||
"StarsGiveawayResult",
|
|
||||||
"StarsResult",
|
|
||||||
"TerminateSessionsResult",
|
"TerminateSessionsResult",
|
||||||
"UsernamesResult",
|
"UsernamesResult",
|
||||||
"WalletInfo",
|
# exceptions
|
||||||
"ClientError",
|
|
||||||
"ConfigurationError",
|
|
||||||
"CookieError",
|
|
||||||
"CookieResult",
|
|
||||||
"FragmentAPIError",
|
|
||||||
"FragmentError",
|
"FragmentError",
|
||||||
|
"FragmentAPIError",
|
||||||
"FragmentPageError",
|
"FragmentPageError",
|
||||||
|
"ConfigurationError",
|
||||||
|
"UserNotFoundError",
|
||||||
|
"WalletError",
|
||||||
|
"VerificationError",
|
||||||
|
"TransactionError",
|
||||||
"AnonymousNumberError",
|
"AnonymousNumberError",
|
||||||
|
"ClientError",
|
||||||
|
"CookieError",
|
||||||
"OperationError",
|
"OperationError",
|
||||||
"ParseError",
|
"ParseError",
|
||||||
"TransactionError",
|
|
||||||
"UnexpectedError",
|
"UnexpectedError",
|
||||||
"UserNotFoundError",
|
# literal types
|
||||||
"VerificationError",
|
"PaymentMethod",
|
||||||
"WalletError",
|
|
||||||
]
|
]
|
||||||
|
|||||||
+56
-32
@@ -1,20 +1,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any, cast
|
from typing import Any, cast, get_args
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes
|
from pyfragment.methods import (
|
||||||
from pyfragment.methods.giveaway_premium import giveaway_premium
|
get_login_code,
|
||||||
from pyfragment.methods.giveaway_stars import giveaway_stars
|
giveaway_premium,
|
||||||
from pyfragment.methods.purchase_premium import purchase_premium
|
giveaway_stars,
|
||||||
from pyfragment.methods.purchase_stars import purchase_stars
|
purchase_premium,
|
||||||
from pyfragment.methods.recharge_ads import recharge_ads
|
purchase_stars,
|
||||||
from pyfragment.methods.search_gifts import search_gifts
|
recharge_ads,
|
||||||
from pyfragment.methods.search_numbers import search_numbers
|
search_gifts,
|
||||||
from pyfragment.methods.search_usernames import search_usernames
|
search_numbers,
|
||||||
from pyfragment.methods.topup_ton import topup_ton
|
search_usernames,
|
||||||
|
terminate_sessions,
|
||||||
|
toggle_login_codes,
|
||||||
|
topup_ton,
|
||||||
|
)
|
||||||
from pyfragment.types import (
|
from pyfragment.types import (
|
||||||
AdsRechargeResult,
|
AdsRechargeResult,
|
||||||
AdsTopupResult,
|
AdsTopupResult,
|
||||||
@@ -32,13 +36,14 @@ from pyfragment.types import (
|
|||||||
WalletInfo,
|
WalletInfo,
|
||||||
)
|
)
|
||||||
from pyfragment.types.constants import (
|
from pyfragment.types.constants import (
|
||||||
|
BASE_HEADERS,
|
||||||
DEFAULT_TIMEOUT,
|
DEFAULT_TIMEOUT,
|
||||||
FRAGMENT_BASE_URL,
|
FRAGMENT_BASE_URL,
|
||||||
REQUIRED_COOKIE_KEYS,
|
REQUIRED_COOKIE_KEYS,
|
||||||
SUPPORTED_WALLET_VERSIONS,
|
PaymentMethod,
|
||||||
WalletVersion,
|
WalletVersion,
|
||||||
)
|
)
|
||||||
from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers
|
from pyfragment.utils.api import fragment_request, get_fragment_hash
|
||||||
from pyfragment.utils.wallet import get_wallet_info
|
from pyfragment.utils.wallet import get_wallet_info
|
||||||
|
|
||||||
|
|
||||||
@@ -77,7 +82,7 @@ class FragmentClient:
|
|||||||
self,
|
self,
|
||||||
seed: str,
|
seed: str,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
cookies: dict | str,
|
cookies: dict[str, Any] | str,
|
||||||
wallet_version: str = "V5R1",
|
wallet_version: str = "V5R1",
|
||||||
timeout: float = DEFAULT_TIMEOUT,
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -98,25 +103,25 @@ class FragmentClient:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from 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:
|
if missing_keys:
|
||||||
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
|
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
|
||||||
|
|
||||||
version = wallet_version.strip().upper()
|
version = wallet_version.strip().upper()
|
||||||
if version not in SUPPORTED_WALLET_VERSIONS:
|
if version not in get_args(WalletVersion):
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
ConfigurationError.UNSUPPORTED_VERSION.format(
|
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.seed: str = seed.strip()
|
||||||
self.api_key: str = api_key.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.wallet_version: WalletVersion = version # type: ignore[assignment]
|
||||||
self.timeout: float = timeout
|
self.timeout: float = timeout
|
||||||
|
|
||||||
async def __aenter__(self) -> "FragmentClient":
|
async def __aenter__(self) -> FragmentClient:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, *_: object) -> None:
|
async def __aexit__(self, *_: object) -> None:
|
||||||
@@ -125,31 +130,45 @@ class FragmentClient:
|
|||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"FragmentClient(wallet_version='{self.wallet_version}', cookies={len(self.cookies)} keys)"
|
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:
|
async def purchase_premium(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
months: int,
|
||||||
|
show_sender: bool = True,
|
||||||
|
payment_method: PaymentMethod = "ton",
|
||||||
|
) -> PremiumResult:
|
||||||
"""Gift Telegram Premium to a user.
|
"""Gift Telegram Premium to a user.
|
||||||
|
|
||||||
Args:
|
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``.
|
months: Duration — ``3``, ``6``, or ``12``.
|
||||||
show_sender: Show your name as the sender. Defaults to ``True``.
|
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
|
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
"""
|
"""
|
||||||
return await purchase_premium(self, username, months, show_sender)
|
return await purchase_premium(self, username, months, show_sender, payment_method)
|
||||||
|
|
||||||
async def purchase_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult:
|
async def purchase_stars(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
amount: int,
|
||||||
|
show_sender: bool = True,
|
||||||
|
payment_method: PaymentMethod = "ton",
|
||||||
|
) -> StarsResult:
|
||||||
"""Send Telegram Stars to a user.
|
"""Send Telegram Stars to a user.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
username: Recipient's Telegram username (with or without ``@``).
|
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
|
||||||
amount: Number of stars — integer from ``50`` to ``1 000 000``.
|
amount: Number of stars — integer from ``50`` to ``1 000 000``.
|
||||||
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
|
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
"""
|
"""
|
||||||
return await purchase_stars(self, username, amount, show_sender)
|
return await purchase_stars(self, username, amount, show_sender, payment_method)
|
||||||
|
|
||||||
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||||
"""Top up TON to a recipient's Telegram balance.
|
"""Top up TON to a recipient's Telegram balance.
|
||||||
@@ -178,11 +197,12 @@ class FragmentClient:
|
|||||||
return await recharge_ads(self, account, amount)
|
return await recharge_ads(self, account, amount)
|
||||||
|
|
||||||
async def get_wallet(self) -> WalletInfo:
|
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:
|
Returns:
|
||||||
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
|
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
|
||||||
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), and ``balance`` in TON.
|
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``),
|
||||||
|
``ton_balance`` in TON, and ``usdt_balance`` in USDT.
|
||||||
"""
|
"""
|
||||||
return await get_wallet_info(self)
|
return await get_wallet_info(self)
|
||||||
|
|
||||||
@@ -191,38 +211,42 @@ class FragmentClient:
|
|||||||
channel: str,
|
channel: str,
|
||||||
winners: int,
|
winners: int,
|
||||||
amount: int,
|
amount: int,
|
||||||
|
payment_method: PaymentMethod = "ton",
|
||||||
) -> StarsGiveawayResult:
|
) -> StarsGiveawayResult:
|
||||||
"""Run a Telegram Stars giveaway for a channel.
|
"""Run a Telegram Stars giveaway for a channel.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
channel: Channel username (with or without ``@``).
|
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
|
||||||
winners: Number of winners — integer from ``1`` to ``5``.
|
winners: Number of winners — integer from ``1`` to ``5``.
|
||||||
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
|
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
|
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
|
||||||
``winners``, and ``amount``.
|
``winners``, and ``amount``.
|
||||||
"""
|
"""
|
||||||
return await giveaway_stars(self, channel, winners, amount)
|
return await giveaway_stars(self, channel, winners, amount, payment_method)
|
||||||
|
|
||||||
async def giveaway_premium(
|
async def giveaway_premium(
|
||||||
self,
|
self,
|
||||||
channel: str,
|
channel: str,
|
||||||
winners: int,
|
winners: int,
|
||||||
months: int = 3,
|
months: int = 3,
|
||||||
|
payment_method: PaymentMethod = "ton",
|
||||||
) -> PremiumGiveawayResult:
|
) -> PremiumGiveawayResult:
|
||||||
"""Run a Telegram Premium giveaway for a channel.
|
"""Run a Telegram Premium giveaway for a channel.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
channel: Channel username (with or without ``@``).
|
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
|
||||||
winners: Number of winners — positive integer.
|
winners: Number of winners — positive integer.
|
||||||
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
|
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
|
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
|
||||||
``winners``, and ``amount``.
|
``winners``, and ``amount``.
|
||||||
"""
|
"""
|
||||||
return await giveaway_premium(self, channel, winners, months)
|
return await giveaway_premium(self, channel, winners, months, payment_method)
|
||||||
|
|
||||||
async def get_login_code(self, number: str) -> LoginCodeResult:
|
async def get_login_code(self, number: str) -> LoginCodeResult:
|
||||||
"""Fetch the current pending login code for an anonymous number.
|
"""Fetch the current pending login code for an anonymous number.
|
||||||
@@ -366,7 +390,7 @@ class FragmentClient:
|
|||||||
page_url="https://fragment.com/premium/gift",
|
page_url="https://fragment.com/premium/gift",
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
headers = make_headers(page_url)
|
headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
|
||||||
async with httpx.AsyncClient(cookies=self.cookies, timeout=self.timeout) as session:
|
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)
|
fragment_hash = await get_fragment_hash(self.cookies, headers, page_url, self.timeout)
|
||||||
return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})})
|
return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})})
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def _strip_plus(number: str) -> str:
|
|||||||
return number.lstrip("+") if isinstance(number, str) else number
|
return number.lstrip("+") if isinstance(number, str) else number
|
||||||
|
|
||||||
|
|
||||||
async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResult:
|
async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult:
|
||||||
"""Fetch the current pending login code for an anonymous number.
|
"""Fetch the current pending login code for an anonymous number.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -58,7 +58,7 @@ async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResu
|
|||||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
async def toggle_login_codes(client: "FragmentClient", number: str, can_receive: bool) -> None:
|
async def toggle_login_codes(client: FragmentClient, number: str, can_receive: bool) -> None:
|
||||||
"""Enable or disable login code delivery for an anonymous number.
|
"""Enable or disable login code delivery for an anonymous number.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -87,7 +87,7 @@ async def toggle_login_codes(client: "FragmentClient", number: str, can_receive:
|
|||||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
async def terminate_sessions(client: "FragmentClient", number: str) -> TerminateSessionsResult:
|
async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSessionsResult:
|
||||||
"""Terminate all active Telegram sessions for an anonymous number.
|
"""Terminate all active Telegram sessions for an anonymous number.
|
||||||
|
|
||||||
This is a two-step operation: Fragment first returns a confirmation hash,
|
This is a two-step operation: Fragment first returns a confirmation hash,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, get_args
|
||||||
|
|
||||||
from pyfragment.types import (
|
from pyfragment.types import (
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
@@ -12,26 +12,28 @@ from pyfragment.types import (
|
|||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
VerificationError,
|
VerificationError,
|
||||||
)
|
)
|
||||||
from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE
|
from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, PaymentMethod
|
||||||
from pyfragment.utils import get_account_info, process_transaction
|
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
async def giveaway_premium(
|
async def giveaway_premium(
|
||||||
client: "FragmentClient",
|
client: FragmentClient,
|
||||||
channel: str,
|
channel: str,
|
||||||
winners: int,
|
winners: int,
|
||||||
months: int = 3,
|
months: int = 3,
|
||||||
|
payment_method: PaymentMethod = "ton",
|
||||||
) -> PremiumGiveawayResult:
|
) -> PremiumGiveawayResult:
|
||||||
"""Run a Telegram Premium giveaway for a channel.
|
"""Run a Telegram Premium giveaway for a channel.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
channel: Channel username (with or without ``@``).
|
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
|
||||||
winners: Number of winners — integer from ``1`` to ``24 000``.
|
winners: Number of winners — integer from ``1`` to ``24 000``.
|
||||||
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
|
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
|
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
|
||||||
@@ -47,6 +49,13 @@ async def giveaway_premium(
|
|||||||
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
|
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
|
||||||
if months not in (3, 6, 12):
|
if months not in (3, 6, 12):
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
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:
|
try:
|
||||||
result = await client.call(
|
result = await client.call(
|
||||||
@@ -60,9 +69,15 @@ async def giveaway_premium(
|
|||||||
|
|
||||||
result = await client.call(
|
result = await client.call(
|
||||||
"initGiveawayPremiumRequest",
|
"initGiveawayPremiumRequest",
|
||||||
{"recipient": recipient, "quantity": str(winners), "months": str(months)},
|
{
|
||||||
|
"recipient": recipient,
|
||||||
|
"quantity": str(winners),
|
||||||
|
"months": str(months),
|
||||||
|
"payment_method": payment_method,
|
||||||
|
},
|
||||||
page_url=PREMIUM_GIVEAWAY_PAGE,
|
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||||
)
|
)
|
||||||
|
required_payment_amount = parse_required_payment_amount(result)
|
||||||
req_id = result.get("req_id")
|
req_id = result.get("req_id")
|
||||||
if not req_id:
|
if not req_id:
|
||||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway"))
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway"))
|
||||||
@@ -81,7 +96,12 @@ async def giveaway_premium(
|
|||||||
if transaction.get("need_verify"):
|
if transaction.get("need_verify"):
|
||||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||||
|
|
||||||
tx_hash = await process_transaction(client, transaction)
|
tx_hash = await process_transaction(
|
||||||
|
client,
|
||||||
|
transaction,
|
||||||
|
payment_method=payment_method,
|
||||||
|
required_payment_amount=required_payment_amount,
|
||||||
|
)
|
||||||
return PremiumGiveawayResult(
|
return PremiumGiveawayResult(
|
||||||
transaction_id=tx_hash,
|
transaction_id=tx_hash,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, get_args
|
||||||
|
|
||||||
from pyfragment.types import (
|
from pyfragment.types import (
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
@@ -12,26 +12,28 @@ from pyfragment.types import (
|
|||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
VerificationError,
|
VerificationError,
|
||||||
)
|
)
|
||||||
from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE
|
from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE, PaymentMethod
|
||||||
from pyfragment.utils import get_account_info, process_transaction
|
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
async def giveaway_stars(
|
async def giveaway_stars(
|
||||||
client: "FragmentClient",
|
client: FragmentClient,
|
||||||
channel: str,
|
channel: str,
|
||||||
winners: int,
|
winners: int,
|
||||||
amount: int,
|
amount: int,
|
||||||
|
payment_method: PaymentMethod = "ton",
|
||||||
) -> StarsGiveawayResult:
|
) -> StarsGiveawayResult:
|
||||||
"""Run a Telegram Stars giveaway for a channel.
|
"""Run a Telegram Stars giveaway for a channel.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
channel: Channel username (with or without ``@``).
|
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
|
||||||
winners: Number of winners — integer from ``1`` to ``5``.
|
winners: Number of winners — integer from ``1`` to ``5``.
|
||||||
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
|
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
|
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
|
||||||
@@ -47,6 +49,13 @@ async def giveaway_stars(
|
|||||||
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
|
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
|
||||||
if not isinstance(amount, int) or not (500 <= amount <= 1_000_000):
|
if not isinstance(amount, int) or not (500 <= amount <= 1_000_000):
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
|
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:
|
try:
|
||||||
result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE)
|
result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE)
|
||||||
@@ -56,9 +65,15 @@ async def giveaway_stars(
|
|||||||
|
|
||||||
result = await client.call(
|
result = await client.call(
|
||||||
"initGiveawayStarsRequest",
|
"initGiveawayStarsRequest",
|
||||||
{"recipient": recipient, "quantity": str(winners), "stars": str(amount)},
|
{
|
||||||
|
"recipient": recipient,
|
||||||
|
"quantity": str(winners),
|
||||||
|
"stars": str(amount),
|
||||||
|
"payment_method": payment_method,
|
||||||
|
},
|
||||||
page_url=STARS_GIVEAWAY_PAGE,
|
page_url=STARS_GIVEAWAY_PAGE,
|
||||||
)
|
)
|
||||||
|
required_payment_amount = parse_required_payment_amount(result)
|
||||||
req_id = result.get("req_id")
|
req_id = result.get("req_id")
|
||||||
if not req_id:
|
if not req_id:
|
||||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway"))
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway"))
|
||||||
@@ -77,7 +92,12 @@ async def giveaway_stars(
|
|||||||
if transaction.get("need_verify"):
|
if transaction.get("need_verify"):
|
||||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||||
|
|
||||||
tx_hash = await process_transaction(client, transaction)
|
tx_hash = await process_transaction(
|
||||||
|
client,
|
||||||
|
transaction,
|
||||||
|
payment_method=payment_method,
|
||||||
|
required_payment_amount=required_payment_amount,
|
||||||
|
)
|
||||||
return StarsGiveawayResult(
|
return StarsGiveawayResult(
|
||||||
transaction_id=tx_hash,
|
transaction_id=tx_hash,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, get_args
|
||||||
|
|
||||||
from pyfragment.types import (
|
from pyfragment.types import (
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
@@ -13,21 +13,28 @@ from pyfragment.types import (
|
|||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
VerificationError,
|
VerificationError,
|
||||||
)
|
)
|
||||||
from pyfragment.types.constants import DEVICE, PREMIUM_PAGE
|
from pyfragment.types.constants import DEVICE, PREMIUM_PAGE, PaymentMethod
|
||||||
from pyfragment.utils import get_account_info, process_transaction
|
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult:
|
async def purchase_premium(
|
||||||
|
client: FragmentClient,
|
||||||
|
username: str,
|
||||||
|
months: int,
|
||||||
|
show_sender: bool = True,
|
||||||
|
payment_method: PaymentMethod = "ton",
|
||||||
|
) -> PremiumResult:
|
||||||
"""Gift Telegram Premium to a user.
|
"""Gift Telegram Premium to a user.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
username: Recipient's Telegram username (with or without ``@``).
|
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
|
||||||
months: Premium duration — ``3``, ``6``, or ``12``.
|
months: Premium duration — ``3``, ``6``, or ``12``.
|
||||||
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
|
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
@@ -40,6 +47,13 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int,
|
|||||||
"""
|
"""
|
||||||
if months not in (3, 6, 12):
|
if months not in (3, 6, 12):
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
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:
|
try:
|
||||||
result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE)
|
result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE)
|
||||||
@@ -52,7 +66,12 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int,
|
|||||||
{"mode": "new", "lv": "false", "dh": str(int(time.time()))},
|
{"mode": "new", "lv": "false", "dh": str(int(time.time()))},
|
||||||
page_url=PREMIUM_PAGE,
|
page_url=PREMIUM_PAGE,
|
||||||
)
|
)
|
||||||
result = await client.call("initGiftPremiumRequest", {"recipient": recipient, "months": months}, 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")
|
req_id = result.get("req_id")
|
||||||
if not req_id:
|
if not req_id:
|
||||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
|
||||||
@@ -72,7 +91,12 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int,
|
|||||||
if transaction.get("need_verify"):
|
if transaction.get("need_verify"):
|
||||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||||
|
|
||||||
tx_hash = await process_transaction(client, transaction)
|
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)
|
return PremiumResult(transaction_id=tx_hash, username=username, amount=months)
|
||||||
|
|
||||||
except FragmentError:
|
except FragmentError:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import TYPE_CHECKING
|
import time
|
||||||
|
from typing import TYPE_CHECKING, get_args
|
||||||
|
|
||||||
from pyfragment.types import (
|
from pyfragment.types import (
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
@@ -12,21 +13,24 @@ from pyfragment.types import (
|
|||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
VerificationError,
|
VerificationError,
|
||||||
)
|
)
|
||||||
from pyfragment.types.constants import DEVICE, STARS_PAGE
|
from pyfragment.types.constants import DEVICE, STARS_PAGE, PaymentMethod
|
||||||
from pyfragment.utils import get_account_info, process_transaction
|
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
async def purchase_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult:
|
async def purchase_stars(
|
||||||
|
client: FragmentClient, username: str, amount: int, show_sender: bool = True, payment_method: PaymentMethod = "ton"
|
||||||
|
) -> StarsResult:
|
||||||
"""Send Telegram Stars to a user.
|
"""Send Telegram Stars to a user.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
username: Recipient's Telegram username (with or without ``@``).
|
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
|
||||||
amount: Number of Stars to send — integer from ``50`` to ``1 000 000``.
|
amount: Number of Stars to send — integer from ``50`` to ``1 000 000``.
|
||||||
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
|
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
@@ -39,6 +43,13 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s
|
|||||||
"""
|
"""
|
||||||
if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
|
if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
|
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:
|
try:
|
||||||
result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE)
|
result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE)
|
||||||
@@ -46,7 +57,17 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s
|
|||||||
if not recipient:
|
if not recipient:
|
||||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||||
|
|
||||||
result = await client.call("initBuyStarsRequest", {"recipient": recipient, "quantity": amount}, page_url=STARS_PAGE)
|
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")
|
req_id = result.get("req_id")
|
||||||
if not req_id:
|
if not req_id:
|
||||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
|
||||||
@@ -66,7 +87,12 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s
|
|||||||
if transaction.get("need_verify"):
|
if transaction.get("need_verify"):
|
||||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||||
|
|
||||||
tx_hash = await process_transaction(client, transaction)
|
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)
|
return StarsResult(transaction_id=tx_hash, username=username, amount=amount)
|
||||||
|
|
||||||
except FragmentError:
|
except FragmentError:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ if TYPE_CHECKING:
|
|||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
async def recharge_ads(client: "FragmentClient", account: str, amount: int) -> AdsRechargeResult:
|
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
|
||||||
"""Add funds to your own Telegram Ads account.
|
"""Add funds to your own Telegram Ads account.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
async def search_gifts(
|
async def search_gifts(
|
||||||
client: "FragmentClient",
|
client: FragmentClient,
|
||||||
query: str = "",
|
query: str = "",
|
||||||
collection: str | None = None,
|
collection: str | None = None,
|
||||||
sort: str | None = None,
|
sort: str | None = None,
|
||||||
@@ -45,7 +45,7 @@ async def search_gifts(
|
|||||||
FragmentAPIError: If the Fragment API returns an error.
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
UnexpectedError: For any other unexpected failure.
|
UnexpectedError: For any other unexpected failure.
|
||||||
"""
|
"""
|
||||||
data: dict[str, Any] = {"method": "searchAuctions", "type": "gifts", "query": query}
|
data: dict[str, Any] = {"type": "gifts", "query": query}
|
||||||
if collection is not None:
|
if collection is not None:
|
||||||
data["collection"] = collection
|
data["collection"] = collection
|
||||||
if sort is not None:
|
if sort is not None:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
async def search_numbers(
|
async def search_numbers(
|
||||||
client: "FragmentClient",
|
client: FragmentClient,
|
||||||
query: str = "",
|
query: str = "",
|
||||||
sort: str | None = None,
|
sort: str | None = None,
|
||||||
filter: str | None = None,
|
filter: str | None = None,
|
||||||
@@ -37,7 +37,7 @@ async def search_numbers(
|
|||||||
FragmentAPIError: If the Fragment API returns an error.
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
UnexpectedError: For any other unexpected failure.
|
UnexpectedError: For any other unexpected failure.
|
||||||
"""
|
"""
|
||||||
data: dict[str, Any] = {"method": "searchAuctions", "type": "numbers", "query": query}
|
data: dict[str, Any] = {"type": "numbers", "query": query}
|
||||||
if sort is not None:
|
if sort is not None:
|
||||||
data["sort"] = sort
|
data["sort"] = sort
|
||||||
if filter is not None:
|
if filter is not None:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
async def search_usernames(
|
async def search_usernames(
|
||||||
client: "FragmentClient",
|
client: FragmentClient,
|
||||||
query: str = "",
|
query: str = "",
|
||||||
sort: str | None = None,
|
sort: str | None = None,
|
||||||
filter: str | None = None,
|
filter: str | None = None,
|
||||||
@@ -37,7 +37,7 @@ async def search_usernames(
|
|||||||
FragmentAPIError: If the Fragment API returns an error.
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
UnexpectedError: For any other unexpected failure.
|
UnexpectedError: For any other unexpected failure.
|
||||||
"""
|
"""
|
||||||
data: dict[str, Any] = {"method": "searchAuctions", "type": "usernames", "query": query}
|
data: dict[str, Any] = {"type": "usernames", "query": query}
|
||||||
if sort is not None:
|
if sort is not None:
|
||||||
data["sort"] = sort
|
data["sort"] = sort
|
||||||
if filter is not None:
|
if filter is not None:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
|||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||||
"""Top up TON to a recipient's Telegram balance.
|
"""Top up TON to a recipient's Telegram balance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from pyfragment.types.constants import PaymentMethod
|
||||||
from pyfragment.types.exceptions import (
|
from pyfragment.types.exceptions import (
|
||||||
AnonymousNumberError,
|
AnonymousNumberError,
|
||||||
ClientError,
|
ClientError,
|
||||||
@@ -26,6 +27,8 @@ from pyfragment.types.results import (
|
|||||||
StarsGiveawayResult,
|
StarsGiveawayResult,
|
||||||
StarsResult,
|
StarsResult,
|
||||||
TerminateSessionsResult,
|
TerminateSessionsResult,
|
||||||
|
TonTransferResult,
|
||||||
|
UsdtTransferResult,
|
||||||
UsernamesResult,
|
UsernamesResult,
|
||||||
WalletInfo,
|
WalletInfo,
|
||||||
)
|
)
|
||||||
@@ -59,6 +62,10 @@ __all__ = [
|
|||||||
"StarsGiveawayResult",
|
"StarsGiveawayResult",
|
||||||
"StarsResult",
|
"StarsResult",
|
||||||
"TerminateSessionsResult",
|
"TerminateSessionsResult",
|
||||||
|
"TonTransferResult",
|
||||||
|
"UsdtTransferResult",
|
||||||
"UsernamesResult",
|
"UsernamesResult",
|
||||||
"WalletInfo",
|
"WalletInfo",
|
||||||
|
# literal types
|
||||||
|
"PaymentMethod",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any, Literal, get_args
|
from typing import Any, Literal
|
||||||
|
|
||||||
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
|
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
|
||||||
|
|
||||||
|
# Payment methods
|
||||||
|
PaymentMethod = Literal["ton", "usdt_ton"]
|
||||||
|
|
||||||
# Single source of truth for supported wallet versions
|
# Single source of truth for supported wallet versions
|
||||||
WalletVersion = Literal["V4R2", "V5R1"]
|
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 class map — used to resolve the correct contract from WALLET_VERSION
|
||||||
WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
|
WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
|
||||||
|
|
||||||
# Minimum wallet balance required to cover TON network gas fees.
|
# Minimum TON balance threshold required for payment flows.
|
||||||
MIN_TON_BALANCE: float = 0.056
|
MIN_TON_BALANCE: float = 0.33
|
||||||
|
|
||||||
|
# USDT (TON) jetton metadata used for payment-method balance checks.
|
||||||
|
USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
|
||||||
|
MIN_USDT_BALANCE: float = 0.75
|
||||||
|
|
||||||
# Default HTTP request timeout in seconds.
|
# Default HTTP request timeout in seconds.
|
||||||
DEFAULT_TIMEOUT: float = 30.0
|
DEFAULT_TIMEOUT: float = 30.0
|
||||||
@@ -74,12 +80,15 @@ BASE_HEADERS: dict[str, str] = {
|
|||||||
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
"origin": FRAGMENT_BASE_URL,
|
"origin": FRAGMENT_BASE_URL,
|
||||||
"priority": "u=1, i",
|
"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-dest": "empty",
|
||||||
"sec-fetch-mode": "cors",
|
"sec-fetch-mode": "cors",
|
||||||
"sec-fetch-site": "same-origin",
|
"sec-fetch-site": "same-origin",
|
||||||
"user-agent": (
|
"user-agent": (
|
||||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) "
|
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) "
|
||||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"
|
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36"
|
||||||
),
|
),
|
||||||
"x-requested-with": "XMLHttpRequest",
|
"x-requested-with": "XMLHttpRequest",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class ConfigurationError(ClientError):
|
|||||||
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
|
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
|
||||||
INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}."
|
INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}."
|
||||||
INVALID_API_KEY = (
|
INVALID_API_KEY = (
|
||||||
"Invalid Tonapi API key: expected at least 68 characters, got {length}. " "Generate a key at https://tonconsole.com."
|
"Invalid Tonapi API key: expected at least 68 characters, got {length}. Generate a key at https://tonconsole.com."
|
||||||
)
|
)
|
||||||
INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months."
|
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_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000."
|
||||||
@@ -28,6 +28,7 @@ class ConfigurationError(ClientError):
|
|||||||
INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5."
|
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_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_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):
|
class CookieError(ClientError):
|
||||||
@@ -40,12 +41,13 @@ class CookieError(ClientError):
|
|||||||
)
|
)
|
||||||
UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}."
|
UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}."
|
||||||
BROWSER_READ_FAILED = (
|
BROWSER_READ_FAILED = (
|
||||||
"Failed to read {browser} cookies: {exc}. " "Make sure {browser} is installed and you are logged in to {url}."
|
"Failed to read {browser} cookies: {exc}. Make sure {browser} is installed and you are logged in to {url}."
|
||||||
)
|
)
|
||||||
MISSING_BROWSER_KEYS = (
|
MISSING_BROWSER_KEYS = (
|
||||||
"Fragment cookies not found in {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}."
|
"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):
|
class FragmentAPIError(FragmentError):
|
||||||
@@ -74,7 +76,7 @@ class UserNotFoundError(FragmentAPIError):
|
|||||||
"""Raised when the target Telegram user is not found on Fragment."""
|
"""Raised when the target Telegram user is not found on Fragment."""
|
||||||
|
|
||||||
NOT_FOUND = (
|
NOT_FOUND = (
|
||||||
"Telegram user '{username}' was not found on Fragment. " "Double-check the username and make sure the account exists."
|
"Telegram user '{username}' was not found on Fragment. Double-check the username and make sure the account exists."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -89,7 +91,7 @@ class TransactionError(FragmentAPIError):
|
|||||||
"""Raised when a TON transaction fails to build or broadcast."""
|
"""Raised when a TON transaction fails to build or broadcast."""
|
||||||
|
|
||||||
INVALID_PAYLOAD = (
|
INVALID_PAYLOAD = (
|
||||||
"Fragment returned an invalid transaction payload — " "'transaction.messages' is missing or empty in the API response."
|
"Fragment returned an invalid transaction payload — 'transaction.messages' is missing or empty in the API response."
|
||||||
)
|
)
|
||||||
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
|
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
|
||||||
BROADCAST_FAILED_SSL = (
|
BROADCAST_FAILED_SSL = (
|
||||||
@@ -129,11 +131,10 @@ class OperationError(FragmentError):
|
|||||||
class WalletError(OperationError):
|
class WalletError(OperationError):
|
||||||
"""Raised for TON wallet issues (connection, balance, account info)."""
|
"""Raised for TON wallet issues (connection, balance, account info)."""
|
||||||
|
|
||||||
LOW_BALANCE = (
|
LOW_TON_BALANCE = "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required."
|
||||||
"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."
|
||||||
"(transaction amount + {gas:.3f} TON gas reserve)."
|
TON_BALANCE_CHECK_FAILED = "Failed to fetch TON balance: {exc}"
|
||||||
)
|
USDT_BALANCE_CHECK_FAILED = "Failed to fetch USDT balance: {exc}"
|
||||||
BALANCE_CHECK_FAILED = "Failed to fetch wallet balance: {exc}"
|
|
||||||
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {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}"
|
WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}"
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,14 @@ class WalletInfo:
|
|||||||
|
|
||||||
address: str
|
address: str
|
||||||
state: str
|
state: str
|
||||||
balance: float
|
ton_balance: float
|
||||||
|
usdt_balance: float
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"WalletInfo(address='{self.address}', state='{self.state}', balance={self.balance} TON)"
|
return (
|
||||||
|
f"WalletInfo(address='{self.address}', state='{self.state}', "
|
||||||
|
f"ton_balance={self.ton_balance} TON, usdt_balance={self.usdt_balance} USDT)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -202,6 +206,30 @@ class GiftsResult:
|
|||||||
return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})"
|
return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TonTransferResult:
|
||||||
|
"""Result of a direct TON transfer via :meth:`FragmentClient.send_ton`."""
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Result of a direct USDT transfer via :meth:`FragmentClient.send_usdt`."""
|
||||||
|
|
||||||
|
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__ = [
|
__all__ = [
|
||||||
"AdsRechargeResult",
|
"AdsRechargeResult",
|
||||||
"AdsTopupResult",
|
"AdsTopupResult",
|
||||||
@@ -213,6 +241,8 @@ __all__ = [
|
|||||||
"StarsGiveawayResult",
|
"StarsGiveawayResult",
|
||||||
"StarsResult",
|
"StarsResult",
|
||||||
"TerminateSessionsResult",
|
"TerminateSessionsResult",
|
||||||
|
"TonTransferResult",
|
||||||
|
"UsdtTransferResult",
|
||||||
"UsernamesResult",
|
"UsernamesResult",
|
||||||
"WalletInfo",
|
"WalletInfo",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
from pyfragment.utils.cookies import CookieResult, get_cookies_from_browser
|
from pyfragment.utils.api import (
|
||||||
from pyfragment.utils.decoder import clean_decode
|
|
||||||
from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code
|
|
||||||
from pyfragment.utils.http import (
|
|
||||||
execute_transaction_request,
|
execute_transaction_request,
|
||||||
fragment_request,
|
fragment_request,
|
||||||
get_fragment_hash,
|
get_fragment_hash,
|
||||||
make_headers,
|
|
||||||
parse_json_response,
|
parse_json_response,
|
||||||
)
|
)
|
||||||
from pyfragment.utils.wallet import get_account_info, process_transaction
|
from pyfragment.utils.cookies import CookieResult, get_cookies_from_browser
|
||||||
|
from pyfragment.utils.parser import parse_auction_rows, parse_gift_items, parse_login_code, parse_required_payment_amount
|
||||||
|
from pyfragment.utils.wallet import clean_decode, get_account_info, process_transaction, send_ton_transfer, send_usdt_transfer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"clean_decode",
|
"clean_decode",
|
||||||
@@ -17,11 +15,13 @@ __all__ = [
|
|||||||
"parse_auction_rows",
|
"parse_auction_rows",
|
||||||
"parse_gift_items",
|
"parse_gift_items",
|
||||||
"parse_login_code",
|
"parse_login_code",
|
||||||
|
"parse_required_payment_amount",
|
||||||
"execute_transaction_request",
|
"execute_transaction_request",
|
||||||
"fragment_request",
|
"fragment_request",
|
||||||
"get_account_info",
|
"get_account_info",
|
||||||
"get_fragment_hash",
|
"get_fragment_hash",
|
||||||
"make_headers",
|
|
||||||
"parse_json_response",
|
"parse_json_response",
|
||||||
"process_transaction",
|
"process_transaction",
|
||||||
|
"send_ton_transfer",
|
||||||
|
"send_usdt_transfer",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from pyfragment.types import FragmentPageError, ParseError, VerificationError
|
from pyfragment.types import FragmentPageError, ParseError, VerificationError
|
||||||
from pyfragment.types.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
|
from pyfragment.types.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
|
||||||
|
|
||||||
|
|
||||||
def make_headers(page_url: str = FRAGMENT_BASE_URL) -> dict[str, str]:
|
|
||||||
return {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
|
|
||||||
|
|
||||||
|
|
||||||
async def get_fragment_hash(
|
async def get_fragment_hash(
|
||||||
@@ -80,7 +78,7 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any
|
|||||||
ParseError: If the response body cannot be decoded as JSON.
|
ParseError: If the response body cannot be decoded as JSON.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return response.json()
|
return cast(dict[str, Any], response.json())
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
||||||
|
|
||||||
@@ -106,12 +104,21 @@ async def fragment_request(
|
|||||||
Returns:
|
Returns:
|
||||||
Parsed API response as a dict.
|
Parsed API response as a dict.
|
||||||
"""
|
"""
|
||||||
resp = await session.post(
|
for attempt in range(3):
|
||||||
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
|
resp = await session.post(
|
||||||
headers=headers,
|
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
|
||||||
data=data,
|
headers=headers,
|
||||||
)
|
data=data,
|
||||||
return parse_json_response(resp, data.get("method", "request"))
|
)
|
||||||
|
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(
|
async def execute_transaction_request(
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import rookiepy
|
import rookiepy
|
||||||
|
|
||||||
from pyfragment.types import CookieError, CookieResult
|
from pyfragment.types import CookieError
|
||||||
|
from pyfragment.types import CookieResult as CookieResult
|
||||||
from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS
|
from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
|
|||||||
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
|
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
jar: list[dict] = getattr(rookiepy, key)([FRAGMENT_DOMAIN])
|
jar: list[dict[str, Any]] = getattr(rookiepy, key)([FRAGMENT_DOMAIN])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc
|
raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc
|
||||||
|
|
||||||
@@ -59,6 +61,11 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
|
|||||||
continue
|
continue
|
||||||
break
|
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(
|
return CookieResult(
|
||||||
cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS},
|
cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS},
|
||||||
expires=expires_iso,
|
expires=expires_iso,
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
|
|
||||||
from ton_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
|
|
||||||
@@ -161,3 +161,12 @@ def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]:
|
|||||||
next_offset = int(next_offset_m.group(1)) if next_offset_m else None
|
next_offset = int(next_offset_m.group(1)) if next_offset_m else None
|
||||||
|
|
||||||
return items, next_offset
|
return items, next_offset
|
||||||
|
|
||||||
|
|
||||||
|
def parse_required_payment_amount(init_response: dict[str, Any]) -> float | None:
|
||||||
|
"""Extract required payment amount from init*Request response."""
|
||||||
|
raw_amount = init_response.get("amount")
|
||||||
|
try:
|
||||||
|
return float(str(raw_amount))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import base64
|
|
||||||
import ssl
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from ton_core import NetworkGlobalID
|
|
||||||
from tonutils.clients import TonapiClient
|
|
||||||
from tonutils.exceptions import ProviderResponseError
|
|
||||||
|
|
||||||
from pyfragment.types import TransactionError, WalletError, WalletInfo
|
|
||||||
from pyfragment.types.constants import MIN_TON_BALANCE, WALLET_CLASSES
|
|
||||||
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, Any]) -> str:
|
|
||||||
"""Sign and broadcast a Fragment transaction to the TON network.
|
|
||||||
|
|
||||||
Validates the payload structure, checks the wallet balance, decodes the
|
|
||||||
on-chain comment, and calls ``wallet.transfer``.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
|
||||||
transaction_data: Raw transaction dict from ``execute_transaction_request``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Normalised transaction hash string.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TransactionError: If the payload is malformed or the broadcast fails.
|
|
||||||
WalletError: If the wallet balance is too low or cannot be fetched.
|
|
||||||
"""
|
|
||||||
if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
|
|
||||||
raise TransactionError(TransactionError.INVALID_PAYLOAD)
|
|
||||||
|
|
||||||
message = transaction_data["transaction"]["messages"][0]
|
|
||||||
amount_ton = int(message["amount"]) / 1_000_000_000
|
|
||||||
|
|
||||||
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
|
||||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
|
||||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
|
||||||
|
|
||||||
# Check balance covers transaction amount + gas reserve
|
|
||||||
try:
|
|
||||||
await wallet.refresh()
|
|
||||||
balance_ton = wallet.balance / 1_000_000_000
|
|
||||||
required = amount_ton + MIN_TON_BALANCE
|
|
||||||
if balance_ton < required:
|
|
||||||
raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton, required=required, gas=MIN_TON_BALANCE))
|
|
||||||
except WalletError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
raise WalletError(WalletError.BALANCE_CHECK_FAILED.format(exc=exc)) from exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = clean_decode(message["payload"])
|
|
||||||
|
|
||||||
for attempt in range(3):
|
|
||||||
try:
|
|
||||||
result = await wallet.transfer(
|
|
||||||
destination=message["address"],
|
|
||||||
amount=int(message["amount"]), # nanotons, not TON
|
|
||||||
body=payload,
|
|
||||||
)
|
|
||||||
return result.normalized_hash
|
|
||||||
except ProviderResponseError as exc:
|
|
||||||
if exc.code == 429 and attempt == 0:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
continue
|
|
||||||
if exc.code == 406 and "seqno" in str(exc).lower():
|
|
||||||
# Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry
|
|
||||||
if attempt < 2:
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
continue
|
|
||||||
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
|
|
||||||
raise
|
|
||||||
except (WalletError, TransactionError):
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
cause: BaseException | None = exc
|
|
||||||
while cause is not None:
|
|
||||||
if isinstance(cause, ssl.SSLError):
|
|
||||||
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
|
|
||||||
cause = cause.__cause__ or cause.__context__
|
|
||||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
|
|
||||||
|
|
||||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
|
|
||||||
|
|
||||||
|
|
||||||
async def get_account_info(client: "FragmentClient") -> dict[str, Any]:
|
|
||||||
"""Fetch wallet address, public key, and state-init for the Fragment API.
|
|
||||||
|
|
||||||
Fragment requires account info to build each transaction payload. The
|
|
||||||
returned dict is JSON-serialised and passed as the ``account`` field in
|
|
||||||
``getBuy*Link`` / ``get*Link`` requests.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with ``address``, ``publicKey``, ``chain``, ``walletStateInit``.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
WalletError: If account info cannot be retrieved.
|
|
||||||
"""
|
|
||||||
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
|
||||||
try:
|
|
||||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
|
||||||
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
|
||||||
boc = wallet.state_init.serialize().to_boc()
|
|
||||||
return {
|
|
||||||
"address": wallet.address.to_str(False, False),
|
|
||||||
"publicKey": pub_key.as_hex,
|
|
||||||
"chain": "-239",
|
|
||||||
"walletStateInit": base64.b64encode(boc).decode(),
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
async def get_wallet_info(client: "FragmentClient") -> "WalletInfo":
|
|
||||||
"""Return the address, state and balance of the TON wallet.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
:class:`WalletInfo` with ``address``, ``state``, and ``balance`` in TON.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
WalletError: If the wallet state cannot be fetched.
|
|
||||||
"""
|
|
||||||
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
|
||||||
try:
|
|
||||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
|
||||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
|
||||||
await wallet.refresh()
|
|
||||||
return WalletInfo(
|
|
||||||
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
|
|
||||||
state=wallet.state.value,
|
|
||||||
balance=round(wallet.balance / 1_000_000_000, 4),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from pyfragment.utils.wallet.balance import get_usdt_balance
|
||||||
|
from pyfragment.utils.wallet.info import get_account_info, get_wallet_info
|
||||||
|
from pyfragment.utils.wallet.transaction import clean_decode, process_transaction
|
||||||
|
from pyfragment.utils.wallet.transfer import send_ton_transfer, send_usdt_transfer
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_account_info",
|
||||||
|
"get_usdt_balance",
|
||||||
|
"get_wallet_info",
|
||||||
|
"process_transaction",
|
||||||
|
"clean_decode",
|
||||||
|
"send_ton_transfer",
|
||||||
|
"send_usdt_transfer",
|
||||||
|
]
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method
|
||||||
|
from tonutils.exceptions import ProviderResponseError
|
||||||
|
|
||||||
|
from pyfragment.types import WalletError
|
||||||
|
from pyfragment.types.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS
|
||||||
|
|
||||||
|
|
||||||
|
async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
|
||||||
|
"""Return wallet USDT balance via tonutils jetton get-methods."""
|
||||||
|
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:
|
||||||
|
# No jetton wallet deployed yet -> effectively zero USDT balance.
|
||||||
|
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 balance requirements for TON payment method."""
|
||||||
|
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 balance requirements for USDT payment method."""
|
||||||
|
# USDT payment still needs TON for network fees.
|
||||||
|
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))
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from ton_core import NetworkGlobalID
|
||||||
|
from tonutils.clients import TonapiClient
|
||||||
|
|
||||||
|
from pyfragment.types import WalletError, WalletInfo
|
||||||
|
from pyfragment.types.constants import WALLET_CLASSES
|
||||||
|
from pyfragment.utils.wallet.balance import get_usdt_balance
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
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``, ``balance`` in TON,
|
||||||
|
and ``usdt_balance`` in USDT.
|
||||||
|
|
||||||
|
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()
|
||||||
|
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,128 @@
|
|||||||
|
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.types import ParseError, TransactionError, WalletError
|
||||||
|
from pyfragment.types.constants import WALLET_CLASSES, PaymentMethod
|
||||||
|
from pyfragment.utils.wallet.balance import check_ton_payment_balance, check_usdt_payment_balance
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
def clean_decode(payload: str) -> str | Cell:
|
||||||
|
"""Decode a base64-encoded BOC payload to a plain-text comment string."""
|
||||||
|
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 message (e.g. jetton transfer),
|
||||||
|
# not a plain text comment — return the full cell as-is.
|
||||||
|
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 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``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` or ``"usdt_ton"``.
|
||||||
|
required_payment_amount: Optional price from init*Request response.
|
||||||
|
|
||||||
|
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 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 covers selected payment flow requirements.
|
||||||
|
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 withdrawn from the Fragment-linked wallet (transaction["from"]),
|
||||||
|
# not from the signing seed wallet. Seed wallet only pays TON gas.
|
||||||
|
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():
|
||||||
|
# Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry
|
||||||
|
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,103 @@
|
|||||||
|
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.types import TransactionError, WalletError
|
||||||
|
from pyfragment.types.constants import USDT_TON_MASTER_ADDRESS, WALLET_CLASSES
|
||||||
|
from pyfragment.types.results 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 on-chain using ToncenterClient.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance (seed and wallet_version used).
|
||||||
|
destination: Recipient TON address (any format, e.g. ``"UQ..."``).
|
||||||
|
amount: Amount in nanotons (1 TON = 1 000 000 000 nanotons).
|
||||||
|
body: Optional on-chain comment attached to the transfer.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`TonTransferResult` with ``transaction_id``, ``destination``, and ``amount``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TransactionError: If the transaction fails to broadcast.
|
||||||
|
"""
|
||||||
|
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 (TON jetton) transfer on-chain using ToncenterClient.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance (seed and wallet_version used).
|
||||||
|
destination: Recipient TON address (any format, e.g. ``"UQ..."``).
|
||||||
|
usdt_amount: Amount in USDT base units (6 decimals; 1 USDT = 1 000 000).
|
||||||
|
forward_payload: Optional comment forwarded to the recipient with the transfer notification.
|
||||||
|
ton_for_gas: TON attached for gas in nanotons. Defaults to ``50_000_000`` (0.05 TON).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`UsdtTransferResult` with ``transaction_id``, ``destination``, and ``amount``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TransactionError: If the transaction fails to broadcast.
|
||||||
|
"""
|
||||||
|
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
|
||||||
+45
-26
@@ -4,44 +4,60 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pyfragment"
|
name = "pyfragment"
|
||||||
version = "2026.2.0"
|
version = "2026.2.3"
|
||||||
description = "Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts."
|
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"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
authors = [{ name = "bohd4nx", url = "https://github.com/bohd4nx" }]
|
authors = [{ name = "bohd4nx" }]
|
||||||
keywords = ["fragment", "telegram", "ton", "stars", "premium", "crypto", "blockchain"]
|
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 = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
|
"Intended Audience :: Financial and Insurance Industry",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Natural Language :: English",
|
"Natural Language :: English",
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
|
"Programming Language :: Python",
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Framework :: AsyncIO",
|
"Framework :: AsyncIO",
|
||||||
|
"Topic :: Software Development :: Libraries",
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
"Topic :: Internet",
|
"Topic :: Internet",
|
||||||
|
"Topic :: Internet :: WWW/HTTP",
|
||||||
"Topic :: Office/Business :: Financial",
|
"Topic :: Office/Business :: Financial",
|
||||||
|
"Topic :: Office/Business :: Financial :: Investment",
|
||||||
"Typing :: Typed",
|
"Typing :: Typed",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = ["httpx>=0.25", "rookiepy>=0.5.6", "tonutils>=2.0.1"]
|
||||||
"httpx==0.28.1",
|
|
||||||
"rookiepy==0.5.6",
|
|
||||||
"tonutils==2.1.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
|
||||||
"pytest==9.0.3",
|
|
||||||
"pytest-asyncio==1.3.0",
|
|
||||||
"pytest-mock",
|
|
||||||
"mypy",
|
|
||||||
"ruff",
|
|
||||||
"black",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/bohd4nx/pyfragment"
|
Homepage = "https://github.com/bohd4nx/pyfragment"
|
||||||
@@ -53,24 +69,27 @@ Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md"
|
|||||||
packages = ["pyfragment"]
|
packages = ["pyfragment"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
python_files = ["[0-9][0-9][0-9]_test_*.py"]
|
python_files = ["[0-9][0-9][0-9]_test_*.py"]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
addopts = "-v --tb=short"
|
addopts = "-v --tb=short"
|
||||||
|
|
||||||
[tool.black]
|
|
||||||
line-length = 128
|
|
||||||
target-version = ["py312"]
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 128
|
line-length = 128
|
||||||
target-version = "py312"
|
target-version = "py312"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort
|
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
|
||||||
select = ["E", "F", "W", "I"]
|
select = ["E", "F", "W", "I", "UP"]
|
||||||
ignore = ["E501"]
|
# 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]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"tests/*" = ["E402"]
|
"tests/*" = ["E402"]
|
||||||
"systests/*" = ["E402"]
|
"systests/*" = ["E402"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.10"
|
||||||
|
strict = true
|
||||||
|
exclude = ["^systests/", "^examples/"]
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
-e .[dev]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
-e .
|
|
||||||
@@ -1,37 +1,62 @@
|
|||||||
"""Tests for clean_decode() — TON BOC payload decoding."""
|
"""Tests for clean_decode() — TON BOC payload decoding."""
|
||||||
|
|
||||||
|
import base64
|
||||||
import re
|
import re
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from ton_core import Cell
|
||||||
|
|
||||||
from pyfragment.types import ParseError
|
from pyfragment.types import ParseError
|
||||||
from pyfragment.utils.decoder import clean_decode
|
from pyfragment.utils.wallet.transaction import clean_decode
|
||||||
|
|
||||||
PAYLOADS = [
|
PAYLOAD_CASES = [
|
||||||
pytest.param(
|
pytest.param(
|
||||||
"te6ccgEBAgEALwABTgAAAAAxMDAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmI1RQb01wegEABkM3ZQ",
|
"te6ccgEBAgEALwABTgAAAAAxMDAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmI1RQb01wegEABkM3ZQ",
|
||||||
|
True,
|
||||||
id="stars",
|
id="stars",
|
||||||
),
|
),
|
||||||
pytest.param(
|
pytest.param(
|
||||||
"te6ccgEBAgEANAABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAxIHllYXIgCgpSZWYjcgEAEE9OQnM2cmNt",
|
"te6ccgEBAgEANAABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAxIHllYXIgCgpSZWYjcgEAEE9OQnM2cmNt",
|
||||||
|
True,
|
||||||
id="premium",
|
id="premium",
|
||||||
),
|
),
|
||||||
pytest.param(
|
pytest.param(
|
||||||
"te6ccgEBAgEAMAABTgAAAABUZWxlZ3JhbSBhY2NvdW50IHRvcCB1cCAKClJlZiNrMXpDRQEACFkxd3g",
|
"te6ccgEBAgEAMAABTgAAAABUZWxlZ3JhbSBhY2NvdW50IHRvcCB1cCAKClJlZiNrMXpDRQEACFkxd3g",
|
||||||
|
True,
|
||||||
id="topup",
|
id="topup",
|
||||||
),
|
),
|
||||||
|
pytest.param(
|
||||||
|
"te6ccgEBAgEAfgABqA-KfqVP885dhccidjC3GwgBCkiH8LM_zUu0afyGCTWJwX1mDjdlf2rMa9UoQlD4UHUAF1jLlcMomlo5RJTwl8jnDDdfdhc7EgQQWPqFQ9IjyLPCAwEASgAAAAA1MCBUZWxlZ3JhbSBTdGFycyAKClJlZiNtOUpoWndBcFE",
|
||||||
|
False,
|
||||||
|
id="real_stars_50",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"te6ccgEBAgEANgABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAzIG1vbnRocyAKClJlZgEAFCMzcFdKdGJkYnU",
|
||||||
|
False,
|
||||||
|
id="real_premium_3m",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"te6ccgEBAwEAhgABqg-KfqWibdDaYaJCPUWWgvAIAQpIh_CzP81LtGn8hgk1icF9Zg43ZX9qzGvVKEJQ-FB1ABdYy5XDKJpaOUSU8JfI5ww3X3YXOxIEEFj6hUPSI8izwgMBAU4AAAAAMTAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmIzBoZ0RmNEYCAAQ5VA",
|
||||||
|
False,
|
||||||
|
id="real_stars_100k",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Decode valid payload tests
|
# Decode valid payload tests
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("payload", PAYLOADS)
|
@pytest.mark.parametrize(("payload", "strict_ref"), PAYLOAD_CASES)
|
||||||
def test_decode_payload(payload: str) -> None:
|
def test_decode_payload(payload: str, strict_ref: bool) -> None:
|
||||||
result = clean_decode(payload)
|
result = clean_decode(payload)
|
||||||
assert "Telegram" in result
|
if isinstance(result, str):
|
||||||
assert re.search(r"Ref#[A-Za-z0-9]+", result), f"no Ref# in {result!r}"
|
assert "Telegram" in result
|
||||||
assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}"
|
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
|
# Edge case tests
|
||||||
@@ -44,3 +69,64 @@ def test_empty_payload_returns_empty_string() -> None:
|
|||||||
def test_invalid_payload_raises_parse_error() -> None:
|
def test_invalid_payload_raises_parse_error() -> None:
|
||||||
with pytest.raises(ParseError):
|
with pytest.raises(ParseError):
|
||||||
clean_decode("!!!not-valid-base64!!!")
|
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.utils.wallet.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.utils.wallet.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.utils.wallet.transaction.Cell.one_from_boc", return_value=fake_cell):
|
||||||
|
parsed = clean_decode(payload)
|
||||||
|
|
||||||
|
assert parsed is fake_cell
|
||||||
|
|||||||
+56
-11
@@ -1,5 +1,6 @@
|
|||||||
"""Unit tests for process_transaction() — balance validation and broadcast retry logic."""
|
"""Unit tests for process_transaction() — balance validation and broadcast retry logic."""
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
@@ -45,10 +46,10 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
|
|||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _patch_wallet(wallet: MagicMock):
|
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
patch("pyfragment.utils.wallet.transaction.TonapiClient") as mock_tonapi,
|
||||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
patch("pyfragment.utils.wallet.transaction.WALLET_CLASSES") as mock_classes,
|
||||||
):
|
):
|
||||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
@@ -61,8 +62,8 @@ def _patch_wallet(wallet: MagicMock):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sufficient_balance_broadcasts() -> None:
|
async def test_sufficient_balance_broadcasts() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, needs 0.556 TON
|
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold
|
||||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"):
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value="50 Telegram Stars"):
|
||||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert result == "abc123"
|
assert result == "abc123"
|
||||||
wallet.transfer.assert_called_once()
|
wallet.transfer.assert_called_once()
|
||||||
@@ -70,7 +71,7 @@ async def test_sufficient_balance_broadcasts() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_insufficient_balance_raises() -> None:
|
async def test_insufficient_balance_raises() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, needs 0.556 TON
|
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, below threshold
|
||||||
with _patch_wallet(wallet):
|
with _patch_wallet(wallet):
|
||||||
with pytest.raises(WalletError, match="required"):
|
with pytest.raises(WalletError, match="required"):
|
||||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
@@ -79,15 +80,15 @@ async def test_insufficient_balance_raises() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_exact_minimum_balance_broadcasts() -> None:
|
async def test_exact_minimum_balance_broadcasts() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=556_000_000) # exactly 0.5 + 0.056 TON
|
wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold
|
||||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"):
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value="50 Telegram Stars"):
|
||||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert result == "abc123"
|
assert result == "abc123"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_one_nanoton_below_minimum_raises() -> None:
|
async def test_one_nanoton_below_minimum_raises() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=555_999_999) # 1 nanoton below threshold
|
wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanoton below transaction amount threshold
|
||||||
with _patch_wallet(wallet):
|
with _patch_wallet(wallet):
|
||||||
with pytest.raises(WalletError, match="required"):
|
with pytest.raises(WalletError, match="required"):
|
||||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
@@ -102,6 +103,12 @@ async def test_invalid_payload_raises() -> None:
|
|||||||
await process_transaction(_make_client(), {"transaction": {}})
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_balance_check_failed_raises_wallet_error() -> None:
|
async def test_balance_check_failed_raises_wallet_error() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||||
@@ -116,7 +123,7 @@ async def test_balance_check_failed_raises_wallet_error() -> None:
|
|||||||
async def test_rate_limit_retries_and_succeeds() -> None:
|
async def test_rate_limit_retries_and_succeeds() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||||
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
|
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
|
||||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""):
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value=""):
|
||||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert result == "abc123"
|
assert result == "abc123"
|
||||||
assert wallet.transfer.call_count == 2
|
assert wallet.transfer.call_count == 2
|
||||||
@@ -127,7 +134,45 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
|
|||||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||||
err = _provider_error(406, "Duplicate msg_seqno")
|
err = _provider_error(406, "Duplicate msg_seqno")
|
||||||
wallet.transfer = AsyncMock(side_effect=[err, err, err])
|
wallet.transfer = AsyncMock(side_effect=[err, err, err])
|
||||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""):
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value=""):
|
||||||
with pytest.raises(TransactionError, match="seqno"):
|
with pytest.raises(TransactionError, match="seqno"):
|
||||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert wallet.transfer.call_count == 3
|
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.utils.wallet.balance.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.utils.wallet.transaction.clean_decode", return_value=""),
|
||||||
|
patch("pyfragment.utils.wallet.balance.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,
|
||||||
|
)
|
||||||
|
|||||||
+96
-11
@@ -32,23 +32,27 @@ async def test_purchase_stars_float_amount(client: FragmentClient) -> None:
|
|||||||
await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type]
|
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
|
# Stars purchase mocked tests
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_purchase_stars_success(client: FragmentClient) -> None:
|
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 (
|
with (
|
||||||
patch.object(
|
patch.object(client, "call", call_mock),
|
||||||
client,
|
|
||||||
"call",
|
|
||||||
AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
|
||||||
{"req_id": FAKE_REQ_ID},
|
|
||||||
FAKE_TRANSACTION,
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
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)),
|
patch.object(_purchase_stars_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||||
):
|
):
|
||||||
@@ -60,6 +64,44 @@ async def test_purchase_stars_success(client: FragmentClient) -> None:
|
|||||||
assert result.amount == 500
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
|
async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
|
||||||
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||||
@@ -106,6 +148,12 @@ async def test_giveaway_stars_float_amount(client: FragmentClient) -> None:
|
|||||||
await client.giveaway_stars("@channel", winners=1, amount=500.5) # type: ignore[arg-type]
|
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
|
# Stars giveaway mocked tests
|
||||||
|
|
||||||
|
|
||||||
@@ -135,6 +183,43 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None:
|
|||||||
assert result.amount == 1000
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None:
|
async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None:
|
||||||
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ async def test_purchase_premium_months_zero(client: FragmentClient) -> None:
|
|||||||
await client.purchase_premium("@user", months=0)
|
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
|
# Premium purchase mocked tests
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +61,44 @@ async def test_purchase_premium_success(client: FragmentClient) -> None:
|
|||||||
assert result.amount == 3
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
|
async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
|
||||||
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||||
@@ -89,6 +133,12 @@ async def test_giveaway_premium_invalid_months(client: FragmentClient) -> None:
|
|||||||
await client.giveaway_premium("@channel", winners=10, months=5)
|
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
|
# Premium giveaway mocked tests
|
||||||
|
|
||||||
|
|
||||||
@@ -118,6 +168,43 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None:
|
|||||||
assert result.amount == 3
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> None:
|
async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> None:
|
||||||
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Unit tests for get_wallet() — wallet address and TON balance lookup."""
|
"""Unit tests for get_wallet() — wallet address/state with separate TON and USDT balances."""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import pytest
|
|||||||
from pyfragment import FragmentClient, WalletInfo
|
from pyfragment import FragmentClient, WalletInfo
|
||||||
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON
|
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON
|
||||||
|
|
||||||
# Wallet mocked tests
|
# Wallet mocked tests (TON and USDT balances are returned separately)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -19,8 +19,9 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
|
|||||||
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
patch("pyfragment.utils.wallet.info.TonapiClient") as mock_tonapi,
|
||||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
patch("pyfragment.utils.wallet.info.WALLET_CLASSES") as mock_classes,
|
||||||
|
patch("pyfragment.utils.wallet.info.get_usdt_balance", AsyncMock(return_value=12.3456)),
|
||||||
):
|
):
|
||||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
@@ -31,7 +32,8 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
|
|||||||
assert isinstance(result, WalletInfo)
|
assert isinstance(result, WalletInfo)
|
||||||
assert result.address == FAKE_ADDRESS
|
assert result.address == FAKE_ADDRESS
|
||||||
assert result.state == "active"
|
assert result.state == "active"
|
||||||
assert result.balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4)
|
assert result.ton_balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4)
|
||||||
|
assert result.usdt_balance == 12.3456
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -43,8 +45,9 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
|
|||||||
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
patch("pyfragment.utils.wallet.info.TonapiClient") as mock_tonapi,
|
||||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
patch("pyfragment.utils.wallet.info.WALLET_CLASSES") as mock_classes,
|
||||||
|
patch("pyfragment.utils.wallet.info.get_usdt_balance", AsyncMock(return_value=0.0)),
|
||||||
):
|
):
|
||||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
@@ -52,5 +55,6 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
|
|||||||
|
|
||||||
result = await client.get_wallet()
|
result = await client.get_wallet()
|
||||||
|
|
||||||
assert result.balance == 0.0
|
assert result.ton_balance == 0.0
|
||||||
|
assert result.usdt_balance == 0.0
|
||||||
assert result.state == "uninit"
|
assert result.state == "uninit"
|
||||||
|
|||||||
+20
-1
@@ -1,10 +1,13 @@
|
|||||||
"""Unit tests for FragmentClient.call() — raw Fragment API access."""
|
"""Unit tests for FragmentClient.call() — raw Fragment API access."""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pyfragment import FragmentClient
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.types import FragmentPageError
|
||||||
|
from pyfragment.utils.api import fragment_request
|
||||||
from tests.shared import FAKE_HASH, FAKE_RESPONSE
|
from tests.shared import FAKE_HASH, FAKE_RESPONSE
|
||||||
|
|
||||||
# client.call() mocked tests
|
# client.call() mocked tests
|
||||||
@@ -61,3 +64,19 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
|||||||
|
|
||||||
_, _, _, sent_data = mock_request.call_args.args
|
_, _, _, sent_data = mock_request.call_args.args
|
||||||
assert sent_data == {"method": "anyMethod", "key": "value", "num": 7}
|
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:
|
||||||
|
"""fragment_request raises FragmentPageError on non-200 HTTP responses."""
|
||||||
|
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"})
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ FAKE_JAR = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _mock_rookiepy(jar: list[dict] | None = None) -> MagicMock:
|
def _mock_rookiepy(jar: list[dict[str, str]] | None = None) -> MagicMock:
|
||||||
mock = MagicMock()
|
mock = MagicMock()
|
||||||
mock.chrome.return_value = jar if jar is not None else FAKE_JAR
|
mock.chrome.return_value = jar if jar is not None else FAKE_JAR
|
||||||
return mock
|
return mock
|
||||||
@@ -106,6 +106,21 @@ def test_empty_cookie_value_treated_as_missing() -> None:
|
|||||||
get_cookies_from_browser("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
|
# read failure tests
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Unit tests for init payment amount parsing."""
|
||||||
|
|
||||||
|
from pyfragment.utils.parser 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
|
||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -14,13 +15,13 @@ from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def cookies():
|
def cookies() -> dict[str, str]:
|
||||||
"""Load Fragment cookies from COOKIES_JSON env var; skip if unavailable."""
|
"""Load Fragment cookies from COOKIES_JSON env var; skip if unavailable."""
|
||||||
raw = os.environ.get("COOKIES_JSON")
|
raw = os.environ.get("COOKIES_JSON")
|
||||||
if not raw:
|
if not raw:
|
||||||
pytest.skip("COOKIES_JSON env var not set")
|
pytest.skip("COOKIES_JSON env var not set")
|
||||||
try:
|
try:
|
||||||
return json.loads(raw)
|
return cast(dict[str, str], json.loads(raw))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
pytest.skip(f"Cookies unavailable — {exc}")
|
pytest.skip(f"Cookies unavailable — {exc}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user