diff --git a/CHANGELOG.md b/CHANGELOG.md
index 14b491b..7e0980c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,13 +10,28 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
## [Unreleased]
### Added
-- `giveaway_stars(channel, winners, amount)` — run a Telegram Stars giveaway for a channel (1–5 winners, 500–1 000 000 stars each)
-- `giveaway_premium(channel, winners, months)` — run a Telegram Premium giveaway for a channel (1–24 000 winners, 3/6/12 months each)
+
+**Giveaways**
+- `giveaway_stars(channel, winners, amount)` — run a Telegram Stars giveaway for a channel (1–5 winners, 500–1 000 000 stars each); raises `UserNotFoundError` if the channel is not found on Fragment, `ConfigurationError` if `winners` or `amount` are out of range
+- `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
-- `STARS_GIVEAWAY_PAGE` and `PREMIUM_GIVEAWAY_PAGE` URL constants
+
+**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
-- `examples/call.py` — usage example for `client.call()`
+
+**Anonymous number management**
+- `get_login_code(number)` — fetch the current pending login code for an anonymous number
+- `toggle_login_codes(number, can_receive)` — enable or disable login code delivery for an anonymous number
+- `terminate_sessions(number)` — terminate all active Telegram sessions for an anonymous number (two-step flow handled internally)
+- `LoginCodeResult` and `TerminateSessionsResult` result types
+- `AnonymousNumberError` exception — raised when a number is not owned or session termination fails
+
+**Examples**
+- `examples/giveaway_stars.py` — Stars giveaway with `UserNotFoundError` / `ConfigurationError` handling
+- `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
### Changed
- All result types (`PremiumResult`, `StarsResult`, `StarsGiveawayResult`, `PremiumGiveawayResult`) now use a single unified `amount` field instead of `months`, `stars` — consistent API across every method
diff --git a/examples/anonymous_number.py b/examples/anonymous_number.py
new file mode 100644
index 0000000..3355b22
--- /dev/null
+++ b/examples/anonymous_number.py
@@ -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())
diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py
index a3d92e8..6fa5225 100644
--- a/pyfragment/__init__.py
+++ b/pyfragment/__init__.py
@@ -8,18 +8,21 @@ from importlib.metadata import version
from pyfragment.client import FragmentClient
from pyfragment.types import (
AdsTopupResult,
+ AnonymousNumberError,
ClientError,
ConfigurationError,
CookieError,
FragmentAPIError,
FragmentError,
FragmentPageError,
+ LoginCodeResult,
OperationError,
ParseError,
PremiumGiveawayResult,
PremiumResult,
StarsGiveawayResult,
StarsResult,
+ TerminateSessionsResult,
TransactionError,
UnexpectedError,
UserNotFoundError,
@@ -34,10 +37,12 @@ __all__ = [
"__version__",
"FragmentClient",
"AdsTopupResult",
+ "LoginCodeResult",
"PremiumGiveawayResult",
"PremiumResult",
"StarsGiveawayResult",
"StarsResult",
+ "TerminateSessionsResult",
"WalletInfo",
"ClientError",
"ConfigurationError",
@@ -45,6 +50,7 @@ __all__ = [
"FragmentAPIError",
"FragmentError",
"FragmentPageError",
+ "AnonymousNumberError",
"OperationError",
"ParseError",
"TransactionError",
diff --git a/pyfragment/client.py b/pyfragment/client.py
index 4e544a1..d900568 100644
--- a/pyfragment/client.py
+++ b/pyfragment/client.py
@@ -3,6 +3,7 @@ from typing import Any, cast
import httpx
+from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes
from pyfragment.methods.giveaway_premium import giveaway_premium
from pyfragment.methods.giveaway_stars import giveaway_stars
from pyfragment.methods.purchase_premium import purchase_premium
@@ -12,8 +13,10 @@ from pyfragment.types import (
AdsTopupResult,
ConfigurationError,
CookieError,
+ LoginCodeResult,
PremiumResult,
StarsResult,
+ TerminateSessionsResult,
WalletInfo,
)
from pyfragment.types.constants import (
@@ -197,6 +200,41 @@ class FragmentClient:
"""
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 call(
self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL
) -> dict[str, Any]:
diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py
index 21ddeee..71af4f5 100644
--- a/pyfragment/methods/__init__.py
+++ b/pyfragment/methods/__init__.py
@@ -1,7 +1,17 @@
+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.topup_ton import topup_ton
-__all__ = ["giveaway_premium", "giveaway_stars", "purchase_premium", "purchase_stars", "topup_ton"]
+__all__ = [
+ "get_login_code",
+ "giveaway_premium",
+ "giveaway_stars",
+ "purchase_premium",
+ "purchase_stars",
+ "terminate_sessions",
+ "toggle_login_codes",
+ "topup_ton",
+]
diff --git a/pyfragment/methods/anonymous_number.py b/pyfragment/methods/anonymous_number.py
new file mode 100644
index 0000000..b8facfe
--- /dev/null
+++ b/pyfragment/methods/anonymous_number.py
@@ -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
diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py
index 5de01d6..395d68f 100644
--- a/pyfragment/types/__init__.py
+++ b/pyfragment/types/__init__.py
@@ -1,4 +1,5 @@
from pyfragment.types.exceptions import (
+ AnonymousNumberError,
ClientError,
ConfigurationError,
CookieError,
@@ -15,10 +16,12 @@ from pyfragment.types.exceptions import (
)
from pyfragment.types.results import (
AdsTopupResult,
+ LoginCodeResult,
PremiumGiveawayResult,
PremiumResult,
StarsGiveawayResult,
StarsResult,
+ TerminateSessionsResult,
WalletInfo,
)
@@ -31,6 +34,7 @@ __all__ = [
"FragmentAPIError",
"FragmentError",
"FragmentPageError",
+ "AnonymousNumberError",
"OperationError",
"ParseError",
"TransactionError",
@@ -40,9 +44,11 @@ __all__ = [
"WalletError",
# result types
"AdsTopupResult",
+ "LoginCodeResult",
"PremiumGiveawayResult",
"PremiumResult",
"StarsGiveawayResult",
"StarsResult",
+ "TerminateSessionsResult",
"WalletInfo",
]
diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py
index b5f64fe..86c9f12 100644
--- a/pyfragment/types/constants.py
+++ b/pyfragment/types/constants.py
@@ -26,6 +26,7 @@ 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"
+NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers"
# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload.
DEVICE: str = json.dumps(
diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py
index 3c5f390..5207a94 100644
--- a/pyfragment/types/exceptions.py
+++ b/pyfragment/types/exceptions.py
@@ -67,6 +67,13 @@ class UserNotFoundError(FragmentAPIError):
)
+class AnonymousNumberError(FragmentAPIError):
+ """Raised for Fragment anonymous number API failures."""
+
+ NOT_OWNED = "Number '{number}' is not associated with your Fragment account or has no active sessions to terminate."
+ TERMINATE_FAILED = "Failed to terminate sessions for '{number}': {error}"
+
+
class TransactionError(FragmentAPIError):
"""Raised when a TON transaction fails to build or broadcast."""
@@ -133,6 +140,7 @@ __all__ = [
"CookieError",
"FragmentAPIError",
"FragmentPageError",
+ "AnonymousNumberError",
"UserNotFoundError",
"TransactionError",
"ParseError",
diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py
index 1cf129f..a91d7a7 100644
--- a/pyfragment/types/results.py
+++ b/pyfragment/types/results.py
@@ -1,6 +1,15 @@
from dataclasses import dataclass
-__all__ = ["AdsTopupResult", "PremiumGiveawayResult", "PremiumResult", "StarsGiveawayResult", "StarsResult", "WalletInfo"]
+__all__ = [
+ "AdsTopupResult",
+ "LoginCodeResult",
+ "PremiumGiveawayResult",
+ "PremiumResult",
+ "StarsGiveawayResult",
+ "StarsResult",
+ "TerminateSessionsResult",
+ "WalletInfo",
+]
@dataclass
@@ -81,3 +90,27 @@ class PremiumGiveawayResult:
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 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})"
diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py
index 406d506..4c092bb 100644
--- a/pyfragment/utils/__init__.py
+++ b/pyfragment/utils/__init__.py
@@ -1,4 +1,5 @@
from pyfragment.utils.decoder import clean_decode
+from pyfragment.utils.html import parse_login_code
from pyfragment.utils.http import (
execute_transaction_request,
fragment_request,
@@ -10,6 +11,7 @@ from pyfragment.utils.wallet import get_account_info, process_transaction
__all__ = [
"clean_decode",
+ "parse_login_code",
"execute_transaction_request",
"fragment_request",
"get_account_info",
diff --git a/pyfragment/utils/html.py b/pyfragment/utils/html.py
new file mode 100644
index 0000000..30004f4
--- /dev/null
+++ b/pyfragment/utils/html.py
@@ -0,0 +1,23 @@
+import re
+
+# 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"
]")
+
+
+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 ``
``
+ 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
diff --git a/tests/010_test_number.py b/tests/010_test_number.py
new file mode 100644
index 0000000..4138b4f
--- /dev/null
+++ b/tests/010_test_number.py
@@ -0,0 +1,179 @@
+"""Unit tests for number methods — get_login_code, toggle_login_codes, terminate_sessions."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from pyfragment import AnonymousNumberError, FragmentClient, LoginCodeResult, TerminateSessionsResult
+from tests.shared import FAKE_HASH
+
+FAKE_HTML_WITH_CODE = """
+
+
+ | 12345 |
+
+
+ | session data |
+
+
+"""
+
+FAKE_HTML_NO_CODE = ""
+
+FAKE_TERMINATE_HASH = "terminate_hash_abc123"
+
+
+# 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
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"