mirror of
https://github.com/vibe-existing/pyfragment.git
synced 2026-07-25 06:54:31 +00:00
chore: merge dev into master for v2026.1.0
This commit is contained in:
@@ -7,6 +7,51 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
## [2026.0.2] — 2026-03-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -38,5 +83,6 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
|||||||
- `py.typed` marker — full PEP 561 typing support for type-checkers
|
- `py.typed` marker — full PEP 561 typing support for type-checkers
|
||||||
- `__repr__` on all result types for readable debug output
|
- `__repr__` on all result types for readable debug output
|
||||||
|
|
||||||
|
[2026.1.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.1.0
|
||||||
[2026.0.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.2
|
[2026.0.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.2
|
||||||
[2026.0.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.1
|
[2026.0.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<h1 style="margin-top: 24px;">Fragment API</h1>
|
<h1 style="margin-top: 24px;">Fragment API</h1>
|
||||||
|
|
||||||
<p style="font-size: 18px; margin-bottom: 24px;">
|
<p style="font-size: 18px; margin-bottom: 24px;">
|
||||||
<b>Python library for the Fragment.com API — purchase Telegram Stars, Premium, and top up TON Ads balance.</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>
|
</p>
|
||||||
|
|
||||||
[](https://pypi.org/project/pyfragment/)
|
[](https://pypi.org/project/pyfragment/)
|
||||||
@@ -90,11 +90,11 @@ async def main() -> None:
|
|||||||
try:
|
try:
|
||||||
# Purchase 6 months of Telegram Premium
|
# Purchase 6 months of Telegram Premium
|
||||||
result = await client.purchase_premium("@username", months=6)
|
result = await client.purchase_premium("@username", months=6)
|
||||||
print(f"{result.months} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
|
print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||||
|
|
||||||
# Purchase 500 Stars
|
# Purchase 500 Stars
|
||||||
result = await client.purchase_stars("@username", amount=500)
|
result = await client.purchase_stars("@username", amount=500)
|
||||||
print(f"{result.stars} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
|
print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||||
|
|
||||||
# Top up 10 TON to Telegram balance
|
# Top up 10 TON to Telegram balance
|
||||||
# wallet must hold at least amount + ~0.056 TON for gas
|
# wallet must hold at least amount + ~0.056 TON for gas
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
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,40 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
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,40 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
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,37 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
COOKIES = {
|
||||||
|
"stel_ssid": "YOUR_STEL_SSID",
|
||||||
|
"stel_dt": "YOUR_STEL_DT",
|
||||||
|
"stel_token": "YOUR_STEL_TOKEN",
|
||||||
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
METHOD = "anyFragmentMethod" # replace with the actual method name
|
||||||
|
DATA = {"key": "value"} # replace with the actual request payload
|
||||||
|
PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragment page (optional)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
|
result = await client.call(METHOD, DATA, page_url=PAGE_URL)
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Example: 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
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
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,45 @@
|
|||||||
|
"""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
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,43 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
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,43 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
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())
|
||||||
@@ -19,7 +19,7 @@ COOKIES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
USERNAME = "@username"
|
USERNAME = "@username"
|
||||||
MONTHS = 3 # 3, 6, or 12
|
MONTHS = 3 # 3, 6 or 12
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
@@ -33,7 +33,7 @@ async def main() -> None:
|
|||||||
print(f"Invalid argument: {e}")
|
print(f"Invalid argument: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"{result.months} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
|
print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -19,7 +19,7 @@ COOKIES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
USERNAME = "@username"
|
USERNAME = "@username"
|
||||||
AMOUNT = 500 # 50–1 000 000
|
AMOUNT = 500 # 50–1 000 000 stars
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
@@ -33,7 +33,7 @@ async def main() -> None:
|
|||||||
print(f"Invalid argument: {e}")
|
print(f"Invalid argument: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"{result.stars} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
|
print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Example: top up a Telegram Ads account with TON.
|
Example: Topup ton to recipient's Telegram balance.
|
||||||
|
|
||||||
|
For adding TON to Telegram Ads account, use recharge_ads() instead.
|
||||||
|
|
||||||
Amount must be an integer between 1 and 1 000 000 000 TON.
|
Amount must be an integer between 1 and 1 000 000 000 TON.
|
||||||
Your wallet must hold at least the topup amount + ~0.056 TON for gas.
|
Your wallet must hold at least the topup amount + ~0.056 TON for gas.
|
||||||
@@ -7,7 +9,12 @@ Your wallet must hold at least the topup amount + ~0.056 TON for gas.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError, WalletError
|
from pyfragment import (
|
||||||
|
ConfigurationError,
|
||||||
|
FragmentClient,
|
||||||
|
UserNotFoundError,
|
||||||
|
WalletError,
|
||||||
|
)
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
SEED = "word1 word2 ... word24"
|
||||||
API_KEY = "YOUR_TONAPI_KEY"
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
@@ -19,7 +26,7 @@ COOKIES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
USERNAME = "@username"
|
USERNAME = "@username"
|
||||||
AMOUNT = 10 # TON, integer — 1–1 000 000 000
|
AMOUNT = 10 # 1–1 000 000 000 TON
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
@@ -36,7 +43,7 @@ async def main() -> None:
|
|||||||
print(f"Invalid argument: {e}")
|
print(f"Invalid argument: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}")
|
print(f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__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 |
@@ -7,19 +7,28 @@ from importlib.metadata import version
|
|||||||
|
|
||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
from pyfragment.types import (
|
from pyfragment.types import (
|
||||||
|
AdsRechargeResult,
|
||||||
AdsTopupResult,
|
AdsTopupResult,
|
||||||
|
AnonymousNumberError,
|
||||||
ClientError,
|
ClientError,
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
CookieError,
|
CookieError,
|
||||||
FragmentAPIError,
|
FragmentAPIError,
|
||||||
FragmentError,
|
FragmentError,
|
||||||
FragmentPageError,
|
FragmentPageError,
|
||||||
|
GiftsResult,
|
||||||
|
LoginCodeResult,
|
||||||
|
NumbersResult,
|
||||||
OperationError,
|
OperationError,
|
||||||
ParseError,
|
ParseError,
|
||||||
|
PremiumGiveawayResult,
|
||||||
PremiumResult,
|
PremiumResult,
|
||||||
|
StarsGiveawayResult,
|
||||||
StarsResult,
|
StarsResult,
|
||||||
|
TerminateSessionsResult,
|
||||||
TransactionError,
|
TransactionError,
|
||||||
UnexpectedError,
|
UnexpectedError,
|
||||||
|
UsernamesResult,
|
||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
VerificationError,
|
VerificationError,
|
||||||
WalletError,
|
WalletError,
|
||||||
@@ -31,9 +40,17 @@ __version__: str = version("pyfragment")
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"__version__",
|
"__version__",
|
||||||
"FragmentClient",
|
"FragmentClient",
|
||||||
|
"AdsRechargeResult",
|
||||||
"AdsTopupResult",
|
"AdsTopupResult",
|
||||||
|
"GiftsResult",
|
||||||
|
"LoginCodeResult",
|
||||||
|
"NumbersResult",
|
||||||
|
"PremiumGiveawayResult",
|
||||||
"PremiumResult",
|
"PremiumResult",
|
||||||
|
"StarsGiveawayResult",
|
||||||
"StarsResult",
|
"StarsResult",
|
||||||
|
"TerminateSessionsResult",
|
||||||
|
"UsernamesResult",
|
||||||
"WalletInfo",
|
"WalletInfo",
|
||||||
"ClientError",
|
"ClientError",
|
||||||
"ConfigurationError",
|
"ConfigurationError",
|
||||||
@@ -41,6 +58,7 @@ __all__ = [
|
|||||||
"FragmentAPIError",
|
"FragmentAPIError",
|
||||||
"FragmentError",
|
"FragmentError",
|
||||||
"FragmentPageError",
|
"FragmentPageError",
|
||||||
|
"AnonymousNumberError",
|
||||||
"OperationError",
|
"OperationError",
|
||||||
"ParseError",
|
"ParseError",
|
||||||
"TransactionError",
|
"TransactionError",
|
||||||
|
|||||||
+232
-11
@@ -1,18 +1,41 @@
|
|||||||
import json
|
import json
|
||||||
from typing import cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from pyfragment.methods.premium import purchase_premium
|
import httpx
|
||||||
from pyfragment.methods.stars import purchase_stars
|
|
||||||
from pyfragment.methods.ton import topup_ton
|
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 (
|
from pyfragment.types import (
|
||||||
|
AdsRechargeResult,
|
||||||
AdsTopupResult,
|
AdsTopupResult,
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
CookieError,
|
CookieError,
|
||||||
|
GiftsResult,
|
||||||
|
LoginCodeResult,
|
||||||
|
NumbersResult,
|
||||||
PremiumResult,
|
PremiumResult,
|
||||||
StarsResult,
|
StarsResult,
|
||||||
|
TerminateSessionsResult,
|
||||||
|
UsernamesResult,
|
||||||
WalletInfo,
|
WalletInfo,
|
||||||
)
|
)
|
||||||
from pyfragment.types.constants import DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS, SUPPORTED_WALLET_VERSIONS, WalletVersion
|
from pyfragment.types.constants import (
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
FRAGMENT_BASE_URL,
|
||||||
|
REQUIRED_COOKIE_KEYS,
|
||||||
|
SUPPORTED_WALLET_VERSIONS,
|
||||||
|
WalletVersion,
|
||||||
|
)
|
||||||
|
from pyfragment.types.results import PremiumGiveawayResult, StarsGiveawayResult
|
||||||
|
from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers
|
||||||
from pyfragment.utils.wallet import get_wallet_info
|
from pyfragment.utils.wallet import get_wallet_info
|
||||||
|
|
||||||
|
|
||||||
@@ -108,7 +131,7 @@ class FragmentClient:
|
|||||||
show_sender: Show your name as the sender. Defaults to ``True``.
|
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`PremiumResult` with ``transaction_id``, ``username``, ``months``, ``timestamp``.
|
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
"""
|
"""
|
||||||
return await purchase_premium(self, username, months, show_sender)
|
return await purchase_premium(self, username, months, show_sender)
|
||||||
|
|
||||||
@@ -121,28 +144,226 @@ class FragmentClient:
|
|||||||
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`StarsResult` with ``transaction_id``, ``username``, ``stars``, ``timestamp``.
|
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
"""
|
"""
|
||||||
return await purchase_stars(self, username, amount, show_sender)
|
return await purchase_stars(self, username, amount, show_sender)
|
||||||
|
|
||||||
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||||
"""Top up Telegram Ads balance with TON.
|
"""Topup ton to recipient's Telegram balance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
username: Ads account username (with or without ``@``).
|
username: Recipient's Telegram username (with or without ``@``).
|
||||||
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||||
show_sender: Show your name as the sender. Defaults to ``True``.
|
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`AdsTopupResult` with ``transaction_id``, ``username``, ``amount``, ``timestamp``.
|
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
"""
|
"""
|
||||||
return await topup_ton(self, username, amount, show_sender)
|
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:
|
async def get_wallet(self) -> WalletInfo:
|
||||||
"""Return the address, state and balance of the TON wallet.
|
"""Return the address, state and balance of the TON wallet.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
|
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
|
||||||
(``"active"``, ``"uninit"``, or ``"frozen"``), and ``balance`` in TON.
|
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), and ``balance`` in TON.
|
||||||
"""
|
"""
|
||||||
return await get_wallet_info(self)
|
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 {})})
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
from pyfragment.methods.premium import purchase_premium
|
from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes
|
||||||
from pyfragment.methods.stars import purchase_stars
|
from pyfragment.methods.giveaway_premium import giveaway_premium
|
||||||
from pyfragment.methods.ton import topup_ton
|
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__ = ["purchase_premium", "purchase_stars", "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,151 @@
|
|||||||
|
import html
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from pyfragment.types import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError
|
||||||
|
from pyfragment.types.constants import NUMBERS_PAGE
|
||||||
|
from pyfragment.types.results import LoginCodeResult, TerminateSessionsResult
|
||||||
|
from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_login_code
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
HEADERS: dict[str, str] = make_headers(NUMBERS_PAGE)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_plus(number: str) -> str:
|
||||||
|
return number.lstrip("+") if isinstance(number, str) else number
|
||||||
|
|
||||||
|
|
||||||
|
async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResult:
|
||||||
|
"""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)
|
||||||
|
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
|
||||||
|
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout)
|
||||||
|
result = await fragment_request(
|
||||||
|
session,
|
||||||
|
fragment_hash,
|
||||||
|
HEADERS,
|
||||||
|
{"number": clean, "lt": "0", "from_app": "1", "method": "updateLoginCodes"},
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
|
||||||
|
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout)
|
||||||
|
result = await fragment_request(
|
||||||
|
session,
|
||||||
|
fragment_hash,
|
||||||
|
HEADERS,
|
||||||
|
{"number": clean, "can_receive": 1 if can_receive else 0, "method": "toggleLoginCodes"},
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
|
||||||
|
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout)
|
||||||
|
|
||||||
|
# Step 1: initiate — Fragment returns a confirmation hash.
|
||||||
|
confirmation = await fragment_request(
|
||||||
|
session,
|
||||||
|
fragment_hash,
|
||||||
|
HEADERS,
|
||||||
|
{"number": clean, "method": "terminatePhoneSessions"},
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
# Step 2: confirm with the hash.
|
||||||
|
result = await fragment_request(
|
||||||
|
session,
|
||||||
|
fragment_hash,
|
||||||
|
HEADERS,
|
||||||
|
{"number": clean, "terminate_hash": terminate_hash, "method": "terminatePhoneSessions"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
raise AnonymousNumberError(
|
||||||
|
AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(result["error"]))
|
||||||
|
)
|
||||||
|
|
||||||
|
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,135 @@
|
|||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from pyfragment.types import (
|
||||||
|
ConfigurationError,
|
||||||
|
FragmentAPIError,
|
||||||
|
FragmentError,
|
||||||
|
UnexpectedError,
|
||||||
|
UserNotFoundError,
|
||||||
|
)
|
||||||
|
from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE
|
||||||
|
from pyfragment.types.results import PremiumGiveawayResult
|
||||||
|
from pyfragment.utils import (
|
||||||
|
execute_transaction_request,
|
||||||
|
fragment_request,
|
||||||
|
get_account_info,
|
||||||
|
get_fragment_hash,
|
||||||
|
make_headers,
|
||||||
|
process_transaction,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
HEADERS: dict[str, str] = make_headers(PREMIUM_GIVEAWAY_PAGE)
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_recipient(
|
||||||
|
session: httpx.AsyncClient,
|
||||||
|
fragment_hash: str,
|
||||||
|
channel: str,
|
||||||
|
winners: int,
|
||||||
|
months: int,
|
||||||
|
) -> str:
|
||||||
|
result = await fragment_request(
|
||||||
|
session,
|
||||||
|
fragment_hash,
|
||||||
|
HEADERS,
|
||||||
|
{
|
||||||
|
"query": channel,
|
||||||
|
"quantity": winners,
|
||||||
|
"months": months,
|
||||||
|
"method": "searchPremiumGiveawayRecipient",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
recipient = result.get("found", {}).get("recipient")
|
||||||
|
if not recipient:
|
||||||
|
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
|
||||||
|
return recipient
|
||||||
|
|
||||||
|
|
||||||
|
async def _init_request(
|
||||||
|
session: httpx.AsyncClient,
|
||||||
|
fragment_hash: str,
|
||||||
|
recipient: str,
|
||||||
|
winners: int,
|
||||||
|
months: int,
|
||||||
|
) -> str:
|
||||||
|
result = await fragment_request(
|
||||||
|
session,
|
||||||
|
fragment_hash,
|
||||||
|
HEADERS,
|
||||||
|
{
|
||||||
|
"recipient": recipient,
|
||||||
|
"quantity": str(winners),
|
||||||
|
"months": str(months),
|
||||||
|
"method": "initGiveawayPremiumRequest",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
req_id = result.get("req_id")
|
||||||
|
if not req_id:
|
||||||
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway"))
|
||||||
|
return req_id
|
||||||
|
|
||||||
|
|
||||||
|
async def giveaway_premium(
|
||||||
|
client: "FragmentClient",
|
||||||
|
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:
|
||||||
|
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_GIVEAWAY_PAGE, client.timeout)
|
||||||
|
account = await get_account_info(client)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
|
||||||
|
recipient = await _search_recipient(session, fragment_hash, channel, winners, months)
|
||||||
|
req_id = await _init_request(session, fragment_hash, recipient, winners, months)
|
||||||
|
|
||||||
|
tx_data = {
|
||||||
|
"account": json.dumps(account),
|
||||||
|
"device": DEVICE,
|
||||||
|
"transaction": 1,
|
||||||
|
"id": req_id,
|
||||||
|
"method": "getGiveawayPremiumLink",
|
||||||
|
}
|
||||||
|
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
|
||||||
|
|
||||||
|
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,131 @@
|
|||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from pyfragment.types import (
|
||||||
|
ConfigurationError,
|
||||||
|
FragmentAPIError,
|
||||||
|
FragmentError,
|
||||||
|
UnexpectedError,
|
||||||
|
UserNotFoundError,
|
||||||
|
)
|
||||||
|
from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE
|
||||||
|
from pyfragment.types.results import StarsGiveawayResult
|
||||||
|
from pyfragment.utils import (
|
||||||
|
execute_transaction_request,
|
||||||
|
fragment_request,
|
||||||
|
get_account_info,
|
||||||
|
get_fragment_hash,
|
||||||
|
make_headers,
|
||||||
|
process_transaction,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
HEADERS: dict[str, str] = make_headers(STARS_GIVEAWAY_PAGE)
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_recipient(
|
||||||
|
session: httpx.AsyncClient,
|
||||||
|
fragment_hash: str,
|
||||||
|
channel: str,
|
||||||
|
) -> str:
|
||||||
|
result = await fragment_request(
|
||||||
|
session,
|
||||||
|
fragment_hash,
|
||||||
|
HEADERS,
|
||||||
|
{
|
||||||
|
"query": channel,
|
||||||
|
"method": "searchStarsGiveawayRecipient",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
recipient = result.get("found", {}).get("recipient")
|
||||||
|
if not recipient:
|
||||||
|
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
|
||||||
|
return recipient
|
||||||
|
|
||||||
|
|
||||||
|
async def _init_request(
|
||||||
|
session: httpx.AsyncClient,
|
||||||
|
fragment_hash: str,
|
||||||
|
recipient: str,
|
||||||
|
winners: int,
|
||||||
|
amount: int,
|
||||||
|
) -> str:
|
||||||
|
result = await fragment_request(
|
||||||
|
session,
|
||||||
|
fragment_hash,
|
||||||
|
HEADERS,
|
||||||
|
{
|
||||||
|
"recipient": recipient,
|
||||||
|
"quantity": str(winners),
|
||||||
|
"stars": str(amount),
|
||||||
|
"method": "initGiveawayStarsRequest",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
req_id = result.get("req_id")
|
||||||
|
if not req_id:
|
||||||
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway"))
|
||||||
|
return req_id
|
||||||
|
|
||||||
|
|
||||||
|
async def giveaway_stars(
|
||||||
|
client: "FragmentClient",
|
||||||
|
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:
|
||||||
|
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_GIVEAWAY_PAGE, client.timeout)
|
||||||
|
account = await get_account_info(client)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
|
||||||
|
recipient = await _search_recipient(session, fragment_hash, channel)
|
||||||
|
req_id = await _init_request(session, fragment_hash, recipient, winners, amount)
|
||||||
|
|
||||||
|
tx_data = {
|
||||||
|
"account": json.dumps(account),
|
||||||
|
"device": DEVICE,
|
||||||
|
"transaction": 1,
|
||||||
|
"id": req_id,
|
||||||
|
"method": "getGiveawayStarsLink",
|
||||||
|
}
|
||||||
|
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
|
||||||
|
|
||||||
|
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
|
||||||
@@ -12,24 +12,20 @@ from pyfragment.types import (
|
|||||||
UnexpectedError,
|
UnexpectedError,
|
||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
)
|
)
|
||||||
from pyfragment.types.constants import BASE_HEADERS, DEVICE, PREMIUM_PAGE
|
from pyfragment.types.constants import DEVICE, PREMIUM_PAGE
|
||||||
from pyfragment.utils import (
|
from pyfragment.utils import (
|
||||||
execute_transaction_request,
|
execute_transaction_request,
|
||||||
fragment_post,
|
fragment_request,
|
||||||
get_account_info,
|
get_account_info,
|
||||||
get_fragment_hash,
|
get_fragment_hash,
|
||||||
|
make_headers,
|
||||||
process_transaction,
|
process_transaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
# Page-specific headers
|
HEADERS: dict[str, str] = make_headers(PREMIUM_PAGE)
|
||||||
HEADERS: dict[str, str] = {
|
|
||||||
**BASE_HEADERS,
|
|
||||||
"referer": PREMIUM_PAGE,
|
|
||||||
"x-aj-referer": PREMIUM_PAGE,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _search_recipient(
|
async def _search_recipient(
|
||||||
@@ -38,7 +34,7 @@ async def _search_recipient(
|
|||||||
username: str,
|
username: str,
|
||||||
months: int,
|
months: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
result = await fragment_post(
|
result = await fragment_request(
|
||||||
session,
|
session,
|
||||||
fragment_hash,
|
fragment_hash,
|
||||||
HEADERS,
|
HEADERS,
|
||||||
@@ -60,7 +56,7 @@ async def _init_request(
|
|||||||
recipient: str,
|
recipient: str,
|
||||||
months: int,
|
months: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
await fragment_post(
|
await fragment_request(
|
||||||
session,
|
session,
|
||||||
fragment_hash,
|
fragment_hash,
|
||||||
HEADERS,
|
HEADERS,
|
||||||
@@ -71,7 +67,7 @@ async def _init_request(
|
|||||||
"method": "updatePremiumState",
|
"method": "updatePremiumState",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
result = await fragment_post(
|
result = await fragment_request(
|
||||||
session,
|
session,
|
||||||
fragment_hash,
|
fragment_hash,
|
||||||
HEADERS,
|
HEADERS,
|
||||||
@@ -88,6 +84,23 @@ async def _init_request(
|
|||||||
|
|
||||||
|
|
||||||
async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult:
|
async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult:
|
||||||
|
"""Gift Telegram Premium to a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
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):
|
if months not in (3, 6, 12):
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
||||||
|
|
||||||
@@ -110,7 +123,7 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int,
|
|||||||
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
|
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
|
||||||
|
|
||||||
tx_hash = await process_transaction(client, transaction)
|
tx_hash = await process_transaction(client, transaction)
|
||||||
return PremiumResult(transaction_id=tx_hash, username=username, months=months)
|
return PremiumResult(transaction_id=tx_hash, username=username, amount=months)
|
||||||
|
|
||||||
except FragmentError:
|
except FragmentError:
|
||||||
raise
|
raise
|
||||||
@@ -11,24 +11,20 @@ from pyfragment.types import (
|
|||||||
UnexpectedError,
|
UnexpectedError,
|
||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
)
|
)
|
||||||
from pyfragment.types.constants import BASE_HEADERS, DEVICE, STARS_PAGE
|
from pyfragment.types.constants import DEVICE, STARS_PAGE
|
||||||
from pyfragment.utils import (
|
from pyfragment.utils import (
|
||||||
execute_transaction_request,
|
execute_transaction_request,
|
||||||
fragment_post,
|
fragment_request,
|
||||||
get_account_info,
|
get_account_info,
|
||||||
get_fragment_hash,
|
get_fragment_hash,
|
||||||
|
make_headers,
|
||||||
process_transaction,
|
process_transaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
# Page-specific headers
|
HEADERS: dict[str, str] = make_headers(STARS_PAGE)
|
||||||
HEADERS: dict[str, str] = {
|
|
||||||
**BASE_HEADERS,
|
|
||||||
"referer": STARS_PAGE,
|
|
||||||
"x-aj-referer": STARS_PAGE,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _search_recipient(
|
async def _search_recipient(
|
||||||
@@ -36,7 +32,7 @@ async def _search_recipient(
|
|||||||
fragment_hash: str,
|
fragment_hash: str,
|
||||||
username: str,
|
username: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
result = await fragment_post(
|
result = await fragment_request(
|
||||||
session,
|
session,
|
||||||
fragment_hash,
|
fragment_hash,
|
||||||
HEADERS,
|
HEADERS,
|
||||||
@@ -58,7 +54,7 @@ async def _init_request(
|
|||||||
recipient: str,
|
recipient: str,
|
||||||
amount: int,
|
amount: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
result = await fragment_post(
|
result = await fragment_request(
|
||||||
session,
|
session,
|
||||||
fragment_hash,
|
fragment_hash,
|
||||||
HEADERS,
|
HEADERS,
|
||||||
@@ -75,6 +71,23 @@ async def _init_request(
|
|||||||
|
|
||||||
|
|
||||||
async def purchase_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult:
|
async def purchase_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult:
|
||||||
|
"""Send Telegram Stars to a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
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):
|
if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
|
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
|
||||||
|
|
||||||
@@ -97,7 +110,7 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s
|
|||||||
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
|
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
|
||||||
|
|
||||||
tx_hash = await process_transaction(client, transaction)
|
tx_hash = await process_transaction(client, transaction)
|
||||||
return StarsResult(transaction_id=tx_hash, username=username, stars=amount)
|
return StarsResult(transaction_id=tx_hash, username=username, amount=amount)
|
||||||
|
|
||||||
except FragmentError:
|
except FragmentError:
|
||||||
raise
|
raise
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from pyfragment.types import (
|
||||||
|
ConfigurationError,
|
||||||
|
FragmentAPIError,
|
||||||
|
FragmentError,
|
||||||
|
UnexpectedError,
|
||||||
|
)
|
||||||
|
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
|
||||||
|
from pyfragment.types.results import AdsRechargeResult
|
||||||
|
from pyfragment.utils import (
|
||||||
|
execute_transaction_request,
|
||||||
|
fragment_request,
|
||||||
|
get_account_info,
|
||||||
|
get_fragment_hash,
|
||||||
|
make_headers,
|
||||||
|
process_transaction,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
HEADERS: dict[str, str] = make_headers(ADS_TOPUP_PAGE)
|
||||||
|
|
||||||
|
|
||||||
|
async def _init_request(
|
||||||
|
session: httpx.AsyncClient,
|
||||||
|
fragment_hash: str,
|
||||||
|
account: str,
|
||||||
|
amount: int,
|
||||||
|
) -> str:
|
||||||
|
result = await fragment_request(
|
||||||
|
session,
|
||||||
|
fragment_hash,
|
||||||
|
HEADERS,
|
||||||
|
{
|
||||||
|
"account": account,
|
||||||
|
"amount": amount,
|
||||||
|
"method": "initAdsRechargeRequest",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
req_id = result.get("req_id")
|
||||||
|
if not req_id:
|
||||||
|
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Ads recharge"))
|
||||||
|
return req_id
|
||||||
|
|
||||||
|
|
||||||
|
async def recharge_ads(client: "FragmentClient", account: str, amount: int) -> AdsRechargeResult:
|
||||||
|
"""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:
|
||||||
|
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, ADS_TOPUP_PAGE, client.timeout)
|
||||||
|
account_info = await get_account_info(client)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
|
||||||
|
await fragment_request(session, fragment_hash, HEADERS, {"method": "updateAdsState", "mode": "new"})
|
||||||
|
req_id = await _init_request(session, fragment_hash, account, amount)
|
||||||
|
|
||||||
|
tx_data = {
|
||||||
|
"account": json.dumps(account_info),
|
||||||
|
"device": DEVICE,
|
||||||
|
"transaction": 1,
|
||||||
|
"id": req_id,
|
||||||
|
"method": "getAdsRechargeLink",
|
||||||
|
}
|
||||||
|
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
|
||||||
|
|
||||||
|
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,80 @@
|
|||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError
|
||||||
|
from pyfragment.types.constants import GIFTS_PAGE
|
||||||
|
from pyfragment.types.results import GiftsResult
|
||||||
|
from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_gift_items
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
HEADERS: dict[str, str] = make_headers(GIFTS_PAGE)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, GIFTS_PAGE, client.timeout)
|
||||||
|
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
|
||||||
|
result = await fragment_request(session, fragment_hash, HEADERS, data)
|
||||||
|
|
||||||
|
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,67 @@
|
|||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError
|
||||||
|
from pyfragment.types.constants import NUMBERS_PAGE
|
||||||
|
from pyfragment.types.results import NumbersResult
|
||||||
|
from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_auction_rows
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
HEADERS: dict[str, str] = make_headers(NUMBERS_PAGE)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout)
|
||||||
|
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
|
||||||
|
result = await fragment_request(session, fragment_hash, HEADERS, data)
|
||||||
|
|
||||||
|
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,67 @@
|
|||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError
|
||||||
|
from pyfragment.types.constants import FRAGMENT_BASE_URL
|
||||||
|
from pyfragment.types.results import UsernamesResult
|
||||||
|
from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_auction_rows
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
HEADERS: dict[str, str] = make_headers(FRAGMENT_BASE_URL)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, FRAGMENT_BASE_URL, client.timeout)
|
||||||
|
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
|
||||||
|
result = await fragment_request(session, fragment_hash, HEADERS, data)
|
||||||
|
|
||||||
|
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
|
||||||
@@ -11,24 +11,20 @@ from pyfragment.types import (
|
|||||||
UnexpectedError,
|
UnexpectedError,
|
||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
)
|
)
|
||||||
from pyfragment.types.constants import BASE_HEADERS, DEVICE, TON_PAGE
|
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
|
||||||
from pyfragment.utils import (
|
from pyfragment.utils import (
|
||||||
execute_transaction_request,
|
execute_transaction_request,
|
||||||
fragment_post,
|
fragment_request,
|
||||||
get_account_info,
|
get_account_info,
|
||||||
get_fragment_hash,
|
get_fragment_hash,
|
||||||
|
make_headers,
|
||||||
process_transaction,
|
process_transaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
# Page-specific headers
|
HEADERS: dict[str, str] = make_headers(ADS_TOPUP_PAGE)
|
||||||
HEADERS: dict[str, str] = {
|
|
||||||
**BASE_HEADERS,
|
|
||||||
"referer": TON_PAGE,
|
|
||||||
"x-aj-referer": TON_PAGE,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _search_recipient(
|
async def _search_recipient(
|
||||||
@@ -36,8 +32,8 @@ async def _search_recipient(
|
|||||||
fragment_hash: str,
|
fragment_hash: str,
|
||||||
username: str,
|
username: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
await fragment_post(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"})
|
await fragment_request(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"})
|
||||||
result = await fragment_post(
|
result = await fragment_request(
|
||||||
session,
|
session,
|
||||||
fragment_hash,
|
fragment_hash,
|
||||||
HEADERS,
|
HEADERS,
|
||||||
@@ -58,7 +54,7 @@ async def _init_request(
|
|||||||
recipient: str,
|
recipient: str,
|
||||||
amount: int,
|
amount: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
result = await fragment_post(
|
result = await fragment_request(
|
||||||
session,
|
session,
|
||||||
fragment_hash,
|
fragment_hash,
|
||||||
HEADERS,
|
HEADERS,
|
||||||
@@ -75,11 +71,28 @@ async def _init_request(
|
|||||||
|
|
||||||
|
|
||||||
async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||||
|
"""Topup ton to 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 Fragment.
|
||||||
|
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):
|
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
|
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, TON_PAGE, client.timeout)
|
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, ADS_TOPUP_PAGE, client.timeout)
|
||||||
account = await get_account_info(client)
|
account = await get_account_info(client)
|
||||||
|
|
||||||
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
|
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from pyfragment.types.exceptions import (
|
from pyfragment.types.exceptions import (
|
||||||
|
AnonymousNumberError,
|
||||||
ClientError,
|
ClientError,
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
CookieError,
|
CookieError,
|
||||||
@@ -13,7 +14,20 @@ from pyfragment.types.exceptions import (
|
|||||||
VerificationError,
|
VerificationError,
|
||||||
WalletError,
|
WalletError,
|
||||||
)
|
)
|
||||||
from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult, WalletInfo
|
from pyfragment.types.results import (
|
||||||
|
AdsRechargeResult,
|
||||||
|
AdsTopupResult,
|
||||||
|
GiftsResult,
|
||||||
|
LoginCodeResult,
|
||||||
|
NumbersResult,
|
||||||
|
PremiumGiveawayResult,
|
||||||
|
PremiumResult,
|
||||||
|
StarsGiveawayResult,
|
||||||
|
StarsResult,
|
||||||
|
TerminateSessionsResult,
|
||||||
|
UsernamesResult,
|
||||||
|
WalletInfo,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# client exceptions
|
# client exceptions
|
||||||
@@ -24,6 +38,7 @@ __all__ = [
|
|||||||
"FragmentAPIError",
|
"FragmentAPIError",
|
||||||
"FragmentError",
|
"FragmentError",
|
||||||
"FragmentPageError",
|
"FragmentPageError",
|
||||||
|
"AnonymousNumberError",
|
||||||
"OperationError",
|
"OperationError",
|
||||||
"ParseError",
|
"ParseError",
|
||||||
"TransactionError",
|
"TransactionError",
|
||||||
@@ -32,8 +47,16 @@ __all__ = [
|
|||||||
"VerificationError",
|
"VerificationError",
|
||||||
"WalletError",
|
"WalletError",
|
||||||
# result types
|
# result types
|
||||||
|
"AdsRechargeResult",
|
||||||
"AdsTopupResult",
|
"AdsTopupResult",
|
||||||
|
"GiftsResult",
|
||||||
|
"LoginCodeResult",
|
||||||
|
"NumbersResult",
|
||||||
|
"PremiumGiveawayResult",
|
||||||
"PremiumResult",
|
"PremiumResult",
|
||||||
|
"StarsGiveawayResult",
|
||||||
"StarsResult",
|
"StarsResult",
|
||||||
|
"TerminateSessionsResult",
|
||||||
|
"UsernamesResult",
|
||||||
"WalletInfo",
|
"WalletInfo",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -20,9 +20,14 @@ DEFAULT_TIMEOUT: float = 30.0
|
|||||||
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
|
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
|
||||||
|
|
||||||
# Fragment page URLs
|
# Fragment page URLs
|
||||||
STARS_PAGE: str = "https://fragment.com/stars/buy"
|
FRAGMENT_BASE_URL: str = "https://fragment.com"
|
||||||
PREMIUM_PAGE: str = "https://fragment.com/premium/gift"
|
STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy"
|
||||||
TON_PAGE: str = "https://fragment.com/ads/topup"
|
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"
|
||||||
|
|
||||||
# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload.
|
# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload.
|
||||||
DEVICE: str = json.dumps(
|
DEVICE: str = json.dumps(
|
||||||
@@ -45,7 +50,7 @@ BASE_HEADERS: dict[str, str] = {
|
|||||||
"accept": "application/json, text/javascript, */*; q=0.01",
|
"accept": "application/json, text/javascript, */*; q=0.01",
|
||||||
"accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7",
|
"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",
|
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
"origin": "https://fragment.com",
|
"origin": FRAGMENT_BASE_URL,
|
||||||
"priority": "u=1, i",
|
"priority": "u=1, i",
|
||||||
"sec-fetch-dest": "empty",
|
"sec-fetch-dest": "empty",
|
||||||
"sec-fetch-mode": "cors",
|
"sec-fetch-mode": "cors",
|
||||||
|
|||||||
@@ -11,22 +11,29 @@ class ConfigurationError(ClientError):
|
|||||||
|
|
||||||
MISSING_VARS = "Missing required parameter(s): {keys}."
|
MISSING_VARS = "Missing required parameter(s): {keys}."
|
||||||
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
|
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
|
||||||
INVALID_MNEMONIC = "Invalid mnemonic: got {count} words, expected 12, 18, or 24."
|
INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}."
|
||||||
INVALID_API_KEY = "Invalid Tonapi key: got {length} characters, expected at least 68. Get one at https://tonconsole.com."
|
INVALID_API_KEY = (
|
||||||
INVALID_MONTHS = "Invalid duration. Choose 3, 6, or 12 months."
|
"Invalid Tonapi API key: expected at least 68 characters, got {length}. " "Generate a key at https://tonconsole.com."
|
||||||
INVALID_STARS_AMOUNT = "Amount must be an integer between 50 and 1 000 000 stars."
|
|
||||||
INVALID_TON_AMOUNT = "Amount must be an integer between 1 and 1 000 000 000 TON."
|
|
||||||
INVALID_USERNAME = (
|
|
||||||
"Invalid username '{username}'. Must be 5–32 characters: letters (A–Z, a–z), digits (0–9), or underscores (_)."
|
|
||||||
)
|
)
|
||||||
|
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):
|
class CookieError(ClientError):
|
||||||
"""Raised when cookies are unreadable or missing required fields."""
|
"""Raised when cookies are unreadable or missing required fields."""
|
||||||
|
|
||||||
READ_FAILED = "Failed to parse cookies: {exc}"
|
READ_FAILED = "Failed to parse cookies — expected a JSON string or a dict, got: {exc}"
|
||||||
MISSING_KEYS = (
|
MISSING_KEYS = (
|
||||||
"Cookies are missing or have empty values for: {keys}. " "Open Fragment.com in your browser and copy fresh cookies."
|
"Fragment cookies are missing or empty for key(s): {keys}. "
|
||||||
|
"Open fragment.com in your browser, log in, and copy fresh cookies."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -34,16 +41,21 @@ class FragmentAPIError(FragmentError):
|
|||||||
"""Raised for errors returned by Fragment's API responses."""
|
"""Raised for errors returned by Fragment's API responses."""
|
||||||
|
|
||||||
NO_REQUEST_ID = (
|
NO_REQUEST_ID = (
|
||||||
"Fragment did not return a request ID for '{context}'. " "The session may have expired — refresh your cookies."
|
"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):
|
class FragmentPageError(FragmentAPIError):
|
||||||
"""Raised when the Fragment page cannot be fetched or the API hash is not found."""
|
"""Raised when the Fragment page cannot be fetched or the API hash is not found."""
|
||||||
|
|
||||||
BAD_STATUS = "Fragment returned HTTP {status} for {url}. " "Check that your cookies are valid and not expired."
|
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 = (
|
NOT_FOUND = (
|
||||||
"Fragment hash not found in the page source of {url}. " "The page structure may have changed or you are not logged in."
|
"Could not extract the API hash from {url}. "
|
||||||
|
"The page structure may have changed, or you are not logged in — refresh your cookies."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -51,29 +63,52 @@ class UserNotFoundError(FragmentAPIError):
|
|||||||
"""Raised when the target Telegram user is not found on Fragment."""
|
"""Raised when the target Telegram user is not found on Fragment."""
|
||||||
|
|
||||||
NOT_FOUND = (
|
NOT_FOUND = (
|
||||||
"Telegram user '{username}' was not found on Fragment. " "Make sure the username is correct and the account exists."
|
"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):
|
class TransactionError(FragmentAPIError):
|
||||||
"""Raised when a TON transaction fails to build or broadcast."""
|
"""Raised when a TON transaction fails to build or broadcast."""
|
||||||
|
|
||||||
INVALID_PAYLOAD = (
|
INVALID_PAYLOAD = (
|
||||||
"Fragment returned an invalid transaction payload. " "The API response is missing expected 'transaction.messages' data."
|
"Fragment returned an invalid transaction payload — " "'transaction.messages' is missing or empty in the API response."
|
||||||
)
|
)
|
||||||
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
|
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
|
||||||
|
BROADCAST_FAILED_SSL = (
|
||||||
|
"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):
|
class ParseError(FragmentAPIError):
|
||||||
"""Raised when a Fragment API response or payload cannot be parsed."""
|
"""Raised when a Fragment API response or payload cannot be parsed."""
|
||||||
|
|
||||||
UNPARSEABLE = "Fragment API returned an unparseable response for '{context}': {exc}"
|
UNPARSEABLE = "Failed to parse the Fragment API response for '{context}': {exc}"
|
||||||
|
|
||||||
|
|
||||||
class VerificationError(FragmentAPIError):
|
class VerificationError(FragmentAPIError):
|
||||||
"""Raised when Fragment requires KYC verification before proceeding."""
|
"""Raised when Fragment requires KYC verification before proceeding."""
|
||||||
|
|
||||||
KYC_REQUIRED = "Fragment requires identity (KYC) verification. " "Complete it at https://fragment.com/my/profile and retry."
|
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):
|
class OperationError(FragmentError):
|
||||||
@@ -83,16 +118,19 @@ class OperationError(FragmentError):
|
|||||||
class WalletError(OperationError):
|
class WalletError(OperationError):
|
||||||
"""Raised for TON wallet issues (connection, balance, account info)."""
|
"""Raised for TON wallet issues (connection, balance, account info)."""
|
||||||
|
|
||||||
LOW_BALANCE = "TON wallet balance is too low: {balance:.4f} TON available, {required:.4f} TON required."
|
LOW_BALANCE = (
|
||||||
BALANCE_CHECK_FAILED = "Wallet balance check failed: {exc}"
|
"Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required "
|
||||||
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info: {exc}"
|
"(transaction amount + {gas:.3f} TON gas reserve)."
|
||||||
WALLET_INFO_FAILED = "Failed to retrieve wallet info: {exc}"
|
)
|
||||||
|
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):
|
class UnexpectedError(OperationError):
|
||||||
"""Raised when an unexpected error occurs during an API call."""
|
"""Raised when an unexpected error occurs during an API call."""
|
||||||
|
|
||||||
UNEXPECTED = "An unexpected error occurred: {exc}"
|
UNEXPECTED = "An unexpected error occurred during the operation: {exc}"
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -102,6 +140,7 @@ __all__ = [
|
|||||||
"CookieError",
|
"CookieError",
|
||||||
"FragmentAPIError",
|
"FragmentAPIError",
|
||||||
"FragmentPageError",
|
"FragmentPageError",
|
||||||
|
"AnonymousNumberError",
|
||||||
"UserNotFoundError",
|
"UserNotFoundError",
|
||||||
"TransactionError",
|
"TransactionError",
|
||||||
"ParseError",
|
"ParseError",
|
||||||
|
|||||||
+155
-11
@@ -1,7 +1,5 @@
|
|||||||
import time
|
from dataclasses import dataclass
|
||||||
from dataclasses import dataclass, field
|
from typing import Any
|
||||||
|
|
||||||
__all__ = ["AdsTopupResult", "PremiumResult", "StarsResult", "WalletInfo"]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -22,11 +20,10 @@ class PremiumResult:
|
|||||||
|
|
||||||
transaction_id: str
|
transaction_id: str
|
||||||
username: str
|
username: str
|
||||||
months: int
|
amount: int
|
||||||
timestamp: int = field(default_factory=lambda: int(time.time()))
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"PremiumResult(username='{self.username}', months={self.months}, tx='{self.transaction_id}')"
|
return f"PremiumResult(username='{self.username}', amount={self.amount} months, tx='{self.transaction_id}')"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -35,11 +32,10 @@ class StarsResult:
|
|||||||
|
|
||||||
transaction_id: str
|
transaction_id: str
|
||||||
username: str
|
username: str
|
||||||
stars: int
|
amount: int
|
||||||
timestamp: int = field(default_factory=lambda: int(time.time()))
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"StarsResult(username='{self.username}', stars={self.stars}, tx='{self.transaction_id}')"
|
return f"StarsResult(username='{self.username}', amount={self.amount} stars, tx='{self.transaction_id}')"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -49,7 +45,155 @@ class AdsTopupResult:
|
|||||||
transaction_id: str
|
transaction_id: str
|
||||||
username: str
|
username: str
|
||||||
amount: int
|
amount: int
|
||||||
timestamp: int = field(default_factory=lambda: int(time.time()))
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"AdsTopupResult(username='{self.username}', amount={self.amount} TON, tx='{self.transaction_id}')"
|
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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
from pyfragment.utils.decoder import clean_decode
|
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 (
|
from pyfragment.utils.http import (
|
||||||
execute_transaction_request,
|
execute_transaction_request,
|
||||||
fragment_post,
|
fragment_request,
|
||||||
get_fragment_hash,
|
get_fragment_hash,
|
||||||
|
make_headers,
|
||||||
parse_json_response,
|
parse_json_response,
|
||||||
)
|
)
|
||||||
from pyfragment.utils.wallet import get_account_info, process_transaction
|
from pyfragment.utils.wallet import get_account_info, process_transaction
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"clean_decode",
|
"clean_decode",
|
||||||
|
"parse_auction_rows",
|
||||||
|
"parse_gift_items",
|
||||||
|
"parse_login_code",
|
||||||
"execute_transaction_request",
|
"execute_transaction_request",
|
||||||
"fragment_post",
|
"fragment_request",
|
||||||
"get_account_info",
|
"get_account_info",
|
||||||
"get_fragment_hash",
|
"get_fragment_hash",
|
||||||
|
"make_headers",
|
||||||
"parse_json_response",
|
"parse_json_response",
|
||||||
"process_transaction",
|
"process_transaction",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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
|
||||||
@@ -4,7 +4,11 @@ from typing import Any
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from pyfragment.types import FragmentPageError, ParseError, VerificationError
|
from pyfragment.types import FragmentPageError, ParseError, VerificationError
|
||||||
from pyfragment.types.constants import DEFAULT_TIMEOUT
|
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(
|
async def get_fragment_hash(
|
||||||
@@ -40,7 +44,7 @@ async def get_fragment_hash(
|
|||||||
page_headers.update(
|
page_headers.update(
|
||||||
{
|
{
|
||||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
"referer": "https://fragment.com/",
|
"referer": f"{FRAGMENT_BASE_URL}/",
|
||||||
"sec-fetch-dest": "document",
|
"sec-fetch-dest": "document",
|
||||||
"sec-fetch-mode": "navigate",
|
"sec-fetch-mode": "navigate",
|
||||||
"upgrade-insecure-requests": "1",
|
"upgrade-insecure-requests": "1",
|
||||||
@@ -79,7 +83,7 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any
|
|||||||
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
async def fragment_post(
|
async def fragment_request(
|
||||||
session: httpx.AsyncClient,
|
session: httpx.AsyncClient,
|
||||||
fragment_hash: str,
|
fragment_hash: str,
|
||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
@@ -101,7 +105,7 @@ async def fragment_post(
|
|||||||
Parsed API response as a dict.
|
Parsed API response as a dict.
|
||||||
"""
|
"""
|
||||||
resp = await session.post(
|
resp = await session.post(
|
||||||
f"https://fragment.com/api?hash={fragment_hash}",
|
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
data=data,
|
data=data,
|
||||||
)
|
)
|
||||||
@@ -129,7 +133,7 @@ async def execute_transaction_request(
|
|||||||
VerificationError: If Fragment requires KYC verification.
|
VerificationError: If Fragment requires KYC verification.
|
||||||
ParseError: If the response cannot be parsed.
|
ParseError: If the response cannot be parsed.
|
||||||
"""
|
"""
|
||||||
transaction = await fragment_post(session, fragment_hash, headers, tx_data)
|
transaction = await fragment_request(session, fragment_hash, headers, tx_data)
|
||||||
|
|
||||||
if transaction.get("need_verify"):
|
if transaction.get("need_verify"):
|
||||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import ssl
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from tonutils.clients import TonapiClient
|
from tonutils.clients import TonapiClient
|
||||||
@@ -38,9 +39,6 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict)
|
|||||||
message = transaction_data["transaction"]["messages"][0]
|
message = transaction_data["transaction"]["messages"][0]
|
||||||
amount_ton = int(message["amount"]) / 1_000_000_000
|
amount_ton = int(message["amount"]) / 1_000_000_000
|
||||||
|
|
||||||
# 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 TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
||||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||||
@@ -51,7 +49,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict)
|
|||||||
balance_ton = wallet.balance / 1_000_000_000
|
balance_ton = wallet.balance / 1_000_000_000
|
||||||
required = amount_ton + MIN_TON_BALANCE
|
required = amount_ton + MIN_TON_BALANCE
|
||||||
if balance_ton < required:
|
if balance_ton < required:
|
||||||
raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton, required=required))
|
raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton, required=required, gas=MIN_TON_BALANCE))
|
||||||
except WalletError:
|
except WalletError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -60,7 +58,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict)
|
|||||||
try:
|
try:
|
||||||
payload = clean_decode(message["payload"])
|
payload = clean_decode(message["payload"])
|
||||||
|
|
||||||
for attempt in range(2):
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
result = await wallet.transfer(
|
result = await wallet.transfer(
|
||||||
destination=message["address"],
|
destination=message["address"],
|
||||||
@@ -72,10 +70,21 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict)
|
|||||||
if exc.code == 429 and attempt == 0:
|
if exc.code == 429 and attempt == 0:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
continue
|
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
|
raise
|
||||||
except (WalletError, TransactionError):
|
except (WalletError, TransactionError):
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
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=exc)) from exc
|
||||||
|
|
||||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
|
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pyfragment"
|
name = "pyfragment"
|
||||||
version = "2026.0.2"
|
version = "2026.1.0"
|
||||||
description = "Python library for the Fragment.com API — purchase Telegram Stars, Premium, and top up TON Ads balance."
|
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"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for clean_decode() — BOC-encoded Fragment payloads decode to UTF-8."""
|
"""Tests for clean_decode() — TON BOC payload decoding."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@@ -23,6 +23,9 @@ PAYLOADS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Decode valid payload tests
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("payload", PAYLOADS)
|
@pytest.mark.parametrize("payload", PAYLOADS)
|
||||||
def test_decode_payload(payload: str) -> None:
|
def test_decode_payload(payload: str) -> None:
|
||||||
result = clean_decode(payload)
|
result = clean_decode(payload)
|
||||||
@@ -31,6 +34,9 @@ def test_decode_payload(payload: str) -> None:
|
|||||||
assert all(ord(c) < 128 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}"
|
||||||
|
|
||||||
|
|
||||||
|
# Edge case tests
|
||||||
|
|
||||||
|
|
||||||
def test_empty_payload_returns_empty_string() -> None:
|
def test_empty_payload_returns_empty_string() -> None:
|
||||||
assert clean_decode("") == ""
|
assert clean_decode("") == ""
|
||||||
|
|
||||||
|
|||||||
+35
-29
@@ -1,4 +1,4 @@
|
|||||||
"""Unit tests for FragmentClient — init validation and cookie parsing (no network calls)."""
|
"""Unit tests for FragmentClient — initialization, validation, and cookie parsing."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -6,15 +6,9 @@ import pytest
|
|||||||
|
|
||||||
from pyfragment import FragmentClient
|
from pyfragment import FragmentClient
|
||||||
from pyfragment.types import ConfigurationError, CookieError
|
from pyfragment.types import ConfigurationError, CookieError
|
||||||
|
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
||||||
|
|
||||||
VALID_SEED = "abandon " * 23 + "about"
|
# Client init tests
|
||||||
VALID_API_KEY = "A" * 68
|
|
||||||
VALID_COOKIES = {
|
|
||||||
"stel_ssid": "x",
|
|
||||||
"stel_dt": "x",
|
|
||||||
"stel_token": "x",
|
|
||||||
"stel_ton_token": "x",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_valid_init() -> None:
|
def test_valid_init() -> None:
|
||||||
@@ -24,6 +18,9 @@ def test_valid_init() -> None:
|
|||||||
assert client.wallet_version == "V5R1"
|
assert client.wallet_version == "V5R1"
|
||||||
|
|
||||||
|
|
||||||
|
# Wallet version tests
|
||||||
|
|
||||||
|
|
||||||
def test_wallet_version_v4r2() -> None:
|
def test_wallet_version_v4r2() -> None:
|
||||||
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V4R2")
|
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V4R2")
|
||||||
assert client.wallet_version == "V4R2"
|
assert client.wallet_version == "V4R2"
|
||||||
@@ -34,6 +31,14 @@ def test_wallet_version_is_case_insensitive() -> None:
|
|||||||
assert client.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:
|
def test_missing_seed_raises() -> None:
|
||||||
with pytest.raises(ConfigurationError):
|
with pytest.raises(ConfigurationError):
|
||||||
FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||||
@@ -44,14 +49,33 @@ def test_whitespace_only_seed_raises() -> None:
|
|||||||
FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
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:
|
def test_missing_api_key_raises() -> None:
|
||||||
with pytest.raises(ConfigurationError):
|
with pytest.raises(ConfigurationError):
|
||||||
FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES)
|
FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES)
|
||||||
|
|
||||||
|
|
||||||
def test_unsupported_wallet_version_raises() -> None:
|
def test_short_api_key_raises() -> None:
|
||||||
with pytest.raises(ConfigurationError):
|
with pytest.raises(ConfigurationError):
|
||||||
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V3R2")
|
FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES)
|
||||||
|
|
||||||
|
|
||||||
|
# Cookie validation tests
|
||||||
|
|
||||||
|
|
||||||
def test_cookies_as_json_string() -> None:
|
def test_cookies_as_json_string() -> None:
|
||||||
@@ -81,24 +105,6 @@ def test_whitespace_cookie_value_raises() -> None:
|
|||||||
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad)
|
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad)
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_short_api_key_raises() -> None:
|
|
||||||
with pytest.raises(ConfigurationError):
|
|
||||||
FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES)
|
|
||||||
|
|
||||||
|
|
||||||
def test_repr() -> None:
|
def test_repr() -> None:
|
||||||
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||||
r = repr(client)
|
r = repr(client)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""Tests for get_fragment_hash() — fetches a valid lowercase hex hash
|
"""Tests for get_fragment_hash() — API hash extraction from Fragment page source."""
|
||||||
from the fragment.com/stars/buy page source."""
|
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
"""Unit tests for process_transaction() — balance checks before broadcast."""
|
"""Unit tests for process_transaction() — balance validation and broadcast retry logic."""
|
||||||
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from tonutils.exceptions import ProviderResponseError
|
||||||
|
|
||||||
from pyfragment.types import TransactionError, WalletError
|
from pyfragment.types import TransactionError, WalletError
|
||||||
from pyfragment.utils.wallet import process_transaction
|
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")
|
||||||
|
|
||||||
VALID_SEED = "abandon " * 23 + "about"
|
|
||||||
|
|
||||||
TRANSACTION_DATA = {
|
TRANSACTION_DATA = {
|
||||||
"transaction": {
|
"transaction": {
|
||||||
@@ -51,6 +56,9 @@ def _patch_wallet(wallet: MagicMock):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
# Balance threshold tests
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sufficient_balance_broadcasts() -> None:
|
async def test_sufficient_balance_broadcasts() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, needs 0.556 TON
|
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, needs 0.556 TON
|
||||||
@@ -85,7 +93,41 @@ async def test_one_nanoton_below_minimum_raises() -> None:
|
|||||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
|
|
||||||
|
|
||||||
|
# Error handling tests
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invalid_payload_raises() -> None:
|
async def test_invalid_payload_raises() -> None:
|
||||||
with pytest.raises(TransactionError):
|
with pytest.raises(TransactionError):
|
||||||
await process_transaction(_make_client(), {"transaction": {}})
|
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
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
"""Unit tests for method-level input validation — no network calls."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from pyfragment import FragmentClient
|
|
||||||
from pyfragment.types import ConfigurationError
|
|
||||||
|
|
||||||
VALID_SEED = "abandon " * 23 + "about"
|
|
||||||
VALID_API_KEY = "A" * 68
|
|
||||||
VALID_COOKIES = {
|
|
||||||
"stel_ssid": "x",
|
|
||||||
"stel_dt": "x",
|
|
||||||
"stel_token": "x",
|
|
||||||
"stel_ton_token": "x",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client() -> FragmentClient:
|
|
||||||
return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
|
||||||
|
|
||||||
|
|
||||||
@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_stars_amount_too_low(client: FragmentClient) -> None:
|
|
||||||
with pytest.raises(ConfigurationError):
|
|
||||||
await client.purchase_stars("@user", amount=49)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None:
|
|
||||||
with pytest.raises(ConfigurationError):
|
|
||||||
await client.purchase_stars("@user", amount=1_000_001)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_purchase_stars_float_amount(client: FragmentClient) -> None:
|
|
||||||
with pytest.raises(ConfigurationError):
|
|
||||||
await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_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)
|
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Unit tests for Stars methods — purchase_stars and giveaway_stars."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.types import ConfigurationError, StarsGiveawayResult, StarsResult, UserNotFoundError
|
||||||
|
from tests.shared import FAKE_ACCOUNT, FAKE_HASH, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||||
|
|
||||||
|
# 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("pyfragment.methods.purchase_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.purchase_stars.fragment_request",
|
||||||
|
AsyncMock(side_effect=[{"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}]),
|
||||||
|
),
|
||||||
|
patch("pyfragment.methods.purchase_stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
|
||||||
|
patch("pyfragment.methods.purchase_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||||
|
):
|
||||||
|
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("pyfragment.methods.purchase_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch("pyfragment.methods.purchase_stars.fragment_request", AsyncMock(return_value={"found": {}})),
|
||||||
|
):
|
||||||
|
with 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("pyfragment.methods.giveaway_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.giveaway_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.giveaway_stars.fragment_request",
|
||||||
|
AsyncMock(side_effect=[{"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}]),
|
||||||
|
),
|
||||||
|
patch("pyfragment.methods.giveaway_stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
|
||||||
|
patch("pyfragment.methods.giveaway_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||||
|
):
|
||||||
|
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("pyfragment.methods.giveaway_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.giveaway_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch("pyfragment.methods.giveaway_stars.fragment_request", AsyncMock(return_value={"found": {}})),
|
||||||
|
):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await client.giveaway_stars("@ghost", winners=1, amount=500)
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
"""Tests for purchase methods with all network calls mocked."""
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from pyfragment import FragmentClient
|
|
||||||
from pyfragment.types import AdsTopupResult, PremiumResult, StarsResult, UserNotFoundError
|
|
||||||
|
|
||||||
VALID_SEED = "abandon " * 23 + "about"
|
|
||||||
VALID_API_KEY = "A" * 68
|
|
||||||
VALID_COOKIES = {
|
|
||||||
"stel_ssid": "x",
|
|
||||||
"stel_dt": "x",
|
|
||||||
"stel_token": "x",
|
|
||||||
"stel_ton_token": "x",
|
|
||||||
}
|
|
||||||
FAKE_HASH = "abc123"
|
|
||||||
FAKE_RECIPIENT = "recipient_token"
|
|
||||||
FAKE_REQ_ID = "req_42"
|
|
||||||
FAKE_TX_HASH = "deadbeef" * 8
|
|
||||||
FAKE_ACCOUNT = {"address": "0:abc", "publicKey": "pub", "chain": "-239", "walletStateInit": "base64=="}
|
|
||||||
FAKE_TRANSACTION = {"transaction": {"messages": [{"address": "0:abc", "amount": "100000000", "payload": ""}]}}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client() -> FragmentClient:
|
|
||||||
return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_purchase_stars_success(client: FragmentClient) -> None:
|
|
||||||
with (
|
|
||||||
patch("pyfragment.methods.stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
|
||||||
patch("pyfragment.methods.stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
|
||||||
patch(
|
|
||||||
"pyfragment.methods.stars.fragment_post",
|
|
||||||
AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
{"found": {"recipient": FAKE_RECIPIENT}}, # searchStarsRecipient
|
|
||||||
{"req_id": FAKE_REQ_ID}, # initBuyStarsRequest
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
patch("pyfragment.methods.stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
|
|
||||||
patch("pyfragment.methods.stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
|
||||||
):
|
|
||||||
result = await client.purchase_stars("testuser", amount=100)
|
|
||||||
|
|
||||||
assert isinstance(result, StarsResult)
|
|
||||||
assert result.transaction_id == FAKE_TX_HASH
|
|
||||||
assert result.username == "testuser"
|
|
||||||
assert result.stars == 100
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
|
|
||||||
with (
|
|
||||||
patch("pyfragment.methods.stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
|
||||||
patch("pyfragment.methods.stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
|
||||||
patch("pyfragment.methods.stars.fragment_post", AsyncMock(return_value={"found": {}})),
|
|
||||||
):
|
|
||||||
with pytest.raises(UserNotFoundError):
|
|
||||||
await client.purchase_stars("ghost", amount=100)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_purchase_premium_success(client: FragmentClient) -> None:
|
|
||||||
with (
|
|
||||||
patch("pyfragment.methods.premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
|
||||||
patch("pyfragment.methods.premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
|
||||||
patch(
|
|
||||||
"pyfragment.methods.premium.fragment_post",
|
|
||||||
AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
{"found": {"recipient": FAKE_RECIPIENT}}, # searchPremiumGiftRecipient
|
|
||||||
{}, # updatePremiumState
|
|
||||||
{"req_id": FAKE_REQ_ID}, # initGiftPremiumRequest
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
patch("pyfragment.methods.premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
|
|
||||||
patch("pyfragment.methods.premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
|
||||||
):
|
|
||||||
result = await client.purchase_premium("testuser", months=6)
|
|
||||||
|
|
||||||
assert isinstance(result, PremiumResult)
|
|
||||||
assert result.transaction_id == FAKE_TX_HASH
|
|
||||||
assert result.username == "testuser"
|
|
||||||
assert result.months == 6
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
|
|
||||||
with (
|
|
||||||
patch("pyfragment.methods.premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
|
||||||
patch("pyfragment.methods.premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
|
||||||
patch("pyfragment.methods.premium.fragment_post", AsyncMock(return_value={"found": {}})),
|
|
||||||
):
|
|
||||||
with pytest.raises(UserNotFoundError):
|
|
||||||
await client.purchase_premium("ghost", months=3)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_topup_ton_success(client: FragmentClient) -> None:
|
|
||||||
with (
|
|
||||||
patch("pyfragment.methods.ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
|
||||||
patch("pyfragment.methods.ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
|
||||||
patch(
|
|
||||||
"pyfragment.methods.ton.fragment_post",
|
|
||||||
AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
{}, # updateAdsTopupState
|
|
||||||
{"found": {"recipient": FAKE_RECIPIENT}}, # searchAdsTopupRecipient
|
|
||||||
{"req_id": FAKE_REQ_ID}, # initAdsTopupRequest
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
patch("pyfragment.methods.ton.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
|
|
||||||
patch("pyfragment.methods.ton.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
|
||||||
):
|
|
||||||
result = await client.topup_ton("testuser", amount=10)
|
|
||||||
|
|
||||||
assert isinstance(result, AdsTopupResult)
|
|
||||||
assert result.transaction_id == FAKE_TX_HASH
|
|
||||||
assert result.username == "testuser"
|
|
||||||
assert result.amount == 10
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
|
|
||||||
with (
|
|
||||||
patch("pyfragment.methods.ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
|
||||||
patch("pyfragment.methods.ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
|
||||||
patch(
|
|
||||||
"pyfragment.methods.ton.fragment_post",
|
|
||||||
AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
{}, # updateAdsTopupState
|
|
||||||
{"found": {}}, # searchAdsTopupRecipient → not found
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
with pytest.raises(UserNotFoundError):
|
|
||||||
await client.topup_ton("ghost", amount=10)
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Unit tests for Premium methods — purchase_premium and giveaway_premium."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.types import ConfigurationError, PremiumGiveawayResult, PremiumResult, UserNotFoundError
|
||||||
|
from tests.shared import FAKE_ACCOUNT, FAKE_HASH, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||||
|
|
||||||
|
# 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("pyfragment.methods.purchase_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.purchase_premium.fragment_request",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updatePremiumState
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch("pyfragment.methods.purchase_premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
|
||||||
|
patch("pyfragment.methods.purchase_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||||
|
):
|
||||||
|
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("pyfragment.methods.purchase_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch("pyfragment.methods.purchase_premium.fragment_request", AsyncMock(return_value={"found": {}})),
|
||||||
|
):
|
||||||
|
with 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("pyfragment.methods.giveaway_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.giveaway_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.giveaway_premium.fragment_request",
|
||||||
|
AsyncMock(side_effect=[{"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}]),
|
||||||
|
),
|
||||||
|
patch("pyfragment.methods.giveaway_premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
|
||||||
|
patch("pyfragment.methods.giveaway_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||||
|
):
|
||||||
|
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("pyfragment.methods.giveaway_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.giveaway_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch("pyfragment.methods.giveaway_premium.fragment_request", AsyncMock(return_value={"found": {}})),
|
||||||
|
):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await client.giveaway_premium("@ghost", winners=1, months=3)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Unit tests for topup_ton — TON Ads balance top-up."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.types import AdsTopupResult, ConfigurationError, UserNotFoundError
|
||||||
|
from tests.shared import FAKE_ACCOUNT, FAKE_HASH, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||||
|
|
||||||
|
# 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("pyfragment.methods.topup_ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.topup_ton.fragment_request",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{}, # updateAdsTopupState
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch("pyfragment.methods.topup_ton.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
|
||||||
|
patch("pyfragment.methods.topup_ton.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||||
|
):
|
||||||
|
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("pyfragment.methods.topup_ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.topup_ton.fragment_request",
|
||||||
|
AsyncMock(side_effect=[{}, {"found": {}}]),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with 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,163 @@
|
|||||||
|
"""Unit tests for anonymous number methods — login codes and session management."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyfragment import AnonymousNumberError, FragmentClient, LoginCodeResult, TerminateSessionsResult
|
||||||
|
from tests.shared import FAKE_HASH, FAKE_HTML_NO_CODE, FAKE_HTML_WITH_CODE, FAKE_TERMINATE_HASH
|
||||||
|
|
||||||
|
# get_login_code tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_login_code_returns_code(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.anonymous_number.fragment_request",
|
||||||
|
AsyncMock(return_value={"html": FAKE_HTML_WITH_CODE}),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await client.get_login_code("+1234567890")
|
||||||
|
|
||||||
|
assert isinstance(result, LoginCodeResult)
|
||||||
|
assert result.number == "+1234567890"
|
||||||
|
assert result.code == "12345"
|
||||||
|
assert result.active_sessions == 2 # 2 <tr> in the HTML
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_login_code_no_pending_code(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.anonymous_number.fragment_request",
|
||||||
|
AsyncMock(return_value={"html": FAKE_HTML_NO_CODE}),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await client.get_login_code("1234567890")
|
||||||
|
|
||||||
|
assert result.code is None
|
||||||
|
assert result.active_sessions == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_login_code_no_html_returns_none(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={})),
|
||||||
|
):
|
||||||
|
result = await client.get_login_code("+1234567890")
|
||||||
|
|
||||||
|
assert result.code is None
|
||||||
|
assert result.active_sessions == 0
|
||||||
|
|
||||||
|
|
||||||
|
# terminate_sessions tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terminate_sessions_success(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.anonymous_number.fragment_request",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"terminate_hash": FAKE_TERMINATE_HASH}, # step 1: confirmation
|
||||||
|
{"msg": "All sessions terminated"}, # step 2: confirmed
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await client.terminate_sessions("+1234567890")
|
||||||
|
|
||||||
|
assert isinstance(result, TerminateSessionsResult)
|
||||||
|
assert result.number == "+1234567890"
|
||||||
|
assert result.message == "All sessions terminated"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terminate_sessions_not_owned_raises(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={})),
|
||||||
|
):
|
||||||
|
with pytest.raises(AnonymousNumberError, match="not associated"):
|
||||||
|
await client.terminate_sessions("+1234567890")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terminate_sessions_api_error_raises(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.anonymous_number.fragment_request",
|
||||||
|
AsyncMock(return_value={"error": "SESSION_ALREADY_TERMINATED"}),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(AnonymousNumberError, match="SESSION_ALREADY_TERMINATED"):
|
||||||
|
await client.terminate_sessions("+1234567890")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terminate_sessions_confirm_error_raises(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.anonymous_number.fragment_request",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"terminate_hash": FAKE_TERMINATE_HASH},
|
||||||
|
{"error": "INTERNAL_ERROR"},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(AnonymousNumberError, match="INTERNAL_ERROR"):
|
||||||
|
await client.terminate_sessions("+1234567890")
|
||||||
|
|
||||||
|
|
||||||
|
# toggle_login_codes tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle_login_codes_enable(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={"ok": True})) as mock_req,
|
||||||
|
):
|
||||||
|
await client.toggle_login_codes("+1234567890", can_receive=True)
|
||||||
|
|
||||||
|
call_data = mock_req.call_args[0][3]
|
||||||
|
assert call_data["can_receive"] == 1
|
||||||
|
assert call_data["method"] == "toggleLoginCodes"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle_login_codes_disable(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={"ok": True})) as mock_req,
|
||||||
|
):
|
||||||
|
await client.toggle_login_codes("+1234567890", can_receive=False)
|
||||||
|
|
||||||
|
call_data = mock_req.call_args[0][3]
|
||||||
|
assert call_data["can_receive"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# strip_plus tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_login_code_strips_plus(client: FragmentClient) -> None:
|
||||||
|
"""Number passed with '+' is stripped before the API call."""
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={})) as mock_req,
|
||||||
|
):
|
||||||
|
await client.get_login_code("+1234567890")
|
||||||
|
|
||||||
|
call_data = mock_req.call_args[0][3]
|
||||||
|
assert call_data["number"] == "1234567890"
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Unit tests for recharge_ads — self-service Telegram Ads recharge."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.types import AdsRechargeResult, ConfigurationError
|
||||||
|
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_HASH, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||||
|
|
||||||
|
# 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("pyfragment.methods.recharge_ads.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch("pyfragment.methods.recharge_ads.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.recharge_ads.fragment_request",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{}, # updateAdsState
|
||||||
|
{"req_id": FAKE_REQ_ID}, # initAdsRechargeRequest
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch("pyfragment.methods.recharge_ads.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
|
||||||
|
patch("pyfragment.methods.recharge_ads.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||||
|
):
|
||||||
|
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,111 @@
|
|||||||
|
"""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
|
||||||
|
from tests.shared import FAKE_HASH
|
||||||
|
|
||||||
|
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>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_usernames_basic(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_usernames.fragment_request",
|
||||||
|
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
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("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_usernames.fragment_request",
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_usernames_with_sort_and_filter(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_usernames.fragment_request",
|
||||||
|
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
|
||||||
|
) as mock_request,
|
||||||
|
):
|
||||||
|
result = await client.search_usernames("durov", sort="price_desc", filter="auction")
|
||||||
|
|
||||||
|
assert isinstance(result, UsernamesResult)
|
||||||
|
call_data = mock_request.call_args[0][3]
|
||||||
|
assert call_data["type"] == "usernames"
|
||||||
|
assert call_data["method"] == "searchAuctions"
|
||||||
|
assert call_data["sort"] == "price_desc"
|
||||||
|
assert call_data["filter"] == "auction"
|
||||||
|
assert call_data["query"] == "durov"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_usernames_with_offset_id(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_usernames.fragment_request",
|
||||||
|
AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"}),
|
||||||
|
) as mock_request,
|
||||||
|
):
|
||||||
|
result = await client.search_usernames("durov", offset_id="offset_10")
|
||||||
|
|
||||||
|
assert isinstance(result, UsernamesResult)
|
||||||
|
assert result.next_offset_id == "offset_99"
|
||||||
|
call_data = mock_request.call_args[0][3]
|
||||||
|
assert call_data["offset_id"] == "offset_10"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_usernames_default_query(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_usernames.fragment_request",
|
||||||
|
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
|
||||||
|
) as mock_request,
|
||||||
|
):
|
||||||
|
result = await client.search_usernames()
|
||||||
|
|
||||||
|
assert isinstance(result, UsernamesResult)
|
||||||
|
call_data = mock_request.call_args[0][3]
|
||||||
|
assert call_data["query"] == ""
|
||||||
|
assert call_data["type"] == "usernames"
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""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
|
||||||
|
from tests.shared import FAKE_HASH
|
||||||
|
|
||||||
|
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>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_numbers_basic(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_numbers.fragment_request",
|
||||||
|
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
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("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_numbers.fragment_request",
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_numbers_with_sort_and_filter(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_numbers.fragment_request",
|
||||||
|
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
|
||||||
|
) as mock_request,
|
||||||
|
):
|
||||||
|
result = await client.search_numbers("888", sort="price_asc", filter="sale")
|
||||||
|
|
||||||
|
assert isinstance(result, NumbersResult)
|
||||||
|
call_data = mock_request.call_args[0][3]
|
||||||
|
assert call_data["type"] == "numbers"
|
||||||
|
assert call_data["method"] == "searchAuctions"
|
||||||
|
assert call_data["sort"] == "price_asc"
|
||||||
|
assert call_data["filter"] == "sale"
|
||||||
|
assert call_data["query"] == "888"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_numbers_with_offset_id(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_numbers.fragment_request",
|
||||||
|
AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_50"}),
|
||||||
|
) as mock_request,
|
||||||
|
):
|
||||||
|
result = await client.search_numbers("888", offset_id="offset_50")
|
||||||
|
|
||||||
|
assert isinstance(result, NumbersResult)
|
||||||
|
assert result.next_offset_id == "offset_50"
|
||||||
|
call_data = mock_request.call_args[0][3]
|
||||||
|
assert call_data["offset_id"] == "offset_50"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_numbers_default_query(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_numbers.fragment_request",
|
||||||
|
AsyncMock(return_value={"ok": True, "html": FAKE_HTML}),
|
||||||
|
) as mock_request,
|
||||||
|
):
|
||||||
|
result = await client.search_numbers()
|
||||||
|
|
||||||
|
assert isinstance(result, NumbersResult)
|
||||||
|
call_data = mock_request.call_args[0][3]
|
||||||
|
assert call_data["query"] == ""
|
||||||
|
assert call_data["type"] == "numbers"
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""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
|
||||||
|
from tests.shared import FAKE_HASH
|
||||||
|
|
||||||
|
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>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_gifts_basic(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_gifts.fragment_request",
|
||||||
|
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
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("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_gifts.fragment_request",
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_gifts_with_collection_and_sort(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_gifts.fragment_request",
|
||||||
|
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
|
||||||
|
) as mock_request,
|
||||||
|
):
|
||||||
|
result = await client.search_gifts(collection="plushpepe", sort="price_desc", filter="sold")
|
||||||
|
|
||||||
|
assert isinstance(result, GiftsResult)
|
||||||
|
call_data = mock_request.call_args[0][3]
|
||||||
|
assert call_data["collection"] == "plushpepe"
|
||||||
|
assert call_data["sort"] == "price_desc"
|
||||||
|
assert call_data["filter"] == "sold"
|
||||||
|
assert call_data["type"] == "gifts"
|
||||||
|
assert call_data["method"] == "searchAuctions"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_gifts_with_offset(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_gifts.fragment_request",
|
||||||
|
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
|
||||||
|
) as mock_request,
|
||||||
|
):
|
||||||
|
result = await client.search_gifts(offset=60)
|
||||||
|
|
||||||
|
assert isinstance(result, GiftsResult)
|
||||||
|
call_data = mock_request.call_args[0][3]
|
||||||
|
assert call_data["offset"] == 60
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_gifts_with_view(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_gifts.fragment_request",
|
||||||
|
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
|
||||||
|
) as mock_request,
|
||||||
|
):
|
||||||
|
result = await client.search_gifts(collection="artisanbrick", view="Model")
|
||||||
|
|
||||||
|
assert isinstance(result, GiftsResult)
|
||||||
|
call_data = mock_request.call_args[0][3]
|
||||||
|
assert call_data["view"] == "Model"
|
||||||
|
assert call_data["collection"] == "artisanbrick"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_gifts_with_attr(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_gifts.fragment_request",
|
||||||
|
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
|
||||||
|
) as mock_request,
|
||||||
|
):
|
||||||
|
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_request.call_args[0][3]
|
||||||
|
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:
|
||||||
|
with (
|
||||||
|
patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
|
patch(
|
||||||
|
"pyfragment.methods.search_gifts.fragment_request",
|
||||||
|
AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}),
|
||||||
|
) as mock_request,
|
||||||
|
):
|
||||||
|
result = await client.search_gifts()
|
||||||
|
|
||||||
|
assert isinstance(result, GiftsResult)
|
||||||
|
call_data = mock_request.call_args[0][3]
|
||||||
|
assert "view" not in call_data
|
||||||
|
assert not any(k.startswith("attr[") for k in call_data)
|
||||||
@@ -3,6 +3,9 @@ import os
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def cookies():
|
def cookies():
|
||||||
@@ -14,3 +17,9 @@ def cookies():
|
|||||||
return json.loads(raw)
|
return json.loads(raw)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
pytest.skip(f"Cookies unavailable — {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