diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e0980c..80a2dd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `giveaway_premium(channel, winners, months)` — run a Telegram Premium giveaway for a channel (1–24 000 winners, 3/6/12 months each); raises `UserNotFoundError` if the channel is not found on Fragment, `ConfigurationError` if `winners` or `months` are invalid - `StarsGiveawayResult` and `PremiumGiveawayResult` result types +**Telegram Ads** +- `recharge_ads(account, amount)` — add funds to your own Telegram Ads account; `account` is the channel or bot username linked to your Ads account; raises `ConfigurationError` if `amount` is out of range (1–1 000 000 000 TON) +- `AdsRechargeResult` result type + **Raw API access** - `FragmentClient.call(method, data, *, page_url)` — send a raw request to any Fragment API method without waiting for a library update - `FRAGMENT_BASE_URL` constant — single source of truth for the Fragment base URL used across all page constants and headers @@ -32,6 +36,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `examples/giveaway_premium.py` — Premium giveaway with `UserNotFoundError` / `ConfigurationError` handling - `examples/call.py` — raw API call via `client.call()` - `examples/anonymous_number.py` — login code fetch and session termination with `AnonymousNumberError` handling +- `examples/recharge_ads.py` — self-service Ads recharge with `ConfigurationError` / `WalletError` handling ### Changed - All result types (`PremiumResult`, `StarsResult`, `StarsGiveawayResult`, `PremiumGiveawayResult`) now use a single unified `amount` field instead of `months`, `stars` — consistent API across every method @@ -39,6 +44,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - Method module files renamed to match their function: `premium.py` → `purchase_premium.py`, `stars.py` → `purchase_stars.py`, `ton.py` → `topup_ton.py` - `timestamp` field removed from all result dataclasses - All page URL constants (`STARS_PAGE`, `PREMIUM_PAGE`, etc.) now built from `FRAGMENT_BASE_URL` instead of hardcoded strings +- `STARS_REVENUE_PAGE` and `ADS_PAGE` constants replaced by a single `ADS_TOPUP_PAGE` shared by both `topup_ton` and `recharge_ads` - `TransactionError` now includes an SSL hint when a broadcast fails due to certificate verification errors - `TransactionError.DUPLICATE_SEQNO` — dedicated error raised after 3 failed attempts due to a `406 Duplicate msg_seqno` response from the TON network; broadcast is automatically retried (up to 2 retries, 2 s apart) before giving up - All error message templates rewritten to follow a "what happened → why → what to do" pattern — every template is now actionable and includes a fix hint where applicable diff --git a/examples/call.py b/examples/call.py index e0a6a22..6bd9594 100644 --- a/examples/call.py +++ b/examples/call.py @@ -4,8 +4,9 @@ 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 must match the Fragment page the method belongs to — Fragment -requires a hash derived from each specific page. +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 @@ -23,7 +24,7 @@ COOKIES = { 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 +PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragment page (optional) async def main() -> None: diff --git a/examples/recharge_ads.py b/examples/recharge_ads.py new file mode 100644 index 0000000..50d0a44 --- /dev/null +++ b/examples/recharge_ads.py @@ -0,0 +1,40 @@ +""" +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()) diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index 6fa5225..ac2af4f 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -7,6 +7,7 @@ from importlib.metadata import version from pyfragment.client import FragmentClient from pyfragment.types import ( + AdsRechargeResult, AdsTopupResult, AnonymousNumberError, ClientError, @@ -36,6 +37,7 @@ __version__: str = version("pyfragment") __all__ = [ "__version__", "FragmentClient", + "AdsRechargeResult", "AdsTopupResult", "LoginCodeResult", "PremiumGiveawayResult", diff --git a/pyfragment/client.py b/pyfragment/client.py index d900568..66a1fe3 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -8,8 +8,10 @@ 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.topup_ton import topup_ton from pyfragment.types import ( + AdsRechargeResult, AdsTopupResult, ConfigurationError, CookieError, @@ -153,6 +155,19 @@ class FragmentClient: """ return await topup_ton(self, username, amount, show_sender) + async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult: + """Add funds to your own Telegram Ads account. + + Args: + account: Your Fragment Ads account identifier — the channel or bot username + the Ads account is linked to (e.g. ``"@mychannel"``). + amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. + + Returns: + :class:`AdsRechargeResult` with ``transaction_id`` and ``amount``. + """ + return await recharge_ads(self, account, amount) + async def get_wallet(self) -> WalletInfo: """Return the address, state and balance of the TON wallet. diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py index 71af4f5..719e8d5 100644 --- a/pyfragment/methods/__init__.py +++ b/pyfragment/methods/__init__.py @@ -3,6 +3,7 @@ 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.topup_ton import topup_ton __all__ = [ @@ -11,6 +12,7 @@ __all__ = [ "giveaway_stars", "purchase_premium", "purchase_stars", + "recharge_ads", "terminate_sessions", "toggle_login_codes", "topup_ton", diff --git a/pyfragment/methods/recharge_ads.py b/pyfragment/methods/recharge_ads.py new file mode 100644 index 0000000..7d27669 --- /dev/null +++ b/pyfragment/methods/recharge_ads.py @@ -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 diff --git a/pyfragment/methods/topup_ton.py b/pyfragment/methods/topup_ton.py index 95528f5..cebe7c3 100644 --- a/pyfragment/methods/topup_ton.py +++ b/pyfragment/methods/topup_ton.py @@ -11,7 +11,7 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) -from pyfragment.types.constants import DEVICE, TON_PAGE +from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE from pyfragment.utils import ( execute_transaction_request, fragment_request, @@ -24,7 +24,7 @@ from pyfragment.utils import ( if TYPE_CHECKING: from pyfragment.client import FragmentClient -HEADERS: dict[str, str] = make_headers(TON_PAGE) +HEADERS: dict[str, str] = make_headers(ADS_TOPUP_PAGE) async def _search_recipient( @@ -92,7 +92,7 @@ async def topup_ton(client: "FragmentClient", username: str, amount: int, show_s raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, TON_PAGE, client.timeout) + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, ADS_TOPUP_PAGE, client.timeout) account = await get_account_info(client) async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py index 395d68f..6d77339 100644 --- a/pyfragment/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -15,6 +15,7 @@ from pyfragment.types.exceptions import ( WalletError, ) from pyfragment.types.results import ( + AdsRechargeResult, AdsTopupResult, LoginCodeResult, PremiumGiveawayResult, @@ -43,6 +44,7 @@ __all__ = [ "VerificationError", "WalletError", # result types + "AdsRechargeResult", "AdsTopupResult", "LoginCodeResult", "PremiumGiveawayResult", diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index 86c9f12..29896d3 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -25,7 +25,7 @@ STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy" STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway" PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift" PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway" -TON_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" +ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers" # Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py index a91d7a7..f73d5a7 100644 --- a/pyfragment/types/results.py +++ b/pyfragment/types/results.py @@ -1,16 +1,5 @@ from dataclasses import dataclass -__all__ = [ - "AdsTopupResult", - "LoginCodeResult", - "PremiumGiveawayResult", - "PremiumResult", - "StarsGiveawayResult", - "StarsResult", - "TerminateSessionsResult", - "WalletInfo", -] - @dataclass class WalletInfo: @@ -105,6 +94,17 @@ class LoginCodeResult: 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`.""" @@ -114,3 +114,16 @@ class TerminateSessionsResult: def __repr__(self) -> str: return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})" + + +__all__ = [ + "AdsRechargeResult", + "AdsTopupResult", + "LoginCodeResult", + "PremiumGiveawayResult", + "PremiumResult", + "StarsGiveawayResult", + "StarsResult", + "TerminateSessionsResult", + "WalletInfo", +] diff --git a/tests/011_test_recharge_ads.py b/tests/011_test_recharge_ads.py new file mode 100644 index 0000000..e1bf593 --- /dev/null +++ b/tests/011_test_recharge_ads.py @@ -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 diff --git a/tests/shared.py b/tests/shared.py index 183ca6d..ee20584 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -32,6 +32,13 @@ FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}} 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 = """