Dev 13 #15

Merged
KerradKerridi merged 15 commits from dev-13 into master 2026-02-01 21:29:08 +00:00
116 changed files with 9804 additions and 6461 deletions

94
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,94 @@
name: CI pipeline
on:
push:
branches: [ 'dev-*', 'feature-*' ]
pull_request:
branches: [ 'dev-*', 'feature-*', 'main' ]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
name: Test & Code Quality
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Code style check (isort + Black, one order — no conflict)
run: |
echo "🔍 Applying isort then black (pyproject.toml: isort profile=black)..."
python -m isort .
python -m black .
echo "🔍 Checking that repo is already formatted (no diff after isort+black)..."
git diff --exit-code || (
echo "❌ Code style drift. Locally run: isort . && black . && git add -A && git commit -m 'style: isort + black'"
exit 1
)
- name: Linting (flake8) - Critical errors
run: |
echo "🔍 Running flake8 linter (critical errors only)..."
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics || true
- name: Linting (flake8) - Warnings
run: |
echo "🔍 Running flake8 linter (warnings)..."
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics || true
continue-on-error: true
- name: Run tests
run: |
echo "🧪 Running tests..."
python -m pytest tests/ -v --tb=short
- name: Send test success notification
if: success()
uses: appleboy/telegram-action@v1.0.0
with:
to: ${{ secrets.TELEGRAM_CHAT_ID }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
message: |
✅ CI Tests Passed
📦 Repository: telegram-helper-bot
🌿 Branch: ${{ github.ref_name }}
📝 Commit: ${{ github.sha }}
👤 Author: ${{ github.actor }}
✅ All tests passed! Code quality checks completed successfully.
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
continue-on-error: true
- name: Send test failure notification
if: failure()
uses: appleboy/telegram-action@v1.0.0
with:
to: ${{ secrets.TELEGRAM_CHAT_ID }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
message: |
❌ CI Tests Failed
📦 Repository: telegram-helper-bot
🌿 Branch: ${{ github.ref_name }}
📝 Commit: ${{ github.sha }}
👤 Author: ${{ github.actor }}
❌ Tests failed! Deployment blocked. Please fix the issues and try again.
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
continue-on-error: true

357
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,357 @@
name: Deploy to Production
on:
push:
branches: [ main ]
workflow_dispatch:
inputs:
action:
description: 'Action to perform'
required: true
type: choice
options:
- deploy
- rollback
rollback_commit:
description: 'Commit hash to rollback to (optional, uses last successful if empty)'
required: false
type: string
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Production
if: |
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'deploy')
concurrency:
group: production-deploy-telegram-helper-bot
cancel-in-progress: false
environment:
name: production
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
- name: Deploy to server
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }}
username: ${{ vars.SERVER_USER || secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }}
script: |
set -e
export TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
export TELEGRAM_TEST_BOT_TOKEN="${{ secrets.TELEGRAM_TEST_BOT_TOKEN }}"
echo "🚀 Starting deployment to production..."
cd /home/prod
# Сохраняем информацию о коммите
CURRENT_COMMIT=$(git rev-parse HEAD)
COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s" || echo "Unknown")
COMMIT_AUTHOR=$(git log -1 --pretty=format:"%an" || echo "Unknown")
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
echo "📝 Current commit: $CURRENT_COMMIT"
echo "📝 Commit message: $COMMIT_MESSAGE"
echo "📝 Author: $COMMIT_AUTHOR"
# Записываем в историю деплоев
HISTORY_FILE="/home/prod/.deploy_history_telegram_helper_bot.txt"
HISTORY_SIZE="${DEPLOY_HISTORY_SIZE:-10}"
echo "${TIMESTAMP}|${CURRENT_COMMIT}|${COMMIT_MESSAGE}|${COMMIT_AUTHOR}|deploying" >> "$HISTORY_FILE"
tail -n "$HISTORY_SIZE" "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" && mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE"
# Обновляем код
echo "📥 Pulling latest changes from main..."
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
cd /home/prod/bots/telegram-helper-bot
git fetch origin main
git reset --hard origin/main
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
NEW_COMMIT=$(git rev-parse HEAD)
echo "✅ Code updated: $CURRENT_COMMIT → $NEW_COMMIT"
# Применяем миграции БД перед перезапуском контейнера
echo "🔄 Applying database migrations..."
DB_PATH="/home/prod/bots/telegram-helper-bot/database/tg-bot-database.db"
if [ -f "$DB_PATH" ]; then
cd /home/prod/bots/telegram-helper-bot
python3 scripts/apply_migrations.py --db "$DB_PATH" || {
echo "❌ Ошибка при применении миграций!"
exit 1
}
echo "✅ Миграции применены успешно"
else
echo "⚠️ База данных не найдена, пропускаем миграции (будет создана при первом запуске)"
fi
# Валидация docker-compose
echo "🔍 Validating docker-compose configuration..."
cd /home/prod
docker-compose config > /dev/null || exit 1
echo "✅ docker-compose.yml is valid"
# Проверка дискового пространства
MIN_FREE_GB=5
AVAILABLE_SPACE=$(df -BG /home/prod 2>/dev/null | tail -1 | awk '{print $4}' | sed 's/G//' || echo "0")
echo "💾 Available disk space: ${AVAILABLE_SPACE}GB"
if [ "$AVAILABLE_SPACE" -lt "$MIN_FREE_GB" ]; then
echo "⚠️ Insufficient disk space! Cleaning up Docker resources..."
docker system prune -f --volumes || true
fi
# Пересобираем и перезапускаем контейнер бота
echo "🔨 Rebuilding and restarting telegram-bot container..."
cd /home/prod
export TELEGRAM_BOT_TOKEN TELEGRAM_TEST_BOT_TOKEN
docker-compose stop telegram-bot || true
docker-compose build --pull telegram-bot
docker-compose up -d telegram-bot
echo "✅ Telegram bot container rebuilt and started"
# Ждем немного и проверяем healthcheck
echo "⏳ Waiting for container to start..."
sleep 10
if docker ps | grep -q bots_telegram_bot; then
echo "✅ Container is running"
else
echo "❌ Container failed to start!"
docker logs bots_telegram_bot --tail 50 || true
exit 1
fi
- name: Update deploy history
if: always()
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }}
username: ${{ vars.SERVER_USER || secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }}
script: |
HISTORY_FILE="/home/prod/.deploy_history_telegram_helper_bot.txt"
if [ -f "$HISTORY_FILE" ]; then
DEPLOY_STATUS="failed"
if [ "${{ job.status }}" = "success" ]; then
DEPLOY_STATUS="success"
fi
sed -i '$s/|deploying$/|'"$DEPLOY_STATUS"'/' "$HISTORY_FILE"
echo "✅ Deploy history updated: $DEPLOY_STATUS"
fi
- name: Send deployment notification
if: always()
uses: appleboy/telegram-action@v1.0.0
with:
to: ${{ secrets.TELEGRAM_CHAT_ID }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
message: |
${{ job.status == 'success' && '✅' || '❌' }} Deployment: ${{ job.status }}
📦 Repository: telegram-helper-bot
🌿 Branch: main
📝 Commit: ${{ github.sha }}
👤 Author: ${{ github.actor }}
${{ job.status == 'success' && '✅ Deployment successful! Container restarted with migrations applied.' || '❌ Deployment failed! Check logs for details.' }}
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
continue-on-error: true
- name: Get PR body from merged PR
if: job.status == 'success' && github.event_name == 'push'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "🔍 Searching for merged PR associated with commit ${{ github.sha }}..."
# Находим последний мерженный PR для main ветки по merge commit SHA
COMMIT_SHA="${{ github.sha }}"
PR_NUMBER=$(gh pr list --state merged --base main --limit 10 --json number,mergeCommit --jq ".[] | select(.mergeCommit.oid == \"$COMMIT_SHA\") | .number" | head -1)
# Если не нашли по merge commit, ищем последний мерженный PR
if [ -z "$PR_NUMBER" ]; then
echo "⚠️ PR not found by merge commit, trying to get latest merged PR..."
PR_NUMBER=$(gh pr list --state merged --base main --limit 1 --json number --jq '.[0].number')
fi
if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then
echo "✅ Found PR #$PR_NUMBER"
PR_BODY=$(gh pr view $PR_NUMBER --json body --jq '.body // ""')
if [ -n "$PR_BODY" ] && [ "$PR_BODY" != "null" ]; then
echo "PR_BODY<<EOF" >> $GITHUB_ENV
echo "$PR_BODY" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
echo "✅ PR body extracted successfully"
else
echo "⚠️ PR body is empty"
fi
else
echo "⚠️ No merged PR found for this commit"
fi
continue-on-error: true
- name: Send PR body to important logs
if: job.status == 'success' && github.event_name == 'push' && env.PR_BODY != ''
uses: appleboy/telegram-action@v1.0.0
with:
to: ${{ secrets.IMPORTANT_LOGS_CHAT }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
message: |
📋 Pull Request Description (PR #${{ env.PR_NUMBER }}):
${{ env.PR_BODY }}
🔗 PR: ${{ github.server_url }}/${{ github.repository }}/pull/${{ env.PR_NUMBER }}
📝 Commit: ${{ github.sha }}
continue-on-error: true
rollback:
runs-on: ubuntu-latest
name: Rollback to Previous Version
if: |
github.event_name == 'workflow_dispatch' &&
github.event.inputs.action == 'rollback'
environment:
name: production
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
- name: Rollback on server
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }}
username: ${{ vars.SERVER_USER || secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }}
script: |
set -e
export TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
export TELEGRAM_TEST_BOT_TOKEN="${{ secrets.TELEGRAM_TEST_BOT_TOKEN }}"
echo "🔄 Starting rollback..."
cd /home/prod
# Определяем коммит для отката
ROLLBACK_COMMIT="${{ github.event.inputs.rollback_commit }}"
HISTORY_FILE="/home/prod/.deploy_history_telegram_helper_bot.txt"
if [ -z "$ROLLBACK_COMMIT" ]; then
echo "📝 No commit specified, finding last successful deploy..."
if [ -f "$HISTORY_FILE" ]; then
ROLLBACK_COMMIT=$(grep "|success$" "$HISTORY_FILE" | tail -1 | cut -d'|' -f2 || echo "")
fi
if [ -z "$ROLLBACK_COMMIT" ]; then
echo "❌ No successful deploy found in history!"
echo "💡 Please specify commit hash manually or check deploy history"
exit 1
fi
fi
echo "📝 Rolling back to commit: $ROLLBACK_COMMIT"
# Проверяем, что коммит существует
cd /home/prod/bots/telegram-helper-bot
if ! git cat-file -e "$ROLLBACK_COMMIT" 2>/dev/null; then
echo "❌ Commit $ROLLBACK_COMMIT not found!"
exit 1
fi
# Сохраняем текущий коммит
CURRENT_COMMIT=$(git rev-parse HEAD)
COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s" "$ROLLBACK_COMMIT" || echo "Rollback")
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
echo "📝 Current commit: $CURRENT_COMMIT"
echo "📝 Target commit: $ROLLBACK_COMMIT"
echo "📝 Commit message: $COMMIT_MESSAGE"
# Исправляем права перед откатом
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
# Откатываем код
echo "🔄 Rolling back code..."
git fetch origin main
git reset --hard "$ROLLBACK_COMMIT"
# Исправляем права после отката
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
echo "✅ Code rolled back: $CURRENT_COMMIT → $ROLLBACK_COMMIT"
# Валидация docker-compose
echo "🔍 Validating docker-compose configuration..."
cd /home/prod
docker-compose config > /dev/null || exit 1
echo "✅ docker-compose.yml is valid"
# Проверка дискового пространства
MIN_FREE_GB=5
AVAILABLE_SPACE=$(df -BG /home/prod 2>/dev/null | tail -1 | awk '{print $4}' | sed 's/G//' || echo "0")
echo "💾 Available disk space: ${AVAILABLE_SPACE}GB"
if [ "$AVAILABLE_SPACE" -lt "$MIN_FREE_GB" ]; then
echo "⚠️ Insufficient disk space! Cleaning up Docker resources..."
docker system prune -f --volumes || true
fi
# Пересобираем и перезапускаем контейнер
echo "🔨 Rebuilding and restarting telegram-bot container..."
cd /home/prod
export TELEGRAM_BOT_TOKEN TELEGRAM_TEST_BOT_TOKEN
docker-compose stop telegram-bot || true
docker-compose build --pull telegram-bot
docker-compose up -d telegram-bot
echo "✅ Telegram bot container rebuilt and started"
# Записываем в историю
echo "${TIMESTAMP}|${ROLLBACK_COMMIT}|Rollback to: ${COMMIT_MESSAGE}|github-actions|rolled_back" >> "$HISTORY_FILE"
HISTORY_SIZE="${DEPLOY_HISTORY_SIZE:-10}"
tail -n "$HISTORY_SIZE" "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" && mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE"
echo "✅ Rollback completed successfully"
- name: Send rollback notification
if: always()
uses: appleboy/telegram-action@v1.0.0
with:
to: ${{ secrets.TELEGRAM_CHAT_ID }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
message: |
${{ job.status == 'success' && '🔄' || '❌' }} Rollback: ${{ job.status }}
📦 Repository: telegram-helper-bot
🌿 Branch: main
📝 Rolled back to: ${{ github.event.inputs.rollback_commit || 'Last successful commit' }}
👤 Triggered by: ${{ github.actor }}
${{ job.status == 'success' && '✅ Rollback completed successfully! Services restored to previous version.' || '❌ Rollback failed! Check logs for details.' }}
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
continue-on-error: true

3
.gitignore vendored
View File

@@ -34,6 +34,9 @@ database/test.db
test.db test.db
*.db *.db
# Случайно созданный файл при использовании SQLite :memory: не по назначению
:memory:
# IDE and editor files # IDE and editor files
.vscode/ .vscode/
.idea/ .idea/

BIN
:memory:

Binary file not shown.

View File

@@ -11,15 +11,35 @@
from .async_db import AsyncBotDB from .async_db import AsyncBotDB
from .base import DatabaseConnection from .base import DatabaseConnection
from .models import (Admin, AudioListenRecord, AudioMessage, AudioModerate, from .models import (
BlacklistUser, MessageContentLink, Migration, PostContent, Admin,
TelegramPost, User, UserMessage) AudioListenRecord,
AudioMessage,
AudioModerate,
BlacklistUser,
MessageContentLink,
Migration,
PostContent,
TelegramPost,
User,
UserMessage,
)
from .repository_factory import RepositoryFactory from .repository_factory import RepositoryFactory
# Для обратной совместимости экспортируем старый интерфейс # Для обратной совместимости экспортируем старый интерфейс
__all__ = [ __all__ = [
'User', 'BlacklistUser', 'UserMessage', 'TelegramPost', 'PostContent', "User",
'MessageContentLink', 'Admin', 'Migration', 'AudioMessage', 'AudioListenRecord', 'AudioModerate', "BlacklistUser",
'RepositoryFactory', 'DatabaseConnection', 'AsyncBotDB' "UserMessage",
"TelegramPost",
"PostContent",
"MessageContentLink",
"Admin",
"Migration",
"AudioMessage",
"AudioListenRecord",
"AudioModerate",
"RepositoryFactory",
"DatabaseConnection",
"AsyncBotDB",
] ]

View File

@@ -2,202 +2,253 @@ from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import aiosqlite import aiosqlite
from database.models import (Admin, AudioMessage, BlacklistHistoryRecord,
BlacklistUser, PostContent, TelegramPost, User, from database.models import (
UserMessage) Admin,
AudioMessage,
BlacklistHistoryRecord,
BlacklistUser,
PostContent,
TelegramPost,
User,
UserMessage,
)
from database.repository_factory import RepositoryFactory from database.repository_factory import RepositoryFactory
class AsyncBotDB: class AsyncBotDB:
"""Новый асинхронный класс для работы с базой данных с использованием репозиториев.""" """Новый асинхронный класс для работы с базой данных с использованием репозиториев."""
def __init__(self, db_path: str): def __init__(self, db_path: str):
self.factory = RepositoryFactory(db_path) self.factory = RepositoryFactory(db_path)
self.logger = self.factory.users.logger self.logger = self.factory.users.logger
async def create_tables(self): async def create_tables(self):
"""Создание всех таблиц в базе данных.""" """Создание всех таблиц в базе данных."""
await self.factory.create_all_tables() await self.factory.create_all_tables()
self.logger.info("Все таблицы успешно созданы") self.logger.info("Все таблицы успешно созданы")
# Методы для работы с пользователями # Методы для работы с пользователями
async def user_exists(self, user_id: int) -> bool: async def user_exists(self, user_id: int) -> bool:
"""Проверяет, существует ли пользователь в базе данных.""" """Проверяет, существует ли пользователь в базе данных."""
return await self.factory.users.user_exists(user_id) return await self.factory.users.user_exists(user_id)
async def add_user(self, user: User): async def add_user(self, user: User):
"""Добавление нового пользователя.""" """Добавление нового пользователя."""
await self.factory.users.add_user(user) await self.factory.users.add_user(user)
async def get_user_info(self, user_id: int) -> Optional[Dict[str, Any]]: async def get_user_info(self, user_id: int) -> Optional[Dict[str, Any]]:
"""Получение информации о пользователе.""" """Получение информации о пользователе."""
user = await self.factory.users.get_user_info(user_id) user = await self.factory.users.get_user_info(user_id)
if user: if user:
return { return {
'username': user.username, "username": user.username,
'full_name': user.full_name, "full_name": user.full_name,
'has_stickers': user.has_stickers, "has_stickers": user.has_stickers,
'emoji': user.emoji "emoji": user.emoji,
} }
return None return None
async def get_username(self, user_id: int) -> Optional[str]: async def get_username(self, user_id: int) -> Optional[str]:
"""Возвращает username пользователя.""" """Возвращает username пользователя."""
return await self.factory.users.get_username(user_id) return await self.factory.users.get_username(user_id)
async def get_user_id_by_username(self, username: str) -> Optional[int]: async def get_user_id_by_username(self, username: str) -> Optional[int]:
"""Возвращает user_id пользователя по username.""" """Возвращает user_id пользователя по username."""
return await self.factory.users.get_user_id_by_username(username) return await self.factory.users.get_user_id_by_username(username)
async def get_full_name_by_id(self, user_id: int) -> Optional[str]: async def get_full_name_by_id(self, user_id: int) -> Optional[str]:
"""Возвращает full_name пользователя.""" """Возвращает full_name пользователя."""
return await self.factory.users.get_full_name_by_id(user_id) return await self.factory.users.get_full_name_by_id(user_id)
async def get_username_and_full_name(self, user_id: int) -> tuple[Optional[str], Optional[str]]: async def get_username_and_full_name(
self, user_id: int
) -> tuple[Optional[str], Optional[str]]:
"""Возвращает username и full_name пользователя.""" """Возвращает username и full_name пользователя."""
username = await self.get_username(user_id) username = await self.get_username(user_id)
full_name = await self.get_full_name_by_id(user_id) full_name = await self.get_full_name_by_id(user_id)
return username, full_name return username, full_name
async def get_user_by_id(self, user_id: int) -> Optional[User]: async def get_user_by_id(self, user_id: int) -> Optional[User]:
"""Получение пользователя по ID.""" """Получение пользователя по ID."""
return await self.factory.users.get_user_by_id(user_id) return await self.factory.users.get_user_by_id(user_id)
async def get_user_first_name(self, user_id: int) -> Optional[str]: async def get_user_first_name(self, user_id: int) -> Optional[str]:
"""Возвращает first_name пользователя.""" """Возвращает first_name пользователя."""
return await self.factory.users.get_user_first_name(user_id) return await self.factory.users.get_user_first_name(user_id)
async def get_all_user_id(self) -> List[int]: async def get_all_user_id(self) -> List[int]:
"""Возвращает список всех user_id.""" """Возвращает список всех user_id."""
return await self.factory.users.get_all_user_ids() return await self.factory.users.get_all_user_ids()
async def get_last_users(self, limit: int = 30) -> List[tuple]: async def get_last_users(self, limit: int = 30) -> List[tuple]:
"""Получение последних пользователей.""" """Получение последних пользователей."""
return await self.factory.users.get_last_users(limit) return await self.factory.users.get_last_users(limit)
async def update_user_date(self, user_id: int): async def update_user_date(self, user_id: int):
"""Обновление даты последнего изменения пользователя.""" """Обновление даты последнего изменения пользователя."""
await self.factory.users.update_user_date(user_id) await self.factory.users.update_user_date(user_id)
async def update_user_info(self, user_id: int, username: str = None, full_name: str = None): async def update_user_info(
self, user_id: int, username: str = None, full_name: str = None
):
"""Обновление информации о пользователе.""" """Обновление информации о пользователе."""
await self.factory.users.update_user_info(user_id, username, full_name) await self.factory.users.update_user_info(user_id, username, full_name)
async def update_user_emoji(self, user_id: int, emoji: str): async def update_user_emoji(self, user_id: int, emoji: str):
"""Обновление эмодзи пользователя.""" """Обновление эмодзи пользователя."""
await self.factory.users.update_user_emoji(user_id, emoji) await self.factory.users.update_user_emoji(user_id, emoji)
async def update_stickers_info(self, user_id: int): async def update_stickers_info(self, user_id: int):
"""Обновление информации о стикерах.""" """Обновление информации о стикерах."""
await self.factory.users.update_stickers_info(user_id) await self.factory.users.update_stickers_info(user_id)
async def get_stickers_info(self, user_id: int) -> bool: async def get_stickers_info(self, user_id: int) -> bool:
"""Получение информации о стикерах.""" """Получение информации о стикерах."""
return await self.factory.users.get_stickers_info(user_id) return await self.factory.users.get_stickers_info(user_id)
async def check_emoji_exists(self, emoji: str) -> bool: async def check_emoji_exists(self, emoji: str) -> bool:
"""Проверка существования эмодзи.""" """Проверка существования эмодзи."""
return await self.factory.users.check_emoji_exists(emoji) return await self.factory.users.check_emoji_exists(emoji)
async def get_user_emoji(self, user_id: int) -> str: async def get_user_emoji(self, user_id: int) -> str:
"""Получает эмодзи пользователя.""" """Получает эмодзи пользователя."""
return await self.factory.users.get_user_emoji(user_id) return await self.factory.users.get_user_emoji(user_id)
async def check_emoji_for_user(self, user_id: int) -> str: async def check_emoji_for_user(self, user_id: int) -> str:
"""Проверяет, есть ли уже у пользователя назначенный emoji.""" """Проверяет, есть ли уже у пользователя назначенный emoji."""
return await self.factory.users.check_emoji_for_user(user_id) return await self.factory.users.check_emoji_for_user(user_id)
# Методы для работы с сообщениями # Методы для работы с сообщениями
async def add_message(self, message_text: str, user_id: int, message_id: int, date: int = None): async def add_message(
self, message_text: str, user_id: int, message_id: int, date: int = None
):
"""Добавление сообщения пользователя.""" """Добавление сообщения пользователя."""
if date is None: if date is None:
from datetime import datetime from datetime import datetime
date = int(datetime.now().timestamp()) date = int(datetime.now().timestamp())
message = UserMessage( message = UserMessage(
message_text=message_text, message_text=message_text,
user_id=user_id, user_id=user_id,
telegram_message_id=message_id, telegram_message_id=message_id,
date=date date=date,
) )
await self.factory.messages.add_message(message) await self.factory.messages.add_message(message)
async def get_user_by_message_id(self, message_id: int) -> Optional[int]: async def get_user_by_message_id(self, message_id: int) -> Optional[int]:
"""Получение пользователя по message_id.""" """Получение пользователя по message_id."""
return await self.factory.messages.get_user_by_message_id(message_id) return await self.factory.messages.get_user_by_message_id(message_id)
# Методы для работы с постами # Методы для работы с постами
async def add_post(self, post: TelegramPost): async def add_post(self, post: TelegramPost):
"""Добавление поста.""" """Добавление поста."""
await self.factory.posts.add_post(post) await self.factory.posts.add_post(post)
async def update_helper_message(self, message_id: int, helper_message_id: int): async def update_helper_message(self, message_id: int, helper_message_id: int):
"""Обновление helper сообщения.""" """Обновление helper сообщения."""
await self.factory.posts.update_helper_message(message_id, helper_message_id) await self.factory.posts.update_helper_message(message_id, helper_message_id)
async def add_post_content(self, post_id: int, message_id: int, content_name: str, content_type: str): async def add_post_content(
self, post_id: int, message_id: int, content_name: str, content_type: str
):
"""Добавление контента поста.""" """Добавление контента поста."""
return await self.factory.posts.add_post_content(post_id, message_id, content_name, content_type) return await self.factory.posts.add_post_content(
post_id, message_id, content_name, content_type
)
async def add_message_link(self, post_id: int, message_id: int) -> bool: async def add_message_link(self, post_id: int, message_id: int) -> bool:
"""Добавляет связь между post_id и message_id в таблицу message_link_to_content.""" """Добавляет связь между post_id и message_id в таблицу message_link_to_content."""
return await self.factory.posts.add_message_link(post_id, message_id) return await self.factory.posts.add_message_link(post_id, message_id)
async def get_post_content_from_telegram_by_last_id(self, last_post_id: int) -> List[Tuple[str, str]]: async def get_post_content_from_telegram_by_last_id(
self, last_post_id: int
) -> List[Tuple[str, str]]:
"""Получает контент поста по helper_text_message_id.""" """Получает контент поста по helper_text_message_id."""
return await self.factory.posts.get_post_content_by_helper_id(last_post_id) return await self.factory.posts.get_post_content_by_helper_id(last_post_id)
async def get_post_content_by_helper_id(self, helper_message_id: int) -> List[Tuple[str, str]]: async def get_post_content_by_helper_id(
self, helper_message_id: int
) -> List[Tuple[str, str]]:
"""Алиас для get_post_content_from_telegram_by_last_id (используется callback-сервисом).""" """Алиас для get_post_content_from_telegram_by_last_id (используется callback-сервисом)."""
return await self.get_post_content_from_telegram_by_last_id(helper_message_id) return await self.get_post_content_from_telegram_by_last_id(helper_message_id)
async def get_post_content_by_message_id(self, message_id: int) -> List[Tuple[str, str]]: async def get_post_content_by_message_id(
self, message_id: int
) -> List[Tuple[str, str]]:
"""Получает контент одиночного поста по message_id.""" """Получает контент одиночного поста по message_id."""
return await self.factory.posts.get_post_content_by_message_id(message_id) return await self.factory.posts.get_post_content_by_message_id(message_id)
async def update_published_message_id(self, original_message_id: int, published_message_id: int): async def update_published_message_id(
self, original_message_id: int, published_message_id: int
):
"""Обновляет published_message_id для опубликованного поста.""" """Обновляет published_message_id для опубликованного поста."""
await self.factory.posts.update_published_message_id(original_message_id, published_message_id) await self.factory.posts.update_published_message_id(
original_message_id, published_message_id
async def add_published_post_content(self, published_message_id: int, content_path: str, content_type: str): )
async def add_published_post_content(
self, published_message_id: int, content_path: str, content_type: str
):
"""Добавляет контент опубликованного поста.""" """Добавляет контент опубликованного поста."""
return await self.factory.posts.add_published_post_content(published_message_id, content_path, content_type) return await self.factory.posts.add_published_post_content(
published_message_id, content_path, content_type
async def get_published_post_content(self, published_message_id: int) -> List[Tuple[str, str]]: )
async def get_published_post_content(
self, published_message_id: int
) -> List[Tuple[str, str]]:
"""Получает контент опубликованного поста.""" """Получает контент опубликованного поста."""
return await self.factory.posts.get_published_post_content(published_message_id) return await self.factory.posts.get_published_post_content(published_message_id)
async def get_post_text_from_telegram_by_last_id(self, last_post_id: int) -> Optional[str]: async def get_post_text_from_telegram_by_last_id(
self, last_post_id: int
) -> Optional[str]:
"""Получает текст поста по helper_text_message_id.""" """Получает текст поста по helper_text_message_id."""
return await self.factory.posts.get_post_text_by_helper_id(last_post_id) return await self.factory.posts.get_post_text_by_helper_id(last_post_id)
async def get_post_text_by_helper_id(self, helper_message_id: int) -> Optional[str]: async def get_post_text_by_helper_id(self, helper_message_id: int) -> Optional[str]:
"""Алиас для get_post_text_from_telegram_by_last_id (используется callback-сервисом).""" """Алиас для get_post_text_from_telegram_by_last_id (используется callback-сервисом)."""
return await self.get_post_text_from_telegram_by_last_id(helper_message_id) return await self.get_post_text_from_telegram_by_last_id(helper_message_id)
async def get_post_ids_from_telegram_by_last_id(self, last_post_id: int) -> List[int]: async def get_post_ids_from_telegram_by_last_id(
self, last_post_id: int
) -> List[int]:
"""Получает ID сообщений по helper_text_message_id.""" """Получает ID сообщений по helper_text_message_id."""
return await self.factory.posts.get_post_ids_by_helper_id(last_post_id) return await self.factory.posts.get_post_ids_by_helper_id(last_post_id)
async def get_post_ids_by_helper_id(self, helper_message_id: int) -> List[int]: async def get_post_ids_by_helper_id(self, helper_message_id: int) -> List[int]:
"""Алиас для get_post_ids_from_telegram_by_last_id (используется callback-сервисом).""" """Алиас для get_post_ids_from_telegram_by_last_id (используется callback-сервисом)."""
return await self.get_post_ids_from_telegram_by_last_id(helper_message_id) return await self.get_post_ids_from_telegram_by_last_id(helper_message_id)
async def get_author_id_by_message_id(self, message_id: int) -> Optional[int]: async def get_author_id_by_message_id(self, message_id: int) -> Optional[int]:
"""Получает ID автора по message_id.""" """Получает ID автора по message_id."""
return await self.factory.posts.get_author_id_by_message_id(message_id) return await self.factory.posts.get_author_id_by_message_id(message_id)
async def get_author_id_by_helper_message_id(self, helper_text_message_id: int) -> Optional[int]: async def get_author_id_by_helper_message_id(
self, helper_text_message_id: int
) -> Optional[int]:
"""Получает ID автора по helper_text_message_id.""" """Получает ID автора по helper_text_message_id."""
return await self.factory.posts.get_author_id_by_helper_message_id(helper_text_message_id) return await self.factory.posts.get_author_id_by_helper_message_id(
helper_text_message_id
async def get_post_text_and_anonymity_by_message_id(self, message_id: int) -> tuple[Optional[str], Optional[bool]]: )
async def get_post_text_and_anonymity_by_message_id(
self, message_id: int
) -> tuple[Optional[str], Optional[bool]]:
"""Получает текст и is_anonymous поста по message_id.""" """Получает текст и is_anonymous поста по message_id."""
return await self.factory.posts.get_post_text_and_anonymity_by_message_id(message_id) return await self.factory.posts.get_post_text_and_anonymity_by_message_id(
message_id
async def get_post_text_and_anonymity_by_helper_id(self, helper_message_id: int) -> tuple[Optional[str], Optional[bool]]: )
async def get_post_text_and_anonymity_by_helper_id(
self, helper_message_id: int
) -> tuple[Optional[str], Optional[bool]]:
"""Получает текст и is_anonymous поста по helper_text_message_id.""" """Получает текст и is_anonymous поста по helper_text_message_id."""
return await self.factory.posts.get_post_text_and_anonymity_by_helper_id(helper_message_id) return await self.factory.posts.get_post_text_and_anonymity_by_helper_id(
helper_message_id
)
async def update_status_by_message_id(self, message_id: int, status: str) -> int: async def update_status_by_message_id(self, message_id: int, status: str) -> int:
"""Обновление статуса поста по message_id (одиночные посты). Возвращает число обновлённых строк.""" """Обновление статуса поста по message_id (одиночные посты). Возвращает число обновлённых строк."""
@@ -210,20 +261,20 @@ class AsyncBotDB:
return await self.factory.posts.update_status_for_media_group_by_helper_id( return await self.factory.posts.update_status_for_media_group_by_helper_id(
helper_message_id, status helper_message_id, status
) )
# Методы для ML Scoring # Методы для ML Scoring
async def get_post_text_by_message_id(self, message_id: int) -> Optional[str]: async def get_post_text_by_message_id(self, message_id: int) -> Optional[str]:
"""Получает текст поста по message_id.""" """Получает текст поста по message_id."""
return await self.factory.posts.get_post_text_by_message_id(message_id) return await self.factory.posts.get_post_text_by_message_id(message_id)
async def update_ml_scores(self, message_id: int, ml_scores_json: str) -> bool: async def update_ml_scores(self, message_id: int, ml_scores_json: str) -> bool:
"""Обновляет ML-скоры для поста.""" """Обновляет ML-скоры для поста."""
return await self.factory.posts.update_ml_scores(message_id, ml_scores_json) return await self.factory.posts.update_ml_scores(message_id, ml_scores_json)
async def get_approved_posts_texts(self, limit: int = 1000) -> List[str]: async def get_approved_posts_texts(self, limit: int = 1000) -> List[str]:
"""Получает тексты одобренных постов для обучения RAG.""" """Получает тексты одобренных постов для обучения RAG."""
return await self.factory.posts.get_approved_posts_texts(limit) return await self.factory.posts.get_approved_posts_texts(limit)
async def get_declined_posts_texts(self, limit: int = 1000) -> List[str]: async def get_declined_posts_texts(self, limit: int = 1000) -> List[str]:
"""Получает тексты отклоненных постов для обучения RAG.""" """Получает тексты отклоненных постов для обучения RAG."""
return await self.factory.posts.get_declined_posts_texts(limit) return await self.factory.posts.get_declined_posts_texts(limit)
@@ -248,7 +299,7 @@ class AsyncBotDB:
ban_author=ban_author, ban_author=ban_author,
) )
await self.factory.blacklist.add_user(blacklist_user) await self.factory.blacklist.add_user(blacklist_user)
# Логируем в историю банов # Логируем в историю банов
try: try:
date_ban = int(datetime.now().timestamp()) date_ban = int(datetime.now().timestamp())
@@ -265,7 +316,7 @@ class AsyncBotDB:
self.logger.error( self.logger.error(
f"Ошибка записи в историю банов для user_id={user_id}: {e}" f"Ошибка записи в историю банов для user_id={user_id}: {e}"
) )
async def delete_user_blacklist(self, user_id: int) -> bool: async def delete_user_blacklist(self, user_id: int) -> bool:
""" """
Удаляет пользователя из черного списка. Удаляет пользователя из черного списка.
@@ -280,174 +331,206 @@ class AsyncBotDB:
self.logger.error( self.logger.error(
f"Ошибка обновления истории при разбане для user_id={user_id}: {e}" f"Ошибка обновления истории при разбане для user_id={user_id}: {e}"
) )
# Удаляем из черного списка (критический путь) # Удаляем из черного списка (критический путь)
return await self.factory.blacklist.remove_user(user_id) return await self.factory.blacklist.remove_user(user_id)
async def check_user_in_blacklist(self, user_id: int) -> bool: async def check_user_in_blacklist(self, user_id: int) -> bool:
"""Проверяет, существует ли запись с данным user_id в blacklist.""" """Проверяет, существует ли запись с данным user_id в blacklist."""
return await self.factory.blacklist.user_exists(user_id) return await self.factory.blacklist.user_exists(user_id)
async def get_blacklist_users(self, offset: int = 0, limit: int = 10) -> List[tuple]: async def get_blacklist_users(
self, offset: int = 0, limit: int = 10
) -> List[tuple]:
"""Получение пользователей из черного списка.""" """Получение пользователей из черного списка."""
users = await self.factory.blacklist.get_all_users(offset, limit) users = await self.factory.blacklist.get_all_users(offset, limit)
return [(user.user_id, user.message_for_user, user.date_to_unban) for user in users] return [
(user.user_id, user.message_for_user, user.date_to_unban) for user in users
]
async def get_banned_users_from_db(self) -> List[tuple]: async def get_banned_users_from_db(self) -> List[tuple]:
"""Возвращает список пользователей в черном списке.""" """Возвращает список пользователей в черном списке."""
users = await self.factory.blacklist.get_all_users_no_limit() users = await self.factory.blacklist.get_all_users_no_limit()
return [(user.user_id, user.message_for_user, user.date_to_unban) for user in users] return [
(user.user_id, user.message_for_user, user.date_to_unban) for user in users
async def get_banned_users_from_db_with_limits(self, offset: int, limit: int) -> List[tuple]: ]
async def get_banned_users_from_db_with_limits(
self, offset: int, limit: int
) -> List[tuple]:
"""Возвращает список пользователей в черном списке с учетом смещения и ограничения.""" """Возвращает список пользователей в черном списке с учетом смещения и ограничения."""
users = await self.factory.blacklist.get_all_users(offset, limit) users = await self.factory.blacklist.get_all_users(offset, limit)
return [(user.user_id, user.message_for_user, user.date_to_unban) for user in users] return [
(user.user_id, user.message_for_user, user.date_to_unban) for user in users
]
async def get_blacklist_users_by_id(self, user_id: int) -> Optional[tuple]: async def get_blacklist_users_by_id(self, user_id: int) -> Optional[tuple]:
"""Возвращает информацию о пользователе в черном списке по user_id.""" """Возвращает информацию о пользователе в черном списке по user_id."""
user = await self.factory.blacklist.get_user(user_id) user = await self.factory.blacklist.get_user(user_id)
if user: if user:
return (user.user_id, user.message_for_user, user.date_to_unban) return (user.user_id, user.message_for_user, user.date_to_unban)
return None return None
async def get_blacklist_count(self) -> int: async def get_blacklist_count(self) -> int:
"""Получение количества пользователей в черном списке.""" """Получение количества пользователей в черном списке."""
return await self.factory.blacklist.get_count() return await self.factory.blacklist.get_count()
async def get_users_for_unblock_today(self, current_timestamp: int) -> Dict[int, int]: async def get_users_for_unblock_today(
self, current_timestamp: int
) -> Dict[int, int]:
"""Возвращает список пользователей, у которых истек срок блокировки.""" """Возвращает список пользователей, у которых истек срок блокировки."""
return await self.factory.blacklist.get_users_for_unblock_today(current_timestamp) return await self.factory.blacklist.get_users_for_unblock_today(
current_timestamp
)
# Методы для работы с администраторами # Методы для работы с администраторами
async def add_admin(self, user_id: int, role: str = "admin"): async def add_admin(self, user_id: int, role: str = "admin"):
"""Добавление администратора.""" """Добавление администратора."""
admin = Admin(user_id=user_id, role=role) admin = Admin(user_id=user_id, role=role)
await self.factory.admins.add_admin(admin) await self.factory.admins.add_admin(admin)
async def remove_admin(self, user_id: int): async def remove_admin(self, user_id: int):
"""Удаление администратора.""" """Удаление администратора."""
await self.factory.admins.remove_admin(user_id) await self.factory.admins.remove_admin(user_id)
async def is_admin(self, user_id: int) -> bool: async def is_admin(self, user_id: int) -> bool:
"""Проверка, является ли пользователь администратором.""" """Проверка, является ли пользователь администратором."""
return await self.factory.admins.is_admin(user_id) return await self.factory.admins.is_admin(user_id)
async def get_all_admins(self) -> list[Admin]: async def get_all_admins(self) -> list[Admin]:
"""Получение всех администраторов.""" """Получение всех администраторов."""
return await self.factory.admins.get_all_admins() return await self.factory.admins.get_all_admins()
# Методы для работы с аудио # Методы для работы с аудио
async def add_audio_record(self, file_name: str, author_id: int, date_added: str, async def add_audio_record(
listen_count: int, file_id: str): self,
file_name: str,
author_id: int,
date_added: str,
listen_count: int,
file_id: str,
):
"""Добавляет информацию о войсе пользователя.""" """Добавляет информацию о войсе пользователя."""
audio = AudioMessage( audio = AudioMessage(
file_name=file_name, file_name=file_name,
author_id=author_id, author_id=author_id,
date_added=date_added, date_added=date_added,
listen_count=listen_count, listen_count=listen_count,
file_id=file_id file_id=file_id,
) )
await self.factory.audio.add_audio_record(audio) await self.factory.audio.add_audio_record(audio)
async def add_audio_record_simple(self, file_name: str, user_id: int, date_added) -> None: async def add_audio_record_simple(
self, file_name: str, user_id: int, date_added
) -> None:
"""Добавляет простую запись об аудио файле.""" """Добавляет простую запись об аудио файле."""
await self.factory.audio.add_audio_record_simple(file_name, user_id, date_added) await self.factory.audio.add_audio_record_simple(file_name, user_id, date_added)
async def last_date_audio(self) -> Optional[str]: async def last_date_audio(self) -> Optional[str]:
"""Получает дату последнего войса.""" """Получает дату последнего войса."""
return await self.factory.audio.get_last_date_audio() return await self.factory.audio.get_last_date_audio()
async def get_last_user_audio_record(self, user_id: int) -> bool: async def get_last_user_audio_record(self, user_id: int) -> bool:
"""Получает данные о количестве записей пользователя.""" """Получает данные о количестве записей пользователя."""
count = await self.factory.audio.get_user_audio_records_count(user_id) count = await self.factory.audio.get_user_audio_records_count(user_id)
return bool(count) return bool(count)
async def get_path_for_audio_record(self, user_id: int) -> Optional[str]: async def get_path_for_audio_record(self, user_id: int) -> Optional[str]:
"""Получает данные о названии файла.""" """Получает данные о названии файла."""
return await self.factory.audio.get_path_for_audio_record(user_id) return await self.factory.audio.get_path_for_audio_record(user_id)
async def check_listen_audio(self, user_id: int) -> List[str]: async def check_listen_audio(self, user_id: int) -> List[str]:
"""Проверяет прослушано ли аудио пользователем.""" """Проверяет прослушано ли аудио пользователем."""
return await self.factory.audio.check_listen_audio(user_id) return await self.factory.audio.check_listen_audio(user_id)
async def mark_listened_audio(self, file_name: str, user_id: int): async def mark_listened_audio(self, file_name: str, user_id: int):
"""Отмечает аудио прослушанным для конкретного пользователя.""" """Отмечает аудио прослушанным для конкретного пользователя."""
await self.factory.audio.mark_listened_audio(file_name, user_id) await self.factory.audio.mark_listened_audio(file_name, user_id)
async def get_id_for_audio_record(self, user_id: int) -> int: async def get_id_for_audio_record(self, user_id: int) -> int:
"""Получает следующий номер аудио сообщения пользователя.""" """Получает следующий номер аудио сообщения пользователя."""
return await self.factory.audio.get_user_audio_records_count(user_id) return await self.factory.audio.get_user_audio_records_count(user_id)
async def get_user_audio_records_count(self, user_id: int) -> int: async def get_user_audio_records_count(self, user_id: int) -> int:
"""Получает количество аудио записей пользователя.""" """Получает количество аудио записей пользователя."""
return await self.factory.audio.get_user_audio_records_count(user_id) return await self.factory.audio.get_user_audio_records_count(user_id)
async def refresh_listen_audio(self, user_id: int): async def refresh_listen_audio(self, user_id: int):
"""Очищает всю информацию о прослушанных аудио пользователем.""" """Очищает всю информацию о прослушанных аудио пользователем."""
await self.factory.audio.refresh_listen_audio(user_id) await self.factory.audio.refresh_listen_audio(user_id)
async def delete_listen_count_for_user(self, user_id: int): async def delete_listen_count_for_user(self, user_id: int):
"""Удаляет данные о прослушанных пользователем аудио.""" """Удаляет данные о прослушанных пользователем аудио."""
await self.factory.audio.delete_listen_count_for_user(user_id) await self.factory.audio.delete_listen_count_for_user(user_id)
async def get_user_id_by_file_name(self, file_name: str) -> Optional[int]: async def get_user_id_by_file_name(self, file_name: str) -> Optional[int]:
"""Получает user_id пользователя по имени файла.""" """Получает user_id пользователя по имени файла."""
return await self.factory.audio.get_user_id_by_file_name(file_name) return await self.factory.audio.get_user_id_by_file_name(file_name)
async def get_date_by_file_name(self, file_name: str) -> Optional[str]: async def get_date_by_file_name(self, file_name: str) -> Optional[str]:
"""Получает дату добавления файла.""" """Получает дату добавления файла."""
return await self.factory.audio.get_date_by_file_name(file_name) return await self.factory.audio.get_date_by_file_name(file_name)
# Методы для voice bot # Методы для voice bot
async def set_user_id_and_message_id_for_voice_bot(self, message_id: int, user_id: int) -> bool: async def set_user_id_and_message_id_for_voice_bot(
self, message_id: int, user_id: int
) -> bool:
"""Устанавливает связь между message_id и user_id для voice bot.""" """Устанавливает связь между message_id и user_id для voice bot."""
return await self.factory.audio.set_user_id_and_message_id_for_voice_bot(message_id, user_id) return await self.factory.audio.set_user_id_and_message_id_for_voice_bot(
message_id, user_id
async def get_user_id_by_message_id_for_voice_bot(self, message_id: int) -> Optional[int]: )
async def get_user_id_by_message_id_for_voice_bot(
self, message_id: int
) -> Optional[int]:
"""Получает user_id пользователя по message_id для voice bot.""" """Получает user_id пользователя по message_id для voice bot."""
return await self.factory.audio.get_user_id_by_message_id_for_voice_bot(message_id) return await self.factory.audio.get_user_id_by_message_id_for_voice_bot(
message_id
)
async def delete_audio_moderate_record(self, message_id: int) -> None: async def delete_audio_moderate_record(self, message_id: int) -> None:
"""Удаляет запись из таблицы audio_moderate по message_id.""" """Удаляет запись из таблицы audio_moderate по message_id."""
await self.factory.audio.delete_audio_moderate_record(message_id) await self.factory.audio.delete_audio_moderate_record(message_id)
async def get_all_audio_records(self) -> List[Dict[str, Any]]: async def get_all_audio_records(self) -> List[Dict[str, Any]]:
"""Получить все записи аудио сообщений.""" """Получить все записи аудио сообщений."""
return await self.factory.audio.get_all_audio_records() return await self.factory.audio.get_all_audio_records()
async def delete_audio_record_by_file_name(self, file_name: str) -> None: async def delete_audio_record_by_file_name(self, file_name: str) -> None:
"""Удалить запись аудио сообщения по имени файла.""" """Удалить запись аудио сообщения по имени файла."""
await self.factory.audio.delete_audio_record_by_file_name(file_name) await self.factory.audio.delete_audio_record_by_file_name(file_name)
# Методы для миграций # Методы для миграций
async def create_table(self, sql_script: str): async def create_table(self, sql_script: str):
"""Создает таблицу в базе. Используется в миграциях.""" """Создает таблицу в базе. Используется в миграциях."""
await self.factory.migrations.create_table_from_sql(sql_script) await self.factory.migrations.create_table_from_sql(sql_script)
# Методы для voice bot welcome tracking # Методы для voice bot welcome tracking
async def check_voice_bot_welcome_received(self, user_id: int) -> bool: async def check_voice_bot_welcome_received(self, user_id: int) -> bool:
"""Проверяет, получал ли пользователь приветственное сообщение от voice_bot.""" """Проверяет, получал ли пользователь приветственное сообщение от voice_bot."""
return await self.factory.users.check_voice_bot_welcome_received(user_id) return await self.factory.users.check_voice_bot_welcome_received(user_id)
async def mark_voice_bot_welcome_received(self, user_id: int) -> bool: async def mark_voice_bot_welcome_received(self, user_id: int) -> bool:
"""Отмечает, что пользователь получил приветственное сообщение от voice_bot.""" """Отмечает, что пользователь получил приветственное сообщение от voice_bot."""
return await self.factory.users.mark_voice_bot_welcome_received(user_id) return await self.factory.users.mark_voice_bot_welcome_received(user_id)
# Методы для проверки целостности # Методы для проверки целостности
async def check_database_integrity(self): async def check_database_integrity(self):
"""Проверяет целостность базы данных и очищает WAL файлы.""" """Проверяет целостность базы данных и очищает WAL файлы."""
await self.factory.check_database_integrity() await self.factory.check_database_integrity()
async def cleanup_wal_files(self): async def cleanup_wal_files(self):
"""Очищает WAL файлы и переключает на DELETE режим для предотвращения проблем с I/O.""" """Очищает WAL файлы и переключает на DELETE режим для предотвращения проблем с I/O."""
await self.factory.cleanup_wal_files() await self.factory.cleanup_wal_files()
async def close(self): async def close(self):
"""Закрытие соединений.""" """Закрытие соединений."""
# Соединения закрываются в каждом методе # Соединения закрываются в каждом методе
pass pass
async def fetch_one(self, query: str, params: tuple = ()) -> Optional[Dict[str, Any]]: async def fetch_one(
self, query: str, params: tuple = ()
) -> Optional[Dict[str, Any]]:
"""Выполняет SQL запрос и возвращает один результат.""" """Выполняет SQL запрос и возвращает один результат."""
try: try:
async with aiosqlite.connect(self.factory.db_path) as conn: async with aiosqlite.connect(self.factory.db_path) as conn:

View File

@@ -2,17 +2,18 @@ import os
from typing import Optional from typing import Optional
import aiosqlite import aiosqlite
from logs.custom_logger import logger from logs.custom_logger import logger
class DatabaseConnection: class DatabaseConnection:
"""Базовый класс для работы с базой данных.""" """Базовый класс для работы с базой данных."""
def __init__(self, db_path: str): def __init__(self, db_path: str):
self.db_path = os.path.abspath(db_path) self.db_path = os.path.abspath(db_path)
self.logger = logger self.logger = logger
self.logger.info(f'Инициация базы данных: {self.db_path}') self.logger.info(f"Инициация базы данных: {self.db_path}")
async def _get_connection(self): async def _get_connection(self):
"""Получение асинхронного соединения с базой данных.""" """Получение асинхронного соединения с базой данных."""
try: try:
@@ -28,7 +29,7 @@ class DatabaseConnection:
except Exception as e: except Exception as e:
self.logger.error(f"Ошибка при получении соединения: {e}") self.logger.error(f"Ошибка при получении соединения: {e}")
raise raise
async def _execute_query(self, query: str, params: tuple = ()): async def _execute_query(self, query: str, params: tuple = ()):
"""Выполнение запроса с автоматическим закрытием соединения.""" """Выполнение запроса с автоматическим закрытием соединения."""
conn = None conn = None
@@ -43,7 +44,7 @@ class DatabaseConnection:
finally: finally:
if conn: if conn:
await conn.close() await conn.close()
async def _execute_query_with_result(self, query: str, params: tuple = ()): async def _execute_query_with_result(self, query: str, params: tuple = ()):
"""Выполнение запроса с результатом и автоматическим закрытием соединения.""" """Выполнение запроса с результатом и автоматическим закрытием соединения."""
conn = None conn = None
@@ -59,7 +60,7 @@ class DatabaseConnection:
finally: finally:
if conn: if conn:
await conn.close() await conn.close()
async def _execute_transaction(self, queries: list): async def _execute_transaction(self, queries: list):
"""Выполнение транзакции с несколькими запросами.""" """Выполнение транзакции с несколькими запросами."""
conn = None conn = None
@@ -76,7 +77,7 @@ class DatabaseConnection:
finally: finally:
if conn: if conn:
await conn.close() await conn.close()
async def check_database_integrity(self): async def check_database_integrity(self):
"""Проверяет целостность базы данных и очищает WAL файлы.""" """Проверяет целостность базы данных и очищает WAL файлы."""
conn = None conn = None
@@ -84,14 +85,16 @@ class DatabaseConnection:
conn = await self._get_connection() conn = await self._get_connection()
result = await conn.execute("PRAGMA integrity_check") result = await conn.execute("PRAGMA integrity_check")
integrity_result = await result.fetchone() integrity_result = await result.fetchone()
if integrity_result and integrity_result[0] == "ok": if integrity_result and integrity_result[0] == "ok":
self.logger.info("Проверка целостности базы данных прошла успешно") self.logger.info("Проверка целостности базы данных прошла успешно")
await conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") await conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
self.logger.info("WAL файлы очищены") self.logger.info("WAL файлы очищены")
else: else:
self.logger.warning(f"Проблемы с целостностью базы данных: {integrity_result}") self.logger.warning(
f"Проблемы с целостностью базы данных: {integrity_result}"
)
except Exception as e: except Exception as e:
self.logger.error(f"Ошибка при проверке целостности базы данных: {e}") self.logger.error(f"Ошибка при проверке целостности базы данных: {e}")
raise raise

View File

@@ -6,6 +6,7 @@ from typing import List, Optional
@dataclass @dataclass
class User: class User:
"""Модель пользователя.""" """Модель пользователя."""
user_id: int user_id: int
first_name: str first_name: str
full_name: str full_name: str
@@ -22,6 +23,7 @@ class User:
@dataclass @dataclass
class BlacklistUser: class BlacklistUser:
"""Модель пользователя в черном списке.""" """Модель пользователя в черном списке."""
user_id: int user_id: int
message_for_user: Optional[str] = None message_for_user: Optional[str] = None
date_to_unban: Optional[int] = None date_to_unban: Optional[int] = None
@@ -32,6 +34,7 @@ class BlacklistUser:
@dataclass @dataclass
class BlacklistHistoryRecord: class BlacklistHistoryRecord:
"""Модель записи истории банов/разбанов.""" """Модель записи истории банов/разбанов."""
user_id: int user_id: int
message_for_user: Optional[str] = None message_for_user: Optional[str] = None
date_ban: int = 0 date_ban: int = 0
@@ -45,6 +48,7 @@ class BlacklistHistoryRecord:
@dataclass @dataclass
class UserMessage: class UserMessage:
"""Модель сообщения пользователя.""" """Модель сообщения пользователя."""
message_text: str message_text: str
user_id: int user_id: int
telegram_message_id: int telegram_message_id: int
@@ -54,6 +58,7 @@ class UserMessage:
@dataclass @dataclass
class TelegramPost: class TelegramPost:
"""Модель поста из Telegram.""" """Модель поста из Telegram."""
message_id: int message_id: int
text: str text: str
author_id: int author_id: int
@@ -66,6 +71,7 @@ class TelegramPost:
@dataclass @dataclass
class PostContent: class PostContent:
"""Модель контента поста.""" """Модель контента поста."""
message_id: int message_id: int
content_name: str content_name: str
content_type: str content_type: str
@@ -74,6 +80,7 @@ class PostContent:
@dataclass @dataclass
class MessageContentLink: class MessageContentLink:
"""Модель связи сообщения с контентом.""" """Модель связи сообщения с контентом."""
post_id: int post_id: int
message_id: int message_id: int
@@ -81,6 +88,7 @@ class MessageContentLink:
@dataclass @dataclass
class Admin: class Admin:
"""Модель администратора.""" """Модель администратора."""
user_id: int user_id: int
role: str = "admin" role: str = "admin"
created_at: Optional[str] = None created_at: Optional[str] = None
@@ -89,6 +97,7 @@ class Admin:
@dataclass @dataclass
class Migration: class Migration:
"""Модель миграции.""" """Модель миграции."""
script_name: str script_name: str
applied_at: Optional[str] = None applied_at: Optional[str] = None
@@ -96,6 +105,7 @@ class Migration:
@dataclass @dataclass
class AudioMessage: class AudioMessage:
"""Модель аудио сообщения.""" """Модель аудио сообщения."""
file_name: str file_name: str
author_id: int author_id: int
date_added: str date_added: str
@@ -106,6 +116,7 @@ class AudioMessage:
@dataclass @dataclass
class AudioListenRecord: class AudioListenRecord:
"""Модель записи прослушивания аудио.""" """Модель записи прослушивания аудио."""
file_name: str file_name: str
user_id: int user_id: int
is_listen: bool = False is_listen: bool = False
@@ -114,5 +125,6 @@ class AudioListenRecord:
@dataclass @dataclass
class AudioModerate: class AudioModerate:
"""Модель для voice bot.""" """Модель для voice bot."""
message_id: int message_id: int
user_id: int user_id: int

View File

@@ -22,7 +22,12 @@ from .post_repository import PostRepository
from .user_repository import UserRepository from .user_repository import UserRepository
__all__ = [ __all__ = [
'UserRepository', 'BlacklistRepository', 'BlacklistHistoryRepository', "UserRepository",
'MessageRepository', 'PostRepository', 'AdminRepository', 'AudioRepository', "BlacklistRepository",
'MigrationRepository' "BlacklistHistoryRepository",
"MessageRepository",
"PostRepository",
"AdminRepository",
"AudioRepository",
"MigrationRepository",
] ]

View File

@@ -6,70 +6,68 @@ from database.models import Admin
class AdminRepository(DatabaseConnection): class AdminRepository(DatabaseConnection):
"""Репозиторий для работы с администраторами.""" """Репозиторий для работы с администраторами."""
async def create_tables(self): async def create_tables(self):
"""Создание таблицы администраторов.""" """Создание таблицы администраторов."""
# Включаем поддержку внешних ключей # Включаем поддержку внешних ключей
await self._execute_query("PRAGMA foreign_keys = ON") await self._execute_query("PRAGMA foreign_keys = ON")
query = ''' query = """
CREATE TABLE IF NOT EXISTS admins ( CREATE TABLE IF NOT EXISTS admins (
user_id INTEGER NOT NULL PRIMARY KEY, user_id INTEGER NOT NULL PRIMARY KEY,
role TEXT DEFAULT 'admin', role TEXT DEFAULT 'admin',
created_at INTEGER DEFAULT (strftime('%s', 'now')), created_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
) )
''' """
await self._execute_query(query) await self._execute_query(query)
self.logger.info("Таблица администраторов создана") self.logger.info("Таблица администраторов создана")
async def add_admin(self, admin: Admin) -> None: async def add_admin(self, admin: Admin) -> None:
"""Добавление администратора.""" """Добавление администратора."""
query = "INSERT INTO admins (user_id, role) VALUES (?, ?)" query = "INSERT INTO admins (user_id, role) VALUES (?, ?)"
params = (admin.user_id, admin.role) params = (admin.user_id, admin.role)
await self._execute_query(query, params) await self._execute_query(query, params)
self.logger.info(f"Администратор добавлен: user_id={admin.user_id}, role={admin.role}") self.logger.info(
f"Администратор добавлен: user_id={admin.user_id}, role={admin.role}"
)
async def remove_admin(self, user_id: int) -> None: async def remove_admin(self, user_id: int) -> None:
"""Удаление администратора.""" """Удаление администратора."""
query = "DELETE FROM admins WHERE user_id = ?" query = "DELETE FROM admins WHERE user_id = ?"
await self._execute_query(query, (user_id,)) await self._execute_query(query, (user_id,))
self.logger.info(f"Администратор удален: user_id={user_id}") self.logger.info(f"Администратор удален: user_id={user_id}")
async def is_admin(self, user_id: int) -> bool: async def is_admin(self, user_id: int) -> bool:
"""Проверка, является ли пользователь администратором.""" """Проверка, является ли пользователь администратором."""
query = "SELECT 1 FROM admins WHERE user_id = ?" query = "SELECT 1 FROM admins WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,)) rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
return bool(row) return bool(row)
async def get_admin(self, user_id: int) -> Optional[Admin]: async def get_admin(self, user_id: int) -> Optional[Admin]:
"""Получение информации об администраторе.""" """Получение информации об администраторе."""
query = "SELECT user_id, role, created_at FROM admins WHERE user_id = ?" query = "SELECT user_id, role, created_at FROM admins WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,)) rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
return Admin( return Admin(
user_id=row[0], user_id=row[0], role=row[1], created_at=row[2] if len(row) > 2 else None
role=row[1],
created_at=row[2] if len(row) > 2 else None
) )
return None return None
async def get_all_admins(self) -> list[Admin]: async def get_all_admins(self) -> list[Admin]:
"""Получение всех администраторов.""" """Получение всех администраторов."""
query = "SELECT user_id, role, created_at FROM admins ORDER BY created_at DESC" query = "SELECT user_id, role, created_at FROM admins ORDER BY created_at DESC"
rows = await self._execute_query_with_result(query) rows = await self._execute_query_with_result(query)
admins = [] admins = []
for row in rows: for row in rows:
admin = Admin( admin = Admin(
user_id=row[0], user_id=row[0], role=row[1], created_at=row[2] if len(row) > 2 else None
role=row[1],
created_at=row[2] if len(row) > 2 else None
) )
admins.append(admin) admins.append(admin)
return admins return admins

View File

@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from database.base import DatabaseConnection from database.base import DatabaseConnection
@@ -7,15 +7,15 @@ from database.models import AudioListenRecord, AudioMessage, AudioModerate
class AudioRepository(DatabaseConnection): class AudioRepository(DatabaseConnection):
"""Репозиторий для работы с аудио сообщениями.""" """Репозиторий для работы с аудио сообщениями."""
async def enable_foreign_keys(self): async def enable_foreign_keys(self):
"""Включает поддержку внешних ключей.""" """Включает поддержку внешних ключей."""
await self._execute_query("PRAGMA foreign_keys = ON;") await self._execute_query("PRAGMA foreign_keys = ON;")
async def create_tables(self): async def create_tables(self):
"""Создание таблиц для аудио.""" """Создание таблиц для аудио."""
# Таблица аудио сообщений # Таблица аудио сообщений
audio_query = ''' audio_query = """
CREATE TABLE IF NOT EXISTS audio_message_reference ( CREATE TABLE IF NOT EXISTS audio_message_reference (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
file_name TEXT NOT NULL UNIQUE, file_name TEXT NOT NULL UNIQUE,
@@ -23,33 +23,33 @@ class AudioRepository(DatabaseConnection):
date_added INTEGER NOT NULL, date_added INTEGER NOT NULL,
FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE
) )
''' """
await self._execute_query(audio_query) await self._execute_query(audio_query)
# Таблица прослушивания аудио # Таблица прослушивания аудио
listen_query = ''' listen_query = """
CREATE TABLE IF NOT EXISTS user_audio_listens ( CREATE TABLE IF NOT EXISTS user_audio_listens (
file_name TEXT NOT NULL, file_name TEXT NOT NULL,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
PRIMARY KEY (file_name, user_id), PRIMARY KEY (file_name, user_id),
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
) )
''' """
await self._execute_query(listen_query) await self._execute_query(listen_query)
# Таблица для voice bot # Таблица для voice bot
voice_query = ''' voice_query = """
CREATE TABLE IF NOT EXISTS audio_moderate ( CREATE TABLE IF NOT EXISTS audio_moderate (
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
message_id INTEGER, message_id INTEGER,
PRIMARY KEY (user_id, message_id), PRIMARY KEY (user_id, message_id),
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
) )
''' """
await self._execute_query(voice_query) await self._execute_query(voice_query)
self.logger.info("Таблицы для аудио созданы") self.logger.info("Таблицы для аудио созданы")
async def add_audio_record(self, audio: AudioMessage) -> None: async def add_audio_record(self, audio: AudioMessage) -> None:
"""Добавляет информацию о войсе пользователя.""" """Добавляет информацию о войсе пользователя."""
query = """ query = """
@@ -63,13 +63,17 @@ class AudioRepository(DatabaseConnection):
date_timestamp = int(audio.date_added.timestamp()) date_timestamp = int(audio.date_added.timestamp())
else: else:
date_timestamp = audio.date_added date_timestamp = audio.date_added
params = (audio.file_name, audio.author_id, date_timestamp) params = (audio.file_name, audio.author_id, date_timestamp)
await self._execute_query(query, params) await self._execute_query(query, params)
self.logger.info(f"Аудио добавлено: file_name={audio.file_name}, author_id={audio.author_id}") self.logger.info(
f"Аудио добавлено: file_name={audio.file_name}, author_id={audio.author_id}"
async def add_audio_record_simple(self, file_name: str, user_id: int, date_added) -> None: )
async def add_audio_record_simple(
self, file_name: str, user_id: int, date_added
) -> None:
"""Добавляет информацию о войсе пользователя (упрощенная версия).""" """Добавляет информацию о войсе пользователя (упрощенная версия)."""
query = """ query = """
INSERT INTO audio_message_reference (file_name, author_id, date_added) INSERT INTO audio_message_reference (file_name, author_id, date_added)
@@ -82,30 +86,30 @@ class AudioRepository(DatabaseConnection):
date_timestamp = int(date_added.timestamp()) date_timestamp = int(date_added.timestamp())
else: else:
date_timestamp = date_added date_timestamp = date_added
params = (file_name, user_id, date_timestamp) params = (file_name, user_id, date_timestamp)
await self._execute_query(query, params) await self._execute_query(query, params)
self.logger.info(f"Аудио добавлено: file_name={file_name}, user_id={user_id}") self.logger.info(f"Аудио добавлено: file_name={file_name}, user_id={user_id}")
async def get_last_date_audio(self) -> Optional[int]: async def get_last_date_audio(self) -> Optional[int]:
"""Получает дату последнего войса.""" """Получает дату последнего войса."""
query = "SELECT date_added FROM audio_message_reference ORDER BY date_added DESC LIMIT 1" query = "SELECT date_added FROM audio_message_reference ORDER BY date_added DESC LIMIT 1"
rows = await self._execute_query_with_result(query) rows = await self._execute_query_with_result(query)
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
self.logger.info(f"Последняя дата аудио: {row[0]}") self.logger.info(f"Последняя дата аудио: {row[0]}")
return row[0] return row[0]
return None return None
async def get_user_audio_records_count(self, user_id: int) -> int: async def get_user_audio_records_count(self, user_id: int) -> int:
"""Получает количество записей пользователя.""" """Получает количество записей пользователя."""
query = "SELECT COUNT(*) FROM audio_message_reference WHERE author_id = ?" query = "SELECT COUNT(*) FROM audio_message_reference WHERE author_id = ?"
rows = await self._execute_query_with_result(query, (user_id,)) rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
return row[0] if row else 0 return row[0] if row else 0
async def get_path_for_audio_record(self, user_id: int) -> Optional[str]: async def get_path_for_audio_record(self, user_id: int) -> Optional[str]:
"""Получает название последнего файла пользователя.""" """Получает название последнего файла пользователя."""
query = """ query = """
@@ -115,7 +119,7 @@ class AudioRepository(DatabaseConnection):
rows = await self._execute_query_with_result(query, (user_id,)) rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
return row[0] if row else None return row[0] if row else None
async def check_listen_audio(self, user_id: int) -> List[str]: async def check_listen_audio(self, user_id: int) -> List[str]:
"""Проверяет непрослушанные аудио для пользователя.""" """Проверяет непрослушанные аудио для пользователя."""
query = """ query = """
@@ -125,115 +129,129 @@ class AudioRepository(DatabaseConnection):
WHERE l.user_id = ? AND l.file_name IS NOT NULL WHERE l.user_id = ? AND l.file_name IS NOT NULL
""" """
listened_files = await self._execute_query_with_result(query, (user_id,)) listened_files = await self._execute_query_with_result(query, (user_id,))
# Получаем все аудио, кроме созданных пользователем # Получаем все аудио, кроме созданных пользователем
all_audio_query = 'SELECT file_name FROM audio_message_reference WHERE author_id <> ?' all_audio_query = (
"SELECT file_name FROM audio_message_reference WHERE author_id <> ?"
)
all_files = await self._execute_query_with_result(all_audio_query, (user_id,)) all_files = await self._execute_query_with_result(all_audio_query, (user_id,))
# Находим непрослушанные # Находим непрослушанные
listened_set = {row[0] for row in listened_files} listened_set = {row[0] for row in listened_files}
all_set = {row[0] for row in all_files} all_set = {row[0] for row in all_files}
new_files = list(all_set - listened_set) new_files = list(all_set - listened_set)
self.logger.info(f"Найдено {len(new_files)} непрослушанных аудио для пользователя {user_id}") self.logger.info(
f"Найдено {len(new_files)} непрослушанных аудио для пользователя {user_id}"
)
return new_files return new_files
async def mark_listened_audio(self, file_name: str, user_id: int) -> None: async def mark_listened_audio(self, file_name: str, user_id: int) -> None:
"""Отмечает аудио прослушанным для пользователя.""" """Отмечает аудио прослушанным для пользователя."""
query = "INSERT OR IGNORE INTO user_audio_listens (file_name, user_id) VALUES (?, ?)" query = "INSERT OR IGNORE INTO user_audio_listens (file_name, user_id) VALUES (?, ?)"
params = (file_name, user_id) params = (file_name, user_id)
await self._execute_query(query, params) await self._execute_query(query, params)
self.logger.info(f"Аудио {file_name} отмечено как прослушанное для пользователя {user_id}") self.logger.info(
f"Аудио {file_name} отмечено как прослушанное для пользователя {user_id}"
)
async def get_user_id_by_file_name(self, file_name: str) -> Optional[int]: async def get_user_id_by_file_name(self, file_name: str) -> Optional[int]:
"""Получает user_id пользователя по имени файла.""" """Получает user_id пользователя по имени файла."""
query = "SELECT author_id FROM audio_message_reference WHERE file_name = ?" query = "SELECT author_id FROM audio_message_reference WHERE file_name = ?"
rows = await self._execute_query_with_result(query, (file_name,)) rows = await self._execute_query_with_result(query, (file_name,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
user_id = row[0] user_id = row[0]
self.logger.info(f"Получен user_id {user_id} для файла {file_name}") self.logger.info(f"Получен user_id {user_id} для файла {file_name}")
return user_id return user_id
return None return None
async def get_date_by_file_name(self, file_name: str) -> Optional[str]: async def get_date_by_file_name(self, file_name: str) -> Optional[str]:
"""Получает дату добавления файла.""" """Получает дату добавления файла."""
query = "SELECT date_added FROM audio_message_reference WHERE file_name = ?" query = "SELECT date_added FROM audio_message_reference WHERE file_name = ?"
rows = await self._execute_query_with_result(query, (file_name,)) rows = await self._execute_query_with_result(query, (file_name,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
date_added = row[0] date_added = row[0]
# Преобразуем UNIX timestamp в читаемую дату # Преобразуем UNIX timestamp в читаемую дату (UTC для одинакового результата везде)
readable_date = datetime.fromtimestamp(date_added).strftime('%d.%m.%Y %H:%M') readable_date = datetime.fromtimestamp(
date_added, tz=timezone.utc
).strftime("%d.%m.%Y %H:%M")
self.logger.info(f"Получена дата {readable_date} для файла {file_name}") self.logger.info(f"Получена дата {readable_date} для файла {file_name}")
return readable_date return readable_date
return None return None
async def refresh_listen_audio(self, user_id: int) -> None: async def refresh_listen_audio(self, user_id: int) -> None:
"""Очищает всю информацию о прослушанных аудио пользователем.""" """Очищает всю информацию о прослушанных аудио пользователем."""
query = "DELETE FROM user_audio_listens WHERE user_id = ?" query = "DELETE FROM user_audio_listens WHERE user_id = ?"
await self._execute_query(query, (user_id,)) await self._execute_query(query, (user_id,))
self.logger.info(f"Очищены записи прослушивания для пользователя {user_id}") self.logger.info(f"Очищены записи прослушивания для пользователя {user_id}")
async def delete_listen_count_for_user(self, user_id: int) -> None: async def delete_listen_count_for_user(self, user_id: int) -> None:
"""Удаляет данные о прослушанных пользователем аудио.""" """Удаляет данные о прослушанных пользователем аудио."""
query = "DELETE FROM user_audio_listens WHERE user_id = ?" query = "DELETE FROM user_audio_listens WHERE user_id = ?"
await self._execute_query(query, (user_id,)) await self._execute_query(query, (user_id,))
self.logger.info(f"Удалены записи прослушивания для пользователя {user_id}") self.logger.info(f"Удалены записи прослушивания для пользователя {user_id}")
# Методы для voice bot # Методы для voice bot
async def set_user_id_and_message_id_for_voice_bot(self, message_id: int, user_id: int) -> bool: async def set_user_id_and_message_id_for_voice_bot(
self, message_id: int, user_id: int
) -> bool:
"""Устанавливает связь между message_id и user_id для voice bot.""" """Устанавливает связь между message_id и user_id для voice bot."""
try: try:
query = "INSERT OR IGNORE INTO audio_moderate (user_id, message_id) VALUES (?, ?)" query = "INSERT OR IGNORE INTO audio_moderate (user_id, message_id) VALUES (?, ?)"
params = (user_id, message_id) params = (user_id, message_id)
await self._execute_query(query, params) await self._execute_query(query, params)
self.logger.info(f"Связь установлена: message_id={message_id}, user_id={user_id}") self.logger.info(
f"Связь установлена: message_id={message_id}, user_id={user_id}"
)
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Ошибка установки связи: {e}") self.logger.error(f"Ошибка установки связи: {e}")
return False return False
async def get_user_id_by_message_id_for_voice_bot(self, message_id: int) -> Optional[int]: async def get_user_id_by_message_id_for_voice_bot(
self, message_id: int
) -> Optional[int]:
"""Получает user_id пользователя по message_id для voice bot.""" """Получает user_id пользователя по message_id для voice bot."""
query = "SELECT user_id FROM audio_moderate WHERE message_id = ?" query = "SELECT user_id FROM audio_moderate WHERE message_id = ?"
rows = await self._execute_query_with_result(query, (message_id,)) rows = await self._execute_query_with_result(query, (message_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
user_id = row[0] user_id = row[0]
self.logger.info(f"Получен user_id {user_id} для message_id {message_id}") self.logger.info(f"Получен user_id {user_id} для message_id {message_id}")
return user_id return user_id
return None return None
async def delete_audio_moderate_record(self, message_id: int) -> None: async def delete_audio_moderate_record(self, message_id: int) -> None:
"""Удаляет запись из таблицы audio_moderate по message_id.""" """Удаляет запись из таблицы audio_moderate по message_id."""
query = "DELETE FROM audio_moderate WHERE message_id = ?" query = "DELETE FROM audio_moderate WHERE message_id = ?"
await self._execute_query(query, (message_id,)) await self._execute_query(query, (message_id,))
self.logger.info(f"Удалена запись из audio_moderate для message_id {message_id}") self.logger.info(
f"Удалена запись из audio_moderate для message_id {message_id}"
)
async def get_all_audio_records(self) -> List[Dict[str, Any]]: async def get_all_audio_records(self) -> List[Dict[str, Any]]:
"""Получить все записи аудио сообщений.""" """Получить все записи аудио сообщений."""
query = "SELECT file_name, author_id, date_added FROM audio_message_reference" query = "SELECT file_name, author_id, date_added FROM audio_message_reference"
rows = await self._execute_query_with_result(query) rows = await self._execute_query_with_result(query)
records = [] records = []
for row in rows: for row in rows:
records.append({ records.append(
'file_name': row[0], {"file_name": row[0], "author_id": row[1], "date_added": row[2]}
'author_id': row[1], )
'date_added': row[2]
})
self.logger.info(f"Получено {len(records)} записей аудио сообщений") self.logger.info(f"Получено {len(records)} записей аудио сообщений")
return records return records
async def delete_audio_record_by_file_name(self, file_name: str) -> None: async def delete_audio_record_by_file_name(self, file_name: str) -> None:
"""Удалить запись аудио сообщения по имени файла.""" """Удалить запись аудио сообщения по имени файла."""
query = "DELETE FROM audio_message_reference WHERE file_name = ?" query = "DELETE FROM audio_message_reference WHERE file_name = ?"
await self._execute_query(query, (file_name,)) await self._execute_query(query, (file_name,))
self.logger.info(f"Удалена запись аудио сообщения: {file_name}") self.logger.info(f"Удалена запись аудио сообщения: {file_name}")

View File

@@ -6,10 +6,10 @@ from database.models import BlacklistHistoryRecord
class BlacklistHistoryRepository(DatabaseConnection): class BlacklistHistoryRepository(DatabaseConnection):
"""Репозиторий для работы с историей банов/разбанов.""" """Репозиторий для работы с историей банов/разбанов."""
async def create_tables(self): async def create_tables(self):
"""Создание таблицы истории банов/разбанов.""" """Создание таблицы истории банов/разбанов."""
query = ''' query = """
CREATE TABLE IF NOT EXISTS blacklist_history ( CREATE TABLE IF NOT EXISTS blacklist_history (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -22,9 +22,9 @@ class BlacklistHistoryRepository(DatabaseConnection):
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE,
FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL
) )
''' """
await self._execute_query(query) await self._execute_query(query)
# Создаем индексы # Создаем индексы
await self._execute_query( await self._execute_query(
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_user_id ON blacklist_history(user_id)" "CREATE INDEX IF NOT EXISTS idx_blacklist_history_user_id ON blacklist_history(user_id)"
@@ -35,9 +35,9 @@ class BlacklistHistoryRepository(DatabaseConnection):
await self._execute_query( await self._execute_query(
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_unban ON blacklist_history(date_unban)" "CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_unban ON blacklist_history(date_unban)"
) )
self.logger.info("Таблица истории банов/разбанов создана") self.logger.info("Таблица истории банов/разбанов создана")
async def add_record_on_ban(self, record: BlacklistHistoryRecord) -> None: async def add_record_on_ban(self, record: BlacklistHistoryRecord) -> None:
"""Добавляет запись о бане в историю.""" """Добавляет запись о бане в историю."""
query = """ query = """
@@ -48,8 +48,9 @@ class BlacklistHistoryRepository(DatabaseConnection):
""" """
# Используем текущее время, если не указано # Используем текущее время, если не указано
from datetime import datetime from datetime import datetime
current_timestamp = int(datetime.now().timestamp()) current_timestamp = int(datetime.now().timestamp())
params = ( params = (
record.user_id, record.user_id,
record.message_for_user, record.message_for_user,
@@ -59,28 +60,29 @@ class BlacklistHistoryRepository(DatabaseConnection):
record.created_at if record.created_at is not None else current_timestamp, record.created_at if record.created_at is not None else current_timestamp,
record.updated_at if record.updated_at is not None else current_timestamp, record.updated_at if record.updated_at is not None else current_timestamp,
) )
await self._execute_query(query, params) await self._execute_query(query, params)
self.logger.info( self.logger.info(
f"Запись о бане добавлена в историю: user_id={record.user_id}, " f"Запись о бане добавлена в историю: user_id={record.user_id}, "
f"date_ban={record.date_ban}" f"date_ban={record.date_ban}"
) )
async def set_unban_date(self, user_id: int, date_unban: int) -> bool: async def set_unban_date(self, user_id: int, date_unban: int) -> bool:
""" """
Обновляет date_unban и updated_at в последней записи (date_unban IS NULL) для пользователя. Обновляет date_unban и updated_at в последней записи (date_unban IS NULL) для пользователя.
Args: Args:
user_id: ID пользователя user_id: ID пользователя
date_unban: Timestamp даты разбана date_unban: Timestamp даты разбана
Returns: Returns:
True если запись обновлена, False если не найдена открытая запись True если запись обновлена, False если не найдена открытая запись
""" """
try: try:
from datetime import datetime from datetime import datetime
current_timestamp = int(datetime.now().timestamp()) current_timestamp = int(datetime.now().timestamp())
# SQLite не поддерживает ORDER BY в UPDATE, поэтому используем подзапрос # SQLite не поддерживает ORDER BY в UPDATE, поэтому используем подзапрос
# Сначала проверяем, есть ли открытая запись # Сначала проверяем, есть ли открытая запись
check_query = """ check_query = """
@@ -90,13 +92,13 @@ class BlacklistHistoryRepository(DatabaseConnection):
LIMIT 1 LIMIT 1
""" """
rows = await self._execute_query_with_result(check_query, (user_id,)) rows = await self._execute_query_with_result(check_query, (user_id,))
if not rows: if not rows:
self.logger.warning( self.logger.warning(
f"Не найдена открытая запись в истории для обновления: user_id={user_id}" f"Не найдена открытая запись в истории для обновления: user_id={user_id}"
) )
return False return False
# Обновляем найденную запись # Обновляем найденную запись
update_query = """ update_query = """
UPDATE blacklist_history UPDATE blacklist_history
@@ -104,11 +106,11 @@ class BlacklistHistoryRepository(DatabaseConnection):
updated_at = ? updated_at = ?
WHERE id = ? WHERE id = ?
""" """
record_id = rows[0][0] record_id = rows[0][0]
params = (date_unban, current_timestamp, record_id) params = (date_unban, current_timestamp, record_id)
await self._execute_query(update_query, params) await self._execute_query(update_query, params)
self.logger.info( self.logger.info(
f"Дата разбана обновлена в истории: user_id={user_id}, date_unban={date_unban}" f"Дата разбана обновлена в истории: user_id={user_id}, date_unban={date_unban}"
) )

View File

@@ -6,10 +6,10 @@ from database.models import BlacklistUser
class BlacklistRepository(DatabaseConnection): class BlacklistRepository(DatabaseConnection):
"""Репозиторий для работы с черным списком.""" """Репозиторий для работы с черным списком."""
async def create_tables(self): async def create_tables(self):
"""Создание таблицы черного списка.""" """Создание таблицы черного списка."""
query = ''' query = """
CREATE TABLE IF NOT EXISTS blacklist ( CREATE TABLE IF NOT EXISTS blacklist (
user_id INTEGER NOT NULL PRIMARY KEY, user_id INTEGER NOT NULL PRIMARY KEY,
message_for_user TEXT, message_for_user TEXT,
@@ -19,10 +19,10 @@ class BlacklistRepository(DatabaseConnection):
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE,
FOREIGN KEY (ban_author) REFERENCES our_users (user_id) ON DELETE SET NULL FOREIGN KEY (ban_author) REFERENCES our_users (user_id) ON DELETE SET NULL
) )
''' """
await self._execute_query(query) await self._execute_query(query)
self.logger.info("Таблица черного списка создана") self.logger.info("Таблица черного списка создана")
async def add_user(self, blacklist_user: BlacklistUser) -> None: async def add_user(self, blacklist_user: BlacklistUser) -> None:
"""Добавляет пользователя в черный список.""" """Добавляет пользователя в черный список."""
query = """ query = """
@@ -35,29 +35,35 @@ class BlacklistRepository(DatabaseConnection):
blacklist_user.date_to_unban, blacklist_user.date_to_unban,
blacklist_user.ban_author, blacklist_user.ban_author,
) )
await self._execute_query(query, params) await self._execute_query(query, params)
self.logger.info(f"Пользователь добавлен в черный список: user_id={blacklist_user.user_id}") self.logger.info(
f"Пользователь добавлен в черный список: user_id={blacklist_user.user_id}"
)
async def remove_user(self, user_id: int) -> bool: async def remove_user(self, user_id: int) -> bool:
"""Удаляет пользователя из черного списка.""" """Удаляет пользователя из черного списка."""
try: try:
query = "DELETE FROM blacklist WHERE user_id = ?" query = "DELETE FROM blacklist WHERE user_id = ?"
await self._execute_query(query, (user_id,)) await self._execute_query(query, (user_id,))
self.logger.info(f"Пользователь с идентификатором {user_id} успешно удален из черного списка.") self.logger.info(
f"Пользователь с идентификатором {user_id} успешно удален из черного списка."
)
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Ошибка удаления пользователя с идентификатором {user_id} " self.logger.error(
f"из таблицы blacklist. Ошибка: {str(e)}") f"Ошибка удаления пользователя с идентификатором {user_id} "
f"из таблицы blacklist. Ошибка: {str(e)}"
)
return False return False
async def user_exists(self, user_id: int) -> bool: async def user_exists(self, user_id: int) -> bool:
"""Проверяет, существует ли запись с данным user_id в blacklist.""" """Проверяет, существует ли запись с данным user_id в blacklist."""
query = "SELECT 1 FROM blacklist WHERE user_id = ?" query = "SELECT 1 FROM blacklist WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,)) rows = await self._execute_query_with_result(query, (user_id,))
self.logger.info(f"Существует ли пользователь: user_id={user_id} Итог: {rows}") self.logger.info(f"Существует ли пользователь: user_id={user_id} Итог: {rows}")
return bool(rows) return bool(rows)
async def get_user(self, user_id: int) -> Optional[BlacklistUser]: async def get_user(self, user_id: int) -> Optional[BlacklistUser]:
"""Возвращает информацию о пользователе в черном списке по user_id.""" """Возвращает информацию о пользователе в черном списке по user_id."""
query = """ query = """
@@ -67,7 +73,7 @@ class BlacklistRepository(DatabaseConnection):
""" """
rows = await self._execute_query_with_result(query, (user_id,)) rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
return BlacklistUser( return BlacklistUser(
user_id=row[0], user_id=row[0],
@@ -77,8 +83,10 @@ class BlacklistRepository(DatabaseConnection):
ban_author=row[4] if len(row) > 4 else None, ban_author=row[4] if len(row) > 4 else None,
) )
return None return None
async def get_all_users(self, offset: int = 0, limit: int = 10) -> List[BlacklistUser]: async def get_all_users(
self, offset: int = 0, limit: int = 10
) -> List[BlacklistUser]:
"""Возвращает список пользователей в черном списке.""" """Возвращает список пользователей в черном списке."""
query = """ query = """
SELECT user_id, message_for_user, date_to_unban, created_at, ban_author SELECT user_id, message_for_user, date_to_unban, created_at, ban_author
@@ -86,7 +94,7 @@ class BlacklistRepository(DatabaseConnection):
LIMIT ?, ? LIMIT ?, ?
""" """
rows = await self._execute_query_with_result(query, (offset, limit)) rows = await self._execute_query_with_result(query, (offset, limit))
users = [] users = []
for row in rows: for row in rows:
users.append( users.append(
@@ -98,10 +106,12 @@ class BlacklistRepository(DatabaseConnection):
ban_author=row[4] if len(row) > 4 else None, ban_author=row[4] if len(row) > 4 else None,
) )
) )
self.logger.info(f"Получен список пользователей в черном списке (offset={offset}, limit={limit}): {len(users)}") self.logger.info(
f"Получен список пользователей в черном списке (offset={offset}, limit={limit}): {len(users)}"
)
return users return users
async def get_all_users_no_limit(self) -> List[BlacklistUser]: async def get_all_users_no_limit(self) -> List[BlacklistUser]:
"""Возвращает список всех пользователей в черном списке без лимитов.""" """Возвращает список всех пользователей в черном списке без лимитов."""
query = """ query = """
@@ -109,7 +119,7 @@ class BlacklistRepository(DatabaseConnection):
FROM blacklist FROM blacklist
""" """
rows = await self._execute_query_with_result(query) rows = await self._execute_query_with_result(query)
users = [] users = []
for row in rows: for row in rows:
users.append( users.append(
@@ -121,19 +131,23 @@ class BlacklistRepository(DatabaseConnection):
ban_author=row[4] if len(row) > 4 else None, ban_author=row[4] if len(row) > 4 else None,
) )
) )
self.logger.info(f"Получен список всех пользователей в черном списке: {len(users)}") self.logger.info(
f"Получен список всех пользователей в черном списке: {len(users)}"
)
return users return users
async def get_users_for_unblock_today(self, current_timestamp: int) -> Dict[int, int]: async def get_users_for_unblock_today(
self, current_timestamp: int
) -> Dict[int, int]:
"""Возвращает список пользователей, у которых истек срок блокировки.""" """Возвращает список пользователей, у которых истек срок блокировки."""
query = "SELECT user_id FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?" query = "SELECT user_id FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?"
rows = await self._execute_query_with_result(query, (current_timestamp,)) rows = await self._execute_query_with_result(query, (current_timestamp,))
users = {user_id: user_id for user_id, in rows} users = {user_id: user_id for user_id, in rows}
self.logger.info(f"Получен список пользователей для разблокировки: {users}") self.logger.info(f"Получен список пользователей для разблокировки: {users}")
return users return users
async def get_count(self) -> int: async def get_count(self) -> int:
"""Получение количества пользователей в черном списке.""" """Получение количества пользователей в черном списке."""
query = "SELECT COUNT(*) FROM blacklist" query = "SELECT COUNT(*) FROM blacklist"

View File

@@ -7,10 +7,10 @@ from database.models import UserMessage
class MessageRepository(DatabaseConnection): class MessageRepository(DatabaseConnection):
"""Репозиторий для работы с сообщениями пользователей.""" """Репозиторий для работы с сообщениями пользователей."""
async def create_tables(self): async def create_tables(self):
"""Создание таблицы сообщений пользователей.""" """Создание таблицы сообщений пользователей."""
query = ''' query = """
CREATE TABLE IF NOT EXISTS user_messages ( CREATE TABLE IF NOT EXISTS user_messages (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
message_text TEXT, message_text TEXT,
@@ -19,24 +19,31 @@ class MessageRepository(DatabaseConnection):
date INTEGER NOT NULL, date INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
) )
''' """
await self._execute_query(query) await self._execute_query(query)
self.logger.info("Таблица сообщений пользователей создана") self.logger.info("Таблица сообщений пользователей создана")
async def add_message(self, message: UserMessage) -> None: async def add_message(self, message: UserMessage) -> None:
"""Добавление сообщения пользователя.""" """Добавление сообщения пользователя."""
if message.date is None: if message.date is None:
message.date = int(datetime.now().timestamp()) message.date = int(datetime.now().timestamp())
query = """ query = """
INSERT INTO user_messages (message_text, user_id, telegram_message_id, date) INSERT INTO user_messages (message_text, user_id, telegram_message_id, date)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
""" """
params = (message.message_text, message.user_id, message.telegram_message_id, message.date) params = (
message.message_text,
message.user_id,
message.telegram_message_id,
message.date,
)
await self._execute_query(query, params) await self._execute_query(query, params)
self.logger.info(f"Новое сообщение добавлено: telegram_message_id={message.telegram_message_id}") self.logger.info(
f"Новое сообщение добавлено: telegram_message_id={message.telegram_message_id}"
)
async def get_user_by_message_id(self, message_id: int) -> Optional[int]: async def get_user_by_message_id(self, message_id: int) -> Optional[int]:
"""Получение пользователя по message_id.""" """Получение пользователя по message_id."""
query = "SELECT user_id FROM user_messages WHERE telegram_message_id = ?" query = "SELECT user_id FROM user_messages WHERE telegram_message_id = ?"

View File

@@ -1,11 +1,13 @@
"""Репозиторий для работы с миграциями базы данных.""" """Репозиторий для работы с миграциями базы данных."""
import aiosqlite import aiosqlite
from database.base import DatabaseConnection from database.base import DatabaseConnection
class MigrationRepository(DatabaseConnection): class MigrationRepository(DatabaseConnection):
"""Репозиторий для управления миграциями базы данных.""" """Репозиторий для управления миграциями базы данных."""
async def create_table(self): async def create_table(self):
"""Создает таблицу migrations, если она не существует.""" """Создает таблицу migrations, если она не существует."""
query = """ query = """
@@ -17,13 +19,15 @@ class MigrationRepository(DatabaseConnection):
""" """
await self._execute_query(query) await self._execute_query(query)
self.logger.info("Таблица migrations создана или уже существует") self.logger.info("Таблица migrations создана или уже существует")
async def get_applied_migrations(self) -> list[str]: async def get_applied_migrations(self) -> list[str]:
"""Возвращает список имен примененных скриптов миграций.""" """Возвращает список имен примененных скриптов миграций."""
conn = None conn = None
try: try:
conn = await self._get_connection() conn = await self._get_connection()
cursor = await conn.execute("SELECT script_name FROM migrations ORDER BY applied_at") cursor = await conn.execute(
"SELECT script_name FROM migrations ORDER BY applied_at"
)
rows = await cursor.fetchall() rows = await cursor.fetchall()
await cursor.close() await cursor.close()
return [row[0] for row in rows] return [row[0] for row in rows]
@@ -33,15 +37,14 @@ class MigrationRepository(DatabaseConnection):
finally: finally:
if conn: if conn:
await conn.close() await conn.close()
async def is_migration_applied(self, script_name: str) -> bool: async def is_migration_applied(self, script_name: str) -> bool:
"""Проверяет, применена ли миграция.""" """Проверяет, применена ли миграция."""
conn = None conn = None
try: try:
conn = await self._get_connection() conn = await self._get_connection()
cursor = await conn.execute( cursor = await conn.execute(
"SELECT COUNT(*) FROM migrations WHERE script_name = ?", "SELECT COUNT(*) FROM migrations WHERE script_name = ?", (script_name,)
(script_name,)
) )
row = await cursor.fetchone() row = await cursor.fetchone()
await cursor.close() await cursor.close()
@@ -52,15 +55,14 @@ class MigrationRepository(DatabaseConnection):
finally: finally:
if conn: if conn:
await conn.close() await conn.close()
async def mark_migration_applied(self, script_name: str) -> None: async def mark_migration_applied(self, script_name: str) -> None:
"""Отмечает миграцию как примененную.""" """Отмечает миграцию как примененную."""
conn = None conn = None
try: try:
conn = await self._get_connection() conn = await self._get_connection()
await conn.execute( await conn.execute(
"INSERT INTO migrations (script_name) VALUES (?)", "INSERT INTO migrations (script_name) VALUES (?)", (script_name,)
(script_name,)
) )
await conn.commit() await conn.commit()
self.logger.info(f"Миграция {script_name} отмечена как примененная") self.logger.info(f"Миграция {script_name} отмечена как примененная")
@@ -72,7 +74,7 @@ class MigrationRepository(DatabaseConnection):
finally: finally:
if conn: if conn:
await conn.close() await conn.close()
async def create_table_from_sql(self, sql_script: str) -> None: async def create_table_from_sql(self, sql_script: str) -> None:
"""Создает таблицу из SQL скрипта. Используется в миграциях.""" """Создает таблицу из SQL скрипта. Используется в миграциях."""
await self._execute_query(sql_script) await self._execute_query(sql_script)

View File

@@ -7,11 +7,11 @@ from database.models import MessageContentLink, PostContent, TelegramPost
class PostRepository(DatabaseConnection): class PostRepository(DatabaseConnection):
"""Репозиторий для работы с постами из Telegram.""" """Репозиторий для работы с постами из Telegram."""
async def create_tables(self): async def create_tables(self):
"""Создание таблиц для постов.""" """Создание таблиц для постов."""
# Таблица постов из Telegram # Таблица постов из Telegram
post_query = ''' post_query = """
CREATE TABLE IF NOT EXISTS post_from_telegram_suggest ( CREATE TABLE IF NOT EXISTS post_from_telegram_suggest (
message_id INTEGER NOT NULL PRIMARY KEY, message_id INTEGER NOT NULL PRIMARY KEY,
text TEXT, text TEXT,
@@ -23,9 +23,9 @@ class PostRepository(DatabaseConnection):
published_message_id INTEGER, published_message_id INTEGER,
FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE
) )
''' """
await self._execute_query(post_query) await self._execute_query(post_query)
# Добавляем поле published_message_id если его нет (для существующих БД) # Добавляем поле published_message_id если его нет (для существующих БД)
try: try:
check_column_query = """ check_column_query = """
@@ -34,19 +34,27 @@ class PostRepository(DatabaseConnection):
""" """
existing_columns = await self._execute_query_with_result(check_column_query) existing_columns = await self._execute_query_with_result(check_column_query)
if not existing_columns: if not existing_columns:
await self._execute_query('ALTER TABLE post_from_telegram_suggest ADD COLUMN published_message_id INTEGER') await self._execute_query(
self.logger.info("Столбец published_message_id добавлен в post_from_telegram_suggest") "ALTER TABLE post_from_telegram_suggest ADD COLUMN published_message_id INTEGER"
)
self.logger.info(
"Столбец published_message_id добавлен в post_from_telegram_suggest"
)
except Exception as e: except Exception as e:
# Если проверка не удалась, пытаемся добавить столбец (может быть уже существует) # Если проверка не удалась, пытаемся добавить столбец (может быть уже существует)
try: try:
await self._execute_query('ALTER TABLE post_from_telegram_suggest ADD COLUMN published_message_id INTEGER') await self._execute_query(
self.logger.info("Столбец published_message_id добавлен в post_from_telegram_suggest (fallback)") "ALTER TABLE post_from_telegram_suggest ADD COLUMN published_message_id INTEGER"
)
self.logger.info(
"Столбец published_message_id добавлен в post_from_telegram_suggest (fallback)"
)
except Exception: except Exception:
# Столбец уже существует, игнорируем ошибку # Столбец уже существует, игнорируем ошибку
pass pass
# Таблица контента постов # Таблица контента постов
content_query = ''' content_query = """
CREATE TABLE IF NOT EXISTS content_post_from_telegram ( CREATE TABLE IF NOT EXISTS content_post_from_telegram (
message_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
content_name TEXT NOT NULL, content_name TEXT NOT NULL,
@@ -54,22 +62,22 @@ class PostRepository(DatabaseConnection):
PRIMARY KEY (message_id, content_name), PRIMARY KEY (message_id, content_name),
FOREIGN KEY (message_id) REFERENCES post_from_telegram_suggest (message_id) ON DELETE CASCADE FOREIGN KEY (message_id) REFERENCES post_from_telegram_suggest (message_id) ON DELETE CASCADE
) )
''' """
await self._execute_query(content_query) await self._execute_query(content_query)
# Таблица связи сообщений с контентом # Таблица связи сообщений с контентом
link_query = ''' link_query = """
CREATE TABLE IF NOT EXISTS message_link_to_content ( CREATE TABLE IF NOT EXISTS message_link_to_content (
post_id INTEGER NOT NULL, post_id INTEGER NOT NULL,
message_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
PRIMARY KEY (post_id, message_id), PRIMARY KEY (post_id, message_id),
FOREIGN KEY (post_id) REFERENCES post_from_telegram_suggest (message_id) ON DELETE CASCADE FOREIGN KEY (post_id) REFERENCES post_from_telegram_suggest (message_id) ON DELETE CASCADE
) )
''' """
await self._execute_query(link_query) await self._execute_query(link_query)
# Таблица контента опубликованных постов # Таблица контента опубликованных постов
published_content_query = ''' published_content_query = """
CREATE TABLE IF NOT EXISTS published_post_content ( CREATE TABLE IF NOT EXISTS published_post_content (
published_message_id INTEGER NOT NULL, published_message_id INTEGER NOT NULL,
content_name TEXT NOT NULL, content_name TEXT NOT NULL,
@@ -77,38 +85,55 @@ class PostRepository(DatabaseConnection):
published_at INTEGER NOT NULL, published_at INTEGER NOT NULL,
PRIMARY KEY (published_message_id, content_name) PRIMARY KEY (published_message_id, content_name)
) )
''' """
await self._execute_query(published_content_query) await self._execute_query(published_content_query)
# Создаем индексы # Создаем индексы
try: try:
await self._execute_query('CREATE INDEX IF NOT EXISTS idx_published_post_content_message_id ON published_post_content(published_message_id)') await self._execute_query(
await self._execute_query('CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published ON post_from_telegram_suggest(published_message_id)') "CREATE INDEX IF NOT EXISTS idx_published_post_content_message_id ON published_post_content(published_message_id)"
)
await self._execute_query(
"CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published ON post_from_telegram_suggest(published_message_id)"
)
except Exception: except Exception:
# Индексы уже существуют, игнорируем ошибку # Индексы уже существуют, игнорируем ошибку
pass pass
self.logger.info("Таблицы для постов созданы") self.logger.info("Таблицы для постов созданы")
async def add_post(self, post: TelegramPost) -> None: async def add_post(self, post: TelegramPost) -> None:
"""Добавление поста.""" """Добавление поста."""
if not post.created_at: if not post.created_at:
post.created_at = int(datetime.now().timestamp()) post.created_at = int(datetime.now().timestamp())
status = post.status if post.status else "suggest" status = post.status if post.status else "suggest"
# Преобразуем bool в int для SQLite (True -> 1, False -> 0, None -> None) # Преобразуем bool в int для SQLite (True -> 1, False -> 0, None -> None)
is_anonymous_int = None if post.is_anonymous is None else (1 if post.is_anonymous else 0) is_anonymous_int = (
None if post.is_anonymous is None else (1 if post.is_anonymous else 0)
)
# Используем INSERT OR IGNORE чтобы избежать ошибок при повторном создании # Используем INSERT OR IGNORE чтобы избежать ошибок при повторном создании
query = """ query = """
INSERT OR IGNORE INTO post_from_telegram_suggest (message_id, text, author_id, created_at, status, is_anonymous) INSERT OR IGNORE INTO post_from_telegram_suggest (message_id, text, author_id, created_at, status, is_anonymous)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
""" """
params = (post.message_id, post.text, post.author_id, post.created_at, status, is_anonymous_int) params = (
post.message_id,
post.text,
post.author_id,
post.created_at,
status,
is_anonymous_int,
)
await self._execute_query(query, params) await self._execute_query(query, params)
self.logger.info(f"Пост добавлен (или уже существует): message_id={post.message_id}, text длина={len(post.text) if post.text else 0}, is_anonymous={is_anonymous_int}") self.logger.info(
f"Пост добавлен (или уже существует): message_id={post.message_id}, text длина={len(post.text) if post.text else 0}, is_anonymous={is_anonymous_int}"
async def update_helper_message(self, message_id: int, helper_message_id: int) -> None: )
async def update_helper_message(
self, message_id: int, helper_message_id: int
) -> None:
"""Обновление helper сообщения.""" """Обновление helper сообщения."""
query = "UPDATE post_from_telegram_suggest SET helper_text_message_id = ? WHERE message_id = ?" query = "UPDATE post_from_telegram_suggest SET helper_text_message_id = ? WHERE message_id = ?"
await self._execute_query(query, (helper_message_id, message_id)) await self._execute_query(query, (helper_message_id, message_id))
@@ -131,12 +156,16 @@ class PostRepository(DatabaseConnection):
f"update_status_by_message_id: 0 строк обновлено для message_id={message_id}, status={status}" f"update_status_by_message_id: 0 строк обновлено для message_id={message_id}, status={status}"
) )
else: else:
self.logger.info(f"Статус поста message_id={message_id} обновлён на {status}") self.logger.info(
f"Статус поста message_id={message_id} обновлён на {status}"
)
return n return n
except Exception as e: except Exception as e:
if conn: if conn:
await conn.rollback() await conn.rollback()
self.logger.error(f"Ошибка при обновлении статуса message_id={message_id}: {e}") self.logger.error(
f"Ошибка при обновлении статуса message_id={message_id}: {e}"
)
raise raise
finally: finally:
if conn: if conn:
@@ -182,39 +211,53 @@ class PostRepository(DatabaseConnection):
if conn: if conn:
await conn.close() await conn.close()
async def add_post_content(self, post_id: int, message_id: int, content_name: str, content_type: str) -> bool: async def add_post_content(
self, post_id: int, message_id: int, content_name: str, content_type: str
) -> bool:
"""Добавление контента поста.""" """Добавление контента поста."""
try: try:
# Сначала добавляем связь # Сначала добавляем связь
link_query = "INSERT OR IGNORE INTO message_link_to_content (post_id, message_id) VALUES (?, ?)" link_query = "INSERT OR IGNORE INTO message_link_to_content (post_id, message_id) VALUES (?, ?)"
await self._execute_query(link_query, (post_id, message_id)) await self._execute_query(link_query, (post_id, message_id))
# Затем добавляем контент # Затем добавляем контент
content_query = """ content_query = """
INSERT OR IGNORE INTO content_post_from_telegram (message_id, content_name, content_type) INSERT OR IGNORE INTO content_post_from_telegram (message_id, content_name, content_type)
VALUES (?, ?, ?) VALUES (?, ?, ?)
""" """
await self._execute_query(content_query, (message_id, content_name, content_type)) await self._execute_query(
content_query, (message_id, content_name, content_type)
self.logger.info(f"Контент поста добавлен: post_id={post_id}, message_id={message_id}") )
self.logger.info(
f"Контент поста добавлен: post_id={post_id}, message_id={message_id}"
)
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Ошибка при добавлении контента поста: {e}") self.logger.error(f"Ошибка при добавлении контента поста: {e}")
return False return False
async def add_message_link(self, post_id: int, message_id: int) -> bool: async def add_message_link(self, post_id: int, message_id: int) -> bool:
"""Добавляет связь между post_id и message_id в таблицу message_link_to_content.""" """Добавляет связь между post_id и message_id в таблицу message_link_to_content."""
try: try:
self.logger.info(f"Добавление связи: post_id={post_id}, message_id={message_id}") self.logger.info(
f"Добавление связи: post_id={post_id}, message_id={message_id}"
)
link_query = "INSERT OR IGNORE INTO message_link_to_content (post_id, message_id) VALUES (?, ?)" link_query = "INSERT OR IGNORE INTO message_link_to_content (post_id, message_id) VALUES (?, ?)"
await self._execute_query(link_query, (post_id, message_id)) await self._execute_query(link_query, (post_id, message_id))
self.logger.info(f"Связь успешно добавлена: post_id={post_id}, message_id={message_id}") self.logger.info(
f"Связь успешно добавлена: post_id={post_id}, message_id={message_id}"
)
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Ошибка при добавлении связи post_id={post_id}, message_id={message_id}: {e}") self.logger.error(
f"Ошибка при добавлении связи post_id={post_id}, message_id={message_id}: {e}"
)
return False return False
async def get_post_content_by_helper_id(self, helper_message_id: int) -> List[Tuple[str, str]]: async def get_post_content_by_helper_id(
self, helper_message_id: int
) -> List[Tuple[str, str]]:
"""Получает контент поста по helper_text_message_id.""" """Получает контент поста по helper_text_message_id."""
query = """ query = """
SELECT cpft.content_name, cpft.content_type SELECT cpft.content_name, cpft.content_type
@@ -223,12 +266,16 @@ class PostRepository(DatabaseConnection):
JOIN content_post_from_telegram cpft ON cpft.message_id = mltc.message_id JOIN content_post_from_telegram cpft ON cpft.message_id = mltc.message_id
WHERE pft.helper_text_message_id = ? WHERE pft.helper_text_message_id = ?
""" """
post_content = await self._execute_query_with_result(query, (helper_message_id,)) post_content = await self._execute_query_with_result(
query, (helper_message_id,)
)
self.logger.info(f"Получен контент поста: {len(post_content)} элементов") self.logger.info(f"Получен контент поста: {len(post_content)} элементов")
return post_content return post_content
async def get_post_content_by_message_id(self, message_id: int) -> List[Tuple[str, str]]: async def get_post_content_by_message_id(
self, message_id: int
) -> List[Tuple[str, str]]:
"""Получает контент одиночного поста по message_id.""" """Получает контент одиночного поста по message_id."""
query = """ query = """
SELECT cpft.content_name, cpft.content_type SELECT cpft.content_name, cpft.content_type
@@ -238,21 +285,25 @@ class PostRepository(DatabaseConnection):
WHERE pft.message_id = ? AND pft.helper_text_message_id IS NULL WHERE pft.message_id = ? AND pft.helper_text_message_id IS NULL
""" """
post_content = await self._execute_query_with_result(query, (message_id,)) post_content = await self._execute_query_with_result(query, (message_id,))
self.logger.info(f"Получен контент одиночного поста: {len(post_content)} элементов для message_id={message_id}") self.logger.info(
f"Получен контент одиночного поста: {len(post_content)} элементов для message_id={message_id}"
)
return post_content return post_content
async def get_post_text_by_helper_id(self, helper_message_id: int) -> Optional[str]: async def get_post_text_by_helper_id(self, helper_message_id: int) -> Optional[str]:
"""Получает текст поста по helper_text_message_id.""" """Получает текст поста по helper_text_message_id."""
query = "SELECT text FROM post_from_telegram_suggest WHERE helper_text_message_id = ?" query = "SELECT text FROM post_from_telegram_suggest WHERE helper_text_message_id = ?"
rows = await self._execute_query_with_result(query, (helper_message_id,)) rows = await self._execute_query_with_result(query, (helper_message_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
self.logger.info(f"Получен текст поста для helper_message_id={helper_message_id}") self.logger.info(
f"Получен текст поста для helper_message_id={helper_message_id}"
)
return row[0] return row[0]
return None return None
async def get_post_ids_by_helper_id(self, helper_message_id: int) -> List[int]: async def get_post_ids_by_helper_id(self, helper_message_id: int) -> List[int]:
"""Получает ID сообщений по helper_text_message_id.""" """Получает ID сообщений по helper_text_message_id."""
query = """ query = """
@@ -262,114 +313,145 @@ class PostRepository(DatabaseConnection):
WHERE pft.helper_text_message_id = ? WHERE pft.helper_text_message_id = ?
""" """
rows = await self._execute_query_with_result(query, (helper_message_id,)) rows = await self._execute_query_with_result(query, (helper_message_id,))
post_ids = [row[0] for row in rows] post_ids = [row[0] for row in rows]
self.logger.info(f"Получены ID сообщений: {len(post_ids)} элементов") self.logger.info(f"Получены ID сообщений: {len(post_ids)} элементов")
return post_ids return post_ids
async def get_author_id_by_message_id(self, message_id: int) -> Optional[int]: async def get_author_id_by_message_id(self, message_id: int) -> Optional[int]:
"""Получает ID автора по message_id.""" """Получает ID автора по message_id."""
query = "SELECT author_id FROM post_from_telegram_suggest WHERE message_id = ?" query = "SELECT author_id FROM post_from_telegram_suggest WHERE message_id = ?"
rows = await self._execute_query_with_result(query, (message_id,)) rows = await self._execute_query_with_result(query, (message_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
author_id = row[0] author_id = row[0]
self.logger.info(f"Получен author_id: {author_id} для message_id={message_id}") self.logger.info(
f"Получен author_id: {author_id} для message_id={message_id}"
)
return author_id return author_id
return None return None
async def get_author_id_by_helper_message_id(self, helper_message_id: int) -> Optional[int]: async def get_author_id_by_helper_message_id(
self, helper_message_id: int
) -> Optional[int]:
"""Получает ID автора по helper_text_message_id.""" """Получает ID автора по helper_text_message_id."""
query = "SELECT author_id FROM post_from_telegram_suggest WHERE helper_text_message_id = ?" query = "SELECT author_id FROM post_from_telegram_suggest WHERE helper_text_message_id = ?"
rows = await self._execute_query_with_result(query, (helper_message_id,)) rows = await self._execute_query_with_result(query, (helper_message_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
author_id = row[0] author_id = row[0]
self.logger.info(f"Получен author_id: {author_id} для helper_message_id={helper_message_id}") self.logger.info(
f"Получен author_id: {author_id} для helper_message_id={helper_message_id}"
)
return author_id return author_id
return None return None
async def get_post_text_and_anonymity_by_message_id(self, message_id: int) -> Tuple[Optional[str], Optional[bool]]: async def get_post_text_and_anonymity_by_message_id(
self, message_id: int
) -> Tuple[Optional[str], Optional[bool]]:
"""Получает текст и is_anonymous поста по message_id.""" """Получает текст и is_anonymous поста по message_id."""
query = "SELECT text, is_anonymous FROM post_from_telegram_suggest WHERE message_id = ?" query = "SELECT text, is_anonymous FROM post_from_telegram_suggest WHERE message_id = ?"
rows = await self._execute_query_with_result(query, (message_id,)) rows = await self._execute_query_with_result(query, (message_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
text = row[0] or "" text = row[0] or ""
is_anonymous_int = row[1] is_anonymous_int = row[1]
# Преобразуем int в bool (1 -> True, 0 -> False, NULL -> None) # Преобразуем int в bool (1 -> True, 0 -> False, NULL -> None)
is_anonymous = None if is_anonymous_int is None else bool(is_anonymous_int) is_anonymous = None if is_anonymous_int is None else bool(is_anonymous_int)
self.logger.info(f"Получены текст и is_anonymous для message_id={message_id}") self.logger.info(
f"Получены текст и is_anonymous для message_id={message_id}"
)
return text, is_anonymous return text, is_anonymous
return None, None return None, None
async def get_post_text_and_anonymity_by_helper_id(self, helper_message_id: int) -> Tuple[Optional[str], Optional[bool]]: async def get_post_text_and_anonymity_by_helper_id(
self, helper_message_id: int
) -> Tuple[Optional[str], Optional[bool]]:
"""Получает текст и is_anonymous поста по helper_text_message_id.""" """Получает текст и is_anonymous поста по helper_text_message_id."""
query = "SELECT text, is_anonymous FROM post_from_telegram_suggest WHERE helper_text_message_id = ?" query = "SELECT text, is_anonymous FROM post_from_telegram_suggest WHERE helper_text_message_id = ?"
rows = await self._execute_query_with_result(query, (helper_message_id,)) rows = await self._execute_query_with_result(query, (helper_message_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
text = row[0] or "" text = row[0] or ""
is_anonymous_int = row[1] is_anonymous_int = row[1]
# Преобразуем int в bool (1 -> True, 0 -> False, NULL -> None) # Преобразуем int в bool (1 -> True, 0 -> False, NULL -> None)
is_anonymous = None if is_anonymous_int is None else bool(is_anonymous_int) is_anonymous = None if is_anonymous_int is None else bool(is_anonymous_int)
self.logger.info(f"Получены текст и is_anonymous для helper_message_id={helper_message_id}") self.logger.info(
f"Получены текст и is_anonymous для helper_message_id={helper_message_id}"
)
return text, is_anonymous return text, is_anonymous
return None, None return None, None
async def update_published_message_id(self, original_message_id: int, published_message_id: int) -> None: async def update_published_message_id(
self, original_message_id: int, published_message_id: int
) -> None:
"""Обновляет published_message_id для опубликованного поста.""" """Обновляет published_message_id для опубликованного поста."""
query = "UPDATE post_from_telegram_suggest SET published_message_id = ? WHERE message_id = ?" query = "UPDATE post_from_telegram_suggest SET published_message_id = ? WHERE message_id = ?"
await self._execute_query(query, (published_message_id, original_message_id)) await self._execute_query(query, (published_message_id, original_message_id))
self.logger.info(f"Обновлен published_message_id: {original_message_id} -> {published_message_id}") self.logger.info(
f"Обновлен published_message_id: {original_message_id} -> {published_message_id}"
)
async def add_published_post_content( async def add_published_post_content(
self, published_message_id: int, content_path: str, content_type: str self, published_message_id: int, content_path: str, content_type: str
) -> bool: ) -> bool:
"""Добавляет контент опубликованного поста.""" """Добавляет контент опубликованного поста."""
try: try:
from datetime import datetime from datetime import datetime
published_at = int(datetime.now().timestamp()) published_at = int(datetime.now().timestamp())
query = """ query = """
INSERT OR IGNORE INTO published_post_content INSERT OR IGNORE INTO published_post_content
(published_message_id, content_name, content_type, published_at) (published_message_id, content_name, content_type, published_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
""" """
await self._execute_query(query, (published_message_id, content_path, content_type, published_at)) await self._execute_query(
self.logger.info(f"Добавлен контент опубликованного поста: published_message_id={published_message_id}, type={content_type}") query, (published_message_id, content_path, content_type, published_at)
)
self.logger.info(
f"Добавлен контент опубликованного поста: published_message_id={published_message_id}, type={content_type}"
)
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Ошибка при добавлении контента опубликованного поста: {e}") self.logger.error(
f"Ошибка при добавлении контента опубликованного поста: {e}"
)
return False return False
async def get_published_post_content(self, published_message_id: int) -> List[Tuple[str, str]]: async def get_published_post_content(
self, published_message_id: int
) -> List[Tuple[str, str]]:
"""Получает контент опубликованного поста.""" """Получает контент опубликованного поста."""
query = """ query = """
SELECT content_name, content_type SELECT content_name, content_type
FROM published_post_content FROM published_post_content
WHERE published_message_id = ? WHERE published_message_id = ?
""" """
post_content = await self._execute_query_with_result(query, (published_message_id,)) post_content = await self._execute_query_with_result(
self.logger.info(f"Получен контент опубликованного поста: {len(post_content)} элементов для published_message_id={published_message_id}") query, (published_message_id,)
)
self.logger.info(
f"Получен контент опубликованного поста: {len(post_content)} элементов для published_message_id={published_message_id}"
)
return post_content return post_content
# ============================================ # ============================================
# Методы для работы с ML-скорингом # Методы для работы с ML-скорингом
# ============================================ # ============================================
async def update_ml_scores(self, message_id: int, ml_scores_json: str) -> bool: async def update_ml_scores(self, message_id: int, ml_scores_json: str) -> bool:
""" """
Обновляет ML-скоры для поста. Обновляет ML-скоры для поста.
Args: Args:
message_id: ID сообщения в группе модерации message_id: ID сообщения в группе модерации
ml_scores_json: JSON строка со скорами ml_scores_json: JSON строка со скорами
Returns: Returns:
True если обновлено успешно True если обновлено успешно
""" """
@@ -379,16 +461,18 @@ class PostRepository(DatabaseConnection):
self.logger.info(f"ML-скоры обновлены для message_id={message_id}") self.logger.info(f"ML-скоры обновлены для message_id={message_id}")
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Ошибка обновления ML-скоров для message_id={message_id}: {e}") self.logger.error(
f"Ошибка обновления ML-скоров для message_id={message_id}: {e}"
)
return False return False
async def get_ml_scores_by_message_id(self, message_id: int) -> Optional[str]: async def get_ml_scores_by_message_id(self, message_id: int) -> Optional[str]:
""" """
Получает ML-скоры для поста. Получает ML-скоры для поста.
Args: Args:
message_id: ID сообщения message_id: ID сообщения
Returns: Returns:
JSON строка со скорами или None JSON строка со скорами или None
""" """
@@ -397,14 +481,14 @@ class PostRepository(DatabaseConnection):
if rows and rows[0][0]: if rows and rows[0][0]:
return rows[0][0] return rows[0][0]
return None return None
async def get_post_text_by_message_id(self, message_id: int) -> Optional[str]: async def get_post_text_by_message_id(self, message_id: int) -> Optional[str]:
""" """
Получает текст поста по message_id. Получает текст поста по message_id.
Args: Args:
message_id: ID сообщения message_id: ID сообщения
Returns: Returns:
Текст поста или None Текст поста или None
""" """
@@ -413,14 +497,14 @@ class PostRepository(DatabaseConnection):
if rows and rows[0][0]: if rows and rows[0][0]:
return rows[0][0] return rows[0][0]
return None return None
async def get_approved_posts_texts(self, limit: int = 1000) -> List[str]: async def get_approved_posts_texts(self, limit: int = 1000) -> List[str]:
""" """
Получает тексты опубликованных постов для обучения RAG. Получает тексты опубликованных постов для обучения RAG.
Args: Args:
limit: Максимальное количество постов limit: Максимальное количество постов
Returns: Returns:
Список текстов Список текстов
""" """
@@ -437,14 +521,14 @@ class PostRepository(DatabaseConnection):
texts = [row[0] for row in rows if row[0]] texts = [row[0] for row in rows if row[0]]
self.logger.info(f"Получено {len(texts)} опубликованных постов для обучения") self.logger.info(f"Получено {len(texts)} опубликованных постов для обучения")
return texts return texts
async def get_declined_posts_texts(self, limit: int = 1000) -> List[str]: async def get_declined_posts_texts(self, limit: int = 1000) -> List[str]:
""" """
Получает тексты отклоненных постов для обучения RAG. Получает тексты отклоненных постов для обучения RAG.
Args: Args:
limit: Максимальное количество постов limit: Максимальное количество постов
Returns: Returns:
Список текстов Список текстов
""" """
@@ -461,4 +545,3 @@ class PostRepository(DatabaseConnection):
texts = [row[0] for row in rows if row[0]] texts = [row[0] for row in rows if row[0]]
self.logger.info(f"Получено {len(texts)} отклоненных постов для обучения") self.logger.info(f"Получено {len(texts)} отклоненных постов для обучения")
return texts return texts

View File

@@ -7,10 +7,10 @@ from database.models import User
class UserRepository(DatabaseConnection): class UserRepository(DatabaseConnection):
"""Репозиторий для работы с пользователями.""" """Репозиторий для работы с пользователями."""
async def create_tables(self): async def create_tables(self):
"""Создание таблицы пользователей.""" """Создание таблицы пользователей."""
query = ''' query = """
CREATE TABLE IF NOT EXISTS our_users ( CREATE TABLE IF NOT EXISTS our_users (
user_id INTEGER NOT NULL PRIMARY KEY, user_id INTEGER NOT NULL PRIMARY KEY,
first_name TEXT, first_name TEXT,
@@ -24,42 +24,56 @@ class UserRepository(DatabaseConnection):
date_changed INTEGER NOT NULL, date_changed INTEGER NOT NULL,
voice_bot_welcome_received BOOLEAN DEFAULT 0 voice_bot_welcome_received BOOLEAN DEFAULT 0
) )
''' """
await self._execute_query(query) await self._execute_query(query)
self.logger.info("Таблица пользователей создана") self.logger.info("Таблица пользователей создана")
async def user_exists(self, user_id: int) -> bool: async def user_exists(self, user_id: int) -> bool:
"""Проверяет, существует ли пользователь в базе данных.""" """Проверяет, существует ли пользователь в базе данных."""
query = "SELECT user_id FROM our_users WHERE user_id = ?" query = "SELECT user_id FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,)) rows = await self._execute_query_with_result(query, (user_id,))
self.logger.info(f"Проверка существования пользователя: user_id={user_id}, результат={rows}") self.logger.info(
f"Проверка существования пользователя: user_id={user_id}, результат={rows}"
)
return bool(len(rows)) return bool(len(rows))
async def add_user(self, user: User) -> None: async def add_user(self, user: User) -> None:
"""Добавление нового пользователя с защитой от дублирования.""" """Добавление нового пользователя с защитой от дублирования."""
if not user.date_added: if not user.date_added:
user.date_added = int(datetime.now().timestamp()) user.date_added = int(datetime.now().timestamp())
if not user.date_changed: if not user.date_changed:
user.date_changed = int(datetime.now().timestamp()) user.date_changed = int(datetime.now().timestamp())
query = """ query = """
INSERT OR IGNORE INTO our_users (user_id, first_name, full_name, username, is_bot, INSERT OR IGNORE INTO our_users (user_id, first_name, full_name, username, is_bot,
language_code, emoji, has_stickers, date_added, date_changed, voice_bot_welcome_received) language_code, emoji, has_stickers, date_added, date_changed, voice_bot_welcome_received)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""" """
params = (user.user_id, user.first_name, user.full_name, user.username, params = (
user.is_bot, user.language_code, user.emoji, user.has_stickers, user.user_id,
user.date_added, user.date_changed, user.voice_bot_welcome_received) user.first_name,
user.full_name,
user.username,
user.is_bot,
user.language_code,
user.emoji,
user.has_stickers,
user.date_added,
user.date_changed,
user.voice_bot_welcome_received,
)
await self._execute_query(query, params) await self._execute_query(query, params)
self.logger.info(f"Пользователь обработан (создан или уже существует): {user.user_id}") self.logger.info(
f"Пользователь обработан (создан или уже существует): {user.user_id}"
)
async def get_user_info(self, user_id: int) -> Optional[User]: async def get_user_info(self, user_id: int) -> Optional[User]:
"""Получение информации о пользователе.""" """Получение информации о пользователе."""
query = "SELECT username, full_name, has_stickers, emoji FROM our_users WHERE user_id = ?" query = "SELECT username, full_name, has_stickers, emoji FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,)) rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
return User( return User(
user_id=user_id, user_id=user_id,
@@ -67,16 +81,16 @@ class UserRepository(DatabaseConnection):
full_name=row[1], full_name=row[1],
username=row[0], username=row[0],
has_stickers=bool(row[2]) if row[2] is not None else False, has_stickers=bool(row[2]) if row[2] is not None else False,
emoji=row[3] emoji=row[3],
) )
return None return None
async def get_user_by_id(self, user_id: int) -> Optional[User]: async def get_user_by_id(self, user_id: int) -> Optional[User]:
"""Получение пользователя по ID.""" """Получение пользователя по ID."""
query = "SELECT * FROM our_users WHERE user_id = ?" query = "SELECT * FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,)) rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
return User( return User(
user_id=row[0], user_id=row[0],
@@ -89,58 +103,66 @@ class UserRepository(DatabaseConnection):
emoji=row[7], emoji=row[7],
date_added=row[8], date_added=row[8],
date_changed=row[9], date_changed=row[9],
voice_bot_welcome_received=bool(row[10]) if len(row) > 10 else False voice_bot_welcome_received=bool(row[10]) if len(row) > 10 else False,
) )
return None return None
async def get_username(self, user_id: int) -> Optional[str]: async def get_username(self, user_id: int) -> Optional[str]:
"""Возвращает username пользователя.""" """Возвращает username пользователя."""
query = "SELECT username FROM our_users WHERE user_id = ?" query = "SELECT username FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,)) rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
username = row[0] username = row[0]
self.logger.info(f"Username пользователя найден: user_id={user_id}, username={username}") self.logger.info(
f"Username пользователя найден: user_id={user_id}, username={username}"
)
return username return username
return None return None
async def get_user_id_by_username(self, username: str) -> Optional[int]: async def get_user_id_by_username(self, username: str) -> Optional[int]:
"""Возвращает user_id пользователя по username.""" """Возвращает user_id пользователя по username."""
query = "SELECT user_id FROM our_users WHERE username = ?" query = "SELECT user_id FROM our_users WHERE username = ?"
rows = await self._execute_query_with_result(query, (username,)) rows = await self._execute_query_with_result(query, (username,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
user_id = row[0] user_id = row[0]
self.logger.info(f"User_id пользователя найден: username={username}, user_id={user_id}") self.logger.info(
f"User_id пользователя найден: username={username}, user_id={user_id}"
)
return user_id return user_id
return None return None
async def get_full_name_by_id(self, user_id: int) -> Optional[str]: async def get_full_name_by_id(self, user_id: int) -> Optional[str]:
"""Возвращает full_name пользователя.""" """Возвращает full_name пользователя."""
query = "SELECT full_name FROM our_users WHERE user_id = ?" query = "SELECT full_name FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,)) rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
full_name = row[0] full_name = row[0]
self.logger.info(f"Full_name пользователя найден: user_id={user_id}, full_name={full_name}") self.logger.info(
f"Full_name пользователя найден: user_id={user_id}, full_name={full_name}"
)
return full_name return full_name
return None return None
async def get_user_first_name(self, user_id: int) -> Optional[str]: async def get_user_first_name(self, user_id: int) -> Optional[str]:
"""Возвращает first_name пользователя.""" """Возвращает first_name пользователя."""
query = "SELECT first_name FROM our_users WHERE user_id = ?" query = "SELECT first_name FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,)) rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
first_name = row[0] first_name = row[0]
self.logger.info(f"First_name пользователя найден: user_id={user_id}, first_name={first_name}") self.logger.info(
f"First_name пользователя найден: user_id={user_id}, first_name={first_name}"
)
return first_name return first_name
return None return None
async def get_all_user_ids(self) -> List[int]: async def get_all_user_ids(self) -> List[int]:
"""Возвращает список всех user_id.""" """Возвращает список всех user_id."""
query = "SELECT user_id FROM our_users" query = "SELECT user_id FROM our_users"
@@ -148,20 +170,22 @@ class UserRepository(DatabaseConnection):
user_ids = [row[0] for row in rows] user_ids = [row[0] for row in rows]
self.logger.info(f"Получен список всех user_id: {user_ids}") self.logger.info(f"Получен список всех user_id: {user_ids}")
return user_ids return user_ids
async def get_last_users(self, limit: int = 30) -> List[tuple]: async def get_last_users(self, limit: int = 30) -> List[tuple]:
"""Получение последних пользователей.""" """Получение последних пользователей."""
query = "SELECT full_name, user_id FROM our_users ORDER BY date_changed DESC LIMIT ?" query = "SELECT full_name, user_id FROM our_users ORDER BY date_changed DESC LIMIT ?"
rows = await self._execute_query_with_result(query, (limit,)) rows = await self._execute_query_with_result(query, (limit,))
return rows return rows
async def update_user_date(self, user_id: int) -> None: async def update_user_date(self, user_id: int) -> None:
"""Обновление даты последнего изменения пользователя.""" """Обновление даты последнего изменения пользователя."""
date_changed = int(datetime.now().timestamp()) date_changed = int(datetime.now().timestamp())
query = "UPDATE our_users SET date_changed = ? WHERE user_id = ?" query = "UPDATE our_users SET date_changed = ? WHERE user_id = ?"
await self._execute_query(query, (date_changed, user_id)) await self._execute_query(query, (date_changed, user_id))
async def update_user_info(self, user_id: int, username: str = None, full_name: str = None) -> None: async def update_user_info(
self, user_id: int, username: str = None, full_name: str = None
) -> None:
"""Обновление информации о пользователе.""" """Обновление информации о пользователе."""
if username and full_name: if username and full_name:
query = "UPDATE our_users SET username = ?, full_name = ? WHERE user_id = ?" query = "UPDATE our_users SET username = ?, full_name = ? WHERE user_id = ?"
@@ -174,85 +198,93 @@ class UserRepository(DatabaseConnection):
params = (full_name, user_id) params = (full_name, user_id)
else: else:
return return
await self._execute_query(query, params) await self._execute_query(query, params)
async def update_user_emoji(self, user_id: int, emoji: str) -> None: async def update_user_emoji(self, user_id: int, emoji: str) -> None:
"""Обновление эмодзи пользователя.""" """Обновление эмодзи пользователя."""
query = "UPDATE our_users SET emoji = ? WHERE user_id = ?" query = "UPDATE our_users SET emoji = ? WHERE user_id = ?"
await self._execute_query(query, (emoji, user_id)) await self._execute_query(query, (emoji, user_id))
async def update_stickers_info(self, user_id: int) -> None: async def update_stickers_info(self, user_id: int) -> None:
"""Обновление информации о стикерах.""" """Обновление информации о стикерах."""
query = "UPDATE our_users SET has_stickers = 1 WHERE user_id = ?" query = "UPDATE our_users SET has_stickers = 1 WHERE user_id = ?"
await self._execute_query(query, (user_id,)) await self._execute_query(query, (user_id,))
async def get_stickers_info(self, user_id: int) -> bool: async def get_stickers_info(self, user_id: int) -> bool:
"""Получение информации о стикерах.""" """Получение информации о стикерах."""
query = "SELECT has_stickers FROM our_users WHERE user_id = ?" query = "SELECT has_stickers FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,)) rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
return bool(row[0]) if row and row[0] is not None else False return bool(row[0]) if row and row[0] is not None else False
async def check_emoji_exists(self, emoji: str) -> bool: async def check_emoji_exists(self, emoji: str) -> bool:
"""Проверка существования эмодзи.""" """Проверка существования эмодзи."""
query = "SELECT 1 FROM our_users WHERE emoji = ?" query = "SELECT 1 FROM our_users WHERE emoji = ?"
rows = await self._execute_query_with_result(query, (emoji,)) rows = await self._execute_query_with_result(query, (emoji,))
row = rows[0] if rows else None row = rows[0] if rows else None
return bool(row) return bool(row)
async def get_user_emoji(self, user_id: int) -> str: async def get_user_emoji(self, user_id: int) -> str:
""" """
Получает эмодзи пользователя. Получает эмодзи пользователя.
Args: Args:
user_id: ID пользователя. user_id: ID пользователя.
Returns: Returns:
str: Эмодзи пользователя или "Смайл еще не определен" если не установлен. str: Эмодзи пользователя или "Смайл еще не определен" если не установлен.
""" """
query = "SELECT emoji FROM our_users WHERE user_id = ?" query = "SELECT emoji FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,)) rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row and row[0]: if row and row[0]:
emoji = row[0] emoji = row[0]
self.logger.info(f"Эмодзи пользователя найден: user_id={user_id}, emoji={emoji}") self.logger.info(
f"Эмодзи пользователя найден: user_id={user_id}, emoji={emoji}"
)
return str(emoji) return str(emoji)
else: else:
self.logger.info(f"Эмодзи пользователя не найден: user_id={user_id}") self.logger.info(f"Эмодзи пользователя не найден: user_id={user_id}")
return "Смайл еще не определен" return "Смайл еще не определен"
async def check_emoji_for_user(self, user_id: int) -> str: async def check_emoji_for_user(self, user_id: int) -> str:
""" """
Проверяет, есть ли уже у пользователя назначенный emoji. Проверяет, есть ли уже у пользователя назначенный emoji.
Args: Args:
user_id: ID пользователя. user_id: ID пользователя.
Returns: Returns:
str: Эмодзи пользователя или "Смайл еще не определен" если не установлен. str: Эмодзи пользователя или "Смайл еще не определен" если не установлен.
""" """
return await self.get_user_emoji(user_id) return await self.get_user_emoji(user_id)
async def check_voice_bot_welcome_received(self, user_id: int) -> bool: async def check_voice_bot_welcome_received(self, user_id: int) -> bool:
"""Проверяет, получал ли пользователь приветственное сообщение от voice_bot.""" """Проверяет, получал ли пользователь приветственное сообщение от voice_bot."""
query = "SELECT voice_bot_welcome_received FROM our_users WHERE user_id = ?" query = "SELECT voice_bot_welcome_received FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,)) rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None row = rows[0] if rows else None
if row: if row:
welcome_received = bool(row[0]) welcome_received = bool(row[0])
self.logger.info(f"Пользователь {user_id} получал приветствие: {welcome_received}") self.logger.info(
f"Пользователь {user_id} получал приветствие: {welcome_received}"
)
return welcome_received return welcome_received
return False return False
async def mark_voice_bot_welcome_received(self, user_id: int) -> bool: async def mark_voice_bot_welcome_received(self, user_id: int) -> bool:
"""Отмечает, что пользователь получил приветственное сообщение от voice_bot.""" """Отмечает, что пользователь получил приветственное сообщение от voice_bot."""
try: try:
query = "UPDATE our_users SET voice_bot_welcome_received = 1 WHERE user_id = ?" query = (
"UPDATE our_users SET voice_bot_welcome_received = 1 WHERE user_id = ?"
)
await self._execute_query(query, (user_id,)) await self._execute_query(query, (user_id,))
self.logger.info(f"Пользователь {user_id} отмечен как получивший приветствие") self.logger.info(
f"Пользователь {user_id} отмечен как получивший приветствие"
)
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Ошибка при отметке получения приветствия: {e}") self.logger.error(f"Ошибка при отметке получения приветствия: {e}")

View File

@@ -2,8 +2,9 @@ from typing import Optional
from database.repositories.admin_repository import AdminRepository from database.repositories.admin_repository import AdminRepository
from database.repositories.audio_repository import AudioRepository from database.repositories.audio_repository import AudioRepository
from database.repositories.blacklist_history_repository import \ from database.repositories.blacklist_history_repository import (
BlacklistHistoryRepository BlacklistHistoryRepository,
)
from database.repositories.blacklist_repository import BlacklistRepository from database.repositories.blacklist_repository import BlacklistRepository
from database.repositories.message_repository import MessageRepository from database.repositories.message_repository import MessageRepository
from database.repositories.migration_repository import MigrationRepository from database.repositories.migration_repository import MigrationRepository
@@ -13,7 +14,7 @@ from database.repositories.user_repository import UserRepository
class RepositoryFactory: class RepositoryFactory:
"""Фабрика для создания репозиториев.""" """Фабрика для создания репозиториев."""
def __init__(self, db_path: str): def __init__(self, db_path: str):
self.db_path = db_path self.db_path = db_path
self._user_repo: Optional[UserRepository] = None self._user_repo: Optional[UserRepository] = None
@@ -24,63 +25,63 @@ class RepositoryFactory:
self._admin_repo: Optional[AdminRepository] = None self._admin_repo: Optional[AdminRepository] = None
self._audio_repo: Optional[AudioRepository] = None self._audio_repo: Optional[AudioRepository] = None
self._migration_repo: Optional[MigrationRepository] = None self._migration_repo: Optional[MigrationRepository] = None
@property @property
def users(self) -> UserRepository: def users(self) -> UserRepository:
"""Возвращает репозиторий пользователей.""" """Возвращает репозиторий пользователей."""
if self._user_repo is None: if self._user_repo is None:
self._user_repo = UserRepository(self.db_path) self._user_repo = UserRepository(self.db_path)
return self._user_repo return self._user_repo
@property @property
def blacklist(self) -> BlacklistRepository: def blacklist(self) -> BlacklistRepository:
"""Возвращает репозиторий черного списка.""" """Возвращает репозиторий черного списка."""
if self._blacklist_repo is None: if self._blacklist_repo is None:
self._blacklist_repo = BlacklistRepository(self.db_path) self._blacklist_repo = BlacklistRepository(self.db_path)
return self._blacklist_repo return self._blacklist_repo
@property @property
def blacklist_history(self) -> BlacklistHistoryRepository: def blacklist_history(self) -> BlacklistHistoryRepository:
"""Возвращает репозиторий истории банов/разбанов.""" """Возвращает репозиторий истории банов/разбанов."""
if self._blacklist_history_repo is None: if self._blacklist_history_repo is None:
self._blacklist_history_repo = BlacklistHistoryRepository(self.db_path) self._blacklist_history_repo = BlacklistHistoryRepository(self.db_path)
return self._blacklist_history_repo return self._blacklist_history_repo
@property @property
def messages(self) -> MessageRepository: def messages(self) -> MessageRepository:
"""Возвращает репозиторий сообщений.""" """Возвращает репозиторий сообщений."""
if self._message_repo is None: if self._message_repo is None:
self._message_repo = MessageRepository(self.db_path) self._message_repo = MessageRepository(self.db_path)
return self._message_repo return self._message_repo
@property @property
def posts(self) -> PostRepository: def posts(self) -> PostRepository:
"""Возвращает репозиторий постов.""" """Возвращает репозиторий постов."""
if self._post_repo is None: if self._post_repo is None:
self._post_repo = PostRepository(self.db_path) self._post_repo = PostRepository(self.db_path)
return self._post_repo return self._post_repo
@property @property
def admins(self) -> AdminRepository: def admins(self) -> AdminRepository:
"""Возвращает репозиторий администраторов.""" """Возвращает репозиторий администраторов."""
if self._admin_repo is None: if self._admin_repo is None:
self._admin_repo = AdminRepository(self.db_path) self._admin_repo = AdminRepository(self.db_path)
return self._admin_repo return self._admin_repo
@property @property
def audio(self) -> AudioRepository: def audio(self) -> AudioRepository:
"""Возвращает репозиторий аудио.""" """Возвращает репозиторий аудио."""
if self._audio_repo is None: if self._audio_repo is None:
self._audio_repo = AudioRepository(self.db_path) self._audio_repo = AudioRepository(self.db_path)
return self._audio_repo return self._audio_repo
@property @property
def migrations(self) -> MigrationRepository: def migrations(self) -> MigrationRepository:
"""Возвращает репозиторий миграций.""" """Возвращает репозиторий миграций."""
if self._migration_repo is None: if self._migration_repo is None:
self._migration_repo = MigrationRepository(self.db_path) self._migration_repo = MigrationRepository(self.db_path)
return self._migration_repo return self._migration_repo
async def create_all_tables(self): async def create_all_tables(self):
"""Создает все таблицы в базе данных.""" """Создает все таблицы в базе данных."""
await self.migrations.create_table() # Сначала создаем таблицу миграций await self.migrations.create_table() # Сначала создаем таблицу миграций
@@ -91,11 +92,11 @@ class RepositoryFactory:
await self.posts.create_tables() await self.posts.create_tables()
await self.admins.create_tables() await self.admins.create_tables()
await self.audio.create_tables() await self.audio.create_tables()
async def check_database_integrity(self): async def check_database_integrity(self):
"""Проверяет целостность базы данных.""" """Проверяет целостность базы данных."""
await self.users.check_database_integrity() await self.users.check_database_integrity()
async def cleanup_wal_files(self): async def cleanup_wal_files(self):
"""Очищает WAL файлы.""" """Очищает WAL файлы."""
await self.users.cleanup_wal_files() await self.users.cleanup_wal_files()

View File

@@ -1,6 +1,7 @@
""" """
Конфигурация для rate limiting Конфигурация для rate limiting
""" """
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
@@ -8,26 +9,28 @@ from typing import Optional
@dataclass @dataclass
class RateLimitSettings: class RateLimitSettings:
"""Настройки rate limiting для разных типов сообщений""" """Настройки rate limiting для разных типов сообщений"""
# Основные настройки # Основные настройки
messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат
burst_limit: int = 2 # Максимум 2 сообщения подряд burst_limit: int = 2 # Максимум 2 сообщения подряд
retry_after_multiplier: float = 1.5 # Множитель для увеличения задержки при retry retry_after_multiplier: float = 1.5 # Множитель для увеличения задержки при retry
max_retry_delay: float = 30.0 # Максимальная задержка между попытками max_retry_delay: float = 30.0 # Максимальная задержка между попытками
max_retries: int = 3 # Максимальное количество повторных попыток max_retries: int = 3 # Максимальное количество повторных попыток
# Специальные настройки для разных типов сообщений # Специальные настройки для разных типов сообщений
voice_message_delay: float = 2.0 # Дополнительная задержка для голосовых сообщений voice_message_delay: float = 2.0 # Дополнительная задержка для голосовых сообщений
media_message_delay: float = 1.5 # Дополнительная задержка для медиа сообщений media_message_delay: float = 1.5 # Дополнительная задержка для медиа сообщений
text_message_delay: float = 1.0 # Дополнительная задержка для текстовых сообщений text_message_delay: float = 1.0 # Дополнительная задержка для текстовых сообщений
# Настройки для разных типов чатов # Настройки для разных типов чатов
private_chat_multiplier: float = 1.0 # Множитель для приватных чатов private_chat_multiplier: float = 1.0 # Множитель для приватных чатов
group_chat_multiplier: float = 0.8 # Множитель для групповых чатов group_chat_multiplier: float = 0.8 # Множитель для групповых чатов
channel_multiplier: float = 0.6 # Множитель для каналов channel_multiplier: float = 0.6 # Множитель для каналов
# Глобальные ограничения # Глобальные ограничения
global_messages_per_second: float = 10.0 # Максимум 10 сообщений в секунду глобально global_messages_per_second: float = (
10.0 # Максимум 10 сообщений в секунду глобально
)
global_burst_limit: int = 20 # Максимум 20 сообщений подряд глобально global_burst_limit: int = 20 # Максимум 20 сообщений подряд глобально
@@ -37,7 +40,7 @@ DEVELOPMENT_CONFIG = RateLimitSettings(
burst_limit=3, burst_limit=3,
retry_after_multiplier=1.2, retry_after_multiplier=1.2,
max_retry_delay=15.0, max_retry_delay=15.0,
max_retries=2 max_retries=2,
) )
PRODUCTION_CONFIG = RateLimitSettings( PRODUCTION_CONFIG = RateLimitSettings(
@@ -48,7 +51,7 @@ PRODUCTION_CONFIG = RateLimitSettings(
max_retries=3, max_retries=3,
voice_message_delay=2.5, voice_message_delay=2.5,
media_message_delay=2.0, media_message_delay=2.0,
text_message_delay=1.5 text_message_delay=1.5,
) )
STRICT_CONFIG = RateLimitSettings( STRICT_CONFIG = RateLimitSettings(
@@ -59,46 +62,45 @@ STRICT_CONFIG = RateLimitSettings(
max_retries=5, max_retries=5,
voice_message_delay=3.0, voice_message_delay=3.0,
media_message_delay=2.5, media_message_delay=2.5,
text_message_delay=2.0 text_message_delay=2.0,
) )
def get_rate_limit_config(environment: str = "production") -> RateLimitSettings: def get_rate_limit_config(environment: str = "production") -> RateLimitSettings:
""" """
Получает конфигурацию rate limiting в зависимости от окружения Получает конфигурацию rate limiting в зависимости от окружения
Args: Args:
environment: Окружение ('development', 'production', 'strict') environment: Окружение ('development', 'production', 'strict')
Returns: Returns:
RateLimitSettings: Конфигурация для указанного окружения RateLimitSettings: Конфигурация для указанного окружения
""" """
configs = { configs = {
"development": DEVELOPMENT_CONFIG, "development": DEVELOPMENT_CONFIG,
"production": PRODUCTION_CONFIG, "production": PRODUCTION_CONFIG,
"strict": STRICT_CONFIG "strict": STRICT_CONFIG,
} }
return configs.get(environment, PRODUCTION_CONFIG) return configs.get(environment, PRODUCTION_CONFIG)
def get_adaptive_config( def get_adaptive_config(
current_error_rate: float, current_error_rate: float, base_config: Optional[RateLimitSettings] = None
base_config: Optional[RateLimitSettings] = None
) -> RateLimitSettings: ) -> RateLimitSettings:
""" """
Получает адаптивную конфигурацию на основе текущего уровня ошибок Получает адаптивную конфигурацию на основе текущего уровня ошибок
Args: Args:
current_error_rate: Текущий уровень ошибок (0.0 - 1.0) current_error_rate: Текущий уровень ошибок (0.0 - 1.0)
base_config: Базовая конфигурация base_config: Базовая конфигурация
Returns: Returns:
RateLimitSettings: Адаптированная конфигурация RateLimitSettings: Адаптированная конфигурация
""" """
if base_config is None: if base_config is None:
base_config = PRODUCTION_CONFIG base_config = PRODUCTION_CONFIG
# Если уровень ошибок высокий, ужесточаем ограничения # Если уровень ошибок высокий, ужесточаем ограничения
if current_error_rate > 0.1: # Более 10% ошибок if current_error_rate > 0.1: # Более 10% ошибок
return RateLimitSettings( return RateLimitSettings(
@@ -109,9 +111,9 @@ def get_adaptive_config(
max_retries=base_config.max_retries + 1, max_retries=base_config.max_retries + 1,
voice_message_delay=base_config.voice_message_delay * 1.5, voice_message_delay=base_config.voice_message_delay * 1.5,
media_message_delay=base_config.media_message_delay * 1.3, media_message_delay=base_config.media_message_delay * 1.3,
text_message_delay=base_config.text_message_delay * 1.2 text_message_delay=base_config.text_message_delay * 1.2,
) )
# Если уровень ошибок низкий, можно немного ослабить ограничения # Если уровень ошибок низкий, можно немного ослабить ограничения
elif current_error_rate < 0.01: # Менее 1% ошибок elif current_error_rate < 0.01: # Менее 1% ошибок
return RateLimitSettings( return RateLimitSettings(
@@ -122,8 +124,8 @@ def get_adaptive_config(
max_retries=max(1, base_config.max_retries - 1), max_retries=max(1, base_config.max_retries - 1),
voice_message_delay=base_config.voice_message_delay * 0.8, voice_message_delay=base_config.voice_message_delay * 0.8,
media_message_delay=base_config.media_message_delay * 0.9, media_message_delay=base_config.media_message_delay * 0.9,
text_message_delay=base_config.text_message_delay * 0.9 text_message_delay=base_config.text_message_delay * 0.9,
) )
# Возвращаем базовую конфигурацию # Возвращаем базовую конфигурацию
return base_config return base_config

View File

@@ -5,7 +5,7 @@ from aiogram.types import Message
class ChatTypeFilter(BaseFilter): # [1] class ChatTypeFilter(BaseFilter): # [1]
def __init__(self, chat_type: Union[str, list]): # [2] def __init__(self, chat_type: Union[str, list]): # [2]
self.chat_type = chat_type self.chat_type = chat_type
async def __call__(self, message: Message) -> bool: # [3] async def __call__(self, message: Message) -> bool: # [3]

View File

@@ -1,27 +1,37 @@
from .admin_handlers import admin_router from .admin_handlers import admin_router
from .dependencies import AdminAccessMiddleware, BotDB, Settings from .dependencies import AdminAccessMiddleware, BotDB, Settings
from .exceptions import (AdminAccessDeniedError, AdminError, InvalidInputError, from .exceptions import (
UserAlreadyBannedError, UserNotFoundError) AdminAccessDeniedError,
AdminError,
InvalidInputError,
UserAlreadyBannedError,
UserNotFoundError,
)
from .services import AdminService, BannedUser, User from .services import AdminService, BannedUser, User
from .utils import (escape_html, format_ban_confirmation, format_user_info, from .utils import (
handle_admin_error, return_to_admin_menu) escape_html,
format_ban_confirmation,
format_user_info,
handle_admin_error,
return_to_admin_menu,
)
__all__ = [ __all__ = [
'admin_router', "admin_router",
'AdminAccessMiddleware', "AdminAccessMiddleware",
'BotDB', "BotDB",
'Settings', "Settings",
'AdminService', "AdminService",
'User', "User",
'BannedUser', "BannedUser",
'AdminError', "AdminError",
'AdminAccessDeniedError', "AdminAccessDeniedError",
'UserNotFoundError', "UserNotFoundError",
'InvalidInputError', "InvalidInputError",
'UserAlreadyBannedError', "UserAlreadyBannedError",
'return_to_admin_menu', "return_to_admin_menu",
'handle_admin_error', "handle_admin_error",
'format_user_info', "format_user_info",
'format_ban_confirmation', "format_ban_confirmation",
'escape_html' "escape_html",
] ]

View File

@@ -1,22 +1,30 @@
from aiogram import F, Router, types from aiogram import F, Router, types
from aiogram.filters import Command, MagicData, StateFilter from aiogram.filters import Command, MagicData, StateFilter
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from helper_bot.filters.main import ChatTypeFilter from helper_bot.filters.main import ChatTypeFilter
from helper_bot.handlers.admin.dependencies import AdminAccessMiddleware from helper_bot.handlers.admin.dependencies import AdminAccessMiddleware
from helper_bot.handlers.admin.exceptions import (InvalidInputError, from helper_bot.handlers.admin.exceptions import (
UserAlreadyBannedError) InvalidInputError,
UserAlreadyBannedError,
)
from helper_bot.handlers.admin.services import AdminService from helper_bot.handlers.admin.services import AdminService
from helper_bot.handlers.admin.utils import (escape_html, from helper_bot.handlers.admin.utils import (
format_ban_confirmation, escape_html,
format_user_info, format_ban_confirmation,
handle_admin_error, format_user_info,
return_to_admin_menu) handle_admin_error,
from helper_bot.keyboards.keyboards import (create_keyboard_for_approve_ban, return_to_admin_menu,
create_keyboard_for_ban_days, )
create_keyboard_for_ban_reason, from helper_bot.keyboards.keyboards import (
create_keyboard_with_pagination, create_keyboard_for_approve_ban,
get_reply_keyboard_admin) create_keyboard_for_ban_days,
create_keyboard_for_ban_reason,
create_keyboard_with_pagination,
get_reply_keyboard_admin,
)
from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.base_dependency_factory import get_global_instance
# Local imports - metrics # Local imports - metrics
from helper_bot.utils.metrics import db_query_time, track_errors, track_time from helper_bot.utils.metrics import db_query_time, track_errors, track_time
from logs.custom_logger import logger from logs.custom_logger import logger
@@ -30,23 +38,19 @@ admin_router.message.middleware(AdminAccessMiddleware())
# ХЕНДЛЕРЫ МЕНЮ # ХЕНДЛЕРЫ МЕНЮ
# ============================================================================ # ============================================================================
@admin_router.message(
ChatTypeFilter(chat_type=["private"]), @admin_router.message(ChatTypeFilter(chat_type=["private"]), Command("admin"))
Command('admin')
)
@track_time("admin_panel", "admin_handlers") @track_time("admin_panel", "admin_handlers")
@track_errors("admin_handlers", "admin_panel") @track_errors("admin_handlers", "admin_panel")
async def admin_panel( async def admin_panel(message: types.Message, state: FSMContext, **kwargs):
message: types.Message,
state: FSMContext,
**kwargs
):
"""Главное меню администратора""" """Главное меню администратора"""
try: try:
await state.set_state("ADMIN") await state.set_state("ADMIN")
logger.info(f"Запуск админ панели для пользователя: {message.from_user.id}") logger.info(f"Запуск админ панели для пользователя: {message.from_user.id}")
markup = get_reply_keyboard_admin() markup = get_reply_keyboard_admin()
await message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup) await message.answer(
"Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup
)
except Exception as e: except Exception as e:
await handle_admin_error(message, e, state, "admin_panel") await handle_admin_error(message, e, state, "admin_panel")
@@ -55,18 +59,20 @@ async def admin_panel(
# ХЕНДЛЕР ОТМЕНЫ # ХЕНДЛЕР ОТМЕНЫ
# ============================================================================ # ============================================================================
@admin_router.message( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
StateFilter("AWAIT_BAN_TARGET", "AWAIT_BAN_DETAILS", "AWAIT_BAN_DURATION", "BAN_CONFIRMATION"), StateFilter(
F.text == 'Отменить' "AWAIT_BAN_TARGET",
"AWAIT_BAN_DETAILS",
"AWAIT_BAN_DURATION",
"BAN_CONFIRMATION",
),
F.text == "Отменить",
) )
@track_time("cancel_ban_process", "admin_handlers") @track_time("cancel_ban_process", "admin_handlers")
@track_errors("admin_handlers", "cancel_ban_process") @track_errors("admin_handlers", "cancel_ban_process")
async def cancel_ban_process( async def cancel_ban_process(message: types.Message, state: FSMContext, **kwargs):
message: types.Message,
state: FSMContext,
**kwargs
):
"""Отмена процесса блокировки""" """Отмена процесса блокировки"""
try: try:
current_state = await state.get_state() current_state = await state.get_state()
@@ -79,32 +85,31 @@ async def cancel_ban_process(
@admin_router.message( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
StateFilter("ADMIN"), StateFilter("ADMIN"),
F.text == 'Бан (Список)' F.text == "Бан (Список)",
) )
@track_time("get_last_users", "admin_handlers") @track_time("get_last_users", "admin_handlers")
@track_errors("admin_handlers", "get_last_users") @track_errors("admin_handlers", "get_last_users")
@db_query_time("get_last_users", "users", "select") @db_query_time("get_last_users", "users", "select")
async def get_last_users( async def get_last_users(
message: types.Message, message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
state: FSMContext, ):
bot_db: MagicData("bot_db")
):
"""Получение списка последних пользователей""" """Получение списка последних пользователей"""
try: try:
logger.info(f"Получение списка последних пользователей. Пользователь: {message.from_user.full_name}") logger.info(
f"Получение списка последних пользователей. Пользователь: {message.from_user.full_name}"
)
admin_service = AdminService(bot_db) admin_service = AdminService(bot_db)
users = await admin_service.get_last_users() users = await admin_service.get_last_users()
# Преобразуем в формат для клавиатуры (кортежи как ожидает create_keyboard_with_pagination) # Преобразуем в формат для клавиатуры (кортежи как ожидает create_keyboard_with_pagination)
users_data = [ users_data = [(user.full_name, user.user_id) for user in users]
(user.full_name, user.user_id)
for user in users keyboard = create_keyboard_with_pagination(
] 1, len(users_data), users_data, "ban"
)
keyboard = create_keyboard_with_pagination(1, len(users_data), users_data, 'ban')
await message.answer( await message.answer(
text="Список пользователей которые последними обращались к боту", text="Список пользователей которые последними обращались к боту",
reply_markup=keyboard reply_markup=keyboard,
) )
except Exception as e: except Exception as e:
await handle_admin_error(message, e, state, "get_last_users") await handle_admin_error(message, e, state, "get_last_users")
@@ -113,27 +118,31 @@ async def get_last_users(
@admin_router.message( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
StateFilter("ADMIN"), StateFilter("ADMIN"),
F.text == 'Разбан (список)' F.text == "Разбан (список)",
) )
@track_time("get_banned_users", "admin_handlers") @track_time("get_banned_users", "admin_handlers")
@track_errors("admin_handlers", "get_banned_users") @track_errors("admin_handlers", "get_banned_users")
@db_query_time("get_banned_users", "users", "select") @db_query_time("get_banned_users", "users", "select")
async def get_banned_users( async def get_banned_users(
message: types.Message, message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
state: FSMContext, ):
bot_db: MagicData("bot_db")
):
"""Получение списка заблокированных пользователей""" """Получение списка заблокированных пользователей"""
try: try:
logger.info(f"Получение списка заблокированных пользователей. Пользователь: {message.from_user.full_name}") logger.info(
f"Получение списка заблокированных пользователей. Пользователь: {message.from_user.full_name}"
)
admin_service = AdminService(bot_db) admin_service = AdminService(bot_db)
message_text, buttons_list = await admin_service.get_banned_users_for_display(0) message_text, buttons_list = await admin_service.get_banned_users_for_display(0)
if buttons_list: if buttons_list:
keyboard = create_keyboard_with_pagination(1, len(buttons_list), buttons_list, 'unlock') keyboard = create_keyboard_with_pagination(
1, len(buttons_list), buttons_list, "unlock"
)
await message.answer(text=message_text, reply_markup=keyboard) await message.answer(text=message_text, reply_markup=keyboard)
else: else:
await message.answer(text="В списке заблокированных пользователей никого нет") await message.answer(
text="В списке заблокированных пользователей никого нет"
)
except Exception as e: except Exception as e:
await handle_admin_error(message, e, state, "get_banned_users") await handle_admin_error(message, e, state, "get_banned_users")
@@ -141,85 +150,95 @@ async def get_banned_users(
@admin_router.message( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
StateFilter("ADMIN"), StateFilter("ADMIN"),
F.text == '📊 ML Статистика' F.text == "📊 ML Статистика",
) )
@track_time("get_ml_stats", "admin_handlers") @track_time("get_ml_stats", "admin_handlers")
@track_errors("admin_handlers", "get_ml_stats") @track_errors("admin_handlers", "get_ml_stats")
async def get_ml_stats( async def get_ml_stats(message: types.Message, state: FSMContext, **kwargs):
message: types.Message,
state: FSMContext,
**kwargs
):
"""Получение статистики ML-скоринга""" """Получение статистики ML-скоринга"""
try: try:
logger.info(f"Запрос ML статистики от пользователя: {message.from_user.full_name}") logger.info(
f"Запрос ML статистики от пользователя: {message.from_user.full_name}"
)
bdf = get_global_instance() bdf = get_global_instance()
scoring_manager = bdf.get_scoring_manager() scoring_manager = bdf.get_scoring_manager()
if not scoring_manager: if not scoring_manager:
await message.answer("📊 ML Scoring отключен\n\nДля включения установите RAG_ENABLED=true или DEEPSEEK_ENABLED=true в .env") await message.answer(
"📊 ML Scoring отключен\n\nДля включения установите RAG_ENABLED=true или DEEPSEEK_ENABLED=true в .env"
)
return return
stats = await scoring_manager.get_stats() stats = await scoring_manager.get_stats()
# Формируем текст статистики # Формируем текст статистики
lines = ["📊 <b>ML Scoring Статистика</b>\n"] lines = ["📊 <b>ML Scoring Статистика</b>\n"]
# RAG статистика # RAG статистика
if "rag" in stats: if "rag" in stats:
rag = stats["rag"] rag = stats["rag"]
lines.append("🤖 <b>RAG API:</b>") lines.append("🤖 <b>RAG API:</b>")
# Проверяем, есть ли данные из API (новый контракт содержит model_loaded и vector_store) # Проверяем, есть ли данные из API (новый контракт содержит model_loaded и vector_store)
if "model_loaded" in rag or "vector_store" in rag: if "model_loaded" in rag or "vector_store" in rag:
# Данные из API /stats # Данные из API /stats
if "model_loaded" in rag: if "model_loaded" in rag:
model_loaded = rag.get('model_loaded', False) model_loaded = rag.get("model_loaded", False)
lines.append(f" • Модель загружена: {'' if model_loaded else ''}") lines.append(
f" • Модель загружена: {'' if model_loaded else ''}"
)
if "model_name" in rag: if "model_name" in rag:
lines.append(f" • Модель: {rag.get('model_name', 'N/A')}") lines.append(f" • Модель: {rag.get('model_name', 'N/A')}")
if "device" in rag: if "device" in rag:
lines.append(f" • Устройство: {rag.get('device', 'N/A')}") lines.append(f" • Устройство: {rag.get('device', 'N/A')}")
# Статистика из vector_store # Статистика из vector_store
if "vector_store" in rag: if "vector_store" in rag:
vector_store = rag["vector_store"] vector_store = rag["vector_store"]
positive_count = vector_store.get("positive_count", 0) positive_count = vector_store.get("positive_count", 0)
negative_count = vector_store.get("negative_count", 0) negative_count = vector_store.get("negative_count", 0)
total_count = vector_store.get("total_count", 0) total_count = vector_store.get("total_count", 0)
lines.append(f" • Положительных примеров: {positive_count}") lines.append(f" • Положительных примеров: {positive_count}")
lines.append(f" • Отрицательных примеров: {negative_count}") lines.append(f" • Отрицательных примеров: {negative_count}")
lines.append(f"Всего примеров: {total_count}") lines.append(f"Всего примеров: {total_count}")
if "vector_dim" in vector_store: if "vector_dim" in vector_store:
lines.append(f" • Размерность векторов: {vector_store.get('vector_dim', 'N/A')}") lines.append(
f" • Размерность векторов: {vector_store.get('vector_dim', 'N/A')}"
)
if "max_examples" in vector_store: if "max_examples" in vector_store:
lines.append(f" • Макс. примеров: {vector_store.get('max_examples', 'N/A')}") lines.append(
f" • Макс. примеров: {vector_store.get('max_examples', 'N/A')}"
)
else: else:
# Fallback на синхронные данные (если API недоступен) # Fallback на синхронные данные (если API недоступен)
lines.append(f" • API URL: {rag.get('api_url', 'N/A')}") lines.append(f" • API URL: {rag.get('api_url', 'N/A')}")
if "enabled" in rag: if "enabled" in rag:
lines.append(f" • Статус: {'✅ Включен' if rag.get('enabled') else '❌ Отключен'}") lines.append(
f" • Статус: {'✅ Включен' if rag.get('enabled') else '❌ Отключен'}"
)
lines.append("") lines.append("")
# DeepSeek статистика # DeepSeek статистика
if "deepseek" in stats: if "deepseek" in stats:
ds = stats["deepseek"] ds = stats["deepseek"]
lines.append("🔮 <b>DeepSeek API:</b>") lines.append("🔮 <b>DeepSeek API:</b>")
lines.append(f" • Статус: {'✅ Включен' if ds.get('enabled') else '❌ Отключен'}") lines.append(
f" • Статус: {'✅ Включен' if ds.get('enabled') else '❌ Отключен'}"
)
lines.append(f" • Модель: {ds.get('model', 'N/A')}") lines.append(f" • Модель: {ds.get('model', 'N/A')}")
lines.append(f" • Таймаут: {ds.get('timeout', 'N/A')}с") lines.append(f" • Таймаут: {ds.get('timeout', 'N/A')}с")
lines.append("") lines.append("")
# Если ничего не включено # Если ничего не включено
if "rag" not in stats and "deepseek" not in stats: if "rag" not in stats and "deepseek" not in stats:
lines.append("⚠️ Ни один сервис не настроен") lines.append("⚠️ Ни один сервис не настроен")
await message.answer("\n".join(lines), parse_mode="HTML") await message.answer("\n".join(lines), parse_mode="HTML")
except Exception as e: except Exception as e:
logger.error(f"Ошибка получения ML статистики: {e}") logger.error(f"Ошибка получения ML статистики: {e}")
await message.answer(f"❌ Ошибка получения статистики: {str(e)}") await message.answer(f"❌ Ошибка получения статистики: {str(e)}")
@@ -229,68 +248,80 @@ async def get_ml_stats(
# ХЕНДЛЕРЫ ПРОЦЕССА БАНА # ХЕНДЛЕРЫ ПРОЦЕССА БАНА
# ============================================================================ # ============================================================================
@admin_router.message( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
StateFilter("ADMIN"), StateFilter("ADMIN"),
F.text.in_(['Бан по нику', 'Бан по ID']) F.text.in_(["Бан по нику", "Бан по ID"]),
) )
@track_time("start_ban_process", "admin_handlers") @track_time("start_ban_process", "admin_handlers")
@track_errors("admin_handlers", "start_ban_process") @track_errors("admin_handlers", "start_ban_process")
async def start_ban_process( async def start_ban_process(message: types.Message, state: FSMContext, **kwargs):
message: types.Message,
state: FSMContext,
**kwargs
):
"""Начало процесса блокировки пользователя""" """Начало процесса блокировки пользователя"""
try: try:
ban_type = "username" if message.text == 'Бан по нику' else "id" ban_type = "username" if message.text == "Бан по нику" else "id"
await state.update_data(ban_type=ban_type) await state.update_data(ban_type=ban_type)
prompt_text = "Пришли мне username блокируемого пользователя" if ban_type == "username" else "Пришли мне ID блокируемого пользователя" prompt_text = (
"Пришли мне username блокируемого пользователя"
if ban_type == "username"
else "Пришли мне ID блокируемого пользователя"
)
await message.answer(prompt_text) await message.answer(prompt_text)
await state.set_state('AWAIT_BAN_TARGET') await state.set_state("AWAIT_BAN_TARGET")
except Exception as e: except Exception as e:
await handle_admin_error(message, e, state, "start_ban_process") await handle_admin_error(message, e, state, "start_ban_process")
@admin_router.message( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]), StateFilter("AWAIT_BAN_TARGET")
StateFilter("AWAIT_BAN_TARGET")
) )
@track_time("process_ban_target", "admin_handlers") @track_time("process_ban_target", "admin_handlers")
@track_errors("admin_handlers", "process_ban_target") @track_errors("admin_handlers", "process_ban_target")
async def process_ban_target( async def process_ban_target(
message: types.Message, message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
state: FSMContext, ):
bot_db: MagicData("bot_db")
):
"""Обработка введенного username/ID для блокировки""" """Обработка введенного username/ID для блокировки"""
logger.info(f"process_ban_target: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}") logger.info(
f"process_ban_target: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}"
)
try: try:
user_data = await state.get_data() user_data = await state.get_data()
ban_type = user_data.get('ban_type') ban_type = user_data.get("ban_type")
admin_service = AdminService(bot_db) admin_service = AdminService(bot_db)
logger.info(f"process_ban_target: ban_type={ban_type}, user_data={user_data}") logger.info(f"process_ban_target: ban_type={ban_type}, user_data={user_data}")
# Определяем пользователя # Определяем пользователя
if ban_type == "username": if ban_type == "username":
logger.info(f"process_ban_target: Поиск пользователя по username: {message.text}") logger.info(
f"process_ban_target: Поиск пользователя по username: {message.text}"
)
user = await admin_service.get_user_by_username(message.text) user = await admin_service.get_user_by_username(message.text)
if not user: if not user:
logger.warning(f"process_ban_target: Пользователь с username '{message.text}' не найден") logger.warning(
await message.answer(f"Пользователь с username '{escape_html(message.text)}' не найден.") f"process_ban_target: Пользователь с username '{message.text}' не найден"
)
await message.answer(
f"Пользователь с username '{escape_html(message.text)}' не найден."
)
await return_to_admin_menu(message, state) await return_to_admin_menu(message, state)
return return
else: # ban_type == "id" else: # ban_type == "id"
try: try:
logger.info(f"process_ban_target: Валидация и поиск пользователя по ID: {message.text}") logger.info(
f"process_ban_target: Валидация и поиск пользователя по ID: {message.text}"
)
user_id = await admin_service.validate_user_input(message.text) user_id = await admin_service.validate_user_input(message.text)
user = await admin_service.get_user_by_id(user_id) user = await admin_service.get_user_by_id(user_id)
if not user: if not user:
logger.warning(f"process_ban_target: Пользователь с ID {user_id} не найден в базе данных") logger.warning(
await message.answer(f"Пользователь с ID {user_id} не найден в базе данных.") f"process_ban_target: Пользователь с ID {user_id} не найден в базе данных"
)
await message.answer(
f"Пользователь с ID {user_id} не найден в базе данных."
)
await return_to_admin_menu(message, state) await return_to_admin_menu(message, state)
return return
except InvalidInputError as e: except InvalidInputError as e:
@@ -298,115 +329,117 @@ async def process_ban_target(
await message.answer(str(e)) await message.answer(str(e))
await return_to_admin_menu(message, state) await return_to_admin_menu(message, state)
return return
logger.info(f"process_ban_target: Найден пользователь: {user.user_id}, {user.username}, {user.full_name}") logger.info(
f"process_ban_target: Найден пользователь: {user.user_id}, {user.username}, {user.full_name}"
)
# Сохраняем данные пользователя # Сохраняем данные пользователя
await state.update_data( await state.update_data(
target_user_id=user.user_id, target_user_id=user.user_id,
target_username=user.username, target_username=user.username,
target_full_name=user.full_name target_full_name=user.full_name,
) )
# Показываем информацию о пользователе и запрашиваем причину # Показываем информацию о пользователе и запрашиваем причину
user_info = format_user_info(user.user_id, user.username, user.full_name) user_info = format_user_info(user.user_id, user.username, user.full_name)
markup = create_keyboard_for_ban_reason() markup = create_keyboard_for_ban_reason()
logger.info(f"process_ban_target: Отправка сообщения с причиной бана, user_info: {user_info}") logger.info(
f"process_ban_target: Отправка сообщения с причиной бана, user_info: {user_info}"
)
await message.answer( await message.answer(
text=f"{user_info}\n\nВыбери причину бана из списка или напиши ее в чат", text=f"{user_info}\n\nВыбери причину бана из списка или напиши ее в чат",
reply_markup=markup reply_markup=markup,
) )
await state.set_state('AWAIT_BAN_DETAILS') await state.set_state("AWAIT_BAN_DETAILS")
logger.info("process_ban_target: Состояние изменено на AWAIT_BAN_DETAILS") logger.info("process_ban_target: Состояние изменено на AWAIT_BAN_DETAILS")
except Exception as e: except Exception as e:
logger.error(f"process_ban_target: Неожиданная ошибка: {e}", exc_info=True) logger.error(f"process_ban_target: Неожиданная ошибка: {e}", exc_info=True)
await handle_admin_error(message, e, state, "process_ban_target") await handle_admin_error(message, e, state, "process_ban_target")
@admin_router.message( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]), StateFilter("AWAIT_BAN_DETAILS")
StateFilter("AWAIT_BAN_DETAILS")
) )
@track_time("process_ban_reason", "admin_handlers") @track_time("process_ban_reason", "admin_handlers")
@track_errors("admin_handlers", "process_ban_reason") @track_errors("admin_handlers", "process_ban_reason")
async def process_ban_reason( async def process_ban_reason(message: types.Message, state: FSMContext, **kwargs):
message: types.Message,
state: FSMContext,
**kwargs
):
"""Обработка причины блокировки""" """Обработка причины блокировки"""
logger.info(f"process_ban_reason: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}") logger.info(
f"process_ban_reason: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}"
)
try: try:
# Проверяем текущее состояние # Проверяем текущее состояние
current_state = await state.get_state() current_state = await state.get_state()
logger.info(f"process_ban_reason: Текущее состояние: {current_state}") logger.info(f"process_ban_reason: Текущее состояние: {current_state}")
# Проверяем данные состояния # Проверяем данные состояния
state_data = await state.get_data() state_data = await state.get_data()
logger.info(f"process_ban_reason: Данные состояния: {state_data}") logger.info(f"process_ban_reason: Данные состояния: {state_data}")
logger.info(f"process_ban_reason: Обновление данных состояния с причиной: {message.text}") logger.info(
f"process_ban_reason: Обновление данных состояния с причиной: {message.text}"
)
await state.update_data(ban_reason=message.text) await state.update_data(ban_reason=message.text)
markup = create_keyboard_for_ban_days() markup = create_keyboard_for_ban_days()
safe_reason = escape_html(message.text) safe_reason = escape_html(message.text)
logger.info(f"process_ban_reason: Отправка сообщения с выбором срока бана, причина: {safe_reason}") logger.info(
f"process_ban_reason: Отправка сообщения с выбором срока бана, причина: {safe_reason}"
)
await message.answer( await message.answer(
f"Выбрана причина: {safe_reason}. Выбери срок бана в днях или напиши его в чат", f"Выбрана причина: {safe_reason}. Выбери срок бана в днях или напиши его в чат",
reply_markup=markup reply_markup=markup,
) )
await state.set_state('AWAIT_BAN_DURATION') await state.set_state("AWAIT_BAN_DURATION")
logger.info("process_ban_reason: Состояние изменено на AWAIT_BAN_DURATION") logger.info("process_ban_reason: Состояние изменено на AWAIT_BAN_DURATION")
except Exception as e: except Exception as e:
logger.error(f"process_ban_reason: Неожиданная ошибка: {e}", exc_info=True) logger.error(f"process_ban_reason: Неожиданная ошибка: {e}", exc_info=True)
await handle_admin_error(message, e, state, "process_ban_reason") await handle_admin_error(message, e, state, "process_ban_reason")
@admin_router.message( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]), StateFilter("AWAIT_BAN_DURATION")
StateFilter("AWAIT_BAN_DURATION")
) )
@track_time("process_ban_duration", "admin_handlers") @track_time("process_ban_duration", "admin_handlers")
@track_errors("admin_handlers", "process_ban_duration") @track_errors("admin_handlers", "process_ban_duration")
async def process_ban_duration( async def process_ban_duration(message: types.Message, state: FSMContext, **kwargs):
message: types.Message,
state: FSMContext,
**kwargs
):
"""Обработка срока блокировки""" """Обработка срока блокировки"""
try: try:
user_data = await state.get_data() user_data = await state.get_data()
# Определяем срок блокировки # Определяем срок блокировки
if message.text == 'Навсегда': if message.text == "Навсегда":
ban_days = None ban_days = None
else: else:
try: try:
ban_days = int(message.text) ban_days = int(message.text)
if ban_days <= 0: if ban_days <= 0:
await message.answer("Срок блокировки должен быть положительным числом.") await message.answer(
"Срок блокировки должен быть положительным числом."
)
return return
except ValueError: except ValueError:
await message.answer("Пожалуйста, введите корректное число дней или выберите 'Навсегда'.") await message.answer(
"Пожалуйста, введите корректное число дней или выберите 'Навсегда'."
)
return return
await state.update_data(ban_days=ban_days) await state.update_data(ban_days=ban_days)
# Показываем подтверждение # Показываем подтверждение
confirmation_text = format_ban_confirmation( confirmation_text = format_ban_confirmation(
user_data['target_user_id'], user_data["target_user_id"], user_data["ban_reason"], ban_days
user_data['ban_reason'],
ban_days
) )
markup = create_keyboard_for_approve_ban() markup = create_keyboard_for_approve_ban()
await message.answer(confirmation_text, reply_markup=markup) await message.answer(confirmation_text, reply_markup=markup)
await state.set_state('BAN_CONFIRMATION') await state.set_state("BAN_CONFIRMATION")
except Exception as e: except Exception as e:
await handle_admin_error(message, e, state, "process_ban_duration") await handle_admin_error(message, e, state, "process_ban_duration")
@@ -414,35 +447,31 @@ async def process_ban_duration(
@admin_router.message( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
StateFilter("BAN_CONFIRMATION"), StateFilter("BAN_CONFIRMATION"),
F.text == 'Подтвердить' F.text == "Подтвердить",
) )
@track_time("confirm_ban", "admin_handlers") @track_time("confirm_ban", "admin_handlers")
@track_errors("admin_handlers", "confirm_ban") @track_errors("admin_handlers", "confirm_ban")
async def confirm_ban( async def confirm_ban(
message: types.Message, message: types.Message, state: FSMContext, bot_db: MagicData("bot_db"), **kwargs
state: FSMContext, ):
bot_db: MagicData("bot_db"),
**kwargs
):
"""Подтверждение блокировки пользователя""" """Подтверждение блокировки пользователя"""
try: try:
user_data = await state.get_data() user_data = await state.get_data()
admin_service = AdminService(bot_db) admin_service = AdminService(bot_db)
# Выполняем блокировку # Выполняем блокировку
await admin_service.ban_user( await admin_service.ban_user(
user_id=user_data['target_user_id'], user_id=user_data["target_user_id"],
username=user_data['target_username'], username=user_data["target_username"],
reason=user_data['ban_reason'], reason=user_data["ban_reason"],
ban_days=user_data['ban_days'], ban_days=user_data["ban_days"],
ban_author_id=message.from_user.id, ban_author_id=message.from_user.id,
) )
safe_username = escape_html(user_data['target_username']) safe_username = escape_html(user_data["target_username"])
await message.reply(f"Пользователь {safe_username} успешно заблокирован.") await message.reply(f"Пользователь {safe_username} успешно заблокирован.")
await return_to_admin_menu(message, state) await return_to_admin_menu(message, state)
except UserAlreadyBannedError as e: except UserAlreadyBannedError as e:
await message.reply(str(e)) await message.reply(str(e))
await return_to_admin_menu(message, state) await return_to_admin_menu(message, state)

View File

@@ -9,7 +9,7 @@ ADMIN_BUTTON_TEXTS: Final[Dict[str, str]] = {
"BAN_BY_ID": "Бан по ID", "BAN_BY_ID": "Бан по ID",
"UNBAN_LIST": "Разбан (список)", "UNBAN_LIST": "Разбан (список)",
"RETURN_TO_BOT": "Вернуться в бота", "RETURN_TO_BOT": "Вернуться в бота",
"CANCEL": "Отменить" "CANCEL": "Отменить",
} }
# Admin button to command mapping for metrics # Admin button to command mapping for metrics
@@ -19,11 +19,11 @@ ADMIN_BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = {
"Бан по ID": "admin_ban_by_id", "Бан по ID": "admin_ban_by_id",
"Разбан (список)": "admin_unban_list", "Разбан (список)": "admin_unban_list",
"Вернуться в бота": "admin_return_to_bot", "Вернуться в бота": "admin_return_to_bot",
"Отменить": "admin_cancel" "Отменить": "admin_cancel",
} }
# Admin commands # Admin commands
ADMIN_COMMANDS: Final[Dict[str, str]] = { ADMIN_COMMANDS: Final[Dict[str, str]] = {
"ADMIN": "admin", "ADMIN": "admin",
"TEST_METRICS": "test_metrics" "TEST_METRICS": "test_metrics",
} }

View File

@@ -7,6 +7,7 @@ except ImportError:
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from aiogram.types import TelegramObject from aiogram.types import TelegramObject
from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.base_dependency_factory import get_global_instance
from helper_bot.utils.helper_func import check_access from helper_bot.utils.helper_func import check_access
from logs.custom_logger import logger from logs.custom_logger import logger
@@ -14,36 +15,46 @@ from logs.custom_logger import logger
class AdminAccessMiddleware(BaseMiddleware): class AdminAccessMiddleware(BaseMiddleware):
"""Middleware для проверки административного доступа""" """Middleware для проверки административного доступа"""
async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any: async def __call__(
if hasattr(event, 'from_user'): self, handler, event: TelegramObject, data: Dict[str, Any]
) -> Any:
if hasattr(event, "from_user"):
user_id = event.from_user.id user_id = event.from_user.id
username = getattr(event.from_user, 'username', 'Unknown') username = getattr(event.from_user, "username", "Unknown")
logger.info(f"AdminAccessMiddleware: проверка доступа для пользователя {username} (ID: {user_id})") logger.info(
f"AdminAccessMiddleware: проверка доступа для пользователя {username} (ID: {user_id})"
)
# Получаем bot_db из data (внедренного DependenciesMiddleware) # Получаем bot_db из data (внедренного DependenciesMiddleware)
bot_db = data.get('bot_db') bot_db = data.get("bot_db")
if not bot_db: if not bot_db:
# Fallback: получаем напрямую если middleware не сработала # Fallback: получаем напрямую если middleware не сработала
bdf = get_global_instance() bdf = get_global_instance()
bot_db = bdf.get_db() bot_db = bdf.get_db()
is_admin_result = await check_access(user_id, bot_db) is_admin_result = await check_access(user_id, bot_db)
logger.info(f"AdminAccessMiddleware: результат проверки для {username}: {is_admin_result}") logger.info(
f"AdminAccessMiddleware: результат проверки для {username}: {is_admin_result}"
)
if not is_admin_result: if not is_admin_result:
logger.warning(f"AdminAccessMiddleware: доступ запрещен для пользователя {username} (ID: {user_id})") logger.warning(
if hasattr(event, 'answer'): f"AdminAccessMiddleware: доступ запрещен для пользователя {username} (ID: {user_id})"
await event.answer('Доступ запрещен!') )
if hasattr(event, "answer"):
await event.answer("Доступ запрещен!")
return return
try: try:
# Вызываем хендлер с data # Вызываем хендлер с data
return await handler(event, data) return await handler(event, data)
except TypeError as e: except TypeError as e:
if "missing 1 required positional argument: 'data'" in str(e): if "missing 1 required positional argument: 'data'" in str(e):
logger.error(f"Ошибка в AdminAccessMiddleware: {e}. Хендлер не принимает параметр 'data'") logger.error(
f"Ошибка в AdminAccessMiddleware: {e}. Хендлер не принимает параметр 'data'"
)
# Пытаемся вызвать хендлер без data (для совместимости с MagicData) # Пытаемся вызвать хендлер без data (для совместимости с MagicData)
return await handler(event) return await handler(event)
else: else:

View File

@@ -1,23 +1,28 @@
class AdminError(Exception): class AdminError(Exception):
"""Базовое исключение для административных операций""" """Базовое исключение для административных операций"""
pass pass
class AdminAccessDeniedError(AdminError): class AdminAccessDeniedError(AdminError):
"""Исключение при отказе в административном доступе""" """Исключение при отказе в административном доступе"""
pass pass
class UserNotFoundError(AdminError): class UserNotFoundError(AdminError):
"""Исключение при отсутствии пользователя""" """Исключение при отсутствии пользователя"""
pass pass
class InvalidInputError(AdminError): class InvalidInputError(AdminError):
"""Исключение при некорректном вводе данных""" """Исключение при некорректном вводе данных"""
pass pass
class UserAlreadyBannedError(AdminError): class UserAlreadyBannedError(AdminError):
"""Исключение при попытке забанить уже заблокированного пользователя""" """Исключение при попытке забанить уже заблокированного пользователя"""
pass pass

View File

@@ -1,25 +1,31 @@
""" """
Обработчики команд для мониторинга rate limiting Обработчики команд для мониторинга rate limiting
""" """
from aiogram import F, Router, types from aiogram import F, Router, types
from aiogram.filters import Command, MagicData from aiogram.filters import Command, MagicData
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import FSInputFile from aiogram.types import FSInputFile
from helper_bot.filters.main import ChatTypeFilter from helper_bot.filters.main import ChatTypeFilter
from helper_bot.middlewares.dependencies_middleware import \ from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
DependenciesMiddleware
# Local imports - metrics # Local imports - metrics
from helper_bot.utils.metrics import track_errors, track_time from helper_bot.utils.metrics import track_errors, track_time
from helper_bot.utils.rate_limit_metrics import ( from helper_bot.utils.rate_limit_metrics import (
get_rate_limit_metrics_summary, update_rate_limit_gauges) get_rate_limit_metrics_summary,
from helper_bot.utils.rate_limit_monitor import (get_rate_limit_summary, update_rate_limit_gauges,
rate_limit_monitor) )
from helper_bot.utils.rate_limit_monitor import (
get_rate_limit_summary,
rate_limit_monitor,
)
from logs.custom_logger import logger from logs.custom_logger import logger
class RateLimitHandlers: class RateLimitHandlers:
def __init__(self, db, settings): def __init__(self, db, settings):
self.db = db.get_db() if hasattr(db, 'get_db') else db self.db = db.get_db() if hasattr(db, "get_db") else db
self.settings = settings self.settings = settings
self.router = Router() self.router = Router()
self._setup_handlers() self._setup_handlers()
@@ -33,38 +39,38 @@ class RateLimitHandlers:
self.router.message.register( self.router.message.register(
self.rate_limit_stats_handler, self.rate_limit_stats_handler,
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
Command("ratelimit_stats") Command("ratelimit_stats"),
) )
# Команда для сброса статистики rate limiting # Команда для сброса статистики rate limiting
self.router.message.register( self.router.message.register(
self.reset_rate_limit_stats_handler, self.reset_rate_limit_stats_handler,
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
Command("reset_ratelimit_stats") Command("reset_ratelimit_stats"),
) )
# Команда для просмотра ошибок rate limiting # Команда для просмотра ошибок rate limiting
self.router.message.register( self.router.message.register(
self.rate_limit_errors_handler, self.rate_limit_errors_handler,
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
Command("ratelimit_errors") Command("ratelimit_errors"),
) )
# Команда для просмотра Prometheus метрик # Команда для просмотра Prometheus метрик
self.router.message.register( self.router.message.register(
self.rate_limit_prometheus_handler, self.rate_limit_prometheus_handler,
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
Command("ratelimit_prometheus") Command("ratelimit_prometheus"),
) )
@track_time("rate_limit_stats_handler", "rate_limit_handlers") @track_time("rate_limit_stats_handler", "rate_limit_handlers")
@track_errors("rate_limit_handlers", "rate_limit_stats_handler") @track_errors("rate_limit_handlers", "rate_limit_stats_handler")
async def rate_limit_stats_handler( async def rate_limit_stats_handler(
self, self,
message: types.Message, message: types.Message,
state: FSMContext, state: FSMContext,
bot_db: MagicData("bot_db"), bot_db: MagicData("bot_db"),
settings: MagicData("settings") settings: MagicData("settings"),
): ):
"""Показывает статистику rate limiting""" """Показывает статистику rate limiting"""
try: try:
@@ -72,11 +78,11 @@ class RateLimitHandlers:
if not await bot_db.is_admin(message.from_user.id): if not await bot_db.is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды.") await message.answer("У вас нет прав для выполнения этой команды.")
return return
# Получаем сводку # Получаем сводку
summary = get_rate_limit_summary() summary = get_rate_limit_summary()
global_stats = rate_limit_monitor.get_global_stats() global_stats = rate_limit_monitor.get_global_stats()
# Формируем сообщение со статистикой # Формируем сообщение со статистикой
stats_text = ( stats_text = (
f"📊 <b>Статистика Rate Limiting</b>\n\n" f"📊 <b>Статистика Rate Limiting</b>\n\n"
@@ -89,15 +95,17 @@ class RateLimitHandlers:
f"• Активных чатов: {summary['active_chats']}\n" f"• Активных чатов: {summary['active_chats']}\n"
f"• Ошибок за час: {summary['recent_errors_count']}\n\n" f"• Ошибок за час: {summary['recent_errors_count']}\n\n"
) )
# Добавляем детальную статистику # Добавляем детальную статистику
stats_text += f"🔍 <b>Детальная статистика:</b>\n" stats_text += f"🔍 <b>Детальная статистика:</b>\n"
stats_text += f"• Успешных запросов: {global_stats.successful_requests}\n" stats_text += f"• Успешных запросов: {global_stats.successful_requests}\n"
stats_text += f"• Неудачных запросов: {global_stats.failed_requests}\n" stats_text += f"• Неудачных запросов: {global_stats.failed_requests}\n"
stats_text += f"• RetryAfter ошибок: {global_stats.retry_after_errors}\n" stats_text += f"• RetryAfter ошибок: {global_stats.retry_after_errors}\n"
stats_text += f"• Других ошибок: {global_stats.other_errors}\n" stats_text += f"• Других ошибок: {global_stats.other_errors}\n"
stats_text += f"• Общее время ожидания: {global_stats.total_wait_time:.2f}с\n\n" stats_text += (
f"• Общее время ожидания: {global_stats.total_wait_time:.2f}с\n\n"
)
# Добавляем топ чатов по запросам # Добавляем топ чатов по запросам
top_chats = rate_limit_monitor.get_top_chats_by_requests(5) top_chats = rate_limit_monitor.get_top_chats_by_requests(5)
if top_chats: if top_chats:
@@ -105,16 +113,16 @@ class RateLimitHandlers:
for i, (chat_id, chat_stats) in enumerate(top_chats, 1): for i, (chat_id, chat_stats) in enumerate(top_chats, 1):
stats_text += f"{i}. Chat {chat_id}: {chat_stats.total_requests} запросов ({chat_stats.success_rate:.1%} успех)\n" stats_text += f"{i}. Chat {chat_id}: {chat_stats.total_requests} запросов ({chat_stats.success_rate:.1%} успех)\n"
stats_text += "\n" stats_text += "\n"
# Добавляем чаты с высоким процентом ошибок # Добавляем чаты с высоким процентом ошибок
high_error_chats = rate_limit_monitor.get_chats_with_high_error_rate(0.1) high_error_chats = rate_limit_monitor.get_chats_with_high_error_rate(0.1)
if high_error_chats: if high_error_chats:
stats_text += f"⚠️ <b>Чаты с высоким процентом ошибок (>10%):</b>\n" stats_text += f"⚠️ <b>Чаты с высоким процентом ошибок (>10%):</b>\n"
for chat_id, chat_stats in high_error_chats[:3]: for chat_id, chat_stats in high_error_chats[:3]:
stats_text += f"• Chat {chat_id}: {chat_stats.error_rate:.1%} ошибок ({chat_stats.failed_requests}/{chat_stats.total_requests})\n" stats_text += f"• Chat {chat_id}: {chat_stats.error_rate:.1%} ошибок ({chat_stats.failed_requests}/{chat_stats.total_requests})\n"
await message.answer(stats_text, parse_mode='HTML') await message.answer(stats_text, parse_mode="HTML")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении статистики rate limiting: {e}") logger.error(f"Ошибка при получении статистики rate limiting: {e}")
await message.answer("Произошла ошибка при получении статистики.") await message.answer("Произошла ошибка при получении статистики.")
@@ -123,10 +131,10 @@ class RateLimitHandlers:
@track_errors("rate_limit_handlers", "reset_rate_limit_stats_handler") @track_errors("rate_limit_handlers", "reset_rate_limit_stats_handler")
async def reset_rate_limit_stats_handler( async def reset_rate_limit_stats_handler(
self, self,
message: types.Message, message: types.Message,
state: FSMContext, state: FSMContext,
bot_db: MagicData("bot_db"), bot_db: MagicData("bot_db"),
settings: MagicData("settings") settings: MagicData("settings"),
): ):
"""Сбрасывает статистику rate limiting""" """Сбрасывает статистику rate limiting"""
try: try:
@@ -134,12 +142,12 @@ class RateLimitHandlers:
if not await bot_db.is_admin(message.from_user.id): if not await bot_db.is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды.") await message.answer("У вас нет прав для выполнения этой команды.")
return return
# Сбрасываем статистику # Сбрасываем статистику
rate_limit_monitor.reset_stats() rate_limit_monitor.reset_stats()
await message.answer("✅ Статистика rate limiting сброшена.") await message.answer("✅ Статистика rate limiting сброшена.")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при сбросе статистики rate limiting: {e}") logger.error(f"Ошибка при сбросе статистики rate limiting: {e}")
await message.answer("Произошла ошибка при сбросе статистики.") await message.answer("Произошла ошибка при сбросе статистики.")
@@ -148,10 +156,10 @@ class RateLimitHandlers:
@track_errors("rate_limit_handlers", "rate_limit_errors_handler") @track_errors("rate_limit_handlers", "rate_limit_errors_handler")
async def rate_limit_errors_handler( async def rate_limit_errors_handler(
self, self,
message: types.Message, message: types.Message,
state: FSMContext, state: FSMContext,
bot_db: MagicData("bot_db"), bot_db: MagicData("bot_db"),
settings: MagicData("settings") settings: MagicData("settings"),
): ):
"""Показывает недавние ошибки rate limiting""" """Показывает недавние ошибки rate limiting"""
try: try:
@@ -159,29 +167,34 @@ class RateLimitHandlers:
if not await bot_db.is_admin(message.from_user.id): if not await bot_db.is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды.") await message.answer("У вас нет прав для выполнения этой команды.")
return return
# Получаем ошибки за последний час # Получаем ошибки за последний час
recent_errors = rate_limit_monitor.get_recent_errors(60) recent_errors = rate_limit_monitor.get_recent_errors(60)
error_summary = rate_limit_monitor.get_error_summary(60) error_summary = rate_limit_monitor.get_error_summary(60)
if not recent_errors: if not recent_errors:
await message.answer("✅ Ошибок rate limiting за последний час не было.") await message.answer(
"✅ Ошибок rate limiting за последний час не было."
)
return return
# Формируем сообщение с ошибками # Формируем сообщение с ошибками
errors_text = f"🚨 <b>Ошибки Rate Limiting (последний час)</b>\n\n" errors_text = f"🚨 <b>Ошибки Rate Limiting (последний час)</b>\n\n"
errors_text += f"📊 <b>Сводка ошибок:</b>\n" errors_text += f"📊 <b>Сводка ошибок:</b>\n"
for error_type, count in error_summary.items(): for error_type, count in error_summary.items():
errors_text += f"{error_type}: {count}\n" errors_text += f"{error_type}: {count}\n"
errors_text += f"\nВсего ошибок: {len(recent_errors)}\n\n" errors_text += f"\nВсего ошибок: {len(recent_errors)}\n\n"
# Показываем последние 10 ошибок # Показываем последние 10 ошибок
errors_text += f"🔍 <b>Последние ошибки:</b>\n" errors_text += f"🔍 <b>Последние ошибки:</b>\n"
for i, error in enumerate(recent_errors[-10:], 1): for i, error in enumerate(recent_errors[-10:], 1):
from datetime import datetime from datetime import datetime
timestamp = datetime.fromtimestamp(error['timestamp']).strftime("%H:%M:%S")
timestamp = datetime.fromtimestamp(error["timestamp"]).strftime(
"%H:%M:%S"
)
errors_text += f"{i}. {timestamp} - Chat {error['chat_id']} - {error['error_type']}\n" errors_text += f"{i}. {timestamp} - Chat {error['chat_id']} - {error['error_type']}\n"
# Если сообщение слишком длинное, разбиваем на части # Если сообщение слишком длинное, разбиваем на части
if len(errors_text) > 4000: if len(errors_text) > 4000:
# Отправляем сводку # Отправляем сводку
@@ -190,32 +203,37 @@ class RateLimitHandlers:
for error_type, count in error_summary.items(): for error_type, count in error_summary.items():
summary_text += f"{error_type}: {count}\n" summary_text += f"{error_type}: {count}\n"
summary_text += f"\nВсего ошибок: {len(recent_errors)}" summary_text += f"\nВсего ошибок: {len(recent_errors)}"
await message.answer(summary_text, parse_mode='HTML') await message.answer(summary_text, parse_mode="HTML")
# Отправляем детали отдельным сообщением # Отправляем детали отдельным сообщением
details_text = f"🔍 <b>Последние ошибки:</b>\n" details_text = f"🔍 <b>Последние ошибки:</b>\n"
for i, error in enumerate(recent_errors[-10:], 1): for i, error in enumerate(recent_errors[-10:], 1):
from datetime import datetime from datetime import datetime
timestamp = datetime.fromtimestamp(error['timestamp']).strftime("%H:%M:%S")
timestamp = datetime.fromtimestamp(error["timestamp"]).strftime(
"%H:%M:%S"
)
details_text += f"{i}. {timestamp} - Chat {error['chat_id']} - {error['error_type']}\n" details_text += f"{i}. {timestamp} - Chat {error['chat_id']} - {error['error_type']}\n"
await message.answer(details_text, parse_mode='HTML') await message.answer(details_text, parse_mode="HTML")
else: else:
await message.answer(errors_text, parse_mode='HTML') await message.answer(errors_text, parse_mode="HTML")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении ошибок rate limiting: {e}") logger.error(f"Ошибка при получении ошибок rate limiting: {e}")
await message.answer("Произошла ошибка при получении информации об ошибках.") await message.answer(
"Произошла ошибка при получении информации об ошибках."
)
@track_time("rate_limit_prometheus_handler", "rate_limit_handlers") @track_time("rate_limit_prometheus_handler", "rate_limit_handlers")
@track_errors("rate_limit_handlers", "rate_limit_prometheus_handler") @track_errors("rate_limit_handlers", "rate_limit_prometheus_handler")
async def rate_limit_prometheus_handler( async def rate_limit_prometheus_handler(
self, self,
message: types.Message, message: types.Message,
state: FSMContext, state: FSMContext,
bot_db: MagicData("bot_db"), bot_db: MagicData("bot_db"),
settings: MagicData("settings") settings: MagicData("settings"),
): ):
"""Показывает Prometheus метрики rate limiting""" """Показывает Prometheus метрики rate limiting"""
try: try:
@@ -223,13 +241,13 @@ class RateLimitHandlers:
if not await bot_db.is_admin(message.from_user.id): if not await bot_db.is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды.") await message.answer("У вас нет прав для выполнения этой команды.")
return return
# Обновляем gauge метрики # Обновляем gauge метрики
update_rate_limit_gauges() update_rate_limit_gauges()
# Получаем сводку метрик # Получаем сводку метрик
metrics_summary = get_rate_limit_metrics_summary() metrics_summary = get_rate_limit_metrics_summary()
# Формируем сообщение с метриками # Формируем сообщение с метриками
metrics_text = ( metrics_text = (
f"📊 <b>Prometheus метрики Rate Limiting</b>\n\n" f"📊 <b>Prometheus метрики Rate Limiting</b>\n\n"
@@ -241,30 +259,40 @@ class RateLimitHandlers:
f"• rate_limit_avg_wait_time: {metrics_summary['average_wait_time']:.3f}s\n" f"• rate_limit_avg_wait_time: {metrics_summary['average_wait_time']:.3f}s\n"
f"• rate_limit_active_chats: {metrics_summary['active_chats']}\n\n" f"• rate_limit_active_chats: {metrics_summary['active_chats']}\n\n"
) )
# Добавляем детальные метрики # Добавляем детальные метрики
metrics_text += f"🔍 <b>Детальные метрики:</b>\n" metrics_text += f"🔍 <b>Детальные метрики:</b>\n"
metrics_text += f"• Успешных запросов: {metrics_summary['successful_requests']}\n" metrics_text += (
metrics_text += f"Неудачных запросов: {metrics_summary['failed_requests']}\n" f"Успешных запросов: {metrics_summary['successful_requests']}\n"
metrics_text += f"• RetryAfter ошибок: {metrics_summary['retry_after_errors']}\n" )
metrics_text += (
f"• Неудачных запросов: {metrics_summary['failed_requests']}\n"
)
metrics_text += (
f"• RetryAfter ошибок: {metrics_summary['retry_after_errors']}\n"
)
metrics_text += f"• Других ошибок: {metrics_summary['other_errors']}\n" metrics_text += f"• Других ошибок: {metrics_summary['other_errors']}\n"
metrics_text += f"• Общее время ожидания: {metrics_summary['total_wait_time']:.2f}s\n\n" metrics_text += (
f"• Общее время ожидания: {metrics_summary['total_wait_time']:.2f}s\n\n"
)
# Добавляем информацию о доступных метриках # Добавляем информацию о доступных метриках
metrics_text += f"📈 <b>Доступные Prometheus метрики:</b>\n" metrics_text += f"📈 <b>Доступные Prometheus метрики:</b>\n"
metrics_text += f"• rate_limit_requests_total - общее количество запросов\n" metrics_text += f"• rate_limit_requests_total - общее количество запросов\n"
metrics_text += f"• rate_limit_errors_total - количество ошибок по типам\n" metrics_text += f"• rate_limit_errors_total - количество ошибок по типам\n"
metrics_text += f"• rate_limit_wait_duration_seconds - время ожидания\n" metrics_text += f"• rate_limit_wait_duration_seconds - время ожидания\n"
metrics_text += f"• rate_limit_request_interval_seconds - интервалы между запросами\n" metrics_text += (
f"• rate_limit_request_interval_seconds - интервалы между запросами\n"
)
metrics_text += f"• rate_limit_active_chats - количество активных чатов\n" metrics_text += f"• rate_limit_active_chats - количество активных чатов\n"
metrics_text += f"• rate_limit_success_rate - процент успеха по чатам\n" metrics_text += f"• rate_limit_success_rate - процент успеха по чатам\n"
metrics_text += f"• rate_limit_requests_per_minute - запросов в минуту\n" metrics_text += f"• rate_limit_requests_per_minute - запросов в минуту\n"
metrics_text += f"• rate_limit_total_requests - общее количество запросов\n" metrics_text += f"• rate_limit_total_requests - общее количество запросов\n"
metrics_text += f"• rate_limit_total_errors - количество ошибок\n" metrics_text += f"• rate_limit_total_errors - количество ошибок\n"
metrics_text += f"• rate_limit_avg_wait_time - среднее время ожидания\n" metrics_text += f"• rate_limit_avg_wait_time - среднее время ожидания\n"
await message.answer(metrics_text, parse_mode='HTML') await message.answer(metrics_text, parse_mode="HTML")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении Prometheus метрик: {e}") logger.error(f"Ошибка при получении Prometheus метрик: {e}")
await message.answer("Произошла ошибка при получении метрик.") await message.answer("Произошла ошибка при получении метрик.")

View File

@@ -1,11 +1,16 @@
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
from helper_bot.handlers.admin.exceptions import (InvalidInputError, from helper_bot.handlers.admin.exceptions import (
UserAlreadyBannedError) InvalidInputError,
from helper_bot.utils.helper_func import (add_days_to_date, UserAlreadyBannedError,
get_banned_users_buttons, )
get_banned_users_list) from helper_bot.utils.helper_func import (
add_days_to_date,
get_banned_users_buttons,
get_banned_users_list,
)
# Local imports - metrics # Local imports - metrics
from helper_bot.utils.metrics import track_errors, track_time from helper_bot.utils.metrics import track_errors, track_time
from logs.custom_logger import logger from logs.custom_logger import logger
@@ -13,6 +18,7 @@ from logs.custom_logger import logger
class User: class User:
"""Модель пользователя""" """Модель пользователя"""
def __init__(self, user_id: int, username: str, full_name: str): def __init__(self, user_id: int, username: str, full_name: str):
self.user_id = user_id self.user_id = user_id
self.username = username self.username = username
@@ -21,7 +27,10 @@ class User:
class BannedUser: class BannedUser:
"""Модель заблокированного пользователя""" """Модель заблокированного пользователя"""
def __init__(self, user_id: int, username: str, reason: str, unban_date: Optional[datetime]):
def __init__(
self, user_id: int, username: str, reason: str, unban_date: Optional[datetime]
):
self.user_id = user_id self.user_id = user_id
self.username = username self.username = username
self.reason = reason self.reason = reason
@@ -30,10 +39,10 @@ class BannedUser:
class AdminService: class AdminService:
"""Сервис для административных операций""" """Сервис для административных операций"""
def __init__(self, bot_db): def __init__(self, bot_db):
self.bot_db = bot_db self.bot_db = bot_db
@track_time("get_last_users", "admin_service") @track_time("get_last_users", "admin_service")
@track_errors("admin_service", "get_last_users") @track_errors("admin_service", "get_last_users")
async def get_last_users(self) -> List[User]: async def get_last_users(self) -> List[User]:
@@ -41,17 +50,13 @@ class AdminService:
try: try:
users_data = await self.bot_db.get_last_users(30) users_data = await self.bot_db.get_last_users(30)
return [ return [
User( User(user_id=user[1], username="Неизвестно", full_name=user[0])
user_id=user[1],
username='Неизвестно',
full_name=user[0]
)
for user in users_data for user in users_data
] ]
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении списка последних пользователей: {e}") logger.error(f"Ошибка при получении списка последних пользователей: {e}")
raise raise
@track_time("get_banned_users", "admin_service") @track_time("get_banned_users", "admin_service")
@track_errors("admin_service", "get_banned_users") @track_errors("admin_service", "get_banned_users")
async def get_banned_users(self) -> List[BannedUser]: async def get_banned_users(self) -> List[BannedUser]:
@@ -65,18 +70,22 @@ class AdminService:
username = await self.bot_db.get_username(user_id) username = await self.bot_db.get_username(user_id)
full_name = await self.bot_db.get_full_name_by_id(user_id) full_name = await self.bot_db.get_full_name_by_id(user_id)
user_name = username or full_name or f"User_{user_id}" user_name = username or full_name or f"User_{user_id}"
banned_users.append(BannedUser( banned_users.append(
user_id=user_id, BannedUser(
username=user_name, user_id=user_id,
reason=reason, username=user_name,
unban_date=unban_date reason=reason,
)) unban_date=unban_date,
)
)
return banned_users return banned_users
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении списка заблокированных пользователей: {e}") logger.error(
f"Ошибка при получении списка заблокированных пользователей: {e}"
)
raise raise
@track_time("get_user_by_username", "admin_service") @track_time("get_user_by_username", "admin_service")
@track_errors("admin_service", "get_user_by_username") @track_errors("admin_service", "get_user_by_username")
async def get_user_by_username(self, username: str) -> Optional[User]: async def get_user_by_username(self, username: str) -> Optional[User]:
@@ -85,17 +94,15 @@ class AdminService:
user_id = await self.bot_db.get_user_id_by_username(username) user_id = await self.bot_db.get_user_id_by_username(username)
if not user_id: if not user_id:
return None return None
full_name = await self.bot_db.get_full_name_by_id(user_id) full_name = await self.bot_db.get_full_name_by_id(user_id)
return User( return User(
user_id=user_id, user_id=user_id, username=username, full_name=full_name or "Неизвестно"
username=username,
full_name=full_name or 'Неизвестно'
) )
except Exception as e: except Exception as e:
logger.error(f"Ошибка при поиске пользователя по username {username}: {e}") logger.error(f"Ошибка при поиске пользователя по username {username}: {e}")
raise raise
@track_time("get_user_by_id", "admin_service") @track_time("get_user_by_id", "admin_service")
@track_errors("admin_service", "get_user_by_id") @track_errors("admin_service", "get_user_by_id")
async def get_user_by_id(self, user_id: int) -> Optional[User]: async def get_user_by_id(self, user_id: int) -> Optional[User]:
@@ -104,39 +111,50 @@ class AdminService:
user_info = await self.bot_db.get_user_by_id(user_id) user_info = await self.bot_db.get_user_by_id(user_id)
if not user_info: if not user_info:
return None return None
return User( return User(
user_id=user_id, user_id=user_id,
username=user_info.username or 'Неизвестно', username=user_info.username or "Неизвестно",
full_name=user_info.full_name or 'Неизвестно' full_name=user_info.full_name or "Неизвестно",
) )
except Exception as e: except Exception as e:
logger.error(f"Ошибка при поиске пользователя по ID {user_id}: {e}") logger.error(f"Ошибка при поиске пользователя по ID {user_id}: {e}")
raise raise
@track_time("ban_user", "admin_service") @track_time("ban_user", "admin_service")
@track_errors("admin_service", "ban_user") @track_errors("admin_service", "ban_user")
async def ban_user(self, user_id: int, username: str, reason: str, ban_days: Optional[int], ban_author_id: int) -> None: async def ban_user(
self,
user_id: int,
username: str,
reason: str,
ban_days: Optional[int],
ban_author_id: int,
) -> None:
"""Заблокировать пользователя""" """Заблокировать пользователя"""
try: try:
# Проверяем, не заблокирован ли уже пользователь # Проверяем, не заблокирован ли уже пользователь
if await self.bot_db.check_user_in_blacklist(user_id): if await self.bot_db.check_user_in_blacklist(user_id):
raise UserAlreadyBannedError(f"Пользователь {user_id} уже заблокирован") raise UserAlreadyBannedError(f"Пользователь {user_id} уже заблокирован")
# Рассчитываем дату разблокировки # Рассчитываем дату разблокировки
date_to_unban = None date_to_unban = None
if ban_days is not None: if ban_days is not None:
date_to_unban = add_days_to_date(ban_days) date_to_unban = add_days_to_date(ban_days)
# Сохраняем в БД (username больше не передается, так как не используется в новой схеме) # Сохраняем в БД (username больше не передается, так как не используется в новой схеме)
await self.bot_db.set_user_blacklist(user_id, None, reason, date_to_unban, ban_author=ban_author_id) await self.bot_db.set_user_blacklist(
user_id, None, reason, date_to_unban, ban_author=ban_author_id
logger.info(f"Пользователь {user_id} ({username}) заблокирован. Причина: {reason}, срок: {ban_days} дней") )
logger.info(
f"Пользователь {user_id} ({username}) заблокирован. Причина: {reason}, срок: {ban_days} дней"
)
except Exception as e: except Exception as e:
logger.error(f"Ошибка при блокировке пользователя {user_id}: {e}") logger.error(f"Ошибка при блокировке пользователя {user_id}: {e}")
raise raise
@track_time("unban_user", "admin_service") @track_time("unban_user", "admin_service")
@track_errors("admin_service", "unban_user") @track_errors("admin_service", "unban_user")
async def unban_user(self, user_id: int) -> None: async def unban_user(self, user_id: int) -> None:
@@ -147,7 +165,7 @@ class AdminService:
except Exception as e: except Exception as e:
logger.error(f"Ошибка при разблокировке пользователя {user_id}: {e}") logger.error(f"Ошибка при разблокировке пользователя {user_id}: {e}")
raise raise
@track_time("validate_user_input", "admin_service") @track_time("validate_user_input", "admin_service")
@track_errors("admin_service", "validate_user_input") @track_errors("admin_service", "validate_user_input")
async def validate_user_input(self, input_text: str) -> int: async def validate_user_input(self, input_text: str) -> int:
@@ -155,11 +173,13 @@ class AdminService:
try: try:
user_id = int(input_text.strip()) user_id = int(input_text.strip())
if user_id <= 0: if user_id <= 0:
raise InvalidInputError("ID пользователя должен быть положительным числом") raise InvalidInputError(
"ID пользователя должен быть положительным числом"
)
return user_id return user_id
except ValueError: except ValueError:
raise InvalidInputError("ID пользователя должен быть числом") raise InvalidInputError("ID пользователя должен быть числом")
@track_time("get_banned_users_for_display", "admin_service") @track_time("get_banned_users_for_display", "admin_service")
@track_errors("admin_service", "get_banned_users_for_display") @track_errors("admin_service", "get_banned_users_for_display")
async def get_banned_users_for_display(self, page: int = 0) -> tuple[str, list]: async def get_banned_users_for_display(self, page: int = 0) -> tuple[str, list]:
@@ -170,5 +190,7 @@ class AdminService:
buttons_list = await get_banned_users_buttons(self.bot_db) buttons_list = await get_banned_users_buttons(self.bot_db)
return message_text, buttons_list return message_text, buttons_list
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении данных заблокированных пользователей: {e}") logger.error(
f"Ошибка при получении данных заблокированных пользователей: {e}"
)
raise raise

View File

@@ -3,6 +3,7 @@ from typing import Optional
from aiogram import types from aiogram import types
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from helper_bot.handlers.admin.exceptions import AdminError from helper_bot.handlers.admin.exceptions import AdminError
from helper_bot.keyboards.keyboards import get_reply_keyboard_admin from helper_bot.keyboards.keyboards import get_reply_keyboard_admin
from logs.custom_logger import logger from logs.custom_logger import logger
@@ -13,33 +14,41 @@ def escape_html(text: str) -> str:
return html.escape(str(text)) if text else "" return html.escape(str(text)) if text else ""
async def return_to_admin_menu(message: types.Message, state: FSMContext, async def return_to_admin_menu(
additional_message: Optional[str] = None) -> None: message: types.Message, state: FSMContext, additional_message: Optional[str] = None
) -> None:
"""Универсальная функция для возврата в админ-меню""" """Универсальная функция для возврата в админ-меню"""
logger.info(f"return_to_admin_menu: Возврат в админ-меню для пользователя {message.from_user.id}") logger.info(
f"return_to_admin_menu: Возврат в админ-меню для пользователя {message.from_user.id}"
)
await state.set_data({}) await state.set_data({})
await state.set_state("ADMIN") await state.set_state("ADMIN")
markup = get_reply_keyboard_admin() markup = get_reply_keyboard_admin()
if additional_message: if additional_message:
logger.info(f"return_to_admin_menu: Отправка дополнительного сообщения: {additional_message}") logger.info(
f"return_to_admin_menu: Отправка дополнительного сообщения: {additional_message}"
)
await message.answer(additional_message) await message.answer(additional_message)
await message.answer('Вернулись в меню', reply_markup=markup) await message.answer("Вернулись в меню", reply_markup=markup)
logger.info(f"return_to_admin_menu: Пользователь {message.from_user.id} успешно возвращен в админ-меню") logger.info(
f"return_to_admin_menu: Пользователь {message.from_user.id} успешно возвращен в админ-меню"
)
async def handle_admin_error(message: types.Message, error: Exception, async def handle_admin_error(
state: FSMContext, error_context: str = "") -> None: message: types.Message, error: Exception, state: FSMContext, error_context: str = ""
) -> None:
"""Централизованная обработка ошибок административных операций""" """Централизованная обработка ошибок административных операций"""
logger.error(f"Ошибка в {error_context}: {error}") logger.error(f"Ошибка в {error_context}: {error}")
if isinstance(error, AdminError): if isinstance(error, AdminError):
await message.answer(f"Ошибка: {str(error)}") await message.answer(f"Ошибка: {str(error)}")
else: else:
await message.answer("Произошла внутренняя ошибка. Попробуйте позже.") await message.answer("Произошла внутренняя ошибка. Попробуйте позже.")
await return_to_admin_menu(message, state) await return_to_admin_menu(message, state)
@@ -47,19 +56,23 @@ def format_user_info(user_id: int, username: str, full_name: str) -> str:
"""Форматирование информации о пользователе для отображения""" """Форматирование информации о пользователе для отображения"""
safe_username = escape_html(username) safe_username = escape_html(username)
safe_full_name = escape_html(full_name) safe_full_name = escape_html(full_name)
return (f"<b>Выбран пользователь:</b>\n" return (
f"<b>ID:</b> {user_id}\n" f"<b>Выбран пользователь:</b>\n"
f"<b>Username:</b> {safe_username}\n" f"<b>ID:</b> {user_id}\n"
f"<b>Имя:</b> {safe_full_name}") f"<b>Username:</b> {safe_username}\n"
f"<b>Имя:</b> {safe_full_name}"
)
def format_ban_confirmation(user_id: int, reason: str, ban_days: Optional[int]) -> str: def format_ban_confirmation(user_id: int, reason: str, ban_days: Optional[int]) -> str:
"""Форматирование подтверждения бана""" """Форматирование подтверждения бана"""
safe_reason = escape_html(reason) safe_reason = escape_html(reason)
ban_text = "Навсегда" if ban_days is None else f"{ban_days} дней" ban_text = "Навсегда" if ban_days is None else f"{ban_days} дней"
return (f"<b>Необходимо подтверждение:</b>\n" return (
f"<b>Пользователь:</b> {user_id}\n" f"<b>Необходимо подтверждение:</b>\n"
f"<b>Причина бана:</b> {safe_reason}\n" f"<b>Пользователь:</b> {user_id}\n"
f"<b>Срок бана:</b> {ban_text}") f"<b>Причина бана:</b> {safe_reason}\n"
f"<b>Срок бана:</b> {ban_text}"
)

View File

@@ -1,23 +1,34 @@
from .callback_handlers import callback_router from .callback_handlers import callback_router
from .constants import (CALLBACK_BAN, CALLBACK_DECLINE, CALLBACK_PAGE, from .constants import (
CALLBACK_PUBLISH, CALLBACK_RETURN, CALLBACK_UNLOCK) CALLBACK_BAN,
from .exceptions import (BanError, PostNotFoundError, PublishError, CALLBACK_DECLINE,
UserBlockedBotError, UserNotFoundError) CALLBACK_PAGE,
CALLBACK_PUBLISH,
CALLBACK_RETURN,
CALLBACK_UNLOCK,
)
from .exceptions import (
BanError,
PostNotFoundError,
PublishError,
UserBlockedBotError,
UserNotFoundError,
)
from .services import BanService, PostPublishService from .services import BanService, PostPublishService
__all__ = [ __all__ = [
'callback_router', "callback_router",
'PostPublishService', "PostPublishService",
'BanService', "BanService",
'UserBlockedBotError', "UserBlockedBotError",
'PostNotFoundError', "PostNotFoundError",
'UserNotFoundError', "UserNotFoundError",
'PublishError', "PublishError",
'BanError', "BanError",
'CALLBACK_PUBLISH', "CALLBACK_PUBLISH",
'CALLBACK_DECLINE', "CALLBACK_DECLINE",
'CALLBACK_BAN', "CALLBACK_BAN",
'CALLBACK_UNLOCK', "CALLBACK_UNLOCK",
'CALLBACK_RETURN', "CALLBACK_RETURN",
'CALLBACK_PAGE' "CALLBACK_PAGE",
] ]

View File

@@ -7,28 +7,49 @@ from aiogram import F, Router
from aiogram.filters import MagicData from aiogram.filters import MagicData
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery
from helper_bot.handlers.admin.utils import format_user_info from helper_bot.handlers.admin.utils import format_user_info
from helper_bot.handlers.voice.constants import CALLBACK_DELETE, CALLBACK_SAVE from helper_bot.handlers.voice.constants import CALLBACK_DELETE, CALLBACK_SAVE
from helper_bot.handlers.voice.services import AudioFileService from helper_bot.handlers.voice.services import AudioFileService
from helper_bot.keyboards.keyboards import (create_keyboard_for_ban_reason, from helper_bot.keyboards.keyboards import (
create_keyboard_with_pagination, create_keyboard_for_ban_reason,
get_reply_keyboard_admin) create_keyboard_with_pagination,
get_reply_keyboard_admin,
)
from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.base_dependency_factory import get_global_instance
from helper_bot.utils.helper_func import (get_banned_users_buttons, from helper_bot.utils.helper_func import get_banned_users_buttons, get_banned_users_list
get_banned_users_list)
# Local imports - metrics # Local imports - metrics
from helper_bot.utils.metrics import (db_query_time, track_errors, from helper_bot.utils.metrics import (
track_file_operations, track_time) db_query_time,
track_errors,
track_file_operations,
track_time,
)
from logs.custom_logger import logger from logs.custom_logger import logger
from .constants import (CALLBACK_BAN, CALLBACK_DECLINE, CALLBACK_PAGE, from .constants import (
CALLBACK_PUBLISH, CALLBACK_RETURN, CALLBACK_UNLOCK, CALLBACK_BAN,
ERROR_BOT_BLOCKED, MESSAGE_DECLINED, MESSAGE_ERROR, CALLBACK_DECLINE,
MESSAGE_PUBLISHED, MESSAGE_USER_BANNED, CALLBACK_PAGE,
MESSAGE_USER_UNLOCKED) CALLBACK_PUBLISH,
CALLBACK_RETURN,
CALLBACK_UNLOCK,
ERROR_BOT_BLOCKED,
MESSAGE_DECLINED,
MESSAGE_ERROR,
MESSAGE_PUBLISHED,
MESSAGE_USER_BANNED,
MESSAGE_USER_UNLOCKED,
)
from .dependency_factory import get_ban_service, get_post_publish_service from .dependency_factory import get_ban_service, get_post_publish_service
from .exceptions import (BanError, PostNotFoundError, PublishError, from .exceptions import (
UserBlockedBotError, UserNotFoundError) BanError,
PostNotFoundError,
PublishError,
UserBlockedBotError,
UserNotFoundError,
)
callback_router = Router() callback_router = Router()
@@ -36,65 +57,61 @@ callback_router = Router()
@callback_router.callback_query(F.data == CALLBACK_PUBLISH) @callback_router.callback_query(F.data == CALLBACK_PUBLISH)
@track_time("post_for_group", "callback_handlers") @track_time("post_for_group", "callback_handlers")
@track_errors("callback_handlers", "post_for_group") @track_errors("callback_handlers", "post_for_group")
async def post_for_group( async def post_for_group(call: CallbackQuery, settings: MagicData("settings")):
call: CallbackQuery,
settings: MagicData("settings")
):
publish_service = get_post_publish_service() publish_service = get_post_publish_service()
# TODO: переделать на MagicData # TODO: переделать на MagicData
logger.info( logger.info(
f'Получен callback-запрос с действием: {call.data} от пользователя {call.from_user.full_name} (ID сообщения: {call.message.message_id})') f"Получен callback-запрос с действием: {call.data} от пользователя {call.from_user.full_name} (ID сообщения: {call.message.message_id})"
)
try: try:
await publish_service.publish_post(call) await publish_service.publish_post(call)
await call.answer(text=MESSAGE_PUBLISHED, cache_time=3) await call.answer(text=MESSAGE_PUBLISHED, cache_time=3)
except UserBlockedBotError: except UserBlockedBotError:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except (PostNotFoundError, PublishError) as e: except (PostNotFoundError, PublishError) as e:
logger.error(f'Ошибка при публикации поста: {str(e)}') logger.error(f"Ошибка при публикации поста: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except Exception as e: except Exception as e:
if str(e) == ERROR_BOT_BLOCKED: if str(e) == ERROR_BOT_BLOCKED:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
else: else:
important_logs = settings['Telegram']['important_logs'] important_logs = settings["Telegram"]["important_logs"]
await call.bot.send_message( await call.bot.send_message(
chat_id=important_logs, chat_id=important_logs,
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}" text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
) )
logger.error(f'Неожиданная ошибка при публикации поста: {str(e)}') logger.error(f"Неожиданная ошибка при публикации поста: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
@callback_router.callback_query(F.data == CALLBACK_DECLINE) @callback_router.callback_query(F.data == CALLBACK_DECLINE)
@track_time("decline_post_for_group", "callback_handlers") @track_time("decline_post_for_group", "callback_handlers")
@track_errors("callback_handlers", "decline_post_for_group") @track_errors("callback_handlers", "decline_post_for_group")
async def decline_post_for_group( async def decline_post_for_group(call: CallbackQuery, settings: MagicData("settings")):
call: CallbackQuery,
settings: MagicData("settings")
):
publish_service = get_post_publish_service() publish_service = get_post_publish_service()
# TODO: переделать на MagicData # TODO: переделать на MagicData
logger.info( logger.info(
f'Получен callback-запрос с данными: {call.data} от пользователя {call.from_user.full_name} (ID: {call.from_user.id})') f"Получен callback-запрос с данными: {call.data} от пользователя {call.from_user.full_name} (ID: {call.from_user.id})"
)
try: try:
await publish_service.decline_post(call) await publish_service.decline_post(call)
await call.answer(text=MESSAGE_DECLINED, cache_time=3) await call.answer(text=MESSAGE_DECLINED, cache_time=3)
except UserBlockedBotError: except UserBlockedBotError:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except (PostNotFoundError, PublishError) as e: except (PostNotFoundError, PublishError) as e:
logger.error(f'Ошибка при отклонении поста: {str(e)}') logger.error(f"Ошибка при отклонении поста: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except Exception as e: except Exception as e:
if str(e) == ERROR_BOT_BLOCKED: if str(e) == ERROR_BOT_BLOCKED:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
else: else:
important_logs = settings['Telegram']['important_logs'] important_logs = settings["Telegram"]["important_logs"]
await call.bot.send_message( await call.bot.send_message(
chat_id=important_logs, chat_id=important_logs,
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}" text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
) )
logger.error(f'Неожиданная ошибка при отклонении поста: {str(e)}') logger.error(f"Неожиданная ошибка при отклонении поста: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
@@ -110,65 +127,75 @@ async def ban_user_from_post(call: CallbackQuery, **kwargs):
except UserBlockedBotError: except UserBlockedBotError:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except (UserNotFoundError, BanError) as e: except (UserNotFoundError, BanError) as e:
logger.error(f'Ошибка при блокировке пользователя: {str(e)}') logger.error(f"Ошибка при блокировке пользователя: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except Exception as e: except Exception as e:
if str(e) == ERROR_BOT_BLOCKED: if str(e) == ERROR_BOT_BLOCKED:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
else: else:
logger.error(f'Неожиданная ошибка при блокировке пользователя: {str(e)}') logger.error(f"Неожиданная ошибка при блокировке пользователя: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
@callback_router.callback_query(F.data.contains(CALLBACK_BAN)) @callback_router.callback_query(F.data.contains(CALLBACK_BAN))
@track_time("process_ban_user", "callback_handlers") @track_time("process_ban_user", "callback_handlers")
@track_errors("callback_handlers", "process_ban_user") @track_errors("callback_handlers", "process_ban_user")
async def process_ban_user(call: CallbackQuery, state: FSMContext, bot_db: MagicData("bot_db"), **kwargs): async def process_ban_user(
call: CallbackQuery, state: FSMContext, bot_db: MagicData("bot_db"), **kwargs
):
ban_service = get_ban_service() ban_service = get_ban_service()
# TODO: переделать на MagicData # TODO: переделать на MagicData
user_id = call.data[4:] user_id = call.data[4:]
logger.info(f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}") logger.info(
f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}"
)
# Проверяем, что user_id является валидным числом # Проверяем, что user_id является валидным числом
try: try:
user_id_int = int(user_id) user_id_int = int(user_id)
except ValueError: except ValueError:
logger.error(f"Некорректный user_id в callback: {user_id}") logger.error(f"Некорректный user_id в callback: {user_id}")
await call.answer(text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3) await call.answer(
text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3
)
return return
try: try:
# Получаем username пользователя # Получаем username пользователя
username = await ban_service.ban_user(str(user_id_int), "") username = await ban_service.ban_user(str(user_id_int), "")
if not username: if not username:
raise UserNotFoundError(f"Пользователь с ID {user_id_int} не найден в базе") raise UserNotFoundError(f"Пользователь с ID {user_id_int} не найден в базе")
# Получаем full_name пользователя из базы данных # Получаем full_name пользователя из базы данных
full_name = await bot_db.get_full_name_by_id(user_id_int) full_name = await bot_db.get_full_name_by_id(user_id_int)
if not full_name: if not full_name:
full_name = 'Неизвестно' full_name = "Неизвестно"
# Сохраняем данные в формате, совместимом с admin_handlers # Сохраняем данные в формате, совместимом с admin_handlers
await state.update_data( await state.update_data(
target_user_id=user_id_int, target_user_id=user_id_int,
target_username=username, target_username=username,
target_full_name=full_name target_full_name=full_name,
) )
# Используем единый формат отображения информации о пользователе # Используем единый формат отображения информации о пользователе
user_info = format_user_info(user_id_int, username, full_name) user_info = format_user_info(user_id_int, username, full_name)
markup = create_keyboard_for_ban_reason() markup = create_keyboard_for_ban_reason()
await call.message.answer( await call.message.answer(
text=f"{user_info}\n\nВыбери причину бана из списка или напиши ее в чат", text=f"{user_info}\n\nВыбери причину бана из списка или напиши ее в чат",
reply_markup=markup reply_markup=markup,
)
await state.set_state("AWAIT_BAN_DETAILS")
logger.info(
f"process_ban_user: Состояние изменено на AWAIT_BAN_DETAILS для пользователя {user_id_int}"
) )
await state.set_state('AWAIT_BAN_DETAILS')
logger.info(f"process_ban_user: Состояние изменено на AWAIT_BAN_DETAILS для пользователя {user_id_int}")
except UserNotFoundError: except UserNotFoundError:
markup = get_reply_keyboard_admin() markup = get_reply_keyboard_admin()
await call.message.answer(text='Пользователь с таким ID не найден в базе', reply_markup=markup) await call.message.answer(
await state.set_state('ADMIN') text="Пользователь с таким ID не найден в базе", reply_markup=markup
)
await state.set_state("ADMIN")
@callback_router.callback_query(F.data.contains(CALLBACK_UNLOCK)) @callback_router.callback_query(F.data.contains(CALLBACK_UNLOCK))
@@ -178,22 +205,26 @@ async def process_unlock_user(call: CallbackQuery, **kwargs):
ban_service = get_ban_service() ban_service = get_ban_service()
# TODO: переделать на MagicData # TODO: переделать на MagicData
user_id = call.data[7:] user_id = call.data[7:]
# Проверяем, что user_id является валидным числом # Проверяем, что user_id является валидным числом
try: try:
user_id_int = int(user_id) user_id_int = int(user_id)
except ValueError: except ValueError:
logger.error(f"Некорректный user_id в callback: {user_id}") logger.error(f"Некорректный user_id в callback: {user_id}")
await call.answer(text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3) await call.answer(
text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3
)
return return
try: try:
username = await ban_service.unlock_user(str(user_id_int)) username = await ban_service.unlock_user(str(user_id_int))
await call.answer(f'{MESSAGE_USER_UNLOCKED} {username}', show_alert=True) await call.answer(f"{MESSAGE_USER_UNLOCKED} {username}", show_alert=True)
except UserNotFoundError: except UserNotFoundError:
await call.answer(text='Пользователь не найден в базе', show_alert=True, cache_time=3) await call.answer(
text="Пользователь не найден в базе", show_alert=True, cache_time=3
)
except Exception as e: except Exception as e:
logger.error(f'Ошибка при разблокировке пользователя: {str(e)}') logger.error(f"Ошибка при разблокировке пользователя: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
@@ -204,48 +235,52 @@ async def return_to_main_menu(call: CallbackQuery, **kwargs):
await call.message.delete() await call.message.delete()
logger.info(f"Запуск админ панели для пользователя: {call.message.from_user.id}") logger.info(f"Запуск админ панели для пользователя: {call.message.from_user.id}")
markup = get_reply_keyboard_admin() markup = get_reply_keyboard_admin()
await call.message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup) await call.message.answer(
"Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup
)
@callback_router.callback_query(F.data.contains(CALLBACK_PAGE)) @callback_router.callback_query(F.data.contains(CALLBACK_PAGE))
@track_time("change_page", "callback_handlers") @track_time("change_page", "callback_handlers")
@track_errors("callback_handlers", "change_page") @track_errors("callback_handlers", "change_page")
async def change_page( async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs):
call: CallbackQuery,
bot_db: MagicData("bot_db"),
**kwargs
):
try: try:
page_number = int(call.data[5:]) page_number = int(call.data[5:])
except ValueError: except ValueError:
logger.error(f"Некорректный номер страницы в callback: {call.data}") logger.error(f"Некорректный номер страницы в callback: {call.data}")
await call.answer(text="Ошибка: некорректный номер страницы", show_alert=True, cache_time=3) await call.answer(
text="Ошибка: некорректный номер страницы", show_alert=True, cache_time=3
)
return return
logger.info(f"Переход на страницу {page_number}") logger.info(f"Переход на страницу {page_number}")
if call.message.text == 'Список пользователей которые последними обращались к боту': if call.message.text == "Список пользователей которые последними обращались к боту":
list_users = await bot_db.get_last_users(30) list_users = await bot_db.get_last_users(30)
keyboard = create_keyboard_with_pagination(page_number, len(list_users), list_users, 'ban') keyboard = create_keyboard_with_pagination(
page_number, len(list_users), list_users, "ban"
)
await call.bot.edit_message_reply_markup( await call.bot.edit_message_reply_markup(
chat_id=call.message.chat.id, chat_id=call.message.chat.id,
message_id=call.message.message_id, message_id=call.message.message_id,
reply_markup=keyboard reply_markup=keyboard,
) )
else: else:
message_user = await get_banned_users_list(int(page_number) * 7 - 7, bot_db) message_user = await get_banned_users_list(int(page_number) * 7 - 7, bot_db)
await call.bot.edit_message_text( await call.bot.edit_message_text(
chat_id=call.message.chat.id, chat_id=call.message.chat.id,
message_id=call.message.message_id, message_id=call.message.message_id,
text=message_user text=message_user,
) )
buttons = await get_banned_users_buttons(bot_db) buttons = await get_banned_users_buttons(bot_db)
keyboard = create_keyboard_with_pagination(page_number, len(buttons), buttons, 'unlock') keyboard = create_keyboard_with_pagination(
page_number, len(buttons), buttons, "unlock"
)
await call.bot.edit_message_reply_markup( await call.bot.edit_message_reply_markup(
chat_id=call.message.chat.id, chat_id=call.message.chat.id,
message_id=call.message.message_id, message_id=call.message.message_id,
reply_markup=keyboard reply_markup=keyboard,
) )
@@ -255,73 +290,81 @@ async def change_page(
@track_file_operations("voice") @track_file_operations("voice")
@db_query_time("save_voice_message", "audio_moderate", "mixed") @db_query_time("save_voice_message", "audio_moderate", "mixed")
async def save_voice_message( async def save_voice_message(
call: CallbackQuery, call: CallbackQuery,
bot_db: MagicData("bot_db"), bot_db: MagicData("bot_db"),
settings: MagicData("settings"), settings: MagicData("settings"),
**kwargs **kwargs,
): ):
try: try:
logger.info(f"Начинаем сохранение голосового сообщения. Message ID: {call.message.message_id}") logger.info(
f"Начинаем сохранение голосового сообщения. Message ID: {call.message.message_id}"
)
# Создаем сервис для работы с аудио файлами # Создаем сервис для работы с аудио файлами
audio_service = AudioFileService(bot_db) audio_service = AudioFileService(bot_db)
# Получаем ID пользователя из базы # Получаем ID пользователя из базы
user_id = await bot_db.get_user_id_by_message_id_for_voice_bot(call.message.message_id) user_id = await bot_db.get_user_id_by_message_id_for_voice_bot(
call.message.message_id
)
logger.info(f"Получен user_id: {user_id}") logger.info(f"Получен user_id: {user_id}")
# Генерируем имя файла # Генерируем имя файла
file_name = await audio_service.generate_file_name(user_id) file_name = await audio_service.generate_file_name(user_id)
logger.info(f"Сгенерировано имя файла: {file_name}") logger.info(f"Сгенерировано имя файла: {file_name}")
# Собираем инфо о сообщении # Собираем инфо о сообщении
time_UTC = int(time.time()) time_UTC = int(time.time())
date_added = datetime.fromtimestamp(time_UTC) date_added = datetime.fromtimestamp(time_UTC)
# Получаем file_id из voice сообщения # Получаем file_id из voice сообщения
file_id = call.message.voice.file_id if call.message.voice else "" file_id = call.message.voice.file_id if call.message.voice else ""
logger.info(f"Получен file_id: {file_id}") logger.info(f"Получен file_id: {file_id}")
# ВАЖНО: Сначала скачиваем и сохраняем файл на диск # ВАЖНО: Сначала скачиваем и сохраняем файл на диск
logger.info("Начинаем скачивание и сохранение файла на диск...") logger.info("Начинаем скачивание и сохранение файла на диск...")
await audio_service.download_and_save_audio(call.bot, call.message, file_name) await audio_service.download_and_save_audio(call.bot, call.message, file_name)
logger.info("Файл успешно скачан и сохранен на диск") logger.info("Файл успешно скачан и сохранен на диск")
# Только после успешного сохранения файла - сохраняем в базу данных # Только после успешного сохранения файла - сохраняем в базу данных
logger.info("Начинаем сохранение информации в базу данных...") logger.info("Начинаем сохранение информации в базу данных...")
await audio_service.save_audio_file(file_name, user_id, date_added, file_id) await audio_service.save_audio_file(file_name, user_id, date_added, file_id)
logger.info("Информация успешно сохранена в базу данных") logger.info("Информация успешно сохранена в базу данных")
# Удаляем сообщение из предложки # Удаляем сообщение из предложки
logger.info("Удаляем сообщение из предложки...") logger.info("Удаляем сообщение из предложки...")
await call.bot.delete_message( await call.bot.delete_message(
chat_id=settings['Telegram']['group_for_posts'], chat_id=settings["Telegram"]["group_for_posts"],
message_id=call.message.message_id message_id=call.message.message_id,
) )
logger.info("Сообщение удалено из предложки") logger.info("Сообщение удалено из предложки")
# Удаляем запись из таблицы audio_moderate # Удаляем запись из таблицы audio_moderate
logger.info("Удаляем запись из таблицы audio_moderate...") logger.info("Удаляем запись из таблицы audio_moderate...")
await bot_db.delete_audio_moderate_record(call.message.message_id) await bot_db.delete_audio_moderate_record(call.message.message_id)
logger.info("Запись удалена из таблицы audio_moderate") logger.info("Запись удалена из таблицы audio_moderate")
await call.answer(text='Сохранено!', cache_time=3) await call.answer(text="Сохранено!", cache_time=3)
logger.info(f"Голосовое сообщение успешно сохранено: {file_name}") logger.info(f"Голосовое сообщение успешно сохранено: {file_name}")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при сохранении голосового сообщения: {e}") logger.error(f"Ошибка при сохранении голосового сообщения: {e}")
logger.error(f"Traceback: {traceback.format_exc()}") logger.error(f"Traceback: {traceback.format_exc()}")
# Дополнительная информация для диагностики # Дополнительная информация для диагностики
try: try:
if 'call' in locals() and call.message: if "call" in locals() and call.message:
logger.error(f"Message ID: {call.message.message_id}") logger.error(f"Message ID: {call.message.message_id}")
logger.error(f"User ID: {user_id if 'user_id' in locals() else 'не определен'}") logger.error(
logger.error(f"File name: {file_name if 'file_name' in locals() else 'не определен'}") f"User ID: {user_id if 'user_id' in locals() else 'не определен'}"
)
logger.error(
f"File name: {file_name if 'file_name' in locals() else 'не определен'}"
)
except: except:
pass pass
await call.answer(text='Ошибка при сохранении!', cache_time=3) await call.answer(text="Ошибка при сохранении!", cache_time=3)
@callback_router.callback_query(F.data == CALLBACK_DELETE) @callback_router.callback_query(F.data == CALLBACK_DELETE)
@@ -329,23 +372,23 @@ async def save_voice_message(
@track_errors("callback_handlers", "delete_voice_message") @track_errors("callback_handlers", "delete_voice_message")
@db_query_time("delete_voice_message", "audio_moderate", "delete") @db_query_time("delete_voice_message", "audio_moderate", "delete")
async def delete_voice_message( async def delete_voice_message(
call: CallbackQuery, call: CallbackQuery,
bot_db: MagicData("bot_db"), bot_db: MagicData("bot_db"),
settings: MagicData("settings"), settings: MagicData("settings"),
**kwargs **kwargs,
): ):
try: try:
# Удаляем сообщение из предложки # Удаляем сообщение из предложки
await call.bot.delete_message( await call.bot.delete_message(
chat_id=settings['Telegram']['group_for_posts'], chat_id=settings["Telegram"]["group_for_posts"],
message_id=call.message.message_id message_id=call.message.message_id,
) )
# Удаляем запись из таблицы audio_moderate # Удаляем запись из таблицы audio_moderate
await bot_db.delete_audio_moderate_record(call.message.message_id) await bot_db.delete_audio_moderate_record(call.message.message_id)
await call.answer(text='Удалено!', cache_time=3) await call.answer(text="Удалено!", cache_time=3)
except Exception as e: except Exception as e:
logger.error(f"Ошибка при удалении голосового сообщения: {e}") logger.error(f"Ошибка при удалении голосового сообщения: {e}")
await call.answer(text='Ошибка при удалении!', cache_time=3) await call.answer(text="Ошибка при удалении!", cache_time=3)

View File

@@ -33,9 +33,9 @@ ERROR_BOT_BLOCKED = "Forbidden: bot was blocked by the user"
# Callback to command mapping for metrics # Callback to command mapping for metrics
CALLBACK_COMMAND_MAPPING: Final[Dict[str, str]] = { CALLBACK_COMMAND_MAPPING: Final[Dict[str, str]] = {
"publish": "publish", "publish": "publish",
"decline": "decline", "decline": "decline",
"ban": "ban", "ban": "ban",
"unlock": "unlock", "unlock": "unlock",
"return": "return", "return": "return",
"page": "page" "page": "page",
} }

View File

@@ -3,6 +3,7 @@ from typing import Callable
from aiogram import Bot from aiogram import Bot
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.base_dependency_factory import get_global_instance
from .services import BanService, PostPublishService from .services import BanService, PostPublishService
@@ -22,7 +23,7 @@ def get_post_publish_service() -> PostPublishService:
def get_ban_service() -> BanService: def get_ban_service() -> BanService:
"""Фабрика для BanService""" """Фабрика для BanService"""
bdf = get_global_instance() bdf = get_global_instance()
db = bdf.get_db() db = bdf.get_db()
settings = bdf.settings settings = bdf.settings
return BanService(None, db, settings) return BanService(None, db, settings)

View File

@@ -1,23 +1,28 @@
class UserBlockedBotError(Exception): class UserBlockedBotError(Exception):
"""Исключение, возникающее когда пользователь заблокировал бота""" """Исключение, возникающее когда пользователь заблокировал бота"""
pass pass
class PostNotFoundError(Exception): class PostNotFoundError(Exception):
"""Исключение, возникающее когда пост не найден в базе данных""" """Исключение, возникающее когда пост не найден в базе данных"""
pass pass
class UserNotFoundError(Exception): class UserNotFoundError(Exception):
"""Исключение, возникающее когда пользователь не найден в базе данных""" """Исключение, возникающее когда пользователь не найден в базе данных"""
pass pass
class PublishError(Exception): class PublishError(Exception):
"""Общее исключение для ошибок публикации""" """Общее исключение для ошибок публикации"""
pass pass
class BanError(Exception): class BanError(Exception):
"""Исключение для ошибок бана/разбана пользователей""" """Исключение для ошибок бана/разбана пользователей"""
pass pass

File diff suppressed because it is too large Load Diff

View File

@@ -6,27 +6,24 @@ from .constants import ERROR_MESSAGES, FSM_STATES
from .decorators import error_handler from .decorators import error_handler
from .exceptions import NoReplyToMessageError, UserNotFoundError from .exceptions import NoReplyToMessageError, UserNotFoundError
from .group_handlers import GroupHandlers, create_group_handlers, group_router from .group_handlers import GroupHandlers, create_group_handlers, group_router
# Local imports - services # Local imports - services
from .services import AdminReplyService, DatabaseProtocol from .services import AdminReplyService, DatabaseProtocol
__all__ = [ __all__ = [
# Main components # Main components
'group_router', "group_router",
'create_group_handlers', "create_group_handlers",
'GroupHandlers', "GroupHandlers",
# Services # Services
'AdminReplyService', "AdminReplyService",
'DatabaseProtocol', "DatabaseProtocol",
# Constants # Constants
'FSM_STATES', "FSM_STATES",
'ERROR_MESSAGES', "ERROR_MESSAGES",
# Exceptions # Exceptions
'NoReplyToMessageError', "NoReplyToMessageError",
'UserNotFoundError', "UserNotFoundError",
# Utilities # Utilities
'error_handler' "error_handler",
] ]

View File

@@ -3,12 +3,10 @@
from typing import Dict, Final from typing import Dict, Final
# FSM States # FSM States
FSM_STATES: Final[Dict[str, str]] = { FSM_STATES: Final[Dict[str, str]] = {"CHAT": "CHAT"}
"CHAT": "CHAT"
}
# Error messages # Error messages
ERROR_MESSAGES: Final[Dict[str, str]] = { ERROR_MESSAGES: Final[Dict[str, str]] = {
"NO_REPLY_TO_MESSAGE": "Блять, выдели сообщение!", "NO_REPLY_TO_MESSAGE": "Блять, выдели сообщение!",
"USER_NOT_FOUND": "Не могу найти кому ответить в базе, проебали сообщение." "USER_NOT_FOUND": "Не могу найти кому ответить в базе, проебали сообщение.",
} }

View File

@@ -6,12 +6,14 @@ from typing import Any, Callable
# Third-party imports # Third-party imports
from aiogram import types from aiogram import types
# Local imports # Local imports
from logs.custom_logger import logger from logs.custom_logger import logger
def error_handler(func: Callable[..., Any]) -> Callable[..., Any]: def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator for centralized error handling""" """Decorator for centralized error handling"""
async def wrapper(*args: Any, **kwargs: Any) -> Any: async def wrapper(*args: Any, **kwargs: Any) -> Any:
try: try:
return await func(*args, **kwargs) return await func(*args, **kwargs)
@@ -19,18 +21,23 @@ def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
logger.error(f"Error in {func.__name__}: {str(e)}") logger.error(f"Error in {func.__name__}: {str(e)}")
# Try to send error to logs if possible # Try to send error to logs if possible
try: try:
message = next((arg for arg in args if isinstance(arg, types.Message)), None) message = next(
if message and hasattr(message, 'bot'): (arg for arg in args if isinstance(arg, types.Message)), None
from helper_bot.utils.base_dependency_factory import \ )
get_global_instance if message and hasattr(message, "bot"):
from helper_bot.utils.base_dependency_factory import (
get_global_instance,
)
bdf = get_global_instance() bdf = get_global_instance()
important_logs = bdf.settings['Telegram']['important_logs'] important_logs = bdf.settings["Telegram"]["important_logs"]
await message.bot.send_message( await message.bot.send_message(
chat_id=important_logs, chat_id=important_logs,
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}" text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
) )
except Exception: except Exception:
# If we can't log the error, at least it was logged to logger # If we can't log the error, at least it was logged to logger
pass pass
raise raise
return wrapper return wrapper

View File

@@ -3,9 +3,11 @@
class NoReplyToMessageError(Exception): class NoReplyToMessageError(Exception):
"""Raised when admin tries to reply without selecting a message""" """Raised when admin tries to reply without selecting a message"""
pass pass
class UserNotFoundError(Exception): class UserNotFoundError(Exception):
"""Raised when user is not found in database for the given message_id""" """Raised when user is not found in database for the given message_id"""
pass pass

View File

@@ -3,11 +3,14 @@
# Third-party imports # Third-party imports
from aiogram import Router, types from aiogram import Router, types
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
# Local imports - filters # Local imports - filters
from database.async_db import AsyncBotDB from database.async_db import AsyncBotDB
from helper_bot.filters.main import ChatTypeFilter from helper_bot.filters.main import ChatTypeFilter
# Local imports - metrics # Local imports - metrics
from helper_bot.utils.metrics import metrics, track_errors, track_time from helper_bot.utils.metrics import metrics, track_errors, track_time
# Local imports - utilities # Local imports - utilities
from logs.custom_logger import logger from logs.custom_logger import logger
@@ -20,25 +23,24 @@ from .services import AdminReplyService
class GroupHandlers: class GroupHandlers:
"""Main handler class for group messages""" """Main handler class for group messages"""
def __init__(self, db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMarkup): def __init__(self, db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMarkup):
self.db = db self.db = db
self.keyboard_markup = keyboard_markup self.keyboard_markup = keyboard_markup
self.admin_reply_service = AdminReplyService(db) self.admin_reply_service = AdminReplyService(db)
# Create router # Create router
self.router = Router() self.router = Router()
# Register handlers # Register handlers
self._register_handlers() self._register_handlers()
def _register_handlers(self): def _register_handlers(self):
"""Register all message handlers""" """Register all message handlers"""
self.router.message.register( self.router.message.register(
self.handle_message, self.handle_message, ChatTypeFilter(chat_type=["group", "supergroup"])
ChatTypeFilter(chat_type=["group", "supergroup"])
) )
@error_handler @error_handler
@track_errors("group_handlers", "handle_message") @track_errors("group_handlers", "handle_message")
@track_time("handle_message", "group_handlers") @track_time("handle_message", "group_handlers")
@@ -46,44 +48,46 @@ class GroupHandlers:
"""Handle admin reply to user through group chat""" """Handle admin reply to user through group chat"""
logger.info( logger.info(
f'Получено сообщение в группе {message.chat.title} (ID: {message.chat.id}) ' f"Получено сообщение в группе {message.chat.title} (ID: {message.chat.id}) "
f'от пользователя {message.from_user.full_name} (ID: {message.from_user.id}): "{message.text}"' f'от пользователя {message.from_user.full_name} (ID: {message.from_user.id}): "{message.text}"'
) )
# Check if message is a reply # Check if message is a reply
if not message.reply_to_message: if not message.reply_to_message:
await message.answer(ERROR_MESSAGES["NO_REPLY_TO_MESSAGE"]) await message.answer(ERROR_MESSAGES["NO_REPLY_TO_MESSAGE"])
logger.warning( logger.warning(
f'В группе {message.chat.title} (ID: {message.chat.id}) ' f"В группе {message.chat.title} (ID: {message.chat.id}) "
f'админ не выделил сообщение для ответа.' f"админ не выделил сообщение для ответа."
) )
return return
message_id = message.reply_to_message.message_id message_id = message.reply_to_message.message_id
reply_text = message.text reply_text = message.text
try: try:
# Get user ID for reply # Get user ID for reply
chat_id = await self.admin_reply_service.get_user_id_for_reply(message_id) chat_id = await self.admin_reply_service.get_user_id_for_reply(message_id)
# Send reply to user # Send reply to user
await self.admin_reply_service.send_reply_to_user( await self.admin_reply_service.send_reply_to_user(
chat_id, message, reply_text, self.keyboard_markup chat_id, message, reply_text, self.keyboard_markup
) )
# Set state # Set state
await state.set_state(FSM_STATES["CHAT"]) await state.set_state(FSM_STATES["CHAT"])
except UserNotFoundError: except UserNotFoundError:
await message.answer(ERROR_MESSAGES["USER_NOT_FOUND"]) await message.answer(ERROR_MESSAGES["USER_NOT_FOUND"])
logger.error( logger.error(
f'Ошибка при поиске пользователя в базе для ответа на сообщение: {reply_text} ' f"Ошибка при поиске пользователя в базе для ответа на сообщение: {reply_text} "
f'в группе {message.chat.title} (ID сообщения: {message.message_id})' f"в группе {message.chat.title} (ID сообщения: {message.message_id})"
) )
# Factory function to create handlers with dependencies # Factory function to create handlers with dependencies
def create_group_handlers(db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMarkup) -> GroupHandlers: def create_group_handlers(
db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMarkup
) -> GroupHandlers:
"""Create group handlers instance with dependencies""" """Create group handlers instance with dependencies"""
return GroupHandlers(db, keyboard_markup) return GroupHandlers(db, keyboard_markup)
@@ -91,21 +95,23 @@ def create_group_handlers(db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMa
# Legacy router for backward compatibility # Legacy router for backward compatibility
group_router = Router() group_router = Router()
# Initialize with global dependencies (for backward compatibility) # Initialize with global dependencies (for backward compatibility)
def init_legacy_router(): def init_legacy_router():
"""Initialize legacy router with global dependencies""" """Initialize legacy router with global dependencies"""
global group_router global group_router
from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.base_dependency_factory import get_global_instance
bdf = get_global_instance() bdf = get_global_instance()
#TODO: поменять архитектуру и подключить правильный BotDB # TODO: поменять архитектуру и подключить правильный BotDB
db = bdf.get_db() db = bdf.get_db()
keyboard_markup = get_reply_keyboard_leave_chat() keyboard_markup = get_reply_keyboard_leave_chat()
handlers = create_group_handlers(db, keyboard_markup) handlers = create_group_handlers(db, keyboard_markup)
group_router = handlers.router group_router = handlers.router
# Initialize legacy router # Initialize legacy router
init_legacy_router() init_legacy_router()

View File

@@ -5,8 +5,10 @@ from typing import Optional, Protocol
# Third-party imports # Third-party imports
from aiogram import types from aiogram import types
# Local imports # Local imports
from helper_bot.utils.helper_func import send_text_message from helper_bot.utils.helper_func import send_text_message
# Local imports - metrics # Local imports - metrics
from helper_bot.utils.metrics import db_query_time, track_errors, track_time from helper_bot.utils.metrics import db_query_time, track_errors, track_time
from logs.custom_logger import logger from logs.custom_logger import logger
@@ -16,29 +18,32 @@ from .exceptions import NoReplyToMessageError, UserNotFoundError
class DatabaseProtocol(Protocol): class DatabaseProtocol(Protocol):
"""Protocol for database operations""" """Protocol for database operations"""
async def get_user_by_message_id(self, message_id: int) -> Optional[int]: ... async def get_user_by_message_id(self, message_id: int) -> Optional[int]: ...
async def add_message(self, message_text: str, user_id: int, message_id: int, date: int = None): ... async def add_message(
self, message_text: str, user_id: int, message_id: int, date: int = None
): ...
class AdminReplyService: class AdminReplyService:
"""Service for admin reply operations""" """Service for admin reply operations"""
def __init__(self, db: DatabaseProtocol) -> None: def __init__(self, db: DatabaseProtocol) -> None:
self.db = db self.db = db
@track_time("get_user_id_for_reply", "admin_reply_service") @track_time("get_user_id_for_reply", "admin_reply_service")
@track_errors("admin_reply_service", "get_user_id_for_reply") @track_errors("admin_reply_service", "get_user_id_for_reply")
@db_query_time("get_user_id_for_reply", "users", "select") @db_query_time("get_user_id_for_reply", "users", "select")
async def get_user_id_for_reply(self, message_id: int) -> int: async def get_user_id_for_reply(self, message_id: int) -> int:
""" """
Get user ID for reply by message ID. Get user ID for reply by message ID.
Args: Args:
message_id: ID of the message to reply to message_id: ID of the message to reply to
Returns: Returns:
User ID for the reply User ID for the reply
Raises: Raises:
UserNotFoundError: If user is not found in database UserNotFoundError: If user is not found in database
""" """
@@ -46,19 +51,19 @@ class AdminReplyService:
if user_id is None: if user_id is None:
raise UserNotFoundError(f"User not found for message_id: {message_id}") raise UserNotFoundError(f"User not found for message_id: {message_id}")
return user_id return user_id
@track_time("send_reply_to_user", "admin_reply_service") @track_time("send_reply_to_user", "admin_reply_service")
@track_errors("admin_reply_service", "send_reply_to_user") @track_errors("admin_reply_service", "send_reply_to_user")
async def send_reply_to_user( async def send_reply_to_user(
self, self,
chat_id: int, chat_id: int,
message: types.Message, message: types.Message,
reply_text: str, reply_text: str,
markup: types.ReplyKeyboardMarkup markup: types.ReplyKeyboardMarkup,
) -> None: ) -> None:
""" """
Send reply to user. Send reply to user.
Args: Args:
chat_id: User's chat ID chat_id: User's chat ID
message: Original message from admin message: Original message from admin

View File

@@ -4,28 +4,25 @@
# Local imports - constants and utilities # Local imports - constants and utilities
from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES
from .decorators import error_handler from .decorators import error_handler
from .private_handlers import (PrivateHandlers, create_private_handlers, from .private_handlers import PrivateHandlers, create_private_handlers, private_router
private_router)
# Local imports - services # Local imports - services
from .services import BotSettings, PostService, StickerService, UserService from .services import BotSettings, PostService, StickerService, UserService
__all__ = [ __all__ = [
# Main components # Main components
'private_router', "private_router",
'create_private_handlers', "create_private_handlers",
'PrivateHandlers', "PrivateHandlers",
# Services # Services
'BotSettings', "BotSettings",
'UserService', "UserService",
'PostService', "PostService",
'StickerService', "StickerService",
# Constants # Constants
'FSM_STATES', "FSM_STATES",
'BUTTON_TEXTS', "BUTTON_TEXTS",
'ERROR_MESSAGES', "ERROR_MESSAGES",
# Utilities # Utilities
'error_handler' "error_handler",
] ]

View File

@@ -7,7 +7,7 @@ FSM_STATES: Final[Dict[str, str]] = {
"START": "START", "START": "START",
"SUGGEST": "SUGGEST", "SUGGEST": "SUGGEST",
"PRE_CHAT": "PRE_CHAT", "PRE_CHAT": "PRE_CHAT",
"CHAT": "CHAT" "CHAT": "CHAT",
} }
# Button texts # Button texts
@@ -18,7 +18,7 @@ BUTTON_TEXTS: Final[Dict[str, str]] = {
"RETURN_TO_BOT": "Вернуться в бота", "RETURN_TO_BOT": "Вернуться в бота",
"WANT_STICKERS": "🤪Хочу стикеры", "WANT_STICKERS": "🤪Хочу стикеры",
"CONNECT_ADMIN": "📩Связаться с админами", "CONNECT_ADMIN": "📩Связаться с админами",
"VOICE_BOT": "🎤Голосовой бот" "VOICE_BOT": "🎤Голосовой бот",
} }
# Button to command mapping for metrics # Button to command mapping for metrics
@@ -29,15 +29,15 @@ BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = {
"Вернуться в бота": "return_to_bot", "Вернуться в бота": "return_to_bot",
"🤪Хочу стикеры": "want_stickers", "🤪Хочу стикеры": "want_stickers",
"📩Связаться с админами": "connect_admin", "📩Связаться с админами": "connect_admin",
"🎤Голосовой бот": "voice_bot" "🎤Голосовой бот": "voice_bot",
} }
# Error messages # Error messages
ERROR_MESSAGES: Final[Dict[str, str]] = { ERROR_MESSAGES: Final[Dict[str, str]] = {
"UNSUPPORTED_CONTENT": ( "UNSUPPORTED_CONTENT": (
'Я пока не умею работать с таким сообщением. ' "Я пока не умею работать с таким сообщением. "
'Пришли текст и фото/фоты(ы). А лучше перешли это сообщение админу @kerrad1\n' "Пришли текст и фото/фоты(ы). А лучше перешли это сообщение админу @kerrad1\n"
'Мы добавим его к обработке если необходимо' "Мы добавим его к обработке если необходимо"
), ),
"STICKERS_LINK": "Хорошо, лови, добавить можно отсюда: https://t.me/addstickers/love_biysk" "STICKERS_LINK": "Хорошо, лови, добавить можно отсюда: https://t.me/addstickers/love_biysk",
} }

View File

@@ -6,12 +6,14 @@ from typing import Any, Callable
# Third-party imports # Third-party imports
from aiogram import types from aiogram import types
# Local imports # Local imports
from logs.custom_logger import logger from logs.custom_logger import logger
def error_handler(func: Callable[..., Any]) -> Callable[..., Any]: def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator for centralized error handling""" """Decorator for centralized error handling"""
async def wrapper(*args: Any, **kwargs: Any) -> Any: async def wrapper(*args: Any, **kwargs: Any) -> Any:
try: try:
return await func(*args, **kwargs) return await func(*args, **kwargs)
@@ -19,18 +21,23 @@ def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
logger.error(f"Error in {func.__name__}: {str(e)}") logger.error(f"Error in {func.__name__}: {str(e)}")
# Try to send error to logs if possible # Try to send error to logs if possible
try: try:
message = next((arg for arg in args if isinstance(arg, types.Message)), None) message = next(
if message and hasattr(message, 'bot'): (arg for arg in args if isinstance(arg, types.Message)), None
from helper_bot.utils.base_dependency_factory import \ )
get_global_instance if message and hasattr(message, "bot"):
from helper_bot.utils.base_dependency_factory import (
get_global_instance,
)
bdf = get_global_instance() bdf = get_global_instance()
important_logs = bdf.settings['Telegram']['important_logs'] important_logs = bdf.settings["Telegram"]["important_logs"]
await message.bot.send_message( await message.bot.send_message(
chat_id=important_logs, chat_id=important_logs,
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}" text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
) )
except Exception: except Exception:
# If we can't log the error, at least it was logged to logger # If we can't log the error, at least it was logged to logger
pass pass
raise raise
return wrapper return wrapper

View File

@@ -8,18 +8,23 @@ from datetime import datetime
from aiogram import F, Router, types from aiogram import F, Router, types
from aiogram.filters import Command, StateFilter from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
# Local imports - filters and middlewares # Local imports - filters and middlewares
from database.async_db import AsyncBotDB from database.async_db import AsyncBotDB
from helper_bot.filters.main import ChatTypeFilter from helper_bot.filters.main import ChatTypeFilter
# Local imports - utilities # Local imports - utilities
from helper_bot.keyboards import (get_reply_keyboard, from helper_bot.keyboards import get_reply_keyboard, get_reply_keyboard_for_post
get_reply_keyboard_for_post)
from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
from helper_bot.middlewares.album_middleware import AlbumMiddleware from helper_bot.middlewares.album_middleware import AlbumMiddleware
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
from helper_bot.utils import messages from helper_bot.utils import messages
from helper_bot.utils.helper_func import (check_user_emoji, get_first_name, from helper_bot.utils.helper_func import (
update_user_info) check_user_emoji,
get_first_name,
update_user_info,
)
# Local imports - metrics # Local imports - metrics
from helper_bot.utils.metrics import db_query_time, track_errors, track_time from helper_bot.utils.metrics import db_query_time, track_errors, track_time
@@ -34,83 +39,144 @@ sleep = asyncio.sleep
class PrivateHandlers: class PrivateHandlers:
"""Main handler class for private messages""" """Main handler class for private messages"""
def __init__(self, db: AsyncBotDB, settings: BotSettings, s3_storage=None, scoring_manager=None): def __init__(
self,
db: AsyncBotDB,
settings: BotSettings,
s3_storage=None,
scoring_manager=None,
):
self.db = db self.db = db
self.settings = settings self.settings = settings
self.user_service = UserService(db, settings) self.user_service = UserService(db, settings)
self.post_service = PostService(db, settings, s3_storage, scoring_manager) self.post_service = PostService(db, settings, s3_storage, scoring_manager)
self.sticker_service = StickerService(settings) self.sticker_service = StickerService(settings)
self.router = Router() self.router = Router()
self.router.message.middleware(AlbumMiddleware(latency=5.0)) self.router.message.middleware(AlbumMiddleware(latency=5.0))
self.router.message.middleware(BlacklistMiddleware()) self.router.message.middleware(BlacklistMiddleware())
# Register handlers # Register handlers
self._register_handlers() self._register_handlers()
def _register_handlers(self): def _register_handlers(self):
"""Register all message handlers""" """Register all message handlers"""
# Command handlers # Command handlers
self.router.message.register(self.handle_emoji_message, ChatTypeFilter(chat_type=["private"]), Command("emoji")) self.router.message.register(
self.router.message.register(self.handle_restart_message, ChatTypeFilter(chat_type=["private"]), Command("restart")) self.handle_emoji_message,
self.router.message.register(self.handle_start_message, ChatTypeFilter(chat_type=["private"]), Command("start")) ChatTypeFilter(chat_type=["private"]),
self.router.message.register(self.handle_start_message, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["RETURN_TO_BOT"]) Command("emoji"),
)
# Button handlers self.router.message.register(
self.router.message.register(self.suggest_post, StateFilter(FSM_STATES["START"]), ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["SUGGEST_POST"]) self.handle_restart_message,
self.router.message.register(self.end_message, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["SAY_GOODBYE"]) ChatTypeFilter(chat_type=["private"]),
self.router.message.register(self.end_message, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["LEAVE_CHAT"]) Command("restart"),
self.router.message.register(self.stickers, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["WANT_STICKERS"]) )
self.router.message.register(self.connect_with_admin, StateFilter(FSM_STATES["START"]), ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["CONNECT_ADMIN"]) self.router.message.register(
self.handle_start_message,
ChatTypeFilter(chat_type=["private"]),
Command("start"),
)
self.router.message.register(
self.handle_start_message,
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["RETURN_TO_BOT"],
)
# Button handlers
self.router.message.register(
self.suggest_post,
StateFilter(FSM_STATES["START"]),
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["SUGGEST_POST"],
)
self.router.message.register(
self.end_message,
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["SAY_GOODBYE"],
)
self.router.message.register(
self.end_message,
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["LEAVE_CHAT"],
)
self.router.message.register(
self.stickers,
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["WANT_STICKERS"],
)
self.router.message.register(
self.connect_with_admin,
StateFilter(FSM_STATES["START"]),
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["CONNECT_ADMIN"],
)
# State handlers # State handlers
self.router.message.register(self.suggest_router, StateFilter(FSM_STATES["SUGGEST"]), ChatTypeFilter(chat_type=["private"])) self.router.message.register(
self.router.message.register(self.resend_message_in_group_for_message, StateFilter(FSM_STATES["PRE_CHAT"]), ChatTypeFilter(chat_type=["private"])) self.suggest_router,
self.router.message.register(self.resend_message_in_group_for_message, StateFilter(FSM_STATES["CHAT"]), ChatTypeFilter(chat_type=["private"])) StateFilter(FSM_STATES["SUGGEST"]),
ChatTypeFilter(chat_type=["private"]),
)
self.router.message.register(
self.resend_message_in_group_for_message,
StateFilter(FSM_STATES["PRE_CHAT"]),
ChatTypeFilter(chat_type=["private"]),
)
self.router.message.register(
self.resend_message_in_group_for_message,
StateFilter(FSM_STATES["CHAT"]),
ChatTypeFilter(chat_type=["private"]),
)
@error_handler @error_handler
@track_errors("private_handlers", "handle_emoji_message") @track_errors("private_handlers", "handle_emoji_message")
@track_time("handle_emoji_message", "private_handlers") @track_time("handle_emoji_message", "private_handlers")
async def handle_emoji_message(self, message: types.Message, state: FSMContext, **kwargs): async def handle_emoji_message(
self, message: types.Message, state: FSMContext, **kwargs
):
"""Handle emoji command""" """Handle emoji command"""
await self.user_service.log_user_message(message) await self.user_service.log_user_message(message)
user_emoji = await check_user_emoji(message) user_emoji = await check_user_emoji(message)
await state.set_state(FSM_STATES["START"]) await state.set_state(FSM_STATES["START"])
if user_emoji is not None: if user_emoji is not None:
await message.answer(f'Твоя эмодзя - {user_emoji}', parse_mode='HTML') await message.answer(f"Твоя эмодзя - {user_emoji}", parse_mode="HTML")
@error_handler @error_handler
@track_errors("private_handlers", "handle_restart_message") @track_errors("private_handlers", "handle_restart_message")
@track_time("handle_restart_message", "private_handlers") @track_time("handle_restart_message", "private_handlers")
async def handle_restart_message(self, message: types.Message, state: FSMContext, **kwargs): async def handle_restart_message(
self, message: types.Message, state: FSMContext, **kwargs
):
"""Handle restart command""" """Handle restart command"""
markup = await get_reply_keyboard(self.db, message.from_user.id) markup = await get_reply_keyboard(self.db, message.from_user.id)
await self.user_service.log_user_message(message) await self.user_service.log_user_message(message)
await state.set_state(FSM_STATES["START"]) await state.set_state(FSM_STATES["START"])
await update_user_info('love', message) await update_user_info("love", message)
await check_user_emoji(message) await check_user_emoji(message)
await message.answer('Я перезапущен!', reply_markup=markup, parse_mode='HTML') await message.answer("Я перезапущен!", reply_markup=markup, parse_mode="HTML")
@error_handler @error_handler
@track_errors("private_handlers", "handle_start_message") @track_errors("private_handlers", "handle_start_message")
@track_time("handle_start_message", "private_handlers") @track_time("handle_start_message", "private_handlers")
async def handle_start_message(self, message: types.Message, state: FSMContext, **kwargs): async def handle_start_message(
self, message: types.Message, state: FSMContext, **kwargs
):
"""Handle start command and return to bot button with metrics tracking""" """Handle start command and return to bot button with metrics tracking"""
# User service operations with metrics # User service operations with metrics
await self.user_service.log_user_message(message) await self.user_service.log_user_message(message)
await self.user_service.ensure_user_exists(message) await self.user_service.ensure_user_exists(message)
await state.set_state(FSM_STATES["START"]) await state.set_state(FSM_STATES["START"])
# Send sticker with metrics # Send sticker with metrics
await self.sticker_service.send_random_hello_sticker(message) await self.sticker_service.send_random_hello_sticker(message)
# Send welcome message with metrics # Send welcome message with metrics
markup = await get_reply_keyboard(self.db, message.from_user.id) markup = await get_reply_keyboard(self.db, message.from_user.id)
hello_message = messages.get_message(get_first_name(message), 'HELLO_MESSAGE') hello_message = messages.get_message(get_first_name(message), "HELLO_MESSAGE")
await message.answer(hello_message, reply_markup=markup, parse_mode='HTML') await message.answer(hello_message, reply_markup=markup, parse_mode="HTML")
@error_handler @error_handler
@track_errors("private_handlers", "suggest_post") @track_errors("private_handlers", "suggest_post")
@track_time("suggest_post", "private_handlers") @track_time("suggest_post", "private_handlers")
@@ -120,11 +186,11 @@ class PrivateHandlers:
await self.user_service.update_user_activity(message.from_user.id) await self.user_service.update_user_activity(message.from_user.id)
await self.user_service.log_user_message(message) await self.user_service.log_user_message(message)
await state.set_state(FSM_STATES["SUGGEST"]) await state.set_state(FSM_STATES["SUGGEST"])
markup = types.ReplyKeyboardRemove() markup = types.ReplyKeyboardRemove()
suggest_news = messages.get_message(get_first_name(message), 'SUGGEST_NEWS') suggest_news = messages.get_message(get_first_name(message), "SUGGEST_NEWS")
await message.answer(suggest_news, reply_markup=markup) await message.answer(suggest_news, reply_markup=markup)
@error_handler @error_handler
@track_errors("private_handlers", "end_message") @track_errors("private_handlers", "end_message")
@track_time("end_message", "private_handlers") @track_time("end_message", "private_handlers")
@@ -133,40 +199,44 @@ class PrivateHandlers:
# User service operations with metrics # User service operations with metrics
await self.user_service.update_user_activity(message.from_user.id) await self.user_service.update_user_activity(message.from_user.id)
await self.user_service.log_user_message(message) await self.user_service.log_user_message(message)
# Send sticker # Send sticker
await self.sticker_service.send_random_goodbye_sticker(message) await self.sticker_service.send_random_goodbye_sticker(message)
# Send goodbye message # Send goodbye message
markup = types.ReplyKeyboardRemove() markup = types.ReplyKeyboardRemove()
bye_message = messages.get_message(get_first_name(message), 'BYE_MESSAGE') bye_message = messages.get_message(get_first_name(message), "BYE_MESSAGE")
await message.answer(bye_message, reply_markup=markup) await message.answer(bye_message, reply_markup=markup)
await state.set_state(FSM_STATES["START"]) await state.set_state(FSM_STATES["START"])
@error_handler @error_handler
@track_errors("private_handlers", "suggest_router") @track_errors("private_handlers", "suggest_router")
@track_time("suggest_router", "private_handlers") @track_time("suggest_router", "private_handlers")
async def suggest_router(self, message: types.Message, state: FSMContext, album: list = None, **kwargs): async def suggest_router(
self, message: types.Message, state: FSMContext, album: list = None, **kwargs
):
"""Handle post submission in suggest state - сразу отвечает пользователю, обработка в фоне""" """Handle post submission in suggest state - сразу отвечает пользователю, обработка в фоне"""
# Сразу отвечаем пользователю # Сразу отвечаем пользователю
markup_for_user = await get_reply_keyboard(self.db, message.from_user.id) markup_for_user = await get_reply_keyboard(self.db, message.from_user.id)
success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE') success_send_message = messages.get_message(
get_first_name(message), "SUCCESS_SEND_MESSAGE"
)
await message.answer(success_send_message, reply_markup=markup_for_user) await message.answer(success_send_message, reply_markup=markup_for_user)
await state.set_state(FSM_STATES["START"]) await state.set_state(FSM_STATES["START"])
# Проверяем, есть ли механизм для получения полной медиагруппы (для медиагрупп) # Проверяем, есть ли механизм для получения полной медиагруппы (для медиагрупп)
album_getter = kwargs.get("album_getter") album_getter = kwargs.get("album_getter")
# В фоне обрабатываем пост # В фоне обрабатываем пост
async def process_post_background(): async def process_post_background():
try: try:
# Обновляем активность пользователя # Обновляем активность пользователя
await self.user_service.update_user_activity(message.from_user.id) await self.user_service.update_user_activity(message.from_user.id)
# Логируем сообщение (только для одиночных сообщений, не медиагрупп) # Логируем сообщение (только для одиночных сообщений, не медиагрупп)
if message.media_group_id is None: if message.media_group_id is None:
await self.user_service.log_user_message(message) await self.user_service.log_user_message(message)
# Для медиагрупп ждем полную медиагруппу # Для медиагрупп ждем полную медиагруппу
if album_getter and message.media_group_id: if album_getter and message.media_group_id:
full_album = await album_getter.get_album(timeout=10.0) full_album = await album_getter.get_album(timeout=10.0)
@@ -177,10 +247,11 @@ class PrivateHandlers:
await self.post_service.process_post(message, album) await self.post_service.process_post(message, album)
except Exception as e: except Exception as e:
from logs.custom_logger import logger from logs.custom_logger import logger
logger.error(f"Ошибка при фоновой обработке поста: {e}") logger.error(f"Ошибка при фоновой обработке поста: {e}")
asyncio.create_task(process_post_background()) asyncio.create_task(process_post_background())
@error_handler @error_handler
@track_errors("private_handlers", "stickers") @track_errors("private_handlers", "stickers")
@track_time("stickers", "private_handlers") @track_time("stickers", "private_handlers")
@@ -191,41 +262,46 @@ class PrivateHandlers:
markup = await get_reply_keyboard(self.db, message.from_user.id) markup = await get_reply_keyboard(self.db, message.from_user.id)
await self.db.update_stickers_info(message.from_user.id) await self.db.update_stickers_info(message.from_user.id)
await self.user_service.log_user_message(message) await self.user_service.log_user_message(message)
await message.answer( await message.answer(text=ERROR_MESSAGES["STICKERS_LINK"], reply_markup=markup)
text=ERROR_MESSAGES["STICKERS_LINK"],
reply_markup=markup
)
await state.set_state(FSM_STATES["START"]) await state.set_state(FSM_STATES["START"])
@error_handler @error_handler
@track_errors("private_handlers", "connect_with_admin") @track_errors("private_handlers", "connect_with_admin")
@track_time("connect_with_admin", "private_handlers") @track_time("connect_with_admin", "private_handlers")
async def connect_with_admin(self, message: types.Message, state: FSMContext, **kwargs): async def connect_with_admin(
self, message: types.Message, state: FSMContext, **kwargs
):
"""Handle connect with admin button""" """Handle connect with admin button"""
# User service operations with metrics # User service operations with metrics
await self.user_service.update_user_activity(message.from_user.id) await self.user_service.update_user_activity(message.from_user.id)
admin_message = messages.get_message(get_first_name(message), 'CONNECT_WITH_ADMIN') admin_message = messages.get_message(
get_first_name(message), "CONNECT_WITH_ADMIN"
)
await message.answer(admin_message, parse_mode="html") await message.answer(admin_message, parse_mode="html")
await self.user_service.log_user_message(message) await self.user_service.log_user_message(message)
await state.set_state(FSM_STATES["PRE_CHAT"]) await state.set_state(FSM_STATES["PRE_CHAT"])
@error_handler @error_handler
@track_errors("private_handlers", "resend_message_in_group_for_message") @track_errors("private_handlers", "resend_message_in_group_for_message")
@track_time("resend_message_in_group_for_message", "private_handlers") @track_time("resend_message_in_group_for_message", "private_handlers")
@db_query_time("resend_message_in_group_for_message", "messages", "insert") @db_query_time("resend_message_in_group_for_message", "messages", "insert")
async def resend_message_in_group_for_message(self, message: types.Message, state: FSMContext, **kwargs): async def resend_message_in_group_for_message(
self, message: types.Message, state: FSMContext, **kwargs
):
"""Handle messages in admin chat states""" """Handle messages in admin chat states"""
# User service operations with metrics # User service operations with metrics
await self.user_service.update_user_activity(message.from_user.id) await self.user_service.update_user_activity(message.from_user.id)
await message.forward(chat_id=self.settings.group_for_message) await message.forward(chat_id=self.settings.group_for_message)
current_date = datetime.now() current_date = datetime.now()
date = int(current_date.timestamp()) date = int(current_date.timestamp())
await self.db.add_message(message.text, message.from_user.id, message.message_id + 1, date) await self.db.add_message(
message.text, message.from_user.id, message.message_id + 1, date
question = messages.get_message(get_first_name(message), 'QUESTION') )
question = messages.get_message(get_first_name(message), "QUESTION")
user_state = await state.get_state() user_state = await state.get_state()
if user_state == FSM_STATES["PRE_CHAT"]: if user_state == FSM_STATES["PRE_CHAT"]:
markup = await get_reply_keyboard(self.db, message.from_user.id) markup = await get_reply_keyboard(self.db, message.from_user.id)
await message.answer(question, reply_markup=markup) await message.answer(question, reply_markup=markup)
@@ -236,7 +312,9 @@ class PrivateHandlers:
# Factory function to create handlers with dependencies # Factory function to create handlers with dependencies
def create_private_handlers(db: AsyncBotDB, settings: BotSettings, s3_storage=None, scoring_manager=None) -> PrivateHandlers: def create_private_handlers(
db: AsyncBotDB, settings: BotSettings, s3_storage=None, scoring_manager=None
) -> PrivateHandlers:
"""Create private handlers instance with dependencies""" """Create private handlers instance with dependencies"""
return PrivateHandlers(db, settings, s3_storage, scoring_manager) return PrivateHandlers(db, settings, s3_storage, scoring_manager)
@@ -247,37 +325,39 @@ private_router = Router()
# Флаг инициализации для защиты от повторного вызова # Флаг инициализации для защиты от повторного вызова
_legacy_router_initialized = False _legacy_router_initialized = False
# Initialize with global dependencies (for backward compatibility) # Initialize with global dependencies (for backward compatibility)
def init_legacy_router(): def init_legacy_router():
"""Initialize legacy router with global dependencies""" """Initialize legacy router with global dependencies"""
global private_router, _legacy_router_initialized global private_router, _legacy_router_initialized
if _legacy_router_initialized: if _legacy_router_initialized:
return return
from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.base_dependency_factory import get_global_instance
bdf = get_global_instance() bdf = get_global_instance()
settings = BotSettings( settings = BotSettings(
group_for_posts=bdf.settings['Telegram']['group_for_posts'], group_for_posts=bdf.settings["Telegram"]["group_for_posts"],
group_for_message=bdf.settings['Telegram']['group_for_message'], group_for_message=bdf.settings["Telegram"]["group_for_message"],
main_public=bdf.settings['Telegram']['main_public'], main_public=bdf.settings["Telegram"]["main_public"],
group_for_logs=bdf.settings['Telegram']['group_for_logs'], group_for_logs=bdf.settings["Telegram"]["group_for_logs"],
important_logs=bdf.settings['Telegram']['important_logs'], important_logs=bdf.settings["Telegram"]["important_logs"],
preview_link=bdf.settings['Telegram']['preview_link'], preview_link=bdf.settings["Telegram"]["preview_link"],
logs=bdf.settings['Settings']['logs'], logs=bdf.settings["Settings"]["logs"],
test=bdf.settings['Settings']['test'] test=bdf.settings["Settings"]["test"],
) )
db = bdf.get_db() db = bdf.get_db()
s3_storage = bdf.get_s3_storage() s3_storage = bdf.get_s3_storage()
scoring_manager = bdf.get_scoring_manager() scoring_manager = bdf.get_scoring_manager()
handlers = create_private_handlers(db, settings, s3_storage, scoring_manager) handlers = create_private_handlers(db, settings, s3_storage, scoring_manager)
# Instead of trying to copy handlers, we'll use the new router directly # Instead of trying to copy handlers, we'll use the new router directly
# This maintains backward compatibility while using the new architecture # This maintains backward compatibility while using the new architecture
private_router = handlers.router private_router = handlers.router
_legacy_router_initialized = True _legacy_router_initialized = True
# Initialize legacy router # Initialize legacy router
init_legacy_router() init_legacy_router()

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
""" """
Утилиты для очистки и диагностики проблем с голосовыми файлами Утилиты для очистки и диагностики проблем с голосовыми файлами
""" """
import asyncio import asyncio
import os import os
from pathlib import Path from pathlib import Path
@@ -12,108 +13,122 @@ from logs.custom_logger import logger
class VoiceFileCleanupUtils: class VoiceFileCleanupUtils:
"""Утилиты для очистки и диагностики голосовых файлов""" """Утилиты для очистки и диагностики голосовых файлов"""
def __init__(self, bot_db): def __init__(self, bot_db):
self.bot_db = bot_db self.bot_db = bot_db
async def find_orphaned_db_records(self) -> List[Tuple[str, int]]: async def find_orphaned_db_records(self) -> List[Tuple[str, int]]:
"""Найти записи в БД, для которых нет соответствующих файлов""" """Найти записи в БД, для которых нет соответствующих файлов"""
try: try:
# Получаем все записи из БД # Получаем все записи из БД
all_audio_records = await self.bot_db.get_all_audio_records() all_audio_records = await self.bot_db.get_all_audio_records()
orphaned_records = [] orphaned_records = []
for record in all_audio_records: for record in all_audio_records:
file_name = record.get('file_name', '') file_name = record.get("file_name", "")
user_id = record.get('author_id', 0) user_id = record.get("author_id", 0)
file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg' file_path = f"{VOICE_USERS_DIR}/{file_name}.ogg"
if not os.path.exists(file_path): if not os.path.exists(file_path):
orphaned_records.append((file_name, user_id)) orphaned_records.append((file_name, user_id))
logger.warning(f"Найдена запись в БД без файла: {file_name} (user_id: {user_id})") logger.warning(
f"Найдена запись в БД без файла: {file_name} (user_id: {user_id})"
logger.info(f"Найдено {len(orphaned_records)} записей в БД без соответствующих файлов") )
logger.info(
f"Найдено {len(orphaned_records)} записей в БД без соответствующих файлов"
)
return orphaned_records return orphaned_records
except Exception as e: except Exception as e:
logger.error(f"Ошибка при поиске orphaned записей: {e}") logger.error(f"Ошибка при поиске orphaned записей: {e}")
return [] return []
async def find_orphaned_files(self) -> List[str]: async def find_orphaned_files(self) -> List[str]:
"""Найти файлы на диске, для которых нет записей в БД""" """Найти файлы на диске, для которых нет записей в БД"""
try: try:
if not os.path.exists(VOICE_USERS_DIR): if not os.path.exists(VOICE_USERS_DIR):
logger.warning(f"Директория {VOICE_USERS_DIR} не существует") logger.warning(f"Директория {VOICE_USERS_DIR} не существует")
return [] return []
# Получаем все файлы .ogg в директории # Получаем все файлы .ogg в директории
ogg_files = list(Path(VOICE_USERS_DIR).glob("*.ogg")) ogg_files = list(Path(VOICE_USERS_DIR).glob("*.ogg"))
orphaned_files = [] orphaned_files = []
# Получаем все записи из БД # Получаем все записи из БД
all_audio_records = await self.bot_db.get_all_audio_records() all_audio_records = await self.bot_db.get_all_audio_records()
db_file_names = {record.get('file_name', '') for record in all_audio_records} db_file_names = {
record.get("file_name", "") for record in all_audio_records
}
for file_path in ogg_files: for file_path in ogg_files:
file_name = file_path.stem # Имя файла без расширения file_name = file_path.stem # Имя файла без расширения
if file_name not in db_file_names: if file_name not in db_file_names:
orphaned_files.append(str(file_path)) orphaned_files.append(str(file_path))
logger.warning(f"Найден файл без записи в БД: {file_path}") logger.warning(f"Найден файл без записи в БД: {file_path}")
logger.info(f"Найдено {len(orphaned_files)} файлов без записей в БД") logger.info(f"Найдено {len(orphaned_files)} файлов без записей в БД")
return orphaned_files return orphaned_files
except Exception as e: except Exception as e:
logger.error(f"Ошибка при поиске orphaned файлов: {e}") logger.error(f"Ошибка при поиске orphaned файлов: {e}")
return [] return []
async def cleanup_orphaned_db_records(self, dry_run: bool = True) -> int: async def cleanup_orphaned_db_records(self, dry_run: bool = True) -> int:
"""Удалить записи в БД, для которых нет файлов""" """Удалить записи в БД, для которых нет файлов"""
try: try:
orphaned_records = await self.find_orphaned_db_records() orphaned_records = await self.find_orphaned_db_records()
if not orphaned_records: if not orphaned_records:
logger.info("Нет orphaned записей для удаления") logger.info("Нет orphaned записей для удаления")
return 0 return 0
if dry_run: if dry_run:
logger.info(f"DRY RUN: Найдено {len(orphaned_records)} записей для удаления") logger.info(
f"DRY RUN: Найдено {len(orphaned_records)} записей для удаления"
)
for file_name, user_id in orphaned_records: for file_name, user_id in orphaned_records:
logger.info(f"DRY RUN: Будет удалена запись: {file_name} (user_id: {user_id})") logger.info(
f"DRY RUN: Будет удалена запись: {file_name} (user_id: {user_id})"
)
return len(orphaned_records) return len(orphaned_records)
# Удаляем записи # Удаляем записи
deleted_count = 0 deleted_count = 0
for file_name, user_id in orphaned_records: for file_name, user_id in orphaned_records:
try: try:
await self.bot_db.delete_audio_record_by_file_name(file_name) await self.bot_db.delete_audio_record_by_file_name(file_name)
deleted_count += 1 deleted_count += 1
logger.info(f"Удалена запись в БД: {file_name} (user_id: {user_id})") logger.info(
f"Удалена запись в БД: {file_name} (user_id: {user_id})"
)
except Exception as e: except Exception as e:
logger.error(f"Ошибка при удалении записи {file_name}: {e}") logger.error(f"Ошибка при удалении записи {file_name}: {e}")
logger.info(f"Удалено {deleted_count} orphaned записей из БД") logger.info(f"Удалено {deleted_count} orphaned записей из БД")
return deleted_count return deleted_count
except Exception as e: except Exception as e:
logger.error(f"Ошибка при очистке orphaned записей: {e}") logger.error(f"Ошибка при очистке orphaned записей: {e}")
return 0 return 0
async def cleanup_orphaned_files(self, dry_run: bool = True) -> int: async def cleanup_orphaned_files(self, dry_run: bool = True) -> int:
"""Удалить файлы на диске, для которых нет записей в БД""" """Удалить файлы на диске, для которых нет записей в БД"""
try: try:
orphaned_files = await self.find_orphaned_files() orphaned_files = await self.find_orphaned_files()
if not orphaned_files: if not orphaned_files:
logger.info("Нет orphaned файлов для удаления") logger.info("Нет orphaned файлов для удаления")
return 0 return 0
if dry_run: if dry_run:
logger.info(f"DRY RUN: Найдено {len(orphaned_files)} файлов для удаления") logger.info(
f"DRY RUN: Найдено {len(orphaned_files)} файлов для удаления"
)
for file_path in orphaned_files: for file_path in orphaned_files:
logger.info(f"DRY RUN: Будет удален файл: {file_path}") logger.info(f"DRY RUN: Будет удален файл: {file_path}")
return len(orphaned_files) return len(orphaned_files)
# Удаляем файлы # Удаляем файлы
deleted_count = 0 deleted_count = 0
for file_path in orphaned_files: for file_path in orphaned_files:
@@ -123,70 +138,76 @@ class VoiceFileCleanupUtils:
logger.info(f"Удален файл: {file_path}") logger.info(f"Удален файл: {file_path}")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при удалении файла {file_path}: {e}") logger.error(f"Ошибка при удалении файла {file_path}: {e}")
logger.info(f"Удалено {deleted_count} orphaned файлов") logger.info(f"Удалено {deleted_count} orphaned файлов")
return deleted_count return deleted_count
except Exception as e: except Exception as e:
logger.error(f"Ошибка при очистке orphaned файлов: {e}") logger.error(f"Ошибка при очистке orphaned файлов: {e}")
return 0 return 0
async def get_disk_usage_stats(self) -> dict: async def get_disk_usage_stats(self) -> dict:
"""Получить статистику использования диска""" """Получить статистику использования диска"""
try: try:
if not os.path.exists(VOICE_USERS_DIR): if not os.path.exists(VOICE_USERS_DIR):
return {"error": f"Директория {VOICE_USERS_DIR} не существует"} return {"error": f"Директория {VOICE_USERS_DIR} не существует"}
total_size = 0 total_size = 0
file_count = 0 file_count = 0
for file_path in Path(VOICE_USERS_DIR).glob("*.ogg"): for file_path in Path(VOICE_USERS_DIR).glob("*.ogg"):
if file_path.is_file(): if file_path.is_file():
total_size += file_path.stat().st_size total_size += file_path.stat().st_size
file_count += 1 file_count += 1
return { return {
"total_files": file_count, "total_files": file_count,
"total_size_bytes": total_size, "total_size_bytes": total_size,
"total_size_mb": round(total_size / (1024 * 1024), 2), "total_size_mb": round(total_size / (1024 * 1024), 2),
"directory": VOICE_USERS_DIR "directory": VOICE_USERS_DIR,
} }
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении статистики диска: {e}") logger.error(f"Ошибка при получении статистики диска: {e}")
return {"error": str(e)} return {"error": str(e)}
async def run_full_diagnostic(self) -> dict: async def run_full_diagnostic(self) -> dict:
"""Запустить полную диагностику""" """Запустить полную диагностику"""
try: try:
logger.info("Запуск полной диагностики голосовых файлов...") logger.info("Запуск полной диагностики голосовых файлов...")
# Статистика диска # Статистика диска
disk_stats = await self.get_disk_usage_stats() disk_stats = await self.get_disk_usage_stats()
# Orphaned записи в БД # Orphaned записи в БД
orphaned_db_records = await self.find_orphaned_db_records() orphaned_db_records = await self.find_orphaned_db_records()
# Orphaned файлы # Orphaned файлы
orphaned_files = await self.find_orphaned_files() orphaned_files = await self.find_orphaned_files()
# Количество записей в БД # Количество записей в БД
all_audio_records = await self.bot_db.get_all_audio_records() all_audio_records = await self.bot_db.get_all_audio_records()
db_records_count = len(all_audio_records) db_records_count = len(all_audio_records)
diagnostic_result = { diagnostic_result = {
"disk_stats": disk_stats, "disk_stats": disk_stats,
"db_records_count": db_records_count, "db_records_count": db_records_count,
"orphaned_db_records_count": len(orphaned_db_records), "orphaned_db_records_count": len(orphaned_db_records),
"orphaned_files_count": len(orphaned_files), "orphaned_files_count": len(orphaned_files),
"orphaned_db_records": orphaned_db_records[:10], # Первые 10 для примера "orphaned_db_records": orphaned_db_records[
:10
], # Первые 10 для примера
"orphaned_files": orphaned_files[:10], # Первые 10 для примера "orphaned_files": orphaned_files[:10], # Первые 10 для примера
"status": "healthy" if len(orphaned_db_records) == 0 and len(orphaned_files) == 0 else "issues_found" "status": (
"healthy"
if len(orphaned_db_records) == 0 and len(orphaned_files) == 0
else "issues_found"
),
} }
logger.info(f"Диагностика завершена. Статус: {diagnostic_result['status']}") logger.info(f"Диагностика завершена. Статус: {diagnostic_result['status']}")
return diagnostic_result return diagnostic_result
except Exception as e: except Exception as e:
logger.error(f"Ошибка при диагностике: {e}") logger.error(f"Ошибка при диагностике: {e}")
return {"error": str(e)} return {"error": str(e)}

View File

@@ -17,10 +17,10 @@ CMD_REFRESH = "refresh"
# Command to command mapping for metrics # Command to command mapping for metrics
COMMAND_MAPPING: Final[Dict[str, str]] = { COMMAND_MAPPING: Final[Dict[str, str]] = {
"start": "voice_start", "start": "voice_start",
"help": "voice_help", "help": "voice_help",
"restart": "voice_restart", "restart": "voice_restart",
"emoji": "voice_emoji", "emoji": "voice_emoji",
"refresh": "voice_refresh" "refresh": "voice_refresh",
} }
# Button texts # Button texts
@@ -33,7 +33,7 @@ BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = {
"🎧Послушать": "voice_listen", "🎧Послушать": "voice_listen",
"Отменить": "voice_cancel", "Отменить": "voice_cancel",
"🔄Сбросить прослушивания": "voice_refresh_listen", "🔄Сбросить прослушивания": "voice_refresh_listen",
"😊Узнать эмодзи": "voice_emoji" "😊Узнать эмодзи": "voice_emoji",
} }
# Callback data # Callback data
@@ -43,7 +43,7 @@ CALLBACK_DELETE = "delete"
# Callback to command mapping for metrics # Callback to command mapping for metrics
CALLBACK_COMMAND_MAPPING: Final[Dict[str, str]] = { CALLBACK_COMMAND_MAPPING: Final[Dict[str, str]] = {
"save": "voice_save", "save": "voice_save",
"delete": "voice_delete" "delete": "voice_delete",
} }
# File paths # File paths

View File

@@ -1,23 +1,28 @@
class VoiceBotError(Exception): class VoiceBotError(Exception):
"""Базовое исключение для voice_bot""" """Базовое исключение для voice_bot"""
pass pass
class VoiceMessageError(VoiceBotError): class VoiceMessageError(VoiceBotError):
"""Ошибка при работе с голосовыми сообщениями""" """Ошибка при работе с голосовыми сообщениями"""
pass pass
class AudioProcessingError(VoiceBotError): class AudioProcessingError(VoiceBotError):
"""Ошибка при обработке аудио""" """Ошибка при обработке аудио"""
pass pass
class DatabaseError(VoiceBotError): class DatabaseError(VoiceBotError):
"""Ошибка базы данных""" """Ошибка базы данных"""
pass pass
class FileOperationError(VoiceBotError): class FileOperationError(VoiceBotError):
"""Ошибка при работе с файлами""" """Ошибка при работе с файлами"""
pass pass

View File

@@ -7,16 +7,24 @@ from pathlib import Path
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from aiogram.types import FSInputFile from aiogram.types import FSInputFile
from helper_bot.handlers.voice.constants import (MESSAGE_DELAY_1,
MESSAGE_DELAY_2, from helper_bot.handlers.voice.constants import (
MESSAGE_DELAY_3, MESSAGE_DELAY_1,
MESSAGE_DELAY_4, STICK_DIR, MESSAGE_DELAY_2,
STICK_PATTERN, STICKER_DELAY, MESSAGE_DELAY_3,
VOICE_USERS_DIR) MESSAGE_DELAY_4,
from helper_bot.handlers.voice.exceptions import (AudioProcessingError, STICK_DIR,
DatabaseError, STICK_PATTERN,
FileOperationError, STICKER_DELAY,
VoiceMessageError) VOICE_USERS_DIR,
)
from helper_bot.handlers.voice.exceptions import (
AudioProcessingError,
DatabaseError,
FileOperationError,
VoiceMessageError,
)
# Local imports - metrics # Local imports - metrics
from helper_bot.utils.metrics import db_query_time, track_errors, track_time from helper_bot.utils.metrics import db_query_time, track_errors, track_time
from logs.custom_logger import logger from logs.custom_logger import logger
@@ -24,19 +32,23 @@ from logs.custom_logger import logger
class VoiceMessage: class VoiceMessage:
"""Модель голосового сообщения""" """Модель голосового сообщения"""
def __init__(self, file_name: str, user_id: int, date_added: datetime, file_id: int):
def __init__(
self, file_name: str, user_id: int, date_added: datetime, file_id: int
):
self.file_name = file_name self.file_name = file_name
self.user_id = user_id self.user_id = user_id
self.date_added = date_added self.date_added = date_added
self.file_id = file_id self.file_id = file_id
class VoiceBotService: class VoiceBotService:
"""Сервис для работы с голосовыми сообщениями""" """Сервис для работы с голосовыми сообщениями"""
def __init__(self, bot_db, settings): def __init__(self, bot_db, settings):
self.bot_db = bot_db self.bot_db = bot_db
self.settings = settings self.settings = settings
@track_time("get_welcome_sticker", "voice_bot_service") @track_time("get_welcome_sticker", "voice_bot_service")
@track_errors("voice_bot_service", "get_welcome_sticker") @track_errors("voice_bot_service", "get_welcome_sticker")
async def get_welcome_sticker(self) -> Optional[FSInputFile]: async def get_welcome_sticker(self) -> Optional[FSInputFile]:
@@ -45,17 +57,21 @@ class VoiceBotService:
name_stick_hello = list(Path(STICK_DIR).rglob(STICK_PATTERN)) name_stick_hello = list(Path(STICK_DIR).rglob(STICK_PATTERN))
if not name_stick_hello: if not name_stick_hello:
return None return None
random_stick_hello = random.choice(name_stick_hello) random_stick_hello = random.choice(name_stick_hello)
random_stick_hello = FSInputFile(path=random_stick_hello) random_stick_hello = FSInputFile(path=random_stick_hello)
logger.info(f"Стикер успешно получен. Наименование стикера: {random_stick_hello}") logger.info(
f"Стикер успешно получен. Наименование стикера: {random_stick_hello}"
)
return random_stick_hello return random_stick_hello
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении стикера: {e}") logger.error(f"Ошибка при получении стикера: {e}")
if self.settings['Settings']['logs']: if self.settings["Settings"]["logs"]:
await self._send_error_to_logs(f'Отправка приветственных стикеров лажает. Ошибка: {e}') await self._send_error_to_logs(
f"Отправка приветственных стикеров лажает. Ошибка: {e}"
)
return None return None
@track_time("send_welcome_messages", "voice_bot_service") @track_time("send_welcome_messages", "voice_bot_service")
@track_errors("voice_bot_service", "send_welcome_messages") @track_errors("voice_bot_service", "send_welcome_messages")
async def send_welcome_messages(self, message, user_emoji: str): async def send_welcome_messages(self, message, user_emoji: str):
@@ -66,92 +82,94 @@ class VoiceBotService:
if sticker: if sticker:
await message.answer_sticker(sticker) await message.answer_sticker(sticker)
await asyncio.sleep(STICKER_DELAY) await asyncio.sleep(STICKER_DELAY)
# Отправляем приветственное сообщение # Отправляем приветственное сообщение
markup = self._get_main_keyboard() markup = self._get_main_keyboard()
await message.answer( await message.answer(
text="<b>Привет.</b>", text="<b>Привет.</b>",
parse_mode='html', parse_mode="html",
reply_markup=markup, reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link'] disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
) )
await asyncio.sleep(STICKER_DELAY) await asyncio.sleep(STICKER_DELAY)
# Отправляем описание # Отправляем описание
await message.answer( await message.answer(
text="<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>", text="<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>",
parse_mode='html', parse_mode="html",
reply_markup=markup, reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link'] disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
) )
await asyncio.sleep(MESSAGE_DELAY_1) await asyncio.sleep(MESSAGE_DELAY_1)
# Отправляем аналогию # Отправляем аналогию
await message.answer( await message.answer(
text="Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится..", text="Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится..",
parse_mode='html', parse_mode="html",
reply_markup=markup, reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link'] disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
) )
await asyncio.sleep(MESSAGE_DELAY_2) await asyncio.sleep(MESSAGE_DELAY_2)
# Отправляем правила # Отправляем правила
await message.answer( await message.answer(
text="Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>", text="Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>",
parse_mode='html', parse_mode="html",
reply_markup=markup, reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link'] disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
) )
await asyncio.sleep(MESSAGE_DELAY_3) await asyncio.sleep(MESSAGE_DELAY_3)
# Отправляем информацию об анонимности # Отправляем информацию об анонимности
await message.answer( await message.answer(
text="Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)", text="Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)",
parse_mode='html', parse_mode="html",
reply_markup=markup, reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link'] disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
) )
await asyncio.sleep(MESSAGE_DELAY_4) await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем предложения # Отправляем предложения
await message.answer( await message.answer(
text="Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)", text="Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)",
parse_mode='html', parse_mode="html",
reply_markup=markup, reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link'] disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
) )
await asyncio.sleep(MESSAGE_DELAY_4) await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем информацию об эмодзи # Отправляем информацию об эмодзи
await message.answer( await message.answer(
text=f"Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{user_emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)", text=f"Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{user_emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)",
parse_mode='html', parse_mode="html",
reply_markup=markup, reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link'] disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
) )
await asyncio.sleep(MESSAGE_DELAY_4) await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем информацию о помощи # Отправляем информацию о помощи
await message.answer( await message.answer(
text="Так же можешь ознакомиться с инструкцией к боту по команде /help", text="Так же можешь ознакомиться с инструкцией к боту по команде /help",
parse_mode='html', parse_mode="html",
reply_markup=markup, reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link'] disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
) )
await asyncio.sleep(MESSAGE_DELAY_4) await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем финальное сообщение # Отправляем финальное сообщение
await message.answer( await message.answer(
text="<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤", text="<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤",
parse_mode='html', parse_mode="html",
reply_markup=markup, reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link'] disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
) )
except Exception as e: except Exception as e:
logger.error(f"Ошибка при отправке приветственных сообщений: {e}") logger.error(f"Ошибка при отправке приветственных сообщений: {e}")
raise VoiceMessageError(f"Не удалось отправить приветственные сообщения: {e}") raise VoiceMessageError(
f"Не удалось отправить приветственные сообщения: {e}"
)
@track_time("get_random_audio", "voice_bot_service") @track_time("get_random_audio", "voice_bot_service")
@track_errors("voice_bot_service", "get_random_audio") @track_errors("voice_bot_service", "get_random_audio")
async def get_random_audio(self, user_id: int) -> Optional[Tuple[str, str, str]]: async def get_random_audio(self, user_id: int) -> Optional[Tuple[str, str, str]]:
@@ -159,25 +177,25 @@ class VoiceBotService:
try: try:
check_audio = await self.bot_db.check_listen_audio(user_id=user_id) check_audio = await self.bot_db.check_listen_audio(user_id=user_id)
list_audio = list(check_audio) list_audio = list(check_audio)
if not list_audio: if not list_audio:
return None return None
# Получаем случайное аудио # Получаем случайное аудио
number_element = random.randint(0, len(list_audio) - 1) number_element = random.randint(0, len(list_audio) - 1)
audio_for_user = check_audio[number_element] audio_for_user = check_audio[number_element]
# Получаем информацию об авторе # Получаем информацию об авторе
user_id_author = await self.bot_db.get_user_id_by_file_name(audio_for_user) user_id_author = await self.bot_db.get_user_id_by_file_name(audio_for_user)
date_added = await self.bot_db.get_date_by_file_name(audio_for_user) date_added = await self.bot_db.get_date_by_file_name(audio_for_user)
user_emoji = await self.bot_db.get_user_emoji(user_id_author) user_emoji = await self.bot_db.get_user_emoji(user_id_author)
return audio_for_user, date_added, user_emoji return audio_for_user, date_added, user_emoji
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении случайного аудио: {e}") logger.error(f"Ошибка при получении случайного аудио: {e}")
raise AudioProcessingError(f"Не удалось получить случайное аудио: {e}") raise AudioProcessingError(f"Не удалось получить случайное аудио: {e}")
@track_time("mark_audio_as_listened", "voice_bot_service") @track_time("mark_audio_as_listened", "voice_bot_service")
@track_errors("voice_bot_service", "mark_audio_as_listened") @track_errors("voice_bot_service", "mark_audio_as_listened")
async def mark_audio_as_listened(self, file_name: str, user_id: int) -> None: async def mark_audio_as_listened(self, file_name: str, user_id: int) -> None:
@@ -187,7 +205,7 @@ class VoiceBotService:
except Exception as e: except Exception as e:
logger.error(f"Ошибка при пометке аудио как прослушанного: {e}") logger.error(f"Ошибка при пометке аудио как прослушанного: {e}")
raise DatabaseError(f"Не удалось пометить аудио как прослушанное: {e}") raise DatabaseError(f"Не удалось пометить аудио как прослушанное: {e}")
@track_time("clear_user_listenings", "voice_bot_service") @track_time("clear_user_listenings", "voice_bot_service")
@track_errors("voice_bot_service", "clear_user_listenings") @track_errors("voice_bot_service", "clear_user_listenings")
@db_query_time("clear_user_listenings", "audio_moderate", "delete") @db_query_time("clear_user_listenings", "audio_moderate", "delete")
@@ -198,7 +216,7 @@ class VoiceBotService:
except Exception as e: except Exception as e:
logger.error(f"Ошибка при очистке прослушиваний: {e}") logger.error(f"Ошибка при очистке прослушиваний: {e}")
raise DatabaseError(f"Не удалось очистить прослушивания: {e}") raise DatabaseError(f"Не удалось очистить прослушивания: {e}")
@track_time("get_remaining_audio_count", "voice_bot_service") @track_time("get_remaining_audio_count", "voice_bot_service")
@track_errors("voice_bot_service", "get_remaining_audio_count") @track_errors("voice_bot_service", "get_remaining_audio_count")
async def get_remaining_audio_count(self, user_id: int) -> int: async def get_remaining_audio_count(self, user_id: int) -> int:
@@ -209,25 +227,24 @@ class VoiceBotService:
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении количества аудио: {e}") logger.error(f"Ошибка при получении количества аудио: {e}")
raise DatabaseError(f"Не удалось получить количество аудио: {e}") raise DatabaseError(f"Не удалось получить количество аудио: {e}")
@track_time("get_main_keyboard", "voice_bot_service") @track_time("get_main_keyboard", "voice_bot_service")
@track_errors("voice_bot_service", "get_main_keyboard") @track_errors("voice_bot_service", "get_main_keyboard")
def _get_main_keyboard(self): def _get_main_keyboard(self):
"""Получить основную клавиатуру""" """Получить основную клавиатуру"""
from helper_bot.keyboards.keyboards import get_main_keyboard from helper_bot.keyboards.keyboards import get_main_keyboard
return get_main_keyboard() return get_main_keyboard()
@track_time("send_error_to_logs", "voice_bot_service") @track_time("send_error_to_logs", "voice_bot_service")
@track_errors("voice_bot_service", "send_error_to_logs") @track_errors("voice_bot_service", "send_error_to_logs")
async def _send_error_to_logs(self, message: str) -> None: async def _send_error_to_logs(self, message: str) -> None:
"""Отправить ошибку в логи""" """Отправить ошибку в логи"""
try: try:
from helper_bot.utils.helper_func import send_voice_message from helper_bot.utils.helper_func import send_voice_message
await send_voice_message( await send_voice_message(
self.settings['Telegram']['important_logs'], self.settings["Telegram"]["important_logs"], None, None, None
None,
None,
None
) )
except Exception as e: except Exception as e:
logger.error(f"Не удалось отправить ошибку в логи: {e}") logger.error(f"Не удалось отправить ошибку в логи: {e}")
@@ -235,45 +252,49 @@ class VoiceBotService:
class AudioFileService: class AudioFileService:
"""Сервис для работы с аудио файлами""" """Сервис для работы с аудио файлами"""
def __init__(self, bot_db): def __init__(self, bot_db):
self.bot_db = bot_db self.bot_db = bot_db
@track_time("generate_file_name", "audio_file_service") @track_time("generate_file_name", "audio_file_service")
@track_errors("audio_file_service", "generate_file_name") @track_errors("audio_file_service", "generate_file_name")
async def generate_file_name(self, user_id: int) -> str: async def generate_file_name(self, user_id: int) -> str:
"""Сгенерировать имя файла для аудио""" """Сгенерировать имя файла для аудио"""
try: try:
# Проверяем есть ли запись о файле в базе данных # Проверяем есть ли запись о файле в базе данных
user_audio_count = await self.bot_db.get_user_audio_records_count(user_id=user_id) user_audio_count = await self.bot_db.get_user_audio_records_count(
user_id=user_id
)
if user_audio_count == 0: if user_audio_count == 0:
# Если нет, то генерируем имя файла # Если нет, то генерируем имя файла
file_name = f'message_from_{user_id}_number_1' file_name = f"message_from_{user_id}_number_1"
else: else:
# Иначе берем последнюю запись из БД, добавляем к ней 1 # Иначе берем последнюю запись из БД, добавляем к ней 1
file_name = await self.bot_db.get_path_for_audio_record(user_id=user_id) file_name = await self.bot_db.get_path_for_audio_record(user_id=user_id)
if file_name: if file_name:
# Извлекаем номер из имени файла и увеличиваем на 1 # Извлекаем номер из имени файла и увеличиваем на 1
try: try:
current_number = int(file_name.split('_')[-1]) current_number = int(file_name.split("_")[-1])
new_number = current_number + 1 new_number = current_number + 1
except (ValueError, IndexError): except (ValueError, IndexError):
new_number = user_audio_count + 1 new_number = user_audio_count + 1
else: else:
new_number = user_audio_count + 1 new_number = user_audio_count + 1
file_name = f'message_from_{user_id}_number_{new_number}' file_name = f"message_from_{user_id}_number_{new_number}"
return file_name return file_name
except Exception as e: except Exception as e:
logger.error(f"Ошибка при генерации имени файла: {e}") logger.error(f"Ошибка при генерации имени файла: {e}")
raise FileOperationError(f"Не удалось сгенерировать имя файла: {e}") raise FileOperationError(f"Не удалось сгенерировать имя файла: {e}")
@track_time("save_audio_file", "audio_file_service") @track_time("save_audio_file", "audio_file_service")
@track_errors("audio_file_service", "save_audio_file") @track_errors("audio_file_service", "save_audio_file")
async def save_audio_file(self, file_name: str, user_id: int, date_added: datetime, file_id: str) -> None: async def save_audio_file(
self, file_name: str, user_id: int, date_added: datetime, file_id: str
) -> None:
"""Сохранить информацию об аудио файле в базу данных""" """Сохранить информацию об аудио файле в базу данных"""
try: try:
# Проверяем существование файла перед сохранением в БД # Проверяем существование файла перед сохранением в БД
@@ -281,16 +302,20 @@ class AudioFileService:
error_msg = f"Файл {file_name} не существует или поврежден, отменяем сохранение в БД" error_msg = f"Файл {file_name} не существует или поврежден, отменяем сохранение в БД"
logger.error(error_msg) logger.error(error_msg)
raise FileOperationError(error_msg) raise FileOperationError(error_msg)
await self.bot_db.add_audio_record_simple(file_name, user_id, date_added) await self.bot_db.add_audio_record_simple(file_name, user_id, date_added)
logger.info(f"Информация об аудио файле успешно сохранена в БД: {file_name}") logger.info(
f"Информация об аудио файле успешно сохранена в БД: {file_name}"
)
except Exception as e: except Exception as e:
logger.error(f"Ошибка при сохранении аудио файла в БД: {e}") logger.error(f"Ошибка при сохранении аудио файла в БД: {e}")
raise DatabaseError(f"Не удалось сохранить аудио файл в БД: {e}") raise DatabaseError(f"Не удалось сохранить аудио файл в БД: {e}")
@track_time("save_audio_file_with_transaction", "audio_file_service") @track_time("save_audio_file_with_transaction", "audio_file_service")
@track_errors("audio_file_service", "save_audio_file_with_transaction") @track_errors("audio_file_service", "save_audio_file_with_transaction")
async def save_audio_file_with_transaction(self, file_name: str, user_id: int, date_added: datetime, file_id: str) -> None: async def save_audio_file_with_transaction(
self, file_name: str, user_id: int, date_added: datetime, file_id: str
) -> None:
"""Сохранить информацию об аудио файле в базу данных с транзакцией""" """Сохранить информацию об аудио файле в базу данных с транзакцией"""
try: try:
# Проверяем существование файла перед сохранением в БД # Проверяем существование файла перед сохранением в БД
@@ -298,68 +323,80 @@ class AudioFileService:
error_msg = f"Файл {file_name} не существует или поврежден, отменяем сохранение в БД" error_msg = f"Файл {file_name} не существует или поврежден, отменяем сохранение в БД"
logger.error(error_msg) logger.error(error_msg)
raise FileOperationError(error_msg) raise FileOperationError(error_msg)
# Используем транзакцию для атомарности операции # Используем транзакцию для атомарности операции
await self.bot_db.add_audio_record_simple(file_name, user_id, date_added) await self.bot_db.add_audio_record_simple(file_name, user_id, date_added)
logger.info(f"Информация об аудио файле успешно сохранена в БД с транзакцией: {file_name}") logger.info(
f"Информация об аудио файле успешно сохранена в БД с транзакцией: {file_name}"
)
except Exception as e: except Exception as e:
logger.error(f"Ошибка при сохранении аудио файла в БД с транзакцией: {e}") logger.error(f"Ошибка при сохранении аудио файла в БД с транзакцией: {e}")
raise DatabaseError(f"Не удалось сохранить аудио файл в БД с транзакцией: {e}") raise DatabaseError(
f"Не удалось сохранить аудио файл в БД с транзакцией: {e}"
)
@track_time("download_and_save_audio", "audio_file_service") @track_time("download_and_save_audio", "audio_file_service")
@track_errors("audio_file_service", "download_and_save_audio") @track_errors("audio_file_service", "download_and_save_audio")
async def download_and_save_audio(self, bot, message, file_name: str, max_retries: int = 3) -> None: async def download_and_save_audio(
self, bot, message, file_name: str, max_retries: int = 3
) -> None:
"""Скачать и сохранить аудио файл с retry механизмом""" """Скачать и сохранить аудио файл с retry механизмом"""
last_exception = None last_exception = None
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
logger.info(f"Попытка {attempt + 1}/{max_retries} скачивания и сохранения аудио: {file_name}") logger.info(
f"Попытка {attempt + 1}/{max_retries} скачивания и сохранения аудио: {file_name}"
)
# Проверяем наличие голосового сообщения # Проверяем наличие голосового сообщения
if not message or not message.voice: if not message or not message.voice:
error_msg = "Сообщение или голосовое сообщение не найдено" error_msg = "Сообщение или голосовое сообщение не найдено"
logger.error(error_msg) logger.error(error_msg)
raise FileOperationError(error_msg) raise FileOperationError(error_msg)
file_id = message.voice.file_id file_id = message.voice.file_id
logger.info(f"Получен file_id: {file_id}") logger.info(f"Получен file_id: {file_id}")
# Получаем информацию о файле # Получаем информацию о файле
try: try:
file_info = await bot.get_file(file_id=file_id) file_info = await bot.get_file(file_id=file_id)
logger.info(f"Получена информация о файле: {file_info.file_path}") logger.info(f"Получена информация о файле: {file_info.file_path}")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении информации о файле: {e}") logger.error(f"Ошибка при получении информации о файле: {e}")
raise FileOperationError(f"Не удалось получить информацию о файле: {e}") raise FileOperationError(
f"Не удалось получить информацию о файле: {e}"
)
# Скачиваем файл # Скачиваем файл
try: try:
downloaded_file = await bot.download_file(file_path=file_info.file_path) downloaded_file = await bot.download_file(
file_path=file_info.file_path
)
except Exception as e: except Exception as e:
logger.error(f"Ошибка при скачивании файла: {e}") logger.error(f"Ошибка при скачивании файла: {e}")
raise FileOperationError(f"Не удалось скачать файл: {e}") raise FileOperationError(f"Не удалось скачать файл: {e}")
# Проверяем что файл успешно скачан # Проверяем что файл успешно скачан
if not downloaded_file: if not downloaded_file:
error_msg = "Не удалось скачать файл - получен пустой объект" error_msg = "Не удалось скачать файл - получен пустой объект"
logger.error(error_msg) logger.error(error_msg)
raise FileOperationError(error_msg) raise FileOperationError(error_msg)
# Получаем размер файла без изменения позиции # Получаем размер файла без изменения позиции
current_pos = downloaded_file.tell() current_pos = downloaded_file.tell()
downloaded_file.seek(0, 2) # Переходим в конец файла downloaded_file.seek(0, 2) # Переходим в конец файла
file_size = downloaded_file.tell() file_size = downloaded_file.tell()
downloaded_file.seek(current_pos) # Возвращаемся в исходную позицию downloaded_file.seek(current_pos) # Возвращаемся в исходную позицию
logger.info(f"Файл скачан, размер: {file_size} bytes") logger.info(f"Файл скачан, размер: {file_size} bytes")
# Проверяем минимальный размер файла # Проверяем минимальный размер файла
if file_size < 100: # Минимальный размер для аудио файла if file_size < 100: # Минимальный размер для аудио файла
error_msg = f"Файл слишком маленький: {file_size} bytes" error_msg = f"Файл слишком маленький: {file_size} bytes"
logger.error(error_msg) logger.error(error_msg)
raise FileOperationError(error_msg) raise FileOperationError(error_msg)
# Создаем директорию если она не существует # Создаем директорию если она не существует
try: try:
os.makedirs(VOICE_USERS_DIR, exist_ok=True) os.makedirs(VOICE_USERS_DIR, exist_ok=True)
@@ -367,27 +404,27 @@ class AudioFileService:
except Exception as e: except Exception as e:
logger.error(f"Ошибка при создании директории: {e}") logger.error(f"Ошибка при создании директории: {e}")
raise FileOperationError(f"Не удалось создать директорию: {e}") raise FileOperationError(f"Не удалось создать директорию: {e}")
file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg' file_path = f"{VOICE_USERS_DIR}/{file_name}.ogg"
logger.info(f"Сохраняем файл по пути: {file_path}") logger.info(f"Сохраняем файл по пути: {file_path}")
# Сбрасываем позицию в файле перед сохранением # Сбрасываем позицию в файле перед сохранением
downloaded_file.seek(0) downloaded_file.seek(0)
# Сохраняем файл # Сохраняем файл
try: try:
with open(file_path, 'wb') as new_file: with open(file_path, "wb") as new_file:
new_file.write(downloaded_file.read()) new_file.write(downloaded_file.read())
except Exception as e: except Exception as e:
logger.error(f"Ошибка при записи файла на диск: {e}") logger.error(f"Ошибка при записи файла на диск: {e}")
raise FileOperationError(f"Не удалось записать файл на диск: {e}") raise FileOperationError(f"Не удалось записать файл на диск: {e}")
# Проверяем что файл действительно создался и имеет правильный размер # Проверяем что файл действительно создался и имеет правильный размер
if not os.path.exists(file_path): if not os.path.exists(file_path):
error_msg = f"Файл не был создан: {file_path}" error_msg = f"Файл не был создан: {file_path}"
logger.error(error_msg) logger.error(error_msg)
raise FileOperationError(error_msg) raise FileOperationError(error_msg)
saved_file_size = os.path.getsize(file_path) saved_file_size = os.path.getsize(file_path)
if saved_file_size != file_size: if saved_file_size != file_size:
error_msg = f"Размер сохраненного файла не совпадает: ожидалось {file_size}, получено {saved_file_size}" error_msg = f"Размер сохраненного файла не совпадает: ожидалось {file_size}, получено {saved_file_size}"
@@ -398,48 +435,62 @@ class AudioFileService:
except: except:
pass pass
raise FileOperationError(error_msg) raise FileOperationError(error_msg)
logger.info(f"Файл успешно сохранен: {file_path}, размер: {saved_file_size} bytes") logger.info(
f"Файл успешно сохранен: {file_path}, размер: {saved_file_size} bytes"
)
return # Успешное завершение return # Успешное завершение
except Exception as e: except Exception as e:
last_exception = e last_exception = e
logger.error(f"Попытка {attempt + 1}/{max_retries} неудачна: {e}") logger.error(f"Попытка {attempt + 1}/{max_retries} неудачна: {e}")
if attempt < max_retries - 1: if attempt < max_retries - 1:
wait_time = (attempt + 1) * 2 # Экспоненциальная задержка: 2, 4, 6 секунд wait_time = (
logger.info(f"Ожидание {wait_time} секунд перед следующей попыткой...") attempt + 1
) * 2 # Экспоненциальная задержка: 2, 4, 6 секунд
logger.info(
f"Ожидание {wait_time} секунд перед следующей попыткой..."
)
await asyncio.sleep(wait_time) await asyncio.sleep(wait_time)
else: else:
logger.error(f"Все {max_retries} попыток скачивания неудачны") logger.error(f"Все {max_retries} попыток скачивания неудачны")
logger.error(f"Traceback последней ошибки: {traceback.format_exc()}") logger.error(
f"Traceback последней ошибки: {traceback.format_exc()}"
)
# Если все попытки неудачны # Если все попытки неудачны
raise FileOperationError(f"Не удалось скачать и сохранить аудио после {max_retries} попыток. Последняя ошибка: {last_exception}") raise FileOperationError(
f"Не удалось скачать и сохранить аудио после {max_retries} попыток. Последняя ошибка: {last_exception}"
)
@track_time("verify_file_exists", "audio_file_service") @track_time("verify_file_exists", "audio_file_service")
@track_errors("audio_file_service", "verify_file_exists") @track_errors("audio_file_service", "verify_file_exists")
async def verify_file_exists(self, file_name: str) -> bool: async def verify_file_exists(self, file_name: str) -> bool:
"""Проверить существование и валидность файла""" """Проверить существование и валидность файла"""
try: try:
file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg' file_path = f"{VOICE_USERS_DIR}/{file_name}.ogg"
if not os.path.exists(file_path): if not os.path.exists(file_path):
logger.warning(f"Файл не существует: {file_path}") logger.warning(f"Файл не существует: {file_path}")
return False return False
file_size = os.path.getsize(file_path) file_size = os.path.getsize(file_path)
if file_size == 0: if file_size == 0:
logger.warning(f"Файл пустой: {file_path}") logger.warning(f"Файл пустой: {file_path}")
return False return False
if file_size < 100: # Минимальный размер для аудио файла if file_size < 100: # Минимальный размер для аудио файла
logger.warning(f"Файл слишком маленький: {file_path}, размер: {file_size} bytes") logger.warning(
f"Файл слишком маленький: {file_path}, размер: {file_size} bytes"
)
return False return False
logger.info(f"Файл проверен и валиден: {file_path}, размер: {file_size} bytes") logger.info(
f"Файл проверен и валиден: {file_path}, размер: {file_size} bytes"
)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Ошибка при проверке файла {file_name}: {e}") logger.error(f"Ошибка при проверке файла {file_name}: {e}")
return False return False

View File

@@ -18,31 +18,37 @@ def format_time_ago(date_from_db: str) -> Optional[str]:
last_voice_time_timestamp = time.mktime(parse_date.timetuple()) last_voice_time_timestamp = time.mktime(parse_date.timetuple())
time_now_timestamp = time.time() time_now_timestamp = time.time()
date_difference = time_now_timestamp - last_voice_time_timestamp date_difference = time_now_timestamp - last_voice_time_timestamp
# Считаем минуты, часы, дни # Считаем минуты, часы, дни
much_minutes_ago = round(date_difference / 60, 0) much_minutes_ago = round(date_difference / 60, 0)
much_hour_ago = round(date_difference / 3600, 0) much_hour_ago = round(date_difference / 3600, 0)
much_days_ago = int(round(much_hour_ago / 24, 0)) much_days_ago = int(round(much_hour_ago / 24, 0))
message_with_date = '' message_with_date = ""
if much_minutes_ago <= 60: if much_minutes_ago <= 60:
word_minute = plural_time(1, much_minutes_ago) word_minute = plural_time(1, much_minutes_ago)
# Экранируем потенциально проблемные символы # Экранируем потенциально проблемные символы
word_minute_escaped = html.escape(word_minute) word_minute_escaped = html.escape(word_minute)
message_with_date = f'<b>Последнее сообщение было записано {word_minute_escaped} назад</b>' message_with_date = (
f"<b>Последнее сообщение было записано {word_minute_escaped} назад</b>"
)
elif much_minutes_ago > 60 and much_hour_ago <= 24: elif much_minutes_ago > 60 and much_hour_ago <= 24:
word_hour = plural_time(2, much_hour_ago) word_hour = plural_time(2, much_hour_ago)
# Экранируем потенциально проблемные символы # Экранируем потенциально проблемные символы
word_hour_escaped = html.escape(word_hour) word_hour_escaped = html.escape(word_hour)
message_with_date = f'<b>Последнее сообщение было записано {word_hour_escaped} назад</b>' message_with_date = (
f"<b>Последнее сообщение было записано {word_hour_escaped} назад</b>"
)
elif much_hour_ago > 24: elif much_hour_ago > 24:
word_day = plural_time(3, much_days_ago) word_day = plural_time(3, much_days_ago)
# Экранируем потенциально проблемные символы # Экранируем потенциально проблемные символы
word_day_escaped = html.escape(word_day) word_day_escaped = html.escape(word_day)
message_with_date = f'<b>Последнее сообщение было записано {word_day_escaped} назад</b>' message_with_date = (
f"<b>Последнее сообщение было записано {word_day_escaped} назад</b>"
)
return message_with_date return message_with_date
except Exception as e: except Exception as e:
logger.error(f"Ошибка при форматировании времени: {e}") logger.error(f"Ошибка при форматировании времени: {e}")
return None return None
@@ -52,11 +58,11 @@ def plural_time(type: int, n: float) -> str:
"""Форматировать множественное число для времени""" """Форматировать множественное число для времени"""
word = [] word = []
if type == 1: if type == 1:
word = ['минуту', 'минуты', 'минут'] word = ["минуту", "минуты", "минут"]
elif type == 2: elif type == 2:
word = ['час', 'часа', 'часов'] word = ["час", "часа", "часов"]
elif type == 3: elif type == 3:
word = ['день', 'дня', 'дней'] word = ["день", "дня", "дней"]
else: else:
return str(int(n)) return str(int(n))
@@ -66,9 +72,10 @@ def plural_time(type: int, n: float) -> str:
p = 1 p = 1
else: else:
p = 2 p = 2
new_number = int(n) new_number = int(n)
return str(new_number) + ' ' + word[p] return str(new_number) + " " + word[p]
@track_time("get_last_message_text", "voice_utils") @track_time("get_last_message_text", "voice_utils")
@track_errors("voice_utils", "get_last_message_text") @track_errors("voice_utils", "get_last_message_text")
@@ -89,7 +96,8 @@ async def get_last_message_text(bot_db) -> Optional[str]:
async def validate_voice_message(message) -> bool: async def validate_voice_message(message) -> bool:
"""Проверить валидность голосового сообщения""" """Проверить валидность голосового сообщения"""
return message.content_type == 'voice' return message.content_type == "voice"
@track_time("get_user_emoji_safe", "voice_utils") @track_time("get_user_emoji_safe", "voice_utils")
@track_errors("voice_utils", "get_user_emoji_safe") @track_errors("voice_utils", "get_user_emoji_safe")
@@ -98,7 +106,11 @@ async def get_user_emoji_safe(bot_db, user_id: int) -> str:
"""Безопасно получить эмодзи пользователя""" """Безопасно получить эмодзи пользователя"""
try: try:
user_emoji = await bot_db.get_user_emoji(user_id) user_emoji = await bot_db.get_user_emoji(user_id)
return user_emoji if user_emoji and user_emoji != "Смайл еще не определен" else "😊" return (
user_emoji
if user_emoji and user_emoji != "Смайл еще не определен"
else "😊"
)
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении эмодзи пользователя {user_id}: {e}") logger.error(f"Ошибка при получении эмодзи пользователя {user_id}: {e}")
return "😊" return "😊"

View File

@@ -6,31 +6,44 @@ from aiogram import F, Router, types
from aiogram.filters import Command, MagicData, StateFilter from aiogram.filters import Command, MagicData, StateFilter
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import FSInputFile from aiogram.types import FSInputFile
from helper_bot.filters.main import ChatTypeFilter from helper_bot.filters.main import ChatTypeFilter
from helper_bot.handlers.private.constants import BUTTON_TEXTS, FSM_STATES from helper_bot.handlers.private.constants import BUTTON_TEXTS, FSM_STATES
from helper_bot.handlers.voice.constants import * from helper_bot.handlers.voice.constants import *
from helper_bot.handlers.voice.services import VoiceBotService from helper_bot.handlers.voice.services import VoiceBotService
from helper_bot.handlers.voice.utils import (get_last_message_text, from helper_bot.handlers.voice.utils import (
get_user_emoji_safe, get_last_message_text,
validate_voice_message) get_user_emoji_safe,
validate_voice_message,
)
from helper_bot.keyboards import get_reply_keyboard from helper_bot.keyboards import get_reply_keyboard
from helper_bot.keyboards.keyboards import (get_main_keyboard, from helper_bot.keyboards.keyboards import (
get_reply_keyboard_for_voice) get_main_keyboard,
get_reply_keyboard_for_voice,
)
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
from helper_bot.middlewares.dependencies_middleware import \ from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
DependenciesMiddleware
from helper_bot.utils import messages from helper_bot.utils import messages
from helper_bot.utils.helper_func import (check_user_emoji, get_first_name, from helper_bot.utils.helper_func import (
send_voice_message, update_user_info) check_user_emoji,
get_first_name,
send_voice_message,
update_user_info,
)
# Local imports - metrics # Local imports - metrics
from helper_bot.utils.metrics import (db_query_time, track_errors, from helper_bot.utils.metrics import (
track_file_operations, track_time) db_query_time,
track_errors,
track_file_operations,
track_time,
)
from logs.custom_logger import logger from logs.custom_logger import logger
class VoiceHandlers: class VoiceHandlers:
def __init__(self, db, settings): def __init__(self, db, settings):
self.db = db.get_db() if hasattr(db, 'get_db') else db self.db = db.get_db() if hasattr(db, "get_db") else db
self.settings = settings self.settings = settings
self.router = Router() self.router = Router()
self._setup_handlers() self._setup_handlers()
@@ -44,102 +57,114 @@ class VoiceHandlers:
self.router.message.register( self.router.message.register(
self.cancel_handler, self.cancel_handler,
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
F.text == "Отменить" F.text == "Отменить",
) )
# Обработчик кнопки "Голосовой бот" # Обработчик кнопки "Голосовой бот"
self.router.message.register( self.router.message.register(
self.voice_bot_button_handler, self.voice_bot_button_handler,
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["VOICE_BOT"] F.text == BUTTON_TEXTS["VOICE_BOT"],
) )
# Команды # Команды
self.router.message.register( self.router.message.register(
self.restart_function, self.restart_function,
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
Command(CMD_RESTART) Command(CMD_RESTART),
) )
self.router.message.register( self.router.message.register(
self.handle_emoji_message, self.handle_emoji_message,
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
Command(CMD_EMOJI) Command(CMD_EMOJI),
) )
self.router.message.register( self.router.message.register(
self.help_function, self.help_function, ChatTypeFilter(chat_type=["private"]), Command(CMD_HELP)
ChatTypeFilter(chat_type=["private"]),
Command(CMD_HELP)
) )
self.router.message.register( self.router.message.register(
self.start, self.start, ChatTypeFilter(chat_type=["private"]), Command(CMD_START)
ChatTypeFilter(chat_type=["private"]),
Command(CMD_START)
) )
# Дополнительные команды # Дополнительные команды
self.router.message.register( self.router.message.register(
self.refresh_listen_function, self.refresh_listen_function,
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
Command(CMD_REFRESH) Command(CMD_REFRESH),
) )
# Обработчики состояний и кнопок # Обработчики состояний и кнопок
self.router.message.register( self.router.message.register(
self.standup_write, self.standup_write,
StateFilter(STATE_START), StateFilter(STATE_START),
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
F.text == BTN_SPEAK F.text == BTN_SPEAK,
) )
self.router.message.register( self.router.message.register(
self.suggest_voice, self.suggest_voice,
StateFilter(STATE_STANDUP_WRITE), StateFilter(STATE_STANDUP_WRITE),
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
) )
self.router.message.register( self.router.message.register(
self.standup_listen_audio, self.standup_listen_audio,
StateFilter(STATE_START), StateFilter(STATE_START),
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
F.text == BTN_LISTEN F.text == BTN_LISTEN,
) )
# Новые обработчики кнопок # Новые обработчики кнопок
self.router.message.register( self.router.message.register(
self.refresh_listen_function, self.refresh_listen_function,
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
F.text == "🔄Сбросить прослушивания" F.text == "🔄Сбросить прослушивания",
) )
self.router.message.register( self.router.message.register(
self.handle_emoji_message, self.handle_emoji_message,
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
F.text == "😊Узнать эмодзи" F.text == "😊Узнать эмодзи",
) )
@track_time("voice_bot_button_handler", "voice_handlers") @track_time("voice_bot_button_handler", "voice_handlers")
@track_errors("voice_handlers", "voice_bot_button_handler") @track_errors("voice_handlers", "voice_bot_button_handler")
async def voice_bot_button_handler(self, message: types.Message, state: FSMContext, bot_db: MagicData("bot_db"), settings: MagicData("settings")): async def voice_bot_button_handler(
self,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings"),
):
"""Обработчик кнопки 'Голосовой бот' из основной клавиатуры""" """Обработчик кнопки 'Голосовой бот' из основной клавиатуры"""
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) нажал кнопку 'Голосовой бот'") logger.info(
f"Пользователь {message.from_user.id} ({message.from_user.full_name}) нажал кнопку 'Голосовой бот'"
)
try: try:
# Проверяем, получал ли пользователь приветственное сообщение # Проверяем, получал ли пользователь приветственное сообщение
welcome_received = await bot_db.check_voice_bot_welcome_received(message.from_user.id) welcome_received = await bot_db.check_voice_bot_welcome_received(
logger.info(f"Пользователь {message.from_user.id}: welcome_received = {welcome_received}") message.from_user.id
)
logger.info(
f"Пользователь {message.from_user.id}: welcome_received = {welcome_received}"
)
if welcome_received: if welcome_received:
# Если уже получал приветствие, вызываем restart_function # Если уже получал приветствие, вызываем restart_function
logger.info(f"Пользователь {message.from_user.id}: вызываем restart_function") logger.info(
f"Пользователь {message.from_user.id}: вызываем restart_function"
)
await self.restart_function(message, state, bot_db, settings) await self.restart_function(message, state, bot_db, settings)
else: else:
# Если не получал, вызываем start # Если не получал, вызываем start
logger.info(f"Пользователь {message.from_user.id}: вызываем start") logger.info(f"Пользователь {message.from_user.id}: вызываем start")
await self.start(message, state, bot_db, settings) await self.start(message, state, bot_db, settings)
except Exception as e: except Exception as e:
logger.error(f"Ошибка при проверке приветственного сообщения для пользователя {message.from_user.id}: {e}") logger.error(
f"Ошибка при проверке приветственного сообщения для пользователя {message.from_user.id}: {e}"
)
# В случае ошибки вызываем start # В случае ошибки вызываем start
await self.start(message, state, bot_db, settings) await self.start(message, state, bot_db, settings)
@@ -147,49 +172,49 @@ class VoiceHandlers:
@track_errors("voice_handlers", "restart_function") @track_errors("voice_handlers", "restart_function")
async def restart_function( async def restart_function(
self, self,
message: types.Message, message: types.Message,
state: FSMContext, state: FSMContext,
bot_db: MagicData("bot_db"), bot_db: MagicData("bot_db"),
settings: MagicData("settings") settings: MagicData("settings"),
): ):
logger.info(f"Пользователь {message.from_user.id}: вызывается функция restart_function") logger.info(
await message.forward(chat_id=settings['Telegram']['group_for_logs']) f"Пользователь {message.from_user.id}: вызывается функция restart_function"
)
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
await update_user_info(VOICE_BOT_NAME, message) await update_user_info(VOICE_BOT_NAME, message)
await check_user_emoji(message) await check_user_emoji(message)
markup = get_main_keyboard() markup = get_main_keyboard()
await message.answer(text='🎤 Записывайся или слушай!', reply_markup=markup) await message.answer(text="🎤 Записывайся или слушай!", reply_markup=markup)
await state.set_state(STATE_START) await state.set_state(STATE_START)
@track_time("handle_emoji_message", "voice_handlers") @track_time("handle_emoji_message", "voice_handlers")
@track_errors("voice_handlers", "handle_emoji_message") @track_errors("voice_handlers", "handle_emoji_message")
async def handle_emoji_message( async def handle_emoji_message(
self, self, message: types.Message, state: FSMContext, settings: MagicData("settings")
message: types.Message,
state: FSMContext,
settings: MagicData("settings")
): ):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) запросил информацию об эмодзи") logger.info(
await message.forward(chat_id=settings['Telegram']['group_for_logs']) f"Пользователь {message.from_user.id} ({message.from_user.full_name}) запросил информацию об эмодзи"
)
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
user_emoji = await check_user_emoji(message) user_emoji = await check_user_emoji(message)
await state.set_state(STATE_START) await state.set_state(STATE_START)
if user_emoji is not None: if user_emoji is not None:
await message.answer(f'Твоя эмодзя - {user_emoji}', parse_mode='HTML') await message.answer(f"Твоя эмодзя - {user_emoji}", parse_mode="HTML")
@track_time("help_function", "voice_handlers") @track_time("help_function", "voice_handlers")
@track_errors("voice_handlers", "help_function") @track_errors("voice_handlers", "help_function")
async def help_function( async def help_function(
self, self, message: types.Message, state: FSMContext, settings: MagicData("settings")
message: types.Message,
state: FSMContext,
settings: MagicData("settings")
): ):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию help_function") logger.info(
await message.forward(chat_id=settings['Telegram']['group_for_logs']) f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию help_function"
)
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
await update_user_info(VOICE_BOT_NAME, message) await update_user_info(VOICE_BOT_NAME, message)
help_message = messages.get_message(get_first_name(message), 'HELP_MESSAGE') help_message = messages.get_message(get_first_name(message), "HELP_MESSAGE")
await message.answer( await message.answer(
text=help_message, text=help_message,
disable_web_page_preview=not settings['Telegram']['preview_link'] disable_web_page_preview=not settings["Telegram"]["preview_link"],
) )
await state.set_state(STATE_START) await state.set_state(STATE_START)
@@ -198,43 +223,53 @@ class VoiceHandlers:
@db_query_time("mark_voice_bot_welcome_received", "audio_moderate", "update") @db_query_time("mark_voice_bot_welcome_received", "audio_moderate", "update")
async def start( async def start(
self, self,
message: types.Message, message: types.Message,
state: FSMContext, state: FSMContext,
bot_db: MagicData("bot_db"), bot_db: MagicData("bot_db"),
settings: MagicData("settings") settings: MagicData("settings"),
): ):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}): вызывается функция start") logger.info(
f"Пользователь {message.from_user.id} ({message.from_user.full_name}): вызывается функция start"
)
await state.set_state(STATE_START) await state.set_state(STATE_START)
await message.forward(chat_id=settings['Telegram']['group_for_logs']) await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
await update_user_info(VOICE_BOT_NAME, message) await update_user_info(VOICE_BOT_NAME, message)
user_emoji = await get_user_emoji_safe(bot_db, message.from_user.id) user_emoji = await get_user_emoji_safe(bot_db, message.from_user.id)
# Создаем сервис и отправляем приветственные сообщения # Создаем сервис и отправляем приветственные сообщения
voice_service = VoiceBotService(bot_db, settings) voice_service = VoiceBotService(bot_db, settings)
await voice_service.send_welcome_messages(message, user_emoji) await voice_service.send_welcome_messages(message, user_emoji)
logger.info(f"Приветственные сообщения отправлены пользователю {message.from_user.id}") logger.info(
f"Приветственные сообщения отправлены пользователю {message.from_user.id}"
)
# Отмечаем, что пользователь получил приветственное сообщение # Отмечаем, что пользователь получил приветственное сообщение
try: try:
await bot_db.mark_voice_bot_welcome_received(message.from_user.id) await bot_db.mark_voice_bot_welcome_received(message.from_user.id)
logger.info(f"Пользователь {message.from_user.id}: отмечен как получивший приветствие") logger.info(
f"Пользователь {message.from_user.id}: отмечен как получивший приветствие"
)
except Exception as e: except Exception as e:
logger.error(f"Ошибка при отметке получения приветствия для пользователя {message.from_user.id}: {e}") logger.error(
f"Ошибка при отметке получения приветствия для пользователя {message.from_user.id}: {e}"
)
@track_time("cancel_handler", "voice_handlers") @track_time("cancel_handler", "voice_handlers")
@track_errors("voice_handlers", "cancel_handler") @track_errors("voice_handlers", "cancel_handler")
async def cancel_handler( async def cancel_handler(
self, self,
message: types.Message, message: types.Message,
state: FSMContext, state: FSMContext,
bot_db: MagicData("bot_db"), bot_db: MagicData("bot_db"),
settings: MagicData("settings") settings: MagicData("settings"),
): ):
"""Обработчик кнопки 'Отменить' - возвращает в начальное состояние""" """Обработчик кнопки 'Отменить' - возвращает в начальное состояние"""
await message.forward(chat_id=settings['Telegram']['group_for_logs']) await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
await update_user_info(VOICE_BOT_NAME, message) await update_user_info(VOICE_BOT_NAME, message)
markup = await get_reply_keyboard(self.db, message.from_user.id) markup = await get_reply_keyboard(self.db, message.from_user.id)
await message.answer(text='Добро пожаловать в меню!', reply_markup=markup, parse_mode='HTML') await message.answer(
text="Добро пожаловать в меню!", reply_markup=markup, parse_mode="HTML"
)
await state.set_state(FSM_STATES["START"]) await state.set_state(FSM_STATES["START"])
logger.info(f"Пользователь {message.from_user.id} возвращен в главное меню") logger.info(f"Пользователь {message.from_user.id} возвращен в главное меню")
@@ -242,208 +277,253 @@ class VoiceHandlers:
@track_errors("voice_handlers", "refresh_listen_function") @track_errors("voice_handlers", "refresh_listen_function")
async def refresh_listen_function( async def refresh_listen_function(
self, self,
message: types.Message, message: types.Message,
state: FSMContext, state: FSMContext,
bot_db: MagicData("bot_db"), bot_db: MagicData("bot_db"),
settings: MagicData("settings") settings: MagicData("settings"),
): ):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию refresh_listen_function") logger.info(
await message.forward(chat_id=settings['Telegram']['group_for_logs']) f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию refresh_listen_function"
)
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
await update_user_info(VOICE_BOT_NAME, message) await update_user_info(VOICE_BOT_NAME, message)
markup = get_main_keyboard() markup = get_main_keyboard()
# Очищаем прослушивания через сервис # Очищаем прослушивания через сервис
voice_service = VoiceBotService(bot_db, settings) voice_service = VoiceBotService(bot_db, settings)
await voice_service.clear_user_listenings(message.from_user.id) await voice_service.clear_user_listenings(message.from_user.id)
listenings_cleared_message = messages.get_message(get_first_name(message), 'LISTENINGS_CLEARED_MESSAGE') listenings_cleared_message = messages.get_message(
get_first_name(message), "LISTENINGS_CLEARED_MESSAGE"
)
await message.answer( await message.answer(
text=listenings_cleared_message, text=listenings_cleared_message,
disable_web_page_preview=not settings['Telegram']['preview_link'], disable_web_page_preview=not settings["Telegram"]["preview_link"],
reply_markup=markup reply_markup=markup,
) )
await state.set_state(STATE_START) await state.set_state(STATE_START)
@track_time("standup_write", "voice_handlers") @track_time("standup_write", "voice_handlers")
@track_errors("voice_handlers", "standup_write") @track_errors("voice_handlers", "standup_write")
async def standup_write( async def standup_write(
self, self,
message: types.Message, message: types.Message,
state: FSMContext, state: FSMContext,
bot_db: MagicData("bot_db"), bot_db: MagicData("bot_db"),
settings: MagicData("settings") settings: MagicData("settings"),
): ):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию standup_write") logger.info(
await message.forward(chat_id=settings['Telegram']['group_for_logs']) f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию standup_write"
)
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
markup = types.ReplyKeyboardRemove() markup = types.ReplyKeyboardRemove()
record_voice_message = messages.get_message(get_first_name(message), 'RECORD_VOICE_MESSAGE') record_voice_message = messages.get_message(
get_first_name(message), "RECORD_VOICE_MESSAGE"
)
await message.answer(text=record_voice_message, reply_markup=markup) await message.answer(text=record_voice_message, reply_markup=markup)
try: try:
message_with_date = await get_last_message_text(bot_db) message_with_date = await get_last_message_text(bot_db)
if message_with_date: if message_with_date:
await message.answer(text=message_with_date, parse_mode="html") await message.answer(text=message_with_date, parse_mode="html")
except Exception as e: except Exception as e:
logger.error(f'Не удалось получить дату последнего сообщения для пользователя {message.from_user.id}: {e}') logger.error(
f"Не удалось получить дату последнего сообщения для пользователя {message.from_user.id}: {e}"
await state.set_state(STATE_STANDUP_WRITE) )
await state.set_state(STATE_STANDUP_WRITE)
@track_time("suggest_voice", "voice_handlers") @track_time("suggest_voice", "voice_handlers")
@track_errors("voice_handlers", "suggest_voice") @track_errors("voice_handlers", "suggest_voice")
async def suggest_voice( async def suggest_voice(
self, self,
message: types.Message, message: types.Message,
state: FSMContext, state: FSMContext,
bot_db: MagicData("bot_db"), bot_db: MagicData("bot_db"),
settings: MagicData("settings") settings: MagicData("settings"),
): ):
logger.info( logger.info(
f"Вызов функции suggest_voice. Пользователь: {message.from_user.id} Имя автора сообщения: {message.from_user.full_name}" f"Вызов функции suggest_voice. Пользователь: {message.from_user.id} Имя автора сообщения: {message.from_user.full_name}"
) )
await message.forward(chat_id=settings['Telegram']['group_for_logs']) await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
markup = get_main_keyboard() markup = get_main_keyboard()
if await validate_voice_message(message): if await validate_voice_message(message):
markup_for_voice = get_reply_keyboard_for_voice() markup_for_voice = get_reply_keyboard_for_voice()
# Отправляем аудио в приватный канал # Отправляем аудио в приватный канал
sent_message = await send_voice_message( sent_message = await send_voice_message(
settings['Telegram']['group_for_posts'], settings["Telegram"]["group_for_posts"],
message, message,
message.voice.file_id, message.voice.file_id,
markup_for_voice markup_for_voice,
)
logger.info(
f"Голосовое сообщение пользователя {message.from_user.id} отправлено в группу постов (message_id: {sent_message.message_id})"
) )
logger.info(f"Голосовое сообщение пользователя {message.from_user.id} отправлено в группу постов (message_id: {sent_message.message_id})")
# Сохраняем в базу инфо о посте # Сохраняем в базу инфо о посте
await bot_db.set_user_id_and_message_id_for_voice_bot(sent_message.message_id, message.from_user.id) await bot_db.set_user_id_and_message_id_for_voice_bot(
sent_message.message_id, message.from_user.id
)
# Отправляем юзеру ответ и возвращаем его в меню # Отправляем юзеру ответ и возвращаем его в меню
voice_saved_message = messages.get_message(get_first_name(message), 'VOICE_SAVED_MESSAGE') voice_saved_message = messages.get_message(
get_first_name(message), "VOICE_SAVED_MESSAGE"
)
await message.answer(text=voice_saved_message, reply_markup=markup) await message.answer(text=voice_saved_message, reply_markup=markup)
await state.set_state(STATE_START) await state.set_state(STATE_START)
else: else:
logger.warning(f"Голосовое сообщение пользователя {message.from_user.id} не прошло валидацию") logger.warning(
unknown_content_message = messages.get_message(get_first_name(message), 'UNKNOWN_CONTENT_MESSAGE') f"Голосовое сообщение пользователя {message.from_user.id} не прошло валидацию"
await message.forward(chat_id=settings['Telegram']['group_for_logs']) )
unknown_content_message = messages.get_message(
get_first_name(message), "UNKNOWN_CONTENT_MESSAGE"
)
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
await message.answer(text=unknown_content_message, reply_markup=markup) await message.answer(text=unknown_content_message, reply_markup=markup)
await state.set_state(STATE_STANDUP_WRITE) await state.set_state(STATE_STANDUP_WRITE)
@track_time("standup_listen_audio", "voice_handlers") @track_time("standup_listen_audio", "voice_handlers")
@track_errors("voice_handlers", "standup_listen_audio") @track_errors("voice_handlers", "standup_listen_audio")
@track_file_operations("voice") @track_file_operations("voice")
@db_query_time("standup_listen_audio", "audio_moderate", "mixed") @db_query_time("standup_listen_audio", "audio_moderate", "mixed")
async def standup_listen_audio( async def standup_listen_audio(
self, self,
message: types.Message, message: types.Message,
bot_db: MagicData("bot_db"), bot_db: MagicData("bot_db"),
settings: MagicData("settings") settings: MagicData("settings"),
): ):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) запросил прослушивание аудио") logger.info(
f"Пользователь {message.from_user.id} ({message.from_user.full_name}) запросил прослушивание аудио"
)
markup = get_main_keyboard() markup = get_main_keyboard()
# Создаем сервис для работы с аудио # Создаем сервис для работы с аудио
voice_service = VoiceBotService(bot_db, settings) voice_service = VoiceBotService(bot_db, settings)
try: try:
#TODO: удалить логику из хендлера # TODO: удалить логику из хендлера
# Получаем случайное аудио # Получаем случайное аудио
audio_data = await voice_service.get_random_audio(message.from_user.id) audio_data = await voice_service.get_random_audio(message.from_user.id)
if not audio_data: if not audio_data:
logger.warning(f"Для пользователя {message.from_user.id} не найдено доступных аудио для прослушивания") logger.warning(
no_audio_message = messages.get_message(get_first_name(message), 'NO_AUDIO_MESSAGE') f"Для пользователя {message.from_user.id} не найдено доступных аудио для прослушивания"
)
no_audio_message = messages.get_message(
get_first_name(message), "NO_AUDIO_MESSAGE"
)
await message.answer(text=no_audio_message, reply_markup=markup) await message.answer(text=no_audio_message, reply_markup=markup)
try: try:
message_with_date = await get_last_message_text(bot_db) message_with_date = await get_last_message_text(bot_db)
if message_with_date: if message_with_date:
await message.answer(text=message_with_date, parse_mode="html") await message.answer(text=message_with_date, parse_mode="html")
except Exception as e: except Exception as e:
logger.error(f'Не удалось получить последнюю дату для пользователя {message.from_user.id}: {e}') logger.error(
f"Не удалось получить последнюю дату для пользователя {message.from_user.id}: {e}"
)
return return
audio_for_user, date_added, user_emoji = audio_data audio_for_user, date_added, user_emoji = audio_data
# Получаем путь к файлу # Получаем путь к файлу
path = Path(f'{VOICE_USERS_DIR}/{audio_for_user}.ogg') path = Path(f"{VOICE_USERS_DIR}/{audio_for_user}.ogg")
# Проверяем существование файла # Проверяем существование файла
if not path.exists(): if not path.exists():
logger.error(f"Файл не найден: {path} для пользователя {message.from_user.id}") logger.error(
f"Файл не найден: {path} для пользователя {message.from_user.id}"
)
# Дополнительная диагностика # Дополнительная диагностика
logger.error(f"Директория {VOICE_USERS_DIR} существует: {Path(VOICE_USERS_DIR).exists()}") logger.error(
f"Директория {VOICE_USERS_DIR} существует: {Path(VOICE_USERS_DIR).exists()}"
)
if Path(VOICE_USERS_DIR).exists(): if Path(VOICE_USERS_DIR).exists():
files_in_dir = list(Path(VOICE_USERS_DIR).glob("*.ogg")) files_in_dir = list(Path(VOICE_USERS_DIR).glob("*.ogg"))
logger.error(f"Файлы в директории: {[f.name for f in files_in_dir]}") logger.error(
f"Файлы в директории: {[f.name for f in files_in_dir]}"
)
await message.answer( await message.answer(
text="Файл аудио не найден. Обратитесь к администратору.", text="Файл аудио не найден. Обратитесь к администратору.",
reply_markup=markup reply_markup=markup,
) )
return return
# Проверяем размер файла # Проверяем размер файла
if path.stat().st_size == 0: if path.stat().st_size == 0:
logger.error(f"Файл пустой: {path} для пользователя {message.from_user.id}") logger.error(
f"Файл пустой: {path} для пользователя {message.from_user.id}"
)
await message.answer( await message.answer(
text="Файл аудио поврежден. Обратитесь к администратору.", text="Файл аудио поврежден. Обратитесь к администратору.",
reply_markup=markup reply_markup=markup,
) )
return return
voice = FSInputFile(path) voice = FSInputFile(path)
# Формируем подпись # Формируем подпись
if user_emoji: if user_emoji:
caption = f'{user_emoji}\nДата записи: {date_added}' caption = f"{user_emoji}\nДата записи: {date_added}"
else: else:
caption = f'Дата записи: {date_added}' caption = f"Дата записи: {date_added}"
logger.info(f"Подготовлено голосовое сообщение для пользователя {message.from_user.id}: {caption}") logger.info(
f"Подготовлено голосовое сообщение для пользователя {message.from_user.id}: {caption}"
)
try: try:
from helper_bot.utils.rate_limiter import send_with_rate_limit from helper_bot.utils.rate_limiter import send_with_rate_limit
async def _send_voice(): async def _send_voice():
return await message.bot.send_voice( return await message.bot.send_voice(
chat_id=message.chat.id, chat_id=message.chat.id,
voice=voice, voice=voice,
caption=caption, caption=caption,
reply_markup=markup reply_markup=markup,
) )
await send_with_rate_limit(_send_voice, message.chat.id) await send_with_rate_limit(_send_voice, message.chat.id)
# Маркируем сообщение как прослушанное только после успешной отправки # Маркируем сообщение как прослушанное только после успешной отправки
await voice_service.mark_audio_as_listened(audio_for_user, message.from_user.id) await voice_service.mark_audio_as_listened(
audio_for_user, message.from_user.id
# Получаем количество оставшихся аудио только после успешной отправки
remaining_count = await voice_service.get_remaining_audio_count(message.from_user.id)
await message.answer(
text=f'Осталось непрослушанных: <b>{remaining_count}</b>',
reply_markup=markup
) )
# Получаем количество оставшихся аудио только после успешной отправки
remaining_count = await voice_service.get_remaining_audio_count(
message.from_user.id
)
await message.answer(
text=f"Осталось непрослушанных: <b>{remaining_count}</b>",
reply_markup=markup,
)
except Exception as voice_error: except Exception as voice_error:
if "VOICE_MESSAGES_FORBIDDEN" in str(voice_error): if "VOICE_MESSAGES_FORBIDDEN" in str(voice_error):
# Если голосовые сообщения запрещены, отправляем информативное сообщение # Если голосовые сообщения запрещены, отправляем информативное сообщение
logger.warning(f"Пользователь {message.from_user.id} запретил получение голосовых сообщений") logger.warning(
f"Пользователь {message.from_user.id} запретил получение голосовых сообщений"
)
privacy_message = "🔇 К сожалению, у тебя закрыт доступ к получению голосовых сообщений.\n\nДля продолжения взаимодействия с ботом необходимо дать возможность мне присылать войсы в настройках приватности Telegram.\n\n💡 Как это сделать:\n1. Открой настройки Telegram\n2. Перейди в 'Конфиденциальность и безопасность'\n3. Выбери 'Голосовые сообщения'\n4. Разреши получение от 'Всех' или добавь меня в исключения" privacy_message = "🔇 К сожалению, у тебя закрыт доступ к получению голосовых сообщений.\n\nДля продолжения взаимодействия с ботом необходимо дать возможность мне присылать войсы в настройках приватности Telegram.\n\n💡 Как это сделать:\n1. Открой настройки Telegram\n2. Перейди в 'Конфиденциальность и безопасность'\n3. Выбери 'Голосовые сообщения'\n4. Разреши получение от 'Всех' или добавь меня в исключения"
await message.answer(text=privacy_message, reply_markup=markup) await message.answer(text=privacy_message, reply_markup=markup)
return # Выходим без записи о прослушивании return # Выходим без записи о прослушивании
else: else:
logger.error(f"Ошибка при отправке голосового сообщения пользователю {message.from_user.id}: {voice_error}") logger.error(
f"Ошибка при отправке голосового сообщения пользователю {message.from_user.id}: {voice_error}"
)
raise voice_error raise voice_error
except Exception as e: except Exception as e:
logger.error(f"Ошибка при прослушивании аудио для пользователя {message.from_user.id}: {e}") logger.error(
f"Ошибка при прослушивании аудио для пользователя {message.from_user.id}: {e}"
)
await message.answer( await message.answer(
text="Произошла ошибка при получении аудио. Попробуйте позже.", text="Произошла ошибка при получении аудио. Попробуйте позже.",
reply_markup=markup reply_markup=markup,
) )

View File

@@ -1,24 +1,21 @@
from aiogram import types from aiogram import types
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
# Local imports - metrics # Local imports - metrics
from helper_bot.utils.metrics import track_errors, track_time from helper_bot.utils.metrics import track_errors, track_time
def get_reply_keyboard_for_post(): def get_reply_keyboard_for_post():
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.row(types.InlineKeyboardButton( builder.row(
text="Опубликовать", callback_data="publish"), types.InlineKeyboardButton(text="Опубликовать", callback_data="publish"),
types.InlineKeyboardButton( types.InlineKeyboardButton(text="Отклонить", callback_data="decline"),
text="Отклонить", callback_data="decline")
)
builder.row(types.InlineKeyboardButton(
text="👮‍♂️ Забанить", callback_data="ban")
) )
builder.row(types.InlineKeyboardButton(text="👮‍♂️ Забанить", callback_data="ban"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True) markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup return markup
async def get_reply_keyboard(db, user_id): async def get_reply_keyboard(db, user_id):
builder = ReplyKeyboardBuilder() builder = ReplyKeyboardBuilder()
builder.row(types.KeyboardButton(text="📢Предложить свой пост")) builder.row(types.KeyboardButton(text="📢Предложить свой пост"))
@@ -43,21 +40,22 @@ def get_reply_keyboard_admin():
builder.row( builder.row(
types.KeyboardButton(text="Бан (Список)"), types.KeyboardButton(text="Бан (Список)"),
types.KeyboardButton(text="Бан по нику"), types.KeyboardButton(text="Бан по нику"),
types.KeyboardButton(text="Бан по ID") types.KeyboardButton(text="Бан по ID"),
) )
builder.row( builder.row(
types.KeyboardButton(text="Разбан (список)"), types.KeyboardButton(text="Разбан (список)"),
types.KeyboardButton(text="📊 ML Статистика") types.KeyboardButton(text="📊 ML Статистика"),
)
builder.row(
types.KeyboardButton(text="Вернуться в бота")
) )
builder.row(types.KeyboardButton(text="Вернуться в бота"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True) markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup return markup
@track_time("create_keyboard_with_pagination", "keyboard_service") @track_time("create_keyboard_with_pagination", "keyboard_service")
@track_errors("keyboard_service", "create_keyboard_with_pagination") @track_errors("keyboard_service", "create_keyboard_with_pagination")
def create_keyboard_with_pagination(page: int, total_items: int, array_items: list, callback: str): def create_keyboard_with_pagination(
page: int, total_items: int, array_items: list, callback: str
):
""" """
Создает клавиатуру с пагинацией для заданного набора элементов и устанавливает необходимый callback Создает клавиатуру с пагинацией для заданного набора элементов и устанавливает необходимый callback
@@ -70,74 +68,79 @@ def create_keyboard_with_pagination(page: int, total_items: int, array_items: li
Returns: Returns:
InlineKeyboardMarkup: Клавиатура с кнопками пагинации. InlineKeyboardMarkup: Клавиатура с кнопками пагинации.
""" """
# Проверяем валидность входных данных # Проверяем валидность входных данных
if page < 1: if page < 1:
page = 1 page = 1
if not array_items: if not array_items:
# Если нет элементов, возвращаем только кнопку "Назад" # Если нет элементов, возвращаем только кнопку "Назад"
keyboard = InlineKeyboardBuilder() keyboard = InlineKeyboardBuilder()
home_button = types.InlineKeyboardButton(text="🏠 Назад", callback_data="return") home_button = types.InlineKeyboardButton(
text="🏠 Назад", callback_data="return"
)
keyboard.row(home_button) keyboard.row(home_button)
return keyboard.as_markup() return keyboard.as_markup()
# Определяем общее количество страниц # Определяем общее количество страниц
items_per_page = 9 items_per_page = 9
total_pages = (total_items + items_per_page - 1) // items_per_page total_pages = (total_items + items_per_page - 1) // items_per_page
# Ограничиваем страницу максимальным значением # Ограничиваем страницу максимальным значением
if page > total_pages: if page > total_pages:
page = total_pages page = total_pages
# Создаем билдер для клавиатуры # Создаем билдер для клавиатуры
keyboard = InlineKeyboardBuilder() keyboard = InlineKeyboardBuilder()
# Вычисляем стартовый номер для текущей страницы # Вычисляем стартовый номер для текущей страницы
start_index = (page - 1) * items_per_page start_index = (page - 1) * items_per_page
# Кнопки с элементами текущей страницы # Кнопки с элементами текущей страницы
end_index = min(start_index + items_per_page, len(array_items)) end_index = min(start_index + items_per_page, len(array_items))
current_row = [] current_row = []
for i in range(start_index, end_index): for i in range(start_index, end_index):
current_row.append(types.InlineKeyboardButton( current_row.append(
text=f"{array_items[i][0]}", callback_data=f"{callback}_{array_items[i][1]}" types.InlineKeyboardButton(
)) text=f"{array_items[i][0]}",
callback_data=f"{callback}_{array_items[i][1]}",
)
)
# Когда набирается 3 кнопки, добавляем ряд # Когда набирается 3 кнопки, добавляем ряд
if len(current_row) == 3: if len(current_row) == 3:
keyboard.row(*current_row) keyboard.row(*current_row)
current_row = [] current_row = []
# Добавляем оставшиеся кнопки, если они есть # Добавляем оставшиеся кнопки, если они есть
if current_row: if current_row:
keyboard.row(*current_row) keyboard.row(*current_row)
# Создаем кнопки навигации только если нужно # Создаем кнопки навигации только если нужно
navigation_buttons = [] navigation_buttons = []
# Кнопка "Предыдущая" - показываем только если не первая страница # Кнопка "Предыдущая" - показываем только если не первая страница
if page > 1: if page > 1:
prev_button = types.InlineKeyboardButton( prev_button = types.InlineKeyboardButton(
text="⬅️ Предыдущая", callback_data=f"page_{page - 1}" text="⬅️ Предыдущая", callback_data=f"page_{page - 1}"
) )
navigation_buttons.append(prev_button) navigation_buttons.append(prev_button)
# Кнопка "Следующая" - показываем только если не последняя страница # Кнопка "Следующая" - показываем только если не последняя страница
if page < total_pages: if page < total_pages:
next_button = types.InlineKeyboardButton( next_button = types.InlineKeyboardButton(
text="➡️ Следующая", callback_data=f"page_{page + 1}" text="➡️ Следующая", callback_data=f"page_{page + 1}"
) )
navigation_buttons.append(next_button) navigation_buttons.append(next_button)
# Добавляем кнопки навигации, если они есть # Добавляем кнопки навигации, если они есть
if navigation_buttons: if navigation_buttons:
keyboard.row(*navigation_buttons) keyboard.row(*navigation_buttons)
# Кнопка "Назад" # Кнопка "Назад"
home_button = types.InlineKeyboardButton(text="🏠 Назад", callback_data="return") home_button = types.InlineKeyboardButton(text="🏠 Назад", callback_data="return")
keyboard.row(home_button) keyboard.row(home_button)
return keyboard.as_markup() return keyboard.as_markup()
@@ -146,7 +149,11 @@ def create_keyboard_for_ban_reason():
builder.add(types.KeyboardButton(text="Спам")) builder.add(types.KeyboardButton(text="Спам"))
builder.add(types.KeyboardButton(text="Заебал стикерами")) builder.add(types.KeyboardButton(text="Заебал стикерами"))
builder.row(types.KeyboardButton(text="Реклама здесь: @kerrad1 ")) builder.row(types.KeyboardButton(text="Реклама здесь: @kerrad1 "))
builder.row(types.KeyboardButton(text="Тема с лагерями: https://vk.com/topic-75343895_50049913")) builder.row(
types.KeyboardButton(
text="Тема с лагерями: https://vk.com/topic-75343895_50049913"
)
)
builder.row(types.KeyboardButton(text="Отменить")) builder.row(types.KeyboardButton(text="Отменить"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True) markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup return markup
@@ -176,12 +183,12 @@ def get_main_keyboard():
# Первая строка: Высказаться и послушать # Первая строка: Высказаться и послушать
builder.row( builder.row(
types.KeyboardButton(text="🎤Высказаться"), types.KeyboardButton(text="🎤Высказаться"),
types.KeyboardButton(text="🎧Послушать") types.KeyboardButton(text="🎧Послушать"),
) )
# Вторая строка: сбросить прослушивания и узнать эмодзи # Вторая строка: сбросить прослушивания и узнать эмодзи
builder.row( builder.row(
types.KeyboardButton(text="🔄Сбросить прослушивания"), types.KeyboardButton(text="🔄Сбросить прослушивания"),
types.KeyboardButton(text="😊Узнать эмодзи") types.KeyboardButton(text="😊Узнать эмодзи"),
) )
# Третья строка: Вернуться в меню # Третья строка: Вернуться в меню
builder.row(types.KeyboardButton(text="Отменить")) builder.row(types.KeyboardButton(text="Отменить"))
@@ -191,11 +198,7 @@ def get_main_keyboard():
def get_reply_keyboard_for_voice(): def get_reply_keyboard_for_voice():
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.row(types.InlineKeyboardButton( builder.row(types.InlineKeyboardButton(text="Сохранить", callback_data="save"))
text="Сохранить", callback_data="save") builder.row(types.InlineKeyboardButton(text="Удалить", callback_data="delete"))
)
builder.row(types.InlineKeyboardButton(
text="Удалить", callback_data="delete")
)
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True) markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup return markup

View File

@@ -6,22 +6,25 @@ from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.strategy import FSMStrategy from aiogram.fsm.strategy import FSMStrategy
from helper_bot.handlers.admin import admin_router from helper_bot.handlers.admin import admin_router
from helper_bot.handlers.callback import callback_router from helper_bot.handlers.callback import callback_router
from helper_bot.handlers.group import group_router from helper_bot.handlers.group import group_router
from helper_bot.handlers.private import private_router from helper_bot.handlers.private import private_router
from helper_bot.handlers.voice import VoiceHandlers from helper_bot.handlers.voice import VoiceHandlers
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
from helper_bot.middlewares.dependencies_middleware import \ from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
DependenciesMiddleware from helper_bot.middlewares.metrics_middleware import (
from helper_bot.middlewares.metrics_middleware import (ErrorMetricsMiddleware, ErrorMetricsMiddleware,
MetricsMiddleware) MetricsMiddleware,
)
from helper_bot.middlewares.rate_limit_middleware import RateLimitMiddleware from helper_bot.middlewares.rate_limit_middleware import RateLimitMiddleware
from helper_bot.server_prometheus import (start_metrics_server, from helper_bot.server_prometheus import start_metrics_server, stop_metrics_server
stop_metrics_server)
async def start_bot_with_retry(bot: Bot, dp: Dispatcher, max_retries: int = 5, base_delay: float = 1.0): async def start_bot_with_retry(
bot: Bot, dp: Dispatcher, max_retries: int = 5, base_delay: float = 1.0
):
"""Запуск бота с автоматическим перезапуском при сетевых ошибках""" """Запуск бота с автоматическим перезапуском при сетевых ошибках"""
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
@@ -30,14 +33,21 @@ async def start_bot_with_retry(bot: Bot, dp: Dispatcher, max_retries: int = 5, b
break break
except Exception as e: except Exception as e:
error_msg = str(e).lower() error_msg = str(e).lower()
if any(keyword in error_msg for keyword in ['network', 'disconnected', 'timeout', 'connection']): if any(
keyword in error_msg
for keyword in ["network", "disconnected", "timeout", "connection"]
):
if attempt < max_retries - 1: if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt) # Exponential backoff delay = base_delay * (2**attempt) # Exponential backoff
logging.warning(f"Сетевая ошибка при запуске бота: {e}. Повтор через {delay:.1f}с (попытка {attempt + 1}/{max_retries})") logging.warning(
f"Сетевая ошибка при запуске бота: {e}. Повтор через {delay:.1f}с (попытка {attempt + 1}/{max_retries})"
)
await asyncio.sleep(delay) await asyncio.sleep(delay)
continue continue
else: else:
logging.error(f"Превышено максимальное количество попыток запуска бота: {e}") logging.error(
f"Превышено максимальное количество попыток запуска бота: {e}"
)
raise raise
else: else:
logging.error(f"Критическая ошибка при запуске бота: {e}") logging.error(f"Критическая ошибка при запуске бота: {e}")
@@ -45,30 +55,36 @@ async def start_bot_with_retry(bot: Bot, dp: Dispatcher, max_retries: int = 5, b
async def start_bot(bdf): async def start_bot(bdf):
token = bdf.settings['Telegram']['bot_token'] token = bdf.settings["Telegram"]["bot_token"]
bot = Bot(token=token, default=DefaultBotProperties( bot = Bot(
parse_mode='HTML', token=token,
link_preview_is_disabled=bdf.settings['Telegram']['preview_link'] default=DefaultBotProperties(
), timeout=60.0) # Увеличиваем timeout для стабильности parse_mode="HTML",
link_preview_is_disabled=bdf.settings["Telegram"]["preview_link"],
),
timeout=60.0,
) # Увеличиваем timeout для стабильности
dp = Dispatcher(storage=MemoryStorage(), fsm_strategy=FSMStrategy.GLOBAL_USER) dp = Dispatcher(storage=MemoryStorage(), fsm_strategy=FSMStrategy.GLOBAL_USER)
# ✅ Оптимизированная регистрация middleware # ✅ Оптимизированная регистрация middleware
dp.update.outer_middleware(DependenciesMiddleware()) dp.update.outer_middleware(DependenciesMiddleware())
dp.update.outer_middleware(MetricsMiddleware()) dp.update.outer_middleware(MetricsMiddleware())
dp.update.outer_middleware(BlacklistMiddleware()) dp.update.outer_middleware(BlacklistMiddleware())
dp.update.outer_middleware(RateLimitMiddleware()) dp.update.outer_middleware(RateLimitMiddleware())
# Создаем экземпляр VoiceHandlers # Создаем экземпляр VoiceHandlers
voice_handlers = VoiceHandlers(bdf, bdf.settings) voice_handlers = VoiceHandlers(bdf, bdf.settings)
voice_router = voice_handlers.router voice_router = voice_handlers.router
# Middleware уже добавлены на уровне dispatcher # Middleware уже добавлены на уровне dispatcher
dp.include_routers(admin_router, private_router, callback_router, group_router, voice_router) dp.include_routers(
admin_router, private_router, callback_router, group_router, voice_router
)
# Получаем scoring_manager для использования в shutdown # Получаем scoring_manager для использования в shutdown
scoring_manager = bdf.get_scoring_manager() scoring_manager = bdf.get_scoring_manager()
# Добавляем обработчик завершения для корректного закрытия # Добавляем обработчик завершения для корректного закрытия
@dp.shutdown() @dp.shutdown()
async def on_shutdown(): async def on_shutdown():
@@ -81,25 +97,25 @@ async def start_bot(bdf):
logging.info("ScoringManager закрыт") logging.info("ScoringManager закрыт")
except Exception as e: except Exception as e:
logging.error(f"Ошибка закрытия ScoringManager: {e}") logging.error(f"Ошибка закрытия ScoringManager: {e}")
await bot.session.close() await bot.session.close()
logging.info("Bot session closed successfully") logging.info("Bot session closed successfully")
except Exception as e: except Exception as e:
logging.error(f"Error closing bot session during shutdown: {e}") logging.error(f"Error closing bot session during shutdown: {e}")
await bot.delete_webhook(drop_pending_updates=True) await bot.delete_webhook(drop_pending_updates=True)
# Запускаем HTTP сервер для метрик параллельно с ботом # Запускаем HTTP сервер для метрик параллельно с ботом
metrics_host = bdf.settings.get('Metrics', {}).get('host', '0.0.0.0') metrics_host = bdf.settings.get("Metrics", {}).get("host", "0.0.0.0")
metrics_port = bdf.settings.get('Metrics', {}).get('port', 8080) metrics_port = bdf.settings.get("Metrics", {}).get("port", 8080)
try: try:
# Запускаем метрики сервер # Запускаем метрики сервер
await start_metrics_server(metrics_host, metrics_port) await start_metrics_server(metrics_host, metrics_port)
logging.info(f"✅ Метрики сервер запущен на {metrics_host}:{metrics_port}") logging.info(f"✅ Метрики сервер запущен на {metrics_host}:{metrics_port}")
logging.info("✅ Метрики будут обновляться в реальном времени через middleware") logging.info("✅ Метрики будут обновляться в реальном времени через middleware")
# Запускаем бота с retry логикой # Запускаем бота с retry логикой
await start_bot_with_retry(bot, dp) await start_bot_with_retry(bot, dp)
@@ -115,13 +131,13 @@ async def start_bot(bdf):
logging.info("ScoringManager закрыт в finally") logging.info("ScoringManager закрыт в finally")
except Exception as e: except Exception as e:
logging.error(f"Ошибка закрытия ScoringManager в finally: {e}") logging.error(f"Ошибка закрытия ScoringManager в finally: {e}")
# Останавливаем метрики сервер при завершении # Останавливаем метрики сервер при завершении
try: try:
await stop_metrics_server() await stop_metrics_server()
except Exception as e: except Exception as e:
logging.error(f"Error stopping metrics server: {e}") logging.error(f"Error stopping metrics server: {e}")
# Закрываем сессию бота # Закрываем сессию бота
try: try:
await bot.session.close() await bot.session.close()

View File

@@ -7,19 +7,21 @@ from aiogram.types import Message
class AlbumGetter: class AlbumGetter:
"""Вспомогательный класс для получения полной медиагруппы из middleware""" """Вспомогательный класс для получения полной медиагруппы из middleware"""
def __init__(self, album_data: Dict[str, Any], media_group_id: str, event: asyncio.Event): def __init__(
self, album_data: Dict[str, Any], media_group_id: str, event: asyncio.Event
):
self.album_data = album_data self.album_data = album_data
self.media_group_id = media_group_id self.media_group_id = media_group_id
self.event = event self.event = event
async def get_album(self, timeout: float = 10.0) -> Optional[List[Message]]: async def get_album(self, timeout: float = 10.0) -> Optional[List[Message]]:
""" """
Ждет полную медиагруппу и возвращает ее. Ждет полную медиагруппу и возвращает ее.
Args: Args:
timeout: Максимальное время ожидания в секундах timeout: Максимальное время ожидания в секундах
Returns: Returns:
Список сообщений медиагруппы или None при таймауте Список сообщений медиагруппы или None при таймауте
""" """
@@ -38,11 +40,11 @@ class AlbumMiddleware(BaseMiddleware):
Собирает все сообщения одной медиа группы и передает их как album в data. Собирает все сообщения одной медиа группы и передает их как album в data.
Не блокирует handler - сразу вызывает его, а полную медиагруппу передает через Event. Не блокирует handler - сразу вызывает его, а полную медиагруппу передает через Event.
""" """
def __init__(self, latency: Union[int, float] = 5.0): def __init__(self, latency: Union[int, float] = 5.0):
""" """
Инициализация middleware. Инициализация middleware.
Args: Args:
latency: Задержка в секундах для сбора всех сообщений медиа группы latency: Задержка в секундах для сбора всех сообщений медиа группы
""" """
@@ -54,43 +56,43 @@ class AlbumMiddleware(BaseMiddleware):
def collect_album_messages(self, event: Message) -> int: def collect_album_messages(self, event: Message) -> int:
""" """
Собирает сообщения одной медиа группы. Собирает сообщения одной медиа группы.
Args: Args:
event: Сообщение для обработки event: Сообщение для обработки
Returns: Returns:
Количество сообщений в текущей медиа группе Количество сообщений в текущей медиа группе
""" """
if not event.media_group_id: if not event.media_group_id:
return 0 return 0
if event.media_group_id not in self.album_data: if event.media_group_id not in self.album_data:
self.album_data[event.media_group_id] = {"messages": []} self.album_data[event.media_group_id] = {"messages": []}
self.album_data[event.media_group_id]["messages"].append(event) self.album_data[event.media_group_id]["messages"].append(event)
return len(self.album_data[event.media_group_id]["messages"]) return len(self.album_data[event.media_group_id]["messages"])
async def _collect_album_background(self, media_group_id: str) -> None: async def _collect_album_background(self, media_group_id: str) -> None:
""" """
Фоновая задача для сбора всех сообщений медиагруппы. Фоновая задача для сбора всех сообщений медиагруппы.
Args: Args:
media_group_id: ID медиагруппы для сбора media_group_id: ID медиагруппы для сбора
""" """
try: try:
await asyncio.sleep(self.latency) await asyncio.sleep(self.latency)
if media_group_id not in self.album_data: if media_group_id not in self.album_data:
return return
# Получаем текущий список сообщений # Получаем текущий список сообщений
album_messages = self.album_data[media_group_id]["messages"].copy() album_messages = self.album_data[media_group_id]["messages"].copy()
album_messages.sort(key=lambda x: x.message_id) album_messages.sort(key=lambda x: x.message_id)
# Сохраняем собранную медиагруппу и уведомляем через Event # Сохраняем собранную медиагруппу и уведомляем через Event
self.album_data[media_group_id]["collected_album"] = album_messages self.album_data[media_group_id]["collected_album"] = album_messages
self.album_data[media_group_id]["event"].set() self.album_data[media_group_id]["event"].set()
# Очищаем данные после небольшой задержки (чтобы handler успел получить album) # Очищаем данные после небольшой задержки (чтобы handler успел получить album)
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
if media_group_id in self.album_data: if media_group_id in self.album_data:
@@ -114,24 +116,24 @@ class AlbumMiddleware(BaseMiddleware):
async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any: async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any:
""" """
Основная логика middleware. Основная логика middleware.
Для медиагрупп: сразу вызывает handler, передавая Event для получения полной медиагруппы. Для медиагрупп: сразу вызывает handler, передавая Event для получения полной медиагруппы.
Для обычных сообщений: сразу вызывает handler. Для обычных сообщений: сразу вызывает handler.
Args: Args:
handler: Обработчик события handler: Обработчик события
event: Событие (сообщение) event: Событие (сообщение)
data: Данные для передачи в обработчик data: Данные для передачи в обработчик
Returns: Returns:
Результат выполнения обработчика Результат выполнения обработчика
""" """
if not event.media_group_id: if not event.media_group_id:
return await handler(event, data) return await handler(event, data)
media_group_id = event.media_group_id media_group_id = event.media_group_id
message_id = event.message_id message_id = event.message_id
# Если это первое сообщение медиагруппы - создаем структуру данных # Если это первое сообщение медиагруппы - создаем структуру данных
is_first_message = False is_first_message = False
if media_group_id not in self.album_data: if media_group_id not in self.album_data:
@@ -141,27 +143,25 @@ class AlbumMiddleware(BaseMiddleware):
"messages": [], "messages": [],
"event": album_event, "event": album_event,
"task": None, "task": None,
"first_message_id": message_id "first_message_id": message_id,
} }
# Запускаем фоновую задачу для сбора медиагруппы # Запускаем фоновую задачу для сбора медиагруппы
task = asyncio.create_task(self._collect_album_background(media_group_id)) task = asyncio.create_task(self._collect_album_background(media_group_id))
self.album_data[media_group_id]["task"] = task self.album_data[media_group_id]["task"] = task
# Добавляем сообщение в медиагруппу # Добавляем сообщение в медиагруппу
self.album_data[media_group_id]["messages"].append(event) self.album_data[media_group_id]["messages"].append(event)
# Обрабатываем только первое сообщение медиагруппы # Обрабатываем только первое сообщение медиагруппы
if not is_first_message: if not is_first_message:
# Для остальных сообщений просто возвращаемся, не вызывая handler # Для остальных сообщений просто возвращаемся, не вызывая handler
return return
# Передаем объект-геттер в data, чтобы handler мог получить полную медиагруппу # Передаем объект-геттер в data, чтобы handler мог получить полную медиагруппу
album_getter = AlbumGetter( album_getter = AlbumGetter(
self.album_data, self.album_data, media_group_id, self.album_data[media_group_id]["event"]
media_group_id,
self.album_data[media_group_id]["event"]
) )
data["album_getter"] = album_getter data["album_getter"] = album_getter
# Сразу вызываем handler только для первого сообщения (не блокируем) # Сразу вызываем handler только для первого сообщения (не блокируем)
return await handler(event, data) return await handler(event, data)

View File

@@ -4,6 +4,7 @@ from typing import Any, Dict
from aiogram import BaseMiddleware, types from aiogram import BaseMiddleware, types
from aiogram.types import CallbackQuery, Message, TelegramObject from aiogram.types import CallbackQuery, Message, TelegramObject
from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.base_dependency_factory import get_global_instance
from logs.custom_logger import logger from logs.custom_logger import logger
@@ -12,47 +13,61 @@ BotDB = bdf.get_db()
class BlacklistMiddleware(BaseMiddleware): class BlacklistMiddleware(BaseMiddleware):
async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any: async def __call__(
self, handler, event: TelegramObject, data: Dict[str, Any]
) -> Any:
# Проверяем тип события и получаем пользователя # Проверяем тип события и получаем пользователя
user = None user = None
if isinstance(event, Message): if isinstance(event, Message):
user = event.from_user user = event.from_user
elif isinstance(event, CallbackQuery): elif isinstance(event, CallbackQuery):
user = event.from_user user = event.from_user
# Если это не сообщение или callback, пропускаем проверку # Если это не сообщение или callback, пропускаем проверку
if not user: if not user:
return await handler(event, data) return await handler(event, data)
logger.info(f'Вызов BlacklistMiddleware для пользователя {user.username}') logger.info(f"Вызов BlacklistMiddleware для пользователя {user.username}")
# Используем асинхронную версию для предотвращения блокировки # Используем асинхронную версию для предотвращения блокировки
if await BotDB.check_user_in_blacklist(user.id): if await BotDB.check_user_in_blacklist(user.id):
logger.info(f'BlacklistMiddleware результат для пользователя: {user.username} заблокирован!') logger.info(
f"BlacklistMiddleware результат для пользователя: {user.username} заблокирован!"
)
user_info = await BotDB.get_blacklist_users_by_id(user.id) user_info = await BotDB.get_blacklist_users_by_id(user.id)
# Экранируем потенциально проблемные символы # Экранируем потенциально проблемные символы
reason = html.escape(str(user_info[1])) if user_info and user_info[1] else "Не указана" reason = (
html.escape(str(user_info[1]))
if user_info and user_info[1]
else "Не указана"
)
# Преобразуем timestamp в человекочитаемый формат # Преобразуем timestamp в человекочитаемый формат
if user_info and user_info[2]: if user_info and user_info[2]:
try: try:
timestamp = int(user_info[2]) timestamp = int(user_info[2])
date_unban = datetime.fromtimestamp(timestamp).strftime("%d-%m-%Y %H:%M") date_unban = datetime.fromtimestamp(timestamp).strftime(
"%d-%m-%Y %H:%M"
)
except (ValueError, TypeError): except (ValueError, TypeError):
date_unban = "Не указана" date_unban = "Не указана"
else: else:
date_unban = "Не указана" date_unban = "Не указана"
# Отправляем сообщение в зависимости от типа события # Отправляем сообщение в зависимости от типа события
if isinstance(event, Message): if isinstance(event, Message):
await event.answer( await event.answer(
f"<b>Ты заблокирован.</b>\n<b>Причина блокировки:</b> {reason}\n<b>Дата разбана:</b> {date_unban}") f"<b>Ты заблокирован.</b>\n<b>Причина блокировки:</b> {reason}\n<b>Дата разбана:</b> {date_unban}"
)
elif isinstance(event, CallbackQuery): elif isinstance(event, CallbackQuery):
await event.answer( await event.answer(
f"<b>Ты заблокирован.</b>\n<b>Причина блокировки:</b> {reason}\n<b>Дата разбана:</b> {date_unban}", f"<b>Ты заблокирован.</b>\n<b>Причина блокировки:</b> {reason}\n<b>Дата разбана:</b> {date_unban}",
show_alert=True) show_alert=True,
)
return False return False
logger.info(f'BlacklistMiddleware результат для пользователя: {user.username} доступ разрешен') logger.info(
f"BlacklistMiddleware результат для пользователя: {user.username} доступ разрешен"
)
return await handler(event, data) return await handler(event, data)

View File

@@ -2,30 +2,35 @@ from typing import Any, Dict
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from aiogram.types import TelegramObject from aiogram.types import TelegramObject
from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.base_dependency_factory import get_global_instance
from logs.custom_logger import logger from logs.custom_logger import logger
class DependenciesMiddleware(BaseMiddleware): class DependenciesMiddleware(BaseMiddleware):
"""Универсальная middleware для внедрения зависимостей во все хендлеры""" """Универсальная middleware для внедрения зависимостей во все хендлеры"""
async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any: async def __call__(
self, handler, event: TelegramObject, data: Dict[str, Any]
) -> Any:
try: try:
# Получаем глобальные зависимости # Получаем глобальные зависимости
bdf = get_global_instance() bdf = get_global_instance()
# Внедряем зависимости в data для MagicData # Внедряем зависимости в data для MagicData
if 'bot_db' not in data: if "bot_db" not in data:
data['bot_db'] = bdf.get_db() data["bot_db"] = bdf.get_db()
if 'settings' not in data: if "settings" not in data:
data['settings'] = bdf.settings data["settings"] = bdf.settings
data['bot'] = data.get('bot') data["bot"] = data.get("bot")
data['dp'] = data.get('dp') data["dp"] = data.get("dp")
logger.debug(f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}") logger.debug(
f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}"
)
except Exception as e: except Exception as e:
logger.error(f"Ошибка в DependenciesMiddleware: {e}") logger.error(f"Ошибка в DependenciesMiddleware: {e}")
# Не прерываем выполнение, продолжаем без зависимостей # Не прерываем выполнение, продолжаем без зависимостей
return await handler(event, data) return await handler(event, data)

View File

@@ -16,16 +16,16 @@ from ..utils.metrics import metrics
# Import button command mapping # Import button command mapping
try: try:
from ..handlers.admin.constants import (ADMIN_BUTTON_COMMAND_MAPPING, from ..handlers.admin.constants import ADMIN_BUTTON_COMMAND_MAPPING, ADMIN_COMMANDS
ADMIN_COMMANDS)
from ..handlers.callback.constants import CALLBACK_COMMAND_MAPPING from ..handlers.callback.constants import CALLBACK_COMMAND_MAPPING
from ..handlers.private.constants import BUTTON_COMMAND_MAPPING from ..handlers.private.constants import BUTTON_COMMAND_MAPPING
from ..handlers.voice.constants import \ from ..handlers.voice.constants import (
BUTTON_COMMAND_MAPPING as VOICE_BUTTON_COMMAND_MAPPING BUTTON_COMMAND_MAPPING as VOICE_BUTTON_COMMAND_MAPPING,
from ..handlers.voice.constants import \ )
CALLBACK_COMMAND_MAPPING as VOICE_CALLBACK_COMMAND_MAPPING from ..handlers.voice.constants import (
from ..handlers.voice.constants import \ CALLBACK_COMMAND_MAPPING as VOICE_CALLBACK_COMMAND_MAPPING,
COMMAND_MAPPING as VOICE_COMMAND_MAPPING )
from ..handlers.voice.constants import COMMAND_MAPPING as VOICE_COMMAND_MAPPING
except ImportError: except ImportError:
# Fallback if constants not available # Fallback if constants not available
BUTTON_COMMAND_MAPPING = {} BUTTON_COMMAND_MAPPING = {}
@@ -39,40 +39,49 @@ except ImportError:
class MetricsMiddleware(BaseMiddleware): class MetricsMiddleware(BaseMiddleware):
"""Enhanced middleware for automatic collection of ALL available metrics.""" """Enhanced middleware for automatic collection of ALL available metrics."""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# Metrics update intervals # Metrics update intervals
self.last_active_users_update = 0 self.last_active_users_update = 0
self.active_users_update_interval = 300 # 5 minutes self.active_users_update_interval = 300 # 5 minutes
async def __call__( async def __call__(
self, self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject, event: TelegramObject,
data: Dict[str, Any] data: Dict[str, Any],
) -> Any: ) -> Any:
"""Process event and collect comprehensive metrics.""" """Process event and collect comprehensive metrics."""
# Update active users periodically # Update active users periodically
current_time = time.time() current_time = time.time()
if current_time - self.last_active_users_update > self.active_users_update_interval: if (
current_time - self.last_active_users_update
> self.active_users_update_interval
):
await self._update_active_users_metric() await self._update_active_users_metric()
self.last_active_users_update = current_time self.last_active_users_update = current_time
# Extract command and event info # Extract command and event info
command_info = None command_info = None
event_metrics = {} event_metrics = {}
# Process event based on type # Process event based on type
if hasattr(event, 'message') and event.message: if hasattr(event, "message") and event.message:
event_metrics = await self._record_comprehensive_message_metrics(event.message) event_metrics = await self._record_comprehensive_message_metrics(
event.message
)
command_info = self._extract_command_info_with_fallback(event.message) command_info = self._extract_command_info_with_fallback(event.message)
elif hasattr(event, 'callback_query') and event.callback_query: elif hasattr(event, "callback_query") and event.callback_query:
event_metrics = await self._record_comprehensive_callback_metrics(event.callback_query) event_metrics = await self._record_comprehensive_callback_metrics(
command_info = self._extract_callback_command_info_with_fallback(event.callback_query) event.callback_query
)
command_info = self._extract_callback_command_info_with_fallback(
event.callback_query
)
elif isinstance(event, Message): elif isinstance(event, Message):
event_metrics = await self._record_comprehensive_message_metrics(event) event_metrics = await self._record_comprehensive_message_metrics(event)
command_info = self._extract_command_info_with_fallback(event) command_info = self._extract_command_info_with_fallback(event)
@@ -81,107 +90,106 @@ class MetricsMiddleware(BaseMiddleware):
command_info = self._extract_callback_command_info_with_fallback(event) command_info = self._extract_callback_command_info_with_fallback(event)
else: else:
event_metrics = await self._record_unknown_event_metrics(event) event_metrics = await self._record_unknown_event_metrics(event)
if command_info: if command_info:
self.logger.info(f"📊 Command info extracted: {command_info}") self.logger.info(f"📊 Command info extracted: {command_info}")
else: else:
self.logger.warning(f"📊 No command info extracted for event type: {type(event).__name__}") self.logger.warning(
f"📊 No command info extracted for event type: {type(event).__name__}"
)
# Execute handler with comprehensive timing and metrics # Execute handler with comprehensive timing and metrics
start_time = time.time() start_time = time.time()
try: try:
result = await handler(event, data) result = await handler(event, data)
duration = time.time() - start_time duration = time.time() - start_time
# Record successful execution metrics # Record successful execution metrics
handler_name = self._get_handler_name(handler) handler_name = self._get_handler_name(handler)
metrics.record_method_duration( metrics.record_method_duration(handler_name, duration, "handler", "success")
handler_name,
duration,
"handler",
"success"
)
if command_info: if command_info:
metrics.record_command( metrics.record_command(
command_info['command'], command_info["command"],
command_info['handler_type'], command_info["handler_type"],
command_info['user_type'], command_info["user_type"],
"success" "success",
) )
await self._record_additional_success_metrics(event, event_metrics, handler_name) await self._record_additional_success_metrics(
event, event_metrics, handler_name
)
return result return result
except Exception as e: except Exception as e:
duration = time.time() - start_time duration = time.time() - start_time
# Record error metrics # Record error metrics
handler_name = self._get_handler_name(handler) handler_name = self._get_handler_name(handler)
error_type = type(e).__name__ error_type = type(e).__name__
metrics.record_method_duration( metrics.record_method_duration(handler_name, duration, "handler", "error")
handler_name,
duration, metrics.record_error(error_type, "handler", handler_name)
"handler",
"error"
)
metrics.record_error(
error_type,
"handler",
handler_name
)
if command_info: if command_info:
metrics.record_command( metrics.record_command(
command_info['command'], command_info["command"],
command_info['handler_type'], command_info["handler_type"],
command_info['user_type'], command_info["user_type"],
"error" "error",
) )
await self._record_additional_error_metrics(event, event_metrics, handler_name, error_type) await self._record_additional_error_metrics(
event, event_metrics, handler_name, error_type
)
raise raise
finally: finally:
# Record middleware execution time # Record middleware execution time
middleware_duration = time.time() - start_time middleware_duration = time.time() - start_time
metrics.record_middleware("MetricsMiddleware", middleware_duration, "success") metrics.record_middleware(
"MetricsMiddleware", middleware_duration, "success"
)
async def _update_active_users_metric(self): async def _update_active_users_metric(self):
"""Periodically update active users metric from database.""" """Periodically update active users metric from database."""
try: try:
#TODO: Должна подключаться к базе данных, а не к глобальному экземпляру # TODO: Должна подключаться к базе данных, а не к глобальному экземпляру
from ..utils.base_dependency_factory import get_global_instance from ..utils.base_dependency_factory import get_global_instance
bdf = get_global_instance() bdf = get_global_instance()
bot_db = bdf.get_db() bot_db = bdf.get_db()
# Используем правильные методы AsyncBotDB для выполнения запросов # Используем правильные методы AsyncBotDB для выполнения запросов
# Простой подсчет всех пользователей в базе # Простой подсчет всех пользователей в базе
total_users_query = "SELECT COUNT(DISTINCT user_id) as total FROM our_users" total_users_query = "SELECT COUNT(DISTINCT user_id) as total FROM our_users"
total_users_result = await bot_db.fetch_one(total_users_query) total_users_result = await bot_db.fetch_one(total_users_query)
total_users = total_users_result['total'] if total_users_result else 1 total_users = total_users_result["total"] if total_users_result else 1
# Подсчет активных за день пользователей (date_changed - это Unix timestamp) # Подсчет активных за день пользователей (date_changed - это Unix timestamp)
daily_users_query = "SELECT COUNT(DISTINCT user_id) as daily FROM our_users WHERE date_changed > (strftime('%s', 'now', '-1 day'))" daily_users_query = "SELECT COUNT(DISTINCT user_id) as daily FROM our_users WHERE date_changed > (strftime('%s', 'now', '-1 day'))"
daily_users_result = await bot_db.fetch_one(daily_users_query) daily_users_result = await bot_db.fetch_one(daily_users_query)
daily_users = daily_users_result['daily'] if daily_users_result else 1 daily_users = daily_users_result["daily"] if daily_users_result else 1
# Устанавливаем метрики с правильными лейблами # Устанавливаем метрики с правильными лейблами
metrics.set_active_users(daily_users, "daily") metrics.set_active_users(daily_users, "daily")
metrics.set_total_users(total_users) metrics.set_total_users(total_users)
self.logger.info(f"📊 Active users metric updated: {daily_users} (daily), {total_users} (total)") self.logger.info(
f"📊 Active users metric updated: {daily_users} (daily), {total_users} (total)"
)
except Exception as e: except Exception as e:
self.logger.error(f"❌ Failed to update users metric: {e}") self.logger.error(f"❌ Failed to update users metric: {e}")
# Устанавливаем 1 как fallback # Устанавливаем 1 как fallback
metrics.set_active_users(1, "daily") metrics.set_active_users(1, "daily")
metrics.set_total_users(1) metrics.set_total_users(1)
async def _record_comprehensive_message_metrics(self, message: Message) -> Dict[str, Any]: async def _record_comprehensive_message_metrics(
self, message: Message
) -> Dict[str, Any]:
"""Record comprehensive message metrics.""" """Record comprehensive message metrics."""
# Determine message type # Determine message type
message_type = "text" message_type = "text"
@@ -199,7 +207,7 @@ class MetricsMiddleware(BaseMiddleware):
message_type = "sticker" message_type = "sticker"
elif message.animation: elif message.animation:
message_type = "animation" message_type = "animation"
# Determine chat type # Determine chat type
chat_type = "private" chat_type = "private"
if message.chat.type == ChatType.GROUP: if message.chat.type == ChatType.GROUP:
@@ -208,129 +216,139 @@ class MetricsMiddleware(BaseMiddleware):
chat_type = "supergroup" chat_type = "supergroup"
elif message.chat.type == ChatType.CHANNEL: elif message.chat.type == ChatType.CHANNEL:
chat_type = "channel" chat_type = "channel"
# Record message processing # Record message processing
metrics.record_message(message_type, chat_type, "message_handler") metrics.record_message(message_type, chat_type, "message_handler")
return { return {
'message_type': message_type, "message_type": message_type,
'chat_type': chat_type, "chat_type": chat_type,
'user_id': message.from_user.id if message.from_user else None, "user_id": message.from_user.id if message.from_user else None,
'is_bot': message.from_user.is_bot if message.from_user else False "is_bot": message.from_user.is_bot if message.from_user else False,
} }
async def _record_comprehensive_callback_metrics(self, callback: CallbackQuery) -> Dict[str, Any]: async def _record_comprehensive_callback_metrics(
self, callback: CallbackQuery
) -> Dict[str, Any]:
"""Record comprehensive callback metrics.""" """Record comprehensive callback metrics."""
# Record callback message # Record callback message
metrics.record_message("callback_query", "callback", "callback_handler") metrics.record_message("callback_query", "callback", "callback_handler")
return { return {
'callback_data': callback.data, "callback_data": callback.data,
'user_id': callback.from_user.id if callback.from_user else None, "user_id": callback.from_user.id if callback.from_user else None,
'is_bot': callback.from_user.is_bot if callback.from_user else False "is_bot": callback.from_user.is_bot if callback.from_user else False,
} }
async def _record_unknown_event_metrics(self, event: TelegramObject) -> Dict[str, Any]: async def _record_unknown_event_metrics(
self, event: TelegramObject
) -> Dict[str, Any]:
"""Record metrics for unknown event types.""" """Record metrics for unknown event types."""
# Record unknown event # Record unknown event
metrics.record_message("unknown", "unknown", "unknown_handler") metrics.record_message("unknown", "unknown", "unknown_handler")
return { return {
'event_type': type(event).__name__, "event_type": type(event).__name__,
'event_data': str(event)[:100] if hasattr(event, '__str__') else "unknown" "event_data": str(event)[:100] if hasattr(event, "__str__") else "unknown",
} }
def _extract_command_info_with_fallback(self, message: Message) -> Optional[Dict[str, str]]: def _extract_command_info_with_fallback(
self, message: Message
) -> Optional[Dict[str, str]]:
"""Extract command information with fallback for unknown commands.""" """Extract command information with fallback for unknown commands."""
if not message.text: if not message.text:
return None return None
# Check if it's a slash command # Check if it's a slash command
if message.text.startswith('/'): if message.text.startswith("/"):
command_name = message.text.split()[0][1:] # Remove '/' and get command name command_name = message.text.split()[0][
1:
] # Remove '/' and get command name
# Check if it's an admin command # Check if it's an admin command
if command_name in ADMIN_COMMANDS: if command_name in ADMIN_COMMANDS:
return { return {
'command': ADMIN_COMMANDS[command_name], "command": ADMIN_COMMANDS[command_name],
'user_type': "admin" if message.from_user else "unknown", "user_type": "admin" if message.from_user else "unknown",
'handler_type': "admin_handler" "handler_type": "admin_handler",
} }
# Check if it's a voice bot command # Check if it's a voice bot command
elif command_name in VOICE_COMMAND_MAPPING: elif command_name in VOICE_COMMAND_MAPPING:
return { return {
'command': VOICE_COMMAND_MAPPING[command_name], "command": VOICE_COMMAND_MAPPING[command_name],
'user_type': "user" if message.from_user else "unknown", "user_type": "user" if message.from_user else "unknown",
'handler_type': "voice_command_handler" "handler_type": "voice_command_handler",
} }
else: else:
# FALLBACK: Record unknown command # FALLBACK: Record unknown command
return { return {
'command': command_name, "command": command_name,
'user_type': "user" if message.from_user else "unknown", "user_type": "user" if message.from_user else "unknown",
'handler_type': "unknown_command_handler" "handler_type": "unknown_command_handler",
} }
# Check if it's an admin button click # Check if it's an admin button click
if message.text in ADMIN_BUTTON_COMMAND_MAPPING: if message.text in ADMIN_BUTTON_COMMAND_MAPPING:
return { return {
'command': ADMIN_BUTTON_COMMAND_MAPPING[message.text], "command": ADMIN_BUTTON_COMMAND_MAPPING[message.text],
'user_type': "admin" if message.from_user else "unknown", "user_type": "admin" if message.from_user else "unknown",
'handler_type': "admin_button_handler" "handler_type": "admin_button_handler",
} }
# Check if it's a regular button click (text button) # Check if it's a regular button click (text button)
if message.text in BUTTON_COMMAND_MAPPING: if message.text in BUTTON_COMMAND_MAPPING:
return { return {
'command': BUTTON_COMMAND_MAPPING[message.text], "command": BUTTON_COMMAND_MAPPING[message.text],
'user_type': "user" if message.from_user else "unknown", "user_type": "user" if message.from_user else "unknown",
'handler_type': "button_handler" "handler_type": "button_handler",
} }
# Check if it's a voice bot button click # Check if it's a voice bot button click
if message.text in VOICE_BUTTON_COMMAND_MAPPING: if message.text in VOICE_BUTTON_COMMAND_MAPPING:
return { return {
'command': VOICE_BUTTON_COMMAND_MAPPING[message.text], "command": VOICE_BUTTON_COMMAND_MAPPING[message.text],
'user_type': "user" if message.from_user else "unknown", "user_type": "user" if message.from_user else "unknown",
'handler_type': "voice_button_handler" "handler_type": "voice_button_handler",
} }
# FALLBACK: Record ANY text message as a command for metrics # FALLBACK: Record ANY text message as a command for metrics
if message.text and len(message.text.strip()) > 0: if message.text and len(message.text.strip()) > 0:
return { return {
'command': f"text", "command": f"text",
'user_type': "user" if message.from_user else "unknown", "user_type": "user" if message.from_user else "unknown",
'handler_type': "text_message_handler" "handler_type": "text_message_handler",
} }
return None return None
def _extract_callback_command_info_with_fallback(self, callback: CallbackQuery) -> Optional[Dict[str, str]]: def _extract_callback_command_info_with_fallback(
self, callback: CallbackQuery
) -> Optional[Dict[str, str]]:
"""Extract callback command information with fallback.""" """Extract callback command information with fallback."""
if not callback.data: if not callback.data:
return None return None
# Extract command from callback data # Extract command from callback data
parts = callback.data.split(':', 1) parts = callback.data.split(":", 1)
if parts and parts[0] in CALLBACK_COMMAND_MAPPING: if parts and parts[0] in CALLBACK_COMMAND_MAPPING:
return { return {
'command': CALLBACK_COMMAND_MAPPING[parts[0]], "command": CALLBACK_COMMAND_MAPPING[parts[0]],
'user_type': "user" if callback.from_user else "unknown", "user_type": "user" if callback.from_user else "unknown",
'handler_type': "callback_handler" "handler_type": "callback_handler",
} }
# Check if it's a voice bot callback # Check if it's a voice bot callback
if parts and parts[0] in VOICE_CALLBACK_COMMAND_MAPPING: if parts and parts[0] in VOICE_CALLBACK_COMMAND_MAPPING:
return { return {
'command': VOICE_CALLBACK_COMMAND_MAPPING[parts[0]], "command": VOICE_CALLBACK_COMMAND_MAPPING[parts[0]],
'user_type': "user" if callback.from_user else "unknown", "user_type": "user" if callback.from_user else "unknown",
'handler_type': "voice_callback_handler" "handler_type": "voice_callback_handler",
} }
# FALLBACK: Record unknown callback # FALLBACK: Record unknown callback
if parts: if parts:
callback_data = parts[0] callback_data = parts[0]
# Группируем похожие callback'и по паттернам # Группируем похожие callback'и по паттернам
if callback_data.startswith("ban_") and callback_data[4:].isdigit(): if callback_data.startswith("ban_") and callback_data[4:].isdigit():
# callback_ban_123456 -> callback_ban # callback_ban_123456 -> callback_ban
@@ -341,60 +359,69 @@ class MetricsMiddleware(BaseMiddleware):
else: else:
# Для остальных неизвестных callback'ов оставляем как есть # Для остальных неизвестных callback'ов оставляем как есть
command = f"callback_{callback_data[:20]}" command = f"callback_{callback_data[:20]}"
return { return {
'command': command, "command": command,
'user_type': "user" if callback.from_user else "unknown", "user_type": "user" if callback.from_user else "unknown",
'handler_type': "unknown_callback_handler" "handler_type": "unknown_callback_handler",
} }
return None return None
async def _record_additional_success_metrics(self, event: TelegramObject, event_metrics: Dict[str, Any], handler_name: str): async def _record_additional_success_metrics(
self, event: TelegramObject, event_metrics: Dict[str, Any], handler_name: str
):
"""Record additional success metrics.""" """Record additional success metrics."""
try: try:
# Record rate limiting metrics (if applicable) # Record rate limiting metrics (if applicable)
if hasattr(event, 'from_user') and event.from_user: if hasattr(event, "from_user") and event.from_user:
# You can add rate limiting logic here # You can add rate limiting logic here
pass pass
# Record user activity metrics # Record user activity metrics
if event_metrics.get('user_id'): if event_metrics.get("user_id"):
# This could trigger additional user activity tracking # This could trigger additional user activity tracking
pass pass
except Exception as e: except Exception as e:
self.logger.error(f"❌ Error recording additional success metrics: {e}") self.logger.error(f"❌ Error recording additional success metrics: {e}")
async def _record_additional_error_metrics(self, event: TelegramObject, event_metrics: Dict[str, Any], handler_name: str, error_type: str): async def _record_additional_error_metrics(
self,
event: TelegramObject,
event_metrics: Dict[str, Any],
handler_name: str,
error_type: str,
):
"""Record additional error metrics.""" """Record additional error metrics."""
try: try:
# Record specific error context # Record specific error context
if event_metrics.get('user_id'): if event_metrics.get("user_id"):
# You can add user-specific error tracking here # You can add user-specific error tracking here
pass pass
except Exception as e: except Exception as e:
self.logger.error(f"❌ Error recording additional error metrics: {e}") self.logger.error(f"❌ Error recording additional error metrics: {e}")
def _get_handler_name(self, handler: Callable) -> str: def _get_handler_name(self, handler: Callable) -> str:
"""Extract handler name efficiently.""" """Extract handler name efficiently."""
# Check various ways to get handler name # Check various ways to get handler name
if hasattr(handler, '__name__') and handler.__name__ != '<lambda>': if hasattr(handler, "__name__") and handler.__name__ != "<lambda>":
return handler.__name__ return handler.__name__
elif hasattr(handler, '__qualname__') and handler.__qualname__ != '<lambda>': elif hasattr(handler, "__qualname__") and handler.__qualname__ != "<lambda>":
return handler.__qualname__ return handler.__qualname__
elif hasattr(handler, 'callback') and hasattr(handler.callback, '__name__'): elif hasattr(handler, "callback") and hasattr(handler.callback, "__name__"):
return handler.callback.__name__ return handler.callback.__name__
elif hasattr(handler, 'view') and hasattr(handler.view, '__name__'): elif hasattr(handler, "view") and hasattr(handler.view, "__name__"):
return handler.view.__name__ return handler.view.__name__
else: else:
# Пытаемся получить имя из строкового представления # Пытаемся получить имя из строкового представления
handler_str = str(handler) handler_str = str(handler)
if 'function' in handler_str: if "function" in handler_str:
# Извлекаем имя функции из строки # Извлекаем имя функции из строки
import re import re
match = re.search(r'function\s+(\w+)', handler_str)
match = re.search(r"function\s+(\w+)", handler_str)
if match: if match:
return match.group(1) return match.group(1)
return "unknown" return "unknown"
@@ -402,83 +429,77 @@ class MetricsMiddleware(BaseMiddleware):
class DatabaseMetricsMiddleware(BaseMiddleware): class DatabaseMetricsMiddleware(BaseMiddleware):
"""Enhanced middleware for database operation metrics.""" """Enhanced middleware for database operation metrics."""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
async def __call__( async def __call__(
self, self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject, event: TelegramObject,
data: Dict[str, Any] data: Dict[str, Any],
) -> Any: ) -> Any:
"""Process event and collect database metrics.""" """Process event and collect database metrics."""
# Check if this handler involves database operations # Check if this handler involves database operations
handler_name = handler.__name__ if hasattr(handler, '__name__') else "unknown" handler_name = handler.__name__ if hasattr(handler, "__name__") else "unknown"
# Record middleware start # Record middleware start
start_time = time.time() start_time = time.time()
try: try:
result = await handler(event, data) result = await handler(event, data)
# Record successful database operation # Record successful database operation
duration = time.time() - start_time duration = time.time() - start_time
metrics.record_middleware("DatabaseMetricsMiddleware", duration, "success") metrics.record_middleware("DatabaseMetricsMiddleware", duration, "success")
return result return result
except Exception as e: except Exception as e:
# Record failed database operation # Record failed database operation
duration = time.time() - start_time duration = time.time() - start_time
metrics.record_middleware("DatabaseMetricsMiddleware", duration, "error") metrics.record_middleware("DatabaseMetricsMiddleware", duration, "error")
metrics.record_error( metrics.record_error(type(e).__name__, "database_middleware", handler_name)
type(e).__name__,
"database_middleware",
handler_name
)
raise raise
class ErrorMetricsMiddleware(BaseMiddleware): class ErrorMetricsMiddleware(BaseMiddleware):
"""Enhanced middleware for error tracking and metrics.""" """Enhanced middleware for error tracking and metrics."""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
async def __call__( async def __call__(
self, self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject, event: TelegramObject,
data: Dict[str, Any] data: Dict[str, Any],
) -> Any: ) -> Any:
"""Process event and collect error metrics.""" """Process event and collect error metrics."""
# Record middleware start # Record middleware start
start_time = time.time() start_time = time.time()
try: try:
result = await handler(event, data) result = await handler(event, data)
# Record successful error handling # Record successful error handling
duration = time.time() - start_time duration = time.time() - start_time
metrics.record_middleware("ErrorMetricsMiddleware", duration, "success") metrics.record_middleware("ErrorMetricsMiddleware", duration, "success")
return result return result
except Exception as e: except Exception as e:
# Record error metrics # Record error metrics
duration = time.time() - start_time duration = time.time() - start_time
handler_name = handler.__name__ if hasattr(handler, '__name__') else "unknown" handler_name = (
handler.__name__ if hasattr(handler, "__name__") else "unknown"
metrics.record_middleware("ErrorMetricsMiddleware", duration, "error")
metrics.record_error(
type(e).__name__,
"error_middleware",
handler_name
) )
metrics.record_middleware("ErrorMetricsMiddleware", duration, "error")
metrics.record_error(type(e).__name__, "error_middleware", handler_name)
raise raise

View File

@@ -1,42 +1,43 @@
""" """
Middleware для автоматического применения rate limiting ко всем входящим сообщениям Middleware для автоматического применения rate limiting ко всем входящим сообщениям
""" """
from typing import Any, Awaitable, Callable, Dict, Union from typing import Any, Awaitable, Callable, Dict, Union
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
from aiogram.types import (CallbackQuery, ChatMemberUpdated, InlineQuery, from aiogram.types import CallbackQuery, ChatMemberUpdated, InlineQuery, Message, Update
Message, Update)
from helper_bot.utils.rate_limiter import telegram_rate_limiter from helper_bot.utils.rate_limiter import telegram_rate_limiter
from logs.custom_logger import logger from logs.custom_logger import logger
class RateLimitMiddleware(BaseMiddleware): class RateLimitMiddleware(BaseMiddleware):
"""Middleware для автоматического rate limiting входящих сообщений""" """Middleware для автоматического rate limiting входящих сообщений"""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.rate_limiter = telegram_rate_limiter self.rate_limiter = telegram_rate_limiter
async def __call__( async def __call__(
self, self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Union[Update, Message, CallbackQuery, InlineQuery, ChatMemberUpdated], event: Union[Update, Message, CallbackQuery, InlineQuery, ChatMemberUpdated],
data: Dict[str, Any] data: Dict[str, Any],
) -> Any: ) -> Any:
"""Обрабатывает событие с rate limiting""" """Обрабатывает событие с rate limiting"""
# Извлекаем сообщение из Update # Извлекаем сообщение из Update
message = None message = None
if isinstance(event, Update): if isinstance(event, Update):
message = event.message message = event.message
elif isinstance(event, Message): elif isinstance(event, Message):
message = event message = event
# Применяем rate limiting только к сообщениям # Применяем rate limiting только к сообщениям
if message is not None: if message is not None:
chat_id = message.chat.id chat_id = message.chat.id
# Обертываем handler в rate limiting # Обертываем handler в rate limiting
async def rate_limited_handler(): async def rate_limited_handler():
try: try:
@@ -46,13 +47,11 @@ class RateLimitMiddleware(BaseMiddleware):
# Middleware не должен перехватывать эти ошибки, # Middleware не должен перехватывать эти ошибки,
# пусть их обрабатывает rate_limiter в функциях отправки # пусть их обрабатывает rate_limiter в функциях отправки
raise raise
# Применяем rate limiting к handler # Применяем rate limiting к handler
return await self.rate_limiter.send_with_rate_limit( return await self.rate_limiter.send_with_rate_limit(
rate_limited_handler, rate_limited_handler, chat_id
chat_id
) )
else: else:
# Для других типов событий просто вызываем handler # Для других типов событий просто вызываем handler
return await handler(event, data) return await handler(event, data)

View File

@@ -12,7 +12,6 @@ class BulkTextMiddleware(BaseMiddleware):
self.latency = latency self.latency = latency
self.texts = defaultdict(list) self.texts = defaultdict(list)
async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any: async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any:
""" """
Main middleware logic. Main middleware logic.
@@ -37,10 +36,9 @@ class BulkTextMiddleware(BaseMiddleware):
# # Sort the album messages by message_id and add to data # # Sort the album messages by message_id and add to data
msg_texts = self.texts[key] msg_texts = self.texts[key]
msg_texts.sort(key=lambda x: x.message_id) msg_texts.sort(key=lambda x: x.message_id)
data["texts"] = ''.join([msg.text for msg in msg_texts]) data["texts"] = "".join([msg.text for msg in msg_texts])
# #
# Remove the media group from tracking to free up memory # Remove the media group from tracking to free up memory
del self.texts[key] del self.texts[key]
# # Call the original event handler # # Call the original event handler
return await handler(event, data) return await handler(event, data)

View File

@@ -1,4 +1,3 @@
""" """
HTTP server for metrics endpoint integration with centralized Prometheus monitoring. HTTP server for metrics endpoint integration with centralized Prometheus monitoring.
Provides /metrics endpoint and health check for the bot. Provides /metrics endpoint and health check for the bot.
@@ -17,53 +16,48 @@ try:
except ImportError: except ImportError:
# Fallback для случаев, когда custom_logger недоступен # Fallback для случаев, когда custom_logger недоступен
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class MetricsServer: class MetricsServer:
"""HTTP server for Prometheus metrics and health checks.""" """HTTP server for Prometheus metrics and health checks."""
def __init__(self, host: str = '0.0.0.0', port: int = 8080): def __init__(self, host: str = "0.0.0.0", port: int = 8080):
self.host = host self.host = host
self.port = port self.port = port
self.app = web.Application() self.app = web.Application()
self.runner: Optional[web.AppRunner] = None self.runner: Optional[web.AppRunner] = None
self.site: Optional[web.TCPSite] = None self.site: Optional[web.TCPSite] = None
# Настраиваем роуты # Настраиваем роуты
self.app.router.add_get('/metrics', self.metrics_handler) self.app.router.add_get("/metrics", self.metrics_handler)
self.app.router.add_get('/health', self.health_handler) self.app.router.add_get("/health", self.health_handler)
async def metrics_handler(self, request: web.Request) -> web.Response: async def metrics_handler(self, request: web.Request) -> web.Response:
"""Handle /metrics endpoint for Prometheus scraping.""" """Handle /metrics endpoint for Prometheus scraping."""
try: try:
logger.debug("Generating metrics...") logger.debug("Generating metrics...")
# Проверяем, что metrics доступен # Проверяем, что metrics доступен
if not metrics: if not metrics:
logger.error("Metrics object is not available") logger.error("Metrics object is not available")
return web.Response( return web.Response(text="Metrics not available", status=500)
text="Metrics not available",
status=500
)
# Генерируем метрики в формате Prometheus # Генерируем метрики в формате Prometheus
metrics_data = metrics.get_metrics() metrics_data = metrics.get_metrics()
logger.debug(f"Generated metrics: {len(metrics_data)} bytes") logger.debug(f"Generated metrics: {len(metrics_data)} bytes")
return web.Response( return web.Response(
body=metrics_data, body=metrics_data, content_type="text/plain; version=0.0.4"
content_type='text/plain; version=0.0.4'
) )
except Exception as e: except Exception as e:
logger.error(f"Error generating metrics: {e}") logger.error(f"Error generating metrics: {e}")
import traceback import traceback
logger.error(f"Traceback: {traceback.format_exc()}") logger.error(f"Traceback: {traceback.format_exc()}")
return web.Response( return web.Response(text=f"Error generating metrics: {e}", status=500)
text=f"Error generating metrics: {e}",
status=500
)
async def health_handler(self, request: web.Request) -> web.Response: async def health_handler(self, request: web.Request) -> web.Response:
"""Handle /health endpoint for health checks.""" """Handle /health endpoint for health checks."""
try: try:
@@ -71,77 +65,72 @@ class MetricsServer:
if not metrics: if not metrics:
return web.Response( return web.Response(
text="ERROR: Metrics not available", text="ERROR: Metrics not available",
content_type='text/plain', content_type="text/plain",
status=503 status=503,
) )
# Проверяем, что можем получить метрики # Проверяем, что можем получить метрики
try: try:
metrics_data = metrics.get_metrics() metrics_data = metrics.get_metrics()
if not metrics_data: if not metrics_data:
return web.Response( return web.Response(
text="ERROR: Empty metrics", text="ERROR: Empty metrics",
content_type='text/plain', content_type="text/plain",
status=503 status=503,
) )
except Exception as e: except Exception as e:
return web.Response( return web.Response(
text=f"ERROR: Metrics generation failed: {e}", text=f"ERROR: Metrics generation failed: {e}",
content_type='text/plain', content_type="text/plain",
status=503 status=503,
) )
return web.Response( return web.Response(text="OK", content_type="text/plain", status=200)
text="OK",
content_type='text/plain',
status=200
)
except Exception as e: except Exception as e:
logger.error(f"Health check failed: {e}") logger.error(f"Health check failed: {e}")
return web.Response( return web.Response(
text=f"ERROR: Health check failed: {e}", text=f"ERROR: Health check failed: {e}",
content_type='text/plain', content_type="text/plain",
status=500 status=500,
) )
async def start(self) -> None: async def start(self) -> None:
"""Start the HTTP server.""" """Start the HTTP server."""
try: try:
self.runner = web.AppRunner(self.app) self.runner = web.AppRunner(self.app)
await self.runner.setup() await self.runner.setup()
self.site = web.TCPSite(self.runner, self.host, self.port) self.site = web.TCPSite(self.runner, self.host, self.port)
await self.site.start() await self.site.start()
logger.info(f"Metrics server started on {self.host}:{self.port}") logger.info(f"Metrics server started on {self.host}:{self.port}")
logger.info("Available endpoints:") logger.info("Available endpoints:")
logger.info(f" - /metrics - Prometheus metrics") logger.info(f" - /metrics - Prometheus metrics")
logger.info(f" - /health - Health check") logger.info(f" - /health - Health check")
except Exception as e: except Exception as e:
logger.error(f"Failed to start metrics server: {e}") logger.error(f"Failed to start metrics server: {e}")
raise raise
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the HTTP server.""" """Stop the HTTP server."""
try: try:
if self.site: if self.site:
await self.site.stop() await self.site.stop()
logger.info("Metrics server site stopped") logger.info("Metrics server site stopped")
if self.runner: if self.runner:
await self.runner.cleanup() await self.runner.cleanup()
logger.info("Metrics server runner cleaned up") logger.info("Metrics server runner cleaned up")
except Exception as e: except Exception as e:
logger.error(f"Error stopping metrics server: {e}") logger.error(f"Error stopping metrics server: {e}")
async def __aenter__(self): async def __aenter__(self):
"""Async context manager entry.""" """Async context manager entry."""
await self.start() await self.start()
return self return self
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit.""" """Async context manager exit."""
await self.stop() await self.stop()
@@ -151,7 +140,9 @@ class MetricsServer:
metrics_server: Optional[MetricsServer] = None metrics_server: Optional[MetricsServer] = None
async def start_metrics_server(host: str = '0.0.0.0', port: int = 8080) -> MetricsServer: async def start_metrics_server(
host: str = "0.0.0.0", port: int = 8080
) -> MetricsServer:
"""Start metrics server and return instance.""" """Start metrics server and return instance."""
global metrics_server global metrics_server
metrics_server = MetricsServer(host, port) metrics_server = MetricsServer(host, port)

View File

@@ -9,9 +9,14 @@
from .base import CombinedScore, ScoringResult, ScoringServiceProtocol from .base import CombinedScore, ScoringResult, ScoringServiceProtocol
from .deepseek_service import DeepSeekService from .deepseek_service import DeepSeekService
from .exceptions import (DeepSeekAPIError, InsufficientExamplesError, from .exceptions import (
ModelNotLoadedError, ScoringError, TextTooShortError, DeepSeekAPIError,
VectorStoreError) InsufficientExamplesError,
ModelNotLoadedError,
ScoringError,
TextTooShortError,
VectorStoreError,
)
from .rag_client import RagApiClient from .rag_client import RagApiClient
from .scoring_manager import ScoringManager from .scoring_manager import ScoringManager

View File

@@ -11,7 +11,7 @@ from typing import Any, Dict, Optional, Protocol
class ScoringResult: class ScoringResult:
""" """
Результат оценки поста от одного сервиса. Результат оценки поста от одного сервиса.
Attributes: Attributes:
score: Оценка от 0.0 до 1.0 (вероятность публикации) score: Оценка от 0.0 до 1.0 (вероятность публикации)
source: Источник оценки ("deepseek", "rag", etc.) source: Источник оценки ("deepseek", "rag", etc.)
@@ -20,18 +20,21 @@ class ScoringResult:
timestamp: Время получения оценки timestamp: Время получения оценки
metadata: Дополнительные данные metadata: Дополнительные данные
""" """
score: float score: float
source: str source: str
model: str model: str
confidence: Optional[float] = None confidence: Optional[float] = None
timestamp: int = field(default_factory=lambda: int(datetime.now().timestamp())) timestamp: int = field(default_factory=lambda: int(datetime.now().timestamp()))
metadata: Dict[str, Any] = field(default_factory=dict) metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self): def __post_init__(self):
"""Валидация score в диапазоне [0.0, 1.0].""" """Валидация score в диапазоне [0.0, 1.0]."""
if not 0.0 <= self.score <= 1.0: if not 0.0 <= self.score <= 1.0:
raise ValueError(f"Score должен быть в диапазоне [0.0, 1.0], получено: {self.score}") raise ValueError(
f"Score должен быть в диапазоне [0.0, 1.0], получено: {self.score}"
)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Преобразует результат в словарь для сохранения в JSON.""" """Преобразует результат в словарь для сохранения в JSON."""
result = { result = {
@@ -44,7 +47,7 @@ class ScoringResult:
if self.metadata: if self.metadata:
result["metadata"] = self.metadata result["metadata"] = self.metadata
return result return result
@classmethod @classmethod
def from_dict(cls, source: str, data: Dict[str, Any]) -> "ScoringResult": def from_dict(cls, source: str, data: Dict[str, Any]) -> "ScoringResult":
"""Создает ScoringResult из словаря.""" """Создает ScoringResult из словаря."""
@@ -62,30 +65,31 @@ class ScoringResult:
class CombinedScore: class CombinedScore:
""" """
Объединенный результат от всех сервисов скоринга. Объединенный результат от всех сервисов скоринга.
Attributes: Attributes:
deepseek: Результат от DeepSeek API (None если отключен/ошибка) deepseek: Результат от DeepSeek API (None если отключен/ошибка)
rag: Результат от RAG сервиса (None если отключен/ошибка) rag: Результат от RAG сервиса (None если отключен/ошибка)
errors: Словарь с ошибками по источникам errors: Словарь с ошибками по источникам
""" """
deepseek: Optional[ScoringResult] = None deepseek: Optional[ScoringResult] = None
rag: Optional[ScoringResult] = None rag: Optional[ScoringResult] = None
errors: Dict[str, str] = field(default_factory=dict) errors: Dict[str, str] = field(default_factory=dict)
@property @property
def deepseek_score(self) -> Optional[float]: def deepseek_score(self) -> Optional[float]:
"""Возвращает только числовой скор от DeepSeek.""" """Возвращает только числовой скор от DeepSeek."""
return self.deepseek.score if self.deepseek else None return self.deepseek.score if self.deepseek else None
@property @property
def rag_score(self) -> Optional[float]: def rag_score(self) -> Optional[float]:
"""Возвращает только числовой скор от RAG.""" """Возвращает только числовой скор от RAG."""
return self.rag.score if self.rag else None return self.rag.score if self.rag else None
def to_json_dict(self) -> Dict[str, Any]: def to_json_dict(self) -> Dict[str, Any]:
""" """
Преобразует в словарь для сохранения в ml_scores колонку. Преобразует в словарь для сохранения в ml_scores колонку.
Формат: Формат:
{ {
"deepseek": {"score": 0.75, "model": "...", "ts": ...}, "deepseek": {"score": 0.75, "model": "...", "ts": ...},
@@ -98,7 +102,7 @@ class CombinedScore:
if self.rag: if self.rag:
result["rag"] = self.rag.to_dict() result["rag"] = self.rag.to_dict()
return result return result
def has_any_score(self) -> bool: def has_any_score(self) -> bool:
"""Проверяет, есть ли хотя бы один успешный скор.""" """Проверяет, есть ли хотя бы один успешный скор."""
return self.deepseek is not None or self.rag is not None return self.deepseek is not None or self.rag is not None
@@ -107,48 +111,48 @@ class CombinedScore:
class ScoringServiceProtocol(Protocol): class ScoringServiceProtocol(Protocol):
""" """
Протокол для сервисов скоринга. Протокол для сервисов скоринга.
Любой сервис скоринга должен реализовывать эти методы. Любой сервис скоринга должен реализовывать эти методы.
""" """
@property @property
def source_name(self) -> str: def source_name(self) -> str:
"""Возвращает имя источника ("deepseek", "rag", etc.).""" """Возвращает имя источника ("deepseek", "rag", etc.)."""
... ...
@property @property
def is_enabled(self) -> bool: def is_enabled(self) -> bool:
"""Проверяет, включен ли сервис.""" """Проверяет, включен ли сервис."""
... ...
async def calculate_score(self, text: str) -> ScoringResult: async def calculate_score(self, text: str) -> ScoringResult:
""" """
Рассчитывает скор для текста поста. Рассчитывает скор для текста поста.
Args: Args:
text: Текст поста для оценки text: Текст поста для оценки
Returns: Returns:
ScoringResult с оценкой ScoringResult с оценкой
Raises: Raises:
ScoringError: При ошибке расчета ScoringError: При ошибке расчета
""" """
... ...
async def add_positive_example(self, text: str) -> None: async def add_positive_example(self, text: str) -> None:
""" """
Добавляет текст как положительный пример (опубликованный пост). Добавляет текст как положительный пример (опубликованный пост).
Args: Args:
text: Текст опубликованного поста text: Текст опубликованного поста
""" """
... ...
async def add_negative_example(self, text: str) -> None: async def add_negative_example(self, text: str) -> None:
""" """
Добавляет текст как отрицательный пример (отклоненный пост). Добавляет текст как отрицательный пример (отклоненный пост).
Args: Args:
text: Текст отклоненного поста text: Текст отклоненного поста
""" """

View File

@@ -9,6 +9,7 @@ import json
from typing import List, Optional from typing import List, Optional
import httpx import httpx
from helper_bot.utils.metrics import track_errors, track_time from helper_bot.utils.metrics import track_errors, track_time
from logs.custom_logger import logger from logs.custom_logger import logger
@@ -19,17 +20,17 @@ from .exceptions import DeepSeekAPIError, ScoringError, TextTooShortError
class DeepSeekService: class DeepSeekService:
""" """
Сервис для оценки постов через DeepSeek API. Сервис для оценки постов через DeepSeek API.
Отправляет текст поста в DeepSeek с промптом для оценки Отправляет текст поста в DeepSeek с промптом для оценки
и получает числовой скор релевантности. и получает числовой скор релевантности.
Attributes: Attributes:
api_key: API ключ DeepSeek api_key: API ключ DeepSeek
api_url: URL API эндпоинта api_url: URL API эндпоинта
model: Название модели model: Название модели
timeout: Таймаут запроса в секундах timeout: Таймаут запроса в секундах
""" """
# Промпт для оценки поста # Промпт для оценки поста
SCORING_PROMPT = """Роль: Ты — строгий и внимательный модератор сообщества в социальной сети, ориентированного на знакомства между людьми. Твоя задача — оценить, можно ли опубликовать пост, основываясь на четких правилах. SCORING_PROMPT = """Роль: Ты — строгий и внимательный модератор сообщества в социальной сети, ориентированного на знакомства между людьми. Твоя задача — оценить, можно ли опубликовать пост, основываясь на четких правилах.
@@ -77,7 +78,7 @@ class DeepSeekService:
DEFAULT_API_URL = "https://api.deepseek.com/v1/chat/completions" DEFAULT_API_URL = "https://api.deepseek.com/v1/chat/completions"
DEFAULT_MODEL = "deepseek-chat" DEFAULT_MODEL = "deepseek-chat"
def __init__( def __init__(
self, self,
api_key: Optional[str] = None, api_key: Optional[str] = None,
@@ -90,7 +91,7 @@ class DeepSeekService:
): ):
""" """
Инициализация DeepSeek сервиса. Инициализация DeepSeek сервиса.
Args: Args:
api_key: API ключ DeepSeek api_key: API ключ DeepSeek
api_url: URL API эндпоинта api_url: URL API эндпоинта
@@ -107,29 +108,29 @@ class DeepSeekService:
self._enabled = enabled and bool(api_key) self._enabled = enabled and bool(api_key)
self.min_text_length = min_text_length self.min_text_length = min_text_length
self.max_retries = max_retries self.max_retries = max_retries
# HTTP клиент (создается лениво) # HTTP клиент (создается лениво)
self._client: Optional[httpx.AsyncClient] = None self._client: Optional[httpx.AsyncClient] = None
if not api_key and enabled: if not api_key and enabled:
logger.warning("DeepSeekService: API ключ не указан, сервис отключен") logger.warning("DeepSeekService: API ключ не указан, сервис отключен")
self._enabled = False self._enabled = False
logger.info( logger.info(
f"DeepSeekService инициализирован " f"DeepSeekService инициализирован "
f"(model={self.model}, enabled={self._enabled})" f"(model={self.model}, enabled={self._enabled})"
) )
@property @property
def source_name(self) -> str: def source_name(self) -> str:
"""Имя источника для результатов.""" """Имя источника для результатов."""
return "deepseek" return "deepseek"
@property @property
def is_enabled(self) -> bool: def is_enabled(self) -> bool:
"""Проверяет, включен ли сервис.""" """Проверяет, включен ли сервис."""
return self._enabled return self._enabled
async def _get_client(self) -> httpx.AsyncClient: async def _get_client(self) -> httpx.AsyncClient:
"""Получает или создает HTTP клиент.""" """Получает или создает HTTP клиент."""
if self._client is None: if self._client is None:
@@ -141,101 +142,106 @@ class DeepSeekService:
}, },
) )
return self._client return self._client
async def close(self) -> None: async def close(self) -> None:
"""Закрывает HTTP клиент.""" """Закрывает HTTP клиент."""
if self._client: if self._client:
await self._client.aclose() await self._client.aclose()
self._client = None self._client = None
def _clean_text(self, text: str) -> str: def _clean_text(self, text: str) -> str:
"""Очищает текст от лишних символов.""" """Очищает текст от лишних символов."""
if not text: if not text:
return "" return ""
# Удаляем лишние пробелы и переносы строк # Удаляем лишние пробелы и переносы строк
clean = " ".join(text.split()) clean = " ".join(text.split())
# Удаляем служебные символы # Удаляем служебные символы
if clean == "^": if clean == "^":
return "" return ""
return clean.strip() return clean.strip()
def _parse_score_response(self, response_text: str) -> float: def _parse_score_response(self, response_text: str) -> float:
""" """
Парсит ответ от DeepSeek и извлекает скор. Парсит ответ от DeepSeek и извлекает скор.
Args: Args:
response_text: Текст ответа от API response_text: Текст ответа от API
Returns: Returns:
Числовой скор от 0.0 до 1.0 Числовой скор от 0.0 до 1.0
Raises: Raises:
DeepSeekAPIError: Если не удалось распарсить ответ DeepSeekAPIError: Если не удалось распарсить ответ
""" """
try: try:
# Пытаемся найти число в ответе # Пытаемся найти число в ответе
text = response_text.strip() text = response_text.strip()
# Убираем возможные обрамления # Убираем возможные обрамления
text = text.strip('"\'`') text = text.strip("\"'`")
# Пробуем распарсить как число # Пробуем распарсить как число
score = float(text) score = float(text)
# Ограничиваем диапазон # Ограничиваем диапазон
score = max(0.0, min(1.0, score)) score = max(0.0, min(1.0, score))
return score return score
except ValueError: except ValueError:
# Пробуем найти число в тексте # Пробуем найти число в тексте
import re import re
matches = re.findall(r'0\.\d+|1\.0|0|1', text)
matches = re.findall(r"0\.\d+|1\.0|0|1", text)
if matches: if matches:
score = float(matches[0]) score = float(matches[0])
return max(0.0, min(1.0, score)) return max(0.0, min(1.0, score))
logger.error(f"DeepSeekService: Не удалось распарсить ответ: {response_text}") logger.error(
raise DeepSeekAPIError(f"Не удалось распарсить скор из ответа: {response_text}") f"DeepSeekService: Не удалось распарсить ответ: {response_text}"
)
raise DeepSeekAPIError(
f"Не удалось распарсить скор из ответа: {response_text}"
)
@track_time("calculate_score", "deepseek_service") @track_time("calculate_score", "deepseek_service")
@track_errors("deepseek_service", "calculate_score") @track_errors("deepseek_service", "calculate_score")
async def calculate_score(self, text: str) -> ScoringResult: async def calculate_score(self, text: str) -> ScoringResult:
""" """
Рассчитывает скор для текста поста через DeepSeek API. Рассчитывает скор для текста поста через DeepSeek API.
Args: Args:
text: Текст поста для оценки text: Текст поста для оценки
Returns: Returns:
ScoringResult с оценкой ScoringResult с оценкой
Raises: Raises:
ScoringError: При ошибке расчета ScoringError: При ошибке расчета
""" """
if not self._enabled: if not self._enabled:
raise ScoringError("DeepSeek сервис отключен") raise ScoringError("DeepSeek сервис отключен")
# Очищаем текст # Очищаем текст
clean_text = self._clean_text(text) clean_text = self._clean_text(text)
if len(clean_text) < self.min_text_length: if len(clean_text) < self.min_text_length:
raise TextTooShortError( raise TextTooShortError(
f"Текст слишком короткий (минимум {self.min_text_length} символов)" f"Текст слишком короткий (минимум {self.min_text_length} символов)"
) )
# Формируем промпт # Формируем промпт
prompt = self.SCORING_PROMPT.format(text=clean_text) prompt = self.SCORING_PROMPT.format(text=clean_text)
# Выполняем запрос с повторными попытками # Выполняем запрос с повторными попытками
last_error = None last_error = None
for attempt in range(self.max_retries): for attempt in range(self.max_retries):
try: try:
score = await self._make_api_request(prompt) score = await self._make_api_request(prompt)
return ScoringResult( return ScoringResult(
score=score, score=score,
source=self.source_name, source=self.source_name,
@@ -245,7 +251,7 @@ class DeepSeekService:
"attempt": attempt + 1, "attempt": attempt + 1,
}, },
) )
except DeepSeekAPIError as e: except DeepSeekAPIError as e:
last_error = e last_error = e
logger.warning( logger.warning(
@@ -254,25 +260,27 @@ class DeepSeekService:
) )
if attempt < self.max_retries - 1: if attempt < self.max_retries - 1:
# Экспоненциальная задержка # Экспоненциальная задержка
await asyncio.sleep(2 ** attempt) await asyncio.sleep(2**attempt)
raise ScoringError(f"Все попытки запроса к DeepSeek API не удались: {last_error}") raise ScoringError(
f"Все попытки запроса к DeepSeek API не удались: {last_error}"
)
async def _make_api_request(self, prompt: str) -> float: async def _make_api_request(self, prompt: str) -> float:
""" """
Выполняет запрос к DeepSeek API. Выполняет запрос к DeepSeek API.
Args: Args:
prompt: Промпт для отправки prompt: Промпт для отправки
Returns: Returns:
Числовой скор от 0.0 до 1.0 Числовой скор от 0.0 до 1.0
Raises: Raises:
DeepSeekAPIError: При ошибке API DeepSeekAPIError: При ошибке API
""" """
client = await self._get_client() client = await self._get_client()
payload = { payload = {
"model": self.model, "model": self.model,
"messages": [ "messages": [
@@ -282,27 +290,27 @@ class DeepSeekService:
} }
], ],
"temperature": 0.1, # Низкая температура для детерминированности "temperature": 0.1, # Низкая температура для детерминированности
"max_tokens": 10, # Ожидаем только число "max_tokens": 10, # Ожидаем только число
} }
try: try:
response = await client.post(self.api_url, json=payload) response = await client.post(self.api_url, json=payload)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
# Извлекаем ответ # Извлекаем ответ
if "choices" not in data or not data["choices"]: if "choices" not in data or not data["choices"]:
raise DeepSeekAPIError("Пустой ответ от API") raise DeepSeekAPIError("Пустой ответ от API")
response_text = data["choices"][0]["message"]["content"] response_text = data["choices"][0]["message"]["content"]
# Парсим скор # Парсим скор
score = self._parse_score_response(response_text) score = self._parse_score_response(response_text)
logger.debug(f"DeepSeekService: Получен скор {score} для текста") logger.debug(f"DeepSeekService: Получен скор {score} для текста")
return score return score
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
error_msg = f"HTTP ошибка {e.response.status_code}" error_msg = f"HTTP ошибка {e.response.status_code}"
try: try:
@@ -312,40 +320,40 @@ class DeepSeekService:
except Exception: except Exception:
pass pass
raise DeepSeekAPIError(error_msg) raise DeepSeekAPIError(error_msg)
except httpx.TimeoutException: except httpx.TimeoutException:
raise DeepSeekAPIError(f"Таймаут запроса ({self.timeout}s)") raise DeepSeekAPIError(f"Таймаут запроса ({self.timeout}s)")
except Exception as e: except Exception as e:
raise DeepSeekAPIError(f"Ошибка запроса: {e}") raise DeepSeekAPIError(f"Ошибка запроса: {e}")
async def add_positive_example(self, text: str) -> None: async def add_positive_example(self, text: str) -> None:
""" """
Добавляет текст как положительный пример. Добавляет текст как положительный пример.
Для DeepSeek не требуется хранить примеры - оценка выполняется Для DeepSeek не требуется хранить примеры - оценка выполняется
на основе промпта. Метод существует для совместимости с протоколом. на основе промпта. Метод существует для совместимости с протоколом.
Args: Args:
text: Текст опубликованного поста text: Текст опубликованного поста
""" """
# DeepSeek не использует примеры для обучения # DeepSeek не использует примеры для обучения
# Промпт уже содержит критерии оценки # Промпт уже содержит критерии оценки
pass pass
async def add_negative_example(self, text: str) -> None: async def add_negative_example(self, text: str) -> None:
""" """
Добавляет текст как отрицательный пример. Добавляет текст как отрицательный пример.
Для DeepSeek не требуется хранить примеры - оценка выполняется Для DeepSeek не требуется хранить примеры - оценка выполняется
на основе промпта. Метод существует для совместимости с протоколом. на основе промпта. Метод существует для совместимости с протоколом.
Args: Args:
text: Текст отклоненного поста text: Текст отклоненного поста
""" """
# DeepSeek не использует примеры для обучения # DeepSeek не использует примеры для обучения
pass pass
def get_stats(self) -> dict: def get_stats(self) -> dict:
"""Возвращает статистику сервиса.""" """Возвращает статистику сервиса."""
return { return {

View File

@@ -5,29 +5,35 @@
class ScoringError(Exception): class ScoringError(Exception):
"""Базовое исключение для ошибок скоринга.""" """Базовое исключение для ошибок скоринга."""
pass pass
class ModelNotLoadedError(ScoringError): class ModelNotLoadedError(ScoringError):
"""Модель не загружена или недоступна.""" """Модель не загружена или недоступна."""
pass pass
class VectorStoreError(ScoringError): class VectorStoreError(ScoringError):
"""Ошибка при работе с хранилищем векторов.""" """Ошибка при работе с хранилищем векторов."""
pass pass
class DeepSeekAPIError(ScoringError): class DeepSeekAPIError(ScoringError):
"""Ошибка при обращении к DeepSeek API.""" """Ошибка при обращении к DeepSeek API."""
pass pass
class InsufficientExamplesError(ScoringError): class InsufficientExamplesError(ScoringError):
"""Недостаточно примеров для расчета скора.""" """Недостаточно примеров для расчета скора."""
pass pass
class TextTooShortError(ScoringError): class TextTooShortError(ScoringError):
"""Текст слишком короткий для векторизации.""" """Текст слишком короткий для векторизации."""
pass pass

View File

@@ -7,24 +7,24 @@ HTTP клиент для взаимодействия с внешним RAG се
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import httpx import httpx
from helper_bot.utils.metrics import track_errors, track_time from helper_bot.utils.metrics import track_errors, track_time
from logs.custom_logger import logger from logs.custom_logger import logger
from .base import ScoringResult from .base import ScoringResult
from .exceptions import (InsufficientExamplesError, ScoringError, from .exceptions import InsufficientExamplesError, ScoringError, TextTooShortError
TextTooShortError)
class RagApiClient: class RagApiClient:
""" """
HTTP клиент для взаимодействия с внешним RAG сервисом. HTTP клиент для взаимодействия с внешним RAG сервисом.
Использует REST API для: Использует REST API для:
- Получения скоров постов (POST /api/v1/score) - Получения скоров постов (POST /api/v1/score)
- Отправки положительных примеров (POST /api/v1/examples/positive) - Отправки положительных примеров (POST /api/v1/examples/positive)
- Отправки отрицательных примеров (POST /api/v1/examples/negative) - Отправки отрицательных примеров (POST /api/v1/examples/negative)
- Получения статистики (GET /api/v1/stats) - Получения статистики (GET /api/v1/stats)
Attributes: Attributes:
api_url: Базовый URL API сервиса api_url: Базовый URL API сервиса
api_key: API ключ для аутентификации api_key: API ключ для аутентификации
@@ -32,7 +32,7 @@ class RagApiClient:
test_mode: Включен ли тестовый режим (добавляет заголовок X-Test-Mode: true) test_mode: Включен ли тестовый режим (добавляет заголовок X-Test-Mode: true)
enabled: Включен ли клиент enabled: Включен ли клиент
""" """
def __init__( def __init__(
self, self,
api_url: str, api_url: str,
@@ -43,7 +43,7 @@ class RagApiClient:
): ):
""" """
Инициализация клиента. Инициализация клиента.
Args: Args:
api_url: Базовый URL API (например, http://хх.ххх.ххх.хх/api/v1) api_url: Базовый URL API (например, http://хх.ххх.ххх.хх/api/v1)
api_key: API ключ для аутентификации api_key: API ключ для аутентификации
@@ -52,49 +52,51 @@ class RagApiClient:
enabled: Включен ли клиент enabled: Включен ли клиент
""" """
# Убираем trailing slash если есть # Убираем trailing slash если есть
self.api_url = api_url.rstrip('/') self.api_url = api_url.rstrip("/")
self.api_key = api_key self.api_key = api_key
self.timeout = timeout self.timeout = timeout
self.test_mode = test_mode self.test_mode = test_mode
self._enabled = enabled self._enabled = enabled
# Создаем HTTP клиент # Создаем HTTP клиент
self._client = httpx.AsyncClient( self._client = httpx.AsyncClient(
timeout=httpx.Timeout(timeout), timeout=httpx.Timeout(timeout),
headers={ headers={
"X-API-Key": api_key, "X-API-Key": api_key,
"Content-Type": "application/json", "Content-Type": "application/json",
} },
) )
logger.info(f"RagApiClient инициализирован (url={self.api_url}, enabled={enabled})") logger.info(
f"RagApiClient инициализирован (url={self.api_url}, enabled={enabled})"
)
@property @property
def source_name(self) -> str: def source_name(self) -> str:
"""Имя источника для результатов.""" """Имя источника для результатов."""
return "rag" return "rag"
@property @property
def is_enabled(self) -> bool: def is_enabled(self) -> bool:
"""Проверяет, включен ли клиент.""" """Проверяет, включен ли клиент."""
return self._enabled return self._enabled
async def close(self) -> None: async def close(self) -> None:
"""Закрывает HTTP клиент.""" """Закрывает HTTP клиент."""
await self._client.aclose() await self._client.aclose()
@track_time("calculate_score", "rag_client") @track_time("calculate_score", "rag_client")
@track_errors("rag_client", "calculate_score") @track_errors("rag_client", "calculate_score")
async def calculate_score(self, text: str) -> ScoringResult: async def calculate_score(self, text: str) -> ScoringResult:
""" """
Рассчитывает скор для текста поста через API. Рассчитывает скор для текста поста через API.
Args: Args:
text: Текст поста для оценки text: Текст поста для оценки
Returns: Returns:
ScoringResult с оценкой ScoringResult с оценкой
Raises: Raises:
ScoringError: При ошибке расчета ScoringError: При ошибке расчета
InsufficientExamplesError: Если недостаточно примеров InsufficientExamplesError: Если недостаточно примеров
@@ -102,16 +104,15 @@ class RagApiClient:
""" """
if not self._enabled: if not self._enabled:
raise ScoringError("RAG API клиент отключен") raise ScoringError("RAG API клиент отключен")
if not text or not text.strip(): if not text or not text.strip():
raise TextTooShortError("Текст пустой") raise TextTooShortError("Текст пустой")
try: try:
response = await self._client.post( response = await self._client.post(
f"{self.api_url}/score", f"{self.api_url}/score", json={"text": text.strip()}
json={"text": text.strip()}
) )
# Обрабатываем различные статусы # Обрабатываем различные статусы
if response.status_code == 400: if response.status_code == 400:
try: try:
@@ -119,57 +120,70 @@ class RagApiClient:
error_msg = error_data.get("detail", "Неизвестная ошибка") error_msg = error_data.get("detail", "Неизвестная ошибка")
except Exception: except Exception:
error_msg = response.text or "Неизвестная ошибка" error_msg = response.text or "Неизвестная ошибка"
logger.warning(f"RagApiClient: Ошибка валидации запроса: {error_msg}") logger.warning(f"RagApiClient: Ошибка валидации запроса: {error_msg}")
if "недостаточно" in error_msg.lower() or "insufficient" in error_msg.lower(): if (
"недостаточно" in error_msg.lower()
or "insufficient" in error_msg.lower()
):
raise InsufficientExamplesError(error_msg) raise InsufficientExamplesError(error_msg)
if "коротк" in error_msg.lower() or "short" in error_msg.lower(): if "коротк" in error_msg.lower() or "short" in error_msg.lower():
raise TextTooShortError(error_msg) raise TextTooShortError(error_msg)
raise ScoringError(f"Ошибка валидации: {error_msg}") raise ScoringError(f"Ошибка валидации: {error_msg}")
if response.status_code == 401: if response.status_code == 401:
logger.error("RagApiClient: Ошибка аутентификации: неверный API ключ") logger.error("RagApiClient: Ошибка аутентификации: неверный API ключ")
raise ScoringError("Ошибка аутентификации: неверный API ключ") raise ScoringError("Ошибка аутентификации: неверный API ключ")
if response.status_code == 404: if response.status_code == 404:
logger.error("RagApiClient: RAG API endpoint не найден") logger.error("RagApiClient: RAG API endpoint не найден")
raise ScoringError("RAG API endpoint не найден") raise ScoringError("RAG API endpoint не найден")
if response.status_code >= 500: if response.status_code >= 500:
logger.error(f"RagApiClient: Ошибка сервера RAG API: {response.status_code}") logger.error(
f"RagApiClient: Ошибка сервера RAG API: {response.status_code}"
)
raise ScoringError(f"Ошибка сервера RAG API: {response.status_code}") raise ScoringError(f"Ошибка сервера RAG API: {response.status_code}")
# Проверяем успешный статус # Проверяем успешный статус
if response.status_code != 200: if response.status_code != 200:
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
# Парсим ответ # Парсим ответ
score = float(data.get("rag_score", 0.0)) score = float(data.get("rag_score", 0.0))
confidence = float(data.get("rag_confidence", 0.0)) if data.get("rag_confidence") is not None else None confidence = (
float(data.get("rag_confidence", 0.0))
if data.get("rag_confidence") is not None
else None
)
# Форматируем confidence для логирования # Форматируем confidence для логирования
confidence_str = f"{confidence:.4f}" if confidence is not None else "None" confidence_str = f"{confidence:.4f}" if confidence is not None else "None"
logger.info( logger.info(
f"RagApiClient: Скор успешно получен " f"RagApiClient: Скор успешно получен "
f"(score={score:.4f}, confidence={confidence_str})" f"(score={score:.4f}, confidence={confidence_str})"
) )
return ScoringResult( return ScoringResult(
score=score, score=score,
source=self.source_name, source=self.source_name,
model=data.get("meta", {}).get("model", "rag-service"), model=data.get("meta", {}).get("model", "rag-service"),
confidence=confidence, confidence=confidence,
metadata={ metadata={
"rag_score_pos_only": float(data.get("rag_score_pos_only", 0.0)) if data.get("rag_score_pos_only") is not None else None, "rag_score_pos_only": (
float(data.get("rag_score_pos_only", 0.0))
if data.get("rag_score_pos_only") is not None
else None
),
"positive_examples": data.get("meta", {}).get("positive_examples"), "positive_examples": data.get("meta", {}).get("positive_examples"),
"negative_examples": data.get("meta", {}).get("negative_examples"), "negative_examples": data.get("meta", {}).get("negative_examples"),
} },
) )
except httpx.TimeoutException: except httpx.TimeoutException:
logger.error(f"RagApiClient: Таймаут запроса к RAG API (>{self.timeout}с)") logger.error(f"RagApiClient: Таймаут запроса к RAG API (>{self.timeout}с)")
raise ScoringError(f"Таймаут запроса к RAG API (>{self.timeout}с)") raise ScoringError(f"Таймаут запроса к RAG API (>{self.timeout}с)")
@@ -177,7 +191,9 @@ class RagApiClient:
logger.error(f"RagApiClient: Ошибка подключения к RAG API: {e}") logger.error(f"RagApiClient: Ошибка подключения к RAG API: {e}")
raise ScoringError(f"Ошибка подключения к RAG API: {e}") raise ScoringError(f"Ошибка подключения к RAG API: {e}")
except (KeyError, ValueError, TypeError) as e: except (KeyError, ValueError, TypeError) as e:
logger.error(f"RagApiClient: Ошибка парсинга ответа: {e}, response: {response.text if 'response' in locals() else 'N/A'}") logger.error(
f"RagApiClient: Ошибка парсинга ответа: {e}, response: {response.text if 'response' in locals() else 'N/A'}"
)
raise ScoringError(f"Ошибка парсинга ответа от RAG API: {e}") raise ScoringError(f"Ошибка парсинга ответа от RAG API: {e}")
except InsufficientExamplesError: except InsufficientExamplesError:
raise raise
@@ -188,122 +204,145 @@ class RagApiClient:
raise raise
except Exception as e: except Exception as e:
# Только действительно неожиданные ошибки логируем здесь # Только действительно неожиданные ошибки логируем здесь
logger.error(f"RagApiClient: Неожиданная ошибка при расчете скора: {e}", exc_info=True) logger.error(
f"RagApiClient: Неожиданная ошибка при расчете скора: {e}",
exc_info=True,
)
raise ScoringError(f"Неожиданная ошибка: {e}") raise ScoringError(f"Неожиданная ошибка: {e}")
@track_time("add_positive_example", "rag_client") @track_time("add_positive_example", "rag_client")
async def add_positive_example(self, text: str) -> None: async def add_positive_example(self, text: str) -> None:
""" """
Добавляет текст как положительный пример (опубликованный пост). Добавляет текст как положительный пример (опубликованный пост).
Args: Args:
text: Текст опубликованного поста text: Текст опубликованного поста
""" """
if not self._enabled: if not self._enabled:
return return
if not text or not text.strip(): if not text or not text.strip():
return return
try: try:
# Формируем заголовки (добавляем X-Test-Mode если включен тестовый режим) # Формируем заголовки (добавляем X-Test-Mode если включен тестовый режим)
headers = {} headers = {}
if self.test_mode: if self.test_mode:
headers["X-Test-Mode"] = "true" headers["X-Test-Mode"] = "true"
response = await self._client.post( response = await self._client.post(
f"{self.api_url}/examples/positive", f"{self.api_url}/examples/positive",
json={"text": text.strip()}, json={"text": text.strip()},
headers=headers headers=headers,
) )
if response.status_code == 200 or response.status_code == 201: if response.status_code == 200 or response.status_code == 201:
logger.info("RagApiClient: Положительный пример успешно добавлен") logger.info("RagApiClient: Положительный пример успешно добавлен")
elif response.status_code == 400: elif response.status_code == 400:
logger.warning(f"RagApiClient: Ошибка валидации при добавлении положительного примера: {response.text}") logger.warning(
f"RagApiClient: Ошибка валидации при добавлении положительного примера: {response.text}"
)
else: else:
logger.warning(f"RagApiClient: Неожиданный статус при добавлении положительного примера: {response.status_code}") logger.warning(
f"RagApiClient: Неожиданный статус при добавлении положительного примера: {response.status_code}"
)
except httpx.TimeoutException: except httpx.TimeoutException:
logger.warning(f"RagApiClient: Таймаут при добавлении положительного примера") logger.warning(
f"RagApiClient: Таймаут при добавлении положительного примера"
)
except httpx.RequestError as e: except httpx.RequestError as e:
logger.warning(f"RagApiClient: Ошибка подключения при добавлении положительного примера: {e}") logger.warning(
f"RagApiClient: Ошибка подключения при добавлении положительного примера: {e}"
)
except Exception as e: except Exception as e:
logger.error(f"RagApiClient: Ошибка добавления положительного примера: {e}") logger.error(f"RagApiClient: Ошибка добавления положительного примера: {e}")
@track_time("add_negative_example", "rag_client") @track_time("add_negative_example", "rag_client")
async def add_negative_example(self, text: str) -> None: async def add_negative_example(self, text: str) -> None:
""" """
Добавляет текст как отрицательный пример (отклоненный пост). Добавляет текст как отрицательный пример (отклоненный пост).
Args: Args:
text: Текст отклоненного поста text: Текст отклоненного поста
""" """
if not self._enabled: if not self._enabled:
return return
if not text or not text.strip(): if not text or not text.strip():
return return
try: try:
# Формируем заголовки (добавляем X-Test-Mode если включен тестовый режим) # Формируем заголовки (добавляем X-Test-Mode если включен тестовый режим)
headers = {} headers = {}
if self.test_mode: if self.test_mode:
headers["X-Test-Mode"] = "true" headers["X-Test-Mode"] = "true"
response = await self._client.post( response = await self._client.post(
f"{self.api_url}/examples/negative", f"{self.api_url}/examples/negative",
json={"text": text.strip()}, json={"text": text.strip()},
headers=headers headers=headers,
) )
if response.status_code == 200 or response.status_code == 201: if response.status_code == 200 or response.status_code == 201:
logger.info("RagApiClient: Отрицательный пример успешно добавлен") logger.info("RagApiClient: Отрицательный пример успешно добавлен")
elif response.status_code == 400: elif response.status_code == 400:
logger.warning(f"RagApiClient: Ошибка валидации при добавлении отрицательного примера: {response.text}") logger.warning(
f"RagApiClient: Ошибка валидации при добавлении отрицательного примера: {response.text}"
)
else: else:
logger.warning(f"RagApiClient: Неожиданный статус при добавлении отрицательного примера: {response.status_code}") logger.warning(
f"RagApiClient: Неожиданный статус при добавлении отрицательного примера: {response.status_code}"
)
except httpx.TimeoutException: except httpx.TimeoutException:
logger.warning(f"RagApiClient: Таймаут при добавлении отрицательного примера") logger.warning(
f"RagApiClient: Таймаут при добавлении отрицательного примера"
)
except httpx.RequestError as e: except httpx.RequestError as e:
logger.warning(f"RagApiClient: Ошибка подключения при добавлении отрицательного примера: {e}") logger.warning(
f"RagApiClient: Ошибка подключения при добавлении отрицательного примера: {e}"
)
except Exception as e: except Exception as e:
logger.error(f"RagApiClient: Ошибка добавления отрицательного примера: {e}") logger.error(f"RagApiClient: Ошибка добавления отрицательного примера: {e}")
async def get_stats(self) -> Dict[str, Any]: async def get_stats(self) -> Dict[str, Any]:
""" """
Получает статистику от RAG API через endpoint /stats. Получает статистику от RAG API через endpoint /stats.
Returns: Returns:
Словарь со статистикой или пустой словарь при ошибке Словарь со статистикой или пустой словарь при ошибке
""" """
if not self._enabled: if not self._enabled:
return {} return {}
try: try:
response = await self._client.get(f"{self.api_url}/stats") response = await self._client.get(f"{self.api_url}/stats")
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
else: else:
logger.warning(f"RagApiClient: Неожиданный статус при получении статистики: {response.status_code}") logger.warning(
f"RagApiClient: Неожиданный статус при получении статистики: {response.status_code}"
)
return {} return {}
except httpx.TimeoutException: except httpx.TimeoutException:
logger.warning(f"RagApiClient: Таймаут при получении статистики") logger.warning(f"RagApiClient: Таймаут при получении статистики")
return {} return {}
except httpx.RequestError as e: except httpx.RequestError as e:
logger.warning(f"RagApiClient: Ошибка подключения при получении статистики: {e}") logger.warning(
f"RagApiClient: Ошибка подключения при получении статистики: {e}"
)
return {} return {}
except Exception as e: except Exception as e:
logger.error(f"RagApiClient: Ошибка получения статистики: {e}") logger.error(f"RagApiClient: Ошибка получения статистики: {e}")
return {} return {}
def get_stats_sync(self) -> Dict[str, Any]: def get_stats_sync(self) -> Dict[str, Any]:
""" """
Синхронная версия get_stats для использования в get_stats() ScoringManager. Синхронная версия get_stats для использования в get_stats() ScoringManager.
Внимание: Это заглушка, реальная статистика будет получена асинхронно. Внимание: Это заглушка, реальная статистика будет получена асинхронно.
""" """
return { return {

View File

@@ -13,23 +13,22 @@ from logs.custom_logger import logger
from .base import CombinedScore, ScoringResult from .base import CombinedScore, ScoringResult
from .deepseek_service import DeepSeekService from .deepseek_service import DeepSeekService
from .exceptions import (InsufficientExamplesError, ScoringError, from .exceptions import InsufficientExamplesError, ScoringError, TextTooShortError
TextTooShortError)
from .rag_client import RagApiClient from .rag_client import RagApiClient
class ScoringManager: class ScoringManager:
""" """
Менеджер для управления всеми сервисами скоринга. Менеджер для управления всеми сервисами скоринга.
Объединяет RagApiClient и DeepSeekService, выполняет параллельные Объединяет RagApiClient и DeepSeekService, выполняет параллельные
запросы и агрегирует результаты в единый CombinedScore. запросы и агрегирует результаты в единый CombinedScore.
Attributes: Attributes:
rag_client: HTTP клиент для RAG API rag_client: HTTP клиент для RAG API
deepseek_service: Сервис DeepSeek API deepseek_service: Сервис DeepSeek API
""" """
def __init__( def __init__(
self, self,
rag_client: Optional[RagApiClient] = None, rag_client: Optional[RagApiClient] = None,
@@ -37,68 +36,70 @@ class ScoringManager:
): ):
""" """
Инициализация менеджера. Инициализация менеджера.
Args: Args:
rag_client: HTTP клиент для RAG API (создается автоматически если не передан) rag_client: HTTP клиент для RAG API (создается автоматически если не передан)
deepseek_service: Сервис DeepSeek (создается автоматически если не передан) deepseek_service: Сервис DeepSeek (создается автоматически если не передан)
""" """
self.rag_client = rag_client self.rag_client = rag_client
self.deepseek_service = deepseek_service self.deepseek_service = deepseek_service
logger.info( logger.info(
f"ScoringManager инициализирован " f"ScoringManager инициализирован "
f"(rag={rag_client is not None and rag_client.is_enabled}, " f"(rag={rag_client is not None and rag_client.is_enabled}, "
f"deepseek={deepseek_service is not None and deepseek_service.is_enabled})" f"deepseek={deepseek_service is not None and deepseek_service.is_enabled})"
) )
@property @property
def is_any_enabled(self) -> bool: def is_any_enabled(self) -> bool:
"""Проверяет, включен ли хотя бы один сервис.""" """Проверяет, включен ли хотя бы один сервис."""
rag_enabled = self.rag_client is not None and self.rag_client.is_enabled rag_enabled = self.rag_client is not None and self.rag_client.is_enabled
deepseek_enabled = self.deepseek_service is not None and self.deepseek_service.is_enabled deepseek_enabled = (
self.deepseek_service is not None and self.deepseek_service.is_enabled
)
return rag_enabled or deepseek_enabled return rag_enabled or deepseek_enabled
@track_time("score_post", "scoring_manager") @track_time("score_post", "scoring_manager")
@track_errors("scoring_manager", "score_post") @track_errors("scoring_manager", "score_post")
async def score_post(self, text: str) -> CombinedScore: async def score_post(self, text: str) -> CombinedScore:
""" """
Рассчитывает скоры для текста поста от всех сервисов. Рассчитывает скоры для текста поста от всех сервисов.
Выполняет запросы параллельно для минимизации задержки. Выполняет запросы параллельно для минимизации задержки.
Args: Args:
text: Текст поста для оценки text: Текст поста для оценки
Returns: Returns:
CombinedScore с результатами от всех сервисов CombinedScore с результатами от всех сервисов
""" """
result = CombinedScore() result = CombinedScore()
if not text or not text.strip(): if not text or not text.strip():
logger.debug("ScoringManager: Пустой текст, пропускаем скоринг") logger.debug("ScoringManager: Пустой текст, пропускаем скоринг")
return result return result
# Собираем задачи для параллельного выполнения # Собираем задачи для параллельного выполнения
tasks = [] tasks = []
task_names = [] task_names = []
# RAG API клиент # RAG API клиент
if self.rag_client and self.rag_client.is_enabled: if self.rag_client and self.rag_client.is_enabled:
tasks.append(self._get_rag_score(text)) tasks.append(self._get_rag_score(text))
task_names.append("rag") task_names.append("rag")
# DeepSeek сервис # DeepSeek сервис
if self.deepseek_service and self.deepseek_service.is_enabled: if self.deepseek_service and self.deepseek_service.is_enabled:
tasks.append(self._get_deepseek_score(text)) tasks.append(self._get_deepseek_score(text))
task_names.append("deepseek") task_names.append("deepseek")
if not tasks: if not tasks:
logger.debug("ScoringManager: Нет активных сервисов для скоринга") logger.debug("ScoringManager: Нет активных сервисов для скоринга")
return result return result
# Выполняем параллельно # Выполняем параллельно
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
# Обрабатываем результаты # Обрабатываем результаты
for name, res in zip(task_names, results): for name, res in zip(task_names, results):
if isinstance(res, Exception): if isinstance(res, Exception):
@@ -111,14 +112,14 @@ class ScoringManager:
result.rag = res result.rag = res
elif name == "deepseek": elif name == "deepseek":
result.deepseek = res result.deepseek = res
logger.info( logger.info(
f"ScoringManager: Скоринг завершен " f"ScoringManager: Скоринг завершен "
f"(rag={result.rag_score}, deepseek={result.deepseek_score})" f"(rag={result.rag_score}, deepseek={result.deepseek_score})"
) )
return result return result
async def _get_rag_score(self, text: str) -> Optional[ScoringResult]: async def _get_rag_score(self, text: str) -> Optional[ScoringResult]:
"""Получает скор от RAG API.""" """Получает скор от RAG API."""
try: try:
@@ -134,7 +135,7 @@ class ScoringManager:
except Exception as e: except Exception as e:
# Ошибки уже залогированы в RagApiClient, здесь только пробрасываем # Ошибки уже залогированы в RagApiClient, здесь только пробрасываем
raise raise
async def _get_deepseek_score(self, text: str) -> Optional[ScoringResult]: async def _get_deepseek_score(self, text: str) -> Optional[ScoringResult]:
"""Получает скор от DeepSeek сервиса.""" """Получает скор от DeepSeek сервиса."""
try: try:
@@ -146,78 +147,77 @@ class ScoringManager:
except Exception as e: except Exception as e:
# Ошибки уже залогированы в DeepSeekService, здесь только пробрасываем # Ошибки уже залогированы в DeepSeekService, здесь только пробрасываем
raise raise
@track_time("on_post_published", "scoring_manager") @track_time("on_post_published", "scoring_manager")
async def on_post_published(self, text: str) -> None: async def on_post_published(self, text: str) -> None:
""" """
Вызывается при публикации поста. Вызывается при публикации поста.
Добавляет текст как положительный пример для обучения RAG. Добавляет текст как положительный пример для обучения RAG.
Args: Args:
text: Текст опубликованного поста text: Текст опубликованного поста
""" """
if not text or not text.strip(): if not text or not text.strip():
return return
tasks = [] tasks = []
if self.rag_client and self.rag_client.is_enabled: if self.rag_client and self.rag_client.is_enabled:
tasks.append(self.rag_client.add_positive_example(text)) tasks.append(self.rag_client.add_positive_example(text))
if self.deepseek_service and self.deepseek_service.is_enabled: if self.deepseek_service and self.deepseek_service.is_enabled:
tasks.append(self.deepseek_service.add_positive_example(text)) tasks.append(self.deepseek_service.add_positive_example(text))
if tasks: if tasks:
await asyncio.gather(*tasks, return_exceptions=True) await asyncio.gather(*tasks, return_exceptions=True)
logger.info("ScoringManager: Добавлен положительный пример") logger.info("ScoringManager: Добавлен положительный пример")
@track_time("on_post_declined", "scoring_manager") @track_time("on_post_declined", "scoring_manager")
async def on_post_declined(self, text: str) -> None: async def on_post_declined(self, text: str) -> None:
""" """
Вызывается при отклонении поста. Вызывается при отклонении поста.
Добавляет текст как отрицательный пример для обучения RAG. Добавляет текст как отрицательный пример для обучения RAG.
Args: Args:
text: Текст отклоненного поста text: Текст отклоненного поста
""" """
if not text or not text.strip(): if not text or not text.strip():
return return
tasks = [] tasks = []
if self.rag_client and self.rag_client.is_enabled: if self.rag_client and self.rag_client.is_enabled:
tasks.append(self.rag_client.add_negative_example(text)) tasks.append(self.rag_client.add_negative_example(text))
if self.deepseek_service and self.deepseek_service.is_enabled: if self.deepseek_service and self.deepseek_service.is_enabled:
tasks.append(self.deepseek_service.add_negative_example(text)) tasks.append(self.deepseek_service.add_negative_example(text))
if tasks: if tasks:
await asyncio.gather(*tasks, return_exceptions=True) await asyncio.gather(*tasks, return_exceptions=True)
logger.info("ScoringManager: Добавлен отрицательный пример") logger.info("ScoringManager: Добавлен отрицательный пример")
async def close(self) -> None: async def close(self) -> None:
"""Закрывает ресурсы всех сервисов.""" """Закрывает ресурсы всех сервисов."""
if self.deepseek_service: if self.deepseek_service:
await self.deepseek_service.close() await self.deepseek_service.close()
if self.rag_client: if self.rag_client:
await self.rag_client.close() await self.rag_client.close()
async def get_stats(self) -> dict: async def get_stats(self) -> dict:
"""Возвращает статистику всех сервисов.""" """Возвращает статистику всех сервисов."""
stats = { stats = {
"any_enabled": self.is_any_enabled, "any_enabled": self.is_any_enabled,
} }
if self.rag_client: if self.rag_client:
# Получаем статистику асинхронно от API # Получаем статистику асинхронно от API
rag_stats = await self.rag_client.get_stats() rag_stats = await self.rag_client.get_stats()
stats["rag"] = rag_stats if rag_stats else self.rag_client.get_stats_sync() stats["rag"] = rag_stats if rag_stats else self.rag_client.get_stats_sync()
if self.deepseek_service: if self.deepseek_service:
stats["deepseek"] = self.deepseek_service.get_stats() stats["deepseek"] = self.deepseek_service.get_stats()
return stats return stats

View File

@@ -4,6 +4,7 @@ from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.base_dependency_factory import get_global_instance
from logs.custom_logger import logger from logs.custom_logger import logger
@@ -15,17 +16,17 @@ class AutoUnbanScheduler:
Класс для автоматического разбана пользователей по истечении срока блокировки. Класс для автоматического разбана пользователей по истечении срока блокировки.
Запускается ежедневно в 5:00 по московскому времени. Запускается ежедневно в 5:00 по московскому времени.
""" """
def __init__(self): def __init__(self):
self.bdf = get_global_instance() self.bdf = get_global_instance()
self.bot_db = self.bdf.get_db() self.bot_db = self.bdf.get_db()
self.scheduler = AsyncIOScheduler() self.scheduler = AsyncIOScheduler()
self.bot = None # Будет установлен позже self.bot = None # Будет установлен позже
def set_bot(self, bot): def set_bot(self, bot):
"""Устанавливает экземпляр бота для отправки уведомлений""" """Устанавливает экземпляр бота для отправки уведомлений"""
self.bot = bot self.bot = bot
@track_time("auto_unban_users", "auto_unban_scheduler") @track_time("auto_unban_users", "auto_unban_scheduler")
@track_errors("auto_unban_scheduler", "auto_unban_users") @track_errors("auto_unban_scheduler", "auto_unban_users")
@db_query_time("auto_unban_users", "users", "mixed") @db_query_time("auto_unban_users", "users", "mixed")
@@ -37,26 +38,32 @@ class AutoUnbanScheduler:
""" """
try: try:
logger.info("Запуск автоматического разбана пользователей") logger.info("Запуск автоматического разбана пользователей")
# Получаем текущий UNIX timestamp # Получаем текущий UNIX timestamp
current_timestamp = int(datetime.now().timestamp()) current_timestamp = int(datetime.now().timestamp())
logger.info(f"Поиск пользователей для разблокировки на timestamp: {current_timestamp}") logger.info(
f"Поиск пользователей для разблокировки на timestamp: {current_timestamp}"
)
# Получаем список пользователей для разблокировки # Получаем список пользователей для разблокировки
users_to_unban = await self.bot_db.get_users_for_unblock_today(current_timestamp) users_to_unban = await self.bot_db.get_users_for_unblock_today(
current_timestamp
)
if not users_to_unban: if not users_to_unban:
logger.info("Нет пользователей для разблокировки сегодня") logger.info("Нет пользователей для разблокировки сегодня")
return return
logger.info(f"Найдено {len(users_to_unban)} пользователей для разблокировки") logger.info(
f"Найдено {len(users_to_unban)} пользователей для разблокировки"
)
# Список для отслеживания результатов # Список для отслеживания результатов
success_count = 0 success_count = 0
failed_count = 0 failed_count = 0
failed_users = [] failed_users = []
# Разблокируем каждого пользователя # Разблокируем каждого пользователя
for user_id in users_to_unban: for user_id in users_to_unban:
try: try:
@@ -71,92 +78,99 @@ class AutoUnbanScheduler:
except Exception as e: except Exception as e:
failed_count += 1 failed_count += 1
failed_users.append(f"{user_id}") failed_users.append(f"{user_id}")
logger.error(f"Исключение при разблокировке пользователя {user_id}: {e}") logger.error(
f"Исключение при разблокировке пользователя {user_id}: {e}"
)
# Формируем отчет # Формируем отчет
report = self._generate_report(success_count, failed_count, failed_users, users_to_unban) report = self._generate_report(
success_count, failed_count, failed_users, users_to_unban
)
# Отправляем отчет в лог-канал # Отправляем отчет в лог-канал
await self._send_report(report) await self._send_report(report)
logger.info(f"Автоматический разбан завершен. Успешно: {success_count}, Ошибок: {failed_count}") logger.info(
f"Автоматический разбан завершен. Успешно: {success_count}, Ошибок: {failed_count}"
)
except Exception as e: except Exception as e:
error_msg = f"Критическая ошибка в автоматическом разбане: {e}" error_msg = f"Критическая ошибка в автоматическом разбане: {e}"
logger.error(error_msg) logger.error(error_msg)
await self._send_error_report(error_msg) await self._send_error_report(error_msg)
def _generate_report(self, success_count: int, failed_count: int, def _generate_report(
failed_users: list, all_users: dict) -> str: self, success_count: int, failed_count: int, failed_users: list, all_users: dict
) -> str:
"""Генерирует отчет о результатах автоматического разбана""" """Генерирует отчет о результатах автоматического разбана"""
report = f"🤖 <b>Отчет об автоматическом разбане</b>\n\n" report = f"🤖 <b>Отчет об автоматическом разбане</b>\n\n"
report += f"📅 Дата: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n" report += f"📅 Дата: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n"
report += f"✅ Успешно разблокировано: {success_count}\n" report += f"✅ Успешно разблокировано: {success_count}\n"
report += f"❌ Ошибок: {failed_count}\n\n" report += f"❌ Ошибок: {failed_count}\n\n"
if success_count > 0: if success_count > 0:
report += "✅ <b>Разблокированные пользователи:</b>\n" report += "✅ <b>Разблокированные пользователи:</b>\n"
for user_id in all_users: for user_id in all_users:
if str(user_id) not in failed_users: if str(user_id) not in failed_users:
report += f"• ID: {user_id}\n" report += f"• ID: {user_id}\n"
report += "\n" report += "\n"
if failed_users: if failed_users:
report += "❌ <b>Ошибки при разблокировке:</b>\n" report += "❌ <b>Ошибки при разблокировке:</b>\n"
for user in failed_users: for user in failed_users:
report += f"{user}\n" report += f"{user}\n"
return report return report
@track_time("send_report", "auto_unban_scheduler") @track_time("send_report", "auto_unban_scheduler")
@track_errors("auto_unban_scheduler", "send_report") @track_errors("auto_unban_scheduler", "send_report")
async def _send_report(self, report: str): async def _send_report(self, report: str):
"""Отправляет отчет в лог-канал""" """Отправляет отчет в лог-канал"""
try: try:
if self.bot: if self.bot:
group_for_logs = self.bdf.settings['Telegram']['group_for_logs'] group_for_logs = self.bdf.settings["Telegram"]["group_for_logs"]
await self.bot.send_message( await self.bot.send_message(
chat_id=group_for_logs, chat_id=group_for_logs, text=report, parse_mode="HTML"
text=report,
parse_mode='HTML'
) )
except Exception as e: except Exception as e:
logger.error(f"Ошибка при отправке отчета: {e}") logger.error(f"Ошибка при отправке отчета: {e}")
@track_time("send_error_report", "auto_unban_scheduler") @track_time("send_error_report", "auto_unban_scheduler")
@track_errors("auto_unban_scheduler", "send_error_report") @track_errors("auto_unban_scheduler", "send_error_report")
async def _send_error_report(self, error_msg: str): async def _send_error_report(self, error_msg: str):
"""Отправляет отчет об ошибке в важный лог-канал""" """Отправляет отчет об ошибке в важный лог-канал"""
try: try:
if self.bot: if self.bot:
important_logs = self.bdf.settings['Telegram']['important_logs'] important_logs = self.bdf.settings["Telegram"]["important_logs"]
await self.bot.send_message( await self.bot.send_message(
chat_id=important_logs, chat_id=important_logs,
text=f"🚨 <b>Ошибка автоматического разбана</b>\n\n{error_msg}", text=f"🚨 <b>Ошибка автоматического разбана</b>\n\n{error_msg}",
parse_mode='HTML' parse_mode="HTML",
) )
except Exception as e: except Exception as e:
logger.error(f"Ошибка при отправке отчета об ошибке: {e}") logger.error(f"Ошибка при отправке отчета об ошибке: {e}")
def start_scheduler(self): def start_scheduler(self):
"""Запускает планировщик задач""" """Запускает планировщик задач"""
try: try:
# Добавляем задачу на ежедневное выполнение в 5:00 по Москве # Добавляем задачу на ежедневное выполнение в 5:00 по Москве
self.scheduler.add_job( self.scheduler.add_job(
self.auto_unban_users, self.auto_unban_users,
CronTrigger(hour=5, minute=0, timezone='Europe/Moscow'), CronTrigger(hour=5, minute=0, timezone="Europe/Moscow"),
id='auto_unban_users', id="auto_unban_users",
name='Автоматический разбан пользователей', name="Автоматический разбан пользователей",
replace_existing=True replace_existing=True,
) )
# Запускаем планировщик # Запускаем планировщик
self.scheduler.start() self.scheduler.start()
logger.info("Планировщик автоматического разбана запущен. Задача запланирована на 5:00 по Москве") logger.info(
"Планировщик автоматического разбана запущен. Задача запланирована на 5:00 по Москве"
)
except Exception as e: except Exception as e:
logger.error(f"Ошибка при запуске планировщика: {e}") logger.error(f"Ошибка при запуске планировщика: {e}")
def stop_scheduler(self): def stop_scheduler(self):
"""Останавливает планировщик задач""" """Останавливает планировщик задач"""
try: try:
@@ -165,7 +179,7 @@ class AutoUnbanScheduler:
logger.info("Планировщик автоматического разбана остановлен") logger.info("Планировщик автоматического разбана остановлен")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при остановке планировщика: {e}") logger.error(f"Ошибка при остановке планировщика: {e}")
async def run_manual_unban(self): async def run_manual_unban(self):
"""Запускает разбан вручную (для тестирования)""" """Запускает разбан вручную (для тестирования)"""
logger.info("Запуск ручного разбана пользователей") logger.info("Запуск ручного разбана пользователей")

View File

@@ -2,23 +2,26 @@ import os
import sys import sys
from typing import Optional from typing import Optional
from database.async_db import AsyncBotDB
from dotenv import load_dotenv from dotenv import load_dotenv
from database.async_db import AsyncBotDB
from helper_bot.utils.s3_storage import S3StorageService from helper_bot.utils.s3_storage import S3StorageService
from logs.custom_logger import logger from logs.custom_logger import logger
class BaseDependencyFactory: class BaseDependencyFactory:
def __init__(self): def __init__(self):
project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) project_dir = os.path.dirname(
env_path = os.path.join(project_dir, '.env') os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
env_path = os.path.join(project_dir, ".env")
if os.path.exists(env_path): if os.path.exists(env_path):
load_dotenv(env_path) load_dotenv(env_path)
self.settings = {} self.settings = {}
self._project_dir = project_dir self._project_dir = project_dir
database_path = os.getenv('DATABASE_PATH', 'database/tg-bot-database.db') database_path = os.getenv("DATABASE_PATH", "database/tg-bot-database.db")
if not os.path.isabs(database_path): if not os.path.isabs(database_path):
database_path = os.path.join(project_dir, database_path) database_path = os.path.join(project_dir, database_path)
@@ -26,78 +29,87 @@ class BaseDependencyFactory:
self._load_settings_from_env() self._load_settings_from_env()
self._init_s3_storage() self._init_s3_storage()
# ScoringManager инициализируется лениво # ScoringManager инициализируется лениво
self._scoring_manager = None self._scoring_manager = None
def _load_settings_from_env(self): def _load_settings_from_env(self):
"""Загружает настройки из переменных окружения.""" """Загружает настройки из переменных окружения."""
self.settings['Telegram'] = { self.settings["Telegram"] = {
'bot_token': os.getenv('BOT_TOKEN', ''), "bot_token": os.getenv("BOT_TOKEN", ""),
'listen_bot_token': os.getenv('LISTEN_BOT_TOKEN', ''), "listen_bot_token": os.getenv("LISTEN_BOT_TOKEN", ""),
'test_bot_token': os.getenv('TEST_BOT_TOKEN', ''), "test_bot_token": os.getenv("TEST_BOT_TOKEN", ""),
'preview_link': self._parse_bool(os.getenv('PREVIEW_LINK', 'false')), "preview_link": self._parse_bool(os.getenv("PREVIEW_LINK", "false")),
'main_public': os.getenv('MAIN_PUBLIC', ''), "main_public": os.getenv("MAIN_PUBLIC", ""),
'group_for_posts': self._parse_int(os.getenv('GROUP_FOR_POSTS', '0')), "group_for_posts": self._parse_int(os.getenv("GROUP_FOR_POSTS", "0")),
'group_for_message': self._parse_int(os.getenv('GROUP_FOR_MESSAGE', '0')), "group_for_message": self._parse_int(os.getenv("GROUP_FOR_MESSAGE", "0")),
'group_for_logs': self._parse_int(os.getenv('GROUP_FOR_LOGS', '0')), "group_for_logs": self._parse_int(os.getenv("GROUP_FOR_LOGS", "0")),
'important_logs': self._parse_int(os.getenv('IMPORTANT_LOGS', '0')), "important_logs": self._parse_int(os.getenv("IMPORTANT_LOGS", "0")),
'archive': self._parse_int(os.getenv('ARCHIVE', '0')), "archive": self._parse_int(os.getenv("ARCHIVE", "0")),
'test_group': self._parse_int(os.getenv('TEST_GROUP', '0')) "test_group": self._parse_int(os.getenv("TEST_GROUP", "0")),
} }
self.settings['Settings'] = { self.settings["Settings"] = {
'logs': self._parse_bool(os.getenv('LOGS', 'false')), "logs": self._parse_bool(os.getenv("LOGS", "false")),
'test': self._parse_bool(os.getenv('TEST', 'false')) "test": self._parse_bool(os.getenv("TEST", "false")),
} }
self.settings['Metrics'] = { self.settings["Metrics"] = {
'host': os.getenv('METRICS_HOST', '0.0.0.0'), "host": os.getenv("METRICS_HOST", "0.0.0.0"),
'port': self._parse_int(os.getenv('METRICS_PORT', '8080')) "port": self._parse_int(os.getenv("METRICS_PORT", "8080")),
} }
self.settings['S3'] = { self.settings["S3"] = {
'enabled': self._parse_bool(os.getenv('S3_ENABLED', 'false')), "enabled": self._parse_bool(os.getenv("S3_ENABLED", "false")),
'endpoint_url': os.getenv('S3_ENDPOINT_URL', ''), "endpoint_url": os.getenv("S3_ENDPOINT_URL", ""),
'access_key': os.getenv('S3_ACCESS_KEY', ''), "access_key": os.getenv("S3_ACCESS_KEY", ""),
'secret_key': os.getenv('S3_SECRET_KEY', ''), "secret_key": os.getenv("S3_SECRET_KEY", ""),
'bucket_name': os.getenv('S3_BUCKET_NAME', ''), "bucket_name": os.getenv("S3_BUCKET_NAME", ""),
'region': os.getenv('S3_REGION', 'us-east-1') "region": os.getenv("S3_REGION", "us-east-1"),
} }
# Настройки ML-скоринга # Настройки ML-скоринга
self.settings['Scoring'] = { self.settings["Scoring"] = {
# RAG API # RAG API
'rag_enabled': self._parse_bool(os.getenv('RAG_ENABLED', 'false')), "rag_enabled": self._parse_bool(os.getenv("RAG_ENABLED", "false")),
'rag_api_url': os.getenv('RAG_API_URL', ''), "rag_api_url": os.getenv("RAG_API_URL", ""),
'rag_api_key': os.getenv('RAG_API_KEY', ''), "rag_api_key": os.getenv("RAG_API_KEY", ""),
'rag_api_timeout': self._parse_int(os.getenv('RAG_API_TIMEOUT', '30')), "rag_api_timeout": self._parse_int(os.getenv("RAG_API_TIMEOUT", "30")),
'rag_test_mode': self._parse_bool(os.getenv('RAG_TEST_MODE', 'false')), "rag_test_mode": self._parse_bool(os.getenv("RAG_TEST_MODE", "false")),
# DeepSeek # DeepSeek
'deepseek_enabled': self._parse_bool(os.getenv('DEEPSEEK_ENABLED', 'false')), "deepseek_enabled": self._parse_bool(
'deepseek_api_key': os.getenv('DEEPSEEK_API_KEY', ''), os.getenv("DEEPSEEK_ENABLED", "false")
'deepseek_api_url': os.getenv('DEEPSEEK_API_URL', 'https://api.deepseek.com/v1/chat/completions'), ),
'deepseek_model': os.getenv('DEEPSEEK_MODEL', 'deepseek-chat'), "deepseek_api_key": os.getenv("DEEPSEEK_API_KEY", ""),
'deepseek_timeout': self._parse_int(os.getenv('DEEPSEEK_TIMEOUT', '30')), "deepseek_api_url": os.getenv(
"DEEPSEEK_API_URL", "https://api.deepseek.com/v1/chat/completions"
),
"deepseek_model": os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
"deepseek_timeout": self._parse_int(os.getenv("DEEPSEEK_TIMEOUT", "30")),
} }
def _init_s3_storage(self): def _init_s3_storage(self):
"""Инициализирует S3StorageService если S3 включен.""" """Инициализирует S3StorageService если S3 включен."""
self.s3_storage = None self.s3_storage = None
if self.settings['S3']['enabled']: if self.settings["S3"]["enabled"]:
s3_config = self.settings['S3'] s3_config = self.settings["S3"]
if s3_config['endpoint_url'] and s3_config['access_key'] and s3_config['secret_key'] and s3_config['bucket_name']: if (
s3_config["endpoint_url"]
and s3_config["access_key"]
and s3_config["secret_key"]
and s3_config["bucket_name"]
):
self.s3_storage = S3StorageService( self.s3_storage = S3StorageService(
endpoint_url=s3_config['endpoint_url'], endpoint_url=s3_config["endpoint_url"],
access_key=s3_config['access_key'], access_key=s3_config["access_key"],
secret_key=s3_config['secret_key'], secret_key=s3_config["secret_key"],
bucket_name=s3_config['bucket_name'], bucket_name=s3_config["bucket_name"],
region=s3_config['region'] region=s3_config["region"],
) )
def _parse_bool(self, value: str) -> bool: def _parse_bool(self, value: str) -> bool:
"""Парсит строковое значение в boolean.""" """Парсит строковое значение в boolean."""
return value.lower() in ('true', '1', 'yes', 'on') return value.lower() in ("true", "1", "yes", "on")
def _parse_int(self, value: str) -> int: def _parse_int(self, value: str) -> int:
"""Парсит строковое значение в integer.""" """Парсит строковое значение в integer."""
@@ -105,7 +117,7 @@ class BaseDependencyFactory:
return int(value) return int(value)
except (ValueError, TypeError): except (ValueError, TypeError):
return 0 return 0
def _parse_float(self, value: str) -> float: def _parse_float(self, value: str) -> float:
"""Парсит строковое значение в float.""" """Парсит строковое значение в float."""
try: try:
@@ -119,87 +131,95 @@ class BaseDependencyFactory:
def get_db(self) -> AsyncBotDB: def get_db(self) -> AsyncBotDB:
"""Возвращает подключение к базе данных.""" """Возвращает подключение к базе данных."""
return self.database return self.database
def get_s3_storage(self) -> Optional[S3StorageService]: def get_s3_storage(self) -> Optional[S3StorageService]:
"""Возвращает S3StorageService если S3 включен, иначе None.""" """Возвращает S3StorageService если S3 включен, иначе None."""
return self.s3_storage return self.s3_storage
def _init_scoring_manager(self): def _init_scoring_manager(self):
""" """
Инициализирует ScoringManager с RAG API клиентом и DeepSeek сервисом. Инициализирует ScoringManager с RAG API клиентом и DeepSeek сервисом.
Вызывается лениво при первом обращении к get_scoring_manager(). Вызывается лениво при первом обращении к get_scoring_manager().
""" """
from helper_bot.services.scoring import (DeepSeekService, RagApiClient, from helper_bot.services.scoring import (
ScoringManager) DeepSeekService,
RagApiClient,
scoring_config = self.settings['Scoring'] ScoringManager,
)
scoring_config = self.settings["Scoring"]
# Инициализация RAG API клиента # Инициализация RAG API клиента
rag_client = None rag_client = None
if scoring_config['rag_enabled']: if scoring_config["rag_enabled"]:
api_url = scoring_config['rag_api_url'] api_url = scoring_config["rag_api_url"]
api_key = scoring_config['rag_api_key'] api_key = scoring_config["rag_api_key"]
if not api_url or not api_key: if not api_url or not api_key:
logger.warning("RAG включен, но не указаны RAG_API_URL или RAG_API_KEY") logger.warning("RAG включен, но не указаны RAG_API_URL или RAG_API_KEY")
else: else:
rag_client = RagApiClient( rag_client = RagApiClient(
api_url=api_url, api_url=api_url,
api_key=api_key, api_key=api_key,
timeout=scoring_config['rag_api_timeout'], timeout=scoring_config["rag_api_timeout"],
test_mode=scoring_config['rag_test_mode'], test_mode=scoring_config["rag_test_mode"],
enabled=True, enabled=True,
) )
logger.info(f"RagApiClient инициализирован: {api_url} (test_mode={scoring_config['rag_test_mode']})") logger.info(
f"RagApiClient инициализирован: {api_url} (test_mode={scoring_config['rag_test_mode']})"
)
# Инициализация DeepSeek сервиса # Инициализация DeepSeek сервиса
deepseek_service = None deepseek_service = None
if scoring_config['deepseek_enabled'] and scoring_config['deepseek_api_key']: if scoring_config["deepseek_enabled"] and scoring_config["deepseek_api_key"]:
deepseek_service = DeepSeekService( deepseek_service = DeepSeekService(
api_key=scoring_config['deepseek_api_key'], api_key=scoring_config["deepseek_api_key"],
api_url=scoring_config['deepseek_api_url'], api_url=scoring_config["deepseek_api_url"],
model=scoring_config['deepseek_model'], model=scoring_config["deepseek_model"],
timeout=scoring_config['deepseek_timeout'], timeout=scoring_config["deepseek_timeout"],
enabled=True, enabled=True,
) )
logger.info(f"DeepSeekService инициализирован: {scoring_config['deepseek_model']}") logger.info(
f"DeepSeekService инициализирован: {scoring_config['deepseek_model']}"
)
# Создаем менеджер # Создаем менеджер
self._scoring_manager = ScoringManager( self._scoring_manager = ScoringManager(
rag_client=rag_client, rag_client=rag_client,
deepseek_service=deepseek_service, deepseek_service=deepseek_service,
) )
return self._scoring_manager return self._scoring_manager
def get_scoring_manager(self): def get_scoring_manager(self):
""" """
Возвращает ScoringManager для ML-скоринга постов. Возвращает ScoringManager для ML-скоринга постов.
Инициализируется лениво при первом вызове. Инициализируется лениво при первом вызове.
Returns: Returns:
ScoringManager или None если скоринг полностью отключен ScoringManager или None если скоринг полностью отключен
""" """
if self._scoring_manager is None: if self._scoring_manager is None:
scoring_config = self.settings.get('Scoring', {}) scoring_config = self.settings.get("Scoring", {})
# Проверяем, включен ли хотя бы один сервис # Проверяем, включен ли хотя бы один сервис
rag_enabled = scoring_config.get('rag_enabled', False) rag_enabled = scoring_config.get("rag_enabled", False)
deepseek_enabled = scoring_config.get('deepseek_enabled', False) deepseek_enabled = scoring_config.get("deepseek_enabled", False)
if not rag_enabled and not deepseek_enabled: if not rag_enabled and not deepseek_enabled:
logger.info("Scoring полностью отключен (RAG и DeepSeek disabled)") logger.info("Scoring полностью отключен (RAG и DeepSeek disabled)")
return None return None
self._init_scoring_manager() self._init_scoring_manager()
return self._scoring_manager return self._scoring_manager
_global_instance = None _global_instance = None
def get_global_instance(): def get_global_instance():
"""Возвращает глобальный экземпляр BaseDependencyFactory.""" """Возвращает глобальный экземпляр BaseDependencyFactory."""
global _global_instance global _global_instance

File diff suppressed because it is too large Load Diff

View File

@@ -4,55 +4,55 @@ import html
from .metrics import metrics, track_errors, track_time from .metrics import metrics, track_errors, track_time
constants = { constants = {
'HELLO_MESSAGE': "Привет, username!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖" "HELLO_MESSAGE": "Привет, username!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖"
"&Я был создан для того, чтобы помочь тебе выложить пост в наш канал и если это необходимо, связаться с админами ✍✉" "&Я был создан для того, чтобы помочь тебе выложить пост в наш канал и если это необходимо, связаться с админами ✍✉"
"&Так же я могу выдать тебе набор стикеров, где я буду главным героем🦸‍♂" "&Так же я могу выдать тебе набор стикеров, где я буду главным героем🦸‍♂"
"&Наш бот голосового общения переехал ко мне! Доступен по кнопке 🎤Голосовой бот &Там можно послушать о чем говорит наш город🎧" "&Наш бот голосового общения переехал ко мне! Доступен по кнопке 🎤Голосовой бот &Там можно послушать о чем говорит наш город🎧"
"&Предлагай свой пост мне и я обязательно его опубликую😉" "&Предлагай свой пост мне и я обязательно его опубликую😉"
"&Для продолжения взаимодействия воспользуйся меню внизу твоего дисплея⬇" "&Для продолжения взаимодействия воспользуйся меню внизу твоего дисплея⬇"
"&&Если что-то пошло не так: введи в чат команду /start или /restart, это перезапустит сценарий сначала." "&&Если что-то пошло не так: введи в чат команду /start или /restart, это перезапустит сценарий сначала."
"Почитать инструкцию к боту можно по команде /help. Если есть вопросы, то пиши в личку: @Kerrad1" "Почитать инструкцию к боту можно по команде /help. Если есть вопросы, то пиши в личку: @Kerrad1"
"&Не жми кнопку несколько раз если я не ответил с первого раза. Возможно ведутся тех.работы и я отвечу позже" "&Не жми кнопку несколько раз если я не ответил с первого раза. Возможно ведутся тех.работы и я отвечу позже"
"&&Группа в ВК: https://vk.com/love_bsk" "&&Группа в ВК: https://vk.com/love_bsk"
"&Канал в ТГ: https://t.me/love_bsk", "&Канал в ТГ: https://t.me/love_bsk",
'SUGGEST_NEWS': "username, окей, жду от тебя текст поста🙌🏼" "SUGGEST_NEWS": "username, окей, жду от тебя текст поста🙌🏼"
"&Обрати внимание, что я умный и смогу из твоего текста понять команды указанные ниже😉" "&Обрати внимание, что я умный и смогу из твоего текста понять команды указанные ниже😉"
"&Если хочешь чтобы пост был опубликован анонимно, напиши в любом месте своего поста слово 'анон'." "&Если хочешь чтобы пост был опубликован анонимно, напиши в любом месте своего поста слово 'анон'."
"&Если хочешь опубликовать пост не анонимно, то напиши 'не анон', 'неанон' или не пиши ничего." "&Если хочешь опубликовать пост не анонимно, то напиши 'не анон', 'неанон' или не пиши ничего."
"&&❗️❗️Я обучен только на команды, указанные мной выше👆" "&&❗️❗️Я обучен только на команды, указанные мной выше👆"
"&❗️❗️Проверь, чтобы указание авторства было выполнено так как я попросил, иначе пост будет выложен не корректно" "&❗️❗️Проверь, чтобы указание авторства было выполнено так как я попросил, иначе пост будет выложен не корректно"
"&Пост будет опубликован только в группе ТГ📩", "&Пост будет опубликован только в группе ТГ📩",
"CONNECT_WITH_ADMIN": "username, напиши свое обращение или предложение✍️" "CONNECT_WITH_ADMIN": "username, напиши свое обращение или предложение✍️"
"&Мы рассмотрим и ответим тебе в ближайшее время☺️❤️", "&Мы рассмотрим и ответим тебе в ближайшее время☺️❤️",
"DEL_MESSAGE": "username, напиши свое обращение или предложение✍" "DEL_MESSAGE": "username, напиши свое обращение или предложение✍"
"&Мы рассмотрим и ответим тебе в ближайшее время☺❤", "&Мы рассмотрим и ответим тебе в ближайшее время☺❤",
"BYE_MESSAGE": "Если позднее захочешь предложить еще один пост или обратиться к админам с вопросом, то просто пришли в чат команду 👉 /restart" "BYE_MESSAGE": "Если позднее захочешь предложить еще один пост или обратиться к админам с вопросом, то просто пришли в чат команду 👉 /restart"
"&&И тебе пока!👋🏼❤️", "&&И тебе пока!👋🏼❤️",
"USER_ERROR": "Увы, я не понимаю тебя😐💔 Выбери один из пунктов в нижнем меню, а затем пиши.", "USER_ERROR": "Увы, я не понимаю тебя😐💔 Выбери один из пунктов в нижнем меню, а затем пиши.",
"QUESTION": "Сообщение успешно отправлено❤️ Ответим, как только сможем😉", "QUESTION": "Сообщение успешно отправлено❤️ Ответим, как только сможем😉",
"SUCCESS_SEND_MESSAGE": "Пост успешно отправлен❤️ Ожидай одобрения😊", "SUCCESS_SEND_MESSAGE": "Пост успешно отправлен❤️ Ожидай одобрения😊",
# Voice handler messages # Voice handler messages
"MESSAGE_FOR_STANDUP": "Отлично, ты вошел в режим стендапа 📣" "MESSAGE_FOR_STANDUP": "Отлично, ты вошел в режим стендапа 📣"
"&Это свободное пространство, в котором может высказаться каждый житель нашего города, и он будет услышан🙌🏼" "&Это свободное пространство, в котором может высказаться каждый житель нашего города, и он будет услышан🙌🏼"
"&Для того чтобы высказаться, нажми кнопку: 'Высказаться' и запиши голосовое сообщение, оно выпадет анонимно кому-то другому🗣" "&Для того чтобы высказаться, нажми кнопку: 'Высказаться' и запиши голосовое сообщение, оно выпадет анонимно кому-то другому🗣"
"&Для того чтобы послушать о чем говорит наш город, нажми кнопку: 'Послушать'👂" "&Для того чтобы послушать о чем говорит наш город, нажми кнопку: 'Послушать'👂"
"&Ты можешь анонимно пообщаться, поделиться чем-то важным, обратиться напрямую к жителям🤝 Также можешь выступить перед аудиторией (спеть песню, рассказать стихотворение, шутку)🎤" "&Ты можешь анонимно пообщаться, поделиться чем-то важным, обратиться напрямую к жителям🤝 Также можешь выступить перед аудиторией (спеть песню, рассказать стихотворение, шутку)🎤"
"&❗️Но пожалуйста не оскорбляй никого, и будь вежлив.", "&❗️Но пожалуйста не оскорбляй никого, и будь вежлив.",
'WELCOME_MESSAGE': "<b>Привет.</b>", "WELCOME_MESSAGE": "<b>Привет.</b>",
'DESCRIPTION_MESSAGE': "<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>", "DESCRIPTION_MESSAGE": "<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>",
'ANALOGY_MESSAGE': "Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится..", "ANALOGY_MESSAGE": "Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится..",
'RULES_MESSAGE': "Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>", "RULES_MESSAGE": "Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>",
'ANONYMITY_MESSAGE': "Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)", "ANONYMITY_MESSAGE": "Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)",
'SUGGESTION_MESSAGE': "Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)", "SUGGESTION_MESSAGE": "Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)",
'EMOJI_INFO_MESSAGE': "Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)", "EMOJI_INFO_MESSAGE": "Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)",
'HELP_INFO_MESSAGE': "Так же можешь ознакомиться с инструкцией к боту по команде /help", "HELP_INFO_MESSAGE": "Так же можешь ознакомиться с инструкцией к боту по команде /help",
'FINAL_MESSAGE': "<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤", "FINAL_MESSAGE": "<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤",
'HELP_MESSAGE': "Когда-нибудь здесь будет инструкция к боту. А пока по вопросам пиши в личку: @Kerrad1 или в Связаться с админами", "HELP_MESSAGE": "Когда-нибудь здесь будет инструкция к боту. А пока по вопросам пиши в личку: @Kerrad1 или в Связаться с админами",
'VOICE_SAVED_MESSAGE': "Окей, сохранил!👌", "VOICE_SAVED_MESSAGE": "Окей, сохранил!👌",
'LISTENINGS_CLEARED_MESSAGE': "Прослушивания очищены. Можешь начать слушать заново🤗", "LISTENINGS_CLEARED_MESSAGE": "Прослушивания очищены. Можешь начать слушать заново🤗",
'NO_AUDIO_MESSAGE': "Прости, ты прослушал все аудио😔. Возвращайся позже, возможно наша база пополнится", "NO_AUDIO_MESSAGE": "Прости, ты прослушал все аудио😔. Возвращайся позже, возможно наша база пополнится",
'UNKNOWN_CONTENT_MESSAGE': "Я тебя не понимаю🤷‍♀️ запиши голосовое", "UNKNOWN_CONTENT_MESSAGE": "Я тебя не понимаю🤷‍♀️ запиши голосовое",
'RECORD_VOICE_MESSAGE': "Хорошо, теперь пришли мне свое голосовое сообщение" "RECORD_VOICE_MESSAGE": "Хорошо, теперь пришли мне свое голосовое сообщение",
} }
@@ -64,5 +64,5 @@ def get_message(username: str, type_message: str):
raise TypeError("username is None") raise TypeError("username is None")
message = constants[type_message] message = constants[type_message]
# Экранируем потенциально проблемные символы для HTML # Экранируем потенциально проблемные символы для HTML
message = message.replace('username', html.escape(username)).replace('&', '\n') message = message.replace("username", html.escape(username)).replace("&", "\n")
return message return message

View File

@@ -10,8 +10,13 @@ from contextlib import asynccontextmanager
from functools import wraps from functools import wraps
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from prometheus_client import (CONTENT_TYPE_LATEST, Counter, Gauge, Histogram, from prometheus_client import (
generate_latest) CONTENT_TYPE_LATEST,
Counter,
Gauge,
Histogram,
generate_latest,
)
from prometheus_client.core import CollectorRegistry from prometheus_client.core import CollectorRegistry
# Метрики rate limiter теперь создаются в основном классе # Метрики rate limiter теперь создаются в основном классе
@@ -19,372 +24,399 @@ from prometheus_client.core import CollectorRegistry
class BotMetrics: class BotMetrics:
"""Central class for managing all bot metrics.""" """Central class for managing all bot metrics."""
def __init__(self): def __init__(self):
self.registry = CollectorRegistry() self.registry = CollectorRegistry()
# Создаем метрики rate limiter в том же registry # Создаем метрики rate limiter в том же registry
self._create_rate_limit_metrics() self._create_rate_limit_metrics()
# Bot commands counter # Bot commands counter
self.bot_commands_total = Counter( self.bot_commands_total = Counter(
'bot_commands_total', "bot_commands_total",
'Total number of bot commands processed', "Total number of bot commands processed",
['command', 'status', 'handler_type', 'user_type'], ["command", "status", "handler_type", "user_type"],
registry=self.registry registry=self.registry,
) )
# Method execution time histogram # Method execution time histogram
self.method_duration_seconds = Histogram( self.method_duration_seconds = Histogram(
'method_duration_seconds', "method_duration_seconds",
'Time spent executing methods', "Time spent executing methods",
['method_name', 'handler_type', 'status'], ["method_name", "handler_type", "status"],
# Оптимизированные buckets для Telegram API (обычно < 1 сек) # Оптимизированные buckets для Telegram API (обычно < 1 сек)
buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0], buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0],
registry=self.registry registry=self.registry,
) )
# Errors counter # Errors counter
self.errors_total = Counter( self.errors_total = Counter(
'errors_total', "errors_total",
'Total number of errors', "Total number of errors",
['error_type', 'handler_type', 'method_name'], ["error_type", "handler_type", "method_name"],
registry=self.registry registry=self.registry,
) )
# Active users gauge # Active users gauge
self.active_users = Gauge( self.active_users = Gauge(
'active_users', "active_users",
'Number of currently active users', "Number of currently active users",
['user_type'], ["user_type"],
registry=self.registry registry=self.registry,
) )
# Total users gauge (отдельная метрика) # Total users gauge (отдельная метрика)
self.total_users = Gauge( self.total_users = Gauge(
'total_users', "total_users", "Total number of users in database", registry=self.registry
'Total number of users in database',
registry=self.registry
) )
# Database query metrics # Database query metrics
self.db_query_duration_seconds = Histogram( self.db_query_duration_seconds = Histogram(
'db_query_duration_seconds', "db_query_duration_seconds",
'Time spent executing database queries', "Time spent executing database queries",
['query_type', 'table_name', 'operation'], ["query_type", "table_name", "operation"],
# Оптимизированные buckets для SQLite/PostgreSQL # Оптимизированные buckets для SQLite/PostgreSQL
buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5], buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5],
registry=self.registry registry=self.registry,
) )
# Database queries counter # Database queries counter
self.db_queries_total = Counter( self.db_queries_total = Counter(
'db_queries_total', "db_queries_total",
'Total number of database queries executed', "Total number of database queries executed",
['query_type', 'table_name', 'operation'], ["query_type", "table_name", "operation"],
registry=self.registry registry=self.registry,
) )
# Database errors counter # Database errors counter
self.db_errors_total = Counter( self.db_errors_total = Counter(
'db_errors_total', "db_errors_total",
'Total number of database errors', "Total number of database errors",
['error_type', 'query_type', 'table_name', 'operation'], ["error_type", "query_type", "table_name", "operation"],
registry=self.registry registry=self.registry,
) )
# Message processing metrics # Message processing metrics
self.messages_processed_total = Counter( self.messages_processed_total = Counter(
'messages_processed_total', "messages_processed_total",
'Total number of messages processed', "Total number of messages processed",
['message_type', 'chat_type', 'handler_type'], ["message_type", "chat_type", "handler_type"],
registry=self.registry registry=self.registry,
) )
# Middleware execution metrics # Middleware execution metrics
self.middleware_duration_seconds = Histogram( self.middleware_duration_seconds = Histogram(
'middleware_duration_seconds', "middleware_duration_seconds",
'Time spent in middleware execution', "Time spent in middleware execution",
['middleware_name', 'status'], ["middleware_name", "status"],
# Middleware должен быть быстрым # Middleware должен быть быстрым
buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.25], buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.25],
registry=self.registry registry=self.registry,
) )
# Rate limiting metrics # Rate limiting metrics
self.rate_limit_hits_total = Counter( self.rate_limit_hits_total = Counter(
'rate_limit_hits_total', "rate_limit_hits_total",
'Total number of rate limit hits', "Total number of rate limit hits",
['limit_type', 'user_id', 'action'], ["limit_type", "user_id", "action"],
registry=self.registry registry=self.registry,
) )
# User activity metrics # User activity metrics
self.user_activity_total = Counter( self.user_activity_total = Counter(
'user_activity_total', "user_activity_total",
'Total user activity events', "Total user activity events",
['activity_type', 'user_type', 'chat_type'], ["activity_type", "user_type", "chat_type"],
registry=self.registry registry=self.registry,
) )
# File download metrics # File download metrics
self.file_downloads_total = Counter( self.file_downloads_total = Counter(
'file_downloads_total', "file_downloads_total",
'Total number of file downloads', "Total number of file downloads",
['content_type', 'status'], ["content_type", "status"],
registry=self.registry registry=self.registry,
) )
self.file_download_duration_seconds = Histogram( self.file_download_duration_seconds = Histogram(
'file_download_duration_seconds', "file_download_duration_seconds",
'Time spent downloading files', "Time spent downloading files",
['content_type'], ["content_type"],
buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0], buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0],
registry=self.registry registry=self.registry,
) )
self.file_download_size_bytes = Histogram( self.file_download_size_bytes = Histogram(
'file_download_size_bytes', "file_download_size_bytes",
'Size of downloaded files in bytes', "Size of downloaded files in bytes",
['content_type'], ["content_type"],
buckets=[1024, 10240, 102400, 1048576, 10485760, 104857600, 1073741824], buckets=[1024, 10240, 102400, 1048576, 10485760, 104857600, 1073741824],
registry=self.registry registry=self.registry,
) )
# Media processing metrics # Media processing metrics
self.media_processing_total = Counter( self.media_processing_total = Counter(
'media_processing_total', "media_processing_total",
'Total number of media processing operations', "Total number of media processing operations",
['content_type', 'status'], ["content_type", "status"],
registry=self.registry registry=self.registry,
) )
self.media_processing_duration_seconds = Histogram( self.media_processing_duration_seconds = Histogram(
'media_processing_duration_seconds', "media_processing_duration_seconds",
'Time spent processing media', "Time spent processing media",
['content_type'], ["content_type"],
buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0], buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0],
registry=self.registry registry=self.registry,
) )
def _create_rate_limit_metrics(self): def _create_rate_limit_metrics(self):
"""Создает метрики rate limiter в основном registry""" """Создает метрики rate limiter в основном registry"""
try: try:
# Создаем метрики rate limiter в том же registry # Создаем метрики rate limiter в том же registry
self.rate_limit_requests_total = Counter( self.rate_limit_requests_total = Counter(
'rate_limit_requests_total', "rate_limit_requests_total",
'Total number of rate limited requests', "Total number of rate limited requests",
['chat_id', 'status', 'error_type'], ["chat_id", "status", "error_type"],
registry=self.registry registry=self.registry,
) )
self.rate_limit_errors_total = Counter( self.rate_limit_errors_total = Counter(
'rate_limit_errors_total', "rate_limit_errors_total",
'Total number of rate limit errors', "Total number of rate limit errors",
['error_type', 'chat_id'], ["error_type", "chat_id"],
registry=self.registry registry=self.registry,
) )
self.rate_limit_wait_duration_seconds = Histogram( self.rate_limit_wait_duration_seconds = Histogram(
'rate_limit_wait_duration_seconds', "rate_limit_wait_duration_seconds",
'Time spent waiting due to rate limiting', "Time spent waiting due to rate limiting",
['chat_id'], ["chat_id"],
buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0], buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0],
registry=self.registry registry=self.registry,
) )
self.rate_limit_active_chats = Gauge( self.rate_limit_active_chats = Gauge(
'rate_limit_active_chats', "rate_limit_active_chats",
'Number of active chats with rate limiting', "Number of active chats with rate limiting",
registry=self.registry registry=self.registry,
) )
self.rate_limit_success_rate = Gauge( self.rate_limit_success_rate = Gauge(
'rate_limit_success_rate', "rate_limit_success_rate",
'Success rate of rate limited requests', "Success rate of rate limited requests",
['chat_id'], ["chat_id"],
registry=self.registry registry=self.registry,
) )
self.rate_limit_requests_per_minute = Gauge( self.rate_limit_requests_per_minute = Gauge(
'rate_limit_requests_per_minute', "rate_limit_requests_per_minute",
'Requests per minute', "Requests per minute",
['chat_id'], ["chat_id"],
registry=self.registry registry=self.registry,
) )
self.rate_limit_total_requests = Gauge( self.rate_limit_total_requests = Gauge(
'rate_limit_total_requests', "rate_limit_total_requests",
'Total number of requests', "Total number of requests",
['chat_id'], ["chat_id"],
registry=self.registry registry=self.registry,
) )
self.rate_limit_total_errors = Gauge( self.rate_limit_total_errors = Gauge(
'rate_limit_total_errors', "rate_limit_total_errors",
'Total number of errors', "Total number of errors",
['chat_id', 'error_type'], ["chat_id", "error_type"],
registry=self.registry registry=self.registry,
) )
self.rate_limit_avg_wait_time_seconds = Gauge( self.rate_limit_avg_wait_time_seconds = Gauge(
'rate_limit_avg_wait_time_seconds', "rate_limit_avg_wait_time_seconds",
'Average wait time in seconds', "Average wait time in seconds",
['chat_id'], ["chat_id"],
registry=self.registry registry=self.registry,
) )
except Exception as e: except Exception as e:
# Логируем ошибку, но не прерываем инициализацию # Логируем ошибку, но не прерываем инициализацию
import logging import logging
logging.warning(f"Failed to create rate limit metrics: {e}") logging.warning(f"Failed to create rate limit metrics: {e}")
def record_command(self, command_type: str, handler_type: str = "unknown", user_type: str = "unknown", status: str = "success"): def record_command(
self,
command_type: str,
handler_type: str = "unknown",
user_type: str = "unknown",
status: str = "success",
):
"""Record a bot command execution.""" """Record a bot command execution."""
self.bot_commands_total.labels( self.bot_commands_total.labels(
command=command_type, command=command_type,
status=status, status=status,
handler_type=handler_type, handler_type=handler_type,
user_type=user_type user_type=user_type,
).inc() ).inc()
def record_error(self, error_type: str, handler_type: str = "unknown", method_name: str = "unknown"): def record_error(
self,
error_type: str,
handler_type: str = "unknown",
method_name: str = "unknown",
):
"""Record an error occurrence.""" """Record an error occurrence."""
self.errors_total.labels( self.errors_total.labels(
error_type=error_type, error_type=error_type, handler_type=handler_type, method_name=method_name
handler_type=handler_type,
method_name=method_name
).inc() ).inc()
def record_method_duration(self, method_name: str, duration: float, handler_type: str = "unknown", status: str = "success"): def record_method_duration(
self,
method_name: str,
duration: float,
handler_type: str = "unknown",
status: str = "success",
):
"""Record method execution duration.""" """Record method execution duration."""
self.method_duration_seconds.labels( self.method_duration_seconds.labels(
method_name=method_name, method_name=method_name, handler_type=handler_type, status=status
handler_type=handler_type,
status=status
).observe(duration) ).observe(duration)
def set_active_users(self, count: int, user_type: str = "daily"): def set_active_users(self, count: int, user_type: str = "daily"):
"""Set the number of active users for a specific type.""" """Set the number of active users for a specific type."""
self.active_users.labels(user_type=user_type).set(count) self.active_users.labels(user_type=user_type).set(count)
def set_total_users(self, count: int): def set_total_users(self, count: int):
"""Set the total number of users in database.""" """Set the total number of users in database."""
self.total_users.set(count) self.total_users.set(count)
def record_db_query(self, query_type: str, duration: float, table_name: str = "unknown", operation: str = "unknown"): def record_db_query(
self,
query_type: str,
duration: float,
table_name: str = "unknown",
operation: str = "unknown",
):
"""Record database query duration.""" """Record database query duration."""
self.db_query_duration_seconds.labels( self.db_query_duration_seconds.labels(
query_type=query_type, query_type=query_type, table_name=table_name, operation=operation
table_name=table_name,
operation=operation
).observe(duration) ).observe(duration)
self.db_queries_total.labels( self.db_queries_total.labels(
query_type=query_type, query_type=query_type, table_name=table_name, operation=operation
table_name=table_name,
operation=operation
).inc() ).inc()
def record_message(self, message_type: str, chat_type: str = "unknown", handler_type: str = "unknown"): def record_message(
self,
message_type: str,
chat_type: str = "unknown",
handler_type: str = "unknown",
):
"""Record a processed message.""" """Record a processed message."""
self.messages_processed_total.labels( self.messages_processed_total.labels(
message_type=message_type, message_type=message_type, chat_type=chat_type, handler_type=handler_type
chat_type=chat_type,
handler_type=handler_type
).inc() ).inc()
def record_middleware(self, middleware_name: str, duration: float, status: str = "success"): def record_middleware(
self, middleware_name: str, duration: float, status: str = "success"
):
"""Record middleware execution duration.""" """Record middleware execution duration."""
self.middleware_duration_seconds.labels( self.middleware_duration_seconds.labels(
middleware_name=middleware_name, middleware_name=middleware_name, status=status
status=status
).observe(duration) ).observe(duration)
def record_file_download(self, content_type: str, file_size: int, duration: float): def record_file_download(self, content_type: str, file_size: int, duration: float):
"""Record file download metrics.""" """Record file download metrics."""
self.file_downloads_total.labels( self.file_downloads_total.labels(
content_type=content_type, content_type=content_type, status="success"
status="success"
).inc() ).inc()
self.file_download_duration_seconds.labels( self.file_download_duration_seconds.labels(content_type=content_type).observe(
content_type=content_type duration
).observe(duration) )
self.file_download_size_bytes.labels( self.file_download_size_bytes.labels(content_type=content_type).observe(
content_type=content_type file_size
).observe(file_size) )
def record_file_download_error(self, content_type: str, error_message: str): def record_file_download_error(self, content_type: str, error_message: str):
"""Record file download error metrics.""" """Record file download error metrics."""
self.file_downloads_total.labels( self.file_downloads_total.labels(
content_type=content_type, content_type=content_type, status="error"
status="error"
).inc() ).inc()
self.errors_total.labels( self.errors_total.labels(
error_type="file_download_error", error_type="file_download_error",
handler_type="media_processing", handler_type="media_processing",
method_name="download_file" method_name="download_file",
).inc() ).inc()
def record_media_processing(self, content_type: str, duration: float, success: bool): def record_media_processing(
self, content_type: str, duration: float, success: bool
):
"""Record media processing metrics.""" """Record media processing metrics."""
status = "success" if success else "error" status = "success" if success else "error"
self.media_processing_total.labels( self.media_processing_total.labels(
content_type=content_type, content_type=content_type, status=status
status=status
).inc() ).inc()
self.media_processing_duration_seconds.labels( self.media_processing_duration_seconds.labels(
content_type=content_type content_type=content_type
).observe(duration) ).observe(duration)
if not success: if not success:
self.errors_total.labels( self.errors_total.labels(
error_type="media_processing_error", error_type="media_processing_error",
handler_type="media_processing", handler_type="media_processing",
method_name="add_in_db_media" method_name="add_in_db_media",
).inc() ).inc()
def record_db_error(self, error_type: str, query_type: str = "unknown", table_name: str = "unknown", operation: str = "unknown"): def record_db_error(
self,
error_type: str,
query_type: str = "unknown",
table_name: str = "unknown",
operation: str = "unknown",
):
"""Record database error occurrence.""" """Record database error occurrence."""
self.db_errors_total.labels( self.db_errors_total.labels(
error_type=error_type, error_type=error_type,
query_type=query_type, query_type=query_type,
table_name=table_name, table_name=table_name,
operation=operation operation=operation,
).inc() ).inc()
def record_rate_limit_request(self, chat_id: int, success: bool, wait_time: float = 0.0, error_type: str = None): def record_rate_limit_request(
self,
chat_id: int,
success: bool,
wait_time: float = 0.0,
error_type: str = None,
):
"""Record rate limit request metrics.""" """Record rate limit request metrics."""
try: try:
# Определяем статус # Определяем статус
status = "success" if success else "error" status = "success" if success else "error"
# Записываем счетчик запросов # Записываем счетчик запросов
self.rate_limit_requests_total.labels( self.rate_limit_requests_total.labels(
chat_id=str(chat_id), chat_id=str(chat_id), status=status, error_type=error_type or "none"
status=status,
error_type=error_type or "none"
).inc() ).inc()
# Записываем время ожидания # Записываем время ожидания
if wait_time > 0: if wait_time > 0:
self.rate_limit_wait_duration_seconds.labels( self.rate_limit_wait_duration_seconds.labels(
chat_id=str(chat_id) chat_id=str(chat_id)
).observe(wait_time) ).observe(wait_time)
# Записываем ошибки # Записываем ошибки
if not success and error_type: if not success and error_type:
self.rate_limit_errors_total.labels( self.rate_limit_errors_total.labels(
error_type=error_type, error_type=error_type, chat_id=str(chat_id)
chat_id=str(chat_id)
).inc() ).inc()
except Exception as e: except Exception as e:
import logging import logging
logging.warning(f"Failed to record rate limit request: {e}") logging.warning(f"Failed to record rate limit request: {e}")
def update_rate_limit_gauges(self): def update_rate_limit_gauges(self):
"""Update rate limit gauge metrics.""" """Update rate limit gauge metrics."""
try: try:
@@ -392,52 +424,51 @@ class BotMetrics:
# Обновляем количество активных чатов # Обновляем количество активных чатов
self.rate_limit_active_chats.set(len(rate_limit_monitor.stats)) self.rate_limit_active_chats.set(len(rate_limit_monitor.stats))
# Обновляем метрики для каждого чата # Обновляем метрики для каждого чата
for chat_id, chat_stats in rate_limit_monitor.stats.items(): for chat_id, chat_stats in rate_limit_monitor.stats.items():
chat_id_str = str(chat_id) chat_id_str = str(chat_id)
# Процент успеха # Процент успеха
self.rate_limit_success_rate.labels( self.rate_limit_success_rate.labels(chat_id=chat_id_str).set(
chat_id=chat_id_str chat_stats.success_rate
).set(chat_stats.success_rate) )
# Запросов в минуту # Запросов в минуту
self.rate_limit_requests_per_minute.labels( self.rate_limit_requests_per_minute.labels(chat_id=chat_id_str).set(
chat_id=chat_id_str chat_stats.requests_per_minute
).set(chat_stats.requests_per_minute) )
# Общее количество запросов # Общее количество запросов
self.rate_limit_total_requests.labels( self.rate_limit_total_requests.labels(chat_id=chat_id_str).set(
chat_id=chat_id_str chat_stats.total_requests
).set(chat_stats.total_requests) )
# Среднее время ожидания # Среднее время ожидания
self.rate_limit_avg_wait_time_seconds.labels( self.rate_limit_avg_wait_time_seconds.labels(chat_id=chat_id_str).set(
chat_id=chat_id_str chat_stats.average_wait_time
).set(chat_stats.average_wait_time) )
# Количество ошибок по типам # Количество ошибок по типам
if chat_stats.retry_after_errors > 0: if chat_stats.retry_after_errors > 0:
self.rate_limit_total_errors.labels( self.rate_limit_total_errors.labels(
chat_id=chat_id_str, chat_id=chat_id_str, error_type="RetryAfter"
error_type="RetryAfter"
).set(chat_stats.retry_after_errors) ).set(chat_stats.retry_after_errors)
if chat_stats.other_errors > 0: if chat_stats.other_errors > 0:
self.rate_limit_total_errors.labels( self.rate_limit_total_errors.labels(
chat_id=chat_id_str, chat_id=chat_id_str, error_type="Other"
error_type="Other"
).set(chat_stats.other_errors) ).set(chat_stats.other_errors)
except Exception as e: except Exception as e:
import logging import logging
logging.warning(f"Failed to update rate limit gauges: {e}") logging.warning(f"Failed to update rate limit gauges: {e}")
def get_metrics(self) -> bytes: def get_metrics(self) -> bytes:
"""Generate metrics in Prometheus format.""" """Generate metrics in Prometheus format."""
# Обновляем gauge метрики rate limiter перед генерацией # Обновляем gauge метрики rate limiter перед генерацией
self.update_rate_limit_gauges() self.update_rate_limit_gauges()
return generate_latest(self.registry) return generate_latest(self.registry)
@@ -448,6 +479,7 @@ metrics = BotMetrics()
# Decorators for easy metric collection # Decorators for easy metric collection
def track_time(method_name: str = None, handler_type: str = "unknown"): def track_time(method_name: str = None, handler_type: str = "unknown"):
"""Decorator to track execution time of functions.""" """Decorator to track execution time of functions."""
def decorator(func): def decorator(func):
@wraps(func) @wraps(func)
async def async_wrapper(*args, **kwargs): async def async_wrapper(*args, **kwargs):
@@ -456,27 +488,19 @@ def track_time(method_name: str = None, handler_type: str = "unknown"):
result = await func(*args, **kwargs) result = await func(*args, **kwargs)
duration = time.time() - start_time duration = time.time() - start_time
metrics.record_method_duration( metrics.record_method_duration(
method_name or func.__name__, method_name or func.__name__, duration, handler_type, "success"
duration,
handler_type,
"success"
) )
return result return result
except Exception as e: except Exception as e:
duration = time.time() - start_time duration = time.time() - start_time
metrics.record_method_duration( metrics.record_method_duration(
method_name or func.__name__, method_name or func.__name__, duration, handler_type, "error"
duration,
handler_type,
"error"
) )
metrics.record_error( metrics.record_error(
type(e).__name__, type(e).__name__, handler_type, method_name or func.__name__
handler_type,
method_name or func.__name__
) )
raise raise
@wraps(func) @wraps(func)
def sync_wrapper(*args, **kwargs): def sync_wrapper(*args, **kwargs):
start_time = time.time() start_time = time.time()
@@ -484,35 +508,29 @@ def track_time(method_name: str = None, handler_type: str = "unknown"):
result = func(*args, **kwargs) result = func(*args, **kwargs)
duration = time.time() - start_time duration = time.time() - start_time
metrics.record_method_duration( metrics.record_method_duration(
method_name or func.__name__, method_name or func.__name__, duration, handler_type, "success"
duration,
handler_type,
"success"
) )
return result return result
except Exception as e: except Exception as e:
duration = time.time() - start_time duration = time.time() - start_time
metrics.record_method_duration( metrics.record_method_duration(
method_name or func.__name__, method_name or func.__name__, duration, handler_type, "error"
duration,
handler_type,
"error"
) )
metrics.record_error( metrics.record_error(
type(e).__name__, type(e).__name__, handler_type, method_name or func.__name__
handler_type,
method_name or func.__name__
) )
raise raise
if asyncio.iscoroutinefunction(func): if asyncio.iscoroutinefunction(func):
return async_wrapper return async_wrapper
return sync_wrapper return sync_wrapper
return decorator return decorator
def track_errors(handler_type: str = "unknown", method_name: str = None): def track_errors(handler_type: str = "unknown", method_name: str = None):
"""Decorator to track errors in functions.""" """Decorator to track errors in functions."""
def decorator(func): def decorator(func):
@wraps(func) @wraps(func)
async def async_wrapper(*args, **kwargs): async def async_wrapper(*args, **kwargs):
@@ -520,32 +538,32 @@ def track_errors(handler_type: str = "unknown", method_name: str = None):
return await func(*args, **kwargs) return await func(*args, **kwargs)
except Exception as e: except Exception as e:
metrics.record_error( metrics.record_error(
type(e).__name__, type(e).__name__, handler_type, method_name or func.__name__
handler_type,
method_name or func.__name__
) )
raise raise
@wraps(func) @wraps(func)
def sync_wrapper(*args, **kwargs): def sync_wrapper(*args, **kwargs):
try: try:
return func(*args, **kwargs) return func(*args, **kwargs)
except Exception as e: except Exception as e:
metrics.record_error( metrics.record_error(
type(e).__name__, type(e).__name__, handler_type, method_name or func.__name__
handler_type,
method_name or func.__name__
) )
raise raise
if asyncio.iscoroutinefunction(func): if asyncio.iscoroutinefunction(func):
return async_wrapper return async_wrapper
return sync_wrapper return sync_wrapper
return decorator return decorator
def db_query_time(query_type: str = "unknown", table_name: str = "unknown", operation: str = "unknown"): def db_query_time(
query_type: str = "unknown", table_name: str = "unknown", operation: str = "unknown"
):
"""Decorator to track database query execution time.""" """Decorator to track database query execution time."""
def decorator(func): def decorator(func):
@wraps(func) @wraps(func)
async def async_wrapper(*args, **kwargs): async def async_wrapper(*args, **kwargs):
@@ -559,18 +577,11 @@ def db_query_time(query_type: str = "unknown", table_name: str = "unknown", oper
duration = time.time() - start_time duration = time.time() - start_time
metrics.record_db_query(query_type, duration, table_name, operation) metrics.record_db_query(query_type, duration, table_name, operation)
metrics.record_db_error( metrics.record_db_error(
type(e).__name__, type(e).__name__, query_type, table_name, operation
query_type,
table_name,
operation
)
metrics.record_error(
type(e).__name__,
"database",
func.__name__
) )
metrics.record_error(type(e).__name__, "database", func.__name__)
raise raise
@wraps(func) @wraps(func)
def sync_wrapper(*args, **kwargs): def sync_wrapper(*args, **kwargs):
start_time = time.time() start_time = time.time()
@@ -583,21 +594,15 @@ def db_query_time(query_type: str = "unknown", table_name: str = "unknown", oper
duration = time.time() - start_time duration = time.time() - start_time
metrics.record_db_query(query_type, duration, table_name, operation) metrics.record_db_query(query_type, duration, table_name, operation)
metrics.record_db_error( metrics.record_db_error(
type(e).__name__, type(e).__name__, query_type, table_name, operation
query_type,
table_name,
operation
)
metrics.record_error(
type(e).__name__,
"database",
func.__name__
) )
metrics.record_error(type(e).__name__, "database", func.__name__)
raise raise
if asyncio.iscoroutinefunction(func): if asyncio.iscoroutinefunction(func):
return async_wrapper return async_wrapper
return sync_wrapper return sync_wrapper
return decorator return decorator
@@ -612,16 +617,13 @@ async def track_middleware(middleware_name: str):
except Exception as e: except Exception as e:
duration = time.time() - start_time duration = time.time() - start_time
metrics.record_middleware(middleware_name, duration, "error") metrics.record_middleware(middleware_name, duration, "error")
metrics.record_error( metrics.record_error(type(e).__name__, "middleware", middleware_name)
type(e).__name__,
"middleware",
middleware_name
)
raise raise
def track_media_processing(content_type: str = "unknown"): def track_media_processing(content_type: str = "unknown"):
"""Decorator to track media processing operations.""" """Decorator to track media processing operations."""
def decorator(func): def decorator(func):
@wraps(func) @wraps(func)
async def async_wrapper(*args, **kwargs): async def async_wrapper(*args, **kwargs):
@@ -635,7 +637,7 @@ def track_media_processing(content_type: str = "unknown"):
duration = time.time() - start_time duration = time.time() - start_time
metrics.record_media_processing(content_type, duration, False) metrics.record_media_processing(content_type, duration, False)
raise raise
@wraps(func) @wraps(func)
def sync_wrapper(*args, **kwargs): def sync_wrapper(*args, **kwargs):
start_time = time.time() start_time = time.time()
@@ -648,15 +650,17 @@ def track_media_processing(content_type: str = "unknown"):
duration = time.time() - start_time duration = time.time() - start_time
metrics.record_media_processing(content_type, duration, False) metrics.record_media_processing(content_type, duration, False)
raise raise
if asyncio.iscoroutinefunction(func): if asyncio.iscoroutinefunction(func):
return async_wrapper return async_wrapper
return sync_wrapper return sync_wrapper
return decorator return decorator
def track_file_operations(content_type: str = "unknown"): def track_file_operations(content_type: str = "unknown"):
"""Decorator to track file download/upload operations.""" """Decorator to track file download/upload operations."""
def decorator(func): def decorator(func):
@wraps(func) @wraps(func)
async def async_wrapper(*args, **kwargs): async def async_wrapper(*args, **kwargs):
@@ -664,43 +668,44 @@ def track_file_operations(content_type: str = "unknown"):
try: try:
result = await func(*args, **kwargs) result = await func(*args, **kwargs)
duration = time.time() - start_time duration = time.time() - start_time
# Получаем размер файла из результата # Получаем размер файла из результата
file_size = 0 file_size = 0
if result and isinstance(result, str) and os.path.exists(result): if result and isinstance(result, str) and os.path.exists(result):
file_size = os.path.getsize(result) file_size = os.path.getsize(result)
# Записываем метрики # Записываем метрики
metrics.record_file_download(content_type, file_size, duration) metrics.record_file_download(content_type, file_size, duration)
return result return result
except Exception as e: except Exception as e:
duration = time.time() - start_time duration = time.time() - start_time
metrics.record_file_download_error(content_type, str(e)) metrics.record_file_download_error(content_type, str(e))
raise raise
@wraps(func) @wraps(func)
def sync_wrapper(*args, **kwargs): def sync_wrapper(*args, **kwargs):
start_time = time.time() start_time = time.time()
try: try:
result = func(*args, **kwargs) result = func(*args, **kwargs)
duration = time.time() - start_time duration = time.time() - start_time
# Получаем размер файла из результата # Получаем размер файла из результата
file_size = 0 file_size = 0
if result and isinstance(result, str) and os.path.exists(result): if result and isinstance(result, str) and os.path.exists(result):
file_size = os.path.getsize(result) file_size = os.path.getsize(result)
# Записываем метрики # Записываем метрики
metrics.record_file_download(content_type, file_size, duration) metrics.record_file_download(content_type, file_size, duration)
return result return result
except Exception as e: except Exception as e:
duration = time.time() - start_time duration = time.time() - start_time
metrics.record_file_download_error(content_type, str(e)) metrics.record_file_download_error(content_type, str(e))
raise raise
if asyncio.iscoroutinefunction(func): if asyncio.iscoroutinefunction(func):
return async_wrapper return async_wrapper
return sync_wrapper return sync_wrapper
return decorator return decorator

View File

@@ -1,6 +1,7 @@
""" """
Мониторинг и статистика rate limiting Мониторинг и статистика rate limiting
""" """
import time import time
from collections import defaultdict, deque from collections import defaultdict, deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -12,6 +13,7 @@ from logs.custom_logger import logger
@dataclass @dataclass
class RateLimitStats: class RateLimitStats:
"""Статистика rate limiting для чата""" """Статистика rate limiting для чата"""
chat_id: int chat_id: int
total_requests: int = 0 total_requests: int = 0
successful_requests: int = 0 successful_requests: int = 0
@@ -21,53 +23,61 @@ class RateLimitStats:
total_wait_time: float = 0.0 total_wait_time: float = 0.0
last_request_time: float = 0.0 last_request_time: float = 0.0
request_times: deque = field(default_factory=lambda: deque(maxlen=100)) request_times: deque = field(default_factory=lambda: deque(maxlen=100))
@property @property
def success_rate(self) -> float: def success_rate(self) -> float:
"""Процент успешных запросов""" """Процент успешных запросов"""
if self.total_requests == 0: if self.total_requests == 0:
return 1.0 return 1.0
return self.successful_requests / self.total_requests return self.successful_requests / self.total_requests
@property @property
def error_rate(self) -> float: def error_rate(self) -> float:
"""Процент ошибок""" """Процент ошибок"""
return 1.0 - self.success_rate return 1.0 - self.success_rate
@property @property
def average_wait_time(self) -> float: def average_wait_time(self) -> float:
"""Среднее время ожидания""" """Среднее время ожидания"""
if self.total_requests == 0: if self.total_requests == 0:
return 0.0 return 0.0
return self.total_wait_time / self.total_requests return self.total_wait_time / self.total_requests
@property @property
def requests_per_minute(self) -> float: def requests_per_minute(self) -> float:
"""Запросов в минуту""" """Запросов в минуту"""
if not self.request_times: if not self.request_times:
return 0.0 return 0.0
current_time = time.time() current_time = time.time()
minute_ago = current_time - 60 minute_ago = current_time - 60
# Подсчитываем запросы за последнюю минуту # Подсчитываем запросы за последнюю минуту
recent_requests = sum(1 for req_time in self.request_times if req_time > minute_ago) recent_requests = sum(
1 for req_time in self.request_times if req_time > minute_ago
)
return recent_requests return recent_requests
class RateLimitMonitor: class RateLimitMonitor:
"""Монитор для отслеживания статистики rate limiting""" """Монитор для отслеживания статистики rate limiting"""
def __init__(self, max_history_size: int = 1000): def __init__(self, max_history_size: int = 1000):
self.stats: Dict[int, RateLimitStats] = defaultdict(lambda: RateLimitStats(0)) self.stats: Dict[int, RateLimitStats] = defaultdict(lambda: RateLimitStats(0))
self.global_stats = RateLimitStats(0) self.global_stats = RateLimitStats(0)
self.max_history_size = max_history_size self.max_history_size = max_history_size
self.error_history: deque = deque(maxlen=max_history_size) self.error_history: deque = deque(maxlen=max_history_size)
def record_request(self, chat_id: int, success: bool, wait_time: float = 0.0, error_type: Optional[str] = None): def record_request(
self,
chat_id: int,
success: bool,
wait_time: float = 0.0,
error_type: Optional[str] = None,
):
"""Записывает информацию о запросе""" """Записывает информацию о запросе"""
current_time = time.time() current_time = time.time()
# Обновляем статистику для чата # Обновляем статистику для чата
chat_stats = self.stats[chat_id] chat_stats = self.stats[chat_id]
chat_stats.chat_id = chat_id chat_stats.chat_id = chat_id
@@ -75,7 +85,7 @@ class RateLimitMonitor:
chat_stats.total_wait_time += wait_time chat_stats.total_wait_time += wait_time
chat_stats.last_request_time = current_time chat_stats.last_request_time = current_time
chat_stats.request_times.append(current_time) chat_stats.request_times.append(current_time)
if success: if success:
chat_stats.successful_requests += 1 chat_stats.successful_requests += 1
else: else:
@@ -84,21 +94,23 @@ class RateLimitMonitor:
chat_stats.retry_after_errors += 1 chat_stats.retry_after_errors += 1
else: else:
chat_stats.other_errors += 1 chat_stats.other_errors += 1
# Записываем ошибку в историю # Записываем ошибку в историю
self.error_history.append({ self.error_history.append(
'chat_id': chat_id, {
'error_type': error_type, "chat_id": chat_id,
'timestamp': current_time, "error_type": error_type,
'wait_time': wait_time "timestamp": current_time,
}) "wait_time": wait_time,
}
)
# Обновляем глобальную статистику # Обновляем глобальную статистику
self.global_stats.total_requests += 1 self.global_stats.total_requests += 1
self.global_stats.total_wait_time += wait_time self.global_stats.total_wait_time += wait_time
self.global_stats.last_request_time = current_time self.global_stats.last_request_time = current_time
self.global_stats.request_times.append(current_time) self.global_stats.request_times.append(current_time)
if success: if success:
self.global_stats.successful_requests += 1 self.global_stats.successful_requests += 1
else: else:
@@ -107,56 +119,54 @@ class RateLimitMonitor:
self.global_stats.retry_after_errors += 1 self.global_stats.retry_after_errors += 1
else: else:
self.global_stats.other_errors += 1 self.global_stats.other_errors += 1
def get_chat_stats(self, chat_id: int) -> Optional[RateLimitStats]: def get_chat_stats(self, chat_id: int) -> Optional[RateLimitStats]:
"""Получает статистику для конкретного чата""" """Получает статистику для конкретного чата"""
return self.stats.get(chat_id) return self.stats.get(chat_id)
def get_global_stats(self) -> RateLimitStats: def get_global_stats(self) -> RateLimitStats:
"""Получает глобальную статистику""" """Получает глобальную статистику"""
return self.global_stats return self.global_stats
def get_top_chats_by_requests(self, limit: int = 10) -> List[tuple]: def get_top_chats_by_requests(self, limit: int = 10) -> List[tuple]:
"""Получает топ чатов по количеству запросов""" """Получает топ чатов по количеству запросов"""
sorted_chats = sorted( sorted_chats = sorted(
self.stats.items(), self.stats.items(), key=lambda x: x[1].total_requests, reverse=True
key=lambda x: x[1].total_requests,
reverse=True
) )
return sorted_chats[:limit] return sorted_chats[:limit]
def get_chats_with_high_error_rate(self, threshold: float = 0.1) -> List[tuple]: def get_chats_with_high_error_rate(self, threshold: float = 0.1) -> List[tuple]:
"""Получает чаты с высоким процентом ошибок""" """Получает чаты с высоким процентом ошибок"""
high_error_chats = [ high_error_chats = [
(chat_id, stats) for chat_id, stats in self.stats.items() (chat_id, stats)
for chat_id, stats in self.stats.items()
if stats.error_rate > threshold and stats.total_requests > 5 if stats.error_rate > threshold and stats.total_requests > 5
] ]
return sorted(high_error_chats, key=lambda x: x[1].error_rate, reverse=True) return sorted(high_error_chats, key=lambda x: x[1].error_rate, reverse=True)
def get_recent_errors(self, minutes: int = 60) -> List[dict]: def get_recent_errors(self, minutes: int = 60) -> List[dict]:
"""Получает недавние ошибки""" """Получает недавние ошибки"""
current_time = time.time() current_time = time.time()
cutoff_time = current_time - (minutes * 60) cutoff_time = current_time - (minutes * 60)
return [ return [
error for error in self.error_history error for error in self.error_history if error["timestamp"] > cutoff_time
if error['timestamp'] > cutoff_time
] ]
def get_error_summary(self, minutes: int = 60) -> Dict[str, int]: def get_error_summary(self, minutes: int = 60) -> Dict[str, int]:
"""Получает сводку ошибок за указанный период""" """Получает сводку ошибок за указанный период"""
recent_errors = self.get_recent_errors(minutes) recent_errors = self.get_recent_errors(minutes)
error_summary = defaultdict(int) error_summary = defaultdict(int)
for error in recent_errors: for error in recent_errors:
error_summary[error['error_type']] += 1 error_summary[error["error_type"]] += 1
return dict(error_summary) return dict(error_summary)
def log_statistics(self, log_level: str = "info"): def log_statistics(self, log_level: str = "info"):
"""Логирует текущую статистику""" """Логирует текущую статистику"""
global_stats = self.get_global_stats() global_stats = self.get_global_stats()
log_message = ( log_message = (
f"Rate Limit Statistics:\n" f"Rate Limit Statistics:\n"
f" Total requests: {global_stats.total_requests}\n" f" Total requests: {global_stats.total_requests}\n"
@@ -168,21 +178,25 @@ class RateLimitMonitor:
f" Requests per minute: {global_stats.requests_per_minute:.1f}\n" f" Requests per minute: {global_stats.requests_per_minute:.1f}\n"
f" Active chats: {len(self.stats)}" f" Active chats: {len(self.stats)}"
) )
if log_level == "error": if log_level == "error":
logger.error(log_message) logger.error(log_message)
elif log_level == "warning": elif log_level == "warning":
logger.warning(log_message) logger.warning(log_message)
else: else:
logger.info(log_message) logger.info(log_message)
# Логируем чаты с высоким процентом ошибок # Логируем чаты с высоким процентом ошибок
high_error_chats = self.get_chats_with_high_error_rate(0.2) high_error_chats = self.get_chats_with_high_error_rate(0.2)
if high_error_chats: if high_error_chats:
logger.warning(f"Chats with high error rate (>20%): {len(high_error_chats)}") logger.warning(
f"Chats with high error rate (>20%): {len(high_error_chats)}"
)
for chat_id, stats in high_error_chats[:5]: # Показываем только первые 5 for chat_id, stats in high_error_chats[:5]: # Показываем только первые 5
logger.warning(f" Chat {chat_id}: {stats.error_rate:.2%} error rate ({stats.failed_requests}/{stats.total_requests})") logger.warning(
f" Chat {chat_id}: {stats.error_rate:.2%} error rate ({stats.failed_requests}/{stats.total_requests})"
)
def reset_stats(self, chat_id: Optional[int] = None): def reset_stats(self, chat_id: Optional[int] = None):
"""Сбрасывает статистику""" """Сбрасывает статистику"""
if chat_id is None: if chat_id is None:
@@ -200,7 +214,12 @@ class RateLimitMonitor:
rate_limit_monitor = RateLimitMonitor() rate_limit_monitor = RateLimitMonitor()
def record_rate_limit_request(chat_id: int, success: bool, wait_time: float = 0.0, error_type: Optional[str] = None): def record_rate_limit_request(
chat_id: int,
success: bool,
wait_time: float = 0.0,
error_type: Optional[str] = None,
):
"""Удобная функция для записи информации о запросе""" """Удобная функция для записи информации о запросе"""
rate_limit_monitor.record_request(chat_id, success, wait_time, error_type) rate_limit_monitor.record_request(chat_id, success, wait_time, error_type)
@@ -209,13 +228,13 @@ def get_rate_limit_summary() -> Dict:
"""Получает краткую сводку по rate limiting""" """Получает краткую сводку по rate limiting"""
global_stats = rate_limit_monitor.get_global_stats() global_stats = rate_limit_monitor.get_global_stats()
recent_errors = rate_limit_monitor.get_recent_errors(60) # За последний час recent_errors = rate_limit_monitor.get_recent_errors(60) # За последний час
return { return {
'total_requests': global_stats.total_requests, "total_requests": global_stats.total_requests,
'success_rate': global_stats.success_rate, "success_rate": global_stats.success_rate,
'error_rate': global_stats.error_rate, "error_rate": global_stats.error_rate,
'recent_errors_count': len(recent_errors), "recent_errors_count": len(recent_errors),
'active_chats': len(rate_limit_monitor.stats), "active_chats": len(rate_limit_monitor.stats),
'requests_per_minute': global_stats.requests_per_minute, "requests_per_minute": global_stats.requests_per_minute,
'average_wait_time': global_stats.average_wait_time "average_wait_time": global_stats.average_wait_time,
} }

View File

@@ -1,20 +1,23 @@
""" """
Rate limiter для предотвращения Flood control ошибок в Telegram Bot API Rate limiter для предотвращения Flood control ошибок в Telegram Bot API
""" """
import asyncio import asyncio
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Dict, Optional from typing import Any, Callable, Dict, Optional
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
from logs.custom_logger import logger from logs.custom_logger import logger
from .metrics import metrics from .metrics import metrics
@dataclass @dataclass
class RateLimitConfig: class RateLimitConfig:
"""Конфигурация для rate limiting""" """Конфигурация для rate limiting"""
messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат
burst_limit: int = 3 # Максимум 3 сообщения подряд burst_limit: int = 3 # Максимум 3 сообщения подряд
retry_after_multiplier: float = 1.2 # Множитель для увеличения задержки при retry retry_after_multiplier: float = 1.2 # Множитель для увеличения задержки при retry
@@ -23,23 +26,23 @@ class RateLimitConfig:
class ChatRateLimiter: class ChatRateLimiter:
"""Rate limiter для конкретного чата""" """Rate limiter для конкретного чата"""
def __init__(self, config: RateLimitConfig): def __init__(self, config: RateLimitConfig):
self.config = config self.config = config
self.last_send_time = 0.0 self.last_send_time = 0.0
self.burst_count = 0 self.burst_count = 0
self.burst_reset_time = 0.0 self.burst_reset_time = 0.0
self.retry_delay = 1.0 self.retry_delay = 1.0
async def wait_if_needed(self) -> None: async def wait_if_needed(self) -> None:
"""Ждет если необходимо для соблюдения rate limit""" """Ждет если необходимо для соблюдения rate limit"""
current_time = time.time() current_time = time.time()
# Сбрасываем счетчик burst если прошло достаточно времени # Сбрасываем счетчик burst если прошло достаточно времени
if current_time >= self.burst_reset_time: if current_time >= self.burst_reset_time:
self.burst_count = 0 self.burst_count = 0
self.burst_reset_time = current_time + 1.0 self.burst_reset_time = current_time + 1.0
# Проверяем burst limit # Проверяем burst limit
if self.burst_count >= self.config.burst_limit: if self.burst_count >= self.config.burst_limit:
wait_time = self.burst_reset_time - current_time wait_time = self.burst_reset_time - current_time
@@ -49,16 +52,16 @@ class ChatRateLimiter:
current_time = time.time() current_time = time.time()
self.burst_count = 0 self.burst_count = 0
self.burst_reset_time = current_time + 1.0 self.burst_reset_time = current_time + 1.0
# Проверяем минимальный интервал между сообщениями # Проверяем минимальный интервал между сообщениями
time_since_last = current_time - self.last_send_time time_since_last = current_time - self.last_send_time
min_interval = 1.0 / self.config.messages_per_second min_interval = 1.0 / self.config.messages_per_second
if time_since_last < min_interval: if time_since_last < min_interval:
wait_time = min_interval - time_since_last wait_time = min_interval - time_since_last
logger.debug(f"Rate limiting: waiting {wait_time:.2f}s") logger.debug(f"Rate limiting: waiting {wait_time:.2f}s")
await asyncio.sleep(wait_time) await asyncio.sleep(wait_time)
# Обновляем время последней отправки # Обновляем время последней отправки
self.last_send_time = time.time() self.last_send_time = time.time()
self.burst_count += 1 self.burst_count += 1
@@ -66,126 +69,126 @@ class ChatRateLimiter:
class GlobalRateLimiter: class GlobalRateLimiter:
"""Глобальный rate limiter для всех чатов""" """Глобальный rate limiter для всех чатов"""
def __init__(self, config: RateLimitConfig): def __init__(self, config: RateLimitConfig):
self.config = config self.config = config
self.chat_limiters: Dict[int, ChatRateLimiter] = {} self.chat_limiters: Dict[int, ChatRateLimiter] = {}
self.global_last_send = 0.0 self.global_last_send = 0.0
self.global_min_interval = 0.1 # Минимум 100ms между любыми сообщениями self.global_min_interval = 0.1 # Минимум 100ms между любыми сообщениями
def get_chat_limiter(self, chat_id: int) -> ChatRateLimiter: def get_chat_limiter(self, chat_id: int) -> ChatRateLimiter:
"""Получает rate limiter для конкретного чата""" """Получает rate limiter для конкретного чата"""
if chat_id not in self.chat_limiters: if chat_id not in self.chat_limiters:
self.chat_limiters[chat_id] = ChatRateLimiter(self.config) self.chat_limiters[chat_id] = ChatRateLimiter(self.config)
return self.chat_limiters[chat_id] return self.chat_limiters[chat_id]
async def wait_if_needed(self, chat_id: int) -> None: async def wait_if_needed(self, chat_id: int) -> None:
"""Ждет если необходимо для соблюдения глобального и чат-специфичного rate limit""" """Ждет если необходимо для соблюдения глобального и чат-специфичного rate limit"""
current_time = time.time() current_time = time.time()
# Глобальный rate limit # Глобальный rate limit
time_since_global = current_time - self.global_last_send time_since_global = current_time - self.global_last_send
if time_since_global < self.global_min_interval: if time_since_global < self.global_min_interval:
wait_time = self.global_min_interval - time_since_global wait_time = self.global_min_interval - time_since_global
await asyncio.sleep(wait_time) await asyncio.sleep(wait_time)
current_time = time.time() current_time = time.time()
# Чат-специфичный rate limit # Чат-специфичный rate limit
chat_limiter = self.get_chat_limiter(chat_id) chat_limiter = self.get_chat_limiter(chat_id)
await chat_limiter.wait_if_needed() await chat_limiter.wait_if_needed()
self.global_last_send = time.time() self.global_last_send = time.time()
class RetryHandler: class RetryHandler:
"""Обработчик повторных попыток с экспоненциальной задержкой""" """Обработчик повторных попыток с экспоненциальной задержкой"""
def __init__(self, config: RateLimitConfig): def __init__(self, config: RateLimitConfig):
self.config = config self.config = config
async def execute_with_retry( async def execute_with_retry(
self, self, func: Callable, chat_id: int, *args, max_retries: int = 3, **kwargs
func: Callable,
chat_id: int,
*args,
max_retries: int = 3,
**kwargs
) -> Any: ) -> Any:
"""Выполняет функцию с повторными попытками при ошибках""" """Выполняет функцию с повторными попытками при ошибках"""
retry_count = 0 retry_count = 0
current_delay = self.config.retry_after_multiplier current_delay = self.config.retry_after_multiplier
total_wait_time = 0.0 total_wait_time = 0.0
while retry_count <= max_retries: while retry_count <= max_retries:
try: try:
result = await func(*args, **kwargs) result = await func(*args, **kwargs)
# Записываем успешный запрос # Записываем успешный запрос
metrics.record_rate_limit_request(chat_id, True, total_wait_time) metrics.record_rate_limit_request(chat_id, True, total_wait_time)
return result return result
except TelegramRetryAfter as e: except TelegramRetryAfter as e:
retry_count += 1 retry_count += 1
if retry_count > max_retries: if retry_count > max_retries:
logger.error(f"Max retries exceeded for RetryAfter: {e}") logger.error(f"Max retries exceeded for RetryAfter: {e}")
metrics.record_rate_limit_request(chat_id, False, total_wait_time, "RetryAfter") metrics.record_rate_limit_request(
chat_id, False, total_wait_time, "RetryAfter"
)
raise raise
# Используем время ожидания от Telegram или наше увеличенное # Используем время ожидания от Telegram или наше увеличенное
wait_time = max(e.retry_after, current_delay) wait_time = max(e.retry_after, current_delay)
wait_time = min(wait_time, self.config.max_retry_delay) wait_time = min(wait_time, self.config.max_retry_delay)
total_wait_time += wait_time total_wait_time += wait_time
logger.warning(f"RetryAfter error, waiting {wait_time:.2f}s (attempt {retry_count}/{max_retries})") logger.warning(
f"RetryAfter error, waiting {wait_time:.2f}s (attempt {retry_count}/{max_retries})"
)
await asyncio.sleep(wait_time) await asyncio.sleep(wait_time)
current_delay *= self.config.retry_after_multiplier current_delay *= self.config.retry_after_multiplier
except TelegramAPIError as e: except TelegramAPIError as e:
retry_count += 1 retry_count += 1
if retry_count > max_retries: if retry_count > max_retries:
logger.error(f"Max retries exceeded for TelegramAPIError: {e}") logger.error(f"Max retries exceeded for TelegramAPIError: {e}")
metrics.record_rate_limit_request(chat_id, False, total_wait_time, "TelegramAPIError") metrics.record_rate_limit_request(
chat_id, False, total_wait_time, "TelegramAPIError"
)
raise raise
wait_time = min(current_delay, self.config.max_retry_delay) wait_time = min(current_delay, self.config.max_retry_delay)
total_wait_time += wait_time total_wait_time += wait_time
logger.warning(f"TelegramAPIError, waiting {wait_time:.2f}s (attempt {retry_count}/{max_retries}): {e}") logger.warning(
f"TelegramAPIError, waiting {wait_time:.2f}s (attempt {retry_count}/{max_retries}): {e}"
)
await asyncio.sleep(wait_time) await asyncio.sleep(wait_time)
current_delay *= self.config.retry_after_multiplier current_delay *= self.config.retry_after_multiplier
except Exception as e: except Exception as e:
# Для других ошибок не делаем retry # Для других ошибок не делаем retry
logger.error(f"Non-retryable error: {e}") logger.error(f"Non-retryable error: {e}")
metrics.record_rate_limit_request(chat_id, False, total_wait_time, "Other") metrics.record_rate_limit_request(
chat_id, False, total_wait_time, "Other"
)
raise raise
class TelegramRateLimiter: class TelegramRateLimiter:
"""Основной класс для rate limiting в Telegram боте""" """Основной класс для rate limiting в Telegram боте"""
def __init__(self, config: Optional[RateLimitConfig] = None): def __init__(self, config: Optional[RateLimitConfig] = None):
self.config = config or RateLimitConfig() self.config = config or RateLimitConfig()
self.global_limiter = GlobalRateLimiter(self.config) self.global_limiter = GlobalRateLimiter(self.config)
self.retry_handler = RetryHandler(self.config) self.retry_handler = RetryHandler(self.config)
async def send_with_rate_limit( async def send_with_rate_limit(
self, self, send_func: Callable, chat_id: int, *args, **kwargs
send_func: Callable,
chat_id: int,
*args,
**kwargs
) -> Any: ) -> Any:
"""Отправляет сообщение с соблюдением rate limit и retry логики""" """Отправляет сообщение с соблюдением rate limit и retry логики"""
async def _send(): async def _send():
await self.global_limiter.wait_if_needed(chat_id) await self.global_limiter.wait_if_needed(chat_id)
return await send_func(*args, **kwargs) return await send_func(*args, **kwargs)
return await self.retry_handler.execute_with_retry(_send, chat_id) return await self.retry_handler.execute_with_retry(_send, chat_id)
# Глобальный экземпляр rate limiter # Глобальный экземпляр rate limiter
from helper_bot.config.rate_limit_config import (RateLimitSettings, from helper_bot.config.rate_limit_config import RateLimitSettings, get_rate_limit_config
get_rate_limit_config)
def _create_rate_limit_config(settings: RateLimitSettings) -> RateLimitConfig: def _create_rate_limit_config(settings: RateLimitSettings) -> RateLimitConfig:
@@ -194,9 +197,10 @@ def _create_rate_limit_config(settings: RateLimitSettings) -> RateLimitConfig:
messages_per_second=settings.messages_per_second, messages_per_second=settings.messages_per_second,
burst_limit=settings.burst_limit, burst_limit=settings.burst_limit,
retry_after_multiplier=settings.retry_after_multiplier, retry_after_multiplier=settings.retry_after_multiplier,
max_retry_delay=settings.max_retry_delay max_retry_delay=settings.max_retry_delay,
) )
# Получаем конфигурацию из настроек # Получаем конфигурацию из настроек
_rate_limit_settings = get_rate_limit_config("production") _rate_limit_settings = get_rate_limit_config("production")
_default_config = _create_rate_limit_config(_rate_limit_settings) _default_config = _create_rate_limit_config(_rate_limit_settings)
@@ -204,16 +208,20 @@ _default_config = _create_rate_limit_config(_rate_limit_settings)
telegram_rate_limiter = TelegramRateLimiter(_default_config) telegram_rate_limiter = TelegramRateLimiter(_default_config)
async def send_with_rate_limit(send_func: Callable, chat_id: int, *args, **kwargs) -> Any: async def send_with_rate_limit(
send_func: Callable, chat_id: int, *args, **kwargs
) -> Any:
""" """
Удобная функция для отправки сообщений с rate limiting Удобная функция для отправки сообщений с rate limiting
Args: Args:
send_func: Функция отправки (например, bot.send_message) send_func: Функция отправки (например, bot.send_message)
chat_id: ID чата chat_id: ID чата
*args, **kwargs: Аргументы для функции отправки *args, **kwargs: Аргументы для функции отправки
Returns: Returns:
Результат выполнения функции отправки Результат выполнения функции отправки
""" """
return await telegram_rate_limiter.send_with_rate_limit(send_func, chat_id, *args, **kwargs) return await telegram_rate_limiter.send_with_rate_limit(
send_func, chat_id, *args, **kwargs
)

View File

@@ -1,114 +1,114 @@
""" """
Сервис для работы с S3 хранилищем. Сервис для работы с S3 хранилищем.
""" """
import os import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import aioboto3 import aioboto3
from logs.custom_logger import logger from logs.custom_logger import logger
class S3StorageService: class S3StorageService:
"""Сервис для работы с S3 хранилищем.""" """Сервис для работы с S3 хранилищем."""
def __init__(self, endpoint_url: str, access_key: str, secret_key: str, def __init__(
bucket_name: str, region: str = "us-east-1"): self,
endpoint_url: str,
access_key: str,
secret_key: str,
bucket_name: str,
region: str = "us-east-1",
):
self.endpoint_url = endpoint_url self.endpoint_url = endpoint_url
self.access_key = access_key self.access_key = access_key
self.secret_key = secret_key self.secret_key = secret_key
self.bucket_name = bucket_name self.bucket_name = bucket_name
self.region = region self.region = region
self.session = aioboto3.Session() self.session = aioboto3.Session()
async def upload_file(self, file_path: str, s3_key: str, async def upload_file(
content_type: Optional[str] = None) -> bool: self, file_path: str, s3_key: str, content_type: Optional[str] = None
) -> bool:
"""Загружает файл в S3.""" """Загружает файл в S3."""
try: try:
async with self.session.client( async with self.session.client(
's3', "s3",
endpoint_url=self.endpoint_url, endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key, aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key, aws_secret_access_key=self.secret_key,
region_name=self.region region_name=self.region,
) as s3: ) as s3:
extra_args = {} extra_args = {}
if content_type: if content_type:
extra_args['ContentType'] = content_type extra_args["ContentType"] = content_type
await s3.upload_file( await s3.upload_file(
file_path, file_path, self.bucket_name, s3_key, ExtraArgs=extra_args
self.bucket_name,
s3_key,
ExtraArgs=extra_args
) )
logger.info(f"Файл загружен в S3: {s3_key}") logger.info(f"Файл загружен в S3: {s3_key}")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Ошибка загрузки файла в S3 {s3_key}: {e}") logger.error(f"Ошибка загрузки файла в S3 {s3_key}: {e}")
return False return False
async def upload_fileobj(self, file_obj, s3_key: str, async def upload_fileobj(
content_type: Optional[str] = None) -> bool: self, file_obj, s3_key: str, content_type: Optional[str] = None
) -> bool:
"""Загружает файл из объекта в S3.""" """Загружает файл из объекта в S3."""
try: try:
async with self.session.client( async with self.session.client(
's3', "s3",
endpoint_url=self.endpoint_url, endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key, aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key, aws_secret_access_key=self.secret_key,
region_name=self.region region_name=self.region,
) as s3: ) as s3:
extra_args = {} extra_args = {}
if content_type: if content_type:
extra_args['ContentType'] = content_type extra_args["ContentType"] = content_type
await s3.upload_fileobj( await s3.upload_fileobj(
file_obj, file_obj, self.bucket_name, s3_key, ExtraArgs=extra_args
self.bucket_name,
s3_key,
ExtraArgs=extra_args
) )
logger.info(f"Файл загружен в S3 из объекта: {s3_key}") logger.info(f"Файл загружен в S3 из объекта: {s3_key}")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Ошибка загрузки файла в S3 из объекта {s3_key}: {e}") logger.error(f"Ошибка загрузки файла в S3 из объекта {s3_key}: {e}")
return False return False
async def download_file(self, s3_key: str, local_path: str) -> bool: async def download_file(self, s3_key: str, local_path: str) -> bool:
"""Скачивает файл из S3 на локальный диск.""" """Скачивает файл из S3 на локальный диск."""
try: try:
async with self.session.client( async with self.session.client(
's3', "s3",
endpoint_url=self.endpoint_url, endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key, aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key, aws_secret_access_key=self.secret_key,
region_name=self.region region_name=self.region,
) as s3: ) as s3:
# Создаем директорию если её нет # Создаем директорию если её нет
os.makedirs(os.path.dirname(local_path), exist_ok=True) os.makedirs(os.path.dirname(local_path), exist_ok=True)
await s3.download_file( await s3.download_file(self.bucket_name, s3_key, local_path)
self.bucket_name,
s3_key,
local_path
)
logger.info(f"Файл скачан из S3: {s3_key} -> {local_path}") logger.info(f"Файл скачан из S3: {s3_key} -> {local_path}")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Ошибка скачивания файла из S3 {s3_key}: {e}") logger.error(f"Ошибка скачивания файла из S3 {s3_key}: {e}")
return False return False
async def download_to_temp(self, s3_key: str) -> Optional[str]: async def download_to_temp(self, s3_key: str) -> Optional[str]:
"""Скачивает файл из S3 во временный файл. Возвращает путь к временному файлу.""" """Скачивает файл из S3 во временный файл. Возвращает путь к временному файлу."""
try: try:
# Определяем расширение из ключа # Определяем расширение из ключа
ext = Path(s3_key).suffix or '.bin' ext = Path(s3_key).suffix or ".bin"
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=ext) temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
temp_path = temp_file.name temp_path = temp_file.name
temp_file.close() temp_file.close()
success = await self.download_file(s3_key, temp_path) success = await self.download_file(s3_key, temp_path)
if success: if success:
return temp_path return temp_path
@@ -120,33 +120,35 @@ class S3StorageService:
pass pass
return None return None
except Exception as e: except Exception as e:
logger.error(f"Ошибка скачивания файла из S3 во временный файл {s3_key}: {e}") logger.error(
f"Ошибка скачивания файла из S3 во временный файл {s3_key}: {e}"
)
return None return None
async def file_exists(self, s3_key: str) -> bool: async def file_exists(self, s3_key: str) -> bool:
"""Проверяет существование файла в S3.""" """Проверяет существование файла в S3."""
try: try:
async with self.session.client( async with self.session.client(
's3', "s3",
endpoint_url=self.endpoint_url, endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key, aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key, aws_secret_access_key=self.secret_key,
region_name=self.region region_name=self.region,
) as s3: ) as s3:
await s3.head_object(Bucket=self.bucket_name, Key=s3_key) await s3.head_object(Bucket=self.bucket_name, Key=s3_key)
return True return True
except: except:
return False return False
async def delete_file(self, s3_key: str) -> bool: async def delete_file(self, s3_key: str) -> bool:
"""Удаляет файл из S3.""" """Удаляет файл из S3."""
try: try:
async with self.session.client( async with self.session.client(
's3', "s3",
endpoint_url=self.endpoint_url, endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key, aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key, aws_secret_access_key=self.secret_key,
region_name=self.region region_name=self.region,
) as s3: ) as s3:
await s3.delete_object(Bucket=self.bucket_name, Key=s3_key) await s3.delete_object(Bucket=self.bucket_name, Key=s3_key)
logger.info(f"Файл удален из S3: {s3_key}") logger.info(f"Файл удален из S3: {s3_key}")
@@ -154,23 +156,35 @@ class S3StorageService:
except Exception as e: except Exception as e:
logger.error(f"Ошибка удаления файла из S3 {s3_key}: {e}") logger.error(f"Ошибка удаления файла из S3 {s3_key}: {e}")
return False return False
def generate_s3_key(self, content_type: str, file_id: str) -> str: def generate_s3_key(self, content_type: str, file_id: str) -> str:
"""Генерирует S3 ключ для файла. Один и тот же для всех постов с этим file_id.""" """Генерирует S3 ключ для файла. Один и тот же для всех постов с этим file_id."""
type_folders = { type_folders = {
'photo': 'photos', "photo": "photos",
'video': 'videos', "video": "videos",
'audio': 'music', "audio": "music",
'voice': 'voice', "voice": "voice",
'video_note': 'video_notes' "video_note": "video_notes",
} }
folder = type_folders.get(content_type, 'other') folder = type_folders.get(content_type, "other")
# Определяем расширение из file_id или используем дефолтное # Определяем расширение из file_id или используем дефолтное
ext = '.jpg' if content_type == 'photo' else \ ext = (
'.mp4' if content_type == 'video' else \ ".jpg"
'.mp3' if content_type == 'audio' else \ if content_type == "photo"
'.ogg' if content_type == 'voice' else \ else (
'.mp4' if content_type == 'video_note' else '.bin' ".mp4"
if content_type == "video"
else (
".mp3"
if content_type == "audio"
else (
".ogg"
if content_type == "voice"
else ".mp4" if content_type == "video_note" else ".bin"
)
)
)
)
return f"{folder}/{file_id}{ext}" return f"{folder}/{file_id}{ext}"

View File

@@ -8,7 +8,7 @@ from loguru import logger
logger.remove() logger.remove()
# Check if running in Docker/container # Check if running in Docker/container
is_container = os.path.exists('/.dockerenv') or os.getenv('DOCKER_CONTAINER') == 'true' is_container = os.path.exists("/.dockerenv") or os.getenv("DOCKER_CONTAINER") == "true"
if is_container: if is_container:
# In container: log to stdout/stderr # In container: log to stdout/stderr
@@ -16,23 +16,23 @@ if is_container:
sys.stdout, sys.stdout,
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {line} | {message}", format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {line} | {message}",
level=os.getenv("LOG_LEVEL", "INFO"), level=os.getenv("LOG_LEVEL", "INFO"),
colorize=True colorize=True,
) )
logger.add( logger.add(
sys.stderr, sys.stderr,
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {line} | {message}", format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {line} | {message}",
level="ERROR", level="ERROR",
colorize=True colorize=True,
) )
else: else:
# Local development: log to files # Local development: log to files
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
if not os.path.exists(current_dir): if not os.path.exists(current_dir):
os.makedirs(current_dir) os.makedirs(current_dir)
today = datetime.date.today().strftime('%Y-%m-%d') today = datetime.date.today().strftime("%Y-%m-%d")
filename = f'{current_dir}/helper_bot_{today}.log' filename = f"{current_dir}/helper_bot_{today}.log"
logger.add( logger.add(
filename, filename,
rotation="00:00", rotation="00:00",
@@ -42,4 +42,4 @@ else:
) )
# Bind logger name # Bind logger name
logger = logger.bind(name='main_log') logger = logger.bind(name="main_log")

View File

@@ -4,6 +4,13 @@ version = "1.0.0"
description = "Telegram bot with monitoring and metrics" description = "Telegram bot with monitoring and metrics"
requires-python = ">=3.11" requires-python = ">=3.11"
[tool.black]
line-length = 88
[tool.isort]
profile = "black"
line_length = 88
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
python_files = ["test_*.py"] python_files = ["test_*.py"]

View File

@@ -9,5 +9,6 @@ coverage>=7.0.0
# Development tools # Development tools
black>=23.0.0 black>=23.0.0
isort>=5.12.0
flake8>=6.0.0 flake8>=6.0.0
mypy>=1.0.0 mypy>=1.0.0

View File

@@ -17,71 +17,70 @@ from logs.custom_logger import logger
async def main(): async def main():
"""Основная функция запуска""" """Основная функция запуска"""
bdf = get_global_instance() bdf = get_global_instance()
# Создаем бота для автоматического разбана # Создаем бота для автоматического разбана
from aiogram import Bot from aiogram import Bot
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
auto_unban_bot = Bot( auto_unban_bot = Bot(
token=bdf.settings['Telegram']['bot_token'], token=bdf.settings["Telegram"]["bot_token"],
default=DefaultBotProperties(parse_mode='HTML'), default=DefaultBotProperties(parse_mode="HTML"),
timeout=30.0 timeout=30.0,
) )
# Инициализируем планировщик автоматического разбана # Инициализируем планировщик автоматического разбана
auto_unban_scheduler = get_auto_unban_scheduler() auto_unban_scheduler = get_auto_unban_scheduler()
auto_unban_scheduler.set_bot(auto_unban_bot) auto_unban_scheduler.set_bot(auto_unban_bot)
auto_unban_scheduler.start_scheduler() auto_unban_scheduler.start_scheduler()
# Метрики запускаются в main.py через server_prometheus.py # Метрики запускаются в main.py через server_prometheus.py
# Здесь не нужно дублировать функциональность # Здесь не нужно дублировать функциональность
# Флаг для корректного завершения # Флаг для корректного завершения
shutdown_event = asyncio.Event() shutdown_event = asyncio.Event()
def signal_handler(signum, frame): def signal_handler(signum, frame):
"""Обработчик сигналов для корректного завершения""" """Обработчик сигналов для корректного завершения"""
logger.info(f"Получен сигнал {signum}, завершаем работу...") logger.info(f"Получен сигнал {signum}, завершаем работу...")
shutdown_event.set() shutdown_event.set()
# Регистрируем обработчики сигналов # Регистрируем обработчики сигналов
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGTERM, signal_handler)
# Запускаем бота (метрики запускаются внутри start_bot) # Запускаем бота (метрики запускаются внутри start_bot)
bot_task = asyncio.create_task(start_bot(bdf)) bot_task = asyncio.create_task(start_bot(bdf))
main_bot = None main_bot = None
try: try:
# Ждем сигнала завершения # Ждем сигнала завершения
await shutdown_event.wait() await shutdown_event.wait()
logger.info("Начинаем корректное завершение...") logger.info("Начинаем корректное завершение...")
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info("Получен сигнал завершения...") logger.info("Получен сигнал завершения...")
finally: finally:
logger.info("Останавливаем планировщик автоматического разбана...") logger.info("Останавливаем планировщик автоматического разбана...")
auto_unban_scheduler.stop_scheduler() auto_unban_scheduler.stop_scheduler()
# Останавливаем планировщик метрик # Останавливаем планировщик метрик
try: try:
from helper_bot.utils.metrics_scheduler import \ from helper_bot.utils.metrics_scheduler import stop_metrics_scheduler
stop_metrics_scheduler
stop_metrics_scheduler() stop_metrics_scheduler()
logger.info("Планировщик метрик остановлен") logger.info("Планировщик метрик остановлен")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при остановке планировщика метрик: {e}") logger.error(f"Ошибка при остановке планировщика метрик: {e}")
# Метрики останавливаются в main.py # Метрики останавливаются в main.py
logger.info("Останавливаем задачи...") logger.info("Останавливаем задачи...")
# Отменяем задачу бота # Отменяем задачу бота
bot_task.cancel() bot_task.cancel()
# Ждем завершения задачи бота и получаем результат main bot # Ждем завершения задачи бота и получаем результат main bot
try: try:
results = await asyncio.gather(bot_task, return_exceptions=True) results = await asyncio.gather(bot_task, return_exceptions=True)
@@ -90,42 +89,46 @@ async def main():
main_bot = results[0] main_bot = results[0]
except Exception as e: except Exception as e:
logger.error(f"Ошибка при остановке задач: {e}") logger.error(f"Ошибка при остановке задач: {e}")
# Закрываем сессию основного бота (если она еще не закрыта) # Закрываем сессию основного бота (если она еще не закрыта)
if main_bot and hasattr(main_bot, 'session') and not main_bot.session.closed: if main_bot and hasattr(main_bot, "session") and not main_bot.session.closed:
try: try:
await main_bot.session.close() await main_bot.session.close()
logger.info("Сессия основного бота корректно закрыта") logger.info("Сессия основного бота корректно закрыта")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при закрытии сессии основного бота: {e}") logger.error(f"Ошибка при закрытии сессии основного бота: {e}")
# Закрываем сессию бота для автоматического разбана # Закрываем сессию бота для автоматического разбана
if not auto_unban_bot.session.closed: if not auto_unban_bot.session.closed:
try: try:
await auto_unban_bot.session.close() await auto_unban_bot.session.close()
logger.info("Сессия бота автоматического разбана корректно закрыта") logger.info("Сессия бота автоматического разбана корректно закрыта")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при закрытии сессии бота автоматического разбана: {e}") logger.error(
f"Ошибка при закрытии сессии бота автоматического разбана: {e}"
)
# Даем время на завершение всех aiohttp соединений # Даем время на завершение всех aiohttp соединений
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
logger.info("Бот корректно остановлен") logger.info("Бот корректно остановлен")
def init_db(): def init_db():
db_path = '/app/database/tg-bot-database.db' db_path = "/app/database/tg-bot-database.db"
schema_path = '/app/database/schema.sql' schema_path = "/app/database/schema.sql"
if not os.path.exists(db_path): if not os.path.exists(db_path):
print("Initializing database...") print("Initializing database...")
with open(schema_path, 'r') as f: with open(schema_path, "r") as f:
schema = f.read() schema = f.read()
with sqlite3.connect(db_path) as conn: with sqlite3.connect(db_path) as conn:
conn.executescript(schema) conn.executescript(schema)
print("Database initialized successfully") print("Database initialized successfully")
if __name__ == '__main__':
if __name__ == "__main__":
try: try:
init_db() init_db()
asyncio.run(main()) asyncio.run(main())
@@ -139,9 +142,11 @@ if __name__ == '__main__':
pending = asyncio.all_tasks(loop) pending = asyncio.all_tasks(loop)
for task in pending: for task in pending:
task.cancel() task.cancel()
# Ждем завершения всех задач # Ждем завершения всех задач
if pending: if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) loop.run_until_complete(
asyncio.gather(*pending, return_exceptions=True)
)
loop.close() loop.close()

View File

@@ -11,6 +11,7 @@
"rag": {"score": 0.90, "model": "rubert-base-cased", "ts": 1706198400} "rag": {"score": 0.90, "model": "rubert-base-cased", "ts": 1706198400}
} }
""" """
import argparse import argparse
import asyncio import asyncio
import os import os
@@ -28,7 +29,10 @@ try:
from logs.custom_logger import logger from logs.custom_logger import logger
except ImportError: except ImportError:
import logging import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_DB_PATH = "database/tg-bot-database.db" DEFAULT_DB_PATH = "database/tg-bot-database.db"
@@ -44,19 +48,19 @@ async def column_exists(conn: aiosqlite.Connection, table: str, column: str) ->
async def main(db_path: str) -> None: async def main(db_path: str) -> None:
""" """
Основная функция миграции. Основная функция миграции.
Добавляет колонку ml_scores в таблицу post_from_telegram_suggest. Добавляет колонку ml_scores в таблицу post_from_telegram_suggest.
Миграция идемпотентна - можно запускать повторно без ошибок. Миграция идемпотентна - можно запускать повторно без ошибок.
""" """
db_path = os.path.abspath(db_path) db_path = os.path.abspath(db_path)
if not os.path.exists(db_path): if not os.path.exists(db_path):
logger.error(f"База данных не найдена: {db_path}") logger.error(f"База данных не найдена: {db_path}")
return return
async with aiosqlite.connect(db_path) as conn: async with aiosqlite.connect(db_path) as conn:
await conn.execute("PRAGMA foreign_keys = ON") await conn.execute("PRAGMA foreign_keys = ON")
# Проверяем и добавляем колонку ml_scores # Проверяем и добавляем колонку ml_scores
if not await column_exists(conn, "post_from_telegram_suggest", "ml_scores"): if not await column_exists(conn, "post_from_telegram_suggest", "ml_scores"):
await conn.execute( await conn.execute(
@@ -65,7 +69,7 @@ async def main(db_path: str) -> None:
logger.info("Колонка ml_scores добавлена в post_from_telegram_suggest") logger.info("Колонка ml_scores добавлена в post_from_telegram_suggest")
else: else:
logger.info("Колонка ml_scores уже существует") logger.info("Колонка ml_scores уже существует")
await conn.commit() await conn.commit()
logger.info("Миграция add_ml_scores_columns завершена успешно") logger.info("Миграция add_ml_scores_columns завершена успешно")

View File

@@ -4,6 +4,7 @@
Сканирует папку scripts/ и применяет все новые миграции, которые еще не были применены. Сканирует папку scripts/ и применяет все новые миграции, которые еще не были применены.
""" """
import argparse import argparse
import asyncio import asyncio
import importlib.util import importlib.util
@@ -15,9 +16,9 @@ from typing import List, Tuple
# Исключаем служебные скрипты из миграций # Исключаем служебные скрипты из миграций
EXCLUDED_SCRIPTS = { EXCLUDED_SCRIPTS = {
'apply_migrations.py', "apply_migrations.py",
'test_s3_connection.py', "test_s3_connection.py",
'voice_cleanup.py', "voice_cleanup.py",
} }
DEFAULT_DB_PATH = "database/tg-bot-database.db" DEFAULT_DB_PATH = "database/tg-bot-database.db"
@@ -26,7 +27,7 @@ DEFAULT_DB_PATH = "database/tg-bot-database.db"
def get_migration_scripts(scripts_dir: Path) -> List[Tuple[str, Path]]: def get_migration_scripts(scripts_dir: Path) -> List[Tuple[str, Path]]:
""" """
Получает список скриптов миграций из папки scripts. Получает список скриптов миграций из папки scripts.
Возвращает список кортежей (имя_файла, путь_к_файлу), отсортированный по имени файла. Возвращает список кортежей (имя_файла, путь_к_файлу), отсортированный по имени файла.
""" """
scripts = [] scripts = []
@@ -39,24 +40,25 @@ def get_migration_scripts(scripts_dir: Path) -> List[Tuple[str, Path]]:
async def is_migration_script(script_path: Path) -> bool: async def is_migration_script(script_path: Path) -> bool:
""" """
Проверяет, является ли скрипт миграцией. Проверяет, является ли скрипт миграцией.
Миграция должна иметь функцию main() с параметром db_path. Миграция должна иметь функцию main() с параметром db_path.
""" """
try: try:
spec = importlib.util.spec_from_file_location("migration_script", script_path) spec = importlib.util.spec_from_file_location("migration_script", script_path)
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
return False return False
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) spec.loader.exec_module(module)
# Проверяем наличие функции main # Проверяем наличие функции main
if hasattr(module, 'main'): if hasattr(module, "main"):
import inspect import inspect
sig = inspect.signature(module.main) sig = inspect.signature(module.main)
# Проверяем, что функция принимает db_path # Проверяем, что функция принимает db_path
params = list(sig.parameters.keys()) params = list(sig.parameters.keys())
return 'db_path' in params return "db_path" in params
return False return False
except Exception: except Exception:
# Если не удалось проверить, считаем что это не миграция # Если не удалось проверить, считаем что это не миграция
@@ -66,12 +68,12 @@ async def is_migration_script(script_path: Path) -> bool:
async def apply_migration(script_path: Path, db_path: str) -> bool: async def apply_migration(script_path: Path, db_path: str) -> bool:
""" """
Применяет миграцию, запуская скрипт. Применяет миграцию, запуская скрипт.
Returns: Returns:
True если миграция применена успешно, False в противном случае. True если миграция применена успешно, False в противном случае.
""" """
script_name = script_path.name script_name = script_path.name
try: try:
# Запускаем скрипт как отдельный процесс # Запускаем скрипт как отдельный процесс
result = subprocess.run( result = subprocess.run(
@@ -79,9 +81,9 @@ async def apply_migration(script_path: Path, db_path: str) -> bool:
cwd=script_path.parent.parent, cwd=script_path.parent.parent,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=300 # 5 минут максимум на миграцию timeout=300, # 5 минут максимум на миграцию
) )
if result.returncode == 0: if result.returncode == 0:
if result.stdout: if result.stdout:
print(f" {result.stdout.strip()}") print(f" {result.stdout.strip()}")
@@ -93,7 +95,7 @@ async def apply_migration(script_path: Path, db_path: str) -> bool:
if result.stderr: if result.stderr:
print(f" STDERR: {result.stderr}") print(f" STDERR: {result.stderr}")
return False return False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
print(f" ❌ Превышен лимит времени (5 минут)") print(f" ❌ Превышен лимит времени (5 минут)")
return False return False
@@ -105,7 +107,7 @@ async def apply_migration(script_path: Path, db_path: str) -> bool:
async def main(db_path: str, dry_run: bool = False) -> None: async def main(db_path: str, dry_run: bool = False) -> None:
""" """
Основная функция для применения миграций. Основная функция для применения миграций.
Args: Args:
db_path: Путь к базе данных db_path: Путь к базе данных
dry_run: Если True, только показывает какие миграции будут применены dry_run: Если True, только показывает какие миграции будут применены
@@ -113,7 +115,7 @@ async def main(db_path: str, dry_run: bool = False) -> None:
# Импортируем зависимости только когда они действительно нужны # Импортируем зависимости только когда они действительно нужны
project_root = Path(__file__).resolve().parent.parent project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root)) sys.path.insert(0, str(project_root))
# Проверяем наличие необходимых зависимостей # Проверяем наличие необходимых зависимостей
try: try:
import aiosqlite import aiosqlite
@@ -121,53 +123,60 @@ async def main(db_path: str, dry_run: bool = False) -> None:
print("❌ Ошибка: модуль aiosqlite не установлен.") print("❌ Ошибка: модуль aiosqlite не установлен.")
print("💡 Установите зависимости: pip install -r requirements.txt") print("💡 Установите зависимости: pip install -r requirements.txt")
sys.exit(1) sys.exit(1)
# Импортируем logger # Импортируем logger
try: try:
from logs.custom_logger import logger from logs.custom_logger import logger
except ImportError: except ImportError:
import logging import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Импортируем MigrationRepository напрямую из файла # Импортируем MigrationRepository напрямую из файла
migration_repo_path = project_root / "database" / "repositories" / "migration_repository.py" migration_repo_path = (
project_root / "database" / "repositories" / "migration_repository.py"
)
if not migration_repo_path.exists(): if not migration_repo_path.exists():
print(f"❌ Файл migration_repository.py не найден: {migration_repo_path}") print(f"❌ Файл migration_repository.py не найден: {migration_repo_path}")
sys.exit(1) sys.exit(1)
spec = importlib.util.spec_from_file_location("migration_repository", migration_repo_path) spec = importlib.util.spec_from_file_location(
"migration_repository", migration_repo_path
)
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
print("Не удалось загрузить модуль migration_repository") print("Не удалось загрузить модуль migration_repository")
sys.exit(1) sys.exit(1)
migration_module = importlib.util.module_from_spec(spec) migration_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration_module) spec.loader.exec_module(migration_module)
MigrationRepository = migration_module.MigrationRepository MigrationRepository = migration_module.MigrationRepository
db_path = os.path.abspath(db_path) db_path = os.path.abspath(db_path)
if not os.path.exists(db_path): if not os.path.exists(db_path):
logger.error(f"База данных не найдена: {db_path}") logger.error(f"База данных не найдена: {db_path}")
print(f"❌ Ошибка: база данных не найдена: {db_path}") print(f"❌ Ошибка: база данных не найдена: {db_path}")
return return
scripts_dir = project_root / "scripts" scripts_dir = project_root / "scripts"
if not scripts_dir.exists(): if not scripts_dir.exists():
logger.error(f"Папка scripts не найдена: {scripts_dir}") logger.error(f"Папка scripts не найдена: {scripts_dir}")
print(f"❌ Ошибка: папка scripts не найдена: {scripts_dir}") print(f"❌ Ошибка: папка scripts не найдена: {scripts_dir}")
return return
# Инициализируем репозиторий миграций напрямую # Инициализируем репозиторий миграций напрямую
migration_repo = MigrationRepository(db_path) migration_repo = MigrationRepository(db_path)
await migration_repo.create_table() await migration_repo.create_table()
# Получаем список примененных миграций # Получаем список примененных миграций
applied_migrations = await migration_repo.get_applied_migrations() applied_migrations = await migration_repo.get_applied_migrations()
logger.info(f"Примененных миграций: {len(applied_migrations)}") logger.info(f"Примененных миграций: {len(applied_migrations)}")
# Получаем все скрипты миграций # Получаем все скрипты миграций
all_scripts = get_migration_scripts(scripts_dir) all_scripts = get_migration_scripts(scripts_dir)
# Фильтруем только миграции # Фильтруем только миграции
migration_scripts = [] migration_scripts = []
for script_name, script_path in all_scripts: for script_name, script_path in all_scripts:
@@ -175,30 +184,31 @@ async def main(db_path: str, dry_run: bool = False) -> None:
migration_scripts.append((script_name, script_path)) migration_scripts.append((script_name, script_path))
else: else:
logger.debug(f"Скрипт {script_name} не является миграцией, пропускаем") logger.debug(f"Скрипт {script_name} не является миграцией, пропускаем")
# Находим новые миграции # Находим новые миграции
new_migrations = [ new_migrations = [
(name, path) for name, path in migration_scripts (name, path)
for name, path in migration_scripts
if name not in applied_migrations if name not in applied_migrations
] ]
if not new_migrations: if not new_migrations:
print("Все миграции уже применены") print("Все миграции уже применены")
logger.info("Новых миграций не найдено") logger.info("Новых миграций не найдено")
return return
print(f"📋 Найдено новых миграций: {len(new_migrations)}") print(f"📋 Найдено новых миграций: {len(new_migrations)}")
for name, _ in new_migrations: for name, _ in new_migrations:
print(f" - {name}") print(f" - {name}")
if dry_run: if dry_run:
print("\n🔍 DRY RUN: миграции не будут применены") print("\n🔍 DRY RUN: миграции не будут применены")
return return
# Применяем миграции по порядку # Применяем миграции по порядку
print("\n🚀 Применение миграций...") print("\n🚀 Применение миграций...")
failed_migrations = [] failed_migrations = []
for script_name, script_path in new_migrations: for script_name, script_path in new_migrations:
print(f"📝 {script_name}...", end=" ", flush=True) print(f"📝 {script_name}...", end=" ", flush=True)
success = await apply_migration(script_path, db_path) success = await apply_migration(script_path, db_path)
@@ -213,7 +223,7 @@ async def main(db_path: str, dry_run: bool = False) -> None:
# Прерываем выполнение при ошибке # Прерываем выполнение при ошибке
print(f"\n⚠️ Прерывание: миграция {script_name} завершилась с ошибкой") print(f"\n⚠️ Прерывание: миграция {script_name} завершилась с ошибкой")
break break
if failed_migrations: if failed_migrations:
print(f"\nНе удалось применить {len(failed_migrations)} миграций:") print(f"\nНе удалось применить {len(failed_migrations)} миграций:")
for name in failed_migrations: for name in failed_migrations:
@@ -224,9 +234,7 @@ async def main(db_path: str, dry_run: bool = False) -> None:
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Применение миграций базы данных")
description="Применение миграций базы данных"
)
parser.add_argument( parser.add_argument(
"--db", "--db",
default=os.environ.get("DATABASE_PATH", DEFAULT_DB_PATH), default=os.environ.get("DATABASE_PATH", DEFAULT_DB_PATH),

View File

@@ -8,6 +8,7 @@
SQLite не поддерживает DROP COLUMN напрямую (до версии 3.35.0), SQLite не поддерживает DROP COLUMN напрямую (до версии 3.35.0),
поэтому используем пересоздание таблицы. поэтому используем пересоздание таблицы.
""" """
import argparse import argparse
import asyncio import asyncio
import os import os
@@ -25,7 +26,10 @@ try:
from logs.custom_logger import logger from logs.custom_logger import logger
except ImportError: except ImportError:
import logging import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_DB_PATH = "database/tg-bot-database.db" DEFAULT_DB_PATH = "database/tg-bot-database.db"
@@ -42,7 +46,7 @@ async def get_sqlite_version(conn: aiosqlite.Connection) -> tuple:
"""Возвращает версию SQLite.""" """Возвращает версию SQLite."""
cursor = await conn.execute("SELECT sqlite_version()") cursor = await conn.execute("SELECT sqlite_version()")
version_str = (await cursor.fetchone())[0] version_str = (await cursor.fetchone())[0]
return tuple(map(int, version_str.split('.'))) return tuple(map(int, version_str.split(".")))
async def main(db_path: str) -> None: async def main(db_path: str) -> None:
@@ -50,21 +54,21 @@ async def main(db_path: str) -> None:
Удаляет колонку vector_hash из таблицы post_from_telegram_suggest. Удаляет колонку vector_hash из таблицы post_from_telegram_suggest.
""" """
db_path = os.path.abspath(db_path) db_path = os.path.abspath(db_path)
if not os.path.exists(db_path): if not os.path.exists(db_path):
logger.error(f"База данных не найдена: {db_path}") logger.error(f"База данных не найдена: {db_path}")
return return
async with aiosqlite.connect(db_path) as conn: async with aiosqlite.connect(db_path) as conn:
# Проверяем существует ли колонка # Проверяем существует ли колонка
if not await column_exists(conn, "post_from_telegram_suggest", "vector_hash"): if not await column_exists(conn, "post_from_telegram_suggest", "vector_hash"):
logger.info("Колонка vector_hash не существует, миграция не требуется") logger.info("Колонка vector_hash не существует, миграция не требуется")
return return
# Проверяем версию SQLite # Проверяем версию SQLite
version = await get_sqlite_version(conn) version = await get_sqlite_version(conn)
logger.info(f"Версия SQLite: {'.'.join(map(str, version))}") logger.info(f"Версия SQLite: {'.'.join(map(str, version))}")
# SQLite 3.35.0+ поддерживает DROP COLUMN # SQLite 3.35.0+ поддерживает DROP COLUMN
if version >= (3, 35, 0): if version >= (3, 35, 0):
logger.info("Используем ALTER TABLE DROP COLUMN") logger.info("Используем ALTER TABLE DROP COLUMN")
@@ -74,15 +78,15 @@ async def main(db_path: str) -> None:
else: else:
# Для старых версий пересоздаём таблицу # Для старых версий пересоздаём таблицу
logger.info("Используем пересоздание таблицы (SQLite < 3.35.0)") logger.info("Используем пересоздание таблицы (SQLite < 3.35.0)")
# Получаем список колонок без vector_hash # Получаем список колонок без vector_hash
cursor = await conn.execute("PRAGMA table_info(post_from_telegram_suggest)") cursor = await conn.execute("PRAGMA table_info(post_from_telegram_suggest)")
columns = await cursor.fetchall() columns = await cursor.fetchall()
column_names = [col[1] for col in columns if col[1] != "vector_hash"] column_names = [col[1] for col in columns if col[1] != "vector_hash"]
columns_str = ", ".join(column_names) columns_str = ", ".join(column_names)
logger.info(f"Колонки для сохранения: {columns_str}") logger.info(f"Колонки для сохранения: {columns_str}")
# Пересоздаём таблицу # Пересоздаём таблицу
await conn.execute("BEGIN TRANSACTION") await conn.execute("BEGIN TRANSACTION")
try: try:
@@ -91,21 +95,21 @@ async def main(db_path: str) -> None:
f"CREATE TABLE post_from_telegram_suggest_backup AS " f"CREATE TABLE post_from_telegram_suggest_backup AS "
f"SELECT {columns_str} FROM post_from_telegram_suggest" f"SELECT {columns_str} FROM post_from_telegram_suggest"
) )
# Удаляем старую таблицу # Удаляем старую таблицу
await conn.execute("DROP TABLE post_from_telegram_suggest") await conn.execute("DROP TABLE post_from_telegram_suggest")
# Переименовываем временную # Переименовываем временную
await conn.execute( await conn.execute(
"ALTER TABLE post_from_telegram_suggest_backup " "ALTER TABLE post_from_telegram_suggest_backup "
"RENAME TO post_from_telegram_suggest" "RENAME TO post_from_telegram_suggest"
) )
await conn.execute("COMMIT") await conn.execute("COMMIT")
except Exception as e: except Exception as e:
await conn.execute("ROLLBACK") await conn.execute("ROLLBACK")
raise e raise e
await conn.commit() await conn.commit()
logger.info("Колонка vector_hash успешно удалена") logger.info("Колонка vector_hash успешно удалена")

View File

@@ -3,6 +3,7 @@
Скрипт для проверки подключения к S3 хранилищу. Скрипт для проверки подключения к S3 хранилищу.
Читает настройки из .env файла или переменных окружения. Читает настройки из .env файла или переменных окружения.
""" """
import asyncio import asyncio
import os import os
import sys import sys
@@ -14,7 +15,7 @@ sys.path.insert(0, str(project_root))
# Загружаем .env файл # Загружаем .env файл
from dotenv import load_dotenv from dotenv import load_dotenv
env_path = os.path.join(project_root, '.env') env_path = os.path.join(project_root, ".env")
if os.path.exists(env_path): if os.path.exists(env_path):
load_dotenv(env_path) load_dotenv(env_path)
@@ -26,11 +27,12 @@ except ImportError:
sys.exit(1) sys.exit(1)
# Данные для подключения из .env или переменных окружения # Данные для подключения из .env или переменных окружения
S3_ACCESS_KEY = os.getenv('S3_ACCESS_KEY', 'j3tears100@gmail.com') S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY", "j3tears100@gmail.com")
S3_SECRET_KEY = os.getenv('S3_SECRET_KEY', 'wQ1-6sZEPs92sbZTSf96') S3_SECRET_KEY = os.getenv("S3_SECRET_KEY", "wQ1-6sZEPs92sbZTSf96")
S3_ENDPOINT_URL = os.getenv('S3_ENDPOINT_URL', 'https://api.s3.miran.ru:443') S3_ENDPOINT_URL = os.getenv("S3_ENDPOINT_URL", "https://api.s3.miran.ru:443")
S3_BUCKET_NAME = os.getenv('S3_BUCKET_NAME', 'telegram-helper-bot') S3_BUCKET_NAME = os.getenv("S3_BUCKET_NAME", "telegram-helper-bot")
S3_REGION = os.getenv('S3_REGION', 'us-east-1') S3_REGION = os.getenv("S3_REGION", "us-east-1")
async def test_s3_connection(): async def test_s3_connection():
"""Тестирует подключение к S3 хранилищу.""" """Тестирует подключение к S3 хранилищу."""
@@ -40,50 +42,54 @@ async def test_s3_connection():
print(f"Region: {S3_REGION}") print(f"Region: {S3_REGION}")
print(f"Access Key: {S3_ACCESS_KEY}") print(f"Access Key: {S3_ACCESS_KEY}")
print() print()
session = aioboto3.Session() session = aioboto3.Session()
try: try:
async with session.client( async with session.client(
's3', "s3",
endpoint_url=S3_ENDPOINT_URL, endpoint_url=S3_ENDPOINT_URL,
aws_access_key_id=S3_ACCESS_KEY, aws_access_key_id=S3_ACCESS_KEY,
aws_secret_access_key=S3_SECRET_KEY, aws_secret_access_key=S3_SECRET_KEY,
region_name=S3_REGION region_name=S3_REGION,
) as s3: ) as s3:
# Пытаемся получить список бакетов (может не иметь прав, пропускаем если ошибка) # Пытаемся получить список бакетов (может не иметь прав, пропускаем если ошибка)
print("📦 Получение списка бакетов...") print("📦 Получение списка бакетов...")
try: try:
response = await s3.list_buckets() response = await s3.list_buckets()
buckets = response.get('Buckets', []) buckets = response.get("Buckets", [])
print(f"✅ Подключение успешно! Найдено бакетов: {len(buckets)}") print(f"✅ Подключение успешно! Найдено бакетов: {len(buckets)}")
if buckets: if buckets:
print("\n📋 Список бакетов:") print("\n📋 Список бакетов:")
for bucket in buckets: for bucket in buckets:
print(f" - {bucket['Name']} (создан: {bucket.get('CreationDate', 'неизвестно')})") print(
f" - {bucket['Name']} (создан: {bucket.get('CreationDate', 'неизвестно')})"
)
else: else:
print("\n⚠️ Бакеты не найдены.") print("\n⚠️ Бакеты не найдены.")
except Exception as list_error: except Exception as list_error:
print(f"⚠️ Не удалось получить список бакетов: {list_error}") print(f"⚠️ Не удалось получить список бакетов: {list_error}")
print(" Это нормально, если нет прав на list_buckets") print(" Это нормально, если нет прав на list_buckets")
print(" Продолжаем тестирование с указанным бакетом...") print(" Продолжаем тестирование с указанным бакетом...")
# Пытаемся создать тестовый файл в указанном бакете # Пытаемся создать тестовый файл в указанном бакете
print("\n🧪 Тестирование записи файла...") print("\n🧪 Тестирование записи файла...")
# Используем первый найденный бакет, если указанный не найден # Используем первый найденный бакет, если указанный не найден
test_bucket = S3_BUCKET_NAME test_bucket = S3_BUCKET_NAME
if buckets: if buckets:
# Проверяем, есть ли указанный бакет в списке # Проверяем, есть ли указанный бакет в списке
bucket_names = [b['Name'] for b in buckets] bucket_names = [b["Name"] for b in buckets]
if test_bucket not in bucket_names: if test_bucket not in bucket_names:
print(f"⚠️ Бакет '{test_bucket}' не найден в списке.") print(f"⚠️ Бакет '{test_bucket}' не найден в списке.")
print(f" Используем первый найденный бакет: '{buckets[0]['Name']}'") print(
test_bucket = buckets[0]['Name'] f" Используем первый найденный бакет: '{buckets[0]['Name']}'"
)
test_key = 'test-connection.txt' test_bucket = buckets[0]["Name"]
test_content = b'Test connection to S3 storage'
test_key = "test-connection.txt"
test_content = b"Test connection to S3 storage"
try: try:
# Проверяем существование бакета # Проверяем существование бакета
try: try:
@@ -93,33 +99,32 @@ async def test_s3_connection():
print(f"❌ Бакет '{test_bucket}' недоступен: {head_error}") print(f"❌ Бакет '{test_bucket}' недоступен: {head_error}")
print(" Проверьте права доступа к бакету") print(" Проверьте права доступа к бакету")
return False return False
await s3.put_object( await s3.put_object(Bucket=test_bucket, Key=test_key, Body=test_content)
Bucket=test_bucket, print(
Key=test_key, f"✅ Файл успешно записан в бакет '{test_bucket}' с ключом '{test_key}'"
Body=test_content
) )
print(f"✅ Файл успешно записан в бакет '{test_bucket}' с ключом '{test_key}'")
# Пытаемся прочитать файл # Пытаемся прочитать файл
print("🧪 Тестирование чтения файла...") print("🧪 Тестирование чтения файла...")
response = await s3.get_object(Bucket=test_bucket, Key=test_key) response = await s3.get_object(Bucket=test_bucket, Key=test_key)
content = await response['Body'].read() content = await response["Body"].read()
if content == test_content: if content == test_content:
print("✅ Файл успешно прочитан, содержимое совпадает") print("✅ Файл успешно прочитан, содержимое совпадает")
else: else:
print("⚠️ Файл прочитан, но содержимое не совпадает") print("⚠️ Файл прочитан, но содержимое не совпадает")
# Удаляем тестовый файл # Удаляем тестовый файл
print("🧹 Удаление тестового файла...") print("🧹 Удаление тестового файла...")
await s3.delete_object(Bucket=test_bucket, Key=test_key) await s3.delete_object(Bucket=test_bucket, Key=test_key)
print("✅ Тестовый файл удален") print("✅ Тестовый файл удален")
except Exception as e: except Exception as e:
print(f"❌ Ошибка при тестировании записи/чтения: {e}") print(f"❌ Ошибка при тестировании записи/чтения: {e}")
print(f" Тип ошибки: {type(e).__name__}") print(f" Тип ошибки: {type(e).__name__}")
import traceback import traceback
print(f" Полный traceback:") print(f" Полный traceback:")
traceback.print_exc() traceback.print_exc()
print("\nВозможные причины:") print("\nВозможные причины:")
@@ -127,9 +132,9 @@ async def test_s3_connection():
print(" 2. Нет прав на запись в бакет") print(" 2. Нет прав на запись в бакет")
print(" 3. Неверный endpoint URL или регион") print(" 3. Неверный endpoint URL или регион")
print(" 4. Проблемы с форматом endpoint (попробуйте без :443)") print(" 4. Проблемы с форматом endpoint (попробуйте без :443)")
return True return True
except Exception as e: except Exception as e:
print(f"❌ Ошибка подключения к S3: {e}") print(f"❌ Ошибка подключения к S3: {e}")
print("\nВозможные причины:") print("\nВозможные причины:")

View File

@@ -2,6 +2,7 @@
""" """
Скрипт для диагностики и очистки проблем с голосовыми файлами Скрипт для диагностики и очистки проблем с голосовыми файлами
""" """
import asyncio import asyncio
import os import os
import sys import sys
@@ -24,15 +25,15 @@ async def main():
if not os.path.exists(db_path): if not os.path.exists(db_path):
logger.error(f"База данных не найдена: {db_path}") logger.error(f"База данных не найдена: {db_path}")
return return
bot_db = AsyncBotDB(db_path) bot_db = AsyncBotDB(db_path)
cleanup_utils = VoiceFileCleanupUtils(bot_db) cleanup_utils = VoiceFileCleanupUtils(bot_db)
print("=== Диагностика голосовых файлов ===") print("=== Диагностика голосовых файлов ===")
# Запускаем полную диагностику # Запускаем полную диагностику
diagnostic_result = await cleanup_utils.run_full_diagnostic() diagnostic_result = await cleanup_utils.run_full_diagnostic()
print(f"\n📊 Статистика диска:") print(f"\n📊 Статистика диска:")
if "error" in diagnostic_result["disk_stats"]: if "error" in diagnostic_result["disk_stats"]:
print(f" ❌ Ошибка: {diagnostic_result['disk_stats']['error']}") print(f" ❌ Ошибка: {diagnostic_result['disk_stats']['error']}")
@@ -41,59 +42,65 @@ async def main():
print(f" 📁 Директория: {stats['directory']}") print(f" 📁 Директория: {stats['directory']}")
print(f" 📄 Всего файлов: {stats['total_files']}") print(f" 📄 Всего файлов: {stats['total_files']}")
print(f" 💾 Размер: {stats['total_size_mb']} MB") print(f" 💾 Размер: {stats['total_size_mb']} MB")
print(f"\n🗄️ База данных:") print(f"\n🗄️ База данных:")
print(f" 📝 Записей в БД: {diagnostic_result['db_records_count']}") print(f" 📝 Записей в БД: {diagnostic_result['db_records_count']}")
print(f" 🔍 Записей без файлов: {diagnostic_result['orphaned_db_records_count']}") print(
f" 🔍 Записей без файлов: {diagnostic_result['orphaned_db_records_count']}"
)
print(f" 📁 Файлов без записей: {diagnostic_result['orphaned_files_count']}") print(f" 📁 Файлов без записей: {diagnostic_result['orphaned_files_count']}")
print(f"\n📋 Статус: {diagnostic_result['status']}") print(f"\n📋 Статус: {diagnostic_result['status']}")
if diagnostic_result['status'] == 'issues_found': if diagnostic_result["status"] == "issues_found":
print("\n⚠️ Найдены проблемы!") print("\n⚠️ Найдены проблемы!")
if diagnostic_result['orphaned_db_records_count'] > 0: if diagnostic_result["orphaned_db_records_count"] > 0:
print(f"\n🗑️ Записи в БД без файлов (первые 10):") print(f"\n🗑️ Записи в БД без файлов (первые 10):")
for file_name, user_id in diagnostic_result['orphaned_db_records']: for file_name, user_id in diagnostic_result["orphaned_db_records"]:
print(f" - {file_name} (user_id: {user_id})") print(f" - {file_name} (user_id: {user_id})")
if diagnostic_result['orphaned_files_count'] > 0: if diagnostic_result["orphaned_files_count"] > 0:
print(f"\n📁 Файлы без записей в БД (первые 10):") print(f"\n📁 Файлы без записей в БД (первые 10):")
for file_path in diagnostic_result['orphaned_files']: for file_path in diagnostic_result["orphaned_files"]:
print(f" - {file_path}") print(f" - {file_path}")
# Предлагаем очистку # Предлагаем очистку
print("\n🧹 Хотите выполнить очистку?") print("\n🧹 Хотите выполнить очистку?")
print("1. Удалить записи в БД без файлов") print("1. Удалить записи в БД без файлов")
print("2. Удалить файлы без записей в БД") print("2. Удалить файлы без записей в БД")
print("3. Выполнить полную очистку") print("3. Выполнить полную очистку")
print("4. Выход") print("4. Выход")
choice = input("\nВыберите действие (1-4): ").strip() choice = input("\nВыберите действие (1-4): ").strip()
if choice == "1": if choice == "1":
print("\n🗑️ Удаление записей в БД без файлов...") print("\n🗑️ Удаление записей в БД без файлов...")
deleted = await cleanup_utils.cleanup_orphaned_db_records(dry_run=False) deleted = await cleanup_utils.cleanup_orphaned_db_records(dry_run=False)
print(f"✅ Удалено {deleted} записей") print(f"✅ Удалено {deleted} записей")
elif choice == "2": elif choice == "2":
print("\n📁 Удаление файлов без записей в БД...") print("\n📁 Удаление файлов без записей в БД...")
deleted = await cleanup_utils.cleanup_orphaned_files(dry_run=False) deleted = await cleanup_utils.cleanup_orphaned_files(dry_run=False)
print(f"✅ Удалено {deleted} файлов") print(f"✅ Удалено {deleted} файлов")
elif choice == "3": elif choice == "3":
print("\n🧹 Полная очистка...") print("\n🧹 Полная очистка...")
db_deleted = await cleanup_utils.cleanup_orphaned_db_records(dry_run=False) db_deleted = await cleanup_utils.cleanup_orphaned_db_records(
files_deleted = await cleanup_utils.cleanup_orphaned_files(dry_run=False) dry_run=False
)
files_deleted = await cleanup_utils.cleanup_orphaned_files(
dry_run=False
)
print(f"✅ Удалено {db_deleted} записей в БД и {files_deleted} файлов") print(f"✅ Удалено {db_deleted} записей в БД и {files_deleted} файлов")
elif choice == "4": elif choice == "4":
print("👋 Выход...") print("👋 Выход...")
else: else:
print("❌ Неверный выбор") print("❌ Неверный выбор")
else: else:
print("\n✅ Проблем не найдено!") print("\n✅ Проблем не найдено!")
except Exception as e: except Exception as e:
logger.error(f"Ошибка в скрипте: {e}") logger.error(f"Ошибка в скрипте: {e}")
print(f"❌ Ошибка: {e}") print(f"❌ Ошибка: {e}")

View File

@@ -6,13 +6,13 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import Chat, Message, User from aiogram.types import Chat, Message, User
from database.async_db import AsyncBotDB
# Импортируем моки в самом начале # Импортируем моки в самом начале
import tests.mocks import tests.mocks
from database.async_db import AsyncBotDB
# Настройка pytest-asyncio # Настройка pytest-asyncio
pytest_plugins = ('pytest_asyncio',) pytest_plugins = ("pytest_asyncio",)
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -93,19 +93,16 @@ def mock_dispatcher():
def test_settings(): def test_settings():
"""Возвращает тестовые настройки""" """Возвращает тестовые настройки"""
return { return {
'Telegram': { "Telegram": {
'bot_token': 'test_token_123', "bot_token": "test_token_123",
'preview_link': False, "preview_link": False,
'group_for_posts': '-1001234567890', "group_for_posts": "-1001234567890",
'group_for_message': '-1001234567891', "group_for_message": "-1001234567891",
'main_public': '-1001234567892', "main_public": "-1001234567892",
'group_for_logs': '-1001234567893', "group_for_logs": "-1001234567893",
'important_logs': '-1001234567894' "important_logs": "-1001234567894",
}, },
'Settings': { "Settings": {"logs": True, "test": False},
'logs': True,
'test': False
}
} }
@@ -122,71 +119,71 @@ def mock_factory(test_settings, mock_db):
@pytest.fixture @pytest.fixture
def sample_photo_message(mock_message): def sample_photo_message(mock_message):
"""Создает сообщение с фото для тестов""" """Создает сообщение с фото для тестов"""
mock_message.content_type = 'photo' mock_message.content_type = "photo"
mock_message.caption = 'Тестовое фото' mock_message.caption = "Тестовое фото"
mock_message.media_group_id = None mock_message.media_group_id = None
mock_message.photo = [Mock()] mock_message.photo = [Mock()]
mock_message.photo[-1].file_id = 'photo_file_id' mock_message.photo[-1].file_id = "photo_file_id"
return mock_message return mock_message
@pytest.fixture @pytest.fixture
def sample_video_message(mock_message): def sample_video_message(mock_message):
"""Создает сообщение с видео для тестов""" """Создает сообщение с видео для тестов"""
mock_message.content_type = 'video' mock_message.content_type = "video"
mock_message.caption = 'Тестовое видео' mock_message.caption = "Тестовое видео"
mock_message.media_group_id = None mock_message.media_group_id = None
mock_message.video = Mock() mock_message.video = Mock()
mock_message.video.file_id = 'video_file_id' mock_message.video.file_id = "video_file_id"
return mock_message return mock_message
@pytest.fixture @pytest.fixture
def sample_audio_message(mock_message): def sample_audio_message(mock_message):
"""Создает сообщение с аудио для тестов""" """Создает сообщение с аудио для тестов"""
mock_message.content_type = 'audio' mock_message.content_type = "audio"
mock_message.caption = 'Тестовое аудио' mock_message.caption = "Тестовое аудио"
mock_message.media_group_id = None mock_message.media_group_id = None
mock_message.audio = Mock() mock_message.audio = Mock()
mock_message.audio.file_id = 'audio_file_id' mock_message.audio.file_id = "audio_file_id"
return mock_message return mock_message
@pytest.fixture @pytest.fixture
def sample_voice_message(mock_message): def sample_voice_message(mock_message):
"""Создает голосовое сообщение для тестов""" """Создает голосовое сообщение для тестов"""
mock_message.content_type = 'voice' mock_message.content_type = "voice"
mock_message.media_group_id = None mock_message.media_group_id = None
mock_message.voice = Mock() mock_message.voice = Mock()
mock_message.voice.file_id = 'voice_file_id' mock_message.voice.file_id = "voice_file_id"
return mock_message return mock_message
@pytest.fixture @pytest.fixture
def sample_video_note_message(mock_message): def sample_video_note_message(mock_message):
"""Создает видеокружок для тестов""" """Создает видеокружок для тестов"""
mock_message.content_type = 'video_note' mock_message.content_type = "video_note"
mock_message.media_group_id = None mock_message.media_group_id = None
mock_message.video_note = Mock() mock_message.video_note = Mock()
mock_message.video_note.file_id = 'video_note_file_id' mock_message.video_note.file_id = "video_note_file_id"
return mock_message return mock_message
@pytest.fixture @pytest.fixture
def sample_media_group(mock_message): def sample_media_group(mock_message):
"""Создает медиагруппу для тестов""" """Создает медиагруппу для тестов"""
mock_message.media_group_id = 'group_123' mock_message.media_group_id = "group_123"
mock_message.content_type = 'photo' mock_message.content_type = "photo"
album = [mock_message] album = [mock_message]
album[0].caption = 'Подпись к медиагруппе' album[0].caption = "Подпись к медиагруппе"
return album return album
@pytest.fixture @pytest.fixture
def sample_text_message(mock_message): def sample_text_message(mock_message):
"""Создает текстовое сообщение для тестов""" """Создает текстовое сообщение для тестов"""
mock_message.content_type = 'text' mock_message.content_type = "text"
mock_message.text = 'Тестовое текстовое сообщение' mock_message.text = "Тестовое текстовое сообщение"
mock_message.media_group_id = None mock_message.media_group_id = None
return mock_message return mock_message
@@ -194,7 +191,7 @@ def sample_text_message(mock_message):
@pytest.fixture @pytest.fixture
def sample_document_message(mock_message): def sample_document_message(mock_message):
"""Создает сообщение с документом для тестов""" """Создает сообщение с документом для тестов"""
mock_message.content_type = 'document' mock_message.content_type = "document"
mock_message.media_group_id = None mock_message.media_group_id = None
return mock_message return mock_message
@@ -202,18 +199,10 @@ def sample_document_message(mock_message):
# Маркеры для категоризации тестов # Маркеры для категоризации тестов
def pytest_configure(config): def pytest_configure(config):
"""Настройка маркеров pytest""" """Настройка маркеров pytest"""
config.addinivalue_line( config.addinivalue_line("markers", "asyncio: mark test as async")
"markers", "asyncio: mark test as async" config.addinivalue_line("markers", "slow: mark test as slow")
) config.addinivalue_line("markers", "integration: mark test as integration test")
config.addinivalue_line( config.addinivalue_line("markers", "unit: mark test as unit test")
"markers", "slow: mark test as slow"
)
config.addinivalue_line(
"markers", "integration: mark test as integration test"
)
config.addinivalue_line(
"markers", "unit: mark test as unit test"
)
# Автоматическая маркировка тестов # Автоматическая маркировка тестов
@@ -223,15 +212,15 @@ def pytest_collection_modifyitems(config, items):
# Маркируем асинхронные тесты # Маркируем асинхронные тесты
if "async" in item.name or "Async" in item.name: if "async" in item.name or "Async" in item.name:
item.add_marker(pytest.mark.asyncio) item.add_marker(pytest.mark.asyncio)
# Маркируем интеграционные тесты # Маркируем интеграционные тесты
if "integration" in item.name.lower() or "Integration" in str(item.cls): if "integration" in item.name.lower() or "Integration" in str(item.cls):
item.add_marker(pytest.mark.integration) item.add_marker(pytest.mark.integration)
# Маркируем unit тесты # Маркируем unit тесты
if "unit" in item.name.lower() or "Unit" in str(item.cls): if "unit" in item.name.lower() or "Unit" in str(item.cls):
item.add_marker(pytest.mark.unit) item.add_marker(pytest.mark.unit)
# Маркируем медленные тесты # Маркируем медленные тесты
if "slow" in item.name.lower() or "Slow" in str(item.cls): if "slow" in item.name.lower() or "Slow" in str(item.cls):
item.add_marker(pytest.mark.slow) item.add_marker(pytest.mark.slow)

View File

@@ -3,6 +3,7 @@ import tempfile
from datetime import datetime from datetime import datetime
import pytest import pytest
from database.models import UserMessage from database.models import UserMessage
from database.repositories.message_repository import MessageRepository from database.repositories.message_repository import MessageRepository
@@ -10,11 +11,11 @@ from database.repositories.message_repository import MessageRepository
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def test_db_path(): def test_db_path():
"""Фикстура для пути к тестовой БД (сессионная область).""" """Фикстура для пути к тестовой БД (сессионная область)."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
temp_path = f.name temp_path = f.name
yield temp_path yield temp_path
# Очистка после всех тестов # Очистка после всех тестов
try: try:
os.unlink(temp_path) os.unlink(temp_path)
@@ -32,26 +33,26 @@ def message_repository(test_db_path):
def sample_messages(): def sample_messages():
"""Фикстура для набора тестовых сообщений.""" """Фикстура для набора тестовых сообщений."""
base_timestamp = int(datetime.now().timestamp()) base_timestamp = int(datetime.now().timestamp())
return [ return [
UserMessage( UserMessage(
message_text="Первое тестовое сообщение", message_text="Первое тестовое сообщение",
user_id=1001, user_id=1001,
telegram_message_id=2001, telegram_message_id=2001,
date=base_timestamp date=base_timestamp,
), ),
UserMessage( UserMessage(
message_text="Второе тестовое сообщение", message_text="Второе тестовое сообщение",
user_id=1002, user_id=1002,
telegram_message_id=2002, telegram_message_id=2002,
date=base_timestamp + 1 date=base_timestamp + 1,
), ),
UserMessage( UserMessage(
message_text="Третье тестовое сообщение", message_text="Третье тестовое сообщение",
user_id=1003, user_id=1003,
telegram_message_id=2003, telegram_message_id=2003,
date=base_timestamp + 2 date=base_timestamp + 2,
) ),
] ]
@@ -62,7 +63,7 @@ def message_without_date():
message_text="Сообщение без даты", message_text="Сообщение без даты",
user_id=1004, user_id=1004,
telegram_message_id=2004, telegram_message_id=2004,
date=None date=None,
) )
@@ -73,7 +74,7 @@ def message_with_zero_date():
message_text="Сообщение с нулевой датой", message_text="Сообщение с нулевой датой",
user_id=1005, user_id=1005,
telegram_message_id=2005, telegram_message_id=2005,
date=0 date=0,
) )
@@ -84,7 +85,7 @@ def message_with_special_chars():
message_text="Сообщение с 'кавычками', \"двойными кавычками\" и эмодзи 😊\nНовая строка", message_text="Сообщение с 'кавычками', \"двойными кавычками\" и эмодзи 😊\nНовая строка",
user_id=1006, user_id=1006,
telegram_message_id=2006, telegram_message_id=2006,
date=int(datetime.now().timestamp()) date=int(datetime.now().timestamp()),
) )
@@ -96,7 +97,7 @@ def long_message():
message_text=long_text, message_text=long_text,
user_id=1007, user_id=1007,
telegram_message_id=2007, telegram_message_id=2007,
date=int(datetime.now().timestamp()) date=int(datetime.now().timestamp()),
) )
@@ -107,7 +108,7 @@ def message_with_unicode():
message_text="Сообщение с Unicode: 你好世界 🌍 Привет мир", message_text="Сообщение с Unicode: 你好世界 🌍 Привет мир",
user_id=1008, user_id=1008,
telegram_message_id=2008, telegram_message_id=2008,
date=int(datetime.now().timestamp()) date=int(datetime.now().timestamp()),
) )

View File

@@ -5,6 +5,7 @@ from datetime import datetime
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
import pytest import pytest
from database.models import MessageContentLink, PostContent, TelegramPost from database.models import MessageContentLink, PostContent, TelegramPost
from database.repositories.post_repository import PostRepository from database.repositories.post_repository import PostRepository
@@ -37,7 +38,7 @@ def sample_telegram_post():
text="Тестовый пост для unit тестов", text="Тестовый пост для unit тестов",
author_id=67890, author_id=67890,
helper_text_message_id=None, helper_text_message_id=None,
created_at=int(datetime.now().timestamp()) created_at=int(datetime.now().timestamp()),
) )
@@ -49,7 +50,7 @@ def sample_telegram_post_with_helper():
text="Тестовый пост с helper сообщением", text="Тестовый пост с helper сообщением",
author_id=67890, author_id=67890,
helper_text_message_id=99999, helper_text_message_id=99999,
created_at=int(datetime.now().timestamp()) created_at=int(datetime.now().timestamp()),
) )
@@ -61,7 +62,7 @@ def sample_telegram_post_no_date():
text="Тестовый пост без даты", text="Тестовый пост без даты",
author_id=67890, author_id=67890,
helper_text_message_id=None, helper_text_message_id=None,
created_at=None created_at=None,
) )
@@ -69,19 +70,14 @@ def sample_telegram_post_no_date():
def sample_post_content(): def sample_post_content():
"""Создает тестовый объект PostContent""" """Создает тестовый объект PostContent"""
return PostContent( return PostContent(
message_id=12345, message_id=12345, content_name="/path/to/test/file.jpg", content_type="photo"
content_name="/path/to/test/file.jpg",
content_type="photo"
) )
@pytest.fixture @pytest.fixture
def sample_message_content_link(): def sample_message_content_link():
"""Создает тестовый объект MessageContentLink""" """Создает тестовый объект MessageContentLink"""
return MessageContentLink( return MessageContentLink(post_id=12345, message_id=67890)
post_id=12345,
message_id=67890
)
@pytest.fixture @pytest.fixture
@@ -105,11 +101,11 @@ def mock_logger():
@pytest.fixture @pytest.fixture
def temp_db_file(): def temp_db_file():
"""Создает временный файл БД для интеграционных тестов""" """Создает временный файл БД для интеграционных тестов"""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as tmp_file: with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp_file:
db_path = tmp_file.name db_path = tmp_file.name
yield db_path yield db_path
# Очищаем временный файл после тестов # Очищаем временный файл после тестов
try: try:
os.unlink(db_path) os.unlink(db_path)
@@ -132,22 +128,22 @@ def sample_posts_batch():
text="Первый тестовый пост", text="Первый тестовый пост",
author_id=11111, author_id=11111,
helper_text_message_id=None, helper_text_message_id=None,
created_at=int(datetime.now().timestamp()) created_at=int(datetime.now().timestamp()),
), ),
TelegramPost( TelegramPost(
message_id=10002, message_id=10002,
text="Второй тестовый пост", text="Второй тестовый пост",
author_id=22222, author_id=22222,
helper_text_message_id=None, helper_text_message_id=None,
created_at=int(datetime.now().timestamp()) created_at=int(datetime.now().timestamp()),
), ),
TelegramPost( TelegramPost(
message_id=10003, message_id=10003,
text="Третий тестовый пост", text="Третий тестовый пост",
author_id=33333, author_id=33333,
helper_text_message_id=88888, helper_text_message_id=88888,
created_at=int(datetime.now().timestamp()) created_at=int(datetime.now().timestamp()),
) ),
] ]
@@ -159,7 +155,7 @@ def sample_content_batch():
(10002, "/path/to/video1.mp4", "video"), (10002, "/path/to/video1.mp4", "video"),
(10003, "/path/to/audio1.mp3", "audio"), (10003, "/path/to/audio1.mp3", "audio"),
(10004, "/path/to/photo2.jpg", "photo"), (10004, "/path/to/photo2.jpg", "photo"),
(10005, "/path/to/video2.mp4", "video") (10005, "/path/to/video2.mp4", "video"),
] ]
@@ -195,19 +191,19 @@ def sample_author_ids():
def mock_sql_queries(): def mock_sql_queries():
"""Создает мок для SQL запросов""" """Создает мок для SQL запросов"""
return { return {
'create_tables': [ "create_tables": [
"CREATE TABLE IF NOT EXISTS post_from_telegram_suggest", "CREATE TABLE IF NOT EXISTS post_from_telegram_suggest",
"CREATE TABLE IF NOT EXISTS content_post_from_telegram", "CREATE TABLE IF NOT EXISTS content_post_from_telegram",
"CREATE TABLE IF NOT EXISTS message_link_to_content" "CREATE TABLE IF NOT EXISTS message_link_to_content",
], ],
'add_post': "INSERT INTO post_from_telegram_suggest", "add_post": "INSERT INTO post_from_telegram_suggest",
'add_post_status': "status", "add_post_status": "status",
'update_helper': "UPDATE post_from_telegram_suggest SET helper_text_message_id", "update_helper": "UPDATE post_from_telegram_suggest SET helper_text_message_id",
'update_status': "UPDATE post_from_telegram_suggest SET status = ?", "update_status": "UPDATE post_from_telegram_suggest SET status = ?",
'add_content': "INSERT OR IGNORE INTO content_post_from_telegram", "add_content": "INSERT OR IGNORE INTO content_post_from_telegram",
'add_link': "INSERT OR IGNORE INTO message_link_to_content", "add_link": "INSERT OR IGNORE INTO message_link_to_content",
'get_content': "SELECT cpft.content_name, cpft.content_type", "get_content": "SELECT cpft.content_name, cpft.content_type",
'get_text': "SELECT text FROM post_from_telegram_suggest", "get_text": "SELECT text FROM post_from_telegram_suggest",
'get_ids': "SELECT mltc.message_id", "get_ids": "SELECT mltc.message_id",
'get_author': "SELECT author_id FROM post_from_telegram_suggest" "get_author": "SELECT author_id FROM post_from_telegram_suggest",
} }

View File

@@ -1,6 +1,7 @@
""" """
Моки для тестового окружения Моки для тестового окружения
""" """
import os import os
import sys import sys
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
@@ -11,33 +12,34 @@ def setup_test_mocks():
"""Настройка моков для тестов""" """Настройка моков для тестов"""
# Мокаем os.getenv # Мокаем os.getenv
mock_env_vars = { mock_env_vars = {
'BOT_TOKEN': 'test_token_123', "BOT_TOKEN": "test_token_123",
'LISTEN_BOT_TOKEN': '', "LISTEN_BOT_TOKEN": "",
'TEST_BOT_TOKEN': '', "TEST_BOT_TOKEN": "",
'PREVIEW_LINK': 'False', "PREVIEW_LINK": "False",
'MAIN_PUBLIC': '@test', "MAIN_PUBLIC": "@test",
'GROUP_FOR_POSTS': '-1001234567890', "GROUP_FOR_POSTS": "-1001234567890",
'GROUP_FOR_MESSAGE': '-1001234567891', "GROUP_FOR_MESSAGE": "-1001234567891",
'GROUP_FOR_LOGS': '-1001234567893', "GROUP_FOR_LOGS": "-1001234567893",
'IMPORTANT_LOGS': '-1001234567894', "IMPORTANT_LOGS": "-1001234567894",
'TEST_GROUP': '-1001234567895', "TEST_GROUP": "-1001234567895",
'LOGS': 'True', "LOGS": "True",
'TEST': 'False', "TEST": "False",
'DATABASE_PATH': 'database/test.db' "DATABASE_PATH": "database/test.db",
} }
def mock_getenv(key, default=None): def mock_getenv(key, default=None):
return mock_env_vars.get(key, default) return mock_env_vars.get(key, default)
env_patcher = patch('os.getenv', side_effect=mock_getenv) env_patcher = patch("os.getenv", side_effect=mock_getenv)
env_patcher.start() env_patcher.start()
# Мокаем AsyncBotDB # Мокаем AsyncBotDB
mock_db = Mock() mock_db = Mock()
db_patcher = patch('helper_bot.utils.base_dependency_factory.AsyncBotDB', mock_db) db_patcher = patch("helper_bot.utils.base_dependency_factory.AsyncBotDB", mock_db)
db_patcher.start() db_patcher.start()
return env_patcher, db_patcher return env_patcher, db_patcher
# Настраиваем моки при импорте модуля # Настраиваем моки при импорте модуля
env_patcher, db_patcher = setup_test_mocks() env_patcher, db_patcher = setup_test_mocks()

View File

@@ -3,13 +3,14 @@ from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest import pytest
from database.models import Admin from database.models import Admin
from database.repositories.admin_repository import AdminRepository from database.repositories.admin_repository import AdminRepository
class TestAdminRepository: class TestAdminRepository:
"""Тесты для AdminRepository""" """Тесты для AdminRepository"""
@pytest.fixture @pytest.fixture
def mock_db_connection(self): def mock_db_connection(self):
"""Мок для DatabaseConnection""" """Мок для DatabaseConnection"""
@@ -18,137 +19,142 @@ class TestAdminRepository:
mock_connection._execute_query_with_result = AsyncMock() mock_connection._execute_query_with_result = AsyncMock()
mock_connection.logger = Mock() mock_connection.logger = Mock()
return mock_connection return mock_connection
@pytest.fixture @pytest.fixture
def admin_repository(self, mock_db_connection): def admin_repository(self, mock_db_connection):
"""Экземпляр AdminRepository для тестов""" """Экземпляр AdminRepository для тестов"""
# Патчим наследование от DatabaseConnection # Патчим наследование от DatabaseConnection
with patch.object(AdminRepository, '__init__', return_value=None): with patch.object(AdminRepository, "__init__", return_value=None):
repo = AdminRepository() repo = AdminRepository()
repo._execute_query = mock_db_connection._execute_query repo._execute_query = mock_db_connection._execute_query
repo._execute_query_with_result = mock_db_connection._execute_query_with_result repo._execute_query_with_result = (
mock_db_connection._execute_query_with_result
)
repo.logger = mock_db_connection.logger repo.logger = mock_db_connection.logger
return repo return repo
@pytest.fixture @pytest.fixture
def sample_admin(self): def sample_admin(self):
"""Тестовый администратор""" """Тестовый администратор"""
return Admin( return Admin(user_id=12345, role="admin")
user_id=12345,
role="admin"
)
@pytest.fixture @pytest.fixture
def sample_admin_with_created_at(self): def sample_admin_with_created_at(self):
"""Тестовый администратор с датой создания""" """Тестовый администратор с датой создания"""
return Admin( return Admin(
user_id=12345, user_id=12345, role="admin", created_at="1705312200" # UNIX timestamp
role="admin",
created_at="1705312200" # UNIX timestamp
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_tables(self, admin_repository): async def test_create_tables(self, admin_repository):
"""Тест создания таблицы администраторов""" """Тест создания таблицы администраторов"""
await admin_repository.create_tables() await admin_repository.create_tables()
# Проверяем, что включены внешние ключи # Проверяем, что включены внешние ключи
admin_repository._execute_query.assert_called() admin_repository._execute_query.assert_called()
calls = admin_repository._execute_query.call_args_list calls = admin_repository._execute_query.call_args_list
# Первый вызов должен быть для включения внешних ключей # Первый вызов должен быть для включения внешних ключей
assert calls[0][0][0] == "PRAGMA foreign_keys = ON" assert calls[0][0][0] == "PRAGMA foreign_keys = ON"
# Второй вызов должен быть для создания таблицы # Второй вызов должен быть для создания таблицы
create_table_call = calls[1] create_table_call = calls[1]
assert "CREATE TABLE IF NOT EXISTS admins" in create_table_call[0][0] assert "CREATE TABLE IF NOT EXISTS admins" in create_table_call[0][0]
assert "user_id INTEGER NOT NULL PRIMARY KEY" in create_table_call[0][0] assert "user_id INTEGER NOT NULL PRIMARY KEY" in create_table_call[0][0]
assert "role TEXT DEFAULT 'admin'" in create_table_call[0][0] assert "role TEXT DEFAULT 'admin'" in create_table_call[0][0]
assert "created_at INTEGER DEFAULT (strftime('%s', 'now'))" in create_table_call[0][0] assert (
assert "FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in create_table_call[0][0] "created_at INTEGER DEFAULT (strftime('%s', 'now'))"
in create_table_call[0][0]
)
assert (
"FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE"
in create_table_call[0][0]
)
# Проверяем логирование # Проверяем логирование
admin_repository.logger.info.assert_called_once_with("Таблица администраторов создана") admin_repository.logger.info.assert_called_once_with(
"Таблица администраторов создана"
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_admin(self, admin_repository, sample_admin): async def test_add_admin(self, admin_repository, sample_admin):
"""Тест добавления администратора""" """Тест добавления администратора"""
await admin_repository.add_admin(sample_admin) await admin_repository.add_admin(sample_admin)
# Проверяем, что метод вызван с правильными параметрами # Проверяем, что метод вызван с правильными параметрами
admin_repository._execute_query.assert_called_once() admin_repository._execute_query.assert_called_once()
call_args = admin_repository._execute_query.call_args call_args = admin_repository._execute_query.call_args
assert call_args[0][0] == "INSERT INTO admins (user_id, role) VALUES (?, ?)" assert call_args[0][0] == "INSERT INTO admins (user_id, role) VALUES (?, ?)"
assert call_args[0][1] == (12345, "admin") assert call_args[0][1] == (12345, "admin")
# Проверяем логирование # Проверяем логирование
admin_repository.logger.info.assert_called_once_with( admin_repository.logger.info.assert_called_once_with(
"Администратор добавлен: user_id=12345, role=admin" "Администратор добавлен: user_id=12345, role=admin"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_admin_with_custom_role(self, admin_repository): async def test_add_admin_with_custom_role(self, admin_repository):
"""Тест добавления администратора с кастомной ролью""" """Тест добавления администратора с кастомной ролью"""
admin = Admin(user_id=67890, role="super_admin") admin = Admin(user_id=67890, role="super_admin")
await admin_repository.add_admin(admin) await admin_repository.add_admin(admin)
call_args = admin_repository._execute_query.call_args call_args = admin_repository._execute_query.call_args
assert call_args[0][1] == (67890, "super_admin") assert call_args[0][1] == (67890, "super_admin")
admin_repository.logger.info.assert_called_once_with( admin_repository.logger.info.assert_called_once_with(
"Администратор добавлен: user_id=67890, role=super_admin" "Администратор добавлен: user_id=67890, role=super_admin"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remove_admin(self, admin_repository): async def test_remove_admin(self, admin_repository):
"""Тест удаления администратора""" """Тест удаления администратора"""
user_id = 12345 user_id = 12345
await admin_repository.remove_admin(user_id) await admin_repository.remove_admin(user_id)
# Проверяем, что метод вызван с правильными параметрами # Проверяем, что метод вызван с правильными параметрами
admin_repository._execute_query.assert_called_once() admin_repository._execute_query.assert_called_once()
call_args = admin_repository._execute_query.call_args call_args = admin_repository._execute_query.call_args
assert call_args[0][0] == "DELETE FROM admins WHERE user_id = ?" assert call_args[0][0] == "DELETE FROM admins WHERE user_id = ?"
assert call_args[0][1] == (user_id,) assert call_args[0][1] == (user_id,)
# Проверяем логирование # Проверяем логирование
admin_repository.logger.info.assert_called_once_with( admin_repository.logger.info.assert_called_once_with(
"Администратор удален: user_id=12345" "Администратор удален: user_id=12345"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_is_admin_true(self, admin_repository): async def test_is_admin_true(self, admin_repository):
"""Тест проверки администратора - пользователь является администратором""" """Тест проверки администратора - пользователь является администратором"""
user_id = 12345 user_id = 12345
# Мокаем результат запроса - пользователь найден # Мокаем результат запроса - пользователь найден
admin_repository._execute_query_with_result.return_value = [(1,)] admin_repository._execute_query_with_result.return_value = [(1,)]
result = await admin_repository.is_admin(user_id) result = await admin_repository.is_admin(user_id)
# Проверяем, что метод вызван с правильными параметрами # Проверяем, что метод вызван с правильными параметрами
admin_repository._execute_query_with_result.assert_called_once() admin_repository._execute_query_with_result.assert_called_once()
call_args = admin_repository._execute_query_with_result.call_args call_args = admin_repository._execute_query_with_result.call_args
assert call_args[0][0] == "SELECT 1 FROM admins WHERE user_id = ?" assert call_args[0][0] == "SELECT 1 FROM admins WHERE user_id = ?"
assert call_args[0][1] == (user_id,) assert call_args[0][1] == (user_id,)
# Проверяем результат # Проверяем результат
assert result is True assert result is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_is_admin_false(self, admin_repository): async def test_is_admin_false(self, admin_repository):
"""Тест проверки администратора - пользователь не является администратором""" """Тест проверки администратора - пользователь не является администратором"""
user_id = 12345 user_id = 12345
# Мокаем результат запроса - пользователь не найден # Мокаем результат запроса - пользователь не найден
admin_repository._execute_query_with_result.return_value = [] admin_repository._execute_query_with_result.return_value = []
result = await admin_repository.is_admin(user_id) result = await admin_repository.is_admin(user_id)
# Проверяем результат # Проверяем результат
assert result is False assert result is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_admin_found(self, admin_repository): async def test_get_admin_found(self, admin_repository):
"""Тест получения информации об администраторе - администратор найден""" """Тест получения информации об администраторе - администратор найден"""
@@ -157,138 +163,143 @@ class TestAdminRepository:
admin_repository._execute_query_with_result.return_value = [ admin_repository._execute_query_with_result.return_value = [
(12345, "admin", "1705312200") (12345, "admin", "1705312200")
] ]
result = await admin_repository.get_admin(user_id) result = await admin_repository.get_admin(user_id)
# Проверяем, что метод вызван с правильными параметрами # Проверяем, что метод вызван с правильными параметрами
admin_repository._execute_query_with_result.assert_called_once() admin_repository._execute_query_with_result.assert_called_once()
call_args = admin_repository._execute_query_with_result.call_args call_args = admin_repository._execute_query_with_result.call_args
assert call_args[0][0] == "SELECT user_id, role, created_at FROM admins WHERE user_id = ?" assert (
call_args[0][0]
== "SELECT user_id, role, created_at FROM admins WHERE user_id = ?"
)
assert call_args[0][1] == (user_id,) assert call_args[0][1] == (user_id,)
# Проверяем результат # Проверяем результат
assert result is not None assert result is not None
assert result.user_id == 12345 assert result.user_id == 12345
assert result.role == "admin" assert result.role == "admin"
assert result.created_at == "1705312200" assert result.created_at == "1705312200"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_admin_not_found(self, admin_repository): async def test_get_admin_not_found(self, admin_repository):
"""Тест получения информации об администраторе - администратор не найден""" """Тест получения информации об администраторе - администратор не найден"""
user_id = 12345 user_id = 12345
# Мокаем результат запроса - администратор не найден # Мокаем результат запроса - администратор не найден
admin_repository._execute_query_with_result.return_value = [] admin_repository._execute_query_with_result.return_value = []
result = await admin_repository.get_admin(user_id) result = await admin_repository.get_admin(user_id)
# Проверяем результат # Проверяем результат
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_admin_without_created_at(self, admin_repository): async def test_get_admin_without_created_at(self, admin_repository):
"""Тест получения информации об администраторе без даты создания""" """Тест получения информации об администраторе без даты создания"""
user_id = 12345 user_id = 12345
# Мокаем результат запроса без created_at # Мокаем результат запроса без created_at
admin_repository._execute_query_with_result.return_value = [ admin_repository._execute_query_with_result.return_value = [(12345, "admin")]
(12345, "admin")
]
result = await admin_repository.get_admin(user_id) result = await admin_repository.get_admin(user_id)
# Проверяем результат # Проверяем результат
assert result is not None assert result is not None
assert result.user_id == 12345 assert result.user_id == 12345
assert result.role == "admin" assert result.role == "admin"
assert result.created_at is None assert result.created_at is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_admin_error_handling(self, admin_repository, sample_admin): async def test_add_admin_error_handling(self, admin_repository, sample_admin):
"""Тест обработки ошибок при добавлении администратора""" """Тест обработки ошибок при добавлении администратора"""
# Мокаем ошибку при выполнении запроса # Мокаем ошибку при выполнении запроса
admin_repository._execute_query.side_effect = Exception("Database error") admin_repository._execute_query.side_effect = Exception("Database error")
with pytest.raises(Exception, match="Database error"): with pytest.raises(Exception, match="Database error"):
await admin_repository.add_admin(sample_admin) await admin_repository.add_admin(sample_admin)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remove_admin_error_handling(self, admin_repository): async def test_remove_admin_error_handling(self, admin_repository):
"""Тест обработки ошибок при удалении администратора""" """Тест обработки ошибок при удалении администратора"""
# Мокаем ошибку при выполнении запроса # Мокаем ошибку при выполнении запроса
admin_repository._execute_query.side_effect = Exception("Database error") admin_repository._execute_query.side_effect = Exception("Database error")
with pytest.raises(Exception, match="Database error"): with pytest.raises(Exception, match="Database error"):
await admin_repository.remove_admin(12345) await admin_repository.remove_admin(12345)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_is_admin_error_handling(self, admin_repository): async def test_is_admin_error_handling(self, admin_repository):
"""Тест обработки ошибок при проверке администратора""" """Тест обработки ошибок при проверке администратора"""
# Мокаем ошибку при выполнении запроса # Мокаем ошибку при выполнении запроса
admin_repository._execute_query_with_result.side_effect = Exception("Database error") admin_repository._execute_query_with_result.side_effect = Exception(
"Database error"
)
with pytest.raises(Exception, match="Database error"): with pytest.raises(Exception, match="Database error"):
await admin_repository.is_admin(12345) await admin_repository.is_admin(12345)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_admin_error_handling(self, admin_repository): async def test_get_admin_error_handling(self, admin_repository):
"""Тест обработки ошибок при получении информации об администраторе""" """Тест обработки ошибок при получении информации об администраторе"""
# Мокаем ошибку при выполнении запроса # Мокаем ошибку при выполнении запроса
admin_repository._execute_query_with_result.side_effect = Exception("Database error") admin_repository._execute_query_with_result.side_effect = Exception(
"Database error"
)
with pytest.raises(Exception, match="Database error"): with pytest.raises(Exception, match="Database error"):
await admin_repository.get_admin(12345) await admin_repository.get_admin(12345)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_tables_error_handling(self, admin_repository): async def test_create_tables_error_handling(self, admin_repository):
"""Тест обработки ошибок при создании таблиц""" """Тест обработки ошибок при создании таблиц"""
# Мокаем ошибку при выполнении первого запроса # Мокаем ошибку при выполнении первого запроса
admin_repository._execute_query.side_effect = Exception("Database error") admin_repository._execute_query.side_effect = Exception("Database error")
with pytest.raises(Exception, match="Database error"): with pytest.raises(Exception, match="Database error"):
await admin_repository.create_tables() await admin_repository.create_tables()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_admin_model_compatibility(self, admin_repository): async def test_admin_model_compatibility(self, admin_repository):
"""Тест совместимости с моделью Admin""" """Тест совместимости с моделью Admin"""
user_id = 12345 user_id = 12345
role = "moderator" role = "moderator"
# Создаем администратора с помощью модели # Создаем администратора с помощью модели
admin = Admin(user_id=user_id, role=role) admin = Admin(user_id=user_id, role=role)
# Проверяем, что можем передать его в репозиторий # Проверяем, что можем передать его в репозиторий
await admin_repository.add_admin(admin) await admin_repository.add_admin(admin)
# Проверяем, что вызов был с правильными параметрами # Проверяем, что вызов был с правильными параметрами
call_args = admin_repository._execute_query.call_args call_args = admin_repository._execute_query.call_args
assert call_args[0][1] == (user_id, role) assert call_args[0][1] == (user_id, role)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_multiple_admin_operations(self, admin_repository): async def test_multiple_admin_operations(self, admin_repository):
"""Тест множественных операций с администраторами""" """Тест множественных операций с администраторами"""
# Добавляем первого администратора # Добавляем первого администратора
admin1 = Admin(user_id=111, role="admin") admin1 = Admin(user_id=111, role="admin")
await admin_repository.add_admin(admin1) await admin_repository.add_admin(admin1)
# Добавляем второго администратора # Добавляем второго администратора
admin2 = Admin(user_id=222, role="moderator") admin2 = Admin(user_id=222, role="moderator")
await admin_repository.add_admin(admin2) await admin_repository.add_admin(admin2)
# Проверяем, что оба добавлены # Проверяем, что оба добавлены
assert admin_repository._execute_query.call_count == 2 assert admin_repository._execute_query.call_count == 2
# Проверяем, что первый администратор существует # Проверяем, что первый администратор существует
admin_repository._execute_query_with_result.return_value = [(1,)] admin_repository._execute_query_with_result.return_value = [(1,)]
result1 = await admin_repository.is_admin(111) result1 = await admin_repository.is_admin(111)
assert result1 is True assert result1 is True
# Проверяем, что второй администратор существует # Проверяем, что второй администратор существует
result2 = await admin_repository.is_admin(222) result2 = await admin_repository.is_admin(222)
assert result2 is True assert result2 is True
# Удаляем первого администратора # Удаляем первого администратора
await admin_repository.remove_admin(111) await admin_repository.remove_admin(111)
# Проверяем, что он больше не существует # Проверяем, что он больше не существует
admin_repository._execute_query_with_result.return_value = [] admin_repository._execute_query_with_result.return_value = []
result3 = await admin_repository.is_admin(111) result3 = await admin_repository.is_admin(111)

View File

@@ -1,12 +1,13 @@
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
from database.async_db import AsyncBotDB from database.async_db import AsyncBotDB
class TestAsyncBotDB: class TestAsyncBotDB:
"""Тесты для AsyncBotDB""" """Тесты для AsyncBotDB"""
@pytest.fixture @pytest.fixture
def mock_factory(self): def mock_factory(self):
"""Мок для RepositoryFactory""" """Мок для RepositoryFactory"""
@@ -23,94 +24,130 @@ class TestAsyncBotDB:
mock_factory.blacklist_history.add_record_on_ban = AsyncMock() mock_factory.blacklist_history.add_record_on_ban = AsyncMock()
mock_factory.blacklist_history.set_unban_date = AsyncMock(return_value=True) mock_factory.blacklist_history.set_unban_date = AsyncMock(return_value=True)
return mock_factory return mock_factory
@pytest.fixture @pytest.fixture
def async_bot_db(self, mock_factory): def async_bot_db(self, mock_factory):
"""Экземпляр AsyncBotDB для тестов""" """Экземпляр AsyncBotDB для тестов"""
with patch('database.async_db.RepositoryFactory') as mock_factory_class: with patch("database.async_db.RepositoryFactory") as mock_factory_class:
mock_factory_class.return_value = mock_factory mock_factory_class.return_value = mock_factory
db = AsyncBotDB("test.db") db = AsyncBotDB("test.db")
return db return db
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_audio_moderate_record(self, async_bot_db, mock_factory): async def test_delete_audio_moderate_record(self, async_bot_db, mock_factory):
"""Тест метода delete_audio_moderate_record""" """Тест метода delete_audio_moderate_record"""
message_id = 12345 message_id = 12345
await async_bot_db.delete_audio_moderate_record(message_id) await async_bot_db.delete_audio_moderate_record(message_id)
# Проверяем, что метод вызван в репозитории # Проверяем, что метод вызван в репозитории
mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(message_id) mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(
message_id
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_audio_moderate_record_with_different_message_id(self, async_bot_db, mock_factory): async def test_delete_audio_moderate_record_with_different_message_id(
self, async_bot_db, mock_factory
):
"""Тест метода delete_audio_moderate_record с разными message_id""" """Тест метода delete_audio_moderate_record с разными message_id"""
test_cases = [123, 456, 789, 99999] test_cases = [123, 456, 789, 99999]
for message_id in test_cases: for message_id in test_cases:
await async_bot_db.delete_audio_moderate_record(message_id) await async_bot_db.delete_audio_moderate_record(message_id)
mock_factory.audio.delete_audio_moderate_record.assert_called_with(message_id) mock_factory.audio.delete_audio_moderate_record.assert_called_with(
message_id
)
# Проверяем, что метод вызван для каждого message_id # Проверяем, что метод вызван для каждого message_id
assert mock_factory.audio.delete_audio_moderate_record.call_count == len(test_cases) assert mock_factory.audio.delete_audio_moderate_record.call_count == len(
test_cases
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_audio_moderate_record_exception_handling(self, async_bot_db, mock_factory): async def test_delete_audio_moderate_record_exception_handling(
self, async_bot_db, mock_factory
):
"""Тест обработки исключений в delete_audio_moderate_record""" """Тест обработки исключений в delete_audio_moderate_record"""
message_id = 12345 message_id = 12345
mock_factory.audio.delete_audio_moderate_record.side_effect = Exception("Database error") mock_factory.audio.delete_audio_moderate_record.side_effect = Exception(
"Database error"
)
# Метод должен пробросить исключение # Метод должен пробросить исключение
with pytest.raises(Exception, match="Database error"): with pytest.raises(Exception, match="Database error"):
await async_bot_db.delete_audio_moderate_record(message_id) await async_bot_db.delete_audio_moderate_record(message_id)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_audio_moderate_record_integration_with_other_methods(self, async_bot_db, mock_factory): async def test_delete_audio_moderate_record_integration_with_other_methods(
self, async_bot_db, mock_factory
):
"""Тест интеграции delete_audio_moderate_record с другими методами""" """Тест интеграции delete_audio_moderate_record с другими методами"""
message_id = 12345 message_id = 12345
user_id = 67890 user_id = 67890
# Мокаем другие методы # Мокаем другие методы
mock_factory.audio.get_user_id_by_message_id_for_voice_bot = AsyncMock(return_value=user_id) mock_factory.audio.get_user_id_by_message_id_for_voice_bot = AsyncMock(
mock_factory.audio.set_user_id_and_message_id_for_voice_bot = AsyncMock(return_value=True) return_value=user_id
)
mock_factory.audio.set_user_id_and_message_id_for_voice_bot = AsyncMock(
return_value=True
)
# Тестируем последовательность операций # Тестируем последовательность операций
await async_bot_db.get_user_id_by_message_id_for_voice_bot(message_id) await async_bot_db.get_user_id_by_message_id_for_voice_bot(message_id)
await async_bot_db.set_user_id_and_message_id_for_voice_bot(message_id, user_id) await async_bot_db.set_user_id_and_message_id_for_voice_bot(message_id, user_id)
await async_bot_db.delete_audio_moderate_record(message_id) await async_bot_db.delete_audio_moderate_record(message_id)
# Проверяем, что все методы вызваны # Проверяем, что все методы вызваны
mock_factory.audio.get_user_id_by_message_id_for_voice_bot.assert_called_once_with(message_id) mock_factory.audio.get_user_id_by_message_id_for_voice_bot.assert_called_once_with(
mock_factory.audio.set_user_id_and_message_id_for_voice_bot.assert_called_once_with(message_id, user_id) message_id
mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(message_id) )
mock_factory.audio.set_user_id_and_message_id_for_voice_bot.assert_called_once_with(
message_id, user_id
)
mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(
message_id
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_audio_moderate_record_zero_message_id(self, async_bot_db, mock_factory): async def test_delete_audio_moderate_record_zero_message_id(
self, async_bot_db, mock_factory
):
"""Тест delete_audio_moderate_record с message_id = 0""" """Тест delete_audio_moderate_record с message_id = 0"""
message_id = 0 message_id = 0
await async_bot_db.delete_audio_moderate_record(message_id) await async_bot_db.delete_audio_moderate_record(message_id)
mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(message_id) mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(
message_id
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_audio_moderate_record_negative_message_id(self, async_bot_db, mock_factory): async def test_delete_audio_moderate_record_negative_message_id(
self, async_bot_db, mock_factory
):
"""Тест delete_audio_moderate_record с отрицательным message_id""" """Тест delete_audio_moderate_record с отрицательным message_id"""
message_id = -12345 message_id = -12345
await async_bot_db.delete_audio_moderate_record(message_id) await async_bot_db.delete_audio_moderate_record(message_id)
mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(message_id) mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(
message_id
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_audio_moderate_record_large_message_id(self, async_bot_db, mock_factory): async def test_delete_audio_moderate_record_large_message_id(
self, async_bot_db, mock_factory
):
"""Тест delete_audio_moderate_record с большим message_id""" """Тест delete_audio_moderate_record с большим message_id"""
message_id = 999999999 message_id = 999999999
await async_bot_db.delete_audio_moderate_record(message_id) await async_bot_db.delete_audio_moderate_record(message_id)
mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(message_id) mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(
message_id
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_user_blacklist_calls_history(self, async_bot_db, mock_factory): async def test_set_user_blacklist_calls_history(self, async_bot_db, mock_factory):
"""Тест что set_user_blacklist вызывает добавление в историю""" """Тест что set_user_blacklist вызывает добавление в историю"""
@@ -118,21 +155,21 @@ class TestAsyncBotDB:
message_for_user = "Нарушение правил" message_for_user = "Нарушение правил"
date_to_unban = 1234567890 date_to_unban = 1234567890
ban_author = 999 ban_author = 999
await async_bot_db.set_user_blacklist( await async_bot_db.set_user_blacklist(
user_id=user_id, user_id=user_id,
user_name=None, user_name=None,
message_for_user=message_for_user, message_for_user=message_for_user,
date_to_unban=date_to_unban, date_to_unban=date_to_unban,
ban_author=ban_author ban_author=ban_author,
) )
# Проверяем, что сначала добавлен в blacklist # Проверяем, что сначала добавлен в blacklist
mock_factory.blacklist.add_user.assert_called_once() mock_factory.blacklist.add_user.assert_called_once()
# Проверяем, что затем добавлена запись в историю # Проверяем, что затем добавлена запись в историю
mock_factory.blacklist_history.add_record_on_ban.assert_called_once() mock_factory.blacklist_history.add_record_on_ban.assert_called_once()
# Проверяем параметры записи в историю # Проверяем параметры записи в историю
history_call = mock_factory.blacklist_history.add_record_on_ban.call_args[0][0] history_call = mock_factory.blacklist_history.add_record_on_ban.call_args[0][0]
assert history_call.user_id == user_id assert history_call.user_id == user_id
@@ -140,77 +177,89 @@ class TestAsyncBotDB:
assert history_call.date_ban is not None assert history_call.date_ban is not None
assert history_call.date_unban is None assert history_call.date_unban is None
assert history_call.ban_author == ban_author assert history_call.ban_author == ban_author
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_user_blacklist_history_error_does_not_fail(self, async_bot_db, mock_factory): async def test_set_user_blacklist_history_error_does_not_fail(
self, async_bot_db, mock_factory
):
"""Тест что ошибка записи в историю не ломает процесс бана""" """Тест что ошибка записи в историю не ломает процесс бана"""
user_id = 12345 user_id = 12345
mock_factory.blacklist_history.add_record_on_ban.side_effect = Exception("History error") mock_factory.blacklist_history.add_record_on_ban.side_effect = Exception(
"History error"
)
# Бан должен пройти успешно, несмотря на ошибку в истории # Бан должен пройти успешно, несмотря на ошибку в истории
await async_bot_db.set_user_blacklist( await async_bot_db.set_user_blacklist(
user_id=user_id, user_id=user_id,
message_for_user="Тест", message_for_user="Тест",
date_to_unban=None, date_to_unban=None,
ban_author=None ban_author=None,
) )
# Проверяем, что пользователь все равно добавлен в blacklist # Проверяем, что пользователь все равно добавлен в blacklist
mock_factory.blacklist.add_user.assert_called_once() mock_factory.blacklist.add_user.assert_called_once()
# Проверяем, что попытка записи в историю была # Проверяем, что попытка записи в историю была
mock_factory.blacklist_history.add_record_on_ban.assert_called_once() mock_factory.blacklist_history.add_record_on_ban.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_user_blacklist_calls_history(self, async_bot_db, mock_factory): async def test_delete_user_blacklist_calls_history(
self, async_bot_db, mock_factory
):
"""Тест что delete_user_blacklist вызывает обновление истории""" """Тест что delete_user_blacklist вызывает обновление истории"""
user_id = 12345 user_id = 12345
result = await async_bot_db.delete_user_blacklist(user_id) result = await async_bot_db.delete_user_blacklist(user_id)
# Проверяем, что сначала обновлена история # Проверяем, что сначала обновлена история
mock_factory.blacklist_history.set_unban_date.assert_called_once() mock_factory.blacklist_history.set_unban_date.assert_called_once()
history_call = mock_factory.blacklist_history.set_unban_date.call_args history_call = mock_factory.blacklist_history.set_unban_date.call_args
assert history_call[0][0] == user_id assert history_call[0][0] == user_id
assert history_call[0][1] is not None # date_unban timestamp assert history_call[0][1] is not None # date_unban timestamp
# Проверяем, что затем удален из blacklist # Проверяем, что затем удален из blacklist
mock_factory.blacklist.remove_user.assert_called_once_with(user_id) mock_factory.blacklist.remove_user.assert_called_once_with(user_id)
# Проверяем результат # Проверяем результат
assert result is True assert result is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_user_blacklist_history_error_does_not_fail(self, async_bot_db, mock_factory): async def test_delete_user_blacklist_history_error_does_not_fail(
self, async_bot_db, mock_factory
):
"""Тест что ошибка обновления истории не ломает процесс разбана""" """Тест что ошибка обновления истории не ломает процесс разбана"""
user_id = 12345 user_id = 12345
mock_factory.blacklist_history.set_unban_date.side_effect = Exception("History error") mock_factory.blacklist_history.set_unban_date.side_effect = Exception(
"History error"
)
# Разбан должен пройти успешно, несмотря на ошибку в истории # Разбан должен пройти успешно, несмотря на ошибку в истории
result = await async_bot_db.delete_user_blacklist(user_id) result = await async_bot_db.delete_user_blacklist(user_id)
# Проверяем, что попытка обновления истории была # Проверяем, что попытка обновления истории была
mock_factory.blacklist_history.set_unban_date.assert_called_once() mock_factory.blacklist_history.set_unban_date.assert_called_once()
# Проверяем, что пользователь все равно удален из blacklist # Проверяем, что пользователь все равно удален из blacklist
mock_factory.blacklist.remove_user.assert_called_once_with(user_id) mock_factory.blacklist.remove_user.assert_called_once_with(user_id)
# Проверяем результат # Проверяем результат
assert result is True assert result is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_user_blacklist_returns_false_on_blacklist_error(self, async_bot_db, mock_factory): async def test_delete_user_blacklist_returns_false_on_blacklist_error(
self, async_bot_db, mock_factory
):
"""Тест что delete_user_blacklist возвращает False при ошибке удаления из blacklist""" """Тест что delete_user_blacklist возвращает False при ошибке удаления из blacklist"""
user_id = 12345 user_id = 12345
mock_factory.blacklist.remove_user.return_value = False mock_factory.blacklist.remove_user.return_value = False
result = await async_bot_db.delete_user_blacklist(user_id) result = await async_bot_db.delete_user_blacklist(user_id)
# Проверяем, что история обновлена # Проверяем, что история обновлена
mock_factory.blacklist_history.set_unban_date.assert_called_once() mock_factory.blacklist_history.set_unban_date.assert_called_once()
# Проверяем, что удаление из blacklist было попытка # Проверяем, что удаление из blacklist было попытка
mock_factory.blacklist.remove_user.assert_called_once_with(user_id) mock_factory.blacklist.remove_user.assert_called_once_with(user_id)
# Проверяем результат # Проверяем результат
assert result is False assert result is False

View File

@@ -3,8 +3,8 @@ from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
import pytest import pytest
from helper_bot.handlers.voice.exceptions import (DatabaseError,
FileOperationError) from helper_bot.handlers.voice.exceptions import DatabaseError, FileOperationError
from helper_bot.handlers.voice.services import AudioFileService from helper_bot.handlers.voice.services import AudioFileService
@@ -17,16 +17,19 @@ def mock_bot_db():
mock_db.add_audio_record_simple = AsyncMock() mock_db.add_audio_record_simple = AsyncMock()
return mock_db return mock_db
@pytest.fixture @pytest.fixture
def audio_service(mock_bot_db): def audio_service(mock_bot_db):
"""Экземпляр AudioFileService для тестов""" """Экземпляр AudioFileService для тестов"""
return AudioFileService(mock_bot_db) return AudioFileService(mock_bot_db)
@pytest.fixture @pytest.fixture
def sample_datetime(): def sample_datetime():
"""Тестовая дата""" """Тестовая дата"""
return datetime(2025, 1, 15, 14, 30, 0) return datetime(2025, 1, 15, 14, 30, 0)
@pytest.fixture @pytest.fixture
def mock_bot(): def mock_bot():
"""Мок для бота""" """Мок для бота"""
@@ -35,6 +38,7 @@ def mock_bot():
bot.download_file = AsyncMock() bot.download_file = AsyncMock()
return bot return bot
@pytest.fixture @pytest.fixture
def mock_message(): def mock_message():
"""Мок для сообщения""" """Мок для сообщения"""
@@ -43,6 +47,7 @@ def mock_message():
message.voice.file_id = "test_file_id" message.voice.file_id = "test_file_id"
return message return message
@pytest.fixture @pytest.fixture
def mock_file_info(): def mock_file_info():
"""Мок для информации о файле""" """Мок для информации о файле"""
@@ -53,76 +58,92 @@ def mock_file_info():
class TestGenerateFileName: class TestGenerateFileName:
"""Тесты для метода generate_file_name""" """Тесты для метода generate_file_name"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_generate_file_name_first_record(self, audio_service, mock_bot_db): async def test_generate_file_name_first_record(self, audio_service, mock_bot_db):
"""Тест генерации имени файла для первой записи пользователя""" """Тест генерации имени файла для первой записи пользователя"""
mock_bot_db.get_user_audio_records_count.return_value = 0 mock_bot_db.get_user_audio_records_count.return_value = 0
result = await audio_service.generate_file_name(12345) result = await audio_service.generate_file_name(12345)
assert result == "message_from_12345_number_1" assert result == "message_from_12345_number_1"
mock_bot_db.get_user_audio_records_count.assert_called_once_with(user_id=12345) mock_bot_db.get_user_audio_records_count.assert_called_once_with(user_id=12345)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_generate_file_name_existing_records(self, audio_service, mock_bot_db): async def test_generate_file_name_existing_records(
self, audio_service, mock_bot_db
):
"""Тест генерации имени файла для существующих записей""" """Тест генерации имени файла для существующих записей"""
mock_bot_db.get_user_audio_records_count.return_value = 3 mock_bot_db.get_user_audio_records_count.return_value = 3
mock_bot_db.get_path_for_audio_record.return_value = "message_from_12345_number_3" mock_bot_db.get_path_for_audio_record.return_value = (
"message_from_12345_number_3"
)
result = await audio_service.generate_file_name(12345) result = await audio_service.generate_file_name(12345)
assert result == "message_from_12345_number_4" assert result == "message_from_12345_number_4"
mock_bot_db.get_user_audio_records_count.assert_called_once_with(user_id=12345) mock_bot_db.get_user_audio_records_count.assert_called_once_with(user_id=12345)
mock_bot_db.get_path_for_audio_record.assert_called_once_with(user_id=12345) mock_bot_db.get_path_for_audio_record.assert_called_once_with(user_id=12345)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_generate_file_name_no_last_record(self, audio_service, mock_bot_db): async def test_generate_file_name_no_last_record(self, audio_service, mock_bot_db):
"""Тест генерации имени файла когда нет последней записи""" """Тест генерации имени файла когда нет последней записи"""
mock_bot_db.get_user_audio_records_count.return_value = 2 mock_bot_db.get_user_audio_records_count.return_value = 2
mock_bot_db.get_path_for_audio_record.return_value = None mock_bot_db.get_path_for_audio_record.return_value = None
result = await audio_service.generate_file_name(12345) result = await audio_service.generate_file_name(12345)
assert result == "message_from_12345_number_3" assert result == "message_from_12345_number_3"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_generate_file_name_invalid_last_record_format(self, audio_service, mock_bot_db): async def test_generate_file_name_invalid_last_record_format(
self, audio_service, mock_bot_db
):
"""Тест генерации имени файла с некорректным форматом последней записи""" """Тест генерации имени файла с некорректным форматом последней записи"""
mock_bot_db.get_user_audio_records_count.return_value = 2 mock_bot_db.get_user_audio_records_count.return_value = 2
mock_bot_db.get_path_for_audio_record.return_value = "invalid_format" mock_bot_db.get_path_for_audio_record.return_value = "invalid_format"
result = await audio_service.generate_file_name(12345) result = await audio_service.generate_file_name(12345)
assert result == "message_from_12345_number_3" assert result == "message_from_12345_number_3"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_generate_file_name_exception_handling(self, audio_service, mock_bot_db): async def test_generate_file_name_exception_handling(
self, audio_service, mock_bot_db
):
"""Тест обработки исключений при генерации имени файла""" """Тест обработки исключений при генерации имени файла"""
mock_bot_db.get_user_audio_records_count.side_effect = Exception("Database error") mock_bot_db.get_user_audio_records_count.side_effect = Exception(
"Database error"
)
with pytest.raises(FileOperationError) as exc_info: with pytest.raises(FileOperationError) as exc_info:
await audio_service.generate_file_name(12345) await audio_service.generate_file_name(12345)
assert "Не удалось сгенерировать имя файла" in str(exc_info.value) assert "Не удалось сгенерировать имя файла" in str(exc_info.value)
class TestSaveAudioFile: class TestSaveAudioFile:
"""Тесты для метода save_audio_file""" """Тесты для метода save_audio_file"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_audio_file_success(self, audio_service, mock_bot_db, sample_datetime): async def test_save_audio_file_success(
self, audio_service, mock_bot_db, sample_datetime
):
"""Тест успешного сохранения аудио файла""" """Тест успешного сохранения аудио файла"""
file_name = "test_audio" file_name = "test_audio"
user_id = 12345 user_id = 12345
file_id = "test_file_id" file_id = "test_file_id"
# Мокаем verify_file_exists чтобы он возвращал True # Мокаем verify_file_exists чтобы он возвращал True
with patch.object(audio_service, 'verify_file_exists', return_value=True): with patch.object(audio_service, "verify_file_exists", return_value=True):
await audio_service.save_audio_file(file_name, user_id, sample_datetime, file_id) await audio_service.save_audio_file(
file_name, user_id, sample_datetime, file_id
mock_bot_db.add_audio_record_simple.assert_called_once_with(file_name, user_id, sample_datetime) )
mock_bot_db.add_audio_record_simple.assert_called_once_with(
file_name, user_id, sample_datetime
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_audio_file_with_string_date(self, audio_service, mock_bot_db): async def test_save_audio_file_with_string_date(self, audio_service, mock_bot_db):
"""Тест сохранения аудио файла со строковой датой""" """Тест сохранения аудио файла со строковой датой"""
@@ -130,149 +151,196 @@ class TestSaveAudioFile:
user_id = 12345 user_id = 12345
date_string = "2025-01-15 14:30:00" date_string = "2025-01-15 14:30:00"
file_id = "test_file_id" file_id = "test_file_id"
# Мокаем verify_file_exists чтобы он возвращал True # Мокаем verify_file_exists чтобы он возвращал True
with patch.object(audio_service, 'verify_file_exists', return_value=True): with patch.object(audio_service, "verify_file_exists", return_value=True):
await audio_service.save_audio_file(file_name, user_id, date_string, file_id) await audio_service.save_audio_file(
file_name, user_id, date_string, file_id
mock_bot_db.add_audio_record_simple.assert_called_once_with(file_name, user_id, date_string) )
mock_bot_db.add_audio_record_simple.assert_called_once_with(
file_name, user_id, date_string
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_audio_file_exception_handling(self, audio_service, mock_bot_db, sample_datetime): async def test_save_audio_file_exception_handling(
self, audio_service, mock_bot_db, sample_datetime
):
"""Тест обработки исключений при сохранении аудио файла""" """Тест обработки исключений при сохранении аудио файла"""
mock_bot_db.add_audio_record_simple.side_effect = Exception("Database error") mock_bot_db.add_audio_record_simple.side_effect = Exception("Database error")
# Мокаем verify_file_exists чтобы он возвращал True # Мокаем verify_file_exists чтобы он возвращал True
with patch.object(audio_service, 'verify_file_exists', return_value=True): with patch.object(audio_service, "verify_file_exists", return_value=True):
with pytest.raises(DatabaseError) as exc_info: with pytest.raises(DatabaseError) as exc_info:
await audio_service.save_audio_file("test", 12345, sample_datetime, "file_id") await audio_service.save_audio_file(
"test", 12345, sample_datetime, "file_id"
)
assert "Не удалось сохранить аудио файл в БД" in str(exc_info.value) assert "Не удалось сохранить аудио файл в БД" in str(exc_info.value)
class TestDownloadAndSaveAudio: class TestDownloadAndSaveAudio:
"""Тесты для метода download_and_save_audio""" """Тесты для метода download_and_save_audio"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_and_save_audio_success(self, audio_service, mock_bot, mock_message, mock_file_info): async def test_download_and_save_audio_success(
self, audio_service, mock_bot, mock_message, mock_file_info
):
"""Тест успешного скачивания и сохранения аудио""" """Тест успешного скачивания и сохранения аудио"""
mock_bot.get_file.return_value = mock_file_info mock_bot.get_file.return_value = mock_file_info
# Мокаем скачанный файл # Мокаем скачанный файл
mock_downloaded_file = Mock() mock_downloaded_file = Mock()
mock_downloaded_file.tell.return_value = 0 mock_downloaded_file.tell.return_value = 0
mock_downloaded_file.seek = Mock() mock_downloaded_file.seek = Mock()
mock_downloaded_file.read.return_value = b"audio_data" mock_downloaded_file.read.return_value = b"audio_data"
# Настраиваем поведение tell() для получения размера файла # Настраиваем поведение tell() для получения размера файла
def mock_tell(): def mock_tell():
return 0 if mock_downloaded_file.seek.call_count == 0 else 1024 return 0 if mock_downloaded_file.seek.call_count == 0 else 1024
mock_downloaded_file.tell = Mock(side_effect=mock_tell) mock_downloaded_file.tell = Mock(side_effect=mock_tell)
mock_bot.download_file.return_value = mock_downloaded_file mock_bot.download_file.return_value = mock_downloaded_file
with patch('builtins.open', mock_open()) as mock_file: with patch("builtins.open", mock_open()) as mock_file:
with patch('os.makedirs'): with patch("os.makedirs"):
with patch('os.path.exists', return_value=True): with patch("os.path.exists", return_value=True):
with patch('os.path.getsize', return_value=1024): with patch("os.path.getsize", return_value=1024):
await audio_service.download_and_save_audio(mock_bot, mock_message, "test_audio") await audio_service.download_and_save_audio(
mock_bot, mock_message, "test_audio"
mock_bot.get_file.assert_called_once_with(file_id="test_file_id") )
mock_bot.download_file.assert_called_once_with(file_path="voice/test_file_id.ogg")
mock_bot.get_file.assert_called_once_with(
file_id="test_file_id"
)
mock_bot.download_file.assert_called_once_with(
file_path="voice/test_file_id.ogg"
)
mock_file.assert_called_once() mock_file.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_and_save_audio_no_message(self, audio_service, mock_bot): async def test_download_and_save_audio_no_message(self, audio_service, mock_bot):
"""Тест скачивания когда сообщение отсутствует""" """Тест скачивания когда сообщение отсутствует."""
with pytest.raises(FileOperationError) as exc_info: with patch(
await audio_service.download_and_save_audio(mock_bot, None, "test_audio") "helper_bot.handlers.voice.services.asyncio.sleep", new_callable=AsyncMock
):
with pytest.raises(FileOperationError) as exc_info:
await audio_service.download_and_save_audio(
mock_bot, None, "test_audio"
)
assert "Сообщение или голосовое сообщение не найдено" in str(exc_info.value) assert "Сообщение или голосовое сообщение не найдено" in str(exc_info.value)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_and_save_audio_no_voice(self, audio_service, mock_bot): async def test_download_and_save_audio_no_voice(self, audio_service, mock_bot):
"""Тест скачивания когда у сообщения нет voice атрибута""" """Тест скачивания когда у сообщения нет voice атрибута."""
message = Mock() message = Mock()
message.voice = None message.voice = None
with pytest.raises(FileOperationError) as exc_info: with patch(
await audio_service.download_and_save_audio(mock_bot, message, "test_audio") "helper_bot.handlers.voice.services.asyncio.sleep", new_callable=AsyncMock
):
with pytest.raises(FileOperationError) as exc_info:
await audio_service.download_and_save_audio(
mock_bot, message, "test_audio"
)
assert "Сообщение или голосовое сообщение не найдено" in str(exc_info.value) assert "Сообщение или голосовое сообщение не найдено" in str(exc_info.value)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_and_save_audio_download_failed(self, audio_service, mock_bot, mock_message, mock_file_info): async def test_download_and_save_audio_download_failed(
"""Тест скачивания когда загрузка не удалась""" self, audio_service, mock_bot, mock_message, mock_file_info
):
"""Тест скачивания когда загрузка не удалась."""
mock_bot.get_file.return_value = mock_file_info mock_bot.get_file.return_value = mock_file_info
mock_bot.download_file.return_value = None mock_bot.download_file.return_value = None
with pytest.raises(FileOperationError) as exc_info: with patch(
await audio_service.download_and_save_audio(mock_bot, mock_message, "test_audio") "helper_bot.handlers.voice.services.asyncio.sleep", new_callable=AsyncMock
):
with pytest.raises(FileOperationError) as exc_info:
await audio_service.download_and_save_audio(
mock_bot, mock_message, "test_audio"
)
assert "Не удалось скачать файл" in str(exc_info.value) assert "Не удалось скачать файл" in str(exc_info.value)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_and_save_audio_exception_handling(self, audio_service, mock_bot, mock_message): async def test_download_and_save_audio_exception_handling(
"""Тест обработки исключений при скачивании""" self, audio_service, mock_bot, mock_message
):
"""Тест обработки исключений при скачивании."""
mock_bot.get_file.side_effect = Exception("Network error") mock_bot.get_file.side_effect = Exception("Network error")
with pytest.raises(FileOperationError) as exc_info: with patch(
await audio_service.download_and_save_audio(mock_bot, mock_message, "test_audio") "helper_bot.handlers.voice.services.asyncio.sleep", new_callable=AsyncMock
):
with pytest.raises(FileOperationError) as exc_info:
await audio_service.download_and_save_audio(
mock_bot, mock_message, "test_audio"
)
assert "Не удалось скачать и сохранить аудио" in str(exc_info.value) assert "Не удалось скачать и сохранить аудио" in str(exc_info.value)
class TestAudioFileServiceIntegration: class TestAudioFileServiceIntegration:
"""Интеграционные тесты для AudioFileService""" """Интеграционные тесты для AudioFileService"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_full_audio_processing_workflow(self, mock_bot_db): async def test_full_audio_processing_workflow(self, mock_bot_db):
"""Тест полного рабочего процесса обработки аудио""" """Тест полного рабочего процесса обработки аудио"""
service = AudioFileService(mock_bot_db) service = AudioFileService(mock_bot_db)
# Настраиваем моки # Настраиваем моки
mock_bot_db.get_user_audio_records_count.return_value = 1 mock_bot_db.get_user_audio_records_count.return_value = 1
mock_bot_db.get_path_for_audio_record.return_value = "message_from_12345_number_1" mock_bot_db.get_path_for_audio_record.return_value = (
"message_from_12345_number_1"
)
mock_bot_db.add_audio_record_simple = AsyncMock() mock_bot_db.add_audio_record_simple = AsyncMock()
# Тестируем генерацию имени файла # Тестируем генерацию имени файла
file_name = await service.generate_file_name(12345) file_name = await service.generate_file_name(12345)
assert file_name == "message_from_12345_number_2" assert file_name == "message_from_12345_number_2"
# Тестируем сохранение в БД # Тестируем сохранение в БД
test_date = datetime.now() test_date = datetime.now()
with patch.object(service, 'verify_file_exists', return_value=True): with patch.object(service, "verify_file_exists", return_value=True):
await service.save_audio_file(file_name, 12345, test_date, "test_file_id") await service.save_audio_file(file_name, 12345, test_date, "test_file_id")
# Проверяем вызовы # Проверяем вызовы
mock_bot_db.get_user_audio_records_count.assert_called_once_with(user_id=12345) mock_bot_db.get_user_audio_records_count.assert_called_once_with(user_id=12345)
mock_bot_db.get_path_for_audio_record.assert_called_once_with(user_id=12345) mock_bot_db.get_path_for_audio_record.assert_called_once_with(user_id=12345)
mock_bot_db.add_audio_record_simple.assert_called_once_with(file_name, 12345, test_date) mock_bot_db.add_audio_record_simple.assert_called_once_with(
file_name, 12345, test_date
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_file_name_generation_sequence(self, mock_bot_db): async def test_file_name_generation_sequence(self, mock_bot_db):
"""Тест последовательности генерации имен файлов""" """Тест последовательности генерации имен файлов"""
service = AudioFileService(mock_bot_db) service = AudioFileService(mock_bot_db)
# Первая запись # Первая запись
mock_bot_db.get_user_audio_records_count.return_value = 0 mock_bot_db.get_user_audio_records_count.return_value = 0
file_name_1 = await service.generate_file_name(12345) file_name_1 = await service.generate_file_name(12345)
assert file_name_1 == "message_from_12345_number_1" assert file_name_1 == "message_from_12345_number_1"
# Вторая запись # Вторая запись
mock_bot_db.get_user_audio_records_count.return_value = 1 mock_bot_db.get_user_audio_records_count.return_value = 1
mock_bot_db.get_path_for_audio_record.return_value = "message_from_12345_number_1" mock_bot_db.get_path_for_audio_record.return_value = (
"message_from_12345_number_1"
)
file_name_2 = await service.generate_file_name(12345) file_name_2 = await service.generate_file_name(12345)
assert file_name_2 == "message_from_12345_number_2" assert file_name_2 == "message_from_12345_number_2"
# Третья запись # Третья запись
mock_bot_db.get_user_audio_records_count.return_value = 2 mock_bot_db.get_user_audio_records_count.return_value = 2
mock_bot_db.get_path_for_audio_record.return_value = "message_from_12345_number_2" mock_bot_db.get_path_for_audio_record.return_value = (
"message_from_12345_number_2"
)
file_name_3 = await service.generate_file_name(12345) file_name_3 = await service.generate_file_name(12345)
assert file_name_3 == "message_from_12345_number_3" assert file_name_3 == "message_from_12345_number_3"
if __name__ == '__main__': if __name__ == "__main__":
pytest.main([__file__]) pytest.main([__file__])

View File

@@ -1,15 +1,16 @@
import time import time
from datetime import datetime from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest import pytest
from database.models import AudioListenRecord, AudioMessage, AudioModerate from database.models import AudioListenRecord, AudioMessage, AudioModerate
from database.repositories.audio_repository import AudioRepository from database.repositories.audio_repository import AudioRepository
class TestAudioRepository: class TestAudioRepository:
"""Тесты для AudioRepository""" """Тесты для AudioRepository"""
@pytest.fixture @pytest.fixture
def mock_db_connection(self): def mock_db_connection(self):
"""Мок для DatabaseConnection""" """Мок для DatabaseConnection"""
@@ -18,18 +19,20 @@ class TestAudioRepository:
mock_connection._execute_query_with_result = AsyncMock() mock_connection._execute_query_with_result = AsyncMock()
mock_connection.logger = Mock() mock_connection.logger = Mock()
return mock_connection return mock_connection
@pytest.fixture @pytest.fixture
def audio_repository(self, mock_db_connection): def audio_repository(self, mock_db_connection):
"""Экземпляр AudioRepository для тестов""" """Экземпляр AudioRepository для тестов"""
# Патчим наследование от DatabaseConnection # Патчим наследование от DatabaseConnection
with patch.object(AudioRepository, '__init__', return_value=None): with patch.object(AudioRepository, "__init__", return_value=None):
repo = AudioRepository() repo = AudioRepository()
repo._execute_query = mock_db_connection._execute_query repo._execute_query = mock_db_connection._execute_query
repo._execute_query_with_result = mock_db_connection._execute_query_with_result repo._execute_query_with_result = (
mock_db_connection._execute_query_with_result
)
repo.logger = mock_db_connection.logger repo.logger = mock_db_connection.logger
return repo return repo
@pytest.fixture @pytest.fixture
def sample_audio_message(self): def sample_audio_message(self):
"""Тестовое аудио сообщение""" """Тестовое аудио сообщение"""
@@ -38,45 +41,49 @@ class TestAudioRepository:
author_id=12345, author_id=12345,
date_added="2025-01-15 14:30:00", date_added="2025-01-15 14:30:00",
file_id="test_file_id", file_id="test_file_id",
listen_count=0 listen_count=0,
) )
@pytest.fixture @pytest.fixture
def sample_datetime(self): def sample_datetime(self):
"""Тестовая дата""" """Тестовая дата"""
return datetime(2025, 1, 15, 14, 30, 0) return datetime(2025, 1, 15, 14, 30, 0)
@pytest.fixture @pytest.fixture
def sample_timestamp(self): def sample_timestamp(self):
"""Тестовый UNIX timestamp""" """Тестовый UNIX timestamp"""
return int(time.mktime(datetime(2025, 1, 15, 14, 30, 0).timetuple())) return int(time.mktime(datetime(2025, 1, 15, 14, 30, 0).timetuple()))
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_enable_foreign_keys(self, audio_repository): async def test_enable_foreign_keys(self, audio_repository):
"""Тест включения внешних ключей""" """Тест включения внешних ключей"""
await audio_repository.enable_foreign_keys() await audio_repository.enable_foreign_keys()
audio_repository._execute_query.assert_called_once_with("PRAGMA foreign_keys = ON;") audio_repository._execute_query.assert_called_once_with(
"PRAGMA foreign_keys = ON;"
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_tables(self, audio_repository): async def test_create_tables(self, audio_repository):
"""Тест создания таблиц""" """Тест создания таблиц"""
await audio_repository.create_tables() await audio_repository.create_tables()
# Проверяем, что все три таблицы созданы # Проверяем, что все три таблицы созданы
assert audio_repository._execute_query.call_count == 3 assert audio_repository._execute_query.call_count == 3
# Проверяем вызовы для каждой таблицы # Проверяем вызовы для каждой таблицы
calls = audio_repository._execute_query.call_args_list calls = audio_repository._execute_query.call_args_list
assert any("audio_message_reference" in str(call) for call in calls) assert any("audio_message_reference" in str(call) for call in calls)
assert any("user_audio_listens" in str(call) for call in calls) assert any("user_audio_listens" in str(call) for call in calls)
assert any("audio_moderate" in str(call) for call in calls) assert any("audio_moderate" in str(call) for call in calls)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_with_string_date(self, audio_repository, sample_audio_message): async def test_add_audio_record_with_string_date(
self, audio_repository, sample_audio_message
):
"""Тест добавления аудио записи со строковой датой""" """Тест добавления аудио записи со строковой датой"""
await audio_repository.add_audio_record(sample_audio_message) await audio_repository.add_audio_record(sample_audio_message)
# Проверяем, что метод вызван с правильными параметрами # Проверяем, что метод вызван с правильными параметрами
audio_repository._execute_query.assert_called_once() audio_repository._execute_query.assert_called_once()
call_args = audio_repository._execute_query.call_args call_args = audio_repository._execute_query.call_args
@@ -88,7 +95,7 @@ class TestAudioRepository:
assert call_args[0][1][0] == "test_audio_123.ogg" assert call_args[0][1][0] == "test_audio_123.ogg"
assert call_args[0][1][1] == 12345 assert call_args[0][1][1] == 12345
assert isinstance(call_args[0][1][2], int) # timestamp assert isinstance(call_args[0][1][2], int) # timestamp
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_with_datetime_date(self, audio_repository): async def test_add_audio_record_with_datetime_date(self, audio_repository):
"""Тест добавления аудио записи с datetime датой""" """Тест добавления аудио записи с datetime датой"""
@@ -97,15 +104,15 @@ class TestAudioRepository:
author_id=67890, author_id=67890,
date_added=datetime(2025, 1, 20, 10, 15, 0), date_added=datetime(2025, 1, 20, 10, 15, 0),
file_id="test_file_id_2", file_id="test_file_id_2",
listen_count=0 listen_count=0,
) )
await audio_repository.add_audio_record(audio_msg) await audio_repository.add_audio_record(audio_msg)
# Проверяем, что date_added преобразован в timestamp # Проверяем, что date_added преобразован в timestamp
call_args = audio_repository._execute_query.call_args call_args = audio_repository._execute_query.call_args
assert isinstance(call_args[0][1][2], int) # timestamp assert isinstance(call_args[0][1][2], int) # timestamp
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_with_timestamp_date(self, audio_repository): async def test_add_audio_record_with_timestamp_date(self, audio_repository):
"""Тест добавления аудио записи с timestamp датой""" """Тест добавления аудио записи с timestamp датой"""
@@ -115,292 +122,321 @@ class TestAudioRepository:
author_id=11111, author_id=11111,
date_added=timestamp, date_added=timestamp,
file_id="test_file_id_3", file_id="test_file_id_3",
listen_count=0 listen_count=0,
) )
await audio_repository.add_audio_record(audio_msg) await audio_repository.add_audio_record(audio_msg)
# Проверяем, что date_added остался timestamp # Проверяем, что date_added остался timestamp
call_args = audio_repository._execute_query.call_args call_args = audio_repository._execute_query.call_args
assert call_args[0][1][2] == timestamp assert call_args[0][1][2] == timestamp
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_simple_with_string_date(self, audio_repository): async def test_add_audio_record_simple_with_string_date(self, audio_repository):
"""Тест упрощенного добавления аудио записи со строковой датой""" """Тест упрощенного добавления аудио записи со строковой датой"""
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, "2025-01-15 14:30:00") await audio_repository.add_audio_record_simple(
"test_audio.ogg", 12345, "2025-01-15 14:30:00"
)
# Проверяем, что метод вызван # Проверяем, что метод вызван
audio_repository._execute_query.assert_called_once() audio_repository._execute_query.assert_called_once()
call_args = audio_repository._execute_query.call_args call_args = audio_repository._execute_query.call_args
assert call_args[0][1][0] == "test_audio.ogg" # file_name assert call_args[0][1][0] == "test_audio.ogg" # file_name
assert call_args[0][1][1] == 12345 # user_id assert call_args[0][1][1] == 12345 # user_id
assert isinstance(call_args[0][1][2], int) # timestamp assert isinstance(call_args[0][1][2], int) # timestamp
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_simple_with_datetime_date(self, audio_repository, sample_datetime): async def test_add_audio_record_simple_with_datetime_date(
self, audio_repository, sample_datetime
):
"""Тест упрощенного добавления аудио записи с datetime датой""" """Тест упрощенного добавления аудио записи с datetime датой"""
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, sample_datetime) await audio_repository.add_audio_record_simple(
"test_audio.ogg", 12345, sample_datetime
)
# Проверяем, что date_added преобразован в timestamp # Проверяем, что date_added преобразован в timestamp
call_args = audio_repository._execute_query.call_args call_args = audio_repository._execute_query.call_args
assert isinstance(call_args[0][1][2], int) # timestamp assert isinstance(call_args[0][1][2], int) # timestamp
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_last_date_audio(self, audio_repository): async def test_get_last_date_audio(self, audio_repository):
"""Тест получения даты последнего аудио""" """Тест получения даты последнего аудио"""
expected_timestamp = 1642248600 # 2022-01-17 10:30:00 expected_timestamp = 1642248600 # 2022-01-17 10:30:00
audio_repository._execute_query_with_result.return_value = [(expected_timestamp,)] audio_repository._execute_query_with_result.return_value = [
(expected_timestamp,)
]
result = await audio_repository.get_last_date_audio() result = await audio_repository.get_last_date_audio()
assert result == expected_timestamp assert result == expected_timestamp
audio_repository._execute_query_with_result.assert_called_once_with( audio_repository._execute_query_with_result.assert_called_once_with(
"SELECT date_added FROM audio_message_reference ORDER BY date_added DESC LIMIT 1" "SELECT date_added FROM audio_message_reference ORDER BY date_added DESC LIMIT 1"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_last_date_audio_no_records(self, audio_repository): async def test_get_last_date_audio_no_records(self, audio_repository):
"""Тест получения даты последнего аудио когда записей нет""" """Тест получения даты последнего аудио когда записей нет"""
audio_repository._execute_query_with_result.return_value = [] audio_repository._execute_query_with_result.return_value = []
result = await audio_repository.get_last_date_audio() result = await audio_repository.get_last_date_audio()
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_user_audio_records_count(self, audio_repository): async def test_get_user_audio_records_count(self, audio_repository):
"""Тест получения количества аудио записей пользователя""" """Тест получения количества аудио записей пользователя"""
audio_repository._execute_query_with_result.return_value = [(5,)] audio_repository._execute_query_with_result.return_value = [(5,)]
result = await audio_repository.get_user_audio_records_count(12345) result = await audio_repository.get_user_audio_records_count(12345)
assert result == 5 assert result == 5
audio_repository._execute_query_with_result.assert_called_once_with( audio_repository._execute_query_with_result.assert_called_once_with(
"SELECT COUNT(*) FROM audio_message_reference WHERE author_id = ?", (12345,) "SELECT COUNT(*) FROM audio_message_reference WHERE author_id = ?", (12345,)
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_path_for_audio_record(self, audio_repository): async def test_get_path_for_audio_record(self, audio_repository):
"""Тест получения пути к аудио записи пользователя""" """Тест получения пути к аудио записи пользователя"""
audio_repository._execute_query_with_result.return_value = [("test_audio.ogg",)] audio_repository._execute_query_with_result.return_value = [("test_audio.ogg",)]
result = await audio_repository.get_path_for_audio_record(12345) result = await audio_repository.get_path_for_audio_record(12345)
assert result == "test_audio.ogg" assert result == "test_audio.ogg"
audio_repository._execute_query_with_result.assert_called_once_with( audio_repository._execute_query_with_result.assert_called_once_with(
""" """
SELECT file_name FROM audio_message_reference SELECT file_name FROM audio_message_reference
WHERE author_id = ? ORDER BY date_added DESC LIMIT 1 WHERE author_id = ? ORDER BY date_added DESC LIMIT 1
""", (12345,) """,
(12345,),
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_path_for_audio_record_no_records(self, audio_repository): async def test_get_path_for_audio_record_no_records(self, audio_repository):
"""Тест получения пути к аудио записи когда записей нет""" """Тест получения пути к аудио записи когда записей нет"""
audio_repository._execute_query_with_result.return_value = [] audio_repository._execute_query_with_result.return_value = []
result = await audio_repository.get_path_for_audio_record(12345) result = await audio_repository.get_path_for_audio_record(12345)
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_check_listen_audio(self, audio_repository): async def test_check_listen_audio(self, audio_repository):
"""Тест проверки непрослушанных аудио""" """Тест проверки непрослушанных аудио"""
# Мокаем результаты запросов # Мокаем результаты запросов
audio_repository._execute_query_with_result.side_effect = [ audio_repository._execute_query_with_result.side_effect = [
[("audio1.ogg",), ("audio2.ogg",)], # прослушанные [("audio1.ogg",), ("audio2.ogg",)], # прослушанные
[("audio1.ogg",), ("audio2.ogg",), ("audio3.ogg",)] # все аудио [("audio1.ogg",), ("audio2.ogg",), ("audio3.ogg",)], # все аудио
] ]
result = await audio_repository.check_listen_audio(12345) result = await audio_repository.check_listen_audio(12345)
# Должно вернуться только непрослушанные (audio3.ogg) # Должно вернуться только непрослушанные (audio3.ogg)
assert result == ["audio3.ogg"] assert result == ["audio3.ogg"]
assert audio_repository._execute_query_with_result.call_count == 2 assert audio_repository._execute_query_with_result.call_count == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mark_listened_audio(self, audio_repository): async def test_mark_listened_audio(self, audio_repository):
"""Тест отметки аудио как прослушанного""" """Тест отметки аудио как прослушанного"""
await audio_repository.mark_listened_audio("test_audio.ogg", 12345) await audio_repository.mark_listened_audio("test_audio.ogg", 12345)
audio_repository._execute_query.assert_called_once_with( audio_repository._execute_query.assert_called_once_with(
"INSERT OR IGNORE INTO user_audio_listens (file_name, user_id) VALUES (?, ?)", "INSERT OR IGNORE INTO user_audio_listens (file_name, user_id) VALUES (?, ?)",
("test_audio.ogg", 12345) ("test_audio.ogg", 12345),
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_user_id_by_file_name(self, audio_repository): async def test_get_user_id_by_file_name(self, audio_repository):
"""Тест получения user_id по имени файла""" """Тест получения user_id по имени файла"""
audio_repository._execute_query_with_result.return_value = [(12345,)] audio_repository._execute_query_with_result.return_value = [(12345,)]
result = await audio_repository.get_user_id_by_file_name("test_audio.ogg") result = await audio_repository.get_user_id_by_file_name("test_audio.ogg")
assert result == 12345 assert result == 12345
audio_repository._execute_query_with_result.assert_called_once_with( audio_repository._execute_query_with_result.assert_called_once_with(
"SELECT author_id FROM audio_message_reference WHERE file_name = ?", ("test_audio.ogg",) "SELECT author_id FROM audio_message_reference WHERE file_name = ?",
("test_audio.ogg",),
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_user_id_by_file_name_not_found(self, audio_repository): async def test_get_user_id_by_file_name_not_found(self, audio_repository):
"""Тест получения user_id по имени файла когда файл не найден""" """Тест получения user_id по имени файла когда файл не найден"""
audio_repository._execute_query_with_result.return_value = [] audio_repository._execute_query_with_result.return_value = []
result = await audio_repository.get_user_id_by_file_name("nonexistent.ogg") result = await audio_repository.get_user_id_by_file_name("nonexistent.ogg")
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_date_by_file_name(self, audio_repository): async def test_get_date_by_file_name(self, audio_repository):
"""Тест получения даты по имени файла""" """Тест получения даты по имени файла (UTC, без зависимости от локали)."""
timestamp = 1642404600 # 2022-01-17 10:30:00 timestamp = 1642404600 # 2022-01-17 10:30:00 UTC
audio_repository._execute_query_with_result.return_value = [(timestamp,)] audio_repository._execute_query_with_result.return_value = [(timestamp,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg") result = await audio_repository.get_date_by_file_name("test_audio.ogg")
# Должна вернуться читаемая дата expected = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime(
assert result == "17.01.2022 10:30" "%d.%m.%Y %H:%M"
audio_repository._execute_query_with_result.assert_called_once_with(
"SELECT date_added FROM audio_message_reference WHERE file_name = ?", ("test_audio.ogg",)
) )
assert result == expected
audio_repository._execute_query_with_result.assert_called_once_with(
"SELECT date_added FROM audio_message_reference WHERE file_name = ?",
("test_audio.ogg",),
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_date_by_file_name_not_found(self, audio_repository): async def test_get_date_by_file_name_not_found(self, audio_repository):
"""Тест получения даты по имени файла когда файл не найден""" """Тест получения даты по имени файла когда файл не найден"""
audio_repository._execute_query_with_result.return_value = [] audio_repository._execute_query_with_result.return_value = []
result = await audio_repository.get_date_by_file_name("nonexistent.ogg") result = await audio_repository.get_date_by_file_name("nonexistent.ogg")
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_refresh_listen_audio(self, audio_repository): async def test_refresh_listen_audio(self, audio_repository):
"""Тест очистки записей прослушивания пользователя""" """Тест очистки записей прослушивания пользователя"""
await audio_repository.refresh_listen_audio(12345) await audio_repository.refresh_listen_audio(12345)
audio_repository._execute_query.assert_called_once_with( audio_repository._execute_query.assert_called_once_with(
"DELETE FROM user_audio_listens WHERE user_id = ?", (12345,) "DELETE FROM user_audio_listens WHERE user_id = ?", (12345,)
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_listen_count_for_user(self, audio_repository): async def test_delete_listen_count_for_user(self, audio_repository):
"""Тест удаления данных о прослушанных аудио пользователя""" """Тест удаления данных о прослушанных аудио пользователя"""
await audio_repository.delete_listen_count_for_user(12345) await audio_repository.delete_listen_count_for_user(12345)
audio_repository._execute_query.assert_called_once_with( audio_repository._execute_query.assert_called_once_with(
"DELETE FROM user_audio_listens WHERE user_id = ?", (12345,) "DELETE FROM user_audio_listens WHERE user_id = ?", (12345,)
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_user_id_and_message_id_for_voice_bot_success(self, audio_repository): async def test_set_user_id_and_message_id_for_voice_bot_success(
self, audio_repository
):
"""Тест успешной установки связи для voice bot""" """Тест успешной установки связи для voice bot"""
result = await audio_repository.set_user_id_and_message_id_for_voice_bot(123, 456) result = await audio_repository.set_user_id_and_message_id_for_voice_bot(
123, 456
)
assert result is True assert result is True
audio_repository._execute_query.assert_called_once_with( audio_repository._execute_query.assert_called_once_with(
"INSERT OR IGNORE INTO audio_moderate (user_id, message_id) VALUES (?, ?)", "INSERT OR IGNORE INTO audio_moderate (user_id, message_id) VALUES (?, ?)",
(456, 123) (456, 123),
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_user_id_and_message_id_for_voice_bot_exception(self, audio_repository): async def test_set_user_id_and_message_id_for_voice_bot_exception(
self, audio_repository
):
"""Тест установки связи для voice bot при ошибке""" """Тест установки связи для voice bot при ошибке"""
audio_repository._execute_query.side_effect = Exception("Database error") audio_repository._execute_query.side_effect = Exception("Database error")
result = await audio_repository.set_user_id_and_message_id_for_voice_bot(123, 456) result = await audio_repository.set_user_id_and_message_id_for_voice_bot(
123, 456
)
assert result is False assert result is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_user_id_by_message_id_for_voice_bot(self, audio_repository): async def test_get_user_id_by_message_id_for_voice_bot(self, audio_repository):
"""Тест получения user_id по message_id для voice bot""" """Тест получения user_id по message_id для voice bot"""
audio_repository._execute_query_with_result.return_value = [(456,)] audio_repository._execute_query_with_result.return_value = [(456,)]
result = await audio_repository.get_user_id_by_message_id_for_voice_bot(123) result = await audio_repository.get_user_id_by_message_id_for_voice_bot(123)
assert result == 456 assert result == 456
audio_repository._execute_query_with_result.assert_called_once_with( audio_repository._execute_query_with_result.assert_called_once_with(
"SELECT user_id FROM audio_moderate WHERE message_id = ?", (123,) "SELECT user_id FROM audio_moderate WHERE message_id = ?", (123,)
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_user_id_by_message_id_for_voice_bot_not_found(self, audio_repository): async def test_get_user_id_by_message_id_for_voice_bot_not_found(
self, audio_repository
):
"""Тест получения user_id по message_id когда связь не найдена""" """Тест получения user_id по message_id когда связь не найдена"""
audio_repository._execute_query_with_result.return_value = [] audio_repository._execute_query_with_result.return_value = []
result = await audio_repository.get_user_id_by_message_id_for_voice_bot(123) result = await audio_repository.get_user_id_by_message_id_for_voice_bot(123)
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_audio_moderate_record(self, audio_repository): async def test_delete_audio_moderate_record(self, audio_repository):
"""Тест удаления записи из таблицы audio_moderate""" """Тест удаления записи из таблицы audio_moderate"""
message_id = 12345 message_id = 12345
await audio_repository.delete_audio_moderate_record(message_id) await audio_repository.delete_audio_moderate_record(message_id)
audio_repository._execute_query.assert_called_once_with( audio_repository._execute_query.assert_called_once_with(
"DELETE FROM audio_moderate WHERE message_id = ?", (message_id,) "DELETE FROM audio_moderate WHERE message_id = ?", (message_id,)
) )
audio_repository.logger.info.assert_called_once_with( audio_repository.logger.info.assert_called_once_with(
f"Удалена запись из audio_moderate для message_id {message_id}" f"Удалена запись из audio_moderate для message_id {message_id}"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_logging(self, audio_repository, sample_audio_message): async def test_add_audio_record_logging(
self, audio_repository, sample_audio_message
):
"""Тест логирования при добавлении аудио записи""" """Тест логирования при добавлении аудио записи"""
await audio_repository.add_audio_record(sample_audio_message) await audio_repository.add_audio_record(sample_audio_message)
# Проверяем, что лог записан # Проверяем, что лог записан
audio_repository.logger.info.assert_called_once() audio_repository.logger.info.assert_called_once()
log_message = audio_repository.logger.info.call_args[0][0] log_message = audio_repository.logger.info.call_args[0][0]
assert "Аудио добавлено" in log_message assert "Аудио добавлено" in log_message
assert "test_audio_123.ogg" in log_message assert "test_audio_123.ogg" in log_message
assert "12345" in log_message assert "12345" in log_message
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_simple_logging(self, audio_repository): async def test_add_audio_record_simple_logging(self, audio_repository):
"""Тест логирования при упрощенном добавлении аудио записи""" """Тест логирования при упрощенном добавлении аудио записи"""
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, "2025-01-15 14:30:00") await audio_repository.add_audio_record_simple(
"test_audio.ogg", 12345, "2025-01-15 14:30:00"
)
# Проверяем, что лог записан # Проверяем, что лог записан
audio_repository.logger.info.assert_called_once() audio_repository.logger.info.assert_called_once()
log_message = audio_repository.logger.info.call_args[0][0] log_message = audio_repository.logger.info.call_args[0][0]
assert "Аудио добавлено" in log_message assert "Аудио добавлено" in log_message
assert "test_audio.ogg" in log_message assert "test_audio.ogg" in log_message
assert "12345" in log_message assert "12345" in log_message
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_date_by_file_name_logging(self, audio_repository): async def test_get_date_by_file_name_logging(self, audio_repository):
"""Тест логирования при получении даты по имени файла""" """Тест логирования при получении даты по имени файла (UTC)."""
timestamp = 1642404600 # 2022-01-17 10:30:00 timestamp = 1642404600 # 2022-01-17 10:30:00 UTC
audio_repository._execute_query_with_result.return_value = [(timestamp,)] audio_repository._execute_query_with_result.return_value = [(timestamp,)]
await audio_repository.get_date_by_file_name("test_audio.ogg") await audio_repository.get_date_by_file_name("test_audio.ogg")
# Проверяем, что лог записан expected = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime(
"%d.%m.%Y %H:%M"
)
audio_repository.logger.info.assert_called_once() audio_repository.logger.info.assert_called_once()
log_message = audio_repository.logger.info.call_args[0][0] log_message = audio_repository.logger.info.call_args[0][0]
assert "Получена дата" in log_message assert "Получена дата" in log_message
assert "17.01.2022 10:30" in log_message assert expected in log_message
assert "test_audio.ogg" in log_message assert "test_audio.ogg" in log_message
class TestAudioRepositoryIntegration: class TestAudioRepositoryIntegration:
"""Интеграционные тесты для AudioRepository""" """Интеграционные тесты для AudioRepository"""
@pytest.fixture @pytest.fixture
def real_audio_repository(self): def real_audio_repository(self):
"""Реальный экземпляр AudioRepository для интеграционных тестов""" """Реальный экземпляр AudioRepository для интеграционных тестов"""
# Здесь можно создать реальное подключение к тестовой БД # Здесь можно создать реальное подключение к тестовой БД
# Но для простоты используем мок # Но для простоты используем мок
return Mock() return Mock()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_full_audio_workflow(self, real_audio_repository): async def test_full_audio_workflow(self, real_audio_repository):
"""Тест полного рабочего процесса с аудио""" """Тест полного рабочего процесса с аудио"""
# Этот тест можно расширить для реальной БД # Этот тест можно расширить для реальной БД
assert True # Placeholder для будущих интеграционных тестов assert True # Placeholder для будущих интеграционных тестов
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_foreign_keys_enabled(self, real_audio_repository): async def test_foreign_keys_enabled(self, real_audio_repository):
"""Тест что внешние ключи включены""" """Тест что внешние ключи включены"""

View File

@@ -1,14 +1,15 @@
import time import time
from datetime import datetime from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest import pytest
from database.repositories.audio_repository import AudioRepository from database.repositories.audio_repository import AudioRepository
class TestAudioRepositoryNewSchema: class TestAudioRepositoryNewSchema:
"""Тесты для AudioRepository с новой схемой БД""" """Тесты для AudioRepository с новой схемой БД"""
@pytest.fixture @pytest.fixture
def mock_db_connection(self): def mock_db_connection(self):
"""Мок для DatabaseConnection""" """Мок для DatabaseConnection"""
@@ -17,327 +18,367 @@ class TestAudioRepositoryNewSchema:
mock_connection._execute_query_with_result = AsyncMock() mock_connection._execute_query_with_result = AsyncMock()
mock_connection.logger = Mock() mock_connection.logger = Mock()
return mock_connection return mock_connection
@pytest.fixture @pytest.fixture
def audio_repository(self, mock_db_connection): def audio_repository(self, mock_db_connection):
"""Экземпляр AudioRepository для тестов""" """Экземпляр AudioRepository для тестов"""
with patch.object(AudioRepository, '__init__', return_value=None): with patch.object(AudioRepository, "__init__", return_value=None):
repo = AudioRepository() repo = AudioRepository()
repo._execute_query = mock_db_connection._execute_query repo._execute_query = mock_db_connection._execute_query
repo._execute_query_with_result = mock_db_connection._execute_query_with_result repo._execute_query_with_result = (
mock_db_connection._execute_query_with_result
)
repo.logger = mock_db_connection.logger repo.logger = mock_db_connection.logger
return repo return repo
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_tables_new_schema(self, audio_repository): async def test_create_tables_new_schema(self, audio_repository):
"""Тест создания таблиц с новой схемой БД""" """Тест создания таблиц с новой схемой БД"""
await audio_repository.create_tables() await audio_repository.create_tables()
# Проверяем, что все три таблицы созданы # Проверяем, что все три таблицы созданы
assert audio_repository._execute_query.call_count == 3 assert audio_repository._execute_query.call_count == 3
# Получаем все вызовы # Получаем все вызовы
calls = audio_repository._execute_query.call_args_list calls = audio_repository._execute_query.call_args_list
# Проверяем таблицу audio_message_reference # Проверяем таблицу audio_message_reference
audio_table_call = next(call for call in calls if "audio_message_reference" in str(call)) audio_table_call = next(
call for call in calls if "audio_message_reference" in str(call)
)
assert "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT" in str(audio_table_call) assert "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT" in str(audio_table_call)
assert "file_name TEXT NOT NULL UNIQUE" in str(audio_table_call) assert "file_name TEXT NOT NULL UNIQUE" in str(audio_table_call)
assert "author_id INTEGER NOT NULL" in str(audio_table_call) assert "author_id INTEGER NOT NULL" in str(audio_table_call)
assert "date_added INTEGER NOT NULL" in str(audio_table_call) assert "date_added INTEGER NOT NULL" in str(audio_table_call)
assert "FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in str(audio_table_call) assert (
"FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE"
in str(audio_table_call)
)
# Проверяем таблицу user_audio_listens # Проверяем таблицу user_audio_listens
listens_table_call = next(call for call in calls if "user_audio_listens" in str(call)) listens_table_call = next(
call for call in calls if "user_audio_listens" in str(call)
)
assert "file_name TEXT NOT NULL" in str(listens_table_call) assert "file_name TEXT NOT NULL" in str(listens_table_call)
assert "user_id INTEGER NOT NULL" in str(listens_table_call) assert "user_id INTEGER NOT NULL" in str(listens_table_call)
assert "PRIMARY KEY (file_name, user_id)" in str(listens_table_call) assert "PRIMARY KEY (file_name, user_id)" in str(listens_table_call)
assert "FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in str(listens_table_call) assert (
"FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE"
in str(listens_table_call)
)
# Проверяем таблицу audio_moderate # Проверяем таблицу audio_moderate
moderate_table_call = next(call for call in calls if "audio_moderate" in str(call)) moderate_table_call = next(
call for call in calls if "audio_moderate" in str(call)
)
assert "user_id INTEGER NOT NULL" in str(moderate_table_call) assert "user_id INTEGER NOT NULL" in str(moderate_table_call)
assert "message_id INTEGER" in str(moderate_table_call) assert "message_id INTEGER" in str(moderate_table_call)
assert "PRIMARY KEY (user_id, message_id)" in str(moderate_table_call) assert "PRIMARY KEY (user_id, message_id)" in str(moderate_table_call)
assert "FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in str(moderate_table_call) assert (
"FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE"
in str(moderate_table_call)
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_string_date_conversion(self, audio_repository): async def test_add_audio_record_string_date_conversion(self, audio_repository):
"""Тест преобразования строковой даты в UNIX timestamp""" """Тест преобразования строковой даты в UNIX timestamp"""
from database.models import AudioMessage from database.models import AudioMessage
audio_msg = AudioMessage( audio_msg = AudioMessage(
file_name="test_audio.ogg", file_name="test_audio.ogg",
author_id=12345, author_id=12345,
date_added="2025-01-15 14:30:00", date_added="2025-01-15 14:30:00",
file_id="test_file_id", file_id="test_file_id",
listen_count=0 listen_count=0,
) )
await audio_repository.add_audio_record(audio_msg) await audio_repository.add_audio_record(audio_msg)
# Проверяем, что метод вызван # Проверяем, что метод вызван
call_args = audio_repository._execute_query.call_args call_args = audio_repository._execute_query.call_args
params = call_args[0][1] params = call_args[0][1]
# Проверяем параметры # Проверяем параметры
assert params[0] == "test_audio.ogg" assert params[0] == "test_audio.ogg"
assert params[1] == 12345 assert params[1] == 12345
assert isinstance(params[2], int) # timestamp assert isinstance(params[2], int) # timestamp
# Проверяем, что timestamp соответствует дате # Проверяем, что timestamp соответствует дате
expected_timestamp = int(datetime(2025, 1, 15, 14, 30, 0).timestamp()) expected_timestamp = int(datetime(2025, 1, 15, 14, 30, 0).timestamp())
assert params[2] == expected_timestamp assert params[2] == expected_timestamp
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_datetime_conversion(self, audio_repository): async def test_add_audio_record_datetime_conversion(self, audio_repository):
"""Тест преобразования datetime в UNIX timestamp""" """Тест преобразования datetime в UNIX timestamp"""
from database.models import AudioMessage from database.models import AudioMessage
test_datetime = datetime(2025, 1, 20, 10, 15, 30) test_datetime = datetime(2025, 1, 20, 10, 15, 30)
audio_msg = AudioMessage( audio_msg = AudioMessage(
file_name="test_audio.ogg", file_name="test_audio.ogg",
author_id=12345, author_id=12345,
date_added=test_datetime, date_added=test_datetime,
file_id="test_file_id", file_id="test_file_id",
listen_count=0 listen_count=0,
) )
await audio_repository.add_audio_record(audio_msg) await audio_repository.add_audio_record(audio_msg)
# Проверяем параметры # Проверяем параметры
call_args = audio_repository._execute_query.call_args call_args = audio_repository._execute_query.call_args
params = call_args[0][1] params = call_args[0][1]
expected_timestamp = int(test_datetime.timestamp()) expected_timestamp = int(test_datetime.timestamp())
assert params[2] == expected_timestamp assert params[2] == expected_timestamp
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_timestamp_no_conversion(self, audio_repository): async def test_add_audio_record_timestamp_no_conversion(self, audio_repository):
"""Тест что timestamp остается timestamp без преобразования""" """Тест что timestamp остается timestamp без преобразования"""
from database.models import AudioMessage from database.models import AudioMessage
test_timestamp = int(time.time()) test_timestamp = int(time.time())
audio_msg = AudioMessage( audio_msg = AudioMessage(
file_name="test_audio.ogg", file_name="test_audio.ogg",
author_id=12345, author_id=12345,
date_added=test_timestamp, date_added=test_timestamp,
file_id="test_file_id", file_id="test_file_id",
listen_count=0 listen_count=0,
) )
await audio_repository.add_audio_record(audio_msg) await audio_repository.add_audio_record(audio_msg)
# Проверяем параметры # Проверяем параметры
call_args = audio_repository._execute_query.call_args call_args = audio_repository._execute_query.call_args
params = call_args[0][1] params = call_args[0][1]
assert params[2] == test_timestamp assert params[2] == test_timestamp
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_simple_string_date(self, audio_repository): async def test_add_audio_record_simple_string_date(self, audio_repository):
"""Тест упрощенного добавления со строковой датой""" """Тест упрощенного добавления со строковой датой"""
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, "2025-01-15 14:30:00") await audio_repository.add_audio_record_simple(
"test_audio.ogg", 12345, "2025-01-15 14:30:00"
)
# Проверяем параметры # Проверяем параметры
call_args = audio_repository._execute_query.call_args call_args = audio_repository._execute_query.call_args
params = call_args[0][1] params = call_args[0][1]
assert params[0] == "test_audio.ogg" assert params[0] == "test_audio.ogg"
assert params[1] == 12345 assert params[1] == 12345
assert isinstance(params[2], int) # timestamp assert isinstance(params[2], int) # timestamp
# Проверяем timestamp # Проверяем timestamp
expected_timestamp = int(datetime(2025, 1, 15, 14, 30, 0).timestamp()) expected_timestamp = int(datetime(2025, 1, 15, 14, 30, 0).timestamp())
assert params[2] == expected_timestamp assert params[2] == expected_timestamp
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_simple_datetime(self, audio_repository): async def test_add_audio_record_simple_datetime(self, audio_repository):
"""Тест упрощенного добавления с datetime""" """Тест упрощенного добавления с datetime"""
test_datetime = datetime(2025, 1, 25, 16, 45, 0) test_datetime = datetime(2025, 1, 25, 16, 45, 0)
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, test_datetime) await audio_repository.add_audio_record_simple(
"test_audio.ogg", 12345, test_datetime
)
# Проверяем параметры # Проверяем параметры
call_args = audio_repository._execute_query.call_args call_args = audio_repository._execute_query.call_args
params = call_args[0][1] params = call_args[0][1]
expected_timestamp = int(test_datetime.timestamp()) expected_timestamp = int(test_datetime.timestamp())
assert params[2] == expected_timestamp assert params[2] == expected_timestamp
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_date_by_file_name_timestamp_conversion(self, audio_repository): async def test_get_date_by_file_name_timestamp_conversion(self, audio_repository):
"""Тест преобразования UNIX timestamp в читаемую дату""" """Тест преобразования UNIX timestamp в читаемую дату (UTC)."""
test_timestamp = 1642248600 # 2022-01-17 10:30:00 test_timestamp = 1642248600 # 2022-01-15 12:10:00 UTC
audio_repository._execute_query_with_result.return_value = [(test_timestamp,)] audio_repository._execute_query_with_result.return_value = [(test_timestamp,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg") result = await audio_repository.get_date_by_file_name("test_audio.ogg")
# Должна вернуться читаемая дата в формате dd.mm.yyyy HH:MM expected = datetime.fromtimestamp(test_timestamp, tz=timezone.utc).strftime(
assert result == "15.01.2022 15:10" "%d.%m.%Y %H:%M"
)
assert result == expected
assert isinstance(result, str) assert isinstance(result, str)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_date_by_file_name_different_timestamp(self, audio_repository): async def test_get_date_by_file_name_different_timestamp(self, audio_repository):
"""Тест преобразования другого timestamp в читаемую дату""" """Тест преобразования другого timestamp в читаемую дату (UTC)."""
test_timestamp = 1705312800 # 2024-01-16 12:00:00 test_timestamp = 1705312800 # 2024-01-16 12:00:00 UTC
audio_repository._execute_query_with_result.return_value = [(test_timestamp,)] audio_repository._execute_query_with_result.return_value = [(test_timestamp,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg") result = await audio_repository.get_date_by_file_name("test_audio.ogg")
assert result == "15.01.2024 13:00" expected = datetime.fromtimestamp(test_timestamp, tz=timezone.utc).strftime(
"%d.%m.%Y %H:%M"
)
assert result == expected
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_date_by_file_name_midnight(self, audio_repository): async def test_get_date_by_file_name_midnight(self, audio_repository):
"""Тест преобразования timestamp для полуночи""" """Тест преобразования timestamp для полуночи (UTC)."""
test_timestamp = 1705190400 # 2024-01-15 00:00:00 test_timestamp = 1705190400 # 2024-01-14 00:00:00 UTC
audio_repository._execute_query_with_result.return_value = [(test_timestamp,)] audio_repository._execute_query_with_result.return_value = [(test_timestamp,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg") result = await audio_repository.get_date_by_file_name("test_audio.ogg")
assert result == "14.01.2024 03:00" expected = datetime.fromtimestamp(test_timestamp, tz=timezone.utc).strftime(
"%d.%m.%Y %H:%M"
)
assert result == expected
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_date_by_file_name_year_end(self, audio_repository): async def test_get_date_by_file_name_year_end(self, audio_repository):
"""Тест преобразования timestamp для конца года""" """Тест преобразования timestamp для конца года (UTC)."""
test_timestamp = 1704067200 # 2023-12-31 23:59:59 test_timestamp = 1704067200 # 2023-12-31 00:00:00 UTC
audio_repository._execute_query_with_result.return_value = [(test_timestamp,)] audio_repository._execute_query_with_result.return_value = [(test_timestamp,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg") result = await audio_repository.get_date_by_file_name("test_audio.ogg")
assert result == "01.01.2024 03:00" expected = datetime.fromtimestamp(test_timestamp, tz=timezone.utc).strftime(
"%d.%m.%Y %H:%M"
)
assert result == expected
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_foreign_keys_enabled_called(self, audio_repository): async def test_foreign_keys_enabled_called(self, audio_repository):
"""Тест что метод enable_foreign_keys вызывается""" """Тест что метод enable_foreign_keys вызывается"""
await audio_repository.enable_foreign_keys() await audio_repository.enable_foreign_keys()
audio_repository._execute_query.assert_called_once_with("PRAGMA foreign_keys = ON;") audio_repository._execute_query.assert_called_once_with(
"PRAGMA foreign_keys = ON;"
)
audio_repository.logger.info.assert_not_called() # Этот метод не логирует audio_repository.logger.info.assert_not_called() # Этот метод не логирует
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_tables_logging(self, audio_repository): async def test_create_tables_logging(self, audio_repository):
"""Тест логирования при создании таблиц""" """Тест логирования при создании таблиц"""
await audio_repository.create_tables() await audio_repository.create_tables()
# Проверяем, что лог записан # Проверяем, что лог записан
audio_repository.logger.info.assert_called_once_with("Таблицы для аудио созданы") audio_repository.logger.info.assert_called_once_with(
"Таблицы для аудио созданы"
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_logging_format(self, audio_repository): async def test_add_audio_record_logging_format(self, audio_repository):
"""Тест формата лога при добавлении аудио записи""" """Тест формата лога при добавлении аудио записи"""
from database.models import AudioMessage from database.models import AudioMessage
audio_msg = AudioMessage( audio_msg = AudioMessage(
file_name="test_audio.ogg", file_name="test_audio.ogg",
author_id=12345, author_id=12345,
date_added="2025-01-15 14:30:00", date_added="2025-01-15 14:30:00",
file_id="test_file_id", file_id="test_file_id",
listen_count=0 listen_count=0,
) )
await audio_repository.add_audio_record(audio_msg) await audio_repository.add_audio_record(audio_msg)
# Проверяем формат лога # Проверяем формат лога
log_call = audio_repository.logger.info.call_args log_call = audio_repository.logger.info.call_args
log_message = log_call[0][0] log_message = log_call[0][0]
assert "Аудио добавлено:" in log_message assert "Аудио добавлено:" in log_message
assert "file_name=test_audio.ogg" in log_message assert "file_name=test_audio.ogg" in log_message
assert "author_id=12345" in log_message assert "author_id=12345" in log_message
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_simple_logging_format(self, audio_repository): async def test_add_audio_record_simple_logging_format(self, audio_repository):
"""Тест формата лога при упрощенном добавлении""" """Тест формата лога при упрощенном добавлении"""
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, "2025-01-15 14:30:00") await audio_repository.add_audio_record_simple(
"test_audio.ogg", 12345, "2025-01-15 14:30:00"
)
# Проверяем формат лога # Проверяем формат лога
log_call = audio_repository.logger.info.call_args log_call = audio_repository.logger.info.call_args
log_message = log_call[0][0] log_message = log_call[0][0]
assert "Аудио добавлено:" in log_message assert "Аудио добавлено:" in log_message
assert "file_name=test_audio.ogg" in log_message assert "file_name=test_audio.ogg" in log_message
assert "user_id=12345" in log_message assert "user_id=12345" in log_message
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_date_by_file_name_logging_format(self, audio_repository): async def test_get_date_by_file_name_logging_format(self, audio_repository):
"""Тест формата лога при получении даты""" """Тест формата лога при получении даты (UTC)."""
test_timestamp = 1642248600 # 2022-01-17 10:30:00 test_timestamp = 1642248600 # 2022-01-15 12:10:00 UTC
audio_repository._execute_query_with_result.return_value = [(test_timestamp,)] audio_repository._execute_query_with_result.return_value = [(test_timestamp,)]
await audio_repository.get_date_by_file_name("test_audio.ogg") await audio_repository.get_date_by_file_name("test_audio.ogg")
# Проверяем формат лога expected = datetime.fromtimestamp(test_timestamp, tz=timezone.utc).strftime(
"%d.%m.%Y %H:%M"
)
log_call = audio_repository.logger.info.call_args log_call = audio_repository.logger.info.call_args
log_message = log_call[0][0] log_message = log_call[0][0]
assert "Получена дата" in log_message assert "Получена дата" in log_message
assert "15.01.2022 15:10" in log_message assert expected in log_message
assert "test_audio.ogg" in log_message assert "test_audio.ogg" in log_message
class TestAudioRepositoryEdgeCases: class TestAudioRepositoryEdgeCases:
"""Тесты граничных случаев для AudioRepository""" """Тесты граничных случаев для AudioRepository"""
@pytest.fixture @pytest.fixture
def audio_repository(self): def audio_repository(self):
"""Экземпляр AudioRepository для тестов""" """Экземпляр AudioRepository для тестов"""
with patch.object(AudioRepository, '__init__', return_value=None): with patch.object(AudioRepository, "__init__", return_value=None):
repo = AudioRepository() repo = AudioRepository()
repo._execute_query = AsyncMock() repo._execute_query = AsyncMock()
repo._execute_query_with_result = AsyncMock() repo._execute_query_with_result = AsyncMock()
repo.logger = Mock() repo.logger = Mock()
return repo return repo
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_empty_string_date(self, audio_repository): async def test_add_audio_record_empty_string_date(self, audio_repository):
"""Тест добавления с пустой строковой датой""" """Тест добавления с пустой строковой датой"""
from database.models import AudioMessage from database.models import AudioMessage
audio_msg = AudioMessage( audio_msg = AudioMessage(
file_name="test_audio.ogg", file_name="test_audio.ogg",
author_id=12345, author_id=12345,
date_added="", date_added="",
file_id="test_file_id", file_id="test_file_id",
listen_count=0 listen_count=0,
) )
# Должно вызвать ValueError при парсинге пустой строки # Должно вызвать ValueError при парсинге пустой строки
with pytest.raises(ValueError): with pytest.raises(ValueError):
await audio_repository.add_audio_record(audio_msg) await audio_repository.add_audio_record(audio_msg)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_invalid_string_date(self, audio_repository): async def test_add_audio_record_invalid_string_date(self, audio_repository):
"""Тест добавления с некорректной строковой датой""" """Тест добавления с некорректной строковой датой"""
from database.models import AudioMessage from database.models import AudioMessage
audio_msg = AudioMessage( audio_msg = AudioMessage(
file_name="test_audio.ogg", file_name="test_audio.ogg",
author_id=12345, author_id=12345,
date_added="invalid_date", date_added="invalid_date",
file_id="test_file_id", file_id="test_file_id",
listen_count=0 listen_count=0,
) )
# Должно вызвать ValueError при парсинге некорректной даты # Должно вызвать ValueError при парсинге некорректной даты
with pytest.raises(ValueError): with pytest.raises(ValueError):
await audio_repository.add_audio_record(audio_msg) await audio_repository.add_audio_record(audio_msg)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_none_date(self, audio_repository): async def test_add_audio_record_none_date(self, audio_repository):
"""Тест добавления с None датой""" """Тест добавления с None датой"""
from database.models import AudioMessage from database.models import AudioMessage
audio_msg = AudioMessage( audio_msg = AudioMessage(
file_name="test_audio.ogg", file_name="test_audio.ogg",
author_id=12345, author_id=12345,
date_added=None, date_added=None,
file_id="test_file_id", file_id="test_file_id",
listen_count=0 listen_count=0,
) )
# Метод обрабатывает None как timestamp без преобразования # Метод обрабатывает None как timestamp без преобразования
await audio_repository.add_audio_record(audio_msg) await audio_repository.add_audio_record(audio_msg)
# Проверяем, что метод был вызван с None # Проверяем, что метод был вызван с None
call_args = audio_repository._execute_query.call_args call_args = audio_repository._execute_query.call_args
params = call_args[0][1] params = call_args[0][1]
@@ -349,49 +390,61 @@ class TestAudioRepositoryEdgeCases:
# Должно вызвать ValueError при парсинге пустой строки # Должно вызвать ValueError при парсинге пустой строки
with pytest.raises(ValueError): with pytest.raises(ValueError):
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, "") await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, "")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_simple_invalid_string_date(self, audio_repository): async def test_add_audio_record_simple_invalid_string_date(self, audio_repository):
"""Тест упрощенного добавления с некорректной строковой датой""" """Тест упрощенного добавления с некорректной строковой датой"""
# Должно вызвать ValueError при парсинге некорректной даты # Должно вызвать ValueError при парсинге некорректной даты
with pytest.raises(ValueError): with pytest.raises(ValueError):
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, "invalid_date") await audio_repository.add_audio_record_simple(
"test_audio.ogg", 12345, "invalid_date"
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_audio_record_simple_none_date(self, audio_repository): async def test_add_audio_record_simple_none_date(self, audio_repository):
"""Тест упрощенного добавления с None датой""" """Тест упрощенного добавления с None датой"""
# Метод обрабатывает None как timestamp без преобразования # Метод обрабатывает None как timestamp без преобразования
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, None) await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, None)
# Проверяем, что метод был вызван с None # Проверяем, что метод был вызван с None
call_args = audio_repository._execute_query.call_args call_args = audio_repository._execute_query.call_args
params = call_args[0][1] params = call_args[0][1]
assert params[2] is None assert params[2] is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_date_by_file_name_zero_timestamp(self, audio_repository): async def test_get_date_by_file_name_zero_timestamp(self, audio_repository):
"""Тест получения даты для timestamp = 0 (1970-01-01)""" """Тест получения даты для timestamp = 0 (1970-01-01 UTC)."""
audio_repository._execute_query_with_result.return_value = [(0,)] audio_repository._execute_query_with_result.return_value = [(0,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg") result = await audio_repository.get_date_by_file_name("test_audio.ogg")
assert result == "01.01.1970 03:00" expected = datetime.fromtimestamp(0, tz=timezone.utc).strftime("%d.%m.%Y %H:%M")
assert result == expected
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_date_by_file_name_negative_timestamp(self, audio_repository): async def test_get_date_by_file_name_negative_timestamp(self, audio_repository):
"""Тест получения даты для отрицательного timestamp""" """Тест получения даты для отрицательного timestamp (UTC)."""
audio_repository._execute_query_with_result.return_value = [(-3600,)] # 1969-12-31 23:00:00 ts = -3600 # 1969-12-31 23:00:00 UTC
audio_repository._execute_query_with_result.return_value = [(ts,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg") result = await audio_repository.get_date_by_file_name("test_audio.ogg")
assert result == "01.01.1970 02:00" expected = datetime.fromtimestamp(ts, tz=timezone.utc).strftime(
"%d.%m.%Y %H:%M"
)
assert result == expected
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_date_by_file_name_future_timestamp(self, audio_repository): async def test_get_date_by_file_name_future_timestamp(self, audio_repository):
"""Тест получения даты для будущего timestamp""" """Тест получения даты для будущего timestamp (UTC, без зависимости от локали)."""
future_timestamp = int(datetime(2030, 12, 31, 23, 59, 59).timestamp()) future_timestamp = int(
datetime(2030, 12, 31, 23, 59, 59, tzinfo=timezone.utc).timestamp()
)
audio_repository._execute_query_with_result.return_value = [(future_timestamp,)] audio_repository._execute_query_with_result.return_value = [(future_timestamp,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg") result = await audio_repository.get_date_by_file_name("test_audio.ogg")
assert result == "31.12.2030 23:59" expected = datetime.fromtimestamp(future_timestamp, tz=timezone.utc).strftime(
"%d.%m.%Y %H:%M"
)
assert result == expected

View File

@@ -4,33 +4,34 @@ from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
from helper_bot.utils.auto_unban_scheduler import AutoUnbanScheduler from helper_bot.utils.auto_unban_scheduler import AutoUnbanScheduler
class TestAutoUnbanIntegration: class TestAutoUnbanIntegration:
"""Интеграционные тесты для автоматического разбана""" """Интеграционные тесты для автоматического разбана"""
@pytest.fixture @pytest.fixture
def test_db_path(self): def test_db_path(self):
"""Путь к тестовой базе данных""" """Путь к тестовой базе данных"""
return 'database/test_auto_unban.db' return "database/test_auto_unban.db"
@pytest.fixture @pytest.fixture
def setup_test_db(self, test_db_path): def setup_test_db(self, test_db_path):
"""Создает тестовую базу данных с таблицами blacklist, our_users и blacklist_history""" """Создает тестовую базу данных с таблицами blacklist, our_users и blacklist_history"""
# Удаляем старую тестовую базу если она существует # Удаляем старую тестовую базу если она существует
if os.path.exists(test_db_path): if os.path.exists(test_db_path):
os.remove(test_db_path) os.remove(test_db_path)
# Создаем новую базу данных # Создаем новую базу данных
conn = sqlite3.connect(test_db_path) conn = sqlite3.connect(test_db_path)
cursor = conn.cursor() cursor = conn.cursor()
# Включаем поддержку внешних ключей # Включаем поддержку внешних ключей
cursor.execute("PRAGMA foreign_keys = ON") cursor.execute("PRAGMA foreign_keys = ON")
# Создаем таблицу our_users (нужна для внешних ключей) # Создаем таблицу our_users (нужна для внешних ключей)
cursor.execute(''' cursor.execute("""
CREATE TABLE IF NOT EXISTS our_users ( CREATE TABLE IF NOT EXISTS our_users (
user_id INTEGER NOT NULL PRIMARY KEY, user_id INTEGER NOT NULL PRIMARY KEY,
first_name TEXT, first_name TEXT,
@@ -44,10 +45,10 @@ class TestAutoUnbanIntegration:
date_changed INTEGER NOT NULL, date_changed INTEGER NOT NULL,
voice_bot_welcome_received BOOLEAN DEFAULT 0 voice_bot_welcome_received BOOLEAN DEFAULT 0
) )
''') """)
# Создаем таблицу blacklist # Создаем таблицу blacklist
cursor.execute(''' cursor.execute("""
CREATE TABLE IF NOT EXISTS blacklist ( CREATE TABLE IF NOT EXISTS blacklist (
user_id INTEGER NOT NULL PRIMARY KEY, user_id INTEGER NOT NULL PRIMARY KEY,
message_for_user TEXT, message_for_user TEXT,
@@ -56,10 +57,10 @@ class TestAutoUnbanIntegration:
ban_author INTEGER, ban_author INTEGER,
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
) )
''') """)
# Создаем таблицу blacklist_history # Создаем таблицу blacklist_history
cursor.execute(''' cursor.execute("""
CREATE TABLE IF NOT EXISTS blacklist_history ( CREATE TABLE IF NOT EXISTS blacklist_history (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -72,273 +73,419 @@ class TestAutoUnbanIntegration:
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE,
FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL
) )
''') """)
# Создаем индексы для blacklist_history # Создаем индексы для blacklist_history
cursor.execute(''' cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_blacklist_history_user_id ON blacklist_history(user_id) CREATE INDEX IF NOT EXISTS idx_blacklist_history_user_id ON blacklist_history(user_id)
''') """)
cursor.execute(''' cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_ban ON blacklist_history(date_ban) CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_ban ON blacklist_history(date_ban)
''') """)
cursor.execute(''' cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_unban ON blacklist_history(date_unban) CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_unban ON blacklist_history(date_unban)
''') """)
# Добавляем тестовых пользователей в our_users # Добавляем тестовых пользователей в our_users
current_time = int(datetime.now(timezone(timedelta(hours=3))).timestamp()) current_time = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
users_data = [ users_data = [
(123, "Test", "Test User 1", "test_user1", 0, "ru", 0, "😊", current_time, current_time, 0), (
(456, "Test", "Test User 2", "test_user2", 0, "ru", 0, "😊", current_time, current_time, 0), 123,
(789, "Test", "Test User 3", "test_user3", 0, "ru", 0, "😊", current_time, current_time, 0), "Test",
(999, "Test", "Test User 4", "test_user4", 0, "ru", 0, "😊", current_time, current_time, 0), "Test User 1",
"test_user1",
0,
"ru",
0,
"😊",
current_time,
current_time,
0,
),
(
456,
"Test",
"Test User 2",
"test_user2",
0,
"ru",
0,
"😊",
current_time,
current_time,
0,
),
(
789,
"Test",
"Test User 3",
"test_user3",
0,
"ru",
0,
"😊",
current_time,
current_time,
0,
),
(
999,
"Test",
"Test User 4",
"test_user4",
0,
"ru",
0,
"😊",
current_time,
current_time,
0,
),
] ]
cursor.executemany( cursor.executemany(
"""INSERT INTO our_users (user_id, first_name, full_name, username, is_bot, """INSERT INTO our_users (user_id, first_name, full_name, username, is_bot,
language_code, has_stickers, emoji, date_added, date_changed, voice_bot_welcome_received) language_code, has_stickers, emoji, date_added, date_changed, voice_bot_welcome_received)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
users_data users_data,
) )
# Добавляем тестовые данные в blacklist # Добавляем тестовые данные в blacklist
today_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp()) today_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
tomorrow_timestamp = int((datetime.now(timezone(timedelta(hours=3))) + timedelta(days=1)).timestamp()) tomorrow_timestamp = int(
(datetime.now(timezone(timedelta(hours=3))) + timedelta(days=1)).timestamp()
)
blacklist_data = [ blacklist_data = [
(123, "Test ban 1", today_timestamp, current_time, None), # Разблокируется сегодня (
(456, "Test ban 2", today_timestamp, current_time, None), # Разблокируется сегодня 123,
(789, "Test ban 3", tomorrow_timestamp, current_time, None), # Разблокируется завтра "Test ban 1",
(999, "Test ban 4", None, current_time, None), # Навсегда заблокирован today_timestamp,
current_time,
None,
), # Разблокируется сегодня
(
456,
"Test ban 2",
today_timestamp,
current_time,
None,
), # Разблокируется сегодня
(
789,
"Test ban 3",
tomorrow_timestamp,
current_time,
None,
), # Разблокируется завтра
(999, "Test ban 4", None, current_time, None), # Навсегда заблокирован
] ]
cursor.executemany( cursor.executemany(
"INSERT INTO blacklist (user_id, message_for_user, date_to_unban, created_at, ban_author) VALUES (?, ?, ?, ?, ?)", "INSERT INTO blacklist (user_id, message_for_user, date_to_unban, created_at, ban_author) VALUES (?, ?, ?, ?, ?)",
blacklist_data blacklist_data,
) )
# Добавляем тестовые данные в blacklist_history # Добавляем тестовые данные в blacklist_history
# Для пользователей 123 и 456 (которые будут разблокированы) создаем записи с date_unban = NULL # Для пользователей 123 и 456 (которые будут разблокированы) создаем записи с date_unban = NULL
yesterday_timestamp = int((datetime.now(timezone(timedelta(hours=3))) - timedelta(days=1)).timestamp()) yesterday_timestamp = int(
(datetime.now(timezone(timedelta(hours=3))) - timedelta(days=1)).timestamp()
)
history_data = [ history_data = [
(123, "Test ban 1", yesterday_timestamp, None, None, yesterday_timestamp, yesterday_timestamp), # Будет разблокирован (
(456, "Test ban 2", yesterday_timestamp, None, None, yesterday_timestamp, yesterday_timestamp), # Будет разблокирован 123,
(789, "Test ban 3", yesterday_timestamp, None, None, yesterday_timestamp, yesterday_timestamp), # Не будет разблокирован сегодня "Test ban 1",
(999, "Test ban 4", yesterday_timestamp, None, None, yesterday_timestamp, yesterday_timestamp), # Навсегда заблокирован yesterday_timestamp,
None,
None,
yesterday_timestamp,
yesterday_timestamp,
), # Будет разблокирован
(
456,
"Test ban 2",
yesterday_timestamp,
None,
None,
yesterday_timestamp,
yesterday_timestamp,
), # Будет разблокирован
(
789,
"Test ban 3",
yesterday_timestamp,
None,
None,
yesterday_timestamp,
yesterday_timestamp,
), # Не будет разблокирован сегодня
(
999,
"Test ban 4",
yesterday_timestamp,
None,
None,
yesterday_timestamp,
yesterday_timestamp,
), # Навсегда заблокирован
] ]
cursor.executemany( cursor.executemany(
"""INSERT INTO blacklist_history """INSERT INTO blacklist_history
(user_id, message_for_user, date_ban, date_unban, ban_author, created_at, updated_at) (user_id, message_for_user, date_ban, date_unban, ban_author, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?)""",
history_data history_data,
) )
conn.commit() conn.commit()
conn.close() conn.close()
yield test_db_path yield test_db_path
# Очистка после тестов # Очистка после тестов
if os.path.exists(test_db_path): if os.path.exists(test_db_path):
os.remove(test_db_path) os.remove(test_db_path)
@pytest.fixture @pytest.fixture
def mock_bdf(self, test_db_path): def mock_bdf(self, test_db_path):
"""Создает мок фабрики зависимостей с тестовой базой""" """Создает мок фабрики зависимостей с тестовой базой"""
mock_factory = Mock() mock_factory = Mock()
mock_factory.settings = { mock_factory.settings = {
'Telegram': { "Telegram": {
'group_for_logs': '-1001234567890', "group_for_logs": "-1001234567890",
'important_logs': '-1001234567891' "important_logs": "-1001234567891",
} }
} }
# Создаем реальный экземпляр базы данных с тестовым файлом # Создаем реальный экземпляр базы данных с тестовым файлом
import os import os
from database.async_db import AsyncBotDB from database.async_db import AsyncBotDB
mock_factory.database = AsyncBotDB(test_db_path) mock_factory.database = AsyncBotDB(test_db_path)
return mock_factory return mock_factory
@pytest.fixture @pytest.fixture
def mock_bot(self): def mock_bot(self):
"""Создает мок бота""" """Создает мок бота"""
mock_bot = Mock() mock_bot = Mock()
mock_bot.send_message = AsyncMock() mock_bot.send_message = AsyncMock()
return mock_bot return mock_bot
@pytest.mark.asyncio @pytest.mark.asyncio
@patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') @patch("helper_bot.utils.auto_unban_scheduler.get_global_instance")
async def test_auto_unban_with_real_db(self, mock_get_instance, setup_test_db, mock_bdf, mock_bot): async def test_auto_unban_with_real_db(
self, mock_get_instance, setup_test_db, mock_bdf, mock_bot
):
"""Тест автоматического разбана с реальной базой данных""" """Тест автоматического разбана с реальной базой данных"""
# Настройка моков # Настройка моков
mock_get_instance.return_value = mock_bdf mock_get_instance.return_value = mock_bdf
# Создаем планировщик # Создаем планировщик
scheduler = AutoUnbanScheduler() scheduler = AutoUnbanScheduler()
scheduler.bot_db = mock_bdf.database scheduler.bot_db = mock_bdf.database
scheduler.set_bot(mock_bot) scheduler.set_bot(mock_bot)
# Проверяем начальное состояние базы # Проверяем начальное состояние базы
conn = sqlite3.connect(setup_test_db) conn = sqlite3.connect(setup_test_db)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM blacklist") cursor.execute("SELECT COUNT(*) FROM blacklist")
initial_count = cursor.fetchone()[0] initial_count = cursor.fetchone()[0]
assert initial_count == 4 assert initial_count == 4
# Проверяем начальное состояние истории: должно быть 2 записи с date_unban IS NULL для user_id 123 и 456 # Проверяем начальное состояние истории: должно быть 2 записи с date_unban IS NULL для user_id 123 и 456
cursor.execute("SELECT COUNT(*) FROM blacklist_history WHERE user_id IN (123, 456) AND date_unban IS NULL") cursor.execute(
"SELECT COUNT(*) FROM blacklist_history WHERE user_id IN (123, 456) AND date_unban IS NULL"
)
initial_open_history = cursor.fetchone()[0] initial_open_history = cursor.fetchone()[0]
assert initial_open_history == 2 assert initial_open_history == 2
# Запоминаем время до разбана для проверки updated_at # Запоминаем время до разбана для проверки updated_at
before_unban_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp()) before_unban_timestamp = int(
datetime.now(timezone(timedelta(hours=3))).timestamp()
)
# Выполняем автоматический разбан # Выполняем автоматический разбан
await scheduler.auto_unban_users() await scheduler.auto_unban_users()
# Запоминаем время после разбана для проверки updated_at # Запоминаем время после разбана для проверки updated_at
after_unban_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp()) after_unban_timestamp = int(
datetime.now(timezone(timedelta(hours=3))).timestamp()
)
# Проверяем, что пользователи с сегодняшней датой разблокированы # Проверяем, что пользователи с сегодняшней датой разблокированы
current_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp()) current_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
cursor.execute("SELECT COUNT(*) FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?", cursor.execute(
(current_timestamp,)) "SELECT COUNT(*) FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?",
(current_timestamp,),
)
today_count = cursor.fetchone()[0] today_count = cursor.fetchone()[0]
assert today_count == 0 assert today_count == 0
# Проверяем, что пользователи с завтрашней датой остались # Проверяем, что пользователи с завтрашней датой остались
cursor.execute("SELECT COUNT(*) FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban > ?", cursor.execute(
(current_timestamp,)) "SELECT COUNT(*) FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban > ?",
(current_timestamp,),
)
tomorrow_count = cursor.fetchone()[0] tomorrow_count = cursor.fetchone()[0]
assert tomorrow_count == 1 assert tomorrow_count == 1
# Проверяем, что навсегда заблокированные пользователи остались # Проверяем, что навсегда заблокированные пользователи остались
cursor.execute("SELECT COUNT(*) FROM blacklist WHERE date_to_unban IS NULL") cursor.execute("SELECT COUNT(*) FROM blacklist WHERE date_to_unban IS NULL")
permanent_count = cursor.fetchone()[0] permanent_count = cursor.fetchone()[0]
assert permanent_count == 1 assert permanent_count == 1
# Проверяем общее количество записей # Проверяем общее количество записей
cursor.execute("SELECT COUNT(*) FROM blacklist") cursor.execute("SELECT COUNT(*) FROM blacklist")
final_count = cursor.fetchone()[0] final_count = cursor.fetchone()[0]
assert final_count == 2 # Остались только завтрашние и навсегда заблокированные assert final_count == 2 # Остались только завтрашние и навсегда заблокированные
# Проверяем историю банов: для user_id 123 и 456 должны быть установлены date_unban # Проверяем историю банов: для user_id 123 и 456 должны быть установлены date_unban
cursor.execute("SELECT user_id, date_unban, updated_at FROM blacklist_history WHERE user_id IN (123, 456) ORDER BY user_id") cursor.execute(
"SELECT user_id, date_unban, updated_at FROM blacklist_history WHERE user_id IN (123, 456) ORDER BY user_id"
)
history_records = cursor.fetchall() history_records = cursor.fetchall()
assert len(history_records) == 2 assert len(history_records) == 2
for user_id, date_unban, updated_at in history_records: for user_id, date_unban, updated_at in history_records:
# Проверяем, что date_unban установлен (не NULL) # Проверяем, что date_unban установлен (не NULL)
assert date_unban is not None, f"date_unban должен быть установлен для user_id={user_id}" assert (
assert isinstance(date_unban, int), f"date_unban должен быть integer для user_id={user_id}" date_unban is not None
), f"date_unban должен быть установлен для user_id={user_id}"
assert isinstance(
date_unban, int
), f"date_unban должен быть integer для user_id={user_id}"
# Проверяем, что date_unban находится в разумных пределах (между before и after) # Проверяем, что date_unban находится в разумных пределах (между before и after)
assert before_unban_timestamp <= date_unban <= after_unban_timestamp, \ assert (
f"date_unban для user_id={user_id} должен быть между {before_unban_timestamp} и {after_unban_timestamp}, получен {date_unban}" before_unban_timestamp <= date_unban <= after_unban_timestamp
), f"date_unban для user_id={user_id} должен быть между {before_unban_timestamp} и {after_unban_timestamp}, получен {date_unban}"
# Проверяем, что updated_at обновлен # Проверяем, что updated_at обновлен
assert updated_at is not None, f"updated_at должен быть установлен для user_id={user_id}" assert (
assert isinstance(updated_at, int), f"updated_at должен быть integer для user_id={user_id}" updated_at is not None
assert before_unban_timestamp <= updated_at <= after_unban_timestamp, \ ), f"updated_at должен быть установлен для user_id={user_id}"
f"updated_at для user_id={user_id} должен быть между {before_unban_timestamp} и {after_unban_timestamp}, получен {updated_at}" assert isinstance(
updated_at, int
), f"updated_at должен быть integer для user_id={user_id}"
assert (
before_unban_timestamp <= updated_at <= after_unban_timestamp
), f"updated_at для user_id={user_id} должен быть между {before_unban_timestamp} и {after_unban_timestamp}, получен {updated_at}"
# Проверяем, что для user_id 789 и 999 записи в истории остались без изменений (date_unban все еще NULL) # Проверяем, что для user_id 789 и 999 записи в истории остались без изменений (date_unban все еще NULL)
cursor.execute("SELECT COUNT(*) FROM blacklist_history WHERE user_id IN (789, 999) AND date_unban IS NULL") cursor.execute(
"SELECT COUNT(*) FROM blacklist_history WHERE user_id IN (789, 999) AND date_unban IS NULL"
)
unchanged_history = cursor.fetchone()[0] unchanged_history = cursor.fetchone()[0]
assert unchanged_history == 2, "Записи для user_id 789 и 999 должны остаться с date_unban = NULL" assert (
unchanged_history == 2
), "Записи для user_id 789 и 999 должны остаться с date_unban = NULL"
conn.close() conn.close()
# Проверяем, что отчет был отправлен # Проверяем, что отчет был отправлен
mock_bot.send_message.assert_called_once() mock_bot.send_message.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
@patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') @patch("helper_bot.utils.auto_unban_scheduler.get_global_instance")
async def test_auto_unban_no_users_today(self, mock_get_instance, setup_test_db, mock_bdf, mock_bot): async def test_auto_unban_no_users_today(
self, mock_get_instance, setup_test_db, mock_bdf, mock_bot
):
"""Тест разбана когда нет пользователей для разблокировки сегодня""" """Тест разбана когда нет пользователей для разблокировки сегодня"""
# Настройка моков # Настройка моков
mock_get_instance.return_value = mock_bdf mock_get_instance.return_value = mock_bdf
# Удаляем пользователей с сегодняшней датой # Удаляем пользователей с сегодняшней датой
conn = sqlite3.connect(setup_test_db) conn = sqlite3.connect(setup_test_db)
cursor = conn.cursor() cursor = conn.cursor()
current_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp()) current_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
cursor.execute("DELETE FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?", (current_timestamp,)) cursor.execute(
"DELETE FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?",
(current_timestamp,),
)
# Проверяем начальное состояние истории: все записи должны иметь date_unban = NULL # Проверяем начальное состояние истории: все записи должны иметь date_unban = NULL
cursor.execute("SELECT COUNT(*) FROM blacklist_history WHERE date_unban IS NULL") cursor.execute(
"SELECT COUNT(*) FROM blacklist_history WHERE date_unban IS NULL"
)
initial_open_history = cursor.fetchone()[0] initial_open_history = cursor.fetchone()[0]
assert initial_open_history == 4 # Все 4 записи должны быть открытыми assert initial_open_history == 4 # Все 4 записи должны быть открытыми
conn.commit() conn.commit()
conn.close() conn.close()
# Создаем планировщик # Создаем планировщик
scheduler = AutoUnbanScheduler() scheduler = AutoUnbanScheduler()
scheduler.bot_db = mock_bdf.database scheduler.bot_db = mock_bdf.database
scheduler.set_bot(mock_bot) scheduler.set_bot(mock_bot)
# Выполняем автоматический разбан # Выполняем автоматический разбан
await scheduler.auto_unban_users() await scheduler.auto_unban_users()
# Проверяем, что история не изменилась (все записи все еще с date_unban = NULL) # Проверяем, что история не изменилась (все записи все еще с date_unban = NULL)
conn = sqlite3.connect(setup_test_db) conn = sqlite3.connect(setup_test_db)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM blacklist_history WHERE date_unban IS NULL") cursor.execute(
"SELECT COUNT(*) FROM blacklist_history WHERE date_unban IS NULL"
)
final_open_history = cursor.fetchone()[0] final_open_history = cursor.fetchone()[0]
assert final_open_history == 4, "История не должна изменяться, если нет пользователей для разблокировки" assert (
final_open_history == 4
), "История не должна изменяться, если нет пользователей для разблокировки"
conn.close() conn.close()
# Проверяем, что отчет не был отправлен (нет пользователей для разблокировки) # Проверяем, что отчет не был отправлен (нет пользователей для разблокировки)
mock_bot.send_message.assert_not_called() mock_bot.send_message.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
@patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') @patch("helper_bot.utils.auto_unban_scheduler.get_global_instance")
async def test_auto_unban_database_error(self, mock_get_instance, setup_test_db, mock_bdf, mock_bot): async def test_auto_unban_database_error(
self, mock_get_instance, setup_test_db, mock_bdf, mock_bot
):
"""Тест обработки ошибок базы данных""" """Тест обработки ошибок базы данных"""
# Настройка моков # Настройка моков
mock_get_instance.return_value = mock_bdf mock_get_instance.return_value = mock_bdf
# Создаем планировщик # Создаем планировщик
scheduler = AutoUnbanScheduler() scheduler = AutoUnbanScheduler()
scheduler.bot_db = mock_bdf.database scheduler.bot_db = mock_bdf.database
scheduler.set_bot(mock_bot) scheduler.set_bot(mock_bot)
# Удаляем таблицу чтобы вызвать ошибку # Удаляем таблицу чтобы вызвать ошибку
conn = sqlite3.connect(setup_test_db) conn = sqlite3.connect(setup_test_db)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("DROP TABLE blacklist") cursor.execute("DROP TABLE blacklist")
conn.commit() conn.commit()
conn.close() conn.close()
# Выполняем автоматический разбан # Выполняем автоматический разбан
await scheduler.auto_unban_users() await scheduler.auto_unban_users()
# Проверяем, что отчет об ошибке был отправлен # Проверяем, что отчет об ошибке был отправлен
mock_bot.send_message.assert_called_once() mock_bot.send_message.assert_called_once()
call_args = mock_bot.send_message.call_args call_args = mock_bot.send_message.call_args
assert call_args[1]['chat_id'] == '-1001234567891' # important_logs assert call_args[1]["chat_id"] == "-1001234567891" # important_logs
assert "Ошибка автоматического разбана" in call_args[1]['text'] assert "Ошибка автоматического разбана" in call_args[1]["text"]
@pytest.mark.asyncio @pytest.mark.asyncio
@patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') @patch("helper_bot.utils.auto_unban_scheduler.get_global_instance")
async def test_auto_unban_updates_history(self, mock_get_instance, setup_test_db, mock_bdf, mock_bot): async def test_auto_unban_updates_history(
self, mock_get_instance, setup_test_db, mock_bdf, mock_bot
):
"""Тест что автоматический разбан обновляет историю банов""" """Тест что автоматический разбан обновляет историю банов"""
# Настройка моков # Настройка моков
mock_get_instance.return_value = mock_bdf mock_get_instance.return_value = mock_bdf
# Создаем планировщик # Создаем планировщик
scheduler = AutoUnbanScheduler() scheduler = AutoUnbanScheduler()
scheduler.bot_db = mock_bdf.database scheduler.bot_db = mock_bdf.database
scheduler.set_bot(mock_bot) scheduler.set_bot(mock_bot)
conn = sqlite3.connect(setup_test_db) conn = sqlite3.connect(setup_test_db)
cursor = conn.cursor() cursor = conn.cursor()
# Проверяем начальное состояние: для user_id 123 и 456 должны быть записи с date_unban = NULL # Проверяем начальное состояние: для user_id 123 и 456 должны быть записи с date_unban = NULL
cursor.execute(""" cursor.execute("""
SELECT id, user_id, date_ban, date_unban, updated_at SELECT id, user_id, date_ban, date_unban, updated_at
@@ -347,26 +494,32 @@ class TestAutoUnbanIntegration:
ORDER BY user_id ORDER BY user_id
""") """)
initial_records = cursor.fetchall() initial_records = cursor.fetchall()
assert len(initial_records) == 2, "Должно быть 2 открытые записи для user_id 123 и 456" assert (
len(initial_records) == 2
), "Должно быть 2 открытые записи для user_id 123 и 456"
# Запоминаем ID записей и их начальные значения updated_at # Запоминаем ID записей и их начальные значения updated_at
record_ids = {row[0]: (row[1], row[4]) for row in initial_records} record_ids = {row[0]: (row[1], row[4]) for row in initial_records}
# Запоминаем время до разбана # Запоминаем время до разбана
before_unban_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp()) before_unban_timestamp = int(
datetime.now(timezone(timedelta(hours=3))).timestamp()
)
conn.close() conn.close()
# Выполняем автоматический разбан # Выполняем автоматический разбан
await scheduler.auto_unban_users() await scheduler.auto_unban_users()
# Запоминаем время после разбана # Запоминаем время после разбана
after_unban_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp()) after_unban_timestamp = int(
datetime.now(timezone(timedelta(hours=3))).timestamp()
)
# Проверяем, что записи обновлены # Проверяем, что записи обновлены
conn = sqlite3.connect(setup_test_db) conn = sqlite3.connect(setup_test_db)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT id, user_id, date_ban, date_unban, updated_at SELECT id, user_id, date_ban, date_unban, updated_at
FROM blacklist_history FROM blacklist_history
@@ -374,32 +527,45 @@ class TestAutoUnbanIntegration:
ORDER BY user_id ORDER BY user_id
""") """)
updated_records = cursor.fetchall() updated_records = cursor.fetchall()
assert len(updated_records) == 2, "Должно быть 2 записи для user_id 123 и 456" assert len(updated_records) == 2, "Должно быть 2 записи для user_id 123 и 456"
for record_id, user_id, date_ban, date_unban, updated_at in updated_records: for record_id, user_id, date_ban, date_unban, updated_at in updated_records:
# Проверяем, что это одна из наших записей # Проверяем, что это одна из наших записей
assert record_id in record_ids, f"Запись с id={record_id} должна быть в исходных записях" assert (
record_id in record_ids
), f"Запись с id={record_id} должна быть в исходных записях"
# Проверяем, что date_unban установлен # Проверяем, что date_unban установлен
assert date_unban is not None, f"date_unban должен быть установлен для user_id={user_id}" assert (
assert isinstance(date_unban, int), f"date_unban должен быть integer для user_id={user_id}" date_unban is not None
), f"date_unban должен быть установлен для user_id={user_id}"
assert isinstance(
date_unban, int
), f"date_unban должен быть integer для user_id={user_id}"
# Проверяем, что date_unban находится в разумных пределах # Проверяем, что date_unban находится в разумных пределах
assert before_unban_timestamp <= date_unban <= after_unban_timestamp, \ assert (
f"date_unban для user_id={user_id} должен быть между {before_unban_timestamp} и {after_unban_timestamp}" before_unban_timestamp <= date_unban <= after_unban_timestamp
), f"date_unban для user_id={user_id} должен быть между {before_unban_timestamp} и {after_unban_timestamp}"
# Проверяем, что updated_at обновлен (должен быть больше начального значения) # Проверяем, что updated_at обновлен (должен быть больше начального значения)
assert updated_at is not None, f"updated_at должен быть установлен для user_id={user_id}" assert (
assert isinstance(updated_at, int), f"updated_at должен быть integer для user_id={user_id}" updated_at is not None
assert before_unban_timestamp <= updated_at <= after_unban_timestamp, \ ), f"updated_at должен быть установлен для user_id={user_id}"
f"updated_at для user_id={user_id} должен быть между {before_unban_timestamp} и {after_unban_timestamp}" assert isinstance(
updated_at, int
), f"updated_at должен быть integer для user_id={user_id}"
assert (
before_unban_timestamp <= updated_at <= after_unban_timestamp
), f"updated_at для user_id={user_id} должен быть между {before_unban_timestamp} и {after_unban_timestamp}"
# Проверяем, что updated_at действительно обновлен (больше начального значения) # Проверяем, что updated_at действительно обновлен (больше начального значения)
initial_updated_at = record_ids[record_id][1] initial_updated_at = record_ids[record_id][1]
assert updated_at >= initial_updated_at, \ assert (
f"updated_at для user_id={user_id} должен быть больше или равен начальному значению" updated_at >= initial_updated_at
), f"updated_at для user_id={user_id} должен быть больше или равен начальному значению"
# Проверяем, что обновлена только последняя запись для каждого пользователя # Проверяем, что обновлена только последняя запись для каждого пользователя
# (если бы было несколько записей, обновилась бы только последняя) # (если бы было несколько записей, обновилась бы только последняя)
cursor.execute(""" cursor.execute("""
@@ -407,29 +573,35 @@ class TestAutoUnbanIntegration:
WHERE user_id IN (123, 456) AND date_unban IS NOT NULL WHERE user_id IN (123, 456) AND date_unban IS NOT NULL
""") """)
closed_records = cursor.fetchone()[0] closed_records = cursor.fetchone()[0]
assert closed_records == 2, "Должно быть закрыто 2 записи (по одной для каждого пользователя)" assert (
closed_records == 2
), "Должно быть закрыто 2 записи (по одной для каждого пользователя)"
cursor.execute(""" cursor.execute("""
SELECT COUNT(*) FROM blacklist_history SELECT COUNT(*) FROM blacklist_history
WHERE user_id IN (123, 456) AND date_unban IS NULL WHERE user_id IN (123, 456) AND date_unban IS NULL
""") """)
open_records = cursor.fetchone()[0] open_records = cursor.fetchone()[0]
assert open_records == 0, "Не должно быть открытых записей для user_id 123 и 456" assert (
open_records == 0
), "Не должно быть открытых записей для user_id 123 и 456"
conn.close() conn.close()
def test_date_format_consistency(self, setup_test_db, mock_bdf): def test_date_format_consistency(self, setup_test_db, mock_bdf):
"""Тест консистентности формата дат""" """Тест консистентности формата дат"""
scheduler = AutoUnbanScheduler() scheduler = AutoUnbanScheduler()
scheduler.bot_db = mock_bdf.database scheduler.bot_db = mock_bdf.database
# Проверяем, что дата в базе соответствует ожидаемому формату (timestamp) # Проверяем, что дата в базе соответствует ожидаемому формату (timestamp)
conn = sqlite3.connect(setup_test_db) conn = sqlite3.connect(setup_test_db)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT date_to_unban FROM blacklist WHERE date_to_unban IS NOT NULL LIMIT 1") cursor.execute(
"SELECT date_to_unban FROM blacklist WHERE date_to_unban IS NOT NULL LIMIT 1"
)
result = cursor.fetchone() result = cursor.fetchone()
conn.close() conn.close()
if result and result[0]: if result and result[0]:
timestamp = result[0] timestamp = result[0]
# Проверяем, что это валидный timestamp (целое число) # Проверяем, что это валидный timestamp (целое число)
@@ -442,38 +614,39 @@ class TestAutoUnbanIntegration:
class TestSchedulerLifecycle: class TestSchedulerLifecycle:
"""Тесты жизненного цикла планировщика""" """Тесты жизненного цикла планировщика"""
def test_scheduler_start_stop(self): def test_scheduler_start_stop(self):
"""Тест запуска и остановки планировщика""" """Тест запуска и остановки планировщика"""
scheduler = AutoUnbanScheduler() scheduler = AutoUnbanScheduler()
# Запускаем планировщик # Запускаем планировщик
scheduler.start_scheduler() scheduler.start_scheduler()
assert scheduler.scheduler.running assert scheduler.scheduler.running
# Останавливаем планировщик (должно пройти без ошибок) # Останавливаем планировщик (должно пройти без ошибок)
scheduler.stop_scheduler() scheduler.stop_scheduler()
# APScheduler может не сразу остановиться, но это нормально # APScheduler может не сразу остановиться, но это нормально
def test_scheduler_job_creation(self): def test_scheduler_job_creation(self):
"""Тест создания задачи в планировщике""" """Тест создания задачи в планировщике"""
scheduler = AutoUnbanScheduler() scheduler = AutoUnbanScheduler()
with patch.object(scheduler.scheduler, 'add_job') as mock_add_job: with patch.object(scheduler.scheduler, "add_job") as mock_add_job:
scheduler.start_scheduler() scheduler.start_scheduler()
# Проверяем, что задача была создана с правильными параметрами # Проверяем, что задача была создана с правильными параметрами
mock_add_job.assert_called_once() mock_add_job.assert_called_once()
call_args = mock_add_job.call_args call_args = mock_add_job.call_args
# Проверяем функцию # Проверяем функцию
assert call_args[0][0] == scheduler.auto_unban_users assert call_args[0][0] == scheduler.auto_unban_users
# Проверяем триггер (должен быть CronTrigger) # Проверяем триггер (должен быть CronTrigger)
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
assert isinstance(call_args[0][1], CronTrigger) assert isinstance(call_args[0][1], CronTrigger)
# Проверяем ID и имя задачи # Проверяем ID и имя задачи
assert call_args[1]['id'] == 'auto_unban_users' assert call_args[1]["id"] == "auto_unban_users"
assert call_args[1]['name'] == 'Автоматический разбан пользователей' assert call_args[1]["name"] == "Автоматический разбан пользователей"
assert call_args[1]['replace_existing'] is True assert call_args[1]["replace_existing"] is True

View File

@@ -3,231 +3,248 @@ from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
from helper_bot.utils.auto_unban_scheduler import (AutoUnbanScheduler,
get_auto_unban_scheduler) from helper_bot.utils.auto_unban_scheduler import (
AutoUnbanScheduler,
get_auto_unban_scheduler,
)
class TestAutoUnbanScheduler: class TestAutoUnbanScheduler:
"""Тесты для класса AutoUnbanScheduler""" """Тесты для класса AutoUnbanScheduler"""
@pytest.fixture @pytest.fixture
def scheduler(self): def scheduler(self):
"""Создает экземпляр планировщика для тестов""" """Создает экземпляр планировщика для тестов"""
return AutoUnbanScheduler() return AutoUnbanScheduler()
@pytest.fixture @pytest.fixture
def mock_bot_db(self): def mock_bot_db(self):
"""Создает мок базы данных""" """Создает мок базы данных"""
mock_db = Mock() mock_db = Mock()
mock_db.get_users_for_unblock_today = AsyncMock(return_value={ mock_db.get_users_for_unblock_today = AsyncMock(
123: "test_user1", return_value={123: "test_user1", 456: "test_user2"}
456: "test_user2" )
})
mock_db.delete_user_blacklist = AsyncMock(return_value=True) mock_db.delete_user_blacklist = AsyncMock(return_value=True)
return mock_db return mock_db
@pytest.fixture @pytest.fixture
def mock_bdf(self): def mock_bdf(self):
"""Создает мок фабрики зависимостей""" """Создает мок фабрики зависимостей"""
mock_factory = Mock() mock_factory = Mock()
mock_factory.settings = { mock_factory.settings = {
'Telegram': { "Telegram": {
'group_for_logs': '-1001234567890', "group_for_logs": "-1001234567890",
'important_logs': '-1001234567891' "important_logs": "-1001234567891",
} }
} }
return mock_factory return mock_factory
@pytest.fixture @pytest.fixture
def mock_bot(self): def mock_bot(self):
"""Создает мок бота""" """Создает мок бота"""
mock_bot = Mock() mock_bot = Mock()
mock_bot.send_message = AsyncMock() mock_bot.send_message = AsyncMock()
return mock_bot return mock_bot
def test_scheduler_initialization(self, scheduler): def test_scheduler_initialization(self, scheduler):
"""Тест инициализации планировщика""" """Тест инициализации планировщика"""
assert scheduler.bot_db is not None assert scheduler.bot_db is not None
assert scheduler.scheduler is not None assert scheduler.scheduler is not None
assert scheduler.bot is None assert scheduler.bot is None
def test_set_bot(self, scheduler, mock_bot): def test_set_bot(self, scheduler, mock_bot):
"""Тест установки бота""" """Тест установки бота"""
scheduler.set_bot(mock_bot) scheduler.set_bot(mock_bot)
assert scheduler.bot == mock_bot assert scheduler.bot == mock_bot
@pytest.mark.asyncio @pytest.mark.asyncio
@patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') @patch("helper_bot.utils.auto_unban_scheduler.get_global_instance")
async def test_auto_unban_users_success(self, mock_get_instance, scheduler, mock_bot_db, mock_bdf, mock_bot): async def test_auto_unban_users_success(
self, mock_get_instance, scheduler, mock_bot_db, mock_bdf, mock_bot
):
"""Тест успешного выполнения автоматического разбана""" """Тест успешного выполнения автоматического разбана"""
# Настройка моков # Настройка моков
mock_get_instance.return_value = mock_bdf mock_get_instance.return_value = mock_bdf
scheduler.bot_db = mock_bot_db scheduler.bot_db = mock_bot_db
scheduler.set_bot(mock_bot) scheduler.set_bot(mock_bot)
# Выполнение теста # Выполнение теста
await scheduler.auto_unban_users() await scheduler.auto_unban_users()
# Проверки # Проверки
mock_bot_db.get_users_for_unblock_today.assert_called_once() mock_bot_db.get_users_for_unblock_today.assert_called_once()
assert mock_bot_db.delete_user_blacklist.call_count == 2 assert mock_bot_db.delete_user_blacklist.call_count == 2
mock_bot.send_message.assert_called_once() mock_bot.send_message.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
@patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') @patch("helper_bot.utils.auto_unban_scheduler.get_global_instance")
async def test_auto_unban_users_no_users(self, mock_get_instance, scheduler, mock_bot_db, mock_bdf, mock_bot): async def test_auto_unban_users_no_users(
self, mock_get_instance, scheduler, mock_bot_db, mock_bdf, mock_bot
):
"""Тест разбана когда нет пользователей для разблокировки""" """Тест разбана когда нет пользователей для разблокировки"""
# Настройка моков # Настройка моков
mock_get_instance.return_value = mock_bdf mock_get_instance.return_value = mock_bdf
mock_bot_db.get_users_for_unblock_today = AsyncMock(return_value={}) mock_bot_db.get_users_for_unblock_today = AsyncMock(return_value={})
scheduler.bot_db = mock_bot_db scheduler.bot_db = mock_bot_db
scheduler.set_bot(mock_bot) scheduler.set_bot(mock_bot)
# Выполнение теста # Выполнение теста
await scheduler.auto_unban_users() await scheduler.auto_unban_users()
# Проверки # Проверки
mock_bot_db.get_users_for_unblock_today.assert_called_once() mock_bot_db.get_users_for_unblock_today.assert_called_once()
mock_bot_db.delete_user_blacklist.assert_not_called() mock_bot_db.delete_user_blacklist.assert_not_called()
mock_bot.send_message.assert_not_called() mock_bot.send_message.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
@patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') @patch("helper_bot.utils.auto_unban_scheduler.get_global_instance")
async def test_auto_unban_users_partial_failure(self, mock_get_instance, scheduler, mock_bot_db, mock_bdf, mock_bot): async def test_auto_unban_users_partial_failure(
self, mock_get_instance, scheduler, mock_bot_db, mock_bdf, mock_bot
):
"""Тест разбана с частичными ошибками""" """Тест разбана с частичными ошибками"""
# Настройка моков # Настройка моков
mock_get_instance.return_value = mock_bdf mock_get_instance.return_value = mock_bdf
mock_bot_db.get_users_for_unblock_today = AsyncMock(return_value={ mock_bot_db.get_users_for_unblock_today = AsyncMock(
123: "test_user1", return_value={123: "test_user1", 456: "test_user2"}
456: "test_user2" )
})
# Первый вызов успешен, второй - ошибка # Первый вызов успешен, второй - ошибка
mock_bot_db.delete_user_blacklist = AsyncMock(side_effect=[True, False]) mock_bot_db.delete_user_blacklist = AsyncMock(side_effect=[True, False])
scheduler.bot_db = mock_bot_db scheduler.bot_db = mock_bot_db
scheduler.set_bot(mock_bot) scheduler.set_bot(mock_bot)
# Выполнение теста # Выполнение теста
await scheduler.auto_unban_users() await scheduler.auto_unban_users()
# Проверки # Проверки
assert mock_bot_db.delete_user_blacklist.call_count == 2 assert mock_bot_db.delete_user_blacklist.call_count == 2
mock_bot.send_message.assert_called_once() mock_bot.send_message.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
@patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') @patch("helper_bot.utils.auto_unban_scheduler.get_global_instance")
async def test_auto_unban_users_exception(self, mock_get_instance, scheduler, mock_bot_db, mock_bdf, mock_bot): async def test_auto_unban_users_exception(
self, mock_get_instance, scheduler, mock_bot_db, mock_bdf, mock_bot
):
"""Тест разбана с исключением""" """Тест разбана с исключением"""
# Настройка моков # Настройка моков
mock_get_instance.return_value = mock_bdf mock_get_instance.return_value = mock_bdf
mock_bot_db.get_users_for_unblock_today = AsyncMock(side_effect=Exception("Database error")) mock_bot_db.get_users_for_unblock_today = AsyncMock(
side_effect=Exception("Database error")
)
scheduler.bot_db = mock_bot_db scheduler.bot_db = mock_bot_db
scheduler.set_bot(mock_bot) scheduler.set_bot(mock_bot)
# Выполнение теста # Выполнение теста
await scheduler.auto_unban_users() await scheduler.auto_unban_users()
# Проверки # Проверки
mock_bot.send_message.assert_called_once() mock_bot.send_message.assert_called_once()
# Проверяем, что сообщение об ошибке было отправлено # Проверяем, что сообщение об ошибке было отправлено
call_args = mock_bot.send_message.call_args call_args = mock_bot.send_message.call_args
assert "Ошибка автоматического разбана" in call_args[1]['text'] assert "Ошибка автоматического разбана" in call_args[1]["text"]
def test_generate_report(self, scheduler): def test_generate_report(self, scheduler):
"""Тест генерации отчета""" """Тест генерации отчета"""
users = {123: "test_user1", 456: "test_user2"} users = {123: "test_user1", 456: "test_user2"}
failed_users = ["456 (test_user2)"] failed_users = ["456 (test_user2)"]
report = scheduler._generate_report(1, 1, failed_users, users) report = scheduler._generate_report(1, 1, failed_users, users)
assert "Отчет об автоматическом разбане" in report assert "Отчет об автоматическом разбане" in report
assert "Успешно разблокировано: 1" in report assert "Успешно разблокировано: 1" in report
assert "Ошибок: 1" in report assert "Ошибок: 1" in report
assert "ID: 123" in report assert "ID: 123" in report
assert "456 (test_user2)" in report assert "456 (test_user2)" in report
@pytest.mark.asyncio @pytest.mark.asyncio
@patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') @patch("helper_bot.utils.auto_unban_scheduler.get_global_instance")
async def test_send_report(self, mock_get_instance, scheduler, mock_bdf, mock_bot): async def test_send_report(self, mock_get_instance, scheduler, mock_bdf, mock_bot):
"""Тест отправки отчета""" """Тест отправки отчета"""
mock_get_instance.return_value = mock_bdf mock_get_instance.return_value = mock_bdf
scheduler.set_bot(mock_bot) scheduler.set_bot(mock_bot)
report = "Test report" report = "Test report"
await scheduler._send_report(report) await scheduler._send_report(report)
# Проверяем, что send_message был вызван # Проверяем, что send_message был вызван
mock_bot.send_message.assert_called_once() mock_bot.send_message.assert_called_once()
# Проверяем аргументы вызова # Проверяем аргументы вызова
call_args = mock_bot.send_message.call_args call_args = mock_bot.send_message.call_args
assert call_args[1]['text'] == report assert call_args[1]["text"] == report
assert call_args[1]['parse_mode'] == 'HTML' assert call_args[1]["parse_mode"] == "HTML"
@pytest.mark.asyncio @pytest.mark.asyncio
@patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') @patch("helper_bot.utils.auto_unban_scheduler.get_global_instance")
async def test_send_error_report(self, mock_get_instance, scheduler, mock_bdf, mock_bot): async def test_send_error_report(
self, mock_get_instance, scheduler, mock_bdf, mock_bot
):
"""Тест отправки отчета об ошибке""" """Тест отправки отчета об ошибке"""
mock_get_instance.return_value = mock_bdf mock_get_instance.return_value = mock_bdf
scheduler.set_bot(mock_bot) scheduler.set_bot(mock_bot)
error_msg = "Test error" error_msg = "Test error"
await scheduler._send_error_report(error_msg) await scheduler._send_error_report(error_msg)
# Проверяем, что send_message был вызван # Проверяем, что send_message был вызван
mock_bot.send_message.assert_called_once() mock_bot.send_message.assert_called_once()
# Проверяем аргументы вызова # Проверяем аргументы вызова
call_args = mock_bot.send_message.call_args call_args = mock_bot.send_message.call_args
assert "Ошибка автоматического разбана" in call_args[1]['text'] assert "Ошибка автоматического разбана" in call_args[1]["text"]
assert error_msg in call_args[1]['text'] assert error_msg in call_args[1]["text"]
assert call_args[1]['parse_mode'] == 'HTML' assert call_args[1]["parse_mode"] == "HTML"
def test_start_scheduler(self, scheduler): def test_start_scheduler(self, scheduler):
"""Тест запуска планировщика""" """Тест запуска планировщика"""
with patch.object(scheduler.scheduler, 'add_job') as mock_add_job, \ with (
patch.object(scheduler.scheduler, 'start') as mock_start: patch.object(scheduler.scheduler, "add_job") as mock_add_job,
patch.object(scheduler.scheduler, "start") as mock_start,
):
scheduler.start_scheduler() scheduler.start_scheduler()
mock_add_job.assert_called_once() mock_add_job.assert_called_once()
mock_start.assert_called_once() mock_start.assert_called_once()
def test_stop_scheduler(self, scheduler): def test_stop_scheduler(self, scheduler):
"""Тест остановки планировщика""" """Тест остановки планировщика"""
# Сначала запускаем планировщик # Сначала запускаем планировщик
scheduler.start_scheduler() scheduler.start_scheduler()
# Проверяем, что планировщик запущен # Проверяем, что планировщик запущен
assert scheduler.scheduler.running assert scheduler.scheduler.running
# Теперь останавливаем (должно пройти без ошибок) # Теперь останавливаем (должно пройти без ошибок)
scheduler.stop_scheduler() scheduler.stop_scheduler()
# Проверяем, что метод выполнился без исключений # Проверяем, что метод выполнился без исключений
# APScheduler может не сразу остановиться, но это нормально # APScheduler может не сразу остановиться, но это нормально
@pytest.mark.asyncio @pytest.mark.asyncio
@patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') @patch("helper_bot.utils.auto_unban_scheduler.get_global_instance")
async def test_run_manual_unban(self, mock_get_instance, scheduler, mock_bot_db, mock_bdf, mock_bot): async def test_run_manual_unban(
self, mock_get_instance, scheduler, mock_bot_db, mock_bdf, mock_bot
):
"""Тест ручного запуска разбана""" """Тест ручного запуска разбана"""
mock_get_instance.return_value = mock_bdf mock_get_instance.return_value = mock_bdf
mock_bot_db.get_users_for_unblock_today.return_value = {} mock_bot_db.get_users_for_unblock_today.return_value = {}
scheduler.bot_db = mock_bot_db scheduler.bot_db = mock_bot_db
scheduler.set_bot(mock_bot) scheduler.set_bot(mock_bot)
await scheduler.run_manual_unban() await scheduler.run_manual_unban()
mock_bot_db.get_users_for_unblock_today.assert_called_once() mock_bot_db.get_users_for_unblock_today.assert_called_once()
class TestGetAutoUnbanScheduler: class TestGetAutoUnbanScheduler:
"""Тесты для функции get_auto_unban_scheduler""" """Тесты для функции get_auto_unban_scheduler"""
def test_get_auto_unban_scheduler(self): def test_get_auto_unban_scheduler(self):
"""Тест получения глобального экземпляра планировщика""" """Тест получения глобального экземпляра планировщика"""
scheduler = get_auto_unban_scheduler() scheduler = get_auto_unban_scheduler()
assert isinstance(scheduler, AutoUnbanScheduler) assert isinstance(scheduler, AutoUnbanScheduler)
# Проверяем, что возвращается один и тот же экземпляр # Проверяем, что возвращается один и тот же экземпляр
scheduler2 = get_auto_unban_scheduler() scheduler2 = get_auto_unban_scheduler()
assert scheduler is scheduler2 assert scheduler is scheduler2
@@ -235,17 +252,17 @@ class TestGetAutoUnbanScheduler:
class TestDateHandling: class TestDateHandling:
"""Тесты для обработки дат""" """Тесты для обработки дат"""
def test_moscow_timezone(self): def test_moscow_timezone(self):
"""Тест работы с московским временем""" """Тест работы с московским временем"""
scheduler = AutoUnbanScheduler() scheduler = AutoUnbanScheduler()
# Проверяем, что дата формируется в правильном формате # Проверяем, что дата формируется в правильном формате
moscow_tz = timezone(timedelta(hours=3)) moscow_tz = timezone(timedelta(hours=3))
today = datetime.now(moscow_tz).strftime("%Y-%m-%d") today = datetime.now(moscow_tz).strftime("%Y-%m-%d")
assert len(today) == 10 # YYYY-MM-DD assert len(today) == 10 # YYYY-MM-DD
assert today.count('-') == 2 assert today.count("-") == 2
assert today[:4].isdigit() # Год assert today[:4].isdigit() # Год
assert today[5:7].isdigit() # Месяц assert today[5:7].isdigit() # Месяц
assert today[8:10].isdigit() # День assert today[8:10].isdigit() # День
@@ -254,35 +271,37 @@ class TestDateHandling:
@pytest.mark.asyncio @pytest.mark.asyncio
class TestAsyncOperations: class TestAsyncOperations:
"""Тесты асинхронных операций""" """Тесты асинхронных операций"""
@patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') @patch("helper_bot.utils.auto_unban_scheduler.get_global_instance")
async def test_async_auto_unban_flow(self, mock_get_instance): async def test_async_auto_unban_flow(self, mock_get_instance):
"""Тест полного асинхронного потока разбана""" """Тест полного асинхронного потока разбана"""
# Создаем моки # Создаем моки
mock_bdf = Mock() mock_bdf = Mock()
mock_bdf.settings = { mock_bdf.settings = {
'Telegram': { "Telegram": {
'group_for_logs': '-1001234567890', "group_for_logs": "-1001234567890",
'important_logs': '-1001234567891' "important_logs": "-1001234567891",
} }
} }
mock_get_instance.return_value = mock_bdf mock_get_instance.return_value = mock_bdf
mock_bot_db = Mock() mock_bot_db = Mock()
mock_bot_db.get_users_for_unblock_today = AsyncMock(return_value={123: "test_user"}) mock_bot_db.get_users_for_unblock_today = AsyncMock(
return_value={123: "test_user"}
)
mock_bot_db.delete_user_blacklist = AsyncMock(return_value=True) mock_bot_db.delete_user_blacklist = AsyncMock(return_value=True)
mock_bot = Mock() mock_bot = Mock()
mock_bot.send_message = AsyncMock() mock_bot.send_message = AsyncMock()
# Создаем планировщик # Создаем планировщик
scheduler = AutoUnbanScheduler() scheduler = AutoUnbanScheduler()
scheduler.bot_db = mock_bot_db scheduler.bot_db = mock_bot_db
scheduler.set_bot(mock_bot) scheduler.set_bot(mock_bot)
# Выполняем разбан # Выполняем разбан
await scheduler.auto_unban_users() await scheduler.auto_unban_users()
# Проверяем результаты # Проверяем результаты
mock_bot_db.get_users_for_unblock_today.assert_called_once() mock_bot_db.get_users_for_unblock_today.assert_called_once()
mock_bot_db.delete_user_blacklist.assert_called_once_with(123) mock_bot_db.delete_user_blacklist.assert_called_once_with(123)

View File

@@ -3,14 +3,16 @@ from datetime import datetime
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
from database.models import BlacklistHistoryRecord from database.models import BlacklistHistoryRecord
from database.repositories.blacklist_history_repository import \ from database.repositories.blacklist_history_repository import (
BlacklistHistoryRepository BlacklistHistoryRepository,
)
class TestBlacklistHistoryRepository: class TestBlacklistHistoryRepository:
"""Тесты для BlacklistHistoryRepository""" """Тесты для BlacklistHistoryRepository"""
@pytest.fixture @pytest.fixture
def mock_db_connection(self): def mock_db_connection(self):
"""Мок для DatabaseConnection""" """Мок для DatabaseConnection"""
@@ -19,18 +21,20 @@ class TestBlacklistHistoryRepository:
mock_connection._execute_query_with_result = AsyncMock() mock_connection._execute_query_with_result = AsyncMock()
mock_connection.logger = Mock() mock_connection.logger = Mock()
return mock_connection return mock_connection
@pytest.fixture @pytest.fixture
def blacklist_history_repository(self, mock_db_connection): def blacklist_history_repository(self, mock_db_connection):
"""Экземпляр BlacklistHistoryRepository для тестов""" """Экземпляр BlacklistHistoryRepository для тестов"""
# Патчим наследование от DatabaseConnection # Патчим наследование от DatabaseConnection
with patch.object(BlacklistHistoryRepository, '__init__', return_value=None): with patch.object(BlacklistHistoryRepository, "__init__", return_value=None):
repo = BlacklistHistoryRepository() repo = BlacklistHistoryRepository()
repo._execute_query = mock_db_connection._execute_query repo._execute_query = mock_db_connection._execute_query
repo._execute_query_with_result = mock_db_connection._execute_query_with_result repo._execute_query_with_result = (
mock_db_connection._execute_query_with_result
)
repo.logger = mock_db_connection.logger repo.logger = mock_db_connection.logger
return repo return repo
@pytest.fixture @pytest.fixture
def sample_history_record(self): def sample_history_record(self):
"""Тестовая запись истории бана""" """Тестовая запись истории бана"""
@@ -44,7 +48,7 @@ class TestBlacklistHistoryRepository:
created_at=current_time, created_at=current_time,
updated_at=current_time, updated_at=current_time,
) )
@pytest.fixture @pytest.fixture
def sample_history_record_with_unban(self): def sample_history_record_with_unban(self):
"""Тестовая запись истории бана с датой разбана""" """Тестовая запись истории бана с датой разбана"""
@@ -58,56 +62,75 @@ class TestBlacklistHistoryRepository:
created_at=current_time - 86400, created_at=current_time - 86400,
updated_at=current_time, updated_at=current_time,
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_tables(self, blacklist_history_repository): async def test_create_tables(self, blacklist_history_repository):
"""Тест создания таблицы истории банов/разбанов""" """Тест создания таблицы истории банов/разбанов"""
await blacklist_history_repository.create_tables() await blacklist_history_repository.create_tables()
# Проверяем, что метод вызван (4 раза: таблица + 3 индекса) # Проверяем, что метод вызван (4 раза: таблица + 3 индекса)
assert blacklist_history_repository._execute_query.call_count == 4 assert blacklist_history_repository._execute_query.call_count == 4
calls = blacklist_history_repository._execute_query.call_args_list calls = blacklist_history_repository._execute_query.call_args_list
# Проверяем, что создается таблица с правильной структурой # Проверяем, что создается таблица с правильной структурой
create_table_call = calls[0] create_table_call = calls[0]
assert "CREATE TABLE IF NOT EXISTS blacklist_history" in create_table_call[0][0] assert "CREATE TABLE IF NOT EXISTS blacklist_history" in create_table_call[0][0]
assert "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT" in create_table_call[0][0] assert (
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT" in create_table_call[0][0]
)
assert "user_id INTEGER NOT NULL" in create_table_call[0][0] assert "user_id INTEGER NOT NULL" in create_table_call[0][0]
assert "message_for_user TEXT" in create_table_call[0][0] assert "message_for_user TEXT" in create_table_call[0][0]
assert "date_ban INTEGER NOT NULL" in create_table_call[0][0] assert "date_ban INTEGER NOT NULL" in create_table_call[0][0]
assert "date_unban INTEGER" in create_table_call[0][0] assert "date_unban INTEGER" in create_table_call[0][0]
assert "ban_author INTEGER" in create_table_call[0][0] assert "ban_author INTEGER" in create_table_call[0][0]
assert "created_at INTEGER DEFAULT (strftime('%s', 'now'))" in create_table_call[0][0] assert (
assert "updated_at INTEGER DEFAULT (strftime('%s', 'now'))" in create_table_call[0][0] "created_at INTEGER DEFAULT (strftime('%s', 'now'))"
assert "FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE" in create_table_call[0][0] in create_table_call[0][0]
assert "FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL" in create_table_call[0][0] )
assert (
"updated_at INTEGER DEFAULT (strftime('%s', 'now'))"
in create_table_call[0][0]
)
assert (
"FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE"
in create_table_call[0][0]
)
assert (
"FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL"
in create_table_call[0][0]
)
# Проверяем создание индексов # Проверяем создание индексов
index_calls = calls[1:4] index_calls = calls[1:4]
index_names = [call[0][0] for call in index_calls] index_names = [call[0][0] for call in index_calls]
assert any("idx_blacklist_history_user_id" in idx for idx in index_names) assert any("idx_blacklist_history_user_id" in idx for idx in index_names)
assert any("idx_blacklist_history_date_ban" in idx for idx in index_names) assert any("idx_blacklist_history_date_ban" in idx for idx in index_names)
assert any("idx_blacklist_history_date_unban" in idx for idx in index_names) assert any("idx_blacklist_history_date_unban" in idx for idx in index_names)
# Проверяем логирование # Проверяем логирование
blacklist_history_repository.logger.info.assert_called_once_with( blacklist_history_repository.logger.info.assert_called_once_with(
"Таблица истории банов/разбанов создана" "Таблица истории банов/разбанов создана"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_record_on_ban(self, blacklist_history_repository, sample_history_record): async def test_add_record_on_ban(
self, blacklist_history_repository, sample_history_record
):
"""Тест добавления записи о бане в историю""" """Тест добавления записи о бане в историю"""
await blacklist_history_repository.add_record_on_ban(sample_history_record) await blacklist_history_repository.add_record_on_ban(sample_history_record)
# Проверяем, что метод вызван с правильными параметрами # Проверяем, что метод вызван с правильными параметрами
blacklist_history_repository._execute_query.assert_called_once() blacklist_history_repository._execute_query.assert_called_once()
call_args = blacklist_history_repository._execute_query.call_args call_args = blacklist_history_repository._execute_query.call_args
# Проверяем SQL запрос # Проверяем SQL запрос
sql_query = call_args[0][0].replace('\n', ' ').replace(' ', ' ').strip() sql_query = call_args[0][0].replace("\n", " ").replace(" ", " ").strip()
assert "INSERT INTO blacklist_history" in sql_query assert "INSERT INTO blacklist_history" in sql_query
assert "user_id, message_for_user, date_ban, date_unban, ban_author, created_at, updated_at" in sql_query assert (
"user_id, message_for_user, date_ban, date_unban, ban_author, created_at, updated_at"
in sql_query
)
# Проверяем параметры # Проверяем параметры
params = call_args[0][1] params = call_args[0][1]
assert params[0] == 12345 # user_id assert params[0] == 12345 # user_id
@@ -117,13 +140,13 @@ class TestBlacklistHistoryRepository:
assert params[4] == 999 # ban_author assert params[4] == 999 # ban_author
assert params[5] == sample_history_record.created_at # created_at assert params[5] == sample_history_record.created_at # created_at
assert params[6] == sample_history_record.updated_at # updated_at assert params[6] == sample_history_record.updated_at # updated_at
# Проверяем логирование # Проверяем логирование
blacklist_history_repository.logger.info.assert_called_once() blacklist_history_repository.logger.info.assert_called_once()
log_call = blacklist_history_repository.logger.info.call_args[0][0] log_call = blacklist_history_repository.logger.info.call_args[0][0]
assert "Запись о бане добавлена в историю" in log_call assert "Запись о бане добавлена в историю" in log_call
assert "user_id=12345" in log_call assert "user_id=12345" in log_call
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_record_on_ban_with_defaults(self, blacklist_history_repository): async def test_add_record_on_ban_with_defaults(self, blacklist_history_repository):
"""Тест добавления записи о бане с дефолтными значениями created_at и updated_at""" """Тест добавления записи о бане с дефолтными значениями created_at и updated_at"""
@@ -136,122 +159,130 @@ class TestBlacklistHistoryRepository:
created_at=None, # Будет установлено автоматически created_at=None, # Будет установлено автоматически
updated_at=None, # Будет установлено автоматически updated_at=None, # Будет установлено автоматически
) )
await blacklist_history_repository.add_record_on_ban(record) await blacklist_history_repository.add_record_on_ban(record)
# Проверяем, что метод вызван # Проверяем, что метод вызван
blacklist_history_repository._execute_query.assert_called_once() blacklist_history_repository._execute_query.assert_called_once()
call_args = blacklist_history_repository._execute_query.call_args call_args = blacklist_history_repository._execute_query.call_args
# Проверяем, что created_at и updated_at установлены (не None) # Проверяем, что created_at и updated_at установлены (не None)
params = call_args[0][1] params = call_args[0][1]
assert params[5] is not None # created_at assert params[5] is not None # created_at
assert params[6] is not None # updated_at assert params[6] is not None # updated_at
assert isinstance(params[5], int) assert isinstance(params[5], int)
assert isinstance(params[6], int) assert isinstance(params[6], int)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_unban_date_success(self, blacklist_history_repository): async def test_set_unban_date_success(self, blacklist_history_repository):
"""Тест успешного обновления даты разбана""" """Тест успешного обновления даты разбана"""
user_id = 12345 user_id = 12345
date_unban = int(time.time()) date_unban = int(time.time())
# Мокируем результат проверки - находим открытую запись # Мокируем результат проверки - находим открытую запись
blacklist_history_repository._execute_query_with_result.return_value = [(100,)] # id записи blacklist_history_repository._execute_query_with_result.return_value = [
(100,)
] # id записи
result = await blacklist_history_repository.set_unban_date(user_id, date_unban) result = await blacklist_history_repository.set_unban_date(user_id, date_unban)
# Проверяем, что сначала проверяется наличие записи # Проверяем, что сначала проверяется наличие записи
assert blacklist_history_repository._execute_query_with_result.call_count == 1 assert blacklist_history_repository._execute_query_with_result.call_count == 1
check_call = blacklist_history_repository._execute_query_with_result.call_args check_call = blacklist_history_repository._execute_query_with_result.call_args
assert "SELECT id FROM blacklist_history" in check_call[0][0] assert "SELECT id FROM blacklist_history" in check_call[0][0]
assert check_call[0][1] == (user_id,) assert check_call[0][1] == (user_id,)
# Проверяем, что затем обновляется запись # Проверяем, что затем обновляется запись
assert blacklist_history_repository._execute_query.call_count == 1 assert blacklist_history_repository._execute_query.call_count == 1
update_call = blacklist_history_repository._execute_query.call_args update_call = blacklist_history_repository._execute_query.call_args
update_query = update_call[0][0].replace('\n', ' ').replace(' ', ' ').strip() update_query = (
update_call[0][0].replace("\n", " ").replace(" ", " ").strip()
)
assert "UPDATE blacklist_history" in update_query assert "UPDATE blacklist_history" in update_query
assert "SET date_unban = ?" in update_query assert "SET date_unban = ?" in update_query
assert "updated_at = ?" in update_query assert "updated_at = ?" in update_query
# Проверяем параметры обновления # Проверяем параметры обновления
update_params = update_call[0][1] update_params = update_call[0][1]
assert update_params[0] == date_unban assert update_params[0] == date_unban
assert update_params[1] is not None # updated_at (текущее время) assert update_params[1] is not None # updated_at (текущее время)
assert isinstance(update_params[1], int) assert isinstance(update_params[1], int)
assert update_params[2] == 100 # id записи assert update_params[2] == 100 # id записи
# Проверяем результат # Проверяем результат
assert result is True assert result is True
# Проверяем логирование # Проверяем логирование
blacklist_history_repository.logger.info.assert_called_once() blacklist_history_repository.logger.info.assert_called_once()
log_call = blacklist_history_repository.logger.info.call_args[0][0] log_call = blacklist_history_repository.logger.info.call_args[0][0]
assert "Дата разбана обновлена в истории" in log_call assert "Дата разбана обновлена в истории" in log_call
assert f"user_id={user_id}" in log_call assert f"user_id={user_id}" in log_call
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_unban_date_no_open_record(self, blacklist_history_repository): async def test_set_unban_date_no_open_record(self, blacklist_history_repository):
"""Тест обновления даты разбана когда нет открытой записи""" """Тест обновления даты разбана когда нет открытой записи"""
user_id = 12345 user_id = 12345
date_unban = int(time.time()) date_unban = int(time.time())
# Мокируем результат проверки - нет открытых записей # Мокируем результат проверки - нет открытых записей
blacklist_history_repository._execute_query_with_result.return_value = [] blacklist_history_repository._execute_query_with_result.return_value = []
result = await blacklist_history_repository.set_unban_date(user_id, date_unban) result = await blacklist_history_repository.set_unban_date(user_id, date_unban)
# Проверяем, что проверка была выполнена # Проверяем, что проверка была выполнена
assert blacklist_history_repository._execute_query_with_result.call_count == 1 assert blacklist_history_repository._execute_query_with_result.call_count == 1
# Проверяем, что UPDATE не был вызван (нет записей для обновления) # Проверяем, что UPDATE не был вызван (нет записей для обновления)
blacklist_history_repository._execute_query.assert_not_called() blacklist_history_repository._execute_query.assert_not_called()
# Проверяем результат # Проверяем результат
assert result is False assert result is False
# Проверяем логирование предупреждения # Проверяем логирование предупреждения
blacklist_history_repository.logger.warning.assert_called_once() blacklist_history_repository.logger.warning.assert_called_once()
log_call = blacklist_history_repository.logger.warning.call_args[0][0] log_call = blacklist_history_repository.logger.warning.call_args[0][0]
assert "Не найдена открытая запись в истории для обновления" in log_call assert "Не найдена открытая запись в истории для обновления" in log_call
assert f"user_id={user_id}" in log_call assert f"user_id={user_id}" in log_call
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_unban_date_exception(self, blacklist_history_repository): async def test_set_unban_date_exception(self, blacklist_history_repository):
"""Тест обработки исключения при обновлении даты разбана""" """Тест обработки исключения при обновлении даты разбана"""
user_id = 12345 user_id = 12345
date_unban = int(time.time()) date_unban = int(time.time())
# Мокируем исключение при проверке # Мокируем исключение при проверке
blacklist_history_repository._execute_query_with_result.side_effect = Exception("Database error") blacklist_history_repository._execute_query_with_result.side_effect = Exception(
"Database error"
)
result = await blacklist_history_repository.set_unban_date(user_id, date_unban) result = await blacklist_history_repository.set_unban_date(user_id, date_unban)
# Проверяем, что метод вернул False при ошибке # Проверяем, что метод вернул False при ошибке
assert result is False assert result is False
# Проверяем логирование ошибки # Проверяем логирование ошибки
blacklist_history_repository.logger.error.assert_called_once() blacklist_history_repository.logger.error.assert_called_once()
log_call = blacklist_history_repository.logger.error.call_args[0][0] log_call = blacklist_history_repository.logger.error.call_args[0][0]
assert "Ошибка обновления даты разбана в истории" in log_call assert "Ошибка обновления даты разбана в истории" in log_call
assert f"user_id={user_id}" in log_call assert f"user_id={user_id}" in log_call
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_set_unban_date_update_exception(self, blacklist_history_repository): async def test_set_unban_date_update_exception(self, blacklist_history_repository):
"""Тест обработки исключения при обновлении записи""" """Тест обработки исключения при обновлении записи"""
user_id = 12345 user_id = 12345
date_unban = int(time.time()) date_unban = int(time.time())
# Мокируем успешную проверку, но ошибку при обновлении # Мокируем успешную проверку, но ошибку при обновлении
blacklist_history_repository._execute_query_with_result.return_value = [(100,)] blacklist_history_repository._execute_query_with_result.return_value = [(100,)]
blacklist_history_repository._execute_query.side_effect = Exception("Update error") blacklist_history_repository._execute_query.side_effect = Exception(
"Update error"
)
result = await blacklist_history_repository.set_unban_date(user_id, date_unban) result = await blacklist_history_repository.set_unban_date(user_id, date_unban)
# Проверяем, что метод вернул False при ошибке # Проверяем, что метод вернул False при ошибке
assert result is False assert result is False
# Проверяем логирование ошибки # Проверяем логирование ошибки
blacklist_history_repository.logger.error.assert_called_once() blacklist_history_repository.logger.error.assert_called_once()
log_call = blacklist_history_repository.logger.error.call_args[0][0] log_call = blacklist_history_repository.logger.error.call_args[0][0]

View File

@@ -3,13 +3,14 @@ from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest import pytest
from database.models import BlacklistUser from database.models import BlacklistUser
from database.repositories.blacklist_repository import BlacklistRepository from database.repositories.blacklist_repository import BlacklistRepository
class TestBlacklistRepository: class TestBlacklistRepository:
"""Тесты для BlacklistRepository""" """Тесты для BlacklistRepository"""
@pytest.fixture @pytest.fixture
def mock_db_connection(self): def mock_db_connection(self):
"""Мок для DatabaseConnection""" """Мок для DatabaseConnection"""
@@ -18,18 +19,20 @@ class TestBlacklistRepository:
mock_connection._execute_query_with_result = AsyncMock() mock_connection._execute_query_with_result = AsyncMock()
mock_connection.logger = Mock() mock_connection.logger = Mock()
return mock_connection return mock_connection
@pytest.fixture @pytest.fixture
def blacklist_repository(self, mock_db_connection): def blacklist_repository(self, mock_db_connection):
"""Экземпляр BlacklistRepository для тестов""" """Экземпляр BlacklistRepository для тестов"""
# Патчим наследование от DatabaseConnection # Патчим наследование от DatabaseConnection
with patch.object(BlacklistRepository, '__init__', return_value=None): with patch.object(BlacklistRepository, "__init__", return_value=None):
repo = BlacklistRepository() repo = BlacklistRepository()
repo._execute_query = mock_db_connection._execute_query repo._execute_query = mock_db_connection._execute_query
repo._execute_query_with_result = mock_db_connection._execute_query_with_result repo._execute_query_with_result = (
mock_db_connection._execute_query_with_result
)
repo.logger = mock_db_connection.logger repo.logger = mock_db_connection.logger
return repo return repo
@pytest.fixture @pytest.fixture
def sample_blacklist_user(self): def sample_blacklist_user(self):
"""Тестовый пользователь в черном списке""" """Тестовый пользователь в черном списке"""
@@ -40,7 +43,7 @@ class TestBlacklistRepository:
created_at=int(time.time()), created_at=int(time.time()),
ban_author=999, ban_author=999,
) )
@pytest.fixture @pytest.fixture
def sample_blacklist_user_permanent(self): def sample_blacklist_user_permanent(self):
"""Тестовый пользователь с постоянным баном""" """Тестовый пользователь с постоянным баном"""
@@ -51,144 +54,171 @@ class TestBlacklistRepository:
created_at=int(time.time()), created_at=int(time.time()),
ban_author=None, ban_author=None,
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_tables(self, blacklist_repository): async def test_create_tables(self, blacklist_repository):
"""Тест создания таблицы черного списка""" """Тест создания таблицы черного списка"""
await blacklist_repository.create_tables() await blacklist_repository.create_tables()
# Проверяем, что метод вызван # Проверяем, что метод вызван
blacklist_repository._execute_query.assert_called() blacklist_repository._execute_query.assert_called()
calls = blacklist_repository._execute_query.call_args_list calls = blacklist_repository._execute_query.call_args_list
# Проверяем, что создается таблица с правильной структурой # Проверяем, что создается таблица с правильной структурой
create_table_call = calls[0] create_table_call = calls[0]
assert "CREATE TABLE IF NOT EXISTS blacklist" in create_table_call[0][0] assert "CREATE TABLE IF NOT EXISTS blacklist" in create_table_call[0][0]
assert "user_id INTEGER NOT NULL PRIMARY KEY" in create_table_call[0][0] assert "user_id INTEGER NOT NULL PRIMARY KEY" in create_table_call[0][0]
assert "message_for_user TEXT" in create_table_call[0][0] assert "message_for_user TEXT" in create_table_call[0][0]
assert "date_to_unban INTEGER" in create_table_call[0][0] assert "date_to_unban INTEGER" in create_table_call[0][0]
assert "created_at INTEGER DEFAULT (strftime('%s', 'now'))" in create_table_call[0][0] assert (
assert "FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in create_table_call[0][0] "created_at INTEGER DEFAULT (strftime('%s', 'now'))"
in create_table_call[0][0]
)
assert (
"FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE"
in create_table_call[0][0]
)
# Проверяем логирование # Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with("Таблица черного списка создана") blacklist_repository.logger.info.assert_called_once_with(
"Таблица черного списка создана"
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_user(self, blacklist_repository, sample_blacklist_user): async def test_add_user(self, blacklist_repository, sample_blacklist_user):
"""Тест добавления пользователя в черный список""" """Тест добавления пользователя в черный список"""
await blacklist_repository.add_user(sample_blacklist_user) await blacklist_repository.add_user(sample_blacklist_user)
# Проверяем, что метод вызван с правильными параметрами # Проверяем, что метод вызван с правильными параметрами
blacklist_repository._execute_query.assert_called_once() blacklist_repository._execute_query.assert_called_once()
call_args = blacklist_repository._execute_query.call_args call_args = blacklist_repository._execute_query.call_args
# Проверяем SQL запрос (учитываем форматирование) # Проверяем SQL запрос (учитываем форматирование)
sql_query = call_args[0][0].replace('\n', ' ').replace(' ', ' ').replace(' ', ' ').strip() sql_query = (
call_args[0][0]
.replace("\n", " ")
.replace(" ", " ")
.replace(" ", " ")
.strip()
)
expected_sql = "INSERT INTO blacklist (user_id, message_for_user, date_to_unban, ban_author) VALUES (?, ?, ?, ?)" expected_sql = "INSERT INTO blacklist (user_id, message_for_user, date_to_unban, ban_author) VALUES (?, ?, ?, ?)"
assert sql_query == expected_sql assert sql_query == expected_sql
# Проверяем параметры # Проверяем параметры
assert call_args[0][1] == (12345, "Нарушение правил", sample_blacklist_user.date_to_unban, 999) assert call_args[0][1] == (
12345,
"Нарушение правил",
sample_blacklist_user.date_to_unban,
999,
)
# Проверяем логирование # Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with( blacklist_repository.logger.info.assert_called_once_with(
"Пользователь добавлен в черный список: user_id=12345" "Пользователь добавлен в черный список: user_id=12345"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_user_permanent_ban(self, blacklist_repository, sample_blacklist_user_permanent): async def test_add_user_permanent_ban(
self, blacklist_repository, sample_blacklist_user_permanent
):
"""Тест добавления пользователя с постоянным баном""" """Тест добавления пользователя с постоянным баном"""
await blacklist_repository.add_user(sample_blacklist_user_permanent) await blacklist_repository.add_user(sample_blacklist_user_permanent)
call_args = blacklist_repository._execute_query.call_args call_args = blacklist_repository._execute_query.call_args
assert call_args[0][1] == (67890, "Постоянный бан", None, None) assert call_args[0][1] == (67890, "Постоянный бан", None, None)
blacklist_repository.logger.info.assert_called_once_with( blacklist_repository.logger.info.assert_called_once_with(
"Пользователь добавлен в черный список: user_id=67890" "Пользователь добавлен в черный список: user_id=67890"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remove_user_success(self, blacklist_repository): async def test_remove_user_success(self, blacklist_repository):
"""Тест успешного удаления пользователя из черного списка""" """Тест успешного удаления пользователя из черного списка"""
await blacklist_repository.remove_user(12345) await blacklist_repository.remove_user(12345)
# Проверяем, что метод вызван с правильными параметрами # Проверяем, что метод вызван с правильными параметрами
blacklist_repository._execute_query.assert_called_once() blacklist_repository._execute_query.assert_called_once()
call_args = blacklist_repository._execute_query.call_args call_args = blacklist_repository._execute_query.call_args
assert call_args[0][0] == "DELETE FROM blacklist WHERE user_id = ?" assert call_args[0][0] == "DELETE FROM blacklist WHERE user_id = ?"
assert call_args[0][1] == (12345,) assert call_args[0][1] == (12345,)
# Проверяем логирование # Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with( blacklist_repository.logger.info.assert_called_once_with(
"Пользователь с идентификатором 12345 успешно удален из черного списка." "Пользователь с идентификатором 12345 успешно удален из черного списка."
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remove_user_failure(self, blacklist_repository): async def test_remove_user_failure(self, blacklist_repository):
"""Тест неудачного удаления пользователя из черного списка""" """Тест неудачного удаления пользователя из черного списка"""
# Симулируем ошибку при удалении # Симулируем ошибку при удалении
blacklist_repository._execute_query.side_effect = Exception("Database error") blacklist_repository._execute_query.side_effect = Exception("Database error")
result = await blacklist_repository.remove_user(12345) result = await blacklist_repository.remove_user(12345)
# Проверяем, что возвращается False при ошибке # Проверяем, что возвращается False при ошибке
assert result is False assert result is False
# Проверяем логирование ошибки # Проверяем логирование ошибки
blacklist_repository.logger.error.assert_called_once() blacklist_repository.logger.error.assert_called_once()
error_log = blacklist_repository.logger.error.call_args[0][0] error_log = blacklist_repository.logger.error.call_args[0][0]
assert "Ошибка удаления пользователя с идентификатором 12345" in error_log assert "Ошибка удаления пользователя с идентификатором 12345" in error_log
assert "Database error" in error_log assert "Database error" in error_log
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_user_exists_true(self, blacklist_repository): async def test_user_exists_true(self, blacklist_repository):
"""Тест проверки существования пользователя (пользователь существует)""" """Тест проверки существования пользователя (пользователь существует)"""
# Симулируем результат запроса - пользователь найден # Симулируем результат запроса - пользователь найден
blacklist_repository._execute_query_with_result.return_value = [(1,)] blacklist_repository._execute_query_with_result.return_value = [(1,)]
result = await blacklist_repository.user_exists(12345) result = await blacklist_repository.user_exists(12345)
# Проверяем, что возвращается True # Проверяем, что возвращается True
assert result is True assert result is True
# Проверяем, что метод вызван с правильными параметрами # Проверяем, что метод вызван с правильными параметрами
blacklist_repository._execute_query_with_result.assert_called_once() blacklist_repository._execute_query_with_result.assert_called_once()
call_args = blacklist_repository._execute_query_with_result.call_args call_args = blacklist_repository._execute_query_with_result.call_args
assert call_args[0][0] == "SELECT 1 FROM blacklist WHERE user_id = ?" assert call_args[0][0] == "SELECT 1 FROM blacklist WHERE user_id = ?"
assert call_args[0][1] == (12345,) assert call_args[0][1] == (12345,)
# Проверяем логирование # Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with( blacklist_repository.logger.info.assert_called_once_with(
"Существует ли пользователь: user_id=12345 Итог: [(1,)]" "Существует ли пользователь: user_id=12345 Итог: [(1,)]"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_user_exists_false(self, blacklist_repository): async def test_user_exists_false(self, blacklist_repository):
"""Тест проверки существования пользователя (пользователь не существует)""" """Тест проверки существования пользователя (пользователь не существует)"""
# Симулируем результат запроса - пользователь не найден # Симулируем результат запроса - пользователь не найден
blacklist_repository._execute_query_with_result.return_value = [] blacklist_repository._execute_query_with_result.return_value = []
result = await blacklist_repository.user_exists(12345) result = await blacklist_repository.user_exists(12345)
# Проверяем, что возвращается False # Проверяем, что возвращается False
assert result is False assert result is False
# Проверяем логирование # Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with( blacklist_repository.logger.info.assert_called_once_with(
"Существует ли пользователь: user_id=12345 Итог: []" "Существует ли пользователь: user_id=12345 Итог: []"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_user_success(self, blacklist_repository): async def test_get_user_success(self, blacklist_repository):
"""Тест успешного получения пользователя по ID""" """Тест успешного получения пользователя по ID"""
# Симулируем результат запроса # Симулируем результат запроса
mock_row = (12345, "Нарушение правил", int(time.time()) + 86400, int(time.time()), 111) mock_row = (
12345,
"Нарушение правил",
int(time.time()) + 86400,
int(time.time()),
111,
)
blacklist_repository._execute_query_with_result.return_value = [mock_row] blacklist_repository._execute_query_with_result.return_value = [mock_row]
result = await blacklist_repository.get_user(12345) result = await blacklist_repository.get_user(12345)
# Проверяем, что возвращается правильный объект # Проверяем, что возвращается правильный объект
assert result is not None assert result is not None
assert result.user_id == 12345 assert result.user_id == 12345
@@ -196,37 +226,40 @@ class TestBlacklistRepository:
assert result.date_to_unban == mock_row[2] assert result.date_to_unban == mock_row[2]
assert result.created_at == mock_row[3] assert result.created_at == mock_row[3]
assert result.ban_author == mock_row[4] assert result.ban_author == mock_row[4]
# Проверяем, что метод вызван с правильными параметрами # Проверяем, что метод вызван с правильными параметрами
blacklist_repository._execute_query_with_result.assert_called_once() blacklist_repository._execute_query_with_result.assert_called_once()
call_args = blacklist_repository._execute_query_with_result.call_args call_args = blacklist_repository._execute_query_with_result.call_args
assert "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author" in call_args[0][0] assert (
"SELECT user_id, message_for_user, date_to_unban, created_at, ban_author"
in call_args[0][0]
)
assert call_args[0][1] == (12345,) assert call_args[0][1] == (12345,)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_user_not_found(self, blacklist_repository): async def test_get_user_not_found(self, blacklist_repository):
"""Тест получения пользователя по ID (пользователь не найден)""" """Тест получения пользователя по ID (пользователь не найден)"""
# Симулируем результат запроса - пользователь не найден # Симулируем результат запроса - пользователь не найден
blacklist_repository._execute_query_with_result.return_value = [] blacklist_repository._execute_query_with_result.return_value = []
result = await blacklist_repository.get_user(12345) result = await blacklist_repository.get_user(12345)
# Проверяем, что возвращается None # Проверяем, что возвращается None
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_all_users_with_limits(self, blacklist_repository): async def test_get_all_users_with_limits(self, blacklist_repository):
"""Тест получения пользователей с лимитами""" """Тест получения пользователей с лимитами"""
# Симулируем результат запроса # Симулируем результат запроса
mock_rows = [ mock_rows = [
(12345, "Нарушение правил", int(time.time()) + 86400, int(time.time())), (12345, "Нарушение правил", int(time.time()) + 86400, int(time.time())),
(67890, "Постоянный бан", None, int(time.time()) - 86400) (67890, "Постоянный бан", None, int(time.time()) - 86400),
] ]
blacklist_repository._execute_query_with_result.return_value = mock_rows blacklist_repository._execute_query_with_result.return_value = mock_rows
result = await blacklist_repository.get_all_users(offset=0, limit=10) result = await blacklist_repository.get_all_users(offset=0, limit=10)
# Проверяем, что возвращается правильный список # Проверяем, что возвращается правильный список
assert len(result) == 2 assert len(result) == 2
assert result[0].user_id == 12345 assert result[0].user_id == 12345
@@ -234,188 +267,211 @@ class TestBlacklistRepository:
assert result[1].user_id == 67890 assert result[1].user_id == 67890
assert result[1].message_for_user == "Постоянный бан" assert result[1].message_for_user == "Постоянный бан"
assert result[1].date_to_unban is None assert result[1].date_to_unban is None
# Проверяем, что метод вызван с правильными параметрами # Проверяем, что метод вызван с правильными параметрами
blacklist_repository._execute_query_with_result.assert_called_once() blacklist_repository._execute_query_with_result.assert_called_once()
call_args = blacklist_repository._execute_query_with_result.call_args call_args = blacklist_repository._execute_query_with_result.call_args
# Нормализуем SQL запрос (убираем лишние пробелы и переносы строк) # Нормализуем SQL запрос (убираем лишние пробелы и переносы строк)
actual_query = ' '.join(call_args[0][0].split()) actual_query = " ".join(call_args[0][0].split())
expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist LIMIT ?, ?" expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist LIMIT ?, ?"
assert actual_query == expected_query assert actual_query == expected_query
assert call_args[0][1] == (0, 10) assert call_args[0][1] == (0, 10)
# Проверяем логирование # Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with( blacklist_repository.logger.info.assert_called_once_with(
"Получен список пользователей в черном списке (offset=0, limit=10): 2" "Получен список пользователей в черном списке (offset=0, limit=10): 2"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_all_users_no_limit(self, blacklist_repository): async def test_get_all_users_no_limit(self, blacklist_repository):
"""Тест получения всех пользователей без лимитов""" """Тест получения всех пользователей без лимитов"""
# Симулируем результат запроса (теперь включает ban_author) # Симулируем результат запроса (теперь включает ban_author)
mock_rows = [ mock_rows = [
(12345, "Нарушение правил", int(time.time()) + 86400, int(time.time()), 999), (
(67890, "Постоянный бан", None, int(time.time()) - 86400, None) 12345,
"Нарушение правил",
int(time.time()) + 86400,
int(time.time()),
999,
),
(67890, "Постоянный бан", None, int(time.time()) - 86400, None),
] ]
blacklist_repository._execute_query_with_result.return_value = mock_rows blacklist_repository._execute_query_with_result.return_value = mock_rows
result = await blacklist_repository.get_all_users_no_limit() result = await blacklist_repository.get_all_users_no_limit()
# Проверяем, что возвращается правильный список # Проверяем, что возвращается правильный список
assert len(result) == 2 assert len(result) == 2
# Проверяем, что метод вызван без лимитов # Проверяем, что метод вызван без лимитов
blacklist_repository._execute_query_with_result.assert_called_once() blacklist_repository._execute_query_with_result.assert_called_once()
call_args = blacklist_repository._execute_query_with_result.call_args call_args = blacklist_repository._execute_query_with_result.call_args
# Нормализуем SQL запрос (убираем лишние пробелы и переносы строк) # Нормализуем SQL запрос (убираем лишние пробелы и переносы строк)
actual_query = ' '.join(call_args[0][0].split()) actual_query = " ".join(call_args[0][0].split())
expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist" expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist"
assert actual_query == expected_query assert actual_query == expected_query
# Проверяем, что параметры пустые (без лимитов) # Проверяем, что параметры пустые (без лимитов)
assert len(call_args[0]) == 1 # Только SQL запрос, без параметров assert len(call_args[0]) == 1 # Только SQL запрос, без параметров
# Проверяем логирование # Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with( blacklist_repository.logger.info.assert_called_once_with(
"Получен список всех пользователей в черном списке: 2" "Получен список всех пользователей в черном списке: 2"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_users_for_unblock_today(self, blacklist_repository): async def test_get_users_for_unblock_today(self, blacklist_repository):
"""Тест получения пользователей для разблокировки сегодня""" """Тест получения пользователей для разблокировки сегодня"""
current_timestamp = int(time.time()) current_timestamp = int(time.time())
# Симулируем результат запроса - пользователи с истекшим сроком # Симулируем результат запроса - пользователи с истекшим сроком
mock_rows = [(12345,), (67890,)] mock_rows = [(12345,), (67890,)]
blacklist_repository._execute_query_with_result.return_value = mock_rows blacklist_repository._execute_query_with_result.return_value = mock_rows
result = await blacklist_repository.get_users_for_unblock_today(current_timestamp) result = await blacklist_repository.get_users_for_unblock_today(
current_timestamp
)
# Проверяем, что возвращается правильный словарь # Проверяем, что возвращается правильный словарь
assert len(result) == 2 assert len(result) == 2
assert 12345 in result assert 12345 in result
assert 67890 in result assert 67890 in result
assert result[12345] == 12345 assert result[12345] == 12345
assert result[67890] == 67890 assert result[67890] == 67890
# Проверяем, что метод вызван с правильными параметрами # Проверяем, что метод вызван с правильными параметрами
blacklist_repository._execute_query_with_result.assert_called_once() blacklist_repository._execute_query_with_result.assert_called_once()
call_args = blacklist_repository._execute_query_with_result.call_args call_args = blacklist_repository._execute_query_with_result.call_args
assert call_args[0][0] == "SELECT user_id FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?" assert (
call_args[0][0]
== "SELECT user_id FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?"
)
assert call_args[0][1] == (current_timestamp,) assert call_args[0][1] == (current_timestamp,)
# Проверяем логирование # Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with( blacklist_repository.logger.info.assert_called_once_with(
"Получен список пользователей для разблокировки: {12345: 12345, 67890: 67890}" "Получен список пользователей для разблокировки: {12345: 12345, 67890: 67890}"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_users_for_unblock_today_empty(self, blacklist_repository): async def test_get_users_for_unblock_today_empty(self, blacklist_repository):
"""Тест получения пользователей для разблокировки (пустой результат)""" """Тест получения пользователей для разблокировки (пустой результат)"""
current_timestamp = int(time.time()) current_timestamp = int(time.time())
# Симулируем пустой результат запроса # Симулируем пустой результат запроса
blacklist_repository._execute_query_with_result.return_value = [] blacklist_repository._execute_query_with_result.return_value = []
result = await blacklist_repository.get_users_for_unblock_today(current_timestamp) result = await blacklist_repository.get_users_for_unblock_today(
current_timestamp
)
# Проверяем, что возвращается пустой словарь # Проверяем, что возвращается пустой словарь
assert result == {} assert result == {}
# Проверяем логирование # Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with( blacklist_repository.logger.info.assert_called_once_with(
"Получен список пользователей для разблокировки: {}" "Получен список пользователей для разблокировки: {}"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_count(self, blacklist_repository): async def test_get_count(self, blacklist_repository):
"""Тест получения количества пользователей в черном списке""" """Тест получения количества пользователей в черном списке"""
# Симулируем результат запроса # Симулируем результат запроса
blacklist_repository._execute_query_with_result.return_value = [(5,)] blacklist_repository._execute_query_with_result.return_value = [(5,)]
result = await blacklist_repository.get_count() result = await blacklist_repository.get_count()
# Проверяем, что возвращается правильное количество # Проверяем, что возвращается правильное количество
assert result == 5 assert result == 5
# Проверяем, что метод вызван с правильными параметрами # Проверяем, что метод вызван с правильными параметрами
blacklist_repository._execute_query_with_result.assert_called_once() blacklist_repository._execute_query_with_result.assert_called_once()
call_args = blacklist_repository._execute_query_with_result.call_args call_args = blacklist_repository._execute_query_with_result.call_args
assert call_args[0][0] == "SELECT COUNT(*) FROM blacklist" assert call_args[0][0] == "SELECT COUNT(*) FROM blacklist"
# Проверяем, что параметры пустые # Проверяем, что параметры пустые
assert len(call_args[0]) == 1 # Только SQL запрос, без параметров assert len(call_args[0]) == 1 # Только SQL запрос, без параметров
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_count_zero(self, blacklist_repository): async def test_get_count_zero(self, blacklist_repository):
"""Тест получения количества пользователей (0 пользователей)""" """Тест получения количества пользователей (0 пользователей)"""
# Симулируем пустой результат запроса # Симулируем пустой результат запроса
blacklist_repository._execute_query_with_result.return_value = [] blacklist_repository._execute_query_with_result.return_value = []
result = await blacklist_repository.get_count() result = await blacklist_repository.get_count()
# Проверяем, что возвращается 0 # Проверяем, что возвращается 0
assert result == 0 assert result == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_count_none_result(self, blacklist_repository): async def test_get_count_none_result(self, blacklist_repository):
"""Тест получения количества пользователей (None результат)""" """Тест получения количества пользователей (None результат)"""
# Симулируем None результат запроса # Симулируем None результат запроса
blacklist_repository._execute_query_with_result.return_value = None blacklist_repository._execute_query_with_result.return_value = None
result = await blacklist_repository.get_count() result = await blacklist_repository.get_count()
# Проверяем, что возвращается 0 # Проверяем, что возвращается 0
assert result == 0 assert result == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_error_handling_in_get_user(self, blacklist_repository): async def test_error_handling_in_get_user(self, blacklist_repository):
"""Тест обработки ошибок при получении пользователя""" """Тест обработки ошибок при получении пользователя"""
# Симулируем ошибку базы данных # Симулируем ошибку базы данных
blacklist_repository._execute_query_with_result.side_effect = Exception("Database connection failed") blacklist_repository._execute_query_with_result.side_effect = Exception(
"Database connection failed"
)
# Проверяем, что исключение пробрасывается # Проверяем, что исключение пробрасывается
with pytest.raises(Exception) as exc_info: with pytest.raises(Exception) as exc_info:
await blacklist_repository.get_user(12345) await blacklist_repository.get_user(12345)
assert "Database connection failed" in str(exc_info.value) assert "Database connection failed" in str(exc_info.value)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_error_handling_in_get_all_users(self, blacklist_repository): async def test_error_handling_in_get_all_users(self, blacklist_repository):
"""Тест обработки ошибок при получении всех пользователей""" """Тест обработки ошибок при получении всех пользователей"""
# Симулируем ошибку базы данных # Симулируем ошибку базы данных
blacklist_repository._execute_query_with_result.side_effect = Exception("Database connection failed") blacklist_repository._execute_query_with_result.side_effect = Exception(
"Database connection failed"
)
# Проверяем, что исключение пробрасывается # Проверяем, что исключение пробрасывается
with pytest.raises(Exception) as exc_info: with pytest.raises(Exception) as exc_info:
await blacklist_repository.get_all_users() await blacklist_repository.get_all_users()
assert "Database connection failed" in str(exc_info.value) assert "Database connection failed" in str(exc_info.value)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_error_handling_in_get_count(self, blacklist_repository): async def test_error_handling_in_get_count(self, blacklist_repository):
"""Тест обработки ошибок при получении количества""" """Тест обработки ошибок при получении количества"""
# Симулируем ошибку базы данных # Симулируем ошибку базы данных
blacklist_repository._execute_query_with_result.side_effect = Exception("Database connection failed") blacklist_repository._execute_query_with_result.side_effect = Exception(
"Database connection failed"
)
# Проверяем, что исключение пробрасывается # Проверяем, что исключение пробрасывается
with pytest.raises(Exception) as exc_info: with pytest.raises(Exception) as exc_info:
await blacklist_repository.get_count() await blacklist_repository.get_count()
assert "Database connection failed" in str(exc_info.value) assert "Database connection failed" in str(exc_info.value)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_error_handling_in_get_users_for_unblock_today(self, blacklist_repository): async def test_error_handling_in_get_users_for_unblock_today(
self, blacklist_repository
):
"""Тест обработки ошибок при получении пользователей для разблокировки""" """Тест обработки ошибок при получении пользователей для разблокировки"""
# Симулируем ошибку базы данных # Симулируем ошибку базы данных
blacklist_repository._execute_query_with_result.side_effect = Exception("Database connection failed") blacklist_repository._execute_query_with_result.side_effect = Exception(
"Database connection failed"
)
# Проверяем, что исключение пробрасывается # Проверяем, что исключение пробрасывается
with pytest.raises(Exception) as exc_info: with pytest.raises(Exception) as exc_info:
await blacklist_repository.get_users_for_unblock_today(int(time.time())) await blacklist_repository.get_users_for_unblock_today(int(time.time()))
assert "Database connection failed" in str(exc_info.value) assert "Database connection failed" in str(exc_info.value)
# TODO: 20-й тест - test_integration_workflow # TODO: 20-й тест - test_integration_workflow
@@ -426,7 +482,7 @@ class TestBlacklistRepository:
# 4. Получение общего количества пользователей # 4. Получение общего количества пользователей
# 5. Удаление пользователя из черного списка # 5. Удаление пользователя из черного списка
# 6. Проверка, что пользователь больше не существует # 6. Проверка, что пользователь больше не существует
# #
# Проблема: тест падает из-за сложности мокирования возвращаемых значений # Проблема: тест падает из-за сложности мокирования возвращаемых значений
# при создании объектов BlacklistUser из результатов запросов к БД. # при создании объектов BlacklistUser из результатов запросов к БД.
# Требует более сложной настройки моков для корректной работы. # Требует более сложной настройки моков для корректной работы.

View File

@@ -3,8 +3,11 @@ from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest import pytest
from helper_bot.handlers.callback.callback_handlers import ( from helper_bot.handlers.callback.callback_handlers import (
delete_voice_message, save_voice_message) delete_voice_message,
save_voice_message,
)
from helper_bot.handlers.voice.constants import CALLBACK_DELETE, CALLBACK_SAVE from helper_bot.handlers.voice.constants import CALLBACK_DELETE, CALLBACK_SAVE
@@ -21,6 +24,7 @@ def mock_call():
call.answer = AsyncMock() call.answer = AsyncMock()
return call return call
@pytest.fixture @pytest.fixture
def mock_bot_db(): def mock_bot_db():
"""Мок для базы данных""" """Мок для базы данных"""
@@ -29,20 +33,20 @@ def mock_bot_db():
mock_db.delete_audio_moderate_record = AsyncMock() mock_db.delete_audio_moderate_record = AsyncMock()
return mock_db return mock_db
@pytest.fixture @pytest.fixture
def mock_settings(): def mock_settings():
"""Мок для настроек""" """Мок для настроек"""
return { return {"Telegram": {"group_for_posts": "test_group_id"}}
'Telegram': {
'group_for_posts': 'test_group_id'
}
}
@pytest.fixture @pytest.fixture
def mock_audio_service(): def mock_audio_service():
"""Мок для AudioFileService""" """Мок для AudioFileService"""
mock_service = Mock() mock_service = Mock()
mock_service.generate_file_name = AsyncMock(return_value="message_from_67890_number_1") mock_service.generate_file_name = AsyncMock(
return_value="message_from_67890_number_1"
)
mock_service.save_audio_file = AsyncMock() mock_service.save_audio_file = AsyncMock()
mock_service.download_and_save_audio = AsyncMock() mock_service.download_and_save_audio = AsyncMock()
return mock_service return mock_service
@@ -50,143 +54,205 @@ def mock_audio_service():
class TestSaveVoiceMessage: class TestSaveVoiceMessage:
"""Тесты для функции save_voice_message""" """Тесты для функции save_voice_message"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_voice_message_success(self, mock_call, mock_bot_db, mock_settings, mock_audio_service): async def test_save_voice_message_success(
self, mock_call, mock_bot_db, mock_settings, mock_audio_service
):
"""Тест успешного сохранения голосового сообщения""" """Тест успешного сохранения голосового сообщения"""
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class: with patch(
"helper_bot.handlers.callback.callback_handlers.AudioFileService"
) as mock_service_class:
mock_service_class.return_value = mock_audio_service mock_service_class.return_value = mock_audio_service
await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) await save_voice_message(
mock_call, bot_db=mock_bot_db, settings=mock_settings
)
# Проверяем, что все методы вызваны # Проверяем, что все методы вызваны
mock_bot_db.get_user_id_by_message_id_for_voice_bot.assert_called_once_with(12345) mock_bot_db.get_user_id_by_message_id_for_voice_bot.assert_called_once_with(
12345
)
mock_audio_service.generate_file_name.assert_called_once_with(67890) mock_audio_service.generate_file_name.assert_called_once_with(67890)
mock_audio_service.save_audio_file.assert_called_once() mock_audio_service.save_audio_file.assert_called_once()
mock_audio_service.download_and_save_audio.assert_called_once_with( mock_audio_service.download_and_save_audio.assert_called_once_with(
mock_call.bot, mock_call.message, "message_from_67890_number_1" mock_call.bot, mock_call.message, "message_from_67890_number_1"
) )
# Проверяем удаление сообщения из чата # Проверяем удаление сообщения из чата
mock_call.bot.delete_message.assert_called_once_with( mock_call.bot.delete_message.assert_called_once_with(
chat_id='test_group_id', chat_id="test_group_id", message_id=12345
message_id=12345
) )
# Проверяем удаление записи из audio_moderate # Проверяем удаление записи из audio_moderate
mock_bot_db.delete_audio_moderate_record.assert_called_once_with(12345) mock_bot_db.delete_audio_moderate_record.assert_called_once_with(12345)
# Проверяем ответ пользователю # Проверяем ответ пользователю
mock_call.answer.assert_called_once_with(text='Сохранено!', cache_time=3) mock_call.answer.assert_called_once_with(text="Сохранено!", cache_time=3)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_voice_message_with_correct_parameters(self, mock_call, mock_bot_db, mock_settings, mock_audio_service): async def test_save_voice_message_with_correct_parameters(
self, mock_call, mock_bot_db, mock_settings, mock_audio_service
):
"""Тест сохранения с правильными параметрами""" """Тест сохранения с правильными параметрами"""
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class: with patch(
"helper_bot.handlers.callback.callback_handlers.AudioFileService"
) as mock_service_class:
mock_service_class.return_value = mock_audio_service mock_service_class.return_value = mock_audio_service
await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) await save_voice_message(
mock_call, bot_db=mock_bot_db, settings=mock_settings
)
# Проверяем параметры save_audio_file # Проверяем параметры save_audio_file
save_call_args = mock_audio_service.save_audio_file.call_args save_call_args = mock_audio_service.save_audio_file.call_args
assert save_call_args[0][0] == "message_from_67890_number_1" # file_name assert save_call_args[0][0] == "message_from_67890_number_1" # file_name
assert save_call_args[0][1] == 67890 # user_id assert save_call_args[0][1] == 67890 # user_id
assert isinstance(save_call_args[0][2], datetime) # date_added assert isinstance(save_call_args[0][2], datetime) # date_added
assert save_call_args[0][3] == "test_file_id_123" # file_id assert save_call_args[0][3] == "test_file_id_123" # file_id
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_voice_message_exception_handling(self, mock_call, mock_bot_db, mock_settings): async def test_save_voice_message_exception_handling(
self, mock_call, mock_bot_db, mock_settings
):
"""Тест обработки исключений при сохранении""" """Тест обработки исключений при сохранении"""
mock_bot_db.get_user_id_by_message_id_for_voice_bot.side_effect = Exception("Database error") mock_bot_db.get_user_id_by_message_id_for_voice_bot.side_effect = Exception(
"Database error"
)
await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings)
# Проверяем, что при ошибке отправляется соответствующий ответ # Проверяем, что при ошибке отправляется соответствующий ответ
mock_call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3) mock_call.answer.assert_called_once_with(
text="Ошибка при сохранении!", cache_time=3
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_voice_message_audio_service_exception(self, mock_call, mock_bot_db, mock_settings, mock_audio_service): async def test_save_voice_message_audio_service_exception(
self, mock_call, mock_bot_db, mock_settings, mock_audio_service
):
"""Тест обработки исключений в AudioFileService""" """Тест обработки исключений в AudioFileService"""
mock_audio_service.save_audio_file.side_effect = Exception("Save error") mock_audio_service.save_audio_file.side_effect = Exception("Save error")
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class: with patch(
"helper_bot.handlers.callback.callback_handlers.AudioFileService"
) as mock_service_class:
mock_service_class.return_value = mock_audio_service mock_service_class.return_value = mock_audio_service
await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) await save_voice_message(
mock_call, bot_db=mock_bot_db, settings=mock_settings
)
# Проверяем, что при ошибке отправляется соответствующий ответ # Проверяем, что при ошибке отправляется соответствующий ответ
mock_call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3) mock_call.answer.assert_called_once_with(
text="Ошибка при сохранении!", cache_time=3
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_voice_message_download_exception(self, mock_call, mock_bot_db, mock_settings, mock_audio_service): async def test_save_voice_message_download_exception(
self, mock_call, mock_bot_db, mock_settings, mock_audio_service
):
"""Тест обработки исключений при скачивании файла""" """Тест обработки исключений при скачивании файла"""
mock_audio_service.download_and_save_audio.side_effect = Exception("Download error") mock_audio_service.download_and_save_audio.side_effect = Exception(
"Download error"
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class: )
with patch(
"helper_bot.handlers.callback.callback_handlers.AudioFileService"
) as mock_service_class:
mock_service_class.return_value = mock_audio_service mock_service_class.return_value = mock_audio_service
await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) await save_voice_message(
mock_call, bot_db=mock_bot_db, settings=mock_settings
)
# Проверяем, что при ошибке отправляется соответствующий ответ # Проверяем, что при ошибке отправляется соответствующий ответ
mock_call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3) mock_call.answer.assert_called_once_with(
text="Ошибка при сохранении!", cache_time=3
)
class TestDeleteVoiceMessage: class TestDeleteVoiceMessage:
"""Тесты для функции delete_voice_message""" """Тесты для функции delete_voice_message"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_voice_message_success(self, mock_call, mock_bot_db, mock_settings): async def test_delete_voice_message_success(
self, mock_call, mock_bot_db, mock_settings
):
"""Тест успешного удаления голосового сообщения""" """Тест успешного удаления голосового сообщения"""
await delete_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) await delete_voice_message(
mock_call, bot_db=mock_bot_db, settings=mock_settings
)
# Проверяем удаление сообщения из чата # Проверяем удаление сообщения из чата
mock_call.bot.delete_message.assert_called_once_with( mock_call.bot.delete_message.assert_called_once_with(
chat_id='test_group_id', chat_id="test_group_id", message_id=12345
message_id=12345
) )
# Проверяем удаление записи из audio_moderate # Проверяем удаление записи из audio_moderate
mock_bot_db.delete_audio_moderate_record.assert_called_once_with(12345) mock_bot_db.delete_audio_moderate_record.assert_called_once_with(12345)
# Проверяем ответ пользователю # Проверяем ответ пользователю
mock_call.answer.assert_called_once_with(text='Удалено!', cache_time=3) mock_call.answer.assert_called_once_with(text="Удалено!", cache_time=3)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_voice_message_exception_handling(self, mock_call, mock_bot_db, mock_settings): async def test_delete_voice_message_exception_handling(
self, mock_call, mock_bot_db, mock_settings
):
"""Тест обработки исключений при удалении""" """Тест обработки исключений при удалении"""
mock_call.bot.delete_message.side_effect = Exception("Delete error") mock_call.bot.delete_message.side_effect = Exception("Delete error")
await delete_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) await delete_voice_message(
mock_call, bot_db=mock_bot_db, settings=mock_settings
)
# Проверяем, что при ошибке отправляется соответствующий ответ # Проверяем, что при ошибке отправляется соответствующий ответ
mock_call.answer.assert_called_once_with(text='Ошибка при удалении!', cache_time=3) mock_call.answer.assert_called_once_with(
text="Ошибка при удалении!", cache_time=3
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_voice_message_database_exception(self, mock_call, mock_bot_db, mock_settings): async def test_delete_voice_message_database_exception(
self, mock_call, mock_bot_db, mock_settings
):
"""Тест обработки исключений в базе данных при удалении""" """Тест обработки исключений в базе данных при удалении"""
mock_bot_db.delete_audio_moderate_record.side_effect = Exception("Database error") mock_bot_db.delete_audio_moderate_record.side_effect = Exception(
"Database error"
await delete_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) )
await delete_voice_message(
mock_call, bot_db=mock_bot_db, settings=mock_settings
)
# Проверяем, что при ошибке отправляется соответствующий ответ # Проверяем, что при ошибке отправляется соответствующий ответ
mock_call.answer.assert_called_once_with(text='Ошибка при удалении!', cache_time=3) mock_call.answer.assert_called_once_with(
text="Ошибка при удалении!", cache_time=3
)
class TestCallbackHandlersIntegration: class TestCallbackHandlersIntegration:
"""Интеграционные тесты для callback handlers""" """Интеграционные тесты для callback handlers"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_voice_message_full_workflow(self, mock_call, mock_bot_db, mock_settings): async def test_save_voice_message_full_workflow(
self, mock_call, mock_bot_db, mock_settings
):
"""Тест полного рабочего процесса сохранения""" """Тест полного рабочего процесса сохранения"""
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class: with patch(
"helper_bot.handlers.callback.callback_handlers.AudioFileService"
) as mock_service_class:
mock_service = Mock() mock_service = Mock()
mock_service.generate_file_name = AsyncMock(return_value="message_from_67890_number_1") mock_service.generate_file_name = AsyncMock(
return_value="message_from_67890_number_1"
)
mock_service.save_audio_file = AsyncMock() mock_service.save_audio_file = AsyncMock()
mock_service.download_and_save_audio = AsyncMock() mock_service.download_and_save_audio = AsyncMock()
mock_service_class.return_value = mock_service mock_service_class.return_value = mock_service
await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) await save_voice_message(
mock_call, bot_db=mock_bot_db, settings=mock_settings
)
# Проверяем последовательность вызовов # Проверяем последовательность вызовов
assert mock_bot_db.get_user_id_by_message_id_for_voice_bot.called assert mock_bot_db.get_user_id_by_message_id_for_voice_bot.called
assert mock_service.generate_file_name.called assert mock_service.generate_file_name.called
@@ -195,46 +261,62 @@ class TestCallbackHandlersIntegration:
assert mock_call.bot.delete_message.called assert mock_call.bot.delete_message.called
assert mock_bot_db.delete_audio_moderate_record.called assert mock_bot_db.delete_audio_moderate_record.called
assert mock_call.answer.called assert mock_call.answer.called
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_voice_message_full_workflow(self, mock_call, mock_bot_db, mock_settings): async def test_delete_voice_message_full_workflow(
self, mock_call, mock_bot_db, mock_settings
):
"""Тест полного рабочего процесса удаления""" """Тест полного рабочего процесса удаления"""
await delete_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) await delete_voice_message(
mock_call, bot_db=mock_bot_db, settings=mock_settings
)
# Проверяем последовательность вызовов # Проверяем последовательность вызовов
assert mock_call.bot.delete_message.called assert mock_call.bot.delete_message.called
assert mock_bot_db.delete_audio_moderate_record.called assert mock_bot_db.delete_audio_moderate_record.called
assert mock_call.answer.called assert mock_call.answer.called
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_audio_moderate_cleanup_consistency(self, mock_call, mock_bot_db, mock_settings): async def test_audio_moderate_cleanup_consistency(
self, mock_call, mock_bot_db, mock_settings
):
"""Тест консистентности очистки audio_moderate""" """Тест консистентности очистки audio_moderate"""
# Тестируем, что в обоих случаях (сохранение и удаление) # Тестируем, что в обоих случаях (сохранение и удаление)
# вызывается delete_audio_moderate_record # вызывается delete_audio_moderate_record
# Создаем отдельные моки для каждого теста # Создаем отдельные моки для каждого теста
mock_bot_db_save = Mock() mock_bot_db_save = Mock()
mock_bot_db_save.get_user_id_by_message_id_for_voice_bot = AsyncMock(return_value=67890) mock_bot_db_save.get_user_id_by_message_id_for_voice_bot = AsyncMock(
return_value=67890
)
mock_bot_db_save.delete_audio_moderate_record = AsyncMock() mock_bot_db_save.delete_audio_moderate_record = AsyncMock()
mock_bot_db_delete = Mock() mock_bot_db_delete = Mock()
mock_bot_db_delete.delete_audio_moderate_record = AsyncMock() mock_bot_db_delete.delete_audio_moderate_record = AsyncMock()
# Тест для сохранения # Тест для сохранения
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class: with patch(
"helper_bot.handlers.callback.callback_handlers.AudioFileService"
) as mock_service_class:
mock_service = Mock() mock_service = Mock()
mock_service.generate_file_name = AsyncMock(return_value="message_from_67890_number_1") mock_service.generate_file_name = AsyncMock(
return_value="message_from_67890_number_1"
)
mock_service.save_audio_file = AsyncMock() mock_service.save_audio_file = AsyncMock()
mock_service.download_and_save_audio = AsyncMock() mock_service.download_and_save_audio = AsyncMock()
mock_service_class.return_value = mock_service mock_service_class.return_value = mock_service
await save_voice_message(mock_call, bot_db=mock_bot_db_save, settings=mock_settings) await save_voice_message(
mock_call, bot_db=mock_bot_db_save, settings=mock_settings
)
save_calls = mock_bot_db_save.delete_audio_moderate_record.call_count save_calls = mock_bot_db_save.delete_audio_moderate_record.call_count
# Тест для удаления # Тест для удаления
await delete_voice_message(mock_call, bot_db=mock_bot_db_delete, settings=mock_settings) await delete_voice_message(
mock_call, bot_db=mock_bot_db_delete, settings=mock_settings
)
delete_calls = mock_bot_db_delete.delete_audio_moderate_record.call_count delete_calls = mock_bot_db_delete.delete_audio_moderate_record.call_count
# Проверяем, что в обоих случаях вызывается очистка # Проверяем, что в обоих случаях вызывается очистка
assert save_calls == 1 assert save_calls == 1
assert delete_calls == 1 assert delete_calls == 1
@@ -242,9 +324,11 @@ class TestCallbackHandlersIntegration:
class TestCallbackHandlersEdgeCases: class TestCallbackHandlersEdgeCases:
"""Тесты граничных случаев для callback handlers""" """Тесты граничных случаев для callback handlers"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_voice_message_no_voice_attribute(self, mock_bot_db, mock_settings): async def test_save_voice_message_no_voice_attribute(
self, mock_bot_db, mock_settings
):
"""Тест сохранения когда у сообщения нет voice атрибута""" """Тест сохранения когда у сообщения нет voice атрибута"""
call = Mock() call = Mock()
call.message = Mock() call.message = Mock()
@@ -253,26 +337,36 @@ class TestCallbackHandlersEdgeCases:
call.bot = Mock() call.bot = Mock()
call.bot.delete_message = AsyncMock() call.bot.delete_message = AsyncMock()
call.answer = AsyncMock() call.answer = AsyncMock()
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService'): with patch("helper_bot.handlers.callback.callback_handlers.AudioFileService"):
await save_voice_message(call, bot_db=mock_bot_db, settings=mock_settings) await save_voice_message(call, bot_db=mock_bot_db, settings=mock_settings)
# Должна быть ошибка # Должна быть ошибка
call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3) call.answer.assert_called_once_with(
text="Ошибка при сохранении!", cache_time=3
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_voice_message_user_not_found(self, mock_call, mock_bot_db, mock_settings): async def test_save_voice_message_user_not_found(
self, mock_call, mock_bot_db, mock_settings
):
"""Тест сохранения когда пользователь не найден""" """Тест сохранения когда пользователь не найден"""
mock_bot_db.get_user_id_by_message_id_for_voice_bot.return_value = None mock_bot_db.get_user_id_by_message_id_for_voice_bot.return_value = None
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService'): with patch("helper_bot.handlers.callback.callback_handlers.AudioFileService"):
await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) await save_voice_message(
mock_call, bot_db=mock_bot_db, settings=mock_settings
)
# Должна быть ошибка # Должна быть ошибка
mock_call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3) mock_call.answer.assert_called_once_with(
text="Ошибка при сохранении!", cache_time=3
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_voice_message_with_different_message_id(self, mock_bot_db, mock_settings): async def test_delete_voice_message_with_different_message_id(
self, mock_bot_db, mock_settings
):
"""Тест удаления с другим message_id""" """Тест удаления с другим message_id"""
call = Mock() call = Mock()
call.message = Mock() call.message = Mock()
@@ -280,16 +374,15 @@ class TestCallbackHandlersEdgeCases:
call.bot = Mock() call.bot = Mock()
call.bot.delete_message = AsyncMock() call.bot.delete_message = AsyncMock()
call.answer = AsyncMock() call.answer = AsyncMock()
await delete_voice_message(call, bot_db=mock_bot_db, settings=mock_settings) await delete_voice_message(call, bot_db=mock_bot_db, settings=mock_settings)
# Проверяем, что используется правильный message_id # Проверяем, что используется правильный message_id
call.bot.delete_message.assert_called_once_with( call.bot.delete_message.assert_called_once_with(
chat_id='test_group_id', chat_id="test_group_id", message_id=99999
message_id=99999
) )
mock_bot_db.delete_audio_moderate_record.assert_called_once_with(99999) mock_bot_db.delete_audio_moderate_record.assert_called_once_with(99999)
if __name__ == '__main__': if __name__ == "__main__":
pytest.main([__file__]) pytest.main([__file__])

View File

@@ -8,67 +8,81 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest import pytest
from aiogram import types from aiogram import types
from helper_bot.utils.helper_func import ( from helper_bot.utils.helper_func import (
add_in_db_media, add_in_db_media_mediagroup, download_file, add_in_db_media,
send_media_group_message_to_private_chat) add_in_db_media_mediagroup,
download_file,
send_media_group_message_to_private_chat,
)
class TestDownloadFile: class TestDownloadFile:
"""Тесты для функции download_file""" """Тесты для функции download_file"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_file_success_photo(self): async def test_download_file_success_photo(self):
"""Тест успешного скачивания фото""" """Тест успешного скачивания фото"""
# Создаем временную директорию # Создаем временную директорию
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
with patch('helper_bot.utils.helper_func.os.makedirs'), \ with (
patch('helper_bot.utils.helper_func.os.path.exists', return_value=True), \ patch("helper_bot.utils.helper_func.os.makedirs"),
patch('helper_bot.utils.helper_func.os.path.getsize', return_value=1024), \ patch("helper_bot.utils.helper_func.os.path.exists", return_value=True),
patch('helper_bot.utils.helper_func.os.path.basename', return_value='photo.jpg'), \ patch(
patch('helper_bot.utils.helper_func.os.path.splitext', return_value=('photo', '.jpg')): "helper_bot.utils.helper_func.os.path.getsize", return_value=1024
),
patch(
"helper_bot.utils.helper_func.os.path.basename",
return_value="photo.jpg",
),
patch(
"helper_bot.utils.helper_func.os.path.splitext",
return_value=("photo", ".jpg"),
),
):
# Мокаем сообщение и бота # Мокаем сообщение и бота
mock_message = Mock() mock_message = Mock()
mock_message.bot = Mock() mock_message.bot = Mock()
mock_file = Mock() mock_file = Mock()
mock_file.file_path = 'photos/photo.jpg' mock_file.file_path = "photos/photo.jpg"
mock_message.bot.get_file = AsyncMock(return_value=mock_file) mock_message.bot.get_file = AsyncMock(return_value=mock_file)
mock_message.bot.download_file = AsyncMock() mock_message.bot.download_file = AsyncMock()
# Вызываем функцию # Вызываем функцию
result = await download_file(mock_message, 'test_file_id', 'photo') result = await download_file(mock_message, "test_file_id", "photo")
# Проверяем результат # Проверяем результат
assert result is not None assert result is not None
assert 'files/photos/test_file_id.jpg' in result assert "files/photos/test_file_id.jpg" in result
mock_message.bot.get_file.assert_called_once_with('test_file_id') mock_message.bot.get_file.assert_called_once_with("test_file_id")
mock_message.bot.download_file.assert_called_once() mock_message.bot.download_file.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_file_invalid_parameters(self): async def test_download_file_invalid_parameters(self):
"""Тест с неверными параметрами""" """Тест с неверными параметрами"""
result = await download_file(None, 'test_file_id', 'photo') result = await download_file(None, "test_file_id", "photo")
assert result is None assert result is None
mock_message = Mock() mock_message = Mock()
mock_message.bot = None mock_message.bot = None
result = await download_file(mock_message, 'test_file_id', 'photo') result = await download_file(mock_message, "test_file_id", "photo")
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_file_error(self): async def test_download_file_error(self):
"""Тест обработки ошибки при скачивании""" """Тест обработки ошибки при скачивании"""
mock_message = Mock() mock_message = Mock()
mock_message.bot = Mock() mock_message.bot = Mock()
mock_message.bot.get_file = AsyncMock(side_effect=Exception("Network error")) mock_message.bot.get_file = AsyncMock(side_effect=Exception("Network error"))
result = await download_file(mock_message, 'test_file_id', 'photo') result = await download_file(mock_message, "test_file_id", "photo")
assert result is None assert result is None
class TestAddInDbMedia: class TestAddInDbMedia:
"""Тесты для функции add_in_db_media""" """Тесты для функции add_in_db_media"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_in_db_media_success_photo(self): async def test_add_in_db_media_success_photo(self):
"""Тест успешного добавления фото в БД""" """Тест успешного добавления фото в БД"""
@@ -76,65 +90,75 @@ class TestAddInDbMedia:
mock_message = Mock() mock_message = Mock()
mock_message.message_id = 123 mock_message.message_id = 123
mock_message.photo = [Mock()] mock_message.photo = [Mock()]
mock_message.photo[-1].file_id = 'photo_123' mock_message.photo[-1].file_id = "photo_123"
mock_message.video = None mock_message.video = None
mock_message.voice = None mock_message.voice = None
mock_message.audio = None mock_message.audio = None
mock_message.video_note = None mock_message.video_note = None
# Мокаем БД # Мокаем БД
mock_db = AsyncMock() mock_db = AsyncMock()
mock_db.add_post_content = AsyncMock(return_value=True) mock_db.add_post_content = AsyncMock(return_value=True)
with patch('helper_bot.utils.helper_func.download_file', return_value='files/photos/photo_123.jpg'): with patch(
"helper_bot.utils.helper_func.download_file",
return_value="files/photos/photo_123.jpg",
):
result = await add_in_db_media(mock_message, mock_db) result = await add_in_db_media(mock_message, mock_db)
assert result is True assert result is True
mock_db.add_post_content.assert_called_once_with(123, 123, 'files/photos/photo_123.jpg', 'photo') mock_db.add_post_content.assert_called_once_with(
123, 123, "files/photos/photo_123.jpg", "photo"
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_in_db_media_download_fails(self): async def test_add_in_db_media_download_fails(self):
"""Тест когда скачивание файла не удается""" """Тест когда скачивание файла не удается"""
mock_message = Mock() mock_message = Mock()
mock_message.message_id = 123 mock_message.message_id = 123
mock_message.photo = [Mock()] mock_message.photo = [Mock()]
mock_message.photo[-1].file_id = 'photo_123' mock_message.photo[-1].file_id = "photo_123"
mock_message.video = None mock_message.video = None
mock_message.voice = None mock_message.voice = None
mock_message.audio = None mock_message.audio = None
mock_message.video_note = None mock_message.video_note = None
mock_db = AsyncMock() mock_db = AsyncMock()
with patch('helper_bot.utils.helper_func.download_file', return_value=None): with patch("helper_bot.utils.helper_func.download_file", return_value=None):
result = await add_in_db_media(mock_message, mock_db) result = await add_in_db_media(mock_message, mock_db)
assert result is False assert result is False
mock_db.add_post_content.assert_not_called() mock_db.add_post_content.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_in_db_media_db_fails(self): async def test_add_in_db_media_db_fails(self):
"""Тест когда добавление в БД не удается""" """Тест когда добавление в БД не удается"""
mock_message = Mock() mock_message = Mock()
mock_message.message_id = 123 mock_message.message_id = 123
mock_message.photo = [Mock()] mock_message.photo = [Mock()]
mock_message.photo[-1].file_id = 'photo_123' mock_message.photo[-1].file_id = "photo_123"
mock_message.video = None mock_message.video = None
mock_message.voice = None mock_message.voice = None
mock_message.audio = None mock_message.audio = None
mock_message.video_note = None mock_message.video_note = None
mock_db = AsyncMock() mock_db = AsyncMock()
mock_db.add_post_content = AsyncMock(return_value=False) mock_db.add_post_content = AsyncMock(return_value=False)
with patch('helper_bot.utils.helper_func.download_file', return_value='files/photos/photo_123.jpg'), \ with (
patch('helper_bot.utils.helper_func.os.remove'): patch(
"helper_bot.utils.helper_func.download_file",
return_value="files/photos/photo_123.jpg",
),
patch("helper_bot.utils.helper_func.os.remove"),
):
result = await add_in_db_media(mock_message, mock_db) result = await add_in_db_media(mock_message, mock_db)
assert result is False assert result is False
mock_db.add_post_content.assert_called_once() mock_db.add_post_content.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_in_db_media_unsupported_content(self): async def test_add_in_db_media_unsupported_content(self):
"""Тест с неподдерживаемым типом контента""" """Тест с неподдерживаемым типом контента"""
@@ -145,18 +169,18 @@ class TestAddInDbMedia:
mock_message.voice = None mock_message.voice = None
mock_message.audio = None mock_message.audio = None
mock_message.video_note = None mock_message.video_note = None
mock_db = AsyncMock() mock_db = AsyncMock()
result = await add_in_db_media(mock_message, mock_db) result = await add_in_db_media(mock_message, mock_db)
assert result is False assert result is False
mock_db.add_post_content.assert_not_called() mock_db.add_post_content.assert_not_called()
class TestAddInDbMediaMediagroup: class TestAddInDbMediaMediagroup:
"""Тесты для функции add_in_db_media_mediagroup""" """Тесты для функции add_in_db_media_mediagroup"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_in_db_media_mediagroup_success(self): async def test_add_in_db_media_mediagroup_success(self):
"""Тест успешного добавления медиагруппы в БД""" """Тест успешного добавления медиагруппы в БД"""
@@ -164,43 +188,47 @@ class TestAddInDbMediaMediagroup:
mock_message1 = Mock() mock_message1 = Mock()
mock_message1.message_id = 1 mock_message1.message_id = 1
mock_message1.photo = [Mock()] mock_message1.photo = [Mock()]
mock_message1.photo[-1].file_id = 'photo_1' mock_message1.photo[-1].file_id = "photo_1"
mock_message1.video = None mock_message1.video = None
mock_message1.voice = None mock_message1.voice = None
mock_message1.audio = None mock_message1.audio = None
mock_message1.video_note = None mock_message1.video_note = None
mock_message2 = Mock() mock_message2 = Mock()
mock_message2.message_id = 2 mock_message2.message_id = 2
mock_message2.photo = None mock_message2.photo = None
mock_message2.video = Mock() mock_message2.video = Mock()
mock_message2.video.file_id = 'video_1' mock_message2.video.file_id = "video_1"
mock_message2.voice = None mock_message2.voice = None
mock_message2.audio = None mock_message2.audio = None
mock_message2.video_note = None mock_message2.video_note = None
sent_messages = [mock_message1, mock_message2] sent_messages = [mock_message1, mock_message2]
# Мокаем БД # Мокаем БД
mock_db = AsyncMock() mock_db = AsyncMock()
mock_db.add_post_content = AsyncMock(return_value=True) mock_db.add_post_content = AsyncMock(return_value=True)
with patch('helper_bot.utils.helper_func.download_file', return_value='files/test.jpg'): with patch(
result = await add_in_db_media_mediagroup(sent_messages, mock_db, main_post_id=100) "helper_bot.utils.helper_func.download_file", return_value="files/test.jpg"
):
result = await add_in_db_media_mediagroup(
sent_messages, mock_db, main_post_id=100
)
assert result is True assert result is True
assert mock_db.add_post_content.call_count == 2 assert mock_db.add_post_content.call_count == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_in_db_media_mediagroup_empty_list(self): async def test_add_in_db_media_mediagroup_empty_list(self):
"""Тест с пустым списком сообщений""" """Тест с пустым списком сообщений"""
mock_db = AsyncMock() mock_db = AsyncMock()
result = await add_in_db_media_mediagroup([], mock_db) result = await add_in_db_media_mediagroup([], mock_db)
assert result is False assert result is False
mock_db.add_post_content.assert_not_called() mock_db.add_post_content.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_in_db_media_mediagroup_partial_failure(self): async def test_add_in_db_media_mediagroup_partial_failure(self):
"""Тест когда часть сообщений обрабатывается успешно""" """Тест когда часть сообщений обрабатывается успешно"""
@@ -208,12 +236,12 @@ class TestAddInDbMediaMediagroup:
mock_message1 = Mock() mock_message1 = Mock()
mock_message1.message_id = 1 mock_message1.message_id = 1
mock_message1.photo = [Mock()] mock_message1.photo = [Mock()]
mock_message1.photo[-1].file_id = 'photo_1' mock_message1.photo[-1].file_id = "photo_1"
mock_message1.video = None mock_message1.video = None
mock_message1.voice = None mock_message1.voice = None
mock_message1.audio = None mock_message1.audio = None
mock_message1.video_note = None mock_message1.video_note = None
mock_message2 = Mock() mock_message2 = Mock()
mock_message2.message_id = 2 mock_message2.message_id = 2
mock_message2.photo = None mock_message2.photo = None
@@ -221,16 +249,18 @@ class TestAddInDbMediaMediagroup:
mock_message2.voice = None mock_message2.voice = None
mock_message2.audio = None mock_message2.audio = None
mock_message2.video_note = None # Неподдерживаемый тип mock_message2.video_note = None # Неподдерживаемый тип
sent_messages = [mock_message1, mock_message2] sent_messages = [mock_message1, mock_message2]
# Мокаем БД # Мокаем БД
mock_db = AsyncMock() mock_db = AsyncMock()
mock_db.add_post_content = AsyncMock(return_value=True) mock_db.add_post_content = AsyncMock(return_value=True)
with patch('helper_bot.utils.helper_func.download_file', return_value='files/test.jpg'): with patch(
"helper_bot.utils.helper_func.download_file", return_value="files/test.jpg"
):
result = await add_in_db_media_mediagroup(sent_messages, mock_db) result = await add_in_db_media_mediagroup(sent_messages, mock_db)
# Должен вернуть False, так как есть ошибки (второе сообщение не поддерживается) # Должен вернуть False, так как есть ошибки (второе сообщение не поддерживается)
assert result is False assert result is False
assert mock_db.add_post_content.call_count == 1 assert mock_db.add_post_content.call_count == 1
@@ -238,7 +268,7 @@ class TestAddInDbMediaMediagroup:
class TestSendMediaGroupMessageToPrivateChat: class TestSendMediaGroupMessageToPrivateChat:
"""Тесты для функции send_media_group_message_to_private_chat""" """Тесты для функции send_media_group_message_to_private_chat"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_media_group_message_success(self): async def test_send_media_group_message_success(self):
"""Тест успешной отправки медиагруппы""" """Тест успешной отправки медиагруппы"""
@@ -246,25 +276,29 @@ class TestSendMediaGroupMessageToPrivateChat:
mock_message = Mock() mock_message = Mock()
mock_message.from_user.id = 123 mock_message.from_user.id = 123
mock_message.bot = Mock() mock_message.bot = Mock()
# Мокаем отправленное сообщение # Мокаем отправленное сообщение
mock_sent_message = Mock() mock_sent_message = Mock()
mock_sent_message.message_id = 456 mock_sent_message.message_id = 456
mock_sent_message.caption = "Test caption" mock_sent_message.caption = "Test caption"
mock_message.bot.send_media_group = AsyncMock(return_value=[mock_sent_message]) mock_message.bot.send_media_group = AsyncMock(return_value=[mock_sent_message])
# Мокаем БД # Мокаем БД
mock_db = AsyncMock() mock_db = AsyncMock()
with patch('helper_bot.utils.helper_func.add_in_db_media_mediagroup', return_value=True): with patch(
with patch('asyncio.create_task'): # Мокаем create_task, чтобы фоновая задача не выполнялась "helper_bot.utils.helper_func.add_in_db_media_mediagroup", return_value=True
):
with patch(
"asyncio.create_task"
): # Мокаем create_task, чтобы фоновая задача не выполнялась
result = await send_media_group_message_to_private_chat( result = await send_media_group_message_to_private_chat(
100, mock_message, [], mock_db, main_post_id=789 100, mock_message, [], mock_db, main_post_id=789
) )
assert result == [456] # Функция возвращает список message_id assert result == [456] # Функция возвращает список message_id
mock_message.bot.send_media_group.assert_called_once() mock_message.bot.send_media_group.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_media_group_message_media_processing_fails(self): async def test_send_media_group_message_media_processing_fails(self):
"""Тест когда обработка медиа не удается""" """Тест когда обработка медиа не удается"""
@@ -272,22 +306,27 @@ class TestSendMediaGroupMessageToPrivateChat:
mock_message = Mock() mock_message = Mock()
mock_message.from_user.id = 123 mock_message.from_user.id = 123
mock_message.bot = Mock() mock_message.bot = Mock()
# Мокаем отправленное сообщение # Мокаем отправленное сообщение
mock_sent_message = Mock() mock_sent_message = Mock()
mock_sent_message.message_id = 456 mock_sent_message.message_id = 456
mock_sent_message.caption = "Test caption" mock_sent_message.caption = "Test caption"
mock_message.bot.send_media_group = AsyncMock(return_value=[mock_sent_message]) mock_message.bot.send_media_group = AsyncMock(return_value=[mock_sent_message])
# Мокаем БД # Мокаем БД
mock_db = AsyncMock() mock_db = AsyncMock()
with patch('helper_bot.utils.helper_func.add_in_db_media_mediagroup', return_value=False): with patch(
with patch('asyncio.create_task'): # Мокаем create_task, чтобы фоновая задача не выполнялась "helper_bot.utils.helper_func.add_in_db_media_mediagroup",
return_value=False,
):
with patch(
"asyncio.create_task"
): # Мокаем create_task, чтобы фоновая задача не выполнялась
result = await send_media_group_message_to_private_chat( result = await send_media_group_message_to_private_chat(
100, mock_message, [], mock_db, main_post_id=789 100, mock_message, [], mock_db, main_post_id=789
) )
assert result == [456] # Функция возвращает список message_id assert result == [456] # Функция возвращает список message_id
mock_message.bot.send_media_group.assert_called_once() mock_message.bot.send_media_group.assert_called_once()

View File

@@ -1,475 +1,489 @@
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
from aiogram.types import (InlineKeyboardButton, InlineKeyboardMarkup, from aiogram.types import (
KeyboardButton, ReplyKeyboardMarkup) InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
ReplyKeyboardMarkup,
)
from database.async_db import AsyncBotDB from database.async_db import AsyncBotDB
from helper_bot.filters.main import ChatTypeFilter from helper_bot.filters.main import ChatTypeFilter
from helper_bot.keyboards.keyboards import (create_keyboard_with_pagination, from helper_bot.keyboards.keyboards import (
get_reply_keyboard, create_keyboard_with_pagination,
get_reply_keyboard_admin, get_reply_keyboard,
get_reply_keyboard_for_post, get_reply_keyboard_admin,
get_reply_keyboard_leave_chat) get_reply_keyboard_for_post,
get_reply_keyboard_leave_chat,
)
class TestKeyboards: class TestKeyboards:
"""Тесты для клавиатур""" """Тесты для клавиатур"""
@pytest.fixture @pytest.fixture
def mock_db(self): def mock_db(self):
"""Создает мок базы данных""" """Создает мок базы данных"""
db = Mock(spec=AsyncBotDB) db = Mock(spec=AsyncBotDB)
db.get_user_info = Mock(return_value={ db.get_user_info = Mock(return_value={"stickers": True, "admin": False})
'stickers': True,
'admin': False
})
return db return db
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_reply_keyboard_basic(self, mock_db): async def test_get_reply_keyboard_basic(self, mock_db):
"""Тест базовой клавиатуры""" """Тест базовой клавиатуры"""
user_id = 123456 user_id = 123456
keyboard = await get_reply_keyboard(mock_db, user_id) keyboard = await get_reply_keyboard(mock_db, user_id)
# Проверяем, что возвращается клавиатура # Проверяем, что возвращается клавиатура
assert isinstance(keyboard, ReplyKeyboardMarkup) assert isinstance(keyboard, ReplyKeyboardMarkup)
assert keyboard.keyboard is not None assert keyboard.keyboard is not None
assert len(keyboard.keyboard) > 0 assert len(keyboard.keyboard) > 0
# Проверяем, что каждая кнопка в отдельной строке # Проверяем, что каждая кнопка в отдельной строке
for row in keyboard.keyboard: for row in keyboard.keyboard:
assert len(row) == 1 # Каждая строка содержит только одну кнопку assert len(row) == 1 # Каждая строка содержит только одну кнопку
# Проверяем наличие основных кнопок # Проверяем наличие основных кнопок
all_buttons = [] all_buttons = []
for row in keyboard.keyboard: for row in keyboard.keyboard:
for button in row: for button in row:
all_buttons.append(button.text) all_buttons.append(button.text)
# Проверяем наличие основных кнопок # Проверяем наличие основных кнопок
assert '📢Предложить свой пост' in all_buttons assert "📢Предложить свой пост" in all_buttons
assert '👋🏼Сказать пока!' in all_buttons assert "👋🏼Сказать пока!" in all_buttons
assert '📩Связаться с админами' in all_buttons assert "📩Связаться с админами" in all_buttons
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_reply_keyboard_with_stickers(self, mock_db): async def test_get_reply_keyboard_with_stickers(self, mock_db):
"""Тест клавиатуры со стикерами""" """Тест клавиатуры со стикерами"""
user_id = 123456 user_id = 123456
# Мокаем метод get_stickers_info # Мокаем метод get_stickers_info
mock_db.get_stickers_info = AsyncMock(return_value=False) mock_db.get_stickers_info = AsyncMock(return_value=False)
keyboard = await get_reply_keyboard(mock_db, user_id) keyboard = await get_reply_keyboard(mock_db, user_id)
all_buttons = [] all_buttons = []
for row in keyboard.keyboard: for row in keyboard.keyboard:
for button in row: for button in row:
all_buttons.append(button.text) all_buttons.append(button.text)
# Проверяем наличие кнопки стикеров # Проверяем наличие кнопки стикеров
assert '🤪Хочу стикеры' in all_buttons assert "🤪Хочу стикеры" in all_buttons
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_reply_keyboard_without_stickers(self, mock_db): async def test_get_reply_keyboard_without_stickers(self, mock_db):
"""Тест клавиатуры без стикеров""" """Тест клавиатуры без стикеров"""
user_id = 123456 user_id = 123456
# Мокаем метод get_stickers_info # Мокаем метод get_stickers_info
mock_db.get_stickers_info = AsyncMock(return_value=True) mock_db.get_stickers_info = AsyncMock(return_value=True)
keyboard = await get_reply_keyboard(mock_db, user_id) keyboard = await get_reply_keyboard(mock_db, user_id)
all_buttons = [] all_buttons = []
for row in keyboard.keyboard: for row in keyboard.keyboard:
for button in row: for button in row:
all_buttons.append(button.text) all_buttons.append(button.text)
# Проверяем отсутствие кнопки стикеров # Проверяем отсутствие кнопки стикеров
assert '🤪Хочу стикеры' not in all_buttons assert "🤪Хочу стикеры" not in all_buttons
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_reply_keyboard_admin(self, mock_db): async def test_get_reply_keyboard_admin(self, mock_db):
"""Тест клавиатуры для админа""" """Тест клавиатуры для админа"""
user_id = 123456 user_id = 123456
# Мокаем метод get_stickers_info # Мокаем метод get_stickers_info
mock_db.get_stickers_info = AsyncMock(return_value=False) mock_db.get_stickers_info = AsyncMock(return_value=False)
keyboard = await get_reply_keyboard(mock_db, user_id) keyboard = await get_reply_keyboard(mock_db, user_id)
all_buttons = [] all_buttons = []
for row in keyboard.keyboard: for row in keyboard.keyboard:
for button in row: for button in row:
all_buttons.append(button.text) all_buttons.append(button.text)
# Проверяем наличие основных кнопок # Проверяем наличие основных кнопок
assert '📢Предложить свой пост' in all_buttons assert "📢Предложить свой пост" in all_buttons
assert '👋🏼Сказать пока!' in all_buttons assert "👋🏼Сказать пока!" in all_buttons
assert '📩Связаться с админами' in all_buttons assert "📩Связаться с админами" in all_buttons
def test_get_reply_keyboard_admin_keyboard(self): def test_get_reply_keyboard_admin_keyboard(self):
"""Тест админской клавиатуры""" """Тест админской клавиатуры"""
keyboard = get_reply_keyboard_admin() keyboard = get_reply_keyboard_admin()
assert isinstance(keyboard, ReplyKeyboardMarkup) assert isinstance(keyboard, ReplyKeyboardMarkup)
assert keyboard.keyboard is not None assert keyboard.keyboard is not None
assert len(keyboard.keyboard) == 3 # Три строки assert len(keyboard.keyboard) == 3 # Три строки
# Проверяем первую строку (3 кнопки) # Проверяем первую строку (3 кнопки)
first_row = keyboard.keyboard[0] first_row = keyboard.keyboard[0]
assert len(first_row) == 3 assert len(first_row) == 3
assert first_row[0].text == "Бан (Список)" assert first_row[0].text == "Бан (Список)"
assert first_row[1].text == "Бан по нику" assert first_row[1].text == "Бан по нику"
assert first_row[2].text == "Бан по ID" assert first_row[2].text == "Бан по ID"
# Проверяем вторую строку (2 кнопки) # Проверяем вторую строку (2 кнопки)
second_row = keyboard.keyboard[1] second_row = keyboard.keyboard[1]
assert len(second_row) == 2 assert len(second_row) == 2
assert second_row[0].text == "Разбан (список)" assert second_row[0].text == "Разбан (список)"
assert second_row[1].text == "📊 ML Статистика" assert second_row[1].text == "📊 ML Статистика"
# Проверяем третью строку (1 кнопка) # Проверяем третью строку (1 кнопка)
third_row = keyboard.keyboard[2] third_row = keyboard.keyboard[2]
assert len(third_row) == 1 assert len(third_row) == 1
assert third_row[0].text == "Вернуться в бота" assert third_row[0].text == "Вернуться в бота"
def test_get_reply_keyboard_for_post(self): def test_get_reply_keyboard_for_post(self):
"""Тест клавиатуры для постов""" """Тест клавиатуры для постов"""
keyboard = get_reply_keyboard_for_post() keyboard = get_reply_keyboard_for_post()
assert isinstance(keyboard, InlineKeyboardMarkup) assert isinstance(keyboard, InlineKeyboardMarkup)
assert keyboard.inline_keyboard is not None assert keyboard.inline_keyboard is not None
assert len(keyboard.inline_keyboard) > 0 assert len(keyboard.inline_keyboard) > 0
all_buttons = [] all_buttons = []
for row in keyboard.inline_keyboard: for row in keyboard.inline_keyboard:
for button in row: for button in row:
all_buttons.append(button.text) all_buttons.append(button.text)
# Проверяем наличие кнопок для постов # Проверяем наличие кнопок для постов
assert 'Опубликовать' in all_buttons assert "Опубликовать" in all_buttons
assert 'Отклонить' in all_buttons assert "Отклонить" in all_buttons
def test_get_reply_keyboard_leave_chat(self): def test_get_reply_keyboard_leave_chat(self):
"""Тест клавиатуры для выхода из чата""" """Тест клавиатуры для выхода из чата"""
keyboard = get_reply_keyboard_leave_chat() keyboard = get_reply_keyboard_leave_chat()
assert isinstance(keyboard, ReplyKeyboardMarkup) assert isinstance(keyboard, ReplyKeyboardMarkup)
assert keyboard.keyboard is not None assert keyboard.keyboard is not None
assert len(keyboard.keyboard) > 0 assert len(keyboard.keyboard) > 0
all_buttons = [] all_buttons = []
for row in keyboard.keyboard: for row in keyboard.keyboard:
for button in row: for button in row:
all_buttons.append(button.text) all_buttons.append(button.text)
# Проверяем наличие кнопки выхода # Проверяем наличие кнопки выхода
assert 'Выйти из чата' in all_buttons assert "Выйти из чата" in all_buttons
def test_keyboard_resize(self): def test_keyboard_resize(self):
"""Тест настройки resize клавиатуры""" """Тест настройки resize клавиатуры"""
keyboard = get_reply_keyboard_for_post() keyboard = get_reply_keyboard_for_post()
# Проверяем, что клавиатура настроена правильно # Проверяем, что клавиатура настроена правильно
# InlineKeyboardMarkup не имеет resize_keyboard # InlineKeyboardMarkup не имеет resize_keyboard
assert isinstance(keyboard, InlineKeyboardMarkup) assert isinstance(keyboard, InlineKeyboardMarkup)
def test_keyboard_one_time(self): def test_keyboard_one_time(self):
"""Тест настройки one_time клавиатуры""" """Тест настройки one_time клавиатуры"""
keyboard = get_reply_keyboard_leave_chat() keyboard = get_reply_keyboard_leave_chat()
# Проверяем, что клавиатура настроена правильно # Проверяем, что клавиатура настроена правильно
assert hasattr(keyboard, 'one_time_keyboard') assert hasattr(keyboard, "one_time_keyboard")
assert keyboard.one_time_keyboard is True assert keyboard.one_time_keyboard is True
class TestChatTypeFilter: class TestChatTypeFilter:
"""Тесты для фильтра типа чата""" """Тесты для фильтра типа чата"""
@pytest.fixture @pytest.fixture
def mock_message(self): def mock_message(self):
"""Создает мок сообщения""" """Создает мок сообщения"""
message = Mock() message = Mock()
message.chat = Mock() message.chat = Mock()
return message return message
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_type_filter_private(self, mock_message): async def test_chat_type_filter_private(self, mock_message):
"""Тест фильтра для приватного чата""" """Тест фильтра для приватного чата"""
mock_message.chat.type = "private" mock_message.chat.type = "private"
filter_private = ChatTypeFilter(chat_type=["private"]) filter_private = ChatTypeFilter(chat_type=["private"])
filter_group = ChatTypeFilter(chat_type=["group"]) filter_group = ChatTypeFilter(chat_type=["group"])
filter_supergroup = ChatTypeFilter(chat_type=["supergroup"]) filter_supergroup = ChatTypeFilter(chat_type=["supergroup"])
# Проверяем, что фильтр работает правильно # Проверяем, что фильтр работает правильно
assert await filter_private(mock_message) is True assert await filter_private(mock_message) is True
assert await filter_group(mock_message) is False assert await filter_group(mock_message) is False
assert await filter_supergroup(mock_message) is False assert await filter_supergroup(mock_message) is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_type_filter_group(self, mock_message): async def test_chat_type_filter_group(self, mock_message):
"""Тест фильтра для группового чата""" """Тест фильтра для группового чата"""
mock_message.chat.type = "group" mock_message.chat.type = "group"
filter_private = ChatTypeFilter(chat_type=["private"]) filter_private = ChatTypeFilter(chat_type=["private"])
filter_group = ChatTypeFilter(chat_type=["group"]) filter_group = ChatTypeFilter(chat_type=["group"])
filter_supergroup = ChatTypeFilter(chat_type=["supergroup"]) filter_supergroup = ChatTypeFilter(chat_type=["supergroup"])
# Проверяем, что фильтр работает правильно # Проверяем, что фильтр работает правильно
assert await filter_private(mock_message) is False assert await filter_private(mock_message) is False
assert await filter_group(mock_message) is True assert await filter_group(mock_message) is True
assert await filter_supergroup(mock_message) is False assert await filter_supergroup(mock_message) is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_type_filter_supergroup(self, mock_message): async def test_chat_type_filter_supergroup(self, mock_message):
"""Тест фильтра для супергруппы""" """Тест фильтра для супергруппы"""
mock_message.chat.type = "supergroup" mock_message.chat.type = "supergroup"
filter_private = ChatTypeFilter(chat_type=["private"]) filter_private = ChatTypeFilter(chat_type=["private"])
filter_group = ChatTypeFilter(chat_type=["group"]) filter_group = ChatTypeFilter(chat_type=["group"])
filter_supergroup = ChatTypeFilter(chat_type=["supergroup"]) filter_supergroup = ChatTypeFilter(chat_type=["supergroup"])
# Проверяем, что фильтр работает правильно # Проверяем, что фильтр работает правильно
assert await filter_private(mock_message) is False assert await filter_private(mock_message) is False
assert await filter_group(mock_message) is False assert await filter_group(mock_message) is False
assert await filter_supergroup(mock_message) is True assert await filter_supergroup(mock_message) is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_type_filter_multiple_types(self, mock_message): async def test_chat_type_filter_multiple_types(self, mock_message):
"""Тест фильтра с несколькими типами чатов""" """Тест фильтра с несколькими типами чатов"""
filter_private_group = ChatTypeFilter(chat_type=["private", "group"]) filter_private_group = ChatTypeFilter(chat_type=["private", "group"])
filter_all = ChatTypeFilter(chat_type=["private", "group", "supergroup"]) filter_all = ChatTypeFilter(chat_type=["private", "group", "supergroup"])
# Тест для приватного чата # Тест для приватного чата
mock_message.chat.type = "private" mock_message.chat.type = "private"
assert await filter_private_group(mock_message) is True assert await filter_private_group(mock_message) is True
assert await filter_all(mock_message) is True assert await filter_all(mock_message) is True
# Тест для группового чата # Тест для группового чата
mock_message.chat.type = "group" mock_message.chat.type = "group"
assert await filter_private_group(mock_message) is True assert await filter_private_group(mock_message) is True
assert await filter_all(mock_message) is True assert await filter_all(mock_message) is True
# Тест для супергруппы # Тест для супергруппы
mock_message.chat.type = "supergroup" mock_message.chat.type = "supergroup"
assert await filter_private_group(mock_message) is False assert await filter_private_group(mock_message) is False
assert await filter_all(mock_message) is True assert await filter_all(mock_message) is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_type_filter_channel(self, mock_message): async def test_chat_type_filter_channel(self, mock_message):
"""Тест фильтра для канала""" """Тест фильтра для канала"""
mock_message.chat.type = "channel" mock_message.chat.type = "channel"
filter_channel = ChatTypeFilter(chat_type=["channel"]) filter_channel = ChatTypeFilter(chat_type=["channel"])
filter_private = ChatTypeFilter(chat_type=["private"]) filter_private = ChatTypeFilter(chat_type=["private"])
assert await filter_channel(mock_message) is True assert await filter_channel(mock_message) is True
assert await filter_private(mock_message) is False assert await filter_private(mock_message) is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_type_filter_empty_list(self, mock_message): async def test_chat_type_filter_empty_list(self, mock_message):
"""Тест фильтра с пустым списком типов""" """Тест фильтра с пустым списком типов"""
mock_message.chat.type = "private" mock_message.chat.type = "private"
filter_empty = ChatTypeFilter(chat_type=[]) filter_empty = ChatTypeFilter(chat_type=[])
# Фильтр с пустым списком должен возвращать False # Фильтр с пустым списком должен возвращать False
assert await filter_empty(mock_message) is False assert await filter_empty(mock_message) is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_type_filter_invalid_type(self, mock_message): async def test_chat_type_filter_invalid_type(self, mock_message):
"""Тест фильтра с несуществующим типом чата""" """Тест фильтра с несуществующим типом чата"""
mock_message.chat.type = "invalid_type" mock_message.chat.type = "invalid_type"
filter_private = ChatTypeFilter(chat_type=["private"]) filter_private = ChatTypeFilter(chat_type=["private"])
filter_invalid = ChatTypeFilter(chat_type=["invalid_type"]) filter_invalid = ChatTypeFilter(chat_type=["invalid_type"])
assert await filter_private(mock_message) is False assert await filter_private(mock_message) is False
assert await filter_invalid(mock_message) is True assert await filter_invalid(mock_message) is True
class TestKeyboardIntegration: class TestKeyboardIntegration:
"""Интеграционные тесты клавиатур""" """Интеграционные тесты клавиатур"""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_keyboard_structure_consistency(self): async def test_keyboard_structure_consistency(self):
"""Тест консистентности структуры клавиатур""" """Тест консистентности структуры клавиатур"""
# Мокаем базу данных # Мокаем базу данных
mock_db = Mock(spec=AsyncBotDB) mock_db = Mock(spec=AsyncBotDB)
mock_db.get_stickers_info = AsyncMock(return_value=False) mock_db.get_stickers_info = AsyncMock(return_value=False)
# Тестируем все типы клавиатур # Тестируем все типы клавиатур
keyboard1 = await get_reply_keyboard(mock_db, 123456) keyboard1 = await get_reply_keyboard(mock_db, 123456)
keyboard2 = get_reply_keyboard_for_post() keyboard2 = get_reply_keyboard_for_post()
keyboard3 = get_reply_keyboard_leave_chat() keyboard3 = get_reply_keyboard_leave_chat()
# Проверяем первую клавиатуру (ReplyKeyboardMarkup) # Проверяем первую клавиатуру (ReplyKeyboardMarkup)
assert isinstance(keyboard1, ReplyKeyboardMarkup) assert isinstance(keyboard1, ReplyKeyboardMarkup)
assert hasattr(keyboard1, 'keyboard') assert hasattr(keyboard1, "keyboard")
assert isinstance(keyboard1.keyboard, list) assert isinstance(keyboard1.keyboard, list)
# Проверяем вторую клавиатуру (InlineKeyboardMarkup) # Проверяем вторую клавиатуру (InlineKeyboardMarkup)
assert isinstance(keyboard2, InlineKeyboardMarkup) assert isinstance(keyboard2, InlineKeyboardMarkup)
assert hasattr(keyboard2, 'inline_keyboard') assert hasattr(keyboard2, "inline_keyboard")
assert isinstance(keyboard2.inline_keyboard, list) assert isinstance(keyboard2.inline_keyboard, list)
# Проверяем третью клавиатуру (ReplyKeyboardMarkup) # Проверяем третью клавиатуру (ReplyKeyboardMarkup)
assert isinstance(keyboard3, ReplyKeyboardMarkup) assert isinstance(keyboard3, ReplyKeyboardMarkup)
assert hasattr(keyboard3, 'keyboard') assert hasattr(keyboard3, "keyboard")
assert isinstance(keyboard3.keyboard, list) assert isinstance(keyboard3.keyboard, list)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_keyboard_button_texts(self): async def test_keyboard_button_texts(self):
"""Тест текстов кнопок клавиатур""" """Тест текстов кнопок клавиатур"""
# Тестируем основные кнопки # Тестируем основные кнопки
db = Mock(spec=AsyncBotDB) db = Mock(spec=AsyncBotDB)
db.get_stickers_info = AsyncMock(return_value=False) db.get_stickers_info = AsyncMock(return_value=False)
main_keyboard = await get_reply_keyboard(db, 123456) main_keyboard = await get_reply_keyboard(db, 123456)
post_keyboard = get_reply_keyboard_for_post() post_keyboard = get_reply_keyboard_for_post()
leave_keyboard = get_reply_keyboard_leave_chat() leave_keyboard = get_reply_keyboard_leave_chat()
# Собираем все тексты кнопок # Собираем все тексты кнопок
main_buttons = [] main_buttons = []
for row in main_keyboard.keyboard: for row in main_keyboard.keyboard:
for button in row: for button in row:
main_buttons.append(button.text) main_buttons.append(button.text)
post_buttons = [] post_buttons = []
for row in post_keyboard.inline_keyboard: for row in post_keyboard.inline_keyboard:
for button in row: for button in row:
post_buttons.append(button.text) post_buttons.append(button.text)
leave_buttons = [] leave_buttons = []
for row in leave_keyboard.keyboard: for row in leave_keyboard.keyboard:
for button in row: for button in row:
leave_buttons.append(button.text) leave_buttons.append(button.text)
# Проверяем наличие основных кнопок # Проверяем наличие основных кнопок
assert '📢Предложить свой пост' in main_buttons assert "📢Предложить свой пост" in main_buttons
assert '👋🏼Сказать пока!' in main_buttons assert "👋🏼Сказать пока!" in main_buttons
assert '📩Связаться с админами' in main_buttons assert "📩Связаться с админами" in main_buttons
assert '🤪Хочу стикеры' in main_buttons assert "🤪Хочу стикеры" in main_buttons
# Проверяем кнопки для постов # Проверяем кнопки для постов
assert 'Опубликовать' in post_buttons assert "Опубликовать" in post_buttons
assert 'Отклонить' in post_buttons assert "Отклонить" in post_buttons
# Проверяем кнопку выхода # Проверяем кнопку выхода
assert 'Выйти из чата' in leave_buttons assert "Выйти из чата" in leave_buttons
class TestPagination: class TestPagination:
"""Тесты для функции create_keyboard_with_pagination""" """Тесты для функции create_keyboard_with_pagination"""
def test_pagination_empty_list(self): def test_pagination_empty_list(self):
"""Тест с пустым списком элементов""" """Тест с пустым списком элементов"""
keyboard = create_keyboard_with_pagination(1, 0, [], 'test') keyboard = create_keyboard_with_pagination(1, 0, [], "test")
assert keyboard is not None assert keyboard is not None
# Проверяем, что есть только кнопка "Назад" # Проверяем, что есть только кнопка "Назад"
assert len(keyboard.inline_keyboard) == 1 assert len(keyboard.inline_keyboard) == 1
assert keyboard.inline_keyboard[0][0].text == "🏠 Назад" assert keyboard.inline_keyboard[0][0].text == "🏠 Назад"
def test_pagination_single_page(self): def test_pagination_single_page(self):
"""Тест с одной страницей""" """Тест с одной страницей"""
items = [("User1", 1), ("User2", 2), ("User3", 3)] items = [("User1", 1), ("User2", 2), ("User3", 3)]
keyboard = create_keyboard_with_pagination(1, 3, items, 'test') keyboard = create_keyboard_with_pagination(1, 3, items, "test")
# Проверяем количество кнопок (3 пользователя + кнопка "Назад") # Проверяем количество кнопок (3 пользователя + кнопка "Назад")
assert len(keyboard.inline_keyboard) == 2 # 1 ряд с пользователями + 1 ряд с "Назад" assert (
len(keyboard.inline_keyboard) == 2
) # 1 ряд с пользователями + 1 ряд с "Назад"
assert len(keyboard.inline_keyboard[0]) == 3 # 3 пользователя в первом ряду assert len(keyboard.inline_keyboard[0]) == 3 # 3 пользователя в первом ряду
assert keyboard.inline_keyboard[1][0].text == "🏠 Назад" assert keyboard.inline_keyboard[1][0].text == "🏠 Назад"
# Проверяем, что нет кнопок навигации # Проверяем, что нет кнопок навигации
assert len(keyboard.inline_keyboard[0]) == 3 # только пользователи assert len(keyboard.inline_keyboard[0]) == 3 # только пользователи
def test_pagination_multiple_pages(self): def test_pagination_multiple_pages(self):
"""Тест с несколькими страницами""" """Тест с несколькими страницами"""
items = [("User" + str(i), i) for i in range(1, 15)] # 14 пользователей items = [("User" + str(i), i) for i in range(1, 15)] # 14 пользователей
keyboard = create_keyboard_with_pagination(1, 14, items, 'test') keyboard = create_keyboard_with_pagination(1, 14, items, "test")
# На первой странице должно быть 9 пользователей (3 ряда по 3) + кнопка "Следующая" + "Назад" # На первой странице должно быть 9 пользователей (3 ряда по 3) + кнопка "Следующая" + "Назад"
assert len(keyboard.inline_keyboard) == 5 # 3 ряда пользователей + навигация + назад assert (
len(keyboard.inline_keyboard) == 5
) # 3 ряда пользователей + навигация + назад
assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя
assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя
assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя
assert keyboard.inline_keyboard[3][0].text == "➡️ Следующая" # кнопка навигации assert keyboard.inline_keyboard[3][0].text == "➡️ Следующая" # кнопка навигации
assert keyboard.inline_keyboard[4][0].text == "🏠 Назад" # кнопка назад assert keyboard.inline_keyboard[4][0].text == "🏠 Назад" # кнопка назад
def test_pagination_second_page(self): def test_pagination_second_page(self):
"""Тест второй страницы""" """Тест второй страницы"""
items = [("User" + str(i), i) for i in range(1, 15)] # 14 пользователей items = [("User" + str(i), i) for i in range(1, 15)] # 14 пользователей
keyboard = create_keyboard_with_pagination(2, 14, items, 'test') keyboard = create_keyboard_with_pagination(2, 14, items, "test")
# На второй странице должно быть 5 пользователей (2 ряда: 3+2) + кнопки "Предыдущая" и "Назад" # На второй странице должно быть 5 пользователей (2 ряда: 3+2) + кнопки "Предыдущая" и "Назад"
assert len(keyboard.inline_keyboard) == 4 # 2 ряда пользователей + навигация + назад assert (
len(keyboard.inline_keyboard) == 4
) # 2 ряда пользователей + навигация + назад
assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя
assert len(keyboard.inline_keyboard[1]) == 2 # второй ряд: 2 пользователя assert len(keyboard.inline_keyboard[1]) == 2 # второй ряд: 2 пользователя
assert keyboard.inline_keyboard[2][0].text == "⬅️ Предыдущая" assert keyboard.inline_keyboard[2][0].text == "⬅️ Предыдущая"
assert keyboard.inline_keyboard[3][0].text == "🏠 Назад" assert keyboard.inline_keyboard[3][0].text == "🏠 Назад"
def test_pagination_middle_page(self): def test_pagination_middle_page(self):
"""Тест средней страницы""" """Тест средней страницы"""
items = [("User" + str(i), i) for i in range(1, 25)] # 24 пользователя items = [("User" + str(i), i) for i in range(1, 25)] # 24 пользователя
keyboard = create_keyboard_with_pagination(2, 24, items, 'test') keyboard = create_keyboard_with_pagination(2, 24, items, "test")
# На второй странице должно быть 9 пользователей (3 ряда по 3) + кнопки "Предыдущая" и "Следующая" # На второй странице должно быть 9 пользователей (3 ряда по 3) + кнопки "Предыдущая" и "Следующая"
assert len(keyboard.inline_keyboard) == 5 # 3 ряда пользователей + навигация + назад assert (
len(keyboard.inline_keyboard) == 5
) # 3 ряда пользователей + навигация + назад
assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя
assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя
assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя
assert keyboard.inline_keyboard[3][0].text == "⬅️ Предыдущая" assert keyboard.inline_keyboard[3][0].text == "⬅️ Предыдущая"
assert keyboard.inline_keyboard[3][1].text == "➡️ Следующая" assert keyboard.inline_keyboard[3][1].text == "➡️ Следующая"
def test_pagination_invalid_page_number(self): def test_pagination_invalid_page_number(self):
"""Тест с некорректным номером страницы""" """Тест с некорректным номером страницы"""
items = [("User" + str(i), i) for i in range(1, 10)] # 9 пользователей items = [("User" + str(i), i) for i in range(1, 10)] # 9 пользователей
keyboard = create_keyboard_with_pagination(0, 9, items, 'test') # страница 0 keyboard = create_keyboard_with_pagination(0, 9, items, "test") # страница 0
# Должна вернуться первая страница # Должна вернуться первая страница
assert len(keyboard.inline_keyboard) == 4 # 3 ряда пользователей + назад assert len(keyboard.inline_keyboard) == 4 # 3 ряда пользователей + назад
assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя
assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя
assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя
def test_pagination_page_out_of_range(self): def test_pagination_page_out_of_range(self):
"""Тест с номером страницы больше максимального""" """Тест с номером страницы больше максимального"""
items = [("User" + str(i), i) for i in range(1, 10)] # 9 пользователей items = [("User" + str(i), i) for i in range(1, 10)] # 9 пользователей
keyboard = create_keyboard_with_pagination(5, 9, items, 'test') # страница 5 при 1 странице keyboard = create_keyboard_with_pagination(
5, 9, items, "test"
) # страница 5 при 1 странице
# Должна вернуться первая страница # Должна вернуться первая страница
assert len(keyboard.inline_keyboard) == 4 # 3 ряда пользователей + назад assert len(keyboard.inline_keyboard) == 4 # 3 ряда пользователей + назад
assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя
assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя
assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя
def test_pagination_callback_data_format(self): def test_pagination_callback_data_format(self):
"""Тест формата callback_data""" """Тест формата callback_data"""
items = [("User1", 123), ("User2", 456)] items = [("User1", 123), ("User2", 456)]
keyboard = create_keyboard_with_pagination(1, 2, items, 'ban') keyboard = create_keyboard_with_pagination(1, 2, items, "ban")
# Проверяем формат callback_data для пользователей # Проверяем формат callback_data для пользователей
assert keyboard.inline_keyboard[0][0].callback_data == "ban_123" assert keyboard.inline_keyboard[0][0].callback_data == "ban_123"
assert keyboard.inline_keyboard[0][1].callback_data == "ban_456" assert keyboard.inline_keyboard[0][1].callback_data == "ban_456"
# Проверяем формат callback_data для кнопки "Назад" # Проверяем формат callback_data для кнопки "Назад"
assert keyboard.inline_keyboard[1][0].callback_data == "return" assert keyboard.inline_keyboard[1][0].callback_data == "return"
def test_pagination_navigation_callback_data(self): def test_pagination_navigation_callback_data(self):
"""Тест callback_data для кнопок навигации""" """Тест callback_data для кнопок навигации"""
items = [("User" + str(i), i) for i in range(1, 15)] # 14 пользователей items = [("User" + str(i), i) for i in range(1, 15)] # 14 пользователей
keyboard = create_keyboard_with_pagination(2, 14, items, 'test') keyboard = create_keyboard_with_pagination(2, 14, items, "test")
# Проверяем callback_data для кнопки "Предыдущая" # Проверяем callback_data для кнопки "Предыдущая"
assert keyboard.inline_keyboard[2][0].callback_data == "page_1" assert keyboard.inline_keyboard[2][0].callback_data == "page_1"
# Проверяем callback_data для кнопки "Назад" # Проверяем callback_data для кнопки "Назад"
assert keyboard.inline_keyboard[3][0].callback_data == "return" assert keyboard.inline_keyboard[3][0].callback_data == "return"
def test_pagination_exactly_items_per_page(self): def test_pagination_exactly_items_per_page(self):
"""Тест когда количество элементов точно равно items_per_page""" """Тест когда количество элементов точно равно items_per_page"""
items = [("User" + str(i), i) for i in range(1, 10)] # ровно 9 пользователей items = [("User" + str(i), i) for i in range(1, 10)] # ровно 9 пользователей
keyboard = create_keyboard_with_pagination(1, 9, items, 'test') keyboard = create_keyboard_with_pagination(1, 9, items, "test")
# Должна быть только одна страница без кнопок навигации # Должна быть только одна страница без кнопок навигации
assert len(keyboard.inline_keyboard) == 4 # 3 ряда пользователей + назад assert len(keyboard.inline_keyboard) == 4 # 3 ряда пользователей + назад
assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя
@@ -478,5 +492,5 @@ class TestPagination:
assert keyboard.inline_keyboard[3][0].text == "🏠 Назад" assert keyboard.inline_keyboard[3][0].text == "🏠 Назад"
if __name__ == '__main__': if __name__ == "__main__":
pytest.main([__file__, '-v']) pytest.main([__file__, "-v"])

View File

@@ -3,23 +3,24 @@ from datetime import datetime
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from database.models import UserMessage from database.models import UserMessage
from database.repositories.message_repository import MessageRepository from database.repositories.message_repository import MessageRepository
class TestMessageRepository: class TestMessageRepository:
"""Тесты для MessageRepository.""" """Тесты для MessageRepository."""
@pytest.fixture @pytest.fixture
def mock_db_path(self): def mock_db_path(self):
"""Фикстура для пути к тестовой БД.""" """Фикстура для пути к тестовой БД."""
return ":memory:" return ":memory:"
@pytest.fixture @pytest.fixture
def message_repository(self, mock_db_path): def message_repository(self, mock_db_path):
"""Фикстура для MessageRepository.""" """Фикстура для MessageRepository."""
return MessageRepository(mock_db_path) return MessageRepository(mock_db_path)
@pytest.fixture @pytest.fixture
def sample_message(self): def sample_message(self):
"""Фикстура для тестового сообщения.""" """Фикстура для тестового сообщения."""
@@ -27,9 +28,9 @@ class TestMessageRepository:
message_text="Тестовое сообщение", message_text="Тестовое сообщение",
user_id=12345, user_id=12345,
telegram_message_id=67890, telegram_message_id=67890,
date=int(datetime.now().timestamp()) date=int(datetime.now().timestamp()),
) )
@pytest.fixture @pytest.fixture
def sample_message_no_date(self): def sample_message_no_date(self):
"""Фикстура для тестового сообщения без даты.""" """Фикстура для тестового сообщения без даты."""
@@ -37,134 +38,145 @@ class TestMessageRepository:
message_text="Тестовое сообщение без даты", message_text="Тестовое сообщение без даты",
user_id=12345, user_id=12345,
telegram_message_id=67891, telegram_message_id=67891,
date=None date=None,
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_tables(self, message_repository): async def test_create_tables(self, message_repository):
"""Тест создания таблиц.""" """Тест создания таблиц."""
# Мокаем _execute_query # Мокаем _execute_query
message_repository._execute_query = AsyncMock() message_repository._execute_query = AsyncMock()
await message_repository.create_tables() await message_repository.create_tables()
message_repository._execute_query.assert_called_once() message_repository._execute_query.assert_called_once()
call_args = message_repository._execute_query.call_args[0][0] call_args = message_repository._execute_query.call_args[0][0]
assert "CREATE TABLE IF NOT EXISTS user_messages" in call_args assert "CREATE TABLE IF NOT EXISTS user_messages" in call_args
assert "telegram_message_id INTEGER NOT NULL" in call_args assert "telegram_message_id INTEGER NOT NULL" in call_args
assert "date INTEGER NOT NULL" in call_args assert "date INTEGER NOT NULL" in call_args
assert "FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in call_args assert (
"FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE"
in call_args
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_message_with_date(self, message_repository, sample_message): async def test_add_message_with_date(self, message_repository, sample_message):
"""Тест добавления сообщения с датой.""" """Тест добавления сообщения с датой."""
# Мокаем _execute_query # Мокаем _execute_query
message_repository._execute_query = AsyncMock() message_repository._execute_query = AsyncMock()
await message_repository.add_message(sample_message) await message_repository.add_message(sample_message)
message_repository._execute_query.assert_called_once() message_repository._execute_query.assert_called_once()
call_args = message_repository._execute_query.call_args call_args = message_repository._execute_query.call_args
query = call_args[0][0] query = call_args[0][0]
params = call_args[0][1] params = call_args[0][1]
assert "INSERT INTO user_messages" in query assert "INSERT INTO user_messages" in query
assert "VALUES (?, ?, ?, ?)" in query assert "VALUES (?, ?, ?, ?)" in query
assert params == ( assert params == (
sample_message.message_text, sample_message.message_text,
sample_message.user_id, sample_message.user_id,
sample_message.telegram_message_id, sample_message.telegram_message_id,
sample_message.date sample_message.date,
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_message_without_date(self, message_repository, sample_message_no_date): async def test_add_message_without_date(
self, message_repository, sample_message_no_date
):
"""Тест добавления сообщения без даты (должна генерироваться автоматически).""" """Тест добавления сообщения без даты (должна генерироваться автоматически)."""
# Мокаем _execute_query # Мокаем _execute_query
message_repository._execute_query = AsyncMock() message_repository._execute_query = AsyncMock()
await message_repository.add_message(sample_message_no_date) await message_repository.add_message(sample_message_no_date)
# Проверяем, что дата была установлена # Проверяем, что дата была установлена
assert sample_message_no_date.date is not None assert sample_message_no_date.date is not None
assert isinstance(sample_message_no_date.date, int) assert isinstance(sample_message_no_date.date, int)
assert sample_message_no_date.date > 0 assert sample_message_no_date.date > 0
message_repository._execute_query.assert_called_once() message_repository._execute_query.assert_called_once()
call_args = message_repository._execute_query.call_args call_args = message_repository._execute_query.call_args
params = call_args[0][1] params = call_args[0][1]
assert params[3] == sample_message_no_date.date # date field assert params[3] == sample_message_no_date.date # date field
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_message_logs_correctly(self, message_repository, sample_message): async def test_add_message_logs_correctly(self, message_repository, sample_message):
"""Тест логирования при добавлении сообщения.""" """Тест логирования при добавлении сообщения."""
# Мокаем _execute_query и logger # Мокаем _execute_query и logger
message_repository._execute_query = AsyncMock() message_repository._execute_query = AsyncMock()
message_repository.logger = MagicMock() message_repository.logger = MagicMock()
await message_repository.add_message(sample_message) await message_repository.add_message(sample_message)
message_repository.logger.info.assert_called_once() message_repository.logger.info.assert_called_once()
log_message = message_repository.logger.info.call_args[0][0] log_message = message_repository.logger.info.call_args[0][0]
assert f"telegram_message_id={sample_message.telegram_message_id}" in log_message assert (
f"telegram_message_id={sample_message.telegram_message_id}" in log_message
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_user_by_message_id_found(self, message_repository): async def test_get_user_by_message_id_found(self, message_repository):
"""Тест получения пользователя по message_id (пользователь найден).""" """Тест получения пользователя по message_id (пользователь найден)."""
message_id = 67890 message_id = 67890
expected_user_id = 12345 expected_user_id = 12345
# Мокаем _execute_query_with_result # Мокаем _execute_query_with_result
message_repository._execute_query_with_result = AsyncMock( message_repository._execute_query_with_result = AsyncMock(
return_value=[[expected_user_id]] return_value=[[expected_user_id]]
) )
result = await message_repository.get_user_by_message_id(message_id) result = await message_repository.get_user_by_message_id(message_id)
assert result == expected_user_id assert result == expected_user_id
message_repository._execute_query_with_result.assert_called_once_with( message_repository._execute_query_with_result.assert_called_once_with(
"SELECT user_id FROM user_messages WHERE telegram_message_id = ?", "SELECT user_id FROM user_messages WHERE telegram_message_id = ?",
(message_id,) (message_id,),
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_user_by_message_id_not_found(self, message_repository): async def test_get_user_by_message_id_not_found(self, message_repository):
"""Тест получения пользователя по message_id (пользователь не найден).""" """Тест получения пользователя по message_id (пользователь не найден)."""
message_id = 99999 message_id = 99999
# Мокаем _execute_query_with_result # Мокаем _execute_query_with_result
message_repository._execute_query_with_result = AsyncMock(return_value=[]) message_repository._execute_query_with_result = AsyncMock(return_value=[])
result = await message_repository.get_user_by_message_id(message_id) result = await message_repository.get_user_by_message_id(message_id)
assert result is None assert result is None
message_repository._execute_query_with_result.assert_called_once_with( message_repository._execute_query_with_result.assert_called_once_with(
"SELECT user_id FROM user_messages WHERE telegram_message_id = ?", "SELECT user_id FROM user_messages WHERE telegram_message_id = ?",
(message_id,) (message_id,),
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_user_by_message_id_empty_result(self, message_repository): async def test_get_user_by_message_id_empty_result(self, message_repository):
"""Тест получения пользователя по message_id (пустой результат).""" """Тест получения пользователя по message_id (пустой результат)."""
message_id = 99999 message_id = 99999
# Мокаем _execute_query_with_result # Мокаем _execute_query_with_result
message_repository._execute_query_with_result = AsyncMock(return_value=[[]]) message_repository._execute_query_with_result = AsyncMock(return_value=[[]])
result = await message_repository.get_user_by_message_id(message_id) result = await message_repository.get_user_by_message_id(message_id)
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_message_handles_exception(self, message_repository, sample_message): async def test_add_message_handles_exception(
self, message_repository, sample_message
):
"""Тест обработки исключений при добавлении сообщения.""" """Тест обработки исключений при добавлении сообщения."""
# Мокаем _execute_query для вызова исключения # Мокаем _execute_query для вызова исключения
message_repository._execute_query = AsyncMock(side_effect=Exception("Database error")) message_repository._execute_query = AsyncMock(
side_effect=Exception("Database error")
)
with pytest.raises(Exception, match="Database error"): with pytest.raises(Exception, match="Database error"):
await message_repository.add_message(sample_message) await message_repository.add_message(sample_message)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_user_by_message_id_handles_exception(self, message_repository): async def test_get_user_by_message_id_handles_exception(self, message_repository):
"""Тест обработки исключений при получении пользователя.""" """Тест обработки исключений при получении пользователя."""
@@ -172,10 +184,10 @@ class TestMessageRepository:
message_repository._execute_query_with_result = AsyncMock( message_repository._execute_query_with_result = AsyncMock(
side_effect=Exception("Database error") side_effect=Exception("Database error")
) )
with pytest.raises(Exception, match="Database error"): with pytest.raises(Exception, match="Database error"):
await message_repository.get_user_by_message_id(12345) await message_repository.get_user_by_message_id(12345)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_message_with_zero_date(self, message_repository): async def test_add_message_with_zero_date(self, message_repository):
"""Тест добавления сообщения с датой равной 0 (должна генерироваться новая).""" """Тест добавления сообщения с датой равной 0 (должна генерироваться новая)."""
@@ -183,19 +195,19 @@ class TestMessageRepository:
message_text="Тестовое сообщение с нулевой датой", message_text="Тестовое сообщение с нулевой датой",
user_id=12345, user_id=12345,
telegram_message_id=67892, telegram_message_id=67892,
date=0 date=0,
) )
# Мокаем _execute_query # Мокаем _execute_query
message_repository._execute_query = AsyncMock() message_repository._execute_query = AsyncMock()
await message_repository.add_message(message) await message_repository.add_message(message)
# Проверяем, что дата была изменена с 0 (теперь это происходит только если date is None) # Проверяем, что дата была изменена с 0 (теперь это происходит только если date is None)
# В текущей реализации дата 0 считается валидной и не изменяется # В текущей реализации дата 0 считается валидной и не изменяется
assert isinstance(message.date, int) assert isinstance(message.date, int)
assert message.date >= 0 assert message.date >= 0
message_repository._execute_query.assert_called_once() message_repository._execute_query.assert_called_once()
params = message_repository._execute_query.call_args[0][1] params = message_repository._execute_query.call_args[0][1]
assert params[3] == message.date # date field assert params[3] == message.date # date field

Some files were not shown because too many files have changed in this diff Show More