Add source

This commit is contained in:
2025-12-04 00:12:56 +03:00
parent b75875df5e
commit 0cb7045e7a
75 changed files with 9055 additions and 0 deletions

40
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
Generic single-database configuration.

114
alembic/env.py Normal file
View 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
View 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"}

View 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
View File

@@ -0,0 +1,4 @@
"""
Telegram Bot Application
"""

7
bot/config.py Normal file
View File

@@ -0,0 +1,7 @@
"""
Bot configuration
"""
from shared.config import settings
__all__ = ["settings"]

4
bot/modules/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""
Модули Telegram бота
"""

View File

@@ -0,0 +1,4 @@
"""
Access control module
"""

View 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

View 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")

View 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

View 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)}")

View File

@@ -0,0 +1,4 @@
"""
Database module (ORM)
"""

View File

@@ -0,0 +1,7 @@
"""
ORM models for bot (imported from shared)
"""
from shared.database.models import User, Task, Download
__all__ = ["User", "Task", "Download"]

View 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']

View File

@@ -0,0 +1,4 @@
"""
Media loader module
"""

View 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

View 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

View 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

View File

@@ -0,0 +1,4 @@
"""
Message handler module
"""

View 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")

View 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")

View 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()

View File

@@ -0,0 +1,4 @@
"""
Task scheduler module
"""

View 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()

View 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}. Возможно, она уже завершается.")

View 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
View File

@@ -0,0 +1,4 @@
"""
Bot utilities
"""

147
bot/utils/file_cleanup.py Normal file
View 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
View 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
View 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
View 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
View 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)

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
"""
Shared modules for bot and web application
"""

131
shared/config.py Normal file
View 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!")

View File

@@ -0,0 +1,4 @@
"""
Общие модели базы данных
"""

View 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
View 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
View 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()

View 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

Binary file not shown.

4
web/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""
Web application for administrative management
"""

4
web/admin/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""
Web site admin panel
"""

859
web/admin/routes.py Normal file
View 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
)

View File

@@ -0,0 +1,2 @@
# Директория для статических файлов (CSS, JS, изображения)

View 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>

View 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 %}

View 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 %}

View 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>

View 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">&nbsp;</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 %}

View 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 %}

View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
"""
Web application configuration
"""
from shared.config import settings
__all__ = ["settings"]

4
web/utils/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""
Web application utilities
"""

310
web/utils/auth.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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