diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index 68d59b3..0000000
--- a/.dockerignore
+++ /dev/null
@@ -1,19 +0,0 @@
-**/node_modules
-__pycache__
-*.pyc
-*.pyo
-*.pyd
-.env*
-db.sqlite3
-db.sqlite3-journal
-*.log
-*.git
-*.github
-docker-compose.yml
-docker-compose.debug.yml
-Makefile
-openapi-generator-config.yaml
-openapi.json
-README.md
-.gitignore
-*.env*
\ No newline at end of file
diff --git a/.env.example b/.env.example
deleted file mode 100644
index 5a31b3d..0000000
--- a/.env.example
+++ /dev/null
@@ -1,6 +0,0 @@
-TELEGRAM_BOT_TOKEN='123456789:XXXXXXXXXXXXXXXXXXXXXXXXXXXX'
-TELEGRAM_ADMINS_ID=[123456789 , 987654321]
-MARZBAN_ADDRESS='https://sub.domain.com:port'
-MARZBAN_USERNAME='sudo_username'
-MARZBAN_PASSWORD='sudo_password'
-EXCLUDED_MONITORINGS=['NODE_NAME_ONE' , 'NODE_NAME_TWO']
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 0da652e..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,21 +0,0 @@
-FROM python:3.11.8-alpine
-
-ENV TZ=Asia/Tehran
-
-ENV PYTHONUNBUFFERED=1
-
-WORKDIR /code
-
-COPY ./requirements.txt .
-
-RUN pip install --no-cache-dir --upgrade -r requirements.txt
-
-COPY . .
-
-RUN chmod +x main.py
-
-RUN apk add --no-cache tzdata
-
-RUN cp /usr/share/zoneinfo/Asia/Tehran /etc/localtime && echo "Asia/Tehran" > /etc/timezone
-
-CMD ["sh", "-c", "alembic upgrade head && python3 main.py"]
\ No newline at end of file
diff --git a/README.md b/README.md
deleted file mode 100644
index 423fc68..0000000
--- a/README.md
+++ /dev/null
@@ -1,123 +0,0 @@
-## 1. Server Setup
-
-### 1.1: Update the Server
-
-Ensure your server is up to date:
-
-```bash
-sudo apt update && sudo apt upgrade -y
-```
-
-### 1.2: Install Docker
-
-Install Docker using this command:
-
-```bash
-curl -fsSL https://get.docker.com | sh
-```
-
----
-
-## 2. Download and Configure
-
-### 2.1: Create Directory and Download `docker-compose.yml`
-
-Create the necessary directory and download the `docker-compose.yml` file:
-
-```bash
-mkdir -p /opt/erfjab/holderbot/data
-curl -o /opt/erfjab/holderbot/docker-compose.yml https://raw.githubusercontent.com/erfjab/holderbot/master/docker-compose.yml
-cd /opt/erfjab/holderbot
-```
-
-### 2.2: Download and Configure `.env`
-
-Download the example environment file:
-
-```bash
-curl -o .env https://raw.githubusercontent.com/erfjab/holderbot/master/.env.example
-```
-
-Edit the `.env` file to add your **Telegram Bot Token** and **API keys**:
-
-```bash
-nano .env
-```
-
----
-
-## 3. Run the Bot
-
-### 3.1: Pull the Latest Docker Image
-
-Pull the latest Docker image for the bot:
-
-```bash
-docker compose pull
-```
-
-### 3.2: Start the Bot
-
-Start the bot in detached mode:
-
-```bash
-docker compose up -d
-```
-
-### 3.3: Verify the Bot is Running
-
-Check the status of running containers:
-
-```bash
-docker compose ps
-```
-
----
-
-## Updating the Bot
-
-To update the bot to the latest version:
-
-1. Pull the latest Docker image:
-
- ```bash
- docker compose pull
- ```
-
-2. Restart the bot:
-
- ```bash
- docker compose up -d
- ```
-
----
-
-## Managing the Bot with Docker
-
-### Restart the Bot
-
-```bash
-docker compose restart
-```
-
-### Stop the Bot
-
-```bash
-docker compose down
-```
-
-### View Real-Time Logs
-
-```bash
-docker compose logs -f
-```
-
----
-
-## Contact & Support
-
-- Telegram Channel: [@ErfJabs](https://t.me/ErfJabs)
-
-Feel free to ⭐ the project to show your support!
-
-[![Stargazers over time](https://starchart.cc/erfjab/holderbot.svg?variant=adaptive)](https://starchart.cc/erfjab/holderbot)
\ No newline at end of file
diff --git a/alembic.ini b/alembic.ini
deleted file mode 100644
index 2672310..0000000
--- a/alembic.ini
+++ /dev/null
@@ -1,113 +0,0 @@
-# A generic, single database configuration.
-
-[alembic]
-# path to migration scripts
-# Use forward slashes (/) also on windows to provide an os agnostic path
-script_location = db/alembic
-
-# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
-# Uncomment the line below if you want the files to be prepended with date and time
-# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
-# for all available tokens
-# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
-
-# sys.path path, will be prepended to sys.path if present.
-# defaults to the current working directory.
-prepend_sys_path = .
-
-# timezone to use when rendering the date within the migration file
-# as well as the filename.
-# If specified, requires the python>=3.9 or backports.zoneinfo library.
-# Any required deps can installed by adding `alembic[tz]` to the pip requirements
-# string value is passed to ZoneInfo()
-# leave blank for localtime
-# timezone =
-
-# max length of characters to apply to the "slug" field
-# truncate_slug_length = 40
-
-# set to 'true' to run the environment during
-# the 'revision' command, regardless of autogenerate
-# revision_environment = false
-
-# set to 'true' to allow .pyc and .pyo files without
-# a source .py file to be detected as revisions in the
-# versions/ directory
-# sourceless = false
-
-# version location specification; This defaults
-# to alembic/versions. When using multiple version
-# directories, initial revisions must be specified with --version-path.
-# The path separator used here should be the separator specified by "version_path_separator" below.
-# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
-
-# version path separator; As mentioned above, this is the character used to split
-# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
-# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
-# Valid values for version_path_separator are:
-#
-# version_path_separator = :
-# version_path_separator = ;
-# version_path_separator = space
-version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
-
-# set to 'true' to search source files recursively
-# in each "version_locations" directory
-# new in Alembic version 1.10
-# recursive_version_locations = false
-
-# the output encoding used when revision files
-# are written from script.py.mako
-# output_encoding = utf-8
-
-[post_write_hooks]
-# post_write_hooks defines scripts or Python functions that are run
-# on newly generated revision scripts. See the documentation for further
-# detail and examples
-
-# format using "black" - use the console_scripts runner, against the "black" entrypoint
-# hooks = black
-# black.type = console_scripts
-# black.entrypoint = black
-# black.options = -l 79 REVISION_SCRIPT_FILENAME
-
-# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
-# hooks = ruff
-# ruff.type = exec
-# ruff.executable = %(here)s/.venv/bin/ruff
-# ruff.options = --fix REVISION_SCRIPT_FILENAME
-
-# Logging configuration
-[loggers]
-keys = root,sqlalchemy,alembic
-
-[handlers]
-keys = console
-
-[formatters]
-keys = generic
-
-[logger_root]
-level = WARN
-handlers = console
-qualname =
-
-[logger_sqlalchemy]
-level = WARN
-handlers =
-qualname = sqlalchemy.engine
-
-[logger_alembic]
-level = INFO
-handlers =
-qualname = alembic
-
-[handler_console]
-class = StreamHandler
-args = (sys.stderr,)
-level = NOTSET
-formatter = generic
-
-[formatter_generic]
-format = %(levelname)-5.5s [%(name)s] %(message)s
-datefmt = %H:%M:%S
diff --git a/db/__init__.py b/db/__init__.py
deleted file mode 100644
index 5d5329a..0000000
--- a/db/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""Database module initialization."""
-
-from .base import Base, get_db
-from .crud import TokenManager
-from .models import Token
-
-__all__ = ["Base", "get_db", "TokenManager", "Token"]
diff --git a/db/alembic/README b/db/alembic/README
deleted file mode 100644
index 98e4f9c..0000000
--- a/db/alembic/README
+++ /dev/null
@@ -1 +0,0 @@
-Generic single-database configuration.
\ No newline at end of file
diff --git a/db/alembic/env.py b/db/alembic/env.py
deleted file mode 100644
index 07ebda8..0000000
--- a/db/alembic/env.py
+++ /dev/null
@@ -1,92 +0,0 @@
-# pylint: disable=all
-
-"""
-Alembic environment configuration for running database migrations.
-
-This module configures and runs Alembic migrations for the database, supporting both
-synchronous (offline) and asynchronous (online) migration modes.
-"""
-
-import asyncio
-from logging.config import fileConfig
-
-from alembic import context
-from sqlalchemy import pool
-from sqlalchemy.engine import Connection
-from sqlalchemy.ext.asyncio import async_engine_from_config
-
-from db.base import Base
-
-# Alembic Config object for accessing values within the .ini file.
-config = context.config # pylint: disable=no-member
-config.set_main_option("sqlalchemy.url", "sqlite+aiosqlite:///data/db.sqlite3")
-
-# Set up loggers from config file if available
-if config.config_file_name is not None:
- fileConfig(config.config_file_name)
-
-# Metadata object for 'autogenerate' support in migrations
-target_metadata = Base.metadata
-
-
-def run_migrations_offline() -> None:
- """
- Run migrations in 'offline' mode.
-
- Configures the context with a URL instead of an Engine,
- allowing migrations without DBAPI.
- """
- url = config.get_main_option("sqlalchemy.url")
- context.configure( # pylint: disable=no-member
- url=url,
- target_metadata=target_metadata,
- literal_binds=True,
- dialect_opts={"paramstyle": "named"},
- )
-
- with context.begin_transaction(): # pylint: disable=no-member
- context.run_migrations() # pylint: disable=no-member
-
-
-def do_run_migrations(connection: Connection) -> None:
- """
- Configures the context for a migration and executes the migrations.
- """
- context.configure(
- connection=connection,
- target_metadata=target_metadata, # pylint: disable=no-member
- )
-
- with context.begin_transaction(): # pylint: disable=no-member
- context.run_migrations() # pylint: disable=no-member
-
-
-async def run_async_migrations() -> None:
- """
- Creates an asynchronous Engine and associates a connection
- with the Alembic migration context.
- """
- connectable = async_engine_from_config(
- config.get_section(config.config_ini_section) or {},
- prefix="sqlalchemy.",
- poolclass=pool.NullPool,
- )
-
- async with connectable.connect() as connection:
- await connection.run_sync(do_run_migrations)
-
- await connectable.dispose()
-
-
-def run_migrations_online() -> None:
- """
- Run migrations in 'online' mode using asynchronous connections.
- """
- asyncio.run(run_async_migrations())
-
-
-# Run migrations based on the mode (offline or online)
-if context.is_offline_mode(): # pylint: disable=no-member
- run_migrations_offline()
-else:
- run_migrations_online()
diff --git a/db/alembic/script.py.mako b/db/alembic/script.py.mako
deleted file mode 100644
index fbc4b07..0000000
--- a/db/alembic/script.py.mako
+++ /dev/null
@@ -1,26 +0,0 @@
-"""${message}
-
-Revision ID: ${up_revision}
-Revises: ${down_revision | comma,n}
-Create Date: ${create_date}
-
-"""
-from typing import Sequence, Union
-
-from alembic import op
-import sqlalchemy as sa
-${imports if imports else ""}
-
-# revision identifiers, used by Alembic.
-revision: str = ${repr(up_revision)}
-down_revision: Union[str, None] = ${repr(down_revision)}
-branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
-depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
-
-
-def upgrade() -> None:
- ${upgrades if upgrades else "pass"}
-
-
-def downgrade() -> None:
- ${downgrades if downgrades else "pass"}
diff --git a/db/alembic/versions/10ce718f423f_add_node_excluded_monitorings.py b/db/alembic/versions/10ce718f423f_add_node_excluded_monitorings.py
deleted file mode 100644
index d912edb..0000000
--- a/db/alembic/versions/10ce718f423f_add_node_excluded_monitorings.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""add node excluded monitorings
-
-Revision ID: 10ce718f423f
-Revises: 4abf3adb8ab8
-Create Date: 2024-11-08 04:53:18.184923
-
-"""
-# pylint: disable=all
-
-from typing import Sequence, Union
-
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision: str = "10ce718f423f"
-down_revision: Union[str, None] = "4abf3adb8ab8"
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
-
-
-def upgrade() -> None:
- # ### commands auto generated by Alembic - please adjust! ###
- op.add_column(
- "settings", sa.Column("node_excluded_monitorings", sa.JSON(), default=[])
- )
- # ### end Alembic commands ###
-
-
-def downgrade() -> None:
- # ### commands auto generated by Alembic - please adjust! ###
- op.drop_column("settings", "node_excluded_monitorings")
- # ### end Alembic commands ###
diff --git a/db/alembic/versions/3e5deef43bf0_init_commit.py b/db/alembic/versions/3e5deef43bf0_init_commit.py
deleted file mode 100644
index 5dcd281..0000000
--- a/db/alembic/versions/3e5deef43bf0_init_commit.py
+++ /dev/null
@@ -1,36 +0,0 @@
-# pylint: skip-file
-"""init commit
-
-Revision ID: 3e5deef43bf0
-Revises:
-Create Date: 2024-10-11 15:47:37.464534
-"""
-# pylint: disable=all
-
-from typing import Sequence, Union
-from alembic import op
-import sqlalchemy as sa
-
-# Revision identifiers, used by Alembic.
-revision: str = "3e5deef43bf0"
-down_revision: Union[str, None] = None
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
-
-
-def upgrade() -> None:
- """Create the tokens table."""
- op.create_table( # pylint: disable=no-member
- "tokens",
- sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
- sa.Column("token", sa.String(length=255), nullable=False),
- sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
- sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
- sa.PrimaryKeyConstraint("id"),
- sa.UniqueConstraint("token"),
- )
-
-
-def downgrade() -> None:
- """Drop the tokens table."""
- op.drop_table("tokens") # pylint: disable=no-member
diff --git a/db/alembic/versions/4abf3adb8ab8_refact_setting_table.py b/db/alembic/versions/4abf3adb8ab8_refact_setting_table.py
deleted file mode 100644
index 619b79c..0000000
--- a/db/alembic/versions/4abf3adb8ab8_refact_setting_table.py
+++ /dev/null
@@ -1,52 +0,0 @@
-"""Refactor settings table
-
-Revision ID: 4abf3adb8ab8
-Revises: ab1ce3ef2a57
-Create Date: 2024-11-08 02:42:49.391685
-
-"""
-# pylint: disable=all
-
-from typing import Sequence, Union
-from alembic import op
-import sqlalchemy as sa
-
-# Revision identifiers, used by Alembic.
-revision: str = "4abf3adb8ab8"
-down_revision: Union[str, None] = "ab1ce3ef2a57"
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
-
-
-def upgrade() -> None:
- """Refactor settings table by dropping the old table and creating a new structure."""
- # Drop the old settings table
- op.drop_table("settings") # pylint: disable=no-member
-
- # Recreate the settings table with the new structure
- op.create_table(
- "settings",
- sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
- sa.Column("node_monitoring", sa.Boolean(), nullable=False),
- sa.Column("node_auto_restart", sa.Boolean(), nullable=False),
- ) # pylint: disable=no-member
-
-
-def downgrade() -> None:
- """Revert the settings table to its original structure."""
- # Drop the new settings table
- op.drop_table("settings") # pylint: disable=no-member
-
- # Recreate the original settings table structure
- op.create_table(
- "settings",
- sa.Column("key", sa.VARCHAR(length=256), nullable=False),
- sa.Column("value", sa.VARCHAR(length=2048), nullable=True),
- sa.Column(
- "created_at",
- sa.DATETIME(),
- server_default=sa.text("(CURRENT_TIMESTAMP)"),
- nullable=False,
- ),
- sa.Column("updated_at", sa.DATETIME(), nullable=True),
- ) # pylint: disable=no-member
diff --git a/db/alembic/versions/ab1ce3ef2a57_add_settings.py b/db/alembic/versions/ab1ce3ef2a57_add_settings.py
deleted file mode 100644
index c2c0dcb..0000000
--- a/db/alembic/versions/ab1ce3ef2a57_add_settings.py
+++ /dev/null
@@ -1,38 +0,0 @@
-"""add settings
-
-Revision ID: ab1ce3ef2a57
-Revises: 3e5deef43bf0
-Create Date: 2024-10-13 01:42:55.733416
-"""
-# pylint: disable=all
-
-from typing import Sequence, Union
-from alembic import op
-import sqlalchemy as sa
-
-# Revision identifiers, used by Alembic.
-revision: str = "ab1ce3ef2a57"
-down_revision: Union[str, None] = "3e5deef43bf0"
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
-
-
-def upgrade() -> None:
- """Create the settings table."""
- op.create_table( # pylint: disable=no-member
- "settings",
- sa.Column("key", sa.String(256), primary_key=True),
- sa.Column("value", sa.String(2048), nullable=True),
- sa.Column(
- "created_at",
- sa.DateTime(),
- server_default=sa.func.current_timestamp(), # pylint: disable=not-callable
- nullable=False,
- ),
- sa.Column("updated_at", sa.DateTime(), nullable=True),
- )
-
-
-def downgrade() -> None:
- """Drop the settings table."""
- op.drop_table("settings") # pylint: disable=no-member
diff --git a/db/base.py b/db/base.py
deleted file mode 100644
index 764c230..0000000
--- a/db/base.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""
-Database base module.
-
-This module provides the asynchronous engine, session factory, and base class for
-declarative models.
-"""
-
-from contextlib import asynccontextmanager
-from typing import AsyncGenerator
-
-from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncSession, create_async_engine
-from sqlalchemy.orm import DeclarativeBase, sessionmaker
-
-# Create an asynchronous engine
-engine = create_async_engine(
- "sqlite+aiosqlite:///data/db.sqlite3",
- connect_args={"check_same_thread": False},
- echo=False,
-)
-
-# Create an asynchronous session factory
-AsyncSessionLocal = sessionmaker(
- bind=engine,
- class_=AsyncSession,
- autocommit=False,
- autoflush=False,
-)
-
-
-class Base(DeclarativeBase, AsyncAttrs):
- """Base class for declarative models using SQLAlchemy."""
-
- def save(self, session: AsyncSession) -> None:
- """Save the current instance to the database."""
- session.add(self)
-
- def delete(self, session: AsyncSession) -> None:
- """Delete the current instance from the database."""
- session.delete(self)
-
-
-@asynccontextmanager
-async def get_db() -> AsyncGenerator[AsyncSession, None]:
- """
- Provide an asynchronous database session to the application.
- """
- async with AsyncSessionLocal() as session:
- yield session
diff --git a/db/crud/__init__.py b/db/crud/__init__.py
deleted file mode 100644
index fa73785..0000000
--- a/db/crud/__init__.py
+++ /dev/null
@@ -1,10 +0,0 @@
-"""
-This module initializes the CRUD operations for the application.
-
-It includes managers for handling tokens and settings.
-"""
-
-from .token import TokenManager
-from .setting import SettingManager
-
-__all__ = ["TokenManager", "SettingManager"]
diff --git a/db/crud/setting.py b/db/crud/setting.py
deleted file mode 100644
index 23bdaba..0000000
--- a/db/crud/setting.py
+++ /dev/null
@@ -1,88 +0,0 @@
-"""
-This module provides functionality for managing application settings,
-including methods for updating and retrieving settings.
-"""
-
-from typing import List
-
-from sqlalchemy.future import select
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from db.base import get_db
-from db.models import Setting
-from models import SettingKeys
-
-
-class SettingManager:
- """Handles the application's settings management, ensuring a single settings record exists."""
-
- @staticmethod
- async def _get_or_create_settings(db: AsyncSession) -> Setting:
- """
- Helper method to get existing settings or create new ones if they don't exist.
- """
- result = await db.execute(select(Setting))
- settings = result.scalar_one_or_none()
-
- if not settings:
- settings = Setting()
- db.add(settings)
- await db.commit()
- await db.refresh(settings)
-
- return settings
-
- @staticmethod
- async def get(field: SettingKeys) -> bool:
- """
- Retrieve the specified setting field, ensuring the settings record exists.
- If the settings record does not exist, it will be created.
- """
- async with get_db() as db:
- settings = await SettingManager._get_or_create_settings(db)
- return getattr(settings, field.value)
-
- @staticmethod
- async def toggle_field(field: SettingKeys) -> bool:
- """
- Toggle a boolean setting field and return its new value.
- Ensures the settings record exists before toggling.
- """
- async with get_db() as db:
- settings = await SettingManager._get_or_create_settings(db)
-
- # Toggle the field's current value
- current_value = getattr(settings, field.value)
- new_value = not current_value
- setattr(settings, field.value, new_value)
-
- # Commit the change
- db.add(settings)
- await db.commit()
-
- return new_value
-
- @staticmethod
- async def get_node_excluded() -> List[str]:
- """
- Retrieve the list of excluded node monitorings.
- If settings don't exist, creates a new record with empty list.
- """
- async with get_db() as db:
- settings = await SettingManager._get_or_create_settings(db)
- return settings.node_excluded_monitorings or []
-
- @staticmethod
- async def update_node_excluded(excluded_nodes: List[str]) -> List[str]:
- """
- Update the list of excluded node monitorings.
- Creates settings record if it doesn't exist.
- """
- async with get_db() as db:
- settings = await SettingManager._get_or_create_settings(db)
-
- settings.node_excluded_monitorings = excluded_nodes
- await db.commit()
- await db.refresh(settings)
-
- return settings.node_excluded_monitorings
diff --git a/db/crud/token.py b/db/crud/token.py
deleted file mode 100644
index 4cea832..0000000
--- a/db/crud/token.py
+++ /dev/null
@@ -1,55 +0,0 @@
-"""
-This module provides functionality for managing tokens in the application.
-
-It includes methods for upserting and retrieving tokens from the database.
-"""
-
-from sqlalchemy.future import select
-from db.base import get_db
-from db.models import Token
-from models import TokenUpsert, TokenData
-
-
-class TokenManager:
- """Manager class for handling token operations."""
-
- @staticmethod
- async def upsert(token_upsert: TokenUpsert) -> TokenData:
- """
- Upsert a token in the database.
-
- If the token with ID 1 exists, it will be updated. If it does not exist,
- a new token will be created with ID 1.
-
- Args:
- token_upsert (TokenUpsert): The token data to upsert.
-
- Returns:
- TokenData: The upserted token data.
- """
- async with get_db() as db:
- existing_token = await db.execute(select(Token).where(Token.id == 1))
- token = existing_token.scalar_one_or_none()
-
- if token:
- token.token = token_upsert.token
- else:
- token = Token(id=1, token=token_upsert.token)
-
- db.add(token)
- await db.commit()
- await db.refresh(token)
- return TokenData.from_orm(token)
-
- @staticmethod
- async def get() -> TokenData:
- """
- Retrieve the token with ID 1 from the database.
-
- Returns:
- TokenData | None: The retrieved token data or None if not found.
- """
- async with get_db() as db:
- result = await db.execute(select(Token).where(Token.id == 1))
- token = result.scalar_one_or_none()
- return TokenData.from_orm(token) if token else None
diff --git a/db/models.py b/db/models.py
deleted file mode 100644
index 9552cff..0000000
--- a/db/models.py
+++ /dev/null
@@ -1,39 +0,0 @@
-"""
-Database models for the application.
-
-This module defines the SQLAlchemy models for the tokens and settings.
-"""
-
-from datetime import datetime
-from sqlalchemy import Integer, DateTime, String, Boolean, JSON
-from sqlalchemy.orm import Mapped, mapped_column
-from db.base import Base
-
-
-class Token(Base):
- """Model representing a token."""
-
- __tablename__ = "tokens"
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
- token: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
- created_at: Mapped[datetime] = mapped_column(
- DateTime(timezone=True), default=datetime.now, nullable=False
- )
- updated_at: Mapped[datetime] = mapped_column(
- DateTime(timezone=True), onupdate=datetime.now, nullable=True
- )
-
-
-class Setting(Base):
- """
- Model representing application settings.
- Only one record should exist in this table at any time.
- """
-
- __tablename__ = "settings"
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
- node_monitoring: Mapped[bool] = mapped_column(Boolean, default=False)
- node_auto_restart: Mapped[bool] = mapped_column(Boolean, default=False)
- node_excluded_monitorings: Mapped[list[str]] = mapped_column(JSON)
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 4cadeef..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,8 +0,0 @@
-services:
- holderbot:
- image: erfjab/holderbot:latest
- restart: always
- env_file:
- - .env
- volumes:
- - ./data/:/code/data/
\ No newline at end of file
diff --git a/jobs/__init__.py b/jobs/__init__.py
deleted file mode 100644
index 570e6ba..0000000
--- a/jobs/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""
-This module serves as the initialization for the jobs package,
-including the scheduling of background tasks such as token updates
-and node monitoring.
-"""
-
-from .scheduler import start_scheduler, stop_scheduler
-
-__all__ = ["start_scheduler", "stop_scheduler"]
diff --git a/jobs/node_monitoring.py b/jobs/node_monitoring.py
deleted file mode 100644
index 57454ad..0000000
--- a/jobs/node_monitoring.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""
-This module handles monitoring of nodes in the Marzban panel, including error reporting
-and automatic restarts if configured.
-"""
-
-import asyncio
-from marzban import MarzbanAPI
-
-from db.crud import SettingManager, TokenManager
-from models.setting import SettingKeys
-from utils import report, EnvSettings
-
-panel = MarzbanAPI(base_url=EnvSettings.MARZBAN_ADDRESS)
-
-
-async def node_checker():
- """Check the status of nodes and perform actions based on their status."""
- node_checker_is_active = await SettingManager.get(SettingKeys.NODE_MONITORING)
- if not node_checker_is_active:
- return
-
- token = await TokenManager.get()
- if not token:
- return
-
- nodes = await panel.get_nodes(token.token)
- anti_spam = False
- excluded_nodes = await SettingManager.get_node_excluded()
- for node in nodes:
- if node.name in excluded_nodes:
- continue
-
- if node.status in ["connecting", "error"]:
- anti_spam = True
- await report.node_error(node)
-
- node_auto_restart = await SettingManager.get(SettingKeys.NODE_AUTO_RESTART)
- if not node_auto_restart:
- continue
-
- await asyncio.sleep(2.0)
-
- try:
- await panel.reconnect_node(node.id, token.token)
- await report.node_restart(node, True)
- except (ConnectionError, TimeoutError):
- await report.node_restart(node, False)
-
- if anti_spam:
- await asyncio.sleep(60.0)
diff --git a/jobs/scheduler.py b/jobs/scheduler.py
deleted file mode 100644
index 115976a..0000000
--- a/jobs/scheduler.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""
-This module handles scheduling jobs for token updates and node monitoring.
-"""
-
-from apscheduler.schedulers.asyncio import AsyncIOScheduler
-from apscheduler.triggers.interval import IntervalTrigger
-from jobs.token_updater import token_update
-from jobs.node_monitoring import node_checker
-from utils import logger
-
-scheduler = AsyncIOScheduler()
-
-
-async def start_scheduler() -> bool:
- """Start the job scheduler for token updates and node monitoring."""
- logger.info("Trying to start the scheduler.")
-
- try:
- logger.info("Testing token update job...")
- test_token = await token_update()
- if not test_token:
- logger.error("Token update test failed. Scheduler will not start.")
- return False
-
- logger.info("Token update test succeeded.")
-
- scheduler.start()
- logger.info("Scheduler started successfully.")
-
- scheduler.add_job(
- token_update,
- trigger=IntervalTrigger(hours=8),
- id="token_update",
- replace_existing=True,
- )
- logger.info("Token update job added to scheduler with ID 'token_update'.")
- scheduler.add_job(
- node_checker,
- trigger=IntervalTrigger(seconds=20),
- id="node_monitor",
- replace_existing=True,
- )
- logger.info("Node monitoring job added to scheduler with ID 'node_monitor'.")
- return True
-
- except (RuntimeError, ValueError) as e: # Specify expected exceptions here
- logger.error("An error occurred while starting the scheduler: %s", e)
- return False
-
-
-async def stop_scheduler() -> None:
- """Stop the job scheduler."""
- logger.info("Trying to stop the scheduler.")
- try:
- scheduler.shutdown(wait=True)
- logger.info("Scheduler stopped successfully.")
- except (RuntimeError, ValueError) as e: # Specify expected exceptions here
- logger.error("An error occurred while stopping the scheduler: %s", e)
diff --git a/jobs/token_updater.py b/jobs/token_updater.py
deleted file mode 100644
index 690d847..0000000
--- a/jobs/token_updater.py
+++ /dev/null
@@ -1,42 +0,0 @@
-"""
-This module handles updating the Marzban panel token at regular intervals.
-"""
-
-import httpx
-
-from marzban import MarzbanAPI
-from utils import EnvSettings, logger
-from db.crud import TokenManager
-from models import TokenUpsert
-
-
-async def token_update() -> bool:
- """Add or update Marzban panel token every X time."""
- if not EnvSettings.MARZBAN_USERNAME or not EnvSettings.MARZBAN_PASSWORD:
- logger.error("MARZBAN_USERNAME or MARZBAN_PASSWORD is not set.")
- return False
-
- api = MarzbanAPI(base_url=EnvSettings.MARZBAN_ADDRESS)
-
- try:
- get_token = await api.get_token(
- username=EnvSettings.MARZBAN_USERNAME, password=EnvSettings.MARZBAN_PASSWORD
- )
-
- if get_token and get_token.access_token:
- token_data = await TokenManager.upsert(
- TokenUpsert(token=get_token.access_token)
- )
- if token_data:
- logger.info("Token updated successfully.")
- return True
-
- logger.error("Failed to update token in database.")
- return False
-
- logger.error("Failed to retrieve token: No token received.")
- return False
-
- except (httpx.HTTPStatusError, httpx.RequestError) as e:
- logger.error("An error occurred during the API request: %s", str(e))
- return False
diff --git a/main.py b/main.py
deleted file mode 100644
index 0c6eee8..0000000
--- a/main.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""
-Main module for the Telegram bot application.
-This module initializes and runs the bot with all necessary configurations,
-including scheduler setup, router configuration, and middleware integration.
-"""
-
-import asyncio
-from aiogram import Bot, Dispatcher
-from aiogram.enums.parse_mode import ParseMode
-from aiogram.client.default import DefaultBotProperties
-
-from jobs import stop_scheduler, start_scheduler
-from middlewares.auth import CheckAdminAccess
-from routers import setup_routers
-from utils import EnvSettings, logger, Storage
-
-
-async def on_startup() -> None:
- """Execute startup tasks for HolderBot."""
- logger.info("Starting HolderBot...")
-
- admin_ids = ", ".join(map(str, EnvSettings.TELEGRAM_ADMINS_ID))
- logger.debug("Admin IDs: %s", admin_ids) # Admin IDs only logged for debug
-
- # Start the scheduler
- if not await start_scheduler():
- logger.critical("Scheduler startup failed. Shutting down.")
- raise SystemExit
-
- logger.info("Scheduler started successfully. Bot is now running.")
-
-
-async def on_shutdown() -> None:
- """Execute shutdown tasks for HolderBot."""
- logger.info("Shutting down HolderBot...")
- await stop_scheduler()
- logger.info("Scheduler stopped. Shutdown complete.")
-
-
-async def main() -> None:
- """Initialize and run the bot."""
- bot = Bot(
- token=EnvSettings.TELEGRAM_BOT_TOKEN,
- default=DefaultBotProperties(
- parse_mode=ParseMode.HTML, link_preview_is_disabled=True
- ),
- )
- dp = Dispatcher(storage=Storage)
-
- # Setup dispatcher with routers and middleware
- dp.include_router(setup_routers())
- dp.update.middleware(CheckAdminAccess())
- dp.startup.register(on_startup)
- dp.shutdown.register(on_shutdown)
-
- # 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:
- logger.error("Polling error (connection issue): %s", conn_err)
- except RuntimeError as runtime_err:
- logger.error("Runtime error during polling: %s", runtime_err)
- except asyncio.CancelledError:
- logger.warning("Polling was cancelled.")
-
-
-if __name__ == "__main__":
- try:
- logger.info("Launching HolderBot...")
- asyncio.run(main())
- except KeyboardInterrupt:
- logger.warning("Bot stopped manually by user.")
- except RuntimeError as runtime_err:
- logger.error("Unexpected runtime error: %s", runtime_err)
- except (ConnectionError, TimeoutError, asyncio.TimeoutError) as conn_err:
- logger.error("Connection or timeout error: %s", conn_err)
- except asyncio.CancelledError:
- logger.warning("Polling was cancelled.")
diff --git a/middlewares/auth.py b/middlewares/auth.py
deleted file mode 100644
index 8c79f4d..0000000
--- a/middlewares/auth.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""
-Middleware for handling admin access in Telegram bot updates.
-"""
-
-from typing import Any, Awaitable, Callable, Dict
-from aiogram import BaseMiddleware
-from aiogram.types import Update
-
-from utils import logger, Storage, EnvSettings
-
-
-# pylint: disable=too-few-public-methods
-class CheckAdminAccess(BaseMiddleware):
- """
- Middleware to check if the user is an admin based on their user ID.
- This middleware processes incoming updates and allows only admins
- to proceed with the handler.
- """
-
- async def __call__(
- self,
- handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
- event: Update,
- data: Dict[str, Any],
- ) -> Any:
- user = None
- if event.message:
- user = event.message.from_user
- await Storage.add_log_message(user.id, event.message.message_id)
- elif event.callback_query:
- user = event.callback_query.from_user
- elif event.inline_query:
- user = event.inline_query.from_user
- elif event.chosen_inline_result:
- user = event.chosen_inline_result.from_user
-
- if not user:
- logger.warning("Received update without user information!")
- return None
-
- if user.id not in EnvSettings.TELEGRAM_ADMINS_ID:
- logger.warning("Blocked %s", user.username or user.first_name)
- return None
-
- return await handler(event, data)
diff --git a/models/__init__.py b/models/__init__.py
deleted file mode 100644
index bf12e15..0000000
--- a/models/__init__.py
+++ /dev/null
@@ -1,35 +0,0 @@
-"""
-Module containing all the model definitions for the application.
-This includes models for token, user state, settings, and other core entities.
-"""
-
-from .token import TokenData, TokenUpsert
-from .state import UserCreateForm
-from .setting import SettingKeys
-from .callback import (
- PagesActions,
- PagesCallbacks,
- AdminActions,
- ConfirmCallbacks,
- UserStatusCallbacks,
- UserInboundsCallbacks,
- AdminSelectCallbacks,
- BotActions,
- NodeSelectCallbacks,
-)
-
-__all__ = [
- "TokenData",
- "TokenUpsert",
- "UserCreateForm",
- "SettingKeys",
- "PagesActions",
- "PagesCallbacks",
- "AdminActions",
- "ConfirmCallbacks",
- "UserStatusCallbacks",
- "UserInboundsCallbacks",
- "AdminSelectCallbacks",
- "BotActions",
- "NodeSelectCallbacks",
-]
diff --git a/models/callback.py b/models/callback.py
deleted file mode 100644
index 66fbfe9..0000000
--- a/models/callback.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""
-Module defining callback data classes for handling bot actions and page navigation.
-"""
-
-from enum import Enum
-from aiogram.filters.callback_data import CallbackData
-
-
-class AdminActions(str, Enum):
- """
- Enum representing various admin actions that can be performed.
- """
-
- ADD = "add"
- EDIT = "edit"
- INFO = "info"
- DELETE = "delete"
-
-
-class BotActions(str, Enum):
- """
- Enum representing various bot actions.
- """
-
- NODE_CHECKER = "node_checker"
- NODE_AUTO_RESTART = "node_auto_restart"
- USERS_INBOUND = "users_inbound"
- NODE_EXCLUDED = "node_excluded"
-
-
-class PagesActions(str, Enum):
- """
- Enum representing different pages in the bot navigation.
- """
-
- HOME = "home"
- USER_CREATE = "user_create"
- NODE_MONITORING = "node_monitoring"
- USERS_MENU = "users_menu"
-
-
-class PagesCallbacks(CallbackData, prefix="pages"):
- """
- Callback data structure for page navigation.
- """
-
- page: PagesActions
-
-
-class ConfirmCallbacks(CallbackData, prefix="confirm"):
- """
- Callback data structure for confirmation actions.
- """
-
- page: BotActions
- action: AdminActions
- is_confirm: bool = False
-
-
-class UserStatusCallbacks(CallbackData, prefix="user_status"):
- """
- Callback data structure for user status actions.
- """
-
- status: str
- action: AdminActions
-
-
-class UserInboundsCallbacks(CallbackData, prefix="user_inbounds"):
- """
- Callback data structure for user inbounds actions.
- """
-
- tag: str | None = None
- protocol: str | None = None
- is_selected: bool | None = None
- action: AdminActions
- is_done: bool = False
- just_one_inbound: bool = False
-
-
-class AdminSelectCallbacks(CallbackData, prefix="admin_select"):
- """
- Callback data structure for selecting an admin by username.
- """
-
- username: str
-
-
-class NodeSelectCallbacks(CallbackData, prefix="node_select"):
- """
- Callback data structure for selecting an node by name.
- """
-
- name: str | None = None
- is_done: bool = False
diff --git a/models/setting.py b/models/setting.py
deleted file mode 100644
index 05582dd..0000000
--- a/models/setting.py
+++ /dev/null
@@ -1,12 +0,0 @@
-"""
-Module defining settings models for application configuration.
-"""
-
-from enum import Enum
-
-
-class SettingKeys(Enum):
- """Enum for settings table columns."""
-
- NODE_MONITORING = "node_monitoring"
- NODE_AUTO_RESTART = "node_auto_restart"
diff --git a/models/state.py b/models/state.py
deleted file mode 100644
index c4d1e54..0000000
--- a/models/state.py
+++ /dev/null
@@ -1,23 +0,0 @@
-"""
-Module defining the states for the user creation process.
-"""
-
-from aiogram.fsm.state import StatesGroup, State
-
-
-# pylint: disable=R0903
-class UserCreateForm(StatesGroup):
- """
- States group for the user creation process in the bot.
- This defines the various states a user can go through while
- filling out the form for user creation.
- """
-
- base_username = State() # State for base username
- start_number = State() # State for start number
- how_much = State() # State for amount
- data_limit = State() # State for data limit
- date_limit = State() # State for date limit
- status = State() # State for user status
- admin = State() # State for admin status
- inbounds = State() # State for inbounds
diff --git a/models/token.py b/models/token.py
deleted file mode 100644
index b9fb49c..0000000
--- a/models/token.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""
-Module defining token-related models.
-"""
-
-from datetime import datetime
-from pydantic import BaseModel
-
-
-class TokenData(BaseModel):
- """
- Model for representing a token with its associated data,
- including creation and update timestamps.
- """
-
- id: int
- token: str
- updated_at: datetime | None
- created_at: datetime
-
- # pylint: disable=R0903
- class Config:
- """Pydantic configuration options."""
-
- from_attributes = True
-
-
-class TokenUpsert(BaseModel):
- """
- Model for upserting a token.
- """
-
- token: str
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 8038713..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,12 +0,0 @@
-aiogram==3.14.0
-python-decouple==3.8
-SQLAlchemy==2.0.31
-pydantic==2.8.2
-qrcode==7.4.2
-aiosqlite==0.20.0
-APScheduler==3.10.4
-marzban==0.2.8
-pillow==10.4.0
-alembic==1.13.1
-httpx==0.27.0
-pydantic_settings==2.6.1
\ No newline at end of file
diff --git a/routers/__init__.py b/routers/__init__.py
deleted file mode 100644
index 89a746b..0000000
--- a/routers/__init__.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""
-This module sets up the routers for the bot application.
-It includes base, user, node, and users routers.
-"""
-
-from aiogram import Router
-from . import base, user, node, users, inline
-
-__all__ = ["setup_routers", "base", "user", "node", "users", "inline"]
-
-
-def setup_routers() -> Router:
- """
- Sets up the routers for the bot application by including the necessary sub-routers.
- """
- router = Router()
-
- router.include_router(base.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/base.py b/routers/base.py
deleted file mode 100644
index c78b81b..0000000
--- a/routers/base.py
+++ /dev/null
@@ -1,49 +0,0 @@
-"""
-This module defines the base router for handling commands and callbacks in the bot.
-It includes handlers for commands like start and version, and processes specific callback queries.
-"""
-
-from aiogram import Router, F
-from aiogram.types import Message, CallbackQuery
-from aiogram.filters.command import CommandStart, Command
-from aiogram.fsm.context import FSMContext
-
-from utils import MessageTexts, Storage, BotKeyboards
-from models import PagesCallbacks, PagesActions
-
-router = Router()
-
-
-@router.message(CommandStart(ignore_case=True))
-async def start(message: Message, state: FSMContext):
- """
- Handler for the '/start' command. It clears the user's state and sends the start message
- with the home keyboard.
- """
- await state.clear()
- new_message = await message.answer(
- text=MessageTexts.START, reply_markup=BotKeyboards.home()
- )
- return await Storage.clear_and_add_message(new_message)
-
-
-@router.callback_query(PagesCallbacks.filter(F.page.is_(PagesActions.HOME)))
-async def home(callback: CallbackQuery, state: FSMContext):
- """
- Callback handler for the 'HOME' page action. Clears the state and sends the start message
- with the home keyboard again.
- """
- await state.clear()
- new_message = await callback.message.answer(
- text=MessageTexts.START, reply_markup=BotKeyboards.home()
- )
- return await Storage.clear_and_add_message(new_message)
-
-
-@router.message(Command(commands=["version", "v"]))
-async def version(message: Message):
- """
- Handler for the '/version' and '/v' commands. Sends the version information of the bot.
- """
- new_message = await message.answer(text=MessageTexts.VERSION)
- return await Storage.clear_and_add_message(new_message)
diff --git a/routers/inline.py b/routers/inline.py
deleted file mode 100644
index 1ef64e6..0000000
--- a/routers/inline.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""
-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/routers/node.py b/routers/node.py
deleted file mode 100644
index c810c96..0000000
--- a/routers/node.py
+++ /dev/null
@@ -1,116 +0,0 @@
-"""
-This module contains the handlers for the node monitoring menu and actions.
-It includes callbacks for toggling settings related to node monitoring.
-"""
-
-from aiogram import Router, F
-from aiogram.types import CallbackQuery
-from aiogram.fsm.context import FSMContext
-
-from db.crud import SettingManager
-from utils import MessageTexts, BotKeyboards, panel
-from models import (
- PagesActions,
- PagesCallbacks,
- SettingKeys,
- ConfirmCallbacks,
- BotActions,
- NodeSelectCallbacks,
-)
-
-router = Router()
-
-
-@router.callback_query(PagesCallbacks.filter(F.page.is_(PagesActions.NODE_MONITORING)))
-async def node_monitoring_menu(callback: CallbackQuery):
- """
- Handler for the node monitoring menu callback. It retrieves the current status
- of node monitoring settings and updates the menu text.
- """
- checker_status = await SettingManager.get(SettingKeys.NODE_MONITORING)
- auto_restart_status = await SettingManager.get(SettingKeys.NODE_AUTO_RESTART)
- excluded_nodes = await SettingManager.get_node_excluded()
- excluded_text = ", ".join(excluded_nodes) if excluded_nodes else "None"
-
- text = MessageTexts.NODE_MONITORING_MENU.format(
- checker=checker_status,
- auto_restart=auto_restart_status,
- excluded=excluded_text,
- )
- await callback.message.edit_text(
- text=text, reply_markup=BotKeyboards.node_monitoring()
- )
-
-
-@router.callback_query(
- ConfirmCallbacks.filter(F.page.is_(BotActions.NODE_AUTO_RESTART))
-)
-async def node_monitoring_auto_restart(callback: CallbackQuery):
- """
- Handler for toggling the auto-restart setting for node monitoring.
- """
- await SettingManager.toggle_field(SettingKeys.NODE_AUTO_RESTART)
- await node_monitoring_menu(callback)
-
-
-@router.callback_query(ConfirmCallbacks.filter(F.page.is_(BotActions.NODE_CHECKER)))
-async def node_monitoring_checker(callback: CallbackQuery):
- """
- Handler for toggling the checker setting for node monitoring.
- """
- await SettingManager.toggle_field(SettingKeys.NODE_MONITORING)
- await node_monitoring_menu(callback)
-
-
-@router.callback_query(ConfirmCallbacks.filter(F.page.is_(BotActions.NODE_EXCLUDED)))
-async def node_excluded(callback: CallbackQuery, state: FSMContext):
- """Start the process to exclude nodes from monitoring."""
- await state.clear()
- nodes = await panel.get_nodes()
- await state.set_data(
- {
- "all_nodes": [node.__dict__ for node in nodes],
- "selected_nodes": [node.name for node in nodes],
- }
- )
-
- return await callback.message.edit_text(
- text=MessageTexts.NODE_MONITORING_EXCLUDED,
- reply_markup=BotKeyboards.select_nodes(nodes, [node.name for node in nodes]),
- )
-
-
-@router.callback_query(NodeSelectCallbacks.filter(F.is_done.is_(False)))
-async def select_node_excluded(
- callback: CallbackQuery, state: FSMContext, callback_data: NodeSelectCallbacks
-):
- """Toggle a node’s selection for exclusion."""
- data = await state.get_data()
- selected_nodes: list = data.get("selected_nodes", [])
- all_nodes = data.get("all_nodes", [])
-
- if callback_data.name in selected_nodes:
- selected_nodes.remove(callback_data.name)
- else:
- selected_nodes.append(callback_data.name)
-
- await state.update_data(selected_nodes=selected_nodes)
-
- return await callback.message.edit_text(
- text=MessageTexts.NODE_MONITORING_EXCLUDED,
- reply_markup=BotKeyboards.select_nodes(all_nodes, selected_nodes),
- )
-
-
-@router.callback_query(NodeSelectCallbacks.filter(F.is_done.is_(True)))
-async def finish_node_selection(callback: CallbackQuery, state: FSMContext):
- """Save the selected nodes and finish the process."""
- data = await state.get_data()
- selected_nodes = data.get("selected_nodes", [])
-
- await SettingManager.update_node_excluded(selected_nodes)
- await state.clear()
-
- return await callback.message.edit_text(
- text=MessageTexts.SUCCESS_UPDATED, reply_markup=BotKeyboards.home()
- )
diff --git a/routers/user.py b/routers/user.py
deleted file mode 100644
index 3cf1ea1..0000000
--- a/routers/user.py
+++ /dev/null
@@ -1,279 +0,0 @@
-"""
-This module contains the user-related callback functions and their handlers
-for user creation and management.
-"""
-
-from aiogram import Router, F
-from aiogram.types import CallbackQuery, Message, BufferedInputFile
-from aiogram.fsm.context import FSMContext
-from aiogram.filters import StateFilter
-
-from marzban import ProxyInbound
-
-from utils import (
- panel,
- text_info,
- helpers,
- MessageTexts,
- Storage,
- EnvSettings,
- BotKeyboards,
-)
-from models import (
- PagesActions,
- PagesCallbacks,
- AdminActions,
- UserCreateForm,
- UserStatusCallbacks,
- UserInboundsCallbacks,
- AdminSelectCallbacks,
-)
-
-router = Router()
-
-
-@router.callback_query(PagesCallbacks.filter(F.page.is_(PagesActions.USER_CREATE)))
-async def user_create(callback: CallbackQuery, state: FSMContext):
- """
- Initiates the user creation process by asking for the base username.
- """
- await state.set_state(UserCreateForm.base_username)
- return await callback.message.edit_text(
- text=MessageTexts.ASK_CREATE_USER_BASE_USERNAME,
- reply_markup=BotKeyboards.cancel(),
- )
-
-
-@router.message(StateFilter(UserCreateForm.base_username))
-async def user_create_base_username(message: Message, state: FSMContext):
- """
- Handles the input for the base username in the user creation process.
- """
- await state.update_data(base_username=message.text)
- await state.set_state(UserCreateForm.how_much)
- new_message = await message.answer(
- text=MessageTexts.ASK_CREATE_USER_HOW_MUCH,
- reply_markup=BotKeyboards.cancel(),
- )
- return await Storage.clear_and_add_message(new_message)
-
-
-@router.message(StateFilter(UserCreateForm.how_much))
-async def user_create_how_much(message: Message, state: FSMContext):
- """
- Handles the input for the 'how much' field in the user creation process.
- """
- if not message.text.isdigit():
- new_message = await message.answer(text=MessageTexts.JUST_NUMBER)
- return await Storage.add_log_message(
- message.from_user.id, new_message.message_id
- )
-
- await state.update_data(how_much=int(message.text))
-
- if int(message.text) == 1:
- await state.set_state(UserCreateForm.data_limit)
- text = MessageTexts.ASK_CREATE_USER_DATA_LIMIT
- else:
- await state.set_state(UserCreateForm.start_number)
- text = MessageTexts.ASK_CREATE_USER_START_NUMBER
-
- new_message = await message.answer(text=text, reply_markup=BotKeyboards.cancel())
- return await Storage.clear_and_add_message(new_message)
-
-
-@router.message(StateFilter(UserCreateForm.start_number))
-async def user_create_start_number(message: Message, state: FSMContext):
- """
- Handles the input for the starting number in the user creation process.
- """
- if not message.text.isdigit():
- new_message = await message.answer(text=MessageTexts.JUST_NUMBER)
- return await Storage.add_log_message(
- message.from_user.id, new_message.message_id
- )
-
- await state.update_data(start_number=int(message.text))
- await state.set_state(UserCreateForm.data_limit)
- new_message = await message.answer(
- text=MessageTexts.ASK_CREATE_USER_DATA_LIMIT, reply_markup=BotKeyboards.cancel()
- )
- return await Storage.clear_and_add_message(new_message)
-
-
-@router.message(StateFilter(UserCreateForm.data_limit))
-async def user_create_data_limit(message: Message, state: FSMContext):
- """
- Handles the input for the data limit in the user creation process.
- """
- if not message.text.isdigit():
- new_message = await message.answer(text=MessageTexts.JUST_NUMBER)
- return await Storage.add_log_message(
- message.from_user.id, new_message.message_id
- )
-
- await state.update_data(data_limit=int(message.text))
- await state.set_state(UserCreateForm.date_limit)
- new_message = await message.answer(
- text=MessageTexts.ASK_CREATE_USER_DATE_LIMIT, reply_markup=BotKeyboards.cancel()
- )
- return await Storage.clear_and_add_message(new_message)
-
-
-@router.message(StateFilter(UserCreateForm.date_limit))
-async def user_create_date_limit(message: Message, state: FSMContext):
- """
- Handles the input for the date limit in the user creation process.
- """
- if not message.text.isdigit():
- new_message = await message.answer(text=MessageTexts.JUST_NUMBER)
- return await Storage.add_log_message(
- message.from_user.id, new_message.message_id
- )
-
- await state.update_data(date_limit=int(message.text))
- new_message = await message.answer(
- text=MessageTexts.ASK_CREATE_USER_STATUS,
- reply_markup=BotKeyboards.user_status(AdminActions.ADD),
- )
- return await Storage.clear_and_add_message(new_message)
-
-
-@router.callback_query(UserStatusCallbacks.filter(F.action.is_(AdminActions.ADD)))
-async def user_create_status(
- callback: CallbackQuery, callback_data: UserStatusCallbacks, state: FSMContext
-):
- """
- Handles the status selection for user creation.
- """
- await state.update_data(status=callback_data.status)
- admins = await panel.admins()
- return await callback.message.edit_text(
- text=MessageTexts.ASK_CREATE_ADMIN_USERNAME,
- reply_markup=BotKeyboards.admins(admins),
- )
-
-
-@router.callback_query(AdminSelectCallbacks.filter())
-async def user_create_owner_select(
- callback: CallbackQuery, callback_data: AdminSelectCallbacks, state: FSMContext
-):
- """
- Handles the selection of the admin owner during the user creation process.
- """
- await state.update_data(admin=callback_data.username)
- inbounds = await panel.get_inbounds()
- tags = [item["tag"] for sublist in inbounds.values() for item in sublist]
- await state.update_data(inbounds=inbounds)
- await state.update_data(selected_inbounds=tags)
- return await callback.message.edit_text(
- text=MessageTexts.ASK_CREATE_USER_INBOUNDS,
- reply_markup=BotKeyboards.inbounds(inbounds, tags),
- )
-
-
-@router.callback_query(
- UserInboundsCallbacks.filter(
- (
- F.action.is_(AdminActions.ADD)
- & (F.is_done.is_(False))
- & (F.just_one_inbound.is_(False))
- )
- )
-)
-async def user_create_inbounds(
- callback: CallbackQuery,
- callback_data: UserInboundsCallbacks,
- state: FSMContext,
-):
- """
- Handles the inbound selection for user creation.
- """
- data = await state.get_data()
- inbounds = data.get("inbounds")
- selected_inbounds = set(data.get("selected_inbounds", []))
-
- if callback_data.is_selected is False:
- selected_inbounds.add(callback_data.tag)
- else:
- selected_inbounds.discard(callback_data.tag)
-
- await state.update_data(selected_inbounds=list(selected_inbounds))
- await callback.message.edit_reply_markup(
- reply_markup=BotKeyboards.inbounds(inbounds, selected_inbounds)
- )
-
-
-@router.callback_query(
- UserInboundsCallbacks.filter(
- (
- F.action.is_(AdminActions.ADD)
- & (F.is_done.is_(True))
- & (F.just_one_inbound.is_(False))
- )
- )
-)
-async def user_create_inbounds_save(callback: CallbackQuery, state: FSMContext):
- """
- Saves the selected inbounds and creates users with the provided information.
- """
- data = await state.get_data()
- inbounds: dict[str, list[ProxyInbound]] = data.get("inbounds")
- selected_inbounds = set(data.get("selected_inbounds", []))
-
- if not selected_inbounds:
- return await callback.answer(
- text=MessageTexts.NONE_USER_INBOUNDS, show_alert=True
- )
-
- proxies = {
- inbound["protocol"]: {}
- for protocol_list in inbounds.values()
- for inbound in protocol_list
- if inbound["tag"] in selected_inbounds
- }
-
- inbounds_dict = {
- protocol: [
- inbound["tag"]
- for inbound in protocol_list
- if inbound["protocol"] == protocol and inbound["tag"] in selected_inbounds
- ]
- for protocol, protocol_list in inbounds.items()
- }
- inbounds_dict = {k: v for k, v in inbounds_dict.items() if v}
-
- for i in range(int(data["how_much"])):
- if int(data["how_much"]) == 1:
- username = data["base_username"]
- else:
- username = f"{data['base_username']}{int(data['start_number']) + i}"
- new_user = await panel.create_user(
- username=username,
- status=data["status"],
- proxies=proxies,
- inbounds=inbounds_dict,
- data_limit=data["data_limit"],
- date_limit=data["date_limit"],
- )
-
- if new_user:
- if data["admin"] != EnvSettings.MARZBAN_USERNAME:
- await panel.set_owner(data["admin"], new_user.username)
- qr_bytes = await helpers.create_qr(new_user.subscription_url)
- await callback.message.answer_photo(
- caption=text_info.user_info(new_user),
- photo=BufferedInputFile(qr_bytes, filename="qr_code.png"),
- reply_markup=BotKeyboards.user(new_user),
- )
- else:
- await callback.message.answer(
- text=f"❌ Error {username}
Create!"
- )
-
- await callback.message.delete()
- await state.clear()
- new_message = await callback.message.answer(
- text=MessageTexts.START, reply_markup=BotKeyboards.home()
- )
- return await Storage.clear_and_add_message(new_message)
diff --git a/routers/users.py b/routers/users.py
deleted file mode 100644
index 33f6b08..0000000
--- a/routers/users.py
+++ /dev/null
@@ -1,82 +0,0 @@
-"""
-This module contains the callback functions for managing user actions,
-such as navigating the users menu, adding or deleting inbounds, and updating
-user settings related to inbounds.
-"""
-
-from aiogram import Router, F
-from aiogram.types import CallbackQuery
-from models import (
- PagesActions,
- PagesCallbacks,
- AdminActions,
- ConfirmCallbacks,
- BotActions,
- UserInboundsCallbacks,
-)
-
-from utils import panel, helpers, BotKeyboards, MessageTexts
-
-router = Router()
-
-
-@router.callback_query(PagesCallbacks.filter(F.page == PagesActions.USERS_MENU))
-async def menu(callback: CallbackQuery):
- """
- Handles the callback for the Users Menu page and displays the corresponding menu.
- """
- return await callback.message.edit_text(
- text=MessageTexts.USERS_MENU, reply_markup=BotKeyboards.users()
- )
-
-
-@router.callback_query(ConfirmCallbacks.filter(F.page == BotActions.USERS_INBOUND))
-async def inbound_add(callback: CallbackQuery, callback_data: ConfirmCallbacks):
- """
- Handles the callback for adding or managing inbounds in the users' settings.
- Displays the inbound selection menu based on the provided callback data.
- """
- inbounds = await panel.get_inbounds()
- return await callback.message.edit_text(
- text=MessageTexts.USERS_INBOUND_SELECT,
- reply_markup=BotKeyboards.inbounds(
- inbounds=inbounds, action=callback_data.action, just_one_inbound=True
- ),
- )
-
-
-@router.callback_query(
- UserInboundsCallbacks.filter(
- (
- F.action.in_([AdminActions.ADD, AdminActions.DELETE])
- & (F.is_done.is_(True))
- & (F.just_one_inbound.is_(True))
- )
- )
-)
-async def inbound_confirm(
- callback: CallbackQuery, callback_data: UserInboundsCallbacks
-):
- """
- Confirms the addition or deletion of an inbound for the user based on the
- selected action. After processing the action, it updates the message with the result.
- """
- working_message = await callback.message.edit_text(text=MessageTexts.WORKING)
- result = await helpers.manage_panel_inbounds(
- callback_data.tag,
- callback_data.protocol,
- (
- AdminActions.ADD
- if callback_data.action.value == AdminActions.ADD.value
- else AdminActions.DELETE
- ),
- )
-
- return await working_message.edit_text(
- text=(
- MessageTexts.USERS_INBOUND_SUCCESS_UPDATED
- if result
- else MessageTexts.USERS_INBOUND_ERROR_UPDATED
- ),
- reply_markup=BotKeyboards.home(),
- )
diff --git a/utils/__init__.py b/utils/__init__.py
deleted file mode 100644
index 690ec8d..0000000
--- a/utils/__init__.py
+++ /dev/null
@@ -1,18 +0,0 @@
-"""
-This module imports necessary components for database access and logging.
-"""
-
-from .statedb import SQLAlchemyStorage
-from .log import BotLogger
-from .config import EnvFile
-from .lang import MessageTextsFile, KeyboardTextsFile
-from .keys import BotKeyboards
-
-logger = BotLogger("HolderBot").get_logger()
-EnvSettings = EnvFile()
-MessageTexts = MessageTextsFile()
-KeyboardTexts = KeyboardTextsFile()
-Storage = SQLAlchemyStorage()
-
-
-__all__ = ["Storage", "logger", "KeyboardTexts", "MessageTexts", "BotKeyboards"]
diff --git a/utils/config.py b/utils/config.py
deleted file mode 100644
index ca5cf88..0000000
--- a/utils/config.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""
-This module contains configuration settings for the application, including
-Telegram bot settings, Marzban panel settings, and excluded monitorings.
-It ensures that all required settings are provided and checks for missing values.
-"""
-
-from pydantic_settings import BaseSettings, SettingsConfigDict
-
-
-class EnvFile(BaseSettings):
- """.env file config data"""
-
- model_config: SettingsConfigDict = SettingsConfigDict(
- env_file=".env", extra="ignore"
- )
-
- TELEGRAM_BOT_TOKEN: str
- TELEGRAM_ADMINS_ID: list[int]
- MARZBAN_USERNAME: str
- MARZBAN_PASSWORD: str
- MARZBAN_ADDRESS: str
- ACTION_LIMIT: int = 25
diff --git a/utils/helpers.py b/utils/helpers.py
deleted file mode 100644
index 298bc4b..0000000
--- a/utils/helpers.py
+++ /dev/null
@@ -1,124 +0,0 @@
-"""
-This module contains helper functions for processing users' inbound data,
-generating QR codes, and managing user data updates concurrently with rate limiting.
-"""
-
-import asyncio
-from io import BytesIO
-import qrcode
-import httpx
-from marzban import UserModify, UserResponse
-from models import AdminActions
-from utils import logger, panel, EnvSettings
-
-
-async def create_qr(text: str) -> bytes:
- """Create a QR code from the given text and return it as bytes."""
- qr = qrcode.QRCode(
- version=1,
- error_correction=qrcode.constants.ERROR_CORRECT_L,
- box_size=7,
- border=4,
- )
- qr.add_data(text)
- qr.make(fit=True)
-
- # Create the QR image with custom colors
- qr_img = qr.make_image(fill_color="black", back_color="transparent").convert("RGBA")
-
- # Convert the image to bytes
- img_bytes_io = BytesIO()
- qr_img.save(img_bytes_io, "PNG")
- return img_bytes_io.getvalue()
-
-
-async def process_user(
- semaphore: asyncio.Semaphore,
- user: UserResponse,
- tag: str,
- protocol: str,
- action: AdminActions,
-) -> bool:
- """Process a single user with semaphore for rate limiting and retry mechanism."""
- async with semaphore:
- current_inbounds = user.inbounds.copy() if user.inbounds else {}
- current_proxies = user.proxies.copy() if user.proxies else {}
-
- needs_update = False
-
- if action == AdminActions.DELETE:
- if protocol in current_inbounds and tag in current_inbounds[protocol]:
- current_inbounds[protocol].remove(tag)
- needs_update = True
-
- if protocol in current_inbounds and not current_inbounds[protocol]:
- current_inbounds.pop(protocol, None)
- current_proxies.pop(protocol, None)
-
- elif action == AdminActions.ADD:
- if protocol not in current_inbounds:
- current_inbounds[protocol] = []
- current_proxies[protocol] = {}
- needs_update = True
-
- if tag not in current_inbounds.get(protocol, []):
- if protocol not in current_inbounds:
- current_inbounds[protocol] = []
- current_inbounds[protocol].append(tag)
- needs_update = True
-
- if not needs_update:
- return True
-
- update_data = UserModify(
- proxies=current_proxies,
- inbounds=current_inbounds,
- )
-
- success = await panel.user_modify(user.username, update_data)
-
- if success:
- return True
-
-
-async def process_batch(
- users: list[UserResponse], tag: str, protocol: str, action: AdminActions
-) -> int:
- """Process a batch of users concurrently with rate limiting."""
- semaphore = asyncio.Semaphore(5)
- tasks = []
-
- for user in users:
- task = asyncio.create_task(process_user(semaphore, user, tag, protocol, action))
- tasks.append(task)
-
- results = await asyncio.gather(*tasks)
- return sum(results)
-
-
-async def manage_panel_inbounds(tag: str, protocol: str, action: AdminActions) -> bool:
- """Manage inbounds for users, processing them in batches and handling updates."""
- try:
- offset = 0
- batch_size = EnvSettings.ACTION_LIMIT
-
- while True:
- users = await panel.get_users(offset)
- if not users:
- break
-
- await process_batch(users, tag, protocol, action)
-
- if len(users) < batch_size:
- break
- offset += batch_size
-
- await asyncio.sleep(1.0)
-
- return True
-
- except asyncio.CancelledError:
- logger.warning("Operation was cancelled.")
- except (httpx.RequestError, httpx.HTTPStatusError) as e:
- logger.error("HTTP error in manage panel inbounds: %s", e)
- return False
diff --git a/utils/keys.py b/utils/keys.py
deleted file mode 100644
index 9224ba2..0000000
--- a/utils/keys.py
+++ /dev/null
@@ -1,262 +0,0 @@
-"""
-Module for managing key configurations and callback handling for the bot.
-
-This module defines functions to build and handle callback buttons, as well as
-handling key actions related to users, nodes, and admins in the bot system.
-"""
-
-from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton, CopyTextButton
-from aiogram.utils.keyboard import InlineKeyboardBuilder
-
-from marzban import ProxyInbound, Admin, UserResponse, NodeResponse
-from utils.lang import KeyboardTextsFile
-from models import (
- PagesActions,
- PagesCallbacks,
- AdminActions,
- AdminSelectCallbacks,
- UserStatusCallbacks,
- UserInboundsCallbacks,
- ConfirmCallbacks,
- BotActions,
- NodeSelectCallbacks,
-)
-
-KeyboardTexts = KeyboardTextsFile()
-
-
-class BotKeyboards:
- """
- A class containing static methods to generate various inline keyboards used by the bot.
- These keyboards are used for actions like creating users, monitoring nodes, managing users, etc.
- """
-
- @staticmethod
- def home() -> InlineKeyboardMarkup:
- """
- Generates the home screen keyboard with buttons for User creation,
- Node Monitoring, and Users Menu.
- """
- kb = InlineKeyboardBuilder()
- kb.button(
- text=KeyboardTexts.USER_CREATE,
- callback_data=PagesCallbacks(page=PagesActions.USER_CREATE).pack(),
- )
- kb.button(
- text=KeyboardTexts.NODE_MONITORING,
- callback_data=PagesCallbacks(page=PagesActions.NODE_MONITORING).pack(),
- )
- kb.button(
- text=KeyboardTexts.USERS_MENU,
- callback_data=PagesCallbacks(page=PagesActions.USERS_MENU).pack(),
- )
- return kb.adjust(2).as_markup()
-
- @staticmethod
- def cancel() -> InlineKeyboardMarkup:
- """
- Generates a cancel button to return to the home screen.
- """
- return (
- InlineKeyboardBuilder()
- .row(
- InlineKeyboardButton(
- text=KeyboardTexts.HOLDERBOT,
- callback_data=PagesCallbacks(page=PagesActions.HOME).pack(),
- )
- )
- .as_markup()
- )
-
- @staticmethod
- def user_status(action: AdminActions) -> InlineKeyboardMarkup:
- """
- Generates a keyboard for changing user status to either 'active' or 'on hold'.
- """
- kb = InlineKeyboardBuilder()
- kb.row(
- InlineKeyboardButton(
- text=KeyboardTexts.ACTIVE,
- callback_data=UserStatusCallbacks(
- status="active", action=action
- ).pack(),
- ),
- InlineKeyboardButton(
- text=KeyboardTexts.ON_HOLD,
- callback_data=UserStatusCallbacks(
- status="on_hold", action=action
- ).pack(),
- ),
- )
- kb.button(
- text=KeyboardTexts.HOLDERBOT,
- callback_data=PagesCallbacks(page=PagesActions.HOME).pack(),
- )
- return kb.adjust(2).as_markup()
-
- @staticmethod
- def inbounds(
- inbounds: dict[str, list[ProxyInbound]],
- selected: set[str] = None,
- action: AdminActions = AdminActions.ADD,
- just_one_inbound: bool = False,
- ) -> InlineKeyboardMarkup:
- """
- Generates a keyboard with available inbounds, allowing the user to select or deselect them.
- """
- if selected is None:
- selected = set()
-
- kb = InlineKeyboardBuilder()
- for protocol_list in inbounds.values():
- for inbound in protocol_list:
- is_selected = inbound["tag"] in selected
- kb.button(
- text=f"{('✅' if is_selected else '❌') if not just_one_inbound else '🔘'} "
- f"{inbound['tag']} ({inbound['protocol']})",
- callback_data=UserInboundsCallbacks(
- tag=inbound["tag"],
- protocol=inbound["protocol"],
- is_selected=is_selected,
- action=action,
- just_one_inbound=just_one_inbound,
- is_done=just_one_inbound,
- ),
- )
- kb.row(
- InlineKeyboardButton(
- text=KeyboardTexts.FINISH,
- callback_data=UserInboundsCallbacks(action=action, is_done=True).pack(),
- ),
- InlineKeyboardButton(
- text=KeyboardTexts.HOLDERBOT,
- callback_data=PagesCallbacks(page=PagesActions.HOME).pack(),
- ),
- )
- return kb.adjust(2).as_markup()
-
- @staticmethod
- def admins(admins: list[Admin]) -> InlineKeyboardMarkup:
- """
- Generates a keyboard with buttons for each admin in the list.
- """
- kb = InlineKeyboardBuilder()
-
- for admin in admins:
- kb.button(
- text=admin.username,
- callback_data=AdminSelectCallbacks(username=admin.username),
- )
-
- kb.row(
- InlineKeyboardButton(
- text=KeyboardTexts.HOLDERBOT,
- callback_data=PagesCallbacks(page=PagesActions.HOME).pack(),
- ),
- )
- return kb.adjust(2).as_markup()
-
- @staticmethod
- def node_monitoring() -> InlineKeyboardMarkup:
- """
- Generates a keyboard for node monitoring actions, such as checking or restarting nodes.
- """
- kb = InlineKeyboardBuilder()
-
- kb.button(
- text=KeyboardTexts.NODE_MONITORING_CHECKER,
- callback_data=ConfirmCallbacks(
- page=BotActions.NODE_CHECKER, action=AdminActions.EDIT, is_confirm=True
- ),
- )
- kb.button(
- text=KeyboardTexts.NODE_MONITORING_AUTO_RESTART,
- callback_data=ConfirmCallbacks(
- page=BotActions.NODE_AUTO_RESTART,
- action=AdminActions.EDIT,
- is_confirm=True,
- ),
- )
- kb.button(
- text=KeyboardTexts.NODE_MONITORING_EXCLUDED,
- callback_data=ConfirmCallbacks(
- page=BotActions.NODE_EXCLUDED, action=AdminActions.EDIT
- ),
- )
- kb.adjust(2)
- kb.row(
- InlineKeyboardButton(
- text=KeyboardTexts.HOLDERBOT,
- callback_data=PagesCallbacks(page=PagesActions.HOME).pack(),
- ),
- )
- return kb.as_markup()
-
- @staticmethod
- def users() -> InlineKeyboardMarkup:
- """
- Generates a keyboard with options for managing user inbounds, such as adding or deleting.
- """
- kb = InlineKeyboardBuilder()
-
- kb.button(
- text=KeyboardTexts.USERS_ADD_INBOUND,
- callback_data=ConfirmCallbacks(
- page=BotActions.USERS_INBOUND, action=AdminActions.ADD
- ),
- )
- kb.button(
- text=KeyboardTexts.USERS_DELETE_INBOUND,
- callback_data=ConfirmCallbacks(
- page=BotActions.USERS_INBOUND, action=AdminActions.DELETE
- ),
- )
- kb.row(
- InlineKeyboardButton(
- text=KeyboardTexts.HOLDERBOT,
- callback_data=PagesCallbacks(page=PagesActions.HOME).pack(),
- ),
- width=1,
- )
- return kb.adjust(2).as_markup()
-
- @staticmethod
- def user(user: UserResponse) -> InlineKeyboardMarkup:
- """
- Generates a keyboard with a button to copy the user subscription link.
- """
- 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.adjust(1).as_markup()
-
- @staticmethod
- def select_nodes(
- nodes: list[NodeResponse], selected: list[str] = None
- ) -> InlineKeyboardMarkup:
- """Builds a keyboard to show selected/unselected nodes."""
- kb = InlineKeyboardBuilder()
- for node in nodes:
- node_name = node.name if isinstance(node, NodeResponse) else node["name"]
- is_selected = node_name in selected
- kb.button(
- text=f"{'✅' if is_selected else '❌'} {node_name}",
- callback_data=NodeSelectCallbacks(name=node_name),
- )
- kb.adjust(2)
- kb.row(
- InlineKeyboardButton(
- text=KeyboardTexts.HOLDERBOT,
- callback_data=PagesCallbacks(page=PagesActions.HOME).pack(),
- ),
- InlineKeyboardButton(
- text=KeyboardTexts.FINISH,
- callback_data=NodeSelectCallbacks(is_done=True).pack(),
- ),
- width=2,
- )
- return kb.as_markup()
diff --git a/utils/lang.py b/utils/lang.py
deleted file mode 100644
index c05aba9..0000000
--- a/utils/lang.py
+++ /dev/null
@@ -1,105 +0,0 @@
-"""
-This module contains constants and texts used in the HolderBot.
-"""
-
-from pydantic_settings import BaseSettings, SettingsConfigDict
-
-
-class KeyboardTextsFile(BaseSettings):
- """Keyboard texts used in the bot."""
-
- model_config: SettingsConfigDict = SettingsConfigDict(
- env_file=".env", extra="ignore"
- )
-
- HOLDERBOT: str = "🏠 Back to home"
- USER_CREATE: str = "👤 User Create"
- NODE_MONITORING: str = "🗃 Node Monitoring"
- ACTIVE: str = "✅ Active"
- ON_HOLD: str = "⏸️ On hold"
- FINISH: str = "✔️ Finish"
- NODE_MONITORING_CHECKER: str = "🧨 Checker"
- NODE_MONITORING_AUTO_RESTART: str = "🔁 AutoRestart"
- NODE_MONITORING_EXCLUDED: str = "👀 Excluded Nodes"
- USERS_MENU: str = "👥 Users"
- 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):
- """Message texts used in the bot."""
-
- model_config: SettingsConfigDict = SettingsConfigDict(
- env_file=".env", extra="ignore"
- )
-
- VERSION_NUMBER: str = "0.2.6"
- OWNER_ID: str = "@ErfJabs"
-
- START: str = (
- f"Welcome to HolderBot 🤖 [{VERSION_NUMBER}]\n"
- f"Developed and designed by {OWNER_ID}"
- )
- VERSION: str = f"⚡️ Current Version: {VERSION_NUMBER}
"
- ASK_CREATE_USER_BASE_USERNAME: str = "👤 Please enter the user base name"
- ASK_CREATE_USER_START_NUMBER: str = (
- "🔢 Please enter the starting user number"
- )
- ASK_CREATE_USER_HOW_MUCH: str = "👥 How many users would you like to create?"
- ASK_CREATE_USER_DATA_LIMIT: str = "📊 Please enter the data limit in GB"
- ASK_CREATE_USER_DATE_LIMIT: str = "📅 Please enter the date limit in days"
- ASK_CREATE_USER_STATUS: str = "🔄 Select the user status"
- ASK_CREATE_ADMIN_USERNAME: str = "👤 Select the owner admin"
- ASK_CREATE_USER_INBOUNDS: str = "🌐 Select the user inbounds"
- JUST_NUMBER: str = "🔢 Please enter numbers only"
- NONE_USER_INBOUNDS: str = "⚠️ Please select an inbound first"
- USER_INFO: str = (
- "{status_emoji} Username: {username}
\n"
- "📊 Data limit: {data_limit}
GB\n"
- "📅 Date limit: {date_limit}
days\n"
- "🔗 Subscription: {subscription}"
- )
- NODE_ERROR: str = (
- "🗃 Node: {name}
\n"
- "📍 IP: {ip}
\n"
- "📪 Message: {message}
"
- )
- NODE_AUTO_RESTART_DONE: str = "✅ {name}
auto restart is Done!"
- NODE_AUTO_RESTART_ERROR: str = (
- "❌ {name}
auto restart is Wrong!"
- )
- NODE_MONITORING_MENU: str = (
- "🧨 Checker is {checker}
\n"
- "🔁 AutoRestart is {auto_restart}
\n"
- "👀 Excluded nodes: {excluded}"
- )
- NODE_MONITORING_EXCLUDED: str = "👀 Selected your excluded nodes:"
- USERS_MENU: str = "👥 What do you need?"
- USERS_INBOUND_SELECT: str = "🌐 Select Your Inbound:"
- WORKING: str = "⏳"
- USERS_INBOUND_SUCCESS_UPDATED: str = "✅ Users Inbounds is Updated!"
- 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/log.py b/utils/log.py
deleted file mode 100644
index 9d2c73c..0000000
--- a/utils/log.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""
-Logging setup module for the HolderBot.
-
-This module provides a function to set up a logger for the bot,
-allowing logging to both the console and a file.
-"""
-
-import logging
-
-
-class BotLogger:
- """
- A class to set up and manage a logger for the bot.
- This class allows logging to both the console and a file.
- """
-
- def __init__(self, bot_name: str, level: int = logging.INFO):
- """
- Initialize the logger for the specified bot.
-
- Args:
- bot_name (str): The name of the bot for which the logger is set up.
- level (int): The logging level (default is INFO).
- """
- self.bot_name = bot_name
- self.level = level
- self.bot_logger = logging.getLogger(bot_name)
- self.bot_logger.setLevel(level)
- self._setup_handlers()
-
- def _setup_handlers(self):
- """
- Set up the console and file handlers for logging.
- """
- # Console handler
- console_handler = logging.StreamHandler()
- console_handler.setLevel(logging.DEBUG)
-
- # File handler
- file_handler = logging.FileHandler(f"data/{self.bot_name}.log")
- file_handler.setLevel(logging.INFO)
-
- # Formatter for both handlers
- formatter = logging.Formatter(
- f"%(asctime)-25s | {self.bot_name} | %(levelname)-8s | %(message)s"
- )
- console_handler.setFormatter(formatter)
- file_handler.setFormatter(formatter)
-
- # Add handlers to the logger
- self.bot_logger.addHandler(console_handler)
- self.bot_logger.addHandler(file_handler)
-
- def get_logger(self):
- """
- Get the configured logger instance.
-
- Returns:
- logging.Logger: The configured logger instance.
- """
- return self.bot_logger
-
- def set_log_level(self, level: int):
- """
- Set the logging level for the logger.
-
- Args:
- level (int): The logging level to set.
- """
- self.bot_logger.setLevel(level)
diff --git a/utils/panel.py b/utils/panel.py
deleted file mode 100644
index cf937c0..0000000
--- a/utils/panel.py
+++ /dev/null
@@ -1,214 +0,0 @@
-"""
-This module provides functions to interact with the Marzban API,
-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,
- UserResponse,
- UserCreate,
- Admin,
- UserModify,
- NodeResponse,
- UsersResponse,
-)
-from db import TokenManager
-from utils import EnvSettings, logger
-
-marzban_panel = MarzbanAPI(EnvSettings.MARZBAN_ADDRESS, timeout=30.0, verify=False)
-
-
-async def get_inbounds() -> dict[str, list[ProxyInbound]]:
- """
- Retrieve a list of inbounds from the Marzban panel.
- """
- try:
- get_token = await TokenManager.get()
- return await marzban_panel.get_inbounds(get_token.token) or False
- except (httpx.RequestError, httpx.HTTPStatusError) as e:
- logger.error("Error getting panel inbounds: %s", e)
- return False
-
-
-# pylint: disable=R0913, R0917
-async def create_user(
- username: str,
- status: str,
- proxies: dict,
- inbounds: dict,
- data_limit: int,
- date_limit: int,
-) -> UserResponse:
- """
- Create a new user in the Marzban panel.
- """
- try:
- get_token = await TokenManager.get()
-
- new_user = UserCreate(
- username=username,
- status=status,
- proxies=proxies,
- inbounds=inbounds,
- data_limit=(data_limit * (1024**3)),
- data_limit_reset_strategy="no_reset",
- )
-
- if status == "active":
- new_user.expire = int(
- (datetime.utcnow() + timedelta(days=date_limit)).timestamp()
- )
- elif status == "on_hold":
- new_user.on_hold_expire_duration = int(date_limit) * 86400
- new_user.on_hold_timeout = (
- datetime.utcnow() + timedelta(days=365)
- ).strftime("%Y-%m-%d %H:%M:%S")
-
- return await marzban_panel.add_user(new_user, get_token.token) or None
- except (httpx.RequestError, httpx.HTTPStatusError) as e:
- logger.error("Error creating user: %s", e)
- return False
-
-
-async def admins() -> list[Admin]:
- """
- Retrieve a list of admins from the Marzban panel.
- """
- try:
- get_token = await TokenManager.get()
- return await marzban_panel.get_admins(get_token.token) or False
- except (httpx.RequestError, httpx.HTTPStatusError) as e:
- logger.error("Error getting admins list: %s", e)
- return False
-
-
-async def set_owner(admin: str, user: str) -> bool:
- """
- Set an admin as the owner of a user.
- """
- try:
- get_token = await TokenManager.get()
- return (
- await marzban_panel.set_owner(
- username=user, admin_username=admin, token=get_token.token
- )
- is not None
- )
- except (httpx.RequestError, httpx.HTTPStatusError) as e:
- logger.error("Error setting owner: %s", e)
- return False
-
-
-async def user_modify(username: str, data: UserModify) -> bool:
- """
- Modify an existing user's details.
- """
- try:
- get_token = await TokenManager.get()
- return (
- await marzban_panel.modify_user(
- username=username, user=data, token=get_token.token
- )
- is not None
- )
- except (httpx.RequestError, httpx.HTTPStatusError) as e:
- logger.error("Error modifying user: %s", e)
- return False
-
-
-async def get_users(
- offset: int = 0, limit: int = EnvSettings.ACTION_LIMIT
-) -> list[UserResponse]:
- """
- Retrieve a list of users from the Marzban panel.
- """
- try:
- get_token = await TokenManager.get()
- users_response = await marzban_panel.get_users(
- token=get_token.token, offset=offset, limit=limit
- )
- return users_response.users if users_response else False
- except (httpx.RequestError, httpx.HTTPStatusError) as e:
- logger.error("Error getting all users: %s", e)
- return False
-
-
-async def get_nodes() -> list[NodeResponse]:
- """Fetch all nodes from the panel."""
- try:
- get_token = await TokenManager.get()
- return await marzban_panel.get_nodes(get_token.token)
- 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/report.py b/utils/report.py
deleted file mode 100644
index 79d2622..0000000
--- a/utils/report.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""
-This module is responsible for sending messages to admins
-"""
-
-from aiogram import Bot
-from aiogram.client.default import DefaultBotProperties
-from aiogram.enums.parse_mode import ParseMode
-from aiogram.exceptions import AiogramError, TelegramAPIError
-
-from marzban import NodeResponse
-
-from utils import EnvSettings, MessageTexts, logger
-
-bot = Bot(
- token=EnvSettings.TELEGRAM_BOT_TOKEN,
- default=DefaultBotProperties(parse_mode=ParseMode.HTML),
-)
-
-
-async def send_message(message: str):
- """
- Sends a message to all admins.
- """
- try:
- for admin_chatid in EnvSettings.TELEGRAM_ADMINS_ID:
- await bot.send_message(chat_id=admin_chatid, text=message)
- except (AiogramError, TelegramAPIError) as e:
- logger.error("Failed send report message: %s", str(e))
-
-
-async def node_error(node: NodeResponse):
- """
- Sends a notification to admins about a node error.
- """
- text = (MessageTexts.NODE_ERROR).format(
- name=node.name, ip=node.address, message=node.message or "None"
- )
- await send_message(text)
-
-
-async def node_restart(node: NodeResponse, success: bool):
- """
- Sends a notification to admins about the result of a node restart.
- """
- text = (
- (MessageTexts.NODE_AUTO_RESTART_DONE).format(name=node.name)
- if success is True
- else (MessageTexts.NODE_AUTO_RESTART_ERROR).format(name=node.name)
- )
- await send_message(text)
diff --git a/utils/statedb.py b/utils/statedb.py
deleted file mode 100644
index 51f4bf9..0000000
--- a/utils/statedb.py
+++ /dev/null
@@ -1,175 +0,0 @@
-# pylint: disable=all
-# because this is a plugin
-
-from typing import Any, Dict, Optional, List, Union
-from sqlalchemy import Column, Integer, String, JSON, and_
-from sqlalchemy.ext.declarative import declarative_base
-from sqlalchemy.orm import sessionmaker
-from sqlalchemy.future import select
-from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
-from aiogram.fsm.storage.base import BaseStorage, StorageKey
-from aiogram.fsm.state import State
-from aiogram.types import Message, CallbackQuery
-import asyncio
-from contextlib import asynccontextmanager
-
-Base = declarative_base()
-
-# Constants
-DATABASE_URL = "sqlite+aiosqlite:///data/state.db"
-
-
-# Model Classes
-class StateModel(Base):
- __tablename__ = "states"
- user_id = Column(Integer, primary_key=True)
- chat_id = Column(Integer, primary_key=True)
- state = Column(String)
-
-
-class DataModel(Base):
- __tablename__ = "data"
- user_id = Column(Integer, primary_key=True)
- chat_id = Column(Integer, primary_key=True)
- data = Column(JSON)
-
-
-class MessageModel(Base):
- __tablename__ = "messages"
- id = Column(Integer, primary_key=True, autoincrement=True)
- chat_id = Column(Integer, index=True)
- message_id = Column(Integer, index=True)
-
-
-class SQLAlchemyStorage(BaseStorage):
- def __init__(self, db_url: str = DATABASE_URL):
- self.engine = create_async_engine(db_url, echo=False)
- self.async_session = sessionmaker(
- self.engine, class_=AsyncSession, expire_on_commit=False
- )
- self._connection_lock = asyncio.Lock()
-
- @asynccontextmanager
- async def session(self):
- await self.init()
- async with self._connection_lock:
- async with self.async_session() as session:
- async with session.begin():
- yield session
-
- async def init(self):
- async with self.engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
-
- async def set_state(self, key: StorageKey, state: Optional[State] = None) -> None:
- async with self.session() as session:
- if state is None:
- await session.execute(
- StateModel.__table__.delete().where(
- and_(
- StateModel.user_id == key.user_id,
- StateModel.chat_id == key.chat_id,
- )
- )
- )
- else:
- state_str = state.state if isinstance(state, State) else state
- await session.merge(
- StateModel(
- user_id=key.user_id, chat_id=key.chat_id, state=state_str
- )
- )
-
- async def get_state(self, key: StorageKey) -> Optional[str]:
- async with self.session() as session:
- result = await session.execute(
- select(StateModel).where(
- and_(
- StateModel.user_id == key.user_id,
- StateModel.chat_id == key.chat_id,
- )
- )
- )
- state = result.scalar_one_or_none()
- return state.state if state else None
-
- async def set_data(self, key: StorageKey, data: Dict[str, Any]) -> None:
- async with self.session() as session:
- existing_data = await session.get(DataModel, (key.user_id, key.chat_id))
- if existing_data:
- existing_data.data = data
- else:
- new_data = DataModel(
- user_id=key.user_id, chat_id=key.chat_id, data=data
- )
- session.add(new_data)
-
- async def get_data(self, key: StorageKey) -> Dict[str, Any]:
- async with self.session() as session:
- result = await session.get(DataModel, (key.user_id, key.chat_id))
- return result.data if result else {}
-
- async def add_log_message(self, chat_id: int, message_id: int) -> None:
- async with self.session() as session:
- new_log = MessageModel(chat_id=chat_id, message_id=message_id)
- session.add(new_log)
-
- async def delete_log_messages(self, chat_id: int) -> int:
- async with self.session() as session:
- result = await session.execute(
- MessageModel.__table__.delete().where(MessageModel.chat_id == chat_id)
- )
- return result.rowcount
-
- async def get_log_messages(self, chat_id: int) -> List[int]:
- async with self.session() as session:
- result = await session.execute(
- select(MessageModel.message_id).where(MessageModel.chat_id == chat_id)
- )
- return [row[0] for row in result.fetchall()]
-
- async def clear_chat_messages(
- self, message_or_callback: Union[Message, CallbackQuery]
- ) -> None:
- chat_id = (
- message_or_callback.chat.id
- if isinstance(message_or_callback, Message)
- else message_or_callback.message.chat.id
- )
-
- message_ids = await self.get_log_messages(chat_id)
-
- for msg_id in message_ids:
- try:
- await message_or_callback.bot.delete_message(chat_id, msg_id)
- except Exception as e:
- print(f"Failed to delete message {msg_id}: {e}")
-
- async def clear_and_add_message(
- self, message_or_callback: Union[Message, CallbackQuery]
- ) -> None:
- chat_id = (
- message_or_callback.chat.id
- if isinstance(message_or_callback, Message)
- else message_or_callback.message.chat.id
- )
-
- message_ids = await self.get_log_messages(chat_id)
-
- for msg_id in message_ids:
- try:
- await message_or_callback.bot.delete_message(chat_id, msg_id)
- except Exception as e:
- print(f"Failed to delete message {msg_id}: {e}")
-
- await self.delete_log_messages(chat_id)
-
- message_id = (
- message_or_callback.message_id
- if isinstance(message_or_callback, Message)
- else message_or_callback.message.message_id
- )
- await self.add_log_message(chat_id, message_id)
-
- async def close(self) -> None:
- await self.engine.dispose()
diff --git a/utils/text_info.py b/utils/text_info.py
deleted file mode 100644
index c69e9e2..0000000
--- a/utils/text_info.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""
-Module docstring: This module contains functions for formatting user information
-for display, including user status, data limit, subscription, etc.
-"""
-
-from typing import Optional
-from datetime import datetime, timezone
-from marzban import UserResponse
-from utils import MessageTexts
-
-
-def user_info(user: UserResponse) -> str:
- """
- Formats user information with detailed time remaining display.
- """
-
- 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),
- )