diff --git a/main.py b/main.py index 69a0771..0c6eee8 100644 --- a/main.py +++ b/main.py @@ -56,6 +56,7 @@ async def main() -> None: # Start polling for bot messages try: bot_info = await bot.get_me() + await bot.delete_webhook(True) logger.info("Polling messages for HolderBot [@%s]...", bot_info.username) await dp.start_polling(bot) except (ConnectionError, TimeoutError, asyncio.TimeoutError) as conn_err: diff --git a/routers/__init__.py b/routers/__init__.py index 2fa1d22..89a746b 100644 --- a/routers/__init__.py +++ b/routers/__init__.py @@ -4,9 +4,9 @@ """ from aiogram import Router -from . import base, user, node, users +from . import base, user, node, users, inline -__all__ = ["setup_routers", "base", "user", "node", "users"] +__all__ = ["setup_routers", "base", "user", "node", "users", "inline"] def setup_routers() -> Router: @@ -19,5 +19,6 @@ def setup_routers() -> Router: router.include_router(user.router) router.include_router(node.router) router.include_router(users.router) + router.include_router(inline.router) return router diff --git a/routers/inline.py b/routers/inline.py new file mode 100644 index 0000000..1ef64e6 --- /dev/null +++ b/routers/inline.py @@ -0,0 +1,45 @@ +""" +Inline query handler for the bot. +Provides user search functionality through inline mode. +""" + +from aiogram import Router, types +from aiogram.types import InlineQueryResultArticle, InputTextMessageContent +from marzban import UsersResponse + +from utils import panel, text_info, EnvSettings, BotKeyboards +from db.crud import TokenManager + +router = Router() + + +@router.inline_query() +async def get(query: types.InlineQuery): + """ + Handle inline queries to search and display user information. + """ + text = query.query.strip() + results = [] + + emarz = panel.APIClient(EnvSettings.MARZBAN_ADDRESS) + token = await TokenManager.get() + users: UsersResponse = await emarz.get_users( + search=text, limit=5, token=token.token + ) + + for user in users.users: + user_info = text_info.user_info(user) + + result = InlineQueryResultArticle( + id=user.username, + title=f"{user.username}", + description=f"Status: {user.status}", + input_message_content=InputTextMessageContent( + message_text=user_info, parse_mode="HTML" + ), + reply_markup=BotKeyboards.user(user), + ) + + results.append(result) + + await query.answer(results=results, cache_time=10) diff --git a/utils/keys.py b/utils/keys.py index d03a4ef..9224ba2 100644 --- a/utils/keys.py +++ b/utils/keys.py @@ -227,11 +227,12 @@ def user(user: UserResponse) -> InlineKeyboardMarkup: """ kb = InlineKeyboardBuilder() + kb.button(text=KeyboardTexts.USER_CREATE_LINK_URL, url=user.subscription_url) kb.button( text=KeyboardTexts.USER_CREATE_LINK_COPY, copy_text=CopyTextButton(text=user.subscription_url), ) - return kb.as_markup() + return kb.adjust(1).as_markup() @staticmethod def select_nodes( diff --git a/utils/lang.py b/utils/lang.py index 1413430..1c803f5 100644 --- a/utils/lang.py +++ b/utils/lang.py @@ -25,6 +25,7 @@ class KeyboardTextsFile(BaseSettings): USERS_ADD_INBOUND: str = "βž• Add inbound" USERS_DELETE_INBOUND: str = "βž– Delete inbound" USER_CREATE_LINK_COPY: str = "To copy the link, please click." + USER_CREATE_LINK_URL: str = "πŸ›οΈ Subscription Page" class MessageTextsFile(BaseSettings): @@ -82,3 +83,23 @@ class MessageTextsFile(BaseSettings): USERS_INBOUND_ERROR_UPDATED: str = "❌ Users Inbounds not Updated!" SUCCESS_UPDATED: str = "βœ… Is Updated!" ERROR_UPDATED: str = "❌ Not Updated!" + # pylint: disable=C0301 + ACCOUNT_INFO_ACTIVE: str = """{status_emoji} Username: {username} [{status}] +πŸ“Š Data Used: {date_used} GB [from {data_limit}] +⏳ Date Left: {date_left} +πŸ”„ Reset Strategy: {data_limit_reset_strategy} +πŸ“… Created: {created_at} +πŸ•’ Last Online: {online_at} +πŸ•’ Last Sub update: {sub_update_at} + +πŸ”— Subscription URL: {subscription_url} +""" + # pylint: disable=C0301 + ACCOUNT_INFO_ONHOLD: str = """{status_emoji} Username: {username} [{status}] +πŸ“Š Data limit: {date_limit} GB +⏳ Date limit: {on_hold_expire_duration} +πŸ”„ Reset Strategy: {data_limit_reset_strategy} +πŸ“… Created: {created_at} + +πŸ”— Subscription URL: {subscription_url} +""" diff --git a/utils/panel.py b/utils/panel.py index 16c3b67..cf937c0 100644 --- a/utils/panel.py +++ b/utils/panel.py @@ -3,8 +3,12 @@ including user management, retrieving inbounds, and managing admins. """ +from typing import List, Optional, Dict, Any from datetime import datetime, timedelta + import httpx +from pydantic import BaseModel + from marzban import ( MarzbanAPI, ProxyInbound, @@ -13,6 +17,7 @@ Admin, UserModify, NodeResponse, + UsersResponse, ) from db import TokenManager from utils import EnvSettings, logger @@ -143,3 +148,67 @@ async def get_nodes() -> list[NodeResponse]: except (httpx.RequestError, httpx.HTTPStatusError) as e: logger.error("Error getting all nodes: %s", e) return False + + +class APIClient: + """ + HTTP client for making API requests to the Marzban panel. + """ + + def __init__(self, base_url: str, *, timeout: float = 10.0, verify: bool = False): + self.base_url = base_url + self.client = httpx.AsyncClient( + base_url=base_url, verify=verify, timeout=timeout + ) + + def _get_headers(self, token: str) -> Dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + async def _request( + self, + method: str, + url: str, + token: Optional[str] = None, + data: Optional[BaseModel] = None, + params: Optional[Dict[str, Any]] = None, + ) -> httpx.Response: + headers = self._get_headers(token) if token else {} + json_data = data.model_dump(exclude_none=True) if data else None + params = {k: v for k, v in (params or {}).items() if v is not None} + + response = await self.client.request( + method, url, headers=headers, json=json_data, params=params + ) + response.raise_for_status() + return response + + async def close(self): + """Close HTTP client connection""" + await self.client.aclose() + + async def get_users( + self, + token: str, + offset: int = 0, + limit: int = 50, + username: Optional[List[str]] = None, + status: Optional[str] = None, + sort: Optional[str] = None, + search: Optional[str] = None, + ) -> UsersResponse: + """Get list of users with optional filters""" + headers = {"Authorization": f"Bearer {token}"} + + params = { + "offset": offset, + "limit": limit, + "username": username, + "status": status, + "sort": sort, + "search": search, + } + params = {k: v for k, v in params.items() if v is not None} + + response = await self.client.get("/api/users", headers=headers, params=params) + response.raise_for_status() + return UsersResponse(**response.json()) diff --git a/utils/text_info.py b/utils/text_info.py index 9e23cbc..c69e9e2 100644 --- a/utils/text_info.py +++ b/utils/text_info.py @@ -3,23 +3,84 @@ for display, including user status, data limit, subscription, etc. """ -from datetime import datetime +from typing import Optional +from datetime import datetime, timezone from marzban import UserResponse from utils import MessageTexts def user_info(user: UserResponse) -> str: """ - Formats the user information for display. + Formats user information with detailed time remaining display. """ - return (MessageTexts.USER_INFO).format( - status_emoji="🟣" if user.status == "on_hold" else "🟒", - username=user.username, - data_limit=round((user.data_limit / (1024**3)), 3), - date_limit=( - int(user.on_hold_expire_duration / (24 * 60 * 60)) - if user.status == "on_hold" - else (user.expire - datetime.utcnow().timestamp()) // (24 * 60 * 60) - ), - subscription=user.subscription_url, + + def format_traffic(bytes_val: Optional[int]) -> str: + if not bytes_val and bytes_val != 0: + return "♾️" + return f"{round(bytes_val / (1024**3), 1)}" + + def format_time_remaining(timestamp: Optional[int]) -> str: + if not timestamp: + return "♾️" + + now = datetime.now(timezone.utc) + expire_date = datetime.fromtimestamp(timestamp, tz=timezone.utc) + + if now > expire_date: + return "Expired" + + diff = expire_date - now + days = diff.days + hours = diff.seconds // 3600 + minutes = (diff.seconds % 3600) // 60 + + if days > 0: + return f"{days}d {hours}h {minutes}m" + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m" + + def format_ago(dt_str: Optional[str]) -> str: + if not dt_str: + return "βž–" + try: + dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + + diff = datetime.now(timezone.utc) - dt + days = diff.days + hours = diff.seconds // 3600 + minutes = (diff.seconds % 3600) // 60 + + if days > 0: + return f"{days}d ago" + if hours > 0: + return f"{hours}h ago" + return f"{minutes}m ago" + except ValueError: + return "Invalid date" + + status_emojis = {"on_hold": "🟣", "active": "🟒"} + template = ( + MessageTexts.ACCOUNT_INFO_ONHOLD + if user.status == "on_hold" + else MessageTexts.ACCOUNT_INFO_ACTIVE + ) + + return template.format( + username=user.username or "Unknown", + status=user.status or "unknown", + status_emoji=status_emojis.get(user.status, "πŸ”΄"), + data_used=format_traffic(user.used_traffic), + data_limit=format_traffic(user.data_limit), + date_used=format_traffic(user.used_traffic), + date_limit=format_traffic(user.data_limit), + date_left=format_time_remaining(user.expire), + data_limit_reset_strategy=user.data_limit_reset_strategy or "None", + created_at=format_ago(user.created_at), + online_at=format_ago(user.online_at), + sub_update_at=format_ago(user.sub_updated_at), + subscription_url=user.subscription_url or "None", + on_hold_expire_duration=round((user.on_hold_expire_duration or 0) / 86400, 1), )