Skip to content

Commit

Permalink
feat: add inline user search (#97)
Browse files Browse the repository at this point in the history
  • Loading branch information
erfjab authored Nov 9, 2024
1 parent dab10e7 commit b5c4dea
Show file tree
Hide file tree
Showing 7 changed files with 214 additions and 15 deletions.
1 change: 1 addition & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions routers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
45 changes: 45 additions & 0 deletions routers/inline.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 2 additions & 1 deletion utils/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions utils/lang.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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} <b>Username:</b> <code>{username}</code> [<code>{status}</code>]
📊 <b>Data Used:</b> <code>{date_used}</code> GB [<code>from {data_limit}</code>]
⏳ <b>Date Left:</b> <code>{date_left}</code>
🔄 <b>Reset Strategy:</b> <code>{data_limit_reset_strategy}</code>
📅 <b>Created:</b> <code>{created_at}</code>
🕒 <b>Last Online:</b> <code>{online_at}</code>
🕒 <b>Last Sub update:</b> <code>{sub_update_at}</code>
🔗 <b>Subscription URL:</b> <code>{subscription_url}</code>
"""
# pylint: disable=C0301
ACCOUNT_INFO_ONHOLD: str = """{status_emoji} <b>Username:</b> <code>{username}</code> [<code>{status}</code>]
📊 <b>Data limit:</b> <code>{date_limit}</code> GB
⏳ <b>Date limit:</b> <code>{on_hold_expire_duration}</code>
🔄 <b>Reset Strategy:</b> <code>{data_limit_reset_strategy}</code>
📅 <b>Created:</b> <code>{created_at}</code>
🔗 <b>Subscription URL:</b> <code>{subscription_url}</code>
"""
69 changes: 69 additions & 0 deletions utils/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -13,6 +17,7 @@
Admin,
UserModify,
NodeResponse,
UsersResponse,
)
from db import TokenManager
from utils import EnvSettings, logger
Expand Down Expand Up @@ -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())
85 changes: 73 additions & 12 deletions utils/text_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)

0 comments on commit b5c4dea

Please sign in to comment.