mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 06:14:29 +00:00
Merge pull request #5 from bohd4nx/refactor/tonutils-v2
Refactor/tonutils v2
This commit is contained in:
@@ -28,12 +28,4 @@ jobs:
|
||||
COOKIES_JSON: ${{ secrets.COOKIES_JSON }}
|
||||
|
||||
- name: Run tests
|
||||
if: github.ref != 'refs/heads/master'
|
||||
run: pytest --ignore=tests/003_test_integration.py
|
||||
|
||||
- name: Run tests (master — full suite)
|
||||
if: github.ref == 'refs/heads/master'
|
||||
run: pytest
|
||||
env:
|
||||
SEED: ${{ secrets.SEED }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
|
||||
@@ -134,8 +134,8 @@ async def main():
|
||||
result = await topup_ton("@username", 10)
|
||||
print(result)
|
||||
|
||||
# Gift 6 months of Telegram Premium
|
||||
result = await buy_premium("@username", 6)
|
||||
# Gift 6 months of Telegram Premium (anonymous — recipient won't see sender)
|
||||
result = await buy_premium("@username", 6, show_sender=False)
|
||||
print(result)
|
||||
|
||||
# Buy 500 Stars for @username
|
||||
@@ -170,11 +170,11 @@ asyncio.run(main())
|
||||
|
||||
### Supported Operations
|
||||
|
||||
| Operation | Function | Parameters | Limits |
|
||||
| ------------------ | ------------------------------- | ---------------------- | ------------------- |
|
||||
| **TON Topup** | `topup_ton(username, amount)` | Username, TON amount | 1–1,000,000,000 TON |
|
||||
| **Premium Gift** | `buy_premium(username, months)` | Username, duration | 3, 6, or 12 months |
|
||||
| **Stars Purchase** | `buy_stars(username, amount)` | Username, Stars amount | 50–1,000,000 Stars |
|
||||
| Operation | Function | Parameters | Limits |
|
||||
| ------------------ | ----------------------------------------------------- | ----------------------------------- | ------------------- |
|
||||
| **TON Topup** | `topup_ton(username, amount, show_sender=True)` | Username, TON amount, show sender | 1–1,000,000,000 TON |
|
||||
| **Premium Gift** | `buy_premium(username, months, show_sender=True)` | Username, duration, show sender | 3, 6, or 12 months |
|
||||
| **Stars Purchase** | `buy_stars(username, amount, show_sender=True)` | Username, Stars amount, show sender | 50–1,000,000 Stars |
|
||||
|
||||
Usernames can be passed with or without `@`.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from app.core.constants import (
|
||||
PREMIUM_PAGE,
|
||||
STARS_PAGE,
|
||||
WALLET_CLASSES,
|
||||
WalletVersion,
|
||||
)
|
||||
from app.core.cookies import load_cookies
|
||||
from app.core.exceptions import (
|
||||
@@ -27,6 +28,7 @@ __all__ = [
|
||||
"PREMIUM_PAGE",
|
||||
"STARS_PAGE",
|
||||
"WALLET_CLASSES",
|
||||
"WalletVersion",
|
||||
"ConfigError",
|
||||
"CookiesError",
|
||||
"FragmentError",
|
||||
|
||||
+2
-6
@@ -1,17 +1,14 @@
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from app.core.constants import SUPPORTED_WALLET_VERSIONS
|
||||
from app.core.constants import SUPPORTED_WALLET_VERSIONS, WalletVersion
|
||||
from app.core.exceptions import ConfigError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WalletVersion = Literal["V4R2", "V5R1"]
|
||||
|
||||
|
||||
class Config:
|
||||
SEED: str
|
||||
@@ -37,8 +34,7 @@ class Config:
|
||||
version = os.getenv("WALLET_VERSION", "V5R1").strip().upper()
|
||||
if version not in SUPPORTED_WALLET_VERSIONS:
|
||||
raise ConfigError(
|
||||
f"Unsupported WALLET_VERSION '{version}'. "
|
||||
f"Must be one of: {', '.join(sorted(SUPPORTED_WALLET_VERSIONS))}."
|
||||
f"Unsupported WALLET_VERSION '{version}'. " f"Must be one of: {', '.join(sorted(SUPPORTED_WALLET_VERSIONS))}."
|
||||
)
|
||||
self.WALLET_VERSION: WalletVersion = version # type: ignore[assignment]
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import json
|
||||
from typing import Literal, get_args
|
||||
|
||||
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
|
||||
|
||||
# Supported TON wallet contract versions
|
||||
SUPPORTED_WALLET_VERSIONS: set[str] = {"V4R2", "V5R1"}
|
||||
# Single source of truth for supported wallet versions
|
||||
WalletVersion = Literal["V4R2", "V5R1"]
|
||||
SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion))
|
||||
|
||||
# Wallet class map — used to resolve the correct contract from WALLET_VERSION
|
||||
WALLET_CLASSES: dict[str, type] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
|
||||
|
||||
+1
-3
@@ -14,9 +14,7 @@ def load_cookies() -> dict[str, Any]:
|
||||
cookies_path = Path(__file__).resolve().parents[2] / "cookies.json"
|
||||
|
||||
if not cookies_path.exists():
|
||||
raise CookiesError(
|
||||
"cookies.json not found. Create it in the project root and paste your Fragment cookies."
|
||||
)
|
||||
raise CookiesError("cookies.json not found. Create it in the project root and paste your Fragment cookies.")
|
||||
|
||||
try:
|
||||
with cookies_path.open("r", encoding="utf-8") as f:
|
||||
|
||||
+1
-3
@@ -2,9 +2,7 @@ import logging
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
formatter = logging.Formatter(
|
||||
fmt="[%(asctime)s] - %(levelname)s: %(message)s", datefmt="%d.%m.%y %H:%M:%S"
|
||||
)
|
||||
formatter = logging.Formatter(fmt="[%(asctime)s] - %(levelname)s: %(message)s", datefmt="%d.%m.%y %H:%M:%S")
|
||||
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
|
||||
+21
-9
@@ -39,7 +39,11 @@ async def search_premium_recipient(
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={"query": username, "months": months, "method": "searchPremiumGiftRecipient"},
|
||||
data={
|
||||
"query": username,
|
||||
"months": months,
|
||||
"method": "searchPremiumGiftRecipient",
|
||||
},
|
||||
)
|
||||
result = parse_json_response(resp, "searchPremiumGiftRecipient")
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
@@ -70,7 +74,11 @@ async def init_gift_premium(
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={"recipient": recipient, "months": months, "method": "initGiftPremiumRequest"},
|
||||
data={
|
||||
"recipient": recipient,
|
||||
"months": months,
|
||||
"method": "initGiftPremiumRequest",
|
||||
},
|
||||
)
|
||||
result = parse_json_response(resp, "initGiftPremiumRequest")
|
||||
req_id = result.get("req_id")
|
||||
@@ -82,9 +90,12 @@ async def init_gift_premium(
|
||||
return req_id
|
||||
|
||||
|
||||
async def buy_premium(username: str, months: int) -> dict:
|
||||
async def buy_premium(username: str, months: int, show_sender: bool = True) -> dict:
|
||||
if months not in (3, 6, 12):
|
||||
return {"success": False, "error": "Invalid duration. Choose 3, 6, or 12 months."}
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid duration. Choose 3, 6, or 12 months.",
|
||||
}
|
||||
|
||||
try:
|
||||
logger.info("Loading session cookies")
|
||||
@@ -109,17 +120,18 @@ async def buy_premium(username: str, months: int) -> dict:
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": 1,
|
||||
"show_sender": int(show_sender),
|
||||
"method": "getGiftPremiumLink",
|
||||
}
|
||||
transaction = await execute_transaction_request(
|
||||
client, HEADERS, account, tx_data, fragment_hash
|
||||
)
|
||||
transaction = await execute_transaction_request(client, HEADERS, account, tx_data, fragment_hash)
|
||||
|
||||
logger.info("Broadcasting transaction to TON blockchain")
|
||||
tx_hash = await process_transaction(transaction)
|
||||
logger.info(
|
||||
"Premium purchase successful: %s months -> %s | tx: %s", months, username, tx_hash
|
||||
"Premium purchase successful: %s months -> %s | tx: %s",
|
||||
months,
|
||||
username,
|
||||
tx_hash,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
+14
-7
@@ -59,7 +59,11 @@ async def init_buy_stars(
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={"recipient": recipient, "quantity": amount, "method": "initBuyStarsRequest"},
|
||||
data={
|
||||
"recipient": recipient,
|
||||
"quantity": amount,
|
||||
"method": "initBuyStarsRequest",
|
||||
},
|
||||
)
|
||||
result = parse_json_response(resp, "initBuyStarsRequest")
|
||||
req_id = result.get("req_id")
|
||||
@@ -71,7 +75,7 @@ async def init_buy_stars(
|
||||
return req_id
|
||||
|
||||
|
||||
async def buy_stars(username: str, amount: int) -> dict:
|
||||
async def buy_stars(username: str, amount: int, show_sender: bool = True) -> dict:
|
||||
if not isinstance(amount, int) or amount < 50:
|
||||
return {"success": False, "error": "Amount must be an integer >= 50 stars."}
|
||||
|
||||
@@ -98,16 +102,19 @@ async def buy_stars(username: str, amount: int) -> dict:
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": 1,
|
||||
"show_sender": int(show_sender),
|
||||
"method": "getBuyStarsLink",
|
||||
}
|
||||
transaction = await execute_transaction_request(
|
||||
client, HEADERS, account, tx_data, fragment_hash
|
||||
)
|
||||
transaction = await execute_transaction_request(client, HEADERS, account, tx_data, fragment_hash)
|
||||
|
||||
logger.info("Broadcasting transaction to TON blockchain")
|
||||
tx_hash = await process_transaction(transaction)
|
||||
logger.info("Stars purchase successful: %s stars -> %s | tx: %s", amount, username, tx_hash)
|
||||
logger.info(
|
||||
"Stars purchase successful: %s stars -> %s | tx: %s",
|
||||
amount,
|
||||
username,
|
||||
tx_hash,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
|
||||
+17
-9
@@ -4,7 +4,14 @@ import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core import ADS_PAGE, BASE_HEADERS, DEVICE, FragmentError, UserNotFoundError, load_cookies
|
||||
from app.core import (
|
||||
ADS_PAGE,
|
||||
BASE_HEADERS,
|
||||
DEVICE,
|
||||
FragmentError,
|
||||
UserNotFoundError,
|
||||
load_cookies,
|
||||
)
|
||||
from app.utils import (
|
||||
execute_transaction_request,
|
||||
get_account_info,
|
||||
@@ -57,19 +64,22 @@ async def init_ads_topup(
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={"recipient": recipient, "amount": amount, "method": "initAdsTopupRequest"},
|
||||
data={
|
||||
"recipient": recipient,
|
||||
"amount": amount,
|
||||
"method": "initAdsTopupRequest",
|
||||
},
|
||||
)
|
||||
result = parse_json_response(resp, "initAdsTopupRequest")
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentError(
|
||||
"Fragment did not return a request ID for this TON topup. "
|
||||
"The session may have expired — refresh your cookies."
|
||||
"Fragment did not return a request ID for this TON topup. " "The session may have expired — refresh your cookies."
|
||||
)
|
||||
return req_id
|
||||
|
||||
|
||||
async def topup_ton(username: str, amount: int) -> dict:
|
||||
async def topup_ton(username: str, amount: int, show_sender: bool = True) -> dict:
|
||||
if not isinstance(amount, int) or amount < 1:
|
||||
return {"success": False, "error": "Amount must be an integer >= 1 TON."}
|
||||
|
||||
@@ -96,12 +106,10 @@ async def topup_ton(username: str, amount: int) -> dict:
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": 1,
|
||||
"show_sender": int(show_sender),
|
||||
"method": "getAdsTopupLink",
|
||||
}
|
||||
transaction = await execute_transaction_request(
|
||||
client, HEADERS, account, tx_data, fragment_hash
|
||||
)
|
||||
transaction = await execute_transaction_request(client, HEADERS, account, tx_data, fragment_hash)
|
||||
|
||||
logger.info("Broadcasting transaction to TON blockchain")
|
||||
tx_hash = await process_transaction(transaction)
|
||||
|
||||
+2
-5
@@ -13,9 +13,7 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any
|
||||
try:
|
||||
return response.json()
|
||||
except Exception as exc:
|
||||
raise RequestError(
|
||||
f"Fragment API returned an unparseable response for '{context}': {exc}"
|
||||
) from exc
|
||||
raise RequestError(f"Fragment API returned an unparseable response for '{context}': {exc}") from exc
|
||||
|
||||
|
||||
async def execute_transaction_request(
|
||||
@@ -33,8 +31,7 @@ async def execute_transaction_request(
|
||||
if transaction.get("need_verify"):
|
||||
if not await link_wallet(client, headers, account, fragment_hash):
|
||||
raise WalletError(
|
||||
"Failed to link your TON wallet to Fragment. "
|
||||
"Make sure the wallet matching your cookies is used."
|
||||
"Failed to link your TON wallet to Fragment. " "Make sure the wallet matching your cookies is used."
|
||||
)
|
||||
resp = await client.post(url, headers=headers, data=tx_data)
|
||||
transaction = parse_json_response(resp, tx_data.get("method", "transaction"))
|
||||
|
||||
+7
-1
@@ -20,7 +20,13 @@ async def get_fragment_hash(
|
||||
k: v
|
||||
for k, v in headers.items()
|
||||
if k
|
||||
not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
|
||||
not in (
|
||||
"accept",
|
||||
"accept-encoding",
|
||||
"content-type",
|
||||
"x-requested-with",
|
||||
"x-aj-referer",
|
||||
)
|
||||
}
|
||||
page_headers.update(
|
||||
{
|
||||
|
||||
+1
-4
@@ -38,10 +38,7 @@ async def process_transaction(transaction_data: dict) -> str:
|
||||
await wallet.refresh()
|
||||
balance_ton = wallet.balance / 1_000_000_000
|
||||
if balance_ton < 0.056:
|
||||
raise WalletError(
|
||||
f"TON wallet balance is too low: {balance_ton:.2f} TON. "
|
||||
"Minimum required is 0.056 TON."
|
||||
)
|
||||
raise WalletError(f"TON wallet balance is too low: {balance_ton:.2f} TON. " "Minimum required is 0.056 TON.")
|
||||
except WalletError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
|
||||
@@ -11,7 +11,8 @@ async def topup_ton_example():
|
||||
logger.info("Starting TON topup example")
|
||||
|
||||
# @bohd4nx - target username, 100 - TON amount (integer 1-1000000000 (one billion))
|
||||
result = await topup_ton("@bohd4nx", 100)
|
||||
# show_sender=True — recipient sees who sent the topup
|
||||
result = await topup_ton("@bohd4nx", 100, show_sender=True)
|
||||
|
||||
if result["success"]:
|
||||
pass # Transaction successful, details are logged in the method
|
||||
@@ -23,7 +24,8 @@ async def buy_premium_example():
|
||||
logger.info("Starting Premium purchase example")
|
||||
|
||||
# @bohd4nx - target username, 12 - months duration (3, 6, or 12 only)
|
||||
result = await buy_premium("@bohd4nx", 12)
|
||||
# show_sender=True — recipient sees who gifted the Premium
|
||||
result = await buy_premium("@bohd4nx", 12, show_sender=True)
|
||||
|
||||
if result["success"]:
|
||||
pass # Transaction successful, details are logged in the method
|
||||
@@ -35,7 +37,8 @@ async def buy_stars_example():
|
||||
logger.info("Starting Stars purchase example")
|
||||
|
||||
# @bohd4nx - target username, 1000000 - stars amount (integer 50-1000000 (one million))
|
||||
result = await buy_stars("@bohd4nx", 1000000)
|
||||
# show_sender=True — recipient sees who sent the Stars
|
||||
result = await buy_stars("@bohd4nx", 1000000, show_sender=True)
|
||||
|
||||
if result["success"]:
|
||||
pass # Transaction successful, details are logged in the method
|
||||
|
||||
+8
-6
@@ -4,14 +4,16 @@ python_files = ["[0-9][0-9][0-9]_test_*.py"]
|
||||
asyncio_mode = "auto"
|
||||
addopts = "-v --tb=short"
|
||||
|
||||
[tool.black]
|
||||
line-length = 128
|
||||
target-version = ["py312"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
src = ["app", "tests"]
|
||||
line-length = 128
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "W", "F", "I", "UP", "B", "C4", "RUF"]
|
||||
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort
|
||||
select = ["E", "F", "W", "I"]
|
||||
# E501 — line too long (covered by line-length above)
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["app"]
|
||||
@@ -1,13 +0,0 @@
|
||||
"""End-to-end integration test: buy 50 Stars for @bohd4nx.
|
||||
|
||||
Requires cookies.json and a valid .env (API_KEY + SEED).
|
||||
Auto-skipped when cookies or config are unavailable (e.g. CI without secrets).
|
||||
"""
|
||||
|
||||
from app.methods.stars import buy_stars
|
||||
|
||||
|
||||
async def test_buy_stars_e2e(cookies, tests_config):
|
||||
result = await buy_stars("@bohd4nx", 50)
|
||||
assert result["success"] is True, result.get("error")
|
||||
assert result["data"]["transaction_id"]
|
||||
@@ -1,6 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from app.core.config import config
|
||||
from app.core.cookies import load_cookies
|
||||
from app.core.exceptions import CookiesError
|
||||
|
||||
@@ -12,11 +11,3 @@ def cookies():
|
||||
return load_cookies()
|
||||
except CookiesError as exc:
|
||||
pytest.skip(f"Cookies unavailable — {exc}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tests_config():
|
||||
"""Require a fully configured environment (SEED + API_KEY); skip otherwise."""
|
||||
if config is None:
|
||||
pytest.skip("Config unavailable — set SEED and API_KEY in .env or environment")
|
||||
return config
|
||||
|
||||
Reference in New Issue
Block a user