diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b556946..d698a55 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 }} diff --git a/README.md b/README.md index f1aeb84..280f9a1 100644 --- a/README.md +++ b/README.md @@ -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 `@`. diff --git a/app/core/__init__.py b/app/core/__init__.py index 4388f7a..c31b827 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -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", diff --git a/app/core/config.py b/app/core/config.py index 37b5e4a..52811ee 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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] diff --git a/app/core/constants.py b/app/core/constants.py index 41812a7..243edb5 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -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} diff --git a/app/core/cookies.py b/app/core/cookies.py index 1e2d4a0..4f43be0 100644 --- a/app/core/cookies.py +++ b/app/core/cookies.py @@ -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: diff --git a/app/core/logging.py b/app/core/logging.py index 08c986f..f701f52 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -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) diff --git a/app/methods/premium.py b/app/methods/premium.py index 05f8621..798bc6e 100644 --- a/app/methods/premium.py +++ b/app/methods/premium.py @@ -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, diff --git a/app/methods/stars.py b/app/methods/stars.py index f7d6ec9..af21079 100644 --- a/app/methods/stars.py +++ b/app/methods/stars.py @@ -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": { diff --git a/app/methods/ton.py b/app/methods/ton.py index 7cd985d..ee5d49f 100644 --- a/app/methods/ton.py +++ b/app/methods/ton.py @@ -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) diff --git a/app/utils/client.py b/app/utils/client.py index 434da6c..2ed4624 100644 --- a/app/utils/client.py +++ b/app/utils/client.py @@ -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")) diff --git a/app/utils/hash.py b/app/utils/hash.py index 790e683..bf4f380 100644 --- a/app/utils/hash.py +++ b/app/utils/hash.py @@ -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( { diff --git a/app/utils/wallet.py b/app/utils/wallet.py index 021575d..96ca4f4 100644 --- a/app/utils/wallet.py +++ b/app/utils/wallet.py @@ -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: diff --git a/main.py b/main.py index 884b7f7..4c85964 100644 --- a/main.py +++ b/main.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index fc02a18..48cb023 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] \ No newline at end of file diff --git a/tests/003_test_integration.py b/tests/003_test_integration.py deleted file mode 100644 index 0fa44a3..0000000 --- a/tests/003_test_integration.py +++ /dev/null @@ -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"] diff --git a/tests/conftest.py b/tests/conftest.py index 8c02ca9..ac14129 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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