25 Commits

Author SHA1 Message Date
Bohdan 👾 eec59f7e12 Merge pull request #20 from bohd4nx/dev
Dev
2026-05-03 19:21:35 +03:00
bohd4nx c1ae5f0f28 fix: update download-artifact action version to v8 for consistency 2026-05-03 19:20:30 +03:00
bohd4nx 4873d6d6dd fix: update PyPI publish action to use the latest release version
Co-authored-by: Copilot <copilot@github.com>
2026-05-03 19:19:54 +03:00
Bohdan 👾 1f6085acdc Merge pull request #18 from bohd4nx/dev
Dev
2026-05-03 19:16:19 +03:00
bohd4nx d5dfed5f1a fix: update CI and publish workflows to use consistent action versions and improve error handling in API requests
Co-authored-by: Copilot <copilot@github.com>
2026-05-03 19:10:11 +03:00
bohd4nx 7923dff8b2 fix: remove unnecessary 'method' key from API request data and improve error handling for expired cookies and non-200 HTTP responses 2026-04-21 20:06:11 +03:00
bohd4nx 8c7423a6ab fix: update Python version in CI workflow and refine project metadata for clarity 2026-04-18 21:11:14 +03:00
bohd4nx 8f3e9f4bd8 chore: update action versions in CI workflow and adjust linter ignores for compatibility 2026-04-18 21:05:12 +03:00
bohd4nx fca60135a6 refactor: update type hints for better clarity and consistency across the codebase 2026-04-18 21:02:53 +03:00
bohd4nx d66602b646 fix: replace Miniconda setup with Python setup in CI workflow for consistency 2026-04-14 02:03:22 +03:00
bohd4nx 333b6f45ce fix: update Miniconda setup action version and adjust activation settings 2026-04-14 02:01:16 +03:00
Bohdan 👾 1a070ce8b9 Merge pull request #13 from bohd4nx/dev
feat: browser cookie extraction, CookieResult type, Python 3.10+ support
2026-04-14 02:00:46 +03:00
bohd4nx 415d9d9a9c docs: update examples and changelog to reflect CookieResult usage and Python version changes 2026-04-14 01:57:12 +03:00
bohd4nx 6a5e007a1b refactor: update import statements and patching methods in test files for better modularity 2026-04-14 01:45:04 +03:00
bohd4nx d81b83ac91 fix: reorder import statements in conftest.py for better organization 2026-04-14 01:37:52 +03:00
bohd4nx 6b6ca37f10 fix: update Python version requirements to support 3.10 and adjust CI workflow 2026-04-14 01:34:27 +03:00
bohd4nx 028984155b fix: update Python version matrix in CI workflow to include 3.10, 3.11, and 3.12 2026-04-14 01:30:52 +03:00
bohd4nx 391d15221b feat: enhance cookie handling by introducing CookieResult and updating get_cookies_from_browser to return structured results 2026-04-14 01:25:25 +03:00
bohd4nx 269551da2f feat: Update CI workflows, add changelog extraction, and enhance README with cookie retrieval methods 2026-04-14 01:13:45 +03:00
bohd4nx ebd45d2991 Add unit tests for FragmentClient methods
- Implement tests for Stars methods: purchase_stars and giveaway_stars, including validation and success scenarios.
- Add tests for Premium methods: purchase_premium and giveaway_premium, covering validation and success cases.
- Create tests for topup_ton functionality, validating input and mocking successful operations.
- Introduce wallet tests to verify get_wallet() functionality, ensuring correct wallet info retrieval.
- Add tests for FragmentClient.call() method, validating API response handling and data merging.
- Implement tests for anonymous number methods, including login codes and session management.
- Create recharge_ads tests for self-service Telegram Ads recharge, validating input and success scenarios.
- Add username search tests for the Fragment marketplace, ensuring correct result parsing and parameter forwarding.
- Implement number search tests for the Fragment marketplace, validating result parsing and parameter handling.
- Create gift search tests for the Fragment gifts marketplace, ensuring correct result parsing and parameter forwarding.
- Add cookie extraction tests for get_cookies_from_browser(), validating successful extraction and error handling.
2026-04-14 00:22:48 +03:00
bohd4nx 733d138fcc feat: Enhance cookie handling and browser support
- Added new exceptions in `exceptions.py` for unsupported browsers and cookie read failures.
- Introduced `get_cookies_from_browser` function in `cookies.py` to extract session cookies from various browsers.
- Modified tests to cover new cookie extraction functionality, including success and failure cases.
- Refactored existing tests to use `patch.object` for mocking client calls.
- Updated dependencies in `pyproject.toml` to include `rookiepy` for cookie extraction.
2026-04-14 00:12:22 +03:00
bohd4nx 503baa9a94 ci: update test job to use matrix strategy for multiple Python versions and switch to conda setup
fix: specify type for headers parameter in execute_transaction_request
fix: specify type for transaction_data parameter in process_transaction
deps: update tonutils dependency version to 2.0.5
2026-04-07 00:52:39 +03:00
bohd4nx d4ac44f698 style: remove unnecessary blank line in test_hash.py 2026-03-25 18:35:09 +02:00
bohd4nx 3aedd619dc Merge remote-tracking branch 'origin/master' into dev 2026-03-25 18:34:30 +02:00
bohd4nx bae2e1b400 tests: add section comments to all test files 2026-03-25 18:33:47 +02:00
61 changed files with 1245 additions and 1159 deletions
+86 -86
View File
@@ -1,101 +1,101 @@
name: Bug report
description: Report an issue or unexpected behavior in pyfragment.
labels:
- bug
- bug
body:
- type: checkboxes
attributes:
label: Checklist
options:
- label: I am sure the error is coming from pyfragment code
required: true
- label: I have searched the issue tracker for similar bug reports, including closed ones
required: true
- type: markdown
attributes:
value: |
## Context
Please provide as much detail as possible to help us reproduce and fix the issue.
- type: input
attributes:
label: Operating system
placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11
validations:
- type: checkboxes
attributes:
label: Checklist
options:
- label: I am sure the error is coming from pyfragment code
required: true
- label: I have searched the issue tracker for similar bug reports, including closed ones
required: true
- type: input
attributes:
label: Python version
description: Run `python --version` inside your virtualenv
placeholder: e.g. 3.12.3
validations:
required: true
- type: markdown
attributes:
value: |
## Context
Please provide as much detail as possible to help us reproduce and fix the issue.
- type: input
attributes:
label: pyfragment version
description: Run `pip show pyfragment` inside your virtualenv
placeholder: e.g. 2026.1.0
validations:
required: true
- type: input
attributes:
label: Operating system
placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11
validations:
required: true
- type: textarea
attributes:
label: Expected behavior
description: Describe what you expected to happen.
placeholder: e.g. Stars should be purchased and StarsResult returned.
validations:
required: true
- type: input
attributes:
label: Python version
description: Run `python --version` inside your virtualenv
placeholder: e.g. 3.12.3
validations:
required: true
- type: textarea
attributes:
label: Current behavior
description: Describe what is actually happening.
placeholder: e.g. ParseError is raised with status 400.
validations:
required: true
- type: input
attributes:
label: pyfragment version
description: Run `pip show pyfragment` inside your virtualenv
placeholder: e.g. 2026.1.0
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce
description: Minimal steps that reproduce the issue.
placeholder: |
1. Create FragmentClient with valid credentials
2. Call purchase_stars("@username", amount=100)
3. See error
validations:
required: true
- type: textarea
attributes:
label: Expected behavior
description: Describe what you expected to happen.
placeholder: e.g. Stars should be purchased and StarsResult returned.
validations:
required: true
- type: textarea
attributes:
label: Code example
description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable.
placeholder: |
import asyncio
from pyfragment import FragmentClient
- type: textarea
attributes:
label: Current behavior
description: Describe what is actually happening.
placeholder: e.g. ParseError is raised with status 400.
validations:
required: true
async def main():
client = FragmentClient(...)
result = await client.purchase_stars("@username", amount=100)
- type: textarea
attributes:
label: Steps to reproduce
description: Minimal steps that reproduce the issue.
placeholder: |
1. Create FragmentClient with valid credentials
2. Call purchase_stars("@username", amount=100)
3. See error
validations:
required: true
asyncio.run(main())
render: python
- type: textarea
attributes:
label: Code example
description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable.
placeholder: |
import asyncio
from pyfragment import FragmentClient
- type: textarea
attributes:
label: Traceback / logs
description: Paste the full traceback or relevant logs.
placeholder: |
Traceback (most recent call last):
File "main.py", line 7, in main
...
pyfragment.types.ParseError: ...
render: sh
async def main():
client = FragmentClient(...)
result = await client.purchase_stars("@username", amount=100)
- type: textarea
attributes:
label: Additional information
description: Anything else that might help us diagnose the problem.
placeholder: e.g. Only happens with V5R1 wallet version.
asyncio.run(main())
render: python
- type: textarea
attributes:
label: Traceback / logs
description: Paste the full traceback or relevant logs.
placeholder: |
Traceback (most recent call last):
File "main.py", line 7, in main
...
pyfragment.types.ParseError: ...
render: sh
- type: textarea
attributes:
label: Additional information
description: Anything else that might help us diagnose the problem.
placeholder: e.g. Only happens with V5R1 wallet version.
+3 -3
View File
@@ -1,5 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Ask a question or start a discussion
url: https://github.com/bohd4nx/pyfragment/discussions
about: General questions, ideas, and community help go here — not in the issue tracker.
- name: Ask a question or start a discussion
url: https://github.com/bohd4nx/pyfragment/discussions
about: General questions, ideas, and community help go here — not in the issue tracker.
+42 -42
View File
@@ -1,51 +1,51 @@
name: Feature request
description: Suggest an improvement or new feature for pyfragment.
labels:
- enhancement
- enhancement
body:
- type: dropdown
attributes:
label: pyfragment version
description: Which version are you running?
options:
- latest
- older
- n/a
validations:
required: true
- type: dropdown
attributes:
label: pyfragment version
description: Which version are you running?
options:
- latest
- older
- n/a
validations:
required: true
- type: textarea
attributes:
label: Problem
description: Is your request related to a specific problem? Describe it.
placeholder: e.g. There is no way to check my current TON balance before sending.
validations:
required: true
- type: textarea
attributes:
label: Problem
description: Is your request related to a specific problem? Describe it.
placeholder: e.g. There is no way to check my current TON balance before sending.
validations:
required: true
- type: textarea
attributes:
label: Proposed solution
description: Describe what you would like to see added or changed.
placeholder: e.g. Add a get_balance() method to FragmentClient.
validations:
required: true
- type: textarea
attributes:
label: Proposed solution
description: Describe what you would like to see added or changed.
placeholder: e.g. Add a get_balance() method to FragmentClient.
validations:
required: true
- type: textarea
attributes:
label: Alternatives considered
description: Any workarounds or alternative approaches you have thought of.
placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
- type: textarea
attributes:
label: Alternatives considered
description: Any workarounds or alternative approaches you have thought of.
placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
- type: textarea
attributes:
label: Code example
description: A short example demonstrating the desired API, if applicable.
placeholder: |
balance = await client.get_balance()
print(balance.ton)
render: python
- type: textarea
attributes:
label: Code example
description: A short example demonstrating the desired API, if applicable.
placeholder: |
balance = await client.get_balance()
print(balance.ton)
render: python
- type: textarea
attributes:
label: Additional information
description: Any other context, screenshots, or references.
- type: textarea
attributes:
label: Additional information
description: Any other context, screenshots, or references.
+16 -16
View File
@@ -1,19 +1,19 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
+22 -20
View File
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: ["**"]
branches: [ "**" ]
pull_request:
branches: ["**"]
branches: [ "**" ]
jobs:
lint:
@@ -14,34 +14,36 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6
- uses: actions/setup-python@v6.2.0
with:
python-version: "3.12"
- uses: actions/setup-python@v6
with:
python-version: "3.10"
- uses: astral-sh/setup-uv@v7.6.0
- run: pip install ".[dev]"
- run: uv pip install --system ".[dev]"
- run: ruff check . && black --check . --target-version py312 && mypy pyfragment
- run: ruff check . && ruff format --check . && mypy pyfragment
test:
name: Tests
name: Tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
COOKIES_JSON: ${{ secrets.COOKIES_JSON }}
strategy:
fail-fast: false
matrix:
python-version: [ "3.10", "3.11", "3.12" ] # 3.13, 3.14 are not supported by some dependencies yet
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6
- uses: actions/setup-python@v6.2.0
with:
python-version: "3.12"
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- uses: astral-sh/setup-uv@v7.6.0
- name: Install package and dev dependencies
run: pip install ".[dev]"
- run: uv pip install --system ".[dev]"
- run: pytest
- name: Run tests
run: pytest
+58 -50
View File
@@ -2,9 +2,9 @@ name: Publish
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [master]
workflows: [ "CI" ]
types: [ completed ]
branches: [ master ]
jobs:
version-check:
@@ -18,24 +18,24 @@ jobs:
is-new: ${{ steps.tag.outputs.is-new }}
steps:
- uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Read version
id: version
run: |
value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
echo "value=$value" >> $GITHUB_OUTPUT
- name: Read version
id: version
run: |
value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
echo "value=$value" >> $GITHUB_OUTPUT
- name: Check tag
id: tag
run: |
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
echo "is-new=false" >> $GITHUB_OUTPUT
else
echo "is-new=true" >> $GITHUB_OUTPUT
fi
- name: Check tag
id: tag
run: |
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
echo "is-new=false" >> $GITHUB_OUTPUT
else
echo "is-new=true" >> $GITHUB_OUTPUT
fi
build:
name: Build
@@ -46,24 +46,24 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6
- uses: actions/setup-python@v6.2.0
with:
python-version: "3.12"
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v7.6.0
- uses: astral-sh/setup-uv@v8.1.0
- run: uv build
- run: uv build
- uses: actions/upload-artifact@v7
with:
name: dist
path: dist/*
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/*
publish:
name: Publish to PyPI
needs: [version-check, build]
needs: [ version-check, build ]
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
@@ -74,16 +74,16 @@ jobs:
id-token: write
steps:
- uses: actions/download-artifact@v8.0.1
with:
name: dist
path: dist
- uses: actions/download-artifact@v8
with:
name: dist
path: dist
- uses: pypa/gh-action-pypi-publish@v1.13.0
- uses: pypa/gh-action-pypi-publish@release/v1
release:
name: GitHub Release
needs: [version-check, build]
needs: [ version-check, build ]
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
@@ -91,19 +91,27 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/download-artifact@v8.0.1
with:
name: dist
path: dist
- uses: actions/download-artifact@v8
with:
name: dist
path: dist
- uses: softprops/action-gh-release@v2.6.1
with:
tag_name: v${{ needs.version-check.outputs.version }}
name: v${{ needs.version-check.outputs.version }}
files: dist/*
generate_release_notes: true
make_latest: true
- name: Extract latest changelog entry
id: changelog
run: |
body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$body" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- uses: softprops/action-gh-release@v3
with:
tag_name: v${{ needs.version-check.outputs.version }}
name: v${{ needs.version-check.outputs.version }}
files: dist/*
body: ${{ steps.changelog.outputs.body }}
make_latest: true
+1 -1
View File
@@ -31,7 +31,7 @@ Thumbs.db
.ruff_cache/
.coverage
htmlcov/
demo.run.py
systests/
# Build & distribution
dist/
+38
View File
@@ -7,6 +7,42 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
---
## [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
@@ -83,6 +119,8 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
- `py.typed` marker — full PEP 561 typing support for type-checkers
- `__repr__` on all result types for readable debug output
[2026.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
+14 -3
View File
@@ -9,7 +9,7 @@
[![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/)
[![PyPI downloads](https://img.shields.io/pypi/dm/pyfragment?style=flat&color=brightgreen)](https://pypi.org/project/pyfragment/)
[![Python](https://img.shields.io/badge/Python-3.12+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org)
[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org)
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=lightgrey)](LICENSE)
[![Stars](https://img.shields.io/github/stars/bohd4nx/pyfragment?style=flat&color=yellow)](https://github.com/bohd4nx/pyfragment/stargazers)
[![CI](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions)
@@ -34,7 +34,7 @@ To install the latest unreleased changes from the `dev` branch:
pip install git+https://github.com/bohd4nx/pyfragment.git@dev
```
Requires Python 3.12+.
Requires Python 3.10+.
---
@@ -52,7 +52,18 @@ Requires Python 3.12+.
## Credentials
**Fragment cookies** — log in to [fragment.com](https://fragment.com), install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm), and export these four keys: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass them as a `dict` or as a JSON string. Refresh when you get authentication errors.
**Fragment cookies** — log in to [fragment.com](https://fragment.com) and connect your TON wallet. You can get cookies in two ways:
- **Automatically** (recommended) — 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).
+6
View File
@@ -11,9 +11,15 @@ 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",
+6
View File
@@ -10,9 +10,15 @@ 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",
+6
View File
@@ -10,9 +10,15 @@ 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",
+6
View File
@@ -12,9 +12,15 @@ 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",
+6
View File
@@ -8,9 +8,15 @@ 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",
+6
View File
@@ -9,9 +9,15 @@ 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",
@@ -13,9 +13,15 @@ from pyfragment import (
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",
@@ -8,9 +8,15 @@ months (Premium duration per winner) must be 3, 6, or 12.
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",
+6
View File
@@ -8,9 +8,15 @@ amount (stars per winner) must be an integer between 500 and 1 000 000.
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",
+6
View File
@@ -8,9 +8,15 @@ Set show_sender=False to send anonymously.
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",
+6
View File
@@ -8,9 +8,15 @@ Set show_sender=False to send anonymously.
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",
+9 -3
View File
@@ -1,10 +1,10 @@
"""
Example: Topup ton to recipient's Telegram balance.
Example: top up TON to a recipient's Telegram balance.
For adding TON to Telegram Ads account, use recharge_ads() instead.
For adding TON to a Telegram Ads account, use recharge_ads() instead.
Amount must be an integer between 1 and 1 000 000 000 TON.
Your wallet must hold at least the topup amount + ~0.056 TON for gas.
Your wallet must hold at least the top-up amount + ~0.056 TON for gas.
"""
import asyncio
@@ -15,9 +15,15 @@ from pyfragment import (
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",
+2
View File
@@ -13,6 +13,7 @@ from pyfragment.types import (
ClientError,
ConfigurationError,
CookieError,
CookieResult,
FragmentAPIError,
FragmentError,
FragmentPageError,
@@ -55,6 +56,7 @@ __all__ = [
"ClientError",
"ConfigurationError",
"CookieError",
"CookieResult",
"FragmentAPIError",
"FragmentError",
"FragmentPageError",
+11 -8
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import json
from typing import Any, cast
@@ -21,7 +23,9 @@ from pyfragment.types import (
GiftsResult,
LoginCodeResult,
NumbersResult,
PremiumGiveawayResult,
PremiumResult,
StarsGiveawayResult,
StarsResult,
TerminateSessionsResult,
UsernamesResult,
@@ -34,7 +38,6 @@ from pyfragment.types.constants import (
SUPPORTED_WALLET_VERSIONS,
WalletVersion,
)
from pyfragment.types.results import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers
from pyfragment.utils.wallet import get_wallet_info
@@ -74,7 +77,7 @@ class FragmentClient:
self,
seed: str,
api_key: str,
cookies: dict | str,
cookies: dict[str, Any] | str,
wallet_version: str = "V5R1",
timeout: float = DEFAULT_TIMEOUT,
) -> None:
@@ -95,7 +98,7 @@ class FragmentClient:
except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict, cookies).get(k, "")).strip()]
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict[str, Any], cookies).get(k, "")).strip()]
if missing_keys:
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
@@ -109,11 +112,11 @@ class FragmentClient:
self.seed: str = seed.strip()
self.api_key: str = api_key.strip()
self.cookies: dict = cast(dict, cookies)
self.cookies: dict[str, Any] = cast(dict[str, Any], cookies)
self.wallet_version: WalletVersion = version # type: ignore[assignment]
self.timeout: float = timeout
async def __aenter__(self) -> "FragmentClient":
async def __aenter__(self) -> FragmentClient:
return self
async def __aexit__(self, *_: object) -> None:
@@ -123,7 +126,7 @@ class FragmentClient:
return f"FragmentClient(wallet_version='{self.wallet_version}', cookies={len(self.cookies)} keys)"
async def purchase_premium(self, username: str, months: int, show_sender: bool = True) -> PremiumResult:
"""Purchase Telegram Premium for a user.
"""Gift Telegram Premium to a user.
Args:
username: Recipient's Telegram username (with or without ``@``).
@@ -136,7 +139,7 @@ class FragmentClient:
return await purchase_premium(self, username, months, show_sender)
async def purchase_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult:
"""Purchase Telegram Stars for a user.
"""Send Telegram Stars to a user.
Args:
username: Recipient's Telegram username (with or without ``@``).
@@ -149,7 +152,7 @@ class FragmentClient:
return await purchase_stars(self, username, amount, show_sender)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Topup ton to recipient's Telegram balance.
"""Top up TON to a recipient's Telegram balance.
Args:
username: Recipient's Telegram username (with or without ``@``).
+44 -52
View File
@@ -1,24 +1,28 @@
from __future__ import annotations
import html
from typing import TYPE_CHECKING
import httpx
from pyfragment.types import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.types import (
AnonymousNumberError,
FragmentAPIError,
FragmentError,
LoginCodeResult,
TerminateSessionsResult,
UnexpectedError,
)
from pyfragment.types.constants import NUMBERS_PAGE
from pyfragment.types.results import LoginCodeResult, TerminateSessionsResult
from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_login_code
from pyfragment.utils import parse_login_code
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
HEADERS: dict[str, str] = make_headers(NUMBERS_PAGE)
def _strip_plus(number: str) -> str:
return number.lstrip("+") if isinstance(number, str) else number
async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResult:
async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult:
"""Fetch the current pending login code for an anonymous number.
Args:
@@ -35,14 +39,11 @@ async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResu
"""
try:
clean = _strip_plus(number)
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout)
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{"number": clean, "lt": "0", "from_app": "1", "method": "updateLoginCodes"},
)
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"])
@@ -57,7 +58,7 @@ async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResu
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
async def toggle_login_codes(client: "FragmentClient", number: str, can_receive: bool) -> None:
async def toggle_login_codes(client: FragmentClient, number: str, can_receive: bool) -> None:
"""Enable or disable login code delivery for an anonymous number.
Args:
@@ -71,14 +72,11 @@ async def toggle_login_codes(client: "FragmentClient", number: str, can_receive:
"""
try:
clean = _strip_plus(number)
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout)
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{"number": clean, "can_receive": 1 if can_receive else 0, "method": "toggleLoginCodes"},
)
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"]))
@@ -89,7 +87,7 @@ async def toggle_login_codes(client: "FragmentClient", number: str, can_receive:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
async def terminate_sessions(client: "FragmentClient", number: str) -> TerminateSessionsResult:
async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSessionsResult:
"""Terminate all active Telegram sessions for an anonymous number.
This is a two-step operation: Fragment first returns a confirmation hash,
@@ -110,39 +108,33 @@ async def terminate_sessions(client: "FragmentClient", number: str) -> Terminate
"""
try:
clean = _strip_plus(number)
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout)
# Step 1: initiate — Fragment returns a confirmation hash.
confirmation = await fragment_request(
session,
fragment_hash,
HEADERS,
{"number": clean, "method": "terminatePhoneSessions"},
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"]))
)
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))
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,
)
# Step 2: confirm with the hash.
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{"number": clean, "terminate_hash": terminate_hash, "method": "terminatePhoneSessions"},
if result.get("error"):
raise AnonymousNumberError(
AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(result["error"]))
)
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:
+32 -72
View File
@@ -1,82 +1,26 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import httpx
from pyfragment.types import (
ConfigurationError,
FragmentAPIError,
FragmentError,
PremiumGiveawayResult,
UnexpectedError,
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE
from pyfragment.types.results import PremiumGiveawayResult
from pyfragment.utils import (
execute_transaction_request,
fragment_request,
get_account_info,
get_fragment_hash,
make_headers,
process_transaction,
)
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
HEADERS: dict[str, str] = make_headers(PREMIUM_GIVEAWAY_PAGE)
async def _search_recipient(
session: httpx.AsyncClient,
fragment_hash: str,
channel: str,
winners: int,
months: int,
) -> str:
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"query": channel,
"quantity": winners,
"months": months,
"method": "searchPremiumGiveawayRecipient",
},
)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
return recipient
async def _init_request(
session: httpx.AsyncClient,
fragment_hash: str,
recipient: str,
winners: int,
months: int,
) -> str:
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"recipient": recipient,
"quantity": str(winners),
"months": str(months),
"method": "initGiveawayPremiumRequest",
},
)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway"))
return req_id
async def giveaway_premium(
client: "FragmentClient",
client: FragmentClient,
channel: str,
winners: int,
months: int = 3,
@@ -105,21 +49,37 @@ async def giveaway_premium(
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
try:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_GIVEAWAY_PAGE, client.timeout)
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)},
page_url=PREMIUM_GIVEAWAY_PAGE,
)
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)
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
recipient = await _search_recipient(session, fragment_hash, channel, winners, months)
req_id = await _init_request(session, fragment_hash, recipient, winners, months)
tx_data = {
transaction = await client.call(
"getGiveawayPremiumLink",
{
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
"method": "getGiveawayPremiumLink",
}
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
},
page_url=PREMIUM_GIVEAWAY_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction)
return PremiumGiveawayResult(
+28 -68
View File
@@ -1,78 +1,26 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import httpx
from pyfragment.types import (
ConfigurationError,
FragmentAPIError,
FragmentError,
StarsGiveawayResult,
UnexpectedError,
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE
from pyfragment.types.results import StarsGiveawayResult
from pyfragment.utils import (
execute_transaction_request,
fragment_request,
get_account_info,
get_fragment_hash,
make_headers,
process_transaction,
)
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
HEADERS: dict[str, str] = make_headers(STARS_GIVEAWAY_PAGE)
async def _search_recipient(
session: httpx.AsyncClient,
fragment_hash: str,
channel: str,
) -> str:
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"query": channel,
"method": "searchStarsGiveawayRecipient",
},
)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
return recipient
async def _init_request(
session: httpx.AsyncClient,
fragment_hash: str,
recipient: str,
winners: int,
amount: int,
) -> str:
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"recipient": recipient,
"quantity": str(winners),
"stars": str(amount),
"method": "initGiveawayStarsRequest",
},
)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway"))
return req_id
async def giveaway_stars(
client: "FragmentClient",
client: FragmentClient,
channel: str,
winners: int,
amount: int,
@@ -101,21 +49,33 @@ async def giveaway_stars(
raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
try:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_GIVEAWAY_PAGE, client.timeout)
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)},
page_url=STARS_GIVEAWAY_PAGE,
)
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)
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
recipient = await _search_recipient(session, fragment_hash, channel)
req_id = await _init_request(session, fragment_hash, recipient, winners, amount)
tx_data = {
transaction = await client.call(
"getGiveawayStarsLink",
{
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
"method": "getGiveawayStarsLink",
}
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
},
page_url=STARS_GIVEAWAY_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction)
return StarsGiveawayResult(
+28 -78
View File
@@ -1,9 +1,9 @@
from __future__ import annotations
import json
import time
from typing import TYPE_CHECKING
import httpx
from pyfragment.types import (
ConfigurationError,
FragmentAPIError,
@@ -11,79 +11,16 @@ from pyfragment.types import (
PremiumResult,
UnexpectedError,
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import DEVICE, PREMIUM_PAGE
from pyfragment.utils import (
execute_transaction_request,
fragment_request,
get_account_info,
get_fragment_hash,
make_headers,
process_transaction,
)
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
HEADERS: dict[str, str] = make_headers(PREMIUM_PAGE)
async def _search_recipient(
session: httpx.AsyncClient,
fragment_hash: str,
username: str,
months: int,
) -> str:
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"query": username,
"months": months,
"method": "searchPremiumGiftRecipient",
},
)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
return recipient
async def _init_request(
session: httpx.AsyncClient,
fragment_hash: str,
recipient: str,
months: int,
) -> str:
await fragment_request(
session,
fragment_hash,
HEADERS,
{
"mode": "new",
"lv": "false",
"dh": str(int(time.time())),
"method": "updatePremiumState",
},
)
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"recipient": recipient,
"months": months,
"method": "initGiftPremiumRequest",
},
)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
return req_id
async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult:
async def purchase_premium(client: FragmentClient, username: str, months: int, show_sender: bool = True) -> PremiumResult:
"""Gift Telegram Premium to a user.
Args:
@@ -105,22 +42,35 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int,
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
try:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_PAGE, client.timeout)
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}, page_url=PREMIUM_PAGE)
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)
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
recipient = await _search_recipient(session, fragment_hash, username, months)
req_id = await _init_request(session, fragment_hash, recipient, months)
tx_data = {
transaction = await client.call(
"getGiftPremiumLink",
{
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
"show_sender": int(show_sender),
"method": "getGiftPremiumLink",
}
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
},
page_url=PREMIUM_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction)
return PremiumResult(transaction_id=tx_hash, username=username, amount=months)
+23 -66
View File
@@ -1,8 +1,8 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import httpx
from pyfragment.types import (
ConfigurationError,
FragmentAPIError,
@@ -10,67 +10,16 @@ from pyfragment.types import (
StarsResult,
UnexpectedError,
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import DEVICE, STARS_PAGE
from pyfragment.utils import (
execute_transaction_request,
fragment_request,
get_account_info,
get_fragment_hash,
make_headers,
process_transaction,
)
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
HEADERS: dict[str, str] = make_headers(STARS_PAGE)
async def _search_recipient(
session: httpx.AsyncClient,
fragment_hash: str,
username: str,
) -> str:
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"query": username,
"quantity": "",
"method": "searchStarsRecipient",
},
)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
return recipient
async def _init_request(
session: httpx.AsyncClient,
fragment_hash: str,
recipient: str,
amount: int,
) -> str:
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"recipient": recipient,
"quantity": amount,
"method": "initBuyStarsRequest",
},
)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
return req_id
async def purchase_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult:
async def purchase_stars(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> StarsResult:
"""Send Telegram Stars to a user.
Args:
@@ -92,22 +41,30 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
try:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_PAGE, client.timeout)
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))
result = await client.call("initBuyStarsRequest", {"recipient": recipient, "quantity": amount}, page_url=STARS_PAGE)
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)
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
recipient = await _search_recipient(session, fragment_hash, username)
req_id = await _init_request(session, fragment_hash, recipient, amount)
tx_data = {
transaction = await client.call(
"getBuyStarsLink",
{
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
"show_sender": int(show_sender),
"method": "getBuyStarsLink",
}
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
},
page_url=STARS_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction)
return StarsResult(transaction_id=tx_hash, username=username, amount=amount)
+21 -46
View File
@@ -1,54 +1,24 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import httpx
from pyfragment.types import (
AdsRechargeResult,
ConfigurationError,
FragmentAPIError,
FragmentError,
UnexpectedError,
VerificationError,
)
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.types.results import AdsRechargeResult
from pyfragment.utils import (
execute_transaction_request,
fragment_request,
get_account_info,
get_fragment_hash,
make_headers,
process_transaction,
)
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
HEADERS: dict[str, str] = make_headers(ADS_TOPUP_PAGE)
async def _init_request(
session: httpx.AsyncClient,
fragment_hash: str,
account: str,
amount: int,
) -> str:
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"account": account,
"amount": amount,
"method": "initAdsRechargeRequest",
},
)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Ads recharge"))
return req_id
async def recharge_ads(client: "FragmentClient", account: str, amount: int) -> AdsRechargeResult:
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account.
Args:
@@ -69,21 +39,26 @@ async def recharge_ads(client: "FragmentClient", account: str, amount: int) -> A
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
try:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, ADS_TOPUP_PAGE, client.timeout)
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)
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
await fragment_request(session, fragment_hash, HEADERS, {"method": "updateAdsState", "mode": "new"})
req_id = await _init_request(session, fragment_hash, account, amount)
tx_data = {
transaction = await client.call(
"getAdsRechargeLink",
{
"account": json.dumps(account_info),
"device": DEVICE,
"transaction": 1,
"id": req_id,
"method": "getAdsRechargeLink",
}
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
},
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)
+7 -12
View File
@@ -1,20 +1,17 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import httpx
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.types import FragmentAPIError, FragmentError, GiftsResult, UnexpectedError
from pyfragment.types.constants import GIFTS_PAGE
from pyfragment.types.results import GiftsResult
from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_gift_items
from pyfragment.utils import parse_gift_items
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
HEADERS: dict[str, str] = make_headers(GIFTS_PAGE)
async def search_gifts(
client: "FragmentClient",
client: FragmentClient,
query: str = "",
collection: str | None = None,
sort: str | None = None,
@@ -48,7 +45,7 @@ async def search_gifts(
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
data: dict[str, Any] = {"method": "searchAuctions", "type": "gifts", "query": query}
data: dict[str, Any] = {"type": "gifts", "query": query}
if collection is not None:
data["collection"] = collection
if sort is not None:
@@ -64,9 +61,7 @@ async def search_gifts(
data["offset"] = offset
try:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, GIFTS_PAGE, client.timeout)
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
result = await fragment_request(session, fragment_hash, HEADERS, data)
result = await client.call("searchAuctions", data, page_url=GIFTS_PAGE)
if result.get("error"):
raise FragmentAPIError(result["error"])
+7 -12
View File
@@ -1,20 +1,17 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import httpx
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.types import FragmentAPIError, FragmentError, NumbersResult, UnexpectedError
from pyfragment.types.constants import NUMBERS_PAGE
from pyfragment.types.results import NumbersResult
from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_auction_rows
from pyfragment.utils import parse_auction_rows
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
HEADERS: dict[str, str] = make_headers(NUMBERS_PAGE)
async def search_numbers(
client: "FragmentClient",
client: FragmentClient,
query: str = "",
sort: str | None = None,
filter: str | None = None,
@@ -40,7 +37,7 @@ async def search_numbers(
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
data: dict[str, Any] = {"method": "searchAuctions", "type": "numbers", "query": query}
data: dict[str, Any] = {"type": "numbers", "query": query}
if sort is not None:
data["sort"] = sort
if filter is not None:
@@ -49,9 +46,7 @@ async def search_numbers(
data["offset_id"] = offset_id
try:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout)
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
result = await fragment_request(session, fragment_hash, HEADERS, data)
result = await client.call("searchAuctions", data, page_url=NUMBERS_PAGE)
if result.get("error"):
raise FragmentAPIError(result["error"])
+7 -12
View File
@@ -1,20 +1,17 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import httpx
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError, UsernamesResult
from pyfragment.types.constants import FRAGMENT_BASE_URL
from pyfragment.types.results import UsernamesResult
from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_auction_rows
from pyfragment.utils import parse_auction_rows
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
HEADERS: dict[str, str] = make_headers(FRAGMENT_BASE_URL)
async def search_usernames(
client: "FragmentClient",
client: FragmentClient,
query: str = "",
sort: str | None = None,
filter: str | None = None,
@@ -40,7 +37,7 @@ async def search_usernames(
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
data: dict[str, Any] = {"method": "searchAuctions", "type": "usernames", "query": query}
data: dict[str, Any] = {"type": "usernames", "query": query}
if sort is not None:
data["sort"] = sort
if filter is not None:
@@ -49,9 +46,7 @@ async def search_usernames(
data["offset_id"] = offset_id
try:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, FRAGMENT_BASE_URL, client.timeout)
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
result = await fragment_request(session, fragment_hash, HEADERS, data)
result = await client.call("searchAuctions", data, page_url=FRAGMENT_BASE_URL)
if result.get("error"):
raise FragmentAPIError(result["error"])
+27 -68
View File
@@ -1,8 +1,8 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import httpx
from pyfragment.types import (
AdsTopupResult,
ConfigurationError,
@@ -10,68 +10,17 @@ from pyfragment.types import (
FragmentError,
UnexpectedError,
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.utils import (
execute_transaction_request,
fragment_request,
get_account_info,
get_fragment_hash,
make_headers,
process_transaction,
)
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
HEADERS: dict[str, str] = make_headers(ADS_TOPUP_PAGE)
async def _search_recipient(
session: httpx.AsyncClient,
fragment_hash: str,
username: str,
) -> str:
await fragment_request(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"})
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"query": username,
"method": "searchAdsTopupRecipient",
},
)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
return recipient
async def _init_request(
session: httpx.AsyncClient,
fragment_hash: str,
recipient: str,
amount: int,
) -> str:
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"recipient": recipient,
"amount": amount,
"method": "initAdsTopupRequest",
},
)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
return req_id
async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Topup ton to recipient's Telegram balance.
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.
@@ -84,7 +33,7 @@ async def topup_ton(client: "FragmentClient", username: str, amount: int, show_s
Raises:
ConfigurationError: If ``amount`` is not an integer between 1 and 1 000 000 000.
UserNotFoundError: If the recipient is not found on Fragment.
UserNotFoundError: If the recipient is not found on Telegram.
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
@@ -92,22 +41,32 @@ async def topup_ton(client: "FragmentClient", username: str, amount: int, show_s
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
try:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, ADS_TOPUP_PAGE, client.timeout)
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)
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
recipient = await _search_recipient(session, fragment_hash, username)
req_id = await _init_request(session, fragment_hash, recipient, amount)
tx_data = {
transaction = await client.call(
"getAdsTopupLink",
{
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
"show_sender": int(show_sender),
"method": "getAdsTopupLink",
}
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
},
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)
+2
View File
@@ -17,6 +17,7 @@ from pyfragment.types.exceptions import (
from pyfragment.types.results import (
AdsRechargeResult,
AdsTopupResult,
CookieResult,
GiftsResult,
LoginCodeResult,
NumbersResult,
@@ -49,6 +50,7 @@ __all__ = [
# result types
"AdsRechargeResult",
"AdsTopupResult",
"CookieResult",
"GiftsResult",
"LoginCodeResult",
"NumbersResult",
+25 -3
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import json
from typing import Any, Literal, get_args
@@ -19,8 +21,9 @@ DEFAULT_TIMEOUT: float = 30.0
# Required Fragment session cookie keys
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
# Fragment page URLs
FRAGMENT_BASE_URL: str = "https://fragment.com"
# Fragment domain 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"
@@ -29,12 +32,31 @@ 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": "5.5.2",
"appVersion": "26.04.0",
"maxProtocolVersion": 2,
"features": [
"SendTransaction",
+15 -3
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
class FragmentError(Exception):
"""Base exception for all pyfragment library errors."""
@@ -13,7 +16,7 @@ class ConfigurationError(ClientError):
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 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."
@@ -35,6 +38,15 @@ class CookieError(ClientError):
"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):
@@ -63,7 +75,7 @@ class UserNotFoundError(FragmentAPIError):
"""Raised when the target Telegram user is not found on Fragment."""
NOT_FOUND = (
"Telegram user '{username}' was not found on Fragment. " "Double-check the username and make sure the account exists."
"Telegram user '{username}' was not found on Fragment. Double-check the username and make sure the account exists."
)
@@ -78,7 +90,7 @@ 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."
"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 = (
+19
View File
@@ -1,7 +1,26 @@
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`."""
+3
View File
@@ -1,3 +1,4 @@
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
from pyfragment.utils.http import (
@@ -11,6 +12,8 @@ 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",
+72
View File
@@ -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,
)
+3 -1
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import base64
from pytoniq_core import Cell
from ton_core import Cell
from pyfragment.types import ParseError
+2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import re
from typing import Any
+22 -9
View File
@@ -1,5 +1,9 @@
from __future__ import annotations
import asyncio
import random
import re
from typing import Any
from typing import Any, cast
import httpx
@@ -78,7 +82,7 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any
ParseError: If the response body cannot be decoded as JSON.
"""
try:
return response.json()
return cast(dict[str, Any], response.json())
except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
@@ -104,17 +108,26 @@ async def fragment_request(
Returns:
Parsed API response as a dict.
"""
resp = await session.post(
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
headers=headers,
data=data,
)
return parse_json_response(resp, data.get("method", "request"))
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,
headers: dict[str, str],
tx_data: dict[str, Any],
fragment_hash: str,
) -> dict[str, Any]:
+12 -10
View File
@@ -1,22 +1,24 @@
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.exceptions import ProviderResponseError
from tonutils.types import NetworkGlobalID
from pyfragment.types import TransactionError, WalletError
from pyfragment.types import TransactionError, WalletError, WalletInfo
from pyfragment.types.constants import MIN_TON_BALANCE, WALLET_CLASSES
from pyfragment.types.results import WalletInfo
from pyfragment.utils.decoder import clean_decode
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def process_transaction(client: "FragmentClient", transaction_data: dict) -> str:
async def process_transaction(client: FragmentClient, transaction_data: dict[str, Any]) -> str:
"""Sign and broadcast a Fragment transaction to the TON network.
Validates the payload structure, checks the wallet balance, decodes the
@@ -33,7 +35,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict)
TransactionError: If the payload is malformed or the broadcast fails.
WalletError: If the wallet balance is too low or cannot be fetched.
"""
if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
raise TransactionError(TransactionError.INVALID_PAYLOAD)
message = transaction_data["transaction"]["messages"][0]
@@ -65,15 +67,15 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict)
amount=int(message["amount"]), # nanotons, not TON
body=payload,
)
return result.normalized_hash
return str(result.normalized_hash)
except ProviderResponseError as exc:
if exc.code == 429 and attempt == 0:
await asyncio.sleep(1)
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)
await asyncio.sleep(2 + random.uniform(0, 1))
continue
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
raise
@@ -90,7 +92,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict)
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
async def get_account_info(client: "FragmentClient") -> dict[str, Any]:
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
@@ -121,7 +123,7 @@ async def get_account_info(client: "FragmentClient") -> dict[str, Any]:
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
async def get_wallet_info(client: "FragmentClient") -> "WalletInfo":
async def get_wallet_info(client: FragmentClient) -> WalletInfo:
"""Return the address, state and balance of the TON wallet.
Args:
+44 -25
View File
@@ -4,41 +4,56 @@ build-backend = "hatchling.build"
[project]
name = "pyfragment"
version = "2026.1.0"
version = "2026.2.1"
description = "Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts."
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.12"
authors = [{ name = "bohd4nx", url = "https://github.com/bohd4nx" }]
keywords = ["fragment", "telegram", "ton", "stars", "premium", "crypto", "blockchain"]
requires-python = ">=3.10"
authors = [{ name = "bohd4nx" }]
keywords = [
"fragment",
"fragment-api",
"telegram",
"telegram-stars",
"telegram-premium",
"ton",
"tonkeeper",
"tonapi",
"crypto",
"blockchain",
"web3",
"giveaway",
"anonymous-number",
"username",
"nft",
"async",
"asyncio",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: Financial and Insurance Industry",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Framework :: AsyncIO",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Office/Business :: Financial",
"Topic :: Office/Business :: Financial :: Investment",
"Typing :: Typed",
]
dependencies = [
"httpx==0.28.1",
"tonutils[pytoniq]==2.0.4",
]
dependencies = ["httpx>=0.25", "rookiepy>=0.5.6", "tonutils>=2.0.1"]
[project.optional-dependencies]
dev = [
"pytest==9.0.2",
"pytest-asyncio==1.3.0",
"pytest-mock",
"mypy",
"ruff",
"black",
]
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
[project.urls]
Homepage = "https://github.com/bohd4nx/pyfragment"
@@ -50,23 +65,27 @@ Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md"
packages = ["pyfragment"]
[tool.pytest.ini_options]
testpaths = ["tests"]
testpaths = ["tests"]
python_files = ["[0-9][0-9][0-9]_test_*.py"]
asyncio_mode = "auto"
addopts = "-v --tb=short"
[tool.black]
line-length = 128
target-version = ["py312"]
addopts = "-v --tb=short"
[tool.ruff]
line-length = 128
target-version = "py312"
[tool.ruff.lint]
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort
select = ["E", "F", "W", "I"]
ignore = ["E501"]
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
select = ["E", "F", "W", "I", "UP"]
# E501 — line too long (covered by line-length above)
# UP017 — use datetime.UTC (only available in Python 3.11+, we support 3.10)
ignore = ["E501", "UP017"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["E402"]
"systests/*" = ["E402"]
[tool.mypy]
python_version = "3.10"
strict = true
exclude = ["^systests/", "^examples/"]
-1
View File
@@ -1 +0,0 @@
-e .[dev]
-1
View File
@@ -1 +0,0 @@
-e .
@@ -1,5 +1,6 @@
"""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
@@ -45,7 +46,7 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
@contextmanager
def _patch_wallet(wallet: MagicMock):
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
@@ -102,6 +103,12 @@ async def test_invalid_payload_raises() -> None:
await process_transaction(_make_client(), {"transaction": {}})
@pytest.mark.asyncio
async def test_empty_messages_list_raises() -> None:
with pytest.raises(TransactionError):
await process_transaction(_make_client(), {"transaction": {"messages": []}})
@pytest.mark.asyncio
async def test_balance_check_failed_raises_wallet_error() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000)
-16
View File
@@ -1,16 +0,0 @@
"""Tests for get_fragment_hash() — API hash extraction from Fragment page source."""
import re
import pytest
from pyfragment.types.constants import BASE_HEADERS, STARS_PAGE
from pyfragment.utils import get_fragment_hash
@pytest.mark.asyncio
async def test_hash_is_valid_hex(cookies: dict) -> None:
result = await get_fragment_hash(cookies, BASE_HEADERS, STARS_PAGE)
assert isinstance(result, str)
assert len(result) >= 10, f"hash too short: {result!r}"
assert re.fullmatch(r"[a-f0-9]+", result), f"not a hex string: {result!r}"
@@ -1,12 +1,15 @@
"""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_HASH, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Stars purchase validation tests
@@ -35,14 +38,19 @@ async def test_purchase_stars_float_amount(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_purchase_stars_success(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.purchase_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch(
"pyfragment.methods.purchase_stars.fragment_request",
AsyncMock(side_effect=[{"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}]),
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
patch("pyfragment.methods.purchase_stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
patch("pyfragment.methods.purchase_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
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)
@@ -54,11 +62,7 @@ async def test_purchase_stars_success(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.purchase_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch("pyfragment.methods.purchase_stars.fragment_request", AsyncMock(return_value={"found": {}})),
):
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
with pytest.raises(UserNotFoundError):
await client.purchase_stars("@ghost", amount=500)
@@ -108,14 +112,19 @@ async def test_giveaway_stars_float_amount(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_giveaway_stars_success(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.giveaway_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.giveaway_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch(
"pyfragment.methods.giveaway_stars.fragment_request",
AsyncMock(side_effect=[{"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}]),
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
patch("pyfragment.methods.giveaway_stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
patch("pyfragment.methods.giveaway_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
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)
@@ -128,10 +137,6 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.giveaway_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.giveaway_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch("pyfragment.methods.giveaway_stars.fragment_request", AsyncMock(return_value={"found": {}})),
):
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
with pytest.raises(UserNotFoundError):
await client.giveaway_stars("@ghost", winners=1, amount=500)
@@ -1,12 +1,15 @@
"""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_HASH, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Premium purchase validation tests
@@ -29,20 +32,20 @@ async def test_purchase_premium_months_zero(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_purchase_premium_success(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.purchase_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch(
"pyfragment.methods.purchase_premium.fragment_request",
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updatePremiumState
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
patch("pyfragment.methods.purchase_premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
patch("pyfragment.methods.purchase_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
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)
@@ -54,11 +57,7 @@ async def test_purchase_premium_success(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.purchase_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch("pyfragment.methods.purchase_premium.fragment_request", AsyncMock(return_value={"found": {}})),
):
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
with pytest.raises(UserNotFoundError):
await client.purchase_premium("@ghost", months=3)
@@ -96,14 +95,19 @@ async def test_giveaway_premium_invalid_months(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_giveaway_premium_success(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.giveaway_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.giveaway_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch(
"pyfragment.methods.giveaway_premium.fragment_request",
AsyncMock(side_effect=[{"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}]),
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
patch("pyfragment.methods.giveaway_premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
patch("pyfragment.methods.giveaway_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
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)
@@ -116,10 +120,6 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.giveaway_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.giveaway_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch("pyfragment.methods.giveaway_premium.fragment_request", AsyncMock(return_value={"found": {}})),
):
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
with pytest.raises(UserNotFoundError):
await client.giveaway_premium("@ghost", winners=1, months=3)
@@ -1,12 +1,14 @@
"""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_HASH, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Topup TON validation tests
@@ -35,20 +37,20 @@ async def test_topup_ton_float_amount(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_topup_ton_success(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.topup_ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch(
"pyfragment.methods.topup_ton.fragment_request",
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsTopupState
{"found": {"recipient": FAKE_RECIPIENT}},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
patch("pyfragment.methods.topup_ton.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
patch("pyfragment.methods.topup_ton.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
patch.object(_topup_ton_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_topup_ton_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
):
result = await client.topup_ton("@user", amount=10)
@@ -60,12 +62,14 @@ async def test_topup_ton_success(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.topup_ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch(
"pyfragment.methods.topup_ton.fragment_request",
AsyncMock(side_effect=[{}, {"found": {}}]),
with patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsTopupState
{"found": {}},
]
),
):
with pytest.raises(UserNotFoundError):
@@ -1,10 +1,13 @@
"""Unit tests for FragmentClient.call() — raw Fragment API access."""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
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
@@ -61,3 +64,19 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
_, _, _, 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"})
+128
View File
@@ -0,0 +1,128 @@
"""Unit tests for anonymous number methods — login codes and session management."""
from unittest.mock import AsyncMock, patch
import pytest
from pyfragment import AnonymousNumberError, FragmentClient, LoginCodeResult, TerminateSessionsResult
from tests.shared import FAKE_HTML_NO_CODE, FAKE_HTML_WITH_CODE, FAKE_TERMINATE_HASH
# get_login_code mocked tests
@pytest.mark.asyncio
async def test_get_login_code_returns_code(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"html": FAKE_HTML_WITH_CODE})):
result = await client.get_login_code("+1234567890")
assert isinstance(result, LoginCodeResult)
assert result.number == "+1234567890"
assert result.code == "12345"
assert result.active_sessions == 2 # 2 <tr> in the HTML
@pytest.mark.asyncio
async def test_get_login_code_no_pending_code(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"html": FAKE_HTML_NO_CODE})):
result = await client.get_login_code("1234567890")
assert result.code is None
assert result.active_sessions == 1
@pytest.mark.asyncio
async def test_get_login_code_no_html_returns_none(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={})):
result = await client.get_login_code("+1234567890")
assert result.code is None
assert result.active_sessions == 0
# terminate_sessions mocked tests
@pytest.mark.asyncio
async def test_terminate_sessions_success(client: FragmentClient) -> None:
with patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"terminate_hash": FAKE_TERMINATE_HASH}, # step 1: confirmation
{"msg": "All sessions terminated"}, # step 2: confirmed
]
),
):
result = await client.terminate_sessions("+1234567890")
assert isinstance(result, TerminateSessionsResult)
assert result.number == "+1234567890"
assert result.message == "All sessions terminated"
@pytest.mark.asyncio
async def test_terminate_sessions_not_owned_raises(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={})):
with pytest.raises(AnonymousNumberError, match="not associated"):
await client.terminate_sessions("+1234567890")
@pytest.mark.asyncio
async def test_terminate_sessions_api_error_raises(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"error": "SESSION_ALREADY_TERMINATED"})):
with pytest.raises(AnonymousNumberError, match="SESSION_ALREADY_TERMINATED"):
await client.terminate_sessions("+1234567890")
@pytest.mark.asyncio
async def test_terminate_sessions_confirm_error_raises(client: FragmentClient) -> None:
with patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"terminate_hash": FAKE_TERMINATE_HASH},
{"error": "INTERNAL_ERROR"},
]
),
):
with pytest.raises(AnonymousNumberError, match="INTERNAL_ERROR"):
await client.terminate_sessions("+1234567890")
# toggle_login_codes mocked tests
@pytest.mark.asyncio
async def test_toggle_login_codes_enable(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True})
with patch.object(client, "call", mock_call):
await client.toggle_login_codes("+1234567890", can_receive=True)
call_data = mock_call.call_args[0][1]
assert call_data["can_receive"] == 1
@pytest.mark.asyncio
async def test_toggle_login_codes_disable(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True})
with patch.object(client, "call", mock_call):
await client.toggle_login_codes("+1234567890", can_receive=False)
call_data = mock_call.call_args[0][1]
assert call_data["can_receive"] == 0
# strip_plus mocked tests
@pytest.mark.asyncio
async def test_get_login_code_strips_plus(client: FragmentClient) -> None:
"""Number passed with '+' is stripped before the API call."""
mock_call = AsyncMock(return_value={})
with patch.object(client, "call", mock_call):
await client.get_login_code("+1234567890")
call_data = mock_call.call_args[0][1]
assert call_data["number"] == "1234567890"
-163
View File
@@ -1,163 +0,0 @@
"""Unit tests for anonymous number methods — login codes and session management."""
from unittest.mock import AsyncMock, patch
import pytest
from pyfragment import AnonymousNumberError, FragmentClient, LoginCodeResult, TerminateSessionsResult
from tests.shared import FAKE_HASH, FAKE_HTML_NO_CODE, FAKE_HTML_WITH_CODE, FAKE_TERMINATE_HASH
# get_login_code tests
@pytest.mark.asyncio
async def test_get_login_code_returns_code(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.anonymous_number.fragment_request",
AsyncMock(return_value={"html": FAKE_HTML_WITH_CODE}),
),
):
result = await client.get_login_code("+1234567890")
assert isinstance(result, LoginCodeResult)
assert result.number == "+1234567890"
assert result.code == "12345"
assert result.active_sessions == 2 # 2 <tr> in the HTML
@pytest.mark.asyncio
async def test_get_login_code_no_pending_code(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.anonymous_number.fragment_request",
AsyncMock(return_value={"html": FAKE_HTML_NO_CODE}),
),
):
result = await client.get_login_code("1234567890")
assert result.code is None
assert result.active_sessions == 1
@pytest.mark.asyncio
async def test_get_login_code_no_html_returns_none(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={})),
):
result = await client.get_login_code("+1234567890")
assert result.code is None
assert result.active_sessions == 0
# terminate_sessions tests
@pytest.mark.asyncio
async def test_terminate_sessions_success(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.anonymous_number.fragment_request",
AsyncMock(
side_effect=[
{"terminate_hash": FAKE_TERMINATE_HASH}, # step 1: confirmation
{"msg": "All sessions terminated"}, # step 2: confirmed
]
),
),
):
result = await client.terminate_sessions("+1234567890")
assert isinstance(result, TerminateSessionsResult)
assert result.number == "+1234567890"
assert result.message == "All sessions terminated"
@pytest.mark.asyncio
async def test_terminate_sessions_not_owned_raises(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={})),
):
with pytest.raises(AnonymousNumberError, match="not associated"):
await client.terminate_sessions("+1234567890")
@pytest.mark.asyncio
async def test_terminate_sessions_api_error_raises(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.anonymous_number.fragment_request",
AsyncMock(return_value={"error": "SESSION_ALREADY_TERMINATED"}),
),
):
with pytest.raises(AnonymousNumberError, match="SESSION_ALREADY_TERMINATED"):
await client.terminate_sessions("+1234567890")
@pytest.mark.asyncio
async def test_terminate_sessions_confirm_error_raises(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.anonymous_number.fragment_request",
AsyncMock(
side_effect=[
{"terminate_hash": FAKE_TERMINATE_HASH},
{"error": "INTERNAL_ERROR"},
]
),
),
):
with pytest.raises(AnonymousNumberError, match="INTERNAL_ERROR"):
await client.terminate_sessions("+1234567890")
# toggle_login_codes tests
@pytest.mark.asyncio
async def test_toggle_login_codes_enable(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={"ok": True})) as mock_req,
):
await client.toggle_login_codes("+1234567890", can_receive=True)
call_data = mock_req.call_args[0][3]
assert call_data["can_receive"] == 1
assert call_data["method"] == "toggleLoginCodes"
@pytest.mark.asyncio
async def test_toggle_login_codes_disable(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={"ok": True})) as mock_req,
):
await client.toggle_login_codes("+1234567890", can_receive=False)
call_data = mock_req.call_args[0][3]
assert call_data["can_receive"] == 0
# strip_plus tests
@pytest.mark.asyncio
async def test_get_login_code_strips_plus(client: FragmentClient) -> None:
"""Number passed with '+' is stripped before the API call."""
with (
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={})) as mock_req,
):
await client.get_login_code("+1234567890")
call_data = mock_req.call_args[0][3]
assert call_data["number"] == "1234567890"
@@ -1,12 +1,14 @@
"""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_HASH, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# recharge_ads validation tests
@@ -35,19 +37,19 @@ async def test_recharge_ads_float_amount(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_recharge_ads_success(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.recharge_ads.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.methods.recharge_ads.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch(
"pyfragment.methods.recharge_ads.fragment_request",
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsState
{"req_id": FAKE_REQ_ID}, # initAdsRechargeRequest
FAKE_TRANSACTION, # getAdsRechargeLink
]
),
),
patch("pyfragment.methods.recharge_ads.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
patch("pyfragment.methods.recharge_ads.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_recharge_ads_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
):
result = await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
@@ -6,7 +6,6 @@ import pytest
from pyfragment import FragmentClient
from pyfragment.types import UsernamesResult
from tests.shared import FAKE_HASH
FAKE_HTML = """
<tr class="tm-row-selectable">
@@ -22,15 +21,12 @@ FAKE_HTML = """
"""
# search_usernames result parsing tests
@pytest.mark.asyncio
async def test_search_usernames_basic(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_usernames.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
),
):
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})):
result = await client.search_usernames("coolname")
assert isinstance(result, UsernamesResult)
@@ -43,13 +39,7 @@ async def test_search_usernames_basic(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_search_usernames_empty_html(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_usernames.fragment_request",
AsyncMock(return_value={"ok": True}),
),
):
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
result = await client.search_usernames("zzz_no_results")
assert isinstance(result, UsernamesResult)
@@ -57,21 +47,18 @@ async def test_search_usernames_empty_html(client: FragmentClient) -> None:
assert result.next_offset_id is None
# search_usernames parameter forwarding tests
@pytest.mark.asyncio
async def test_search_usernames_with_sort_and_filter(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_usernames.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
) as mock_request,
):
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_usernames("durov", sort="price_desc", filter="auction")
assert isinstance(result, UsernamesResult)
call_data = mock_request.call_args[0][3]
call_data = mock_call.call_args[0][1]
assert call_data["type"] == "usernames"
assert call_data["method"] == "searchAuctions"
assert call_data["sort"] == "price_desc"
assert call_data["filter"] == "auction"
assert call_data["query"] == "durov"
@@ -79,33 +66,23 @@ async def test_search_usernames_with_sort_and_filter(client: FragmentClient) ->
@pytest.mark.asyncio
async def test_search_usernames_with_offset_id(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_usernames.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"}),
) as mock_request,
):
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"})
with patch.object(client, "call", mock_call):
result = await client.search_usernames("durov", offset_id="offset_10")
assert isinstance(result, UsernamesResult)
assert result.next_offset_id == "offset_99"
call_data = mock_request.call_args[0][3]
call_data = mock_call.call_args[0][1]
assert call_data["offset_id"] == "offset_10"
@pytest.mark.asyncio
async def test_search_usernames_default_query(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_usernames.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
) as mock_request,
):
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_usernames()
assert isinstance(result, UsernamesResult)
call_data = mock_request.call_args[0][3]
call_data = mock_call.call_args[0][1]
assert call_data["query"] == ""
assert call_data["type"] == "usernames"
@@ -6,7 +6,6 @@ import pytest
from pyfragment import FragmentClient
from pyfragment.types import NumbersResult
from tests.shared import FAKE_HASH
FAKE_HTML = """
<tr class="tm-row-selectable">
@@ -22,15 +21,12 @@ FAKE_HTML = """
"""
# search_numbers result parsing tests
@pytest.mark.asyncio
async def test_search_numbers_basic(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_numbers.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
),
):
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})):
result = await client.search_numbers("888")
assert isinstance(result, NumbersResult)
@@ -43,13 +39,7 @@ async def test_search_numbers_basic(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_search_numbers_empty_html(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_numbers.fragment_request",
AsyncMock(return_value={"ok": True}),
),
):
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
result = await client.search_numbers("zzz_no_results")
assert isinstance(result, NumbersResult)
@@ -57,21 +47,18 @@ async def test_search_numbers_empty_html(client: FragmentClient) -> None:
assert result.next_offset_id is None
# search_numbers parameter forwarding tests
@pytest.mark.asyncio
async def test_search_numbers_with_sort_and_filter(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_numbers.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
) as mock_request,
):
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_numbers("888", sort="price_asc", filter="sale")
assert isinstance(result, NumbersResult)
call_data = mock_request.call_args[0][3]
call_data = mock_call.call_args[0][1]
assert call_data["type"] == "numbers"
assert call_data["method"] == "searchAuctions"
assert call_data["sort"] == "price_asc"
assert call_data["filter"] == "sale"
assert call_data["query"] == "888"
@@ -79,33 +66,23 @@ async def test_search_numbers_with_sort_and_filter(client: FragmentClient) -> No
@pytest.mark.asyncio
async def test_search_numbers_with_offset_id(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_numbers.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_50"}),
) as mock_request,
):
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_50"})
with patch.object(client, "call", mock_call):
result = await client.search_numbers("888", offset_id="offset_50")
assert isinstance(result, NumbersResult)
assert result.next_offset_id == "offset_50"
call_data = mock_request.call_args[0][3]
call_data = mock_call.call_args[0][1]
assert call_data["offset_id"] == "offset_50"
@pytest.mark.asyncio
async def test_search_numbers_default_query(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_numbers.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
) as mock_request,
):
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_numbers()
assert isinstance(result, NumbersResult)
call_data = mock_request.call_args[0][3]
call_data = mock_call.call_args[0][1]
assert call_data["query"] == ""
assert call_data["type"] == "numbers"
@@ -6,7 +6,6 @@ import pytest
from pyfragment import FragmentClient
from pyfragment.types import GiftsResult
from tests.shared import FAKE_HASH
FAKE_GIFTS_HTML = """
<div class="tm-catalog-grid">
@@ -48,15 +47,12 @@ FAKE_GIFTS_HTML = """
"""
# search_gifts result parsing tests
@pytest.mark.asyncio
async def test_search_gifts_basic(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_gifts.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
),
):
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})):
result = await client.search_gifts()
assert isinstance(result, GiftsResult)
@@ -73,13 +69,7 @@ async def test_search_gifts_basic(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_search_gifts_empty(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_gifts.fragment_request",
AsyncMock(return_value={"ok": True}),
),
):
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
result = await client.search_gifts(query="zzz_no_results")
assert isinstance(result, GiftsResult)
@@ -87,68 +77,50 @@ async def test_search_gifts_empty(client: FragmentClient) -> None:
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:
with (
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_gifts.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
) as mock_request,
):
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_request.call_args[0][3]
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"
assert call_data["method"] == "searchAuctions"
@pytest.mark.asyncio
async def test_search_gifts_with_offset(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_gifts.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
) as mock_request,
):
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_request.call_args[0][3]
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:
with (
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_gifts.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
) as mock_request,
):
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_request.call_args[0][3]
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:
with (
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_gifts.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
) as mock_request,
):
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",
@@ -161,7 +133,7 @@ async def test_search_gifts_with_attr(client: FragmentClient) -> None:
)
assert isinstance(result, GiftsResult)
call_data = mock_request.call_args[0][3]
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"]
@@ -173,16 +145,11 @@ async def test_search_gifts_with_attr(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_search_gifts_attr_not_in_data_when_none(client: FragmentClient) -> None:
with (
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch(
"pyfragment.methods.search_gifts.fragment_request",
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
) as mock_request,
):
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_request.call_args[0][3]
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)
+132
View File
@@ -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")
+9 -2
View File
@@ -1,20 +1,27 @@
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():
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 json.loads(raw)
return cast(dict[str, str], json.loads(raw))
except Exception as exc:
pytest.skip(f"Cookies unavailable — {exc}")