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),
)