mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 06:14:29 +00:00
feat: add anonymous number management methods; implement get_login_code, toggle_login_codes, and terminate_sessions with associated results and error handling
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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})"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"<tr[\s>]")
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user