From 15ad13e66cfce2324e6ab5165e609b6cf5d38a9e Mon Sep 17 00:00:00 2001 From: erfjab Date: Sun, 8 Dec 2024 17:54:33 +0330 Subject: [PATCH] breaking change: delete old source --- .dockerignore | 19 -- .env.example | 6 - Dockerfile | 21 -- README.md | 123 -------- alembic.ini | 113 ------- db/__init__.py | 7 - db/alembic/README | 1 - db/alembic/env.py | 92 ------ db/alembic/script.py.mako | 26 -- ...e718f423f_add_node_excluded_monitorings.py | 34 --- .../versions/3e5deef43bf0_init_commit.py | 36 --- .../4abf3adb8ab8_refact_setting_table.py | 52 ---- .../versions/ab1ce3ef2a57_add_settings.py | 38 --- db/base.py | 48 --- db/crud/__init__.py | 10 - db/crud/setting.py | 88 ------ db/crud/token.py | 55 ---- db/models.py | 39 --- docker-compose.yml | 8 - jobs/__init__.py | 9 - jobs/node_monitoring.py | 50 ---- jobs/scheduler.py | 58 ---- jobs/token_updater.py | 42 --- main.py | 81 ----- middlewares/auth.py | 45 --- models/__init__.py | 35 --- models/callback.py | 96 ------ models/setting.py | 12 - models/state.py | 23 -- models/token.py | 32 -- requirements.txt | 12 - routers/__init__.py | 24 -- routers/base.py | 49 --- routers/inline.py | 45 --- routers/node.py | 116 -------- routers/user.py | 279 ------------------ routers/users.py | 82 ----- utils/__init__.py | 18 -- utils/config.py | 22 -- utils/helpers.py | 124 -------- utils/keys.py | 262 ---------------- utils/lang.py | 105 ------- utils/log.py | 70 ----- utils/panel.py | 214 -------------- utils/report.py | 50 ---- utils/statedb.py | 175 ----------- utils/text_info.py | 86 ------ 47 files changed, 3032 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .env.example delete mode 100644 Dockerfile delete mode 100644 README.md delete mode 100644 alembic.ini delete mode 100644 db/__init__.py delete mode 100644 db/alembic/README delete mode 100644 db/alembic/env.py delete mode 100644 db/alembic/script.py.mako delete mode 100644 db/alembic/versions/10ce718f423f_add_node_excluded_monitorings.py delete mode 100644 db/alembic/versions/3e5deef43bf0_init_commit.py delete mode 100644 db/alembic/versions/4abf3adb8ab8_refact_setting_table.py delete mode 100644 db/alembic/versions/ab1ce3ef2a57_add_settings.py delete mode 100644 db/base.py delete mode 100644 db/crud/__init__.py delete mode 100644 db/crud/setting.py delete mode 100644 db/crud/token.py delete mode 100644 db/models.py delete mode 100644 docker-compose.yml delete mode 100644 jobs/__init__.py delete mode 100644 jobs/node_monitoring.py delete mode 100644 jobs/scheduler.py delete mode 100644 jobs/token_updater.py delete mode 100644 main.py delete mode 100644 middlewares/auth.py delete mode 100644 models/__init__.py delete mode 100644 models/callback.py delete mode 100644 models/setting.py delete mode 100644 models/state.py delete mode 100644 models/token.py delete mode 100644 requirements.txt delete mode 100644 routers/__init__.py delete mode 100644 routers/base.py delete mode 100644 routers/inline.py delete mode 100644 routers/node.py delete mode 100644 routers/user.py delete mode 100644 routers/users.py delete mode 100644 utils/__init__.py delete mode 100644 utils/config.py delete mode 100644 utils/helpers.py delete mode 100644 utils/keys.py delete mode 100644 utils/lang.py delete mode 100644 utils/log.py delete mode 100644 utils/panel.py delete mode 100644 utils/report.py delete mode 100644 utils/statedb.py delete mode 100644 utils/text_info.py 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), - )