Add source
This commit is contained in:
40
.dockerignore
Normal file
40
.dockerignore
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Исключаем локальные конфигурационные файлы из Docker образа
|
||||||
|
# .env используется для Docker, но не должен быть в образе (передается через docker-compose)
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Исключаем данные и логи (они монтируются как volumes)
|
||||||
|
data/
|
||||||
|
downloads/
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Исключаем файлы разработки
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Исключаем Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Исключаем документацию и примеры
|
||||||
|
*.md
|
||||||
|
!README_DOCKER.md
|
||||||
|
*.example
|
||||||
|
|
||||||
|
# Исключаем IDE файлы
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Исключаем тесты
|
||||||
|
tests/
|
||||||
|
test_*.py
|
||||||
|
*_test.py
|
||||||
54
.env.docker.example
Normal file
54
.env.docker.example
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Пример конфигурации для Docker Compose
|
||||||
|
# Скопируйте этот файл в .env.docker и настройте под свои нужды
|
||||||
|
#
|
||||||
|
# ВАЖНО: Используйте .env.docker для Docker, а .env для локальной разработки
|
||||||
|
# Это позволяет работать параллельно без конфликтов
|
||||||
|
|
||||||
|
# Telegram Bot Configuration
|
||||||
|
BOT_TOKEN=your_bot_token_here
|
||||||
|
TELEGRAM_API_ID=your_api_id_here
|
||||||
|
TELEGRAM_API_HASH=your_api_hash_here
|
||||||
|
OWNER_ID=your_owner_id_here
|
||||||
|
|
||||||
|
# Authorization Configuration
|
||||||
|
AUTHORIZED_USERS=
|
||||||
|
ADMIN_IDS=
|
||||||
|
BLOCKED_USERS=
|
||||||
|
PRIVATE_MODE=False
|
||||||
|
|
||||||
|
# Database Configuration для Docker
|
||||||
|
# PostgreSQL автоматически настраивается через docker-compose
|
||||||
|
# Используйте имя сервиса 'postgres' как хост
|
||||||
|
# ВАЖНО: Замените tgloader_password на свой пароль!
|
||||||
|
DATABASE_URL=postgresql+asyncpg://tgloader:tgloader_password@postgres:5432/tgloader
|
||||||
|
|
||||||
|
# PostgreSQL Configuration (используется в docker-compose.yml)
|
||||||
|
POSTGRES_DB=tgloader
|
||||||
|
POSTGRES_USER=tgloader
|
||||||
|
POSTGRES_PASSWORD=tgloader_password
|
||||||
|
# ВАЖНО: POSTGRES_PASSWORD должен совпадать в DATABASE_URL и в настройках сервиса postgres
|
||||||
|
|
||||||
|
# Web Configuration
|
||||||
|
WEB_HOST=0.0.0.0
|
||||||
|
WEB_PORT=5000
|
||||||
|
WEB_SECRET_KEY=your_secret_key_here
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FILE=logs/bot.log
|
||||||
|
|
||||||
|
# Media Download Configuration
|
||||||
|
COOKIES_FILE=
|
||||||
|
|
||||||
|
# Download Limits
|
||||||
|
MAX_FILE_SIZE=
|
||||||
|
MAX_DURATION_MINUTES=
|
||||||
|
MAX_CONCURRENT_TASKS=5
|
||||||
|
|
||||||
|
# Redis Configuration для Docker
|
||||||
|
# Использовать Redis для сессий (рекомендуется для продакшена)
|
||||||
|
USE_REDIS_SESSIONS=true
|
||||||
|
# В Docker используйте имя сервиса 'redis' как хост (docker-compose автоматически переопределит это)
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
59
.gitignore
vendored
Normal file
59
.gitignore
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual Environment
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.docker
|
||||||
|
config.env
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
data/*
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/*
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Downloads
|
||||||
|
downloads/*
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
FROM python:3.11-slim-bookworm
|
||||||
|
|
||||||
|
# Установка системных зависимостей
|
||||||
|
RUN sed -i 's|deb.debian.org|mirror.yandex.ru|g' /etc/apt/sources.list.d/debian.sources \
|
||||||
|
&& apt-get update
|
||||||
|
|
||||||
|
RUN apt-get install -y ffmpeg
|
||||||
|
|
||||||
|
# Создание рабочей директории
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копирование файлов зависимостей
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Установка Python зависимостей
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Копирование кода приложения
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Создание необходимых директорий
|
||||||
|
RUN mkdir -p data downloads logs
|
||||||
|
|
||||||
|
# Переменные окружения
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
# Порт для веб-интерфейса
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Команда запуска
|
||||||
|
CMD ["python", "main.py"]
|
||||||
|
|
||||||
147
alembic.ini
Normal file
147
alembic.ini
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = %(here)s/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. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
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 and tzdata 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 <script_location>/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 "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
|
# file.
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[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 module runner, against the "ruff" module
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = module
|
||||||
|
# ruff.module = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
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
|
||||||
1
alembic/README
Normal file
1
alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
114
alembic/env.py
Normal file
114
alembic/env.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""
|
||||||
|
Alembic environment configuration for async SQLAlchemy
|
||||||
|
"""
|
||||||
|
from logging.config import fileConfig
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Import settings and models
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
project_root = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
from shared.config import settings
|
||||||
|
from shared.database.models import Base
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# Set target metadata for autogenerate
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# Override sqlalchemy.url from settings if not set in alembic.ini
|
||||||
|
if config.get_main_option("sqlalchemy.url") == "driver://user:pass@localhost/dbname":
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""
|
||||||
|
Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
"""
|
||||||
|
Run migrations with the given connection.
|
||||||
|
"""
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=False # Disable autogenerate comparison to use explicit migration definitions
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
"""
|
||||||
|
Run migrations in 'online' mode with async engine.
|
||||||
|
"""
|
||||||
|
# Get database URL from settings
|
||||||
|
database_url = settings.DATABASE_URL
|
||||||
|
|
||||||
|
# Create async engine from config
|
||||||
|
configuration = config.get_section(config.config_ini_section, {})
|
||||||
|
configuration["sqlalchemy.url"] = database_url
|
||||||
|
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
configuration,
|
||||||
|
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.
|
||||||
|
|
||||||
|
For async SQLAlchemy, we use asyncio to run async migrations.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
28
alembic/script.py.mako
Normal file
28
alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""${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, Sequence[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:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
114
alembic/versions/7ac28bbbc5ee_initial_migration.py
Normal file
114
alembic/versions/7ac28bbbc5ee_initial_migration.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""Initial migration
|
||||||
|
|
||||||
|
Revision ID: 7ac28bbbc5ee
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-12-02 00:26:38.350265
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '7ac28bbbc5ee'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# Create users table
|
||||||
|
op.create_table(
|
||||||
|
'users',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('first_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('last_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('is_admin', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('is_blocked', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('user_id', name=op.f('pk_users')),
|
||||||
|
sa.UniqueConstraint('user_id', name=op.f('uq_users_user_id'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_users_user_id'), 'users', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
# Create tasks table
|
||||||
|
op.create_table(
|
||||||
|
'tasks',
|
||||||
|
sa.Column('id', sa.BigInteger(), nullable=False, autoincrement=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('task_type', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('url', sa.Text(), nullable=True),
|
||||||
|
sa.Column('file_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('progress', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('error_message', sa.String(1000), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], name=op.f('fk_tasks_user_id_users')),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_tasks'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_tasks_created_at'), 'tasks', ['created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_tasks_status'), 'tasks', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_tasks_user_id'), 'tasks', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
# Create downloads table
|
||||||
|
op.create_table(
|
||||||
|
'downloads',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
|
||||||
|
sa.Column('task_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('url', sa.Text(), nullable=False),
|
||||||
|
sa.Column('download_type', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('file_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('file_size', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('duration', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], name=op.f('fk_downloads_task_id_tasks')),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_downloads'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_downloads_id'), 'downloads', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Create otp_codes table
|
||||||
|
op.create_table(
|
||||||
|
'otp_codes',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=6), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('used', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], name=op.f('fk_otp_codes_user_id_users')),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_otp_codes'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_otp_codes_code'), 'otp_codes', ['code'], unique=False)
|
||||||
|
op.create_index(op.f('ix_otp_codes_expires_at'), 'otp_codes', ['expires_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_otp_codes_id'), 'otp_codes', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_otp_codes_used'), 'otp_codes', ['used'], unique=False)
|
||||||
|
op.create_index(op.f('ix_otp_codes_user_id'), 'otp_codes', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# Drop tables in reverse order (respecting foreign key constraints)
|
||||||
|
op.drop_index(op.f('ix_otp_codes_user_id'), table_name='otp_codes')
|
||||||
|
op.drop_index(op.f('ix_otp_codes_used'), table_name='otp_codes')
|
||||||
|
op.drop_index(op.f('ix_otp_codes_id'), table_name='otp_codes')
|
||||||
|
op.drop_index(op.f('ix_otp_codes_expires_at'), table_name='otp_codes')
|
||||||
|
op.drop_index(op.f('ix_otp_codes_code'), table_name='otp_codes')
|
||||||
|
op.drop_table('otp_codes')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_downloads_id'), table_name='downloads')
|
||||||
|
op.drop_table('downloads')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_tasks_user_id'), table_name='tasks')
|
||||||
|
op.drop_index(op.f('ix_tasks_status'), table_name='tasks')
|
||||||
|
op.drop_index(op.f('ix_tasks_created_at'), table_name='tasks')
|
||||||
|
op.drop_table('tasks')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_users_user_id'), table_name='users')
|
||||||
|
op.drop_table('users')
|
||||||
4
bot/__init__.py
Normal file
4
bot/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Telegram Bot Application
|
||||||
|
"""
|
||||||
|
|
||||||
7
bot/config.py
Normal file
7
bot/config.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Bot configuration
|
||||||
|
"""
|
||||||
|
from shared.config import settings
|
||||||
|
|
||||||
|
__all__ = ["settings"]
|
||||||
|
|
||||||
4
bot/modules/__init__.py
Normal file
4
bot/modules/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Модули Telegram бота
|
||||||
|
"""
|
||||||
|
|
||||||
4
bot/modules/access_control/__init__.py
Normal file
4
bot/modules/access_control/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Access control module
|
||||||
|
"""
|
||||||
|
|
||||||
98
bot/modules/access_control/auth.py
Normal file
98
bot/modules/access_control/auth.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
User authorization
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from bot.config import settings
|
||||||
|
from bot.modules.database.session import AsyncSessionLocal
|
||||||
|
from bot.modules.database.models import User
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def is_authorized(user_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check user authorization
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user is authorized, False otherwise
|
||||||
|
"""
|
||||||
|
# Check blacklist
|
||||||
|
if user_id in settings.blocked_users_list:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check in database
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if user and user.is_blocked:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If private mode is enabled, check only whitelist
|
||||||
|
if settings.PRIVATE_MODE:
|
||||||
|
# Check in configuration
|
||||||
|
if user_id in settings.authorized_users_list:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check in database (users added via /adduser)
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if user and not user.is_blocked:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# In private mode, access only for authorized users
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If private mode is disabled
|
||||||
|
# Check whitelist (if configured)
|
||||||
|
if settings.authorized_users_list:
|
||||||
|
return user_id in settings.authorized_users_list
|
||||||
|
|
||||||
|
# If whitelist is not configured, check in database
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
# If user exists in database and is not blocked - allow access
|
||||||
|
if user and not user.is_blocked:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# By default - deny access
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def is_admin(user_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if user is administrator
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if administrator, False otherwise
|
||||||
|
"""
|
||||||
|
# Check in configuration
|
||||||
|
if user_id in settings.admin_ids_list:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check in database
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if user and user.is_admin:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def is_owner(user_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if user is owner
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if owner, False otherwise
|
||||||
|
"""
|
||||||
|
return user_id == settings.OWNER_ID
|
||||||
|
|
||||||
49
bot/modules/access_control/middleware.py
Normal file
49
bot/modules/access_control/middleware.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""
|
||||||
|
Access control middleware
|
||||||
|
"""
|
||||||
|
from pyrogram import Client
|
||||||
|
from pyrogram.handlers import MessageHandler, CallbackQueryHandler
|
||||||
|
from bot.modules.access_control.auth import is_authorized
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def access_middleware(client: Client, update, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Middleware for checking bot access
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Pyrogram client
|
||||||
|
update: Telegram update
|
||||||
|
"""
|
||||||
|
user_id = None
|
||||||
|
|
||||||
|
if hasattr(update, 'from_user') and update.from_user:
|
||||||
|
user_id = update.from_user.id
|
||||||
|
elif hasattr(update, 'message') and update.message and update.message.from_user:
|
||||||
|
user_id = update.message.from_user.id
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check authorization
|
||||||
|
if not await is_authorized(user_id):
|
||||||
|
logger.warning(f"Unauthorized user access attempt: {user_id}")
|
||||||
|
if hasattr(update, 'message') and update.message:
|
||||||
|
await update.message.reply("❌ You don't have access to this bot")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def setup_middleware(app: Client):
|
||||||
|
"""
|
||||||
|
Setup middleware for application
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: Pyrogram client
|
||||||
|
"""
|
||||||
|
# Middleware will be applied via decorators in handlers
|
||||||
|
logger.info("Access control middleware configured")
|
||||||
|
|
||||||
55
bot/modules/access_control/permissions.py
Normal file
55
bot/modules/access_control/permissions.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
Access permissions system
|
||||||
|
"""
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Callable, Awaitable
|
||||||
|
from pyrogram import Client
|
||||||
|
from pyrogram.types import Message, CallbackQuery
|
||||||
|
from bot.modules.access_control.auth import is_authorized, is_admin, is_owner
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(Enum):
|
||||||
|
"""Access permission types"""
|
||||||
|
USER = "user" # Regular user
|
||||||
|
ADMIN = "admin" # Administrator
|
||||||
|
OWNER = "owner" # Owner
|
||||||
|
|
||||||
|
|
||||||
|
def require_permission(permission: Permission):
|
||||||
|
"""
|
||||||
|
Decorator for checking access permissions
|
||||||
|
|
||||||
|
Args:
|
||||||
|
permission: Required access level
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable):
|
||||||
|
async def wrapper(client: Client, message: Message, *args, **kwargs):
|
||||||
|
user_id = message.from_user.id if message.from_user else None
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
await message.reply("❌ Failed to identify user")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check authorization
|
||||||
|
if not await is_authorized(user_id):
|
||||||
|
await message.reply("❌ You don't have access to this bot")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if permission == Permission.OWNER:
|
||||||
|
if not await is_owner(user_id):
|
||||||
|
await message.reply("❌ This command is only available to owner")
|
||||||
|
return
|
||||||
|
elif permission == Permission.ADMIN:
|
||||||
|
if not await is_admin(user_id):
|
||||||
|
await message.reply("❌ This command is only available to administrators")
|
||||||
|
return
|
||||||
|
|
||||||
|
return await func(client, message, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
249
bot/modules/access_control/user_manager.py
Normal file
249
bot/modules/access_control/user_manager.py
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"""
|
||||||
|
User and administrator management
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Tuple
|
||||||
|
from bot.modules.database.session import AsyncSessionLocal
|
||||||
|
from bot.modules.database.models import User
|
||||||
|
from bot.modules.access_control.auth import is_admin, is_owner
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_user(user_id: int, username: str = None, first_name: str = None, last_name: str = None) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Add user
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
username: Username (if not specified, will be fetched from Telegram API)
|
||||||
|
first_name: First name (if not specified, will be fetched from Telegram API)
|
||||||
|
last_name: Last name (if not specified, will be fetched from Telegram API)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success: bool, message: str)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
# Check existence
|
||||||
|
existing_user = await session.get(User, user_id)
|
||||||
|
if existing_user:
|
||||||
|
# Update user information if missing
|
||||||
|
updated = False
|
||||||
|
if not existing_user.username and username:
|
||||||
|
existing_user.username = username
|
||||||
|
updated = True
|
||||||
|
if not existing_user.first_name and first_name:
|
||||||
|
existing_user.first_name = first_name
|
||||||
|
updated = True
|
||||||
|
if not existing_user.last_name and last_name:
|
||||||
|
existing_user.last_name = last_name
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# If information is missing, try to get from Telegram API
|
||||||
|
if not existing_user.username or not existing_user.first_name:
|
||||||
|
try:
|
||||||
|
from bot.utils.user_info_updater import update_user_info_from_telegram
|
||||||
|
if await update_user_info_from_telegram(user_id, db_session=session):
|
||||||
|
updated = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to get user {user_id} information from Telegram: {e}")
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"User {user_id} information updated")
|
||||||
|
return (True, f"Пользователь {user_id} уже существует, информация обновлена")
|
||||||
|
return (False, f"Пользователь {user_id} уже существует")
|
||||||
|
|
||||||
|
# If username/first_name/last_name not specified, get from Telegram API
|
||||||
|
if not username or not first_name:
|
||||||
|
try:
|
||||||
|
from bot.utils.telegram_user import get_user_info
|
||||||
|
user_info = await get_user_info(user_id)
|
||||||
|
if user_info:
|
||||||
|
if not username:
|
||||||
|
username = user_info.get('username')
|
||||||
|
if not first_name:
|
||||||
|
first_name = user_info.get('first_name')
|
||||||
|
if not last_name:
|
||||||
|
last_name = user_info.get('last_name')
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to get user {user_id} information from Telegram: {e}")
|
||||||
|
|
||||||
|
# Create new user
|
||||||
|
user = User(
|
||||||
|
user_id=user_id,
|
||||||
|
username=username,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
is_admin=False,
|
||||||
|
is_blocked=False
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"User {user_id} added (username: {username})")
|
||||||
|
return (True, f"Пользователь {user_id} успешно добавлен")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding user: {e}", exc_info=True)
|
||||||
|
return (False, f"Ошибка базы данных: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_user(user_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Remove user
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success: bool, message: str)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
return (False, f"Пользователь {user_id} не найден в базе данных")
|
||||||
|
|
||||||
|
await session.delete(user)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"User {user_id} removed")
|
||||||
|
return (True, f"Пользователь {user_id} успешно удален")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing user: {e}", exc_info=True)
|
||||||
|
return (False, f"Ошибка базы данных: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def block_user(user_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Block user
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success: bool, message: str)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
return (False, f"Пользователь {user_id} не найден в базе данных")
|
||||||
|
|
||||||
|
if user.is_blocked:
|
||||||
|
return (False, f"Пользователь {user_id} уже заблокирован")
|
||||||
|
|
||||||
|
user.is_blocked = True
|
||||||
|
user.updated_at = datetime.utcnow()
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"User {user_id} blocked")
|
||||||
|
return (True, f"Пользователь {user_id} успешно заблокирован")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error blocking user: {e}", exc_info=True)
|
||||||
|
return (False, f"Ошибка базы данных: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def unblock_user(user_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Unblock user
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success: bool, message: str)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
return (False, f"Пользователь {user_id} не найден в базе данных")
|
||||||
|
|
||||||
|
if not user.is_blocked:
|
||||||
|
return (False, f"Пользователь {user_id} не заблокирован")
|
||||||
|
|
||||||
|
user.is_blocked = False
|
||||||
|
user.updated_at = datetime.utcnow()
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"User {user_id} unblocked")
|
||||||
|
return (True, f"Пользователь {user_id} успешно разблокирован")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error unblocking user: {e}", exc_info=True)
|
||||||
|
return (False, f"Ошибка базы данных: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def add_admin(user_id: int, requester_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Assign administrator
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to assign as admin
|
||||||
|
requester_id: ID of user making the request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success: bool, message: str)
|
||||||
|
"""
|
||||||
|
# Check permissions
|
||||||
|
if not await is_admin(requester_id):
|
||||||
|
return (False, "У вас нет прав администратора")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
# Create user if doesn't exist
|
||||||
|
user = User(user_id=user_id, is_admin=True)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"User {user_id} created and assigned as administrator")
|
||||||
|
return (True, f"Пользователь {user_id} создан и назначен администратором")
|
||||||
|
else:
|
||||||
|
if user.is_admin:
|
||||||
|
return (False, f"Пользователь {user_id} уже является администратором")
|
||||||
|
user.is_admin = True
|
||||||
|
user.updated_at = datetime.utcnow()
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"User {user_id} assigned as administrator")
|
||||||
|
return (True, f"Пользователь {user_id} успешно назначен администратором")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error assigning administrator: {e}", exc_info=True)
|
||||||
|
return (False, f"Ошибка базы данных: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_admin(user_id: int, requester_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Remove administrator privileges
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to remove admin privileges from
|
||||||
|
requester_id: ID of user making the request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success: bool, message: str)
|
||||||
|
"""
|
||||||
|
# Check permissions
|
||||||
|
if not await is_admin(requester_id):
|
||||||
|
return (False, "У вас нет прав администратора")
|
||||||
|
|
||||||
|
# Protection against self-removal
|
||||||
|
if user_id == requester_id:
|
||||||
|
return (False, "Вы не можете снять права администратора у самого себя")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
return (False, f"Пользователь {user_id} не найден в базе данных")
|
||||||
|
|
||||||
|
if not user.is_admin:
|
||||||
|
return (False, f"Пользователь {user_id} не является администратором")
|
||||||
|
|
||||||
|
user.is_admin = False
|
||||||
|
user.updated_at = datetime.utcnow()
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"Administrator privileges removed from user {user_id}")
|
||||||
|
return (True, f"Права администратора успешно сняты у пользователя {user_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing administrator privileges: {e}", exc_info=True)
|
||||||
|
return (False, f"Ошибка базы данных: {str(e)}")
|
||||||
|
|
||||||
4
bot/modules/database/__init__.py
Normal file
4
bot/modules/database/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Database module (ORM)
|
||||||
|
"""
|
||||||
|
|
||||||
7
bot/modules/database/models.py
Normal file
7
bot/modules/database/models.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
ORM models for bot (imported from shared)
|
||||||
|
"""
|
||||||
|
from shared.database.models import User, Task, Download
|
||||||
|
|
||||||
|
__all__ = ["User", "Task", "Download"]
|
||||||
|
|
||||||
22
bot/modules/database/session.py
Normal file
22
bot/modules/database/session.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""
|
||||||
|
Database session management (wrapper over shared module)
|
||||||
|
Uses unified module from shared/database/session.py
|
||||||
|
"""
|
||||||
|
from shared.database.session import (
|
||||||
|
init_db,
|
||||||
|
get_async_session_local,
|
||||||
|
get_engine
|
||||||
|
)
|
||||||
|
|
||||||
|
# For backward compatibility - get session factory
|
||||||
|
def get_AsyncSessionLocal():
|
||||||
|
"""Get session factory (for backward compatibility)"""
|
||||||
|
return get_async_session_local()
|
||||||
|
|
||||||
|
# Create object for backward compatibility
|
||||||
|
AsyncSessionLocal = get_async_session_local()
|
||||||
|
engine = get_engine()
|
||||||
|
|
||||||
|
# Export functions
|
||||||
|
__all__ = ['init_db', 'AsyncSessionLocal', 'engine']
|
||||||
|
|
||||||
4
bot/modules/media_loader/__init__.py
Normal file
4
bot/modules/media_loader/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Media loader module
|
||||||
|
"""
|
||||||
|
|
||||||
68
bot/modules/media_loader/direct.py
Normal file
68
bot/modules/media_loader/direct.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
Direct link downloads
|
||||||
|
"""
|
||||||
|
import aiohttp
|
||||||
|
import aiofiles
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def download_file(url: str, output_path: str, chunk_size: int = 8192) -> bool:
|
||||||
|
"""
|
||||||
|
Download file from direct link
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: File URL
|
||||||
|
output_path: Path to save
|
||||||
|
chunk_size: Chunk size for download
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create directory if it doesn't exist
|
||||||
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
logger.error(f"Download error: HTTP {response.status}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async with aiofiles.open(output_path, 'wb') as f:
|
||||||
|
async for chunk in response.content.iter_chunked(chunk_size):
|
||||||
|
await f.write(chunk)
|
||||||
|
|
||||||
|
logger.info(f"File downloaded: {output_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error downloading file: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def get_file_size(url: str) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Get file size from URL
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: File URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File size in bytes or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.head(url) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
content_length = response.headers.get('Content-Length')
|
||||||
|
if content_length:
|
||||||
|
return int(content_length)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting file size: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
270
bot/modules/media_loader/sender.py
Normal file
270
bot/modules/media_loader/sender.py
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
"""
|
||||||
|
Sending files to users
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from pyrogram import Client
|
||||||
|
from pyrogram.types import Message
|
||||||
|
from typing import Optional
|
||||||
|
import aiohttp
|
||||||
|
import aiofiles
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def download_thumbnail(url: str, output_path: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Download thumbnail from URL
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Thumbnail URL
|
||||||
|
output_path: Path to save
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to downloaded file or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
async with aiofiles.open(output_path, 'wb') as f:
|
||||||
|
async for chunk in response.content.iter_chunked(8192):
|
||||||
|
await f.write(chunk)
|
||||||
|
return output_path
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to download thumbnail: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def send_file_to_user(
|
||||||
|
client: Client,
|
||||||
|
chat_id: int,
|
||||||
|
file_path: str,
|
||||||
|
caption: Optional[str] = None,
|
||||||
|
thumbnail: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send file to user
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Pyrogram client
|
||||||
|
chat_id: Chat ID
|
||||||
|
file_path: Path to file
|
||||||
|
caption: File caption
|
||||||
|
thumbnail: Path to thumbnail or URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
thumbnail_path = None
|
||||||
|
try:
|
||||||
|
file = Path(file_path)
|
||||||
|
|
||||||
|
if not file.exists():
|
||||||
|
logger.error(f"File not found: {file_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Maximum file size for Telegram (2GB)
|
||||||
|
max_size = 2 * 1024 * 1024 * 1024
|
||||||
|
file_size = file.stat().st_size
|
||||||
|
|
||||||
|
# If file is larger than 2GB, split into parts
|
||||||
|
if file_size > max_size:
|
||||||
|
logger.info(f"File too large ({file_size / (1024*1024*1024):.2f} GB), splitting into parts...")
|
||||||
|
return await send_large_file_in_parts(
|
||||||
|
client, chat_id, file_path, caption, thumbnail
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process thumbnail (can be URL or file path)
|
||||||
|
if thumbnail:
|
||||||
|
if thumbnail.startswith(('http://', 'https://')):
|
||||||
|
# This is a URL - download thumbnail
|
||||||
|
thumbnail_path = f"downloads/thumb_{file.stem}.jpg"
|
||||||
|
downloaded = await download_thumbnail(thumbnail, thumbnail_path)
|
||||||
|
if downloaded:
|
||||||
|
thumbnail_path = downloaded
|
||||||
|
else:
|
||||||
|
thumbnail_path = None # Don't use thumbnail if download failed
|
||||||
|
else:
|
||||||
|
# This is a file path
|
||||||
|
thumb_file = Path(thumbnail)
|
||||||
|
if thumb_file.exists():
|
||||||
|
thumbnail_path = thumbnail
|
||||||
|
else:
|
||||||
|
thumbnail_path = None
|
||||||
|
|
||||||
|
# Determine file type
|
||||||
|
if file.suffix.lower() in ['.mp4', '.avi', '.mov', '.mkv', '.webm']:
|
||||||
|
# Video - if no thumbnail, try to generate one
|
||||||
|
if not thumbnail_path:
|
||||||
|
from bot.utils.file_processor import generate_thumbnail
|
||||||
|
thumbnail_path_temp = f"downloads/thumb_{file.stem}.jpg"
|
||||||
|
if await generate_thumbnail(str(file), thumbnail_path_temp):
|
||||||
|
thumbnail_path = thumbnail_path_temp
|
||||||
|
|
||||||
|
await client.send_video(
|
||||||
|
chat_id=chat_id,
|
||||||
|
video=str(file),
|
||||||
|
caption=caption,
|
||||||
|
thumb=thumbnail_path
|
||||||
|
)
|
||||||
|
elif file.suffix.lower() in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
|
||||||
|
# Image
|
||||||
|
await client.send_photo(
|
||||||
|
chat_id=chat_id,
|
||||||
|
photo=str(file),
|
||||||
|
caption=caption
|
||||||
|
)
|
||||||
|
elif file.suffix.lower() in ['.mp3', '.wav', '.ogg', '.m4a', '.flac']:
|
||||||
|
# Audio
|
||||||
|
await client.send_audio(
|
||||||
|
chat_id=chat_id,
|
||||||
|
audio=str(file),
|
||||||
|
caption=caption,
|
||||||
|
thumb=thumbnail_path
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Document
|
||||||
|
await client.send_document(
|
||||||
|
chat_id=chat_id,
|
||||||
|
document=str(file),
|
||||||
|
caption=caption,
|
||||||
|
thumb=thumbnail_path
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"File sent to user {chat_id}: {file_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending file: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Delete temporary thumbnail if it was downloaded
|
||||||
|
if thumbnail_path and thumbnail_path.startswith("downloads/thumb_"):
|
||||||
|
try:
|
||||||
|
thumb_file = Path(thumbnail_path)
|
||||||
|
if thumb_file.exists():
|
||||||
|
thumb_file.unlink()
|
||||||
|
logger.debug(f"Temporary thumbnail deleted: {thumbnail_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to delete temporary thumbnail: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_large_file_in_parts(
|
||||||
|
client: Client,
|
||||||
|
chat_id: int,
|
||||||
|
file_path: str,
|
||||||
|
caption: Optional[str] = None,
|
||||||
|
thumbnail: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send large file in parts
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Pyrogram client
|
||||||
|
chat_id: Chat ID
|
||||||
|
file_path: Path to file
|
||||||
|
caption: File caption
|
||||||
|
thumbnail: Path to thumbnail or URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
from bot.utils.file_splitter import split_file, delete_file_parts, get_part_info
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
try:
|
||||||
|
# Split file into parts
|
||||||
|
parts = await split_file(file_path)
|
||||||
|
part_info = get_part_info(parts)
|
||||||
|
total_parts = part_info["total_parts"]
|
||||||
|
|
||||||
|
logger.info(f"Sending file in parts: {total_parts} parts")
|
||||||
|
|
||||||
|
# Send each part
|
||||||
|
for part_num, part_path in enumerate(parts, 1):
|
||||||
|
part_caption = None
|
||||||
|
if caption:
|
||||||
|
part_caption = f"{caption}\n\n📦 Part {part_num} of {total_parts}"
|
||||||
|
else:
|
||||||
|
part_caption = f"📦 Part {part_num} of {total_parts}"
|
||||||
|
|
||||||
|
# Send thumbnail only with first part
|
||||||
|
part_thumbnail = thumbnail if part_num == 1 else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.send_document(
|
||||||
|
chat_id=chat_id,
|
||||||
|
document=str(part_path),
|
||||||
|
caption=part_caption,
|
||||||
|
thumb=part_thumbnail
|
||||||
|
)
|
||||||
|
logger.info(f"Sent part {part_num}/{total_parts}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending part {part_num}: {e}", exc_info=True)
|
||||||
|
# Continue sending other parts
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"File sent in parts to user {chat_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending large file in parts: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Delete file parts after sending
|
||||||
|
if parts:
|
||||||
|
await delete_file_parts(parts)
|
||||||
|
logger.debug("File parts deleted")
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_file(file_path: str, max_retries: int = 3) -> bool:
|
||||||
|
"""
|
||||||
|
Delete file with retries
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to file
|
||||||
|
max_retries: Maximum number of retries
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
file = Path(file_path)
|
||||||
|
if not file.exists():
|
||||||
|
return True # File already doesn't exist
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
file.unlink()
|
||||||
|
logger.info(f"File deleted: {file_path}")
|
||||||
|
return True
|
||||||
|
except PermissionError as e:
|
||||||
|
# File may be locked by another process
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
wait_time = (attempt + 1) * 0.5 # Exponential backoff
|
||||||
|
logger.warning(f"File locked, retrying in {wait_time}s: {file_path}")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to delete file after {max_retries} attempts (locked): {file_path}")
|
||||||
|
# Add to cleanup queue for background cleanup task
|
||||||
|
from bot.utils.file_cleanup import add_file_to_cleanup_queue
|
||||||
|
add_file_to_cleanup_queue(str(file_path))
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
wait_time = (attempt + 1) * 0.5
|
||||||
|
logger.warning(f"Error deleting file, retrying in {wait_time}s: {e}")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
else:
|
||||||
|
logger.error(f"Error deleting file after {max_retries} attempts: {e}", exc_info=True)
|
||||||
|
# Add to cleanup queue for background cleanup task
|
||||||
|
from bot.utils.file_cleanup import add_file_to_cleanup_queue
|
||||||
|
add_file_to_cleanup_queue(str(file_path))
|
||||||
|
return False
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
358
bot/modules/media_loader/ytdlp.py
Normal file
358
bot/modules/media_loader/ytdlp.py
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
"""
|
||||||
|
Downloads via yt-dlp
|
||||||
|
"""
|
||||||
|
import yt_dlp
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Callable
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_progress_hook(progress_callback: Optional[Callable] = None, event_loop=None, cancel_event: Optional[threading.Event] = None, last_update_time: list = None):
|
||||||
|
"""
|
||||||
|
Create progress hook for tracking download progress
|
||||||
|
|
||||||
|
Args:
|
||||||
|
progress_callback: Async callback function for updating progress
|
||||||
|
event_loop: Event loop from main thread (for calling from executor)
|
||||||
|
cancel_event: Event for checking download cancellation
|
||||||
|
last_update_time: List to store last update time (for rate limiting)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hook function for yt-dlp
|
||||||
|
"""
|
||||||
|
if last_update_time is None:
|
||||||
|
last_update_time = [0]
|
||||||
|
|
||||||
|
def progress_hook(d: dict):
|
||||||
|
# Check for cancellation
|
||||||
|
if cancel_event and cancel_event.is_set():
|
||||||
|
raise KeyboardInterrupt("Download cancelled")
|
||||||
|
|
||||||
|
if d.get('status') == 'downloading':
|
||||||
|
percent = 0
|
||||||
|
if 'total_bytes' in d and d['total_bytes']:
|
||||||
|
percent = (d.get('downloaded_bytes', 0) / d['total_bytes']) * 100
|
||||||
|
elif 'total_bytes_estimate' in d and d['total_bytes_estimate']:
|
||||||
|
percent = (d.get('downloaded_bytes', 0) / d['total_bytes_estimate']) * 100
|
||||||
|
|
||||||
|
# Limit update frequency (no more than once per second)
|
||||||
|
current_time = time.time()
|
||||||
|
if progress_callback and percent > 0 and event_loop and (current_time - last_update_time[0] >= 1.0):
|
||||||
|
try:
|
||||||
|
last_update_time[0] = current_time
|
||||||
|
# Use provided event loop for safe call from another thread
|
||||||
|
# run_coroutine_threadsafe doesn't block current thread and doesn't block event loop
|
||||||
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
|
progress_callback(int(percent)),
|
||||||
|
event_loop
|
||||||
|
)
|
||||||
|
# Don't wait for completion (future.result()) to avoid blocking download
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error updating progress: {e}")
|
||||||
|
|
||||||
|
return progress_hook
|
||||||
|
|
||||||
|
|
||||||
|
async def download_media(
|
||||||
|
url: str,
|
||||||
|
output_dir: str = "downloads",
|
||||||
|
quality: str = "best",
|
||||||
|
progress_callback: Optional[Callable] = None,
|
||||||
|
cookies_file: Optional[str] = None,
|
||||||
|
cancel_event: Optional[threading.Event] = None,
|
||||||
|
task_id: Optional[int] = None
|
||||||
|
) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Download media via yt-dlp
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Video/media URL
|
||||||
|
output_dir: Directory for saving
|
||||||
|
quality: Video quality (best, worst, 720p, etc.)
|
||||||
|
progress_callback: Function for updating progress (accepts int 0-100)
|
||||||
|
cookies_file: Path to cookies file (optional)
|
||||||
|
cancel_event: Event for cancellation check (optional)
|
||||||
|
task_id: Task ID for unique file naming (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with downloaded file information or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# URL validation
|
||||||
|
from bot.utils.helpers import is_valid_url
|
||||||
|
if not is_valid_url(url):
|
||||||
|
logger.error(f"Invalid or unsafe URL: {url}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Create directory
|
||||||
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Check free disk space (minimum 1GB)
|
||||||
|
import shutil
|
||||||
|
try:
|
||||||
|
disk_usage = shutil.disk_usage(output_dir)
|
||||||
|
free_space_gb = disk_usage.free / (1024 ** 3)
|
||||||
|
min_free_space_gb = 1.0 # Minimum 1GB free space
|
||||||
|
if free_space_gb < min_free_space_gb:
|
||||||
|
logger.error(f"Insufficient free disk space: {free_space_gb:.2f} GB (minimum {min_free_space_gb} GB required)")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to check free disk space: {e}")
|
||||||
|
|
||||||
|
# Get event loop BEFORE starting executor to pass it to progress hook
|
||||||
|
# Use get_running_loop() for explicit check that we're in async context
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
# If no running loop, try to get current one (for backward compatibility)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
# List to store last progress update time
|
||||||
|
last_update_time = [0]
|
||||||
|
|
||||||
|
# Configure yt-dlp with progress hook that uses correct event loop
|
||||||
|
progress_hook_func = create_progress_hook(
|
||||||
|
progress_callback,
|
||||||
|
event_loop=loop,
|
||||||
|
cancel_event=cancel_event,
|
||||||
|
last_update_time=last_update_time
|
||||||
|
)
|
||||||
|
|
||||||
|
# Form unique filename with task_id to prevent conflicts
|
||||||
|
if task_id:
|
||||||
|
outtmpl = str(Path(output_dir) / f'%(title)s_[task_{task_id}].%(ext)s')
|
||||||
|
else:
|
||||||
|
outtmpl = str(Path(output_dir) / '%(title)s.%(ext)s')
|
||||||
|
|
||||||
|
# Configure format selector for maximum quality with correct aspect ratio
|
||||||
|
# Priority: best video + best audio, or best combined format
|
||||||
|
# This ensures we get the highest quality available while maintaining original proportions
|
||||||
|
if quality == "best":
|
||||||
|
# Format selector for maximum quality:
|
||||||
|
# 1. bestvideo (highest quality video) + bestaudio (highest quality audio) - best quality
|
||||||
|
# 2. best (best combined format if separate streams not available) - fallback
|
||||||
|
# This selector maintains original aspect ratio and resolution
|
||||||
|
format_selector = 'bestvideo+bestaudio/best'
|
||||||
|
else:
|
||||||
|
# Use custom quality if specified
|
||||||
|
format_selector = quality
|
||||||
|
|
||||||
|
ydl_opts = {
|
||||||
|
'format': format_selector,
|
||||||
|
'outtmpl': outtmpl,
|
||||||
|
'quiet': False,
|
||||||
|
'no_warnings': False,
|
||||||
|
'progress_hooks': [progress_hook_func],
|
||||||
|
# Merge video and audio into single file (if separate streams)
|
||||||
|
'merge_output_format': 'mp4',
|
||||||
|
# Don't prefer free formats (they may be lower quality)
|
||||||
|
'prefer_free_formats': False,
|
||||||
|
# Additional options for better quality
|
||||||
|
'writesubtitles': False,
|
||||||
|
'writeautomaticsub': False,
|
||||||
|
'ignoreerrors': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add cookies if specified (for Instagram and other sites)
|
||||||
|
if cookies_file:
|
||||||
|
# Resolve cookies file path (support relative and absolute paths)
|
||||||
|
cookies_path = Path(cookies_file)
|
||||||
|
if not cookies_path.is_absolute():
|
||||||
|
# If path is relative, search relative to project root
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
cookies_path = project_root / cookies_file
|
||||||
|
# Also check current working directory
|
||||||
|
if not cookies_path.exists():
|
||||||
|
cookies_path = Path(cookies_file).resolve()
|
||||||
|
|
||||||
|
if cookies_path.exists():
|
||||||
|
ydl_opts['cookiefile'] = str(cookies_path)
|
||||||
|
logger.info(f"Using cookies from file: {cookies_path}")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Cookies file not found: {cookies_path} (original path: {cookies_file}). "
|
||||||
|
f"Continuing without cookies. Check COOKIES_FILE path in configuration."
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_download():
|
||||||
|
"""Synchronous function to execute in separate thread"""
|
||||||
|
# This function runs in a separate thread (ThreadPoolExecutor)
|
||||||
|
# progress hook will be called from this thread and use
|
||||||
|
# run_coroutine_threadsafe for safe call in main event loop
|
||||||
|
try:
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
# Check for cancellation before start
|
||||||
|
if cancel_event and cancel_event.is_set():
|
||||||
|
raise KeyboardInterrupt("Download cancelled")
|
||||||
|
|
||||||
|
# Get video information
|
||||||
|
info = ydl.extract_info(url, download=False)
|
||||||
|
|
||||||
|
# Check for cancellation after getting info
|
||||||
|
if cancel_event and cancel_event.is_set():
|
||||||
|
raise KeyboardInterrupt("Download cancelled")
|
||||||
|
|
||||||
|
# Download (progress hook will be called from this thread)
|
||||||
|
ydl.download([url])
|
||||||
|
|
||||||
|
return info
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
# Interrupt download on cancellation
|
||||||
|
logger.info("Download interrupted")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Execute in executor for non-blocking download
|
||||||
|
# None uses ThreadPoolExecutor by default
|
||||||
|
# This ensures download doesn't block message processing
|
||||||
|
# Event loop continues processing messages in parallel with download
|
||||||
|
info = await loop.run_in_executor(None, run_download)
|
||||||
|
|
||||||
|
# Search for downloaded file
|
||||||
|
title = info.get('title', 'video')
|
||||||
|
# Clean title from invalid characters
|
||||||
|
title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()
|
||||||
|
ext = info.get('ext', 'mp4')
|
||||||
|
|
||||||
|
logger.info(f"Searching for downloaded file. Title: {title}, ext: {ext}, task_id: {task_id}")
|
||||||
|
|
||||||
|
# Form filename with task_id
|
||||||
|
if task_id:
|
||||||
|
filename = f"{title}_[task_{task_id}].{ext}"
|
||||||
|
else:
|
||||||
|
filename = f"{title}.{ext}"
|
||||||
|
|
||||||
|
file_path = Path(output_dir) / filename
|
||||||
|
logger.debug(f"Expected file path: {file_path}")
|
||||||
|
|
||||||
|
# If file not found at expected path, search in directory
|
||||||
|
if not file_path.exists():
|
||||||
|
logger.info(f"File not found at expected path {file_path}, starting search...")
|
||||||
|
|
||||||
|
# If task_id exists, search for file with this task_id
|
||||||
|
if task_id:
|
||||||
|
# Pattern 1: exact match with task_id
|
||||||
|
pattern = f"*[task_{task_id}].{ext}"
|
||||||
|
files = list(Path(output_dir).glob(pattern))
|
||||||
|
logger.debug(f"Search by pattern '{pattern}': found {len(files)} files")
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
# Pattern 2: search files containing task_id (in case format differs slightly)
|
||||||
|
pattern2 = f"*task_{task_id}*.{ext}"
|
||||||
|
files = list(Path(output_dir).glob(pattern2))
|
||||||
|
logger.debug(f"Search by pattern '{pattern2}': found {len(files)} files")
|
||||||
|
|
||||||
|
if files:
|
||||||
|
# Take newest file from found ones
|
||||||
|
file_path = max(files, key=lambda p: p.stat().st_mtime)
|
||||||
|
logger.info(f"Found file by task_id: {file_path}")
|
||||||
|
else:
|
||||||
|
# If not found by task_id, search newest file with this extension
|
||||||
|
logger.info(f"File with task_id {task_id} not found, searching newest .{ext} file")
|
||||||
|
files = list(Path(output_dir).glob(f"*.{ext}"))
|
||||||
|
if files:
|
||||||
|
# Filter files created recently (last 5 minutes)
|
||||||
|
import time
|
||||||
|
current_time = time.time()
|
||||||
|
recent_files = [
|
||||||
|
f for f in files
|
||||||
|
if (current_time - f.stat().st_mtime) < 300 # 5 minutes
|
||||||
|
]
|
||||||
|
if recent_files:
|
||||||
|
file_path = max(recent_files, key=lambda p: p.stat().st_mtime)
|
||||||
|
logger.info(f"Found recently created file: {file_path}")
|
||||||
|
else:
|
||||||
|
file_path = max(files, key=lambda p: p.stat().st_mtime)
|
||||||
|
logger.warning(f"No recent files found, taking newest: {file_path}")
|
||||||
|
else:
|
||||||
|
# Search file by extension
|
||||||
|
files = list(Path(output_dir).glob(f"*.{ext}"))
|
||||||
|
if files:
|
||||||
|
# Take newest file
|
||||||
|
file_path = max(files, key=lambda p: p.stat().st_mtime)
|
||||||
|
logger.info(f"Found file by time: {file_path}")
|
||||||
|
|
||||||
|
if file_path.exists():
|
||||||
|
file_size = file_path.stat().st_size
|
||||||
|
logger.info(f"File found: {file_path}, size: {file_size / (1024*1024):.2f} MB")
|
||||||
|
return {
|
||||||
|
'file_path': str(file_path),
|
||||||
|
'title': title,
|
||||||
|
'duration': info.get('duration'),
|
||||||
|
'thumbnail': info.get('thumbnail'),
|
||||||
|
'size': file_size
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Output list of all files in directory for debugging
|
||||||
|
all_files = list(Path(output_dir).glob("*"))
|
||||||
|
logger.error(
|
||||||
|
f"File not found after download: {file_path}\n"
|
||||||
|
f"Files in downloads directory: {[str(f.name) for f in all_files[:10]]}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error downloading via yt-dlp: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_media_info(url: str, cookies_file: Optional[str] = None) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Get media information without downloading
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Media URL
|
||||||
|
cookies_file: Path to cookies file (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with information or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
ydl_opts = {
|
||||||
|
'quiet': True,
|
||||||
|
'no_warnings': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add cookies if specified
|
||||||
|
if cookies_file:
|
||||||
|
# Resolve cookies file path (support relative and absolute paths)
|
||||||
|
cookies_path = Path(cookies_file)
|
||||||
|
if not cookies_path.is_absolute():
|
||||||
|
# If path is relative, search relative to project root
|
||||||
|
project_root = Path(__file__).parent.parent.parent.parent
|
||||||
|
cookies_path = project_root / cookies_file
|
||||||
|
# Also check current working directory
|
||||||
|
if not cookies_path.exists():
|
||||||
|
cookies_path = Path(cookies_file).resolve()
|
||||||
|
|
||||||
|
if cookies_path.exists():
|
||||||
|
ydl_opts['cookiefile'] = str(cookies_path)
|
||||||
|
logger.debug(f"Using cookies to get info: {cookies_path}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Cookies file not found for get_media_info: {cookies_path}")
|
||||||
|
|
||||||
|
def extract_info_sync():
|
||||||
|
"""Synchronous function for extracting information"""
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
return ydl.extract_info(url, download=False)
|
||||||
|
|
||||||
|
# Run synchronous yt-dlp in executor to avoid blocking event loop
|
||||||
|
info = await loop.run_in_executor(None, extract_info_sync)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'title': info.get('title'),
|
||||||
|
'duration': info.get('duration'),
|
||||||
|
'thumbnail': info.get('thumbnail'),
|
||||||
|
'uploader': info.get('uploader'),
|
||||||
|
'view_count': info.get('view_count'),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting media info: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
4
bot/modules/message_handler/__init__.py
Normal file
4
bot/modules/message_handler/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Message handler module
|
||||||
|
"""
|
||||||
|
|
||||||
223
bot/modules/message_handler/callbacks.py
Normal file
223
bot/modules/message_handler/callbacks.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"""
|
||||||
|
Callback button handling
|
||||||
|
"""
|
||||||
|
from pyrogram import Client
|
||||||
|
from pyrogram.types import CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from pyrogram.handlers import CallbackQueryHandler
|
||||||
|
from pyrogram.errors import MessageNotModified
|
||||||
|
from bot.modules.access_control.auth import is_authorized, is_admin, is_owner
|
||||||
|
from bot.modules.message_handler.commands import get_start_keyboard
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def callback_handler(client: Client, callback_query: CallbackQuery):
|
||||||
|
"""Handle callback queries"""
|
||||||
|
user_id = callback_query.from_user.id
|
||||||
|
data = callback_query.data
|
||||||
|
|
||||||
|
# Check authorization
|
||||||
|
if not await is_authorized(user_id):
|
||||||
|
await callback_query.answer("❌ У вас нет доступа к этому боту", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Handle different callback data
|
||||||
|
if data == "back":
|
||||||
|
# Return to main menu
|
||||||
|
welcome_text = (
|
||||||
|
"👋 **Привет! Я бот для загрузки медиа-файлов.**\n\n"
|
||||||
|
"📥 **Что я умею:**\n"
|
||||||
|
"• Загружать видео с YouTube, Instagram и других платформ\n"
|
||||||
|
"• Загружать файлы по прямым ссылкам\n"
|
||||||
|
"• Отправлять файлы вам в Telegram\n\n"
|
||||||
|
"**Как использовать:**\n"
|
||||||
|
"Просто отправьте мне ссылку на видео или файл, и я загружу его для вас!\n\n"
|
||||||
|
"Используйте кнопки ниже для управления:"
|
||||||
|
)
|
||||||
|
keyboard = await get_start_keyboard(user_id)
|
||||||
|
await callback_query.edit_message_text(welcome_text, reply_markup=keyboard)
|
||||||
|
await callback_query.answer()
|
||||||
|
|
||||||
|
elif data == "help":
|
||||||
|
# Show help
|
||||||
|
help_text = (
|
||||||
|
"👋 **Привет! Рад помочь!**\n\n"
|
||||||
|
|
||||||
|
"🎯 **Как начать работу:**\n"
|
||||||
|
"Это очень просто! Просто отправьте мне ссылку на видео или файл, и я сразу начну загрузку.\n\n"
|
||||||
|
|
||||||
|
"📥 **Что я умею загружать:**\n"
|
||||||
|
"• 🎬 Видео с YouTube, Instagram, TikTok и других платформ\n"
|
||||||
|
"• 📁 Файлы по прямым ссылкам\n"
|
||||||
|
"• 🎵 Аудио и музыку\n"
|
||||||
|
"• 📸 Изображения и фото\n\n"
|
||||||
|
|
||||||
|
"⌨️ **Основные команды:**\n"
|
||||||
|
"• `/start` - Открыть главное меню с кнопками\n"
|
||||||
|
"• `/help` - Показать эту справку\n"
|
||||||
|
"• `/status` - Посмотреть статус ваших загрузок\n\n"
|
||||||
|
|
||||||
|
"💡 **Совет:** Используйте кнопки в главном меню для быстрого доступа к функциям!"
|
||||||
|
)
|
||||||
|
|
||||||
|
if await is_admin(user_id):
|
||||||
|
help_text += (
|
||||||
|
"\n\n"
|
||||||
|
"👑 **Команды для администраторов:**\n"
|
||||||
|
"• `/adduser <user_id или @username>` - Добавить нового пользователя\n"
|
||||||
|
"• `/removeuser <user_id или @username>` - Удалить пользователя\n"
|
||||||
|
"• `/blockuser <user_id или @username>` - Заблокировать пользователя\n"
|
||||||
|
"• `/listusers` - Посмотреть список всех пользователей\n\n"
|
||||||
|
"💼 **Управление администраторами:**\n"
|
||||||
|
"• `/addadmin <user_id или @username>` - Назначить администратора\n"
|
||||||
|
"• `/removeadmin <user_id или @username>` - Снять права администратора\n"
|
||||||
|
"• `/listadmins` - Список всех администраторов"
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup([[
|
||||||
|
InlineKeyboardButton("🔙 Назад", callback_data="back")
|
||||||
|
]])
|
||||||
|
|
||||||
|
await callback_query.edit_message_text(help_text, reply_markup=keyboard)
|
||||||
|
await callback_query.answer()
|
||||||
|
|
||||||
|
elif data == "status":
|
||||||
|
# Show task status
|
||||||
|
from bot.modules.task_scheduler.monitor import get_user_tasks_status
|
||||||
|
from bot.modules.task_scheduler.queue import TaskStatus
|
||||||
|
|
||||||
|
tasks = await get_user_tasks_status(user_id)
|
||||||
|
active_tasks = [t for t in tasks if t.get('status') in ['pending', 'processing']]
|
||||||
|
completed = [t for t in tasks if t.get('status') == 'completed']
|
||||||
|
failed = [t for t in tasks if t.get('status') == 'failed']
|
||||||
|
|
||||||
|
status_text = (
|
||||||
|
"📊 **Статус задач:**\n\n"
|
||||||
|
f"⏳ Активных задач: {len(active_tasks)}\n"
|
||||||
|
f"✅ Завершено: {len(completed)}\n"
|
||||||
|
f"❌ Ошибок: {len(failed)}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if active_tasks:
|
||||||
|
status_text += "**Активные задачи:**\n"
|
||||||
|
for task in active_tasks[:5]: # Show first 5
|
||||||
|
task_id = task.get('id')
|
||||||
|
progress = task.get('progress', 0)
|
||||||
|
status_text += f"• #{task_id} - {progress}%\n"
|
||||||
|
if len(active_tasks) > 5:
|
||||||
|
status_text += f"... и еще {len(active_tasks) - 5}\n"
|
||||||
|
status_text += "\n💡 Используйте `/cancel <task_id>` для отмены"
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup([[
|
||||||
|
InlineKeyboardButton("🔄 Обновить", callback_data="status"),
|
||||||
|
InlineKeyboardButton("🔙 Назад", callback_data="back")
|
||||||
|
]])
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback_query.edit_message_text(status_text, reply_markup=keyboard)
|
||||||
|
await callback_query.answer("✅ Статус обновлен")
|
||||||
|
except MessageNotModified:
|
||||||
|
# If text didn't change, just answer callback
|
||||||
|
await callback_query.answer("✅ Статус актуален")
|
||||||
|
|
||||||
|
elif data == "download":
|
||||||
|
# Download information
|
||||||
|
download_text = (
|
||||||
|
"📥 **Загрузка файлов:**\n\n"
|
||||||
|
"**Поддерживаемые источники:**\n"
|
||||||
|
"• YouTube (видео, плейлисты)\n"
|
||||||
|
"• Instagram (посты, истории)\n"
|
||||||
|
"• Прямые ссылки на файлы\n"
|
||||||
|
"• Другие платформы через yt-dlp\n\n"
|
||||||
|
"**Как использовать:**\n"
|
||||||
|
"Просто отправьте мне ссылку на видео или файл, и я начну загрузку!\n\n"
|
||||||
|
"Примеры:\n"
|
||||||
|
"• https://www.youtube.com/watch?v=...\n"
|
||||||
|
"• https://www.instagram.com/p/...\n"
|
||||||
|
"• https://example.com/file.mp4"
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup([[
|
||||||
|
InlineKeyboardButton("🔙 Назад", callback_data="back")
|
||||||
|
]])
|
||||||
|
|
||||||
|
await callback_query.edit_message_text(download_text, reply_markup=keyboard)
|
||||||
|
await callback_query.answer()
|
||||||
|
|
||||||
|
elif data == "admin_users":
|
||||||
|
# User management (admin only)
|
||||||
|
if not await is_admin(user_id):
|
||||||
|
await callback_query.answer("❌ Только для администраторов", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine user status
|
||||||
|
is_owner_user = await is_owner(user_id)
|
||||||
|
|
||||||
|
# Form text and buttons depending on status
|
||||||
|
if is_owner_user:
|
||||||
|
# Main admin - full functionality
|
||||||
|
users_text = (
|
||||||
|
"👥 **Управление пользователями:**\n\n"
|
||||||
|
"**Управление пользователями:**\n"
|
||||||
|
"• /adduser <user_id или @username> - Добавить пользователя\n"
|
||||||
|
"• /removeuser <user_id или @username> - Удалить пользователя\n"
|
||||||
|
"• /blockuser <user_id или @username> - Заблокировать пользователя\n"
|
||||||
|
"• /listusers - Список всех пользователей\n\n"
|
||||||
|
"**Управление администраторами:**\n"
|
||||||
|
"• /addadmin <user_id или @username> - Назначить администратора\n"
|
||||||
|
"• /removeadmin <user_id или @username> - Снять права администратора\n"
|
||||||
|
"• /listadmins - Список всех администраторов\n\n"
|
||||||
|
"⚠️ **Внимание:** Вы не можете снять права администратора у самого себя."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Regular administrator - only user management
|
||||||
|
users_text = (
|
||||||
|
"👥 **Управление пользователями:**\n\n"
|
||||||
|
"**Доступные команды:**\n"
|
||||||
|
"• /adduser <user_id или @username> - Добавить пользователя\n"
|
||||||
|
"• /removeuser <user_id или @username> - Удалить пользователя\n"
|
||||||
|
"• /blockuser <user_id или @username> - Заблокировать пользователя\n"
|
||||||
|
"• /listusers - Список всех пользователей\n\n"
|
||||||
|
"_Управление через веб-интерфейс будет доступно позже_"
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup([[
|
||||||
|
InlineKeyboardButton("🔙 Назад", callback_data="back")
|
||||||
|
]])
|
||||||
|
|
||||||
|
await callback_query.edit_message_text(users_text, reply_markup=keyboard)
|
||||||
|
await callback_query.answer()
|
||||||
|
|
||||||
|
elif data == "admin_stats":
|
||||||
|
# Statistics (admin only)
|
||||||
|
if not await is_admin(user_id):
|
||||||
|
await callback_query.answer("❌ Только для администраторов", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
stats_text = (
|
||||||
|
"📈 **Статистика:**\n\n"
|
||||||
|
"👥 Всего пользователей: 0\n"
|
||||||
|
"👑 Администраторов: 0\n"
|
||||||
|
"📥 Всего загрузок: 0\n"
|
||||||
|
"✅ Успешных: 0\n"
|
||||||
|
"❌ Ошибок: 0\n\n"
|
||||||
|
"_Статистика будет реализована в следующем этапе_"
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup([[
|
||||||
|
InlineKeyboardButton("🔙 Назад", callback_data="back")
|
||||||
|
]])
|
||||||
|
|
||||||
|
await callback_query.edit_message_text(stats_text, reply_markup=keyboard)
|
||||||
|
await callback_query.answer()
|
||||||
|
|
||||||
|
else:
|
||||||
|
await callback_query.answer("❓ Неизвестная команда")
|
||||||
|
|
||||||
|
|
||||||
|
def register_callbacks(app: Client):
|
||||||
|
"""Register all callback handlers"""
|
||||||
|
app.add_handler(CallbackQueryHandler(callback_handler))
|
||||||
|
logger.info("Callback handlers registered")
|
||||||
|
|
||||||
747
bot/modules/message_handler/commands.py
Normal file
747
bot/modules/message_handler/commands.py
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
"""
|
||||||
|
Command handling
|
||||||
|
"""
|
||||||
|
from pyrogram import Client
|
||||||
|
from pyrogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from pyrogram.filters import command
|
||||||
|
from pyrogram.handlers import MessageHandler
|
||||||
|
from bot.modules.access_control.permissions import require_permission, Permission
|
||||||
|
from bot.modules.access_control.user_manager import (
|
||||||
|
add_user, remove_user, block_user, unblock_user,
|
||||||
|
add_admin, remove_admin
|
||||||
|
)
|
||||||
|
from bot.modules.message_handler.filters import is_url_message
|
||||||
|
from bot.utils.helpers import parse_user_id
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_start_keyboard(user_id: int) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Create keyboard for /start command
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InlineKeyboardMarkup with buttons
|
||||||
|
"""
|
||||||
|
from bot.modules.access_control.auth import is_admin
|
||||||
|
|
||||||
|
# Base buttons for all users
|
||||||
|
buttons = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("📥 Загрузить", callback_data="download"),
|
||||||
|
InlineKeyboardButton("📊 Статус", callback_data="status")
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("❓ Помощь", callback_data="help")
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Additional buttons for administrators
|
||||||
|
if await is_admin(user_id):
|
||||||
|
buttons.append([
|
||||||
|
InlineKeyboardButton("📈 Статистика", callback_data="admin_stats")
|
||||||
|
])
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(buttons)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_command(client: Client, message: Message):
|
||||||
|
"""Handle /start command"""
|
||||||
|
from bot.modules.access_control.auth import is_authorized
|
||||||
|
|
||||||
|
# Check authorization
|
||||||
|
if not await is_authorized(message.from_user.id):
|
||||||
|
await message.reply("❌ У вас нет доступа к этому боту")
|
||||||
|
return
|
||||||
|
|
||||||
|
welcome_text = (
|
||||||
|
"👋 **Привет! Я бот для загрузки медиа-файлов.**\n\n"
|
||||||
|
"📥 **Что я умею:**\n"
|
||||||
|
"• Загружать видео с YouTube, Instagram и других платформ\n"
|
||||||
|
"• Загружать файлы по прямым ссылкам\n"
|
||||||
|
"• Отправлять файлы вам в Telegram\n\n"
|
||||||
|
"**Как использовать:**\n"
|
||||||
|
"Просто отправьте мне ссылку на видео или файл, и я загружу его для вас!\n\n"
|
||||||
|
"Используйте кнопки ниже для управления:"
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = await get_start_keyboard(message.from_user.id)
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
welcome_text,
|
||||||
|
reply_markup=keyboard
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def help_command(client: Client, message: Message):
|
||||||
|
"""Handle /help command"""
|
||||||
|
from bot.modules.access_control.auth import is_authorized
|
||||||
|
|
||||||
|
# Check authorization
|
||||||
|
if not await is_authorized(message.from_user.id):
|
||||||
|
await message.reply("❌ У вас нет доступа к этому боту")
|
||||||
|
return
|
||||||
|
|
||||||
|
help_text = (
|
||||||
|
"👋 **Привет! Рад помочь!**\n\n"
|
||||||
|
|
||||||
|
"🎯 **Как начать работу:**\n"
|
||||||
|
"Это очень просто! Просто отправьте мне ссылку на видео или файл, и я сразу начну загрузку.\n\n"
|
||||||
|
|
||||||
|
"📥 **Что я умею загружать:**\n"
|
||||||
|
"• 🎬 Видео с YouTube, Instagram, TikTok и других платформ\n"
|
||||||
|
"• 📁 Файлы по прямым ссылкам\n"
|
||||||
|
"• 🎵 Аудио и музыку\n"
|
||||||
|
"• 📸 Изображения и фото\n\n"
|
||||||
|
|
||||||
|
"⌨️ **Основные команды:**\n"
|
||||||
|
"• `/start` - Открыть главное меню с кнопками\n"
|
||||||
|
"• `/help` - Показать эту справку\n"
|
||||||
|
"• `/status` - Посмотреть статус ваших загрузок\n"
|
||||||
|
"• `/cancel <task_id>` - Отменить задачу\n\n"
|
||||||
|
|
||||||
|
"💡 **Совет:** Используйте кнопки в главном меню для быстрого доступа к функциям!"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add information for administrators
|
||||||
|
from bot.modules.access_control.auth import is_admin
|
||||||
|
if await is_admin(message.from_user.id):
|
||||||
|
help_text += (
|
||||||
|
"\n\n"
|
||||||
|
"👑 **Команды для администраторов:**\n"
|
||||||
|
"• `/adduser <user_id или @username>` - Добавить нового пользователя\n"
|
||||||
|
"• `/removeuser <user_id или @username>` - Удалить пользователя\n"
|
||||||
|
"• `/blockuser <user_id или @username>` - Заблокировать пользователя\n"
|
||||||
|
"• `/listusers` - Посмотреть список всех пользователей\n\n"
|
||||||
|
"💼 **Управление администраторами:**\n"
|
||||||
|
"• `/addadmin <user_id или @username>` - Назначить администратора\n"
|
||||||
|
"• `/removeadmin <user_id или @username>` - Снять права администратора\n"
|
||||||
|
"• `/listadmins` - Список всех администраторов"
|
||||||
|
)
|
||||||
|
await message.reply(help_text)
|
||||||
|
|
||||||
|
|
||||||
|
async def status_command(client: Client, message: Message):
|
||||||
|
"""Handle /status command"""
|
||||||
|
from bot.modules.access_control.auth import is_authorized
|
||||||
|
from bot.modules.task_scheduler.monitor import get_user_tasks_status
|
||||||
|
from bot.modules.task_scheduler.queue import TaskStatus
|
||||||
|
|
||||||
|
# Check authorization
|
||||||
|
if not await is_authorized(message.from_user.id):
|
||||||
|
await message.reply("❌ У вас нет доступа к этому боту")
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = message.from_user.id
|
||||||
|
tasks = await get_user_tasks_status(user_id)
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
await message.reply("📊 У вас нет активных задач")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Filter only active tasks (pending, processing)
|
||||||
|
active_tasks = [
|
||||||
|
t for t in tasks
|
||||||
|
if t.get('status') in ['pending', 'processing']
|
||||||
|
]
|
||||||
|
|
||||||
|
if not active_tasks:
|
||||||
|
await message.reply("📊 У вас нет активных задач")
|
||||||
|
return
|
||||||
|
|
||||||
|
status_text = "📊 **Ваши активные задачи:**\n\n"
|
||||||
|
|
||||||
|
for task in active_tasks[:10]: # Show maximum 10 tasks
|
||||||
|
task_id = task.get('id')
|
||||||
|
status = task.get('status', 'unknown')
|
||||||
|
progress = task.get('progress', 0)
|
||||||
|
url = task.get('url', 'N/A')
|
||||||
|
|
||||||
|
status_emoji = {
|
||||||
|
'pending': '⏳',
|
||||||
|
'processing': '🔄',
|
||||||
|
'completed': '✅',
|
||||||
|
'failed': '❌',
|
||||||
|
'cancelled': '🚫'
|
||||||
|
}.get(status, '❓')
|
||||||
|
|
||||||
|
status_text += (
|
||||||
|
f"{status_emoji} **Задача #{task_id}**\n"
|
||||||
|
f"🔗 {url[:50]}...\n"
|
||||||
|
f"📊 Прогресс: {progress}%\n"
|
||||||
|
f"📝 Статус: {status}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(active_tasks) > 10:
|
||||||
|
status_text += f"... и еще {len(active_tasks) - 10} задач\n\n"
|
||||||
|
|
||||||
|
status_text += "💡 Используйте `/cancel <task_id>` для отмены задачи"
|
||||||
|
|
||||||
|
await message.reply(status_text)
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_command(client: Client, message: Message):
|
||||||
|
"""Handle /cancel command"""
|
||||||
|
from bot.modules.access_control.auth import is_authorized
|
||||||
|
from bot.modules.task_scheduler.monitor import cancel_user_task
|
||||||
|
from bot.modules.task_scheduler.queue import task_queue
|
||||||
|
|
||||||
|
# Check authorization
|
||||||
|
if not await is_authorized(message.from_user.id):
|
||||||
|
await message.reply("❌ У вас нет доступа к этому боту")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not message.command or len(message.command) < 2:
|
||||||
|
await message.reply("❌ Использование: /cancel <task_id>\n\nИспользуйте /status чтобы увидеть ID ваших задач")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
task_id = int(message.command[1])
|
||||||
|
except ValueError:
|
||||||
|
await message.reply("❌ Неверный формат task_id. Используйте число.")
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = message.from_user.id
|
||||||
|
|
||||||
|
# Cancel task
|
||||||
|
try:
|
||||||
|
success, message_text = await cancel_user_task(user_id, task_id)
|
||||||
|
if success:
|
||||||
|
await message.reply(f"✅ {message_text}")
|
||||||
|
else:
|
||||||
|
await message.reply(f"❌ {message_text}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in cancel_command: {e}", exc_info=True)
|
||||||
|
await message.reply(f"❌ Произошла ошибка при отмене задачи: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# User management commands (admin only)
|
||||||
|
async def adduser_command(client: Client, message: Message):
|
||||||
|
"""Add user"""
|
||||||
|
from bot.modules.access_control.auth import is_admin
|
||||||
|
from bot.utils.helpers import resolve_user_identifier
|
||||||
|
|
||||||
|
# Check access permissions
|
||||||
|
if not await is_admin(message.from_user.id):
|
||||||
|
await message.reply("❌ Эта команда доступна только администраторам")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not message.command or len(message.command) < 2:
|
||||||
|
await message.reply("❌ Использование: /adduser <user_id или @username>")
|
||||||
|
return
|
||||||
|
|
||||||
|
identifier = message.command[1]
|
||||||
|
|
||||||
|
# Resolve identifier (user_id or username)
|
||||||
|
user_id, error_message = await resolve_user_identifier(identifier)
|
||||||
|
if not user_id:
|
||||||
|
await message.reply(f"❌ {error_message}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
success, message_text = await add_user(user_id)
|
||||||
|
if success:
|
||||||
|
await message.reply(f"✅ {message_text}")
|
||||||
|
else:
|
||||||
|
await message.reply(f"❌ {message_text}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in adduser_command: {e}", exc_info=True)
|
||||||
|
await message.reply(f"❌ Произошла ошибка: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def removeuser_command(client: Client, message: Message):
|
||||||
|
"""Remove user"""
|
||||||
|
from bot.modules.access_control.auth import is_admin
|
||||||
|
from bot.utils.helpers import resolve_user_identifier
|
||||||
|
|
||||||
|
# Check access permissions
|
||||||
|
if not await is_admin(message.from_user.id):
|
||||||
|
await message.reply("❌ Эта команда доступна только администраторам")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not message.command or len(message.command) < 2:
|
||||||
|
await message.reply("❌ Использование: /removeuser <user_id или @username>")
|
||||||
|
return
|
||||||
|
|
||||||
|
identifier = message.command[1]
|
||||||
|
|
||||||
|
# Resolve identifier (user_id or username)
|
||||||
|
user_id, error_message = await resolve_user_identifier(identifier)
|
||||||
|
if not user_id:
|
||||||
|
await message.reply(f"❌ {error_message}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
success, message_text = await remove_user(user_id)
|
||||||
|
if success:
|
||||||
|
await message.reply(f"✅ {message_text}")
|
||||||
|
else:
|
||||||
|
await message.reply(f"❌ {message_text}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in removeuser_command: {e}", exc_info=True)
|
||||||
|
await message.reply(f"❌ Произошла ошибка: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def blockuser_command(client: Client, message: Message):
|
||||||
|
"""Block user"""
|
||||||
|
from bot.modules.access_control.auth import is_admin
|
||||||
|
from bot.utils.helpers import resolve_user_identifier
|
||||||
|
|
||||||
|
# Check access permissions
|
||||||
|
if not await is_admin(message.from_user.id):
|
||||||
|
await message.reply("❌ Эта команда доступна только администраторам")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not message.command or len(message.command) < 2:
|
||||||
|
await message.reply("❌ Использование: /blockuser <user_id или @username>")
|
||||||
|
return
|
||||||
|
|
||||||
|
identifier = message.command[1]
|
||||||
|
|
||||||
|
# Resolve identifier (user_id or username)
|
||||||
|
user_id, error_message = await resolve_user_identifier(identifier)
|
||||||
|
if not user_id:
|
||||||
|
await message.reply(f"❌ {error_message}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
success, message_text = await block_user(user_id)
|
||||||
|
if success:
|
||||||
|
await message.reply(f"✅ {message_text}")
|
||||||
|
else:
|
||||||
|
await message.reply(f"❌ {message_text}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in blockuser_command: {e}", exc_info=True)
|
||||||
|
await message.reply(f"❌ Произошла ошибка: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def unblockuser_command(client: Client, message: Message):
|
||||||
|
"""Unblock user"""
|
||||||
|
from bot.modules.access_control.auth import is_admin
|
||||||
|
from bot.utils.helpers import resolve_user_identifier
|
||||||
|
|
||||||
|
# Check access permissions
|
||||||
|
if not await is_admin(message.from_user.id):
|
||||||
|
await message.reply("❌ Эта команда доступна только администраторам")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not message.command or len(message.command) < 2:
|
||||||
|
await message.reply("❌ Использование: /unblockuser <user_id или @username>")
|
||||||
|
return
|
||||||
|
|
||||||
|
identifier = message.command[1]
|
||||||
|
|
||||||
|
# Resolve identifier (user_id or username)
|
||||||
|
user_id, error_message = await resolve_user_identifier(identifier)
|
||||||
|
if not user_id:
|
||||||
|
await message.reply(f"❌ {error_message}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
success, message_text = await unblock_user(user_id)
|
||||||
|
if success:
|
||||||
|
await message.reply(f"✅ {message_text}")
|
||||||
|
else:
|
||||||
|
await message.reply(f"❌ {message_text}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in unblockuser_command: {e}", exc_info=True)
|
||||||
|
await message.reply(f"❌ Произошла ошибка: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def listusers_command(client: Client, message: Message):
|
||||||
|
"""List users"""
|
||||||
|
from bot.modules.access_control.auth import is_admin
|
||||||
|
from shared.database.models import User
|
||||||
|
from shared.database.session import get_async_session_local
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
|
||||||
|
# Check access permissions
|
||||||
|
if not await is_admin(message.from_user.id):
|
||||||
|
await message.reply("❌ Эта команда доступна только администраторам")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with get_async_session_local()() as session:
|
||||||
|
# Get total count
|
||||||
|
count_result = await session.execute(select(func.count(User.user_id)))
|
||||||
|
total_count = count_result.scalar() or 0
|
||||||
|
|
||||||
|
if total_count == 0:
|
||||||
|
await message.reply("📋 Пользователей в базе данных нет")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get users (limit to 50 for message length)
|
||||||
|
query = select(User).order_by(desc(User.created_at)).limit(50)
|
||||||
|
result = await session.execute(query)
|
||||||
|
users = list(result.scalars().all())
|
||||||
|
|
||||||
|
# Format message
|
||||||
|
text = f"📋 **Список пользователей** (всего: {total_count})\n\n"
|
||||||
|
|
||||||
|
for i, user in enumerate(users, 1):
|
||||||
|
username = f"@{user.username}" if user.username else "-"
|
||||||
|
name = f"{user.first_name or ''} {user.last_name or ''}".strip() or "-"
|
||||||
|
admin_badge = "👑" if user.is_admin else ""
|
||||||
|
blocked_badge = "🚫" if user.is_blocked else ""
|
||||||
|
|
||||||
|
text += (
|
||||||
|
f"{i}. {admin_badge} {blocked_badge} **ID:** `{user.user_id}`\n"
|
||||||
|
f" 👤 {username} ({name})\n"
|
||||||
|
f" 📅 Создан: {user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'N/A'}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if total_count > 50:
|
||||||
|
text += f"\n... и еще {total_count - 50} пользователей (показаны первые 50)"
|
||||||
|
|
||||||
|
# Split message if too long (Telegram limit is 4096 characters)
|
||||||
|
if len(text) > 4000:
|
||||||
|
# Send first part
|
||||||
|
await message.reply(text[:4000])
|
||||||
|
# Send remaining users count
|
||||||
|
await message.reply(f"... и еще {total_count - 50} пользователей")
|
||||||
|
else:
|
||||||
|
await message.reply(text)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing users: {e}", exc_info=True)
|
||||||
|
await message.reply("❌ Ошибка при получении списка пользователей")
|
||||||
|
|
||||||
|
|
||||||
|
async def addadmin_command(client: Client, message: Message):
|
||||||
|
"""Assign administrator"""
|
||||||
|
from bot.modules.access_control.auth import is_admin
|
||||||
|
from bot.utils.helpers import resolve_user_identifier
|
||||||
|
|
||||||
|
# Check access permissions
|
||||||
|
if not await is_admin(message.from_user.id):
|
||||||
|
await message.reply("❌ Эта команда доступна только администраторам")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not message.command or len(message.command) < 2:
|
||||||
|
await message.reply("❌ Использование: /addadmin <user_id или @username>")
|
||||||
|
return
|
||||||
|
|
||||||
|
identifier = message.command[1]
|
||||||
|
requester_id = message.from_user.id
|
||||||
|
|
||||||
|
# Resolve identifier (user_id or username)
|
||||||
|
user_id, error_message = await resolve_user_identifier(identifier)
|
||||||
|
if not user_id:
|
||||||
|
await message.reply(f"❌ {error_message}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
success, message_text = await add_admin(user_id, requester_id)
|
||||||
|
if success:
|
||||||
|
await message.reply(f"✅ {message_text}")
|
||||||
|
else:
|
||||||
|
await message.reply(f"❌ {message_text}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in addadmin_command: {e}", exc_info=True)
|
||||||
|
await message.reply(f"❌ Произошла ошибка: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def removeadmin_command(client: Client, message: Message):
|
||||||
|
"""Remove administrator privileges"""
|
||||||
|
from bot.modules.access_control.auth import is_admin
|
||||||
|
from bot.utils.helpers import resolve_user_identifier
|
||||||
|
|
||||||
|
# Check access permissions
|
||||||
|
if not await is_admin(message.from_user.id):
|
||||||
|
await message.reply("❌ Эта команда доступна только администраторам")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not message.command or len(message.command) < 2:
|
||||||
|
await message.reply("❌ Использование: /removeadmin <user_id или @username>")
|
||||||
|
return
|
||||||
|
|
||||||
|
identifier = message.command[1]
|
||||||
|
requester_id = message.from_user.id
|
||||||
|
|
||||||
|
# Resolve identifier (user_id or username)
|
||||||
|
user_id, error_message = await resolve_user_identifier(identifier)
|
||||||
|
if not user_id:
|
||||||
|
await message.reply(f"❌ {error_message}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
success, message_text = await remove_admin(user_id, requester_id)
|
||||||
|
if success:
|
||||||
|
await message.reply(f"✅ {message_text}")
|
||||||
|
else:
|
||||||
|
await message.reply(f"❌ {message_text}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in removeadmin_command: {e}", exc_info=True)
|
||||||
|
await message.reply(f"❌ Произошла ошибка: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def listadmins_command(client: Client, message: Message):
|
||||||
|
"""List administrators"""
|
||||||
|
from bot.modules.access_control.auth import is_admin
|
||||||
|
from shared.database.models import User
|
||||||
|
from shared.database.session import get_async_session_local
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
|
||||||
|
# Check access permissions
|
||||||
|
if not await is_admin(message.from_user.id):
|
||||||
|
await message.reply("❌ Эта команда доступна только администраторам")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with get_async_session_local()() as session:
|
||||||
|
# Get administrators
|
||||||
|
query = select(User).where(User.is_admin == True).order_by(desc(User.created_at))
|
||||||
|
result = await session.execute(query)
|
||||||
|
admins = list(result.scalars().all())
|
||||||
|
|
||||||
|
if not admins:
|
||||||
|
await message.reply("👑 Администраторов в базе данных нет")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Format message
|
||||||
|
text = f"👑 **Список администраторов** (всего: {len(admins)})\n\n"
|
||||||
|
|
||||||
|
for i, admin in enumerate(admins, 1):
|
||||||
|
username = f"@{admin.username}" if admin.username else "-"
|
||||||
|
name = f"{admin.first_name or ''} {admin.last_name or ''}".strip() or "-"
|
||||||
|
blocked_badge = "🚫" if admin.is_blocked else ""
|
||||||
|
|
||||||
|
text += (
|
||||||
|
f"{i}. {blocked_badge} **ID:** `{admin.user_id}`\n"
|
||||||
|
f" 👤 {username} ({name})\n"
|
||||||
|
f" 📅 Создан: {admin.created_at.strftime('%Y-%m-%d %H:%M') if admin.created_at else 'N/A'}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.reply(text)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing administrators: {e}", exc_info=True)
|
||||||
|
await message.reply("❌ Ошибка при получении списка администраторов")
|
||||||
|
|
||||||
|
|
||||||
|
async def login_command(client: Client, message: Message):
|
||||||
|
"""Handle /login command to get OTP code"""
|
||||||
|
from bot.modules.access_control.auth import is_authorized
|
||||||
|
from bot.modules.database.session import AsyncSessionLocal
|
||||||
|
from web.utils.otp import create_otp_code
|
||||||
|
from shared.config import settings
|
||||||
|
|
||||||
|
user_id = message.from_user.id
|
||||||
|
|
||||||
|
# Check authorization
|
||||||
|
if not await is_authorized(user_id):
|
||||||
|
await message.reply("❌ У вас нет доступа к этому боту")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create OTP code
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
code = await create_otp_code(user_id, session)
|
||||||
|
|
||||||
|
if code:
|
||||||
|
# Form URL for web interface
|
||||||
|
if settings.WEB_HOST == "0.0.0.0":
|
||||||
|
login_url = f"localhost:{settings.WEB_PORT}"
|
||||||
|
else:
|
||||||
|
login_url = f"{settings.WEB_HOST}:{settings.WEB_PORT}"
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
f"🔐 **Ваш код для входа в веб-интерфейс:**\n\n"
|
||||||
|
f"**`{code}`**\n\n"
|
||||||
|
f"⏰ Код действителен 10 минут\n\n"
|
||||||
|
f"🌐 Перейдите на http://{login_url}/admin/login и введите этот код\n\n"
|
||||||
|
f"💡 Или используйте ваш User ID: `{user_id}`"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await message.reply("❌ Не удалось создать код. Попробуйте позже.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при создании OTP кода: {e}")
|
||||||
|
await message.reply("❌ Произошла ошибка при создании кода. Попробуйте позже.")
|
||||||
|
|
||||||
|
|
||||||
|
async def url_handler(client: Client, message: Message):
|
||||||
|
"""Handle URL messages"""
|
||||||
|
from bot.modules.access_control.auth import is_authorized
|
||||||
|
from bot.modules.task_scheduler.queue import task_queue, Task, TaskStatus
|
||||||
|
from bot.modules.task_scheduler.executor import task_executor, set_app_client
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Check authorization
|
||||||
|
if not await is_authorized(message.from_user.id):
|
||||||
|
await message.reply("❌ У вас нет доступа к этому боту")
|
||||||
|
return
|
||||||
|
|
||||||
|
url = message.text.strip()
|
||||||
|
user_id = message.from_user.id
|
||||||
|
|
||||||
|
# URL validation
|
||||||
|
from bot.utils.helpers import is_valid_url
|
||||||
|
if not is_valid_url(url):
|
||||||
|
await message.reply(
|
||||||
|
"❌ Некорректный или небезопасный URL.\n\n"
|
||||||
|
"Пожалуйста, отправьте валидную ссылку (http:// или https://)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check concurrent tasks count
|
||||||
|
from bot.config import settings
|
||||||
|
active_tasks_count = await task_queue.get_user_active_tasks_count(user_id)
|
||||||
|
if active_tasks_count >= settings.MAX_CONCURRENT_TASKS:
|
||||||
|
await message.reply(
|
||||||
|
f"❌ Превышен лимит одновременных задач ({settings.MAX_CONCURRENT_TASKS}).\n"
|
||||||
|
f"⏳ Дождитесь завершения текущих задач или отмените их через /cancel"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Set client for task executor
|
||||||
|
set_app_client(client)
|
||||||
|
|
||||||
|
# Generate unique task_id using UUID
|
||||||
|
from bot.utils.helpers import generate_unique_task_id
|
||||||
|
task_id = generate_unique_task_id()
|
||||||
|
|
||||||
|
# Check that such ID doesn't exist yet (in case of collision, though probability is extremely low)
|
||||||
|
existing_task = await task_queue.get_task_by_id(task_id)
|
||||||
|
max_retries = 10
|
||||||
|
retries = 0
|
||||||
|
while existing_task and retries < max_retries:
|
||||||
|
task_id = generate_unique_task_id()
|
||||||
|
existing_task = await task_queue.get_task_by_id(task_id)
|
||||||
|
retries += 1
|
||||||
|
|
||||||
|
if existing_task:
|
||||||
|
# If after 10 attempts still collision (extremely unlikely), log error
|
||||||
|
logger.error(f"Failed to generate unique task_id after {max_retries} attempts")
|
||||||
|
await message.reply("❌ Ошибка при создании задачи. Попробуйте позже.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Duplicate URL check will be performed atomically in task_queue.add_task()
|
||||||
|
url_normalized = url.strip()
|
||||||
|
|
||||||
|
task = Task(
|
||||||
|
id=task_id,
|
||||||
|
user_id=user_id,
|
||||||
|
task_type="download",
|
||||||
|
url=url_normalized,
|
||||||
|
status=TaskStatus.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save task to database BEFORE adding to queue (race condition fix)
|
||||||
|
try:
|
||||||
|
from shared.database.models import Task as DBTask
|
||||||
|
from shared.database.session import get_async_session_local
|
||||||
|
from shared.database.user_helpers import ensure_user_exists
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
async with get_async_session_local()() as session:
|
||||||
|
# Ensure user exists before creating task
|
||||||
|
await ensure_user_exists(user_id, session)
|
||||||
|
|
||||||
|
db_task = DBTask(
|
||||||
|
id=task_id,
|
||||||
|
user_id=user_id,
|
||||||
|
task_type=task.task_type,
|
||||||
|
status=task.status.value,
|
||||||
|
url=task.url,
|
||||||
|
progress=0,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
session.add(db_task)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"Task {task_id} saved to database from bot")
|
||||||
|
except IntegrityError as e:
|
||||||
|
logger.error(f"IntegrityError saving task {task_id} to database (possibly duplicate ID): {e}", exc_info=True)
|
||||||
|
# Generate new task_id and retry
|
||||||
|
from bot.utils.helpers import generate_unique_task_id
|
||||||
|
task_id = generate_unique_task_id()
|
||||||
|
task.id = task_id
|
||||||
|
try:
|
||||||
|
async with get_async_session_local()() as session:
|
||||||
|
from shared.database.user_helpers import ensure_user_exists
|
||||||
|
# Ensure user exists before creating task
|
||||||
|
await ensure_user_exists(user_id, session)
|
||||||
|
|
||||||
|
db_task = DBTask(
|
||||||
|
id=task_id,
|
||||||
|
user_id=user_id,
|
||||||
|
task_type=task.task_type,
|
||||||
|
status=task.status.value,
|
||||||
|
url=task.url,
|
||||||
|
progress=0,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
session.add(db_task)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"Task {task_id} saved to database from bot with new ID")
|
||||||
|
except Exception as e2:
|
||||||
|
logger.error(f"Error saving task {task_id} to database again: {e2}", exc_info=True)
|
||||||
|
await message.reply("❌ Ошибка при создании задачи. Попробуйте позже.")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving task {task_id} to database: {e}", exc_info=True)
|
||||||
|
await message.reply("❌ Ошибка при создании задачи. Попробуйте позже.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add to queue (with duplicate URL check) AFTER saving to database
|
||||||
|
success = await task_queue.add_task(task, check_duplicate_url=True)
|
||||||
|
if not success:
|
||||||
|
# If failed to add to queue, remove from database
|
||||||
|
try:
|
||||||
|
async with get_async_session_local()() as session:
|
||||||
|
db_task = await session.get(DBTask, task_id)
|
||||||
|
if db_task:
|
||||||
|
await session.delete(db_task)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting task {task_id} from database after failed queue addition: {e}")
|
||||||
|
await message.reply(
|
||||||
|
f"⚠️ Задача с этим URL уже обрабатывается.\n"
|
||||||
|
f"Дождитесь завершения или отмените предыдущую задачу через /cancel"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Start executor if not already started
|
||||||
|
if not task_executor._running:
|
||||||
|
await task_executor.start()
|
||||||
|
|
||||||
|
await message.reply(
|
||||||
|
f"✅ Ссылка получена!\n\n"
|
||||||
|
f"🔗 {url}\n\n"
|
||||||
|
f"📥 Загрузка добавлена в очередь. Я начну загрузку в ближайшее время.\n"
|
||||||
|
f"⏳ Вы получите уведомление о статусе загрузки."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_commands(app: Client):
|
||||||
|
"""Register all commands"""
|
||||||
|
# Base commands (for all users)
|
||||||
|
app.add_handler(MessageHandler(start_command, filters=command("start")))
|
||||||
|
app.add_handler(MessageHandler(help_command, filters=command("help")))
|
||||||
|
app.add_handler(MessageHandler(status_command, filters=command("status")))
|
||||||
|
app.add_handler(MessageHandler(cancel_command, filters=command("cancel")))
|
||||||
|
app.add_handler(MessageHandler(login_command, filters=command("login")))
|
||||||
|
|
||||||
|
# User management commands (admin only)
|
||||||
|
app.add_handler(MessageHandler(adduser_command, filters=command("adduser")))
|
||||||
|
app.add_handler(MessageHandler(removeuser_command, filters=command("removeuser")))
|
||||||
|
app.add_handler(MessageHandler(blockuser_command, filters=command("blockuser")))
|
||||||
|
app.add_handler(MessageHandler(unblockuser_command, filters=command("unblockuser")))
|
||||||
|
app.add_handler(MessageHandler(listusers_command, filters=command("listusers")))
|
||||||
|
|
||||||
|
# Administrator management commands (admin only)
|
||||||
|
app.add_handler(MessageHandler(addadmin_command, filters=command("addadmin")))
|
||||||
|
app.add_handler(MessageHandler(removeadmin_command, filters=command("removeadmin")))
|
||||||
|
app.add_handler(MessageHandler(listadmins_command, filters=command("listadmins")))
|
||||||
|
|
||||||
|
# URL message handling
|
||||||
|
app.add_handler(MessageHandler(url_handler, filters=is_url_message))
|
||||||
|
|
||||||
|
logger.info("Commands registered")
|
||||||
|
|
||||||
37
bot/modules/message_handler/filters.py
Normal file
37
bot/modules/message_handler/filters.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Message filters
|
||||||
|
"""
|
||||||
|
from pyrogram import Client
|
||||||
|
from pyrogram.types import Message
|
||||||
|
from pyrogram.filters import Filter
|
||||||
|
from bot.utils.helpers import is_valid_url
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class URLFilter(Filter):
|
||||||
|
"""Filter for URL messages"""
|
||||||
|
|
||||||
|
async def __call__(self, client: Client, message: Message) -> bool:
|
||||||
|
if not message.text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
text = message.text.strip()
|
||||||
|
return is_valid_url(text)
|
||||||
|
|
||||||
|
|
||||||
|
# Filter instance
|
||||||
|
is_url_message = URLFilter()
|
||||||
|
|
||||||
|
|
||||||
|
def is_youtube_url(url: str) -> bool:
|
||||||
|
"""Check if URL is YouTube"""
|
||||||
|
youtube_domains = ['youtube.com', 'youtu.be', 'm.youtube.com']
|
||||||
|
return any(domain in url.lower() for domain in youtube_domains)
|
||||||
|
|
||||||
|
|
||||||
|
def is_instagram_url(url: str) -> bool:
|
||||||
|
"""Check if URL is Instagram"""
|
||||||
|
return 'instagram.com' in url.lower()
|
||||||
|
|
||||||
4
bot/modules/task_scheduler/__init__.py
Normal file
4
bot/modules/task_scheduler/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Task scheduler module
|
||||||
|
"""
|
||||||
|
|
||||||
694
bot/modules/task_scheduler/executor.py
Normal file
694
bot/modules/task_scheduler/executor.py
Normal file
@@ -0,0 +1,694 @@
|
|||||||
|
"""
|
||||||
|
Task executor
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from bot.modules.task_scheduler.queue import task_queue, Task, TaskStatus
|
||||||
|
from bot.modules.media_loader.ytdlp import download_media
|
||||||
|
from bot.modules.media_loader.sender import send_file_to_user, delete_file
|
||||||
|
from bot.modules.message_handler.filters import is_youtube_url, is_instagram_url
|
||||||
|
from pyrogram import Client
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Global client for sending messages
|
||||||
|
# Protected by threading.Lock for thread-safe access from different threads
|
||||||
|
_app_client: Optional[Client] = None
|
||||||
|
_app_client_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Dictionary to store message_id for tasks to update messages
|
||||||
|
# Format: {task_id: message_id}
|
||||||
|
# Use size limit to prevent memory leaks
|
||||||
|
_task_messages: dict[int, int] = {}
|
||||||
|
_task_messages_lock = threading.Lock()
|
||||||
|
_MAX_TASK_MESSAGES = 10000 # Maximum number of records
|
||||||
|
|
||||||
|
|
||||||
|
def set_app_client(client: Client) -> None:
|
||||||
|
"""
|
||||||
|
Set client for sending messages (thread-safe)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Pyrogram client for sending messages
|
||||||
|
"""
|
||||||
|
global _app_client
|
||||||
|
with _app_client_lock:
|
||||||
|
_app_client = client
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_client() -> Optional[Client]:
|
||||||
|
"""Get client for sending messages (thread-safe)"""
|
||||||
|
global _app_client
|
||||||
|
with _app_client_lock:
|
||||||
|
return _app_client
|
||||||
|
|
||||||
|
|
||||||
|
def set_task_message(task_id: int, message_id: int) -> None:
|
||||||
|
"""
|
||||||
|
Save message_id for task (thread-safe)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: Task ID
|
||||||
|
message_id: Telegram message ID
|
||||||
|
"""
|
||||||
|
global _task_messages
|
||||||
|
with _task_messages_lock:
|
||||||
|
# Clear old records if limit reached
|
||||||
|
if len(_task_messages) >= _MAX_TASK_MESSAGES:
|
||||||
|
# Remove 10% of oldest records (FIFO)
|
||||||
|
keys_to_remove = list(_task_messages.keys())[:_MAX_TASK_MESSAGES // 10]
|
||||||
|
for key in keys_to_remove:
|
||||||
|
_task_messages.pop(key, None)
|
||||||
|
logger.debug(f"Cleared {len(keys_to_remove)} old records from _task_messages")
|
||||||
|
_task_messages[task_id] = message_id
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_message(task_id: int) -> Optional[int]:
|
||||||
|
"""Get message_id for task (thread-safe)"""
|
||||||
|
global _task_messages
|
||||||
|
with _task_messages_lock:
|
||||||
|
return _task_messages.get(task_id)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_task_message(task_id: int) -> None:
|
||||||
|
"""
|
||||||
|
Remove message_id for task (thread-safe)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: Task ID
|
||||||
|
"""
|
||||||
|
global _task_messages
|
||||||
|
with _task_messages_lock:
|
||||||
|
_task_messages.pop(task_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_completed_task_messages():
|
||||||
|
"""
|
||||||
|
Periodic cleanup of message_id for completed tasks
|
||||||
|
Runs in background every 30 minutes
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(30 * 60) # 30 minutes
|
||||||
|
from bot.modules.task_scheduler.queue import task_queue, TaskStatus
|
||||||
|
|
||||||
|
# Get all completed tasks
|
||||||
|
all_tasks = await task_queue.get_all_tasks()
|
||||||
|
completed_task_ids = [
|
||||||
|
task.id for task in all_tasks
|
||||||
|
if task.status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Remove message_id for completed tasks
|
||||||
|
with _task_messages_lock:
|
||||||
|
removed_count = 0
|
||||||
|
for task_id in completed_task_ids:
|
||||||
|
if task_id in _task_messages:
|
||||||
|
del _task_messages[task_id]
|
||||||
|
removed_count += 1
|
||||||
|
|
||||||
|
if removed_count > 0:
|
||||||
|
logger.debug(f"Cleared {removed_count} message_id for completed tasks")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Message ID cleanup task stopped")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error cleaning up message_id: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskExecutor:
|
||||||
|
"""Task executor"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._running = False
|
||||||
|
self._workers: list[asyncio.Task] = []
|
||||||
|
self._running_lock = asyncio.Lock() # Protection for _running flag
|
||||||
|
|
||||||
|
async def start(self, num_workers: int = 2):
|
||||||
|
"""
|
||||||
|
Start task executor
|
||||||
|
|
||||||
|
Args:
|
||||||
|
num_workers: Number of workers (default 2 for parallel processing)
|
||||||
|
"""
|
||||||
|
async with self._running_lock:
|
||||||
|
if self._running:
|
||||||
|
logger.warning("Task executor already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
logger.info(f"Starting task executor with {num_workers} workers")
|
||||||
|
|
||||||
|
# Create workers (each works independently)
|
||||||
|
for i in range(num_workers):
|
||||||
|
worker = asyncio.create_task(self._worker(f"worker-{i+1}"))
|
||||||
|
self._workers.append(worker)
|
||||||
|
# Small delay between worker starts for even load distribution
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
# Start background task for cleaning message_id for completed tasks
|
||||||
|
cleanup_task = asyncio.create_task(cleanup_completed_task_messages())
|
||||||
|
self._workers.append(cleanup_task)
|
||||||
|
logger.info("Started background task for cleaning message_id for completed tasks")
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop task executor"""
|
||||||
|
async with self._running_lock:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
logger.info("Stopping task executor...")
|
||||||
|
|
||||||
|
# Cancel all active tasks
|
||||||
|
# Get all tasks and cancel active ones
|
||||||
|
all_tasks = await task_queue.get_all_tasks()
|
||||||
|
for task in all_tasks:
|
||||||
|
if task.status == TaskStatus.PROCESSING:
|
||||||
|
logger.info(f"Cancelling active task {task.id} on shutdown")
|
||||||
|
await task_queue.update_task_status(task.id, TaskStatus.CANCELLED, error="Bot shutdown")
|
||||||
|
cancel_event = task_queue.get_cancel_event(task.id)
|
||||||
|
cancel_event.set()
|
||||||
|
|
||||||
|
# Wait for all workers to complete
|
||||||
|
await asyncio.gather(*self._workers, return_exceptions=True)
|
||||||
|
self._workers.clear()
|
||||||
|
logger.info("Task executor stopped")
|
||||||
|
|
||||||
|
async def _worker(self, name: str):
|
||||||
|
"""Worker for processing tasks (runs in parallel with other workers)"""
|
||||||
|
logger.info(f"Worker {name} started")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Check state with lock protection
|
||||||
|
async with self._running_lock:
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
# Get task from queue (non-blocking)
|
||||||
|
task = await task_queue.get_task()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
# No tasks, small delay
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for cancellation before starting processing
|
||||||
|
current_task = await task_queue.get_task_by_id(task.id)
|
||||||
|
if current_task and current_task.status == TaskStatus.CANCELLED:
|
||||||
|
logger.info(f"Task {task.id} was cancelled, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update status
|
||||||
|
await task_queue.update_task_status(task.id, TaskStatus.PROCESSING)
|
||||||
|
|
||||||
|
logger.info(f"Worker {name} processing task {task.id}")
|
||||||
|
|
||||||
|
# Execute task (doesn't block other workers and message processing)
|
||||||
|
# Each task executes independently
|
||||||
|
await self._execute_task(task)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info(f"Worker {name} stopped")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in worker {name}: {e}", exc_info=True)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
logger.info(f"Worker {name} finished")
|
||||||
|
|
||||||
|
async def _execute_task(self, task: Task):
|
||||||
|
"""
|
||||||
|
Execute task
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Task to execute
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if task.task_type == "download" and task.url:
|
||||||
|
# Determine download type
|
||||||
|
if is_youtube_url(task.url) or is_instagram_url(task.url) or any(
|
||||||
|
domain in task.url.lower()
|
||||||
|
for domain in ['youtube.com', 'youtu.be', 'instagram.com', 'tiktok.com', 'twitter.com', 'x.com']
|
||||||
|
):
|
||||||
|
# Download via yt-dlp
|
||||||
|
await self._download_with_ytdlp(task)
|
||||||
|
else:
|
||||||
|
# Direct download (to be implemented later)
|
||||||
|
await task_queue.update_task_status(
|
||||||
|
task.id,
|
||||||
|
TaskStatus.FAILED,
|
||||||
|
error="Direct download not supported yet"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await task_queue.update_task_status(
|
||||||
|
task.id,
|
||||||
|
TaskStatus.FAILED,
|
||||||
|
error="Unknown task type or missing URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error executing task {task.id}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Form user-friendly error message
|
||||||
|
error_message = str(e)
|
||||||
|
if "login required" in error_message.lower() or "cookies" in error_message.lower():
|
||||||
|
error_message = (
|
||||||
|
"❌ Authentication required to download this content.\n\n"
|
||||||
|
"💡 Solution: configure cookies in bot configuration.\n"
|
||||||
|
"See instructions in README.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
await task_queue.update_task_status(
|
||||||
|
task.id,
|
||||||
|
TaskStatus.FAILED,
|
||||||
|
error=error_message
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send message to user
|
||||||
|
app_client = get_app_client()
|
||||||
|
if app_client:
|
||||||
|
try:
|
||||||
|
await app_client.send_message(
|
||||||
|
task.user_id,
|
||||||
|
f"❌ Download error:\n{error_message}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send error message to user {task.user_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
async def _download_with_ytdlp(self, task: Task):
|
||||||
|
"""Download via yt-dlp"""
|
||||||
|
# Get cancellation event for this task
|
||||||
|
cancel_event = task_queue.get_cancel_event(task.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check for cancellation
|
||||||
|
current_task = await task_queue.get_task_by_id(task.id)
|
||||||
|
if current_task and current_task.status == TaskStatus.CANCELLED:
|
||||||
|
logger.info(f"Task {task.id} cancelled, stopping download")
|
||||||
|
task_queue.clear_cancel_event(task.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get media information to check limits
|
||||||
|
from bot.modules.media_loader.ytdlp import get_media_info
|
||||||
|
from shared.config import settings
|
||||||
|
|
||||||
|
media_info = await get_media_info(task.url, cookies_file=settings.COOKIES_FILE)
|
||||||
|
if media_info:
|
||||||
|
# Check duration
|
||||||
|
max_duration = settings.max_duration_minutes_int
|
||||||
|
if max_duration and media_info.get('duration'):
|
||||||
|
duration_minutes = media_info['duration'] / 60
|
||||||
|
if duration_minutes > max_duration:
|
||||||
|
await task_queue.update_task_status(
|
||||||
|
task.id,
|
||||||
|
TaskStatus.FAILED,
|
||||||
|
error=f"Maximum duration exceeded ({max_duration} min)"
|
||||||
|
)
|
||||||
|
app_client = get_app_client()
|
||||||
|
if app_client:
|
||||||
|
try:
|
||||||
|
await app_client.send_message(
|
||||||
|
task.user_id,
|
||||||
|
f"❌ File too long ({duration_minutes:.1f} min). "
|
||||||
|
f"Maximum: {max_duration} min."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send duration exceeded message to user {task.user_id}: {e}", exc_info=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Function for updating progress with cancellation check
|
||||||
|
# This function is called from another thread (yt-dlp), so we use run_coroutine_threadsafe
|
||||||
|
async def update_progress(percent: int):
|
||||||
|
# Check cancellation when updating progress
|
||||||
|
if cancel_event.is_set():
|
||||||
|
raise asyncio.CancelledError("Task cancelled by user")
|
||||||
|
current_task = await task_queue.get_task_by_id(task.id)
|
||||||
|
if current_task and current_task.status == TaskStatus.CANCELLED:
|
||||||
|
raise asyncio.CancelledError("Task cancelled by user")
|
||||||
|
await task_queue.update_task_status(task.id, TaskStatus.PROCESSING, progress=percent)
|
||||||
|
|
||||||
|
# Update progress message
|
||||||
|
app_client = get_app_client()
|
||||||
|
if app_client:
|
||||||
|
message_id = get_task_message(task.id)
|
||||||
|
if message_id:
|
||||||
|
try:
|
||||||
|
from pyrogram.errors import MessageNotModified
|
||||||
|
status_text = (
|
||||||
|
f"📥 **Downloading file**\n\n"
|
||||||
|
f"🔗 {task.url[:50]}...\n"
|
||||||
|
f"📊 Progress: **{percent}%**\n"
|
||||||
|
f"⏳ Please wait..."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await app_client.edit_message_text(
|
||||||
|
chat_id=task.user_id,
|
||||||
|
message_id=message_id,
|
||||||
|
text=status_text
|
||||||
|
)
|
||||||
|
except MessageNotModified:
|
||||||
|
pass # Ignore if text didn't change
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to update message: {e}")
|
||||||
|
|
||||||
|
# Save reference to event loop for use in progress hook
|
||||||
|
progress_loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
# Send one message about download start (will be updated)
|
||||||
|
app_client = get_app_client()
|
||||||
|
status_message = None
|
||||||
|
if app_client:
|
||||||
|
try:
|
||||||
|
status_text = (
|
||||||
|
f"📥 **Downloading file**\n\n"
|
||||||
|
f"🔗 {task.url[:50]}...\n"
|
||||||
|
f"📊 Progress: **0%**\n"
|
||||||
|
f"⏳ Please wait..."
|
||||||
|
)
|
||||||
|
status_message = await app_client.send_message(
|
||||||
|
task.user_id,
|
||||||
|
status_text
|
||||||
|
)
|
||||||
|
# Save message_id for updates
|
||||||
|
set_task_message(task.id, status_message.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send notification: {e}")
|
||||||
|
|
||||||
|
# Download media
|
||||||
|
try:
|
||||||
|
logger.info(f"Starting download for task {task.id}, URL: {task.url}")
|
||||||
|
result = await download_media(
|
||||||
|
url=task.url,
|
||||||
|
output_dir="downloads",
|
||||||
|
quality="best",
|
||||||
|
progress_callback=update_progress,
|
||||||
|
cookies_file=settings.COOKIES_FILE,
|
||||||
|
cancel_event=cancel_event,
|
||||||
|
task_id=task.id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Download completed for task {task.id}. Result: {result is not None}, file_path: {result.get('file_path') if result else None}")
|
||||||
|
|
||||||
|
# Save file path to database
|
||||||
|
if result and result.get('file_path'):
|
||||||
|
await task_queue.update_task_file_path(task.id, result['file_path'])
|
||||||
|
logger.info(f"File path saved to DB: {result['file_path']}")
|
||||||
|
|
||||||
|
# Check file size after download
|
||||||
|
max_file_size = settings.max_file_size_bytes
|
||||||
|
if result and max_file_size:
|
||||||
|
file_size = result.get('size', 0)
|
||||||
|
if file_size > max_file_size:
|
||||||
|
await task_queue.update_task_status(
|
||||||
|
task.id,
|
||||||
|
TaskStatus.FAILED,
|
||||||
|
error=f"Maximum file size exceeded ({max_file_size / (1024*1024):.1f} MB)"
|
||||||
|
)
|
||||||
|
# Delete file
|
||||||
|
if result.get('file_path'):
|
||||||
|
await delete_file(result['file_path'])
|
||||||
|
app_client = get_app_client()
|
||||||
|
if app_client:
|
||||||
|
try:
|
||||||
|
await app_client.send_message(
|
||||||
|
task.user_id,
|
||||||
|
f"❌ File too large ({file_size / (1024*1024):.1f} MB). "
|
||||||
|
f"Maximum: {max_file_size / (1024*1024):.1f} MB."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send file size exceeded message to user {task.user_id}: {e}", exc_info=True)
|
||||||
|
clear_task_message(task.id)
|
||||||
|
return
|
||||||
|
except (asyncio.CancelledError, KeyboardInterrupt) as e:
|
||||||
|
logger.info(f"Task {task.id} cancelled during download: {e}")
|
||||||
|
await task_queue.update_task_status(
|
||||||
|
task.id,
|
||||||
|
TaskStatus.CANCELLED,
|
||||||
|
error="Cancelled by user"
|
||||||
|
)
|
||||||
|
app_client = get_app_client()
|
||||||
|
if app_client:
|
||||||
|
try:
|
||||||
|
message_id = get_task_message(task.id)
|
||||||
|
if message_id:
|
||||||
|
# Update cancellation message
|
||||||
|
try:
|
||||||
|
await app_client.edit_message_text(
|
||||||
|
chat_id=task.user_id,
|
||||||
|
message_id=message_id,
|
||||||
|
text=f"🚫 **Task cancelled**\n\nTask #{task.id} was cancelled."
|
||||||
|
)
|
||||||
|
except Exception as edit_error:
|
||||||
|
# If update failed, send new message
|
||||||
|
logger.debug(f"Failed to update cancellation message, sending new: {edit_error}")
|
||||||
|
try:
|
||||||
|
await app_client.send_message(
|
||||||
|
task.user_id,
|
||||||
|
f"🚫 Task #{task.id} cancelled"
|
||||||
|
)
|
||||||
|
except Exception as send_error:
|
||||||
|
logger.error(f"Failed to send cancellation message to user {task.user_id}: {send_error}", exc_info=True)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await app_client.send_message(
|
||||||
|
task.user_id,
|
||||||
|
f"🚫 Task #{task.id} cancelled"
|
||||||
|
)
|
||||||
|
except Exception as send_error:
|
||||||
|
logger.error(f"Failed to send cancellation message to user {task.user_id}: {send_error}", exc_info=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending cancellation notification for task {task.id}: {e}", exc_info=True)
|
||||||
|
clear_task_message(task.id)
|
||||||
|
task_queue.clear_cancel_event(task.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for cancellation after download
|
||||||
|
current_task = await task_queue.get_task_by_id(task.id)
|
||||||
|
if current_task and current_task.status == TaskStatus.CANCELLED:
|
||||||
|
logger.info(f"Task {task.id} cancelled after download")
|
||||||
|
# Delete downloaded file if exists
|
||||||
|
if result and result.get('file_path'):
|
||||||
|
await delete_file(result['file_path'])
|
||||||
|
task_queue.clear_cancel_event(task.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
await task_queue.update_task_status(
|
||||||
|
task.id,
|
||||||
|
TaskStatus.FAILED,
|
||||||
|
error="Failed to download file"
|
||||||
|
)
|
||||||
|
app_client = get_app_client()
|
||||||
|
if app_client:
|
||||||
|
try:
|
||||||
|
message_id = get_task_message(task.id)
|
||||||
|
if message_id:
|
||||||
|
try:
|
||||||
|
await app_client.edit_message_text(
|
||||||
|
chat_id=task.user_id,
|
||||||
|
message_id=message_id,
|
||||||
|
text="❌ **Download error**\n\nFailed to download file. Check the link and try again."
|
||||||
|
)
|
||||||
|
except Exception as edit_error:
|
||||||
|
logger.debug(f"Failed to update error message, sending new: {edit_error}")
|
||||||
|
try:
|
||||||
|
await app_client.send_message(
|
||||||
|
task.user_id,
|
||||||
|
f"❌ Error downloading file. Check the link and try again."
|
||||||
|
)
|
||||||
|
except Exception as send_error:
|
||||||
|
logger.error(f"Failed to send error message to user {task.user_id}: {send_error}", exc_info=True)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await app_client.send_message(
|
||||||
|
task.user_id,
|
||||||
|
f"❌ Error downloading file. Check the link and try again."
|
||||||
|
)
|
||||||
|
except Exception as send_error:
|
||||||
|
logger.error(f"Failed to send error message to user {task.user_id}: {send_error}", exc_info=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending download error notification for task {task.id}: {e}", exc_info=True)
|
||||||
|
clear_task_message(task.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send file to user
|
||||||
|
await task_queue.update_task_status(task.id, TaskStatus.PROCESSING, progress=90)
|
||||||
|
|
||||||
|
# Check that file exists before sending
|
||||||
|
file_path_obj = Path(result['file_path'])
|
||||||
|
if not file_path_obj.exists():
|
||||||
|
logger.error(f"File doesn't exist before sending: {result['file_path']}")
|
||||||
|
await task_queue.update_task_status(
|
||||||
|
task.id,
|
||||||
|
TaskStatus.FAILED,
|
||||||
|
error=f"File not found: {result['file_path']}"
|
||||||
|
)
|
||||||
|
app_client = get_app_client()
|
||||||
|
if app_client:
|
||||||
|
try:
|
||||||
|
await app_client.send_message(
|
||||||
|
task.user_id,
|
||||||
|
f"❌ Error: file not found after download"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send error message: {e}")
|
||||||
|
clear_task_message(task.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Sending file to user {task.user_id}: {result['file_path']}")
|
||||||
|
|
||||||
|
app_client = get_app_client()
|
||||||
|
if app_client:
|
||||||
|
try:
|
||||||
|
# Form caption
|
||||||
|
caption = f"📥 **{result.get('title', 'File')}**"
|
||||||
|
if result.get('duration'):
|
||||||
|
from bot.utils.helpers import format_duration
|
||||||
|
caption += f"\n⏱ Duration: {format_duration(result['duration'])}"
|
||||||
|
|
||||||
|
# Send file
|
||||||
|
logger.info(f"Calling send_file_to_user for file: {result['file_path']}")
|
||||||
|
success = await send_file_to_user(
|
||||||
|
client=app_client,
|
||||||
|
chat_id=task.user_id,
|
||||||
|
file_path=result['file_path'],
|
||||||
|
caption=caption,
|
||||||
|
thumbnail=result.get('thumbnail')
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"File sending result: success={success}")
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# Delete file after successful sending
|
||||||
|
await delete_file(result['file_path'])
|
||||||
|
await task_queue.update_task_status(task.id, TaskStatus.COMPLETED, progress=100)
|
||||||
|
task_queue.clear_cancel_event(task.id)
|
||||||
|
|
||||||
|
# Delete download message (file already sent)
|
||||||
|
message_id = get_task_message(task.id)
|
||||||
|
if message_id:
|
||||||
|
try:
|
||||||
|
await app_client.delete_messages(
|
||||||
|
chat_id=task.user_id,
|
||||||
|
message_ids=message_id
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to delete download message for task {task.id}: {e}")
|
||||||
|
clear_task_message(task.id)
|
||||||
|
else:
|
||||||
|
error_msg = "Failed to send file"
|
||||||
|
logger.error(f"File sending error for task {task.id}: {error_msg}")
|
||||||
|
await task_queue.update_task_status(
|
||||||
|
task.id,
|
||||||
|
TaskStatus.FAILED,
|
||||||
|
error=error_msg
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await app_client.send_message(
|
||||||
|
task.user_id,
|
||||||
|
f"❌ Error sending file. File downloaded but failed to send."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send file sending error message: {e}")
|
||||||
|
except Exception as send_error:
|
||||||
|
error_msg = f"Error sending file: {str(send_error)}"
|
||||||
|
logger.error(f"Exception sending file for task {task.id}: {send_error}", exc_info=True)
|
||||||
|
await task_queue.update_task_status(
|
||||||
|
task.id,
|
||||||
|
TaskStatus.FAILED,
|
||||||
|
error=error_msg
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if app_client:
|
||||||
|
await app_client.send_message(
|
||||||
|
task.user_id,
|
||||||
|
f"❌ Error sending file: {str(send_error)}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send error message: {e}")
|
||||||
|
message_id = get_task_message(task.id)
|
||||||
|
if message_id:
|
||||||
|
try:
|
||||||
|
await app_client.edit_message_text(
|
||||||
|
chat_id=task.user_id,
|
||||||
|
message_id=message_id,
|
||||||
|
text="❌ **Send error**\n\nFailed to send file. Try again later."
|
||||||
|
)
|
||||||
|
except Exception as edit_error:
|
||||||
|
logger.debug(f"Failed to update send error message, sending new: {edit_error}")
|
||||||
|
try:
|
||||||
|
await app_client.send_message(
|
||||||
|
task.user_id,
|
||||||
|
"❌ Error sending file. Try again later."
|
||||||
|
)
|
||||||
|
except Exception as send_error:
|
||||||
|
logger.error(f"Failed to send send error message to user {task.user_id}: {send_error}", exc_info=True)
|
||||||
|
else:
|
||||||
|
await app_client.send_message(
|
||||||
|
task.user_id,
|
||||||
|
"❌ Error sending file. Try again later."
|
||||||
|
)
|
||||||
|
clear_task_message(task.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending file: {e}", exc_info=True)
|
||||||
|
await task_queue.update_task_status(
|
||||||
|
task.id,
|
||||||
|
TaskStatus.FAILED,
|
||||||
|
error=f"Send error: {str(e)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("Client not set, file not sent")
|
||||||
|
await task_queue.update_task_status(
|
||||||
|
task.id,
|
||||||
|
TaskStatus.FAILED,
|
||||||
|
error="Client not available"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error downloading via yt-dlp: {e}", exc_info=True)
|
||||||
|
await task_queue.update_task_status(
|
||||||
|
task.id,
|
||||||
|
TaskStatus.FAILED,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
task_queue.clear_cancel_event(task.id)
|
||||||
|
clear_task_message(task.id)
|
||||||
|
app_client = get_app_client()
|
||||||
|
if app_client:
|
||||||
|
try:
|
||||||
|
message_id = get_task_message(task.id)
|
||||||
|
if message_id:
|
||||||
|
try:
|
||||||
|
await app_client.edit_message_text(
|
||||||
|
chat_id=task.user_id,
|
||||||
|
message_id=message_id,
|
||||||
|
text=f"❌ **Error**\n\nAn error occurred: {str(e)}"
|
||||||
|
)
|
||||||
|
except Exception as edit_error:
|
||||||
|
logger.debug(f"Failed to update error message, sending new: {edit_error}")
|
||||||
|
try:
|
||||||
|
await app_client.send_message(
|
||||||
|
task.user_id,
|
||||||
|
f"❌ An error occurred: {str(e)}"
|
||||||
|
)
|
||||||
|
except Exception as send_error:
|
||||||
|
logger.error(f"Failed to send error message to user {task.user_id}: {send_error}", exc_info=True)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await app_client.send_message(
|
||||||
|
task.user_id,
|
||||||
|
f"❌ An error occurred: {str(e)}"
|
||||||
|
)
|
||||||
|
except Exception as send_error:
|
||||||
|
logger.error(f"Failed to send error message to user {task.user_id}: {send_error}", exc_info=True)
|
||||||
|
except Exception as notify_error:
|
||||||
|
logger.error(f"Error sending error notification for task {task.id}: {notify_error}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Global task executor
|
||||||
|
task_executor = TaskExecutor()
|
||||||
|
|
||||||
84
bot/modules/task_scheduler/monitor.py
Normal file
84
bot/modules/task_scheduler/monitor.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
Task monitoring
|
||||||
|
"""
|
||||||
|
from typing import Tuple
|
||||||
|
from bot.modules.task_scheduler.queue import task_queue, TaskStatus
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_task_status(task_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Get task status
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: Task ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with task status
|
||||||
|
"""
|
||||||
|
task = await task_queue.get_task_by_id(task_id)
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
return {"error": "Task not found"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": task.id,
|
||||||
|
"user_id": task.user_id,
|
||||||
|
"task_type": task.task_type,
|
||||||
|
"status": task.status.value,
|
||||||
|
"progress": task.progress,
|
||||||
|
"error_message": task.error_message,
|
||||||
|
"created_at": task.created_at.isoformat() if task.created_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_tasks_status(user_id: int) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Get status of all user tasks
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of user tasks
|
||||||
|
"""
|
||||||
|
tasks = await task_queue.get_user_tasks(user_id)
|
||||||
|
return [await get_task_status(task.id) for task in tasks]
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_user_task(user_id: int, task_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Cancel user task
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
task_id: Task ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success: bool, message: str)
|
||||||
|
"""
|
||||||
|
task = await task_queue.get_task_by_id(task_id)
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
return (False, f"Задача #{task_id} не найдена")
|
||||||
|
|
||||||
|
if task.user_id != user_id:
|
||||||
|
return (False, "Вы можете отменять только свои задачи")
|
||||||
|
|
||||||
|
if task.status == TaskStatus.COMPLETED:
|
||||||
|
return (False, f"Задача #{task_id} уже завершена")
|
||||||
|
|
||||||
|
if task.status == TaskStatus.CANCELLED:
|
||||||
|
return (False, f"Задача #{task_id} уже отменена")
|
||||||
|
|
||||||
|
if task.status == TaskStatus.FAILED:
|
||||||
|
return (False, f"Задача #{task_id} уже завершилась с ошибкой")
|
||||||
|
|
||||||
|
success = await task_queue.cancel_task(task_id)
|
||||||
|
if success:
|
||||||
|
return (True, f"Задача #{task_id} успешно отменена")
|
||||||
|
else:
|
||||||
|
return (False, f"Не удалось отменить задачу #{task_id}. Возможно, она уже завершается.")
|
||||||
|
|
||||||
297
bot/modules/task_scheduler/queue.py
Normal file
297
bot/modules/task_scheduler/queue.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
"""
|
||||||
|
Task queue
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskStatus(Enum):
|
||||||
|
"""Task statuses"""
|
||||||
|
PENDING = "pending"
|
||||||
|
PROCESSING = "processing"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Task:
|
||||||
|
"""Task for execution"""
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
task_type: str
|
||||||
|
url: Optional[str] = None
|
||||||
|
status: TaskStatus = TaskStatus.PENDING
|
||||||
|
progress: int = 0
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
created_at: datetime = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.created_at is None:
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
class TaskQueue:
|
||||||
|
"""Task queue"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._queue: Optional[asyncio.Queue] = None
|
||||||
|
self._tasks: dict[int, Task] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._initialized = False
|
||||||
|
# Dictionary to store cancellation events for each task
|
||||||
|
# Protected by threading.Lock, as access may be from different threads
|
||||||
|
self._cancel_events: dict[int, threading.Event] = {}
|
||||||
|
self._cancel_events_lock = threading.Lock()
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
"""Initialize queue in current event loop"""
|
||||||
|
if not self._initialized:
|
||||||
|
self._queue = asyncio.Queue()
|
||||||
|
self._initialized = True
|
||||||
|
logger.info("Task queue initialized")
|
||||||
|
|
||||||
|
async def add_task(self, task: Task, check_duplicate_url: bool = True) -> bool:
|
||||||
|
"""
|
||||||
|
Add task to queue
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Task to add
|
||||||
|
check_duplicate_url: Check for duplicate URLs for this user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False if duplicate
|
||||||
|
"""
|
||||||
|
if not self._initialized or not self._queue:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
# Check for duplicate task_id
|
||||||
|
if task.id in self._tasks:
|
||||||
|
logger.warning(f"Task with ID {task.id} already exists, skipping duplicate")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Atomic check for duplicate URLs for this user
|
||||||
|
if check_duplicate_url and task.url:
|
||||||
|
url_normalized = task.url.strip()
|
||||||
|
for existing_task in self._tasks.values():
|
||||||
|
if (existing_task.user_id == task.user_id and
|
||||||
|
existing_task.url and existing_task.url.strip() == url_normalized and
|
||||||
|
existing_task.status in [TaskStatus.PENDING, TaskStatus.PROCESSING] and
|
||||||
|
existing_task.id != task.id):
|
||||||
|
logger.warning(f"Task {task.id} with URL {url_normalized} already processing for user {task.user_id} (task {existing_task.id})")
|
||||||
|
return False # Block duplicate
|
||||||
|
|
||||||
|
self._tasks[task.id] = task
|
||||||
|
await self._queue.put(task)
|
||||||
|
logger.info(f"Task {task.id} added to queue")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_task(self) -> Optional[Task]:
|
||||||
|
"""
|
||||||
|
Get task from queue
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Task or None
|
||||||
|
"""
|
||||||
|
if not self._initialized or not self._queue:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use timeout to avoid blocking indefinitely
|
||||||
|
task = await asyncio.wait_for(self._queue.get(), timeout=1.0)
|
||||||
|
return task
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Timeout is normal, just no tasks
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting task: {e}", exc_info=True)
|
||||||
|
await asyncio.sleep(1) # Small delay before retry
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def update_task_status(self, task_id: int, status: TaskStatus, progress: int = None, error: str = None):
|
||||||
|
"""
|
||||||
|
Update task status
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: Task ID
|
||||||
|
status: New status
|
||||||
|
progress: Progress (0-100)
|
||||||
|
error: Error message
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
if task_id in self._tasks:
|
||||||
|
task = self._tasks[task_id]
|
||||||
|
task.status = status
|
||||||
|
if progress is not None:
|
||||||
|
task.progress = progress
|
||||||
|
if error:
|
||||||
|
task.error_message = error
|
||||||
|
logger.info(f"Task {task_id} updated: {status.value}")
|
||||||
|
|
||||||
|
# Sync with database
|
||||||
|
try:
|
||||||
|
from shared.database.models import Task as DBTask
|
||||||
|
from shared.database.session import get_async_session_local
|
||||||
|
from datetime import datetime
|
||||||
|
async with get_async_session_local()() as session:
|
||||||
|
db_task = await session.get(DBTask, task_id)
|
||||||
|
if db_task:
|
||||||
|
db_task.status = status.value
|
||||||
|
if progress is not None:
|
||||||
|
db_task.progress = progress
|
||||||
|
if error:
|
||||||
|
# Limit error_message length (maximum 1000 characters)
|
||||||
|
db_task.error_message = (error[:1000] if error and len(error) > 1000 else error)
|
||||||
|
if status == TaskStatus.COMPLETED:
|
||||||
|
db_task.completed_at = datetime.utcnow()
|
||||||
|
db_task.updated_at = datetime.utcnow()
|
||||||
|
await session.commit()
|
||||||
|
logger.debug(f"Task {task_id} updated in DB: {status.value}")
|
||||||
|
else:
|
||||||
|
# Task not found in DB - create it for synchronization
|
||||||
|
logger.warning(f"Task {task_id} not found in DB, creating record for synchronization")
|
||||||
|
from bot.modules.task_scheduler.queue import task_queue
|
||||||
|
from shared.database.user_helpers import ensure_user_exists
|
||||||
|
task = await task_queue.get_task_by_id(task_id)
|
||||||
|
if task:
|
||||||
|
# Ensure user exists before creating task
|
||||||
|
await ensure_user_exists(task.user_id, session)
|
||||||
|
|
||||||
|
db_task = DBTask(
|
||||||
|
id=task_id,
|
||||||
|
user_id=task.user_id,
|
||||||
|
task_type=task.task_type,
|
||||||
|
status=status.value,
|
||||||
|
url=task.url,
|
||||||
|
progress=progress if progress is not None else 0,
|
||||||
|
error_message=(error[:1000] if error and len(error) > 1000 else error),
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
session.add(db_task)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"Task {task_id} created in DB for synchronization")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to update task {task_id} in DB: {e}", exc_info=True)
|
||||||
|
# Try to rollback if session is still open
|
||||||
|
try:
|
||||||
|
async with get_async_session_local()() as session:
|
||||||
|
await session.rollback()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def update_task_file_path(self, task_id: int, file_path: str):
|
||||||
|
"""
|
||||||
|
Update task file path
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: Task ID
|
||||||
|
file_path: File path
|
||||||
|
"""
|
||||||
|
# Sync with database
|
||||||
|
try:
|
||||||
|
from shared.database.models import Task as DBTask
|
||||||
|
from shared.database.session import get_async_session_local
|
||||||
|
from datetime import datetime
|
||||||
|
async with get_async_session_local()() as session:
|
||||||
|
db_task = await session.get(DBTask, task_id)
|
||||||
|
if db_task:
|
||||||
|
db_task.file_path = file_path
|
||||||
|
db_task.updated_at = datetime.utcnow()
|
||||||
|
await session.commit()
|
||||||
|
logger.debug(f"Task {task_id} file path updated in DB: {file_path}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Task {task_id} not found in DB for file_path update")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to update task {task_id} file path in DB: {e}", exc_info=True)
|
||||||
|
# Try to rollback if session is still open
|
||||||
|
try:
|
||||||
|
async with get_async_session_local()() as session:
|
||||||
|
await session.rollback()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_task_by_id(self, task_id: int) -> Optional[Task]:
|
||||||
|
"""Get task by ID"""
|
||||||
|
async with self._lock:
|
||||||
|
return self._tasks.get(task_id)
|
||||||
|
|
||||||
|
async def cancel_task(self, task_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Cancel task
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: Task ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
if task_id in self._tasks:
|
||||||
|
task = self._tasks[task_id]
|
||||||
|
# Can only cancel pending or processing tasks
|
||||||
|
if task.status in [TaskStatus.PENDING, TaskStatus.PROCESSING]:
|
||||||
|
task.status = TaskStatus.CANCELLED
|
||||||
|
task.error_message = "Cancelled by user"
|
||||||
|
# Set cancellation event to interrupt download (thread-safe)
|
||||||
|
with self._cancel_events_lock:
|
||||||
|
if task_id in self._cancel_events:
|
||||||
|
self._cancel_events[task_id].set()
|
||||||
|
logger.info(f"Task {task_id} cancelled")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_cancel_event(self, task_id: int) -> threading.Event:
|
||||||
|
"""
|
||||||
|
Get cancellation event for task (created if doesn't exist)
|
||||||
|
Thread-safe method for use from different threads.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: Task ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
threading.Event for cancellation check
|
||||||
|
"""
|
||||||
|
with self._cancel_events_lock:
|
||||||
|
if task_id not in self._cancel_events:
|
||||||
|
self._cancel_events[task_id] = threading.Event()
|
||||||
|
return self._cancel_events[task_id]
|
||||||
|
|
||||||
|
def clear_cancel_event(self, task_id: int):
|
||||||
|
"""
|
||||||
|
Clear cancellation event after task completion
|
||||||
|
Thread-safe method for use from different threads.
|
||||||
|
"""
|
||||||
|
with self._cancel_events_lock:
|
||||||
|
if task_id in self._cancel_events:
|
||||||
|
del self._cancel_events[task_id]
|
||||||
|
|
||||||
|
async def get_user_tasks(self, user_id: int) -> list[Task]:
|
||||||
|
"""Get user tasks"""
|
||||||
|
async with self._lock:
|
||||||
|
return [task for task in self._tasks.values() if task.user_id == user_id]
|
||||||
|
|
||||||
|
async def get_user_active_tasks_count(self, user_id: int) -> int:
|
||||||
|
"""Get count of active user tasks"""
|
||||||
|
async with self._lock:
|
||||||
|
active_statuses = [TaskStatus.PENDING, TaskStatus.PROCESSING]
|
||||||
|
return sum(1 for task in self._tasks.values()
|
||||||
|
if task.user_id == user_id and task.status in active_statuses)
|
||||||
|
|
||||||
|
async def get_all_tasks(self) -> list[Task]:
|
||||||
|
"""Get all tasks"""
|
||||||
|
async with self._lock:
|
||||||
|
return list(self._tasks.values())
|
||||||
|
|
||||||
|
|
||||||
|
# Global task queue
|
||||||
|
task_queue = TaskQueue()
|
||||||
|
|
||||||
4
bot/utils/__init__.py
Normal file
4
bot/utils/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Bot utilities
|
||||||
|
"""
|
||||||
|
|
||||||
147
bot/utils/file_cleanup.py
Normal file
147
bot/utils/file_cleanup.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""
|
||||||
|
Utilities for cleaning up old files
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Constants for time intervals
|
||||||
|
SECONDS_PER_HOUR = 3600
|
||||||
|
|
||||||
|
# Maximum file age before deletion (24 hours)
|
||||||
|
MAX_FILE_AGE_HOURS = 24
|
||||||
|
MAX_FILE_AGE = timedelta(hours=MAX_FILE_AGE_HOURS)
|
||||||
|
|
||||||
|
# Default cleanup interval (6 hours)
|
||||||
|
DEFAULT_CLEANUP_INTERVAL_HOURS = 6
|
||||||
|
|
||||||
|
# Queue of files to delete (files that couldn't be deleted on first attempt)
|
||||||
|
_files_to_cleanup: set[str] = set()
|
||||||
|
_files_to_cleanup_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def add_file_to_cleanup_queue(file_path: str):
|
||||||
|
"""
|
||||||
|
Add file to cleanup queue
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to file
|
||||||
|
"""
|
||||||
|
global _files_to_cleanup
|
||||||
|
_files_to_cleanup.add(file_path)
|
||||||
|
logger.debug(f"File added to cleanup queue: {file_path}")
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_queued_files():
|
||||||
|
"""
|
||||||
|
Delete files from queue
|
||||||
|
"""
|
||||||
|
global _files_to_cleanup
|
||||||
|
async with _files_to_cleanup_lock:
|
||||||
|
if not _files_to_cleanup:
|
||||||
|
return
|
||||||
|
|
||||||
|
files_to_remove = list(_files_to_cleanup)
|
||||||
|
_files_to_cleanup.clear()
|
||||||
|
|
||||||
|
from bot.modules.media_loader.sender import delete_file
|
||||||
|
for file_path in files_to_remove:
|
||||||
|
try:
|
||||||
|
await delete_file(file_path, max_retries=1) # One attempt, as there were already attempts
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to delete file from queue: {file_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_old_files(downloads_dir: str = "downloads"):
|
||||||
|
"""
|
||||||
|
Clean up old files from downloads/ directory
|
||||||
|
|
||||||
|
Args:
|
||||||
|
downloads_dir: Path to downloads directory
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
downloads_path = Path(downloads_dir)
|
||||||
|
if not downloads_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
deleted_count = 0
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
for file_path in downloads_path.iterdir():
|
||||||
|
if file_path.is_file():
|
||||||
|
try:
|
||||||
|
# Get last modification time
|
||||||
|
mtime = datetime.fromtimestamp(file_path.stat().st_mtime)
|
||||||
|
age = now - mtime
|
||||||
|
|
||||||
|
# Delete files older than MAX_FILE_AGE
|
||||||
|
if age > MAX_FILE_AGE:
|
||||||
|
file_size = file_path.stat().st_size
|
||||||
|
file_path.unlink()
|
||||||
|
deleted_count += 1
|
||||||
|
total_size += file_size
|
||||||
|
logger.debug(f"Deleted old file: {file_path.name} (age: {age})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to delete file {file_path}: {e}")
|
||||||
|
|
||||||
|
if deleted_count > 0:
|
||||||
|
logger.info(f"Cleaned up {deleted_count} old files, freed {total_size / (1024*1024):.2f} MB")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error cleaning up old files: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_files_periodically(
|
||||||
|
downloads_dir: str = "downloads",
|
||||||
|
interval_hours: int = DEFAULT_CLEANUP_INTERVAL_HOURS
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Periodically clean up old files
|
||||||
|
|
||||||
|
Args:
|
||||||
|
downloads_dir: Path to downloads directory
|
||||||
|
interval_hours: Interval between cleanups in hours
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(interval_hours * SECONDS_PER_HOUR)
|
||||||
|
await cleanup_old_files(downloads_dir)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("File cleanup task stopped")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in file cleanup task: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_downloads_dir_size(downloads_dir: str = "downloads") -> int:
|
||||||
|
"""
|
||||||
|
Get total size of downloads/ directory
|
||||||
|
|
||||||
|
Args:
|
||||||
|
downloads_dir: Path to downloads directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Size in bytes
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
downloads_path = Path(downloads_dir)
|
||||||
|
if not downloads_path.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
for file_path in downloads_path.rglob('*'):
|
||||||
|
if file_path.is_file():
|
||||||
|
try:
|
||||||
|
total_size += file_path.stat().st_size
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return total_size
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating directory size: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
121
bot/utils/file_processor.py
Normal file
121
bot/utils/file_processor.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
File processing (archives, thumbnails)
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from PIL import Image
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_thumbnail(video_path: str, output_path: str, size: tuple = (320, 240)) -> bool:
|
||||||
|
"""
|
||||||
|
Generate thumbnail for video using ffmpeg
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_path: Path to video file
|
||||||
|
output_path: Path to save thumbnail
|
||||||
|
size: Thumbnail size (width, height)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# Check if ffmpeg is available
|
||||||
|
try:
|
||||||
|
result = await asyncio.create_subprocess_exec(
|
||||||
|
'ffmpeg', '-version',
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
await result.wait()
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.warning("ffmpeg not found, thumbnail generation not possible")
|
||||||
|
return False
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning("ffmpeg not installed, thumbnail generation not possible")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Generate thumbnail from middle of video
|
||||||
|
output_file = Path(output_path)
|
||||||
|
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Get video duration
|
||||||
|
duration_cmd = [
|
||||||
|
'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
||||||
|
'-of', 'default=noprint_wrappers=1:nokey=1', str(video_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*duration_cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
stdout, _ = await proc.communicate()
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
logger.warning("Failed to get video duration")
|
||||||
|
# Use 1 second as default
|
||||||
|
seek_time = 1
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
duration = float(stdout.decode().strip())
|
||||||
|
seek_time = duration / 2 # Middle of video
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
seek_time = 1
|
||||||
|
|
||||||
|
# Generate thumbnail
|
||||||
|
cmd = [
|
||||||
|
'ffmpeg', '-i', str(video_path),
|
||||||
|
'-ss', str(seek_time),
|
||||||
|
'-vframes', '1',
|
||||||
|
'-vf', f'scale={size[0]}:{size[1]}',
|
||||||
|
'-y', # Overwrite if exists
|
||||||
|
str(output_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
await proc.wait()
|
||||||
|
|
||||||
|
if proc.returncode == 0 and Path(output_path).exists():
|
||||||
|
logger.info(f"Thumbnail created: {output_path}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to create thumbnail for {video_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating thumbnail: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_archive(archive_path: str, output_dir: str, password: Optional[str] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Extract archive
|
||||||
|
|
||||||
|
Args:
|
||||||
|
archive_path: Path to archive
|
||||||
|
output_dir: Directory for extraction
|
||||||
|
password: Archive password (if required)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# TODO: Implement archive extraction
|
||||||
|
# Support zip, rar, 7z
|
||||||
|
logger.warning("Archive extraction not implemented yet")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error extracting archive: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
121
bot/utils/file_splitter.py
Normal file
121
bot/utils/file_splitter.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
Utilities for splitting large files into parts
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
import logging
|
||||||
|
import aiofiles
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Maximum part size (1.9 GB for safety)
|
||||||
|
MAX_PART_SIZE = int(1.9 * 1024 * 1024 * 1024) # 1.9 GB
|
||||||
|
|
||||||
|
|
||||||
|
async def split_file(file_path: str, part_size: int = MAX_PART_SIZE) -> List[str]:
|
||||||
|
"""
|
||||||
|
Split file into parts
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to source file
|
||||||
|
part_size: Size of each part in bytes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of paths to file parts
|
||||||
|
"""
|
||||||
|
file = Path(file_path)
|
||||||
|
if not file.exists():
|
||||||
|
raise FileNotFoundError(f"File not found: {file_path}")
|
||||||
|
|
||||||
|
file_size = file.stat().st_size
|
||||||
|
if file_size <= part_size:
|
||||||
|
# File doesn't need to be split
|
||||||
|
return [str(file)]
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
part_number = 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiofiles.open(file_path, 'rb') as source_file:
|
||||||
|
while True:
|
||||||
|
part_path = file.parent / f"{file.stem}.part{part_number:03d}{file.suffix}"
|
||||||
|
parts.append(str(part_path))
|
||||||
|
|
||||||
|
async with aiofiles.open(part_path, 'wb') as part_file:
|
||||||
|
bytes_written = 0
|
||||||
|
|
||||||
|
while bytes_written < part_size:
|
||||||
|
chunk_size = min(8192, part_size - bytes_written)
|
||||||
|
chunk = await source_file.read(chunk_size)
|
||||||
|
|
||||||
|
if not chunk:
|
||||||
|
# End of file reached
|
||||||
|
break
|
||||||
|
|
||||||
|
await part_file.write(chunk)
|
||||||
|
bytes_written += len(chunk)
|
||||||
|
|
||||||
|
if bytes_written == 0:
|
||||||
|
# No data to write, remove empty part
|
||||||
|
part_path.unlink()
|
||||||
|
parts.pop()
|
||||||
|
break
|
||||||
|
|
||||||
|
if bytes_written < part_size:
|
||||||
|
# End of file reached
|
||||||
|
break
|
||||||
|
|
||||||
|
part_number += 1
|
||||||
|
|
||||||
|
logger.info(f"File {file_path} split into {len(parts)} parts")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Clean up partially created parts on error
|
||||||
|
for part_path in parts:
|
||||||
|
try:
|
||||||
|
Path(part_path).unlink()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
raise Exception(f"Error splitting file: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_file_parts(parts: List[str]) -> None:
|
||||||
|
"""
|
||||||
|
Delete all file parts
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parts: List of paths to file parts
|
||||||
|
"""
|
||||||
|
for part_path in parts:
|
||||||
|
try:
|
||||||
|
file = Path(part_path)
|
||||||
|
if file.exists():
|
||||||
|
file.unlink()
|
||||||
|
logger.debug(f"Deleted file part: {part_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to delete file part {part_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_part_info(parts: List[str]) -> dict:
|
||||||
|
"""
|
||||||
|
Get information about file parts
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parts: List of paths to file parts
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with information about parts
|
||||||
|
"""
|
||||||
|
total_size = 0
|
||||||
|
for part_path in parts:
|
||||||
|
file = Path(part_path)
|
||||||
|
if file.exists():
|
||||||
|
total_size += file.stat().st_size
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_parts": len(parts),
|
||||||
|
"total_size": total_size,
|
||||||
|
"parts": parts
|
||||||
|
}
|
||||||
|
|
||||||
146
bot/utils/helpers.py
Normal file
146
bot/utils/helpers.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
Utility functions
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_url(url: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate URL with protection against dangerous schemes
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if URL is valid and safe
|
||||||
|
"""
|
||||||
|
if not url or not isinstance(url, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check URL length (maximum 2048 characters)
|
||||||
|
if len(url) > 2048:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Block dangerous schemes
|
||||||
|
dangerous_schemes = ['file://', 'javascript:', 'data:', 'vbscript:', 'about:']
|
||||||
|
url_lower = url.lower().strip()
|
||||||
|
for scheme in dangerous_schemes:
|
||||||
|
if url_lower.startswith(scheme):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check URL format
|
||||||
|
url_pattern = re.compile(
|
||||||
|
r'^https?://' # http:// or https://
|
||||||
|
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain...
|
||||||
|
r'localhost|' # localhost...
|
||||||
|
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
|
||||||
|
r'(?::\d+)?' # optional port
|
||||||
|
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
|
||||||
|
return url_pattern.match(url) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_user_id(text: str) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Parse user_id from text (numeric only)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text that may contain user_id
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User ID as integer or None if not a valid number
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Remove @ if present
|
||||||
|
text = text.replace("@", "").strip()
|
||||||
|
return int(text)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_user_identifier(identifier: str) -> Tuple[Optional[int], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Resolve user identifier (user_id or username) to user_id
|
||||||
|
|
||||||
|
Args:
|
||||||
|
identifier: User ID (number) or username (with or without @)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (user_id: Optional[int], error_message: Optional[str])
|
||||||
|
If user_id is None, error_message contains the reason
|
||||||
|
"""
|
||||||
|
# First, try to parse as user_id
|
||||||
|
user_id = parse_user_id(identifier)
|
||||||
|
if user_id:
|
||||||
|
return (user_id, None)
|
||||||
|
|
||||||
|
# If not a number, try to resolve username via Telegram API
|
||||||
|
username = identifier.lstrip('@').strip()
|
||||||
|
if not username:
|
||||||
|
return (None, "Идентификатор не может быть пустым")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from bot.modules.task_scheduler.executor import get_app_client
|
||||||
|
app_client = get_app_client()
|
||||||
|
|
||||||
|
if not app_client:
|
||||||
|
return (None, "Telegram клиент не инициализирован. Попробуйте использовать User ID.")
|
||||||
|
|
||||||
|
# Try to get user by username via get_chat
|
||||||
|
# Note: This only works if bot has already interacted with the user
|
||||||
|
chat = await app_client.get_chat(username)
|
||||||
|
if chat and hasattr(chat, 'id'):
|
||||||
|
return (chat.id, None)
|
||||||
|
else:
|
||||||
|
return (None, f"Пользователь @{username} не найден через Telegram API")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Log the error but return user-friendly message
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.debug(f"Failed to resolve username {username}: {e}")
|
||||||
|
return (None, f"Не удалось найти пользователя @{username}. Убедитесь, что бот взаимодействовал с этим пользователем, или используйте User ID.")
|
||||||
|
|
||||||
|
|
||||||
|
def format_file_size(size_bytes: int) -> str:
|
||||||
|
"""Format file size"""
|
||||||
|
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||||
|
if size_bytes < 1024.0:
|
||||||
|
return f"{size_bytes:.2f} {unit}"
|
||||||
|
size_bytes /= 1024.0
|
||||||
|
return f"{size_bytes:.2f} PB"
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds) -> str:
|
||||||
|
"""
|
||||||
|
Format duration
|
||||||
|
|
||||||
|
Args:
|
||||||
|
seconds: Duration in seconds (int or float)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string in "HH:MM:SS" or "MM:SS" format
|
||||||
|
"""
|
||||||
|
# Convert to int as we don't need fractional seconds for display
|
||||||
|
seconds = int(seconds) if seconds else 0
|
||||||
|
hours = seconds // 3600
|
||||||
|
minutes = (seconds % 3600) // 60
|
||||||
|
secs = seconds % 60
|
||||||
|
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||||
|
return f"{minutes:02d}:{secs:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_unique_task_id() -> int:
|
||||||
|
"""
|
||||||
|
Generate unique task ID
|
||||||
|
Uses UUID to guarantee uniqueness
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Unique 63-bit integer ID
|
||||||
|
"""
|
||||||
|
# Use UUID and take first 63 bits (to fit in int)
|
||||||
|
return uuid.uuid4().int & ((1 << 63) - 1)
|
||||||
|
|
||||||
42
bot/utils/logger.py
Normal file
42
bot/utils/logger.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Logging configuration
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from bot.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logger():
|
||||||
|
"""Setup logging system"""
|
||||||
|
# Create log directory
|
||||||
|
log_dir = Path(settings.LOG_FILE).parent
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Setup log format
|
||||||
|
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
date_format = "%Y-%m-%d %H:%M:%S"
|
||||||
|
|
||||||
|
# Log level
|
||||||
|
log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
|
||||||
|
|
||||||
|
# Setup file handler
|
||||||
|
file_handler = logging.FileHandler(settings.LOG_FILE, encoding="utf-8")
|
||||||
|
file_handler.setLevel(log_level)
|
||||||
|
file_handler.setFormatter(logging.Formatter(log_format, date_format))
|
||||||
|
|
||||||
|
# Setup console handler
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(log_level)
|
||||||
|
console_handler.setFormatter(logging.Formatter(log_format, date_format))
|
||||||
|
|
||||||
|
# Setup root logger
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.setLevel(log_level)
|
||||||
|
root_logger.addHandler(file_handler)
|
||||||
|
root_logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# Setup loggers for external libraries
|
||||||
|
logging.getLogger("pyrogram").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
|
||||||
82
bot/utils/telegram_user.py
Normal file
82
bot/utils/telegram_user.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
Utilities for working with Telegram user information
|
||||||
|
"""
|
||||||
|
from typing import Optional, Dict
|
||||||
|
from pyrogram import Client
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_client() -> Optional[Client]:
|
||||||
|
"""
|
||||||
|
Get Pyrogram client from executor.py.
|
||||||
|
|
||||||
|
Uses a single source of client to avoid conflicts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Pyrogram client instance, or None if import fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from bot.modules.task_scheduler.executor import get_app_client as get_executor_client
|
||||||
|
return get_executor_client()
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("Failed to import get_app_client from executor")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_info(user_id: int) -> Optional[Dict[str, Optional[str]]]:
|
||||||
|
"""
|
||||||
|
Get user information from Telegram API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Telegram user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with user information:
|
||||||
|
{
|
||||||
|
"username": str or None,
|
||||||
|
"first_name": str or None,
|
||||||
|
"last_name": str or None
|
||||||
|
}
|
||||||
|
or None in case of error
|
||||||
|
"""
|
||||||
|
app_client = get_app_client()
|
||||||
|
|
||||||
|
if not app_client:
|
||||||
|
logger.warning(f"Pyrogram client not set, cannot get user information for {user_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get user information through Pyrogram
|
||||||
|
user = await app_client.get_users(user_id)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
return {
|
||||||
|
"username": user.username,
|
||||||
|
"first_name": user.first_name,
|
||||||
|
"last_name": user.last_name
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.warning(f"User {user_id} not found in Telegram")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting user information for {user_id}: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_username_by_id(user_id: int) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get username by user ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Telegram user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Username or None if not found
|
||||||
|
"""
|
||||||
|
user_info = await get_user_info(user_id)
|
||||||
|
if user_info:
|
||||||
|
return user_info.get("username")
|
||||||
|
return None
|
||||||
|
|
||||||
161
bot/utils/user_info_updater.py
Normal file
161
bot/utils/user_info_updater.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
Utilities for updating user information from Telegram API
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from shared.database.session import get_async_session_local
|
||||||
|
from shared.database.models import User
|
||||||
|
from bot.utils.telegram_user import get_user_info
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Constants for time intervals
|
||||||
|
SECONDS_PER_HOUR = 3600
|
||||||
|
SECONDS_PER_MINUTE = 60
|
||||||
|
TELEGRAM_API_DELAY = 0.5 # Delay between Telegram API requests in seconds
|
||||||
|
USER_INFO_UPDATE_INTERVAL_HOURS = 24 # Interval for updating user information
|
||||||
|
ERROR_RETRY_DELAY_SECONDS = 60 # Delay before retry on error
|
||||||
|
|
||||||
|
|
||||||
|
def _update_user_fields(user: User, user_info: Dict[str, Optional[str]]) -> bool:
|
||||||
|
"""
|
||||||
|
Update user fields from Telegram API information.
|
||||||
|
|
||||||
|
Updates username, first_name, and last_name if they are missing
|
||||||
|
and available in user_info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User object from database
|
||||||
|
user_info: Dictionary with user information from Telegram API
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if any fields were updated, False otherwise
|
||||||
|
"""
|
||||||
|
updated = False
|
||||||
|
|
||||||
|
if not user.username and user_info.get("username"):
|
||||||
|
user.username = user_info.get("username")
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if not user.first_name and user_info.get("first_name"):
|
||||||
|
user.first_name = user_info.get("first_name")
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if not user.last_name and user_info.get("last_name"):
|
||||||
|
user.last_name = user_info.get("last_name")
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
async def update_user_info_from_telegram(
|
||||||
|
user_id: int,
|
||||||
|
db_session: Optional[AsyncSession] = None
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Update user information from Telegram API.
|
||||||
|
|
||||||
|
Fetches user information from Telegram API and updates the database
|
||||||
|
with missing fields (username, first_name, last_name).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Telegram user ID
|
||||||
|
db_session: Database session (if None, creates a new one)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if information was updated, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get user information from Telegram API
|
||||||
|
user_info = await get_user_info(user_id)
|
||||||
|
if not user_info:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Update information in database
|
||||||
|
if db_session:
|
||||||
|
# Use provided session
|
||||||
|
user = await db_session.get(User, user_id)
|
||||||
|
if user:
|
||||||
|
if _update_user_fields(user, user_info):
|
||||||
|
await db_session.commit()
|
||||||
|
logger.info(f"User {user_id} information updated from Telegram API")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# Create new session
|
||||||
|
async with get_async_session_local()() as session:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if user:
|
||||||
|
if _update_user_fields(user, user_info):
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"User {user_id} information updated from Telegram API")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating user {user_id} information: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def update_users_without_info_periodically(
|
||||||
|
interval_hours: int = USER_INFO_UPDATE_INTERVAL_HOURS
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Periodically update information for users without username or first_name.
|
||||||
|
|
||||||
|
Runs in an infinite loop, updating user information at specified intervals.
|
||||||
|
Can be cancelled with asyncio.CancelledError.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interval_hours: Interval between updates in hours (default: 24 hours)
|
||||||
|
"""
|
||||||
|
logger.info("Background task for updating user information started")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(interval_hours * SECONDS_PER_HOUR)
|
||||||
|
|
||||||
|
logger.info("Starting update of user information for users without username or first_name")
|
||||||
|
|
||||||
|
async with get_async_session_local()() as session:
|
||||||
|
# Get users without username or first_name
|
||||||
|
result = await session.execute(
|
||||||
|
select(User).where(
|
||||||
|
(User.username == None) | (User.first_name == None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
# Update user information
|
||||||
|
if await update_user_info_from_telegram(user.user_id, db_session=session):
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
# Delay between requests to avoid overloading Telegram API
|
||||||
|
await asyncio.sleep(TELEGRAM_API_DELAY)
|
||||||
|
except Exception as e:
|
||||||
|
error_count += 1
|
||||||
|
logger.warning(f"Error updating user {user.user_id}: {e}")
|
||||||
|
|
||||||
|
if updated_count > 0 or error_count > 0:
|
||||||
|
logger.info(
|
||||||
|
f"User information update completed: "
|
||||||
|
f"updated {updated_count}, errors {error_count}, total checked {len(users)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug("No users found for update")
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("User information update task stopped")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in user information update task: {e}", exc_info=True)
|
||||||
|
# Continue working even on error
|
||||||
|
await asyncio.sleep(ERROR_RETRY_DELAY_SECONDS)
|
||||||
|
|
||||||
55
config_sample.env
Normal file
55
config_sample.env
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Telegram Bot Configuration
|
||||||
|
BOT_TOKEN=your_bot_token_here
|
||||||
|
TELEGRAM_API_ID=your_api_id_here
|
||||||
|
TELEGRAM_API_HASH=your_api_hash_here
|
||||||
|
OWNER_ID=your_owner_id_here
|
||||||
|
|
||||||
|
# Authorization Configuration
|
||||||
|
AUTHORIZED_USERS=
|
||||||
|
ADMIN_IDS=
|
||||||
|
BLOCKED_USERS=
|
||||||
|
# Приватный режим: если True, только пользователи из AUTHORIZED_USERS или добавленные через /adduser могут использовать бота
|
||||||
|
PRIVATE_MODE=False
|
||||||
|
|
||||||
|
# Database Configuration для Docker
|
||||||
|
# PostgreSQL автоматически настраивается через docker-compose
|
||||||
|
# Используйте имя сервиса 'postgres' как хост
|
||||||
|
# ВАЖНО: Замените tgloader_password на свой пароль!
|
||||||
|
DATABASE_URL=postgresql+asyncpg://tgloader:tgloader_password@postgres:5432/tgloader
|
||||||
|
|
||||||
|
# PostgreSQL Configuration (используется в docker-compose.yml)
|
||||||
|
POSTGRES_DB=tgloader
|
||||||
|
POSTGRES_USER=tgloader
|
||||||
|
POSTGRES_PASSWORD=tgloader_password
|
||||||
|
# ВАЖНО: POSTGRES_PASSWORD должен совпадать в DATABASE_URL и в настройках сервиса postgres
|
||||||
|
|
||||||
|
# Web Configuration
|
||||||
|
WEB_HOST=0.0.0.0
|
||||||
|
WEB_PORT=5000
|
||||||
|
WEB_SECRET_KEY=your_secret_key_here
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FILE=logs/bot.log
|
||||||
|
|
||||||
|
# Media Download Configuration
|
||||||
|
# Путь к файлу cookies в формате Netscape для Instagram и других сайтов
|
||||||
|
# Получить cookies можно через расширение браузера (например, Get cookies.txt LOCALLY)
|
||||||
|
# Или использовать yt-dlp --cookies-from-browser chrome
|
||||||
|
COOKIES_FILE=
|
||||||
|
|
||||||
|
# Download Limits (Ограничения загрузок)
|
||||||
|
# Максимальный размер файла в байтах (например, 1073741824 = 1GB, None = без ограничений)
|
||||||
|
MAX_FILE_SIZE=
|
||||||
|
# Максимальная длительность видео в минутах (например, 60 = 1 час, None = без ограничений)
|
||||||
|
MAX_DURATION_MINUTES=
|
||||||
|
# Максимальное количество одновременных задач на одного пользователя
|
||||||
|
MAX_CONCURRENT_TASKS=5
|
||||||
|
|
||||||
|
# Redis Configuration для Docker
|
||||||
|
# Использовать Redis для сессий (рекомендуется для продакшена)
|
||||||
|
USE_REDIS_SESSIONS=true
|
||||||
|
# В Docker используйте имя сервиса 'redis' как хост (docker-compose автоматически переопределит это)
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
46
config_sample.env.local
Normal file
46
config_sample.env.local
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Пример конфигурации для локальной разработки
|
||||||
|
# Скопируйте этот файл в .env.local и настройте под свои нужды
|
||||||
|
#
|
||||||
|
# ВАЖНО: Используйте .env.local для локальной разработки, а .env для Docker
|
||||||
|
# Это позволяет работать параллельно без конфликтов
|
||||||
|
|
||||||
|
# Telegram Bot Configuration
|
||||||
|
BOT_TOKEN=your_bot_token_here
|
||||||
|
TELEGRAM_API_ID=your_api_id_here
|
||||||
|
TELEGRAM_API_HASH=your_api_hash_here
|
||||||
|
OWNER_ID=your_owner_id_here
|
||||||
|
|
||||||
|
# Authorization Configuration
|
||||||
|
AUTHORIZED_USERS=
|
||||||
|
ADMIN_IDS=
|
||||||
|
BLOCKED_USERS=
|
||||||
|
PRIVATE_MODE=False
|
||||||
|
|
||||||
|
# Database Configuration для локальной разработки
|
||||||
|
# SQLite (по умолчанию для локальной разработки)
|
||||||
|
DATABASE_URL=sqlite+aiosqlite:///./data/bot.db
|
||||||
|
|
||||||
|
# Web Configuration
|
||||||
|
WEB_HOST=0.0.0.0
|
||||||
|
WEB_PORT=5000
|
||||||
|
WEB_SECRET_KEY=your_secret_key_here
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FILE=logs/bot.log
|
||||||
|
|
||||||
|
# Media Download Configuration
|
||||||
|
COOKIES_FILE=
|
||||||
|
|
||||||
|
# Download Limits
|
||||||
|
MAX_FILE_SIZE=
|
||||||
|
MAX_DURATION_MINUTES=
|
||||||
|
MAX_CONCURRENT_TASKS=5
|
||||||
|
|
||||||
|
# Redis Configuration для локальной разработки
|
||||||
|
# Использовать Redis для сессий (опционально, для локальной разработки обычно false)
|
||||||
|
USE_REDIS_SESSIONS=false
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
78
docker-compose.yml
Normal file
78
docker-compose.yml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
services:
|
||||||
|
# PostgreSQL база данных
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: tgloader_postgres
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-tgloader}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-tgloader}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tgloader_password}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tgloader}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- tgloader_network
|
||||||
|
|
||||||
|
# Redis для сессий
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: tgloader_redis
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- tgloader_network
|
||||||
|
|
||||||
|
# Основное приложение
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: tgloader_app
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
# Флаг для определения Docker окружения (чтобы не загружать .env файл)
|
||||||
|
DOCKER_ENV: "1"
|
||||||
|
# Явно указываем DATABASE_URL для Docker (переопределяет любые значения из env_file или хоста)
|
||||||
|
# Используем значение из .env, если оно задано, иначе значение по умолчанию для Docker
|
||||||
|
# Примечание: docker-compose читает переменные из .env через env_file, но мы явно переопределяем здесь
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://tgloader:${POSTGRES_PASSWORD:-tgloader_password}@postgres:5432/tgloader}
|
||||||
|
# REDIS_HOST для Docker должен указывать на имя сервиса
|
||||||
|
REDIS_HOST: ${REDIS_HOST:-redis}
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./downloads:/app/downloads
|
||||||
|
- ./logs:/app/logs
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- tgloader_network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
tgloader_network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
14
instagram.cookies
Normal file
14
instagram.cookies
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Netscape HTTP Cookie File
|
||||||
|
# https://curl.haxx.se/rfc/cookie_spec.html
|
||||||
|
# This is a generated file! Do not edit.
|
||||||
|
|
||||||
|
.instagram.com TRUE / TRUE 1798925873 datr LxYqaW8CPFD_2ZqCP2eOrsqd
|
||||||
|
.instagram.com TRUE / TRUE 1795901873 ig_did E871BDCF-EE14-4EF8-98DF-A4AFDBF8E82D
|
||||||
|
.instagram.com TRUE / TRUE 1798925874 ps_l 1
|
||||||
|
.instagram.com TRUE / TRUE 1798925874 ps_n 1
|
||||||
|
.instagram.com TRUE / TRUE 1765228563 wd 2560x1305
|
||||||
|
.instagram.com TRUE / TRUE 1798925876 mid aSoWMgALAAGheGKC4ZvV3tHcBXIQ
|
||||||
|
.instagram.com TRUE / TRUE 1799183767 csrftoken eAD6GqQh5m3z3s1wfUfAG5MQH7AGH74V
|
||||||
|
.instagram.com TRUE / TRUE 1772399767 ds_user_id 35180473472
|
||||||
|
.instagram.com TRUE / TRUE 1796159691 sessionid 35180473472%3AErcRnRwPLEBczt%3A21%3AAYhgJPIJGRO-xpD675rYrjFK2zklOfiJ9ScqEMs_6w
|
||||||
|
.instagram.com TRUE / TRUE 0 rur "CLN\05435180473472\0541796159766:01fe847ea6ccfb783c528f1dc28510dbd8d9d588913224515fee335e0fadbbb0076d54da"
|
||||||
145
main.py
Normal file
145
main.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
Unified entry point for bot and web interface
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pyrogram import Client
|
||||||
|
from shared.config import settings
|
||||||
|
from shared.database.session import init_db
|
||||||
|
from bot.modules.message_handler.commands import register_commands
|
||||||
|
from bot.modules.message_handler.callbacks import register_callbacks
|
||||||
|
from bot.modules.access_control.middleware import setup_middleware
|
||||||
|
from bot.modules.task_scheduler.executor import task_executor, set_app_client
|
||||||
|
from bot.modules.task_scheduler.queue import task_queue
|
||||||
|
from bot.utils.logger import setup_logger
|
||||||
|
from web.app import app as web_app
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
setup_logger()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_web_server():
|
||||||
|
"""Run web server in the same event loop"""
|
||||||
|
config = uvicorn.Config(
|
||||||
|
app=web_app,
|
||||||
|
host=settings.WEB_HOST,
|
||||||
|
port=settings.WEB_PORT,
|
||||||
|
log_level="info",
|
||||||
|
loop="asyncio"
|
||||||
|
)
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
logger.info(f"Starting web server on {settings.WEB_HOST}:{settings.WEB_PORT}")
|
||||||
|
await server.serve()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_bot():
|
||||||
|
"""Run Telegram bot"""
|
||||||
|
logger.info("Starting Telegram bot...")
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
await init_db()
|
||||||
|
logger.info("Database initialized")
|
||||||
|
|
||||||
|
# Initialize task queue
|
||||||
|
await task_queue.initialize()
|
||||||
|
logger.info("Task queue initialized")
|
||||||
|
|
||||||
|
# Create Pyrogram client
|
||||||
|
app = Client(
|
||||||
|
"tgloader_bot",
|
||||||
|
api_id=settings.TELEGRAM_API_ID,
|
||||||
|
api_hash=settings.TELEGRAM_API_HASH,
|
||||||
|
bot_token=settings.BOT_TOKEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Setup middleware for access control
|
||||||
|
setup_middleware(app)
|
||||||
|
|
||||||
|
# Set client for task executor
|
||||||
|
set_app_client(app)
|
||||||
|
|
||||||
|
# Register handlers
|
||||||
|
register_commands(app)
|
||||||
|
register_callbacks(app)
|
||||||
|
|
||||||
|
logger.info("Bot ready")
|
||||||
|
|
||||||
|
# Start bot
|
||||||
|
await app.start()
|
||||||
|
logger.info(f"Bot started. Owner ID: {settings.OWNER_ID}")
|
||||||
|
|
||||||
|
# Start task executor AFTER bot is started
|
||||||
|
await task_executor.start()
|
||||||
|
logger.info("Task executor started")
|
||||||
|
|
||||||
|
# Start background task for cleaning old files
|
||||||
|
from bot.utils.file_cleanup import cleanup_files_periodically
|
||||||
|
cleanup_task = asyncio.create_task(cleanup_files_periodically())
|
||||||
|
logger.info("File cleanup task started")
|
||||||
|
|
||||||
|
# Start background task for updating user information
|
||||||
|
from bot.utils.user_info_updater import update_users_without_info_periodically
|
||||||
|
user_info_task = asyncio.create_task(update_users_without_info_periodically())
|
||||||
|
logger.info("User info update task started")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Main startup function"""
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info("Starting TGLoader (Bot + Web Interface)")
|
||||||
|
logger.info("=" * 50)
|
||||||
|
|
||||||
|
# Start bot
|
||||||
|
bot_app = await run_bot()
|
||||||
|
|
||||||
|
# Start web server in the same event loop
|
||||||
|
web_task = asyncio.create_task(run_web_server())
|
||||||
|
logger.info("Web server started in the same event loop")
|
||||||
|
|
||||||
|
# Wait for completion (running in parallel)
|
||||||
|
try:
|
||||||
|
# Wait for web server to complete (runs indefinitely)
|
||||||
|
await web_task
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Received stop signal (Ctrl+C)")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Received cancellation signal")
|
||||||
|
finally:
|
||||||
|
logger.info("Starting graceful shutdown...")
|
||||||
|
|
||||||
|
# Stop web server
|
||||||
|
web_task.cancel()
|
||||||
|
try:
|
||||||
|
await web_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Stop task executor
|
||||||
|
try:
|
||||||
|
await task_executor.stop()
|
||||||
|
logger.info("Task executor stopped")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping executor: {e}")
|
||||||
|
|
||||||
|
# Stop bot
|
||||||
|
try:
|
||||||
|
await bot_app.stop()
|
||||||
|
logger.info("Bot stopped")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping bot: {e}")
|
||||||
|
|
||||||
|
logger.info("Application terminated")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Получен сигнал остановки (KeyboardInterrupt)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Критическая ошибка: {e}", exc_info=True)
|
||||||
|
|
||||||
42
requirements.txt
Normal file
42
requirements.txt
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Telegram Bot
|
||||||
|
pyrogram>=2.0.0
|
||||||
|
tgcrypto>=1.2.5
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
|
||||||
|
# Async HTTP
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
aiofiles>=23.2.0
|
||||||
|
|
||||||
|
# Media Download
|
||||||
|
yt-dlp>=2023.12.0
|
||||||
|
|
||||||
|
# Image Processing
|
||||||
|
Pillow>=10.0.0
|
||||||
|
# Note: ffmpeg must be installed separately for video thumbnail generation
|
||||||
|
# Install: sudo apt-get install ffmpeg (Linux) or brew install ffmpeg (Mac) or download from https://ffmpeg.org (Windows)
|
||||||
|
|
||||||
|
# Archives
|
||||||
|
py7zr>=0.21.0
|
||||||
|
rarfile>=4.1
|
||||||
|
|
||||||
|
# Database ORM
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
alembic>=1.12.0
|
||||||
|
aiosqlite>=0.19.0
|
||||||
|
asyncpg>=0.29.0
|
||||||
|
|
||||||
|
# Web Framework
|
||||||
|
fastapi>=0.104.0
|
||||||
|
uvicorn[standard]>=0.24.0
|
||||||
|
jinja2>=3.1.2
|
||||||
|
python-jose[cryptography]>=3.3.0
|
||||||
|
fastapi-csrf-protect>=0.1.1
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
pydantic>=2.5.0
|
||||||
|
pydantic-settings>=2.1.0
|
||||||
|
redis>=5.0.0
|
||||||
|
|
||||||
4
shared/__init__.py
Normal file
4
shared/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Shared modules for bot and web application
|
||||||
|
"""
|
||||||
|
|
||||||
131
shared/config.py
Normal file
131
shared/config.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
Shared configuration for bot and web application
|
||||||
|
"""
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from typing import Optional
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings"""
|
||||||
|
|
||||||
|
# Telegram Bot
|
||||||
|
BOT_TOKEN: str
|
||||||
|
TELEGRAM_API_ID: int
|
||||||
|
TELEGRAM_API_HASH: str
|
||||||
|
OWNER_ID: int
|
||||||
|
|
||||||
|
# Authorization
|
||||||
|
AUTHORIZED_USERS: str = ""
|
||||||
|
ADMIN_IDS: str = ""
|
||||||
|
BLOCKED_USERS: str = ""
|
||||||
|
PRIVATE_MODE: bool = False # If True, only users from AUTHORIZED_USERS or database can use the bot
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: str = "sqlite+aiosqlite:///./data/bot.db"
|
||||||
|
|
||||||
|
# Redis (for sessions)
|
||||||
|
REDIS_HOST: str = "localhost"
|
||||||
|
REDIS_PORT: int = 6379
|
||||||
|
REDIS_DB: int = 0
|
||||||
|
USE_REDIS_SESSIONS: bool = False # Use Redis for sessions instead of in-memory
|
||||||
|
|
||||||
|
# Web
|
||||||
|
WEB_HOST: str = "0.0.0.0"
|
||||||
|
WEB_PORT: int = 5000
|
||||||
|
WEB_SECRET_KEY: str = ""
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL: str = "INFO"
|
||||||
|
LOG_FILE: str = "logs/bot.log"
|
||||||
|
|
||||||
|
# Media Download
|
||||||
|
COOKIES_FILE: Optional[str] = None # Path to cookies file (Netscape format) for Instagram and other sites
|
||||||
|
|
||||||
|
# Download Limits
|
||||||
|
MAX_FILE_SIZE: Optional[str] = None # Maximum file size in bytes (empty or None = no limit)
|
||||||
|
MAX_DURATION_MINUTES: Optional[str] = None # Maximum duration in minutes (empty or None = no limit)
|
||||||
|
MAX_CONCURRENT_TASKS: int = 5 # Maximum number of concurrent tasks per user
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_file_size_bytes(self) -> Optional[int]:
|
||||||
|
"""Get maximum file size in bytes"""
|
||||||
|
if not self.MAX_FILE_SIZE or self.MAX_FILE_SIZE.strip() == '' or self.MAX_FILE_SIZE.lower() == 'none':
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(self.MAX_FILE_SIZE)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_duration_minutes_int(self) -> Optional[int]:
|
||||||
|
"""Get maximum duration in minutes"""
|
||||||
|
if not self.MAX_DURATION_MINUTES or self.MAX_DURATION_MINUTES.strip() == '' or self.MAX_DURATION_MINUTES.lower() == 'none':
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(self.MAX_DURATION_MINUTES)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
# Configuration file structure:
|
||||||
|
# - .env - for Docker (used via docker-compose env_file)
|
||||||
|
# - .env.local - for local development (used here if not in Docker)
|
||||||
|
# In Docker, environment variables are passed through environment in docker-compose
|
||||||
|
# env_file is only used for local development
|
||||||
|
# In Docker, env_file should not be loaded as variables are passed through environment
|
||||||
|
# Load .env.local only if not in Docker (determined by DOCKER_ENV environment variable)
|
||||||
|
# In Docker, environment variables have priority over env_file
|
||||||
|
# If DOCKER_ENV is set, do not load .env.local file
|
||||||
|
env_file = None if os.getenv("DOCKER_ENV") else ".env.local"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
case_sensitive = True
|
||||||
|
# Pydantic Settings automatically reads environment variables from system
|
||||||
|
# Priority: environment variables > env_file > default values
|
||||||
|
|
||||||
|
@property
|
||||||
|
def authorized_users_list(self) -> list[int]:
|
||||||
|
"""List of authorized users"""
|
||||||
|
if not self.AUTHORIZED_USERS:
|
||||||
|
return []
|
||||||
|
return [int(uid.strip()) for uid in self.AUTHORIZED_USERS.split(",") if uid.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def admin_ids_list(self) -> list[int]:
|
||||||
|
"""List of administrators"""
|
||||||
|
if not self.ADMIN_IDS:
|
||||||
|
return []
|
||||||
|
return [int(uid.strip()) for uid in self.ADMIN_IDS.split(",") if uid.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def blocked_users_list(self) -> list[int]:
|
||||||
|
"""List of blocked users"""
|
||||||
|
if not self.BLOCKED_USERS:
|
||||||
|
return []
|
||||||
|
return [int(uid.strip()) for uid in self.BLOCKED_USERS.split(",") if uid.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
# Global settings instance
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
# Log the DATABASE_URL being used on load (for debugging)
|
||||||
|
import logging
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
# Log only if DATABASE_URL is not default SQLite
|
||||||
|
if settings.DATABASE_URL and "sqlite" not in settings.DATABASE_URL.lower():
|
||||||
|
db_url_safe = settings.DATABASE_URL
|
||||||
|
if '@' in db_url_safe:
|
||||||
|
# Hide password in logs
|
||||||
|
parts = db_url_safe.split('@')
|
||||||
|
if len(parts) == 2:
|
||||||
|
auth_part = parts[0].split('://')
|
||||||
|
if len(auth_part) == 2:
|
||||||
|
scheme = auth_part[0]
|
||||||
|
user_pass = auth_part[1]
|
||||||
|
if ':' in user_pass:
|
||||||
|
user = user_pass.split(':')[0]
|
||||||
|
db_url_safe = f"{scheme}://{user}:***@{parts[1]}"
|
||||||
|
_logger.info(f"Using DATABASE_URL: {db_url_safe}")
|
||||||
|
else:
|
||||||
|
_logger.warning(f"⚠️ Using SQLite database: {settings.DATABASE_URL}. For Docker, use PostgreSQL!")
|
||||||
|
|
||||||
4
shared/database/__init__.py
Normal file
4
shared/database/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Общие модели базы данных
|
||||||
|
"""
|
||||||
|
|
||||||
98
shared/database/migrations.py
Normal file
98
shared/database/migrations.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
Module for automatic Alembic migration application
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from shared.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_alembic_config() -> Config:
|
||||||
|
"""
|
||||||
|
Get Alembic configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Config: Alembic configuration object
|
||||||
|
"""
|
||||||
|
# Path to alembic.ini
|
||||||
|
alembic_ini_path = Path(__file__).parent.parent.parent / "alembic.ini"
|
||||||
|
|
||||||
|
# Create configuration
|
||||||
|
alembic_cfg = Config(str(alembic_ini_path))
|
||||||
|
|
||||||
|
# Set DATABASE_URL from settings
|
||||||
|
alembic_cfg.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||||
|
|
||||||
|
return alembic_cfg
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade_database(revision: str = "head") -> None:
|
||||||
|
"""
|
||||||
|
Apply migrations to database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
revision: Revision to upgrade database to (default "head" - latest)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Applying migrations to database (revision: {revision})...")
|
||||||
|
|
||||||
|
# Get configuration
|
||||||
|
alembic_cfg = get_alembic_config()
|
||||||
|
|
||||||
|
# Apply migrations in separate thread (since command.upgrade is synchronous)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
command.upgrade,
|
||||||
|
alembic_cfg,
|
||||||
|
revision
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Migrations successfully applied")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error applying migrations: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def check_migrations() -> bool:
|
||||||
|
"""
|
||||||
|
Check for unapplied migrations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if there are unapplied migrations, False if all are applied
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
alembic_cfg = get_alembic_config()
|
||||||
|
|
||||||
|
# Check for migrations to apply
|
||||||
|
# This is a simplified check - in reality can use command.heads()
|
||||||
|
# and command.current() to compare revisions
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to check migration status: {e}")
|
||||||
|
# On error assume migrations are needed
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db_with_migrations() -> None:
|
||||||
|
"""
|
||||||
|
Initialize database with migrations applied.
|
||||||
|
Replaces old init_db() method for Alembic usage.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Determine database type from URL
|
||||||
|
db_type = "SQLite" if "sqlite" in settings.DATABASE_URL.lower() else "PostgreSQL"
|
||||||
|
logger.info(f"Initializing {db_type} database with migrations...")
|
||||||
|
|
||||||
|
# Apply migrations (this will create tables if they don't exist)
|
||||||
|
await upgrade_database("head")
|
||||||
|
|
||||||
|
logger.info(f"{db_type} database successfully initialized")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error initializing database: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
91
shared/database/models.py
Normal file
91
shared/database/models.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""
|
||||||
|
ORM models for database
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, BigInteger, String, Boolean, DateTime, Text, ForeignKey
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""User model"""
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
user_id = Column(Integer, primary_key=True, unique=True, index=True)
|
||||||
|
username = Column(String(255), nullable=True)
|
||||||
|
first_name = Column(String(255), nullable=True)
|
||||||
|
last_name = Column(String(255), nullable=True)
|
||||||
|
is_admin = Column(Boolean, default=False)
|
||||||
|
is_blocked = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
tasks = relationship("Task", back_populates="user")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<User(user_id={self.user_id}, username={self.username}, is_admin={self.is_admin})>"
|
||||||
|
|
||||||
|
|
||||||
|
class Task(Base):
|
||||||
|
"""Task model"""
|
||||||
|
__tablename__ = "tasks"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.user_id"), nullable=False, index=True) # Index for frequent queries
|
||||||
|
task_type = Column(String(50), nullable=False) # download, process, etc.
|
||||||
|
status = Column(String(50), default="pending", index=True) # Index for status filtering
|
||||||
|
url = Column(Text, nullable=True)
|
||||||
|
file_path = Column(String(500), nullable=True)
|
||||||
|
progress = Column(Integer, default=0) # 0-100
|
||||||
|
error_message = Column(String(1000), nullable=True) # Limit to 1000 characters
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, index=True) # Index for sorting
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
completed_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User", back_populates="tasks")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Task(id={self.id}, user_id={self.user_id}, status={self.status})>"
|
||||||
|
|
||||||
|
|
||||||
|
class Download(Base):
|
||||||
|
"""Download model"""
|
||||||
|
__tablename__ = "downloads"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
task_id = Column(BigInteger, ForeignKey("tasks.id"), nullable=False)
|
||||||
|
url = Column(Text, nullable=False)
|
||||||
|
download_type = Column(String(50), nullable=False) # direct, ytdlp
|
||||||
|
file_path = Column(String(500), nullable=True)
|
||||||
|
file_size = Column(Integer, nullable=True)
|
||||||
|
duration = Column(Integer, nullable=True) # Download duration in seconds
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
task = relationship("Task")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Download(id={self.id}, task_id={self.task_id}, download_type={self.download_type})>"
|
||||||
|
|
||||||
|
|
||||||
|
class OTPCode(Base):
|
||||||
|
"""One-time password code model for web interface authentication"""
|
||||||
|
__tablename__ = "otp_codes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||||
|
code = Column(String(6), nullable=False, index=True) # 6-digit code
|
||||||
|
expires_at = Column(DateTime, nullable=False, index=True)
|
||||||
|
used = Column(Boolean, default=False, index=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<OTPCode(id={self.id}, user_id={self.user_id}, code={self.code[:2]}**, used={self.used})>"
|
||||||
|
|
||||||
125
shared/database/session.py
Normal file
125
shared/database/session.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
Unified module for database session management
|
||||||
|
Used by both bot and web interface
|
||||||
|
"""
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from shared.config import settings
|
||||||
|
from shared.database.models import Base
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Unified database engine for the entire application
|
||||||
|
_engine = None
|
||||||
|
_AsyncSessionLocal = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
"""Get database engine (created on first call)"""
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
# Log DATABASE_URL being used (without password for security)
|
||||||
|
db_url_safe = settings.DATABASE_URL
|
||||||
|
if '@' in db_url_safe:
|
||||||
|
# Hide password in logs
|
||||||
|
parts = db_url_safe.split('@')
|
||||||
|
if len(parts) == 2:
|
||||||
|
auth_part = parts[0].split('://')
|
||||||
|
if len(auth_part) == 2:
|
||||||
|
scheme = auth_part[0]
|
||||||
|
user_pass = auth_part[1]
|
||||||
|
if ':' in user_pass:
|
||||||
|
user = user_pass.split(':')[0]
|
||||||
|
db_url_safe = f"{scheme}://{user}:***@{parts[1]}"
|
||||||
|
logger.info(f"Creating database engine with URL: {db_url_safe}")
|
||||||
|
|
||||||
|
_engine = create_async_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
echo=False,
|
||||||
|
future=True,
|
||||||
|
pool_pre_ping=True, # Check connections before use
|
||||||
|
pool_recycle=3600, # Reuse connections every 3600 seconds
|
||||||
|
)
|
||||||
|
logger.info("Database engine created")
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_factory():
|
||||||
|
"""Get session factory (created on first call)"""
|
||||||
|
global _AsyncSessionLocal
|
||||||
|
if _AsyncSessionLocal is None:
|
||||||
|
engine = get_engine()
|
||||||
|
_AsyncSessionLocal = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False
|
||||||
|
)
|
||||||
|
logger.info("Session factory created")
|
||||||
|
return _AsyncSessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
"""
|
||||||
|
Initialize database (create tables via Alembic migrations).
|
||||||
|
|
||||||
|
Uses Alembic to apply migrations instead of directly creating tables.
|
||||||
|
This ensures database schema versioning and the ability to rollback changes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from shared.database.migrations import init_db_with_migrations
|
||||||
|
await init_db_with_migrations()
|
||||||
|
except ImportError:
|
||||||
|
# Fallback to old method if migrations not configured
|
||||||
|
logger.warning("Alembic not configured, using direct table creation")
|
||||||
|
engine = get_engine()
|
||||||
|
# Determine database type from URL
|
||||||
|
db_type = "SQLite" if "sqlite" in settings.DATABASE_URL.lower() else "PostgreSQL"
|
||||||
|
logger.info(f"Initializing {db_type} database...")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
logger.info(f"{db_type} database tables successfully created")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error initializing database: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
"""
|
||||||
|
Get database session (generator for use with Depends in FastAPI)
|
||||||
|
Used by both bot and web interface
|
||||||
|
"""
|
||||||
|
AsyncSessionLocal = get_session_factory()
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_async_session_local():
|
||||||
|
"""
|
||||||
|
Get session factory for direct use (e.g., in bot)
|
||||||
|
"""
|
||||||
|
return get_session_factory()
|
||||||
|
|
||||||
|
|
||||||
|
# For backward compatibility - export session factory as AsyncSessionLocal
|
||||||
|
# Use proxy class that behaves like async_sessionmaker
|
||||||
|
class AsyncSessionLocalProxy:
|
||||||
|
"""
|
||||||
|
Proxy for AsyncSessionLocal that initializes session factory on first use.
|
||||||
|
Allows using AsyncSessionLocal() like async_sessionmaker().
|
||||||
|
"""
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
"""Calling AsyncSessionLocal() creates a new session"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
return factory(*args, **kwargs)
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
"""Proxy attributes to session factory"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
return getattr(factory, name)
|
||||||
|
|
||||||
|
# Create proxy instance for backward compatibility
|
||||||
|
AsyncSessionLocal = AsyncSessionLocalProxy()
|
||||||
|
|
||||||
59
shared/database/user_helpers.py
Normal file
59
shared/database/user_helpers.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Helper functions for user management
|
||||||
|
"""
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from shared.database.models import User
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_user_exists(user_id: int, db: AsyncSession) -> User:
|
||||||
|
"""
|
||||||
|
Ensure user exists in database, create if not exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User object (existing or newly created)
|
||||||
|
"""
|
||||||
|
# Check if user exists
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
|
||||||
|
# User doesn't exist, create it
|
||||||
|
user = User(
|
||||||
|
user_id=user_id,
|
||||||
|
username=None,
|
||||||
|
first_name=None,
|
||||||
|
last_name=None,
|
||||||
|
is_admin=False,
|
||||||
|
is_blocked=False,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Automatically created user {user_id} in database")
|
||||||
|
return user
|
||||||
|
except IntegrityError:
|
||||||
|
# User was created by another request/thread, fetch it
|
||||||
|
await db.rollback()
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user:
|
||||||
|
logger.debug(f"User {user_id} was created concurrently, using existing record")
|
||||||
|
return user
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to create or fetch user {user_id}")
|
||||||
|
raise Exception(f"Failed to ensure user {user_id} exists")
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"Error ensuring user {user_id} exists: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
BIN
tgloader_bot.session
Normal file
BIN
tgloader_bot.session
Normal file
Binary file not shown.
4
web/__init__.py
Normal file
4
web/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Web application for administrative management
|
||||||
|
"""
|
||||||
|
|
||||||
4
web/admin/__init__.py
Normal file
4
web/admin/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Web site admin panel
|
||||||
|
"""
|
||||||
|
|
||||||
859
web/admin/routes.py
Normal file
859
web/admin/routes.py
Normal file
@@ -0,0 +1,859 @@
|
|||||||
|
"""
|
||||||
|
Admin panel routes
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Request, Depends, HTTPException, Form, status
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from typing import Optional
|
||||||
|
from web.utils.auth import get_current_user, verify_web_user, create_session, delete_session, is_owner_web
|
||||||
|
from web.utils.database import get_db
|
||||||
|
from web.utils.otp import verify_otp_code, get_user_by_identifier, create_otp_code
|
||||||
|
from web.utils.csrf import verify_csrf, get_csrf_token
|
||||||
|
from web.admin import views
|
||||||
|
from shared.database.models import User, Task
|
||||||
|
from web.admin.user_manager_web import add_user_web, remove_user_web, block_user_web, unblock_user_web, add_admin_web, remove_admin_web
|
||||||
|
from bot.modules.task_scheduler.queue import task_queue, Task as QueueTask, TaskStatus
|
||||||
|
from bot.modules.media_loader.ytdlp import get_media_info
|
||||||
|
from bot.modules.access_control.auth import is_authorized
|
||||||
|
from web.utils.bot_client import send_otp_to_user
|
||||||
|
from shared.config import settings
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="web/admin/templates")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_csrf_token_for_template(request: Request) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get CSRF token for template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI Request object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CSRF token or None
|
||||||
|
"""
|
||||||
|
from web.utils.csrf import get_csrf_token, generate_csrf_token
|
||||||
|
from web.utils.auth import get_session
|
||||||
|
|
||||||
|
token = await get_csrf_token(request)
|
||||||
|
if not token:
|
||||||
|
# Generate new token
|
||||||
|
token = generate_csrf_token()
|
||||||
|
session_id = request.cookies.get("session_id")
|
||||||
|
if session_id:
|
||||||
|
session_data = await get_session(session_id)
|
||||||
|
if session_data:
|
||||||
|
session_data["csrf_token"] = token
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Authentication ====================
|
||||||
|
|
||||||
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_page(request: Request):
|
||||||
|
"""Login page"""
|
||||||
|
csrf_token = await get_csrf_token_for_template(request)
|
||||||
|
return templates.TemplateResponse("login.html", {
|
||||||
|
"request": request,
|
||||||
|
"csrf_token": csrf_token
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/otp/request")
|
||||||
|
async def request_otp(
|
||||||
|
request: Request,
|
||||||
|
identifier: str = Form(...),
|
||||||
|
csrf_token: Optional[str] = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""API: Request OTP code by ID or username"""
|
||||||
|
# CSRF token check (softer for unauthorized users)
|
||||||
|
try:
|
||||||
|
await verify_csrf(request, csrf_token)
|
||||||
|
except HTTPException:
|
||||||
|
# For OTP request allow without CSRF (user not yet authorized)
|
||||||
|
# But check Origin/Referer for basic protection
|
||||||
|
origin = request.headers.get("Origin")
|
||||||
|
referer = request.headers.get("Referer")
|
||||||
|
if not origin and not referer:
|
||||||
|
logger.warning(f"OTP request without Origin/Referer from IP {request.client.host if request.client else 'unknown'}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = None
|
||||||
|
|
||||||
|
# Try to find user by identifier
|
||||||
|
user = await get_user_by_identifier(identifier, db)
|
||||||
|
if user:
|
||||||
|
user_id = user.user_id
|
||||||
|
else:
|
||||||
|
# If user not found in database, try to determine user_id from identifier
|
||||||
|
try:
|
||||||
|
user_id = int(identifier)
|
||||||
|
except ValueError:
|
||||||
|
# This is username, try to find via Telegram API
|
||||||
|
try:
|
||||||
|
from bot.modules.task_scheduler.executor import get_app_client
|
||||||
|
app_client = get_app_client()
|
||||||
|
if app_client:
|
||||||
|
username = identifier.lstrip('@')
|
||||||
|
chat = await app_client.get_chat(username)
|
||||||
|
if chat and hasattr(chat, 'id'):
|
||||||
|
user_id = chat.id
|
||||||
|
logger.info(f"Найден пользователь {user_id} по username {username} через Telegram API")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Не удалось найти пользователя по username {identifier}: {e}")
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
# User not found in database or via Telegram API
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "Пользователь не найден. Убедитесь, что вы используете правильный User ID или username. Для первого входа используйте User ID."},
|
||||||
|
status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check authorization (this can work even if user doesn't exist in database)
|
||||||
|
if not await is_authorized(user_id):
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "У вас нет доступа к боту. Обратитесь к администратору."},
|
||||||
|
status_code=403
|
||||||
|
)
|
||||||
|
|
||||||
|
# If user is authorized but doesn't exist in DB - create them
|
||||||
|
if not user:
|
||||||
|
from shared.database.models import User
|
||||||
|
from datetime import datetime
|
||||||
|
from bot.utils.user_info_updater import update_user_info_from_telegram
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
# Check if user already exists (race condition protection)
|
||||||
|
existing_user = await db.get(User, user_id)
|
||||||
|
if existing_user:
|
||||||
|
user = existing_user
|
||||||
|
else:
|
||||||
|
# Create user with basic information
|
||||||
|
user = User(
|
||||||
|
user_id=user_id,
|
||||||
|
username=None,
|
||||||
|
first_name=None,
|
||||||
|
last_name=None,
|
||||||
|
is_admin=False,
|
||||||
|
is_blocked=False,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Automatically created user {user_id} on OTP request")
|
||||||
|
except IntegrityError:
|
||||||
|
# User was created by another request, fetch it
|
||||||
|
await db.rollback()
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
logger.error(f"Failed to create or fetch user {user_id}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "Ошибка при создании пользователя"},
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
logger.info(f"User {user_id} already exists, using existing record")
|
||||||
|
|
||||||
|
# Try to get information from Telegram API (async, doesn't block response)
|
||||||
|
try:
|
||||||
|
await update_user_info_from_telegram(user_id, db_session=db)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get user {user_id} information from Telegram: {e}")
|
||||||
|
# Continue working, information will be updated by background task later
|
||||||
|
|
||||||
|
# Generate OTP code
|
||||||
|
code = await create_otp_code(user_id, db)
|
||||||
|
if not code:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "Не удалось создать код. Попробуйте позже."},
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send code to user in Telegram
|
||||||
|
sent = await send_otp_to_user(user_id, code)
|
||||||
|
if not sent:
|
||||||
|
# If failed to send, return warning
|
||||||
|
logger.warning(f"Failed to send OTP to user {user_id}, but code created")
|
||||||
|
return JSONResponse({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Код создан: **{code}**. Не удалось отправить в Telegram (бот может быть не запущен). Используйте этот код для входа.",
|
||||||
|
"code": code # Return code in response for debugging
|
||||||
|
})
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"success": True,
|
||||||
|
"message": "Код отправлен вам в Telegram. Проверьте сообщения от бота."
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error requesting OTP: {e}", exc_info=True)
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": f"Ошибка при создании кода: {str(e)}"},
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(
|
||||||
|
request: Request,
|
||||||
|
otp_code: str = Form(None),
|
||||||
|
user_id: int = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Handle login via OTP or direct user_id (for admins)"""
|
||||||
|
# Get IP address for rate limiting
|
||||||
|
client_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
|
# If OTP code provided
|
||||||
|
if otp_code:
|
||||||
|
user_id_from_otp = await verify_otp_code(otp_code, db, ip_address=client_ip)
|
||||||
|
if not user_id_from_otp:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"login.html",
|
||||||
|
{"request": request, "error": "Неверный или истекший код"},
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED
|
||||||
|
)
|
||||||
|
user_id = user_id_from_otp
|
||||||
|
|
||||||
|
# If user_id provided directly (alternative login method)
|
||||||
|
if user_id:
|
||||||
|
# Check user authorization
|
||||||
|
if not await is_authorized(user_id):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"login.html",
|
||||||
|
{"request": request, "error": "У вас нет доступа к боту. Обратитесь к администратору."},
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# If user is authorized but doesn't exist in DB - create them
|
||||||
|
existing_user = await db.get(User, user_id)
|
||||||
|
if not existing_user:
|
||||||
|
from datetime import datetime
|
||||||
|
from bot.utils.user_info_updater import update_user_info_from_telegram
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
# Create user with basic information
|
||||||
|
new_user = User(
|
||||||
|
user_id=user_id,
|
||||||
|
username=None,
|
||||||
|
first_name=None,
|
||||||
|
last_name=None,
|
||||||
|
is_admin=False,
|
||||||
|
is_blocked=False,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
db.add(new_user)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Automatically created user {user_id} on login")
|
||||||
|
except IntegrityError:
|
||||||
|
# User was created by another request, fetch it
|
||||||
|
await db.rollback()
|
||||||
|
existing_user = await db.get(User, user_id)
|
||||||
|
if not existing_user:
|
||||||
|
logger.error(f"Failed to create or fetch user {user_id}")
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"login.html",
|
||||||
|
{"request": request, "error": "Ошибка при создании пользователя"},
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
logger.info(f"User {user_id} already exists, using existing record")
|
||||||
|
|
||||||
|
# Try to get information from Telegram API (async, doesn't block response)
|
||||||
|
try:
|
||||||
|
await update_user_info_from_telegram(user_id, db_session=db)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get user {user_id} information from Telegram: {e}")
|
||||||
|
# Continue working, information will be updated by background task later
|
||||||
|
else:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"login.html",
|
||||||
|
{"request": request, "error": "Необходимо указать код или User ID"},
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
session_id = await create_session(user_id)
|
||||||
|
|
||||||
|
# Set cookie
|
||||||
|
response = RedirectResponse(url="/admin/", status_code=status.HTTP_302_FOUND)
|
||||||
|
response.set_cookie(
|
||||||
|
key="session_id",
|
||||||
|
value=session_id,
|
||||||
|
httponly=True,
|
||||||
|
secure=False, # Set True for HTTPS in production
|
||||||
|
samesite="lax",
|
||||||
|
max_age=86400 * 7 # 7 days
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
async def logout(request: Request):
|
||||||
|
"""Logout"""
|
||||||
|
session_id = request.cookies.get("session_id")
|
||||||
|
if session_id:
|
||||||
|
await delete_session(session_id)
|
||||||
|
|
||||||
|
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_302_FOUND)
|
||||||
|
response.delete_cookie("session_id")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Protected routes ====================
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def admin_dashboard(
|
||||||
|
request: Request,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Main admin page (dashboard)"""
|
||||||
|
try:
|
||||||
|
# Get statistics
|
||||||
|
users_stats = await views.get_users_stats(db)
|
||||||
|
tasks_stats = await views.get_tasks_stats(db)
|
||||||
|
|
||||||
|
# Get recent tasks
|
||||||
|
recent_tasks = await views.get_all_tasks(db, limit=10)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"dashboard.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"current_user": current_user,
|
||||||
|
"users_stats": users_stats,
|
||||||
|
"tasks_stats": tasks_stats,
|
||||||
|
"recent_tasks": recent_tasks,
|
||||||
|
"is_owner": current_user.get("is_owner", False),
|
||||||
|
"csrf_token": await get_csrf_token_for_template(request)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при загрузке дашборда: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Ошибка при загрузке данных")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_class=HTMLResponse)
|
||||||
|
async def users_page(
|
||||||
|
request: Request,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
search: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""User management page"""
|
||||||
|
try:
|
||||||
|
users = await views.get_all_users(db, search=search)
|
||||||
|
is_owner = current_user.get("is_owner", False)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"users.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"current_user": current_user,
|
||||||
|
"users": users,
|
||||||
|
"is_owner": is_owner,
|
||||||
|
"search": search or "",
|
||||||
|
"csrf_token": await get_csrf_token_for_template(request)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при загрузке пользователей: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Ошибка при загрузке данных")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks", response_class=HTMLResponse)
|
||||||
|
async def tasks_page(
|
||||||
|
request: Request,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
status_filter: Optional[str] = None,
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
):
|
||||||
|
"""Task management page"""
|
||||||
|
try:
|
||||||
|
tasks = await views.get_all_tasks(
|
||||||
|
db,
|
||||||
|
status_filter=status_filter,
|
||||||
|
user_id_filter=user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"tasks.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"current_user": current_user,
|
||||||
|
"tasks": tasks,
|
||||||
|
"status_filter": status_filter or "",
|
||||||
|
"user_id_filter": user_id,
|
||||||
|
"csrf_token": await get_csrf_token_for_template(request)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при загрузке задач: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Ошибка при загрузке данных")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/create-task", response_class=HTMLResponse)
|
||||||
|
async def create_task_page(
|
||||||
|
request: Request,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create task page"""
|
||||||
|
try:
|
||||||
|
# Get user tasks to display limits
|
||||||
|
user_tasks = await views.get_all_tasks(db, user_id_filter=current_user["user_id"])
|
||||||
|
active_tasks = [t for t in user_tasks if t.status in ["pending", "processing"]]
|
||||||
|
|
||||||
|
# Get CSRF token for form
|
||||||
|
csrf_token = await get_csrf_token_for_template(request)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"create_task.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"current_user": current_user,
|
||||||
|
"active_tasks_count": len(active_tasks),
|
||||||
|
"max_concurrent_tasks": settings.MAX_CONCURRENT_TASKS,
|
||||||
|
"max_file_size": settings.max_file_size_bytes,
|
||||||
|
"max_duration": settings.max_duration_minutes_int,
|
||||||
|
"csrf_token": csrf_token
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при загрузке страницы создания задачи: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Ошибка при загрузке данных")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== API Endpoints ====================
|
||||||
|
|
||||||
|
@router.post("/api/users/add")
|
||||||
|
async def api_add_user(
|
||||||
|
request: Request,
|
||||||
|
user_id: int = Form(...),
|
||||||
|
csrf_token: Optional[str] = Form(None),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""API: Add user"""
|
||||||
|
# CSRF token check
|
||||||
|
await verify_csrf(request, csrf_token)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await add_user_web(user_id, db)
|
||||||
|
if result["success"]:
|
||||||
|
return JSONResponse({"success": True, "message": result["message"]})
|
||||||
|
else:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": result["message"]},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при добавлении пользователя: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "Ошибка при добавлении пользователя"},
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/users/remove")
|
||||||
|
async def api_remove_user(
|
||||||
|
request: Request,
|
||||||
|
user_id: int = Form(...),
|
||||||
|
csrf_token: Optional[str] = Form(None),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""API: Remove user"""
|
||||||
|
# CSRF token check
|
||||||
|
await verify_csrf(request, csrf_token)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await remove_user_web(user_id, db)
|
||||||
|
if result["success"]:
|
||||||
|
return JSONResponse({"success": True, "message": result["message"]})
|
||||||
|
else:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": result["message"]},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при удалении пользователя: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "Ошибка при удалении пользователя"},
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/users/block")
|
||||||
|
async def api_block_user(
|
||||||
|
request: Request,
|
||||||
|
user_id: int = Form(...),
|
||||||
|
csrf_token: Optional[str] = Form(None),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""API: Block user"""
|
||||||
|
# CSRF token check
|
||||||
|
await verify_csrf(request, csrf_token)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await block_user_web(user_id, db)
|
||||||
|
if result["success"]:
|
||||||
|
return JSONResponse({"success": True, "message": result["message"]})
|
||||||
|
else:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": result["message"]},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при блокировке пользователя: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "Ошибка при блокировке пользователя"},
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/users/unblock")
|
||||||
|
async def api_unblock_user(
|
||||||
|
request: Request,
|
||||||
|
user_id: int = Form(...),
|
||||||
|
csrf_token: Optional[str] = Form(None),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""API: Unblock user"""
|
||||||
|
# CSRF token check
|
||||||
|
await verify_csrf(request, csrf_token)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await unblock_user_web(user_id, db)
|
||||||
|
if result["success"]:
|
||||||
|
return JSONResponse({"success": True, "message": result["message"]})
|
||||||
|
else:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": result["message"]},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при разблокировке пользователя: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "Ошибка при разблокировке пользователя"},
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/users/add-admin")
|
||||||
|
async def api_add_admin(
|
||||||
|
request: Request,
|
||||||
|
user_id: int = Form(...),
|
||||||
|
csrf_token: Optional[str] = Form(None),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""API: Add administrator (Owner only)"""
|
||||||
|
# CSRF token check
|
||||||
|
await verify_csrf(request, csrf_token)
|
||||||
|
|
||||||
|
if not current_user.get("is_owner", False):
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "Только Owner может добавлять администраторов"},
|
||||||
|
status_code=403
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await add_admin_web(user_id, db)
|
||||||
|
if result["success"]:
|
||||||
|
return JSONResponse({"success": True, "message": result["message"]})
|
||||||
|
else:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": result["message"]},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при добавлении администратора: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "Ошибка при добавлении администратора"},
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/users/remove-admin")
|
||||||
|
async def api_remove_admin(
|
||||||
|
request: Request,
|
||||||
|
user_id: int = Form(...),
|
||||||
|
csrf_token: Optional[str] = Form(None),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""API: Remove administrator (Owner only)"""
|
||||||
|
# CSRF token check
|
||||||
|
await verify_csrf(request, csrf_token)
|
||||||
|
|
||||||
|
if not current_user.get("is_owner", False):
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "Только Owner может удалять администраторов"},
|
||||||
|
status_code=403
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await remove_admin_web(user_id, db)
|
||||||
|
if result["success"]:
|
||||||
|
return JSONResponse({"success": True, "message": result["message"]})
|
||||||
|
else:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": result["message"]},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при удалении администратора: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "Ошибка при удалении администратора"},
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/tasks/create")
|
||||||
|
async def api_create_task(
|
||||||
|
request: Request,
|
||||||
|
url: str = Form(...),
|
||||||
|
csrf_token: Optional[str] = Form(None),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""API: Create download task"""
|
||||||
|
# CSRF token check
|
||||||
|
try:
|
||||||
|
await verify_csrf(request, csrf_token)
|
||||||
|
except HTTPException as e:
|
||||||
|
logger.warning(f"CSRF check failed for task creation: {e.detail}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "Невалидный CSRF токен"},
|
||||||
|
status_code=403
|
||||||
|
)
|
||||||
|
|
||||||
|
from bot.modules.task_scheduler.executor import task_executor, set_app_client
|
||||||
|
from bot.modules.access_control.auth import is_authorized
|
||||||
|
from bot.utils.helpers import is_valid_url
|
||||||
|
|
||||||
|
user_id = current_user["user_id"]
|
||||||
|
|
||||||
|
# URL validation
|
||||||
|
if not is_valid_url(url):
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "Некорректный или небезопасный URL"},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check authorization
|
||||||
|
if not await is_authorized(user_id):
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "У вас нет доступа к боту"},
|
||||||
|
status_code=403
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check concurrent tasks count
|
||||||
|
user_tasks = await views.get_all_tasks(db, user_id_filter=user_id)
|
||||||
|
active_tasks = [t for t in user_tasks if t.status in ["pending", "processing"]]
|
||||||
|
if len(active_tasks) >= settings.MAX_CONCURRENT_TASKS:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": f"Превышен лимит одновременных задач ({settings.MAX_CONCURRENT_TASKS})"
|
||||||
|
},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Duplicate URL check will be performed atomically in task_queue.add_task()
|
||||||
|
url_normalized = url.strip()
|
||||||
|
|
||||||
|
# Get media information to check limits
|
||||||
|
# If getting info takes too long, skip check
|
||||||
|
media_info = None
|
||||||
|
try:
|
||||||
|
from shared.config import settings
|
||||||
|
media_info = await asyncio.wait_for(
|
||||||
|
get_media_info(url, cookies_file=settings.COOKIES_FILE),
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(f"Timeout getting media info for URL: {url}")
|
||||||
|
media_info = None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get media info: {e}")
|
||||||
|
media_info = None
|
||||||
|
|
||||||
|
if media_info:
|
||||||
|
# Check duration
|
||||||
|
max_duration = settings.max_duration_minutes_int
|
||||||
|
if max_duration and media_info.get('duration'):
|
||||||
|
duration_minutes = media_info['duration'] / 60
|
||||||
|
if duration_minutes > max_duration:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": f"Файл слишком длинный ({duration_minutes:.1f} мин). Максимум: {max_duration} мин."
|
||||||
|
},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate unique task_id using UUID
|
||||||
|
from bot.utils.helpers import generate_unique_task_id
|
||||||
|
task_id = generate_unique_task_id()
|
||||||
|
|
||||||
|
# Check that such ID doesn't exist yet (in case of collision, though probability is extremely low)
|
||||||
|
existing_task = await task_queue.get_task_by_id(task_id)
|
||||||
|
max_retries = 10
|
||||||
|
retries = 0
|
||||||
|
while existing_task and retries < max_retries:
|
||||||
|
task_id = generate_unique_task_id()
|
||||||
|
existing_task = await task_queue.get_task_by_id(task_id)
|
||||||
|
retries += 1
|
||||||
|
|
||||||
|
if existing_task:
|
||||||
|
# If after 10 attempts still collision (extremely unlikely), return error
|
||||||
|
logger.error(f"Failed to generate unique task_id after {max_retries} attempts")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": "Ошибка при создании задачи. Попробуйте позже."},
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
task = QueueTask(
|
||||||
|
id=task_id,
|
||||||
|
user_id=user_id,
|
||||||
|
task_type="download",
|
||||||
|
url=url_normalized,
|
||||||
|
status=TaskStatus.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save task to database BEFORE adding to queue (race condition fix)
|
||||||
|
try:
|
||||||
|
from shared.database.models import Task as DBTask
|
||||||
|
from shared.database.user_helpers import ensure_user_exists
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
# Ensure user exists before creating task
|
||||||
|
await ensure_user_exists(user_id, db)
|
||||||
|
|
||||||
|
db_task = DBTask(
|
||||||
|
id=task_id,
|
||||||
|
user_id=user_id,
|
||||||
|
task_type=task.task_type,
|
||||||
|
status=task.status.value,
|
||||||
|
url=task.url,
|
||||||
|
progress=0,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(db_task)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Task {task_id} saved to database")
|
||||||
|
except IntegrityError as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"IntegrityError saving task {task_id} to database (possibly duplicate ID): {e}", exc_info=True)
|
||||||
|
# Generate new task_id and retry
|
||||||
|
from bot.utils.helpers import generate_unique_task_id
|
||||||
|
task_id = generate_unique_task_id()
|
||||||
|
task.id = task_id
|
||||||
|
try:
|
||||||
|
from shared.database.user_helpers import ensure_user_exists
|
||||||
|
# Ensure user exists before creating task
|
||||||
|
await ensure_user_exists(user_id, db)
|
||||||
|
|
||||||
|
db_task = DBTask(
|
||||||
|
id=task_id,
|
||||||
|
user_id=user_id,
|
||||||
|
task_type=task.task_type,
|
||||||
|
status=task.status.value,
|
||||||
|
url=task.url,
|
||||||
|
progress=0,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(db_task)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Task {task_id} saved to database with new ID")
|
||||||
|
except Exception as e2:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"Error saving task {task_id} to database again: {e2}", exc_info=True)
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": f"Ошибка при создании задачи: {str(e2)}"},
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"Error saving task {task_id} to database: {e}", exc_info=True)
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": f"Ошибка при создании задачи: {str(e)}"},
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add to queue (with duplicate URL check) AFTER saving to database
|
||||||
|
success = await task_queue.add_task(task, check_duplicate_url=True)
|
||||||
|
if not success:
|
||||||
|
# If failed to add to queue, remove from database
|
||||||
|
try:
|
||||||
|
await db.delete(db_task)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"Error deleting task {task_id} from database after failed queue addition: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": "Задача с этим URL уже обрабатывается. Дождитесь завершения или отмените предыдущую задачу."
|
||||||
|
},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start executor if needed
|
||||||
|
from bot.modules.task_scheduler.executor import get_app_client
|
||||||
|
app_client = get_app_client()
|
||||||
|
if app_client:
|
||||||
|
set_app_client(app_client)
|
||||||
|
if not task_executor._running:
|
||||||
|
await task_executor.start()
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"success": True,
|
||||||
|
"message": "Задача успешно создана и добавлена в очередь",
|
||||||
|
"task_id": task_id
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating task: {e}", exc_info=True)
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "message": f"Ошибка при создании задачи: {str(e)}"},
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/stats")
|
||||||
|
async def api_stats(
|
||||||
|
request: Request,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""API: Get statistics"""
|
||||||
|
try:
|
||||||
|
users_stats = await views.get_users_stats(db)
|
||||||
|
tasks_stats = await views.get_tasks_stats(db)
|
||||||
|
return JSONResponse({
|
||||||
|
"users": users_stats,
|
||||||
|
"tasks": tasks_stats
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении статистики: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Ошибка при получении статистики"},
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
2
web/admin/static/.gitkeep
Normal file
2
web/admin/static/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Директория для статических файлов (CSS, JS, изображения)
|
||||||
|
|
||||||
125
web/admin/templates/base.html
Normal file
125
web/admin/templates/base.html
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}TGLoader Admin Panel{% endblock %}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.sidebar {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
.sidebar .nav-link {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.sidebar .nav-link:hover, .sidebar .nav-link.active {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
.stat-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<nav class="col-md-3 col-lg-2 sidebar p-3">
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<h4 class="text-white mb-4">
|
||||||
|
<i class="bi bi-robot"></i> TGLoader
|
||||||
|
</h4>
|
||||||
|
<ul class="nav nav-pills flex-column">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.url.path == '/admin/' %}active{% endif %}" href="/admin/">
|
||||||
|
<i class="bi bi-speedometer2 me-2"></i> Дашборд
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if '/admin/users' in request.url.path %}active{% endif %}" href="/admin/users">
|
||||||
|
<i class="bi bi-people me-2"></i> Пользователи
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if '/admin/tasks' in request.url.path %}active{% endif %}" href="/admin/tasks">
|
||||||
|
<i class="bi bi-list-task me-2"></i> Задачи
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if '/admin/create-task' in request.url.path %}active{% endif %}" href="/admin/create-task">
|
||||||
|
<i class="bi bi-plus-circle me-2"></i> Создать задачу
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<hr class="text-white">
|
||||||
|
<div class="mt-auto">
|
||||||
|
<a class="nav-link" href="/admin/logout">
|
||||||
|
<i class="bi bi-box-arrow-right me-2"></i> Выход
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="col-md-9 col-lg-10 main-content p-4">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
<script>
|
||||||
|
// Глобальная функция для получения CSRF токена для AJAX запросов
|
||||||
|
function getCSRFToken() {
|
||||||
|
const tokenInput = document.querySelector('input[name="csrf_token"]');
|
||||||
|
return tokenInput ? tokenInput.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перехватываем все fetch запросы и добавляем CSRF токен
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
window.fetch = function(url, options = {}) {
|
||||||
|
if (options.method && options.method.toUpperCase() === 'POST') {
|
||||||
|
const token = getCSRFToken();
|
||||||
|
if (token) {
|
||||||
|
if (!options.headers) {
|
||||||
|
options.headers = {};
|
||||||
|
}
|
||||||
|
if (!(options.headers instanceof Headers)) {
|
||||||
|
options.headers['X-CSRF-Token'] = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalFetch(url, options);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
142
web/admin/templates/create_task.html
Normal file
142
web/admin/templates/create_task.html
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Создать задачу - TGLoader Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1><i class="bi bi-plus-circle me-2"></i>Создать задачу на загрузку</h1>
|
||||||
|
<a href="/admin/tasks" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-2"></i>Назад к задачам
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Информация о лимитах -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted">Активные задачи</h6>
|
||||||
|
<h3>{{ active_tasks_count }} / {{ max_concurrent_tasks }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if max_file_size %}
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted">Макс. размер файла</h6>
|
||||||
|
<h3>{{ (max_file_size / (1024*1024))|round(1) }} MB</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if max_duration %}
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted">Макс. длительность</h6>
|
||||||
|
<h3>{{ max_duration }} мин</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Форма создания задачи -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Новая задача</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="createTaskForm">
|
||||||
|
{% if csrf_token %}
|
||||||
|
<input type="hidden" name="csrf_token" id="csrf_token" value="{{ csrf_token }}">
|
||||||
|
{% endif %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="url" class="form-label">URL для загрузки</label>
|
||||||
|
<input type="url" class="form-control" id="url" name="url"
|
||||||
|
placeholder="https://www.youtube.com/watch?v=..." required>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
Поддерживаются YouTube, Instagram, прямые ссылки и другие платформы
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-cloud-download me-2"></i>Создать задачу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function showAlert(message, type = 'success') {
|
||||||
|
const alertDiv = document.createElement('div');
|
||||||
|
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
|
||||||
|
alertDiv.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
const cardBody = document.querySelector('.card-body');
|
||||||
|
cardBody.insertBefore(alertDiv, cardBody.firstChild);
|
||||||
|
setTimeout(() => alertDiv.remove(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
let isSubmitting = false; // Флаг для предотвращения двойной отправки
|
||||||
|
|
||||||
|
document.getElementById('createTaskForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Защита от двойной отправки
|
||||||
|
if (isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = e.target;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const submitBtn = form.querySelector('button[type="submit"]');
|
||||||
|
|
||||||
|
// Получаем CSRF токен из формы или мета-тега
|
||||||
|
const csrfTokenInput = document.getElementById('csrf_token');
|
||||||
|
const csrfToken = csrfTokenInput ? csrfTokenInput.value :
|
||||||
|
(document.querySelector('meta[name="csrf-token"]')?.content || '');
|
||||||
|
|
||||||
|
isSubmitting = true;
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Создание...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/api/tasks/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-Token': csrfToken || ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showAlert(data.message, 'success');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/admin/tasks';
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
showAlert(data.message, 'danger');
|
||||||
|
isSubmitting = false;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = '<i class="bi bi-cloud-download me-2"></i>Создать задачу';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('Ошибка при создании задачи', 'danger');
|
||||||
|
isSubmitting = false;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = '<i class="bi bi-cloud-download me-2"></i>Создать задачу';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
227
web/admin/templates/dashboard.html
Normal file
227
web/admin/templates/dashboard.html
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Дашборд - TGLoader Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1><i class="bi bi-speedometer2 me-2"></i>Дашборд</h1>
|
||||||
|
<div>
|
||||||
|
<span class="badge bg-secondary">User ID: {{ current_user.user_id }}</span>
|
||||||
|
{% if is_owner %}
|
||||||
|
<span class="badge bg-danger ms-2">Owner</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-primary ms-2">Admin</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Статистика пользователей -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<h5 class="mb-3"><i class="bi bi-people me-2"></i>Пользователи</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="stat-icon bg-primary text-white me-3">
|
||||||
|
<i class="bi bi-people"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-0">Всего</h6>
|
||||||
|
<h3 class="mb-0">{{ users_stats.total }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="stat-icon bg-success text-white me-3">
|
||||||
|
<i class="bi bi-shield-check"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-0">Администраторы</h6>
|
||||||
|
<h3 class="mb-0">{{ users_stats.admins }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="stat-icon bg-danger text-white me-3">
|
||||||
|
<i class="bi bi-ban"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-0">Заблокировано</h6>
|
||||||
|
<h3 class="mb-0">{{ users_stats.blocked }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="stat-icon bg-info text-white me-3">
|
||||||
|
<i class="bi bi-person-plus"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-0">Новых (7 дней)</h6>
|
||||||
|
<h3 class="mb-0">{{ users_stats.new_week }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Статистика задач -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<h5 class="mb-3"><i class="bi bi-list-task me-2"></i>Задачи</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="stat-icon bg-primary text-white me-3">
|
||||||
|
<i class="bi bi-list-ul"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-0">Всего</h6>
|
||||||
|
<h3 class="mb-0">{{ tasks_stats.total }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="stat-icon bg-warning text-white me-3">
|
||||||
|
<i class="bi bi-hourglass-split"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-0">Активные</h6>
|
||||||
|
<h3 class="mb-0">{{ tasks_stats.active }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="stat-icon bg-success text-white me-3">
|
||||||
|
<i class="bi bi-check-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-0">Завершено</h6>
|
||||||
|
<h3 class="mb-0">{{ tasks_stats.completed }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="stat-icon bg-danger text-white me-3">
|
||||||
|
<i class="bi bi-x-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-0">Ошибки</h6>
|
||||||
|
<h3 class="mb-0">{{ tasks_stats.failed }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Последние задачи -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Последние задачи</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if recent_tasks %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Прогресс</th>
|
||||||
|
<th>Создано</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for task in recent_tasks %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ task.id }}</td>
|
||||||
|
<td>
|
||||||
|
{% if task.user %}
|
||||||
|
{{ task.user.username or task.user.user_id }}
|
||||||
|
{% else %}
|
||||||
|
{{ task.user_id }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ task.url[:50] + '...' if task.url and task.url|length > 50 else (task.url or '-') }}
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if task.status == 'completed' %}
|
||||||
|
<span class="badge bg-success">Завершено</span>
|
||||||
|
{% elif task.status == 'processing' %}
|
||||||
|
<span class="badge bg-warning">В процессе</span>
|
||||||
|
{% elif task.status == 'pending' %}
|
||||||
|
<span class="badge bg-info">Ожидание</span>
|
||||||
|
{% elif task.status == 'failed' %}
|
||||||
|
<span class="badge bg-danger">Ошибка</span>
|
||||||
|
{% elif task.status == 'cancelled' %}
|
||||||
|
<span class="badge bg-secondary">Отменено</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ task.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="progress" style="height: 20px; width: 100px;">
|
||||||
|
<div class="progress-bar" role="progressbar"
|
||||||
|
style="width: {{ task.progress }}%"
|
||||||
|
aria-valuenow="{{ task.progress }}"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100">
|
||||||
|
{{ task.progress }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><small>{{ task.created_at.strftime('%Y-%m-%d %H:%M') if task.created_at else '-' }}</small></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted text-center">Нет задач</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<a href="/admin/tasks" class="btn btn-outline-primary">Все задачи</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
220
web/admin/templates/login.html
Normal file
220
web/admin/templates/login.html
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Вход - TGLoader Admin</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
max-width: 450px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.login-tabs {
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
.nav-link.active {
|
||||||
|
color: #667eea;
|
||||||
|
border-bottom-color: #667eea;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card login-card">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<i class="bi bi-robot" style="font-size: 3rem; color: #667eea;"></i>
|
||||||
|
<h2 class="mt-3">TGLoader</h2>
|
||||||
|
<p class="text-muted">Вход в веб-интерфейс</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Вкладки -->
|
||||||
|
<ul class="nav nav-tabs login-tabs mb-4" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="otp-tab" data-bs-toggle="tab"
|
||||||
|
data-bs-target="#otp-pane" type="button" role="tab">
|
||||||
|
<i class="bi bi-key me-2"></i>По коду
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="admin-tab" data-bs-toggle="tab"
|
||||||
|
data-bs-target="#admin-pane" type="button" role="tab">
|
||||||
|
<i class="bi bi-shield-check me-2"></i>Админ (по ID)
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- Вход по OTP коду -->
|
||||||
|
<div class="tab-pane fade show active" id="otp-pane" role="tabpanel">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="identifier" class="form-label">Telegram User ID или Username</label>
|
||||||
|
<input type="text" class="form-control" id="identifier"
|
||||||
|
placeholder="Введите ваш User ID или @username">
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
Код будет отправлен вам в Telegram
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-outline-primary w-100 mb-3"
|
||||||
|
onclick="requestOTP()">
|
||||||
|
<i class="bi bi-send me-2"></i>Получить код
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="otp-input-group" style="display: none;">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="otp_code" class="form-label">Одноразовый код</label>
|
||||||
|
<input type="text" class="form-control" id="otp_code" name="otp_code"
|
||||||
|
placeholder="Введите код из Telegram" maxlength="6">
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
Код действителен 10 минут
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/admin/login" id="otp-form">
|
||||||
|
{% if csrf_token %}
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
{% endif %}
|
||||||
|
<input type="hidden" name="otp_code" id="otp_code_hidden">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-box-arrow-in-right me-2"></i>Войти
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 text-center">
|
||||||
|
<small class="text-muted">
|
||||||
|
Или используйте команду <code>/login</code> в боте для получения кода
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Вход по User ID (альтернативный способ) -->
|
||||||
|
<div class="tab-pane fade" id="admin-pane" role="tabpanel">
|
||||||
|
<form method="POST" action="/admin/login">
|
||||||
|
{% if csrf_token %}
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
{% endif %}
|
||||||
|
<input type="hidden" name="user_id" id="admin_user_id">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="admin_user_id_input" class="form-label">Telegram User ID</label>
|
||||||
|
<input type="number" class="form-control" id="admin_user_id_input"
|
||||||
|
placeholder="Введите ваш Telegram User ID" required>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
Альтернативный способ входа (без OTP кода)
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-box-arrow-in-right me-2"></i>Войти
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
function showAlert(message, type = 'success') {
|
||||||
|
const alertDiv = document.createElement('div');
|
||||||
|
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
|
||||||
|
alertDiv.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
const cardBody = document.querySelector('.card-body');
|
||||||
|
cardBody.insertBefore(alertDiv, cardBody.firstChild);
|
||||||
|
setTimeout(() => alertDiv.remove(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestOTP() {
|
||||||
|
const identifier = document.getElementById('identifier').value.trim();
|
||||||
|
if (!identifier) {
|
||||||
|
showAlert('Введите User ID или username', 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = event.target;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Отправка...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('identifier', identifier);
|
||||||
|
|
||||||
|
const response = await fetch('/admin/api/otp/request', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showAlert(data.message, 'success');
|
||||||
|
document.getElementById('otp-input-group').style.display = 'block';
|
||||||
|
document.getElementById('otp_code').focus();
|
||||||
|
|
||||||
|
// Если код возвращен в ответе (не удалось отправить в Telegram)
|
||||||
|
if (data.code) {
|
||||||
|
document.getElementById('otp_code').value = data.code;
|
||||||
|
showAlert(`Код не был отправлен в Telegram. Используйте код: ${data.code}`, 'warning');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showAlert(data.message, 'danger');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('Ошибка при запросе кода', 'danger');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-send me-2"></i>Получить код';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка формы OTP
|
||||||
|
document.getElementById('otp-form').addEventListener('submit', function(e) {
|
||||||
|
const code = document.getElementById('otp_code').value.trim();
|
||||||
|
if (!code) {
|
||||||
|
e.preventDefault();
|
||||||
|
showAlert('Введите код', 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('otp_code_hidden').value = code;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработка формы админа
|
||||||
|
document.getElementById('admin_user_id_input').addEventListener('input', function(e) {
|
||||||
|
document.getElementById('admin_user_id').value = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter для запроса OTP
|
||||||
|
document.getElementById('identifier').addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
requestOTP();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
153
web/admin/templates/tasks.html
Normal file
153
web/admin/templates/tasks.html
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Задачи - TGLoader Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1><i class="bi bi-list-task me-2"></i>Управление задачами</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Фильтры -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="GET" action="/admin/tasks" class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Статус</label>
|
||||||
|
<select class="form-select" name="status_filter">
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Ожидание</option>
|
||||||
|
<option value="processing" {% if status_filter == 'processing' %}selected{% endif %}>В процессе</option>
|
||||||
|
<option value="completed" {% if status_filter == 'completed' %}selected{% endif %}>Завершено</option>
|
||||||
|
<option value="failed" {% if status_filter == 'failed' %}selected{% endif %}>Ошибка</option>
|
||||||
|
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>Отменено</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">User ID</label>
|
||||||
|
<input type="number" class="form-control" name="user_id"
|
||||||
|
placeholder="Фильтр по User ID"
|
||||||
|
value="{{ user_id_filter or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label"> </label>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-funnel me-2"></i>Применить фильтры
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица задач -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Список задач</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if tasks %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Прогресс</th>
|
||||||
|
<th>Создано</th>
|
||||||
|
<th>Обновлено</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for task in tasks %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ task.id }}</code></td>
|
||||||
|
<td>
|
||||||
|
{% if task.user %}
|
||||||
|
<strong>{{ task.user.username or task.user.user_id }}</strong>
|
||||||
|
<br><small class="text-muted">ID: {{ task.user_id }}</small>
|
||||||
|
{% else %}
|
||||||
|
{{ task.user_id }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><span class="badge bg-info">{{ task.task_type }}</span></td>
|
||||||
|
<td>
|
||||||
|
{% if task.url %}
|
||||||
|
<a href="{{ task.url }}" target="_blank" class="text-decoration-none">
|
||||||
|
{{ task.url[:60] + '...' if task.url|length > 60 else task.url }}
|
||||||
|
<i class="bi bi-box-arrow-up-right ms-1"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if task.status == 'completed' %}
|
||||||
|
<span class="badge bg-success">Завершено</span>
|
||||||
|
{% elif task.status == 'processing' %}
|
||||||
|
<span class="badge bg-warning">В процессе</span>
|
||||||
|
{% elif task.status == 'pending' %}
|
||||||
|
<span class="badge bg-info">Ожидание</span>
|
||||||
|
{% elif task.status == 'failed' %}
|
||||||
|
<span class="badge bg-danger">Ошибка</span>
|
||||||
|
{% elif task.status == 'cancelled' %}
|
||||||
|
<span class="badge bg-secondary">Отменено</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ task.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="progress me-2" style="height: 20px; width: 100px;">
|
||||||
|
<div class="progress-bar
|
||||||
|
{% if task.status == 'completed' %}bg-success
|
||||||
|
{% elif task.status == 'failed' %}bg-danger
|
||||||
|
{% elif task.status == 'processing' %}bg-warning
|
||||||
|
{% else %}bg-info{% endif %}"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: {{ task.progress }}%"
|
||||||
|
aria-valuenow="{{ task.progress }}"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100">
|
||||||
|
{{ task.progress }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><small>{{ task.created_at.strftime('%Y-%m-%d %H:%M') if task.created_at else '-' }}</small></td>
|
||||||
|
<td><small>{{ task.updated_at.strftime('%Y-%m-%d %H:%M') if task.updated_at else '-' }}</small></td>
|
||||||
|
</tr>
|
||||||
|
{% if task.error_message %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="8">
|
||||||
|
<div class="alert alert-danger mb-0 py-2">
|
||||||
|
<small><strong>Ошибка:</strong> {{ task.error_message }}</small>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted text-center">Задачи не найдены</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Автообновление каждые 5 секунд для активных задач
|
||||||
|
setInterval(() => {
|
||||||
|
const hasActiveTasks = Array.from(document.querySelectorAll('.badge')).some(
|
||||||
|
badge => badge.textContent.includes('В процессе') || badge.textContent.includes('Ожидание')
|
||||||
|
);
|
||||||
|
if (hasActiveTasks) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
228
web/admin/templates/users.html
Normal file
228
web/admin/templates/users.html
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Пользователи - TGLoader Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1><i class="bi bi-people me-2"></i>Управление пользователями</h1>
|
||||||
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addUserModal">
|
||||||
|
<i class="bi bi-person-plus me-2"></i>Добавить пользователя
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Поиск -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="GET" action="/admin/users" class="row g-3">
|
||||||
|
<div class="col-md-10">
|
||||||
|
<input type="text" class="form-control" name="search"
|
||||||
|
placeholder="Поиск по User ID или username..."
|
||||||
|
value="{{ search }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-outline-primary w-100">
|
||||||
|
<i class="bi bi-search me-2"></i>Поиск
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица пользователей -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Список пользователей</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if users %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ user.user_id }}</code></td>
|
||||||
|
<td>@{{ user.username or '-' }}</td>
|
||||||
|
<td>{{ user.first_name or '-' }} {{ user.last_name or '' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if user.is_admin %}
|
||||||
|
<span class="badge bg-primary">Администратор</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Пользователь</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if user.is_blocked %}
|
||||||
|
<span class="badge bg-danger">Заблокирован</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success">Активен</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><small>{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else '-' }}</small></td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
{% if not user.is_blocked %}
|
||||||
|
<button class="btn btn-outline-danger"
|
||||||
|
onclick="blockUser({{ user.user_id }})"
|
||||||
|
title="Заблокировать">
|
||||||
|
<i class="bi bi-ban"></i>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-outline-success"
|
||||||
|
onclick="unblockUser({{ user.user_id }})"
|
||||||
|
title="Разблокировать">
|
||||||
|
<i class="bi bi-check-circle"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if is_owner %}
|
||||||
|
{% if user.is_admin %}
|
||||||
|
<button class="btn btn-outline-warning"
|
||||||
|
onclick="removeAdmin({{ user.user_id }})"
|
||||||
|
title="Удалить из администраторов">
|
||||||
|
<i class="bi bi-shield-x"></i>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-outline-primary"
|
||||||
|
onclick="addAdmin({{ user.user_id }})"
|
||||||
|
title="Сделать администратором">
|
||||||
|
<i class="bi bi-shield-check"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button class="btn btn-outline-danger"
|
||||||
|
onclick="removeUser({{ user.user_id }})"
|
||||||
|
title="Удалить">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted text-center">Пользователи не найдены</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно добавления пользователя -->
|
||||||
|
<div class="modal fade" id="addUserModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Добавить пользователя</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form id="addUserForm">
|
||||||
|
{% if csrf_token %}
|
||||||
|
<input type="hidden" name="csrf_token" id="csrf_token_users" value="{{ csrf_token }}">
|
||||||
|
{% endif %}
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_user_id" class="form-label">Telegram User ID</label>
|
||||||
|
<input type="number" class="form-control" id="new_user_id" name="user_id" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Добавить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function showAlert(message, type = 'success') {
|
||||||
|
const alertDiv = document.createElement('div');
|
||||||
|
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
|
||||||
|
alertDiv.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
document.body.insertBefore(alertDiv, document.body.firstChild);
|
||||||
|
setTimeout(() => alertDiv.remove(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiCall(url, formData) {
|
||||||
|
// Добавляем CSRF токен
|
||||||
|
const csrfToken = document.getElementById('csrf_token_users')?.value ||
|
||||||
|
document.querySelector('input[name="csrf_token"]')?.value;
|
||||||
|
if (csrfToken) {
|
||||||
|
formData.append('csrf_token', csrfToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-Token': csrfToken || ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
showAlert(data.message, 'success');
|
||||||
|
setTimeout(() => location.reload(), 1000);
|
||||||
|
} else {
|
||||||
|
showAlert(data.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addUserForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
await apiCall('/admin/api/users/add', formData);
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('addUserModal')).hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
function removeUser(userId) {
|
||||||
|
if (!confirm('Вы уверены, что хотите удалить этого пользователя?')) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('user_id', userId);
|
||||||
|
apiCall('/admin/api/users/remove', formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockUser(userId) {
|
||||||
|
if (!confirm('Вы уверены, что хотите заблокировать этого пользователя?')) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('user_id', userId);
|
||||||
|
apiCall('/admin/api/users/block', formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unblockUser(userId) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('user_id', userId);
|
||||||
|
apiCall('/admin/api/users/unblock', formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAdmin(userId) {
|
||||||
|
if (!confirm('Вы уверены, что хотите сделать этого пользователя администратором?')) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('user_id', userId);
|
||||||
|
apiCall('/admin/api/users/add-admin', formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAdmin(userId) {
|
||||||
|
if (!confirm('Вы уверены, что хотите удалить этого пользователя из администраторов?')) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('user_id', userId);
|
||||||
|
apiCall('/admin/api/users/remove-admin', formData);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
253
web/admin/user_manager_web.py
Normal file
253
web/admin/user_manager_web.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"""
|
||||||
|
Wrappers for user management in web interface
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from shared.database.models import User
|
||||||
|
from shared.config import settings
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_user_web(user_id: int, db: AsyncSession) -> dict:
|
||||||
|
"""
|
||||||
|
Add user (for web interface)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with operation result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check existence
|
||||||
|
existing_user = await db.get(User, user_id)
|
||||||
|
if existing_user:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Пользователь {user_id} уже существует"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create new user
|
||||||
|
user = User(
|
||||||
|
user_id=user_id,
|
||||||
|
is_admin=False,
|
||||||
|
is_blocked=False
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"User {user_id} added via web interface")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Пользователь {user_id} успешно добавлен"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding user: {e}")
|
||||||
|
await db.rollback()
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Ошибка при добавлении пользователя: {str(e)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_user_web(user_id: int, db: AsyncSession) -> dict:
|
||||||
|
"""
|
||||||
|
Remove user (for web interface)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with operation result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Пользователь {user_id} не найден"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cannot delete Owner
|
||||||
|
if user_id == settings.OWNER_ID:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Нельзя удалить Owner"
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(user)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"User {user_id} removed via web interface")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Пользователь {user_id} успешно удален"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing user: {e}")
|
||||||
|
await db.rollback()
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Ошибка при удалении пользователя: {str(e)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def block_user_web(user_id: int, db: AsyncSession) -> dict:
|
||||||
|
"""
|
||||||
|
Block user (for web interface)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with operation result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Пользователь {user_id} не найден"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cannot block Owner
|
||||||
|
if user_id == settings.OWNER_ID:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Нельзя заблокировать Owner"
|
||||||
|
}
|
||||||
|
|
||||||
|
user.is_blocked = True
|
||||||
|
user.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"User {user_id} blocked via web interface")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Пользователь {user_id} успешно заблокирован"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error blocking user: {e}")
|
||||||
|
await db.rollback()
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Ошибка при блокировке пользователя: {str(e)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def unblock_user_web(user_id: int, db: AsyncSession) -> dict:
|
||||||
|
"""
|
||||||
|
Unblock user (for web interface)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with operation result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Пользователь {user_id} не найден"
|
||||||
|
}
|
||||||
|
|
||||||
|
user.is_blocked = False
|
||||||
|
user.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"User {user_id} unblocked via web interface")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Пользователь {user_id} успешно разблокирован"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error unblocking user: {e}")
|
||||||
|
await db.rollback()
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Ошибка при разблокировке пользователя: {str(e)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def add_admin_web(user_id: int, db: AsyncSession) -> dict:
|
||||||
|
"""
|
||||||
|
Add administrator (for web interface, Owner only)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with operation result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
# Create user if doesn't exist
|
||||||
|
user = User(user_id=user_id, is_admin=True, is_blocked=False)
|
||||||
|
db.add(user)
|
||||||
|
else:
|
||||||
|
user.is_admin = True
|
||||||
|
user.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"User {user_id} assigned as administrator via web interface")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Пользователь {user_id} успешно назначен администратором"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error assigning administrator: {e}")
|
||||||
|
await db.rollback()
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Ошибка при назначении администратора: {str(e)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_admin_web(user_id: int, db: AsyncSession) -> dict:
|
||||||
|
"""
|
||||||
|
Remove administrator (for web interface, Owner only)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with operation result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Cannot remove privileges from Owner
|
||||||
|
if user_id == settings.OWNER_ID:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Нельзя снять права администратора у Owner"
|
||||||
|
}
|
||||||
|
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if not user or not user.is_admin:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Пользователь {user_id} не является администратором"
|
||||||
|
}
|
||||||
|
|
||||||
|
user.is_admin = False
|
||||||
|
user.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Administrator privileges removed from user {user_id} via web interface")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Права администратора успешно сняты у пользователя {user_id}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing administrator privileges: {e}")
|
||||||
|
await db.rollback()
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Ошибка при снятии прав администратора: {str(e)}"
|
||||||
|
}
|
||||||
|
|
||||||
176
web/admin/views.py
Normal file
176
web/admin/views.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""
|
||||||
|
Admin panel views - data retrieval logic
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from shared.database.models import User, Task
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_users_stats(session: AsyncSession) -> dict:
|
||||||
|
"""Get user statistics"""
|
||||||
|
try:
|
||||||
|
# Total user count
|
||||||
|
total_users_result = await session.execute(
|
||||||
|
select(func.count(User.user_id))
|
||||||
|
)
|
||||||
|
total_users = total_users_result.scalar() or 0
|
||||||
|
|
||||||
|
# Administrator count
|
||||||
|
admins_result = await session.execute(
|
||||||
|
select(func.count(User.user_id)).where(User.is_admin == True)
|
||||||
|
)
|
||||||
|
total_admins = admins_result.scalar() or 0
|
||||||
|
|
||||||
|
# Blocked user count
|
||||||
|
blocked_result = await session.execute(
|
||||||
|
select(func.count(User.user_id)).where(User.is_blocked == True)
|
||||||
|
)
|
||||||
|
blocked_users = blocked_result.scalar() or 0
|
||||||
|
|
||||||
|
# New users in last 7 days
|
||||||
|
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||||
|
new_users_result = await session.execute(
|
||||||
|
select(func.count(User.user_id)).where(User.created_at >= week_ago)
|
||||||
|
)
|
||||||
|
new_users = new_users_result.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total_users,
|
||||||
|
"admins": total_admins,
|
||||||
|
"blocked": blocked_users,
|
||||||
|
"new_week": new_users
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting user statistics: {e}")
|
||||||
|
return {"total": 0, "admins": 0, "blocked": 0, "new_week": 0}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tasks_stats(session: AsyncSession) -> dict:
|
||||||
|
"""Get task statistics"""
|
||||||
|
try:
|
||||||
|
# Total task count
|
||||||
|
total_tasks_result = await session.execute(
|
||||||
|
select(func.count(Task.id))
|
||||||
|
)
|
||||||
|
total_tasks = total_tasks_result.scalar() or 0
|
||||||
|
|
||||||
|
# Active tasks (pending, processing)
|
||||||
|
active_tasks_result = await session.execute(
|
||||||
|
select(func.count(Task.id)).where(
|
||||||
|
Task.status.in_(["pending", "processing"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
active_tasks = active_tasks_result.scalar() or 0
|
||||||
|
|
||||||
|
# Completed tasks
|
||||||
|
completed_tasks_result = await session.execute(
|
||||||
|
select(func.count(Task.id)).where(Task.status == "completed")
|
||||||
|
)
|
||||||
|
completed_tasks = completed_tasks_result.scalar() or 0
|
||||||
|
|
||||||
|
# Failed tasks
|
||||||
|
failed_tasks_result = await session.execute(
|
||||||
|
select(func.count(Task.id)).where(Task.status == "failed")
|
||||||
|
)
|
||||||
|
failed_tasks = failed_tasks_result.scalar() or 0
|
||||||
|
|
||||||
|
# Tasks in last 24 hours
|
||||||
|
day_ago = datetime.utcnow() - timedelta(hours=24)
|
||||||
|
recent_tasks_result = await session.execute(
|
||||||
|
select(func.count(Task.id)).where(Task.created_at >= day_ago)
|
||||||
|
)
|
||||||
|
recent_tasks = recent_tasks_result.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total_tasks,
|
||||||
|
"active": active_tasks,
|
||||||
|
"completed": completed_tasks,
|
||||||
|
"failed": failed_tasks,
|
||||||
|
"recent_24h": recent_tasks
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting task statistics: {e}")
|
||||||
|
return {"total": 0, "active": 0, "completed": 0, "failed": 0, "recent_24h": 0}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_users(
|
||||||
|
session: AsyncSession,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
search: Optional[str] = None
|
||||||
|
) -> List[User]:
|
||||||
|
"""Get user list"""
|
||||||
|
try:
|
||||||
|
query = select(User)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
# Search by username or user_id
|
||||||
|
try:
|
||||||
|
user_id = int(search)
|
||||||
|
query = query.where(User.user_id == user_id)
|
||||||
|
except ValueError:
|
||||||
|
query = query.where(User.username.ilike(f"%{search}%"))
|
||||||
|
|
||||||
|
query = query.order_by(desc(User.created_at)).limit(limit).offset(offset)
|
||||||
|
result = await session.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting users: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||||
|
"""Get user by ID"""
|
||||||
|
try:
|
||||||
|
return await session.get(User, user_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting user: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_tasks(
|
||||||
|
session: AsyncSession,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
status_filter: Optional[str] = None,
|
||||||
|
user_id_filter: Optional[int] = None
|
||||||
|
) -> List[Task]:
|
||||||
|
"""Get task list"""
|
||||||
|
try:
|
||||||
|
query = select(Task)
|
||||||
|
|
||||||
|
if status_filter:
|
||||||
|
query = query.where(Task.status == status_filter)
|
||||||
|
|
||||||
|
if user_id_filter:
|
||||||
|
query = query.where(Task.user_id == user_id_filter)
|
||||||
|
|
||||||
|
query = query.order_by(desc(Task.created_at)).limit(limit).offset(offset)
|
||||||
|
result = await session.execute(query)
|
||||||
|
tasks = list(result.scalars().all())
|
||||||
|
|
||||||
|
# Load related users
|
||||||
|
for task in tasks:
|
||||||
|
await session.refresh(task, ["user"])
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting tasks: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def get_task_by_id(session: AsyncSession, task_id: int) -> Optional[Task]:
|
||||||
|
"""Get task by ID"""
|
||||||
|
try:
|
||||||
|
task = await session.get(Task, task_id)
|
||||||
|
if task:
|
||||||
|
await session.refresh(task, ["user"])
|
||||||
|
return task
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting task: {e}")
|
||||||
|
return None
|
||||||
112
web/app.py
Normal file
112
web/app.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Web application entry point
|
||||||
|
"""
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
from pathlib import Path
|
||||||
|
from web.admin.routes import router as admin_router
|
||||||
|
from shared.config import settings
|
||||||
|
from web.utils.auth import start_session_cleanup_task
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="TGLoader Admin Panel",
|
||||||
|
description="Web interface for managing TGLoader Telegram bot",
|
||||||
|
version="1.0.0",
|
||||||
|
docs_url="/api/docs",
|
||||||
|
redoc_url="/api/redoc",
|
||||||
|
openapi_url="/api/openapi.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check and generate SECRET_KEY
|
||||||
|
if not settings.WEB_SECRET_KEY or settings.WEB_SECRET_KEY == "your-secret-key-change-in-production":
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ WEB_SECRET_KEY not set or using default value!\n"
|
||||||
|
"⚠️ This is unsafe for production!\n"
|
||||||
|
"⚠️ Generate a random key and add it to .env file:\n"
|
||||||
|
f" WEB_SECRET_KEY={secrets.token_urlsafe(32)}"
|
||||||
|
)
|
||||||
|
# Generate temporary key for development (in production this should be an error)
|
||||||
|
secret_key = secrets.token_urlsafe(32)
|
||||||
|
logger.warning(f"⚠️ Using temporary key (DO NOT USE IN PRODUCTION!): {secret_key[:20]}...")
|
||||||
|
else:
|
||||||
|
secret_key = settings.WEB_SECRET_KEY
|
||||||
|
|
||||||
|
# Session middleware
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key=secret_key,
|
||||||
|
max_age=86400 * 7 # 7 days
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mount static files and templates
|
||||||
|
static_dir = Path("web/admin/static")
|
||||||
|
static_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
|
|
||||||
|
templates_dir = Path("web/admin/templates")
|
||||||
|
templates = Jinja2Templates(directory=str(templates_dir))
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(admin_router, prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
"""Start background tasks on application startup"""
|
||||||
|
try:
|
||||||
|
# Initialize database (create tables if they don't exist)
|
||||||
|
from shared.database.session import init_db
|
||||||
|
try:
|
||||||
|
await init_db()
|
||||||
|
logger.info("Database initialized for web application")
|
||||||
|
except Exception as db_error:
|
||||||
|
logger.error(f"Error initializing database: {db_error}", exc_info=True)
|
||||||
|
# Don't interrupt startup, database might already be initialized
|
||||||
|
|
||||||
|
start_session_cleanup_task()
|
||||||
|
# Start cleanup of old OTP codes on startup
|
||||||
|
try:
|
||||||
|
from web.utils.otp import cleanup_expired_otp_codes
|
||||||
|
from web.utils.database import get_db
|
||||||
|
async for db in get_db():
|
||||||
|
await cleanup_expired_otp_codes(db)
|
||||||
|
break
|
||||||
|
except Exception as otp_cleanup_error:
|
||||||
|
logger.warning(f"Failed to cleanup old OTP codes on startup: {otp_cleanup_error}")
|
||||||
|
|
||||||
|
# Start background task for updating user information
|
||||||
|
try:
|
||||||
|
import asyncio
|
||||||
|
from bot.utils.user_info_updater import update_users_without_info_periodically
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
user_info_task = loop.create_task(update_users_without_info_periodically())
|
||||||
|
logger.info("User information update task started")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to start user information update task: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error starting background tasks: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Redirect to admin panel"""
|
||||||
|
return RedirectResponse(url="/admin/login")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
from shared.config import settings
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host=settings.WEB_HOST,
|
||||||
|
port=settings.WEB_PORT
|
||||||
|
)
|
||||||
|
|
||||||
7
web/config.py
Normal file
7
web/config.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Web application configuration
|
||||||
|
"""
|
||||||
|
from shared.config import settings
|
||||||
|
|
||||||
|
__all__ = ["settings"]
|
||||||
|
|
||||||
4
web/utils/__init__.py
Normal file
4
web/utils/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Web application utilities
|
||||||
|
"""
|
||||||
|
|
||||||
310
web/utils/auth.py
Normal file
310
web/utils/auth.py
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
"""
|
||||||
|
Authentication for web interface
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi import Request, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
from shared.config import settings
|
||||||
|
from shared.database.session import AsyncSessionLocal
|
||||||
|
from shared.database.models import User
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Global variable for storing sessions (use Redis in production)
|
||||||
|
# Format: {session_id: {"user_id": int, "is_owner": bool, "created_at": datetime}}
|
||||||
|
_sessions: dict[str, dict] = {}
|
||||||
|
_sessions_lock = asyncio.Lock() # Use asyncio.Lock for async context
|
||||||
|
|
||||||
|
# Constants for time intervals
|
||||||
|
SECONDS_PER_HOUR = 3600
|
||||||
|
HOURS_PER_DAY = 24
|
||||||
|
|
||||||
|
# TTL for sessions (7 days)
|
||||||
|
SESSION_LIFETIME_DAYS = 7
|
||||||
|
SESSION_TTL_DAYS = 7
|
||||||
|
SESSION_TTL = timedelta(days=SESSION_TTL_DAYS)
|
||||||
|
|
||||||
|
# Session cleanup interval (6 hours)
|
||||||
|
SESSION_CLEANUP_INTERVAL_HOURS = 6
|
||||||
|
|
||||||
|
# Flag for background cleanup task
|
||||||
|
_cleanup_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_web_user(user_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check user access rights to web interface
|
||||||
|
All authorized users can use web interface
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Telegram user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user has access, False otherwise
|
||||||
|
"""
|
||||||
|
from bot.modules.access_control.auth import is_authorized
|
||||||
|
|
||||||
|
# Check user authorization
|
||||||
|
return await is_authorized(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_session(user_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Create session for user
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Telegram user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session ID
|
||||||
|
"""
|
||||||
|
# Use Redis if enabled
|
||||||
|
if settings.USE_REDIS_SESSIONS:
|
||||||
|
try:
|
||||||
|
from web.utils.redis_session import create_redis_session
|
||||||
|
session_id = await create_redis_session(user_id)
|
||||||
|
if session_id:
|
||||||
|
return session_id
|
||||||
|
else:
|
||||||
|
logger.warning("Failed to create session in Redis, using in-memory")
|
||||||
|
return await _create_in_memory_session(user_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to create session in Redis, using in-memory: {e}")
|
||||||
|
return await _create_in_memory_session(user_id)
|
||||||
|
else:
|
||||||
|
return await _create_in_memory_session(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_in_memory_session(user_id: int) -> str:
|
||||||
|
"""Create in-memory session (fallback)"""
|
||||||
|
import secrets
|
||||||
|
from web.utils.csrf import generate_csrf_token
|
||||||
|
|
||||||
|
session_id = secrets.token_urlsafe(32)
|
||||||
|
csrf_token = generate_csrf_token()
|
||||||
|
|
||||||
|
with _sessions_lock:
|
||||||
|
_sessions[session_id] = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"is_owner": user_id == settings.OWNER_ID,
|
||||||
|
"created_at": datetime.utcnow(),
|
||||||
|
"expires_at": datetime.utcnow() + timedelta(days=SESSION_LIFETIME_DAYS),
|
||||||
|
"csrf_token": csrf_token
|
||||||
|
}
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session(session_id: str) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Get session data (async version).
|
||||||
|
|
||||||
|
Checks Redis first if enabled, then falls back to in-memory sessions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session data or None (if session expired)
|
||||||
|
"""
|
||||||
|
# Use Redis if enabled
|
||||||
|
if settings.USE_REDIS_SESSIONS:
|
||||||
|
try:
|
||||||
|
from web.utils.redis_session import get_redis_session
|
||||||
|
session_data = await get_redis_session(session_id)
|
||||||
|
if session_data:
|
||||||
|
return session_data
|
||||||
|
# If not found in Redis, check in-memory (fallback)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get session from Redis, using in-memory: {e}")
|
||||||
|
|
||||||
|
# Fallback to in-memory sessions
|
||||||
|
async with _sessions_lock:
|
||||||
|
session_data = _sessions.get(session_id)
|
||||||
|
if not session_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check TTL by expires_at (more reliable)
|
||||||
|
expires_at = session_data.get("expires_at")
|
||||||
|
if expires_at:
|
||||||
|
if isinstance(expires_at, datetime):
|
||||||
|
if datetime.utcnow() >= expires_at:
|
||||||
|
# Session expired, delete
|
||||||
|
del _sessions[session_id]
|
||||||
|
logger.debug(f"Session {session_id} expired (expires_at: {expires_at})")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# If expires_at in different format, try to parse
|
||||||
|
try:
|
||||||
|
if isinstance(expires_at, str):
|
||||||
|
expires_at_dt = datetime.fromisoformat(expires_at)
|
||||||
|
if datetime.utcnow() >= expires_at_dt:
|
||||||
|
del _sessions[session_id]
|
||||||
|
logger.debug(f"Session {session_id} expired (expires_at: {expires_at})")
|
||||||
|
return None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning(f"Failed to parse expires_at for session {session_id}: {expires_at}")
|
||||||
|
|
||||||
|
# Fallback: check by created_at (for backward compatibility)
|
||||||
|
if not expires_at:
|
||||||
|
created_at = session_data.get("created_at")
|
||||||
|
if created_at and isinstance(created_at, datetime):
|
||||||
|
if datetime.utcnow() - created_at > SESSION_TTL:
|
||||||
|
# Session expired, delete
|
||||||
|
del _sessions[session_id]
|
||||||
|
logger.debug(f"Session {session_id} expired (by created_at)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return session_data
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_session(session_id: str):
|
||||||
|
"""
|
||||||
|
Delete session
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID
|
||||||
|
"""
|
||||||
|
# Use Redis if enabled
|
||||||
|
if settings.USE_REDIS_SESSIONS:
|
||||||
|
try:
|
||||||
|
from web.utils.redis_session import delete_redis_session
|
||||||
|
await delete_redis_session(session_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to delete session from Redis: {e}")
|
||||||
|
|
||||||
|
# Also remove from memory just in case
|
||||||
|
async with _sessions_lock:
|
||||||
|
if session_id in _sessions:
|
||||||
|
del _sessions[session_id]
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_expired_sessions():
|
||||||
|
"""
|
||||||
|
Cleanup expired sessions
|
||||||
|
Called periodically in background
|
||||||
|
"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
expired_sessions = []
|
||||||
|
|
||||||
|
async with _sessions_lock:
|
||||||
|
for session_id, session_data in _sessions.items():
|
||||||
|
created_at = session_data.get("created_at")
|
||||||
|
if created_at and isinstance(created_at, datetime):
|
||||||
|
if now - created_at > SESSION_TTL:
|
||||||
|
expired_sessions.append(session_id)
|
||||||
|
|
||||||
|
for session_id in expired_sessions:
|
||||||
|
del _sessions[session_id]
|
||||||
|
|
||||||
|
if expired_sessions:
|
||||||
|
logger.info(f"Cleaned up {len(expired_sessions)} expired sessions")
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_sessions_periodically():
|
||||||
|
"""
|
||||||
|
Periodic cleanup of expired sessions
|
||||||
|
Runs in background every 6 hours
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(SESSION_CLEANUP_INTERVAL_HOURS * SECONDS_PER_HOUR)
|
||||||
|
await cleanup_expired_sessions()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Session cleanup task stopped")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error cleaning up sessions: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def start_session_cleanup_task():
|
||||||
|
"""
|
||||||
|
Start background session cleanup task
|
||||||
|
"""
|
||||||
|
global _cleanup_task
|
||||||
|
if _cleanup_task is None or _cleanup_task.done():
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
_cleanup_task = loop.create_task(cleanup_sessions_periodically())
|
||||||
|
logger.info("Background session cleanup task started")
|
||||||
|
except RuntimeError:
|
||||||
|
# If no running loop, try to get current one
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
if loop.is_running():
|
||||||
|
_cleanup_task = loop.create_task(cleanup_sessions_periodically())
|
||||||
|
logger.info("Background session cleanup task started")
|
||||||
|
else:
|
||||||
|
logger.warning("Event loop not running, session cleanup task will be started later")
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("Failed to start session cleanup task: no event loop")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(request: Request) -> dict:
|
||||||
|
"""
|
||||||
|
Get current user from session
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI Request object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with user data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If user is not authorized
|
||||||
|
"""
|
||||||
|
session_id = request.cookies.get("session_id")
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get session (from Redis or in-memory)
|
||||||
|
session_data = None
|
||||||
|
if settings.USE_REDIS_SESSIONS:
|
||||||
|
try:
|
||||||
|
from web.utils.redis_session import get_redis_session
|
||||||
|
session_data = await get_redis_session(session_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error getting session from Redis: {e}, trying in-memory")
|
||||||
|
session_data = await get_session(session_id)
|
||||||
|
else:
|
||||||
|
session_data = await get_session(session_id)
|
||||||
|
|
||||||
|
if not session_data:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Session expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that user still has access
|
||||||
|
user_id = session_data.get("user_id")
|
||||||
|
if not await verify_web_user(user_id):
|
||||||
|
await delete_session(session_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
return session_data
|
||||||
|
|
||||||
|
|
||||||
|
async def is_owner_web(request: Request) -> bool:
|
||||||
|
"""
|
||||||
|
Check if current user is Owner
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI Request object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if Owner, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user_data = await get_current_user(request)
|
||||||
|
return user_data.get("is_owner", False)
|
||||||
|
except HTTPException:
|
||||||
|
return False
|
||||||
72
web/utils/bot_client.py
Normal file
72
web/utils/bot_client.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
Utilities for interacting with bot from web interface
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from bot.modules.task_scheduler.executor import get_app_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_otp_to_user(user_id: int, code: str) -> bool:
|
||||||
|
"""
|
||||||
|
Send OTP code to user via Telegram bot
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
code: OTP code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successfully sent, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
app_client = get_app_client()
|
||||||
|
if not app_client:
|
||||||
|
logger.warning(f"Bot client not available for sending OTP to user {user_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check that client is started and connected
|
||||||
|
try:
|
||||||
|
if not hasattr(app_client, 'is_connected') or not app_client.is_connected:
|
||||||
|
logger.warning(f"Bot client not connected for sending OTP to user {user_id}")
|
||||||
|
return False
|
||||||
|
except Exception as check_error:
|
||||||
|
logger.warning(f"Failed to check bot connection status: {check_error}")
|
||||||
|
# Continue sending attempt, client might be working
|
||||||
|
|
||||||
|
from shared.config import settings
|
||||||
|
# Form URL for web interface
|
||||||
|
if settings.WEB_HOST == "0.0.0.0":
|
||||||
|
login_url = f"localhost:{settings.WEB_PORT}"
|
||||||
|
else:
|
||||||
|
login_url = f"{settings.WEB_HOST}:{settings.WEB_PORT}"
|
||||||
|
|
||||||
|
message = (
|
||||||
|
f"🔐 **Ваш код для входа в веб-интерфейс:**\n\n"
|
||||||
|
f"**`{code}`**\n\n"
|
||||||
|
f"⏰ Код действителен 10 минут\n\n"
|
||||||
|
f"🌐 Перейдите на http://{login_url}/admin/login и введите этот код"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to send message
|
||||||
|
result = await app_client.send_message(user_id, message)
|
||||||
|
logger.info(f"OTP code successfully sent to user {user_id}, message_id: {result.id if result else 'unknown'}")
|
||||||
|
return True
|
||||||
|
except Exception as send_error:
|
||||||
|
error_msg = str(send_error)
|
||||||
|
logger.error(f"Error sending message to user {user_id}: {error_msg}", exc_info=True)
|
||||||
|
|
||||||
|
# Check error type for more informative message
|
||||||
|
if "chat not found" in error_msg.lower() or "user not found" in error_msg.lower():
|
||||||
|
logger.error(f"User {user_id} not found or hasn't started dialog with bot")
|
||||||
|
elif "flood" in error_msg.lower():
|
||||||
|
logger.error(f"Message sending rate limit exceeded for user {user_id}")
|
||||||
|
elif "unauthorized" in error_msg.lower():
|
||||||
|
logger.error(f"Bot not authorized or stopped")
|
||||||
|
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Critical error sending OTP to user {user_id}: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
123
web/utils/csrf.py
Normal file
123
web/utils/csrf.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
CSRF token utilities
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import Request, HTTPException, status
|
||||||
|
from web.utils.auth import get_session
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# CSRF token length
|
||||||
|
CSRF_TOKEN_LENGTH = 32
|
||||||
|
|
||||||
|
|
||||||
|
def generate_csrf_token() -> str:
|
||||||
|
"""
|
||||||
|
Generate CSRF token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Random token
|
||||||
|
"""
|
||||||
|
return secrets.token_urlsafe(CSRF_TOKEN_LENGTH)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_csrf_token(request: Request) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get CSRF token from session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI Request object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CSRF token or None
|
||||||
|
"""
|
||||||
|
session_id = request.cookies.get("session_id")
|
||||||
|
if not session_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
session_data = await get_session(session_id)
|
||||||
|
if not session_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return session_data.get("csrf_token")
|
||||||
|
|
||||||
|
|
||||||
|
async def set_csrf_token(request: Request, token: str) -> None:
|
||||||
|
"""
|
||||||
|
Set CSRF token in session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI Request object
|
||||||
|
token: CSRF token
|
||||||
|
"""
|
||||||
|
session_id = request.cookies.get("session_id")
|
||||||
|
if not session_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
session_data = await get_session(session_id)
|
||||||
|
if session_data:
|
||||||
|
session_data["csrf_token"] = token
|
||||||
|
# Save session back to Redis if using Redis
|
||||||
|
from shared.config import settings
|
||||||
|
if settings.USE_REDIS_SESSIONS:
|
||||||
|
from web.utils.redis_session import update_redis_session
|
||||||
|
await update_redis_session(session_id, session_data)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_csrf_token(request: Request, token: Optional[str] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Validate CSRF token
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI Request object
|
||||||
|
token: Token to validate (if None, taken from form/headers)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if token is valid
|
||||||
|
"""
|
||||||
|
# Get token from different sources
|
||||||
|
if not token:
|
||||||
|
# Try to get from form
|
||||||
|
try:
|
||||||
|
form = await request.form()
|
||||||
|
token = form.get("csrf_token")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to get CSRF token from form: {e}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If not found in form, try from headers
|
||||||
|
if not token:
|
||||||
|
token = request.headers.get("X-CSRF-Token")
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get token from session
|
||||||
|
session_token = await get_csrf_token(request)
|
||||||
|
if not session_token:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Compare tokens
|
||||||
|
return secrets.compare_digest(token, session_token)
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_csrf(request: Request, token: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Verify CSRF token with exception on error
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI Request object
|
||||||
|
token: Token to verify
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If token is invalid
|
||||||
|
"""
|
||||||
|
if not await validate_csrf_token(request, token):
|
||||||
|
logger.warning(f"CSRF token invalid for IP {request.client.host if request.client else 'unknown'}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Invalid CSRF token"
|
||||||
|
)
|
||||||
|
|
||||||
9
web/utils/database.py
Normal file
9
web/utils/database.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
Database utilities for web interface (wrapper over shared module)
|
||||||
|
Uses unified module from shared/database/session.py
|
||||||
|
"""
|
||||||
|
from shared.database.session import get_db
|
||||||
|
|
||||||
|
# Export function for use in FastAPI Depends
|
||||||
|
__all__ = ['get_db']
|
||||||
|
|
||||||
288
web/utils/otp.py
Normal file
288
web/utils/otp.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"""
|
||||||
|
One-time password (OTP) utilities
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, and_
|
||||||
|
from shared.database.models import OTPCode, User
|
||||||
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# OTP code lifetime (10 minutes)
|
||||||
|
OTP_EXPIRY_MINUTES = 10
|
||||||
|
|
||||||
|
# Rate limiting for OTP code verification
|
||||||
|
# Format: {ip_address: [(timestamp, success), ...]}
|
||||||
|
_otp_attempts: dict[str, list[tuple[float, bool]]] = defaultdict(list)
|
||||||
|
import threading
|
||||||
|
_otp_locks: dict[str, threading.Lock] = defaultdict(lambda: threading.Lock())
|
||||||
|
|
||||||
|
# Rate limiting settings
|
||||||
|
MAX_OTP_ATTEMPTS = 5 # Maximum attempts
|
||||||
|
OTP_ATTEMPT_WINDOW = 60 # Time window in seconds (1 minute)
|
||||||
|
OTP_CLEANUP_INTERVAL = 3600 # Old attempts cleanup interval (1 hour)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_otp_code() -> str:
|
||||||
|
"""
|
||||||
|
Generate 6-digit OTP code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
6-digit code
|
||||||
|
"""
|
||||||
|
return ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||||
|
|
||||||
|
|
||||||
|
async def create_otp_code(user_id: int, db: AsyncSession) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Create new OTP code for user
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OTP code or None on error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Invalidate all previous unused codes for user
|
||||||
|
await invalidate_user_otp_codes(user_id, db)
|
||||||
|
|
||||||
|
# Generate new code
|
||||||
|
code = generate_otp_code()
|
||||||
|
expires_at = datetime.utcnow() + timedelta(minutes=OTP_EXPIRY_MINUTES)
|
||||||
|
|
||||||
|
# Create database record
|
||||||
|
otp = OTPCode(
|
||||||
|
user_id=user_id,
|
||||||
|
code=code,
|
||||||
|
expires_at=expires_at,
|
||||||
|
used=False
|
||||||
|
)
|
||||||
|
db.add(otp)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(f"OTP code created for user {user_id}, expires at {expires_at}")
|
||||||
|
return code
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating OTP code: {e}")
|
||||||
|
await db.rollback()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rate_limit(ip_address: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check rate limiting for IP address
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip_address: Client IP address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if can continue, False if limit exceeded
|
||||||
|
"""
|
||||||
|
lock = _otp_locks[ip_address]
|
||||||
|
with lock:
|
||||||
|
now = time.time()
|
||||||
|
attempts = _otp_attempts[ip_address]
|
||||||
|
|
||||||
|
# Remove old attempts (older than time window)
|
||||||
|
attempts[:] = [(ts, success) for ts, success in attempts if now - ts < OTP_ATTEMPT_WINDOW]
|
||||||
|
|
||||||
|
# Check attempt count
|
||||||
|
if len(attempts) >= MAX_OTP_ATTEMPTS:
|
||||||
|
logger.warning(f"OTP verification rate limit exceeded for IP {ip_address}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _record_otp_attempt(ip_address: str, success: bool):
|
||||||
|
"""
|
||||||
|
Record OTP verification attempt
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip_address: Client IP address
|
||||||
|
success: Whether attempt was successful
|
||||||
|
"""
|
||||||
|
lock = _otp_locks[ip_address]
|
||||||
|
with lock:
|
||||||
|
now = time.time()
|
||||||
|
_otp_attempts[ip_address].append((now, success))
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_otp_code(code: str, db: AsyncSession, ip_address: Optional[str] = None) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Verify and use OTP code with brute force protection
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: OTP code to verify
|
||||||
|
db: Database session
|
||||||
|
ip_address: Client IP address (for rate limiting)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
user_id if code is valid, None otherwise
|
||||||
|
"""
|
||||||
|
# Check rate limiting
|
||||||
|
if ip_address and not _check_rate_limit(ip_address):
|
||||||
|
logger.warning(f"OTP verification rate limit exceeded for IP {ip_address}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Search for code
|
||||||
|
result = await db.execute(
|
||||||
|
select(OTPCode).where(
|
||||||
|
and_(
|
||||||
|
OTPCode.code == code,
|
||||||
|
OTPCode.used == False,
|
||||||
|
OTPCode.expires_at > datetime.utcnow()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
otp = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not otp:
|
||||||
|
logger.warning(f"Invalid or expired OTP code: {code}")
|
||||||
|
if ip_address:
|
||||||
|
_record_otp_attempt(ip_address, False)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Mark code as used
|
||||||
|
otp.used = True
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
if ip_address:
|
||||||
|
_record_otp_attempt(ip_address, True)
|
||||||
|
|
||||||
|
logger.info(f"OTP code used for user {otp.user_id}")
|
||||||
|
return otp.user_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error verifying OTP code: {e}")
|
||||||
|
await db.rollback()
|
||||||
|
if ip_address:
|
||||||
|
_record_otp_attempt(ip_address, False)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def invalidate_user_otp_codes(user_id: int, db: AsyncSession):
|
||||||
|
"""
|
||||||
|
Invalidate all unused OTP codes for user
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
db: Database session
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await db.execute(
|
||||||
|
select(OTPCode).where(
|
||||||
|
and_(
|
||||||
|
OTPCode.user_id == user_id,
|
||||||
|
OTPCode.used == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
otps = result.scalars().all()
|
||||||
|
|
||||||
|
for otp in otps:
|
||||||
|
otp.used = True
|
||||||
|
|
||||||
|
if otps:
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Invalidated {len(otps)} OTP codes for user {user_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error invalidating OTP codes: {e}")
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_expired_otp_codes(db: AsyncSession):
|
||||||
|
"""
|
||||||
|
Cleanup expired and used OTP codes older than 24 hours
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cutoff_time = datetime.utcnow() - timedelta(hours=24)
|
||||||
|
result = await db.execute(
|
||||||
|
select(OTPCode).where(
|
||||||
|
and_(
|
||||||
|
OTPCode.created_at < cutoff_time,
|
||||||
|
OTPCode.used == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expired_otps = result.scalars().all()
|
||||||
|
|
||||||
|
for otp in expired_otps:
|
||||||
|
await db.delete(otp)
|
||||||
|
|
||||||
|
if expired_otps:
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Cleaned up {len(expired_otps)} old OTP codes")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error cleaning up old OTP codes: {e}")
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_identifier(identifier: str, db: AsyncSession) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Search user by ID or username
|
||||||
|
|
||||||
|
Args:
|
||||||
|
identifier: User ID (number) or username (string with @ or without)
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User object or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Try to find by user_id
|
||||||
|
try:
|
||||||
|
user_id = int(identifier)
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Search by username in database
|
||||||
|
username = identifier.lstrip('@')
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.username == username)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
|
||||||
|
# If not found in database, try to find by username via Telegram API
|
||||||
|
# This is needed for cases when user is not yet registered in database
|
||||||
|
# Bot API doesn't allow searching users by username directly,
|
||||||
|
# but we can try get_chat if bot has already interacted with user
|
||||||
|
try:
|
||||||
|
from bot.modules.task_scheduler.executor import get_app_client
|
||||||
|
app_client = get_app_client()
|
||||||
|
if app_client:
|
||||||
|
try:
|
||||||
|
# Try to get user information via get_chat
|
||||||
|
# This only works if bot has already interacted with user
|
||||||
|
chat = await app_client.get_chat(username)
|
||||||
|
if chat and hasattr(chat, 'id'):
|
||||||
|
# Found user, return None to create later
|
||||||
|
logger.info(f"Found user {chat.id} by username {username} via Telegram API")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to get user {username} via get_chat: {e}")
|
||||||
|
# get_chat didn't work, user must use ID for first login
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to get user information via Telegram API: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error searching for user: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
138
web/utils/redis_session.py
Normal file
138
web/utils/redis_session.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""
|
||||||
|
Redis session utilities
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from shared.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_redis_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis_client():
|
||||||
|
"""Get Redis client"""
|
||||||
|
global _redis_client
|
||||||
|
if _redis_client is None:
|
||||||
|
try:
|
||||||
|
import redis.asyncio as redis
|
||||||
|
_redis_client = redis.Redis(
|
||||||
|
host=settings.REDIS_HOST,
|
||||||
|
port=settings.REDIS_PORT,
|
||||||
|
db=settings.REDIS_DB,
|
||||||
|
decode_responses=True
|
||||||
|
)
|
||||||
|
logger.info(f"Connected to Redis: {settings.REDIS_HOST}:{settings.REDIS_PORT}")
|
||||||
|
except ImportError:
|
||||||
|
logger.error("Redis library not installed. Install: pip install redis")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error connecting to Redis: {e}")
|
||||||
|
return None
|
||||||
|
return _redis_client
|
||||||
|
|
||||||
|
|
||||||
|
async def create_redis_session(user_id: int) -> str:
|
||||||
|
"""Create session in Redis"""
|
||||||
|
import secrets
|
||||||
|
from web.utils.csrf import generate_csrf_token
|
||||||
|
|
||||||
|
session_id = secrets.token_urlsafe(32)
|
||||||
|
csrf_token = generate_csrf_token()
|
||||||
|
|
||||||
|
session_data = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"is_owner": user_id == settings.OWNER_ID,
|
||||||
|
"created_at": datetime.utcnow().isoformat(),
|
||||||
|
"expires_at": (datetime.utcnow() + timedelta(days=7)).isoformat(),
|
||||||
|
"csrf_token": csrf_token
|
||||||
|
}
|
||||||
|
|
||||||
|
redis_client = get_redis_client()
|
||||||
|
if not redis_client:
|
||||||
|
logger.warning("Redis client unavailable, session not created")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Save session with TTL 7 days
|
||||||
|
await redis_client.setex(
|
||||||
|
f"session:{session_id}",
|
||||||
|
7 * 24 * 3600, # 7 days in seconds
|
||||||
|
json.dumps(session_data)
|
||||||
|
)
|
||||||
|
logger.debug(f"Session created in Redis: {session_id}")
|
||||||
|
return session_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating session in Redis: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_redis_session(session_id: str) -> Optional[dict]:
|
||||||
|
"""Get session from Redis"""
|
||||||
|
redis_client = get_redis_client()
|
||||||
|
if not redis_client:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await redis_client.get(f"session:{session_id}")
|
||||||
|
if data:
|
||||||
|
session_data = json.loads(data)
|
||||||
|
# Check expires_at
|
||||||
|
expires_at_str = session_data.get('expires_at')
|
||||||
|
if expires_at_str:
|
||||||
|
try:
|
||||||
|
expires_at = datetime.fromisoformat(expires_at_str)
|
||||||
|
if datetime.utcnow() < expires_at:
|
||||||
|
return session_data
|
||||||
|
else:
|
||||||
|
# Session expired, delete
|
||||||
|
logger.debug(f"Session {session_id} expired in Redis (expires_at: {expires_at})")
|
||||||
|
await delete_redis_session(session_id)
|
||||||
|
return None
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.warning(f"Failed to parse expires_at for session {session_id}: {e}")
|
||||||
|
# If parsing failed, consider session valid (Redis TTL will still work)
|
||||||
|
return session_data
|
||||||
|
else:
|
||||||
|
# If expires_at missing, return session (Redis TTL will still work)
|
||||||
|
return session_data
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting session from Redis: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_redis_session(session_id: str) -> None:
|
||||||
|
"""Delete session from Redis"""
|
||||||
|
redis_client = get_redis_client()
|
||||||
|
if redis_client:
|
||||||
|
try:
|
||||||
|
await redis_client.delete(f"session:{session_id}")
|
||||||
|
logger.debug(f"Session deleted from Redis: {session_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting session from Redis: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def update_redis_session(session_id: str, session_data: dict) -> bool:
|
||||||
|
"""Update session in Redis"""
|
||||||
|
redis_client = get_redis_client()
|
||||||
|
if not redis_client:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
expires_at = datetime.fromisoformat(session_data['expires_at'])
|
||||||
|
ttl = int((expires_at - datetime.utcnow()).total_seconds())
|
||||||
|
if ttl > 0:
|
||||||
|
await redis_client.setex(
|
||||||
|
f"session:{session_id}",
|
||||||
|
ttl,
|
||||||
|
json.dumps(session_data)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating session in Redis: {e}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
Reference in New Issue
Block a user