mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-26 06:41:42 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eec59f7e12 | |||
| c1ae5f0f28 | |||
| 4873d6d6dd | |||
| 1f6085acdc | |||
| d5dfed5f1a | |||
| 7923dff8b2 | |||
| 8c7423a6ab | |||
| 8f3e9f4bd8 | |||
| fca60135a6 | |||
| d66602b646 | |||
| 333b6f45ce | |||
| 1a070ce8b9 | |||
| 415d9d9a9c | |||
| 6a5e007a1b | |||
| d81b83ac91 | |||
| 6b6ca37f10 | |||
| 028984155b | |||
| 391d15221b | |||
| 269551da2f | |||
| ebd45d2991 | |||
| 733d138fcc | |||
| 503baa9a94 | |||
| d4ac44f698 | |||
| 3aedd619dc | |||
| bae2e1b400 |
@@ -1,101 +1,101 @@
|
||||
name: Bug report
|
||||
description: Report an issue or unexpected behavior in pyfragment.
|
||||
labels:
|
||||
- bug
|
||||
- bug
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I am sure the error is coming from pyfragment code
|
||||
required: true
|
||||
- label: I have searched the issue tracker for similar bug reports, including closed ones
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Context
|
||||
Please provide as much detail as possible to help us reproduce and fix the issue.
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Operating system
|
||||
placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11
|
||||
validations:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I am sure the error is coming from pyfragment code
|
||||
required: true
|
||||
- label: I have searched the issue tracker for similar bug reports, including closed ones
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Python version
|
||||
description: Run `python --version` inside your virtualenv
|
||||
placeholder: e.g. 3.12.3
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Context
|
||||
Please provide as much detail as possible to help us reproduce and fix the issue.
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: pyfragment version
|
||||
description: Run `pip show pyfragment` inside your virtualenv
|
||||
placeholder: e.g. 2026.1.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Operating system
|
||||
placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: Describe what you expected to happen.
|
||||
placeholder: e.g. Stars should be purchased and StarsResult returned.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Python version
|
||||
description: Run `python --version` inside your virtualenv
|
||||
placeholder: e.g. 3.12.3
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current behavior
|
||||
description: Describe what is actually happening.
|
||||
placeholder: e.g. ParseError is raised with status 400.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: pyfragment version
|
||||
description: Run `pip show pyfragment` inside your virtualenv
|
||||
placeholder: e.g. 2026.1.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Minimal steps that reproduce the issue.
|
||||
placeholder: |
|
||||
1. Create FragmentClient with valid credentials
|
||||
2. Call purchase_stars("@username", amount=100)
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: Describe what you expected to happen.
|
||||
placeholder: e.g. Stars should be purchased and StarsResult returned.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Code example
|
||||
description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable.
|
||||
placeholder: |
|
||||
import asyncio
|
||||
from pyfragment import FragmentClient
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current behavior
|
||||
description: Describe what is actually happening.
|
||||
placeholder: e.g. ParseError is raised with status 400.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
async def main():
|
||||
client = FragmentClient(...)
|
||||
result = await client.purchase_stars("@username", amount=100)
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Minimal steps that reproduce the issue.
|
||||
placeholder: |
|
||||
1. Create FragmentClient with valid credentials
|
||||
2. Call purchase_stars("@username", amount=100)
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
asyncio.run(main())
|
||||
render: python
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Code example
|
||||
description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable.
|
||||
placeholder: |
|
||||
import asyncio
|
||||
from pyfragment import FragmentClient
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Traceback / logs
|
||||
description: Paste the full traceback or relevant logs.
|
||||
placeholder: |
|
||||
Traceback (most recent call last):
|
||||
File "main.py", line 7, in main
|
||||
...
|
||||
pyfragment.types.ParseError: ...
|
||||
render: sh
|
||||
async def main():
|
||||
client = FragmentClient(...)
|
||||
result = await client.purchase_stars("@username", amount=100)
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Anything else that might help us diagnose the problem.
|
||||
placeholder: e.g. Only happens with V5R1 wallet version.
|
||||
asyncio.run(main())
|
||||
render: python
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Traceback / logs
|
||||
description: Paste the full traceback or relevant logs.
|
||||
placeholder: |
|
||||
Traceback (most recent call last):
|
||||
File "main.py", line 7, in main
|
||||
...
|
||||
pyfragment.types.ParseError: ...
|
||||
render: sh
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Anything else that might help us diagnose the problem.
|
||||
placeholder: e.g. Only happens with V5R1 wallet version.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Ask a question or start a discussion
|
||||
url: https://github.com/bohd4nx/pyfragment/discussions
|
||||
about: General questions, ideas, and community help go here — not in the issue tracker.
|
||||
- name: Ask a question or start a discussion
|
||||
url: https://github.com/bohd4nx/pyfragment/discussions
|
||||
about: General questions, ideas, and community help go here — not in the issue tracker.
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
name: Feature request
|
||||
description: Suggest an improvement or new feature for pyfragment.
|
||||
labels:
|
||||
- enhancement
|
||||
- enhancement
|
||||
body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: pyfragment version
|
||||
description: Which version are you running?
|
||||
options:
|
||||
- latest
|
||||
- older
|
||||
- n/a
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: pyfragment version
|
||||
description: Which version are you running?
|
||||
options:
|
||||
- latest
|
||||
- older
|
||||
- n/a
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem
|
||||
description: Is your request related to a specific problem? Describe it.
|
||||
placeholder: e.g. There is no way to check my current TON balance before sending.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem
|
||||
description: Is your request related to a specific problem? Describe it.
|
||||
placeholder: e.g. There is no way to check my current TON balance before sending.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe what you would like to see added or changed.
|
||||
placeholder: e.g. Add a get_balance() method to FragmentClient.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe what you would like to see added or changed.
|
||||
placeholder: e.g. Add a get_balance() method to FragmentClient.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Any workarounds or alternative approaches you have thought of.
|
||||
placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Any workarounds or alternative approaches you have thought of.
|
||||
placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Code example
|
||||
description: A short example demonstrating the desired API, if applicable.
|
||||
placeholder: |
|
||||
balance = await client.get_balance()
|
||||
print(balance.ton)
|
||||
render: python
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Code example
|
||||
description: A short example demonstrating the desired API, if applicable.
|
||||
placeholder: |
|
||||
balance = await client.get_balance()
|
||||
print(balance.ton)
|
||||
render: python
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Any other context, screenshots, or references.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Any other context, screenshots, or references.
|
||||
|
||||
+16
-16
@@ -1,19 +1,19 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
+22
-20
@@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
branches: [ "**" ]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
branches: [ "**" ]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -14,34 +14,36 @@ jobs:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- uses: astral-sh/setup-uv@v7.6.0
|
||||
- run: pip install ".[dev]"
|
||||
|
||||
- run: uv pip install --system ".[dev]"
|
||||
|
||||
- run: ruff check . && black --check . --target-version py312 && mypy pyfragment
|
||||
- run: ruff check . && ruff format --check . && mypy pyfragment
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
name: Tests (Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
COOKIES_JSON: ${{ secrets.COOKIES_JSON }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [ "3.10", "3.11", "3.12" ] # 3.13, 3.14 are not supported by some dependencies yet
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- uses: astral-sh/setup-uv@v7.6.0
|
||||
- name: Install package and dev dependencies
|
||||
run: pip install ".[dev]"
|
||||
|
||||
- run: uv pip install --system ".[dev]"
|
||||
|
||||
- run: pytest
|
||||
- name: Run tests
|
||||
run: pytest
|
||||
|
||||
@@ -2,9 +2,9 @@ name: Publish
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
branches: [master]
|
||||
workflows: [ "CI" ]
|
||||
types: [ completed ]
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
@@ -18,24 +18,24 @@ jobs:
|
||||
is-new: ${{ steps.tag.outputs.is-new }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Read version
|
||||
id: version
|
||||
run: |
|
||||
value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
|
||||
echo "value=$value" >> $GITHUB_OUTPUT
|
||||
- name: Read version
|
||||
id: version
|
||||
run: |
|
||||
value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
|
||||
echo "value=$value" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check tag
|
||||
id: tag
|
||||
run: |
|
||||
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
|
||||
echo "is-new=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is-new=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Check tag
|
||||
id: tag
|
||||
run: |
|
||||
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
|
||||
echo "is-new=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is-new=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build:
|
||||
name: Build
|
||||
@@ -46,24 +46,24 @@ jobs:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- uses: astral-sh/setup-uv@v7.6.0
|
||||
- uses: astral-sh/setup-uv@v8.1.0
|
||||
|
||||
- run: uv build
|
||||
- run: uv build
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist
|
||||
path: dist/*
|
||||
- uses: actions/upload-artifact@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
@@ -31,7 +31,7 @@ Thumbs.db
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
demo.run.py
|
||||
systests/
|
||||
|
||||
# Build & distribution
|
||||
dist/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
[](https://pypi.org/project/pyfragment/)
|
||||
[](https://pypi.org/project/pyfragment/)
|
||||
[](https://python.org)
|
||||
[](https://python.org)
|
||||
[](LICENSE)
|
||||
[](https://github.com/bohd4nx/pyfragment/stargazers)
|
||||
[](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).
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 ``@``).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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`."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 +0,0 @@
|
||||
-e .[dev]
|
||||
@@ -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)
|
||||
@@ -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"})
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user