mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-28 07:41:41 +00:00
Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35ab0e337d | |||
| b67415bcc2 | |||
| 5df95bea29 | |||
| 7412448f9c | |||
| 6dd9dcb5d4 | |||
| 311222d478 | |||
| 72918a6dd6 | |||
| eec59f7e12 | |||
| c1ae5f0f28 | |||
| 4873d6d6dd | |||
| 1f6085acdc | |||
| d5dfed5f1a | |||
| 7923dff8b2 | |||
| 8c7423a6ab | |||
| 8f3e9f4bd8 | |||
| fca60135a6 | |||
| d66602b646 | |||
| 333b6f45ce | |||
| 1a070ce8b9 | |||
| 415d9d9a9c | |||
| 6a5e007a1b | |||
| d81b83ac91 | |||
| 6b6ca37f10 | |||
| 028984155b | |||
| 391d15221b | |||
| 269551da2f | |||
| ebd45d2991 | |||
| 733d138fcc | |||
| 503baa9a94 | |||
| d4ac44f698 | |||
| 3aedd619dc | |||
| bae2e1b400 | |||
| 0a71e6c508 | |||
| fb0b82e429 | |||
| 3c60bf5e5a | |||
| da2eb37a10 | |||
| 3156160778 | |||
| 8163ed1a9d | |||
| f37a4ab985 | |||
| dfe7bb4140 | |||
| 327ca35190 | |||
| 6e4d4da740 | |||
| ab8d463d54 | |||
| b55cd00205 | |||
| 25d70a77ed | |||
| c7b6c6c933 | |||
| 2d1a119768 | |||
| 902b692dcb | |||
| 7cf5ab4500 | |||
| 6cae351607 | |||
| 13f5aaf555 | |||
| 9660e4858f | |||
| 75cbe52507 | |||
| 4e03d2e2f6 | |||
| 0af5ce5935 | |||
| b07b4670fb | |||
| 581886938b | |||
| 949796df17 | |||
| 75ee76b60e | |||
| 3e14a01c92 | |||
| 2184dc7d98 | |||
| c5edfad06f | |||
| 20b73444ab | |||
| b726a2274c | |||
| 8d58ed890d | |||
| f5d2e8490a | |||
| 520153dfb1 | |||
| 67f8a882c2 | |||
| 041081b919 | |||
| f4a96bb01a | |||
| d8f0240807 | |||
| 8135a9da7e | |||
| 7f269d9a87 | |||
| 9a090e3dbc | |||
| d2d046a2b9 | |||
| 116ed24db9 | |||
| 4e017cb7a1 | |||
| 6c869428aa | |||
| 0c02062291 | |||
| c3de8a8ca3 | |||
| 38a4619e42 | |||
| 8cf57b58b7 | |||
| 2cdc102a74 | |||
| da56afc75f | |||
| 7f3c3ea1c3 | |||
| efcdda6b74 | |||
| b9c02646be | |||
| 2d7860682d | |||
| 64f8058c60 | |||
| f57f8271c3 | |||
| 5dc3cddf1a | |||
| d2faa27c5c | |||
| 91d33a0972 | |||
| e3706f01bf | |||
| b1eae1dcb7 | |||
| d299a1f804 | |||
| 76473993e2 | |||
| 49cf8843bc | |||
| 8adfaf4ad2 | |||
| 760c853acd |
@@ -1,5 +0,0 @@
|
|||||||
root: ./docs
|
|
||||||
|
|
||||||
structure:
|
|
||||||
readme: README.md
|
|
||||||
summary: SUMMARY.md
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Report an issue or unexpected behavior in pyfragment.
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
options:
|
||||||
|
- label: I am sure the error is coming from pyfragment code
|
||||||
|
required: true
|
||||||
|
- label: I have searched the issue tracker for similar bug reports, including closed ones
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Context
|
||||||
|
Please provide as much detail as possible to help us reproduce and fix the issue.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Operating system
|
||||||
|
placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Python version
|
||||||
|
description: Run `python --version` inside your virtualenv
|
||||||
|
placeholder: e.g. 3.12.3
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: pyfragment version
|
||||||
|
description: Run `pip show pyfragment` inside your virtualenv
|
||||||
|
placeholder: e.g. 2026.1.0
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: Describe what you expected to happen.
|
||||||
|
placeholder: e.g. Stars should be purchased and StarsResult returned.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Current behavior
|
||||||
|
description: Describe what is actually happening.
|
||||||
|
placeholder: e.g. ParseError is raised with status 400.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Minimal steps that reproduce the issue.
|
||||||
|
placeholder: |
|
||||||
|
1. Create FragmentClient with valid credentials
|
||||||
|
2. Call purchase_stars("@username", amount=100)
|
||||||
|
3. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Code example
|
||||||
|
description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable.
|
||||||
|
placeholder: |
|
||||||
|
import asyncio
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
client = FragmentClient(...)
|
||||||
|
result = await client.purchase_stars("@username", amount=100)
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
render: python
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Traceback / logs
|
||||||
|
description: Paste the full traceback or relevant logs.
|
||||||
|
placeholder: |
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "main.py", line 7, in main
|
||||||
|
...
|
||||||
|
pyfragment.types.ParseError: ...
|
||||||
|
render: sh
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional information
|
||||||
|
description: Anything else that might help us diagnose the problem.
|
||||||
|
placeholder: e.g. Only happens with V5R1 wallet version.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: Ask a question or start a discussion
|
||||||
|
url: https://github.com/bohd4nx/pyfragment/discussions
|
||||||
|
about: General questions, ideas, and community help go here — not in the issue tracker.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest an improvement or new feature for pyfragment.
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
body:
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: pyfragment version
|
||||||
|
description: Which version are you running?
|
||||||
|
options:
|
||||||
|
- latest
|
||||||
|
- older
|
||||||
|
- n/a
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Problem
|
||||||
|
description: Is your request related to a specific problem? Describe it.
|
||||||
|
placeholder: e.g. There is no way to check my current TON balance before sending.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution
|
||||||
|
description: Describe what you would like to see added or changed.
|
||||||
|
placeholder: e.g. Add a get_balance() method to FragmentClient.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Alternatives considered
|
||||||
|
description: Any workarounds or alternative approaches you have thought of.
|
||||||
|
placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Code example
|
||||||
|
description: A short example demonstrating the desired API, if applicable.
|
||||||
|
placeholder: |
|
||||||
|
balance = await client.get_balance()
|
||||||
|
print(balance.ton)
|
||||||
|
render: python
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional information
|
||||||
|
description: Any other context, screenshots, or references.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Description
|
||||||
|
|
||||||
|
Please include a summary of the change and which issue is fixed.
|
||||||
|
Include relevant motivation and context.
|
||||||
|
|
||||||
|
Fixes # (issue)
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
- [ ] Documentation (typos, examples, or any docs update)
|
||||||
|
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||||
|
- [ ] New feature (non-breaking change which adds functionality)
|
||||||
|
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
|
||||||
|
- [ ] This change requires a documentation update
|
||||||
|
|
||||||
|
## How has this been tested?
|
||||||
|
|
||||||
|
Describe the tests you ran to verify the change and list any relevant details.
|
||||||
|
|
||||||
|
- [ ] Existing tests pass (`pytest`)
|
||||||
|
- [ ] New tests added for this change
|
||||||
|
|
||||||
|
**Test configuration:**
|
||||||
|
* OS:
|
||||||
|
* Python version:
|
||||||
|
* pyfragment version:
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] My code follows the style guidelines of this project
|
||||||
|
- [ ] I have performed a self-review of my own code
|
||||||
|
- [ ] I have updated documentation where necessary
|
||||||
|
- [ ] I have added tests that prove my fix or feature works
|
||||||
|
- [ ] All new and existing tests pass locally
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "pip"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "**" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "**" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint & Format
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.10"
|
||||||
|
|
||||||
|
- run: pip install ".[dev]"
|
||||||
|
|
||||||
|
- run: ruff check . && ruff format --check . && mypy pyfragment
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Tests (Python ${{ matrix.python-version }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python-version: [ "3.10", "3.11", "3.12" ] # 3.13, 3.14 are not supported by some dependencies yet
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install package and dev dependencies
|
||||||
|
run: pip install ".[dev]"
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: pytest
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
name: Docs Branch Check
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [docs]
|
|
||||||
pull_request:
|
|
||||||
branches: [docs]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
docs-tree:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v7.0.1
|
|
||||||
|
|
||||||
- name: Ensure docs structure exists
|
|
||||||
run: |
|
|
||||||
test -f docs/README.md
|
|
||||||
test -f docs/SUMMARY.md
|
|
||||||
test -d docs/getting-started
|
|
||||||
test -d docs/client
|
|
||||||
test -d docs/reference
|
|
||||||
test -d docs/advanced
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
name: Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [ "CI" ]
|
||||||
|
types: [ completed ]
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
version-check:
|
||||||
|
name: Version Check
|
||||||
|
if: github.event.workflow_run.conclusion == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.value }}
|
||||||
|
is-new: ${{ steps.tag.outputs.is-new }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Read version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
|
||||||
|
echo "value=$value" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Check tag
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
|
||||||
|
echo "is-new=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "is-new=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
needs: version-check
|
||||||
|
if: needs.version-check.outputs.is-new == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- uses: astral-sh/setup-uv@v8.1.0
|
||||||
|
|
||||||
|
- run: uv build
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/*
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish to PyPI
|
||||||
|
needs: [ version-check, build ]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
environment:
|
||||||
|
name: pypi
|
||||||
|
url: https://pypi.org/project/pyfragment/
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: GitHub Release
|
||||||
|
needs: [ version-check, build ]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Extract latest changelog entry
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||||
|
echo "body<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
echo "$body" >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- uses: softprops/action-gh-release@v3
|
||||||
|
with:
|
||||||
|
tag_name: v${{ needs.version-check.outputs.version }}
|
||||||
|
name: v${{ needs.version-check.outputs.version }}
|
||||||
|
files: dist/*
|
||||||
|
body: ${{ steps.changelog.outputs.body }}
|
||||||
|
make_latest: true
|
||||||
+38
@@ -1 +1,39 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Testing & tooling artifacts
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
systests/
|
||||||
|
|
||||||
|
# Build & distribution
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to pyfragment are documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MICRO`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026.2.2] — 2026-05-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `payment_method` option (`"ton"` / `"usdt_ton"`) for:
|
||||||
|
- `purchase_stars()`
|
||||||
|
- `purchase_premium()`
|
||||||
|
- `giveaway_stars()`
|
||||||
|
- `giveaway_premium()`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Added runtime validation for `payment_method` via `SUPPORTED_PAYMENT_METHODS` and `ConfigurationError.INVALID_PAYMENT_METHOD`
|
||||||
|
- Updated method docstrings to explicitly document recipient/channel formats:
|
||||||
|
- `@username` / `username` / `https://t.me/username`
|
||||||
|
- `get_wallet()` now returns balances as separate fields: `ton_balance` and `usdt_balance`
|
||||||
|
- Wallet/system test output now prints TON and USDT balances on separate lines
|
||||||
|
- Balance checks are now method-aware with explicit thresholds:
|
||||||
|
- `ton`: minimum TON balance threshold via `MIN_TON_BALANCE` (based on current 50 Stars purchase amount)
|
||||||
|
- `usdt_ton`: minimum USDT balance threshold via `MIN_USDT_BALANCE` (based on current 50 Stars purchase amount)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Extended stars and premium test suites to cover:
|
||||||
|
- invalid payment method
|
||||||
|
- payment method propagation to `init*Request` payloads
|
||||||
|
- accepted query formats (`@`, plain username, `t.me` link)
|
||||||
|
- Extended wallet tests to verify separate TON/USDT balance values in `WalletInfo`
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Simplified `README` usage example
|
||||||
|
|
||||||
|
## [2026.2.1] — 2026-05-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fragment API 429 responses are now retried automatically (up to 3 attempts) with exponential backoff and jitter in `fragment_request`
|
||||||
|
- Retry delays in TON transaction broadcasting now include jitter to reduce contention under concurrent calls
|
||||||
|
- Improved handling of non-200 HTTP responses in `get_fragment_hash`
|
||||||
|
- Removed unnecessary `method` key leaking into certain API request payloads
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Type hints refined across the codebase for better clarity and `mypy` strict compliance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026.2.0] — 2026-04-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `get_cookies_from_browser(browser)` — extract Fragment session cookies directly from an installed browser (Chrome, Firefox, Edge, Brave, Arc, Opera, Safari, and more); no browser extension or manual copy-paste required
|
||||||
|
```python
|
||||||
|
from pyfragment.utils import get_cookies_from_browser
|
||||||
|
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
|
||||||
|
client = FragmentClient(seed="...", api_key="...", cookies=result.cookies)
|
||||||
|
print(result.expires) # ISO 8601 expiry of stel_ssid, or None for session cookies
|
||||||
|
```
|
||||||
|
- `CookieResult` — return type of `get_cookies_from_browser()`; exposes `.cookies` (`dict[str, str]`) and `.expires` (ISO 8601 string or `None`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `DEVICE` Tonkeeper fingerprint updated: `appVersion` → `26.04.0`
|
||||||
|
- `tonutils` upgraded to **2.1.0**
|
||||||
|
- Minimum Python version lowered to **3.10** (previously 3.12)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026.1.0] — 2026-03-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
**Giveaways**
|
||||||
|
|
||||||
|
- `giveaway_stars(channel, winners, amount)` — Stars giveaway; 1–5 winners, 500–1 000 000 stars each
|
||||||
|
- `giveaway_premium(channel, winners, months)` — Premium giveaway; 1–24 000 winners, 3/6/12 months each
|
||||||
|
- `StarsGiveawayResult`, `PremiumGiveawayResult` result types
|
||||||
|
|
||||||
|
**Telegram Ads**
|
||||||
|
|
||||||
|
- `recharge_ads(account, amount)` — top up a Telegram Ads account; 1–1 000 000 000 TON
|
||||||
|
- `AdsRechargeResult` result type
|
||||||
|
|
||||||
|
**Marketplace**
|
||||||
|
|
||||||
|
- `search_usernames(query?, sort?, filter?, offset_id?)` — search Fragment usernames; `sort`: `price_desc / price_asc / listed / ending`, `filter`: `auction / sale / sold`
|
||||||
|
- `search_numbers(query?, sort?, filter?, offset_id?)` — search Fragment anonymous numbers; same `sort` / `filter` / pagination semantics
|
||||||
|
- `search_gifts(query?, collection?, sort?, filter?, view?, attr?, offset?)` — search Fragment gifts; `attr` accepts `{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}`
|
||||||
|
- `UsernamesResult`, `NumbersResult`, `GiftsResult` result types
|
||||||
|
|
||||||
|
**Anonymous numbers**
|
||||||
|
|
||||||
|
- `get_login_code(number)` — fetch the current pending login code
|
||||||
|
- `toggle_login_codes(number, can_receive)` — enable or disable login code delivery
|
||||||
|
- `terminate_sessions(number)` — terminate all active Telegram sessions (two-step flow handled internally)
|
||||||
|
- `LoginCodeResult`, `TerminateSessionsResult` result types; `AnonymousNumberError` exception
|
||||||
|
|
||||||
|
**Raw API**
|
||||||
|
|
||||||
|
- `FragmentClient.call(method, data, *, page_url)` — raw request to any Fragment API method
|
||||||
|
- `FRAGMENT_BASE_URL` constant — base URL shared across all page constants and headers
|
||||||
|
|
||||||
|
**Examples**
|
||||||
|
|
||||||
|
- `examples/client/` — `wallet_info.py` (wallet info), `raw_api_call.py` (raw API call)
|
||||||
|
- `examples/numbers/` — `manage_number.py` (login code fetch, session termination)
|
||||||
|
- `examples/auctions/` — `search_usernames.py`, `search_numbers.py`, `search_gifts.py` (marketplace search with pagination)
|
||||||
|
- `examples/purchase/` — `send_stars.py`, `send_premium.py`, `topup_ton_balance.py`, `run_stars_giveaway.py`, `run_premium_giveaway.py`, `recharge_ads_balance.py`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- All result types now expose a unified `amount` field (`months` and `stars` removed)
|
||||||
|
- `__repr__` includes the unit — `3 months`, `500 stars`, etc.
|
||||||
|
- `timestamp` removed from all result dataclasses
|
||||||
|
- All page URL constants built from `FRAGMENT_BASE_URL`;
|
||||||
|
- `TransactionError` includes an SSL hint; `DUPLICATE_SEQNO` variant auto-retried up to 2 times (2 s apart)
|
||||||
|
- Error messages rewritten: "what happened → why → what to do"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026.0.2] — 2026-03-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `timeout` parameter on `FragmentClient` (default `30.0` s) — passed through to every HTTP request
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Cookie validation: narrowed type internally so no `# type: ignore` is needed in `FragmentClient.__init__`
|
||||||
|
- `WALLET_CLASSES` typed as `dict[str, Any]` so mypy resolves `from_mnemonic` correctly
|
||||||
|
- All four `examples/` files updated to `async with FragmentClient`, f-strings, and aligned error messages
|
||||||
|
- README usage section rewritten with a single comprehensive `async with` example
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- mypy: missing return path in `process_transaction` after retry loop
|
||||||
|
- mypy: `cookies` union-attr error in `FragmentClient.__init__`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026.0.1] — 2026-03-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial stable release of `pyfragment`
|
||||||
|
- `FragmentClient` — async client for the Fragment.com API with context manager support (`async with`)
|
||||||
|
- `purchase_premium(username, months)` — purchase Telegram Premium for any user (3, 6, or 12 months)
|
||||||
|
- `purchase_stars(username, amount)` — send Telegram Stars to any user (50–1,000,000)
|
||||||
|
- `topup_ton(username, amount)` — top up TON Ads balance (1–1,000,000,000 TON)
|
||||||
|
- `get_wallet()` — fetch wallet address and balance
|
||||||
|
- Support for TON wallet versions `V4R2` and `V5R1`
|
||||||
|
- Structured exception hierarchy (`FragmentError`, `ConfigurationError`, `CookieError`, etc.)
|
||||||
|
- `py.typed` marker — full PEP 561 typing support for type-checkers
|
||||||
|
- `__repr__` on all result types for readable debug output
|
||||||
|
|
||||||
|
[2026.2.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.2
|
||||||
|
[2026.2.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.1
|
||||||
|
[2026.2.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.0
|
||||||
|
[2026.1.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.1.0
|
||||||
|
[2026.0.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.2
|
||||||
|
[2026.0.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.1
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 bohd4nx
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,6 +1,122 @@
|
|||||||
# pyfragment docs branch
|
<div align="center">
|
||||||
|
<img src="https://www.bohd4n.dev/assets/projects/pyfragment.svg" alt="Fragment Logo" width="120" height="120" style="border-radius: 24px;">
|
||||||
|
|
||||||
This branch is dedicated to GitBook content.
|
<h1 style="margin-top: 24px;">Fragment API</h1>
|
||||||
|
|
||||||
- Main docs source: docs/
|
<p style="font-size: 18px; margin-bottom: 24px;">
|
||||||
- Navigation: docs/SUMMARY.md
|
<b>Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings.</b>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[](https://pypi.org/project/pyfragment/)
|
||||||
|
[](https://pepy.tech/projects/pyfragment)
|
||||||
|
[](https://python.org)
|
||||||
|
[](https://github.com/bohd4nx/pyfragment/actions)
|
||||||
|
[](https://github.com/bohd4nx/pyfragment/blob/master/LICENSE)
|
||||||
|
|
||||||
|
[Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
> **Disclaimer:** This project is not affiliated with, endorsed by, or in any way officially connected with [Fragment](https://fragment.com) or [Telegram](https://telegram.org).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pyfragment
|
||||||
|
```
|
||||||
|
|
||||||
|
To install the latest unreleased changes from the `dev` branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install git+https://github.com/bohd4nx/pyfragment.git@dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Python 3.10+.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
| ---------------- | ------------- | -------- | -------------------------------------------------------- |
|
||||||
|
| `seed` | `str` | — | 24-word TON wallet mnemonic |
|
||||||
|
| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) |
|
||||||
|
| `cookies` | `dict \| str` | — | Fragment session cookies |
|
||||||
|
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` |
|
||||||
|
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
**Fragment cookies** — log in to [fragment.com](https://fragment.com) and connect your TON wallet. You can get cookies in two ways:
|
||||||
|
|
||||||
|
- **Automatically** (recommended) — use `get_cookies_from_browser()`, which reads them directly from your browser's on-disk store. No extension needed:
|
||||||
|
```python
|
||||||
|
from pyfragment.utils import get_cookies_from_browser
|
||||||
|
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
|
||||||
|
# result.cookies — dict[str, str] to pass to FragmentClient
|
||||||
|
# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies
|
||||||
|
```
|
||||||
|
- **Manually** — install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export these four keys: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass them as a `dict` or JSON string.
|
||||||
|
|
||||||
|
Refresh when you get authentication errors.
|
||||||
|
|
||||||
|
**Tonapi key** — generate at [tonconsole.com](https://tonconsole.com).
|
||||||
|
|
||||||
|
**Seed phrase** — 24-word mnemonic from your TON wallet (Tonkeeper → Settings → Backup). Never share it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with FragmentClient(
|
||||||
|
seed="word1 word2 ... word24",
|
||||||
|
api_key="YOUR_TONAPI_KEY",
|
||||||
|
cookies={
|
||||||
|
"stel_ssid": "...",
|
||||||
|
"stel_dt": "...",
|
||||||
|
"stel_token": "...",
|
||||||
|
"stel_ton_token": "...",
|
||||||
|
},
|
||||||
|
) as client:
|
||||||
|
wallet = await client.get_wallet()
|
||||||
|
print(f"Wallet: {wallet.address} | TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}")
|
||||||
|
|
||||||
|
recipient = "https://t.me/username" # also supports: @username, username
|
||||||
|
|
||||||
|
stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton")
|
||||||
|
print(f"Stars sent: {stars.amount} to {stars.username} | tx: {stars.transaction_id}")
|
||||||
|
|
||||||
|
premium = await client.purchase_premium(recipient, months=6, payment_method="ton")
|
||||||
|
print(f"Premium sent: {premium.amount} months to {premium.username} | tx: {premium.transaction_id}")
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
Full runnable examples:
|
||||||
|
|
||||||
|
- https://github.com/bohd4nx/pyfragment/tree/master/examples
|
||||||
|
|
||||||
|
Payload debug/decode helper (thanks):
|
||||||
|
|
||||||
|
- https://ton-cell-abi-viewer.vercel.app/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
### Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx)
|
||||||
|
|
||||||
|
**Star ⭐ this repo if you found it useful!**
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# Overview
|
|
||||||
|
|
||||||
`pyfragment` is an async Python client for [Fragment](https://fragment.com).
|
|
||||||
|
|
||||||
If you are integrating Fragment into a bot or backend, this docs set is meant to be practical, not theoretical.
|
|
||||||
|
|
||||||
**Recommended reading order:**
|
|
||||||
|
|
||||||
1. Install the package
|
|
||||||
2. Configure `FragmentClient`
|
|
||||||
3. Set up credentials and cookies
|
|
||||||
4. Run the quick start
|
|
||||||
5. Move to feature-specific flows
|
|
||||||
|
|
||||||
## Who this is for
|
|
||||||
|
|
||||||
- Python developers integrating Fragment into bots, services, and automation.
|
|
||||||
- Teams that need predictable typed results and explicit error behavior.
|
|
||||||
|
|
||||||
**Important:** this library is not affiliated with Fragment or Telegram.
|
|
||||||
|
|
||||||
## Where to begin
|
|
||||||
|
|
||||||
1. [Installation](getting-started/installation.md)
|
|
||||||
2. [Library and Configuration](getting-started/configuration.md)
|
|
||||||
3. [Credentials and Cookies](getting-started/credentials-and-cookies.md)
|
|
||||||
4. [Quick Start](getting-started/quickstart.md)
|
|
||||||
|
|
||||||
## Feature entry points
|
|
||||||
|
|
||||||
- Stars: [Purchase](client/stars/purchase.md), [Giveaway](client/stars/giveaway.md)
|
|
||||||
- Premium: [Purchase](client/premium/purchase.md), [Giveaway](client/premium/giveaway.md)
|
|
||||||
- Marketplace: [Overview](client/marketplace/overview.md), Ads: [Overview](client/ads/overview.md)
|
|
||||||
- Numbers: [Anonymous Numbers](client/anonymous-numbers/overview.md)
|
|
||||||
- Utility operations: [Raw API Calls](client/raw-call.md)
|
|
||||||
|
|
||||||
## Additional references
|
|
||||||
|
|
||||||
- [Error Handling](reference/errors.md)
|
|
||||||
- [Result Models](reference/models.md)
|
|
||||||
- [Literal Types](reference/literals.md)
|
|
||||||
- [Troubleshooting](advanced/troubleshooting.md)
|
|
||||||
|
|
||||||
## Live examples
|
|
||||||
|
|
||||||
**Up-to-date runnable examples live in the main repository:**
|
|
||||||
|
|
||||||
- https://github.com/bohd4nx/pyfragment/tree/master/examples
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
- [Overview](README.md)
|
|
||||||
- Setup Guide
|
|
||||||
- [Installation](getting-started/installation.md)
|
|
||||||
- [Library and Configuration](getting-started/configuration.md)
|
|
||||||
- [Credentials and Cookies](getting-started/credentials-and-cookies.md)
|
|
||||||
- [Quick Start](getting-started/quickstart.md)
|
|
||||||
- [Error Handling](reference/errors.md)
|
|
||||||
- API Guides
|
|
||||||
- [Overview](client/overview.md)
|
|
||||||
- Stars
|
|
||||||
- [Purchase](client/stars/purchase.md)
|
|
||||||
- [Giveaway](client/stars/giveaway.md)
|
|
||||||
- Premium
|
|
||||||
- [Purchase](client/premium/purchase.md)
|
|
||||||
- [Giveaway](client/premium/giveaway.md)
|
|
||||||
- Marketplace
|
|
||||||
- [Overview](client/marketplace/overview.md)
|
|
||||||
- [Search Usernames](client/marketplace/search-usernames.md)
|
|
||||||
- [Search Numbers](client/marketplace/search-numbers.md)
|
|
||||||
- [Search Gifts](client/marketplace/search-gifts.md)
|
|
||||||
- Ads
|
|
||||||
- [Overview](client/ads/overview.md)
|
|
||||||
- [Top Up GRAM](client/ads/topup-gram.md)
|
|
||||||
- [Recharge Ads](client/ads/recharge-ads.md)
|
|
||||||
- Anonymous Numbers
|
|
||||||
- [Overview](client/anonymous-numbers/overview.md)
|
|
||||||
- [Get Login Code](client/anonymous-numbers/get-login-code.md)
|
|
||||||
- [Toggle Login Codes](client/anonymous-numbers/toggle-login-codes.md)
|
|
||||||
- [Terminate Sessions](client/anonymous-numbers/terminate-sessions.md)
|
|
||||||
- [Raw API Calls](client/raw-call.md)
|
|
||||||
- Reference
|
|
||||||
- [Result Models](reference/models.md)
|
|
||||||
- [Literal Types](reference/literals.md)
|
|
||||||
- Advanced
|
|
||||||
- [Cookie Extraction Details](advanced/cookies.md)
|
|
||||||
- [Troubleshooting](advanced/troubleshooting.md)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Cookie Extraction Details
|
|
||||||
|
|
||||||
`get_cookies_from_browser(browser)` reads Fragment cookies from local browser storage (via `rookiepy`).
|
|
||||||
|
|
||||||
This is the fastest way to start when you do not want manual cookie export.
|
|
||||||
|
|
||||||
Supported browsers are defined in constants and include:
|
|
||||||
|
|
||||||
- chrome, firefox, edge, brave,
|
|
||||||
- arc, opera, opera_gx,
|
|
||||||
- safari, vivaldi,
|
|
||||||
- chromium variants.
|
|
||||||
|
|
||||||
Validation includes:
|
|
||||||
|
|
||||||
- required key presence,
|
|
||||||
- non-empty values,
|
|
||||||
- optional expiration check for `stel_ssid`.
|
|
||||||
|
|
||||||
**If any required cookie is empty or missing, extraction is treated as failed.**
|
|
||||||
|
|
||||||
If extraction fails, `CookieError` is raised with actionable details.
|
|
||||||
|
|
||||||
Use [Credentials and Cookies](../getting-started/credentials-and-cookies.md) for setup-first instructions.
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Troubleshooting
|
|
||||||
|
|
||||||
When something breaks, start here. Most issues are caused by cookies, session state, or wallet balance.
|
|
||||||
|
|
||||||
## Auth/session errors
|
|
||||||
|
|
||||||
Symptoms:
|
|
||||||
|
|
||||||
- Fragment page hash cannot be extracted,
|
|
||||||
- bad status loading Fragment pages,
|
|
||||||
- missing request IDs.
|
|
||||||
|
|
||||||
Actions:
|
|
||||||
|
|
||||||
- re-login on fragment.com,
|
|
||||||
- refresh cookies,
|
|
||||||
- ensure all `stel_*` keys are present.
|
|
||||||
- verify constructor payload in [Library and Configuration](../getting-started/configuration.md).
|
|
||||||
|
|
||||||
**Re-login + fresh cookies solves the majority of auth errors.**
|
|
||||||
|
|
||||||
## Cookie extraction errors
|
|
||||||
|
|
||||||
Symptoms:
|
|
||||||
|
|
||||||
- browser not supported,
|
|
||||||
- cannot read browser profile,
|
|
||||||
- required cookies not found.
|
|
||||||
|
|
||||||
Actions:
|
|
||||||
|
|
||||||
- install `pyfragment[browser]`,
|
|
||||||
- close locked browser profiles,
|
|
||||||
- use manual cookies if needed.
|
|
||||||
|
|
||||||
## Balance/transaction failures
|
|
||||||
|
|
||||||
Symptoms:
|
|
||||||
|
|
||||||
- low TON/USDT balance errors,
|
|
||||||
- broadcast failures,
|
|
||||||
- duplicate seqno retries.
|
|
||||||
|
|
||||||
Actions:
|
|
||||||
|
|
||||||
- keep GRAM (ex TON) reserve for fees,
|
|
||||||
- ensure USDT is on the **Fragment-linked wallet**,
|
|
||||||
- retry after short delay when seqno collisions happen.
|
|
||||||
- check operation constraints in Stars/Premium/Ads method pages.
|
|
||||||
|
|
||||||
## SSL-related broadcast failures
|
|
||||||
|
|
||||||
If you get SSL-related errors during **TON transaction broadcast** (not Fragment page loading — those use curl_cffi with bundled SSL):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install --upgrade certifi
|
|
||||||
```
|
|
||||||
|
|
||||||
On macOS, also run Python's `Install Certificates.command` if needed.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Ads Overview
|
|
||||||
|
|
||||||
Ads flow is split into two methods:
|
|
||||||
|
|
||||||
- [Top Up GRAM](topup-gram.md)
|
|
||||||
- [Recharge Ads](recharge-ads.md)
|
|
||||||
|
|
||||||
Use the first method to send GRAM (ex TON) to a Telegram user.
|
|
||||||
Use the second method to fund your own Telegram Ads account.
|
|
||||||
|
|
||||||
## Common errors
|
|
||||||
|
|
||||||
- `ConfigurationError`
|
|
||||||
- `UserNotFoundError` (for recipient/account issues)
|
|
||||||
- `WalletError`
|
|
||||||
- `VerificationError`
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Recharge Ads
|
|
||||||
|
|
||||||
Use this method to add funds to your Telegram Ads account.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
```python
|
|
||||||
await client.recharge_ads(
|
|
||||||
account: str,
|
|
||||||
amount: int,
|
|
||||||
) -> AdsRechargeResult
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `account`: channel or bot username linked to your ads account
|
|
||||||
- `amount`: integer from `1` to `1_000_000_000`
|
|
||||||
|
|
||||||
**Important:** `amount` must be an integer in the allowed range.
|
|
||||||
|
|
||||||
## Return
|
|
||||||
|
|
||||||
- `AdsRechargeResult(transaction_id, amount)`
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
result: AdsRechargeResult = await client.recharge_ads("@mychannel", amount=50)
|
|
||||||
print(result.transaction_id)
|
|
||||||
```
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Top Up GRAM
|
|
||||||
|
|
||||||
Use this method to send GRAM (ex TON) to a user's Telegram balance.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
```python
|
|
||||||
await client.topup_gram(
|
|
||||||
username: str,
|
|
||||||
amount: int,
|
|
||||||
show_sender: bool = True,
|
|
||||||
) -> AdsTopupResult
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `username`: recipient Telegram username — `@username`, `username`, or `https://t.me/username`
|
|
||||||
- `amount`: integer from `1` to `1_000_000_000`
|
|
||||||
- `show_sender`: controls sender visibility
|
|
||||||
|
|
||||||
**`amount` must be an integer in the allowed range.**
|
|
||||||
|
|
||||||
## Return
|
|
||||||
|
|
||||||
- `AdsTopupResult(transaction_id, username, amount)`
|
|
||||||
|
|
||||||
## Typical errors
|
|
||||||
|
|
||||||
- `ConfigurationError`: invalid amount
|
|
||||||
- `UserNotFoundError`: recipient not found on Fragment
|
|
||||||
- `WalletError`: insufficient GRAM (ex TON) balance
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
result: AdsTopupResult = await client.topup_gram("@username", amount=10, show_sender=True)
|
|
||||||
print(result.transaction_id)
|
|
||||||
```
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Get Login Code
|
|
||||||
|
|
||||||
Use this method to fetch a pending login code for an anonymous number.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
```python
|
|
||||||
await client.get_login_code(number: str) -> LoginCodeResult
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `number`: anonymous number (with or without leading `+`)
|
|
||||||
|
|
||||||
## Return
|
|
||||||
|
|
||||||
- `number`
|
|
||||||
- `code` (`None` if no pending code)
|
|
||||||
- `active_sessions`
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
result: LoginCodeResult = await client.get_login_code("+1234567890")
|
|
||||||
print(result.code)
|
|
||||||
```
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Anonymous Numbers Overview
|
|
||||||
|
|
||||||
These methods help you manage login behavior and active sessions for anonymous numbers owned by your account.
|
|
||||||
|
|
||||||
Available methods:
|
|
||||||
|
|
||||||
- [Get Login Code](get-login-code.md)
|
|
||||||
- [Toggle Login Codes](toggle-login-codes.md)
|
|
||||||
- [Terminate Sessions](terminate-sessions.md)
|
|
||||||
|
|
||||||
## Common errors
|
|
||||||
|
|
||||||
- `AnonymousNumberError.NOT_OWNED`
|
|
||||||
- `AnonymousNumberError.TERMINATE_FAILED`
|
|
||||||
|
|
||||||
**If a number is not owned by your account, requests will fail.**
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Terminate Sessions
|
|
||||||
|
|
||||||
Use this method to terminate active sessions for an anonymous number.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
```python
|
|
||||||
await client.terminate_sessions(number: str) -> TerminateSessionsResult
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `number`: anonymous number (with or without leading `+`)
|
|
||||||
|
|
||||||
## Return
|
|
||||||
|
|
||||||
- `number`
|
|
||||||
- `message`
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
result: TerminateSessionsResult = await client.terminate_sessions("+1234567890")
|
|
||||||
print(result.message)
|
|
||||||
```
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Toggle Login Codes
|
|
||||||
|
|
||||||
Use this method to allow or block login code delivery.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
```python
|
|
||||||
await client.toggle_login_codes(number: str, can_receive: bool) -> None
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `number`: anonymous number (with or without leading `+`)
|
|
||||||
- `can_receive`: `True` to allow codes, `False` to block codes
|
|
||||||
|
|
||||||
## Return
|
|
||||||
|
|
||||||
- `None`
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
await client.toggle_login_codes("+1234567890", can_receive=False)
|
|
||||||
```
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Marketplace Overview
|
|
||||||
|
|
||||||
Marketplace methods are exposed directly on `FragmentClient` and via `client.marketplace` service.
|
|
||||||
|
|
||||||
If you only need one thing: pick the method by asset type (username, number, gift), then paginate until `next_offset_id` or `next_offset` becomes `None`.
|
|
||||||
|
|
||||||
Available methods:
|
|
||||||
|
|
||||||
- [Search Usernames](search-usernames.md)
|
|
||||||
- [Search Numbers](search-numbers.md)
|
|
||||||
- [Search Gifts](search-gifts.md)
|
|
||||||
|
|
||||||
## Shared behavior
|
|
||||||
|
|
||||||
- All methods are async.
|
|
||||||
- All methods call Fragment `searchAuctions` under the hood.
|
|
||||||
- `sort` and `filter` are optional passthrough strings.
|
|
||||||
|
|
||||||
**These values are passed to Fragment as-is.** If Fragment changes accepted values, behavior can change too.
|
|
||||||
|
|
||||||
Common values used by Fragment pages:
|
|
||||||
|
|
||||||
- `sort`: `price_desc`, `price_asc`, `listed`, `ending`
|
|
||||||
- `filter`: empty string, `auction`, `sale`, `sold`
|
|
||||||
|
|
||||||
## Pagination model
|
|
||||||
|
|
||||||
- Usernames and Numbers return `next_offset_id` (string)
|
|
||||||
- Gifts return `next_offset` (integer)
|
|
||||||
|
|
||||||
Use these fields to request next pages.
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Search Gifts
|
|
||||||
|
|
||||||
This endpoint is the most flexible marketplace search and supports collection, traits, and pagination.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
```python
|
|
||||||
await client.search_gifts(
|
|
||||||
query: str = "",
|
|
||||||
collection: str | None = None,
|
|
||||||
sort: str | None = None,
|
|
||||||
filter: str | None = None,
|
|
||||||
view: str | None = None,
|
|
||||||
attr: dict[str, list[str]] | None = None,
|
|
||||||
offset: int | None = None,
|
|
||||||
) -> GiftsResult
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `query`: search text (empty string for broad listing)
|
|
||||||
- `collection`: collection slug (for example `plushpepe`, `swisswatch`)
|
|
||||||
- `sort`: optional sort key passed to Fragment
|
|
||||||
- `filter`: optional listing filter passed to Fragment
|
|
||||||
- `view`: optional UI/view mode passed to Fragment
|
|
||||||
- `attr`: optional trait filters where key is trait name and value is list of allowed values
|
|
||||||
- `offset`: page offset for next page
|
|
||||||
|
|
||||||
**`attr` is ideal for narrowing results by visual or rarity traits.**
|
|
||||||
|
|
||||||
## Sorting values
|
|
||||||
|
|
||||||
Common values accepted by Fragment:
|
|
||||||
|
|
||||||
- `price_desc`
|
|
||||||
- `price_asc`
|
|
||||||
- `listed`
|
|
||||||
- `ending`
|
|
||||||
|
|
||||||
## Filter values
|
|
||||||
|
|
||||||
Common values accepted by Fragment:
|
|
||||||
|
|
||||||
- empty string
|
|
||||||
- `auction`
|
|
||||||
- `sale`
|
|
||||||
- `sold`
|
|
||||||
|
|
||||||
## Attribute filter format
|
|
||||||
|
|
||||||
`attr` is encoded into request fields in this form:
|
|
||||||
|
|
||||||
- `attr[trait_name] = ["value1", "value2"]`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```python
|
|
||||||
attr={
|
|
||||||
"model": ["gold", "silver"],
|
|
||||||
"rarity": ["rare"],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In requests, each trait is sent as `attr[trait]` with a list of values.
|
|
||||||
|
|
||||||
## Return type
|
|
||||||
|
|
||||||
`GiftsResult` contains:
|
|
||||||
|
|
||||||
- `items: list[dict[str, Any]]`
|
|
||||||
- `next_offset: int | None`
|
|
||||||
|
|
||||||
## Pagination
|
|
||||||
|
|
||||||
If `next_offset` is not `None`, pass it back as `offset` to load the next page.
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
result: GiftsResult = await client.search_gifts(
|
|
||||||
query="",
|
|
||||||
collection="plushpepe",
|
|
||||||
sort="price_desc",
|
|
||||||
filter="auction",
|
|
||||||
)
|
|
||||||
print(len(result.items), result.next_offset)
|
|
||||||
```
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Search Numbers
|
|
||||||
|
|
||||||
Use this endpoint to search anonymous Telegram number listings.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
```python
|
|
||||||
await client.search_numbers(
|
|
||||||
query: str = "",
|
|
||||||
sort: str | None = None,
|
|
||||||
filter: str | None = None,
|
|
||||||
offset_id: str | None = None,
|
|
||||||
) -> NumbersResult
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `query`: digits or text to match number listings
|
|
||||||
- `sort`: optional sort key passed to Fragment
|
|
||||||
- `filter`: optional listing filter passed to Fragment
|
|
||||||
- `offset_id`: page cursor for next page
|
|
||||||
|
|
||||||
`query` can be partial digits (for example `"888"`) when you need pattern-based discovery.
|
|
||||||
|
|
||||||
## Sorting values
|
|
||||||
|
|
||||||
Common values accepted by Fragment:
|
|
||||||
|
|
||||||
- `price_desc`
|
|
||||||
- `price_asc`
|
|
||||||
- `listed`
|
|
||||||
- `ending`
|
|
||||||
|
|
||||||
## Filter values
|
|
||||||
|
|
||||||
Common values accepted by Fragment:
|
|
||||||
|
|
||||||
- empty string
|
|
||||||
- `auction`
|
|
||||||
- `sale`
|
|
||||||
- `sold`
|
|
||||||
|
|
||||||
## Return type
|
|
||||||
|
|
||||||
`NumbersResult` contains:
|
|
||||||
|
|
||||||
- `items: list[dict[str, Any]]`
|
|
||||||
- `next_offset_id: str | None`
|
|
||||||
|
|
||||||
## Pagination
|
|
||||||
|
|
||||||
If `next_offset_id` is not `None`, pass it back as `offset_id` to load the next page.
|
|
||||||
|
|
||||||
Keep requesting pages until `next_offset_id` becomes `None`.
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
result: NumbersResult = await client.search_numbers("888", sort="price_asc", filter="sale")
|
|
||||||
print(len(result.items), result.next_offset_id)
|
|
||||||
```
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Search Usernames
|
|
||||||
|
|
||||||
Use this endpoint to discover Telegram usernames listed on Fragment.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
```python
|
|
||||||
await client.search_usernames(
|
|
||||||
query: str = "",
|
|
||||||
sort: str | None = None,
|
|
||||||
filter: str | None = None,
|
|
||||||
offset_id: str | None = None,
|
|
||||||
) -> UsernamesResult
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `query`: search text (empty string means broad listing)
|
|
||||||
- `sort`: optional sort key passed to Fragment
|
|
||||||
- `filter`: optional listing filter passed to Fragment
|
|
||||||
- `offset_id`: page cursor for next page
|
|
||||||
|
|
||||||
For broad browsing, use empty `query` and set sorting only.
|
|
||||||
|
|
||||||
## Sorting values
|
|
||||||
|
|
||||||
Common values accepted by Fragment:
|
|
||||||
|
|
||||||
- `price_desc`
|
|
||||||
- `price_asc`
|
|
||||||
- `listed`
|
|
||||||
- `ending`
|
|
||||||
|
|
||||||
## Filter values
|
|
||||||
|
|
||||||
Common values accepted by Fragment:
|
|
||||||
|
|
||||||
- empty string
|
|
||||||
- `auction`
|
|
||||||
- `sale`
|
|
||||||
- `sold`
|
|
||||||
|
|
||||||
## Return type
|
|
||||||
|
|
||||||
`UsernamesResult` contains:
|
|
||||||
|
|
||||||
- `items: list[dict[str, Any]]`
|
|
||||||
- `next_offset_id: str | None`
|
|
||||||
|
|
||||||
## Pagination
|
|
||||||
|
|
||||||
If `next_offset_id` is not `None`, pass it back as `offset_id` to load the next page.
|
|
||||||
|
|
||||||
This is cursor pagination, so do not try to calculate offsets manually.
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
result: UsernamesResult = await client.search_usernames("durov", sort="price_desc", filter="auction")
|
|
||||||
print(len(result.items), result.next_offset_id)
|
|
||||||
```
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Client Overview
|
|
||||||
|
|
||||||
`FragmentClient` is the main API surface.
|
|
||||||
|
|
||||||
You can call methods directly on the client or use grouped services.
|
|
||||||
|
|
||||||
Grouped service wrappers:
|
|
||||||
|
|
||||||
- `client.purchases`
|
|
||||||
- `client.giveaways`
|
|
||||||
- `client.ads`
|
|
||||||
- `client.anonymous_numbers`
|
|
||||||
- `client.marketplace`
|
|
||||||
- `client.tonapi`
|
|
||||||
|
|
||||||
Main async methods on `FragmentClient`:
|
|
||||||
|
|
||||||
- `purchase_stars(...)`
|
|
||||||
- `purchase_premium(...)`
|
|
||||||
- `giveaway_stars(...)`
|
|
||||||
- `giveaway_premium(...)`
|
|
||||||
- `topup_gram(...)`
|
|
||||||
- `recharge_ads(...)`
|
|
||||||
- `get_wallet()`
|
|
||||||
- `get_login_code(...)`
|
|
||||||
- `toggle_login_codes(...)`
|
|
||||||
- `terminate_sessions(...)`
|
|
||||||
- `search_usernames(...)`
|
|
||||||
- `search_numbers(...)`
|
|
||||||
- `search_gifts(...)`
|
|
||||||
- `call(...)`
|
|
||||||
|
|
||||||
All methods are async and should be used inside `async with FragmentClient(...) as client:`.
|
|
||||||
|
|
||||||
## Flow map
|
|
||||||
|
|
||||||
- Stars: [Purchase](stars/purchase.md), [Giveaway](stars/giveaway.md)
|
|
||||||
- Premium: [Purchase](premium/purchase.md), [Giveaway](premium/giveaway.md)
|
|
||||||
- Marketplace: [Overview](marketplace/overview.md), Ads: [Overview](ads/overview.md)
|
|
||||||
- Numbers: [Anonymous Numbers](anonymous-numbers/overview.md)
|
|
||||||
- Utility operations: [Raw API Calls](raw-call.md)
|
|
||||||
|
|
||||||
**If you are new to the library, start with Stars Purchase or Wallet read (`get_wallet`) first.**
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# Premium Giveaway
|
|
||||||
|
|
||||||
Use this method to run a Telegram Premium giveaway for your channel.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
```python
|
|
||||||
await client.giveaway_premium(
|
|
||||||
channel: str,
|
|
||||||
winners: int,
|
|
||||||
months: int = 3,
|
|
||||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
|
||||||
) -> PremiumGiveawayResult
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `channel`: accepts `@channel`, `channel`, or `https://t.me/channel`
|
|
||||||
- `winners`: integer from `1` to `24_000`
|
|
||||||
- `months`: one of `3`, `6`, `12`
|
|
||||||
- `payment_method`: `PaymentMethod.GRAM` (default), `PaymentMethod.USDT_GRAM`, or any other `PaymentMethod` value
|
|
||||||
|
|
||||||
**`winners` must be a positive integer, and large values can increase total cost significantly.**
|
|
||||||
|
|
||||||
## Return
|
|
||||||
|
|
||||||
- `PremiumGiveawayResult(transaction_id, channel, winners, amount)`
|
|
||||||
|
|
||||||
## Typical errors
|
|
||||||
|
|
||||||
- `ConfigurationError`
|
|
||||||
- `UserNotFoundError`
|
|
||||||
- `WalletError`
|
|
||||||
- `VerificationError`
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
result: PremiumGiveawayResult = await client.giveaway_premium("@channel", winners=100, months=3)
|
|
||||||
print(result.amount)
|
|
||||||
```
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# Premium Purchase
|
|
||||||
|
|
||||||
Use this method to gift Telegram Premium to a specific user.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
```python
|
|
||||||
await client.purchase_premium(
|
|
||||||
username: str,
|
|
||||||
months: int,
|
|
||||||
show_sender: bool = True,
|
|
||||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
|
||||||
) -> PremiumResult
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `username`: accepts `@username`, `username`, or `https://t.me/username`
|
|
||||||
- `months`: one of `3`, `6`, `12`
|
|
||||||
- `show_sender`: controls sender visibility on recipient side
|
|
||||||
- `payment_method`: `PaymentMethod.GRAM` (default), `PaymentMethod.USDT_GRAM`, or any other `PaymentMethod` value
|
|
||||||
|
|
||||||
**`months` only supports `3`, `6`, or `12`.**
|
|
||||||
|
|
||||||
## Return
|
|
||||||
|
|
||||||
- `PremiumResult(transaction_id, username, amount)`
|
|
||||||
|
|
||||||
## Typical errors
|
|
||||||
|
|
||||||
- `ConfigurationError`
|
|
||||||
- `UserNotFoundError`
|
|
||||||
- `WalletError`
|
|
||||||
- `VerificationError`
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
result: PremiumResult = await client.purchase_premium("@username", months=6, payment_method=PaymentMethod.GRAM)
|
|
||||||
print(result.transaction_id)
|
|
||||||
```
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Raw API Calls
|
|
||||||
|
|
||||||
Use `client.call()` when you need a Fragment API method that does not yet have a dedicated wrapper.
|
|
||||||
|
|
||||||
```python
|
|
||||||
result = await client.call(
|
|
||||||
"searchPremiumGiftRecipient",
|
|
||||||
{"query": "@username", "months": 3},
|
|
||||||
page_url="https://fragment.com/premium/gift",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Signature:
|
|
||||||
|
|
||||||
```python
|
|
||||||
await client.call(
|
|
||||||
method: str,
|
|
||||||
data: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
page_url: str = "https://fragment.com",
|
|
||||||
) -> dict[str, Any]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `method`: Fragment API method name
|
|
||||||
- `data`: optional request payload as dictionary
|
|
||||||
- `page_url`: page URL used for referer/hash context (defaults to `https://fragment.com`)
|
|
||||||
|
|
||||||
## Return
|
|
||||||
|
|
||||||
- `dict[str, Any]`: raw Fragment API response
|
|
||||||
|
|
||||||
Use this carefully:
|
|
||||||
|
|
||||||
- request/response shape is Fragment-defined,
|
|
||||||
- undocumented methods can change without notice,
|
|
||||||
- you are responsible for validating returned fields.
|
|
||||||
|
|
||||||
## Recommended approach
|
|
||||||
|
|
||||||
Use dedicated wrappers first, and fallback to `call()` only for missing API surface.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# Stars Giveaway
|
|
||||||
|
|
||||||
Use this method to run a Stars giveaway for a channel audience.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
```python
|
|
||||||
await client.giveaway_stars(
|
|
||||||
channel: str,
|
|
||||||
winners: int,
|
|
||||||
amount: int,
|
|
||||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
|
||||||
) -> StarsGiveawayResult
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `channel`: accepts `@channel`, `channel`, or `https://t.me/channel`
|
|
||||||
- `winners`: integer from `1` to `15`
|
|
||||||
- `amount`: integer from `500` to `1_000_000` (per winner)
|
|
||||||
- `payment_method`: `PaymentMethod.GRAM` (default), `PaymentMethod.USDT_GRAM`, or any other `PaymentMethod` value
|
|
||||||
|
|
||||||
**Each winner receives the full `amount` value.**
|
|
||||||
|
|
||||||
## Return
|
|
||||||
|
|
||||||
- `StarsGiveawayResult(transaction_id, channel, winners, amount)`
|
|
||||||
|
|
||||||
## Typical errors
|
|
||||||
|
|
||||||
- `ConfigurationError`
|
|
||||||
- `UserNotFoundError`
|
|
||||||
- `WalletError`
|
|
||||||
- `VerificationError`
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
result: StarsGiveawayResult = await client.giveaway_stars("@channel", winners=3, amount=1000)
|
|
||||||
print(result.transaction_id)
|
|
||||||
```
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# Stars Purchase
|
|
||||||
|
|
||||||
Use this method to send Telegram Stars directly to a user.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
```python
|
|
||||||
await client.purchase_stars(
|
|
||||||
username: str,
|
|
||||||
amount: int,
|
|
||||||
show_sender: bool = True,
|
|
||||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
|
||||||
) -> StarsResult
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `username`: accepts `@username`, `username`, or `https://t.me/username`
|
|
||||||
- `amount`: integer from `50` to `10_000_000`
|
|
||||||
- `show_sender`: controls sender visibility on recipient side
|
|
||||||
- `payment_method`: `PaymentMethod.GRAM` (default), `PaymentMethod.USDT_GRAM`, or any other `PaymentMethod` value
|
|
||||||
|
|
||||||
**Amount must be between `50` and `10_000_000`.**
|
|
||||||
|
|
||||||
## Return
|
|
||||||
|
|
||||||
- `StarsResult(transaction_id, username, amount)`
|
|
||||||
|
|
||||||
## Typical errors
|
|
||||||
|
|
||||||
- `ConfigurationError`: invalid amount or payment method
|
|
||||||
- `UserNotFoundError`: target user not found
|
|
||||||
- `WalletError`: insufficient balance or wallet-side issue
|
|
||||||
- `VerificationError`: verification/KYC required for operation
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
result: StarsResult = await client.purchase_stars("@username", amount=500, payment_method=PaymentMethod.GRAM)
|
|
||||||
print(result.amount)
|
|
||||||
```
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# Library and Configuration
|
|
||||||
|
|
||||||
Main entry point of the library is `FragmentClient`.
|
|
||||||
|
|
||||||
```python
|
|
||||||
FragmentClient(
|
|
||||||
seed: str,
|
|
||||||
api_key: str,
|
|
||||||
cookies: dict[str, Any] | str,
|
|
||||||
wallet_version: str = "V5R1",
|
|
||||||
api_provider: str = "tonapi",
|
|
||||||
timeout: float = 30.0,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `seed`: wallet mnemonic (**12 or 24 words**)
|
|
||||||
- `api_key`: API key — from [tonconsole.com](https://tonconsole.com) (tonapi) or [@toncenter](https://t.me/toncenter)
|
|
||||||
- `cookies`: Fragment cookies as a dictionary or JSON string
|
|
||||||
- `wallet_version`: `"V4R2"`, `"V5R1"`, `"HighloadV2"`, or `"HighloadV3R1"`
|
|
||||||
- `api_provider`: blockchain API provider — `"tonapi"` (default) or `"toncenter"`
|
|
||||||
- `timeout`: request timeout in seconds
|
|
||||||
|
|
||||||
**If `api_key` or cookies are missing, initialization fails immediately.**
|
|
||||||
|
|
||||||
## Required cookies
|
|
||||||
|
|
||||||
- `stel_ssid`
|
|
||||||
- `stel_dt`
|
|
||||||
- `stel_token`
|
|
||||||
- `stel_ton_token`
|
|
||||||
|
|
||||||
## Minimal initialization pattern
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyfragment import FragmentClient
|
|
||||||
|
|
||||||
async with FragmentClient(
|
|
||||||
seed="word1 word2 ... word24",
|
|
||||||
api_key="YOUR_API_KEY",
|
|
||||||
cookies={
|
|
||||||
"stel_ssid": "...",
|
|
||||||
"stel_dt": "...",
|
|
||||||
"stel_token": "...",
|
|
||||||
"stel_ton_token": "...",
|
|
||||||
},
|
|
||||||
) as client:
|
|
||||||
wallet = await client.get_wallet()
|
|
||||||
```
|
|
||||||
|
|
||||||
## Switching API provider
|
|
||||||
|
|
||||||
By default, the library uses [tonconsole.com](https://tonconsole.com) (tonapi). To use [toncenter](https://t.me/toncenter) instead, pass `api_provider="toncenter"`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async with FragmentClient(
|
|
||||||
seed="...",
|
|
||||||
api_key="YOUR_TONCENTER_API_KEY",
|
|
||||||
cookies={...},
|
|
||||||
api_provider="toncenter",
|
|
||||||
) as client:
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
Both providers work identically — the correct `tonutils` client is selected automatically based on `api_provider`.
|
|
||||||
|
|
||||||
## Validation behavior
|
|
||||||
|
|
||||||
At initialization, library validates:
|
|
||||||
|
|
||||||
- seed format,
|
|
||||||
- cookie shape and required keys,
|
|
||||||
- supported wallet version,
|
|
||||||
- supported API provider,
|
|
||||||
- parseability of cookie JSON strings.
|
|
||||||
|
|
||||||
Constructor-level issues are raised as `ConfigurationError` or `CookieError`.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# Credentials and Cookies
|
|
||||||
|
|
||||||
This page covers the three things you need before making real requests: Tonapi key, wallet seed, and Fragment cookies.
|
|
||||||
|
|
||||||
## Tonapi key
|
|
||||||
|
|
||||||
Generate an API key at https://tonconsole.com.
|
|
||||||
|
|
||||||
## Seed phrase
|
|
||||||
|
|
||||||
Use your GRAM (ex TON) wallet mnemonic.
|
|
||||||
|
|
||||||
- **Keep it private.**
|
|
||||||
- **Never log it or commit it to git.**
|
|
||||||
|
|
||||||
## Fragment cookies
|
|
||||||
|
|
||||||
You must be logged in to Fragment.
|
|
||||||
|
|
||||||
### Option 1: automatic extraction
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyfragment import get_cookies_from_browser
|
|
||||||
|
|
||||||
cookie_result = get_cookies_from_browser("chrome")
|
|
||||||
cookies = cookie_result.cookies
|
|
||||||
```
|
|
||||||
|
|
||||||
`cookie_result` is `CookieResult`:
|
|
||||||
|
|
||||||
- `cookies`: `dict[str, str]`
|
|
||||||
- `expires`: ISO string or `None`
|
|
||||||
|
|
||||||
### Option 2: manual export
|
|
||||||
|
|
||||||
Export the four required Fragment cookies and pass them directly as dict or JSON string.
|
|
||||||
|
|
||||||
Required keys:
|
|
||||||
|
|
||||||
- `stel_ssid`
|
|
||||||
- `stel_dt`
|
|
||||||
- `stel_token`
|
|
||||||
- `stel_ton_token`
|
|
||||||
|
|
||||||
## Common auth failures
|
|
||||||
|
|
||||||
- expired session cookies,
|
|
||||||
- not logged in on fragment.com,
|
|
||||||
- missing `stel_*` keys,
|
|
||||||
- stale cookies from another browser/profile.
|
|
||||||
|
|
||||||
When this happens, re-login on fragment.com and refresh cookies first. It solves most auth issues.
|
|
||||||
|
|
||||||
## Next step
|
|
||||||
|
|
||||||
Proceed to [Quick Start](quickstart.md).
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# Installation
|
|
||||||
|
|
||||||
You can be up and running in under a minute.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Python 3.11 – 3.14
|
|
||||||
|
|
||||||
## Install from PyPI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install pyfragment
|
|
||||||
```
|
|
||||||
|
|
||||||
## Install latest dev branch
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install git+https://github.com/bohd4nx/pyfragment.git@dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Optional browser cookie extraction support
|
|
||||||
|
|
||||||
If you want automatic cookie extraction from local browser profiles:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install "pyfragment[browser]"
|
|
||||||
```
|
|
||||||
|
|
||||||
This installs `rookiepy`, used by `get_cookies_from_browser()`.
|
|
||||||
|
|
||||||
**Use this extra if you do not want to copy cookies manually.**
|
|
||||||
|
|
||||||
## Next step
|
|
||||||
|
|
||||||
After installation, continue with [Library and Configuration](configuration.md).
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# Quick Start
|
|
||||||
|
|
||||||
Use this minimal example to verify that your credentials, cookies, and wallet setup are correct.
|
|
||||||
|
|
||||||
```python
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from pyfragment import FragmentClient
|
|
||||||
from pyfragment.enums import PaymentMethod
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
|
||||||
async with FragmentClient(
|
|
||||||
seed="word1 word2 ... word24",
|
|
||||||
api_key="YOUR_API_KEY", # tonconsole.com (tonapi, default) or t.me/toncenter
|
|
||||||
cookies={
|
|
||||||
"stel_ssid": "...",
|
|
||||||
"stel_dt": "...",
|
|
||||||
"stel_token": "...",
|
|
||||||
"stel_ton_token": "...",
|
|
||||||
},
|
|
||||||
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
|
|
||||||
api_provider="tonapi", # or "toncenter"
|
|
||||||
) as client:
|
|
||||||
wallet = await client.get_wallet()
|
|
||||||
print("GRAM: %s | USDT: %s" % (wallet.gram_balance, wallet.usdt_balance))
|
|
||||||
|
|
||||||
recipient = "https://t.me/username" # also: @username, username
|
|
||||||
|
|
||||||
stars = await client.purchase_stars(recipient, amount=500, payment_method=PaymentMethod.USDT_GRAM)
|
|
||||||
print("Sent %s Stars to %s | tx: %s" % (stars.amount, stars.username, stars.transaction_id))
|
|
||||||
|
|
||||||
premium = await client.purchase_premium(recipient, months=6, payment_method=PaymentMethod.GRAM)
|
|
||||||
print("Sent Premium %sm to %s | tx: %s" % (premium.amount, premium.username, premium.transaction_id))
|
|
||||||
|
|
||||||
|
|
||||||
asyncio.run(main())
|
|
||||||
```
|
|
||||||
|
|
||||||
If this script returns wallet data, your setup is healthy.
|
|
||||||
|
|
||||||
Then move to feature pages:
|
|
||||||
|
|
||||||
- Stars: [Purchase](client/stars/purchase.md), [Giveaway](client/stars/giveaway.md)
|
|
||||||
- Premium: [Purchase](client/premium/purchase.md), [Giveaway](client/premium/giveaway.md)
|
|
||||||
- Marketplace: [Overview](client/marketplace/overview.md), Ads: [Overview](client/ads/overview.md)
|
|
||||||
- Numbers: [Anonymous Numbers](client/anonymous-numbers/overview.md)
|
|
||||||
- Utility operations: [Raw API Calls](client/raw-call.md)
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# Error Handling
|
|
||||||
|
|
||||||
Good error handling is the difference between a stable integration and random production failures.
|
|
||||||
|
|
||||||
## Exception hierarchy
|
|
||||||
|
|
||||||
- `FragmentError`
|
|
||||||
- `ClientError`
|
|
||||||
- `ConfigurationError`
|
|
||||||
- `CookieError`
|
|
||||||
- `FragmentAPIError`
|
|
||||||
- `FragmentPageError`
|
|
||||||
- `UserNotFoundError`
|
|
||||||
- `AlreadySubscribedError`
|
|
||||||
- `AnonymousNumberError`
|
|
||||||
- `TransactionError`
|
|
||||||
- `ParseError`
|
|
||||||
- `VerificationError`
|
|
||||||
- `OperationError`
|
|
||||||
- `WalletError`
|
|
||||||
- `UnexpectedError`
|
|
||||||
|
|
||||||
## Recommended handling pattern
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyfragment import ConfigurationError, FragmentError, UserNotFoundError, WalletError
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await client.purchase_stars("@username", amount=500)
|
|
||||||
except UserNotFoundError:
|
|
||||||
# recipient does not exist on Fragment
|
|
||||||
...
|
|
||||||
except WalletError:
|
|
||||||
# insufficient balance or wallet-side issue
|
|
||||||
...
|
|
||||||
except ConfigurationError:
|
|
||||||
# invalid local input
|
|
||||||
...
|
|
||||||
except FragmentError:
|
|
||||||
# any other library-level failure
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Catch specific errors first, then fallback to `FragmentError`.**
|
|
||||||
|
|
||||||
## Method-to-error mapping
|
|
||||||
|
|
||||||
- Stars purchase: `ConfigurationError`, `UserNotFoundError`, `WalletError`, `VerificationError`
|
|
||||||
- Premium purchase: `ConfigurationError`, `UserNotFoundError`, `AlreadySubscribedError`, `WalletError`, `VerificationError`
|
|
||||||
- Stars/Premium giveaway: `ConfigurationError`, `UserNotFoundError`, `WalletError`, `VerificationError`
|
|
||||||
- Ads operations: `ConfigurationError`, `UserNotFoundError`, `WalletError`, `VerificationError`
|
|
||||||
- Cookies/auth setup: `CookieError`, `ConfigurationError`, `FragmentPageError`
|
|
||||||
|
|
||||||
## Canonical messages
|
|
||||||
|
|
||||||
See `pyfragment/exceptions.py` for source-of-truth message templates.
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Literal Types
|
|
||||||
|
|
||||||
These literals describe accepted string values for key method parameters.
|
|
||||||
|
|
||||||
## ApiProvider
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyfragment.enums import ApiProvider
|
|
||||||
|
|
||||||
ApiProvider.TONAPI # tonconsole.com — default
|
|
||||||
ApiProvider.TONCENTER # t.me/toncenter
|
|
||||||
```
|
|
||||||
|
|
||||||
Pass as string to `FragmentClient(api_provider=...)`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
FragmentClient(..., api_provider="tonapi") # default
|
|
||||||
FragmentClient(..., api_provider="toncenter")
|
|
||||||
```
|
|
||||||
|
|
||||||
## PaymentMethod
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyfragment.enums import PaymentMethod
|
|
||||||
|
|
||||||
PaymentMethod.GRAM # GRAM (ex TON) — default
|
|
||||||
PaymentMethod.USDT_GRAM # USDT on GRAM (ex TON)
|
|
||||||
PaymentMethod.USDT_ETH # USDT on Ethereum
|
|
||||||
PaymentMethod.USDT_POL # USDT on Polygon
|
|
||||||
PaymentMethod.USDC_ETH # USDC on Ethereum
|
|
||||||
PaymentMethod.USDC_BASE # USDC on Base
|
|
||||||
PaymentMethod.USDC_POL # USDC on Polygon
|
|
||||||
```
|
|
||||||
|
|
||||||
## WalletVersion
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyfragment.enums import WalletVersion
|
|
||||||
|
|
||||||
WalletVersion.V5R1 # default
|
|
||||||
WalletVersion.V4R2
|
|
||||||
WalletVersion.HighloadV2
|
|
||||||
WalletVersion.HighloadV3R1
|
|
||||||
```
|
|
||||||
|
|
||||||
All enums are exported from both `pyfragment` (top-level) and `pyfragment.enums`.
|
|
||||||
|
|
||||||
## Usage notes
|
|
||||||
|
|
||||||
- Use `ApiProvider` when configuring the blockchain API provider in `FragmentClient`.
|
|
||||||
- Use `PaymentMethod` for purchase and giveaway operations.
|
|
||||||
- Use `WalletVersion` when configuring `FragmentClient`.
|
|
||||||
|
|
||||||
**Passing unsupported values raises `ConfigurationError`.**
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Result Models
|
|
||||||
|
|
||||||
Every high-level method returns a typed model, so you can rely on predictable fields instead of raw payload parsing.
|
|
||||||
|
|
||||||
Exported result models:
|
|
||||||
|
|
||||||
- `CookieResult(cookies, expires)`
|
|
||||||
- `StarsResult(transaction_id, username, amount)`
|
|
||||||
- `PremiumResult(transaction_id, username, amount)`
|
|
||||||
- `AdsTopupResult(transaction_id, username, amount)`
|
|
||||||
- `AdsRechargeResult(transaction_id, amount)`
|
|
||||||
- `StarsGiveawayResult(transaction_id, channel, winners, amount)`
|
|
||||||
- `PremiumGiveawayResult(transaction_id, channel, winners, amount)`
|
|
||||||
- `WalletInfo(address, state, gram_balance, usdt_balance)`
|
|
||||||
- `LoginCodeResult(number, code, active_sessions)`
|
|
||||||
- `TerminateSessionsResult(number, message)`
|
|
||||||
- `UsernamesResult(items, next_offset_id)`
|
|
||||||
- `NumbersResult(items, next_offset_id)`
|
|
||||||
- `GiftsResult(items, next_offset)`
|
|
||||||
|
|
||||||
Most high-level methods return one of these dataclasses.
|
|
||||||
|
|
||||||
## Where they are used
|
|
||||||
|
|
||||||
- `purchase_stars()`: `StarsResult`
|
|
||||||
- `purchase_premium()`: `PremiumResult`
|
|
||||||
- `giveaway_stars()`: `StarsGiveawayResult`
|
|
||||||
- `giveaway_premium()`: `PremiumGiveawayResult`
|
|
||||||
- `topup_gram()`: `AdsTopupResult`
|
|
||||||
- `recharge_ads()`: `AdsRechargeResult`
|
|
||||||
- `get_wallet()`: `WalletInfo`
|
|
||||||
- `get_login_code()`: `LoginCodeResult`
|
|
||||||
- `terminate_sessions()`: `TerminateSessionsResult`
|
|
||||||
- `search_usernames()`: `UsernamesResult`
|
|
||||||
- `search_numbers()`: `NumbersResult`
|
|
||||||
- `search_gifts()`: `GiftsResult`
|
|
||||||
|
|
||||||
## Methods without dataclass return
|
|
||||||
|
|
||||||
- `toggle_login_codes()`: returns `None`
|
|
||||||
- `call()`: returns `dict[str, Any]` (raw Fragment API response)
|
|
||||||
|
|
||||||
## Cookie helper
|
|
||||||
|
|
||||||
`CookieResult` is returned by `get_cookies_from_browser()`, not by `FragmentClient` methods.
|
|
||||||
|
|
||||||
**Use these models directly in your app layer and avoid passing raw dictionaries around.**
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Example: search the Fragment gifts marketplace.
|
||||||
|
|
||||||
|
collection filters by gift type slug (e.g. "plushpepe", "swisswatch").
|
||||||
|
sort can be "price_desc", "price_asc", "listed", or "ending".
|
||||||
|
filter can be "", "auction", "sale", or "sold".
|
||||||
|
Use next_offset for pagination.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient, GiftsResult
|
||||||
|
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
|
||||||
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
|
|
||||||
|
# Option B: provide cookies manually
|
||||||
|
COOKIES = {
|
||||||
|
"stel_ssid": "YOUR_STEL_SSID",
|
||||||
|
"stel_dt": "YOUR_STEL_DT",
|
||||||
|
"stel_token": "YOUR_STEL_TOKEN",
|
||||||
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
QUERY = "" # search text — or omit for all
|
||||||
|
COLLECTION = "plushpepe" # gift collection slug — or omit for all
|
||||||
|
SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit
|
||||||
|
FILTER = "" # "", "auction", "sale", "sold" — or omit
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
|
result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER)
|
||||||
|
|
||||||
|
print(f"Found {len(result.items)} result(s):")
|
||||||
|
print(json.dumps(result.items, indent=2))
|
||||||
|
|
||||||
|
if result.next_offset:
|
||||||
|
print(f"\nMore results available — next page offset: {result.next_offset}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Example: search the Fragment marketplace for anonymous Telegram numbers.
|
||||||
|
|
||||||
|
sort can be "price_desc", "price_asc", "listed", or "ending".
|
||||||
|
filter can be "", "auction", "sale", or "sold".
|
||||||
|
Use next_offset_id for pagination.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient, NumbersResult
|
||||||
|
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
|
||||||
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
|
|
||||||
|
# Option B: provide cookies manually
|
||||||
|
COOKIES = {
|
||||||
|
"stel_ssid": "YOUR_STEL_SSID",
|
||||||
|
"stel_dt": "YOUR_STEL_DT",
|
||||||
|
"stel_token": "YOUR_STEL_TOKEN",
|
||||||
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
QUERY = "888" # search term — or omit for all
|
||||||
|
SORT = "price_asc" # "price_desc", "price_asc", "listed", "ending" — or omit
|
||||||
|
FILTER = "" # "", "auction", "sale", "sold" — or omit
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
|
result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER)
|
||||||
|
|
||||||
|
print(f"Found {len(result.items)} result(s):")
|
||||||
|
print(json.dumps(result.items, indent=2))
|
||||||
|
|
||||||
|
if result.next_offset_id:
|
||||||
|
print(f"\nMore results available — next page offset: {result.next_offset_id}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Example: search the Fragment marketplace for Telegram usernames.
|
||||||
|
|
||||||
|
sort can be "price_desc", "price_asc", "listed", or "ending".
|
||||||
|
filter can be "", "auction", "sale", or "sold".
|
||||||
|
Use next_offset_id for pagination.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient, UsernamesResult
|
||||||
|
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
|
||||||
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
|
|
||||||
|
# Option B: provide cookies manually
|
||||||
|
COOKIES = {
|
||||||
|
"stel_ssid": "YOUR_STEL_SSID",
|
||||||
|
"stel_dt": "YOUR_STEL_DT",
|
||||||
|
"stel_token": "YOUR_STEL_TOKEN",
|
||||||
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
QUERY = "durov" # search term
|
||||||
|
SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit
|
||||||
|
FILTER = "auction" # "", "auction", "sale", "sold" — or omit
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
|
result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER)
|
||||||
|
|
||||||
|
print(f"Found {len(result.items)} result(s):")
|
||||||
|
print(json.dumps(result.items, indent=2))
|
||||||
|
|
||||||
|
if result.next_offset_id:
|
||||||
|
print(f"\nMore results available — next page offset: {result.next_offset_id}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Example: send a raw request to any Fragment API method.
|
||||||
|
|
||||||
|
Use client.call() when you need to access a method that is not yet
|
||||||
|
wrapped by the library, or to inspect raw API responses directly.
|
||||||
|
|
||||||
|
page_url is optional — only set it when the target method belongs to a
|
||||||
|
specific Fragment page (Fragment derives the API hash per page).
|
||||||
|
Defaults to the Fragment base URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
|
||||||
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
|
|
||||||
|
# Option B: provide cookies manually
|
||||||
|
COOKIES = {
|
||||||
|
"stel_ssid": "YOUR_STEL_SSID",
|
||||||
|
"stel_dt": "YOUR_STEL_DT",
|
||||||
|
"stel_token": "YOUR_STEL_TOKEN",
|
||||||
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
METHOD = "anyFragmentMethod" # replace with the actual method name
|
||||||
|
DATA = {"key": "value"} # replace with the actual request payload
|
||||||
|
PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragment page (optional)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
|
result = await client.call(METHOD, DATA, page_url=PAGE_URL)
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Example: fetch wallet address, state, and separate TON/USDT balances.
|
||||||
|
|
||||||
|
Cookies can be passed as a dict or as a JSON string.
|
||||||
|
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
|
||||||
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
|
|
||||||
|
# Option B: provide cookies manually
|
||||||
|
COOKIES = {
|
||||||
|
"stel_ssid": "YOUR_STEL_SSID",
|
||||||
|
"stel_dt": "YOUR_STEL_DT",
|
||||||
|
"stel_token": "YOUR_STEL_TOKEN",
|
||||||
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with FragmentClient(
|
||||||
|
seed=SEED,
|
||||||
|
api_key=API_KEY,
|
||||||
|
cookies=COOKIES,
|
||||||
|
wallet_version="V5R1", # or "V4R2"
|
||||||
|
) as client:
|
||||||
|
wallet = await client.get_wallet()
|
||||||
|
print(f"Address: {wallet.address}")
|
||||||
|
print(f"State: {wallet.state}")
|
||||||
|
print(f"Balance: {wallet.ton_balance} TON")
|
||||||
|
print(f"Balance: {wallet.usdt_balance} USDT")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""
|
||||||
|
Example: manage an anonymous Telegram number — read login code and terminate sessions.
|
||||||
|
|
||||||
|
Use get_login_code() to fetch the current pending login code for your number.
|
||||||
|
Use toggle_login_codes() to enable or disable receiving codes.
|
||||||
|
Use terminate_sessions() to forcefully end all active Telegram sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from pyfragment import AnonymousNumberError, FragmentClient
|
||||||
|
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
|
||||||
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
|
|
||||||
|
# Option B: provide cookies manually
|
||||||
|
COOKIES = {
|
||||||
|
"stel_ssid": "YOUR_STEL_SSID",
|
||||||
|
"stel_dt": "YOUR_STEL_DT",
|
||||||
|
"stel_token": "YOUR_STEL_TOKEN",
|
||||||
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
NUMBER = "+88888888888"
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
|
# Fetch the latest login code
|
||||||
|
result = await client.get_login_code(NUMBER)
|
||||||
|
if result.code:
|
||||||
|
print(f"Login code for {result.number}: {result.code} ({result.active_sessions} active session(s))")
|
||||||
|
else:
|
||||||
|
print(f"No pending login code for {result.number} ({result.active_sessions} active session(s))")
|
||||||
|
|
||||||
|
# Terminate all active sessions
|
||||||
|
try:
|
||||||
|
terminated = await client.terminate_sessions(NUMBER)
|
||||||
|
print(f"Sessions terminated for {terminated.number}" + (f": {terminated.message}" if terminated.message else ""))
|
||||||
|
except AnonymousNumberError as e:
|
||||||
|
print(f"Could not terminate sessions: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
Example: recharge your own Telegram Ads account with TON.
|
||||||
|
|
||||||
|
Amount must be an integer between 1 and 1 000 000 000 TON.
|
||||||
|
Your wallet must satisfy the current minimum TON threshold and transaction cost.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from pyfragment import (
|
||||||
|
AdsRechargeResult,
|
||||||
|
ConfigurationError,
|
||||||
|
FragmentClient,
|
||||||
|
WalletError,
|
||||||
|
)
|
||||||
|
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
|
||||||
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
|
|
||||||
|
# Option B: provide cookies manually
|
||||||
|
COOKIES = {
|
||||||
|
"stel_ssid": "YOUR_STEL_SSID",
|
||||||
|
"stel_dt": "YOUR_STEL_DT",
|
||||||
|
"stel_token": "YOUR_STEL_TOKEN",
|
||||||
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account
|
||||||
|
AMOUNT = 10 # 1–1 000 000 000 TON
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
|
try:
|
||||||
|
result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT)
|
||||||
|
except WalletError as e:
|
||||||
|
print(f"Wallet error — insufficient balance or misconfiguration: {e}")
|
||||||
|
return
|
||||||
|
except ConfigurationError as e:
|
||||||
|
print(f"Invalid argument: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Example: run a Telegram Premium giveaway for a channel.
|
||||||
|
|
||||||
|
winners must be an integer between 1 and 24 000.
|
||||||
|
months (Premium duration per winner) must be 3, 6, or 12.
|
||||||
|
payment_method can be "ton" or "usdt_ton".
|
||||||
|
Channel can be "@channel", "channel", or "https://t.me/channel".
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
||||||
|
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
|
||||||
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
|
|
||||||
|
# Option B: provide cookies manually
|
||||||
|
COOKIES = {
|
||||||
|
"stel_ssid": "YOUR_STEL_SSID",
|
||||||
|
"stel_dt": "YOUR_STEL_DT",
|
||||||
|
"stel_token": "YOUR_STEL_TOKEN",
|
||||||
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
CHANNEL = "https://t.me/channel"
|
||||||
|
WINNERS = 10 # 1–24 000
|
||||||
|
MONTHS = 3 # 3, 6 or 12
|
||||||
|
PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
|
try:
|
||||||
|
result = await client.giveaway_premium(
|
||||||
|
CHANNEL,
|
||||||
|
winners=WINNERS,
|
||||||
|
months=MONTHS,
|
||||||
|
payment_method=PAYMENT_METHOD,
|
||||||
|
)
|
||||||
|
except UserNotFoundError:
|
||||||
|
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
|
||||||
|
return
|
||||||
|
except ConfigurationError as e:
|
||||||
|
print(f"Invalid argument: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Premium giveaway created for {result.channel} — {result.winners} winner(s) × {result.amount} months each | tx: {result.transaction_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Example: run a Telegram Stars giveaway for a channel.
|
||||||
|
|
||||||
|
winners must be an integer between 1 and 5.
|
||||||
|
amount (stars per winner) must be an integer between 500 and 1 000 000.
|
||||||
|
payment_method can be "ton" or "usdt_ton".
|
||||||
|
Channel can be "@channel", "channel", or "https://t.me/channel".
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
||||||
|
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
|
||||||
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
|
|
||||||
|
# Option B: provide cookies manually
|
||||||
|
COOKIES = {
|
||||||
|
"stel_ssid": "YOUR_STEL_SSID",
|
||||||
|
"stel_dt": "YOUR_STEL_DT",
|
||||||
|
"stel_token": "YOUR_STEL_TOKEN",
|
||||||
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
CHANNEL = "https://t.me/channel"
|
||||||
|
WINNERS = 3 # 1–5
|
||||||
|
AMOUNT = 1000 # 500–1 000 000 stars per winner
|
||||||
|
PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
|
try:
|
||||||
|
result = await client.giveaway_stars(
|
||||||
|
CHANNEL,
|
||||||
|
winners=WINNERS,
|
||||||
|
amount=AMOUNT,
|
||||||
|
payment_method=PAYMENT_METHOD,
|
||||||
|
)
|
||||||
|
except UserNotFoundError:
|
||||||
|
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
|
||||||
|
return
|
||||||
|
except ConfigurationError as e:
|
||||||
|
print(f"Invalid argument: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Stars giveaway created for {result.channel} — {result.winners} winner(s) × {result.amount} stars each | tx: {result.transaction_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Example: purchase Telegram Premium for a user.
|
||||||
|
|
||||||
|
Supported durations: 3, 6, or 12 months.
|
||||||
|
Set show_sender=False to send anonymously.
|
||||||
|
payment_method can be "ton" or "usdt_ton".
|
||||||
|
Username can be "@username", "username", or "https://t.me/username".
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
||||||
|
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
|
||||||
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
|
|
||||||
|
# Option B: provide cookies manually
|
||||||
|
COOKIES = {
|
||||||
|
"stel_ssid": "YOUR_STEL_SSID",
|
||||||
|
"stel_dt": "YOUR_STEL_DT",
|
||||||
|
"stel_token": "YOUR_STEL_TOKEN",
|
||||||
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
USERNAME = "https://t.me/username"
|
||||||
|
MONTHS = 3 # 3, 6 or 12
|
||||||
|
PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
|
try:
|
||||||
|
result = await client.purchase_premium(
|
||||||
|
USERNAME,
|
||||||
|
months=MONTHS,
|
||||||
|
show_sender=True,
|
||||||
|
payment_method=PAYMENT_METHOD,
|
||||||
|
)
|
||||||
|
except UserNotFoundError:
|
||||||
|
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
||||||
|
return
|
||||||
|
except ConfigurationError as e:
|
||||||
|
print(f"Invalid argument: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Example: purchase Telegram Stars for a user.
|
||||||
|
|
||||||
|
Amount must be an integer between 50 and 1 000 000.
|
||||||
|
Set show_sender=False to send anonymously.
|
||||||
|
payment_method can be "ton" or "usdt_ton".
|
||||||
|
Username can be "@username", "username", or "https://t.me/username".
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
||||||
|
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
|
||||||
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
|
|
||||||
|
# Option B: provide cookies manually
|
||||||
|
COOKIES = {
|
||||||
|
"stel_ssid": "YOUR_STEL_SSID",
|
||||||
|
"stel_dt": "YOUR_STEL_DT",
|
||||||
|
"stel_token": "YOUR_STEL_TOKEN",
|
||||||
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
USERNAME = "https://t.me/username"
|
||||||
|
AMOUNT = 500 # 50–1 000 000 stars
|
||||||
|
PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
|
try:
|
||||||
|
result = await client.purchase_stars(
|
||||||
|
USERNAME,
|
||||||
|
amount=AMOUNT,
|
||||||
|
show_sender=True,
|
||||||
|
payment_method=PAYMENT_METHOD,
|
||||||
|
)
|
||||||
|
except UserNotFoundError:
|
||||||
|
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
||||||
|
return
|
||||||
|
except ConfigurationError as e:
|
||||||
|
print(f"Invalid argument: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
Example: top up TON to a recipient's Telegram balance.
|
||||||
|
|
||||||
|
For adding TON to a Telegram Ads account, use recharge_ads() instead.
|
||||||
|
|
||||||
|
Amount must be an integer between 1 and 1 000 000 000 TON.
|
||||||
|
Your wallet must satisfy the current minimum TON threshold and transaction cost.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from pyfragment import (
|
||||||
|
ConfigurationError,
|
||||||
|
FragmentClient,
|
||||||
|
UserNotFoundError,
|
||||||
|
WalletError,
|
||||||
|
)
|
||||||
|
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
|
||||||
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
|
|
||||||
|
# Option B: provide cookies manually
|
||||||
|
COOKIES = {
|
||||||
|
"stel_ssid": "YOUR_STEL_SSID",
|
||||||
|
"stel_dt": "YOUR_STEL_DT",
|
||||||
|
"stel_token": "YOUR_STEL_TOKEN",
|
||||||
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
USERNAME = "@username"
|
||||||
|
AMOUNT = 10 # 1–1 000 000 000 TON
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
|
try:
|
||||||
|
result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True)
|
||||||
|
except UserNotFoundError:
|
||||||
|
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
||||||
|
return
|
||||||
|
except WalletError as e:
|
||||||
|
print(f"Wallet error — insufficient balance or misconfiguration: {e}")
|
||||||
|
return
|
||||||
|
except ConfigurationError as e:
|
||||||
|
print(f"Invalid argument: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Copyright (c) 2026 bohd4nx
|
||||||
|
#
|
||||||
|
# This source code is licensed under the MIT License found in the
|
||||||
|
# LICENSE file in the root directory of this source tree.
|
||||||
|
|
||||||
|
from importlib.metadata import version
|
||||||
|
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
from pyfragment.types import (
|
||||||
|
AdsRechargeResult,
|
||||||
|
AdsTopupResult,
|
||||||
|
AnonymousNumberError,
|
||||||
|
ClientError,
|
||||||
|
ConfigurationError,
|
||||||
|
CookieError,
|
||||||
|
CookieResult,
|
||||||
|
FragmentAPIError,
|
||||||
|
# exceptions
|
||||||
|
FragmentError,
|
||||||
|
FragmentPageError,
|
||||||
|
GiftsResult,
|
||||||
|
LoginCodeResult,
|
||||||
|
NumbersResult,
|
||||||
|
OperationError,
|
||||||
|
ParseError,
|
||||||
|
# literal types
|
||||||
|
PaymentMethod,
|
||||||
|
PremiumGiveawayResult,
|
||||||
|
PremiumResult,
|
||||||
|
StarsGiveawayResult,
|
||||||
|
# results
|
||||||
|
StarsResult,
|
||||||
|
TerminateSessionsResult,
|
||||||
|
TransactionError,
|
||||||
|
UnexpectedError,
|
||||||
|
UsernamesResult,
|
||||||
|
UserNotFoundError,
|
||||||
|
VerificationError,
|
||||||
|
WalletError,
|
||||||
|
WalletInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
__version__: str = version("pyfragment")
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"__version__",
|
||||||
|
"FragmentClient",
|
||||||
|
# results
|
||||||
|
"StarsResult",
|
||||||
|
"StarsGiveawayResult",
|
||||||
|
"PremiumResult",
|
||||||
|
"PremiumGiveawayResult",
|
||||||
|
"WalletInfo",
|
||||||
|
"AdsTopupResult",
|
||||||
|
"AdsRechargeResult",
|
||||||
|
"CookieResult",
|
||||||
|
"GiftsResult",
|
||||||
|
"LoginCodeResult",
|
||||||
|
"NumbersResult",
|
||||||
|
"TerminateSessionsResult",
|
||||||
|
"UsernamesResult",
|
||||||
|
# exceptions
|
||||||
|
"FragmentError",
|
||||||
|
"FragmentAPIError",
|
||||||
|
"FragmentPageError",
|
||||||
|
"ConfigurationError",
|
||||||
|
"UserNotFoundError",
|
||||||
|
"WalletError",
|
||||||
|
"VerificationError",
|
||||||
|
"TransactionError",
|
||||||
|
"AnonymousNumberError",
|
||||||
|
"ClientError",
|
||||||
|
"CookieError",
|
||||||
|
"OperationError",
|
||||||
|
"ParseError",
|
||||||
|
"UnexpectedError",
|
||||||
|
# literal types
|
||||||
|
"PaymentMethod",
|
||||||
|
]
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes
|
||||||
|
from pyfragment.methods.giveaway_premium import giveaway_premium
|
||||||
|
from pyfragment.methods.giveaway_stars import giveaway_stars
|
||||||
|
from pyfragment.methods.purchase_premium import purchase_premium
|
||||||
|
from pyfragment.methods.purchase_stars import purchase_stars
|
||||||
|
from pyfragment.methods.recharge_ads import recharge_ads
|
||||||
|
from pyfragment.methods.search_gifts import search_gifts
|
||||||
|
from pyfragment.methods.search_numbers import search_numbers
|
||||||
|
from pyfragment.methods.search_usernames import search_usernames
|
||||||
|
from pyfragment.methods.topup_ton import topup_ton
|
||||||
|
from pyfragment.types import (
|
||||||
|
AdsRechargeResult,
|
||||||
|
AdsTopupResult,
|
||||||
|
ConfigurationError,
|
||||||
|
CookieError,
|
||||||
|
GiftsResult,
|
||||||
|
LoginCodeResult,
|
||||||
|
NumbersResult,
|
||||||
|
PremiumGiveawayResult,
|
||||||
|
PremiumResult,
|
||||||
|
StarsGiveawayResult,
|
||||||
|
StarsResult,
|
||||||
|
TerminateSessionsResult,
|
||||||
|
UsernamesResult,
|
||||||
|
WalletInfo,
|
||||||
|
)
|
||||||
|
from pyfragment.types.constants import (
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
FRAGMENT_BASE_URL,
|
||||||
|
REQUIRED_COOKIE_KEYS,
|
||||||
|
SUPPORTED_WALLET_VERSIONS,
|
||||||
|
PaymentMethod,
|
||||||
|
WalletVersion,
|
||||||
|
)
|
||||||
|
from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers
|
||||||
|
from pyfragment.utils.wallet import get_wallet_info
|
||||||
|
|
||||||
|
|
||||||
|
class FragmentClient:
|
||||||
|
"""
|
||||||
|
Client for the Fragment.com API.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
This library is not affiliated with, endorsed by, or in any way officially
|
||||||
|
connected with Fragment or Telegram.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
seed: 24-word mnemonic phrase for the TON wallet.
|
||||||
|
api_key: Tonapi API key — get one at https://tonconsole.com.
|
||||||
|
cookies: Fragment session cookies as a dict or JSON string.
|
||||||
|
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
|
||||||
|
timeout: HTTP request timeout in seconds. Defaults to ``30.0``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid.
|
||||||
|
CookieError: If ``cookies`` cannot be parsed or are missing required keys.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
async with FragmentClient(
|
||||||
|
seed="word1 word2 ...",
|
||||||
|
api_key="AAABBB...",
|
||||||
|
cookies={"stel_ssid": "...", "stel_dt": "...", ...},
|
||||||
|
) as client:
|
||||||
|
print(await client.get_wallet())
|
||||||
|
result = await client.purchase_premium("@username", months=6)
|
||||||
|
print(result.transaction_id)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
seed: str,
|
||||||
|
api_key: str,
|
||||||
|
cookies: dict[str, Any] | str,
|
||||||
|
wallet_version: str = "V5R1",
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
) -> None:
|
||||||
|
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
|
||||||
|
if missing:
|
||||||
|
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
|
||||||
|
|
||||||
|
word_count = len(seed.split())
|
||||||
|
if word_count not in (12, 18, 24):
|
||||||
|
raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count))
|
||||||
|
|
||||||
|
if len(api_key.strip()) < 68:
|
||||||
|
raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip())))
|
||||||
|
|
||||||
|
if isinstance(cookies, str):
|
||||||
|
try:
|
||||||
|
cookies = json.loads(cookies)
|
||||||
|
except Exception as exc:
|
||||||
|
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
|
||||||
|
|
||||||
|
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict[str, Any], cookies).get(k, "")).strip()]
|
||||||
|
if missing_keys:
|
||||||
|
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
|
||||||
|
|
||||||
|
version = wallet_version.strip().upper()
|
||||||
|
if version not in SUPPORTED_WALLET_VERSIONS:
|
||||||
|
raise ConfigurationError(
|
||||||
|
ConfigurationError.UNSUPPORTED_VERSION.format(
|
||||||
|
version=version, supported=", ".join(sorted(SUPPORTED_WALLET_VERSIONS))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.seed: str = seed.strip()
|
||||||
|
self.api_key: str = api_key.strip()
|
||||||
|
self.cookies: dict[str, Any] = cast(dict[str, Any], cookies)
|
||||||
|
self.wallet_version: WalletVersion = version # type: ignore[assignment]
|
||||||
|
self.timeout: float = timeout
|
||||||
|
|
||||||
|
async def __aenter__(self) -> FragmentClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"FragmentClient(wallet_version='{self.wallet_version}', cookies={len(self.cookies)} keys)"
|
||||||
|
|
||||||
|
async def purchase_premium(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
months: int,
|
||||||
|
show_sender: bool = True,
|
||||||
|
payment_method: PaymentMethod = "ton",
|
||||||
|
) -> PremiumResult:
|
||||||
|
"""Gift Telegram Premium to a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
|
||||||
|
months: Duration — ``3``, ``6``, or ``12``.
|
||||||
|
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
|
"""
|
||||||
|
return await purchase_premium(self, username, months, show_sender, payment_method)
|
||||||
|
|
||||||
|
async def purchase_stars(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
amount: int,
|
||||||
|
show_sender: bool = True,
|
||||||
|
payment_method: PaymentMethod = "ton",
|
||||||
|
) -> StarsResult:
|
||||||
|
"""Send Telegram Stars to a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
|
||||||
|
amount: Number of stars — integer from ``50`` to ``1 000 000``.
|
||||||
|
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
|
"""
|
||||||
|
return await purchase_stars(self, username, amount, show_sender, payment_method)
|
||||||
|
|
||||||
|
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||||
|
"""Top up TON to a recipient's Telegram balance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Recipient's Telegram username (with or without ``@``).
|
||||||
|
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||||
|
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
|
"""
|
||||||
|
return await topup_ton(self, username, amount, show_sender)
|
||||||
|
|
||||||
|
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
|
||||||
|
"""Add funds to your own Telegram Ads account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account: Your Fragment Ads account identifier — the channel or bot username
|
||||||
|
the Ads account is linked to (e.g. ``"@mychannel"``).
|
||||||
|
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
|
||||||
|
"""
|
||||||
|
return await recharge_ads(self, account, amount)
|
||||||
|
|
||||||
|
async def get_wallet(self) -> WalletInfo:
|
||||||
|
"""Return the address, state, and balances of the wallet.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
|
||||||
|
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``),
|
||||||
|
``ton_balance`` in TON, and ``usdt_balance`` in USDT.
|
||||||
|
"""
|
||||||
|
return await get_wallet_info(self)
|
||||||
|
|
||||||
|
async def giveaway_stars(
|
||||||
|
self,
|
||||||
|
channel: str,
|
||||||
|
winners: int,
|
||||||
|
amount: int,
|
||||||
|
payment_method: PaymentMethod = "ton",
|
||||||
|
) -> StarsGiveawayResult:
|
||||||
|
"""Run a Telegram Stars giveaway for a channel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
|
||||||
|
winners: Number of winners — integer from ``1`` to ``5``.
|
||||||
|
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
|
||||||
|
``winners``, and ``amount``.
|
||||||
|
"""
|
||||||
|
return await giveaway_stars(self, channel, winners, amount, payment_method)
|
||||||
|
|
||||||
|
async def giveaway_premium(
|
||||||
|
self,
|
||||||
|
channel: str,
|
||||||
|
winners: int,
|
||||||
|
months: int = 3,
|
||||||
|
payment_method: PaymentMethod = "ton",
|
||||||
|
) -> PremiumGiveawayResult:
|
||||||
|
"""Run a Telegram Premium giveaway for a channel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
|
||||||
|
winners: Number of winners — positive integer.
|
||||||
|
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
|
||||||
|
``winners``, and ``amount``.
|
||||||
|
"""
|
||||||
|
return await giveaway_premium(self, channel, winners, months, payment_method)
|
||||||
|
|
||||||
|
async def get_login_code(self, number: str) -> LoginCodeResult:
|
||||||
|
"""Fetch the current pending login code for an anonymous number.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
|
||||||
|
and ``active_sessions`` count.
|
||||||
|
"""
|
||||||
|
return await get_login_code(self, number)
|
||||||
|
|
||||||
|
async def toggle_login_codes(self, number: str, can_receive: bool) -> None:
|
||||||
|
"""Enable or disable login code delivery for an anonymous number.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
number: Phone number with or without leading ``+``.
|
||||||
|
can_receive: ``True`` to allow receiving codes, ``False`` to block them.
|
||||||
|
"""
|
||||||
|
return await toggle_login_codes(self, number, can_receive)
|
||||||
|
|
||||||
|
async def terminate_sessions(self, number: str) -> TerminateSessionsResult:
|
||||||
|
"""Terminate all active Telegram sessions for an anonymous number.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
number: Phone number with or without leading ``+``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`TerminateSessionsResult` with ``number`` and ``message``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AnonymousNumberError: If the number is not owned by this account or has no active sessions.
|
||||||
|
"""
|
||||||
|
return await terminate_sessions(self, number)
|
||||||
|
|
||||||
|
async def search_usernames(
|
||||||
|
self,
|
||||||
|
query: str = "",
|
||||||
|
sort: str | None = None,
|
||||||
|
filter: str | None = None,
|
||||||
|
offset_id: str | None = None,
|
||||||
|
) -> UsernamesResult:
|
||||||
|
"""Search the Fragment marketplace for Telegram usernames.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
|
||||||
|
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||||
|
``"ending"``. Omit to use Fragment's default ordering.
|
||||||
|
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
|
||||||
|
``""`` (available items). Omit to return all.
|
||||||
|
offset_id: Pagination cursor — pass :attr:`UsernamesResult.next_offset_id`
|
||||||
|
from a previous result to fetch the next page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`UsernamesResult` with ``items`` (parsed list of item dicts)
|
||||||
|
and ``next_offset_id`` (``None`` on the last page).
|
||||||
|
"""
|
||||||
|
return await search_usernames(self, query, sort=sort, filter=filter, offset_id=offset_id)
|
||||||
|
|
||||||
|
async def search_numbers(
|
||||||
|
self,
|
||||||
|
query: str = "",
|
||||||
|
sort: str | None = None,
|
||||||
|
filter: str | None = None,
|
||||||
|
offset_id: str | None = None,
|
||||||
|
) -> NumbersResult:
|
||||||
|
"""Search the Fragment marketplace for anonymous Telegram numbers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all.
|
||||||
|
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||||
|
``"ending"``. Omit to use Fragment's default ordering.
|
||||||
|
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
|
||||||
|
``""`` (available items). Omit to return all.
|
||||||
|
offset_id: Pagination cursor — pass :attr:`NumbersResult.next_offset_id`
|
||||||
|
from a previous result to fetch the next page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`NumbersResult` with ``items`` (parsed list of item dicts)
|
||||||
|
and ``next_offset_id`` (``None`` on the last page).
|
||||||
|
"""
|
||||||
|
return await search_numbers(self, query, sort=sort, filter=filter, offset_id=offset_id)
|
||||||
|
|
||||||
|
async def search_gifts(
|
||||||
|
self,
|
||||||
|
query: str = "",
|
||||||
|
collection: str | None = None,
|
||||||
|
sort: str | None = None,
|
||||||
|
filter: str | None = None,
|
||||||
|
view: str | None = None,
|
||||||
|
attr: dict[str, list[str]] | None = None,
|
||||||
|
offset: int | None = None,
|
||||||
|
) -> GiftsResult:
|
||||||
|
"""Search the Fragment gifts marketplace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search text. Omit or pass ``""`` to browse without filtering by name.
|
||||||
|
collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all.
|
||||||
|
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||||
|
``"ending"``. Omit to use Fragment's default ordering.
|
||||||
|
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
|
||||||
|
``""`` (available items). Omit to return all.
|
||||||
|
view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default.
|
||||||
|
attr: Attribute filters — mapping of trait name to accepted values, e.g.
|
||||||
|
``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``.
|
||||||
|
Each key is sent as ``attr[Key]`` with its list of values.
|
||||||
|
offset: Integer page offset from a previous :class:`GiftsResult`.
|
||||||
|
Pass ``next_offset`` to fetch the next page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`GiftsResult` with ``items`` (parsed list of item dicts)
|
||||||
|
and ``next_offset`` (``None`` on the last page).
|
||||||
|
"""
|
||||||
|
return await search_gifts(
|
||||||
|
self, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
|
||||||
|
)
|
||||||
|
|
||||||
|
async def call(
|
||||||
|
self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Send a raw request to the Fragment API.
|
||||||
|
|
||||||
|
Useful for accessing undocumented or future Fragment API methods
|
||||||
|
without waiting for a library update.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``.
|
||||||
|
data: Additional form-data fields to include in the request body.
|
||||||
|
page_url: Fragment page URL used to derive the API hash and headers.
|
||||||
|
Defaults to ``FRAGMENT_BASE_URL`` (``"https://fragment.com"``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Raw parsed JSON response as a dict.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
result = await client.call(
|
||||||
|
"searchPremiumGiftRecipient",
|
||||||
|
{"query": "@username", "months": 3},
|
||||||
|
page_url="https://fragment.com/premium/gift",
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
headers = make_headers(page_url)
|
||||||
|
async with httpx.AsyncClient(cookies=self.cookies, timeout=self.timeout) as session:
|
||||||
|
fragment_hash = await get_fragment_hash(self.cookies, headers, page_url, self.timeout)
|
||||||
|
return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes
|
||||||
|
from pyfragment.methods.giveaway_premium import giveaway_premium
|
||||||
|
from pyfragment.methods.giveaway_stars import giveaway_stars
|
||||||
|
from pyfragment.methods.purchase_premium import purchase_premium
|
||||||
|
from pyfragment.methods.purchase_stars import purchase_stars
|
||||||
|
from pyfragment.methods.recharge_ads import recharge_ads
|
||||||
|
from pyfragment.methods.search_gifts import search_gifts
|
||||||
|
from pyfragment.methods.search_numbers import search_numbers
|
||||||
|
from pyfragment.methods.search_usernames import search_usernames
|
||||||
|
from pyfragment.methods.topup_ton import topup_ton
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_login_code",
|
||||||
|
"giveaway_premium",
|
||||||
|
"giveaway_stars",
|
||||||
|
"purchase_premium",
|
||||||
|
"purchase_stars",
|
||||||
|
"recharge_ads",
|
||||||
|
"search_gifts",
|
||||||
|
"search_numbers",
|
||||||
|
"search_usernames",
|
||||||
|
"terminate_sessions",
|
||||||
|
"toggle_login_codes",
|
||||||
|
"topup_ton",
|
||||||
|
]
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pyfragment.types import (
|
||||||
|
AnonymousNumberError,
|
||||||
|
FragmentAPIError,
|
||||||
|
FragmentError,
|
||||||
|
LoginCodeResult,
|
||||||
|
TerminateSessionsResult,
|
||||||
|
UnexpectedError,
|
||||||
|
)
|
||||||
|
from pyfragment.types.constants import NUMBERS_PAGE
|
||||||
|
from pyfragment.utils import parse_login_code
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_plus(number: str) -> str:
|
||||||
|
return number.lstrip("+") if isinstance(number, str) else number
|
||||||
|
|
||||||
|
|
||||||
|
async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult:
|
||||||
|
"""Fetch the current pending login code for an anonymous number.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if no pending code),
|
||||||
|
and ``active_sessions`` count.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
|
UnexpectedError: For any other unexpected failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
clean = _strip_plus(number)
|
||||||
|
result = await client.call(
|
||||||
|
"updateLoginCodes",
|
||||||
|
{"number": clean, "lt": "0", "from_app": "1"},
|
||||||
|
page_url=NUMBERS_PAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("html"):
|
||||||
|
code, active_sessions = parse_login_code(result["html"])
|
||||||
|
else:
|
||||||
|
code, active_sessions = None, 0
|
||||||
|
|
||||||
|
return LoginCodeResult(number=number, code=code, active_sessions=active_sessions)
|
||||||
|
|
||||||
|
except FragmentError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def toggle_login_codes(client: FragmentClient, number: str, can_receive: bool) -> None:
|
||||||
|
"""Enable or disable login code delivery for an anonymous number.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
number: Phone number with or without leading ``+``.
|
||||||
|
can_receive: ``True`` to allow receiving codes, ``False`` to block them.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
|
UnexpectedError: For any other unexpected failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
clean = _strip_plus(number)
|
||||||
|
result = await client.call(
|
||||||
|
"toggleLoginCodes",
|
||||||
|
{"number": clean, "can_receive": 1 if can_receive else 0},
|
||||||
|
page_url=NUMBERS_PAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
raise FragmentAPIError(html.unescape(result["error"]))
|
||||||
|
|
||||||
|
except FragmentError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSessionsResult:
|
||||||
|
"""Terminate all active Telegram sessions for an anonymous number.
|
||||||
|
|
||||||
|
This is a two-step operation: Fragment first returns a confirmation hash,
|
||||||
|
which is then submitted to confirm the termination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
number: Phone number with or without leading ``+``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`TerminateSessionsResult` with ``number`` and ``message``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AnonymousNumberError: If the number is not owned by this account or has no active sessions,
|
||||||
|
or if Fragment returns an error during termination.
|
||||||
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
|
UnexpectedError: For any other unexpected failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
clean = _strip_plus(number)
|
||||||
|
|
||||||
|
confirmation = await client.call(
|
||||||
|
"terminatePhoneSessions",
|
||||||
|
{"number": clean},
|
||||||
|
page_url=NUMBERS_PAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
if confirmation.get("error"):
|
||||||
|
raise AnonymousNumberError(
|
||||||
|
AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(confirmation["error"]))
|
||||||
|
)
|
||||||
|
|
||||||
|
terminate_hash = confirmation.get("terminate_hash")
|
||||||
|
if not terminate_hash:
|
||||||
|
raise AnonymousNumberError(AnonymousNumberError.NOT_OWNED.format(number=number))
|
||||||
|
|
||||||
|
result = await client.call(
|
||||||
|
"terminatePhoneSessions",
|
||||||
|
{"number": clean, "terminate_hash": terminate_hash},
|
||||||
|
page_url=NUMBERS_PAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
raise AnonymousNumberError(
|
||||||
|
AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(result["error"]))
|
||||||
|
)
|
||||||
|
|
||||||
|
return TerminateSessionsResult(number=number, message=result.get("msg"))
|
||||||
|
|
||||||
|
except FragmentError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pyfragment.types import (
|
||||||
|
ConfigurationError,
|
||||||
|
FragmentAPIError,
|
||||||
|
FragmentError,
|
||||||
|
PremiumGiveawayResult,
|
||||||
|
UnexpectedError,
|
||||||
|
UserNotFoundError,
|
||||||
|
VerificationError,
|
||||||
|
)
|
||||||
|
from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
|
||||||
|
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
async def giveaway_premium(
|
||||||
|
client: FragmentClient,
|
||||||
|
channel: str,
|
||||||
|
winners: int,
|
||||||
|
months: int = 3,
|
||||||
|
payment_method: PaymentMethod = "ton",
|
||||||
|
) -> PremiumGiveawayResult:
|
||||||
|
"""Run a Telegram Premium giveaway for a channel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
|
||||||
|
winners: Number of winners — integer from ``1`` to ``24 000``.
|
||||||
|
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
|
||||||
|
``winners``, and ``amount``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If ``winners`` is not 1–24 000 or ``months`` is not 3, 6, or 12.
|
||||||
|
UserNotFoundError: If the channel is not found on Fragment.
|
||||||
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
|
UnexpectedError: For any other unexpected failure.
|
||||||
|
"""
|
||||||
|
if not isinstance(winners, int) or not (1 <= winners <= 24_000):
|
||||||
|
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
|
||||||
|
if months not in (3, 6, 12):
|
||||||
|
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
||||||
|
if payment_method not in SUPPORTED_PAYMENT_METHODS:
|
||||||
|
raise ConfigurationError(
|
||||||
|
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||||
|
method=payment_method,
|
||||||
|
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await client.call(
|
||||||
|
"searchPremiumGiveawayRecipient",
|
||||||
|
{"query": channel, "quantity": winners, "months": months},
|
||||||
|
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||||
|
)
|
||||||
|
recipient = result.get("found", {}).get("recipient")
|
||||||
|
if not recipient:
|
||||||
|
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
|
||||||
|
|
||||||
|
result = await client.call(
|
||||||
|
"initGiveawayPremiumRequest",
|
||||||
|
{
|
||||||
|
"recipient": recipient,
|
||||||
|
"quantity": str(winners),
|
||||||
|
"months": str(months),
|
||||||
|
"payment_method": payment_method,
|
||||||
|
},
|
||||||
|
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||||
|
)
|
||||||
|
required_payment_amount = parse_required_payment_amount(result)
|
||||||
|
req_id = result.get("req_id")
|
||||||
|
if not req_id:
|
||||||
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway"))
|
||||||
|
|
||||||
|
account = await get_account_info(client)
|
||||||
|
transaction = await client.call(
|
||||||
|
"getGiveawayPremiumLink",
|
||||||
|
{
|
||||||
|
"account": json.dumps(account),
|
||||||
|
"device": DEVICE,
|
||||||
|
"transaction": 1,
|
||||||
|
"id": req_id,
|
||||||
|
},
|
||||||
|
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||||
|
)
|
||||||
|
if transaction.get("need_verify"):
|
||||||
|
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||||
|
|
||||||
|
tx_hash = await process_transaction(
|
||||||
|
client,
|
||||||
|
transaction,
|
||||||
|
payment_method=payment_method,
|
||||||
|
required_payment_amount=required_payment_amount,
|
||||||
|
)
|
||||||
|
return PremiumGiveawayResult(
|
||||||
|
transaction_id=tx_hash,
|
||||||
|
channel=channel,
|
||||||
|
winners=winners,
|
||||||
|
amount=months,
|
||||||
|
)
|
||||||
|
|
||||||
|
except FragmentError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pyfragment.types import (
|
||||||
|
ConfigurationError,
|
||||||
|
FragmentAPIError,
|
||||||
|
FragmentError,
|
||||||
|
StarsGiveawayResult,
|
||||||
|
UnexpectedError,
|
||||||
|
UserNotFoundError,
|
||||||
|
VerificationError,
|
||||||
|
)
|
||||||
|
from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
|
||||||
|
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
async def giveaway_stars(
|
||||||
|
client: FragmentClient,
|
||||||
|
channel: str,
|
||||||
|
winners: int,
|
||||||
|
amount: int,
|
||||||
|
payment_method: PaymentMethod = "ton",
|
||||||
|
) -> StarsGiveawayResult:
|
||||||
|
"""Run a Telegram Stars giveaway for a channel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
|
||||||
|
winners: Number of winners — integer from ``1`` to ``5``.
|
||||||
|
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
|
||||||
|
``winners``, and ``amount``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If ``winners`` is not 1–5 or ``amount`` is not 500–1 000 000.
|
||||||
|
UserNotFoundError: If the channel is not found on Fragment.
|
||||||
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
|
UnexpectedError: For any other unexpected failure.
|
||||||
|
"""
|
||||||
|
if not isinstance(winners, int) or not (1 <= winners <= 5):
|
||||||
|
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
|
||||||
|
if not isinstance(amount, int) or not (500 <= amount <= 1_000_000):
|
||||||
|
raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
|
||||||
|
if payment_method not in SUPPORTED_PAYMENT_METHODS:
|
||||||
|
raise ConfigurationError(
|
||||||
|
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||||
|
method=payment_method,
|
||||||
|
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE)
|
||||||
|
recipient = result.get("found", {}).get("recipient")
|
||||||
|
if not recipient:
|
||||||
|
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
|
||||||
|
|
||||||
|
result = await client.call(
|
||||||
|
"initGiveawayStarsRequest",
|
||||||
|
{
|
||||||
|
"recipient": recipient,
|
||||||
|
"quantity": str(winners),
|
||||||
|
"stars": str(amount),
|
||||||
|
"payment_method": payment_method,
|
||||||
|
},
|
||||||
|
page_url=STARS_GIVEAWAY_PAGE,
|
||||||
|
)
|
||||||
|
required_payment_amount = parse_required_payment_amount(result)
|
||||||
|
req_id = result.get("req_id")
|
||||||
|
if not req_id:
|
||||||
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway"))
|
||||||
|
|
||||||
|
account = await get_account_info(client)
|
||||||
|
transaction = await client.call(
|
||||||
|
"getGiveawayStarsLink",
|
||||||
|
{
|
||||||
|
"account": json.dumps(account),
|
||||||
|
"device": DEVICE,
|
||||||
|
"transaction": 1,
|
||||||
|
"id": req_id,
|
||||||
|
},
|
||||||
|
page_url=STARS_GIVEAWAY_PAGE,
|
||||||
|
)
|
||||||
|
if transaction.get("need_verify"):
|
||||||
|
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||||
|
|
||||||
|
tx_hash = await process_transaction(
|
||||||
|
client,
|
||||||
|
transaction,
|
||||||
|
payment_method=payment_method,
|
||||||
|
required_payment_amount=required_payment_amount,
|
||||||
|
)
|
||||||
|
return StarsGiveawayResult(
|
||||||
|
transaction_id=tx_hash,
|
||||||
|
channel=channel,
|
||||||
|
winners=winners,
|
||||||
|
amount=amount,
|
||||||
|
)
|
||||||
|
|
||||||
|
except FragmentError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pyfragment.types import (
|
||||||
|
ConfigurationError,
|
||||||
|
FragmentAPIError,
|
||||||
|
FragmentError,
|
||||||
|
PremiumResult,
|
||||||
|
UnexpectedError,
|
||||||
|
UserNotFoundError,
|
||||||
|
VerificationError,
|
||||||
|
)
|
||||||
|
from pyfragment.types.constants import DEVICE, PREMIUM_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
|
||||||
|
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
|
||||||
|
months: Premium duration — ``3``, ``6``, or ``12``.
|
||||||
|
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If ``months`` is not ``3``, ``6``, or ``12``.
|
||||||
|
UserNotFoundError: If the user is not found on Fragment.
|
||||||
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
|
UnexpectedError: For any other unexpected failure.
|
||||||
|
"""
|
||||||
|
if months not in (3, 6, 12):
|
||||||
|
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
||||||
|
if payment_method not in SUPPORTED_PAYMENT_METHODS:
|
||||||
|
raise ConfigurationError(
|
||||||
|
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||||
|
method=payment_method,
|
||||||
|
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE)
|
||||||
|
recipient = result.get("found", {}).get("recipient")
|
||||||
|
if not recipient:
|
||||||
|
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||||
|
|
||||||
|
await client.call(
|
||||||
|
"updatePremiumState",
|
||||||
|
{"mode": "new", "lv": "false", "dh": str(int(time.time()))},
|
||||||
|
page_url=PREMIUM_PAGE,
|
||||||
|
)
|
||||||
|
result = await client.call(
|
||||||
|
"initGiftPremiumRequest",
|
||||||
|
{"recipient": recipient, "months": months, "payment_method": payment_method},
|
||||||
|
page_url=PREMIUM_PAGE,
|
||||||
|
)
|
||||||
|
required_payment_amount = parse_required_payment_amount(result)
|
||||||
|
req_id = result.get("req_id")
|
||||||
|
if not req_id:
|
||||||
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
|
||||||
|
|
||||||
|
account = await get_account_info(client)
|
||||||
|
transaction = await client.call(
|
||||||
|
"getGiftPremiumLink",
|
||||||
|
{
|
||||||
|
"account": json.dumps(account),
|
||||||
|
"device": DEVICE,
|
||||||
|
"transaction": 1,
|
||||||
|
"id": req_id,
|
||||||
|
"show_sender": int(show_sender),
|
||||||
|
},
|
||||||
|
page_url=PREMIUM_PAGE,
|
||||||
|
)
|
||||||
|
if transaction.get("need_verify"):
|
||||||
|
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||||
|
|
||||||
|
tx_hash = await process_transaction(
|
||||||
|
client,
|
||||||
|
transaction,
|
||||||
|
payment_method=payment_method,
|
||||||
|
required_payment_amount=required_payment_amount,
|
||||||
|
)
|
||||||
|
return PremiumResult(transaction_id=tx_hash, username=username, amount=months)
|
||||||
|
|
||||||
|
except FragmentError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pyfragment.types import (
|
||||||
|
ConfigurationError,
|
||||||
|
FragmentAPIError,
|
||||||
|
FragmentError,
|
||||||
|
StarsResult,
|
||||||
|
UnexpectedError,
|
||||||
|
UserNotFoundError,
|
||||||
|
VerificationError,
|
||||||
|
)
|
||||||
|
from pyfragment.types.constants import DEVICE, STARS_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
|
||||||
|
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
async def purchase_stars(
|
||||||
|
client: FragmentClient, username: str, amount: int, show_sender: bool = True, payment_method: PaymentMethod = "ton"
|
||||||
|
) -> StarsResult:
|
||||||
|
"""Send Telegram Stars to a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
|
||||||
|
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``.
|
||||||
|
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If ``amount`` is not an integer between 50 and 1 000 000.
|
||||||
|
UserNotFoundError: If the user is not found on Fragment.
|
||||||
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
|
UnexpectedError: For any other unexpected failure.
|
||||||
|
"""
|
||||||
|
if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
|
||||||
|
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
|
||||||
|
if payment_method not in SUPPORTED_PAYMENT_METHODS:
|
||||||
|
raise ConfigurationError(
|
||||||
|
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||||
|
method=payment_method,
|
||||||
|
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE)
|
||||||
|
recipient = result.get("found", {}).get("recipient")
|
||||||
|
if not recipient:
|
||||||
|
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||||
|
|
||||||
|
await client.call(
|
||||||
|
"updateStarsBuyState",
|
||||||
|
{"mode": "new", "lv": "false", "dh": str(int(time.time()))},
|
||||||
|
page_url=STARS_PAGE,
|
||||||
|
)
|
||||||
|
result = await client.call(
|
||||||
|
"initBuyStarsRequest",
|
||||||
|
{"recipient": recipient, "quantity": amount, "payment_method": payment_method},
|
||||||
|
page_url=STARS_PAGE,
|
||||||
|
)
|
||||||
|
required_payment_amount = parse_required_payment_amount(result)
|
||||||
|
req_id = result.get("req_id")
|
||||||
|
if not req_id:
|
||||||
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
|
||||||
|
|
||||||
|
account = await get_account_info(client)
|
||||||
|
transaction = await client.call(
|
||||||
|
"getBuyStarsLink",
|
||||||
|
{
|
||||||
|
"account": json.dumps(account),
|
||||||
|
"device": DEVICE,
|
||||||
|
"transaction": 1,
|
||||||
|
"id": req_id,
|
||||||
|
"show_sender": int(show_sender),
|
||||||
|
},
|
||||||
|
page_url=STARS_PAGE,
|
||||||
|
)
|
||||||
|
if transaction.get("need_verify"):
|
||||||
|
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||||
|
|
||||||
|
tx_hash = await process_transaction(
|
||||||
|
client,
|
||||||
|
transaction,
|
||||||
|
payment_method=payment_method,
|
||||||
|
required_payment_amount=required_payment_amount,
|
||||||
|
)
|
||||||
|
return StarsResult(transaction_id=tx_hash, username=username, amount=amount)
|
||||||
|
|
||||||
|
except FragmentError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pyfragment.types import (
|
||||||
|
AdsRechargeResult,
|
||||||
|
ConfigurationError,
|
||||||
|
FragmentAPIError,
|
||||||
|
FragmentError,
|
||||||
|
UnexpectedError,
|
||||||
|
VerificationError,
|
||||||
|
)
|
||||||
|
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
|
||||||
|
from pyfragment.utils import get_account_info, process_transaction
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
|
||||||
|
"""Add funds to your own Telegram Ads account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
account: Your Fragment Ads account identifier — the channel or bot username
|
||||||
|
the Ads account is linked to (e.g. ``"@mychannel"``).
|
||||||
|
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If ``amount`` is not a valid integer in the allowed range.
|
||||||
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
|
UnexpectedError: For any other unexpected failure.
|
||||||
|
"""
|
||||||
|
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
|
||||||
|
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.call("updateAdsState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
|
||||||
|
|
||||||
|
result = await client.call("initAdsRechargeRequest", {"account": account, "amount": amount}, page_url=ADS_TOPUP_PAGE)
|
||||||
|
req_id = result.get("req_id")
|
||||||
|
if not req_id:
|
||||||
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Ads recharge"))
|
||||||
|
|
||||||
|
account_info = await get_account_info(client)
|
||||||
|
transaction = await client.call(
|
||||||
|
"getAdsRechargeLink",
|
||||||
|
{
|
||||||
|
"account": json.dumps(account_info),
|
||||||
|
"device": DEVICE,
|
||||||
|
"transaction": 1,
|
||||||
|
"id": req_id,
|
||||||
|
},
|
||||||
|
page_url=ADS_TOPUP_PAGE,
|
||||||
|
)
|
||||||
|
if transaction.get("need_verify"):
|
||||||
|
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||||
|
|
||||||
|
tx_hash = await process_transaction(client, transaction)
|
||||||
|
return AdsRechargeResult(transaction_id=tx_hash, amount=amount)
|
||||||
|
|
||||||
|
except FragmentError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from pyfragment.types import FragmentAPIError, FragmentError, GiftsResult, UnexpectedError
|
||||||
|
from pyfragment.types.constants import GIFTS_PAGE
|
||||||
|
from pyfragment.utils import parse_gift_items
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
async def search_gifts(
|
||||||
|
client: FragmentClient,
|
||||||
|
query: str = "",
|
||||||
|
collection: str | None = None,
|
||||||
|
sort: str | None = None,
|
||||||
|
filter: str | None = None,
|
||||||
|
view: str | None = None,
|
||||||
|
attr: dict[str, list[str]] | None = None,
|
||||||
|
offset: int | None = None,
|
||||||
|
) -> GiftsResult:
|
||||||
|
"""Search the Fragment gifts marketplace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
query: Search text. Omit or pass ``""`` to browse without filtering by name.
|
||||||
|
collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all.
|
||||||
|
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||||
|
``"ending"``. Omit to use Fragment's default ordering.
|
||||||
|
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""``
|
||||||
|
(available items). Omit to return all.
|
||||||
|
view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default.
|
||||||
|
attr: Attribute filters as a mapping of trait name to list of accepted values, e.g.
|
||||||
|
``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``.
|
||||||
|
Each key is sent as ``attr[Key]`` with its list of values.
|
||||||
|
offset: Integer page offset from a previous :class:`GiftsResult`.
|
||||||
|
Pass ``next_offset`` to fetch the next page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`GiftsResult` with ``items`` (parsed list of item dicts) and
|
||||||
|
``next_offset`` (``None`` on the last page).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
|
UnexpectedError: For any other unexpected failure.
|
||||||
|
"""
|
||||||
|
data: dict[str, Any] = {"type": "gifts", "query": query}
|
||||||
|
if collection is not None:
|
||||||
|
data["collection"] = collection
|
||||||
|
if sort is not None:
|
||||||
|
data["sort"] = sort
|
||||||
|
if filter is not None:
|
||||||
|
data["filter"] = filter
|
||||||
|
if view is not None:
|
||||||
|
data["view"] = view
|
||||||
|
if attr is not None:
|
||||||
|
for trait, values in attr.items():
|
||||||
|
data[f"attr[{trait}]"] = values
|
||||||
|
if offset is not None:
|
||||||
|
data["offset"] = offset
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await client.call("searchAuctions", data, page_url=GIFTS_PAGE)
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
raise FragmentAPIError(result["error"])
|
||||||
|
|
||||||
|
items, next_offset = parse_gift_items(result.get("html") or "")
|
||||||
|
return GiftsResult(items=items, next_offset=next_offset)
|
||||||
|
|
||||||
|
except FragmentError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from pyfragment.types import FragmentAPIError, FragmentError, NumbersResult, UnexpectedError
|
||||||
|
from pyfragment.types.constants import NUMBERS_PAGE
|
||||||
|
from pyfragment.utils import parse_auction_rows
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
async def search_numbers(
|
||||||
|
client: FragmentClient,
|
||||||
|
query: str = "",
|
||||||
|
sort: str | None = None,
|
||||||
|
filter: str | None = None,
|
||||||
|
offset_id: str | None = None,
|
||||||
|
) -> NumbersResult:
|
||||||
|
"""Search the Fragment marketplace for anonymous Telegram numbers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all.
|
||||||
|
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||||
|
``"ending"``. Omit to use Fragment's default ordering.
|
||||||
|
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""``
|
||||||
|
(available items). Omit to return all.
|
||||||
|
offset_id: Pagination cursor from a previous :class:`NumbersResult`.
|
||||||
|
Pass ``next_offset_id`` to fetch the next page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`NumbersResult` with ``items`` (parsed list of item dicts) and
|
||||||
|
``next_offset_id`` (``None`` when there are no more pages).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
|
UnexpectedError: For any other unexpected failure.
|
||||||
|
"""
|
||||||
|
data: dict[str, Any] = {"type": "numbers", "query": query}
|
||||||
|
if sort is not None:
|
||||||
|
data["sort"] = sort
|
||||||
|
if filter is not None:
|
||||||
|
data["filter"] = filter
|
||||||
|
if offset_id is not None:
|
||||||
|
data["offset_id"] = offset_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await client.call("searchAuctions", data, page_url=NUMBERS_PAGE)
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
raise FragmentAPIError(result["error"])
|
||||||
|
|
||||||
|
items = parse_auction_rows(result.get("html") or "")
|
||||||
|
raw_noi = result.get("next_offset_id")
|
||||||
|
next_offset_id = str(raw_noi) if raw_noi else None
|
||||||
|
return NumbersResult(items=items, next_offset_id=next_offset_id)
|
||||||
|
|
||||||
|
except FragmentError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError, UsernamesResult
|
||||||
|
from pyfragment.types.constants import FRAGMENT_BASE_URL
|
||||||
|
from pyfragment.utils import parse_auction_rows
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
async def search_usernames(
|
||||||
|
client: FragmentClient,
|
||||||
|
query: str = "",
|
||||||
|
sort: str | None = None,
|
||||||
|
filter: str | None = None,
|
||||||
|
offset_id: str | None = None,
|
||||||
|
) -> UsernamesResult:
|
||||||
|
"""Search the Fragment marketplace for Telegram usernames.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
|
||||||
|
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||||
|
``"ending"``. Omit to use Fragment's default ordering.
|
||||||
|
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""``
|
||||||
|
(available items). Omit to return all.
|
||||||
|
offset_id: Pagination cursor from a previous :class:`UsernamesResult`.
|
||||||
|
Pass ``next_offset_id`` to fetch the next page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`UsernamesResult` with ``items`` (parsed list of item dicts) and
|
||||||
|
``next_offset_id`` (``None`` when there are no more pages).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
|
UnexpectedError: For any other unexpected failure.
|
||||||
|
"""
|
||||||
|
data: dict[str, Any] = {"type": "usernames", "query": query}
|
||||||
|
if sort is not None:
|
||||||
|
data["sort"] = sort
|
||||||
|
if filter is not None:
|
||||||
|
data["filter"] = filter
|
||||||
|
if offset_id is not None:
|
||||||
|
data["offset_id"] = offset_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await client.call("searchAuctions", data, page_url=FRAGMENT_BASE_URL)
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
raise FragmentAPIError(result["error"])
|
||||||
|
|
||||||
|
items = parse_auction_rows(result.get("html") or "")
|
||||||
|
raw_noi = result.get("next_offset_id")
|
||||||
|
next_offset_id = str(raw_noi) if raw_noi else None
|
||||||
|
return UsernamesResult(items=items, next_offset_id=next_offset_id)
|
||||||
|
|
||||||
|
except FragmentError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pyfragment.types import (
|
||||||
|
AdsTopupResult,
|
||||||
|
ConfigurationError,
|
||||||
|
FragmentAPIError,
|
||||||
|
FragmentError,
|
||||||
|
UnexpectedError,
|
||||||
|
UserNotFoundError,
|
||||||
|
VerificationError,
|
||||||
|
)
|
||||||
|
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
|
||||||
|
from pyfragment.utils import get_account_info, process_transaction
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||||
|
"""Top up TON to a recipient's Telegram balance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Authenticated :class:`FragmentClient` instance.
|
||||||
|
username: Recipient's Telegram username (with or without ``@``).
|
||||||
|
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||||
|
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If ``amount`` is not an integer between 1 and 1 000 000 000.
|
||||||
|
UserNotFoundError: If the recipient is not found on Telegram.
|
||||||
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
|
UnexpectedError: For any other unexpected failure.
|
||||||
|
"""
|
||||||
|
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
|
||||||
|
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.call("updateAdsTopupState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
|
||||||
|
|
||||||
|
result = await client.call("searchAdsTopupRecipient", {"query": username}, page_url=ADS_TOPUP_PAGE)
|
||||||
|
recipient = result.get("found", {}).get("recipient")
|
||||||
|
if not recipient:
|
||||||
|
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||||
|
|
||||||
|
result = await client.call("initAdsTopupRequest", {"recipient": recipient, "amount": amount}, page_url=ADS_TOPUP_PAGE)
|
||||||
|
req_id = result.get("req_id")
|
||||||
|
if not req_id:
|
||||||
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
|
||||||
|
|
||||||
|
account = await get_account_info(client)
|
||||||
|
transaction = await client.call(
|
||||||
|
"getAdsTopupLink",
|
||||||
|
{
|
||||||
|
"account": json.dumps(account),
|
||||||
|
"device": DEVICE,
|
||||||
|
"transaction": 1,
|
||||||
|
"id": req_id,
|
||||||
|
"show_sender": int(show_sender),
|
||||||
|
},
|
||||||
|
page_url=ADS_TOPUP_PAGE,
|
||||||
|
)
|
||||||
|
if transaction.get("need_verify"):
|
||||||
|
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||||
|
|
||||||
|
tx_hash = await process_transaction(client, transaction)
|
||||||
|
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
|
||||||
|
|
||||||
|
except FragmentError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from pyfragment.types.constants import PaymentMethod
|
||||||
|
from pyfragment.types.exceptions import (
|
||||||
|
AnonymousNumberError,
|
||||||
|
ClientError,
|
||||||
|
ConfigurationError,
|
||||||
|
CookieError,
|
||||||
|
FragmentAPIError,
|
||||||
|
FragmentError,
|
||||||
|
FragmentPageError,
|
||||||
|
OperationError,
|
||||||
|
ParseError,
|
||||||
|
TransactionError,
|
||||||
|
UnexpectedError,
|
||||||
|
UserNotFoundError,
|
||||||
|
VerificationError,
|
||||||
|
WalletError,
|
||||||
|
)
|
||||||
|
from pyfragment.types.results import (
|
||||||
|
AdsRechargeResult,
|
||||||
|
AdsTopupResult,
|
||||||
|
CookieResult,
|
||||||
|
GiftsResult,
|
||||||
|
LoginCodeResult,
|
||||||
|
NumbersResult,
|
||||||
|
PremiumGiveawayResult,
|
||||||
|
PremiumResult,
|
||||||
|
StarsGiveawayResult,
|
||||||
|
StarsResult,
|
||||||
|
TerminateSessionsResult,
|
||||||
|
UsernamesResult,
|
||||||
|
WalletInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# client exceptions
|
||||||
|
"ClientError",
|
||||||
|
"ConfigurationError",
|
||||||
|
"CookieError",
|
||||||
|
# fragment exceptions
|
||||||
|
"FragmentAPIError",
|
||||||
|
"FragmentError",
|
||||||
|
"FragmentPageError",
|
||||||
|
"AnonymousNumberError",
|
||||||
|
"OperationError",
|
||||||
|
"ParseError",
|
||||||
|
"TransactionError",
|
||||||
|
"UnexpectedError",
|
||||||
|
"UserNotFoundError",
|
||||||
|
"VerificationError",
|
||||||
|
"WalletError",
|
||||||
|
# result types
|
||||||
|
"AdsRechargeResult",
|
||||||
|
"AdsTopupResult",
|
||||||
|
"CookieResult",
|
||||||
|
"GiftsResult",
|
||||||
|
"LoginCodeResult",
|
||||||
|
"NumbersResult",
|
||||||
|
"PremiumGiveawayResult",
|
||||||
|
"PremiumResult",
|
||||||
|
"StarsGiveawayResult",
|
||||||
|
"StarsResult",
|
||||||
|
"TerminateSessionsResult",
|
||||||
|
"UsernamesResult",
|
||||||
|
"WalletInfo",
|
||||||
|
# literal types
|
||||||
|
"PaymentMethod",
|
||||||
|
]
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Literal, get_args
|
||||||
|
|
||||||
|
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
|
||||||
|
|
||||||
|
# Payment methods
|
||||||
|
PaymentMethod = Literal["ton", "usdt_ton"]
|
||||||
|
SUPPORTED_PAYMENT_METHODS: frozenset[str] = frozenset(get_args(PaymentMethod))
|
||||||
|
|
||||||
|
# Single source of truth for supported wallet versions
|
||||||
|
WalletVersion = Literal["V4R2", "V5R1"]
|
||||||
|
SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion))
|
||||||
|
|
||||||
|
# Wallet class map — used to resolve the correct contract from WALLET_VERSION
|
||||||
|
WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
|
||||||
|
|
||||||
|
# Minimum TON balance threshold required for payment flows.
|
||||||
|
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_TIMEOUT: float = 30.0
|
||||||
|
|
||||||
|
# Required Fragment session cookie keys
|
||||||
|
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
|
||||||
|
|
||||||
|
# Fragment domain and page URLs
|
||||||
|
FRAGMENT_DOMAIN: str = "fragment.com" # for rookiepy
|
||||||
|
FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}"
|
||||||
|
STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy"
|
||||||
|
STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway"
|
||||||
|
PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift"
|
||||||
|
PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway"
|
||||||
|
ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup"
|
||||||
|
NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers"
|
||||||
|
GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts"
|
||||||
|
|
||||||
|
# Browsers supported by get_cookies_from_browser()
|
||||||
|
SUPPORTED_BROWSERS: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"arc",
|
||||||
|
"brave",
|
||||||
|
"chrome",
|
||||||
|
"chromium",
|
||||||
|
"chromium_based",
|
||||||
|
"edge",
|
||||||
|
"firefox",
|
||||||
|
"firefox_based",
|
||||||
|
"librewolf",
|
||||||
|
"opera",
|
||||||
|
"opera_gx",
|
||||||
|
"safari",
|
||||||
|
"vivaldi",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload.
|
||||||
|
DEVICE: str = json.dumps(
|
||||||
|
{
|
||||||
|
"platform": "iphone",
|
||||||
|
"appName": "Tonkeeper",
|
||||||
|
"appVersion": "26.04.0",
|
||||||
|
"maxProtocolVersion": 2,
|
||||||
|
"features": [
|
||||||
|
"SendTransaction",
|
||||||
|
{"name": "SendTransaction", "maxMessages": 255},
|
||||||
|
{"name": "SignData", "types": ["text", "binary", "cell"]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Base HTTP headers — shared across all Fragment API requests.
|
||||||
|
# Each method merges these with its own "referer" and "x-aj-referer".
|
||||||
|
BASE_HEADERS: dict[str, str] = {
|
||||||
|
"accept": "application/json, text/javascript, */*; q=0.01",
|
||||||
|
"accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7",
|
||||||
|
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
|
"origin": FRAGMENT_BASE_URL,
|
||||||
|
"priority": "u=1, i",
|
||||||
|
"sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
|
||||||
|
"sec-ch-ua-mobile": "?1",
|
||||||
|
"sec-ch-ua-platform": '"Android"',
|
||||||
|
"sec-fetch-dest": "empty",
|
||||||
|
"sec-fetch-mode": "cors",
|
||||||
|
"sec-fetch-site": "same-origin",
|
||||||
|
"user-agent": (
|
||||||
|
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36"
|
||||||
|
),
|
||||||
|
"x-requested-with": "XMLHttpRequest",
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class FragmentError(Exception):
|
||||||
|
"""Base exception for all pyfragment library errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class ClientError(FragmentError):
|
||||||
|
"""Raised for client configuration and setup issues (bad params, invalid cookies)."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurationError(ClientError):
|
||||||
|
"""Raised when required client parameters are missing or invalid."""
|
||||||
|
|
||||||
|
MISSING_VARS = "Missing required parameter(s): {keys}."
|
||||||
|
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
|
||||||
|
INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}."
|
||||||
|
INVALID_API_KEY = (
|
||||||
|
"Invalid Tonapi API key: expected at least 68 characters, got {length}. Generate a key at https://tonconsole.com."
|
||||||
|
)
|
||||||
|
INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months."
|
||||||
|
INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000."
|
||||||
|
INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1 000 000 000."
|
||||||
|
INVALID_USERNAME = (
|
||||||
|
"Invalid username '{username}'. "
|
||||||
|
"Must be 5–32 characters and contain only letters (A–Z, a–z), digits (0–9), or underscores (_)."
|
||||||
|
)
|
||||||
|
INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5."
|
||||||
|
INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24 000."
|
||||||
|
INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1 000 000."
|
||||||
|
INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}."
|
||||||
|
|
||||||
|
|
||||||
|
class CookieError(ClientError):
|
||||||
|
"""Raised when cookies are unreadable or missing required fields."""
|
||||||
|
|
||||||
|
READ_FAILED = "Failed to parse cookies — expected a JSON string or a dict, got: {exc}"
|
||||||
|
MISSING_KEYS = (
|
||||||
|
"Fragment cookies are missing or empty for key(s): {keys}. "
|
||||||
|
"Open fragment.com in your browser, log in, and copy fresh cookies."
|
||||||
|
)
|
||||||
|
UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}."
|
||||||
|
BROWSER_READ_FAILED = (
|
||||||
|
"Failed to read {browser} cookies: {exc}. Make sure {browser} is installed and you are logged in to {url}."
|
||||||
|
)
|
||||||
|
MISSING_BROWSER_KEYS = (
|
||||||
|
"Fragment cookies not found in {browser}: {keys}. "
|
||||||
|
"Make sure you are logged in to {url} and have connected your TON wallet in {browser}."
|
||||||
|
)
|
||||||
|
EXPIRED = "Fragment session cookie expired at {expires}. Log in to fragment.com in your browser and extract fresh cookies."
|
||||||
|
|
||||||
|
|
||||||
|
class FragmentAPIError(FragmentError):
|
||||||
|
"""Raised for errors returned by Fragment's API responses."""
|
||||||
|
|
||||||
|
NO_REQUEST_ID = (
|
||||||
|
"Fragment did not return a request ID for '{context}'. "
|
||||||
|
"Your session may have expired — log in to fragment.com and refresh your cookies."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FragmentPageError(FragmentAPIError):
|
||||||
|
"""Raised when the Fragment page cannot be fetched or the API hash is not found."""
|
||||||
|
|
||||||
|
BAD_STATUS = (
|
||||||
|
"Fragment returned HTTP {status} when loading {url}. "
|
||||||
|
"Your cookies may be invalid or expired — log in to fragment.com and refresh them."
|
||||||
|
)
|
||||||
|
NOT_FOUND = (
|
||||||
|
"Could not extract the API hash from {url}. "
|
||||||
|
"The page structure may have changed, or you are not logged in — refresh your cookies."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserNotFoundError(FragmentAPIError):
|
||||||
|
"""Raised when the target Telegram user is not found on Fragment."""
|
||||||
|
|
||||||
|
NOT_FOUND = (
|
||||||
|
"Telegram user '{username}' was not found on Fragment. Double-check the username and make sure the account exists."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AnonymousNumberError(FragmentAPIError):
|
||||||
|
"""Raised for Fragment anonymous number API failures."""
|
||||||
|
|
||||||
|
NOT_OWNED = "Number '{number}' is not associated with your Fragment account or has no active sessions to terminate."
|
||||||
|
TERMINATE_FAILED = "Failed to terminate sessions for '{number}': {error}"
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionError(FragmentAPIError):
|
||||||
|
"""Raised when a TON transaction fails to build or broadcast."""
|
||||||
|
|
||||||
|
INVALID_PAYLOAD = (
|
||||||
|
"Fragment returned an invalid transaction payload — 'transaction.messages' is missing or empty in the API response."
|
||||||
|
)
|
||||||
|
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
|
||||||
|
BROADCAST_FAILED_SSL = (
|
||||||
|
"Transaction broadcast failed due to an SSL certificate error: {exc}\n"
|
||||||
|
"This usually means your system's CA bundle is missing or outdated.\n"
|
||||||
|
"Fix: run `pip install --upgrade certifi` and retry. "
|
||||||
|
"On macOS you may also need to run the 'Install Certificates.command' "
|
||||||
|
"located in your Python installation folder."
|
||||||
|
)
|
||||||
|
DUPLICATE_SEQNO = (
|
||||||
|
"Transaction broadcast failed: the TON wallet rejected the message "
|
||||||
|
"because a previous transaction with the same sequence number (seqno) "
|
||||||
|
"is still pending confirmation on-chain.\n"
|
||||||
|
"Wait a few seconds for the previous transaction to confirm, then retry."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ParseError(FragmentAPIError):
|
||||||
|
"""Raised when a Fragment API response or payload cannot be parsed."""
|
||||||
|
|
||||||
|
UNPARSEABLE = "Failed to parse the Fragment API response for '{context}': {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationError(FragmentAPIError):
|
||||||
|
"""Raised when Fragment requires KYC verification before proceeding."""
|
||||||
|
|
||||||
|
KYC_REQUIRED = (
|
||||||
|
"Fragment requires identity verification (KYC) before this action can be completed. "
|
||||||
|
"Complete verification at https://fragment.com/my/profile and retry."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OperationError(FragmentError):
|
||||||
|
"""Raised for runtime operation failures unrelated to Fragment's API."""
|
||||||
|
|
||||||
|
|
||||||
|
class WalletError(OperationError):
|
||||||
|
"""Raised for TON wallet issues (connection, balance, account info)."""
|
||||||
|
|
||||||
|
LOW_TON_BALANCE = "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required."
|
||||||
|
LOW_USDT_BALANCE = "Insufficient USDT balance: {balance:.4f} USDT available, {required:.4f} USDT required."
|
||||||
|
TON_BALANCE_CHECK_FAILED = "Failed to fetch TON balance: {exc}"
|
||||||
|
USDT_BALANCE_CHECK_FAILED = "Failed to fetch USDT balance: {exc}"
|
||||||
|
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {exc}"
|
||||||
|
WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
class UnexpectedError(OperationError):
|
||||||
|
"""Raised when an unexpected error occurs during an API call."""
|
||||||
|
|
||||||
|
UNEXPECTED = "An unexpected error occurred during the operation: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FragmentError",
|
||||||
|
"ClientError",
|
||||||
|
"ConfigurationError",
|
||||||
|
"CookieError",
|
||||||
|
"FragmentAPIError",
|
||||||
|
"FragmentPageError",
|
||||||
|
"AnonymousNumberError",
|
||||||
|
"UserNotFoundError",
|
||||||
|
"TransactionError",
|
||||||
|
"ParseError",
|
||||||
|
"VerificationError",
|
||||||
|
"OperationError",
|
||||||
|
"WalletError",
|
||||||
|
"UnexpectedError",
|
||||||
|
]
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CookieResult:
|
||||||
|
"""Result returned by :func:`~pyfragment.utils.get_cookies_from_browser`.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
cookies: Dict with the four required Fragment cookie keys.
|
||||||
|
expires: Expiry of the ``stel_ssid`` session cookie in ISO 8601 format (UTC),
|
||||||
|
or ``None`` for session cookies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cookies: dict[str, str]
|
||||||
|
expires: str | None
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"CookieResult(expires={self.expires!r})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WalletInfo:
|
||||||
|
"""Wallet state returned by :meth:`FragmentClient.get_wallet`."""
|
||||||
|
|
||||||
|
address: str
|
||||||
|
state: str
|
||||||
|
ton_balance: float
|
||||||
|
usdt_balance: float
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"WalletInfo(address='{self.address}', state='{self.state}', "
|
||||||
|
f"ton_balance={self.ton_balance} TON, usdt_balance={self.usdt_balance} USDT)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PremiumResult:
|
||||||
|
"""Result of a successful Telegram Premium gift."""
|
||||||
|
|
||||||
|
transaction_id: str
|
||||||
|
username: str
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"PremiumResult(username='{self.username}', amount={self.amount} months, tx='{self.transaction_id}')"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StarsResult:
|
||||||
|
"""Result of a successful Telegram Stars purchase."""
|
||||||
|
|
||||||
|
transaction_id: str
|
||||||
|
username: str
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"StarsResult(username='{self.username}', amount={self.amount} stars, tx='{self.transaction_id}')"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdsTopupResult:
|
||||||
|
"""Result of a successful Telegram Ads balance top-up."""
|
||||||
|
|
||||||
|
transaction_id: str
|
||||||
|
username: str
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"AdsTopupResult(username='{self.username}', amount={self.amount} TON, tx='{self.transaction_id}')"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StarsGiveawayResult:
|
||||||
|
"""Result of a successful Telegram Stars giveaway."""
|
||||||
|
|
||||||
|
transaction_id: str
|
||||||
|
channel: str
|
||||||
|
winners: int
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"StarsGiveawayResult(channel='{self.channel}', winners={self.winners}, "
|
||||||
|
f"amount={self.amount} stars per winner, tx='{self.transaction_id}')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PremiumGiveawayResult:
|
||||||
|
"""Result of a successful Telegram Premium giveaway."""
|
||||||
|
|
||||||
|
transaction_id: str
|
||||||
|
channel: str
|
||||||
|
winners: int
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"PremiumGiveawayResult(channel='{self.channel}', winners={self.winners}, "
|
||||||
|
f"amount={self.amount} months per winner, tx='{self.transaction_id}')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LoginCodeResult:
|
||||||
|
"""Result of :meth:`FragmentClient.get_login_code`."""
|
||||||
|
|
||||||
|
number: str
|
||||||
|
code: str | None
|
||||||
|
active_sessions: int
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
code_str = f"'{self.code}'" if self.code else "None"
|
||||||
|
return f"LoginCodeResult(number='{self.number}', code={code_str}, active_sessions={self.active_sessions})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdsRechargeResult:
|
||||||
|
"""Result of a successful self-recharge of Telegram Ads balance."""
|
||||||
|
|
||||||
|
transaction_id: str
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"AdsRechargeResult(amount={self.amount} TON, tx='{self.transaction_id}')"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TerminateSessionsResult:
|
||||||
|
"""Result of :meth:`FragmentClient.terminate_sessions`."""
|
||||||
|
|
||||||
|
number: str
|
||||||
|
message: str | None
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UsernamesResult:
|
||||||
|
"""Result of :meth:`FragmentClient.search_usernames`.
|
||||||
|
|
||||||
|
Each dict in ``items`` has the keys:
|
||||||
|
|
||||||
|
- ``slug`` — URL path (e.g. ``"username/durov"``).
|
||||||
|
- ``name`` — display value (e.g. ``"@durov"``).
|
||||||
|
- ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``).
|
||||||
|
- ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``.
|
||||||
|
- ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, or ``None``.
|
||||||
|
|
||||||
|
Use ``next_offset_id`` to paginate to the next page of results.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items: list[dict[str, Any]]
|
||||||
|
next_offset_id: str | None
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"UsernamesResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NumbersResult:
|
||||||
|
"""Result of :meth:`FragmentClient.search_numbers`.
|
||||||
|
|
||||||
|
Each dict in ``items`` has the keys:
|
||||||
|
|
||||||
|
- ``slug`` — URL path (e.g. ``"number/8880000111"``).
|
||||||
|
- ``name`` — display value (e.g. ``"+888 0000 111"``).
|
||||||
|
- ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``).
|
||||||
|
- ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``.
|
||||||
|
- ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, or ``None``.
|
||||||
|
|
||||||
|
Use ``next_offset_id`` to paginate to the next page of results.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items: list[dict[str, Any]]
|
||||||
|
next_offset_id: str | None
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"NumbersResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GiftsResult:
|
||||||
|
"""Result of :meth:`FragmentClient.search_gifts`.
|
||||||
|
|
||||||
|
Each dict in ``items`` has the keys:
|
||||||
|
|
||||||
|
- ``slug`` — URL path (e.g. ``"gift/plushpepe-1821"``).
|
||||||
|
- ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``).
|
||||||
|
- ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``).
|
||||||
|
- ``price`` — price in TON formatted to two decimal places (e.g. ``"88888.00"``), or ``None``.
|
||||||
|
- ``date`` — ISO 8601 datetime of the sale/listing, or ``None``.
|
||||||
|
|
||||||
|
Use ``next_offset`` to paginate to the next page of results.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items: list[dict[str, Any]]
|
||||||
|
next_offset: int | None
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AdsRechargeResult",
|
||||||
|
"AdsTopupResult",
|
||||||
|
"GiftsResult",
|
||||||
|
"LoginCodeResult",
|
||||||
|
"NumbersResult",
|
||||||
|
"PremiumGiveawayResult",
|
||||||
|
"PremiumResult",
|
||||||
|
"StarsGiveawayResult",
|
||||||
|
"StarsResult",
|
||||||
|
"TerminateSessionsResult",
|
||||||
|
"UsernamesResult",
|
||||||
|
"WalletInfo",
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from pyfragment.utils.cookies import CookieResult, get_cookies_from_browser
|
||||||
|
from pyfragment.utils.decoder import clean_decode
|
||||||
|
from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code, parse_required_payment_amount
|
||||||
|
from pyfragment.utils.http import (
|
||||||
|
execute_transaction_request,
|
||||||
|
fragment_request,
|
||||||
|
get_fragment_hash,
|
||||||
|
make_headers,
|
||||||
|
parse_json_response,
|
||||||
|
)
|
||||||
|
from pyfragment.utils.wallet import get_account_info, process_transaction
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"clean_decode",
|
||||||
|
"CookieResult",
|
||||||
|
"get_cookies_from_browser",
|
||||||
|
"parse_auction_rows",
|
||||||
|
"parse_gift_items",
|
||||||
|
"parse_login_code",
|
||||||
|
"parse_required_payment_amount",
|
||||||
|
"execute_transaction_request",
|
||||||
|
"fragment_request",
|
||||||
|
"get_account_info",
|
||||||
|
"get_fragment_hash",
|
||||||
|
"make_headers",
|
||||||
|
"parse_json_response",
|
||||||
|
"process_transaction",
|
||||||
|
]
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import rookiepy
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
|
||||||
|
"""Extract Fragment session cookies directly from an installed browser.
|
||||||
|
|
||||||
|
Reads the browser's on-disk cookie store (no extension required) and
|
||||||
|
returns the four cookies required by :class:`~pyfragment.FragmentClient`
|
||||||
|
along with the session expiry timestamp.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
browser: Browser name to read cookies from — case-insensitive. Supported values:
|
||||||
|
``"chrome"`` (default), ``"firefox"``, ``"edge"``, ``"brave"``, ``"arc"``,
|
||||||
|
``"opera"``, ``"opera_gx"``, ``"chromium"``, ``"chromium_based"``,
|
||||||
|
``"firefox_based"``, ``"vivaldi"``, ``"librewolf"``, ``"safari"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`CookieResult` with ``.cookies`` (dict) and ``.expires`` (ISO 8601 string or ``None``).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CookieError: If the browser is not supported, cookies cannot be read,
|
||||||
|
or required keys are missing.
|
||||||
|
"""
|
||||||
|
key = browser.lower()
|
||||||
|
if key not in SUPPORTED_BROWSERS:
|
||||||
|
supported = ", ".join(sorted(SUPPORTED_BROWSERS))
|
||||||
|
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
|
||||||
|
|
||||||
|
try:
|
||||||
|
jar: list[dict[str, Any]] = getattr(rookiepy, key)([FRAGMENT_DOMAIN])
|
||||||
|
except Exception as exc:
|
||||||
|
raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc
|
||||||
|
|
||||||
|
cookie_map: dict[str, str] = {c["name"]: c["value"] for c in jar if c.get("name") and c.get("value")}
|
||||||
|
|
||||||
|
missing = [k for k in REQUIRED_COOKIE_KEYS if not str(cookie_map.get(k, "")).strip()]
|
||||||
|
if missing:
|
||||||
|
raise CookieError(CookieError.MISSING_BROWSER_KEYS.format(browser=browser, keys=missing, url=FRAGMENT_BASE_URL))
|
||||||
|
|
||||||
|
expires_iso: str | None = None
|
||||||
|
for cookie in jar:
|
||||||
|
if cookie.get("name") == "stel_ssid":
|
||||||
|
raw = cookie.get("expires")
|
||||||
|
if isinstance(raw, (int, float)):
|
||||||
|
expires_iso = datetime.fromtimestamp(raw, tz=timezone.utc).isoformat()
|
||||||
|
elif isinstance(raw, str) and raw:
|
||||||
|
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
|
||||||
|
try:
|
||||||
|
expires_iso = datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc).isoformat()
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
if expires_iso:
|
||||||
|
expires_dt = datetime.fromisoformat(expires_iso)
|
||||||
|
if expires_dt < datetime.now(timezone.utc):
|
||||||
|
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
|
||||||
|
|
||||||
|
return CookieResult(
|
||||||
|
cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS},
|
||||||
|
expires=expires_iso,
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from ton_core import Cell
|
||||||
|
|
||||||
|
from pyfragment.types import ParseError
|
||||||
|
|
||||||
|
|
||||||
|
def clean_decode(payload: str) -> str | Cell:
|
||||||
|
"""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, ``""`` for an empty payload, or raw ``Cell``
|
||||||
|
when payload is a non-UTF8 binary body.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ParseError: If the payload cannot be decoded or parsed.
|
||||||
|
"""
|
||||||
|
s = payload.strip()
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
s += "=" * (-len(s) % 4)
|
||||||
|
try:
|
||||||
|
# Fragment may return URL-safe base64 ("-"/"_") in transaction payloads.
|
||||||
|
boc = base64.b64decode(s, altchars=b"-_", validate=True)
|
||||||
|
cell = Cell.one_from_boc(boc)
|
||||||
|
sl = cell.begin_parse()
|
||||||
|
sl.load_uint(32) # op code
|
||||||
|
try:
|
||||||
|
return sl.load_snake_string().strip()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
# Some Fragment payloads are binary TVM cells rather than text comments.
|
||||||
|
return cell
|
||||||
|
except Exception as exc:
|
||||||
|
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Matches the login code inside a table-cell-value element.
|
||||||
|
CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<')
|
||||||
|
# Counts active session rows in the HTML table.
|
||||||
|
ROW_RE = re.compile(r"<tr[\s>]")
|
||||||
|
|
||||||
|
# Auction table row parsing
|
||||||
|
ROW_BLOCK_RE = re.compile(r'<tr\b[^>]*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)</tr>', re.DOTALL)
|
||||||
|
HREF_RE = re.compile(r'href="(/(?:username|number|nft)/([^"]+))"')
|
||||||
|
VALUE_RE = re.compile(r'class="[^"]*tm-value[^"]*"[^>]*>\s*([^<]+?)\s*<')
|
||||||
|
PRICE_RE = re.compile(r"icon-before\s+icon-ton[^>]*>\s*([0-9][^<]*?)\s*<")
|
||||||
|
DATETIME_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative="text"[^>]*>')
|
||||||
|
DATETIME_SHORT_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative="short-text"[^>]*>')
|
||||||
|
# Matches numeric-only values (plain integers, formatted prices like "150,492", phone numbers like "+888 0088 8888")
|
||||||
|
NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$")
|
||||||
|
|
||||||
|
# Gift grid item parsing
|
||||||
|
GRID_ITEM_RE = re.compile(r'<a\b[^>]*class="[^"]*tm-grid-item[^"]*"[^>]*>(.*?)</a>', re.DOTALL)
|
||||||
|
GRID_HREF_RE = re.compile(r'href="(/gift/([^?"]+))')
|
||||||
|
GRID_NAME_RE = re.compile(r'class="item-name">([^<]+)<')
|
||||||
|
GRID_NUM_RE = re.compile(r'class="item-num">[^#]*#(\w+)<')
|
||||||
|
GRID_PRICE_RE = re.compile(r'class="[^"]*tm-grid-item-value[^"]*icon-ton[^"]*"[^>]*>\s*([0-9][^<]*?)\s*<')
|
||||||
|
GRID_STATUS_RE = re.compile(r'class="[^"]*tm-grid-item-status[^"]*"[^>]*>\s*([^<]+?)\s*<')
|
||||||
|
GRID_DATETIME_RE = re.compile(r'<time[^>]+datetime="([^"]+)"')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_login_code(html: str) -> tuple[str | None, int]:
|
||||||
|
"""Extract the pending login code and active session count from a Fragment numbers page HTML snippet.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: Raw HTML string returned by the Fragment API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of ``(code, active_sessions)`` where ``code`` is ``None`` if no
|
||||||
|
pending code is present, and ``active_sessions`` is the number of ``<tr>``
|
||||||
|
rows found (each row represents one active session).
|
||||||
|
"""
|
||||||
|
match = CODE_RE.search(html)
|
||||||
|
code = match.group(1).strip() if match else None
|
||||||
|
active_sessions = len(ROW_RE.findall(html))
|
||||||
|
return code, active_sessions
|
||||||
|
|
||||||
|
|
||||||
|
def parse_auction_rows(html: str) -> list[dict[str, Any]]:
|
||||||
|
"""Parse Fragment marketplace HTML into structured item dicts.
|
||||||
|
|
||||||
|
Extracts each ``<tr class="tm-row-selectable">`` and returns a list of dicts
|
||||||
|
with the following keys:
|
||||||
|
|
||||||
|
- ``slug`` — URL path segment (e.g. ``"username/durov"``).
|
||||||
|
- ``name`` — display value (e.g. ``"@durov"`` or ``"+888..."``)
|
||||||
|
- ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``).
|
||||||
|
- ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``),
|
||||||
|
or ``None`` if not listed.
|
||||||
|
- ``date`` — ISO 8601 datetime string: auction end date, sale date, or listing date, or ``None``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of item dicts, one per table row.
|
||||||
|
"""
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for row_match in ROW_BLOCK_RE.finditer(html):
|
||||||
|
row = row_match.group(1)
|
||||||
|
|
||||||
|
href_m = HREF_RE.search(row)
|
||||||
|
if not href_m:
|
||||||
|
continue
|
||||||
|
slug = href_m.group(1).lstrip("/") # e.g. "username/durov"
|
||||||
|
|
||||||
|
# All tm-value spans in the row — first is the display name
|
||||||
|
values = [m.group(1).strip() for m in VALUE_RE.finditer(row)]
|
||||||
|
name = values[0] if values else slug
|
||||||
|
|
||||||
|
# Status: find the human-readable label from subsequent tm-value spans.
|
||||||
|
# Skip usernames (@), numeric-only values (prices like "150,492", phone numbers like "+888 0088 8888").
|
||||||
|
status: str | None = None
|
||||||
|
for v in values[1:]:
|
||||||
|
if v and v not in ("Unknown",) and not v.startswith("@") and not NUMERIC_RE.match(v):
|
||||||
|
status = v
|
||||||
|
break
|
||||||
|
|
||||||
|
# Price — look for icon-ton pattern, format as two decimal places
|
||||||
|
price_m = PRICE_RE.search(row)
|
||||||
|
price: str | None = None
|
||||||
|
if price_m:
|
||||||
|
raw_price = price_m.group(1).strip().replace(",", "")
|
||||||
|
try:
|
||||||
|
price = f"{float(raw_price):.2f}"
|
||||||
|
except ValueError:
|
||||||
|
price = raw_price
|
||||||
|
|
||||||
|
# Datetime (ISO 8601) — auction end, sale date, or listing date.
|
||||||
|
time_m = DATETIME_RE.search(row) or DATETIME_SHORT_RE.search(row)
|
||||||
|
date: str | None = time_m.group(1) if time_m else None
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"slug": slug,
|
||||||
|
"name": name,
|
||||||
|
"status": status,
|
||||||
|
"price": price,
|
||||||
|
"date": date,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]:
|
||||||
|
"""Parse Fragment gifts grid HTML into structured item dicts.
|
||||||
|
|
||||||
|
Extracts each ``<a class="tm-grid-item">`` block and returns a list of dicts
|
||||||
|
with the following keys:
|
||||||
|
|
||||||
|
- ``slug`` — URL path segment (e.g. ``"gift/plushpepe-1821"``).
|
||||||
|
- ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``).
|
||||||
|
- ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``).
|
||||||
|
- ``price`` — price in TON formatted to two decimal places, or ``None``.
|
||||||
|
- ``date`` — ISO 8601 datetime of the sale/listing, or ``None``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of ``(items, next_offset)`` where ``next_offset`` is an integer
|
||||||
|
page offset from ``data-next-offset``, or ``None`` on the last page.
|
||||||
|
"""
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for item_match in GRID_ITEM_RE.finditer(html):
|
||||||
|
block = item_match.group(0)
|
||||||
|
|
||||||
|
href_m = GRID_HREF_RE.search(block)
|
||||||
|
if not href_m:
|
||||||
|
continue
|
||||||
|
slug = href_m.group(1).lstrip("/") # e.g. "gift/plushpepe-1821"
|
||||||
|
|
||||||
|
name_m = GRID_NAME_RE.search(block)
|
||||||
|
num_m = GRID_NUM_RE.search(block)
|
||||||
|
item_name = name_m.group(1).strip() if name_m else slug
|
||||||
|
item_num = f" #{num_m.group(1)}" if num_m else ""
|
||||||
|
name = f"{item_name}{item_num}"
|
||||||
|
|
||||||
|
status_m = GRID_STATUS_RE.search(block)
|
||||||
|
status: str | None = status_m.group(1).strip() if status_m else None
|
||||||
|
|
||||||
|
price_m = GRID_PRICE_RE.search(block)
|
||||||
|
price: str | None = None
|
||||||
|
if price_m:
|
||||||
|
raw_price = price_m.group(1).strip().replace(",", "")
|
||||||
|
try:
|
||||||
|
price = f"{float(raw_price):.2f}"
|
||||||
|
except ValueError:
|
||||||
|
price = raw_price
|
||||||
|
|
||||||
|
time_m = GRID_DATETIME_RE.search(block)
|
||||||
|
date: str | None = time_m.group(1) if time_m else None
|
||||||
|
|
||||||
|
items.append({"slug": slug, "name": name, "status": status, "price": price, "date": date})
|
||||||
|
|
||||||
|
# Pagination offset from data-next-offset attribute
|
||||||
|
next_offset_m = re.search(r'data-next-offset="(\d+)"', html)
|
||||||
|
next_offset = int(next_offset_m.group(1)) if next_offset_m else None
|
||||||
|
|
||||||
|
return items, next_offset
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from pyfragment.types import FragmentPageError, ParseError, VerificationError
|
||||||
|
from pyfragment.types.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
def make_headers(page_url: str = FRAGMENT_BASE_URL) -> dict[str, str]:
|
||||||
|
return {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_fragment_hash(
|
||||||
|
cookies: dict[str, Any],
|
||||||
|
headers: dict[str, str],
|
||||||
|
page_url: str,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
) -> str:
|
||||||
|
"""Fetch the API hash from a Fragment page.
|
||||||
|
|
||||||
|
Fragment embeds a short-lived hash in each page's HTML that must be
|
||||||
|
included in every subsequent API request. This function loads the page
|
||||||
|
as a real browser navigation (not XHR) so Fragment returns full HTML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cookies: Active Fragment session cookies.
|
||||||
|
headers: Base headers for the relevant Fragment page.
|
||||||
|
page_url: URL of the Fragment page to fetch the hash from.
|
||||||
|
timeout: HTTP request timeout in seconds. Defaults to ``DEFAULT_TIMEOUT``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lowercase hex hash string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FragmentPageError: If the page returns a non-200 status or the hash
|
||||||
|
is not found in the response HTML.
|
||||||
|
"""
|
||||||
|
page_headers = {
|
||||||
|
k: v
|
||||||
|
for k, v in headers.items()
|
||||||
|
if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
|
||||||
|
}
|
||||||
|
page_headers.update(
|
||||||
|
{
|
||||||
|
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"referer": f"{FRAGMENT_BASE_URL}/",
|
||||||
|
"sec-fetch-dest": "document",
|
||||||
|
"sec-fetch-mode": "navigate",
|
||||||
|
"upgrade-insecure-requests": "1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
|
||||||
|
response = await session.get(page_url, headers=page_headers)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=response.status_code, url=page_url))
|
||||||
|
|
||||||
|
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
|
||||||
|
if not match:
|
||||||
|
raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url))
|
||||||
|
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
|
||||||
|
"""Parse a Fragment API JSON response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response: The HTTP response object.
|
||||||
|
context: Human-readable name of the API method, used in error messages.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed response as a dict.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ParseError: If the response body cannot be decoded as JSON.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return cast(dict[str, Any], response.json())
|
||||||
|
except Exception as exc:
|
||||||
|
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def fragment_request(
|
||||||
|
session: httpx.AsyncClient,
|
||||||
|
fragment_hash: str,
|
||||||
|
headers: dict[str, str],
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""POST a single request to the Fragment API.
|
||||||
|
|
||||||
|
Builds the ``/api?hash=`` URL, sends the request, and returns the
|
||||||
|
parsed JSON body. Use this for every API method call — search,
|
||||||
|
init, state updates, etc.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Active httpx session with Fragment cookies.
|
||||||
|
fragment_hash: Short-lived hash from the Fragment page HTML.
|
||||||
|
headers: Page-specific HTTP headers.
|
||||||
|
data: Form data payload; must include a ``"method"`` key.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed API response as a dict.
|
||||||
|
"""
|
||||||
|
for attempt in range(3):
|
||||||
|
resp = await session.post(
|
||||||
|
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
|
||||||
|
headers=headers,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
if resp.status_code == 429 and attempt < 2:
|
||||||
|
await asyncio.sleep(1 + attempt + random.uniform(0, 0.5))
|
||||||
|
continue
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise FragmentPageError(
|
||||||
|
FragmentPageError.BAD_STATUS.format(status=resp.status_code, url=f"{FRAGMENT_BASE_URL}/api")
|
||||||
|
)
|
||||||
|
return parse_json_response(resp, data.get("method", "request"))
|
||||||
|
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=429, url=f"{FRAGMENT_BASE_URL}/api"))
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_transaction_request(
|
||||||
|
session: httpx.AsyncClient,
|
||||||
|
headers: dict[str, str],
|
||||||
|
tx_data: dict[str, Any],
|
||||||
|
fragment_hash: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Post a transaction request to the Fragment API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Active httpx session with Fragment cookies.
|
||||||
|
headers: Page-specific HTTP headers.
|
||||||
|
tx_data: Form data payload for the API method.
|
||||||
|
fragment_hash: Short-lived hash from the Fragment page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed API response dict containing transaction data.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
VerificationError: If Fragment requires KYC verification.
|
||||||
|
ParseError: If the response cannot be parsed.
|
||||||
|
"""
|
||||||
|
transaction = await fragment_request(session, fragment_hash, headers, tx_data)
|
||||||
|
|
||||||
|
if transaction.get("need_verify"):
|
||||||
|
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||||
|
|
||||||
|
return transaction
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import random
|
||||||
|
import ssl
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from ton_core import NetworkGlobalID
|
||||||
|
from tonutils.clients import TonapiClient
|
||||||
|
from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method
|
||||||
|
from tonutils.exceptions import ProviderResponseError
|
||||||
|
|
||||||
|
from pyfragment.types import TransactionError, WalletError, WalletInfo
|
||||||
|
from pyfragment.types.constants import (
|
||||||
|
MIN_TON_BALANCE,
|
||||||
|
MIN_USDT_BALANCE,
|
||||||
|
USDT_TON_MASTER_ADDRESS,
|
||||||
|
WALLET_CLASSES,
|
||||||
|
PaymentMethod,
|
||||||
|
)
|
||||||
|
from pyfragment.utils.decoder import clean_decode
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
wallet_address = wallet.address.to_str(False, False)
|
||||||
|
if payment_method == "ton":
|
||||||
|
await _check_ton_payment_balance(
|
||||||
|
balance_ton,
|
||||||
|
amount_ton,
|
||||||
|
required_payment_amount,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await _check_usdt_payment_balance(
|
||||||
|
balance_ton,
|
||||||
|
required_payment_amount,
|
||||||
|
ton,
|
||||||
|
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"))
|
||||||
|
|
||||||
|
|
||||||
|
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,95 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "pyfragment"
|
||||||
|
version = "2026.2.2"
|
||||||
|
description = "Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings."
|
||||||
|
readme = "README.md"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
authors = [{ name = "bohd4nx" }]
|
||||||
|
keywords = [
|
||||||
|
"fragment",
|
||||||
|
"fragment-api",
|
||||||
|
"telegram",
|
||||||
|
"telegram-api",
|
||||||
|
"telegram-stars",
|
||||||
|
"telegram-premium",
|
||||||
|
"telegram-giveaway",
|
||||||
|
"telegram-ads",
|
||||||
|
"ton",
|
||||||
|
"ton-blockchain",
|
||||||
|
"tonkeeper",
|
||||||
|
"tonapi",
|
||||||
|
"anonymous-numbers",
|
||||||
|
"username-auctions",
|
||||||
|
"gift-marketplace",
|
||||||
|
"crypto-payments",
|
||||||
|
"nft-marketplace",
|
||||||
|
"web3",
|
||||||
|
"python-client",
|
||||||
|
"typed",
|
||||||
|
"asyncio",
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Intended Audience :: Financial and Insurance Industry",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Natural Language :: English",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Programming Language :: Python",
|
||||||
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Framework :: AsyncIO",
|
||||||
|
"Topic :: Software Development :: Libraries",
|
||||||
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
|
"Topic :: Internet",
|
||||||
|
"Topic :: Internet :: WWW/HTTP",
|
||||||
|
"Topic :: Office/Business :: Financial",
|
||||||
|
"Topic :: Office/Business :: Financial :: Investment",
|
||||||
|
"Typing :: Typed",
|
||||||
|
]
|
||||||
|
dependencies = ["httpx>=0.25", "rookiepy>=0.5.6", "tonutils>=2.0.1"]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/bohd4nx/pyfragment"
|
||||||
|
Repository = "https://github.com/bohd4nx/pyfragment"
|
||||||
|
Issues = "https://github.com/bohd4nx/pyfragment/issues"
|
||||||
|
Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["pyfragment"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["[0-9][0-9][0-9]_test_*.py"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
addopts = "-v --tb=short"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 128
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
|
||||||
|
select = ["E", "F", "W", "I", "UP"]
|
||||||
|
# E501 — line too long (covered by line-length above)
|
||||||
|
# UP017 — use datetime.UTC (only available in Python 3.11+, we support 3.10)
|
||||||
|
ignore = ["E501", "UP017"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/*" = ["E402"]
|
||||||
|
"systests/*" = ["E402"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.10"
|
||||||
|
strict = true
|
||||||
|
exclude = ["^systests/", "^examples/"]
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Tests for clean_decode() — TON BOC payload decoding."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from ton_core import Cell
|
||||||
|
|
||||||
|
from pyfragment.types import ParseError
|
||||||
|
from pyfragment.utils.decoder import clean_decode
|
||||||
|
|
||||||
|
PAYLOAD_CASES = [
|
||||||
|
pytest.param(
|
||||||
|
"te6ccgEBAgEALwABTgAAAAAxMDAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmI1RQb01wegEABkM3ZQ",
|
||||||
|
True,
|
||||||
|
id="stars",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"te6ccgEBAgEANAABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAxIHllYXIgCgpSZWYjcgEAEE9OQnM2cmNt",
|
||||||
|
True,
|
||||||
|
id="premium",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"te6ccgEBAgEAMAABTgAAAABUZWxlZ3JhbSBhY2NvdW50IHRvcCB1cCAKClJlZiNrMXpDRQEACFkxd3g",
|
||||||
|
True,
|
||||||
|
id="topup",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"te6ccgEBAgEAfgABqA-KfqVP885dhccidjC3GwgBCkiH8LM_zUu0afyGCTWJwX1mDjdlf2rMa9UoQlD4UHUAF1jLlcMomlo5RJTwl8jnDDdfdhc7EgQQWPqFQ9IjyLPCAwEASgAAAAA1MCBUZWxlZ3JhbSBTdGFycyAKClJlZiNtOUpoWndBcFE",
|
||||||
|
False,
|
||||||
|
id="real_stars_50",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"te6ccgEBAgEANgABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAzIG1vbnRocyAKClJlZgEAFCMzcFdKdGJkYnU",
|
||||||
|
False,
|
||||||
|
id="real_premium_3m",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"te6ccgEBAwEAhgABqg-KfqWibdDaYaJCPUWWgvAIAQpIh_CzP81LtGn8hgk1icF9Zg43ZX9qzGvVKEJQ-FB1ABdYy5XDKJpaOUSU8JfI5ww3X3YXOxIEEFj6hUPSI8izwgMBAU4AAAAAMTAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmIzBoZ0RmNEYCAAQ5VA",
|
||||||
|
False,
|
||||||
|
id="real_stars_100k",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Decode valid payload tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("payload", "strict_ref"), PAYLOAD_CASES)
|
||||||
|
def test_decode_payload(payload: str, strict_ref: bool) -> None:
|
||||||
|
result = clean_decode(payload)
|
||||||
|
if isinstance(result, str):
|
||||||
|
assert "Telegram" in result
|
||||||
|
if strict_ref:
|
||||||
|
assert re.search(r"Ref#[A-Za-z0-9]+", result), f"no Ref# in {result!r}"
|
||||||
|
assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}"
|
||||||
|
else:
|
||||||
|
assert isinstance(result, Cell)
|
||||||
|
|
||||||
|
|
||||||
|
# Edge case tests
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_payload_returns_empty_string() -> None:
|
||||||
|
assert clean_decode("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_payload_raises_parse_error() -> None:
|
||||||
|
with pytest.raises(ParseError):
|
||||||
|
clean_decode("!!!not-valid-base64!!!")
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_payload_accepts_base64url_alphabet() -> None:
|
||||||
|
class _FakeSlice:
|
||||||
|
def load_uint(self, _: int) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def load_snake_string(self) -> str:
|
||||||
|
return "Telegram Stars Ref#abc"
|
||||||
|
|
||||||
|
class _FakeCell:
|
||||||
|
def begin_parse(self) -> _FakeSlice:
|
||||||
|
return _FakeSlice()
|
||||||
|
|
||||||
|
raw = b"\xfb\xef\xff\x00"
|
||||||
|
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
|
||||||
|
with patch("pyfragment.utils.decoder.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.decoder.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.decoder.Cell.one_from_boc", return_value=fake_cell):
|
||||||
|
parsed = clean_decode(payload)
|
||||||
|
|
||||||
|
assert parsed is fake_cell
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Unit tests for FragmentClient — initialization, validation, and cookie parsing."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.types import ConfigurationError, CookieError
|
||||||
|
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
||||||
|
|
||||||
|
# Client init tests
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_init() -> None:
|
||||||
|
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||||
|
assert client.seed == VALID_SEED.strip()
|
||||||
|
assert client.api_key == VALID_API_KEY
|
||||||
|
assert client.wallet_version == "V5R1"
|
||||||
|
|
||||||
|
|
||||||
|
# Wallet version tests
|
||||||
|
|
||||||
|
|
||||||
|
def test_wallet_version_v4r2() -> None:
|
||||||
|
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V4R2")
|
||||||
|
assert client.wallet_version == "V4R2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wallet_version_is_case_insensitive() -> None:
|
||||||
|
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="v5r1")
|
||||||
|
assert client.wallet_version == "V5R1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_wallet_version_raises() -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V3R2")
|
||||||
|
|
||||||
|
|
||||||
|
# Seed and mnemonic validation tests
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_seed_raises() -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_whitespace_only_seed_raises() -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_mnemonic_length_raises() -> None:
|
||||||
|
bad_seed = " ".join(["word"] * 23)
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
FragmentClient(seed=bad_seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_mnemonic_lengths() -> None:
|
||||||
|
for length in (12, 18, 24):
|
||||||
|
seed = " ".join(["abandon"] * (length - 1) + ["about"])
|
||||||
|
client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||||
|
assert len(client.seed.split()) == length
|
||||||
|
|
||||||
|
|
||||||
|
# API key validation tests
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_api_key_raises() -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_api_key_raises() -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES)
|
||||||
|
|
||||||
|
|
||||||
|
# Cookie validation tests
|
||||||
|
|
||||||
|
|
||||||
|
def test_cookies_as_json_string() -> None:
|
||||||
|
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=json.dumps(VALID_COOKIES))
|
||||||
|
assert client.cookies == VALID_COOKIES
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_cookies_json_raises() -> None:
|
||||||
|
with pytest.raises(CookieError):
|
||||||
|
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies="{not valid json}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_cookie_key_raises() -> None:
|
||||||
|
with pytest.raises(CookieError):
|
||||||
|
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies={"stel_ssid": "x"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_cookie_value_raises() -> None:
|
||||||
|
bad = {**VALID_COOKIES, "stel_token": ""}
|
||||||
|
with pytest.raises(CookieError):
|
||||||
|
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_whitespace_cookie_value_raises() -> None:
|
||||||
|
bad = {**VALID_COOKIES, "stel_ton_token": " "}
|
||||||
|
with pytest.raises(CookieError):
|
||||||
|
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_repr() -> None:
|
||||||
|
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||||
|
r = repr(client)
|
||||||
|
assert "FragmentClient" in r
|
||||||
|
assert "V5R1" in r
|
||||||
|
assert "4 keys" in r
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_context_manager() -> None:
|
||||||
|
async with FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) as client:
|
||||||
|
assert isinstance(client, FragmentClient)
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Unit tests for process_transaction() — balance validation and broadcast retry logic."""
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from tonutils.exceptions import ProviderResponseError
|
||||||
|
|
||||||
|
from pyfragment.types import TransactionError, WalletError
|
||||||
|
from pyfragment.utils.wallet import process_transaction
|
||||||
|
from tests.shared import VALID_SEED
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_error(code: int, message: str = "error") -> ProviderResponseError:
|
||||||
|
return ProviderResponseError(code=code, message=message, endpoint="api.tonapi.io")
|
||||||
|
|
||||||
|
|
||||||
|
TRANSACTION_DATA = {
|
||||||
|
"transaction": {
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
|
||||||
|
"amount": "500000000", # 0.5 TON
|
||||||
|
"payload": "",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client() -> MagicMock:
|
||||||
|
client = MagicMock()
|
||||||
|
client.api_key = "test_key"
|
||||||
|
client.seed = VALID_SEED.split()
|
||||||
|
client.wallet_version = "V5R1"
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _make_wallet(balance_nanotons: int) -> MagicMock:
|
||||||
|
wallet = MagicMock()
|
||||||
|
wallet.refresh = AsyncMock()
|
||||||
|
wallet.balance = balance_nanotons
|
||||||
|
wallet.transfer = AsyncMock(return_value=MagicMock(normalized_hash="abc123"))
|
||||||
|
return wallet
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
||||||
|
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
||||||
|
):
|
||||||
|
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||||
|
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
# Balance threshold tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sufficient_balance_broadcasts() -> None:
|
||||||
|
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold
|
||||||
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"):
|
||||||
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
|
assert result == "abc123"
|
||||||
|
wallet.transfer.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_insufficient_balance_raises() -> None:
|
||||||
|
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, below threshold
|
||||||
|
with _patch_wallet(wallet):
|
||||||
|
with pytest.raises(WalletError, match="required"):
|
||||||
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
|
wallet.transfer.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exact_minimum_balance_broadcasts() -> None:
|
||||||
|
wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold
|
||||||
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"):
|
||||||
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
|
assert result == "abc123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_one_nanoton_below_minimum_raises() -> None:
|
||||||
|
wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanoton below transaction amount threshold
|
||||||
|
with _patch_wallet(wallet):
|
||||||
|
with pytest.raises(WalletError, match="required"):
|
||||||
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
|
|
||||||
|
|
||||||
|
# Error handling tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_payload_raises() -> None:
|
||||||
|
with pytest.raises(TransactionError):
|
||||||
|
await process_transaction(_make_client(), {"transaction": {}})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_messages_list_raises() -> None:
|
||||||
|
with pytest.raises(TransactionError):
|
||||||
|
await process_transaction(_make_client(), {"transaction": {"messages": []}})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_balance_check_failed_raises_wallet_error() -> None:
|
||||||
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||||
|
wallet.refresh = AsyncMock(side_effect=RuntimeError("network timeout"))
|
||||||
|
with _patch_wallet(wallet):
|
||||||
|
with pytest.raises(WalletError, match="balance"):
|
||||||
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
|
wallet.transfer.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_retries_and_succeeds() -> None:
|
||||||
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||||
|
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
|
||||||
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""):
|
||||||
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
|
assert result == "abc123"
|
||||||
|
assert wallet.transfer.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_duplicate_seqno_raises_after_retries() -> None:
|
||||||
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||||
|
err = _provider_error(406, "Duplicate msg_seqno")
|
||||||
|
wallet.transfer = AsyncMock(side_effect=[err, err, err])
|
||||||
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""):
|
||||||
|
with pytest.raises(TransactionError, match="seqno"):
|
||||||
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
|
assert wallet.transfer.call_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
@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._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.clean_decode", return_value=""),
|
||||||
|
patch("pyfragment.utils.wallet._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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""Unit tests for Stars methods — purchase_stars and giveaway_stars."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_purchase_stars_mod = importlib.import_module("pyfragment.methods.purchase_stars")
|
||||||
|
_giveaway_stars_mod = importlib.import_module("pyfragment.methods.giveaway_stars")
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.types import ConfigurationError, StarsGiveawayResult, StarsResult, UserNotFoundError
|
||||||
|
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||||
|
|
||||||
|
# Stars purchase validation tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.purchase_stars("@user", amount=49)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.purchase_stars("@user", amount=1_000_001)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_stars_float_amount(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_stars_invalid_payment_method(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError, match="Invalid payment method"):
|
||||||
|
await client.purchase_stars("@user", amount=500, payment_method="btc") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# Stars purchase mocked tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_stars_success(client: FragmentClient) -> None:
|
||||||
|
call_mock = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updateStarsBuyState
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
FAKE_TRANSACTION,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(client, "call", call_mock),
|
||||||
|
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch.object(_purchase_stars_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||||
|
):
|
||||||
|
result = await client.purchase_stars("@user", amount=500)
|
||||||
|
|
||||||
|
assert isinstance(result, StarsResult)
|
||||||
|
assert result.transaction_id == FAKE_TX_HASH
|
||||||
|
assert result.username == "@user"
|
||||||
|
assert result.amount == 500
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_stars_passes_payment_method(client: FragmentClient) -> None:
|
||||||
|
call_mock = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updateStarsBuyState
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
FAKE_TRANSACTION,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
|
||||||
|
with (
|
||||||
|
patch.object(client, "call", call_mock),
|
||||||
|
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch.object(_purchase_stars_mod, "process_transaction", proc_mock),
|
||||||
|
):
|
||||||
|
await client.purchase_stars("@user", amount=500, payment_method="usdt_ton")
|
||||||
|
|
||||||
|
init_call = call_mock.await_args_list[2]
|
||||||
|
assert init_call.args[0] == "initBuyStarsRequest"
|
||||||
|
assert init_call.args[1]["payment_method"] == "usdt_ton"
|
||||||
|
assert proc_mock.await_args is not None
|
||||||
|
assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("query", ["@user", "monk", "https://t.me/monk"])
|
||||||
|
async def test_purchase_stars_accepts_query_formats(client: FragmentClient, query: str) -> None:
|
||||||
|
call_mock = AsyncMock(return_value={"found": {}})
|
||||||
|
with patch.object(client, "call", call_mock):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await client.purchase_stars(query, amount=500)
|
||||||
|
|
||||||
|
search_call = call_mock.await_args_list[0]
|
||||||
|
assert search_call.args[0] == "searchStarsRecipient"
|
||||||
|
assert search_call.args[1]["query"] == query
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await client.purchase_stars("@ghost", amount=500)
|
||||||
|
|
||||||
|
|
||||||
|
# Stars giveaway validation tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_stars_winners_too_low(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.giveaway_stars("@channel", winners=0, amount=500)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_stars_winners_too_high(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.giveaway_stars("@channel", winners=6, amount=500)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_stars_amount_too_low(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.giveaway_stars("@channel", winners=1, amount=499)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.giveaway_stars("@channel", winners=1, amount=1_000_001)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_stars_float_winners(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.giveaway_stars("@channel", winners=1.5, amount=500) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_stars_float_amount(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.giveaway_stars("@channel", winners=1, amount=500.5) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_stars_invalid_payment_method(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError, match="Invalid payment method"):
|
||||||
|
await client.giveaway_stars("@channel", winners=1, amount=500, payment_method="btc") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# Stars giveaway mocked tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_stars_success(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
FAKE_TRANSACTION,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch.object(_giveaway_stars_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||||
|
):
|
||||||
|
result = await client.giveaway_stars("@channel", winners=3, amount=1000)
|
||||||
|
|
||||||
|
assert isinstance(result, StarsGiveawayResult)
|
||||||
|
assert result.transaction_id == FAKE_TX_HASH
|
||||||
|
assert result.channel == "@channel"
|
||||||
|
assert result.winners == 3
|
||||||
|
assert result.amount == 1000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> None:
|
||||||
|
call_mock = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
FAKE_TRANSACTION,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
|
||||||
|
with (
|
||||||
|
patch.object(client, "call", call_mock),
|
||||||
|
patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch.object(_giveaway_stars_mod, "process_transaction", proc_mock),
|
||||||
|
):
|
||||||
|
await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method="usdt_ton")
|
||||||
|
|
||||||
|
init_call = call_mock.await_args_list[1]
|
||||||
|
assert init_call.args[0] == "initGiveawayStarsRequest"
|
||||||
|
assert init_call.args[1]["payment_method"] == "usdt_ton"
|
||||||
|
assert proc_mock.await_args is not None
|
||||||
|
assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("query", ["@channel", "monk", "https://t.me/id2757542991"])
|
||||||
|
async def test_giveaway_stars_accepts_query_formats(client: FragmentClient, query: str) -> None:
|
||||||
|
call_mock = AsyncMock(return_value={"found": {}})
|
||||||
|
with patch.object(client, "call", call_mock):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await client.giveaway_stars(query, winners=1, amount=500)
|
||||||
|
|
||||||
|
search_call = call_mock.await_args_list[0]
|
||||||
|
assert search_call.args[0] == "searchStarsGiveawayRecipient"
|
||||||
|
assert search_call.args[1]["query"] == query
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await client.giveaway_stars("@ghost", winners=1, amount=500)
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Unit tests for Premium methods — purchase_premium and giveaway_premium."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_purchase_premium_mod = importlib.import_module("pyfragment.methods.purchase_premium")
|
||||||
|
_giveaway_premium_mod = importlib.import_module("pyfragment.methods.giveaway_premium")
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.types import ConfigurationError, PremiumGiveawayResult, PremiumResult, UserNotFoundError
|
||||||
|
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||||
|
|
||||||
|
# Premium purchase validation tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_premium_invalid_months(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.purchase_premium("@user", months=5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_premium_months_zero(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.purchase_premium("@user", months=0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_premium_invalid_payment_method(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError, match="Invalid payment method"):
|
||||||
|
await client.purchase_premium("@user", months=3, payment_method="btc") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# Premium purchase mocked tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_premium_success(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updatePremiumState
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
FAKE_TRANSACTION,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch.object(_purchase_premium_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||||
|
):
|
||||||
|
result = await client.purchase_premium("@user", months=3)
|
||||||
|
|
||||||
|
assert isinstance(result, PremiumResult)
|
||||||
|
assert result.transaction_id == FAKE_TX_HASH
|
||||||
|
assert result.username == "@user"
|
||||||
|
assert result.amount == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_premium_passes_payment_method(client: FragmentClient) -> None:
|
||||||
|
call_mock = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updatePremiumState
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
FAKE_TRANSACTION,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
|
||||||
|
with (
|
||||||
|
patch.object(client, "call", call_mock),
|
||||||
|
patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch.object(_purchase_premium_mod, "process_transaction", proc_mock),
|
||||||
|
):
|
||||||
|
await client.purchase_premium("@user", months=6, payment_method="usdt_ton")
|
||||||
|
|
||||||
|
init_call = call_mock.await_args_list[2]
|
||||||
|
assert init_call.args[0] == "initGiftPremiumRequest"
|
||||||
|
assert init_call.args[1]["payment_method"] == "usdt_ton"
|
||||||
|
assert proc_mock.await_args is not None
|
||||||
|
assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("query", ["@user", "monk", "https://t.me/monk"])
|
||||||
|
async def test_purchase_premium_accepts_query_formats(client: FragmentClient, query: str) -> None:
|
||||||
|
call_mock = AsyncMock(return_value={"found": {}})
|
||||||
|
with patch.object(client, "call", call_mock):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await client.purchase_premium(query, months=6)
|
||||||
|
|
||||||
|
search_call = call_mock.await_args_list[0]
|
||||||
|
assert search_call.args[0] == "searchPremiumGiftRecipient"
|
||||||
|
assert search_call.args[1]["query"] == query
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await client.purchase_premium("@ghost", months=3)
|
||||||
|
|
||||||
|
|
||||||
|
# Premium giveaway validation tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_premium_winners_too_low(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.giveaway_premium("@channel", winners=0, months=3)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.giveaway_premium("@channel", winners=24_001, months=3)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_premium_float_winners(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.giveaway_premium("@channel", winners=2.5, months=3) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_premium_invalid_months(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.giveaway_premium("@channel", winners=10, months=5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_premium_invalid_payment_method(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError, match="Invalid payment method"):
|
||||||
|
await client.giveaway_premium("@channel", winners=10, months=3, payment_method="btc") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# Premium giveaway mocked tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_premium_success(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
FAKE_TRANSACTION,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch.object(_giveaway_premium_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||||
|
):
|
||||||
|
result = await client.giveaway_premium("@channel", winners=10, months=3)
|
||||||
|
|
||||||
|
assert isinstance(result, PremiumGiveawayResult)
|
||||||
|
assert result.transaction_id == FAKE_TX_HASH
|
||||||
|
assert result.channel == "@channel"
|
||||||
|
assert result.winners == 10
|
||||||
|
assert result.amount == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_premium_passes_payment_method(client: FragmentClient) -> None:
|
||||||
|
call_mock = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
FAKE_TRANSACTION,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
|
||||||
|
with (
|
||||||
|
patch.object(client, "call", call_mock),
|
||||||
|
patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch.object(_giveaway_premium_mod, "process_transaction", proc_mock),
|
||||||
|
):
|
||||||
|
await client.giveaway_premium("@channel", winners=10, months=6, payment_method="usdt_ton")
|
||||||
|
|
||||||
|
init_call = call_mock.await_args_list[1]
|
||||||
|
assert init_call.args[0] == "initGiveawayPremiumRequest"
|
||||||
|
assert init_call.args[1]["payment_method"] == "usdt_ton"
|
||||||
|
assert proc_mock.await_args is not None
|
||||||
|
assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("query", ["@channel", "monk", "https://t.me/id2757542991"])
|
||||||
|
async def test_giveaway_premium_accepts_query_formats(client: FragmentClient, query: str) -> None:
|
||||||
|
call_mock = AsyncMock(return_value={"found": {}})
|
||||||
|
with patch.object(client, "call", call_mock):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await client.giveaway_premium(query, winners=10, months=3)
|
||||||
|
|
||||||
|
search_call = call_mock.await_args_list[0]
|
||||||
|
assert search_call.args[0] == "searchPremiumGiveawayRecipient"
|
||||||
|
assert search_call.args[1]["query"] == query
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await client.giveaway_premium("@ghost", winners=1, months=3)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Unit tests for topup_ton — TON Ads balance top-up."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_topup_ton_mod = importlib.import_module("pyfragment.methods.topup_ton")
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.types import AdsTopupResult, ConfigurationError, UserNotFoundError
|
||||||
|
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||||
|
|
||||||
|
# Topup TON validation tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_topup_ton_amount_zero(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.topup_ton("@user", amount=0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_topup_ton_amount_too_high(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.topup_ton("@user", amount=1_000_000_001)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_topup_ton_float_amount(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.topup_ton("@user", amount=1.5) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# Topup TON mocked tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_topup_ton_success(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{}, # updateAdsTopupState
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
FAKE_TRANSACTION,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(_topup_ton_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch.object(_topup_ton_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||||
|
):
|
||||||
|
result = await client.topup_ton("@user", amount=10)
|
||||||
|
|
||||||
|
assert isinstance(result, AdsTopupResult)
|
||||||
|
assert result.transaction_id == FAKE_TX_HASH
|
||||||
|
assert result.username == "@user"
|
||||||
|
assert result.amount == 10
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
|
||||||
|
with patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{}, # updateAdsTopupState
|
||||||
|
{"found": {}},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await client.topup_ton("@ghost", amount=10)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Unit tests for get_wallet() — wallet address/state with separate TON and USDT balances."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient, WalletInfo
|
||||||
|
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON
|
||||||
|
|
||||||
|
# Wallet mocked tests (TON and USDT balances are returned separately)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
|
||||||
|
mock_wallet = MagicMock()
|
||||||
|
mock_wallet.refresh = AsyncMock()
|
||||||
|
mock_wallet.balance = FAKE_BALANCE_NANOTON
|
||||||
|
mock_wallet.state = MagicMock(value="active")
|
||||||
|
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
||||||
|
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
||||||
|
patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=12.3456)),
|
||||||
|
):
|
||||||
|
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||||
|
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
|
||||||
|
|
||||||
|
result = await client.get_wallet()
|
||||||
|
|
||||||
|
assert isinstance(result, WalletInfo)
|
||||||
|
assert result.address == FAKE_ADDRESS
|
||||||
|
assert result.state == "active"
|
||||||
|
assert result.ton_balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4)
|
||||||
|
assert result.usdt_balance == 12.3456
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
|
||||||
|
mock_wallet = MagicMock()
|
||||||
|
mock_wallet.refresh = AsyncMock()
|
||||||
|
mock_wallet.balance = 0
|
||||||
|
mock_wallet.state = MagicMock(value="uninit")
|
||||||
|
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
||||||
|
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
||||||
|
patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=0.0)),
|
||||||
|
):
|
||||||
|
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||||
|
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
|
||||||
|
|
||||||
|
result = await client.get_wallet()
|
||||||
|
|
||||||
|
assert result.ton_balance == 0.0
|
||||||
|
assert result.usdt_balance == 0.0
|
||||||
|
assert result.state == "uninit"
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Unit tests for FragmentClient.call() — raw Fragment API access."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.types import FragmentPageError
|
||||||
|
from pyfragment.utils.http import fragment_request
|
||||||
|
from tests.shared import FAKE_HASH, FAKE_RESPONSE
|
||||||
|
|
||||||
|
# client.call() mocked tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_returns_api_response(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
||||||
|
):
|
||||||
|
result = await client.call("anyMethod", {"key": "value"})
|
||||||
|
|
||||||
|
assert result == FAKE_RESPONSE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_default_page_url(client: FragmentClient) -> None:
|
||||||
|
"""call() works without explicitly passing page_url (defaults to FRAGMENT_BASE_URL)."""
|
||||||
|
with (
|
||||||
|
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
||||||
|
):
|
||||||
|
result = await client.call("anyMethod")
|
||||||
|
|
||||||
|
assert result == FAKE_RESPONSE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_no_data(client: FragmentClient) -> None:
|
||||||
|
"""call() with no extra data passes only the method field."""
|
||||||
|
mock_request = AsyncMock(return_value={})
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.client.fragment_request", mock_request),
|
||||||
|
):
|
||||||
|
await client.call("anyMethod")
|
||||||
|
|
||||||
|
_, _, _, sent_data = mock_request.call_args.args
|
||||||
|
assert sent_data == {"method": "anyMethod"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
||||||
|
"""call() merges caller-supplied data with the method field."""
|
||||||
|
mock_request = AsyncMock(return_value={})
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.client.fragment_request", mock_request),
|
||||||
|
):
|
||||||
|
await client.call("anyMethod", {"key": "value", "num": 7})
|
||||||
|
|
||||||
|
_, _, _, sent_data = mock_request.call_args.args
|
||||||
|
assert sent_data == {"method": "anyMethod", "key": "value", "num": 7}
|
||||||
|
|
||||||
|
|
||||||
|
# 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"})
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Unit tests for anonymous number methods — login codes and session management."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyfragment import AnonymousNumberError, FragmentClient, LoginCodeResult, TerminateSessionsResult
|
||||||
|
from tests.shared import FAKE_HTML_NO_CODE, FAKE_HTML_WITH_CODE, FAKE_TERMINATE_HASH
|
||||||
|
|
||||||
|
# get_login_code mocked tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_login_code_returns_code(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={"html": FAKE_HTML_WITH_CODE})):
|
||||||
|
result = await client.get_login_code("+1234567890")
|
||||||
|
|
||||||
|
assert isinstance(result, LoginCodeResult)
|
||||||
|
assert result.number == "+1234567890"
|
||||||
|
assert result.code == "12345"
|
||||||
|
assert result.active_sessions == 2 # 2 <tr> in the HTML
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_login_code_no_pending_code(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={"html": FAKE_HTML_NO_CODE})):
|
||||||
|
result = await client.get_login_code("1234567890")
|
||||||
|
|
||||||
|
assert result.code is None
|
||||||
|
assert result.active_sessions == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_login_code_no_html_returns_none(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={})):
|
||||||
|
result = await client.get_login_code("+1234567890")
|
||||||
|
|
||||||
|
assert result.code is None
|
||||||
|
assert result.active_sessions == 0
|
||||||
|
|
||||||
|
|
||||||
|
# terminate_sessions mocked tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terminate_sessions_success(client: FragmentClient) -> None:
|
||||||
|
with patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"terminate_hash": FAKE_TERMINATE_HASH}, # step 1: confirmation
|
||||||
|
{"msg": "All sessions terminated"}, # step 2: confirmed
|
||||||
|
]
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await client.terminate_sessions("+1234567890")
|
||||||
|
|
||||||
|
assert isinstance(result, TerminateSessionsResult)
|
||||||
|
assert result.number == "+1234567890"
|
||||||
|
assert result.message == "All sessions terminated"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terminate_sessions_not_owned_raises(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={})):
|
||||||
|
with pytest.raises(AnonymousNumberError, match="not associated"):
|
||||||
|
await client.terminate_sessions("+1234567890")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terminate_sessions_api_error_raises(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={"error": "SESSION_ALREADY_TERMINATED"})):
|
||||||
|
with pytest.raises(AnonymousNumberError, match="SESSION_ALREADY_TERMINATED"):
|
||||||
|
await client.terminate_sessions("+1234567890")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terminate_sessions_confirm_error_raises(client: FragmentClient) -> None:
|
||||||
|
with patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"terminate_hash": FAKE_TERMINATE_HASH},
|
||||||
|
{"error": "INTERNAL_ERROR"},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(AnonymousNumberError, match="INTERNAL_ERROR"):
|
||||||
|
await client.terminate_sessions("+1234567890")
|
||||||
|
|
||||||
|
|
||||||
|
# toggle_login_codes mocked tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle_login_codes_enable(client: FragmentClient) -> None:
|
||||||
|
mock_call = AsyncMock(return_value={"ok": True})
|
||||||
|
with patch.object(client, "call", mock_call):
|
||||||
|
await client.toggle_login_codes("+1234567890", can_receive=True)
|
||||||
|
|
||||||
|
call_data = mock_call.call_args[0][1]
|
||||||
|
assert call_data["can_receive"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle_login_codes_disable(client: FragmentClient) -> None:
|
||||||
|
mock_call = AsyncMock(return_value={"ok": True})
|
||||||
|
with patch.object(client, "call", mock_call):
|
||||||
|
await client.toggle_login_codes("+1234567890", can_receive=False)
|
||||||
|
|
||||||
|
call_data = mock_call.call_args[0][1]
|
||||||
|
assert call_data["can_receive"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# strip_plus mocked tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_login_code_strips_plus(client: FragmentClient) -> None:
|
||||||
|
"""Number passed with '+' is stripped before the API call."""
|
||||||
|
mock_call = AsyncMock(return_value={})
|
||||||
|
with patch.object(client, "call", mock_call):
|
||||||
|
await client.get_login_code("+1234567890")
|
||||||
|
|
||||||
|
call_data = mock_call.call_args[0][1]
|
||||||
|
assert call_data["number"] == "1234567890"
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Unit tests for recharge_ads — self-service Telegram Ads recharge."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_recharge_ads_mod = importlib.import_module("pyfragment.methods.recharge_ads")
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.types import AdsRechargeResult, ConfigurationError
|
||||||
|
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||||
|
|
||||||
|
# recharge_ads validation tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recharge_ads_amount_zero(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=1_000_000_001)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recharge_ads_float_amount(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=5.5) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# recharge_ads mocked tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recharge_ads_success(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{}, # updateAdsState
|
||||||
|
{"req_id": FAKE_REQ_ID}, # initAdsRechargeRequest
|
||||||
|
FAKE_TRANSACTION, # getAdsRechargeLink
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch.object(_recharge_ads_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||||
|
):
|
||||||
|
result = await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
|
||||||
|
|
||||||
|
assert isinstance(result, AdsRechargeResult)
|
||||||
|
assert result.transaction_id == FAKE_TX_HASH
|
||||||
|
assert result.amount == 10
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Unit tests for search_usernames — Fragment marketplace username search."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.types import UsernamesResult
|
||||||
|
|
||||||
|
FAKE_HTML = """
|
||||||
|
<tr class="tm-row-selectable">
|
||||||
|
<td><a href="/username/coolname" class="table-cell">
|
||||||
|
<div class="table-cell-value tm-value">@coolname</div>
|
||||||
|
<div class="table-cell-status-thin thin-only tm-status-avail">On auction</div>
|
||||||
|
</a></td>
|
||||||
|
<td><div class="table-cell-value tm-value icon-before icon-ton">5</div>
|
||||||
|
<time datetime="2026-06-01T12:00:00+00:00" data-relative="text">2 days</time>
|
||||||
|
</td>
|
||||||
|
<td><div class="table-cell-value tm-value tm-status-avail">On auction</div></td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# search_usernames result parsing tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_usernames_basic(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})):
|
||||||
|
result = await client.search_usernames("coolname")
|
||||||
|
|
||||||
|
assert isinstance(result, UsernamesResult)
|
||||||
|
assert len(result.items) == 1
|
||||||
|
assert result.items[0]["slug"] == "username/coolname"
|
||||||
|
assert result.items[0]["name"] == "@coolname"
|
||||||
|
assert result.items[0]["date"] == "2026-06-01T12:00:00+00:00"
|
||||||
|
assert result.next_offset_id is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_usernames_empty_html(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
|
||||||
|
result = await client.search_usernames("zzz_no_results")
|
||||||
|
|
||||||
|
assert isinstance(result, UsernamesResult)
|
||||||
|
assert result.items == []
|
||||||
|
assert result.next_offset_id is None
|
||||||
|
|
||||||
|
|
||||||
|
# search_usernames parameter forwarding tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_usernames_with_sort_and_filter(client: FragmentClient) -> None:
|
||||||
|
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
|
||||||
|
with patch.object(client, "call", mock_call):
|
||||||
|
result = await client.search_usernames("durov", sort="price_desc", filter="auction")
|
||||||
|
|
||||||
|
assert isinstance(result, UsernamesResult)
|
||||||
|
call_data = mock_call.call_args[0][1]
|
||||||
|
assert call_data["type"] == "usernames"
|
||||||
|
assert call_data["sort"] == "price_desc"
|
||||||
|
assert call_data["filter"] == "auction"
|
||||||
|
assert call_data["query"] == "durov"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_usernames_with_offset_id(client: FragmentClient) -> None:
|
||||||
|
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"})
|
||||||
|
with patch.object(client, "call", mock_call):
|
||||||
|
result = await client.search_usernames("durov", offset_id="offset_10")
|
||||||
|
|
||||||
|
assert isinstance(result, UsernamesResult)
|
||||||
|
assert result.next_offset_id == "offset_99"
|
||||||
|
call_data = mock_call.call_args[0][1]
|
||||||
|
assert call_data["offset_id"] == "offset_10"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_usernames_default_query(client: FragmentClient) -> None:
|
||||||
|
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
|
||||||
|
with patch.object(client, "call", mock_call):
|
||||||
|
result = await client.search_usernames()
|
||||||
|
|
||||||
|
assert isinstance(result, UsernamesResult)
|
||||||
|
call_data = mock_call.call_args[0][1]
|
||||||
|
assert call_data["query"] == ""
|
||||||
|
assert call_data["type"] == "usernames"
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Unit tests for search_numbers — Fragment marketplace number search."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.types import NumbersResult
|
||||||
|
|
||||||
|
FAKE_HTML = """
|
||||||
|
<tr class="tm-row-selectable">
|
||||||
|
<td><a href="/number/8880000888" class="table-cell">
|
||||||
|
<div class="table-cell-value tm-value">+888 0000 888</div>
|
||||||
|
<div class="table-cell-status-thin thin-only tm-status-avail">For sale</div>
|
||||||
|
</a></td>
|
||||||
|
<td><div class="table-cell-value tm-value icon-before icon-ton">150</div>
|
||||||
|
<time datetime="2026-05-15T10:00:00+00:00" data-relative="short-text">May 15</time>
|
||||||
|
</td>
|
||||||
|
<td><div class="table-cell-value tm-value tm-status-avail">For sale</div></td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# search_numbers result parsing tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_numbers_basic(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})):
|
||||||
|
result = await client.search_numbers("888")
|
||||||
|
|
||||||
|
assert isinstance(result, NumbersResult)
|
||||||
|
assert len(result.items) == 1
|
||||||
|
assert result.items[0]["slug"] == "number/8880000888"
|
||||||
|
assert result.items[0]["name"] == "+888 0000 888"
|
||||||
|
assert result.items[0]["date"] == "2026-05-15T10:00:00+00:00"
|
||||||
|
assert result.next_offset_id is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_numbers_empty_html(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
|
||||||
|
result = await client.search_numbers("zzz_no_results")
|
||||||
|
|
||||||
|
assert isinstance(result, NumbersResult)
|
||||||
|
assert result.items == []
|
||||||
|
assert result.next_offset_id is None
|
||||||
|
|
||||||
|
|
||||||
|
# search_numbers parameter forwarding tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_numbers_with_sort_and_filter(client: FragmentClient) -> None:
|
||||||
|
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
|
||||||
|
with patch.object(client, "call", mock_call):
|
||||||
|
result = await client.search_numbers("888", sort="price_asc", filter="sale")
|
||||||
|
|
||||||
|
assert isinstance(result, NumbersResult)
|
||||||
|
call_data = mock_call.call_args[0][1]
|
||||||
|
assert call_data["type"] == "numbers"
|
||||||
|
assert call_data["sort"] == "price_asc"
|
||||||
|
assert call_data["filter"] == "sale"
|
||||||
|
assert call_data["query"] == "888"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_numbers_with_offset_id(client: FragmentClient) -> None:
|
||||||
|
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_50"})
|
||||||
|
with patch.object(client, "call", mock_call):
|
||||||
|
result = await client.search_numbers("888", offset_id="offset_50")
|
||||||
|
|
||||||
|
assert isinstance(result, NumbersResult)
|
||||||
|
assert result.next_offset_id == "offset_50"
|
||||||
|
call_data = mock_call.call_args[0][1]
|
||||||
|
assert call_data["offset_id"] == "offset_50"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_numbers_default_query(client: FragmentClient) -> None:
|
||||||
|
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
|
||||||
|
with patch.object(client, "call", mock_call):
|
||||||
|
result = await client.search_numbers()
|
||||||
|
|
||||||
|
assert isinstance(result, NumbersResult)
|
||||||
|
call_data = mock_call.call_args[0][1]
|
||||||
|
assert call_data["query"] == ""
|
||||||
|
assert call_data["type"] == "numbers"
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Unit tests for search_gifts — Fragment gifts marketplace search."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.types import GiftsResult
|
||||||
|
|
||||||
|
FAKE_GIFTS_HTML = """
|
||||||
|
<div class="tm-catalog-grid">
|
||||||
|
<a href="/gift/plushpepe-1821?collection=all" class="tm-grid-item">
|
||||||
|
<div class="tm-grid-item-thumb">
|
||||||
|
<img src="https://nft.fragment.com/gift/plushpepe-1821.medium.jpg" class="tm-grid-thumb"/>
|
||||||
|
</div>
|
||||||
|
<div class="tm-grid-item-content">
|
||||||
|
<div class="tm-grid-item-name wide-only">
|
||||||
|
<span class="item-name">Plush Pepe</span>
|
||||||
|
<span class="item-num"> #1821</span>
|
||||||
|
</div>
|
||||||
|
<div class="tm-grid-item-desc wide-only">
|
||||||
|
<time datetime="2026-02-05T14:41:27+00:00" class="short">Feb 5 at 16:41</time>
|
||||||
|
</div>
|
||||||
|
<div class="tm-grid-item-values">
|
||||||
|
<div class="tm-grid-item-value tm-value icon-before icon-ton">88,888</div>
|
||||||
|
<div class="tm-grid-item-status tm-status-unavail">Sold</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="/gift/swisswatch-7799?collection=all" class="tm-grid-item">
|
||||||
|
<div class="tm-grid-item-content">
|
||||||
|
<div class="tm-grid-item-name wide-only">
|
||||||
|
<span class="item-name">Swiss Watch</span>
|
||||||
|
<span class="item-num"> #7799</span>
|
||||||
|
</div>
|
||||||
|
<div class="tm-grid-item-desc wide-only">
|
||||||
|
<time datetime="2026-01-10T04:52:59+00:00" class="short">Jan 10 at 06:52</time>
|
||||||
|
</div>
|
||||||
|
<div class="tm-grid-item-values">
|
||||||
|
<div class="tm-grid-item-value tm-value icon-before icon-ton">13,588</div>
|
||||||
|
<div class="tm-grid-item-status tm-status-unavail">Sold</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a class="tm-catalog-grid-more js-load-more" data-next-offset="60">Show more</a>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# search_gifts result parsing tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_gifts_basic(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})):
|
||||||
|
result = await client.search_gifts()
|
||||||
|
|
||||||
|
assert isinstance(result, GiftsResult)
|
||||||
|
assert len(result.items) == 2
|
||||||
|
assert result.items[0]["slug"] == "gift/plushpepe-1821"
|
||||||
|
assert result.items[0]["name"] == "Plush Pepe #1821"
|
||||||
|
assert result.items[0]["status"] == "Sold"
|
||||||
|
assert result.items[0]["price"] == "88888.00"
|
||||||
|
assert result.items[0]["date"] == "2026-02-05T14:41:27+00:00"
|
||||||
|
assert result.items[1]["slug"] == "gift/swisswatch-7799"
|
||||||
|
assert result.items[1]["price"] == "13588.00"
|
||||||
|
assert result.next_offset == 60
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_gifts_empty(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
|
||||||
|
result = await client.search_gifts(query="zzz_no_results")
|
||||||
|
|
||||||
|
assert isinstance(result, GiftsResult)
|
||||||
|
assert result.items == []
|
||||||
|
assert result.next_offset is None
|
||||||
|
|
||||||
|
|
||||||
|
# search_gifts parameter forwarding tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_gifts_with_collection_and_sort(client: FragmentClient) -> None:
|
||||||
|
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
|
||||||
|
with patch.object(client, "call", mock_call):
|
||||||
|
result = await client.search_gifts(collection="plushpepe", sort="price_desc", filter="sold")
|
||||||
|
|
||||||
|
assert isinstance(result, GiftsResult)
|
||||||
|
call_data = mock_call.call_args[0][1]
|
||||||
|
assert call_data["collection"] == "plushpepe"
|
||||||
|
assert call_data["sort"] == "price_desc"
|
||||||
|
assert call_data["filter"] == "sold"
|
||||||
|
assert call_data["type"] == "gifts"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_gifts_with_offset(client: FragmentClient) -> None:
|
||||||
|
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
|
||||||
|
with patch.object(client, "call", mock_call):
|
||||||
|
result = await client.search_gifts(offset=60)
|
||||||
|
|
||||||
|
assert isinstance(result, GiftsResult)
|
||||||
|
call_data = mock_call.call_args[0][1]
|
||||||
|
assert call_data["offset"] == 60
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_gifts_with_view(client: FragmentClient) -> None:
|
||||||
|
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
|
||||||
|
with patch.object(client, "call", mock_call):
|
||||||
|
result = await client.search_gifts(collection="artisanbrick", view="Model")
|
||||||
|
|
||||||
|
assert isinstance(result, GiftsResult)
|
||||||
|
call_data = mock_call.call_args[0][1]
|
||||||
|
assert call_data["view"] == "Model"
|
||||||
|
assert call_data["collection"] == "artisanbrick"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_gifts_with_attr(client: FragmentClient) -> None:
|
||||||
|
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
|
||||||
|
with patch.object(client, "call", mock_call):
|
||||||
|
result = await client.search_gifts(
|
||||||
|
collection="artisanbrick",
|
||||||
|
sort="listed",
|
||||||
|
filter="auction",
|
||||||
|
attr={
|
||||||
|
"Model": ["Delicate Wash", "Foosball", "Chocolate"],
|
||||||
|
"Backdrop": ["Celtic Blue", "Carrot Juice", "Orange"],
|
||||||
|
"Symbol": ["Crystal Ball", "Tetsubin", "Acorn"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, GiftsResult)
|
||||||
|
call_data = mock_call.call_args[0][1]
|
||||||
|
assert call_data["attr[Model]"] == ["Delicate Wash", "Foosball", "Chocolate"]
|
||||||
|
assert call_data["attr[Backdrop]"] == ["Celtic Blue", "Carrot Juice", "Orange"]
|
||||||
|
assert call_data["attr[Symbol]"] == ["Crystal Ball", "Tetsubin", "Acorn"]
|
||||||
|
assert call_data["collection"] == "artisanbrick"
|
||||||
|
assert call_data["sort"] == "listed"
|
||||||
|
assert call_data["filter"] == "auction"
|
||||||
|
assert call_data["type"] == "gifts"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_gifts_attr_not_in_data_when_none(client: FragmentClient) -> None:
|
||||||
|
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
|
||||||
|
with patch.object(client, "call", mock_call):
|
||||||
|
result = await client.search_gifts()
|
||||||
|
|
||||||
|
assert isinstance(result, GiftsResult)
|
||||||
|
call_data = mock_call.call_args[0][1]
|
||||||
|
assert "view" not in call_data
|
||||||
|
assert not any(k.startswith("attr[") for k in call_data)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Unit tests for get_cookies_from_browser() — browser cookie extraction helper."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyfragment.types import CookieError
|
||||||
|
from pyfragment.types.constants import REQUIRED_COOKIE_KEYS
|
||||||
|
from pyfragment.utils import get_cookies_from_browser
|
||||||
|
|
||||||
|
FAKE_JAR = [
|
||||||
|
{"name": "stel_ssid", "value": "abc123", "domain": "fragment.com", "expires": "2027-04-03T20:52:16.375Z"},
|
||||||
|
{"name": "stel_dt", "value": "-120", "domain": "fragment.com"},
|
||||||
|
{"name": "stel_token", "value": "tok_xyz", "domain": "fragment.com"},
|
||||||
|
{"name": "stel_ton_token", "value": "ton_xyz", "domain": "fragment.com"},
|
||||||
|
{"name": "unrelated", "value": "noise", "domain": "fragment.com"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_rookiepy(jar: list[dict[str, str]] | None = None) -> MagicMock:
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.chrome.return_value = jar if jar is not None else FAKE_JAR
|
||||||
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
PATCH = "pyfragment.utils.cookies.rookiepy"
|
||||||
|
|
||||||
|
|
||||||
|
# unsupported browser tests
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_browser_raises() -> None:
|
||||||
|
with pytest.raises(CookieError, match="Unsupported browser"):
|
||||||
|
get_cookies_from_browser("internet_explorer")
|
||||||
|
|
||||||
|
|
||||||
|
# successful extraction tests
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_required_keys_only() -> None:
|
||||||
|
with patch(PATCH, _mock_rookiepy()):
|
||||||
|
result = get_cookies_from_browser("chrome")
|
||||||
|
|
||||||
|
assert set(result.cookies.keys()) == set(REQUIRED_COOKIE_KEYS)
|
||||||
|
assert result.cookies["stel_ssid"] == "abc123"
|
||||||
|
assert result.cookies["stel_dt"] == "-120"
|
||||||
|
assert result.cookies["stel_token"] == "tok_xyz"
|
||||||
|
assert result.cookies["stel_ton_token"] == "ton_xyz"
|
||||||
|
assert "unrelated" not in result.cookies
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_expiry() -> None:
|
||||||
|
with patch(PATCH, _mock_rookiepy()):
|
||||||
|
result = get_cookies_from_browser("chrome")
|
||||||
|
|
||||||
|
assert result.expires == "2027-04-03T20:52:16.375000+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_none_expiry_when_missing() -> None:
|
||||||
|
jar = [{"name": k, "value": "v"} for k in REQUIRED_COOKIE_KEYS]
|
||||||
|
with patch(PATCH, _mock_rookiepy(jar)):
|
||||||
|
result = get_cookies_from_browser("chrome")
|
||||||
|
|
||||||
|
assert result.expires is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_browser_is_chrome() -> None:
|
||||||
|
mock_rp = _mock_rookiepy()
|
||||||
|
with patch(PATCH, mock_rp):
|
||||||
|
get_cookies_from_browser()
|
||||||
|
|
||||||
|
mock_rp.chrome.assert_called_once_with(["fragment.com"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_browser_name_is_case_insensitive() -> None:
|
||||||
|
mock_rp = _mock_rookiepy()
|
||||||
|
with patch(PATCH, mock_rp):
|
||||||
|
result = get_cookies_from_browser("Chrome")
|
||||||
|
|
||||||
|
assert result.cookies["stel_ssid"] == "abc123"
|
||||||
|
|
||||||
|
|
||||||
|
# missing cookies tests
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_cookies_raises() -> None:
|
||||||
|
partial_jar = [
|
||||||
|
{"name": "stel_ssid", "value": "abc123"},
|
||||||
|
{"name": "stel_dt", "value": "-120"},
|
||||||
|
# stel_token and stel_ton_token missing
|
||||||
|
]
|
||||||
|
with patch(PATCH, _mock_rookiepy(partial_jar)):
|
||||||
|
with pytest.raises(CookieError, match="Fragment cookies not found in chrome"):
|
||||||
|
get_cookies_from_browser("chrome")
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_cookie_value_treated_as_missing() -> None:
|
||||||
|
jar_with_empty = [
|
||||||
|
{"name": "stel_ssid", "value": "abc123"},
|
||||||
|
{"name": "stel_dt", "value": ""}, # empty — should be treated as missing
|
||||||
|
{"name": "stel_token", "value": "tok_xyz"},
|
||||||
|
{"name": "stel_ton_token", "value": "ton_xyz"},
|
||||||
|
]
|
||||||
|
with patch(PATCH, _mock_rookiepy(jar_with_empty)):
|
||||||
|
with pytest.raises(CookieError, match="Fragment cookies not found in chrome"):
|
||||||
|
get_cookies_from_browser("chrome")
|
||||||
|
|
||||||
|
|
||||||
|
# expired cookie tests
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_cookie_raises() -> None:
|
||||||
|
expired_jar = [
|
||||||
|
{"name": "stel_ssid", "value": "abc123", "expires": "2020-01-01T00:00:00.000Z"},
|
||||||
|
{"name": "stel_dt", "value": "-120"},
|
||||||
|
{"name": "stel_token", "value": "tok_xyz"},
|
||||||
|
{"name": "stel_ton_token", "value": "ton_xyz"},
|
||||||
|
]
|
||||||
|
with patch(PATCH, _mock_rookiepy(expired_jar)):
|
||||||
|
with pytest.raises(CookieError, match="expired"):
|
||||||
|
get_cookies_from_browser("chrome")
|
||||||
|
|
||||||
|
|
||||||
|
# read failure tests
|
||||||
|
|
||||||
|
|
||||||
|
def test_browser_read_error_raises() -> None:
|
||||||
|
mock_rp = MagicMock()
|
||||||
|
mock_rp.chrome.side_effect = PermissionError("locked")
|
||||||
|
with patch(PATCH, mock_rp):
|
||||||
|
with pytest.raises(CookieError, match="Failed to read chrome cookies"):
|
||||||
|
get_cookies_from_browser("chrome")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Unit tests for init payment amount parsing."""
|
||||||
|
|
||||||
|
from pyfragment.utils.html 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
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import pyfragment.methods.giveaway_premium # noqa: F401
|
||||||
|
import pyfragment.methods.giveaway_stars # noqa: F401
|
||||||
|
import pyfragment.methods.purchase_premium # noqa: F401
|
||||||
|
import pyfragment.methods.purchase_stars # noqa: F401
|
||||||
|
import pyfragment.methods.recharge_ads # noqa: F401
|
||||||
|
import pyfragment.methods.topup_ton # noqa: F401
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cookies() -> dict[str, str]:
|
||||||
|
"""Load Fragment cookies from COOKIES_JSON env var; skip if unavailable."""
|
||||||
|
raw = os.environ.get("COOKIES_JSON")
|
||||||
|
if not raw:
|
||||||
|
pytest.skip("COOKIES_JSON env var not set")
|
||||||
|
try:
|
||||||
|
return cast(dict[str, str], json.loads(raw))
|
||||||
|
except Exception as exc:
|
||||||
|
pytest.skip(f"Cookies unavailable — {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> FragmentClient:
|
||||||
|
"""Pre-built FragmentClient with dummy credentials."""
|
||||||
|
return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Shared test constants for the pyfragment test suite.
|
||||||
|
|
||||||
|
pyfragment is an async Python client for the Fragment API — a unified toolkit
|
||||||
|
to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances,
|
||||||
|
run giveaways, manage anonymous numbers, and explore the marketplace for usernames,
|
||||||
|
numbers, and gifts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Credentials and config
|
||||||
|
VALID_SEED: str = "abandon " * 23 + "about"
|
||||||
|
VALID_API_KEY: str = "A" * 68
|
||||||
|
VALID_COOKIES: dict[str, str] = {
|
||||||
|
"stel_ssid": "x",
|
||||||
|
"stel_dt": "x",
|
||||||
|
"stel_token": "x",
|
||||||
|
"stel_ton_token": "x",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generic test data
|
||||||
|
FAKE_HASH: str = "abc123"
|
||||||
|
FAKE_RECIPIENT: str = "recipient_token"
|
||||||
|
FAKE_REQ_ID: str = "req_42"
|
||||||
|
FAKE_TX_HASH: str = "deadbeef" * 8
|
||||||
|
FAKE_ACCOUNT: dict[str, Any] = {"address": "0:abc", "publicKey": "pub", "chain": "-239", "walletStateInit": "base64=="}
|
||||||
|
FAKE_TRANSACTION: dict[str, Any] = {"transaction": {"messages": [{"address": "0:abc", "amount": "100000000", "payload": ""}]}}
|
||||||
|
|
||||||
|
# client.call()
|
||||||
|
FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}}
|
||||||
|
|
||||||
|
# get_wallet()
|
||||||
|
FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5"
|
||||||
|
FAKE_BALANCE_NANOTON: int = 1_500_000_000 # 1.5 TON
|
||||||
|
|
||||||
|
# recharge_ads
|
||||||
|
FAKE_ADS_ACCOUNT: str = "@mychannel"
|
||||||
|
|
||||||
|
# Revenue withdrawals
|
||||||
|
FAKE_WITHDRAWAL_WALLET: str = "EQDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
FAKE_REVENUE_TX: str = "revenue_tx_abc123"
|
||||||
|
|
||||||
|
# Anonymous number
|
||||||
|
FAKE_HTML_WITH_CODE: str = """
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td class="table-cell-value">12345</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>session data</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
"""
|
||||||
|
FAKE_HTML_NO_CODE: str = "<table><tr><td>no code here</td></tr></table>"
|
||||||
|
FAKE_TERMINATE_HASH: str = "terminate_hash_abc123"
|
||||||
Reference in New Issue
Block a user