mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 14:24:31 +00:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a070ce8b9 | |||
| 415d9d9a9c | |||
| 6a5e007a1b | |||
| d81b83ac91 | |||
| 6b6ca37f10 | |||
| 028984155b | |||
| 391d15221b | |||
| 269551da2f | |||
| ebd45d2991 | |||
| 733d138fcc | |||
| 503baa9a94 | |||
| d4ac44f698 | |||
| 3aedd619dc | |||
| bae2e1b400 | |||
| 0a71e6c508 | |||
| fb0b82e429 | |||
| 3c60bf5e5a | |||
| da2eb37a10 | |||
| 3156160778 | |||
| 8163ed1a9d | |||
| f37a4ab985 | |||
| dfe7bb4140 | |||
| 327ca35190 | |||
| 6e4d4da740 | |||
| ab8d463d54 | |||
| b55cd00205 | |||
| 25d70a77ed | |||
| c7b6c6c933 | |||
| 2d1a119768 | |||
| 902b692dcb | |||
| 7cf5ab4500 | |||
| 6cae351607 | |||
| 13f5aaf555 | |||
| 9660e4858f | |||
| 75cbe52507 | |||
| 4e03d2e2f6 | |||
| 0af5ce5935 | |||
| b07b4670fb | |||
| 581886938b | |||
| 949796df17 | |||
| 75ee76b60e | |||
| 3e14a01c92 | |||
| 2184dc7d98 | |||
| c5edfad06f | |||
| 20b73444ab | |||
| b726a2274c | |||
| 8d58ed890d | |||
| f5d2e8490a | |||
| 520153dfb1 | |||
| 67f8a882c2 | |||
| 041081b919 | |||
| f4a96bb01a | |||
| d8f0240807 | |||
| 8135a9da7e | |||
| 7f269d9a87 | |||
| 9a090e3dbc | |||
| d2d046a2b9 |
@@ -1,11 +0,0 @@
|
||||
# Fragment.com cookies - copy from browser after login (Header String format)
|
||||
# Hash is now fetched dynamically
|
||||
|
||||
# TON wallet seed phrase - 12 or 24 words separated by spaces
|
||||
SEED = "your_ton_wallet_seed_phrase_here"
|
||||
|
||||
# TON API key - get from https://tonconsole.com
|
||||
API_KEY = "your_ton_api_key_here"
|
||||
|
||||
# TON wallet contract version: V4R2 or V5R1 (default: V5R1)
|
||||
WALLET_VERSION = "V5R1"
|
||||
@@ -0,0 +1,101 @@
|
||||
name: Bug report
|
||||
description: Report an issue or unexpected behavior in pyfragment.
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I am sure the error is coming from pyfragment code
|
||||
required: true
|
||||
- label: I have searched the issue tracker for similar bug reports, including closed ones
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Context
|
||||
Please provide as much detail as possible to help us reproduce and fix the issue.
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Operating system
|
||||
placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Python version
|
||||
description: Run `python --version` inside your virtualenv
|
||||
placeholder: e.g. 3.12.3
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: pyfragment version
|
||||
description: Run `pip show pyfragment` inside your virtualenv
|
||||
placeholder: e.g. 2026.1.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: Describe what you expected to happen.
|
||||
placeholder: e.g. Stars should be purchased and StarsResult returned.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current behavior
|
||||
description: Describe what is actually happening.
|
||||
placeholder: e.g. ParseError is raised with status 400.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Minimal steps that reproduce the issue.
|
||||
placeholder: |
|
||||
1. Create FragmentClient with valid credentials
|
||||
2. Call purchase_stars("@username", amount=100)
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Code example
|
||||
description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable.
|
||||
placeholder: |
|
||||
import asyncio
|
||||
from pyfragment import FragmentClient
|
||||
|
||||
async def main():
|
||||
client = FragmentClient(...)
|
||||
result = await client.purchase_stars("@username", amount=100)
|
||||
|
||||
asyncio.run(main())
|
||||
render: python
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Traceback / logs
|
||||
description: Paste the full traceback or relevant logs.
|
||||
placeholder: |
|
||||
Traceback (most recent call last):
|
||||
File "main.py", line 7, in main
|
||||
...
|
||||
pyfragment.types.ParseError: ...
|
||||
render: sh
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Anything else that might help us diagnose the problem.
|
||||
placeholder: e.g. Only happens with V5R1 wallet version.
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Ask a question or start a discussion
|
||||
url: https://github.com/bohd4nx/pyfragment/discussions
|
||||
about: General questions, ideas, and community help go here — not in the issue tracker.
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Feature request
|
||||
description: Suggest an improvement or new feature for pyfragment.
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: pyfragment version
|
||||
description: Which version are you running?
|
||||
options:
|
||||
- latest
|
||||
- older
|
||||
- n/a
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem
|
||||
description: Is your request related to a specific problem? Describe it.
|
||||
placeholder: e.g. There is no way to check my current TON balance before sending.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe what you would like to see added or changed.
|
||||
placeholder: e.g. Add a get_balance() method to FragmentClient.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Any workarounds or alternative approaches you have thought of.
|
||||
placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Code example
|
||||
description: A short example demonstrating the desired API, if applicable.
|
||||
placeholder: |
|
||||
balance = await client.get_balance()
|
||||
print(balance.ton)
|
||||
render: python
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Any other context, screenshots, or references.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Description
|
||||
|
||||
Please include a summary of the change and which issue is fixed.
|
||||
Include relevant motivation and context.
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Documentation (typos, examples, or any docs update)
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## How has this been tested?
|
||||
|
||||
Describe the tests you ran to verify the change and list any relevant details.
|
||||
|
||||
- [ ] Existing tests pass (`pytest`)
|
||||
- [ ] New tests added for this change
|
||||
|
||||
**Test configuration:**
|
||||
* OS:
|
||||
* Python version:
|
||||
* pyfragment version:
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have updated documentation where necessary
|
||||
- [ ] I have added tests that prove my fix or feature works
|
||||
- [ ] All new and existing tests pass locally
|
||||
@@ -8,3 +8,12 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint & Format
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
|
||||
- uses: actions/setup-python@v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- run: pip install ".[dev]"
|
||||
|
||||
- run: ruff check . && black --check . --target-version py312 && mypy pyfragment
|
||||
|
||||
test:
|
||||
name: Tests (Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"] # 3.13, 3.14 are not supported by some dependencies yet
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Set up conda (Miniconda)
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
auto-update-conda: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: pyfragment-test
|
||||
auto-activate-base: false
|
||||
|
||||
- name: Install package and dev dependencies
|
||||
shell: bash -el {0}
|
||||
run: pip install ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
shell: bash -el {0}
|
||||
run: pytest
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
name: Version Check
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.value }}
|
||||
is-new: ${{ steps.tag.outputs.is-new }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Read version
|
||||
id: version
|
||||
run: |
|
||||
value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
|
||||
echo "value=$value" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check tag
|
||||
id: tag
|
||||
run: |
|
||||
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
|
||||
echo "is-new=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is-new=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: version-check
|
||||
if: needs.version-check.outputs.is-new == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
|
||||
- uses: actions/setup-python@v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- uses: astral-sh/setup-uv@v7.6.0
|
||||
|
||||
- run: uv build
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist
|
||||
path: dist/*
|
||||
|
||||
publish:
|
||||
name: Publish to PyPI
|
||||
needs: [version-check, build]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/project/pyfragment/
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8.0.1
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
|
||||
- uses: pypa/gh-action-pypi-publish@v1.13.0
|
||||
|
||||
release:
|
||||
name: GitHub Release
|
||||
needs: [version-check, build]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/download-artifact@v8.0.1
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
|
||||
- name: Extract latest changelog entry
|
||||
id: changelog
|
||||
run: |
|
||||
body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "body<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$body" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: softprops/action-gh-release@v2.6.1
|
||||
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,31 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt pytest pytest-asyncio
|
||||
|
||||
- name: Write cookies.json
|
||||
if: ${{ env.COOKIES_JSON != '' }}
|
||||
run: echo "$COOKIES_JSON" > cookies.json
|
||||
env:
|
||||
COOKIES_JSON: ${{ secrets.COOKIES_JSON }}
|
||||
|
||||
- name: Run tests
|
||||
run: pytest
|
||||
+14
-1
@@ -23,4 +23,17 @@ logs/
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
cookies.json
|
||||
|
||||
# Testing & tooling artifacts
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
systests/
|
||||
|
||||
# Build & distribution
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to pyfragment are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MICRO`).
|
||||
|
||||
---
|
||||
|
||||
## [2026.2.0] — 2026-04-14
|
||||
|
||||
### Added
|
||||
|
||||
- `get_cookies_from_browser(browser)` — extract Fragment session cookies directly from an installed browser (Chrome, Firefox, Edge, Brave, Arc, Opera, Safari, and more); no browser extension or manual copy-paste required
|
||||
```python
|
||||
from pyfragment.utils import get_cookies_from_browser
|
||||
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
|
||||
client = FragmentClient(seed="...", api_key="...", cookies=result.cookies)
|
||||
print(result.expires) # ISO 8601 expiry of stel_ssid, or None for session cookies
|
||||
```
|
||||
- `CookieResult` — return type of `get_cookies_from_browser()`; exposes `.cookies` (`dict[str, str]`) and `.expires` (ISO 8601 string or `None`)
|
||||
|
||||
### Changed
|
||||
|
||||
- `DEVICE` Tonkeeper fingerprint updated: `appVersion` → `26.04.0`
|
||||
- `tonutils` upgraded to **2.1.0**
|
||||
- Minimum Python version lowered to **3.10** (previously 3.12)
|
||||
|
||||
---
|
||||
|
||||
## [2026.1.0] — 2026-03-25
|
||||
|
||||
### Added
|
||||
|
||||
**Giveaways**
|
||||
- `giveaway_stars(channel, winners, amount)` — Stars giveaway; 1–5 winners, 500–1 000 000 stars each
|
||||
- `giveaway_premium(channel, winners, months)` — Premium giveaway; 1–24 000 winners, 3/6/12 months each
|
||||
- `StarsGiveawayResult`, `PremiumGiveawayResult` result types
|
||||
|
||||
**Telegram Ads**
|
||||
- `recharge_ads(account, amount)` — top up a Telegram Ads account; 1–1 000 000 000 TON
|
||||
- `AdsRechargeResult` result type
|
||||
|
||||
**Marketplace**
|
||||
- `search_usernames(query?, sort?, filter?, offset_id?)` — search Fragment usernames; `sort`: `price_desc / price_asc / listed / ending`, `filter`: `auction / sale / sold`
|
||||
- `search_numbers(query?, sort?, filter?, offset_id?)` — search Fragment anonymous numbers; same `sort` / `filter` / pagination semantics
|
||||
- `search_gifts(query?, collection?, sort?, filter?, view?, attr?, offset?)` — search Fragment gifts; `attr` accepts `{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}`
|
||||
- `UsernamesResult`, `NumbersResult`, `GiftsResult` result types
|
||||
|
||||
**Anonymous numbers**
|
||||
- `get_login_code(number)` — fetch the current pending login code
|
||||
- `toggle_login_codes(number, can_receive)` — enable or disable login code delivery
|
||||
- `terminate_sessions(number)` — terminate all active Telegram sessions (two-step flow handled internally)
|
||||
- `LoginCodeResult`, `TerminateSessionsResult` result types; `AnonymousNumberError` exception
|
||||
|
||||
**Raw API**
|
||||
- `FragmentClient.call(method, data, *, page_url)` — raw request to any Fragment API method
|
||||
- `FRAGMENT_BASE_URL` constant — base URL shared across all page constants and headers
|
||||
|
||||
**Examples**
|
||||
- `examples/client/` — `wallet_info.py` (wallet info), `raw_api_call.py` (raw API call)
|
||||
- `examples/numbers/` — `manage_number.py` (login code fetch, session termination)
|
||||
- `examples/auctions/` — `search_usernames.py`, `search_numbers.py`, `search_gifts.py` (marketplace search with pagination)
|
||||
- `examples/purchase/` — `send_stars.py`, `send_premium.py`, `topup_ton_balance.py`, `run_stars_giveaway.py`, `run_premium_giveaway.py`, `recharge_ads_balance.py`
|
||||
|
||||
### Changed
|
||||
- All result types now expose a unified `amount` field (`months` and `stars` removed)
|
||||
- `__repr__` includes the unit — `3 months`, `500 stars`, etc.
|
||||
- `timestamp` removed from all result dataclasses
|
||||
- All page URL constants built from `FRAGMENT_BASE_URL`;
|
||||
- `TransactionError` includes an SSL hint; `DUPLICATE_SEQNO` variant auto-retried up to 2 times (2 s apart)
|
||||
- Error messages rewritten: "what happened → why → what to do"
|
||||
|
||||
---
|
||||
|
||||
## [2026.0.2] — 2026-03-20
|
||||
|
||||
### Added
|
||||
- `timeout` parameter on `FragmentClient` (default `30.0` s) — passed through to every HTTP request
|
||||
|
||||
### Changed
|
||||
- Cookie validation: narrowed type internally so no `# type: ignore` is needed in `FragmentClient.__init__`
|
||||
- `WALLET_CLASSES` typed as `dict[str, Any]` so mypy resolves `from_mnemonic` correctly
|
||||
- All four `examples/` files updated to `async with FragmentClient`, f-strings, and aligned error messages
|
||||
- README usage section rewritten with a single comprehensive `async with` example
|
||||
|
||||
### Fixed
|
||||
- mypy: missing return path in `process_transaction` after retry loop
|
||||
- mypy: `cookies` union-attr error in `FragmentClient.__init__`
|
||||
|
||||
---
|
||||
|
||||
## [2026.0.1] — 2026-03-16
|
||||
|
||||
### Added
|
||||
- Initial stable release of `pyfragment`
|
||||
- `FragmentClient` — async client for the Fragment.com API with context manager support (`async with`)
|
||||
- `purchase_premium(username, months)` — purchase Telegram Premium for any user (3, 6, or 12 months)
|
||||
- `purchase_stars(username, amount)` — send Telegram Stars to any user (50–1,000,000)
|
||||
- `topup_ton(username, amount)` — top up TON Ads balance (1–1,000,000,000 TON)
|
||||
- `get_wallet()` — fetch wallet address and balance
|
||||
- Support for TON wallet versions `V4R2` and `V5R1`
|
||||
- Structured exception hierarchy (`FragmentError`, `ConfigurationError`, `CookieError`, etc.)
|
||||
- `py.typed` marker — full PEP 561 typing support for type-checkers
|
||||
- `__repr__` on all result types for readable debug output
|
||||
|
||||
[2026.2.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.0
|
||||
[2026.1.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.1.0
|
||||
[2026.0.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.2
|
||||
[2026.0.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.1
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 bohd4nx
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,182 +1,138 @@
|
||||
<div align="center">
|
||||
<img src="fragment.svg" alt="Fragment Logo" width="120" height="120" style="border-radius: 24px;">
|
||||
<img src="https://www.bohd4n.dev/assets/projects/pyfragment.svg" alt="Fragment Logo" width="120" height="120" style="border-radius: 24px;">
|
||||
|
||||
<h1 style="margin-top: 24px;">💎 Fragment API by @bohd4nx</h1>
|
||||
<h1 style="margin-top: 24px;">Fragment API</h1>
|
||||
|
||||
<p style="font-size: 18px; margin-bottom: 24px;">
|
||||
<b>Automate TON topups, Telegram Premium purchases, and Stars transactions via Fragment.com</b>
|
||||
<b>Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts.</b>
|
||||
</p>
|
||||
|
||||
[](https://pypi.org/project/pyfragment/)
|
||||
[](https://pypi.org/project/pyfragment/)
|
||||
[](https://python.org)
|
||||
[](https://github.com/nessshon/tonutils)
|
||||
[](https://github.com/bohd4nx/FragmentAPI/stargazers)
|
||||
[](https://github.com/bohd4nx/FragmentAPI/issues)
|
||||
[](https://github.com/bohd4nx/FragmentAPI/actions)
|
||||
[](LICENSE)
|
||||
[](https://github.com/bohd4nx/pyfragment/stargazers)
|
||||
[](https://github.com/bohd4nx/pyfragment/actions)
|
||||
|
||||
[Report Bug](https://github.com/bohd4nx/fragmentapi/issues) · [Request Feature](https://github.com/bohd4nx/fragmentapi/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
|
||||
[Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
|
||||
|
||||
</div>
|
||||
|
||||
> **Disclaimer:** This project is not affiliated with, endorsed by, or in any way officially connected with [Fragment](https://fragment.com) or [Telegram](https://telegram.org).
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 💰 **TON Advertisement Topups** — Send TON directly to Fragment ad accounts (1–1,000,000,000 TON)
|
||||
- 👑 **Telegram Premium Gifts** — Purchase Premium subscriptions for any user (3, 6, or 12 months)
|
||||
- ⭐ **Telegram Stars Purchases** — Buy Stars and send them to any Telegram user (50–1,000,000 Stars)
|
||||
- 🔐 **Multi-wallet support** — Configurable wallet contract version (V4R2 / V5R1)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Installation
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bohd4nx/FragmentAPI.git
|
||||
cd FragmentAPI
|
||||
pip install -r requirements.txt
|
||||
pip install pyfragment
|
||||
```
|
||||
|
||||
### 2. Configuration
|
||||
To install the latest unreleased changes from the `dev` branch:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
cp cookies.example.json cookies.json
|
||||
pip install git+https://github.com/bohd4nx/pyfragment.git@dev
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
Requires Python 3.10+.
|
||||
|
||||
```env
|
||||
# 24-word TON wallet seed phrase
|
||||
SEED = word1 word2 word3 ... word24
|
||||
---
|
||||
|
||||
# API key from @tonapibot on Telegram
|
||||
API_KEY = your_tonapi_key_here
|
||||
## Configuration
|
||||
|
||||
# Wallet contract version: V4R2 or V5R1 (default: V5R1)
|
||||
WALLET_VERSION = V5R1
|
||||
```
|
||||
| Parameter | Type | Default | Description |
|
||||
| ---------------- | ------------- | -------- | -------------------------------------------------------- |
|
||||
| `seed` | `str` | — | 24-word TON wallet mnemonic |
|
||||
| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) |
|
||||
| `cookies` | `dict \| str` | — | Fragment session cookies |
|
||||
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` |
|
||||
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
|
||||
|
||||
### 3. Getting Required Data
|
||||
---
|
||||
|
||||
#### 🍪 Fragment.com Cookies
|
||||
## Credentials
|
||||
|
||||
**Prerequisites**: Log in to Telegram on Fragment and connect the TON wallet you'll use for payments.
|
||||
**Fragment cookies** — log in to [fragment.com](https://fragment.com) and connect your TON wallet. You can get cookies in two ways:
|
||||
|
||||
1. Install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) extension
|
||||
2. Open [fragment.com](https://fragment.com) and make sure you're logged in
|
||||
3. Click the Cookie Editor icon → **Export** → **Header String**
|
||||
4. Split the result into the four fields in `cookies.json`:
|
||||
- **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.
|
||||
|
||||
```json
|
||||
{
|
||||
"stel_ssid": "...",
|
||||
"stel_dt": "...",
|
||||
"stel_token": "...",
|
||||
"stel_ton_token": "..."
|
||||
}
|
||||
```
|
||||
Refresh when you get authentication errors.
|
||||
|
||||
#### 🔐 TON Wallet Seed Phrase
|
||||
**Tonapi key** — generate at [tonconsole.com](https://tonconsole.com).
|
||||
|
||||
If you don't have a TON wallet, create one in [Tonkeeper](https://tonkeeper.com) (iOS / Android).
|
||||
Go to **Settings → Backup**, copy the 24 words and paste them into `SEED` in `.env`.
|
||||
**Seed phrase** — 24-word mnemonic from your TON wallet (Tonkeeper → Settings → Backup). Never share it.
|
||||
|
||||
> ⚠️ Never share your seed phrase with anyone. Store it offline.
|
||||
---
|
||||
|
||||
#### 🔑 TON API Key
|
||||
|
||||
1. Go to [tonconsole.com](https://tonconsole.com)
|
||||
2. Create an account and log in
|
||||
3. Generate a new API key
|
||||
4. Paste it into `API_KEY` in `.env`
|
||||
|
||||
#### 🔐 Wallet Version
|
||||
|
||||
| Version | Use when |
|
||||
| ------- | -------------------------------------------------------------- |
|
||||
| `V5R1` | Default — Tonkeeper / MyTonWallet (wallets created after 2024) |
|
||||
| `V4R2` | Older Tonkeeper wallets |
|
||||
|
||||
Not sure? Run this to check which address matches your wallet:
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import asyncio
|
||||
from tonutils.clients import TonapiClient
|
||||
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
|
||||
from tonutils.types import NetworkGlobalID
|
||||
from app.core import config
|
||||
|
||||
client = TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY)
|
||||
w4, _, _, _ = WalletV4R2.from_mnemonic(client=client, mnemonic=config.SEED)
|
||||
w5, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED)
|
||||
print('V4R2:', w4.address.to_str(True, True))
|
||||
print('V5R1:', w5.address.to_str(True, True))
|
||||
"
|
||||
```
|
||||
|
||||
### 4. Usage
|
||||
|
||||
#### Run Examples
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
#### Programmatic Usage
|
||||
## Usage
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from app.methods import topup_ton, buy_premium, buy_stars
|
||||
from pyfragment import (
|
||||
FragmentClient,
|
||||
FragmentError, # base — catches everything below
|
||||
UserNotFoundError, # username doesn't exist on Fragment
|
||||
WalletError, # insufficient balance or misconfiguration
|
||||
CookieError, # cookies are missing or expired
|
||||
TransactionError, # on-chain broadcast failed
|
||||
ConfigurationError, # invalid argument (months, amount, etc.)
|
||||
FragmentAPIError, # unexpected Fragment API response
|
||||
)
|
||||
|
||||
async def main():
|
||||
# Send 10 TON to @username
|
||||
result = await topup_ton("@username", 10)
|
||||
print(result)
|
||||
|
||||
# Gift 6 months of Telegram Premium (anonymous — recipient won't see sender)
|
||||
result = await buy_premium("@username", 6, show_sender=False)
|
||||
print(result)
|
||||
async def main() -> None:
|
||||
async with FragmentClient(
|
||||
seed="word1 word2 ... word24", # 24-word TON wallet mnemonic
|
||||
api_key="YOUR_TONAPI_KEY", # from tonconsole.com
|
||||
cookies={
|
||||
"stel_ssid": "...",
|
||||
"stel_dt": "...",
|
||||
"stel_token": "...",
|
||||
"stel_ton_token": "...",
|
||||
},
|
||||
) as client:
|
||||
try:
|
||||
# Purchase 6 months of Telegram Premium
|
||||
result = await client.purchase_premium("@username", months=6)
|
||||
print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||
|
||||
# Purchase 500 Stars
|
||||
result = await client.purchase_stars("@username", amount=500)
|
||||
print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||
|
||||
# Top up 10 TON to Telegram balance
|
||||
# wallet must hold at least amount + ~0.056 TON for gas
|
||||
result = await client.topup_ton("@username", amount=10)
|
||||
print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||
|
||||
except UserNotFoundError:
|
||||
print(f"User was not found on fragment.com — check the username and try again.")
|
||||
except WalletError as e:
|
||||
print(f"Wallet error — insufficient balance or misconfiguration: {e}")
|
||||
except CookieError:
|
||||
print("Authentication failed — session cookies are missing or expired. Refresh them and retry.")
|
||||
except TransactionError as e:
|
||||
print(f"Transaction failed to broadcast on-chain: {e}")
|
||||
except ConfigurationError as e:
|
||||
print(f"Invalid argument: {e}")
|
||||
except FragmentAPIError as e:
|
||||
print(f"Unexpected response from Fragment API: {e}")
|
||||
except FragmentError as e:
|
||||
# catch-all for any other pyfragment error
|
||||
print(f"Unexpected error: {e}")
|
||||
|
||||
# Buy 500 Stars for @username
|
||||
result = await buy_stars("@username", 500)
|
||||
print(result)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
**Return format** (on success):
|
||||
|
||||
```python
|
||||
{
|
||||
"success": True,
|
||||
"data": {
|
||||
"transaction_id": "<TL-B ExternalMessage ...>",
|
||||
"username": "@username",
|
||||
"amount": 10, # or "months" for Premium
|
||||
"timestamp": 1741234567
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Return format** (on failure):
|
||||
|
||||
```python
|
||||
{
|
||||
"success": False,
|
||||
"error": "Telegram user '@unknown' was not found on Fragment."
|
||||
}
|
||||
```
|
||||
|
||||
### Supported Operations
|
||||
|
||||
| Operation | Function | Parameters | Limits |
|
||||
| ------------------ | ----------------------------------------------------- | ----------------------------------- | ------------------- |
|
||||
| **TON Topup** | `topup_ton(username, amount, show_sender=True)` | Username, TON amount, show sender | 1–1,000,000,000 TON |
|
||||
| **Premium Gift** | `buy_premium(username, months, show_sender=True)` | Username, duration, show sender | 3, 6, or 12 months |
|
||||
| **Stars Purchase** | `buy_stars(username, amount, show_sender=True)` | Username, Stars amount, show sender | 50–1,000,000 Stars |
|
||||
|
||||
Usernames can be passed with or without `@`.
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
from app.core.config import config
|
||||
from app.core.constants import (
|
||||
ADS_PAGE,
|
||||
BASE_HEADERS,
|
||||
DEVICE,
|
||||
PREMIUM_PAGE,
|
||||
STARS_PAGE,
|
||||
WALLET_CLASSES,
|
||||
WalletVersion,
|
||||
)
|
||||
from app.core.cookies import load_cookies
|
||||
from app.core.exceptions import (
|
||||
ConfigError,
|
||||
CookiesError,
|
||||
FragmentError,
|
||||
HashFetchError,
|
||||
RequestError,
|
||||
TransactionError,
|
||||
UserNotFoundError,
|
||||
WalletError,
|
||||
)
|
||||
from app.core.logging import logger, setup_logging
|
||||
|
||||
__all__ = [
|
||||
"ADS_PAGE",
|
||||
"BASE_HEADERS",
|
||||
"DEVICE",
|
||||
"PREMIUM_PAGE",
|
||||
"STARS_PAGE",
|
||||
"WALLET_CLASSES",
|
||||
"WalletVersion",
|
||||
"ConfigError",
|
||||
"CookiesError",
|
||||
"FragmentError",
|
||||
"HashFetchError",
|
||||
"RequestError",
|
||||
"TransactionError",
|
||||
"UserNotFoundError",
|
||||
"WalletError",
|
||||
"config",
|
||||
"load_cookies",
|
||||
"logger",
|
||||
"setup_logging",
|
||||
]
|
||||
@@ -1,46 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from app.core.constants import SUPPORTED_WALLET_VERSIONS, WalletVersion
|
||||
from app.core.exceptions import ConfigError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Config:
|
||||
SEED: str
|
||||
API_KEY: str
|
||||
WALLET_VERSION: WalletVersion
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Load .env if present; env vars already in the process take precedence
|
||||
env_path = Path(__file__).resolve().parents[2] / ".env"
|
||||
if env_path.exists():
|
||||
load_dotenv(env_path)
|
||||
|
||||
missing = [k for k in ("SEED", "API_KEY") if not os.getenv(k, "").strip()]
|
||||
if missing:
|
||||
raise ConfigError(
|
||||
f"Missing required environment variables: {', '.join(missing)}. "
|
||||
"Copy .env.example to .env and fill in SEED and API_KEY."
|
||||
)
|
||||
|
||||
self.SEED = os.getenv("SEED", "").strip()
|
||||
self.API_KEY = os.getenv("API_KEY", "").strip()
|
||||
|
||||
version = os.getenv("WALLET_VERSION", "V5R1").strip().upper()
|
||||
if version not in SUPPORTED_WALLET_VERSIONS:
|
||||
raise ConfigError(
|
||||
f"Unsupported WALLET_VERSION '{version}'. " f"Must be one of: {', '.join(sorted(SUPPORTED_WALLET_VERSIONS))}."
|
||||
)
|
||||
self.WALLET_VERSION: WalletVersion = version # type: ignore[assignment]
|
||||
|
||||
|
||||
config: Config | None = None
|
||||
try:
|
||||
config = Config()
|
||||
except ConfigError as e:
|
||||
logger.warning("Configuration not loaded: %s", e)
|
||||
@@ -1,32 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.core.exceptions import CookiesError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REQUIRED_KEYS = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
|
||||
|
||||
|
||||
def load_cookies() -> dict[str, Any]:
|
||||
cookies_path = Path(__file__).resolve().parents[2] / "cookies.json"
|
||||
|
||||
if not cookies_path.exists():
|
||||
raise CookiesError("cookies.json not found. Create it in the project root and paste your Fragment cookies.")
|
||||
|
||||
try:
|
||||
with cookies_path.open("r", encoding="utf-8") as f:
|
||||
cookies = json.load(f)
|
||||
except Exception as exc:
|
||||
raise CookiesError(f"Failed to read cookies.json: {exc}") from exc
|
||||
|
||||
missing = [k for k in _REQUIRED_KEYS if not str(cookies.get(k, "")).strip()]
|
||||
if missing:
|
||||
raise CookiesError(
|
||||
f"cookies.json is missing or has empty values for: {', '.join(missing)}. "
|
||||
"Open Fragment.com in your browser, copy fresh cookies, and update the file."
|
||||
)
|
||||
|
||||
return cookies
|
||||
@@ -1,42 +0,0 @@
|
||||
__all__ = [
|
||||
"ConfigError",
|
||||
"CookiesError",
|
||||
"FragmentError",
|
||||
"HashFetchError",
|
||||
"RequestError",
|
||||
"TransactionError",
|
||||
"UserNotFoundError",
|
||||
"WalletError",
|
||||
]
|
||||
|
||||
|
||||
class FragmentError(Exception):
|
||||
"""Base exception for all Fragment API errors."""
|
||||
|
||||
|
||||
class ConfigError(FragmentError):
|
||||
"""Raised when .env is missing or required keys are absent."""
|
||||
|
||||
|
||||
class CookiesError(FragmentError):
|
||||
"""Raised when cookies.json is missing, unreadable, or has empty required fields."""
|
||||
|
||||
|
||||
class HashFetchError(FragmentError):
|
||||
"""Raised when the Fragment API hash cannot be fetched from the page."""
|
||||
|
||||
|
||||
class UserNotFoundError(FragmentError):
|
||||
"""Raised when the target Telegram user is not found on Fragment."""
|
||||
|
||||
|
||||
class WalletError(FragmentError):
|
||||
"""Raised for TON wallet issues (connection, balance, account info)."""
|
||||
|
||||
|
||||
class TransactionError(FragmentError):
|
||||
"""Raised when a TON transaction fails to build or broadcast."""
|
||||
|
||||
|
||||
class RequestError(FragmentError):
|
||||
"""Raised when a Fragment API response cannot be parsed."""
|
||||
@@ -1,20 +0,0 @@
|
||||
import logging
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
formatter = logging.Formatter(fmt="[%(asctime)s] - %(levelname)s: %(message)s", datefmt="%d.%m.%y %H:%M:%S")
|
||||
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
file_handler = logging.FileHandler("FragmentAPI.log", mode="w", encoding="utf-8")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(formatter)
|
||||
logging.basicConfig(level=logging.DEBUG, handlers=[console_handler, file_handler], force=True)
|
||||
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1,5 +0,0 @@
|
||||
from app.methods.premium import buy_premium
|
||||
from app.methods.stars import buy_stars
|
||||
from app.methods.ton import topup_ton
|
||||
|
||||
__all__ = ["buy_premium", "buy_stars", "topup_ton"]
|
||||
@@ -1,151 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core import (
|
||||
BASE_HEADERS,
|
||||
DEVICE,
|
||||
PREMIUM_PAGE,
|
||||
FragmentError,
|
||||
UserNotFoundError,
|
||||
load_cookies,
|
||||
)
|
||||
from app.utils import (
|
||||
execute_transaction_request,
|
||||
get_account_info,
|
||||
get_fragment_hash,
|
||||
parse_json_response,
|
||||
process_transaction,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Page-specific headers
|
||||
HEADERS: dict[str, str] = {
|
||||
**BASE_HEADERS,
|
||||
"referer": PREMIUM_PAGE,
|
||||
"x-aj-referer": PREMIUM_PAGE,
|
||||
}
|
||||
|
||||
|
||||
async def search_premium_recipient(
|
||||
client: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
username: str,
|
||||
months: int,
|
||||
) -> str:
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={
|
||||
"query": username,
|
||||
"months": months,
|
||||
"method": "searchPremiumGiftRecipient",
|
||||
},
|
||||
)
|
||||
result = parse_json_response(resp, "searchPremiumGiftRecipient")
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(
|
||||
f"Telegram user '{username}' was not found on Fragment. "
|
||||
"Make sure the username is correct and the account exists."
|
||||
)
|
||||
return recipient
|
||||
|
||||
|
||||
async def init_gift_premium(
|
||||
client: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
recipient: str,
|
||||
months: int,
|
||||
) -> str:
|
||||
await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={
|
||||
"mode": "new",
|
||||
"lv": "false",
|
||||
"dh": str(int(time.time())),
|
||||
"method": "updatePremiumState",
|
||||
},
|
||||
)
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={
|
||||
"recipient": recipient,
|
||||
"months": months,
|
||||
"method": "initGiftPremiumRequest",
|
||||
},
|
||||
)
|
||||
result = parse_json_response(resp, "initGiftPremiumRequest")
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentError(
|
||||
"Fragment did not return a request ID for this Premium purchase. "
|
||||
"The session may have expired — refresh your cookies."
|
||||
)
|
||||
return req_id
|
||||
|
||||
|
||||
async def buy_premium(username: str, months: int, show_sender: bool = True) -> dict:
|
||||
if months not in (3, 6, 12):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid duration. Choose 3, 6, or 12 months.",
|
||||
}
|
||||
|
||||
try:
|
||||
logger.info("Loading session cookies")
|
||||
cookies = load_cookies()
|
||||
|
||||
logger.info("Fetching Fragment session hash")
|
||||
fragment_hash = await get_fragment_hash(cookies, HEADERS, PREMIUM_PAGE)
|
||||
|
||||
# logger.info("Retrieving TON wallet info")
|
||||
account = await get_account_info()
|
||||
|
||||
async with httpx.AsyncClient(cookies=cookies) as client:
|
||||
logger.info("Searching recipient: %s", username)
|
||||
recipient = await search_premium_recipient(client, fragment_hash, username, months)
|
||||
|
||||
logger.info("Initializing Premium gift request: %s months to %s", months, username)
|
||||
req_id = await init_gift_premium(client, fragment_hash, recipient, months)
|
||||
|
||||
# logger.info("Requesting transaction payload (req_id=%s)", req_id)
|
||||
tx_data = {
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": int(show_sender),
|
||||
"method": "getGiftPremiumLink",
|
||||
}
|
||||
transaction = await execute_transaction_request(client, HEADERS, account, tx_data, fragment_hash)
|
||||
|
||||
logger.info("Broadcasting transaction to TON blockchain")
|
||||
tx_hash = await process_transaction(transaction)
|
||||
logger.info(
|
||||
"Premium purchase successful: %s months -> %s | tx: %s",
|
||||
months,
|
||||
username,
|
||||
tx_hash,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"transaction_id": tx_hash,
|
||||
"username": username,
|
||||
"months": months,
|
||||
"timestamp": int(time.time()),
|
||||
},
|
||||
}
|
||||
|
||||
except FragmentError as exc:
|
||||
logger.error("Premium purchase failed — %s", exc)
|
||||
return {"success": False, "error": str(exc)}
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error during Premium purchase")
|
||||
return {"success": False, "error": f"Unexpected error: {exc}"}
|
||||
@@ -1,133 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core import (
|
||||
BASE_HEADERS,
|
||||
DEVICE,
|
||||
STARS_PAGE,
|
||||
FragmentError,
|
||||
UserNotFoundError,
|
||||
load_cookies,
|
||||
)
|
||||
from app.utils import (
|
||||
execute_transaction_request,
|
||||
get_account_info,
|
||||
get_fragment_hash,
|
||||
parse_json_response,
|
||||
process_transaction,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Page-specific headers
|
||||
HEADERS: dict[str, str] = {
|
||||
**BASE_HEADERS,
|
||||
"referer": STARS_PAGE,
|
||||
"x-aj-referer": STARS_PAGE,
|
||||
}
|
||||
|
||||
|
||||
async def search_stars_recipient(
|
||||
client: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
username: str,
|
||||
) -> str:
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={"query": username, "quantity": "", "method": "searchStarsRecipient"},
|
||||
)
|
||||
result = parse_json_response(resp, "searchStarsRecipient")
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(
|
||||
f"Telegram user '{username}' was not found on Fragment. "
|
||||
"Make sure the username is correct and the account exists."
|
||||
)
|
||||
return recipient
|
||||
|
||||
|
||||
async def init_buy_stars(
|
||||
client: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
recipient: str,
|
||||
amount: int,
|
||||
) -> str:
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={
|
||||
"recipient": recipient,
|
||||
"quantity": amount,
|
||||
"method": "initBuyStarsRequest",
|
||||
},
|
||||
)
|
||||
result = parse_json_response(resp, "initBuyStarsRequest")
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentError(
|
||||
"Fragment did not return a request ID for this Stars purchase. "
|
||||
"The session may have expired — refresh your cookies."
|
||||
)
|
||||
return req_id
|
||||
|
||||
|
||||
async def buy_stars(username: str, amount: int, show_sender: bool = True) -> dict:
|
||||
if not isinstance(amount, int) or amount < 50:
|
||||
return {"success": False, "error": "Amount must be an integer >= 50 stars."}
|
||||
|
||||
try:
|
||||
logger.info("Loading session cookies")
|
||||
cookies = load_cookies()
|
||||
|
||||
logger.info("Fetching Fragment session hash")
|
||||
fragment_hash = await get_fragment_hash(cookies, HEADERS, STARS_PAGE)
|
||||
|
||||
# logger.info("Retrieving TON wallet info")
|
||||
account = await get_account_info()
|
||||
|
||||
async with httpx.AsyncClient(cookies=cookies) as client:
|
||||
logger.info("Searching recipient: %s", username)
|
||||
recipient = await search_stars_recipient(client, fragment_hash, username)
|
||||
|
||||
logger.info("Initializing Stars purchase request: %s stars to %s", amount, username)
|
||||
req_id = await init_buy_stars(client, fragment_hash, recipient, amount)
|
||||
|
||||
# logger.info("Requesting transaction payload (req_id=%s)", req_id)
|
||||
tx_data = {
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": int(show_sender),
|
||||
"method": "getBuyStarsLink",
|
||||
}
|
||||
transaction = await execute_transaction_request(client, HEADERS, account, tx_data, fragment_hash)
|
||||
|
||||
logger.info("Broadcasting transaction to TON blockchain")
|
||||
tx_hash = await process_transaction(transaction)
|
||||
logger.info(
|
||||
"Stars purchase successful: %s stars -> %s | tx: %s",
|
||||
amount,
|
||||
username,
|
||||
tx_hash,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"transaction_id": tx_hash,
|
||||
"username": username,
|
||||
"amount": amount,
|
||||
"timestamp": int(time.time()),
|
||||
},
|
||||
}
|
||||
|
||||
except FragmentError as exc:
|
||||
logger.error("Stars purchase failed — %s", exc)
|
||||
return {"success": False, "error": str(exc)}
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error during Stars purchase")
|
||||
return {"success": False, "error": f"Unexpected error: {exc}"}
|
||||
@@ -1,132 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core import (
|
||||
ADS_PAGE,
|
||||
BASE_HEADERS,
|
||||
DEVICE,
|
||||
FragmentError,
|
||||
UserNotFoundError,
|
||||
load_cookies,
|
||||
)
|
||||
from app.utils import (
|
||||
execute_transaction_request,
|
||||
get_account_info,
|
||||
get_fragment_hash,
|
||||
parse_json_response,
|
||||
process_transaction,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Page-specific headers
|
||||
HEADERS: dict[str, str] = {
|
||||
**BASE_HEADERS,
|
||||
"referer": ADS_PAGE,
|
||||
"x-aj-referer": ADS_PAGE,
|
||||
}
|
||||
|
||||
|
||||
async def search_ads_recipient(
|
||||
client: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
username: str,
|
||||
) -> str:
|
||||
await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={"mode": "new", "method": "updateAdsTopupState"},
|
||||
)
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={"query": username, "method": "searchAdsTopupRecipient"},
|
||||
)
|
||||
result = parse_json_response(resp, "searchAdsTopupRecipient")
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(
|
||||
f"Telegram user '{username}' was not found on Fragment. "
|
||||
"Make sure the username is correct and the account exists."
|
||||
)
|
||||
return recipient
|
||||
|
||||
|
||||
async def init_ads_topup(
|
||||
client: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
recipient: str,
|
||||
amount: int,
|
||||
) -> str:
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={
|
||||
"recipient": recipient,
|
||||
"amount": amount,
|
||||
"method": "initAdsTopupRequest",
|
||||
},
|
||||
)
|
||||
result = parse_json_response(resp, "initAdsTopupRequest")
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentError(
|
||||
"Fragment did not return a request ID for this TON topup. " "The session may have expired — refresh your cookies."
|
||||
)
|
||||
return req_id
|
||||
|
||||
|
||||
async def topup_ton(username: str, amount: int, show_sender: bool = True) -> dict:
|
||||
if not isinstance(amount, int) or amount < 1:
|
||||
return {"success": False, "error": "Amount must be an integer >= 1 TON."}
|
||||
|
||||
try:
|
||||
logger.info("Loading session cookies")
|
||||
cookies = load_cookies()
|
||||
|
||||
logger.info("Fetching Fragment session hash")
|
||||
fragment_hash = await get_fragment_hash(cookies, HEADERS, ADS_PAGE)
|
||||
|
||||
# logger.info("Retrieving TON wallet info")
|
||||
account = await get_account_info()
|
||||
|
||||
async with httpx.AsyncClient(cookies=cookies) as client:
|
||||
logger.info("Searching recipient: %s", username)
|
||||
recipient = await search_ads_recipient(client, fragment_hash, username)
|
||||
|
||||
logger.info("Initializing topup request: %s TON to %s", amount, username)
|
||||
req_id = await init_ads_topup(client, fragment_hash, recipient, amount)
|
||||
|
||||
# logger.info("Requesting transaction payload (req_id=%s)", req_id)
|
||||
tx_data = {
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": int(show_sender),
|
||||
"method": "getAdsTopupLink",
|
||||
}
|
||||
transaction = await execute_transaction_request(client, HEADERS, account, tx_data, fragment_hash)
|
||||
|
||||
logger.info("Broadcasting transaction to TON blockchain")
|
||||
tx_hash = await process_transaction(transaction)
|
||||
logger.info("TON topup successful: %s TON -> %s | tx: %s", amount, username, tx_hash)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"transaction_id": tx_hash,
|
||||
"username": username,
|
||||
"amount": amount,
|
||||
"timestamp": int(time.time()),
|
||||
},
|
||||
}
|
||||
|
||||
except FragmentError as exc:
|
||||
logger.error("TON topup failed — %s", exc)
|
||||
return {"success": False, "error": str(exc)}
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error during TON topup")
|
||||
return {"success": False, "error": f"Unexpected error: {exc}"}
|
||||
@@ -1,14 +0,0 @@
|
||||
from app.utils.client import execute_transaction_request, parse_json_response
|
||||
from app.utils.decoder import clean_decode
|
||||
from app.utils.hash import get_fragment_hash
|
||||
from app.utils.wallet import get_account_info, link_wallet, process_transaction
|
||||
|
||||
__all__ = [
|
||||
"clean_decode",
|
||||
"execute_transaction_request",
|
||||
"get_account_info",
|
||||
"get_fragment_hash",
|
||||
"link_wallet",
|
||||
"parse_json_response",
|
||||
"process_transaction",
|
||||
]
|
||||
@@ -1,39 +0,0 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core import RequestError, WalletError
|
||||
from app.utils.wallet import link_wallet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
|
||||
try:
|
||||
return response.json()
|
||||
except Exception as exc:
|
||||
raise RequestError(f"Fragment API returned an unparseable response for '{context}': {exc}") from exc
|
||||
|
||||
|
||||
async def execute_transaction_request(
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict,
|
||||
account: dict[str, Any],
|
||||
tx_data: dict[str, Any],
|
||||
fragment_hash: str,
|
||||
) -> dict[str, Any]:
|
||||
url = f"https://fragment.com/api?hash={fragment_hash}"
|
||||
|
||||
resp = await client.post(url, headers=headers, data=tx_data)
|
||||
transaction = parse_json_response(resp, tx_data.get("method", "transaction"))
|
||||
|
||||
if transaction.get("need_verify"):
|
||||
if not await link_wallet(client, headers, account, fragment_hash):
|
||||
raise WalletError(
|
||||
"Failed to link your TON wallet to Fragment. " "Make sure the wallet matching your cookies is used."
|
||||
)
|
||||
resp = await client.post(url, headers=headers, data=tx_data)
|
||||
transaction = parse_json_response(resp, tx_data.get("method", "transaction"))
|
||||
|
||||
return transaction
|
||||
@@ -1,36 +0,0 @@
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from pytoniq_core import Cell
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# OLD decoder (manual base64 + regex, kept for reference):
|
||||
#
|
||||
# import re, string
|
||||
# def clean_decode(payload: str) -> str:
|
||||
# s = re.sub(r'[^A-Za-z0-9+/=]', '', payload.strip())
|
||||
# s += '=' * (-len(s) % 4)
|
||||
# text = base64.b64decode(s).decode('utf-8', errors='ignore')
|
||||
# text = ''.join(c for c in text if c in string.printable or c.isspace())
|
||||
# match = re.search(r'([0-9]*\s*Telegram .*?Ref#[A-Za-z0-9]+)', text, re.S)
|
||||
# return match.group(1).strip() if match else text.strip()
|
||||
|
||||
|
||||
def clean_decode(payload: str) -> str:
|
||||
# Pad and decode base64 → BOC bytes
|
||||
s = payload.strip()
|
||||
if not s:
|
||||
return ""
|
||||
s += "=" * (-len(s) % 4)
|
||||
boc = base64.b64decode(s)
|
||||
|
||||
# Parse BOC cell and read snake-encoded text (skipping 32-bit op prefix)
|
||||
cell = Cell.one_from_boc(boc)
|
||||
sl = cell.begin_parse()
|
||||
sl.load_uint(32) # op code — always 0 for text comment
|
||||
result = sl.load_snake_string().strip()
|
||||
|
||||
logger.debug("Payload: %s -> %s", payload, result.replace("\n", " "))
|
||||
return result
|
||||
@@ -1,57 +0,0 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core import HashFetchError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_fragment_hash(
|
||||
cookies: dict[str, Any],
|
||||
headers: dict[str, str],
|
||||
page_url: str,
|
||||
) -> str:
|
||||
# Must look like a real browser navigation — not an XHR — otherwise Fragment
|
||||
# returns JSON (no hash in it) instead of full HTML.
|
||||
page_headers = {
|
||||
k: v
|
||||
for k, v in headers.items()
|
||||
if k
|
||||
not in (
|
||||
"accept",
|
||||
"accept-encoding",
|
||||
"content-type",
|
||||
"x-requested-with",
|
||||
"x-aj-referer",
|
||||
)
|
||||
}
|
||||
page_headers.update(
|
||||
{
|
||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"referer": "https://fragment.com/",
|
||||
"sec-fetch-dest": "document",
|
||||
"sec-fetch-mode": "navigate",
|
||||
"upgrade-insecure-requests": "1",
|
||||
}
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(cookies=cookies) as client:
|
||||
response = await client.get(page_url, headers=page_headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise HashFetchError(
|
||||
f"Fragment returned HTTP {response.status_code} for {page_url}. "
|
||||
"Check that your cookies are valid and not expired."
|
||||
)
|
||||
|
||||
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
|
||||
if not match:
|
||||
raise HashFetchError(
|
||||
f"Fragment hash not found in the page source of {page_url}. "
|
||||
"The page structure may have changed or you are not logged in."
|
||||
)
|
||||
|
||||
return match.group(1)
|
||||
@@ -1,107 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from tonutils.clients import TonapiClient
|
||||
from tonutils.types import NetworkGlobalID
|
||||
|
||||
from app.core import DEVICE, WALLET_CLASSES, TransactionError, WalletError, config
|
||||
from app.utils.decoder import clean_decode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def initialize_ton_client() -> TonapiClient:
|
||||
return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY)
|
||||
|
||||
|
||||
async def process_transaction(transaction_data: dict) -> str:
|
||||
logger.debug("transaction_data: %s", transaction_data)
|
||||
|
||||
if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
|
||||
raise TransactionError(
|
||||
"Fragment returned an invalid transaction payload. "
|
||||
"The API response is missing expected 'transaction.messages' data."
|
||||
)
|
||||
|
||||
# TODO: Investigate 406 'inbound external message rejected before smart-contract execution'.
|
||||
# This happens when the previous transaction's seqno hasn't been confirmed on-chain yet,
|
||||
# causing the wallet contract to reject the new message.
|
||||
async with initialize_ton_client() as client:
|
||||
wallet_cls = WALLET_CLASSES[config.WALLET_VERSION]
|
||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED)
|
||||
|
||||
# Check balance before broadcasting
|
||||
try:
|
||||
await wallet.refresh()
|
||||
balance_ton = wallet.balance / 1_000_000_000
|
||||
if balance_ton < 0.056:
|
||||
raise WalletError(f"TON wallet balance is too low: {balance_ton:.2f} TON. " "Minimum required is 0.056 TON.")
|
||||
except WalletError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise WalletError(f"Wallet balance check failed: {exc}") from exc
|
||||
|
||||
try:
|
||||
message = transaction_data["transaction"]["messages"][0]
|
||||
payload = clean_decode(message["payload"])
|
||||
|
||||
result = await wallet.transfer(
|
||||
destination=message["address"],
|
||||
amount=int(message["amount"]), # nanotons, not TON
|
||||
body=payload,
|
||||
)
|
||||
tx_hash = result.normalized_hash
|
||||
return tx_hash
|
||||
except (WalletError, TransactionError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise TransactionError(f"Transaction broadcast failed: {exc}") from exc
|
||||
|
||||
|
||||
async def get_account_info() -> dict[str, Any]:
|
||||
async with initialize_ton_client() as client:
|
||||
try:
|
||||
wallet_cls = WALLET_CLASSES[config.WALLET_VERSION]
|
||||
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED)
|
||||
boc = wallet.state_init.serialize().to_boc()
|
||||
return {
|
||||
"address": wallet.address.to_str(False, False),
|
||||
"publicKey": pub_key.as_hex,
|
||||
"chain": "-239",
|
||||
"walletStateInit": base64.b64encode(boc).decode(),
|
||||
}
|
||||
except Exception as exc:
|
||||
raise WalletError(f"Failed to retrieve wallet account info: {exc}") from exc
|
||||
|
||||
|
||||
async def link_wallet(
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict,
|
||||
account: dict[str, Any],
|
||||
fragment_hash: str,
|
||||
) -> bool:
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=headers,
|
||||
data={
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"method": "linkWallet",
|
||||
},
|
||||
)
|
||||
result = resp.json()
|
||||
|
||||
if result.get("ok"):
|
||||
return True
|
||||
|
||||
if "transaction" in result:
|
||||
try:
|
||||
await process_transaction(result)
|
||||
return True
|
||||
except (TransactionError, WalletError):
|
||||
return False
|
||||
|
||||
return False
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"stel_ssid": "",
|
||||
"stel_dt": "",
|
||||
"stel_token": "",
|
||||
"stel_ton_token": ""
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Example: search the Fragment gifts marketplace.
|
||||
|
||||
collection filters by gift type slug (e.g. "plushpepe", "swisswatch").
|
||||
sort can be "price_desc", "price_asc", "listed", or "ending".
|
||||
filter can be "", "auction", "sale", or "sold".
|
||||
Use next_offset for pagination.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from pyfragment import FragmentClient, GiftsResult
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
|
||||
# Option B: provide cookies manually
|
||||
COOKIES = {
|
||||
"stel_ssid": "YOUR_STEL_SSID",
|
||||
"stel_dt": "YOUR_STEL_DT",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
QUERY = "" # search text — or omit for all
|
||||
COLLECTION = "plushpepe" # gift collection slug — or omit for all
|
||||
SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit
|
||||
FILTER = "" # "", "auction", "sale", "sold" — or omit
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER)
|
||||
|
||||
print(f"Found {len(result.items)} result(s):")
|
||||
print(json.dumps(result.items, indent=2))
|
||||
|
||||
if result.next_offset:
|
||||
print(f"\nMore results available — next page offset: {result.next_offset}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Example: search the Fragment marketplace for anonymous Telegram numbers.
|
||||
|
||||
sort can be "price_desc", "price_asc", "listed", or "ending".
|
||||
filter can be "", "auction", "sale", or "sold".
|
||||
Use next_offset_id for pagination.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from pyfragment import FragmentClient, NumbersResult
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
|
||||
# Option B: provide cookies manually
|
||||
COOKIES = {
|
||||
"stel_ssid": "YOUR_STEL_SSID",
|
||||
"stel_dt": "YOUR_STEL_DT",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
QUERY = "888" # search term — or omit for all
|
||||
SORT = "price_asc" # "price_desc", "price_asc", "listed", "ending" — or omit
|
||||
FILTER = "" # "", "auction", "sale", "sold" — or omit
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER)
|
||||
|
||||
print(f"Found {len(result.items)} result(s):")
|
||||
print(json.dumps(result.items, indent=2))
|
||||
|
||||
if result.next_offset_id:
|
||||
print(f"\nMore results available — next page offset: {result.next_offset_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Example: search the Fragment marketplace for Telegram usernames.
|
||||
|
||||
sort can be "price_desc", "price_asc", "listed", or "ending".
|
||||
filter can be "", "auction", "sale", or "sold".
|
||||
Use next_offset_id for pagination.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from pyfragment import FragmentClient, UsernamesResult
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
|
||||
# Option B: provide cookies manually
|
||||
COOKIES = {
|
||||
"stel_ssid": "YOUR_STEL_SSID",
|
||||
"stel_dt": "YOUR_STEL_DT",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
QUERY = "durov" # search term
|
||||
SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit
|
||||
FILTER = "auction" # "", "auction", "sale", "sold" — or omit
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER)
|
||||
|
||||
print(f"Found {len(result.items)} result(s):")
|
||||
print(json.dumps(result.items, indent=2))
|
||||
|
||||
if result.next_offset_id:
|
||||
print(f"\nMore results available — next page offset: {result.next_offset_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Example: send a raw request to any Fragment API method.
|
||||
|
||||
Use client.call() when you need to access a method that is not yet
|
||||
wrapped by the library, or to inspect raw API responses directly.
|
||||
|
||||
page_url is optional — only set it when the target method belongs to a
|
||||
specific Fragment page (Fragment derives the API hash per page).
|
||||
Defaults to the Fragment base URL.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
|
||||
# Option B: provide cookies manually
|
||||
COOKIES = {
|
||||
"stel_ssid": "YOUR_STEL_SSID",
|
||||
"stel_dt": "YOUR_STEL_DT",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
METHOD = "anyFragmentMethod" # replace with the actual method name
|
||||
DATA = {"key": "value"} # replace with the actual request payload
|
||||
PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragment page (optional)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
result = await client.call(METHOD, DATA, page_url=PAGE_URL)
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Example: fetch wallet address, state, and balance.
|
||||
|
||||
Cookies can be passed as a dict or as a JSON string.
|
||||
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
|
||||
# Option B: provide cookies manually
|
||||
COOKIES = {
|
||||
"stel_ssid": "YOUR_STEL_SSID",
|
||||
"stel_dt": "YOUR_STEL_DT",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(
|
||||
seed=SEED,
|
||||
api_key=API_KEY,
|
||||
cookies=COOKIES,
|
||||
wallet_version="V5R1", # or "V4R2"
|
||||
) as client:
|
||||
wallet = await client.get_wallet()
|
||||
print(f"Address: {wallet.address}")
|
||||
print(f"State: {wallet.state}")
|
||||
print(f"Balance: {wallet.balance} TON")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Example: manage an anonymous Telegram number — read login code and terminate sessions.
|
||||
|
||||
Use get_login_code() to fetch the current pending login code for your number.
|
||||
Use toggle_login_codes() to enable or disable receiving codes.
|
||||
Use terminate_sessions() to forcefully end all active Telegram sessions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import AnonymousNumberError, FragmentClient
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
|
||||
# Option B: provide cookies manually
|
||||
COOKIES = {
|
||||
"stel_ssid": "YOUR_STEL_SSID",
|
||||
"stel_dt": "YOUR_STEL_DT",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
NUMBER = "+88888888888"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
# Fetch the latest login code
|
||||
result = await client.get_login_code(NUMBER)
|
||||
if result.code:
|
||||
print(f"Login code for {result.number}: {result.code} ({result.active_sessions} active session(s))")
|
||||
else:
|
||||
print(f"No pending login code for {result.number} ({result.active_sessions} active session(s))")
|
||||
|
||||
# Terminate all active sessions
|
||||
try:
|
||||
terminated = await client.terminate_sessions(NUMBER)
|
||||
print(f"Sessions terminated for {terminated.number}" + (f": {terminated.message}" if terminated.message else ""))
|
||||
except AnonymousNumberError as e:
|
||||
print(f"Could not terminate sessions: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Example: recharge your own Telegram Ads account with TON.
|
||||
|
||||
Amount must be an integer between 1 and 1 000 000 000 TON.
|
||||
Your wallet must hold at least the recharge amount + ~0.056 TON for gas.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import (
|
||||
AdsRechargeResult,
|
||||
ConfigurationError,
|
||||
FragmentClient,
|
||||
WalletError,
|
||||
)
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
|
||||
# Option B: provide cookies manually
|
||||
COOKIES = {
|
||||
"stel_ssid": "YOUR_STEL_SSID",
|
||||
"stel_dt": "YOUR_STEL_DT",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account
|
||||
AMOUNT = 10 # 1–1 000 000 000 TON
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
try:
|
||||
result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT)
|
||||
except WalletError as e:
|
||||
print(f"Wallet error — insufficient balance or misconfiguration: {e}")
|
||||
return
|
||||
except ConfigurationError as e:
|
||||
print(f"Invalid argument: {e}")
|
||||
return
|
||||
|
||||
print(f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Example: run a Telegram Premium giveaway for a channel.
|
||||
|
||||
winners must be an integer between 1 and 24 000.
|
||||
months (Premium duration per winner) must be 3, 6, or 12.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
|
||||
# Option B: provide cookies manually
|
||||
COOKIES = {
|
||||
"stel_ssid": "YOUR_STEL_SSID",
|
||||
"stel_dt": "YOUR_STEL_DT",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
CHANNEL = "@channel"
|
||||
WINNERS = 10 # 1–24 000
|
||||
MONTHS = 3 # 3, 6 or 12
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
try:
|
||||
result = await client.giveaway_premium(CHANNEL, winners=WINNERS, months=MONTHS)
|
||||
except UserNotFoundError:
|
||||
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
|
||||
return
|
||||
except ConfigurationError as e:
|
||||
print(f"Invalid argument: {e}")
|
||||
return
|
||||
|
||||
print(
|
||||
f"Premium giveaway created for {result.channel} — {result.winners} winner(s) × {result.amount} months each | tx: {result.transaction_id}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Example: run a Telegram Stars giveaway for a channel.
|
||||
|
||||
winners must be an integer between 1 and 5.
|
||||
amount (stars per winner) must be an integer between 500 and 1 000 000.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
|
||||
# Option B: provide cookies manually
|
||||
COOKIES = {
|
||||
"stel_ssid": "YOUR_STEL_SSID",
|
||||
"stel_dt": "YOUR_STEL_DT",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
CHANNEL = "@channel"
|
||||
WINNERS = 3 # 1–5
|
||||
AMOUNT = 1000 # 500–1 000 000 stars per winner
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
try:
|
||||
result = await client.giveaway_stars(CHANNEL, winners=WINNERS, amount=AMOUNT)
|
||||
except UserNotFoundError:
|
||||
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
|
||||
return
|
||||
except ConfigurationError as e:
|
||||
print(f"Invalid argument: {e}")
|
||||
return
|
||||
|
||||
print(
|
||||
f"Stars giveaway created for {result.channel} — {result.winners} winner(s) × {result.amount} stars each | tx: {result.transaction_id}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Example: purchase Telegram Premium for a user.
|
||||
|
||||
Supported durations: 3, 6, or 12 months.
|
||||
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",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
USERNAME = "@username"
|
||||
MONTHS = 3 # 3, 6 or 12
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
try:
|
||||
result = await client.purchase_premium(USERNAME, months=MONTHS, show_sender=True)
|
||||
except UserNotFoundError:
|
||||
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
||||
return
|
||||
except ConfigurationError as e:
|
||||
print(f"Invalid argument: {e}")
|
||||
return
|
||||
|
||||
print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Example: purchase Telegram Stars for a user.
|
||||
|
||||
Amount must be an integer between 50 and 1 000 000.
|
||||
Set show_sender=False to send anonymously.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
|
||||
# Option B: provide cookies manually
|
||||
COOKIES = {
|
||||
"stel_ssid": "YOUR_STEL_SSID",
|
||||
"stel_dt": "YOUR_STEL_DT",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
USERNAME = "@username"
|
||||
AMOUNT = 500 # 50–1 000 000 stars
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
try:
|
||||
result = await client.purchase_stars(USERNAME, amount=AMOUNT, show_sender=True)
|
||||
except UserNotFoundError:
|
||||
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
||||
return
|
||||
except ConfigurationError as e:
|
||||
print(f"Invalid argument: {e}")
|
||||
return
|
||||
|
||||
print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Example: top up TON to a recipient's Telegram balance.
|
||||
|
||||
For adding TON to a Telegram Ads account, use recharge_ads() instead.
|
||||
|
||||
Amount must be an integer between 1 and 1 000 000 000 TON.
|
||||
Your wallet must hold at least the top-up amount + ~0.056 TON for gas.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import (
|
||||
ConfigurationError,
|
||||
FragmentClient,
|
||||
UserNotFoundError,
|
||||
WalletError,
|
||||
)
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
|
||||
# Option B: provide cookies manually
|
||||
COOKIES = {
|
||||
"stel_ssid": "YOUR_STEL_SSID",
|
||||
"stel_dt": "YOUR_STEL_DT",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
USERNAME = "@username"
|
||||
AMOUNT = 10 # 1–1 000 000 000 TON
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
try:
|
||||
result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True)
|
||||
except UserNotFoundError:
|
||||
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
||||
return
|
||||
except WalletError as e:
|
||||
print(f"Wallet error — insufficient balance or misconfiguration: {e}")
|
||||
return
|
||||
except ConfigurationError as e:
|
||||
print(f"Invalid argument: {e}")
|
||||
return
|
||||
|
||||
print(f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512" height="512" style="width: 100%; height: 100%; transform: translate3d(0,0,0); content-visibility: visible;" preserveAspectRatio="xMidYMid meet"><defs><clipPath id="__lottie_element_2"><rect width="512" height="512" x="0" y="0"></rect></clipPath><clipPath id="__lottie_element_4"><path d="M0,0 L100,0 L100,100 L0,100z"></path></clipPath></defs><g clip-path="url(#__lottie_element_2)"><g clip-path="url(#__lottie_element_4)" style="display: block;" transform="matrix(5.119999885559082,0,0,5.119999885559082,0,0)" opacity="1"><g style="display: block;" transform="matrix(1.333299994468689,0,0,1.333299994468689,0,0)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill="rgb(30,40,51)" fill-opacity="1" d=" M47.31999969482422,5.619999885559082 C47.31999969482422,5.619999885559082 27.68000030517578,5.619999885559082 27.68000030517578,5.619999885559082 C15.5,5.619999885559082 5.619999885559082,15.5 5.619999885559082,27.68000030517578 C5.619999885559082,27.68000030517578 5.619999885559082,47.31999969482422 5.619999885559082,47.31999969482422 C5.619999885559082,59.5 15.5,69.37999725341797 27.68000030517578,69.37999725341797 C27.68000030517578,69.37999725341797 47.31999969482422,69.37999725341797 47.31999969482422,69.37999725341797 C59.5,69.37999725341797 69.37999725341797,59.5 69.37999725341797,47.31999969482422 C69.37999725341797,47.31999969482422 69.37999725341797,27.68000030517578 69.37999725341797,27.68000030517578 C69.37999725341797,15.5 59.5,5.619999885559082 47.31999969482422,5.619999885559082 C47.31999969482422,5.619999885559082 47.31999969482422,5.619999885559082 47.31999969482422,5.619999885559082z"></path></g><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill="rgb(255,255,255)" fill-opacity="1" d=" M36.349998474121094,32.79999923706055 C36.349998474121094,32.79999923706055 21.1299991607666,25.940000534057617 21.1299991607666,25.940000534057617 C20.020000457763672,25.450000762939453 20.389999389648438,23.790000915527344 21.600000381469727,23.790000915527344 C21.600000381469727,23.790000915527344 53.41999816894531,23.790000915527344 53.41999816894531,23.790000915527344 C54.630001068115234,23.790000915527344 54.9900016784668,25.440000534057617 53.880001068115234,25.940000534057617 C53.880001068115234,25.940000534057617 38.66999816894531,32.79999923706055 38.66999816894531,32.79999923706055 C37.939998626708984,33.130001068115234 37.09000015258789,33.130001068115234 36.36000061035156,32.79999923706055 C36.36000061035156,32.79999923706055 36.349998474121094,32.79999923706055 36.349998474121094,32.79999923706055z M56.81999969482422,30.06999969482422 C57.43000030517578,29.1200008392334 56.43000030517578,27.979999542236328 55.400001525878906,28.440000534057617 C55.400001525878906,28.440000534057617 40.72999954223633,35.13999938964844 40.72999954223633,35.13999938964844 C39.72999954223633,35.599998474121094 39.09000015258789,36.61000061035156 39.09000015258789,37.70000076293945 C39.09000015258789,37.70000076293945 39.09000015258789,53.810001373291016 39.09000015258789,53.810001373291016 C39.09000015258789,54.93000030517578 40.54999923706055,55.36000061035156 41.15999984741211,54.41999816894531 C41.15999984741211,54.41999816894531 56.810001373291016,30.06999969482422 56.810001373291016,30.06999969482422 C56.810001373291016,30.06999969482422 56.81999969482422,30.06999969482422 56.81999969482422,30.06999969482422z M19.600000381469727,28.440000534057617 C18.579999923706055,27.979999542236328 17.56999969482422,29.1200008392334 18.18000030517578,30.06999969482422 C18.18000030517578,30.06999969482422 33.84000015258789,54.43000030517578 33.84000015258789,54.43000030517578 C34.45000076293945,55.380001068115234 35.90999984741211,54.939998626708984 35.90999984741211,53.81999969482422 C35.90999984741211,53.81999969482422 35.90999984741211,37.70000076293945 35.90999984741211,37.70000076293945 C35.90999984741211,36.599998474121094 35.27000045776367,35.599998474121094 34.27000045776367,35.13999938964844 C34.27000045776367,35.13999938964844 19.59000015258789,28.450000762939453 19.59000015258789,28.450000762939453 C19.59000015258789,28.450000762939453 19.600000381469727,28.440000534057617 19.600000381469727,28.440000534057617z"></path></g></g></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 4.3 KiB |
@@ -1,66 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.core import setup_logging
|
||||
from app.methods import buy_premium, buy_stars, topup_ton
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def topup_ton_example():
|
||||
logger.info("Starting TON topup example")
|
||||
|
||||
# @bohd4nx - target username, 100 - TON amount (integer 1-1000000000 (one billion))
|
||||
# show_sender=True — recipient sees who sent the topup
|
||||
result = await topup_ton("@bohd4nx", 100, show_sender=True)
|
||||
|
||||
if result["success"]:
|
||||
pass # Transaction successful, details are logged in the method
|
||||
else:
|
||||
logger.error(f"TON topup failed: {result['error']}")
|
||||
|
||||
|
||||
async def buy_premium_example():
|
||||
logger.info("Starting Premium purchase example")
|
||||
|
||||
# @bohd4nx - target username, 12 - months duration (3, 6, or 12 only)
|
||||
# show_sender=True — recipient sees who gifted the Premium
|
||||
result = await buy_premium("@bohd4nx", 12, show_sender=True)
|
||||
|
||||
if result["success"]:
|
||||
pass # Transaction successful, details are logged in the method
|
||||
else:
|
||||
logger.error(f"Premium purchase failed: {result['error']}")
|
||||
|
||||
|
||||
async def buy_stars_example():
|
||||
logger.info("Starting Stars purchase example")
|
||||
|
||||
# @bohd4nx - target username, 1000000 - stars amount (integer 50-1000000 (one million))
|
||||
# show_sender=True — recipient sees who sent the Stars
|
||||
result = await buy_stars("@bohd4nx", 1000000, show_sender=True)
|
||||
|
||||
if result["success"]:
|
||||
pass # Transaction successful, details are logged in the method
|
||||
else:
|
||||
logger.error(f"Stars purchase failed: {result['error']}")
|
||||
|
||||
|
||||
async def main():
|
||||
setup_logging()
|
||||
logger.info("Starting Fragment API by @bohd4nx - examples")
|
||||
|
||||
await topup_ton_example()
|
||||
await buy_premium_example()
|
||||
await buy_stars_example()
|
||||
|
||||
logger.info("All examples completed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Fragment API by @bohd4nx - Usage Examples")
|
||||
logger.info("Supported username formats: @username, username")
|
||||
logger.info("Limits: TON minimum 1, Premium 3/6/12 months, Stars minimum 50")
|
||||
logger.info("Setup: Copy .env.example to .env and fill all fields")
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,71 @@
|
||||
# Copyright (c) 2026 bohd4nx
|
||||
#
|
||||
# This source code is licensed under the MIT License found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
from pyfragment.client import FragmentClient
|
||||
from pyfragment.types import (
|
||||
AdsRechargeResult,
|
||||
AdsTopupResult,
|
||||
AnonymousNumberError,
|
||||
ClientError,
|
||||
ConfigurationError,
|
||||
CookieError,
|
||||
CookieResult,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
FragmentPageError,
|
||||
GiftsResult,
|
||||
LoginCodeResult,
|
||||
NumbersResult,
|
||||
OperationError,
|
||||
ParseError,
|
||||
PremiumGiveawayResult,
|
||||
PremiumResult,
|
||||
StarsGiveawayResult,
|
||||
StarsResult,
|
||||
TerminateSessionsResult,
|
||||
TransactionError,
|
||||
UnexpectedError,
|
||||
UsernamesResult,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
WalletError,
|
||||
WalletInfo,
|
||||
)
|
||||
|
||||
__version__: str = version("pyfragment")
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"FragmentClient",
|
||||
"AdsRechargeResult",
|
||||
"AdsTopupResult",
|
||||
"GiftsResult",
|
||||
"LoginCodeResult",
|
||||
"NumbersResult",
|
||||
"PremiumGiveawayResult",
|
||||
"PremiumResult",
|
||||
"StarsGiveawayResult",
|
||||
"StarsResult",
|
||||
"TerminateSessionsResult",
|
||||
"UsernamesResult",
|
||||
"WalletInfo",
|
||||
"ClientError",
|
||||
"ConfigurationError",
|
||||
"CookieError",
|
||||
"CookieResult",
|
||||
"FragmentAPIError",
|
||||
"FragmentError",
|
||||
"FragmentPageError",
|
||||
"AnonymousNumberError",
|
||||
"OperationError",
|
||||
"ParseError",
|
||||
"TransactionError",
|
||||
"UnexpectedError",
|
||||
"UserNotFoundError",
|
||||
"VerificationError",
|
||||
"WalletError",
|
||||
]
|
||||
@@ -0,0 +1,372 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes
|
||||
from pyfragment.methods.giveaway_premium import giveaway_premium
|
||||
from pyfragment.methods.giveaway_stars import giveaway_stars
|
||||
from pyfragment.methods.purchase_premium import purchase_premium
|
||||
from pyfragment.methods.purchase_stars import purchase_stars
|
||||
from pyfragment.methods.recharge_ads import recharge_ads
|
||||
from pyfragment.methods.search_gifts import search_gifts
|
||||
from pyfragment.methods.search_numbers import search_numbers
|
||||
from pyfragment.methods.search_usernames import search_usernames
|
||||
from pyfragment.methods.topup_ton import topup_ton
|
||||
from pyfragment.types import (
|
||||
AdsRechargeResult,
|
||||
AdsTopupResult,
|
||||
ConfigurationError,
|
||||
CookieError,
|
||||
GiftsResult,
|
||||
LoginCodeResult,
|
||||
NumbersResult,
|
||||
PremiumGiveawayResult,
|
||||
PremiumResult,
|
||||
StarsGiveawayResult,
|
||||
StarsResult,
|
||||
TerminateSessionsResult,
|
||||
UsernamesResult,
|
||||
WalletInfo,
|
||||
)
|
||||
from pyfragment.types.constants import (
|
||||
DEFAULT_TIMEOUT,
|
||||
FRAGMENT_BASE_URL,
|
||||
REQUIRED_COOKIE_KEYS,
|
||||
SUPPORTED_WALLET_VERSIONS,
|
||||
WalletVersion,
|
||||
)
|
||||
from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers
|
||||
from pyfragment.utils.wallet import get_wallet_info
|
||||
|
||||
|
||||
class FragmentClient:
|
||||
"""
|
||||
Client for the Fragment.com API.
|
||||
|
||||
.. note::
|
||||
This library is not affiliated with, endorsed by, or in any way officially
|
||||
connected with Fragment or Telegram.
|
||||
|
||||
Args:
|
||||
seed: 24-word mnemonic phrase for the TON wallet.
|
||||
api_key: Tonapi API key — get one at https://tonconsole.com.
|
||||
cookies: Fragment session cookies as a dict or JSON string.
|
||||
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
|
||||
timeout: HTTP request timeout in seconds. Defaults to ``30.0``.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid.
|
||||
CookieError: If ``cookies`` cannot be parsed or are missing required keys.
|
||||
|
||||
Example::
|
||||
|
||||
async with FragmentClient(
|
||||
seed="word1 word2 ...",
|
||||
api_key="AAABBB...",
|
||||
cookies={"stel_ssid": "...", "stel_dt": "...", ...},
|
||||
) as client:
|
||||
print(await client.get_wallet())
|
||||
result = await client.purchase_premium("@username", months=6)
|
||||
print(result.transaction_id)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
seed: str,
|
||||
api_key: str,
|
||||
cookies: dict | str,
|
||||
wallet_version: str = "V5R1",
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> None:
|
||||
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
|
||||
if missing:
|
||||
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
|
||||
|
||||
word_count = len(seed.split())
|
||||
if word_count not in (12, 18, 24):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count))
|
||||
|
||||
if len(api_key.strip()) < 68:
|
||||
raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip())))
|
||||
|
||||
if isinstance(cookies, str):
|
||||
try:
|
||||
cookies = json.loads(cookies)
|
||||
except Exception as exc:
|
||||
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
|
||||
|
||||
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict, cookies).get(k, "")).strip()]
|
||||
if missing_keys:
|
||||
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
|
||||
|
||||
version = wallet_version.strip().upper()
|
||||
if version not in SUPPORTED_WALLET_VERSIONS:
|
||||
raise ConfigurationError(
|
||||
ConfigurationError.UNSUPPORTED_VERSION.format(
|
||||
version=version, supported=", ".join(sorted(SUPPORTED_WALLET_VERSIONS))
|
||||
)
|
||||
)
|
||||
|
||||
self.seed: str = seed.strip()
|
||||
self.api_key: str = api_key.strip()
|
||||
self.cookies: dict = cast(dict, cookies)
|
||||
self.wallet_version: WalletVersion = version # type: ignore[assignment]
|
||||
self.timeout: float = timeout
|
||||
|
||||
async def __aenter__(self) -> "FragmentClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
pass
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"FragmentClient(wallet_version='{self.wallet_version}', cookies={len(self.cookies)} keys)"
|
||||
|
||||
async def purchase_premium(self, username: str, months: int, show_sender: bool = True) -> PremiumResult:
|
||||
"""Gift Telegram Premium to a user.
|
||||
|
||||
Args:
|
||||
username: Recipient's Telegram username (with or without ``@``).
|
||||
months: Duration — ``3``, ``6``, or ``12``.
|
||||
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||
"""
|
||||
return await purchase_premium(self, username, months, show_sender)
|
||||
|
||||
async def purchase_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult:
|
||||
"""Send Telegram Stars to a user.
|
||||
|
||||
Args:
|
||||
username: Recipient's Telegram username (with or without ``@``).
|
||||
amount: Number of stars — integer from ``50`` to ``1 000 000``.
|
||||
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||
"""
|
||||
return await purchase_stars(self, username, amount, show_sender)
|
||||
|
||||
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||
"""Top up TON to a recipient's Telegram balance.
|
||||
|
||||
Args:
|
||||
username: Recipient's Telegram username (with or without ``@``).
|
||||
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||
"""
|
||||
return await topup_ton(self, username, amount, show_sender)
|
||||
|
||||
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
|
||||
"""Add funds to your own Telegram Ads account.
|
||||
|
||||
Args:
|
||||
account: Your Fragment Ads account identifier — the channel or bot username
|
||||
the Ads account is linked to (e.g. ``"@mychannel"``).
|
||||
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||
|
||||
Returns:
|
||||
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
|
||||
"""
|
||||
return await recharge_ads(self, account, amount)
|
||||
|
||||
async def get_wallet(self) -> WalletInfo:
|
||||
"""Return the address, state and balance of the TON wallet.
|
||||
|
||||
Returns:
|
||||
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
|
||||
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), and ``balance`` in TON.
|
||||
"""
|
||||
return await get_wallet_info(self)
|
||||
|
||||
async def giveaway_stars(
|
||||
self,
|
||||
channel: str,
|
||||
winners: int,
|
||||
amount: int,
|
||||
) -> StarsGiveawayResult:
|
||||
"""Run a Telegram Stars giveaway for a channel.
|
||||
|
||||
Args:
|
||||
channel: Channel username (with or without ``@``).
|
||||
winners: Number of winners — integer from ``1`` to ``5``.
|
||||
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
|
||||
|
||||
Returns:
|
||||
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
|
||||
``winners``, and ``amount``.
|
||||
"""
|
||||
return await giveaway_stars(self, channel, winners, amount)
|
||||
|
||||
async def giveaway_premium(
|
||||
self,
|
||||
channel: str,
|
||||
winners: int,
|
||||
months: int = 3,
|
||||
) -> PremiumGiveawayResult:
|
||||
"""Run a Telegram Premium giveaway for a channel.
|
||||
|
||||
Args:
|
||||
channel: Channel username (with or without ``@``).
|
||||
winners: Number of winners — positive integer.
|
||||
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
|
||||
|
||||
Returns:
|
||||
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
|
||||
``winners``, and ``amount``.
|
||||
"""
|
||||
return await giveaway_premium(self, channel, winners, months)
|
||||
|
||||
async def get_login_code(self, number: str) -> LoginCodeResult:
|
||||
"""Fetch the current pending login code for an anonymous number.
|
||||
|
||||
Args:
|
||||
number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
|
||||
|
||||
Returns:
|
||||
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
|
||||
and ``active_sessions`` count.
|
||||
"""
|
||||
return await get_login_code(self, number)
|
||||
|
||||
async def toggle_login_codes(self, number: str, can_receive: bool) -> None:
|
||||
"""Enable or disable login code delivery for an anonymous number.
|
||||
|
||||
Args:
|
||||
number: Phone number with or without leading ``+``.
|
||||
can_receive: ``True`` to allow receiving codes, ``False`` to block them.
|
||||
"""
|
||||
return await toggle_login_codes(self, number, can_receive)
|
||||
|
||||
async def terminate_sessions(self, number: str) -> TerminateSessionsResult:
|
||||
"""Terminate all active Telegram sessions for an anonymous number.
|
||||
|
||||
Args:
|
||||
number: Phone number with or without leading ``+``.
|
||||
|
||||
Returns:
|
||||
:class:`TerminateSessionsResult` with ``number`` and ``message``.
|
||||
|
||||
Raises:
|
||||
AnonymousNumberError: If the number is not owned by this account or has no active sessions.
|
||||
"""
|
||||
return await terminate_sessions(self, number)
|
||||
|
||||
async def search_usernames(
|
||||
self,
|
||||
query: str = "",
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
offset_id: str | None = None,
|
||||
) -> UsernamesResult:
|
||||
"""Search the Fragment marketplace for Telegram usernames.
|
||||
|
||||
Args:
|
||||
query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
|
||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||
``"ending"``. Omit to use Fragment's default ordering.
|
||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
|
||||
``""`` (available items). Omit to return all.
|
||||
offset_id: Pagination cursor — pass :attr:`UsernamesResult.next_offset_id`
|
||||
from a previous result to fetch the next page.
|
||||
|
||||
Returns:
|
||||
:class:`UsernamesResult` with ``items`` (parsed list of item dicts)
|
||||
and ``next_offset_id`` (``None`` on the last page).
|
||||
"""
|
||||
return await search_usernames(self, query, sort=sort, filter=filter, offset_id=offset_id)
|
||||
|
||||
async def search_numbers(
|
||||
self,
|
||||
query: str = "",
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
offset_id: str | None = None,
|
||||
) -> NumbersResult:
|
||||
"""Search the Fragment marketplace for anonymous Telegram numbers.
|
||||
|
||||
Args:
|
||||
query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all.
|
||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||
``"ending"``. Omit to use Fragment's default ordering.
|
||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
|
||||
``""`` (available items). Omit to return all.
|
||||
offset_id: Pagination cursor — pass :attr:`NumbersResult.next_offset_id`
|
||||
from a previous result to fetch the next page.
|
||||
|
||||
Returns:
|
||||
:class:`NumbersResult` with ``items`` (parsed list of item dicts)
|
||||
and ``next_offset_id`` (``None`` on the last page).
|
||||
"""
|
||||
return await search_numbers(self, query, sort=sort, filter=filter, offset_id=offset_id)
|
||||
|
||||
async def search_gifts(
|
||||
self,
|
||||
query: str = "",
|
||||
collection: str | None = None,
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
view: str | None = None,
|
||||
attr: dict[str, list[str]] | None = None,
|
||||
offset: int | None = None,
|
||||
) -> GiftsResult:
|
||||
"""Search the Fragment gifts marketplace.
|
||||
|
||||
Args:
|
||||
query: Search text. Omit or pass ``""`` to browse without filtering by name.
|
||||
collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all.
|
||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||
``"ending"``. Omit to use Fragment's default ordering.
|
||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
|
||||
``""`` (available items). Omit to return all.
|
||||
view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default.
|
||||
attr: Attribute filters — mapping of trait name to accepted values, e.g.
|
||||
``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``.
|
||||
Each key is sent as ``attr[Key]`` with its list of values.
|
||||
offset: Integer page offset from a previous :class:`GiftsResult`.
|
||||
Pass ``next_offset`` to fetch the next page.
|
||||
|
||||
Returns:
|
||||
:class:`GiftsResult` with ``items`` (parsed list of item dicts)
|
||||
and ``next_offset`` (``None`` on the last page).
|
||||
"""
|
||||
return await search_gifts(
|
||||
self, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL
|
||||
) -> dict[str, Any]:
|
||||
"""Send a raw request to the Fragment API.
|
||||
|
||||
Useful for accessing undocumented or future Fragment API methods
|
||||
without waiting for a library update.
|
||||
|
||||
Args:
|
||||
method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``.
|
||||
data: Additional form-data fields to include in the request body.
|
||||
page_url: Fragment page URL used to derive the API hash and headers.
|
||||
Defaults to ``FRAGMENT_BASE_URL`` (``"https://fragment.com"``).
|
||||
|
||||
Returns:
|
||||
Raw parsed JSON response as a dict.
|
||||
|
||||
Example::
|
||||
|
||||
result = await client.call(
|
||||
"searchPremiumGiftRecipient",
|
||||
{"query": "@username", "months": 3},
|
||||
page_url="https://fragment.com/premium/gift",
|
||||
)
|
||||
"""
|
||||
headers = make_headers(page_url)
|
||||
async with httpx.AsyncClient(cookies=self.cookies, timeout=self.timeout) as session:
|
||||
fragment_hash = await get_fragment_hash(self.cookies, headers, page_url, self.timeout)
|
||||
return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})})
|
||||
@@ -0,0 +1,25 @@
|
||||
from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes
|
||||
from pyfragment.methods.giveaway_premium import giveaway_premium
|
||||
from pyfragment.methods.giveaway_stars import giveaway_stars
|
||||
from pyfragment.methods.purchase_premium import purchase_premium
|
||||
from pyfragment.methods.purchase_stars import purchase_stars
|
||||
from pyfragment.methods.recharge_ads import recharge_ads
|
||||
from pyfragment.methods.search_gifts import search_gifts
|
||||
from pyfragment.methods.search_numbers import search_numbers
|
||||
from pyfragment.methods.search_usernames import search_usernames
|
||||
from pyfragment.methods.topup_ton import topup_ton
|
||||
|
||||
__all__ = [
|
||||
"get_login_code",
|
||||
"giveaway_premium",
|
||||
"giveaway_stars",
|
||||
"purchase_premium",
|
||||
"purchase_stars",
|
||||
"recharge_ads",
|
||||
"search_gifts",
|
||||
"search_numbers",
|
||||
"search_usernames",
|
||||
"terminate_sessions",
|
||||
"toggle_login_codes",
|
||||
"topup_ton",
|
||||
]
|
||||
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.types import (
|
||||
AnonymousNumberError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
LoginCodeResult,
|
||||
TerminateSessionsResult,
|
||||
UnexpectedError,
|
||||
)
|
||||
from pyfragment.types.constants import NUMBERS_PAGE
|
||||
from pyfragment.utils import parse_login_code
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
def _strip_plus(number: str) -> str:
|
||||
return number.lstrip("+") if isinstance(number, str) else number
|
||||
|
||||
|
||||
async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResult:
|
||||
"""Fetch the current pending login code for an anonymous number.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
|
||||
|
||||
Returns:
|
||||
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if no pending code),
|
||||
and ``active_sessions`` count.
|
||||
|
||||
Raises:
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
try:
|
||||
clean = _strip_plus(number)
|
||||
result = await client.call(
|
||||
"updateLoginCodes",
|
||||
{"number": clean, "lt": "0", "from_app": "1"},
|
||||
page_url=NUMBERS_PAGE,
|
||||
)
|
||||
|
||||
if result.get("html"):
|
||||
code, active_sessions = parse_login_code(result["html"])
|
||||
else:
|
||||
code, active_sessions = None, 0
|
||||
|
||||
return LoginCodeResult(number=number, code=code, active_sessions=active_sessions)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
|
||||
|
||||
async def toggle_login_codes(client: "FragmentClient", number: str, can_receive: bool) -> None:
|
||||
"""Enable or disable login code delivery for an anonymous number.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
number: Phone number with or without leading ``+``.
|
||||
can_receive: ``True`` to allow receiving codes, ``False`` to block them.
|
||||
|
||||
Raises:
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
try:
|
||||
clean = _strip_plus(number)
|
||||
result = await client.call(
|
||||
"toggleLoginCodes",
|
||||
{"number": clean, "can_receive": 1 if can_receive else 0},
|
||||
page_url=NUMBERS_PAGE,
|
||||
)
|
||||
|
||||
if result.get("error"):
|
||||
raise FragmentAPIError(html.unescape(result["error"]))
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
|
||||
|
||||
async def terminate_sessions(client: "FragmentClient", number: str) -> TerminateSessionsResult:
|
||||
"""Terminate all active Telegram sessions for an anonymous number.
|
||||
|
||||
This is a two-step operation: Fragment first returns a confirmation hash,
|
||||
which is then submitted to confirm the termination.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
number: Phone number with or without leading ``+``.
|
||||
|
||||
Returns:
|
||||
:class:`TerminateSessionsResult` with ``number`` and ``message``.
|
||||
|
||||
Raises:
|
||||
AnonymousNumberError: If the number is not owned by this account or has no active sessions,
|
||||
or if Fragment returns an error during termination.
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
try:
|
||||
clean = _strip_plus(number)
|
||||
|
||||
confirmation = await client.call(
|
||||
"terminatePhoneSessions",
|
||||
{"number": clean},
|
||||
page_url=NUMBERS_PAGE,
|
||||
)
|
||||
|
||||
if confirmation.get("error"):
|
||||
raise AnonymousNumberError(
|
||||
AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(confirmation["error"]))
|
||||
)
|
||||
|
||||
terminate_hash = confirmation.get("terminate_hash")
|
||||
if not terminate_hash:
|
||||
raise AnonymousNumberError(AnonymousNumberError.NOT_OWNED.format(number=number))
|
||||
|
||||
result = await client.call(
|
||||
"terminatePhoneSessions",
|
||||
{"number": clean, "terminate_hash": terminate_hash},
|
||||
page_url=NUMBERS_PAGE,
|
||||
)
|
||||
|
||||
if result.get("error"):
|
||||
raise AnonymousNumberError(
|
||||
AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(result["error"]))
|
||||
)
|
||||
|
||||
return TerminateSessionsResult(number=number, message=result.get("msg"))
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.types import (
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
PremiumGiveawayResult,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
)
|
||||
from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE
|
||||
from pyfragment.utils import get_account_info, process_transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def giveaway_premium(
|
||||
client: "FragmentClient",
|
||||
channel: str,
|
||||
winners: int,
|
||||
months: int = 3,
|
||||
) -> PremiumGiveawayResult:
|
||||
"""Run a Telegram Premium giveaway for a channel.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
channel: Channel username (with or without ``@``).
|
||||
winners: Number of winners — integer from ``1`` to ``24 000``.
|
||||
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
|
||||
|
||||
Returns:
|
||||
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
|
||||
``winners``, and ``amount``.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``winners`` is not 1–24 000 or ``months`` is not 3, 6, or 12.
|
||||
UserNotFoundError: If the channel is not found on Fragment.
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
if not isinstance(winners, int) or not (1 <= winners <= 24_000):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
|
||||
if months not in (3, 6, 12):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
||||
|
||||
try:
|
||||
result = await client.call(
|
||||
"searchPremiumGiveawayRecipient",
|
||||
{"query": channel, "quantity": winners, "months": months},
|
||||
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||
)
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
|
||||
|
||||
result = await client.call(
|
||||
"initGiveawayPremiumRequest",
|
||||
{"recipient": recipient, "quantity": str(winners), "months": str(months)},
|
||||
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)
|
||||
transaction = await client.call(
|
||||
"getGiveawayPremiumLink",
|
||||
{
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
},
|
||||
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||
)
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
tx_hash = await process_transaction(client, transaction)
|
||||
return PremiumGiveawayResult(
|
||||
transaction_id=tx_hash,
|
||||
channel=channel,
|
||||
winners=winners,
|
||||
amount=months,
|
||||
)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.types import (
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
StarsGiveawayResult,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
)
|
||||
from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE
|
||||
from pyfragment.utils import get_account_info, process_transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def giveaway_stars(
|
||||
client: "FragmentClient",
|
||||
channel: str,
|
||||
winners: int,
|
||||
amount: int,
|
||||
) -> StarsGiveawayResult:
|
||||
"""Run a Telegram Stars giveaway for a channel.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
channel: Channel username (with or without ``@``).
|
||||
winners: Number of winners — integer from ``1`` to ``5``.
|
||||
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
|
||||
|
||||
Returns:
|
||||
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
|
||||
``winners``, and ``amount``.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``winners`` is not 1–5 or ``amount`` is not 500–1 000 000.
|
||||
UserNotFoundError: If the channel is not found on Fragment.
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
if not isinstance(winners, int) or not (1 <= winners <= 5):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
|
||||
if not isinstance(amount, int) or not (500 <= amount <= 1_000_000):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
|
||||
|
||||
try:
|
||||
result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE)
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
|
||||
|
||||
result = await client.call(
|
||||
"initGiveawayStarsRequest",
|
||||
{"recipient": recipient, "quantity": str(winners), "stars": str(amount)},
|
||||
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)
|
||||
transaction = await client.call(
|
||||
"getGiveawayStarsLink",
|
||||
{
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
},
|
||||
page_url=STARS_GIVEAWAY_PAGE,
|
||||
)
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
tx_hash = await process_transaction(client, transaction)
|
||||
return StarsGiveawayResult(
|
||||
transaction_id=tx_hash,
|
||||
channel=channel,
|
||||
winners=winners,
|
||||
amount=amount,
|
||||
)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.types import (
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
PremiumResult,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
)
|
||||
from pyfragment.types.constants import DEVICE, PREMIUM_PAGE
|
||||
from pyfragment.utils import get_account_info, process_transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult:
|
||||
"""Gift Telegram Premium to a user.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
username: Recipient's Telegram username (with or without ``@``).
|
||||
months: Premium duration — ``3``, ``6``, or ``12``.
|
||||
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``months`` is not ``3``, ``6``, or ``12``.
|
||||
UserNotFoundError: If the user is not found on Fragment.
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
if months not in (3, 6, 12):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
||||
|
||||
try:
|
||||
result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE)
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||
|
||||
await client.call(
|
||||
"updatePremiumState",
|
||||
{"mode": "new", "lv": "false", "dh": str(int(time.time()))},
|
||||
page_url=PREMIUM_PAGE,
|
||||
)
|
||||
result = await client.call("initGiftPremiumRequest", {"recipient": recipient, "months": months}, 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)
|
||||
transaction = await client.call(
|
||||
"getGiftPremiumLink",
|
||||
{
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": int(show_sender),
|
||||
},
|
||||
page_url=PREMIUM_PAGE,
|
||||
)
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
tx_hash = await process_transaction(client, transaction)
|
||||
return PremiumResult(transaction_id=tx_hash, username=username, amount=months)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.types import (
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
StarsResult,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
)
|
||||
from pyfragment.types.constants import DEVICE, STARS_PAGE
|
||||
from pyfragment.utils import get_account_info, process_transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def purchase_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult:
|
||||
"""Send Telegram Stars to a user.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
username: Recipient's Telegram username (with or without ``@``).
|
||||
amount: Number of Stars to send — integer from ``50`` to ``1 000 000``.
|
||||
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``amount`` is not an integer between 50 and 1 000 000.
|
||||
UserNotFoundError: If the user is not found on Fragment.
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
|
||||
|
||||
try:
|
||||
result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE)
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||
|
||||
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)
|
||||
transaction = await client.call(
|
||||
"getBuyStarsLink",
|
||||
{
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": int(show_sender),
|
||||
},
|
||||
page_url=STARS_PAGE,
|
||||
)
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
tx_hash = await process_transaction(client, transaction)
|
||||
return StarsResult(transaction_id=tx_hash, username=username, amount=amount)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.types import (
|
||||
AdsRechargeResult,
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
UnexpectedError,
|
||||
VerificationError,
|
||||
)
|
||||
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
|
||||
from pyfragment.utils import get_account_info, process_transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def recharge_ads(client: "FragmentClient", account: str, amount: int) -> AdsRechargeResult:
|
||||
"""Add funds to your own Telegram Ads account.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
account: Your Fragment Ads account identifier — the channel or bot username
|
||||
the Ads account is linked to (e.g. ``"@mychannel"``).
|
||||
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||
|
||||
Returns:
|
||||
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``amount`` is not a valid integer in the allowed range.
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
|
||||
|
||||
try:
|
||||
await client.call("updateAdsState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
|
||||
|
||||
result = await client.call("initAdsRechargeRequest", {"account": account, "amount": amount}, page_url=ADS_TOPUP_PAGE)
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Ads recharge"))
|
||||
|
||||
account_info = await get_account_info(client)
|
||||
transaction = await client.call(
|
||||
"getAdsRechargeLink",
|
||||
{
|
||||
"account": json.dumps(account_info),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
},
|
||||
page_url=ADS_TOPUP_PAGE,
|
||||
)
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
tx_hash = await process_transaction(client, transaction)
|
||||
return AdsRechargeResult(transaction_id=tx_hash, amount=amount)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pyfragment.types import FragmentAPIError, FragmentError, GiftsResult, UnexpectedError
|
||||
from pyfragment.types.constants import GIFTS_PAGE
|
||||
from pyfragment.utils import parse_gift_items
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def search_gifts(
|
||||
client: "FragmentClient",
|
||||
query: str = "",
|
||||
collection: str | None = None,
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
view: str | None = None,
|
||||
attr: dict[str, list[str]] | None = None,
|
||||
offset: int | None = None,
|
||||
) -> GiftsResult:
|
||||
"""Search the Fragment gifts marketplace.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
query: Search text. Omit or pass ``""`` to browse without filtering by name.
|
||||
collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all.
|
||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||
``"ending"``. Omit to use Fragment's default ordering.
|
||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""``
|
||||
(available items). Omit to return all.
|
||||
view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default.
|
||||
attr: Attribute filters as a mapping of trait name to list of accepted values, e.g.
|
||||
``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``.
|
||||
Each key is sent as ``attr[Key]`` with its list of values.
|
||||
offset: Integer page offset from a previous :class:`GiftsResult`.
|
||||
Pass ``next_offset`` to fetch the next page.
|
||||
|
||||
Returns:
|
||||
:class:`GiftsResult` with ``items`` (parsed list of item dicts) and
|
||||
``next_offset`` (``None`` on the last page).
|
||||
|
||||
Raises:
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
data: dict[str, Any] = {"method": "searchAuctions", "type": "gifts", "query": query}
|
||||
if collection is not None:
|
||||
data["collection"] = collection
|
||||
if sort is not None:
|
||||
data["sort"] = sort
|
||||
if filter is not None:
|
||||
data["filter"] = filter
|
||||
if view is not None:
|
||||
data["view"] = view
|
||||
if attr is not None:
|
||||
for trait, values in attr.items():
|
||||
data[f"attr[{trait}]"] = values
|
||||
if offset is not None:
|
||||
data["offset"] = offset
|
||||
|
||||
try:
|
||||
result = await client.call("searchAuctions", data, page_url=GIFTS_PAGE)
|
||||
|
||||
if result.get("error"):
|
||||
raise FragmentAPIError(result["error"])
|
||||
|
||||
items, next_offset = parse_gift_items(result.get("html") or "")
|
||||
return GiftsResult(items=items, next_offset=next_offset)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pyfragment.types import FragmentAPIError, FragmentError, NumbersResult, UnexpectedError
|
||||
from pyfragment.types.constants import NUMBERS_PAGE
|
||||
from pyfragment.utils import parse_auction_rows
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def search_numbers(
|
||||
client: "FragmentClient",
|
||||
query: str = "",
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
offset_id: str | None = None,
|
||||
) -> NumbersResult:
|
||||
"""Search the Fragment marketplace for anonymous Telegram numbers.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all.
|
||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||
``"ending"``. Omit to use Fragment's default ordering.
|
||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""``
|
||||
(available items). Omit to return all.
|
||||
offset_id: Pagination cursor from a previous :class:`NumbersResult`.
|
||||
Pass ``next_offset_id`` to fetch the next page.
|
||||
|
||||
Returns:
|
||||
:class:`NumbersResult` with ``items`` (parsed list of item dicts) and
|
||||
``next_offset_id`` (``None`` when there are no more pages).
|
||||
|
||||
Raises:
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
data: dict[str, Any] = {"method": "searchAuctions", "type": "numbers", "query": query}
|
||||
if sort is not None:
|
||||
data["sort"] = sort
|
||||
if filter is not None:
|
||||
data["filter"] = filter
|
||||
if offset_id is not None:
|
||||
data["offset_id"] = offset_id
|
||||
|
||||
try:
|
||||
result = await client.call("searchAuctions", data, page_url=NUMBERS_PAGE)
|
||||
|
||||
if result.get("error"):
|
||||
raise FragmentAPIError(result["error"])
|
||||
|
||||
items = parse_auction_rows(result.get("html") or "")
|
||||
raw_noi = result.get("next_offset_id")
|
||||
next_offset_id = str(raw_noi) if raw_noi else None
|
||||
return NumbersResult(items=items, next_offset_id=next_offset_id)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError, UsernamesResult
|
||||
from pyfragment.types.constants import FRAGMENT_BASE_URL
|
||||
from pyfragment.utils import parse_auction_rows
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def search_usernames(
|
||||
client: "FragmentClient",
|
||||
query: str = "",
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
offset_id: str | None = None,
|
||||
) -> UsernamesResult:
|
||||
"""Search the Fragment marketplace for Telegram usernames.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
|
||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||
``"ending"``. Omit to use Fragment's default ordering.
|
||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""``
|
||||
(available items). Omit to return all.
|
||||
offset_id: Pagination cursor from a previous :class:`UsernamesResult`.
|
||||
Pass ``next_offset_id`` to fetch the next page.
|
||||
|
||||
Returns:
|
||||
:class:`UsernamesResult` with ``items`` (parsed list of item dicts) and
|
||||
``next_offset_id`` (``None`` when there are no more pages).
|
||||
|
||||
Raises:
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
data: dict[str, Any] = {"method": "searchAuctions", "type": "usernames", "query": query}
|
||||
if sort is not None:
|
||||
data["sort"] = sort
|
||||
if filter is not None:
|
||||
data["filter"] = filter
|
||||
if offset_id is not None:
|
||||
data["offset_id"] = offset_id
|
||||
|
||||
try:
|
||||
result = await client.call("searchAuctions", data, page_url=FRAGMENT_BASE_URL)
|
||||
|
||||
if result.get("error"):
|
||||
raise FragmentAPIError(result["error"])
|
||||
|
||||
items = parse_auction_rows(result.get("html") or "")
|
||||
raw_noi = result.get("next_offset_id")
|
||||
next_offset_id = str(raw_noi) if raw_noi else None
|
||||
return UsernamesResult(items=items, next_offset_id=next_offset_id)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.types import (
|
||||
AdsTopupResult,
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
)
|
||||
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
|
||||
from pyfragment.utils import get_account_info, process_transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||
"""Top up TON to a recipient's Telegram balance.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
username: Recipient's Telegram username (with or without ``@``).
|
||||
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``amount`` is not an integer between 1 and 1 000 000 000.
|
||||
UserNotFoundError: If the recipient is not found on Telegram.
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
|
||||
|
||||
try:
|
||||
await client.call("updateAdsTopupState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
|
||||
|
||||
result = await client.call("searchAdsTopupRecipient", {"query": username}, page_url=ADS_TOPUP_PAGE)
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||
|
||||
result = await client.call("initAdsTopupRequest", {"recipient": recipient, "amount": amount}, page_url=ADS_TOPUP_PAGE)
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
|
||||
|
||||
account = await get_account_info(client)
|
||||
transaction = await client.call(
|
||||
"getAdsTopupLink",
|
||||
{
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": int(show_sender),
|
||||
},
|
||||
page_url=ADS_TOPUP_PAGE,
|
||||
)
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
tx_hash = await process_transaction(client, transaction)
|
||||
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,64 @@
|
||||
from pyfragment.types.exceptions import (
|
||||
AnonymousNumberError,
|
||||
ClientError,
|
||||
ConfigurationError,
|
||||
CookieError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
FragmentPageError,
|
||||
OperationError,
|
||||
ParseError,
|
||||
TransactionError,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
WalletError,
|
||||
)
|
||||
from pyfragment.types.results import (
|
||||
AdsRechargeResult,
|
||||
AdsTopupResult,
|
||||
CookieResult,
|
||||
GiftsResult,
|
||||
LoginCodeResult,
|
||||
NumbersResult,
|
||||
PremiumGiveawayResult,
|
||||
PremiumResult,
|
||||
StarsGiveawayResult,
|
||||
StarsResult,
|
||||
TerminateSessionsResult,
|
||||
UsernamesResult,
|
||||
WalletInfo,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# client exceptions
|
||||
"ClientError",
|
||||
"ConfigurationError",
|
||||
"CookieError",
|
||||
# fragment exceptions
|
||||
"FragmentAPIError",
|
||||
"FragmentError",
|
||||
"FragmentPageError",
|
||||
"AnonymousNumberError",
|
||||
"OperationError",
|
||||
"ParseError",
|
||||
"TransactionError",
|
||||
"UnexpectedError",
|
||||
"UserNotFoundError",
|
||||
"VerificationError",
|
||||
"WalletError",
|
||||
# result types
|
||||
"AdsRechargeResult",
|
||||
"AdsTopupResult",
|
||||
"CookieResult",
|
||||
"GiftsResult",
|
||||
"LoginCodeResult",
|
||||
"NumbersResult",
|
||||
"PremiumGiveawayResult",
|
||||
"PremiumResult",
|
||||
"StarsGiveawayResult",
|
||||
"StarsResult",
|
||||
"TerminateSessionsResult",
|
||||
"UsernamesResult",
|
||||
"WalletInfo",
|
||||
]
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Literal, get_args
|
||||
from typing import Any, Literal, get_args
|
||||
|
||||
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
|
||||
|
||||
@@ -8,19 +10,53 @@ WalletVersion = Literal["V4R2", "V5R1"]
|
||||
SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion))
|
||||
|
||||
# Wallet class map — used to resolve the correct contract from WALLET_VERSION
|
||||
WALLET_CLASSES: dict[str, type] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
|
||||
WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
|
||||
|
||||
# Fragment page URLs
|
||||
STARS_PAGE: str = "https://fragment.com/stars/buy"
|
||||
PREMIUM_PAGE: str = "https://fragment.com/premium/gift"
|
||||
ADS_PAGE: str = "https://fragment.com/ads/topup"
|
||||
# Minimum wallet balance required to cover TON network gas fees.
|
||||
MIN_TON_BALANCE: float = 0.056
|
||||
|
||||
# Default HTTP request timeout in seconds.
|
||||
DEFAULT_TIMEOUT: float = 30.0
|
||||
|
||||
# Required Fragment session cookie keys
|
||||
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
|
||||
|
||||
# Fragment domain and page URLs
|
||||
FRAGMENT_DOMAIN: str = "fragment.com" # for rookiepy
|
||||
FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}"
|
||||
STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy"
|
||||
STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway"
|
||||
PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift"
|
||||
PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway"
|
||||
ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup"
|
||||
NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers"
|
||||
GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts"
|
||||
|
||||
# Browsers supported by get_cookies_from_browser()
|
||||
SUPPORTED_BROWSERS: frozenset[str] = frozenset(
|
||||
{
|
||||
"arc",
|
||||
"brave",
|
||||
"chrome",
|
||||
"chromium",
|
||||
"chromium_based",
|
||||
"edge",
|
||||
"firefox",
|
||||
"firefox_based",
|
||||
"librewolf",
|
||||
"opera",
|
||||
"opera_gx",
|
||||
"safari",
|
||||
"vivaldi",
|
||||
}
|
||||
)
|
||||
|
||||
# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload.
|
||||
DEVICE: str = json.dumps(
|
||||
{
|
||||
"platform": "iphone",
|
||||
"appName": "Tonkeeper",
|
||||
"appVersion": "5.5.2",
|
||||
"appVersion": "26.04.0",
|
||||
"maxProtocolVersion": 2,
|
||||
"features": [
|
||||
"SendTransaction",
|
||||
@@ -36,7 +72,7 @@ BASE_HEADERS: dict[str, str] = {
|
||||
"accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7",
|
||||
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"origin": "https://fragment.com",
|
||||
"origin": FRAGMENT_BASE_URL,
|
||||
"priority": "u=1, i",
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class FragmentError(Exception):
|
||||
"""Base exception for all pyfragment library errors."""
|
||||
|
||||
|
||||
class ClientError(FragmentError):
|
||||
"""Raised for client configuration and setup issues (bad params, invalid cookies)."""
|
||||
|
||||
|
||||
class ConfigurationError(ClientError):
|
||||
"""Raised when required client parameters are missing or invalid."""
|
||||
|
||||
MISSING_VARS = "Missing required parameter(s): {keys}."
|
||||
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
|
||||
INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}."
|
||||
INVALID_API_KEY = (
|
||||
"Invalid Tonapi API key: expected at least 68 characters, got {length}. " "Generate a key at https://tonconsole.com."
|
||||
)
|
||||
INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months."
|
||||
INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000."
|
||||
INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1 000 000 000."
|
||||
INVALID_USERNAME = (
|
||||
"Invalid username '{username}'. "
|
||||
"Must be 5–32 characters and contain only letters (A–Z, a–z), digits (0–9), or underscores (_)."
|
||||
)
|
||||
INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5."
|
||||
INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24 000."
|
||||
INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1 000 000."
|
||||
|
||||
|
||||
class CookieError(ClientError):
|
||||
"""Raised when cookies are unreadable or missing required fields."""
|
||||
|
||||
READ_FAILED = "Failed to parse cookies — expected a JSON string or a dict, got: {exc}"
|
||||
MISSING_KEYS = (
|
||||
"Fragment cookies are missing or empty for key(s): {keys}. "
|
||||
"Open fragment.com in your browser, log in, and copy fresh cookies."
|
||||
)
|
||||
UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}."
|
||||
BROWSER_READ_FAILED = (
|
||||
"Failed to read {browser} cookies: {exc}. " "Make sure {browser} is installed and you are logged in to {url}."
|
||||
)
|
||||
MISSING_BROWSER_KEYS = (
|
||||
"Fragment cookies not found in {browser}: {keys}. "
|
||||
"Make sure you are logged in to {url} and have connected your TON wallet in {browser}."
|
||||
)
|
||||
|
||||
|
||||
class FragmentAPIError(FragmentError):
|
||||
"""Raised for errors returned by Fragment's API responses."""
|
||||
|
||||
NO_REQUEST_ID = (
|
||||
"Fragment did not return a request ID for '{context}'. "
|
||||
"Your session may have expired — log in to fragment.com and refresh your cookies."
|
||||
)
|
||||
|
||||
|
||||
class FragmentPageError(FragmentAPIError):
|
||||
"""Raised when the Fragment page cannot be fetched or the API hash is not found."""
|
||||
|
||||
BAD_STATUS = (
|
||||
"Fragment returned HTTP {status} when loading {url}. "
|
||||
"Your cookies may be invalid or expired — log in to fragment.com and refresh them."
|
||||
)
|
||||
NOT_FOUND = (
|
||||
"Could not extract the API hash from {url}. "
|
||||
"The page structure may have changed, or you are not logged in — refresh your cookies."
|
||||
)
|
||||
|
||||
|
||||
class UserNotFoundError(FragmentAPIError):
|
||||
"""Raised when the target Telegram user is not found on Fragment."""
|
||||
|
||||
NOT_FOUND = (
|
||||
"Telegram user '{username}' was not found on Fragment. " "Double-check the username and make sure the account exists."
|
||||
)
|
||||
|
||||
|
||||
class AnonymousNumberError(FragmentAPIError):
|
||||
"""Raised for Fragment anonymous number API failures."""
|
||||
|
||||
NOT_OWNED = "Number '{number}' is not associated with your Fragment account or has no active sessions to terminate."
|
||||
TERMINATE_FAILED = "Failed to terminate sessions for '{number}': {error}"
|
||||
|
||||
|
||||
class TransactionError(FragmentAPIError):
|
||||
"""Raised when a TON transaction fails to build or broadcast."""
|
||||
|
||||
INVALID_PAYLOAD = (
|
||||
"Fragment returned an invalid transaction payload — " "'transaction.messages' is missing or empty in the API response."
|
||||
)
|
||||
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
|
||||
BROADCAST_FAILED_SSL = (
|
||||
"Transaction broadcast failed due to an SSL certificate error: {exc}\n"
|
||||
"This usually means your system's CA bundle is missing or outdated.\n"
|
||||
"Fix: run `pip install --upgrade certifi` and retry. "
|
||||
"On macOS you may also need to run the 'Install Certificates.command' "
|
||||
"located in your Python installation folder."
|
||||
)
|
||||
DUPLICATE_SEQNO = (
|
||||
"Transaction broadcast failed: the TON wallet rejected the message "
|
||||
"because a previous transaction with the same sequence number (seqno) "
|
||||
"is still pending confirmation on-chain.\n"
|
||||
"Wait a few seconds for the previous transaction to confirm, then retry."
|
||||
)
|
||||
|
||||
|
||||
class ParseError(FragmentAPIError):
|
||||
"""Raised when a Fragment API response or payload cannot be parsed."""
|
||||
|
||||
UNPARSEABLE = "Failed to parse the Fragment API response for '{context}': {exc}"
|
||||
|
||||
|
||||
class VerificationError(FragmentAPIError):
|
||||
"""Raised when Fragment requires KYC verification before proceeding."""
|
||||
|
||||
KYC_REQUIRED = (
|
||||
"Fragment requires identity verification (KYC) before this action can be completed. "
|
||||
"Complete verification at https://fragment.com/my/profile and retry."
|
||||
)
|
||||
|
||||
|
||||
class OperationError(FragmentError):
|
||||
"""Raised for runtime operation failures unrelated to Fragment's API."""
|
||||
|
||||
|
||||
class WalletError(OperationError):
|
||||
"""Raised for TON wallet issues (connection, balance, account info)."""
|
||||
|
||||
LOW_BALANCE = (
|
||||
"Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required "
|
||||
"(transaction amount + {gas:.3f} TON gas reserve)."
|
||||
)
|
||||
BALANCE_CHECK_FAILED = "Failed to fetch wallet balance: {exc}"
|
||||
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {exc}"
|
||||
WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}"
|
||||
|
||||
|
||||
class UnexpectedError(OperationError):
|
||||
"""Raised when an unexpected error occurs during an API call."""
|
||||
|
||||
UNEXPECTED = "An unexpected error occurred during the operation: {exc}"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FragmentError",
|
||||
"ClientError",
|
||||
"ConfigurationError",
|
||||
"CookieError",
|
||||
"FragmentAPIError",
|
||||
"FragmentPageError",
|
||||
"AnonymousNumberError",
|
||||
"UserNotFoundError",
|
||||
"TransactionError",
|
||||
"ParseError",
|
||||
"VerificationError",
|
||||
"OperationError",
|
||||
"WalletError",
|
||||
"UnexpectedError",
|
||||
]
|
||||
@@ -0,0 +1,218 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class CookieResult:
|
||||
"""Result returned by :func:`~pyfragment.utils.get_cookies_from_browser`.
|
||||
|
||||
Attributes:
|
||||
cookies: Dict with the four required Fragment cookie keys.
|
||||
expires: Expiry of the ``stel_ssid`` session cookie in ISO 8601 format (UTC),
|
||||
or ``None`` for session cookies.
|
||||
"""
|
||||
|
||||
cookies: dict[str, str]
|
||||
expires: str | None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"CookieResult(expires={self.expires!r})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletInfo:
|
||||
"""Wallet state returned by :meth:`FragmentClient.get_wallet`."""
|
||||
|
||||
address: str
|
||||
state: str
|
||||
balance: float
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"WalletInfo(address='{self.address}', state='{self.state}', balance={self.balance} TON)"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PremiumResult:
|
||||
"""Result of a successful Telegram Premium gift."""
|
||||
|
||||
transaction_id: str
|
||||
username: str
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"PremiumResult(username='{self.username}', amount={self.amount} months, tx='{self.transaction_id}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StarsResult:
|
||||
"""Result of a successful Telegram Stars purchase."""
|
||||
|
||||
transaction_id: str
|
||||
username: str
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"StarsResult(username='{self.username}', amount={self.amount} stars, tx='{self.transaction_id}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdsTopupResult:
|
||||
"""Result of a successful Telegram Ads balance top-up."""
|
||||
|
||||
transaction_id: str
|
||||
username: str
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AdsTopupResult(username='{self.username}', amount={self.amount} TON, tx='{self.transaction_id}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StarsGiveawayResult:
|
||||
"""Result of a successful Telegram Stars giveaway."""
|
||||
|
||||
transaction_id: str
|
||||
channel: str
|
||||
winners: int
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"StarsGiveawayResult(channel='{self.channel}', winners={self.winners}, "
|
||||
f"amount={self.amount} stars per winner, tx='{self.transaction_id}')"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PremiumGiveawayResult:
|
||||
"""Result of a successful Telegram Premium giveaway."""
|
||||
|
||||
transaction_id: str
|
||||
channel: str
|
||||
winners: int
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"PremiumGiveawayResult(channel='{self.channel}', winners={self.winners}, "
|
||||
f"amount={self.amount} months per winner, tx='{self.transaction_id}')"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginCodeResult:
|
||||
"""Result of :meth:`FragmentClient.get_login_code`."""
|
||||
|
||||
number: str
|
||||
code: str | None
|
||||
active_sessions: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
code_str = f"'{self.code}'" if self.code else "None"
|
||||
return f"LoginCodeResult(number='{self.number}', code={code_str}, active_sessions={self.active_sessions})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdsRechargeResult:
|
||||
"""Result of a successful self-recharge of Telegram Ads balance."""
|
||||
|
||||
transaction_id: str
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AdsRechargeResult(amount={self.amount} TON, tx='{self.transaction_id}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerminateSessionsResult:
|
||||
"""Result of :meth:`FragmentClient.terminate_sessions`."""
|
||||
|
||||
number: str
|
||||
message: str | None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsernamesResult:
|
||||
"""Result of :meth:`FragmentClient.search_usernames`.
|
||||
|
||||
Each dict in ``items`` has the keys:
|
||||
|
||||
- ``slug`` — URL path (e.g. ``"username/durov"``).
|
||||
- ``name`` — display value (e.g. ``"@durov"``).
|
||||
- ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``).
|
||||
- ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``.
|
||||
- ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, or ``None``.
|
||||
|
||||
Use ``next_offset_id`` to paginate to the next page of results.
|
||||
"""
|
||||
|
||||
items: list[dict[str, Any]]
|
||||
next_offset_id: str | None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"UsernamesResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NumbersResult:
|
||||
"""Result of :meth:`FragmentClient.search_numbers`.
|
||||
|
||||
Each dict in ``items`` has the keys:
|
||||
|
||||
- ``slug`` — URL path (e.g. ``"number/8880000111"``).
|
||||
- ``name`` — display value (e.g. ``"+888 0000 111"``).
|
||||
- ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``).
|
||||
- ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``.
|
||||
- ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, or ``None``.
|
||||
|
||||
Use ``next_offset_id`` to paginate to the next page of results.
|
||||
"""
|
||||
|
||||
items: list[dict[str, Any]]
|
||||
next_offset_id: str | None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"NumbersResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GiftsResult:
|
||||
"""Result of :meth:`FragmentClient.search_gifts`.
|
||||
|
||||
Each dict in ``items`` has the keys:
|
||||
|
||||
- ``slug`` — URL path (e.g. ``"gift/plushpepe-1821"``).
|
||||
- ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``).
|
||||
- ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``).
|
||||
- ``price`` — price in TON formatted to two decimal places (e.g. ``"88888.00"``), or ``None``.
|
||||
- ``date`` — ISO 8601 datetime of the sale/listing, or ``None``.
|
||||
|
||||
Use ``next_offset`` to paginate to the next page of results.
|
||||
"""
|
||||
|
||||
items: list[dict[str, Any]]
|
||||
next_offset: int | None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AdsRechargeResult",
|
||||
"AdsTopupResult",
|
||||
"GiftsResult",
|
||||
"LoginCodeResult",
|
||||
"NumbersResult",
|
||||
"PremiumGiveawayResult",
|
||||
"PremiumResult",
|
||||
"StarsGiveawayResult",
|
||||
"StarsResult",
|
||||
"TerminateSessionsResult",
|
||||
"UsernamesResult",
|
||||
"WalletInfo",
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
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 (
|
||||
execute_transaction_request,
|
||||
fragment_request,
|
||||
get_fragment_hash,
|
||||
make_headers,
|
||||
parse_json_response,
|
||||
)
|
||||
from pyfragment.utils.wallet import get_account_info, process_transaction
|
||||
|
||||
__all__ = [
|
||||
"clean_decode",
|
||||
"CookieResult",
|
||||
"get_cookies_from_browser",
|
||||
"parse_auction_rows",
|
||||
"parse_gift_items",
|
||||
"parse_login_code",
|
||||
"execute_transaction_request",
|
||||
"fragment_request",
|
||||
"get_account_info",
|
||||
"get_fragment_hash",
|
||||
"make_headers",
|
||||
"parse_json_response",
|
||||
"process_transaction",
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import rookiepy
|
||||
|
||||
from pyfragment.types import CookieError, 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] = 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
|
||||
|
||||
return CookieResult(
|
||||
cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS},
|
||||
expires=expires_iso,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
from ton_core import Cell
|
||||
|
||||
from pyfragment.types import ParseError
|
||||
|
||||
|
||||
def clean_decode(payload: str) -> str:
|
||||
"""Decode a base64-encoded BOC payload to a plain-text comment string.
|
||||
|
||||
Fragment transaction payloads are BOC-serialised TVM cells. This function
|
||||
base64-decodes the payload, parses the cell, skips the 32-bit op-code
|
||||
prefix, and reads the snake-encoded UTF-8 comment.
|
||||
|
||||
Args:
|
||||
payload: Base64url-encoded BOC string (padding is added automatically).
|
||||
|
||||
Returns:
|
||||
Decoded comment string, or ``""`` for an empty payload.
|
||||
|
||||
Raises:
|
||||
ParseError: If the payload cannot be decoded or parsed.
|
||||
"""
|
||||
s = payload.strip()
|
||||
if not s:
|
||||
return ""
|
||||
s += "=" * (-len(s) % 4)
|
||||
try:
|
||||
boc = base64.b64decode(s)
|
||||
cell = Cell.one_from_boc(boc)
|
||||
sl = cell.begin_parse()
|
||||
sl.load_uint(32) # op code — always 0 for text comment
|
||||
return sl.load_snake_string().strip()
|
||||
except Exception as exc:
|
||||
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# Matches the login code inside a table-cell-value element.
|
||||
CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<')
|
||||
# Counts active session rows in the HTML table.
|
||||
ROW_RE = re.compile(r"<tr[\s>]")
|
||||
|
||||
# Auction table row parsing
|
||||
ROW_BLOCK_RE = re.compile(r'<tr\b[^>]*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)</tr>', re.DOTALL)
|
||||
HREF_RE = re.compile(r'href="(/(?:username|number|nft)/([^"]+))"')
|
||||
VALUE_RE = re.compile(r'class="[^"]*tm-value[^"]*"[^>]*>\s*([^<]+?)\s*<')
|
||||
PRICE_RE = re.compile(r"icon-before\s+icon-ton[^>]*>\s*([0-9][^<]*?)\s*<")
|
||||
DATETIME_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative="text"[^>]*>')
|
||||
DATETIME_SHORT_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative="short-text"[^>]*>')
|
||||
# Matches numeric-only values (plain integers, formatted prices like "150,492", phone numbers like "+888 0088 8888")
|
||||
NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$")
|
||||
|
||||
# Gift grid item parsing
|
||||
GRID_ITEM_RE = re.compile(r'<a\b[^>]*class="[^"]*tm-grid-item[^"]*"[^>]*>(.*?)</a>', re.DOTALL)
|
||||
GRID_HREF_RE = re.compile(r'href="(/gift/([^?"]+))')
|
||||
GRID_NAME_RE = re.compile(r'class="item-name">([^<]+)<')
|
||||
GRID_NUM_RE = re.compile(r'class="item-num">[^#]*#(\w+)<')
|
||||
GRID_PRICE_RE = re.compile(r'class="[^"]*tm-grid-item-value[^"]*icon-ton[^"]*"[^>]*>\s*([0-9][^<]*?)\s*<')
|
||||
GRID_STATUS_RE = re.compile(r'class="[^"]*tm-grid-item-status[^"]*"[^>]*>\s*([^<]+?)\s*<')
|
||||
GRID_DATETIME_RE = re.compile(r'<time[^>]+datetime="([^"]+)"')
|
||||
|
||||
|
||||
def parse_login_code(html: str) -> tuple[str | None, int]:
|
||||
"""Extract the pending login code and active session count from a Fragment numbers page HTML snippet.
|
||||
|
||||
Args:
|
||||
html: Raw HTML string returned by the Fragment API.
|
||||
|
||||
Returns:
|
||||
A tuple of ``(code, active_sessions)`` where ``code`` is ``None`` if no
|
||||
pending code is present, and ``active_sessions`` is the number of ``<tr>``
|
||||
rows found (each row represents one active session).
|
||||
"""
|
||||
match = CODE_RE.search(html)
|
||||
code = match.group(1).strip() if match else None
|
||||
active_sessions = len(ROW_RE.findall(html))
|
||||
return code, active_sessions
|
||||
|
||||
|
||||
def parse_auction_rows(html: str) -> list[dict[str, Any]]:
|
||||
"""Parse Fragment marketplace HTML into structured item dicts.
|
||||
|
||||
Extracts each ``<tr class="tm-row-selectable">`` and returns a list of dicts
|
||||
with the following keys:
|
||||
|
||||
- ``slug`` — URL path segment (e.g. ``"username/durov"``).
|
||||
- ``name`` — display value (e.g. ``"@durov"`` or ``"+888..."``)
|
||||
- ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``).
|
||||
- ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``),
|
||||
or ``None`` if not listed.
|
||||
- ``date`` — ISO 8601 datetime string: auction end date, sale date, or listing date, or ``None``.
|
||||
|
||||
Returns:
|
||||
List of item dicts, one per table row.
|
||||
"""
|
||||
items: list[dict[str, Any]] = []
|
||||
for row_match in ROW_BLOCK_RE.finditer(html):
|
||||
row = row_match.group(1)
|
||||
|
||||
href_m = HREF_RE.search(row)
|
||||
if not href_m:
|
||||
continue
|
||||
slug = href_m.group(1).lstrip("/") # e.g. "username/durov"
|
||||
|
||||
# All tm-value spans in the row — first is the display name
|
||||
values = [m.group(1).strip() for m in VALUE_RE.finditer(row)]
|
||||
name = values[0] if values else slug
|
||||
|
||||
# Status: find the human-readable label from subsequent tm-value spans.
|
||||
# Skip usernames (@), numeric-only values (prices like "150,492", phone numbers like "+888 0088 8888").
|
||||
status: str | None = None
|
||||
for v in values[1:]:
|
||||
if v and v not in ("Unknown",) and not v.startswith("@") and not NUMERIC_RE.match(v):
|
||||
status = v
|
||||
break
|
||||
|
||||
# Price — look for icon-ton pattern, format as two decimal places
|
||||
price_m = PRICE_RE.search(row)
|
||||
price: str | None = None
|
||||
if price_m:
|
||||
raw_price = price_m.group(1).strip().replace(",", "")
|
||||
try:
|
||||
price = f"{float(raw_price):.2f}"
|
||||
except ValueError:
|
||||
price = raw_price
|
||||
|
||||
# Datetime (ISO 8601) — auction end, sale date, or listing date.
|
||||
time_m = DATETIME_RE.search(row) or DATETIME_SHORT_RE.search(row)
|
||||
date: str | None = time_m.group(1) if time_m else None
|
||||
|
||||
items.append(
|
||||
{
|
||||
"slug": slug,
|
||||
"name": name,
|
||||
"status": status,
|
||||
"price": price,
|
||||
"date": date,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]:
|
||||
"""Parse Fragment gifts grid HTML into structured item dicts.
|
||||
|
||||
Extracts each ``<a class="tm-grid-item">`` block and returns a list of dicts
|
||||
with the following keys:
|
||||
|
||||
- ``slug`` — URL path segment (e.g. ``"gift/plushpepe-1821"``).
|
||||
- ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``).
|
||||
- ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``).
|
||||
- ``price`` — price in TON formatted to two decimal places, or ``None``.
|
||||
- ``date`` — ISO 8601 datetime of the sale/listing, or ``None``.
|
||||
|
||||
Returns:
|
||||
Tuple of ``(items, next_offset)`` where ``next_offset`` is an integer
|
||||
page offset from ``data-next-offset``, or ``None`` on the last page.
|
||||
"""
|
||||
items: list[dict[str, Any]] = []
|
||||
for item_match in GRID_ITEM_RE.finditer(html):
|
||||
block = item_match.group(0)
|
||||
|
||||
href_m = GRID_HREF_RE.search(block)
|
||||
if not href_m:
|
||||
continue
|
||||
slug = href_m.group(1).lstrip("/") # e.g. "gift/plushpepe-1821"
|
||||
|
||||
name_m = GRID_NAME_RE.search(block)
|
||||
num_m = GRID_NUM_RE.search(block)
|
||||
item_name = name_m.group(1).strip() if name_m else slug
|
||||
item_num = f" #{num_m.group(1)}" if num_m else ""
|
||||
name = f"{item_name}{item_num}"
|
||||
|
||||
status_m = GRID_STATUS_RE.search(block)
|
||||
status: str | None = status_m.group(1).strip() if status_m else None
|
||||
|
||||
price_m = GRID_PRICE_RE.search(block)
|
||||
price: str | None = None
|
||||
if price_m:
|
||||
raw_price = price_m.group(1).strip().replace(",", "")
|
||||
try:
|
||||
price = f"{float(raw_price):.2f}"
|
||||
except ValueError:
|
||||
price = raw_price
|
||||
|
||||
time_m = GRID_DATETIME_RE.search(block)
|
||||
date: str | None = time_m.group(1) if time_m else None
|
||||
|
||||
items.append({"slug": slug, "name": name, "status": status, "price": price, "date": date})
|
||||
|
||||
# Pagination offset from data-next-offset attribute
|
||||
next_offset_m = re.search(r'data-next-offset="(\d+)"', html)
|
||||
next_offset = int(next_offset_m.group(1)) if next_offset_m else None
|
||||
|
||||
return items, next_offset
|
||||
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from pyfragment.types import FragmentPageError, ParseError, VerificationError
|
||||
from pyfragment.types.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
|
||||
|
||||
|
||||
def make_headers(page_url: str = FRAGMENT_BASE_URL) -> dict[str, str]:
|
||||
return {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
|
||||
|
||||
|
||||
async def get_fragment_hash(
|
||||
cookies: dict[str, Any],
|
||||
headers: dict[str, str],
|
||||
page_url: str,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> str:
|
||||
"""Fetch the API hash from a Fragment page.
|
||||
|
||||
Fragment embeds a short-lived hash in each page's HTML that must be
|
||||
included in every subsequent API request. This function loads the page
|
||||
as a real browser navigation (not XHR) so Fragment returns full HTML.
|
||||
|
||||
Args:
|
||||
cookies: Active Fragment session cookies.
|
||||
headers: Base headers for the relevant Fragment page.
|
||||
page_url: URL of the Fragment page to fetch the hash from.
|
||||
timeout: HTTP request timeout in seconds. Defaults to ``DEFAULT_TIMEOUT``.
|
||||
|
||||
Returns:
|
||||
Lowercase hex hash string.
|
||||
|
||||
Raises:
|
||||
FragmentPageError: If the page returns a non-200 status or the hash
|
||||
is not found in the response HTML.
|
||||
"""
|
||||
page_headers = {
|
||||
k: v
|
||||
for k, v in headers.items()
|
||||
if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
|
||||
}
|
||||
page_headers.update(
|
||||
{
|
||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"referer": f"{FRAGMENT_BASE_URL}/",
|
||||
"sec-fetch-dest": "document",
|
||||
"sec-fetch-mode": "navigate",
|
||||
"upgrade-insecure-requests": "1",
|
||||
}
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
|
||||
response = await session.get(page_url, headers=page_headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=response.status_code, url=page_url))
|
||||
|
||||
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
|
||||
if not match:
|
||||
raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url))
|
||||
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
|
||||
"""Parse a Fragment API JSON response.
|
||||
|
||||
Args:
|
||||
response: The HTTP response object.
|
||||
context: Human-readable name of the API method, used in error messages.
|
||||
|
||||
Returns:
|
||||
Parsed response as a dict.
|
||||
|
||||
Raises:
|
||||
ParseError: If the response body cannot be decoded as JSON.
|
||||
"""
|
||||
try:
|
||||
return response.json()
|
||||
except Exception as exc:
|
||||
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
||||
|
||||
|
||||
async def fragment_request(
|
||||
session: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
headers: dict[str, str],
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""POST a single request to the Fragment API.
|
||||
|
||||
Builds the ``/api?hash=`` URL, sends the request, and returns the
|
||||
parsed JSON body. Use this for every API method call — search,
|
||||
init, state updates, etc.
|
||||
|
||||
Args:
|
||||
session: Active httpx session with Fragment cookies.
|
||||
fragment_hash: Short-lived hash from the Fragment page HTML.
|
||||
headers: Page-specific HTTP headers.
|
||||
data: Form data payload; must include a ``"method"`` key.
|
||||
|
||||
Returns:
|
||||
Parsed API response as a dict.
|
||||
"""
|
||||
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"))
|
||||
|
||||
|
||||
async def execute_transaction_request(
|
||||
session: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
tx_data: dict[str, Any],
|
||||
fragment_hash: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Post a transaction request to the Fragment API.
|
||||
|
||||
Args:
|
||||
session: Active httpx session with Fragment cookies.
|
||||
headers: Page-specific HTTP headers.
|
||||
tx_data: Form data payload for the API method.
|
||||
fragment_hash: Short-lived hash from the Fragment page.
|
||||
|
||||
Returns:
|
||||
Parsed API response dict containing transaction data.
|
||||
|
||||
Raises:
|
||||
VerificationError: If Fragment requires KYC verification.
|
||||
ParseError: If the response cannot be parsed.
|
||||
"""
|
||||
transaction = await fragment_request(session, fragment_hash, headers, tx_data)
|
||||
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
return transaction
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import ssl
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ton_core import NetworkGlobalID
|
||||
from tonutils.clients import TonapiClient
|
||||
from tonutils.exceptions import ProviderResponseError
|
||||
|
||||
from pyfragment.types import TransactionError, WalletError, WalletInfo
|
||||
from pyfragment.types.constants import MIN_TON_BALANCE, WALLET_CLASSES
|
||||
from pyfragment.utils.decoder import clean_decode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def process_transaction(client: "FragmentClient", transaction_data: dict[str, Any]) -> str:
|
||||
"""Sign and broadcast a Fragment transaction to the TON network.
|
||||
|
||||
Validates the payload structure, checks the wallet balance, decodes the
|
||||
on-chain comment, and calls ``wallet.transfer``.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
transaction_data: Raw transaction dict from ``execute_transaction_request``.
|
||||
|
||||
Returns:
|
||||
Normalised transaction hash string.
|
||||
|
||||
Raises:
|
||||
TransactionError: If the payload is malformed or the broadcast fails.
|
||||
WalletError: If the wallet balance is too low or cannot be fetched.
|
||||
"""
|
||||
if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
|
||||
raise TransactionError(TransactionError.INVALID_PAYLOAD)
|
||||
|
||||
message = transaction_data["transaction"]["messages"][0]
|
||||
amount_ton = int(message["amount"]) / 1_000_000_000
|
||||
|
||||
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||
|
||||
# Check balance covers transaction amount + gas reserve
|
||||
try:
|
||||
await wallet.refresh()
|
||||
balance_ton = wallet.balance / 1_000_000_000
|
||||
required = amount_ton + MIN_TON_BALANCE
|
||||
if balance_ton < required:
|
||||
raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton, required=required, gas=MIN_TON_BALANCE))
|
||||
except WalletError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise WalletError(WalletError.BALANCE_CHECK_FAILED.format(exc=exc)) from exc
|
||||
|
||||
try:
|
||||
payload = clean_decode(message["payload"])
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
result = await wallet.transfer(
|
||||
destination=message["address"],
|
||||
amount=int(message["amount"]), # nanotons, not TON
|
||||
body=payload,
|
||||
)
|
||||
return result.normalized_hash
|
||||
except ProviderResponseError as exc:
|
||||
if exc.code == 429 and attempt == 0:
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
if exc.code == 406 and "seqno" in str(exc).lower():
|
||||
# Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry
|
||||
if attempt < 2:
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
|
||||
raise
|
||||
except (WalletError, TransactionError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
cause: BaseException | None = exc
|
||||
while cause is not None:
|
||||
if isinstance(cause, ssl.SSLError):
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
|
||||
cause = cause.__cause__ or cause.__context__
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
|
||||
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
|
||||
|
||||
|
||||
async def get_account_info(client: "FragmentClient") -> dict[str, Any]:
|
||||
"""Fetch wallet address, public key, and state-init for the Fragment API.
|
||||
|
||||
Fragment requires account info to build each transaction payload. The
|
||||
returned dict is JSON-serialised and passed as the ``account`` field in
|
||||
``getBuy*Link`` / ``get*Link`` requests.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
|
||||
Returns:
|
||||
Dict with ``address``, ``publicKey``, ``chain``, ``walletStateInit``.
|
||||
|
||||
Raises:
|
||||
WalletError: If account info cannot be retrieved.
|
||||
"""
|
||||
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
||||
try:
|
||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||
boc = wallet.state_init.serialize().to_boc()
|
||||
return {
|
||||
"address": wallet.address.to_str(False, False),
|
||||
"publicKey": pub_key.as_hex,
|
||||
"chain": "-239",
|
||||
"walletStateInit": base64.b64encode(boc).decode(),
|
||||
}
|
||||
except Exception as exc:
|
||||
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
|
||||
|
||||
|
||||
async def get_wallet_info(client: "FragmentClient") -> "WalletInfo":
|
||||
"""Return the address, state and balance of the TON wallet.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
|
||||
Returns:
|
||||
:class:`WalletInfo` with ``address``, ``state``, and ``balance`` in TON.
|
||||
|
||||
Raises:
|
||||
WalletError: If the wallet state cannot be fetched.
|
||||
"""
|
||||
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
||||
try:
|
||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||
await wallet.refresh()
|
||||
return WalletInfo(
|
||||
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
|
||||
state=wallet.state.value,
|
||||
balance=round(wallet.balance / 1_000_000_000, 4),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
|
||||
+59
-2
@@ -1,3 +1,57 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "pyfragment"
|
||||
version = "2026.2.0"
|
||||
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.10"
|
||||
authors = [{ name = "bohd4nx", url = "https://github.com/bohd4nx" }]
|
||||
keywords = ["fragment", "telegram", "ton", "stars", "premium", "crypto", "blockchain"]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Natural Language :: English",
|
||||
"Operating System :: OS Independent",
|
||||
"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 :: Python Modules",
|
||||
"Topic :: Internet",
|
||||
"Topic :: Office/Business :: Financial",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"httpx==0.28.1",
|
||||
"rookiepy==0.5.6",
|
||||
"tonutils==2.1.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest==9.0.3",
|
||||
"pytest-asyncio==1.3.0",
|
||||
"pytest-mock",
|
||||
"mypy",
|
||||
"ruff",
|
||||
"black",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/bohd4nx/pyfragment"
|
||||
Repository = "https://github.com/bohd4nx/pyfragment"
|
||||
Issues = "https://github.com/bohd4nx/pyfragment/issues"
|
||||
Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["pyfragment"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["[0-9][0-9][0-9]_test_*.py"]
|
||||
@@ -9,11 +63,14 @@ line-length = 128
|
||||
target-version = ["py312"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 128
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort
|
||||
select = ["E", "F", "W", "I"]
|
||||
# E501 — line too long (covered by line-length above)
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["E402"]
|
||||
"systests/*" = ["E402"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-e .[dev]
|
||||
+1
-4
@@ -1,4 +1 @@
|
||||
python-dotenv==1.2.2
|
||||
asyncio==4.0.0
|
||||
httpx==0.28.1
|
||||
tonutils[pytoniq]==2.0.0
|
||||
-e .
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Tests for clean_decode() — BOC-encoded Fragment payloads decode to
|
||||
human-readable UTF-8 with the Telegram label and Ref# intact."""
|
||||
"""Tests for clean_decode() — TON BOC payload decoding."""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from app.utils.decoder import clean_decode
|
||||
from pyfragment.types import ParseError
|
||||
from pyfragment.utils.decoder import clean_decode
|
||||
|
||||
PAYLOADS = [
|
||||
pytest.param(
|
||||
@@ -23,13 +23,24 @@ PAYLOADS = [
|
||||
]
|
||||
|
||||
|
||||
# Decode valid payload tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", PAYLOADS)
|
||||
def test_payload(payload: str) -> None:
|
||||
def test_decode_payload(payload: str) -> None:
|
||||
result = clean_decode(payload)
|
||||
assert "Telegram" in result
|
||||
assert re.search(r"Ref#[A-Za-z0-9]+", result), f"no Ref# in {result!r}"
|
||||
assert all(ord(c) <= 127 for c in result), f"non-ASCII chars in {result!r}"
|
||||
assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}"
|
||||
|
||||
|
||||
def test_empty_input_returns_string() -> None:
|
||||
assert isinstance(clean_decode(""), str)
|
||||
# Edge case tests
|
||||
|
||||
|
||||
def test_empty_payload_returns_empty_string() -> None:
|
||||
assert clean_decode("") == ""
|
||||
|
||||
|
||||
def test_invalid_payload_raises_parse_error() -> None:
|
||||
with pytest.raises(ParseError):
|
||||
clean_decode("!!!not-valid-base64!!!")
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Unit tests for FragmentClient — initialization, validation, and cookie parsing."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import ConfigurationError, CookieError
|
||||
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
||||
|
||||
# Client init tests
|
||||
|
||||
|
||||
def test_valid_init() -> None:
|
||||
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
assert client.seed == VALID_SEED.strip()
|
||||
assert client.api_key == VALID_API_KEY
|
||||
assert client.wallet_version == "V5R1"
|
||||
|
||||
|
||||
# Wallet version tests
|
||||
|
||||
|
||||
def test_wallet_version_v4r2() -> None:
|
||||
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V4R2")
|
||||
assert client.wallet_version == "V4R2"
|
||||
|
||||
|
||||
def test_wallet_version_is_case_insensitive() -> None:
|
||||
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="v5r1")
|
||||
assert client.wallet_version == "V5R1"
|
||||
|
||||
|
||||
def test_unsupported_wallet_version_raises() -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V3R2")
|
||||
|
||||
|
||||
# Seed and mnemonic validation tests
|
||||
|
||||
|
||||
def test_missing_seed_raises() -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
def test_whitespace_only_seed_raises() -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
def test_invalid_mnemonic_length_raises() -> None:
|
||||
bad_seed = " ".join(["word"] * 23)
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed=bad_seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
def test_valid_mnemonic_lengths() -> None:
|
||||
for length in (12, 18, 24):
|
||||
seed = " ".join(["abandon"] * (length - 1) + ["about"])
|
||||
client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
assert len(client.seed.split()) == length
|
||||
|
||||
|
||||
# API key validation tests
|
||||
|
||||
|
||||
def test_missing_api_key_raises() -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
def test_short_api_key_raises() -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
# Cookie validation tests
|
||||
|
||||
|
||||
def test_cookies_as_json_string() -> None:
|
||||
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=json.dumps(VALID_COOKIES))
|
||||
assert client.cookies == VALID_COOKIES
|
||||
|
||||
|
||||
def test_invalid_cookies_json_raises() -> None:
|
||||
with pytest.raises(CookieError):
|
||||
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies="{not valid json}")
|
||||
|
||||
|
||||
def test_missing_cookie_key_raises() -> None:
|
||||
with pytest.raises(CookieError):
|
||||
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies={"stel_ssid": "x"})
|
||||
|
||||
|
||||
def test_empty_cookie_value_raises() -> None:
|
||||
bad = {**VALID_COOKIES, "stel_token": ""}
|
||||
with pytest.raises(CookieError):
|
||||
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad)
|
||||
|
||||
|
||||
def test_whitespace_cookie_value_raises() -> None:
|
||||
bad = {**VALID_COOKIES, "stel_ton_token": " "}
|
||||
with pytest.raises(CookieError):
|
||||
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad)
|
||||
|
||||
|
||||
def test_repr() -> None:
|
||||
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
r = repr(client)
|
||||
assert "FragmentClient" in r
|
||||
assert "V5R1" in r
|
||||
assert "4 keys" in r
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_context_manager() -> None:
|
||||
async with FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) as client:
|
||||
assert isinstance(client, FragmentClient)
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Tests for get_fragment_hash() — fetches a valid lowercase hex hash
|
||||
from the fragment.com/stars/buy page source."""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.constants import BASE_HEADERS, STARS_PAGE
|
||||
from app.utils.hash 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}"
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Unit tests for process_transaction() — balance validation and broadcast retry logic."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from tonutils.exceptions import ProviderResponseError
|
||||
|
||||
from pyfragment.types import TransactionError, WalletError
|
||||
from pyfragment.utils.wallet import process_transaction
|
||||
from tests.shared import VALID_SEED
|
||||
|
||||
|
||||
def _provider_error(code: int, message: str = "error") -> ProviderResponseError:
|
||||
return ProviderResponseError(code=code, message=message, endpoint="api.tonapi.io")
|
||||
|
||||
|
||||
TRANSACTION_DATA = {
|
||||
"transaction": {
|
||||
"messages": [
|
||||
{
|
||||
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
|
||||
"amount": "500000000", # 0.5 TON
|
||||
"payload": "",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _make_client() -> MagicMock:
|
||||
client = MagicMock()
|
||||
client.api_key = "test_key"
|
||||
client.seed = VALID_SEED.split()
|
||||
client.wallet_version = "V5R1"
|
||||
return client
|
||||
|
||||
|
||||
def _make_wallet(balance_nanotons: int) -> MagicMock:
|
||||
wallet = MagicMock()
|
||||
wallet.refresh = AsyncMock()
|
||||
wallet.balance = balance_nanotons
|
||||
wallet.transfer = AsyncMock(return_value=MagicMock(normalized_hash="abc123"))
|
||||
return wallet
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch_wallet(wallet: MagicMock):
|
||||
with (
|
||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
||||
):
|
||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
|
||||
yield
|
||||
|
||||
|
||||
# Balance threshold tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sufficient_balance_broadcasts() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, needs 0.556 TON
|
||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"):
|
||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
assert result == "abc123"
|
||||
wallet.transfer.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insufficient_balance_raises() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, needs 0.556 TON
|
||||
with _patch_wallet(wallet):
|
||||
with pytest.raises(WalletError, match="required"):
|
||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
wallet.transfer.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_minimum_balance_broadcasts() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=556_000_000) # exactly 0.5 + 0.056 TON
|
||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"):
|
||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
assert result == "abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_nanoton_below_minimum_raises() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=555_999_999) # 1 nanoton below threshold
|
||||
with _patch_wallet(wallet):
|
||||
with pytest.raises(WalletError, match="required"):
|
||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
|
||||
|
||||
# Error handling tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_payload_raises() -> None:
|
||||
with pytest.raises(TransactionError):
|
||||
await process_transaction(_make_client(), {"transaction": {}})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_balance_check_failed_raises_wallet_error() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||
wallet.refresh = AsyncMock(side_effect=RuntimeError("network timeout"))
|
||||
with _patch_wallet(wallet):
|
||||
with pytest.raises(WalletError, match="balance"):
|
||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
wallet.transfer.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_retries_and_succeeds() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
|
||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""):
|
||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
assert result == "abc123"
|
||||
assert wallet.transfer.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_seqno_raises_after_retries() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||
err = _provider_error(406, "Duplicate msg_seqno")
|
||||
wallet.transfer = AsyncMock(side_effect=[err, err, err])
|
||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""):
|
||||
with pytest.raises(TransactionError, match="seqno"):
|
||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
assert wallet.transfer.call_count == 3
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Unit tests for Stars methods — purchase_stars and giveaway_stars."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_purchase_stars_mod = importlib.import_module("pyfragment.methods.purchase_stars")
|
||||
_giveaway_stars_mod = importlib.import_module("pyfragment.methods.giveaway_stars")
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import ConfigurationError, StarsGiveawayResult, StarsResult, UserNotFoundError
|
||||
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||
|
||||
# Stars purchase validation tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.purchase_stars("@user", amount=49)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.purchase_stars("@user", amount=1_000_001)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_stars_float_amount(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# Stars purchase mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_stars_success(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
FAKE_TRANSACTION,
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch.object(_purchase_stars_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||
):
|
||||
result = await client.purchase_stars("@user", amount=500)
|
||||
|
||||
assert isinstance(result, StarsResult)
|
||||
assert result.transaction_id == FAKE_TX_HASH
|
||||
assert result.username == "@user"
|
||||
assert result.amount == 500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||
with pytest.raises(UserNotFoundError):
|
||||
await client.purchase_stars("@ghost", amount=500)
|
||||
|
||||
|
||||
# Stars giveaway validation tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_stars_winners_too_low(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_stars("@channel", winners=0, amount=500)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_stars_winners_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_stars("@channel", winners=6, amount=500)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_stars_amount_too_low(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_stars("@channel", winners=1, amount=499)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_stars("@channel", winners=1, amount=1_000_001)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_stars_float_winners(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_stars("@channel", winners=1.5, amount=500) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_stars_float_amount(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_stars("@channel", winners=1, amount=500.5) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# Stars giveaway mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_stars_success(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
FAKE_TRANSACTION,
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch.object(_giveaway_stars_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||
):
|
||||
result = await client.giveaway_stars("@channel", winners=3, amount=1000)
|
||||
|
||||
assert isinstance(result, StarsGiveawayResult)
|
||||
assert result.transaction_id == FAKE_TX_HASH
|
||||
assert result.channel == "@channel"
|
||||
assert result.winners == 3
|
||||
assert result.amount == 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||
with pytest.raises(UserNotFoundError):
|
||||
await client.giveaway_stars("@ghost", winners=1, amount=500)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Unit tests for Premium methods — purchase_premium and giveaway_premium."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_purchase_premium_mod = importlib.import_module("pyfragment.methods.purchase_premium")
|
||||
_giveaway_premium_mod = importlib.import_module("pyfragment.methods.giveaway_premium")
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import ConfigurationError, PremiumGiveawayResult, PremiumResult, UserNotFoundError
|
||||
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||
|
||||
# Premium purchase validation tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_premium_invalid_months(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.purchase_premium("@user", months=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_premium_months_zero(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.purchase_premium("@user", months=0)
|
||||
|
||||
|
||||
# Premium purchase mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_premium_success(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{}, # updatePremiumState
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
FAKE_TRANSACTION,
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch.object(_purchase_premium_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||
):
|
||||
result = await client.purchase_premium("@user", months=3)
|
||||
|
||||
assert isinstance(result, PremiumResult)
|
||||
assert result.transaction_id == FAKE_TX_HASH
|
||||
assert result.username == "@user"
|
||||
assert result.amount == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||
with pytest.raises(UserNotFoundError):
|
||||
await client.purchase_premium("@ghost", months=3)
|
||||
|
||||
|
||||
# Premium giveaway validation tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_premium_winners_too_low(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_premium("@channel", winners=0, months=3)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_premium("@channel", winners=24_001, months=3)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_premium_float_winners(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_premium("@channel", winners=2.5, months=3) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_premium_invalid_months(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_premium("@channel", winners=10, months=5)
|
||||
|
||||
|
||||
# Premium giveaway mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_premium_success(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
FAKE_TRANSACTION,
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch.object(_giveaway_premium_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||
):
|
||||
result = await client.giveaway_premium("@channel", winners=10, months=3)
|
||||
|
||||
assert isinstance(result, PremiumGiveawayResult)
|
||||
assert result.transaction_id == FAKE_TX_HASH
|
||||
assert result.channel == "@channel"
|
||||
assert result.winners == 10
|
||||
assert result.amount == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||
with pytest.raises(UserNotFoundError):
|
||||
await client.giveaway_premium("@ghost", winners=1, months=3)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Unit tests for topup_ton — TON Ads balance top-up."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_topup_ton_mod = importlib.import_module("pyfragment.methods.topup_ton")
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import AdsTopupResult, ConfigurationError, UserNotFoundError
|
||||
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||
|
||||
# Topup TON validation tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_amount_zero(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.topup_ton("@user", amount=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_amount_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.topup_ton("@user", amount=1_000_000_001)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_float_amount(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.topup_ton("@user", amount=1.5) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# Topup TON mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_success(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{}, # updateAdsTopupState
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
FAKE_TRANSACTION,
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_topup_ton_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch.object(_topup_ton_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||
):
|
||||
result = await client.topup_ton("@user", amount=10)
|
||||
|
||||
assert isinstance(result, AdsTopupResult)
|
||||
assert result.transaction_id == FAKE_TX_HASH
|
||||
assert result.username == "@user"
|
||||
assert result.amount == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
|
||||
with patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{}, # updateAdsTopupState
|
||||
{"found": {}},
|
||||
]
|
||||
),
|
||||
):
|
||||
with pytest.raises(UserNotFoundError):
|
||||
await client.topup_ton("@ghost", amount=10)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Unit tests for get_wallet() — wallet address and TON balance lookup."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient, WalletInfo
|
||||
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON
|
||||
|
||||
# Wallet mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
|
||||
mock_wallet = MagicMock()
|
||||
mock_wallet.refresh = AsyncMock()
|
||||
mock_wallet.balance = FAKE_BALANCE_NANOTON
|
||||
mock_wallet.state = MagicMock(value="active")
|
||||
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||
|
||||
with (
|
||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
||||
):
|
||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
|
||||
|
||||
result = await client.get_wallet()
|
||||
|
||||
assert isinstance(result, WalletInfo)
|
||||
assert result.address == FAKE_ADDRESS
|
||||
assert result.state == "active"
|
||||
assert result.balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
|
||||
mock_wallet = MagicMock()
|
||||
mock_wallet.refresh = AsyncMock()
|
||||
mock_wallet.balance = 0
|
||||
mock_wallet.state = MagicMock(value="uninit")
|
||||
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||
|
||||
with (
|
||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
||||
):
|
||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
|
||||
|
||||
result = await client.get_wallet()
|
||||
|
||||
assert result.balance == 0.0
|
||||
assert result.state == "uninit"
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Unit tests for FragmentClient.call() — raw Fragment API access."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from tests.shared import FAKE_HASH, FAKE_RESPONSE
|
||||
|
||||
# client.call() mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_returns_api_response(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
||||
):
|
||||
result = await client.call("anyMethod", {"key": "value"})
|
||||
|
||||
assert result == FAKE_RESPONSE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_default_page_url(client: FragmentClient) -> None:
|
||||
"""call() works without explicitly passing page_url (defaults to FRAGMENT_BASE_URL)."""
|
||||
with (
|
||||
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
||||
):
|
||||
result = await client.call("anyMethod")
|
||||
|
||||
assert result == FAKE_RESPONSE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_no_data(client: FragmentClient) -> None:
|
||||
"""call() with no extra data passes only the method field."""
|
||||
mock_request = AsyncMock(return_value={})
|
||||
|
||||
with (
|
||||
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.client.fragment_request", mock_request),
|
||||
):
|
||||
await client.call("anyMethod")
|
||||
|
||||
_, _, _, sent_data = mock_request.call_args.args
|
||||
assert sent_data == {"method": "anyMethod"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
||||
"""call() merges caller-supplied data with the method field."""
|
||||
mock_request = AsyncMock(return_value={})
|
||||
|
||||
with (
|
||||
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.client.fragment_request", mock_request),
|
||||
):
|
||||
await client.call("anyMethod", {"key": "value", "num": 7})
|
||||
|
||||
_, _, _, sent_data = mock_request.call_args.args
|
||||
assert sent_data == {"method": "anyMethod", "key": "value", "num": 7}
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Unit tests for anonymous number methods — login codes and session management."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import AnonymousNumberError, FragmentClient, LoginCodeResult, TerminateSessionsResult
|
||||
from tests.shared import FAKE_HTML_NO_CODE, FAKE_HTML_WITH_CODE, FAKE_TERMINATE_HASH
|
||||
|
||||
# get_login_code mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_code_returns_code(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"html": FAKE_HTML_WITH_CODE})):
|
||||
result = await client.get_login_code("+1234567890")
|
||||
|
||||
assert isinstance(result, LoginCodeResult)
|
||||
assert result.number == "+1234567890"
|
||||
assert result.code == "12345"
|
||||
assert result.active_sessions == 2 # 2 <tr> in the HTML
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_code_no_pending_code(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"html": FAKE_HTML_NO_CODE})):
|
||||
result = await client.get_login_code("1234567890")
|
||||
|
||||
assert result.code is None
|
||||
assert result.active_sessions == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_code_no_html_returns_none(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={})):
|
||||
result = await client.get_login_code("+1234567890")
|
||||
|
||||
assert result.code is None
|
||||
assert result.active_sessions == 0
|
||||
|
||||
|
||||
# terminate_sessions mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminate_sessions_success(client: FragmentClient) -> None:
|
||||
with patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"terminate_hash": FAKE_TERMINATE_HASH}, # step 1: confirmation
|
||||
{"msg": "All sessions terminated"}, # step 2: confirmed
|
||||
]
|
||||
),
|
||||
):
|
||||
result = await client.terminate_sessions("+1234567890")
|
||||
|
||||
assert isinstance(result, TerminateSessionsResult)
|
||||
assert result.number == "+1234567890"
|
||||
assert result.message == "All sessions terminated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminate_sessions_not_owned_raises(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={})):
|
||||
with pytest.raises(AnonymousNumberError, match="not associated"):
|
||||
await client.terminate_sessions("+1234567890")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminate_sessions_api_error_raises(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"error": "SESSION_ALREADY_TERMINATED"})):
|
||||
with pytest.raises(AnonymousNumberError, match="SESSION_ALREADY_TERMINATED"):
|
||||
await client.terminate_sessions("+1234567890")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminate_sessions_confirm_error_raises(client: FragmentClient) -> None:
|
||||
with patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"terminate_hash": FAKE_TERMINATE_HASH},
|
||||
{"error": "INTERNAL_ERROR"},
|
||||
]
|
||||
),
|
||||
):
|
||||
with pytest.raises(AnonymousNumberError, match="INTERNAL_ERROR"):
|
||||
await client.terminate_sessions("+1234567890")
|
||||
|
||||
|
||||
# toggle_login_codes mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_login_codes_enable(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True})
|
||||
with patch.object(client, "call", mock_call):
|
||||
await client.toggle_login_codes("+1234567890", can_receive=True)
|
||||
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["can_receive"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_login_codes_disable(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True})
|
||||
with patch.object(client, "call", mock_call):
|
||||
await client.toggle_login_codes("+1234567890", can_receive=False)
|
||||
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["can_receive"] == 0
|
||||
|
||||
|
||||
# strip_plus mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_login_code_strips_plus(client: FragmentClient) -> None:
|
||||
"""Number passed with '+' is stripped before the API call."""
|
||||
mock_call = AsyncMock(return_value={})
|
||||
with patch.object(client, "call", mock_call):
|
||||
await client.get_login_code("+1234567890")
|
||||
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["number"] == "1234567890"
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Unit tests for recharge_ads — self-service Telegram Ads recharge."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_recharge_ads_mod = importlib.import_module("pyfragment.methods.recharge_ads")
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import AdsRechargeResult, ConfigurationError
|
||||
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||
|
||||
# recharge_ads validation tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_amount_zero(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=1_000_000_001)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_float_amount(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=5.5) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# recharge_ads mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_success(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{}, # updateAdsState
|
||||
{"req_id": FAKE_REQ_ID}, # initAdsRechargeRequest
|
||||
FAKE_TRANSACTION, # getAdsRechargeLink
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch.object(_recharge_ads_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||
):
|
||||
result = await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
|
||||
|
||||
assert isinstance(result, AdsRechargeResult)
|
||||
assert result.transaction_id == FAKE_TX_HASH
|
||||
assert result.amount == 10
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Unit tests for search_usernames — Fragment marketplace username search."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import UsernamesResult
|
||||
|
||||
FAKE_HTML = """
|
||||
<tr class="tm-row-selectable">
|
||||
<td><a href="/username/coolname" class="table-cell">
|
||||
<div class="table-cell-value tm-value">@coolname</div>
|
||||
<div class="table-cell-status-thin thin-only tm-status-avail">On auction</div>
|
||||
</a></td>
|
||||
<td><div class="table-cell-value tm-value icon-before icon-ton">5</div>
|
||||
<time datetime="2026-06-01T12:00:00+00:00" data-relative="text">2 days</time>
|
||||
</td>
|
||||
<td><div class="table-cell-value tm-value tm-status-avail">On auction</div></td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
|
||||
# search_usernames result parsing tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_usernames_basic(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})):
|
||||
result = await client.search_usernames("coolname")
|
||||
|
||||
assert isinstance(result, UsernamesResult)
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0]["slug"] == "username/coolname"
|
||||
assert result.items[0]["name"] == "@coolname"
|
||||
assert result.items[0]["date"] == "2026-06-01T12:00:00+00:00"
|
||||
assert result.next_offset_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_usernames_empty_html(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
|
||||
result = await client.search_usernames("zzz_no_results")
|
||||
|
||||
assert isinstance(result, UsernamesResult)
|
||||
assert result.items == []
|
||||
assert result.next_offset_id is None
|
||||
|
||||
|
||||
# search_usernames parameter forwarding tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_usernames_with_sort_and_filter(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_usernames("durov", sort="price_desc", filter="auction")
|
||||
|
||||
assert isinstance(result, UsernamesResult)
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["type"] == "usernames"
|
||||
assert call_data["sort"] == "price_desc"
|
||||
assert call_data["filter"] == "auction"
|
||||
assert call_data["query"] == "durov"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_usernames_with_offset_id(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_usernames("durov", offset_id="offset_10")
|
||||
|
||||
assert isinstance(result, UsernamesResult)
|
||||
assert result.next_offset_id == "offset_99"
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["offset_id"] == "offset_10"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_usernames_default_query(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_usernames()
|
||||
|
||||
assert isinstance(result, UsernamesResult)
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["query"] == ""
|
||||
assert call_data["type"] == "usernames"
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Unit tests for search_numbers — Fragment marketplace number search."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import NumbersResult
|
||||
|
||||
FAKE_HTML = """
|
||||
<tr class="tm-row-selectable">
|
||||
<td><a href="/number/8880000888" class="table-cell">
|
||||
<div class="table-cell-value tm-value">+888 0000 888</div>
|
||||
<div class="table-cell-status-thin thin-only tm-status-avail">For sale</div>
|
||||
</a></td>
|
||||
<td><div class="table-cell-value tm-value icon-before icon-ton">150</div>
|
||||
<time datetime="2026-05-15T10:00:00+00:00" data-relative="short-text">May 15</time>
|
||||
</td>
|
||||
<td><div class="table-cell-value tm-value tm-status-avail">For sale</div></td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
|
||||
# search_numbers result parsing tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_numbers_basic(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})):
|
||||
result = await client.search_numbers("888")
|
||||
|
||||
assert isinstance(result, NumbersResult)
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0]["slug"] == "number/8880000888"
|
||||
assert result.items[0]["name"] == "+888 0000 888"
|
||||
assert result.items[0]["date"] == "2026-05-15T10:00:00+00:00"
|
||||
assert result.next_offset_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_numbers_empty_html(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
|
||||
result = await client.search_numbers("zzz_no_results")
|
||||
|
||||
assert isinstance(result, NumbersResult)
|
||||
assert result.items == []
|
||||
assert result.next_offset_id is None
|
||||
|
||||
|
||||
# search_numbers parameter forwarding tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_numbers_with_sort_and_filter(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_numbers("888", sort="price_asc", filter="sale")
|
||||
|
||||
assert isinstance(result, NumbersResult)
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["type"] == "numbers"
|
||||
assert call_data["sort"] == "price_asc"
|
||||
assert call_data["filter"] == "sale"
|
||||
assert call_data["query"] == "888"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_numbers_with_offset_id(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_50"})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_numbers("888", offset_id="offset_50")
|
||||
|
||||
assert isinstance(result, NumbersResult)
|
||||
assert result.next_offset_id == "offset_50"
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["offset_id"] == "offset_50"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_numbers_default_query(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_numbers()
|
||||
|
||||
assert isinstance(result, NumbersResult)
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["query"] == ""
|
||||
assert call_data["type"] == "numbers"
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Unit tests for search_gifts — Fragment gifts marketplace search."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import GiftsResult
|
||||
|
||||
FAKE_GIFTS_HTML = """
|
||||
<div class="tm-catalog-grid">
|
||||
<a href="/gift/plushpepe-1821?collection=all" class="tm-grid-item">
|
||||
<div class="tm-grid-item-thumb">
|
||||
<img src="https://nft.fragment.com/gift/plushpepe-1821.medium.jpg" class="tm-grid-thumb"/>
|
||||
</div>
|
||||
<div class="tm-grid-item-content">
|
||||
<div class="tm-grid-item-name wide-only">
|
||||
<span class="item-name">Plush Pepe</span>
|
||||
<span class="item-num"> #1821</span>
|
||||
</div>
|
||||
<div class="tm-grid-item-desc wide-only">
|
||||
<time datetime="2026-02-05T14:41:27+00:00" class="short">Feb 5 at 16:41</time>
|
||||
</div>
|
||||
<div class="tm-grid-item-values">
|
||||
<div class="tm-grid-item-value tm-value icon-before icon-ton">88,888</div>
|
||||
<div class="tm-grid-item-status tm-status-unavail">Sold</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/gift/swisswatch-7799?collection=all" class="tm-grid-item">
|
||||
<div class="tm-grid-item-content">
|
||||
<div class="tm-grid-item-name wide-only">
|
||||
<span class="item-name">Swiss Watch</span>
|
||||
<span class="item-num"> #7799</span>
|
||||
</div>
|
||||
<div class="tm-grid-item-desc wide-only">
|
||||
<time datetime="2026-01-10T04:52:59+00:00" class="short">Jan 10 at 06:52</time>
|
||||
</div>
|
||||
<div class="tm-grid-item-values">
|
||||
<div class="tm-grid-item-value tm-value icon-before icon-ton">13,588</div>
|
||||
<div class="tm-grid-item-status tm-status-unavail">Sold</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="tm-catalog-grid-more js-load-more" data-next-offset="60">Show more</a>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
# search_gifts result parsing tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_gifts_basic(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})):
|
||||
result = await client.search_gifts()
|
||||
|
||||
assert isinstance(result, GiftsResult)
|
||||
assert len(result.items) == 2
|
||||
assert result.items[0]["slug"] == "gift/plushpepe-1821"
|
||||
assert result.items[0]["name"] == "Plush Pepe #1821"
|
||||
assert result.items[0]["status"] == "Sold"
|
||||
assert result.items[0]["price"] == "88888.00"
|
||||
assert result.items[0]["date"] == "2026-02-05T14:41:27+00:00"
|
||||
assert result.items[1]["slug"] == "gift/swisswatch-7799"
|
||||
assert result.items[1]["price"] == "13588.00"
|
||||
assert result.next_offset == 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_gifts_empty(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
|
||||
result = await client.search_gifts(query="zzz_no_results")
|
||||
|
||||
assert isinstance(result, GiftsResult)
|
||||
assert result.items == []
|
||||
assert result.next_offset is None
|
||||
|
||||
|
||||
# search_gifts parameter forwarding tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_gifts_with_collection_and_sort(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_gifts(collection="plushpepe", sort="price_desc", filter="sold")
|
||||
|
||||
assert isinstance(result, GiftsResult)
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["collection"] == "plushpepe"
|
||||
assert call_data["sort"] == "price_desc"
|
||||
assert call_data["filter"] == "sold"
|
||||
assert call_data["type"] == "gifts"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_gifts_with_offset(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_gifts(offset=60)
|
||||
|
||||
assert isinstance(result, GiftsResult)
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["offset"] == 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_gifts_with_view(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_gifts(collection="artisanbrick", view="Model")
|
||||
|
||||
assert isinstance(result, GiftsResult)
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["view"] == "Model"
|
||||
assert call_data["collection"] == "artisanbrick"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_gifts_with_attr(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_gifts(
|
||||
collection="artisanbrick",
|
||||
sort="listed",
|
||||
filter="auction",
|
||||
attr={
|
||||
"Model": ["Delicate Wash", "Foosball", "Chocolate"],
|
||||
"Backdrop": ["Celtic Blue", "Carrot Juice", "Orange"],
|
||||
"Symbol": ["Crystal Ball", "Tetsubin", "Acorn"],
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(result, GiftsResult)
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert call_data["attr[Model]"] == ["Delicate Wash", "Foosball", "Chocolate"]
|
||||
assert call_data["attr[Backdrop]"] == ["Celtic Blue", "Carrot Juice", "Orange"]
|
||||
assert call_data["attr[Symbol]"] == ["Crystal Ball", "Tetsubin", "Acorn"]
|
||||
assert call_data["collection"] == "artisanbrick"
|
||||
assert call_data["sort"] == "listed"
|
||||
assert call_data["filter"] == "auction"
|
||||
assert call_data["type"] == "gifts"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_gifts_attr_not_in_data_when_none(client: FragmentClient) -> None:
|
||||
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
|
||||
with patch.object(client, "call", mock_call):
|
||||
result = await client.search_gifts()
|
||||
|
||||
assert isinstance(result, GiftsResult)
|
||||
call_data = mock_call.call_args[0][1]
|
||||
assert "view" not in call_data
|
||||
assert not any(k.startswith("attr[") for k in call_data)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""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] | 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")
|
||||
|
||||
|
||||
# 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")
|
||||
+23
-5
@@ -1,13 +1,31 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.cookies import load_cookies
|
||||
from app.core.exceptions import CookiesError
|
||||
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():
|
||||
"""Load Fragment cookies; skip the test if they are unavailable."""
|
||||
"""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 load_cookies()
|
||||
except CookiesError as exc:
|
||||
return json.loads(raw)
|
||||
except Exception as exc:
|
||||
pytest.skip(f"Cookies unavailable — {exc}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> FragmentClient:
|
||||
"""Pre-built FragmentClient with dummy credentials."""
|
||||
return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Shared test constants for the pyfragment test suite.
|
||||
|
||||
pyfragment is an async Python client for the Fragment API — a unified toolkit
|
||||
to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances,
|
||||
run giveaways, manage anonymous numbers, and explore the marketplace for usernames,
|
||||
numbers, and gifts.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Credentials and config
|
||||
VALID_SEED: str = "abandon " * 23 + "about"
|
||||
VALID_API_KEY: str = "A" * 68
|
||||
VALID_COOKIES: dict[str, str] = {
|
||||
"stel_ssid": "x",
|
||||
"stel_dt": "x",
|
||||
"stel_token": "x",
|
||||
"stel_ton_token": "x",
|
||||
}
|
||||
|
||||
# Generic test data
|
||||
FAKE_HASH: str = "abc123"
|
||||
FAKE_RECIPIENT: str = "recipient_token"
|
||||
FAKE_REQ_ID: str = "req_42"
|
||||
FAKE_TX_HASH: str = "deadbeef" * 8
|
||||
FAKE_ACCOUNT: dict[str, Any] = {"address": "0:abc", "publicKey": "pub", "chain": "-239", "walletStateInit": "base64=="}
|
||||
FAKE_TRANSACTION: dict[str, Any] = {"transaction": {"messages": [{"address": "0:abc", "amount": "100000000", "payload": ""}]}}
|
||||
|
||||
# client.call()
|
||||
FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}}
|
||||
|
||||
# get_wallet()
|
||||
FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5"
|
||||
FAKE_BALANCE_NANOTON: int = 1_500_000_000 # 1.5 TON
|
||||
|
||||
# recharge_ads
|
||||
FAKE_ADS_ACCOUNT: str = "@mychannel"
|
||||
|
||||
# Revenue withdrawals
|
||||
FAKE_WITHDRAWAL_WALLET: str = "EQDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
FAKE_REVENUE_TX: str = "revenue_tx_abc123"
|
||||
|
||||
# Anonymous number
|
||||
FAKE_HTML_WITH_CODE: str = """
|
||||
<table>
|
||||
<tr>
|
||||
<td class="table-cell-value">12345</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>session data</td>
|
||||
</tr>
|
||||
</table>
|
||||
"""
|
||||
FAKE_HTML_NO_CODE: str = "<table><tr><td>no code here</td></tr></table>"
|
||||
FAKE_TERMINATE_HASH: str = "terminate_hash_abc123"
|
||||
Reference in New Issue
Block a user