Переписал почти все тесты #16

Merged
KerradKerridi merged 4 commits from fix-1 into master 2026-02-03 20:45:52 +00:00
116 changed files with 9801 additions and 6456 deletions
Showing only changes of commit 68041037bd - Show all commits

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
*.db
# Случайно созданный файл при использовании SQLite :memory: не по назначению
:memory:
# IDE and editor files
.vscode/
.idea/

BIN
:memory:

Binary file not shown.

View File

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

View File

@@ -2,9 +2,17 @@ from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
import aiosqlite
from database.models import (Admin, AudioMessage, BlacklistHistoryRecord,
BlacklistUser, PostContent, TelegramPost, User,
UserMessage)
from database.models import (
Admin,
AudioMessage,
BlacklistHistoryRecord,
BlacklistUser,
PostContent,
TelegramPost,
User,
UserMessage,
)
from database.repository_factory import RepositoryFactory
@@ -34,10 +42,10 @@ class AsyncBotDB:
user = await self.factory.users.get_user_info(user_id)
if user:
return {
'username': user.username,
'full_name': user.full_name,
'has_stickers': user.has_stickers,
'emoji': user.emoji
"username": user.username,
"full_name": user.full_name,
"has_stickers": user.has_stickers,
"emoji": user.emoji,
}
return None
@@ -53,7 +61,9 @@ class AsyncBotDB:
"""Возвращает full_name пользователя."""
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 = await self.get_username(user_id)
full_name = await self.get_full_name_by_id(user_id)
@@ -79,7 +89,9 @@ class AsyncBotDB:
"""Обновление даты последнего изменения пользователя."""
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)
@@ -108,17 +120,20 @@ class AsyncBotDB:
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:
from datetime import datetime
date = int(datetime.now().timestamp())
message = UserMessage(
message_text=message_text,
user_id=user_id,
telegram_message_id=message_id,
date=date
date=date,
)
await self.factory.messages.add_message(message)
@@ -135,39 +150,61 @@ class AsyncBotDB:
"""Обновление helper сообщения."""
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:
"""Добавляет связь между post_id и message_id в таблицу message_link_to_content."""
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."""
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-сервисом)."""
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."""
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 для опубликованного поста."""
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)
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."""
return await self.factory.posts.get_post_text_by_helper_id(last_post_id)
@@ -175,7 +212,9 @@ class AsyncBotDB:
"""Алиас для get_post_text_from_telegram_by_last_id (используется callback-сервисом)."""
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."""
return await self.factory.posts.get_post_ids_by_helper_id(last_post_id)
@@ -187,17 +226,29 @@ class AsyncBotDB:
"""Получает 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."""
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."""
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."""
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:
"""Обновление статуса поста по message_id (одиночные посты). Возвращает число обновлённых строк."""
@@ -288,20 +339,30 @@ class AsyncBotDB:
"""Проверяет, существует ли запись с данным user_id в blacklist."""
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)
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]:
"""Возвращает список пользователей в черном списке."""
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)
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]:
"""Возвращает информацию о пользователе в черном списке по user_id."""
@@ -314,9 +375,13 @@ class AsyncBotDB:
"""Получение количества пользователей в черном списке."""
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"):
@@ -337,19 +402,27 @@ class AsyncBotDB:
return await self.factory.admins.get_all_admins()
# Методы для работы с аудио
async def add_audio_record(self, file_name: str, author_id: int, date_added: str,
listen_count: int, file_id: str):
async def add_audio_record(
self,
file_name: str,
author_id: int,
date_added: str,
listen_count: int,
file_id: str,
):
"""Добавляет информацию о войсе пользователя."""
audio = AudioMessage(
file_name=file_name,
author_id=author_id,
date_added=date_added,
listen_count=listen_count,
file_id=file_id
file_id=file_id,
)
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)
@@ -399,13 +472,21 @@ class AsyncBotDB:
return await self.factory.audio.get_date_by_file_name(file_name)
# Методы для 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."""
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."""
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:
"""Удаляет запись из таблицы audio_moderate по message_id."""
@@ -447,7 +528,9 @@ class AsyncBotDB:
# Соединения закрываются в каждом методе
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 запрос и возвращает один результат."""
try:
async with aiosqlite.connect(self.factory.db_path) as conn:

View File

@@ -2,6 +2,7 @@ import os
from typing import Optional
import aiosqlite
from logs.custom_logger import logger
@@ -11,7 +12,7 @@ class DatabaseConnection:
def __init__(self, db_path: str):
self.db_path = os.path.abspath(db_path)
self.logger = logger
self.logger.info(f'Инициация базы данных: {self.db_path}')
self.logger.info(f"Инициация базы данных: {self.db_path}")
async def _get_connection(self):
"""Получение асинхронного соединения с базой данных."""
@@ -90,7 +91,9 @@ class DatabaseConnection:
await conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
self.logger.info("WAL файлы очищены")
else:
self.logger.warning(f"Проблемы с целостностью базы данных: {integrity_result}")
self.logger.warning(
f"Проблемы с целостностью базы данных: {integrity_result}"
)
except Exception as e:
self.logger.error(f"Ошибка при проверке целостности базы данных: {e}")

View File

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

View File

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

View File

@@ -12,14 +12,14 @@ class AdminRepository(DatabaseConnection):
# Включаем поддержку внешних ключей
await self._execute_query("PRAGMA foreign_keys = ON")
query = '''
query = """
CREATE TABLE IF NOT EXISTS admins (
user_id INTEGER NOT NULL PRIMARY KEY,
role TEXT DEFAULT 'admin',
created_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
)
'''
"""
await self._execute_query(query)
self.logger.info("Таблица администраторов создана")
@@ -29,7 +29,9 @@ class AdminRepository(DatabaseConnection):
params = (admin.user_id, admin.role)
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:
"""Удаление администратора."""
@@ -52,9 +54,7 @@ class AdminRepository(DatabaseConnection):
if row:
return Admin(
user_id=row[0],
role=row[1],
created_at=row[2] if len(row) > 2 else None
user_id=row[0], role=row[1], created_at=row[2] if len(row) > 2 else None
)
return None
@@ -66,9 +66,7 @@ class AdminRepository(DatabaseConnection):
admins = []
for row in rows:
admin = Admin(
user_id=row[0],
role=row[1],
created_at=row[2] if len(row) > 2 else None
user_id=row[0], role=row[1], created_at=row[2] if len(row) > 2 else None
)
admins.append(admin)

View File

@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from database.base import DatabaseConnection
@@ -15,7 +15,7 @@ class AudioRepository(DatabaseConnection):
async def create_tables(self):
"""Создание таблиц для аудио."""
# Таблица аудио сообщений
audio_query = '''
audio_query = """
CREATE TABLE IF NOT EXISTS audio_message_reference (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
file_name TEXT NOT NULL UNIQUE,
@@ -23,29 +23,29 @@ class AudioRepository(DatabaseConnection):
date_added INTEGER NOT NULL,
FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE
)
'''
"""
await self._execute_query(audio_query)
# Таблица прослушивания аудио
listen_query = '''
listen_query = """
CREATE TABLE IF NOT EXISTS user_audio_listens (
file_name TEXT NOT NULL,
user_id INTEGER NOT NULL,
PRIMARY KEY (file_name, user_id),
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
)
'''
"""
await self._execute_query(listen_query)
# Таблица для voice bot
voice_query = '''
voice_query = """
CREATE TABLE IF NOT EXISTS audio_moderate (
user_id INTEGER NOT NULL,
message_id INTEGER,
PRIMARY KEY (user_id, message_id),
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
)
'''
"""
await self._execute_query(voice_query)
self.logger.info("Таблицы для аудио созданы")
@@ -67,9 +67,13 @@ class AudioRepository(DatabaseConnection):
params = (audio.file_name, audio.author_id, date_timestamp)
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 = """
INSERT INTO audio_message_reference (file_name, author_id, date_added)
@@ -127,7 +131,9 @@ class AudioRepository(DatabaseConnection):
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,))
# Находим непрослушанные
@@ -135,7 +141,9 @@ class AudioRepository(DatabaseConnection):
all_set = {row[0] for row in all_files}
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
async def mark_listened_audio(self, file_name: str, user_id: int) -> None:
@@ -144,7 +152,9 @@ class AudioRepository(DatabaseConnection):
params = (file_name, user_id)
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]:
"""Получает user_id пользователя по имени файла."""
@@ -166,8 +176,10 @@ class AudioRepository(DatabaseConnection):
if row:
date_added = row[0]
# Преобразуем UNIX timestamp в читаемую дату
readable_date = datetime.fromtimestamp(date_added).strftime('%d.%m.%Y %H:%M')
# Преобразуем UNIX timestamp в читаемую дату (UTC для одинакового результата везде)
readable_date = datetime.fromtimestamp(
date_added, tz=timezone.utc
).strftime("%d.%m.%Y %H:%M")
self.logger.info(f"Получена дата {readable_date} для файла {file_name}")
return readable_date
return None
@@ -185,20 +197,26 @@ class AudioRepository(DatabaseConnection):
self.logger.info(f"Удалены записи прослушивания для пользователя {user_id}")
# Методы для 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."""
try:
query = "INSERT OR IGNORE INTO audio_moderate (user_id, message_id) VALUES (?, ?)"
params = (user_id, message_id)
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
except Exception as e:
self.logger.error(f"Ошибка установки связи: {e}")
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."""
query = "SELECT user_id FROM audio_moderate WHERE message_id = ?"
rows = await self._execute_query_with_result(query, (message_id,))
@@ -214,7 +232,9 @@ class AudioRepository(DatabaseConnection):
"""Удаляет запись из таблицы audio_moderate по message_id."""
query = "DELETE FROM audio_moderate WHERE 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]]:
"""Получить все записи аудио сообщений."""
@@ -223,11 +243,9 @@ class AudioRepository(DatabaseConnection):
records = []
for row in rows:
records.append({
'file_name': row[0],
'author_id': row[1],
'date_added': row[2]
})
records.append(
{"file_name": row[0], "author_id": row[1], "date_added": row[2]}
)
self.logger.info(f"Получено {len(records)} записей аудио сообщений")
return records

View File

@@ -9,7 +9,7 @@ class BlacklistHistoryRepository(DatabaseConnection):
async def create_tables(self):
"""Создание таблицы истории банов/разбанов."""
query = '''
query = """
CREATE TABLE IF NOT EXISTS blacklist_history (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
@@ -22,7 +22,7 @@ class BlacklistHistoryRepository(DatabaseConnection):
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
)
'''
"""
await self._execute_query(query)
# Создаем индексы
@@ -48,6 +48,7 @@ class BlacklistHistoryRepository(DatabaseConnection):
"""
# Используем текущее время, если не указано
from datetime import datetime
current_timestamp = int(datetime.now().timestamp())
params = (
@@ -79,6 +80,7 @@ class BlacklistHistoryRepository(DatabaseConnection):
"""
try:
from datetime import datetime
current_timestamp = int(datetime.now().timestamp())
# SQLite не поддерживает ORDER BY в UPDATE, поэтому используем подзапрос

View File

@@ -9,7 +9,7 @@ class BlacklistRepository(DatabaseConnection):
async def create_tables(self):
"""Создание таблицы черного списка."""
query = '''
query = """
CREATE TABLE IF NOT EXISTS blacklist (
user_id INTEGER NOT NULL PRIMARY KEY,
message_for_user TEXT,
@@ -19,7 +19,7 @@ class BlacklistRepository(DatabaseConnection):
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
)
'''
"""
await self._execute_query(query)
self.logger.info("Таблица черного списка создана")
@@ -37,18 +37,24 @@ class BlacklistRepository(DatabaseConnection):
)
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:
"""Удаляет пользователя из черного списка."""
try:
query = "DELETE FROM blacklist WHERE user_id = ?"
await self._execute_query(query, (user_id,))
self.logger.info(f"Пользователь с идентификатором {user_id} успешно удален из черного списка.")
self.logger.info(
f"Пользователь с идентификатором {user_id} успешно удален из черного списка."
)
return True
except Exception as e:
self.logger.error(f"Ошибка удаления пользователя с идентификатором {user_id} "
f"из таблицы blacklist. Ошибка: {str(e)}")
self.logger.error(
f"Ошибка удаления пользователя с идентификатором {user_id} "
f"из таблицы blacklist. Ошибка: {str(e)}"
)
return False
async def user_exists(self, user_id: int) -> bool:
@@ -78,7 +84,9 @@ class BlacklistRepository(DatabaseConnection):
)
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 = """
SELECT user_id, message_for_user, date_to_unban, created_at, ban_author
@@ -99,7 +107,9 @@ class BlacklistRepository(DatabaseConnection):
)
)
self.logger.info(f"Получен список пользователей в черном списке (offset={offset}, limit={limit}): {len(users)}")
self.logger.info(
f"Получен список пользователей в черном списке (offset={offset}, limit={limit}): {len(users)}"
)
return users
async def get_all_users_no_limit(self) -> List[BlacklistUser]:
@@ -122,10 +132,14 @@ class BlacklistRepository(DatabaseConnection):
)
)
self.logger.info(f"Получен список всех пользователей в черном списке: {len(users)}")
self.logger.info(
f"Получен список всех пользователей в черном списке: {len(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 <= ?"
rows = await self._execute_query_with_result(query, (current_timestamp,))

View File

@@ -10,7 +10,7 @@ class MessageRepository(DatabaseConnection):
async def create_tables(self):
"""Создание таблицы сообщений пользователей."""
query = '''
query = """
CREATE TABLE IF NOT EXISTS user_messages (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
message_text TEXT,
@@ -19,7 +19,7 @@ class MessageRepository(DatabaseConnection):
date INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
)
'''
"""
await self._execute_query(query)
self.logger.info("Таблица сообщений пользователей создана")
@@ -32,10 +32,17 @@ class MessageRepository(DatabaseConnection):
INSERT INTO user_messages (message_text, user_id, telegram_message_id, date)
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)
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]:
"""Получение пользователя по message_id."""

View File

@@ -1,5 +1,7 @@
"""Репозиторий для работы с миграциями базы данных."""
import aiosqlite
from database.base import DatabaseConnection
@@ -23,7 +25,9 @@ class MigrationRepository(DatabaseConnection):
conn = None
try:
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()
await cursor.close()
return [row[0] for row in rows]
@@ -40,8 +44,7 @@ class MigrationRepository(DatabaseConnection):
try:
conn = await self._get_connection()
cursor = await conn.execute(
"SELECT COUNT(*) FROM migrations WHERE script_name = ?",
(script_name,)
"SELECT COUNT(*) FROM migrations WHERE script_name = ?", (script_name,)
)
row = await cursor.fetchone()
await cursor.close()
@@ -59,8 +62,7 @@ class MigrationRepository(DatabaseConnection):
try:
conn = await self._get_connection()
await conn.execute(
"INSERT INTO migrations (script_name) VALUES (?)",
(script_name,)
"INSERT INTO migrations (script_name) VALUES (?)", (script_name,)
)
await conn.commit()
self.logger.info(f"Миграция {script_name} отмечена как примененная")

View File

@@ -11,7 +11,7 @@ class PostRepository(DatabaseConnection):
async def create_tables(self):
"""Создание таблиц для постов."""
# Таблица постов из Telegram
post_query = '''
post_query = """
CREATE TABLE IF NOT EXISTS post_from_telegram_suggest (
message_id INTEGER NOT NULL PRIMARY KEY,
text TEXT,
@@ -23,7 +23,7 @@ class PostRepository(DatabaseConnection):
published_message_id INTEGER,
FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE
)
'''
"""
await self._execute_query(post_query)
# Добавляем поле published_message_id если его нет (для существующих БД)
@@ -34,19 +34,27 @@ class PostRepository(DatabaseConnection):
"""
existing_columns = await self._execute_query_with_result(check_column_query)
if not existing_columns:
await self._execute_query('ALTER TABLE post_from_telegram_suggest ADD COLUMN published_message_id INTEGER')
self.logger.info("Столбец published_message_id добавлен в post_from_telegram_suggest")
await self._execute_query(
"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:
# Если проверка не удалась, пытаемся добавить столбец (может быть уже существует)
try:
await self._execute_query('ALTER TABLE post_from_telegram_suggest ADD COLUMN published_message_id INTEGER')
self.logger.info("Столбец published_message_id добавлен в post_from_telegram_suggest (fallback)")
await self._execute_query(
"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:
# Столбец уже существует, игнорируем ошибку
pass
# Таблица контента постов
content_query = '''
content_query = """
CREATE TABLE IF NOT EXISTS content_post_from_telegram (
message_id INTEGER NOT NULL,
content_name TEXT NOT NULL,
@@ -54,22 +62,22 @@ class PostRepository(DatabaseConnection):
PRIMARY KEY (message_id, content_name),
FOREIGN KEY (message_id) REFERENCES post_from_telegram_suggest (message_id) ON DELETE CASCADE
)
'''
"""
await self._execute_query(content_query)
# Таблица связи сообщений с контентом
link_query = '''
link_query = """
CREATE TABLE IF NOT EXISTS message_link_to_content (
post_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
PRIMARY KEY (post_id, message_id),
FOREIGN KEY (post_id) REFERENCES post_from_telegram_suggest (message_id) ON DELETE CASCADE
)
'''
"""
await self._execute_query(link_query)
# Таблица контента опубликованных постов
published_content_query = '''
published_content_query = """
CREATE TABLE IF NOT EXISTS published_post_content (
published_message_id INTEGER NOT NULL,
content_name TEXT NOT NULL,
@@ -77,13 +85,17 @@ class PostRepository(DatabaseConnection):
published_at INTEGER NOT NULL,
PRIMARY KEY (published_message_id, content_name)
)
'''
"""
await self._execute_query(published_content_query)
# Создаем индексы
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('CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published ON post_from_telegram_suggest(published_message_id)')
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(
"CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published ON post_from_telegram_suggest(published_message_id)"
)
except Exception:
# Индексы уже существуют, игнорируем ошибку
pass
@@ -96,19 +108,32 @@ class PostRepository(DatabaseConnection):
post.created_at = int(datetime.now().timestamp())
status = post.status if post.status else "suggest"
# Преобразуем 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 чтобы избежать ошибок при повторном создании
query = """
INSERT OR IGNORE INTO post_from_telegram_suggest (message_id, text, author_id, created_at, status, is_anonymous)
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)
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 сообщения."""
query = "UPDATE post_from_telegram_suggest SET helper_text_message_id = ? WHERE 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}"
)
else:
self.logger.info(f"Статус поста message_id={message_id} обновлён на {status}")
self.logger.info(
f"Статус поста message_id={message_id} обновлён на {status}"
)
return n
except Exception as e:
if conn:
await conn.rollback()
self.logger.error(f"Ошибка при обновлении статуса message_id={message_id}: {e}")
self.logger.error(
f"Ошибка при обновлении статуса message_id={message_id}: {e}"
)
raise
finally:
if conn:
@@ -182,7 +211,9 @@ class PostRepository(DatabaseConnection):
if conn:
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:
# Сначала добавляем связь
@@ -194,9 +225,13 @@ class PostRepository(DatabaseConnection):
INSERT OR IGNORE INTO content_post_from_telegram (message_id, content_name, content_type)
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
except Exception as e:
self.logger.error(f"Ошибка при добавлении контента поста: {e}")
@@ -205,16 +240,24 @@ class PostRepository(DatabaseConnection):
async def add_message_link(self, post_id: int, message_id: int) -> bool:
"""Добавляет связь между post_id и message_id в таблицу message_link_to_content."""
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 (?, ?)"
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
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
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."""
query = """
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
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)} элементов")
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."""
query = """
SELECT cpft.content_name, cpft.content_type
@@ -239,7 +286,9 @@ class PostRepository(DatabaseConnection):
"""
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
async def get_post_text_by_helper_id(self, helper_message_id: int) -> Optional[str]:
@@ -249,7 +298,9 @@ class PostRepository(DatabaseConnection):
row = rows[0] if rows else None
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 None
@@ -275,11 +326,15 @@ class PostRepository(DatabaseConnection):
if row:
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 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."""
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,))
@@ -287,11 +342,15 @@ class PostRepository(DatabaseConnection):
if row:
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 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."""
query = "SELECT text, is_anonymous FROM post_from_telegram_suggest WHERE message_id = ?"
rows = await self._execute_query_with_result(query, (message_id,))
@@ -302,11 +361,15 @@ class PostRepository(DatabaseConnection):
is_anonymous_int = row[1]
# Преобразуем int в bool (1 -> True, 0 -> False, NULL -> None)
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 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."""
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,))
@@ -317,15 +380,21 @@ class PostRepository(DatabaseConnection):
is_anonymous_int = row[1]
# Преобразуем int в bool (1 -> True, 0 -> False, NULL -> None)
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 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 для опубликованного поста."""
query = "UPDATE post_from_telegram_suggest SET published_message_id = ? WHERE 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(
self, published_message_id: int, content_path: str, content_type: str
@@ -333,6 +402,7 @@ class PostRepository(DatabaseConnection):
"""Добавляет контент опубликованного поста."""
try:
from datetime import datetime
published_at = int(datetime.now().timestamp())
query = """
@@ -340,22 +410,34 @@ class PostRepository(DatabaseConnection):
(published_message_id, content_name, content_type, published_at)
VALUES (?, ?, ?, ?)
"""
await self._execute_query(query, (published_message_id, content_path, content_type, published_at))
self.logger.info(f"Добавлен контент опубликованного поста: published_message_id={published_message_id}, type={content_type}")
await self._execute_query(
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
except Exception as e:
self.logger.error(f"Ошибка при добавлении контента опубликованного поста: {e}")
self.logger.error(
f"Ошибка при добавлении контента опубликованного поста: {e}"
)
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 = """
SELECT content_name, content_type
FROM published_post_content
WHERE published_message_id = ?
"""
post_content = await self._execute_query_with_result(query, (published_message_id,))
self.logger.info(f"Получен контент опубликованного поста: {len(post_content)} элементов для published_message_id={published_message_id}")
post_content = await self._execute_query_with_result(
query, (published_message_id,)
)
self.logger.info(
f"Получен контент опубликованного поста: {len(post_content)} элементов для published_message_id={published_message_id}"
)
return post_content
# ============================================
@@ -379,7 +461,9 @@ class PostRepository(DatabaseConnection):
self.logger.info(f"ML-скоры обновлены для message_id={message_id}")
return True
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
async def get_ml_scores_by_message_id(self, message_id: int) -> Optional[str]:
@@ -461,4 +545,3 @@ class PostRepository(DatabaseConnection):
texts = [row[0] for row in rows if row[0]]
self.logger.info(f"Получено {len(texts)} отклоненных постов для обучения")
return texts

View File

@@ -10,7 +10,7 @@ class UserRepository(DatabaseConnection):
async def create_tables(self):
"""Создание таблицы пользователей."""
query = '''
query = """
CREATE TABLE IF NOT EXISTS our_users (
user_id INTEGER NOT NULL PRIMARY KEY,
first_name TEXT,
@@ -24,7 +24,7 @@ class UserRepository(DatabaseConnection):
date_changed INTEGER NOT NULL,
voice_bot_welcome_received BOOLEAN DEFAULT 0
)
'''
"""
await self._execute_query(query)
self.logger.info("Таблица пользователей создана")
@@ -32,7 +32,9 @@ class UserRepository(DatabaseConnection):
"""Проверяет, существует ли пользователь в базе данных."""
query = "SELECT user_id FROM our_users WHERE 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))
async def add_user(self, user: User) -> None:
@@ -47,12 +49,24 @@ class UserRepository(DatabaseConnection):
language_code, emoji, has_stickers, date_added, date_changed, voice_bot_welcome_received)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
params = (user.user_id, 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)
params = (
user.user_id,
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)
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]:
"""Получение информации о пользователе."""
@@ -67,7 +81,7 @@ class UserRepository(DatabaseConnection):
full_name=row[1],
username=row[0],
has_stickers=bool(row[2]) if row[2] is not None else False,
emoji=row[3]
emoji=row[3],
)
return None
@@ -89,7 +103,7 @@ class UserRepository(DatabaseConnection):
emoji=row[7],
date_added=row[8],
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
@@ -101,7 +115,9 @@ class UserRepository(DatabaseConnection):
if row:
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 None
@@ -113,7 +129,9 @@ class UserRepository(DatabaseConnection):
if row:
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 None
@@ -125,7 +143,9 @@ class UserRepository(DatabaseConnection):
if row:
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 None
@@ -137,7 +157,9 @@ class UserRepository(DatabaseConnection):
if row:
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 None
@@ -161,7 +183,9 @@ class UserRepository(DatabaseConnection):
query = "UPDATE our_users SET date_changed = ? WHERE 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:
query = "UPDATE our_users SET username = ?, full_name = ? WHERE user_id = ?"
@@ -217,7 +241,9 @@ class UserRepository(DatabaseConnection):
if row and 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)
else:
self.logger.info(f"Эмодзи пользователя не найден: user_id={user_id}")
@@ -243,16 +269,22 @@ class UserRepository(DatabaseConnection):
if row:
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 False
async def mark_voice_bot_welcome_received(self, user_id: int) -> bool:
"""Отмечает, что пользователь получил приветственное сообщение от voice_bot."""
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,))
self.logger.info(f"Пользователь {user_id} отмечен как получивший приветствие")
self.logger.info(
f"Пользователь {user_id} отмечен как получивший приветствие"
)
return True
except Exception as 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.audio_repository import AudioRepository
from database.repositories.blacklist_history_repository import \
BlacklistHistoryRepository
from database.repositories.blacklist_history_repository import (
BlacklistHistoryRepository,
)
from database.repositories.blacklist_repository import BlacklistRepository
from database.repositories.message_repository import MessageRepository
from database.repositories.migration_repository import MigrationRepository

View File

@@ -1,6 +1,7 @@
"""
Конфигурация для rate limiting
"""
from dataclasses import dataclass
from typing import Optional
@@ -27,7 +28,9 @@ class RateLimitSettings:
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 сообщений подряд глобально
@@ -37,7 +40,7 @@ DEVELOPMENT_CONFIG = RateLimitSettings(
burst_limit=3,
retry_after_multiplier=1.2,
max_retry_delay=15.0,
max_retries=2
max_retries=2,
)
PRODUCTION_CONFIG = RateLimitSettings(
@@ -48,7 +51,7 @@ PRODUCTION_CONFIG = RateLimitSettings(
max_retries=3,
voice_message_delay=2.5,
media_message_delay=2.0,
text_message_delay=1.5
text_message_delay=1.5,
)
STRICT_CONFIG = RateLimitSettings(
@@ -59,7 +62,7 @@ STRICT_CONFIG = RateLimitSettings(
max_retries=5,
voice_message_delay=3.0,
media_message_delay=2.5,
text_message_delay=2.0
text_message_delay=2.0,
)
@@ -76,15 +79,14 @@ def get_rate_limit_config(environment: str = "production") -> RateLimitSettings:
configs = {
"development": DEVELOPMENT_CONFIG,
"production": PRODUCTION_CONFIG,
"strict": STRICT_CONFIG
"strict": STRICT_CONFIG,
}
return configs.get(environment, PRODUCTION_CONFIG)
def get_adaptive_config(
current_error_rate: float,
base_config: Optional[RateLimitSettings] = None
current_error_rate: float, base_config: Optional[RateLimitSettings] = None
) -> RateLimitSettings:
"""
Получает адаптивную конфигурацию на основе текущего уровня ошибок
@@ -109,7 +111,7 @@ def get_adaptive_config(
max_retries=base_config.max_retries + 1,
voice_message_delay=base_config.voice_message_delay * 1.5,
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,
)
# Если уровень ошибок низкий, можно немного ослабить ограничения
@@ -122,7 +124,7 @@ def get_adaptive_config(
max_retries=max(1, base_config.max_retries - 1),
voice_message_delay=base_config.voice_message_delay * 0.8,
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,
)
# Возвращаем базовую конфигурацию

View File

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

View File

@@ -1,22 +1,30 @@
from aiogram import F, Router, types
from aiogram.filters import Command, MagicData, StateFilter
from aiogram.fsm.context import FSMContext
from helper_bot.filters.main import ChatTypeFilter
from helper_bot.handlers.admin.dependencies import AdminAccessMiddleware
from helper_bot.handlers.admin.exceptions import (InvalidInputError,
UserAlreadyBannedError)
from helper_bot.handlers.admin.exceptions import (
InvalidInputError,
UserAlreadyBannedError,
)
from helper_bot.handlers.admin.services import AdminService
from helper_bot.handlers.admin.utils import (escape_html,
from helper_bot.handlers.admin.utils import (
escape_html,
format_ban_confirmation,
format_user_info,
handle_admin_error,
return_to_admin_menu)
from helper_bot.keyboards.keyboards import (create_keyboard_for_approve_ban,
return_to_admin_menu,
)
from helper_bot.keyboards.keyboards import (
create_keyboard_for_approve_ban,
create_keyboard_for_ban_days,
create_keyboard_for_ban_reason,
create_keyboard_with_pagination,
get_reply_keyboard_admin)
get_reply_keyboard_admin,
)
from helper_bot.utils.base_dependency_factory import get_global_instance
# Local imports - metrics
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
from logs.custom_logger import logger
@@ -30,23 +38,19 @@ admin_router.message.middleware(AdminAccessMiddleware())
# ХЕНДЛЕРЫ МЕНЮ
# ============================================================================
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
Command('admin')
)
@admin_router.message(ChatTypeFilter(chat_type=["private"]), Command("admin"))
@track_time("admin_panel", "admin_handlers")
@track_errors("admin_handlers", "admin_panel")
async def admin_panel(
message: types.Message,
state: FSMContext,
**kwargs
):
async def admin_panel(message: types.Message, state: FSMContext, **kwargs):
"""Главное меню администратора"""
try:
await state.set_state("ADMIN")
logger.info(f"Запуск админ панели для пользователя: {message.from_user.id}")
markup = get_reply_keyboard_admin()
await message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup)
await message.answer(
"Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup
)
except Exception as e:
await handle_admin_error(message, e, state, "admin_panel")
@@ -55,18 +59,20 @@ async def admin_panel(
# ХЕНДЛЕР ОТМЕНЫ
# ============================================================================
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("AWAIT_BAN_TARGET", "AWAIT_BAN_DETAILS", "AWAIT_BAN_DURATION", "BAN_CONFIRMATION"),
F.text == 'Отменить'
StateFilter(
"AWAIT_BAN_TARGET",
"AWAIT_BAN_DETAILS",
"AWAIT_BAN_DURATION",
"BAN_CONFIRMATION",
),
F.text == "Отменить",
)
@track_time("cancel_ban_process", "admin_handlers")
@track_errors("admin_handlers", "cancel_ban_process")
async def cancel_ban_process(
message: types.Message,
state: FSMContext,
**kwargs
):
async def cancel_ban_process(message: types.Message, state: FSMContext, **kwargs):
"""Отмена процесса блокировки"""
try:
current_state = await state.get_state()
@@ -79,32 +85,31 @@ async def cancel_ban_process(
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("ADMIN"),
F.text == 'Бан (Список)'
F.text == "Бан (Список)",
)
@track_time("get_last_users", "admin_handlers")
@track_errors("admin_handlers", "get_last_users")
@db_query_time("get_last_users", "users", "select")
async def get_last_users(
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db")
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
):
"""Получение списка последних пользователей"""
try:
logger.info(f"Получение списка последних пользователей. Пользователь: {message.from_user.full_name}")
logger.info(
f"Получение списка последних пользователей. Пользователь: {message.from_user.full_name}"
)
admin_service = AdminService(bot_db)
users = await admin_service.get_last_users()
# Преобразуем в формат для клавиатуры (кортежи как ожидает create_keyboard_with_pagination)
users_data = [
(user.full_name, user.user_id)
for user in users
]
users_data = [(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(
text="Список пользователей которые последними обращались к боту",
reply_markup=keyboard
reply_markup=keyboard,
)
except Exception as e:
await handle_admin_error(message, e, state, "get_last_users")
@@ -113,27 +118,31 @@ async def get_last_users(
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("ADMIN"),
F.text == 'Разбан (список)'
F.text == "Разбан (список)",
)
@track_time("get_banned_users", "admin_handlers")
@track_errors("admin_handlers", "get_banned_users")
@db_query_time("get_banned_users", "users", "select")
async def get_banned_users(
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db")
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
):
"""Получение списка заблокированных пользователей"""
try:
logger.info(f"Получение списка заблокированных пользователей. Пользователь: {message.from_user.full_name}")
logger.info(
f"Получение списка заблокированных пользователей. Пользователь: {message.from_user.full_name}"
)
admin_service = AdminService(bot_db)
message_text, buttons_list = await admin_service.get_banned_users_for_display(0)
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)
else:
await message.answer(text="В списке заблокированных пользователей никого нет")
await message.answer(
text="В списке заблокированных пользователей никого нет"
)
except Exception as e:
await handle_admin_error(message, e, state, "get_banned_users")
@@ -141,24 +150,24 @@ async def get_banned_users(
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("ADMIN"),
F.text == '📊 ML Статистика'
F.text == "📊 ML Статистика",
)
@track_time("get_ml_stats", "admin_handlers")
@track_errors("admin_handlers", "get_ml_stats")
async def get_ml_stats(
message: types.Message,
state: FSMContext,
**kwargs
):
async def get_ml_stats(message: types.Message, state: FSMContext, **kwargs):
"""Получение статистики ML-скоринга"""
try:
logger.info(f"Запрос ML статистики от пользователя: {message.from_user.full_name}")
logger.info(
f"Запрос ML статистики от пользователя: {message.from_user.full_name}"
)
bdf = get_global_instance()
scoring_manager = bdf.get_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
stats = await scoring_manager.get_stats()
@@ -175,8 +184,10 @@ async def get_ml_stats(
if "model_loaded" in rag or "vector_store" in rag:
# Данные из API /stats
if "model_loaded" in rag:
model_loaded = rag.get('model_loaded', False)
lines.append(f" • Модель загружена: {'' if model_loaded else ''}")
model_loaded = rag.get("model_loaded", False)
lines.append(
f" • Модель загружена: {'' if model_loaded else ''}"
)
if "model_name" in rag:
lines.append(f" • Модель: {rag.get('model_name', 'N/A')}")
if "device" in rag:
@@ -194,14 +205,20 @@ async def get_ml_stats(
lines.append(f"Всего примеров: {total_count}")
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:
lines.append(f" • Макс. примеров: {vector_store.get('max_examples', 'N/A')}")
lines.append(
f" • Макс. примеров: {vector_store.get('max_examples', 'N/A')}"
)
else:
# Fallback на синхронные данные (если API недоступен)
lines.append(f" • API URL: {rag.get('api_url', 'N/A')}")
if "enabled" in rag:
lines.append(f" • Статус: {'✅ Включен' if rag.get('enabled') else '❌ Отключен'}")
lines.append(
f" • Статус: {'✅ Включен' if rag.get('enabled') else '❌ Отключен'}"
)
lines.append("")
@@ -209,7 +226,9 @@ async def get_ml_stats(
if "deepseek" in stats:
ds = stats["deepseek"]
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('timeout', 'N/A')}с")
lines.append("")
@@ -229,68 +248,80 @@ async def get_ml_stats(
# ХЕНДЛЕРЫ ПРОЦЕССА БАНА
# ============================================================================
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("ADMIN"),
F.text.in_(['Бан по нику', 'Бан по ID'])
F.text.in_(["Бан по нику", "Бан по ID"]),
)
@track_time("start_ban_process", "admin_handlers")
@track_errors("admin_handlers", "start_ban_process")
async def start_ban_process(
message: types.Message,
state: FSMContext,
**kwargs
):
async def start_ban_process(message: types.Message, state: FSMContext, **kwargs):
"""Начало процесса блокировки пользователя"""
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)
prompt_text = "Пришли мне username блокируемого пользователя" if ban_type == "username" else "Пришли мне ID блокируемого пользователя"
prompt_text = (
"Пришли мне username блокируемого пользователя"
if ban_type == "username"
else "Пришли мне ID блокируемого пользователя"
)
await message.answer(prompt_text)
await state.set_state('AWAIT_BAN_TARGET')
await state.set_state("AWAIT_BAN_TARGET")
except Exception as e:
await handle_admin_error(message, e, state, "start_ban_process")
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("AWAIT_BAN_TARGET")
ChatTypeFilter(chat_type=["private"]), StateFilter("AWAIT_BAN_TARGET")
)
@track_time("process_ban_target", "admin_handlers")
@track_errors("admin_handlers", "process_ban_target")
async def process_ban_target(
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db")
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
):
"""Обработка введенного 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:
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)
logger.info(f"process_ban_target: ban_type={ban_type}, user_data={user_data}")
# Определяем пользователя
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)
if not user:
logger.warning(f"process_ban_target: Пользователь с username '{message.text}' не найден")
await message.answer(f"Пользователь с username '{escape_html(message.text)}' не найден.")
logger.warning(
f"process_ban_target: Пользователь с username '{message.text}' не найден"
)
await message.answer(
f"Пользователь с username '{escape_html(message.text)}' не найден."
)
await return_to_admin_menu(message, state)
return
else: # ban_type == "id"
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 = await admin_service.get_user_by_id(user_id)
if not user:
logger.warning(f"process_ban_target: Пользователь с ID {user_id} не найден в базе данных")
await message.answer(f"Пользователь с ID {user_id} не найден в базе данных.")
logger.warning(
f"process_ban_target: Пользователь с ID {user_id} не найден в базе данных"
)
await message.answer(
f"Пользователь с ID {user_id} не найден в базе данных."
)
await return_to_admin_menu(message, state)
return
except InvalidInputError as e:
@@ -299,25 +330,29 @@ async def process_ban_target(
await return_to_admin_menu(message, state)
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(
target_user_id=user.user_id,
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)
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(
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")
except Exception as e:
@@ -326,18 +361,15 @@ async def process_ban_target(
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("AWAIT_BAN_DETAILS")
ChatTypeFilter(chat_type=["private"]), StateFilter("AWAIT_BAN_DETAILS")
)
@track_time("process_ban_reason", "admin_handlers")
@track_errors("admin_handlers", "process_ban_reason")
async def process_ban_reason(
message: types.Message,
state: FSMContext,
**kwargs
):
async def process_ban_reason(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:
# Проверяем текущее состояние
@@ -348,18 +380,22 @@ async def process_ban_reason(
state_data = await state.get_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)
markup = create_keyboard_for_ban_days()
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(
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")
except Exception as e:
@@ -368,44 +404,41 @@ async def process_ban_reason(
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("AWAIT_BAN_DURATION")
ChatTypeFilter(chat_type=["private"]), StateFilter("AWAIT_BAN_DURATION")
)
@track_time("process_ban_duration", "admin_handlers")
@track_errors("admin_handlers", "process_ban_duration")
async def process_ban_duration(
message: types.Message,
state: FSMContext,
**kwargs
):
async def process_ban_duration(message: types.Message, state: FSMContext, **kwargs):
"""Обработка срока блокировки"""
try:
user_data = await state.get_data()
# Определяем срок блокировки
if message.text == 'Навсегда':
if message.text == "Навсегда":
ban_days = None
else:
try:
ban_days = int(message.text)
if ban_days <= 0:
await message.answer("Срок блокировки должен быть положительным числом.")
await message.answer(
"Срок блокировки должен быть положительным числом."
)
return
except ValueError:
await message.answer("Пожалуйста, введите корректное число дней или выберите 'Навсегда'.")
await message.answer(
"Пожалуйста, введите корректное число дней или выберите 'Навсегда'."
)
return
await state.update_data(ban_days=ban_days)
# Показываем подтверждение
confirmation_text = format_ban_confirmation(
user_data['target_user_id'],
user_data['ban_reason'],
ban_days
user_data["target_user_id"], user_data["ban_reason"], ban_days
)
markup = create_keyboard_for_approve_ban()
await message.answer(confirmation_text, reply_markup=markup)
await state.set_state('BAN_CONFIRMATION')
await state.set_state("BAN_CONFIRMATION")
except Exception as e:
await handle_admin_error(message, e, state, "process_ban_duration")
@@ -414,32 +447,28 @@ async def process_ban_duration(
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("BAN_CONFIRMATION"),
F.text == 'Подтвердить'
F.text == "Подтвердить",
)
@track_time("confirm_ban", "admin_handlers")
@track_errors("admin_handlers", "confirm_ban")
async def confirm_ban(
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
**kwargs
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db"), **kwargs
):
"""Подтверждение блокировки пользователя"""
try:
user_data = await state.get_data()
admin_service = AdminService(bot_db)
# Выполняем блокировку
await admin_service.ban_user(
user_id=user_data['target_user_id'],
username=user_data['target_username'],
reason=user_data['ban_reason'],
ban_days=user_data['ban_days'],
user_id=user_data["target_user_id"],
username=user_data["target_username"],
reason=user_data["ban_reason"],
ban_days=user_data["ban_days"],
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 return_to_admin_menu(message, state)

View File

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

View File

@@ -7,6 +7,7 @@ except ImportError:
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
from helper_bot.utils.base_dependency_factory import get_global_instance
from helper_bot.utils.helper_func import check_access
from logs.custom_logger import logger
@@ -15,27 +16,35 @@ from logs.custom_logger import logger
class AdminAccessMiddleware(BaseMiddleware):
"""Middleware для проверки административного доступа"""
async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any:
if hasattr(event, 'from_user'):
async def __call__(
self, handler, event: TelegramObject, data: Dict[str, Any]
) -> Any:
if hasattr(event, "from_user"):
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.get('bot_db')
bot_db = data.get("bot_db")
if not bot_db:
# Fallback: получаем напрямую если middleware не сработала
bdf = get_global_instance()
bot_db = bdf.get_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:
logger.warning(f"AdminAccessMiddleware: доступ запрещен для пользователя {username} (ID: {user_id})")
if hasattr(event, 'answer'):
await event.answer('Доступ запрещен!')
logger.warning(
f"AdminAccessMiddleware: доступ запрещен для пользователя {username} (ID: {user_id})"
)
if hasattr(event, "answer"):
await event.answer("Доступ запрещен!")
return
try:
@@ -43,7 +52,9 @@ class AdminAccessMiddleware(BaseMiddleware):
return await handler(event, data)
except TypeError as 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)
return await handler(event)
else:

View File

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

View File

@@ -1,25 +1,31 @@
"""
Обработчики команд для мониторинга rate limiting
"""
from aiogram import F, Router, types
from aiogram.filters import Command, MagicData
from aiogram.fsm.context import FSMContext
from aiogram.types import FSInputFile
from helper_bot.filters.main import ChatTypeFilter
from helper_bot.middlewares.dependencies_middleware import \
DependenciesMiddleware
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
# Local imports - metrics
from helper_bot.utils.metrics import track_errors, track_time
from helper_bot.utils.rate_limit_metrics import (
get_rate_limit_metrics_summary, update_rate_limit_gauges)
from helper_bot.utils.rate_limit_monitor import (get_rate_limit_summary,
rate_limit_monitor)
get_rate_limit_metrics_summary,
update_rate_limit_gauges,
)
from helper_bot.utils.rate_limit_monitor import (
get_rate_limit_summary,
rate_limit_monitor,
)
from logs.custom_logger import logger
class RateLimitHandlers:
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.router = Router()
self._setup_handlers()
@@ -33,28 +39,28 @@ class RateLimitHandlers:
self.router.message.register(
self.rate_limit_stats_handler,
ChatTypeFilter(chat_type=["private"]),
Command("ratelimit_stats")
Command("ratelimit_stats"),
)
# Команда для сброса статистики rate limiting
self.router.message.register(
self.reset_rate_limit_stats_handler,
ChatTypeFilter(chat_type=["private"]),
Command("reset_ratelimit_stats")
Command("reset_ratelimit_stats"),
)
# Команда для просмотра ошибок rate limiting
self.router.message.register(
self.rate_limit_errors_handler,
ChatTypeFilter(chat_type=["private"]),
Command("ratelimit_errors")
Command("ratelimit_errors"),
)
# Команда для просмотра Prometheus метрик
self.router.message.register(
self.rate_limit_prometheus_handler,
ChatTypeFilter(chat_type=["private"]),
Command("ratelimit_prometheus")
Command("ratelimit_prometheus"),
)
@track_time("rate_limit_stats_handler", "rate_limit_handlers")
@@ -64,7 +70,7 @@ class RateLimitHandlers:
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
settings: MagicData("settings"),
):
"""Показывает статистику rate limiting"""
try:
@@ -96,7 +102,9 @@ class RateLimitHandlers:
stats_text += f"• Неудачных запросов: {global_stats.failed_requests}\n"
stats_text += f"• RetryAfter ошибок: {global_stats.retry_after_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)
@@ -113,7 +121,7 @@ class RateLimitHandlers:
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"
await message.answer(stats_text, parse_mode='HTML')
await message.answer(stats_text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка при получении статистики rate limiting: {e}")
@@ -126,7 +134,7 @@ class RateLimitHandlers:
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
settings: MagicData("settings"),
):
"""Сбрасывает статистику rate limiting"""
try:
@@ -151,7 +159,7 @@ class RateLimitHandlers:
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
settings: MagicData("settings"),
):
"""Показывает недавние ошибки rate limiting"""
try:
@@ -165,7 +173,9 @@ class RateLimitHandlers:
error_summary = rate_limit_monitor.get_error_summary(60)
if not recent_errors:
await message.answer("✅ Ошибок rate limiting за последний час не было.")
await message.answer(
"✅ Ошибок rate limiting за последний час не было."
)
return
# Формируем сообщение с ошибками
@@ -179,7 +189,10 @@ class RateLimitHandlers:
errors_text += f"🔍 <b>Последние ошибки:</b>\n"
for i, error in enumerate(recent_errors[-10:], 1):
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"
# Если сообщение слишком длинное, разбиваем на части
@@ -191,22 +204,27 @@ class RateLimitHandlers:
summary_text += f"{error_type}: {count}\n"
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"
for i, error in enumerate(recent_errors[-10:], 1):
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"
await message.answer(details_text, parse_mode='HTML')
await message.answer(details_text, parse_mode="HTML")
else:
await message.answer(errors_text, parse_mode='HTML')
await message.answer(errors_text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка при получении ошибок rate limiting: {e}")
await message.answer("Произошла ошибка при получении информации об ошибках.")
await message.answer(
"Произошла ошибка при получении информации об ошибках."
)
@track_time("rate_limit_prometheus_handler", "rate_limit_handlers")
@track_errors("rate_limit_handlers", "rate_limit_prometheus_handler")
@@ -215,7 +233,7 @@ class RateLimitHandlers:
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
settings: MagicData("settings"),
):
"""Показывает Prometheus метрики rate limiting"""
try:
@@ -244,18 +262,28 @@ class RateLimitHandlers:
# Добавляем детальные метрики
metrics_text += f"🔍 <b>Детальные метрики:</b>\n"
metrics_text += f"• Успешных запросов: {metrics_summary['successful_requests']}\n"
metrics_text += f"Неудачных запросов: {metrics_summary['failed_requests']}\n"
metrics_text += f"• RetryAfter ошибок: {metrics_summary['retry_after_errors']}\n"
metrics_text += (
f"Успешных запросов: {metrics_summary['successful_requests']}\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['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"• rate_limit_requests_total - общее количество запросов\n"
metrics_text += f"• rate_limit_errors_total - количество ошибок по типам\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_success_rate - процент успеха по чатам\n"
metrics_text += f"• rate_limit_requests_per_minute - запросов в минуту\n"
@@ -263,7 +291,7 @@ class RateLimitHandlers:
metrics_text += f"• rate_limit_total_errors - количество ошибок\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:
logger.error(f"Ошибка при получении Prometheus метрик: {e}")

View File

@@ -1,11 +1,16 @@
from datetime import datetime
from typing import List, Optional
from helper_bot.handlers.admin.exceptions import (InvalidInputError,
UserAlreadyBannedError)
from helper_bot.utils.helper_func import (add_days_to_date,
from helper_bot.handlers.admin.exceptions import (
InvalidInputError,
UserAlreadyBannedError,
)
from helper_bot.utils.helper_func import (
add_days_to_date,
get_banned_users_buttons,
get_banned_users_list)
get_banned_users_list,
)
# Local imports - metrics
from helper_bot.utils.metrics import track_errors, track_time
from logs.custom_logger import logger
@@ -13,6 +18,7 @@ from logs.custom_logger import logger
class User:
"""Модель пользователя"""
def __init__(self, user_id: int, username: str, full_name: str):
self.user_id = user_id
self.username = username
@@ -21,7 +27,10 @@ class User:
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.username = username
self.reason = reason
@@ -41,11 +50,7 @@ class AdminService:
try:
users_data = await self.bot_db.get_last_users(30)
return [
User(
user_id=user[1],
username='Неизвестно',
full_name=user[0]
)
User(user_id=user[1], username="Неизвестно", full_name=user[0])
for user in users_data
]
except Exception as e:
@@ -66,15 +71,19 @@ class AdminService:
full_name = await self.bot_db.get_full_name_by_id(user_id)
user_name = username or full_name or f"User_{user_id}"
banned_users.append(BannedUser(
banned_users.append(
BannedUser(
user_id=user_id,
username=user_name,
reason=reason,
unban_date=unban_date
))
unban_date=unban_date,
)
)
return banned_users
except Exception as e:
logger.error(f"Ошибка при получении списка заблокированных пользователей: {e}")
logger.error(
f"Ошибка при получении списка заблокированных пользователей: {e}"
)
raise
@track_time("get_user_by_username", "admin_service")
@@ -88,9 +97,7 @@ class AdminService:
full_name = await self.bot_db.get_full_name_by_id(user_id)
return User(
user_id=user_id,
username=username,
full_name=full_name or 'Неизвестно'
user_id=user_id, username=username, full_name=full_name or "Неизвестно"
)
except Exception as e:
logger.error(f"Ошибка при поиске пользователя по username {username}: {e}")
@@ -107,8 +114,8 @@ class AdminService:
return User(
user_id=user_id,
username=user_info.username or 'Неизвестно',
full_name=user_info.full_name or 'Неизвестно'
username=user_info.username or "Неизвестно",
full_name=user_info.full_name or "Неизвестно",
)
except Exception as e:
logger.error(f"Ошибка при поиске пользователя по ID {user_id}: {e}")
@@ -116,7 +123,14 @@ class AdminService:
@track_time("ban_user", "admin_service")
@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:
# Проверяем, не заблокирован ли уже пользователь
@@ -129,9 +143,13 @@ class AdminService:
date_to_unban = add_days_to_date(ban_days)
# Сохраняем в БД (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:
logger.error(f"Ошибка при блокировке пользователя {user_id}: {e}")
@@ -155,7 +173,9 @@ class AdminService:
try:
user_id = int(input_text.strip())
if user_id <= 0:
raise InvalidInputError("ID пользователя должен быть положительным числом")
raise InvalidInputError(
"ID пользователя должен быть положительным числом"
)
return user_id
except ValueError:
raise InvalidInputError("ID пользователя должен быть числом")
@@ -170,5 +190,7 @@ class AdminService:
buttons_list = await get_banned_users_buttons(self.bot_db)
return message_text, buttons_list
except Exception as e:
logger.error(f"Ошибка при получении данных заблокированных пользователей: {e}")
logger.error(
f"Ошибка при получении данных заблокированных пользователей: {e}"
)
raise

View File

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

View File

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

View File

@@ -7,28 +7,49 @@ from aiogram import F, Router
from aiogram.filters import MagicData
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
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.services import AudioFileService
from helper_bot.keyboards.keyboards import (create_keyboard_for_ban_reason,
from helper_bot.keyboards.keyboards import (
create_keyboard_for_ban_reason,
create_keyboard_with_pagination,
get_reply_keyboard_admin)
get_reply_keyboard_admin,
)
from helper_bot.utils.base_dependency_factory import get_global_instance
from helper_bot.utils.helper_func import (get_banned_users_buttons,
get_banned_users_list)
from helper_bot.utils.helper_func import get_banned_users_buttons, get_banned_users_list
# Local imports - metrics
from helper_bot.utils.metrics import (db_query_time, track_errors,
track_file_operations, track_time)
from helper_bot.utils.metrics import (
db_query_time,
track_errors,
track_file_operations,
track_time,
)
from logs.custom_logger import logger
from .constants import (CALLBACK_BAN, CALLBACK_DECLINE, CALLBACK_PAGE,
CALLBACK_PUBLISH, CALLBACK_RETURN, CALLBACK_UNLOCK,
ERROR_BOT_BLOCKED, MESSAGE_DECLINED, MESSAGE_ERROR,
MESSAGE_PUBLISHED, MESSAGE_USER_BANNED,
MESSAGE_USER_UNLOCKED)
from .constants import (
CALLBACK_BAN,
CALLBACK_DECLINE,
CALLBACK_PAGE,
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 .exceptions import (BanError, PostNotFoundError, PublishError,
UserBlockedBotError, UserNotFoundError)
from .exceptions import (
BanError,
PostNotFoundError,
PublishError,
UserBlockedBotError,
UserNotFoundError,
)
callback_router = Router()
@@ -36,14 +57,12 @@ callback_router = Router()
@callback_router.callback_query(F.data == CALLBACK_PUBLISH)
@track_time("post_for_group", "callback_handlers")
@track_errors("callback_handlers", "post_for_group")
async def post_for_group(
call: CallbackQuery,
settings: MagicData("settings")
):
async def post_for_group(call: CallbackQuery, settings: MagicData("settings")):
publish_service = get_post_publish_service()
# TODO: переделать на MagicData
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:
await publish_service.publish_post(call)
@@ -51,50 +70,48 @@ async def post_for_group(
except UserBlockedBotError:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
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)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
else:
important_logs = settings['Telegram']['important_logs']
important_logs = settings["Telegram"]["important_logs"]
await call.bot.send_message(
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)
@callback_router.callback_query(F.data == CALLBACK_DECLINE)
@track_time("decline_post_for_group", "callback_handlers")
@track_errors("callback_handlers", "decline_post_for_group")
async def decline_post_for_group(
call: CallbackQuery,
settings: MagicData("settings")
):
async def decline_post_for_group(call: CallbackQuery, settings: MagicData("settings")):
publish_service = get_post_publish_service()
# TODO: переделать на MagicData
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:
await publish_service.decline_post(call)
await call.answer(text=MESSAGE_DECLINED, cache_time=3)
except UserBlockedBotError:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
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)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
else:
important_logs = settings['Telegram']['important_logs']
important_logs = settings["Telegram"]["important_logs"]
await call.bot.send_message(
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)
@@ -110,31 +127,37 @@ async def ban_user_from_post(call: CallbackQuery, **kwargs):
except UserBlockedBotError:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
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)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
else:
logger.error(f'Неожиданная ошибка при блокировке пользователя: {str(e)}')
logger.error(f"Неожиданная ошибка при блокировке пользователя: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
@callback_router.callback_query(F.data.contains(CALLBACK_BAN))
@track_time("process_ban_user", "callback_handlers")
@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()
# TODO: переделать на MagicData
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 является валидным числом
try:
user_id_int = int(user_id)
except ValueError:
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
try:
@@ -146,13 +169,13 @@ async def process_ban_user(call: CallbackQuery, state: FSMContext, bot_db: Magic
# Получаем full_name пользователя из базы данных
full_name = await bot_db.get_full_name_by_id(user_id_int)
if not full_name:
full_name = 'Неизвестно'
full_name = "Неизвестно"
# Сохраняем данные в формате, совместимом с admin_handlers
await state.update_data(
target_user_id=user_id_int,
target_username=username,
target_full_name=full_name
target_full_name=full_name,
)
# Используем единый формат отображения информации о пользователе
@@ -161,14 +184,18 @@ async def process_ban_user(call: CallbackQuery, state: FSMContext, bot_db: Magic
await call.message.answer(
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:
markup = get_reply_keyboard_admin()
await call.message.answer(text='Пользователь с таким ID не найден в базе', reply_markup=markup)
await state.set_state('ADMIN')
await call.message.answer(
text="Пользователь с таким ID не найден в базе", reply_markup=markup
)
await state.set_state("ADMIN")
@callback_router.callback_query(F.data.contains(CALLBACK_UNLOCK))
@@ -184,16 +211,20 @@ async def process_unlock_user(call: CallbackQuery, **kwargs):
user_id_int = int(user_id)
except ValueError:
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
try:
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:
await call.answer(text='Пользователь не найден в базе', show_alert=True, cache_time=3)
await call.answer(
text="Пользователь не найден в базе", show_alert=True, cache_time=3
)
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)
@@ -204,48 +235,52 @@ async def return_to_main_menu(call: CallbackQuery, **kwargs):
await call.message.delete()
logger.info(f"Запуск админ панели для пользователя: {call.message.from_user.id}")
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))
@track_time("change_page", "callback_handlers")
@track_errors("callback_handlers", "change_page")
async def change_page(
call: CallbackQuery,
bot_db: MagicData("bot_db"),
**kwargs
):
async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs):
try:
page_number = int(call.data[5:])
except ValueError:
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
logger.info(f"Переход на страницу {page_number}")
if call.message.text == 'Список пользователей которые последними обращались к боту':
if call.message.text == "Список пользователей которые последними обращались к боту":
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(
chat_id=call.message.chat.id,
message_id=call.message.message_id,
reply_markup=keyboard
reply_markup=keyboard,
)
else:
message_user = await get_banned_users_list(int(page_number) * 7 - 7, bot_db)
await call.bot.edit_message_text(
chat_id=call.message.chat.id,
message_id=call.message.message_id,
text=message_user
text=message_user,
)
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(
chat_id=call.message.chat.id,
message_id=call.message.message_id,
reply_markup=keyboard
reply_markup=keyboard,
)
@@ -258,16 +293,20 @@ async def save_voice_message(
call: CallbackQuery,
bot_db: MagicData("bot_db"),
settings: MagicData("settings"),
**kwargs
**kwargs,
):
try:
logger.info(f"Начинаем сохранение голосового сообщения. Message ID: {call.message.message_id}")
logger.info(
f"Начинаем сохранение голосового сообщения. Message ID: {call.message.message_id}"
)
# Создаем сервис для работы с аудио файлами
audio_service = AudioFileService(bot_db)
# Получаем 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}")
# Генерируем имя файла
@@ -295,8 +334,8 @@ async def save_voice_message(
# Удаляем сообщение из предложки
logger.info("Удаляем сообщение из предложки...")
await call.bot.delete_message(
chat_id=settings['Telegram']['group_for_posts'],
message_id=call.message.message_id
chat_id=settings["Telegram"]["group_for_posts"],
message_id=call.message.message_id,
)
logger.info("Сообщение удалено из предложки")
@@ -305,7 +344,7 @@ async def save_voice_message(
await bot_db.delete_audio_moderate_record(call.message.message_id)
logger.info("Запись удалена из таблицы audio_moderate")
await call.answer(text='Сохранено!', cache_time=3)
await call.answer(text="Сохранено!", cache_time=3)
logger.info(f"Голосовое сообщение успешно сохранено: {file_name}")
except Exception as e:
@@ -314,14 +353,18 @@ async def save_voice_message(
# Дополнительная информация для диагностики
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"User ID: {user_id if 'user_id' in locals() else 'не определен'}")
logger.error(f"File name: {file_name if 'file_name' in locals() else 'не определен'}")
logger.error(
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:
pass
await call.answer(text='Ошибка при сохранении!', cache_time=3)
await call.answer(text="Ошибка при сохранении!", cache_time=3)
@callback_router.callback_query(F.data == CALLBACK_DELETE)
@@ -332,20 +375,20 @@ async def delete_voice_message(
call: CallbackQuery,
bot_db: MagicData("bot_db"),
settings: MagicData("settings"),
**kwargs
**kwargs,
):
try:
# Удаляем сообщение из предложки
await call.bot.delete_message(
chat_id=settings['Telegram']['group_for_posts'],
message_id=call.message.message_id
chat_id=settings["Telegram"]["group_for_posts"],
message_id=call.message.message_id,
)
# Удаляем запись из таблицы audio_moderate
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:
logger.error(f"Ошибка при удалении голосового сообщения: {e}")
await call.answer(text='Ошибка при удалении!', cache_time=3)
await call.answer(text="Ошибка при удалении!", cache_time=3)

View File

@@ -37,5 +37,5 @@ CALLBACK_COMMAND_MAPPING: Final[Dict[str, str]] = {
"ban": "ban",
"unlock": "unlock",
"return": "return",
"page": "page"
"page": "page",
}

View File

@@ -3,6 +3,7 @@ from typing import Callable
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.context import FSMContext
from helper_bot.utils.base_dependency_factory import get_global_instance
from .services import BanService, PostPublishService

View File

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

View File

@@ -4,41 +4,69 @@ from typing import Any, Dict
from aiogram import Bot, types
from aiogram.types import CallbackQuery
from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason
from helper_bot.utils.helper_func import (delete_user_blacklist,
get_text_message, send_audio_message,
from helper_bot.utils.helper_func import (
delete_user_blacklist,
get_text_message,
send_audio_message,
send_media_group_to_channel,
send_photo_message,
send_text_message,
send_video_message,
send_video_note_message,
send_voice_message)
send_voice_message,
)
# Local imports - metrics
from helper_bot.utils.metrics import (db_query_time, track_errors,
track_media_processing, track_time)
from helper_bot.utils.metrics import (
db_query_time,
track_errors,
track_media_processing,
track_time,
)
from logs.custom_logger import logger
from .constants import (CONTENT_TYPE_AUDIO, CONTENT_TYPE_MEDIA_GROUP,
CONTENT_TYPE_PHOTO, CONTENT_TYPE_TEXT,
CONTENT_TYPE_VIDEO, CONTENT_TYPE_VIDEO_NOTE,
CONTENT_TYPE_VOICE, ERROR_BOT_BLOCKED,
MESSAGE_POST_DECLINED, MESSAGE_POST_PUBLISHED,
MESSAGE_USER_BANNED_SPAM)
from .exceptions import (BanError, PostNotFoundError, PublishError,
UserBlockedBotError, UserNotFoundError)
from .constants import (
CONTENT_TYPE_AUDIO,
CONTENT_TYPE_MEDIA_GROUP,
CONTENT_TYPE_PHOTO,
CONTENT_TYPE_TEXT,
CONTENT_TYPE_VIDEO,
CONTENT_TYPE_VIDEO_NOTE,
CONTENT_TYPE_VOICE,
ERROR_BOT_BLOCKED,
MESSAGE_POST_DECLINED,
MESSAGE_POST_PUBLISHED,
MESSAGE_USER_BANNED_SPAM,
)
from .exceptions import (
BanError,
PostNotFoundError,
PublishError,
UserBlockedBotError,
UserNotFoundError,
)
class PostPublishService:
def __init__(self, bot: Bot, db, settings: Dict[str, Any], s3_storage=None, scoring_manager=None):
def __init__(
self,
bot: Bot,
db,
settings: Dict[str, Any],
s3_storage=None,
scoring_manager=None,
):
# bot может быть None - в этом случае используем бота из контекста сообщения
self.bot = bot
self.db = db
self.settings = settings
self.s3_storage = s3_storage
self.scoring_manager = scoring_manager
self.group_for_posts = settings['Telegram']['group_for_posts']
self.main_public = settings['Telegram']['main_public']
self.important_logs = settings['Telegram']['important_logs']
self.group_for_posts = settings["Telegram"]["group_for_posts"]
self.main_public = settings["Telegram"]["main_public"]
self.important_logs = settings["Telegram"]["important_logs"]
def _get_bot(self, message) -> Bot:
"""Получает бота из контекста сообщения или использует переданного"""
@@ -83,13 +111,23 @@ class PostPublishService:
"""Публикация текстового поста"""
author_id = await self._get_author_id(call.message.message_id)
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "approved")
updated_rows = await self.db.update_status_by_message_id(
call.message.message_id, "approved"
)
if updated_rows == 0:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
logger.error(
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
)
raise PostNotFoundError(
f"Пост с message_id={call.message.message_id} не найден в базе данных"
)
# Получаем сырой текст и is_anonymous из базы
raw_text, is_anonymous = await self.db.get_post_text_and_anonymity_by_message_id(call.message.message_id)
raw_text, is_anonymous = (
await self.db.get_post_text_and_anonymity_by_message_id(
call.message.message_id
)
)
if raw_text is None:
raw_text = ""
@@ -99,18 +137,24 @@ class PostPublishService:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
formatted_text = get_text_message(
raw_text, user.first_name, user.username, is_anonymous
)
sent_message = await send_text_message(self.main_public, call.message, formatted_text)
sent_message = await send_text_message(
self.main_public, call.message, formatted_text
)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
published_message_id=sent_message.message_id,
)
await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Текст сообщение опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
logger.info(
f"Текст сообщение опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
)
@track_time("_publish_photo_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_photo_post")
@@ -118,13 +162,23 @@ class PostPublishService:
"""Публикация поста с фото"""
author_id = await self._get_author_id(call.message.message_id)
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "approved")
updated_rows = await self.db.update_status_by_message_id(
call.message.message_id, "approved"
)
if updated_rows == 0:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
logger.error(
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
)
raise PostNotFoundError(
f"Пост с message_id={call.message.message_id} не найден в базе данных"
)
# Получаем сырой текст и is_anonymous из базы
raw_text, is_anonymous = await self.db.get_post_text_and_anonymity_by_message_id(call.message.message_id)
raw_text, is_anonymous = (
await self.db.get_post_text_and_anonymity_by_message_id(
call.message.message_id
)
)
if raw_text is None:
raw_text = ""
@@ -134,21 +188,32 @@ class PostPublishService:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
formatted_text = get_text_message(
raw_text, user.first_name, user.username, is_anonymous
)
sent_message = await send_photo_message(self.main_public, call.message, call.message.photo[-1].file_id, formatted_text)
sent_message = await send_photo_message(
self.main_public,
call.message,
call.message.photo[-1].file_id,
formatted_text,
)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
published_message_id=sent_message.message_id,
)
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id)
await self._save_published_post_content(
sent_message, sent_message.message_id, call.message.message_id
)
await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Пост с фото опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
logger.info(
f"Пост с фото опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
)
@track_time("_publish_video_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_video_post")
@@ -156,13 +221,23 @@ class PostPublishService:
"""Публикация поста с видео"""
author_id = await self._get_author_id(call.message.message_id)
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "approved")
updated_rows = await self.db.update_status_by_message_id(
call.message.message_id, "approved"
)
if updated_rows == 0:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
logger.error(
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
)
raise PostNotFoundError(
f"Пост с message_id={call.message.message_id} не найден в базе данных"
)
# Получаем сырой текст и is_anonymous из базы
raw_text, is_anonymous = await self.db.get_post_text_and_anonymity_by_message_id(call.message.message_id)
raw_text, is_anonymous = (
await self.db.get_post_text_and_anonymity_by_message_id(
call.message.message_id
)
)
if raw_text is None:
raw_text = ""
@@ -172,21 +247,29 @@ class PostPublishService:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
formatted_text = get_text_message(
raw_text, user.first_name, user.username, is_anonymous
)
sent_message = await send_video_message(self.main_public, call.message, call.message.video.file_id, formatted_text)
sent_message = await send_video_message(
self.main_public, call.message, call.message.video.file_id, formatted_text
)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
published_message_id=sent_message.message_id,
)
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id)
await self._save_published_post_content(
sent_message, sent_message.message_id, call.message.message_id
)
await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Пост с видео опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
logger.info(
f"Пост с видео опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
)
@track_time("_publish_video_note_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_video_note_post")
@@ -194,24 +277,36 @@ class PostPublishService:
"""Публикация поста с кружком"""
author_id = await self._get_author_id(call.message.message_id)
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "approved")
updated_rows = await self.db.update_status_by_message_id(
call.message.message_id, "approved"
)
if updated_rows == 0:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
logger.error(
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
)
raise PostNotFoundError(
f"Пост с message_id={call.message.message_id} не найден в базе данных"
)
sent_message = await send_video_note_message(self.main_public, call.message, call.message.video_note.file_id)
sent_message = await send_video_note_message(
self.main_public, call.message, call.message.video_note.file_id
)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
published_message_id=sent_message.message_id,
)
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id)
await self._save_published_post_content(
sent_message, sent_message.message_id, call.message.message_id
)
await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Пост с кружком опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
logger.info(
f"Пост с кружком опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
)
@track_time("_publish_audio_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_audio_post")
@@ -219,13 +314,23 @@ class PostPublishService:
"""Публикация поста с аудио"""
author_id = await self._get_author_id(call.message.message_id)
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "approved")
updated_rows = await self.db.update_status_by_message_id(
call.message.message_id, "approved"
)
if updated_rows == 0:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
logger.error(
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
)
raise PostNotFoundError(
f"Пост с message_id={call.message.message_id} не найден в базе данных"
)
# Получаем сырой текст и is_anonymous из базы
raw_text, is_anonymous = await self.db.get_post_text_and_anonymity_by_message_id(call.message.message_id)
raw_text, is_anonymous = (
await self.db.get_post_text_and_anonymity_by_message_id(
call.message.message_id
)
)
if raw_text is None:
raw_text = ""
@@ -235,21 +340,29 @@ class PostPublishService:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
formatted_text = get_text_message(
raw_text, user.first_name, user.username, is_anonymous
)
sent_message = await send_audio_message(self.main_public, call.message, call.message.audio.file_id, formatted_text)
sent_message = await send_audio_message(
self.main_public, call.message, call.message.audio.file_id, formatted_text
)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
published_message_id=sent_message.message_id,
)
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id)
await self._save_published_post_content(
sent_message, sent_message.message_id, call.message.message_id
)
await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Пост с аудио опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
logger.info(
f"Пост с аудио опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
)
@track_time("_publish_voice_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_voice_post")
@@ -257,24 +370,36 @@ class PostPublishService:
"""Публикация поста с войсом"""
author_id = await self._get_author_id(call.message.message_id)
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "approved")
updated_rows = await self.db.update_status_by_message_id(
call.message.message_id, "approved"
)
if updated_rows == 0:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
logger.error(
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
)
raise PostNotFoundError(
f"Пост с message_id={call.message.message_id} не найден в базе данных"
)
sent_message = await send_voice_message(self.main_public, call.message, call.message.voice.file_id)
sent_message = await send_voice_message(
self.main_public, call.message, call.message.voice.file_id
)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
published_message_id=sent_message.message_id,
)
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id)
await self._save_published_post_content(
sent_message, sent_message.message_id, call.message.message_id
)
await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Пост с войсом опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
logger.info(
f"Пост с войсом опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
)
@track_time("_publish_media_group", "post_publish_service")
@track_errors("post_publish_service", "_publish_media_group")
@@ -284,45 +409,68 @@ class PostPublishService:
try:
helper_message_id = call.message.message_id
media_group_message_ids = await self.db.get_post_ids_by_helper_id(helper_message_id)
media_group_message_ids = await self.db.get_post_ids_by_helper_id(
helper_message_id
)
if not media_group_message_ids:
logger.error(f"_publish_media_group: Не найдены message_id медиагруппы для helper_message_id={helper_message_id}")
logger.error(
f"_publish_media_group: Не найдены message_id медиагруппы для helper_message_id={helper_message_id}"
)
raise PublishError("Не найдены message_id медиагруппы в базе данных")
post_content = await self.db.get_post_content_by_helper_id(helper_message_id)
post_content = await self.db.get_post_content_by_helper_id(
helper_message_id
)
if not post_content:
logger.error(f"_publish_media_group: Контент медиагруппы не найден в базе данных для helper_message_id={helper_message_id}")
logger.error(
f"_publish_media_group: Контент медиагруппы не найден в базе данных для helper_message_id={helper_message_id}"
)
raise PublishError("Контент медиагруппы не найден в базе данных")
raw_text, is_anonymous = await self.db.get_post_text_and_anonymity_by_helper_id(helper_message_id)
raw_text, is_anonymous = (
await self.db.get_post_text_and_anonymity_by_helper_id(
helper_message_id
)
)
if raw_text is None:
raw_text = ""
author_id = await self.db.get_author_id_by_helper_message_id(helper_message_id)
author_id = await self.db.get_author_id_by_helper_message_id(
helper_message_id
)
if not author_id:
logger.error(f"_publish_media_group: Автор не найден для медиагруппы helper_message_id={helper_message_id}")
raise PostNotFoundError(f"Автор не найден для медиагруппы {helper_message_id}")
logger.error(
f"_publish_media_group: Автор не найден для медиагруппы helper_message_id={helper_message_id}"
)
raise PostNotFoundError(
f"Автор не найден для медиагруппы {helper_message_id}"
)
user = await self.db.get_user_by_id(author_id)
if not user:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
raise PostNotFoundError(
f"Пользователь {author_id} не найден в базе данных"
)
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
formatted_text = get_text_message(
raw_text, user.first_name, user.username, is_anonymous
)
try:
await self._get_bot(call.message).delete_messages(
chat_id=self.group_for_posts,
message_ids=media_group_message_ids
chat_id=self.group_for_posts, message_ids=media_group_message_ids
)
except Exception as e:
logger.warning(f"_publish_media_group: Ошибка при удалении медиагруппы из чата модерации: {e}")
logger.warning(
f"_publish_media_group: Ошибка при удалении медиагруппы из чата модерации: {e}"
)
sent_messages = await send_media_group_to_channel(
bot=self._get_bot(call.message),
chat_id=self.main_public,
post_content=post_content,
post_text=formatted_text,
s3_storage=self.s3_storage
s3_storage=self.s3_storage,
)
if len(sent_messages) == len(media_group_message_ids):
@@ -331,43 +479,59 @@ class PostPublishService:
try:
await self.db.update_published_message_id(
original_message_id=original_message_id,
published_message_id=published_message_id
published_message_id=published_message_id,
)
await self._save_published_post_content(
sent_messages[i], published_message_id, original_message_id
)
await self._save_published_post_content(sent_messages[i], published_message_id, original_message_id)
except Exception as e:
logger.warning(f"_publish_media_group: Ошибка при сохранении published_message_id для {original_message_id}: {e}")
logger.warning(
f"_publish_media_group: Ошибка при сохранении published_message_id для {original_message_id}: {e}"
)
else:
logger.warning(f"_publish_media_group: Количество опубликованных сообщений ({len(sent_messages)}) не совпадает с количеством оригинальных ({len(media_group_message_ids)})")
logger.warning(
f"_publish_media_group: Количество опубликованных сообщений ({len(sent_messages)}) не совпадает с количеством оригинальных ({len(media_group_message_ids)})"
)
await self.db.update_status_for_media_group_by_helper_id(helper_message_id, "approved")
await self.db.update_status_for_media_group_by_helper_id(
helper_message_id, "approved"
)
# Удаляем helper сообщение - это критично, делаем это всегда
try:
await self._get_bot(call.message).delete_message(
chat_id=self.group_for_posts,
message_id=helper_message_id
chat_id=self.group_for_posts, message_id=helper_message_id
)
except Exception as e:
logger.warning(f"_publish_media_group: Ошибка при удалении helper сообщения: {e}")
logger.warning(
f"_publish_media_group: Ошибка при удалении helper сообщения: {e}"
)
try:
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
logger.warning(f"_publish_media_group: Пользователь {author_id} заблокировал бота")
logger.warning(
f"_publish_media_group: Пользователь {author_id} заблокировал бота"
)
raise UserBlockedBotError("Пользователь заблокировал бота")
logger.error(f"_publish_media_group: Ошибка при отправке уведомления автору: {e}")
logger.error(
f"_publish_media_group: Ошибка при отправке уведомления автору: {e}"
)
except Exception as e:
logger.error(f"_publish_media_group: Ошибка при публикации медиагруппы: {e}")
logger.error(
f"_publish_media_group: Ошибка при публикации медиагруппы: {e}"
)
# Пытаемся удалить helper сообщение даже при ошибке
try:
await self._get_bot(call.message).delete_message(
chat_id=self.group_for_posts,
message_id=call.message.message_id
chat_id=self.group_for_posts, message_id=call.message.message_id
)
except Exception as delete_error:
logger.warning(f"_publish_media_group: Не удалось удалить helper сообщение при ошибке: {delete_error}")
logger.warning(
f"_publish_media_group: Не удалось удалить helper сообщение при ошибке: {delete_error}"
)
raise PublishError(f"Не удалось опубликовать медиагруппу: {str(e)}")
@track_time("decline_post", "post_publish_service")
@@ -381,12 +545,22 @@ class PostPublishService:
content_type = call.message.content_type
if content_type in [CONTENT_TYPE_TEXT, CONTENT_TYPE_PHOTO, CONTENT_TYPE_AUDIO,
CONTENT_TYPE_VOICE, CONTENT_TYPE_VIDEO, CONTENT_TYPE_VIDEO_NOTE]:
if content_type in [
CONTENT_TYPE_TEXT,
CONTENT_TYPE_PHOTO,
CONTENT_TYPE_AUDIO,
CONTENT_TYPE_VOICE,
CONTENT_TYPE_VIDEO,
CONTENT_TYPE_VIDEO_NOTE,
]:
await self._decline_single_post(call)
else:
logger.error(f"Неподдерживаемый тип контента для отклонения: {content_type}")
raise PublishError(f"Неподдерживаемый тип контента для отклонения: {content_type}")
logger.error(
f"Неподдерживаемый тип контента для отклонения: {content_type}"
)
raise PublishError(
f"Неподдерживаемый тип контента для отклонения: {content_type}"
)
@track_time("_decline_single_post", "post_publish_service")
@track_errors("post_publish_service", "_decline_single_post")
@@ -397,12 +571,20 @@ class PostPublishService:
# Обучаем RAG на отклоненном посте перед удалением
await self._train_on_declined(call.message.message_id)
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "declined")
updated_rows = await self.db.update_status_by_message_id(
call.message.message_id, "declined"
)
if updated_rows == 0:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'declined'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
logger.error(
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'declined'"
)
raise PostNotFoundError(
f"Пост с message_id={call.message.message_id} не найден в базе данных"
)
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
await self._get_bot(call.message).delete_message(
chat_id=self.group_for_posts, message_id=call.message.message_id
)
try:
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
@@ -412,7 +594,9 @@ class PostPublishService:
raise UserBlockedBotError("Пользователь заблокировал бота")
logger.error(f"Ошибка при отправке уведомления автору {author_id}: {e}")
raise
logger.info(f'Сообщение отклонено админом {call.from_user.full_name} (ID: {call.from_user.id}).')
logger.info(
f"Сообщение отклонено админом {call.from_user.full_name} (ID: {call.from_user.id})."
)
@track_time("_decline_media_group", "post_publish_service")
@track_errors("post_publish_service", "_decline_media_group")
@@ -421,9 +605,13 @@ class PostPublishService:
"""Отклонение медиагруппы"""
helper_message_id = call.message.message_id
await self.db.update_status_for_media_group_by_helper_id(helper_message_id, "declined")
await self.db.update_status_for_media_group_by_helper_id(
helper_message_id, "declined"
)
media_group_message_ids = await self.db.get_post_ids_by_helper_id(helper_message_id)
media_group_message_ids = await self.db.get_post_ids_by_helper_id(
helper_message_id
)
message_ids_to_delete = media_group_message_ids.copy()
message_ids_to_delete.append(helper_message_id)
@@ -432,8 +620,7 @@ class PostPublishService:
try:
await self._get_bot(call.message).delete_messages(
chat_id=self.group_for_posts,
message_ids=message_ids_to_delete
chat_id=self.group_for_posts, message_ids=message_ids_to_delete
)
except Exception as e:
logger.warning(f"_decline_media_group: Ошибка при удалении сообщений: {e}")
@@ -442,9 +629,13 @@ class PostPublishService:
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
logger.warning(f"_decline_media_group: Пользователь {author_id} заблокировал бота")
logger.warning(
f"_decline_media_group: Пользователь {author_id} заблокировал бота"
)
raise UserBlockedBotError("Пользователь заблокировал бота")
logger.error(f"_decline_media_group: Ошибка при отправке уведомления автору {author_id}: {e}")
logger.error(
f"_decline_media_group: Ошибка при отправке уведомления автору {author_id}: {e}"
)
raise
@track_time("_get_author_id", "post_publish_service")
@@ -487,12 +678,16 @@ class PostPublishService:
@track_time("_delete_post_and_notify_author", "post_publish_service")
@track_errors("post_publish_service", "_delete_post_and_notify_author")
async def _delete_post_and_notify_author(self, call: CallbackQuery, author_id: int) -> None:
async def _delete_post_and_notify_author(
self, call: CallbackQuery, author_id: int
) -> None:
"""Удаление поста и уведомление автора"""
# Получаем текст поста для обучения RAG перед удалением
await self._train_on_published(call.message.message_id)
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
await self._get_bot(call.message).delete_message(
chat_id=self.group_for_posts, message_id=call.message.message_id
)
try:
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
@@ -512,7 +707,9 @@ class PostPublishService:
await self.scoring_manager.on_post_published(text)
logger.debug(f"RAG обучен на опубликованном посте: {message_id}")
except Exception as e:
logger.error(f"Ошибка обучения RAG на опубликованном посте {message_id}: {e}")
logger.error(
f"Ошибка обучения RAG на опубликованном посте {message_id}: {e}"
)
async def _train_on_declined(self, message_id: int) -> None:
"""Обучает RAG на отклоненном посте."""
@@ -530,16 +727,22 @@ class PostPublishService:
@track_time("_delete_media_group_and_notify_author", "post_publish_service")
@track_errors("post_publish_service", "_delete_media_group_and_notify_author")
@track_media_processing("media_group")
async def _delete_media_group_and_notify_author(self, call: CallbackQuery, author_id: int) -> None:
async def _delete_media_group_and_notify_author(
self, call: CallbackQuery, author_id: int
) -> None:
"""Удаление медиагруппы и уведомление автора (legacy метод, используется для обратной совместимости)"""
helper_message_id = call.message.message_id
media_group_message_ids = await self.db.get_post_ids_by_helper_id(helper_message_id)
media_group_message_ids = await self.db.get_post_ids_by_helper_id(
helper_message_id
)
message_ids_to_delete = media_group_message_ids.copy()
message_ids_to_delete.append(helper_message_id)
await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=message_ids_to_delete)
await self._get_bot(call.message).delete_messages(
chat_id=self.group_for_posts, message_ids=message_ids_to_delete
)
try:
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
except Exception as e:
@@ -549,30 +752,47 @@ class PostPublishService:
@track_time("_save_published_post_content", "post_publish_service")
@track_errors("post_publish_service", "_save_published_post_content")
async def _save_published_post_content(self, published_message: types.Message, published_message_id: int, original_message_id: int) -> None:
async def _save_published_post_content(
self,
published_message: types.Message,
published_message_id: int,
original_message_id: int,
) -> None:
"""Сохраняет ссылку на медиафайл из опубликованного поста (файл уже в S3 или на диске)."""
try:
# Получаем уже сохраненный путь/S3 ключ из оригинального поста
saved_content = await self.db.get_post_content_by_message_id(original_message_id)
saved_content = await self.db.get_post_content_by_message_id(
original_message_id
)
if saved_content and len(saved_content) > 0:
# Копируем тот же путь/S3 ключ
file_path, content_type = saved_content[0]
logger.debug(f"Копируем путь/S3 ключ для опубликованного поста: {file_path}")
logger.debug(
f"Копируем путь/S3 ключ для опубликованного поста: {file_path}"
)
success = await self.db.add_published_post_content(
published_message_id=published_message_id,
content_path=file_path, # Тот же путь/S3 ключ
content_type=content_type
content_type=content_type,
)
if success:
logger.info(f"Ссылка на файл сохранена для опубликованного поста: published_message_id={published_message_id}, path={file_path}")
logger.info(
f"Ссылка на файл сохранена для опубликованного поста: published_message_id={published_message_id}, path={file_path}"
)
else:
logger.warning(f"Не удалось сохранить ссылку на файл: published_message_id={published_message_id}")
logger.warning(
f"Не удалось сохранить ссылку на файл: published_message_id={published_message_id}"
)
else:
logger.warning(f"Контент не найден для оригинального поста message_id={original_message_id}")
logger.warning(
f"Контент не найден для оригинального поста message_id={original_message_id}"
)
except Exception as e:
logger.error(f"Ошибка при сохранении ссылки на контент опубликованного поста {published_message_id}: {e}")
logger.error(
f"Ошибка при сохранении ссылки на контент опубликованного поста {published_message_id}: {e}"
)
# Не прерываем публикацию, если сохранение контента не удалось
@@ -581,8 +801,8 @@ class BanService:
self.bot = bot
self.db = db
self.settings = settings
self.group_for_posts = settings['Telegram']['group_for_posts']
self.important_logs = settings['Telegram']['important_logs']
self.group_for_posts = settings["Telegram"]["group_for_posts"]
self.important_logs = settings["Telegram"]["important_logs"]
def _get_bot(self, message) -> Bot:
"""Получает бота из контекста сообщения или использует переданного"""
@@ -597,12 +817,18 @@ class BanService:
"""Бан пользователя за спам"""
# Если это helper-сообщение медиагруппы, используем специальный метод
if call.message.text == CONTENT_TYPE_MEDIA_GROUP:
author_id = await self.db.get_author_id_by_helper_message_id(call.message.message_id)
author_id = await self.db.get_author_id_by_helper_message_id(
call.message.message_id
)
else:
author_id = await self.db.get_author_id_by_message_id(call.message.message_id)
author_id = await self.db.get_author_id_by_message_id(
call.message.message_id
)
if not author_id:
raise UserNotFoundError(f"Автор не найден для сообщения {call.message.message_id}")
raise UserNotFoundError(
f"Автор не найден для сообщения {call.message.message_id}"
)
current_date = datetime.now()
date_to_unban = int((current_date + timedelta(days=7)).timestamp())
@@ -624,18 +850,28 @@ class BanService:
call.message.message_id, "declined"
)
if updated_rows == 0:
logger.warning(f"Не удалось обновить статус медиагруппы helper_message_id={call.message.message_id} на 'declined'")
logger.warning(
f"Не удалось обновить статус медиагруппы helper_message_id={call.message.message_id} на 'declined'"
)
else:
# Для одиночного поста обновляем статус по message_id
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "declined")
updated_rows = await self.db.update_status_by_message_id(
call.message.message_id, "declined"
)
if updated_rows == 0:
logger.warning(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'declined'")
logger.warning(
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'declined'"
)
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
await self._get_bot(call.message).delete_message(
chat_id=self.group_for_posts, message_id=call.message.message_id
)
date_str = (current_date + timedelta(days=7)).strftime("%d.%m.%Y %H:%M")
try:
await send_text_message(author_id, call.message, MESSAGE_USER_BANNED_SPAM.format(date=date_str))
await send_text_message(
author_id, call.message, MESSAGE_USER_BANNED_SPAM.format(date=date_str)
)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
raise UserBlockedBotError("Пользователь заблокировал бота")

View File

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

View File

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

View File

@@ -6,12 +6,14 @@ from typing import Any, Callable
# Third-party imports
from aiogram import types
# Local imports
from logs.custom_logger import logger
def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator for centralized error handling"""
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
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)}")
# Try to send error to logs if possible
try:
message = next((arg for arg in args if isinstance(arg, types.Message)), None)
if message and hasattr(message, 'bot'):
from helper_bot.utils.base_dependency_factory import \
get_global_instance
message = next(
(arg for arg in args if isinstance(arg, types.Message)), None
)
if message and hasattr(message, "bot"):
from helper_bot.utils.base_dependency_factory import (
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(
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:
# If we can't log the error, at least it was logged to logger
pass
raise
return wrapper

View File

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

View File

@@ -3,11 +3,14 @@
# Third-party imports
from aiogram import Router, types
from aiogram.fsm.context import FSMContext
# Local imports - filters
from database.async_db import AsyncBotDB
from helper_bot.filters.main import ChatTypeFilter
# Local imports - metrics
from helper_bot.utils.metrics import metrics, track_errors, track_time
# Local imports - utilities
from logs.custom_logger import logger
@@ -35,8 +38,7 @@ class GroupHandlers:
def _register_handlers(self):
"""Register all message handlers"""
self.router.message.register(
self.handle_message,
ChatTypeFilter(chat_type=["group", "supergroup"])
self.handle_message, ChatTypeFilter(chat_type=["group", "supergroup"])
)
@error_handler
@@ -46,7 +48,7 @@ class GroupHandlers:
"""Handle admin reply to user through group chat"""
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}"'
)
@@ -54,8 +56,8 @@ class GroupHandlers:
if not message.reply_to_message:
await message.answer(ERROR_MESSAGES["NO_REPLY_TO_MESSAGE"])
logger.warning(
f'В группе {message.chat.title} (ID: {message.chat.id}) '
f'админ не выделил сообщение для ответа.'
f"В группе {message.chat.title} (ID: {message.chat.id}) "
f"админ не выделил сообщение для ответа."
)
return
@@ -77,13 +79,15 @@ class GroupHandlers:
except UserNotFoundError:
await message.answer(ERROR_MESSAGES["USER_NOT_FOUND"])
logger.error(
f'Ошибка при поиске пользователя в базе для ответа на сообщение: {reply_text} '
f'в группе {message.chat.title} (ID сообщения: {message.message_id})'
f"Ошибка при поиске пользователя в базе для ответа на сообщение: {reply_text} "
f"в группе {message.chat.title} (ID сообщения: {message.message_id})"
)
# 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"""
return GroupHandlers(db, keyboard_markup)
@@ -91,6 +95,7 @@ def create_group_handlers(db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMa
# Legacy router for backward compatibility
group_router = Router()
# Initialize with global dependencies (for backward compatibility)
def init_legacy_router():
"""Initialize legacy router with global dependencies"""
@@ -107,5 +112,6 @@ def init_legacy_router():
handlers = create_group_handlers(db, keyboard_markup)
group_router = handlers.router
# Initialize legacy router
init_legacy_router()

View File

@@ -5,8 +5,10 @@ from typing import Optional, Protocol
# Third-party imports
from aiogram import types
# Local imports
from helper_bot.utils.helper_func import send_text_message
# Local imports - metrics
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
from logs.custom_logger import logger
@@ -16,8 +18,11 @@ from .exceptions import NoReplyToMessageError, UserNotFoundError
class DatabaseProtocol(Protocol):
"""Protocol for database operations"""
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:
@@ -54,7 +59,7 @@ class AdminReplyService:
chat_id: int,
message: types.Message,
reply_text: str,
markup: types.ReplyKeyboardMarkup
markup: types.ReplyKeyboardMarkup,
) -> None:
"""
Send reply to user.

View File

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

View File

@@ -7,7 +7,7 @@ FSM_STATES: Final[Dict[str, str]] = {
"START": "START",
"SUGGEST": "SUGGEST",
"PRE_CHAT": "PRE_CHAT",
"CHAT": "CHAT"
"CHAT": "CHAT",
}
# Button texts
@@ -18,7 +18,7 @@ BUTTON_TEXTS: Final[Dict[str, str]] = {
"RETURN_TO_BOT": "Вернуться в бота",
"WANT_STICKERS": "🤪Хочу стикеры",
"CONNECT_ADMIN": "📩Связаться с админами",
"VOICE_BOT": "🎤Голосовой бот"
"VOICE_BOT": "🎤Голосовой бот",
}
# Button to command mapping for metrics
@@ -29,15 +29,15 @@ BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = {
"Вернуться в бота": "return_to_bot",
"🤪Хочу стикеры": "want_stickers",
"📩Связаться с админами": "connect_admin",
"🎤Голосовой бот": "voice_bot"
"🎤Голосовой бот": "voice_bot",
}
# Error messages
ERROR_MESSAGES: Final[Dict[str, str]] = {
"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
from aiogram import types
# Local imports
from logs.custom_logger import logger
def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator for centralized error handling"""
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
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)}")
# Try to send error to logs if possible
try:
message = next((arg for arg in args if isinstance(arg, types.Message)), None)
if message and hasattr(message, 'bot'):
from helper_bot.utils.base_dependency_factory import \
get_global_instance
message = next(
(arg for arg in args if isinstance(arg, types.Message)), None
)
if message and hasattr(message, "bot"):
from helper_bot.utils.base_dependency_factory import (
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(
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:
# If we can't log the error, at least it was logged to logger
pass
raise
return wrapper

View File

@@ -8,18 +8,23 @@ from datetime import datetime
from aiogram import F, Router, types
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
# Local imports - filters and middlewares
from database.async_db import AsyncBotDB
from helper_bot.filters.main import ChatTypeFilter
# Local imports - utilities
from helper_bot.keyboards import (get_reply_keyboard,
get_reply_keyboard_for_post)
from helper_bot.keyboards import get_reply_keyboard, get_reply_keyboard_for_post
from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
from helper_bot.middlewares.album_middleware import AlbumMiddleware
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
from helper_bot.utils import messages
from helper_bot.utils.helper_func import (check_user_emoji, get_first_name,
update_user_info)
from helper_bot.utils.helper_func import (
check_user_emoji,
get_first_name,
update_user_info,
)
# Local imports - metrics
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
@@ -35,7 +40,13 @@ sleep = asyncio.sleep
class PrivateHandlers:
"""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.settings = settings
self.user_service = UserService(db, settings)
@@ -52,51 +63,106 @@ class PrivateHandlers:
def _register_handlers(self):
"""Register all message handlers"""
# Command handlers
self.router.message.register(self.handle_emoji_message, ChatTypeFilter(chat_type=["private"]), Command("emoji"))
self.router.message.register(self.handle_restart_message, ChatTypeFilter(chat_type=["private"]), Command("restart"))
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"])
self.router.message.register(
self.handle_emoji_message,
ChatTypeFilter(chat_type=["private"]),
Command("emoji"),
)
self.router.message.register(
self.handle_restart_message,
ChatTypeFilter(chat_type=["private"]),
Command("restart"),
)
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"])
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
self.router.message.register(self.suggest_router, 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"]))
self.router.message.register(
self.suggest_router,
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
@track_errors("private_handlers", "handle_emoji_message")
@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"""
await self.user_service.log_user_message(message)
user_emoji = await check_user_emoji(message)
await state.set_state(FSM_STATES["START"])
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
@track_errors("private_handlers", "handle_restart_message")
@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"""
markup = await get_reply_keyboard(self.db, message.from_user.id)
await self.user_service.log_user_message(message)
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 message.answer('Я перезапущен!', reply_markup=markup, parse_mode='HTML')
await message.answer("Я перезапущен!", reply_markup=markup, parse_mode="HTML")
@error_handler
@track_errors("private_handlers", "handle_start_message")
@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"""
# User service operations with metrics
await self.user_service.log_user_message(message)
@@ -108,8 +174,8 @@ class PrivateHandlers:
# Send welcome message with metrics
markup = await get_reply_keyboard(self.db, message.from_user.id)
hello_message = messages.get_message(get_first_name(message), 'HELLO_MESSAGE')
await message.answer(hello_message, reply_markup=markup, parse_mode='HTML')
hello_message = messages.get_message(get_first_name(message), "HELLO_MESSAGE")
await message.answer(hello_message, reply_markup=markup, parse_mode="HTML")
@error_handler
@track_errors("private_handlers", "suggest_post")
@@ -122,7 +188,7 @@ class PrivateHandlers:
await state.set_state(FSM_STATES["SUGGEST"])
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)
@error_handler
@@ -139,18 +205,22 @@ class PrivateHandlers:
# Send goodbye message
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 state.set_state(FSM_STATES["START"])
@error_handler
@track_errors("private_handlers", "suggest_router")
@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 - сразу отвечает пользователю, обработка в фоне"""
# Сразу отвечаем пользователю
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 state.set_state(FSM_STATES["START"])
@@ -177,6 +247,7 @@ class PrivateHandlers:
await self.post_service.process_post(message, album)
except Exception as e:
from logs.custom_logger import logger
logger.error(f"Ошибка при фоновой обработке поста: {e}")
asyncio.create_task(process_post_background())
@@ -191,20 +262,21 @@ class PrivateHandlers:
markup = await get_reply_keyboard(self.db, message.from_user.id)
await self.db.update_stickers_info(message.from_user.id)
await self.user_service.log_user_message(message)
await message.answer(
text=ERROR_MESSAGES["STICKERS_LINK"],
reply_markup=markup
)
await message.answer(text=ERROR_MESSAGES["STICKERS_LINK"], reply_markup=markup)
await state.set_state(FSM_STATES["START"])
@error_handler
@track_errors("private_handlers", "connect_with_admin")
@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"""
# User service operations with metrics
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 self.user_service.log_user_message(message)
await state.set_state(FSM_STATES["PRE_CHAT"])
@@ -213,7 +285,9 @@ class PrivateHandlers:
@track_errors("private_handlers", "resend_message_in_group_for_message")
@track_time("resend_message_in_group_for_message", "private_handlers")
@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"""
# User service operations with metrics
await self.user_service.update_user_activity(message.from_user.id)
@@ -221,9 +295,11 @@ class PrivateHandlers:
current_date = datetime.now()
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()
if user_state == FSM_STATES["PRE_CHAT"]:
@@ -236,7 +312,9 @@ class PrivateHandlers:
# 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"""
return PrivateHandlers(db, settings, s3_storage, scoring_manager)
@@ -247,6 +325,7 @@ private_router = Router()
# Флаг инициализации для защиты от повторного вызова
_legacy_router_initialized = False
# Initialize with global dependencies (for backward compatibility)
def init_legacy_router():
"""Initialize legacy router with global dependencies"""
@@ -259,14 +338,14 @@ def init_legacy_router():
bdf = get_global_instance()
settings = BotSettings(
group_for_posts=bdf.settings['Telegram']['group_for_posts'],
group_for_message=bdf.settings['Telegram']['group_for_message'],
main_public=bdf.settings['Telegram']['main_public'],
group_for_logs=bdf.settings['Telegram']['group_for_logs'],
important_logs=bdf.settings['Telegram']['important_logs'],
preview_link=bdf.settings['Telegram']['preview_link'],
logs=bdf.settings['Settings']['logs'],
test=bdf.settings['Settings']['test']
group_for_posts=bdf.settings["Telegram"]["group_for_posts"],
group_for_message=bdf.settings["Telegram"]["group_for_message"],
main_public=bdf.settings["Telegram"]["main_public"],
group_for_logs=bdf.settings["Telegram"]["group_for_logs"],
important_logs=bdf.settings["Telegram"]["important_logs"],
preview_link=bdf.settings["Telegram"]["preview_link"],
logs=bdf.settings["Settings"]["logs"],
test=bdf.settings["Settings"]["test"],
)
db = bdf.get_db()
@@ -279,5 +358,6 @@ def init_legacy_router():
private_router = handlers.router
_legacy_router_initialized = True
# Initialize legacy router
init_legacy_router()

View File

@@ -12,37 +12,61 @@ from typing import Any, Callable, Dict, Protocol, Union
# Third-party imports
from aiogram import types
from aiogram.types import FSInputFile
from database.models import TelegramPost, User
from helper_bot.keyboards import get_reply_keyboard_for_post
# Local imports - utilities
from helper_bot.utils.helper_func import (
add_in_db_media, check_username_and_full_name, determine_anonymity,
get_first_name, get_text_message, prepare_media_group_from_middlewares,
send_audio_message, send_media_group_message_to_private_chat,
send_photo_message, send_text_message, send_video_message,
send_video_note_message, send_voice_message)
add_in_db_media,
check_username_and_full_name,
determine_anonymity,
get_first_name,
get_text_message,
prepare_media_group_from_middlewares,
send_audio_message,
send_media_group_message_to_private_chat,
send_photo_message,
send_text_message,
send_video_message,
send_video_note_message,
send_voice_message,
)
# Local imports - metrics
from helper_bot.utils.metrics import (db_query_time, track_errors,
from helper_bot.utils.metrics import (
db_query_time,
track_errors,
track_file_operations,
track_media_processing, track_time)
track_media_processing,
track_time,
)
from logs.custom_logger import logger
class DatabaseProtocol(Protocol):
"""Protocol for database operations"""
async def user_exists(self, user_id: int) -> bool: ...
async def add_user(self, user: User) -> None: ...
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: ...
async def update_user_date(self, user_id: int) -> None: ...
async def add_post(self, post: TelegramPost) -> None: ...
async def update_stickers_info(self, user_id: int) -> None: ...
async def add_message(self, message_text: str, user_id: int, message_id: int, date: int = None) -> None: ...
async def update_helper_message(self, message_id: int, helper_message_id: int) -> None: ...
async def add_message(
self, message_text: str, user_id: int, message_id: int, date: int = None
) -> None: ...
async def update_helper_message(
self, message_id: int, helper_message_id: int
) -> None: ...
@dataclass
class BotSettings:
"""Bot configuration settings"""
group_for_posts: str
group_for_message: str
main_public: str
@@ -93,7 +117,7 @@ class UserService:
has_stickers=False,
date_added=current_timestamp,
date_changed=current_timestamp,
voice_bot_welcome_received=False
voice_bot_welcome_received=False,
)
# Пытаемся создать пользователя (если уже существует - игнорируем)
@@ -101,18 +125,24 @@ class UserService:
await self.db.add_user(user)
# Проверяем, нужно ли обновить информацию о существующем пользователе
is_need_update = await check_username_and_full_name(user_id, username, full_name, self.db)
is_need_update = await check_username_and_full_name(
user_id, username, full_name, self.db
)
if is_need_update:
await self.db.update_user_info(user_id, username, full_name)
safe_full_name = html.escape(full_name) if full_name else "Неизвестный пользователь"
safe_full_name = (
html.escape(full_name) if full_name else "Неизвестный пользователь"
)
# Для отображения используем подстановочное значение, но в БД сохраняем только реальный username
safe_username = html.escape(username) if username else "Без никнейма"
await message.answer(
f"Давно не виделись! Вижу что ты изменился;) Теперь буду звать тебя: {safe_full_name} и ник @{safe_username}")
f"Давно не виделись! Вижу что ты изменился;) Теперь буду звать тебя: {safe_full_name} и ник @{safe_username}"
)
await message.bot.send_message(
chat_id=self.settings.group_for_logs,
text=f'Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {safe_full_name}\nНовый ник:{safe_username}')
text=f"Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {safe_full_name}\nНовый ник:{safe_username}",
)
await self.db.update_user_date(user_id)
@@ -130,20 +160,32 @@ class UserService:
class PostService:
"""Service for post-related operations"""
def __init__(self, db: DatabaseProtocol, settings: BotSettings, s3_storage=None, scoring_manager=None) -> None:
def __init__(
self,
db: DatabaseProtocol,
settings: BotSettings,
s3_storage=None,
scoring_manager=None,
) -> None:
self.db = db
self.settings = settings
self.s3_storage = s3_storage
self.scoring_manager = scoring_manager
async def _save_media_background(self, sent_message: types.Message, bot_db: Any, s3_storage) -> None:
async def _save_media_background(
self, sent_message: types.Message, bot_db: Any, s3_storage
) -> None:
"""Сохраняет медиа в фоне, чтобы не блокировать ответ пользователю"""
try:
success = await add_in_db_media(sent_message, bot_db, s3_storage)
if not success:
logger.warning(f"_save_media_background: Не удалось сохранить медиа для поста {sent_message.message_id}")
logger.warning(
f"_save_media_background: Не удалось сохранить медиа для поста {sent_message.message_id}"
)
except Exception as e:
logger.error(f"_save_media_background: Ошибка при сохранении медиа для поста {sent_message.message_id}: {e}")
logger.error(
f"_save_media_background: Ошибка при сохранении медиа для поста {sent_message.message_id}: {e}"
)
async def _get_scores(self, text: str) -> tuple:
"""
@@ -160,24 +202,39 @@ class PostService:
# Формируем JSON для сохранения в БД
import json
ml_scores_json = json.dumps(scores.to_json_dict()) if scores.has_any_score() else None
ml_scores_json = (
json.dumps(scores.to_json_dict()) if scores.has_any_score() else None
)
# Получаем данные от RAG
rag_confidence = scores.rag.confidence if scores.rag else None
rag_score_pos_only = scores.rag.metadata.get("rag_score_pos_only") if scores.rag else None
rag_score_pos_only = (
scores.rag.metadata.get("rag_score_pos_only") if scores.rag else None
)
return scores.deepseek_score, scores.rag_score, rag_confidence, rag_score_pos_only, ml_scores_json
return (
scores.deepseek_score,
scores.rag_score,
rag_confidence,
rag_score_pos_only,
ml_scores_json,
)
except Exception as e:
logger.error(f"PostService: Ошибка получения скоров: {e}")
return None, None, None, None, None
async def _save_scores_background(self, message_id: int, ml_scores_json: str) -> None:
async def _save_scores_background(
self, message_id: int, ml_scores_json: str
) -> None:
"""Сохраняет скоры в БД в фоне."""
if ml_scores_json:
try:
await self.db.update_ml_scores(message_id, ml_scores_json)
except Exception as e:
logger.error(f"PostService: Ошибка сохранения скоров для {message_id}: {e}")
logger.error(
f"PostService: Ошибка сохранения скоров для {message_id}: {e}"
)
async def _get_scores_with_error_handling(self, text: str) -> tuple:
"""
@@ -199,13 +256,25 @@ class PostService:
# Формируем JSON для сохранения в БД
import json
ml_scores_json = json.dumps(scores.to_json_dict()) if scores.has_any_score() else None
ml_scores_json = (
json.dumps(scores.to_json_dict()) if scores.has_any_score() else None
)
# Получаем данные от RAG
rag_confidence = scores.rag.confidence if scores.rag else None
rag_score_pos_only = scores.rag.metadata.get("rag_score_pos_only") if scores.rag else None
rag_score_pos_only = (
scores.rag.metadata.get("rag_score_pos_only") if scores.rag else None
)
return scores.deepseek_score, scores.rag_score, rag_confidence, rag_score_pos_only, ml_scores_json, None
return (
scores.deepseek_score,
scores.rag_score,
rag_confidence,
rag_score_pos_only,
ml_scores_json,
None,
)
except Exception as e:
logger.error(f"PostService: Ошибка получения скоров: {e}")
# Возвращаем частичные скоры если есть, или сообщение об ошибке
@@ -219,7 +288,7 @@ class PostService:
message: types.Message,
first_name: str,
content_type: str,
album: Union[list, None] = None
album: Union[list, None] = None,
) -> None:
"""
Обрабатывает пост в фоне: получает скоры, отправляет в группу модерации, сохраняет в БД.
@@ -236,13 +305,21 @@ class PostService:
if content_type == "text":
original_raw_text = message.text or ""
elif content_type == "media_group":
original_raw_text = album[0].caption or "" if album and album[0].caption else ""
original_raw_text = (
album[0].caption or "" if album and album[0].caption else ""
)
else:
original_raw_text = message.caption or ""
# Получаем скоры с обработкой ошибок
deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json, error_message = \
await self._get_scores_with_error_handling(original_raw_text)
(
deepseek_score,
rag_score,
rag_confidence,
rag_score_pos_only,
ml_scores_json,
error_message,
) = await self._get_scores_with_error_handling(original_raw_text)
# Формируем текст для поста (с сообщением об ошибке если есть)
text_for_post = original_raw_text
@@ -287,37 +364,65 @@ class PostService:
)
elif content_type == "photo":
sent_message = await send_photo_message(
self.settings.group_for_posts, message, message.photo[-1].file_id, post_text, markup
self.settings.group_for_posts,
message,
message.photo[-1].file_id,
post_text,
markup,
)
elif content_type == "video":
sent_message = await send_video_message(
self.settings.group_for_posts, message, message.video.file_id, post_text, markup
self.settings.group_for_posts,
message,
message.video.file_id,
post_text,
markup,
)
elif content_type == "audio":
sent_message = await send_audio_message(
self.settings.group_for_posts, message, message.audio.file_id, post_text, markup
self.settings.group_for_posts,
message,
message.audio.file_id,
post_text,
markup,
)
elif content_type == "voice":
sent_message = await send_voice_message(
self.settings.group_for_posts, message, message.voice.file_id, markup
self.settings.group_for_posts,
message,
message.voice.file_id,
markup,
)
elif content_type == "video_note":
sent_message = await send_video_note_message(
self.settings.group_for_posts, message, message.video_note.file_id, markup
self.settings.group_for_posts,
message,
message.video_note.file_id,
markup,
)
elif content_type == "media_group":
# Для медиагруппы используем специальную обработку
# Передаем ml_scores_json для сохранения в БД
await self._process_media_group_background(
message, album, first_name, post_text, is_anonymous, original_raw_text, ml_scores_json
message,
album,
first_name,
post_text,
is_anonymous,
original_raw_text,
ml_scores_json,
)
return
else:
logger.error(f"PostService: Неподдерживаемый тип контента: {content_type}")
logger.error(
f"PostService: Неподдерживаемый тип контента: {content_type}"
)
return
if not sent_message:
logger.error(f"PostService: Не удалось отправить пост типа {content_type}")
logger.error(
f"PostService: Не удалось отправить пост типа {content_type}"
)
return
# Сохраняем пост в БД (сохраняем исходный текст, без сообщения об ошибке)
@@ -326,19 +431,27 @@ class PostService:
text=original_raw_text,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(post)
# Сохраняем медиа и скоры в фоне
if content_type in ("photo", "video", "audio", "voice", "video_note"):
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
asyncio.create_task(
self._save_media_background(sent_message, self.db, self.s3_storage)
)
if ml_scores_json:
asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json))
asyncio.create_task(
self._save_scores_background(
sent_message.message_id, ml_scores_json
)
)
except Exception as e:
logger.error(f"PostService: Критическая ошибка в _process_post_background для {content_type}: {e}")
logger.error(
f"PostService: Критическая ошибка в _process_post_background для {content_type}: {e}"
)
async def _process_media_group_background(
self,
@@ -348,14 +461,21 @@ class PostService:
post_caption: str,
is_anonymous: bool,
original_raw_text: str,
ml_scores_json: str = None
ml_scores_json: str = None,
) -> None:
"""Обрабатывает медиагруппу в фоне"""
try:
media_group = await prepare_media_group_from_middlewares(album, post_caption)
media_group = await prepare_media_group_from_middlewares(
album, post_caption
)
media_group_message_ids = await send_media_group_message_to_private_chat(
self.settings.group_for_posts, message, media_group, self.db, None, self.s3_storage
self.settings.group_for_posts,
message,
media_group,
self.db,
None,
self.s3_storage,
)
main_post_id = media_group_message_ids[-1]
@@ -365,13 +485,15 @@ class PostService:
text=original_raw_text,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(main_post)
# Сохраняем скоры в фоне (если они были получены)
if ml_scores_json:
asyncio.create_task(self._save_scores_background(main_post_id, ml_scores_json))
asyncio.create_task(
self._save_scores_background(main_post_id, ml_scores_json)
)
for msg_id in media_group_message_ids:
await self.db.add_message_link(main_post_id, msg_id)
@@ -380,10 +502,7 @@ class PostService:
markup = get_reply_keyboard_for_post()
helper_message = await send_text_message(
self.settings.group_for_posts,
message,
"^",
markup
self.settings.group_for_posts, message, "^", markup
)
helper_message_id = helper_message.message_id
@@ -392,13 +511,12 @@ class PostService:
text="^",
author_id=message.from_user.id,
helper_text_message_id=main_post_id,
created_at=int(datetime.now().timestamp())
created_at=int(datetime.now().timestamp()),
)
await self.db.add_post(helper_post)
await self.db.update_helper_message(
message_id=main_post_id,
helper_message_id=helper_message_id
message_id=main_post_id, helper_message_id=helper_message_id
)
except Exception as e:
logger.error(f"PostService: Ошибка в _process_media_group_background: {e}")
@@ -411,7 +529,13 @@ class PostService:
raw_text = message.text or ""
# Получаем скоры для текста
deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_text)
(
deepseek_score,
rag_score,
rag_confidence,
rag_score_pos_only,
ml_scores_json,
) = await self._get_scores(raw_text)
logger.debug(
f"PostService.handle_text_post: Передача скоров в get_text_message - "
@@ -433,7 +557,9 @@ class PostService:
)
markup = get_reply_keyboard_for_post()
sent_message = await send_text_message(self.settings.group_for_posts, message, post_text, markup)
sent_message = await send_text_message(
self.settings.group_for_posts, message, post_text, markup
)
# Определяем анонимность
is_anonymous = determine_anonymity(raw_text)
@@ -443,13 +569,15 @@ class PostService:
text=raw_text,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(post)
# Сохраняем скоры в фоне
if ml_scores_json:
asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json))
asyncio.create_task(
self._save_scores_background(sent_message.message_id, ml_scores_json)
)
@track_time("handle_photo_post", "post_service")
@track_errors("post_service", "handle_photo_post")
@@ -459,7 +587,13 @@ class PostService:
raw_caption = message.caption or ""
# Получаем скоры для текста
deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption)
(
deepseek_score,
rag_score,
rag_confidence,
rag_score_pos_only,
ml_scores_json,
) = await self._get_scores(raw_caption)
logger.debug(
f"PostService.handle_photo_post: Передача скоров в get_text_message - "
@@ -483,7 +617,11 @@ class PostService:
markup = get_reply_keyboard_for_post()
sent_message = await send_photo_message(
self.settings.group_for_posts, message, message.photo[-1].file_id, post_caption, markup
self.settings.group_for_posts,
message,
message.photo[-1].file_id,
post_caption,
markup,
)
# Определяем анонимность
@@ -494,14 +632,18 @@ class PostService:
text=raw_caption,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(post)
# Сохраняем медиа и скоры в фоне
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
asyncio.create_task(
self._save_media_background(sent_message, self.db, self.s3_storage)
)
if ml_scores_json:
asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json))
asyncio.create_task(
self._save_scores_background(sent_message.message_id, ml_scores_json)
)
@track_time("handle_video_post", "post_service")
@track_errors("post_service", "handle_video_post")
@@ -511,7 +653,13 @@ class PostService:
raw_caption = message.caption or ""
# Получаем скоры для текста
deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption)
(
deepseek_score,
rag_score,
rag_confidence,
rag_score_pos_only,
ml_scores_json,
) = await self._get_scores(raw_caption)
logger.debug(
f"PostService.handle_video_post: Передача скоров в get_text_message - "
@@ -535,7 +683,11 @@ class PostService:
markup = get_reply_keyboard_for_post()
sent_message = await send_video_message(
self.settings.group_for_posts, message, message.video.file_id, post_caption, markup
self.settings.group_for_posts,
message,
message.video.file_id,
post_caption,
markup,
)
# Определяем анонимность
@@ -546,14 +698,18 @@ class PostService:
text=raw_caption,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(post)
# Сохраняем медиа и скоры в фоне
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
asyncio.create_task(
self._save_media_background(sent_message, self.db, self.s3_storage)
)
if ml_scores_json:
asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json))
asyncio.create_task(
self._save_scores_background(sent_message.message_id, ml_scores_json)
)
@track_time("handle_video_note_post", "post_service")
@track_errors("post_service", "handle_video_note_post")
@@ -574,11 +730,13 @@ class PostService:
text=raw_caption,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(post)
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
asyncio.create_task(
self._save_media_background(sent_message, self.db, self.s3_storage)
)
@track_time("handle_audio_post", "post_service")
@track_errors("post_service", "handle_audio_post")
@@ -588,7 +746,13 @@ class PostService:
raw_caption = message.caption or ""
# Получаем скоры для текста
deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption)
(
deepseek_score,
rag_score,
rag_confidence,
rag_score_pos_only,
ml_scores_json,
) = await self._get_scores(raw_caption)
logger.debug(
f"PostService.handle_audio_post: Передача скоров в get_text_message - "
@@ -612,7 +776,11 @@ class PostService:
markup = get_reply_keyboard_for_post()
sent_message = await send_audio_message(
self.settings.group_for_posts, message, message.audio.file_id, post_caption, markup
self.settings.group_for_posts,
message,
message.audio.file_id,
post_caption,
markup,
)
# Определяем анонимность
@@ -623,14 +791,18 @@ class PostService:
text=raw_caption,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(post)
# Сохраняем медиа и скоры в фоне
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
asyncio.create_task(
self._save_media_background(sent_message, self.db, self.s3_storage)
)
if ml_scores_json:
asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json))
asyncio.create_task(
self._save_scores_background(sent_message.message_id, ml_scores_json)
)
@track_time("handle_voice_post", "post_service")
@track_errors("post_service", "handle_voice_post")
@@ -651,17 +823,21 @@ class PostService:
text=raw_caption,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(post)
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
asyncio.create_task(
self._save_media_background(sent_message, self.db, self.s3_storage)
)
@track_time("handle_media_group_post", "post_service")
@track_errors("post_service", "handle_media_group_post")
@db_query_time("handle_media_group_post", "posts", "insert")
@track_media_processing("media_group")
async def handle_media_group_post(self, message: types.Message, album: list, first_name: str) -> None:
async def handle_media_group_post(
self, message: types.Message, album: list, first_name: str
) -> None:
"""Handle media group post submission"""
post_caption = " "
raw_caption = ""
@@ -671,7 +847,13 @@ class PostService:
raw_caption = album[0].caption or ""
# Получаем скоры для текста
deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption)
(
deepseek_score,
rag_score,
rag_confidence,
rag_score_pos_only,
ml_scores_json,
) = await self._get_scores(raw_caption)
logger.debug(
f"PostService.handle_media_group_post: Передача скоров в get_text_message - "
@@ -695,7 +877,12 @@ class PostService:
media_group = await prepare_media_group_from_middlewares(album, post_caption)
media_group_message_ids = await send_media_group_message_to_private_chat(
self.settings.group_for_posts, message, media_group, self.db, None, self.s3_storage
self.settings.group_for_posts,
message,
media_group,
self.db,
None,
self.s3_storage,
)
main_post_id = media_group_message_ids[-1]
@@ -705,13 +892,15 @@ class PostService:
text=raw_caption,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(main_post)
# Сохраняем скоры в фоне
if ml_scores_json:
asyncio.create_task(self._save_scores_background(main_post_id, ml_scores_json))
asyncio.create_task(
self._save_scores_background(main_post_id, ml_scores_json)
)
for msg_id in media_group_message_ids:
await self.db.add_message_link(main_post_id, msg_id)
@@ -720,10 +909,7 @@ class PostService:
markup = get_reply_keyboard_for_post()
helper_message = await send_text_message(
self.settings.group_for_posts,
message,
"^",
markup
self.settings.group_for_posts, message, "^", markup
)
helper_message_id = helper_message.message_id
@@ -732,19 +918,20 @@ class PostService:
text="^",
author_id=message.from_user.id,
helper_text_message_id=main_post_id,
created_at=int(datetime.now().timestamp())
created_at=int(datetime.now().timestamp()),
)
await self.db.add_post(helper_post)
await self.db.update_helper_message(
message_id=main_post_id,
helper_message_id=helper_message_id
message_id=main_post_id, helper_message_id=helper_message_id
)
@track_time("process_post", "post_service")
@track_errors("post_service", "process_post")
@track_media_processing("media_group")
async def process_post(self, message: types.Message, album: Union[list, None] = None) -> None:
async def process_post(
self, message: types.Message, album: Union[list, None] = None
) -> None:
"""
Запускает обработку поста в фоне.
Не блокирует выполнение - сразу возвращает управление.
@@ -752,7 +939,11 @@ class PostService:
first_name = get_first_name(message)
# Определяем тип контента
content_type = "media_group" if message.media_group_id is not None else message.content_type
content_type = (
"media_group"
if message.media_group_id is not None
else message.content_type
)
# Запускаем фоновую обработку
asyncio.create_task(
@@ -771,7 +962,7 @@ class StickerService:
@track_file_operations("sticker")
async def send_random_hello_sticker(self, message: types.Message) -> None:
"""Send random hello sticker with metrics tracking"""
name_stick_hello = list(Path('Stick').rglob('Hello_*'))
name_stick_hello = list(Path("Stick").rglob("Hello_*"))
if not name_stick_hello:
return
random_stick_hello = random.choice(name_stick_hello)
@@ -784,7 +975,7 @@ class StickerService:
@track_file_operations("sticker")
async def send_random_goodbye_sticker(self, message: types.Message) -> None:
"""Send random goodbye sticker with metrics tracking"""
name_stick_bye = list(Path('Stick').rglob('Universal_*'))
name_stick_bye = list(Path("Stick").rglob("Universal_*"))
if not name_stick_bye:
return
random_stick_bye = random.choice(name_stick_bye)

View File

@@ -1,6 +1,7 @@
"""
Утилиты для очистки и диагностики проблем с голосовыми файлами
"""
import asyncio
import os
from pathlib import Path
@@ -24,15 +25,19 @@ class VoiceFileCleanupUtils:
orphaned_records = []
for record in all_audio_records:
file_name = record.get('file_name', '')
user_id = record.get('author_id', 0)
file_name = record.get("file_name", "")
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):
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
except Exception as e:
@@ -52,7 +57,9 @@ class VoiceFileCleanupUtils:
# Получаем все записи из БД
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:
file_name = file_path.stem # Имя файла без расширения
@@ -77,9 +84,13 @@ class VoiceFileCleanupUtils:
return 0
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:
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)
# Удаляем записи
@@ -88,7 +99,9 @@ class VoiceFileCleanupUtils:
try:
await self.bot_db.delete_audio_record_by_file_name(file_name)
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:
logger.error(f"Ошибка при удалении записи {file_name}: {e}")
@@ -109,7 +122,9 @@ class VoiceFileCleanupUtils:
return 0
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:
logger.info(f"DRY RUN: Будет удален файл: {file_path}")
return len(orphaned_files)
@@ -149,7 +164,7 @@ class VoiceFileCleanupUtils:
"total_files": file_count,
"total_size_bytes": total_size,
"total_size_mb": round(total_size / (1024 * 1024), 2),
"directory": VOICE_USERS_DIR
"directory": VOICE_USERS_DIR,
}
except Exception as e:
@@ -179,9 +194,15 @@ class VoiceFileCleanupUtils:
"db_records_count": db_records_count,
"orphaned_db_records_count": len(orphaned_db_records),
"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 для примера
"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']}")

View File

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

View File

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

View File

@@ -7,16 +7,24 @@ from pathlib import Path
from typing import List, Optional, Tuple
from aiogram.types import FSInputFile
from helper_bot.handlers.voice.constants import (MESSAGE_DELAY_1,
from helper_bot.handlers.voice.constants import (
MESSAGE_DELAY_1,
MESSAGE_DELAY_2,
MESSAGE_DELAY_3,
MESSAGE_DELAY_4, STICK_DIR,
STICK_PATTERN, STICKER_DELAY,
VOICE_USERS_DIR)
from helper_bot.handlers.voice.exceptions import (AudioProcessingError,
MESSAGE_DELAY_4,
STICK_DIR,
STICK_PATTERN,
STICKER_DELAY,
VOICE_USERS_DIR,
)
from helper_bot.handlers.voice.exceptions import (
AudioProcessingError,
DatabaseError,
FileOperationError,
VoiceMessageError)
VoiceMessageError,
)
# Local imports - metrics
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
from logs.custom_logger import logger
@@ -24,12 +32,16 @@ from logs.custom_logger import logger
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.user_id = user_id
self.date_added = date_added
self.file_id = file_id
class VoiceBotService:
"""Сервис для работы с голосовыми сообщениями"""
@@ -48,12 +60,16 @@ class VoiceBotService:
random_stick_hello = random.choice(name_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
except Exception as e:
logger.error(f"Ошибка при получении стикера: {e}")
if self.settings['Settings']['logs']:
await self._send_error_to_logs(f'Отправка приветственных стикеров лажает. Ошибка: {e}')
if self.settings["Settings"]["logs"]:
await self._send_error_to_logs(
f"Отправка приветственных стикеров лажает. Ошибка: {e}"
)
return None
@track_time("send_welcome_messages", "voice_bot_service")
@@ -71,86 +87,88 @@ class VoiceBotService:
markup = self._get_main_keyboard()
await message.answer(
text="<b>Привет.</b>",
parse_mode='html',
parse_mode="html",
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 message.answer(
text="<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>",
parse_mode='html',
parse_mode="html",
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 message.answer(
text="Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится..",
parse_mode='html',
parse_mode="html",
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 message.answer(
text="Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>",
parse_mode='html',
parse_mode="html",
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 message.answer(
text="Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)",
parse_mode='html',
parse_mode="html",
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 message.answer(
text="Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)",
parse_mode='html',
parse_mode="html",
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 message.answer(
text=f"Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{user_emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)",
parse_mode='html',
parse_mode="html",
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 message.answer(
text="Так же можешь ознакомиться с инструкцией к боту по команде /help",
parse_mode='html',
parse_mode="html",
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 message.answer(
text="<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤",
parse_mode='html',
parse_mode="html",
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:
logger.error(f"Ошибка при отправке приветственных сообщений: {e}")
raise VoiceMessageError(f"Не удалось отправить приветственные сообщения: {e}")
raise VoiceMessageError(
f"Не удалось отправить приветственные сообщения: {e}"
)
@track_time("get_random_audio", "voice_bot_service")
@track_errors("voice_bot_service", "get_random_audio")
@@ -215,6 +233,7 @@ class VoiceBotService:
def _get_main_keyboard(self):
"""Получить основную клавиатуру"""
from helper_bot.keyboards.keyboards import get_main_keyboard
return get_main_keyboard()
@track_time("send_error_to_logs", "voice_bot_service")
@@ -223,11 +242,9 @@ class VoiceBotService:
"""Отправить ошибку в логи"""
try:
from helper_bot.utils.helper_func import send_voice_message
await send_voice_message(
self.settings['Telegram']['important_logs'],
None,
None,
None
self.settings["Telegram"]["important_logs"], None, None, None
)
except Exception as e:
logger.error(f"Не удалось отправить ошибку в логи: {e}")
@@ -245,25 +262,27 @@ class AudioFileService:
"""Сгенерировать имя файла для аудио"""
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:
# Если нет, то генерируем имя файла
file_name = f'message_from_{user_id}_number_1'
file_name = f"message_from_{user_id}_number_1"
else:
# Иначе берем последнюю запись из БД, добавляем к ней 1
file_name = await self.bot_db.get_path_for_audio_record(user_id=user_id)
if file_name:
# Извлекаем номер из имени файла и увеличиваем на 1
try:
current_number = int(file_name.split('_')[-1])
current_number = int(file_name.split("_")[-1])
new_number = current_number + 1
except (ValueError, IndexError):
new_number = user_audio_count + 1
else:
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
@@ -273,7 +292,9 @@ class AudioFileService:
@track_time("save_audio_file", "audio_file_service")
@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:
# Проверяем существование файла перед сохранением в БД
@@ -283,14 +304,18 @@ class AudioFileService:
raise FileOperationError(error_msg)
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:
logger.error(f"Ошибка при сохранении аудио файла в БД: {e}")
raise DatabaseError(f"Не удалось сохранить аудио файл в БД: {e}")
@track_time("save_audio_file_with_transaction", "audio_file_service")
@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:
# Проверяем существование файла перед сохранением в БД
@@ -301,20 +326,28 @@ class AudioFileService:
# Используем транзакцию для атомарности операции
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:
logger.error(f"Ошибка при сохранении аудио файла в БД с транзакцией: {e}")
raise DatabaseError(f"Не удалось сохранить аудио файл в БД с транзакцией: {e}")
raise DatabaseError(
f"Не удалось сохранить аудио файл в БД с транзакцией: {e}"
)
@track_time("download_and_save_audio", "audio_file_service")
@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 механизмом"""
last_exception = None
for attempt in range(max_retries):
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:
@@ -331,11 +364,15 @@ class AudioFileService:
logger.info(f"Получена информация о файле: {file_info.file_path}")
except Exception as e:
logger.error(f"Ошибка при получении информации о файле: {e}")
raise FileOperationError(f"Не удалось получить информацию о файле: {e}")
raise FileOperationError(
f"Не удалось получить информацию о файле: {e}"
)
# Скачиваем файл
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:
logger.error(f"Ошибка при скачивании файла: {e}")
raise FileOperationError(f"Не удалось скачать файл: {e}")
@@ -368,7 +405,7 @@ class AudioFileService:
logger.error(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}")
# Сбрасываем позицию в файле перед сохранением
@@ -376,7 +413,7 @@ class AudioFileService:
# Сохраняем файл
try:
with open(file_path, 'wb') as new_file:
with open(file_path, "wb") as new_file:
new_file.write(downloaded_file.read())
except Exception as e:
logger.error(f"Ошибка при записи файла на диск: {e}")
@@ -399,7 +436,9 @@ class AudioFileService:
pass
raise FileOperationError(error_msg)
logger.info(f"Файл успешно сохранен: {file_path}, размер: {saved_file_size} bytes")
logger.info(
f"Файл успешно сохранен: {file_path}, размер: {saved_file_size} bytes"
)
return # Успешное завершение
except Exception as e:
@@ -407,22 +446,30 @@ class AudioFileService:
logger.error(f"Попытка {attempt + 1}/{max_retries} неудачна: {e}")
if attempt < max_retries - 1:
wait_time = (attempt + 1) * 2 # Экспоненциальная задержка: 2, 4, 6 секунд
logger.info(f"Ожидание {wait_time} секунд перед следующей попыткой...")
wait_time = (
attempt + 1
) * 2 # Экспоненциальная задержка: 2, 4, 6 секунд
logger.info(
f"Ожидание {wait_time} секунд перед следующей попыткой..."
)
await asyncio.sleep(wait_time)
else:
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_errors("audio_file_service", "verify_file_exists")
async def verify_file_exists(self, file_name: str) -> bool:
"""Проверить существование и валидность файла"""
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):
logger.warning(f"Файл не существует: {file_path}")
@@ -434,10 +481,14 @@ class AudioFileService:
return False
if file_size < 100: # Минимальный размер для аудио файла
logger.warning(f"Файл слишком маленький: {file_path}, размер: {file_size} bytes")
logger.warning(
f"Файл слишком маленький: {file_path}, размер: {file_size} bytes"
)
return False
logger.info(f"Файл проверен и валиден: {file_path}, размер: {file_size} bytes")
logger.info(
f"Файл проверен и валиден: {file_path}, размер: {file_size} bytes"
)
return True
except Exception as e:

View File

@@ -24,22 +24,28 @@ def format_time_ago(date_from_db: str) -> Optional[str]:
much_hour_ago = round(date_difference / 3600, 0)
much_days_ago = int(round(much_hour_ago / 24, 0))
message_with_date = ''
message_with_date = ""
if much_minutes_ago <= 60:
word_minute = plural_time(1, much_minutes_ago)
# Экранируем потенциально проблемные символы
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:
word_hour = plural_time(2, much_hour_ago)
# Экранируем потенциально проблемные символы
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:
word_day = plural_time(3, much_days_ago)
# Экранируем потенциально проблемные символы
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
@@ -52,11 +58,11 @@ def plural_time(type: int, n: float) -> str:
"""Форматировать множественное число для времени"""
word = []
if type == 1:
word = ['минуту', 'минуты', 'минут']
word = ["минуту", "минуты", "минут"]
elif type == 2:
word = ['час', 'часа', 'часов']
word = ["час", "часа", "часов"]
elif type == 3:
word = ['день', 'дня', 'дней']
word = ["день", "дня", "дней"]
else:
return str(int(n))
@@ -68,7 +74,8 @@ def plural_time(type: int, n: float) -> str:
p = 2
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_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:
"""Проверить валидность голосового сообщения"""
return message.content_type == 'voice'
return message.content_type == "voice"
@track_time("get_user_emoji_safe", "voice_utils")
@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:
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:
logger.error(f"Ошибка при получении эмодзи пользователя {user_id}: {e}")
return "😊"

View File

@@ -6,31 +6,44 @@ from aiogram import F, Router, types
from aiogram.filters import Command, MagicData, StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.types import FSInputFile
from helper_bot.filters.main import ChatTypeFilter
from helper_bot.handlers.private.constants import BUTTON_TEXTS, FSM_STATES
from helper_bot.handlers.voice.constants import *
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_last_message_text,
get_user_emoji_safe,
validate_voice_message)
validate_voice_message,
)
from helper_bot.keyboards import get_reply_keyboard
from helper_bot.keyboards.keyboards import (get_main_keyboard,
get_reply_keyboard_for_voice)
from helper_bot.keyboards.keyboards import (
get_main_keyboard,
get_reply_keyboard_for_voice,
)
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
from helper_bot.middlewares.dependencies_middleware import \
DependenciesMiddleware
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
from helper_bot.utils import messages
from helper_bot.utils.helper_func import (check_user_emoji, get_first_name,
send_voice_message, update_user_info)
from helper_bot.utils.helper_func import (
check_user_emoji,
get_first_name,
send_voice_message,
update_user_info,
)
# Local imports - metrics
from helper_bot.utils.metrics import (db_query_time, track_errors,
track_file_operations, track_time)
from helper_bot.utils.metrics import (
db_query_time,
track_errors,
track_file_operations,
track_time,
)
from logs.custom_logger import logger
class VoiceHandlers:
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.router = Router()
self._setup_handlers()
@@ -44,46 +57,42 @@ class VoiceHandlers:
self.router.message.register(
self.cancel_handler,
ChatTypeFilter(chat_type=["private"]),
F.text == "Отменить"
F.text == "Отменить",
)
# Обработчик кнопки "Голосовой бот"
self.router.message.register(
self.voice_bot_button_handler,
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["VOICE_BOT"]
F.text == BUTTON_TEXTS["VOICE_BOT"],
)
# Команды
self.router.message.register(
self.restart_function,
ChatTypeFilter(chat_type=["private"]),
Command(CMD_RESTART)
Command(CMD_RESTART),
)
self.router.message.register(
self.handle_emoji_message,
ChatTypeFilter(chat_type=["private"]),
Command(CMD_EMOJI)
Command(CMD_EMOJI),
)
self.router.message.register(
self.help_function,
ChatTypeFilter(chat_type=["private"]),
Command(CMD_HELP)
self.help_function, ChatTypeFilter(chat_type=["private"]), Command(CMD_HELP)
)
self.router.message.register(
self.start,
ChatTypeFilter(chat_type=["private"]),
Command(CMD_START)
self.start, ChatTypeFilter(chat_type=["private"]), Command(CMD_START)
)
# Дополнительные команды
self.router.message.register(
self.refresh_listen_function,
ChatTypeFilter(chat_type=["private"]),
Command(CMD_REFRESH)
Command(CMD_REFRESH),
)
# Обработчики состояний и кнопок
@@ -91,7 +100,7 @@ class VoiceHandlers:
self.standup_write,
StateFilter(STATE_START),
ChatTypeFilter(chat_type=["private"]),
F.text == BTN_SPEAK
F.text == BTN_SPEAK,
)
self.router.message.register(
@@ -104,42 +113,58 @@ class VoiceHandlers:
self.standup_listen_audio,
StateFilter(STATE_START),
ChatTypeFilter(chat_type=["private"]),
F.text == BTN_LISTEN
F.text == BTN_LISTEN,
)
# Новые обработчики кнопок
self.router.message.register(
self.refresh_listen_function,
ChatTypeFilter(chat_type=["private"]),
F.text == "🔄Сбросить прослушивания"
F.text == "🔄Сбросить прослушивания",
)
self.router.message.register(
self.handle_emoji_message,
ChatTypeFilter(chat_type=["private"]),
F.text == "😊Узнать эмодзи"
F.text == "😊Узнать эмодзи",
)
@track_time("voice_bot_button_handler", "voice_handlers")
@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:
# Проверяем, получал ли пользователь приветственное сообщение
welcome_received = await bot_db.check_voice_bot_welcome_received(message.from_user.id)
logger.info(f"Пользователь {message.from_user.id}: welcome_received = {welcome_received}")
welcome_received = await bot_db.check_voice_bot_welcome_received(
message.from_user.id
)
logger.info(
f"Пользователь {message.from_user.id}: welcome_received = {welcome_received}"
)
if welcome_received:
# Если уже получал приветствие, вызываем 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)
else:
# Если не получал, вызываем start
logger.info(f"Пользователь {message.from_user.id}: вызываем start")
await self.start(message, state, bot_db, settings)
except Exception as e:
logger.error(f"Ошибка при проверке приветственного сообщения для пользователя {message.from_user.id}: {e}")
logger.error(
f"Ошибка при проверке приветственного сообщения для пользователя {message.from_user.id}: {e}"
)
# В случае ошибки вызываем start
await self.start(message, state, bot_db, settings)
@@ -150,46 +175,46 @@ class VoiceHandlers:
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
settings: MagicData("settings"),
):
logger.info(f"Пользователь {message.from_user.id}: вызывается функция restart_function")
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
logger.info(
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 check_user_emoji(message)
markup = get_main_keyboard()
await message.answer(text='🎤 Записывайся или слушай!', reply_markup=markup)
await message.answer(text="🎤 Записывайся или слушай!", reply_markup=markup)
await state.set_state(STATE_START)
@track_time("handle_emoji_message", "voice_handlers")
@track_errors("voice_handlers", "handle_emoji_message")
async def handle_emoji_message(
self,
message: types.Message,
state: FSMContext,
settings: MagicData("settings")
self, message: types.Message, state: FSMContext, settings: MagicData("settings")
):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) запросил информацию об эмодзи")
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
logger.info(
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)
await state.set_state(STATE_START)
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_errors("voice_handlers", "help_function")
async def help_function(
self,
message: types.Message,
state: FSMContext,
settings: MagicData("settings")
self, message: types.Message, state: FSMContext, settings: MagicData("settings")
):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию help_function")
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
logger.info(
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)
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(
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)
@@ -201,25 +226,33 @@ class VoiceHandlers:
message: types.Message,
state: FSMContext,
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 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)
user_emoji = await get_user_emoji_safe(bot_db, message.from_user.id)
# Создаем сервис и отправляем приветственные сообщения
voice_service = VoiceBotService(bot_db, settings)
await voice_service.send_welcome_messages(message, user_emoji)
logger.info(f"Приветственные сообщения отправлены пользователю {message.from_user.id}")
logger.info(
f"Приветственные сообщения отправлены пользователю {message.from_user.id}"
)
# Отмечаем, что пользователь получил приветственное сообщение
try:
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:
logger.error(f"Ошибка при отметке получения приветствия для пользователя {message.from_user.id}: {e}")
logger.error(
f"Ошибка при отметке получения приветствия для пользователя {message.from_user.id}: {e}"
)
@track_time("cancel_handler", "voice_handlers")
@track_errors("voice_handlers", "cancel_handler")
@@ -228,13 +261,15 @@ class VoiceHandlers:
message: types.Message,
state: FSMContext,
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)
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"])
logger.info(f"Пользователь {message.from_user.id} возвращен в главное меню")
@@ -245,10 +280,12 @@ class VoiceHandlers:
message: types.Message,
state: FSMContext,
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")
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
logger.info(
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)
markup = get_main_keyboard()
@@ -256,15 +293,16 @@ class VoiceHandlers:
voice_service = VoiceBotService(bot_db, settings)
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(
text=listenings_cleared_message,
disable_web_page_preview=not settings['Telegram']['preview_link'],
reply_markup=markup
disable_web_page_preview=not settings["Telegram"]["preview_link"],
reply_markup=markup,
)
await state.set_state(STATE_START)
@track_time("standup_write", "voice_handlers")
@track_errors("voice_handlers", "standup_write")
async def standup_write(
@@ -272,12 +310,16 @@ class VoiceHandlers:
message: types.Message,
state: FSMContext,
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")
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
logger.info(
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()
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)
try:
@@ -285,11 +327,12 @@ class VoiceHandlers:
if message_with_date:
await message.answer(text=message_with_date, parse_mode="html")
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)
@track_time("suggest_voice", "voice_handlers")
@track_errors("voice_handlers", "suggest_voice")
async def suggest_voice(
@@ -297,12 +340,12 @@ class VoiceHandlers:
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
settings: MagicData("settings"),
):
logger.info(
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()
if await validate_voice_message(message):
@@ -310,28 +353,37 @@ class VoiceHandlers:
# Отправляем аудио в приватный канал
sent_message = await send_voice_message(
settings['Telegram']['group_for_posts'],
settings["Telegram"]["group_for_posts"],
message,
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 state.set_state(STATE_START)
else:
logger.warning(f"Голосовое сообщение пользователя {message.from_user.id} не прошло валидацию")
unknown_content_message = messages.get_message(get_first_name(message), 'UNKNOWN_CONTENT_MESSAGE')
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
logger.warning(
f"Голосовое сообщение пользователя {message.from_user.id} не прошло валидацию"
)
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 state.set_state(STATE_STANDUP_WRITE)
@track_time("standup_listen_audio", "voice_handlers")
@track_errors("voice_handlers", "standup_listen_audio")
@track_file_operations("voice")
@@ -340,9 +392,11 @@ class VoiceHandlers:
self,
message: types.Message,
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()
# Создаем сервис для работы с аудио
@@ -354,43 +408,57 @@ class VoiceHandlers:
audio_data = await voice_service.get_random_audio(message.from_user.id)
if not audio_data:
logger.warning(f"Для пользователя {message.from_user.id} не найдено доступных аудио для прослушивания")
no_audio_message = messages.get_message(get_first_name(message), 'NO_AUDIO_MESSAGE')
logger.warning(
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)
try:
message_with_date = await get_last_message_text(bot_db)
if message_with_date:
await message.answer(text=message_with_date, parse_mode="html")
except Exception as e:
logger.error(f'Не удалось получить последнюю дату для пользователя {message.from_user.id}: {e}')
logger.error(
f"Не удалось получить последнюю дату для пользователя {message.from_user.id}: {e}"
)
return
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():
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():
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(
text="Файл аудио не найден. Обратитесь к администратору.",
reply_markup=markup
reply_markup=markup,
)
return
# Проверяем размер файла
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(
text="Файл аудио поврежден. Обратитесь к администратору.",
reply_markup=markup
reply_markup=markup,
)
return
@@ -398,11 +466,13 @@ class VoiceHandlers:
# Формируем подпись
if user_emoji:
caption = f'{user_emoji}\nДата записи: {date_added}'
caption = f"{user_emoji}\nДата записи: {date_added}"
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:
from helper_bot.utils.rate_limiter import send_with_rate_limit
@@ -412,25 +482,31 @@ class VoiceHandlers:
chat_id=message.chat.id,
voice=voice,
caption=caption,
reply_markup=markup
reply_markup=markup,
)
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)
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
text=f"Осталось непрослушанных: <b>{remaining_count}</b>",
reply_markup=markup,
)
except Exception as 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. Разреши получение от 'Всех' или добавь меня в исключения"
@@ -438,12 +514,16 @@ class VoiceHandlers:
return # Выходим без записи о прослушивании
else:
logger.error(f"Ошибка при отправке голосового сообщения пользователю {message.from_user.id}: {voice_error}")
logger.error(
f"Ошибка при отправке голосового сообщения пользователю {message.from_user.id}: {voice_error}"
)
raise voice_error
except Exception as e:
logger.error(f"Ошибка при прослушивании аудио для пользователя {message.from_user.id}: {e}")
logger.error(
f"Ошибка при прослушивании аудио для пользователя {message.from_user.id}: {e}"
)
await message.answer(
text="Произошла ошибка при получении аудио. Попробуйте позже.",
reply_markup=markup
reply_markup=markup,
)

View File

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

View File

@@ -6,22 +6,25 @@ from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.strategy import FSMStrategy
from helper_bot.handlers.admin import admin_router
from helper_bot.handlers.callback import callback_router
from helper_bot.handlers.group import group_router
from helper_bot.handlers.private import private_router
from helper_bot.handlers.voice import VoiceHandlers
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
from helper_bot.middlewares.dependencies_middleware import \
DependenciesMiddleware
from helper_bot.middlewares.metrics_middleware import (ErrorMetricsMiddleware,
MetricsMiddleware)
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
from helper_bot.middlewares.metrics_middleware import (
ErrorMetricsMiddleware,
MetricsMiddleware,
)
from helper_bot.middlewares.rate_limit_middleware import RateLimitMiddleware
from helper_bot.server_prometheus import (start_metrics_server,
stop_metrics_server)
from helper_bot.server_prometheus import start_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):
try:
@@ -30,14 +33,21 @@ async def start_bot_with_retry(bot: Bot, dp: Dispatcher, max_retries: int = 5, b
break
except Exception as e:
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:
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)
continue
else:
logging.error(f"Превышено максимальное количество попыток запуска бота: {e}")
logging.error(
f"Превышено максимальное количество попыток запуска бота: {e}"
)
raise
else:
logging.error(f"Критическая ошибка при запуске бота: {e}")
@@ -45,11 +55,15 @@ async def start_bot_with_retry(bot: Bot, dp: Dispatcher, max_retries: int = 5, b
async def start_bot(bdf):
token = bdf.settings['Telegram']['bot_token']
bot = Bot(token=token, default=DefaultBotProperties(
parse_mode='HTML',
link_preview_is_disabled=bdf.settings['Telegram']['preview_link']
), timeout=60.0) # Увеличиваем timeout для стабильности
token = bdf.settings["Telegram"]["bot_token"]
bot = Bot(
token=token,
default=DefaultBotProperties(
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)
@@ -64,7 +78,9 @@ async def start_bot(bdf):
voice_router = voice_handlers.router
# 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 = bdf.get_scoring_manager()
@@ -90,8 +106,8 @@ async def start_bot(bdf):
await bot.delete_webhook(drop_pending_updates=True)
# Запускаем HTTP сервер для метрик параллельно с ботом
metrics_host = bdf.settings.get('Metrics', {}).get('host', '0.0.0.0')
metrics_port = bdf.settings.get('Metrics', {}).get('port', 8080)
metrics_host = bdf.settings.get("Metrics", {}).get("host", "0.0.0.0")
metrics_port = bdf.settings.get("Metrics", {}).get("port", 8080)
try:
# Запускаем метрики сервер

View File

@@ -8,7 +8,9 @@ from aiogram.types import Message
class AlbumGetter:
"""Вспомогательный класс для получения полной медиагруппы из 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.media_group_id = media_group_id
self.event = event
@@ -141,7 +143,7 @@ class AlbumMiddleware(BaseMiddleware):
"messages": [],
"event": album_event,
"task": None,
"first_message_id": message_id
"first_message_id": message_id,
}
# Запускаем фоновую задачу для сбора медиагруппы
task = asyncio.create_task(self._collect_album_background(media_group_id))
@@ -157,9 +159,7 @@ class AlbumMiddleware(BaseMiddleware):
# Передаем объект-геттер в data, чтобы handler мог получить полную медиагруппу
album_getter = AlbumGetter(
self.album_data,
media_group_id,
self.album_data[media_group_id]["event"]
self.album_data, media_group_id, self.album_data[media_group_id]["event"]
)
data["album_getter"] = album_getter

View File

@@ -4,6 +4,7 @@ from typing import Any, Dict
from aiogram import BaseMiddleware, types
from aiogram.types import CallbackQuery, Message, TelegramObject
from helper_bot.utils.base_dependency_factory import get_global_instance
from logs.custom_logger import logger
@@ -12,7 +13,9 @@ BotDB = bdf.get_db()
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
if isinstance(event, Message):
@@ -24,20 +27,28 @@ class BlacklistMiddleware(BaseMiddleware):
if not user:
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):
logger.info(f'BlacklistMiddleware результат для пользователя: {user.username} заблокирован!')
logger.info(
f"BlacklistMiddleware результат для пользователя: {user.username} заблокирован!"
)
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 в человекочитаемый формат
if user_info and user_info[2]:
try:
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):
date_unban = "Не указана"
else:
@@ -46,13 +57,17 @@ class BlacklistMiddleware(BaseMiddleware):
# Отправляем сообщение в зависимости от типа события
if isinstance(event, Message):
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):
await event.answer(
f"<b>Ты заблокирован.</b>\n<b>Причина блокировки:</b> {reason}\n<b>Дата разбана:</b> {date_unban}",
show_alert=True)
show_alert=True,
)
return False
logger.info(f'BlacklistMiddleware результат для пользователя: {user.username} доступ разрешен')
logger.info(
f"BlacklistMiddleware результат для пользователя: {user.username} доступ разрешен"
)
return await handler(event, data)

View File

@@ -2,6 +2,7 @@ from typing import Any, Dict
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
from helper_bot.utils.base_dependency_factory import get_global_instance
from logs.custom_logger import logger
@@ -9,20 +10,24 @@ from logs.custom_logger import logger
class DependenciesMiddleware(BaseMiddleware):
"""Универсальная 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:
# Получаем глобальные зависимости
bdf = get_global_instance()
# Внедряем зависимости в data для MagicData
if 'bot_db' not in data:
data['bot_db'] = bdf.get_db()
if 'settings' not in data:
data['settings'] = bdf.settings
data['bot'] = data.get('bot')
data['dp'] = data.get('dp')
if "bot_db" not in data:
data["bot_db"] = bdf.get_db()
if "settings" not in data:
data["settings"] = bdf.settings
data["bot"] = data.get("bot")
data["dp"] = data.get("dp")
logger.debug(f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}")
logger.debug(
f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}"
)
except Exception as e:
logger.error(f"Ошибка в DependenciesMiddleware: {e}")

View File

@@ -16,16 +16,16 @@ from ..utils.metrics import metrics
# Import button command mapping
try:
from ..handlers.admin.constants import (ADMIN_BUTTON_COMMAND_MAPPING,
ADMIN_COMMANDS)
from ..handlers.admin.constants import ADMIN_BUTTON_COMMAND_MAPPING, ADMIN_COMMANDS
from ..handlers.callback.constants import CALLBACK_COMMAND_MAPPING
from ..handlers.private.constants import BUTTON_COMMAND_MAPPING
from ..handlers.voice.constants import \
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 \
COMMAND_MAPPING as VOICE_COMMAND_MAPPING
from ..handlers.voice.constants import (
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 COMMAND_MAPPING as VOICE_COMMAND_MAPPING
except ImportError:
# Fallback if constants not available
BUTTON_COMMAND_MAPPING = {}
@@ -52,13 +52,16 @@ class MetricsMiddleware(BaseMiddleware):
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
data: Dict[str, Any],
) -> Any:
"""Process event and collect comprehensive metrics."""
# Update active users periodically
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()
self.last_active_users_update = current_time
@@ -67,12 +70,18 @@ class MetricsMiddleware(BaseMiddleware):
event_metrics = {}
# Process event based on type
if hasattr(event, 'message') and event.message:
event_metrics = await self._record_comprehensive_message_metrics(event.message)
if hasattr(event, "message") and event.message:
event_metrics = await self._record_comprehensive_message_metrics(
event.message
)
command_info = self._extract_command_info_with_fallback(event.message)
elif hasattr(event, 'callback_query') and event.callback_query:
event_metrics = await self._record_comprehensive_callback_metrics(event.callback_query)
command_info = self._extract_callback_command_info_with_fallback(event.callback_query)
elif hasattr(event, "callback_query") and event.callback_query:
event_metrics = await self._record_comprehensive_callback_metrics(
event.callback_query
)
command_info = self._extract_callback_command_info_with_fallback(
event.callback_query
)
elif isinstance(event, Message):
event_metrics = await self._record_comprehensive_message_metrics(event)
command_info = self._extract_command_info_with_fallback(event)
@@ -85,7 +94,9 @@ class MetricsMiddleware(BaseMiddleware):
if command_info:
self.logger.info(f"📊 Command info extracted: {command_info}")
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
start_time = time.time()
@@ -96,22 +107,19 @@ class MetricsMiddleware(BaseMiddleware):
# Record successful execution metrics
handler_name = self._get_handler_name(handler)
metrics.record_method_duration(
handler_name,
duration,
"handler",
"success"
)
metrics.record_method_duration(handler_name, duration, "handler", "success")
if command_info:
metrics.record_command(
command_info['command'],
command_info['handler_type'],
command_info['user_type'],
"success"
command_info["command"],
command_info["handler_type"],
command_info["user_type"],
"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
@@ -122,40 +130,36 @@ class MetricsMiddleware(BaseMiddleware):
handler_name = self._get_handler_name(handler)
error_type = type(e).__name__
metrics.record_method_duration(
handler_name,
duration,
"handler",
"error"
)
metrics.record_method_duration(handler_name, duration, "handler", "error")
metrics.record_error(
error_type,
"handler",
handler_name
)
metrics.record_error(error_type, "handler", handler_name)
if command_info:
metrics.record_command(
command_info['command'],
command_info['handler_type'],
command_info['user_type'],
"error"
command_info["command"],
command_info["handler_type"],
command_info["user_type"],
"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
finally:
# Record middleware execution 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):
"""Periodically update active users metric from database."""
try:
# TODO: Должна подключаться к базе данных, а не к глобальному экземпляру
from ..utils.base_dependency_factory import get_global_instance
bdf = get_global_instance()
bot_db = bdf.get_db()
@@ -163,17 +167,19 @@ class MetricsMiddleware(BaseMiddleware):
# Простой подсчет всех пользователей в базе
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 = 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)
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 = 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_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:
self.logger.error(f"❌ Failed to update users metric: {e}")
@@ -181,7 +187,9 @@ class MetricsMiddleware(BaseMiddleware):
metrics.set_active_users(1, "daily")
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."""
# Determine message type
message_type = "text"
@@ -213,118 +221,128 @@ class MetricsMiddleware(BaseMiddleware):
metrics.record_message(message_type, chat_type, "message_handler")
return {
'message_type': message_type,
'chat_type': chat_type,
'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
"message_type": message_type,
"chat_type": chat_type,
"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,
}
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 callback message
metrics.record_message("callback_query", "callback", "callback_handler")
return {
'callback_data': callback.data,
'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
"callback_data": callback.data,
"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,
}
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 unknown event
metrics.record_message("unknown", "unknown", "unknown_handler")
return {
'event_type': type(event).__name__,
'event_data': str(event)[:100] if hasattr(event, '__str__') else "unknown"
"event_type": type(event).__name__,
"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."""
if not message.text:
return None
# Check if it's a slash command
if message.text.startswith('/'):
command_name = message.text.split()[0][1:] # Remove '/' and get command name
if message.text.startswith("/"):
command_name = message.text.split()[0][
1:
] # Remove '/' and get command name
# Check if it's an admin command
if command_name in ADMIN_COMMANDS:
return {
'command': ADMIN_COMMANDS[command_name],
'user_type': "admin" if message.from_user else "unknown",
'handler_type': "admin_handler"
"command": ADMIN_COMMANDS[command_name],
"user_type": "admin" if message.from_user else "unknown",
"handler_type": "admin_handler",
}
# Check if it's a voice bot command
elif command_name in VOICE_COMMAND_MAPPING:
return {
'command': VOICE_COMMAND_MAPPING[command_name],
'user_type': "user" if message.from_user else "unknown",
'handler_type': "voice_command_handler"
"command": VOICE_COMMAND_MAPPING[command_name],
"user_type": "user" if message.from_user else "unknown",
"handler_type": "voice_command_handler",
}
else:
# FALLBACK: Record unknown command
return {
'command': command_name,
'user_type': "user" if message.from_user else "unknown",
'handler_type': "unknown_command_handler"
"command": command_name,
"user_type": "user" if message.from_user else "unknown",
"handler_type": "unknown_command_handler",
}
# Check if it's an admin button click
if message.text in ADMIN_BUTTON_COMMAND_MAPPING:
return {
'command': ADMIN_BUTTON_COMMAND_MAPPING[message.text],
'user_type': "admin" if message.from_user else "unknown",
'handler_type': "admin_button_handler"
"command": ADMIN_BUTTON_COMMAND_MAPPING[message.text],
"user_type": "admin" if message.from_user else "unknown",
"handler_type": "admin_button_handler",
}
# Check if it's a regular button click (text button)
if message.text in BUTTON_COMMAND_MAPPING:
return {
'command': BUTTON_COMMAND_MAPPING[message.text],
'user_type': "user" if message.from_user else "unknown",
'handler_type': "button_handler"
"command": BUTTON_COMMAND_MAPPING[message.text],
"user_type": "user" if message.from_user else "unknown",
"handler_type": "button_handler",
}
# Check if it's a voice bot button click
if message.text in VOICE_BUTTON_COMMAND_MAPPING:
return {
'command': VOICE_BUTTON_COMMAND_MAPPING[message.text],
'user_type': "user" if message.from_user else "unknown",
'handler_type': "voice_button_handler"
"command": VOICE_BUTTON_COMMAND_MAPPING[message.text],
"user_type": "user" if message.from_user else "unknown",
"handler_type": "voice_button_handler",
}
# FALLBACK: Record ANY text message as a command for metrics
if message.text and len(message.text.strip()) > 0:
return {
'command': f"text",
'user_type': "user" if message.from_user else "unknown",
'handler_type': "text_message_handler"
"command": f"text",
"user_type": "user" if message.from_user else "unknown",
"handler_type": "text_message_handler",
}
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."""
if not callback.data:
return None
# Extract command from callback data
parts = callback.data.split(':', 1)
parts = callback.data.split(":", 1)
if parts and parts[0] in CALLBACK_COMMAND_MAPPING:
return {
'command': CALLBACK_COMMAND_MAPPING[parts[0]],
'user_type': "user" if callback.from_user else "unknown",
'handler_type': "callback_handler"
"command": CALLBACK_COMMAND_MAPPING[parts[0]],
"user_type": "user" if callback.from_user else "unknown",
"handler_type": "callback_handler",
}
# Check if it's a voice bot callback
if parts and parts[0] in VOICE_CALLBACK_COMMAND_MAPPING:
return {
'command': VOICE_CALLBACK_COMMAND_MAPPING[parts[0]],
'user_type': "user" if callback.from_user else "unknown",
'handler_type': "voice_callback_handler"
"command": VOICE_CALLBACK_COMMAND_MAPPING[parts[0]],
"user_type": "user" if callback.from_user else "unknown",
"handler_type": "voice_callback_handler",
}
# FALLBACK: Record unknown callback
@@ -343,34 +361,42 @@ class MetricsMiddleware(BaseMiddleware):
command = f"callback_{callback_data[:20]}"
return {
'command': command,
'user_type': "user" if callback.from_user else "unknown",
'handler_type': "unknown_callback_handler"
"command": command,
"user_type": "user" if callback.from_user else "unknown",
"handler_type": "unknown_callback_handler",
}
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."""
try:
# 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
pass
# Record user activity metrics
if event_metrics.get('user_id'):
if event_metrics.get("user_id"):
# This could trigger additional user activity tracking
pass
except Exception as 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."""
try:
# Record specific error context
if event_metrics.get('user_id'):
if event_metrics.get("user_id"):
# You can add user-specific error tracking here
pass
@@ -380,21 +406,22 @@ class MetricsMiddleware(BaseMiddleware):
def _get_handler_name(self, handler: Callable) -> str:
"""Extract handler name efficiently."""
# 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__
elif hasattr(handler, '__qualname__') and handler.__qualname__ != '<lambda>':
elif hasattr(handler, "__qualname__") and handler.__qualname__ != "<lambda>":
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__
elif hasattr(handler, 'view') and hasattr(handler.view, '__name__'):
elif hasattr(handler, "view") and hasattr(handler.view, "__name__"):
return handler.view.__name__
else:
# Пытаемся получить имя из строкового представления
handler_str = str(handler)
if 'function' in handler_str:
if "function" in handler_str:
# Извлекаем имя функции из строки
import re
match = re.search(r'function\s+(\w+)', handler_str)
match = re.search(r"function\s+(\w+)", handler_str)
if match:
return match.group(1)
return "unknown"
@@ -411,12 +438,12 @@ class DatabaseMetricsMiddleware(BaseMiddleware):
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
data: Dict[str, Any],
) -> Any:
"""Process event and collect database metrics."""
# 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
start_time = time.time()
@@ -434,11 +461,7 @@ class DatabaseMetricsMiddleware(BaseMiddleware):
# Record failed database operation
duration = time.time() - start_time
metrics.record_middleware("DatabaseMetricsMiddleware", duration, "error")
metrics.record_error(
type(e).__name__,
"database_middleware",
handler_name
)
metrics.record_error(type(e).__name__, "database_middleware", handler_name)
raise
@@ -453,7 +476,7 @@ class ErrorMetricsMiddleware(BaseMiddleware):
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
data: Dict[str, Any],
) -> Any:
"""Process event and collect error metrics."""
@@ -472,13 +495,11 @@ class ErrorMetricsMiddleware(BaseMiddleware):
except Exception as e:
# Record error metrics
duration = time.time() - start_time
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
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)
raise

View File

@@ -1,12 +1,13 @@
"""
Middleware для автоматического применения rate limiting ко всем входящим сообщениям
"""
from typing import Any, Awaitable, Callable, Dict, Union
from aiogram import BaseMiddleware
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
from aiogram.types import (CallbackQuery, ChatMemberUpdated, InlineQuery,
Message, Update)
from aiogram.types import CallbackQuery, ChatMemberUpdated, InlineQuery, Message, Update
from helper_bot.utils.rate_limiter import telegram_rate_limiter
from logs.custom_logger import logger
@@ -22,7 +23,7 @@ class RateLimitMiddleware(BaseMiddleware):
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Union[Update, Message, CallbackQuery, InlineQuery, ChatMemberUpdated],
data: Dict[str, Any]
data: Dict[str, Any],
) -> Any:
"""Обрабатывает событие с rate limiting"""
@@ -49,10 +50,8 @@ class RateLimitMiddleware(BaseMiddleware):
# Применяем rate limiting к handler
return await self.rate_limiter.send_with_rate_limit(
rate_limited_handler,
chat_id
rate_limited_handler, chat_id
)
else:
# Для других типов событий просто вызываем handler
return await handler(event, data)

View File

@@ -12,7 +12,6 @@ class BulkTextMiddleware(BaseMiddleware):
self.latency = latency
self.texts = defaultdict(list)
async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any:
"""
Main middleware logic.
@@ -37,10 +36,9 @@ class BulkTextMiddleware(BaseMiddleware):
# # Sort the album messages by message_id and add to data
msg_texts = self.texts[key]
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
del self.texts[key]
# # Call the original event handler
return await handler(event, data)

View File

@@ -1,4 +1,3 @@
"""
HTTP server for metrics endpoint integration with centralized Prometheus monitoring.
Provides /metrics endpoint and health check for the bot.
@@ -17,13 +16,14 @@ try:
except ImportError:
# Fallback для случаев, когда custom_logger недоступен
import logging
logger = logging.getLogger(__name__)
class MetricsServer:
"""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.port = port
self.app = web.Application()
@@ -31,8 +31,8 @@ class MetricsServer:
self.site: Optional[web.TCPSite] = None
# Настраиваем роуты
self.app.router.add_get('/metrics', self.metrics_handler)
self.app.router.add_get('/health', self.health_handler)
self.app.router.add_get("/metrics", self.metrics_handler)
self.app.router.add_get("/health", self.health_handler)
async def metrics_handler(self, request: web.Request) -> web.Response:
"""Handle /metrics endpoint for Prometheus scraping."""
@@ -42,27 +42,21 @@ class MetricsServer:
# Проверяем, что metrics доступен
if not metrics:
logger.error("Metrics object is not available")
return web.Response(
text="Metrics not available",
status=500
)
return web.Response(text="Metrics not available", status=500)
# Генерируем метрики в формате Prometheus
metrics_data = metrics.get_metrics()
logger.debug(f"Generated metrics: {len(metrics_data)} bytes")
return web.Response(
body=metrics_data,
content_type='text/plain; version=0.0.4'
body=metrics_data, content_type="text/plain; version=0.0.4"
)
except Exception as e:
logger.error(f"Error generating metrics: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
return web.Response(
text=f"Error generating metrics: {e}",
status=500
)
return web.Response(text=f"Error generating metrics: {e}", status=500)
async def health_handler(self, request: web.Request) -> web.Response:
"""Handle /health endpoint for health checks."""
@@ -71,8 +65,8 @@ class MetricsServer:
if not metrics:
return web.Response(
text="ERROR: Metrics not available",
content_type='text/plain',
status=503
content_type="text/plain",
status=503,
)
# Проверяем, что можем получить метрики
@@ -81,30 +75,25 @@ class MetricsServer:
if not metrics_data:
return web.Response(
text="ERROR: Empty metrics",
content_type='text/plain',
status=503
content_type="text/plain",
status=503,
)
except Exception as e:
return web.Response(
text=f"ERROR: Metrics generation failed: {e}",
content_type='text/plain',
status=503
content_type="text/plain",
status=503,
)
return web.Response(
text="OK",
content_type='text/plain',
status=200
)
return web.Response(text="OK", content_type="text/plain", status=200)
except Exception as e:
logger.error(f"Health check failed: {e}")
return web.Response(
text=f"ERROR: Health check failed: {e}",
content_type='text/plain',
status=500
content_type="text/plain",
status=500,
)
async def start(self) -> None:
"""Start the HTTP server."""
try:
@@ -151,7 +140,9 @@ class MetricsServer:
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."""
global metrics_server
metrics_server = MetricsServer(host, port)

View File

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

View File

@@ -20,6 +20,7 @@ class ScoringResult:
timestamp: Время получения оценки
metadata: Дополнительные данные
"""
score: float
source: str
model: str
@@ -30,7 +31,9 @@ class ScoringResult:
def __post_init__(self):
"""Валидация score в диапазоне [0.0, 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]:
"""Преобразует результат в словарь для сохранения в JSON."""
@@ -68,6 +71,7 @@ class CombinedScore:
rag: Результат от RAG сервиса (None если отключен/ошибка)
errors: Словарь с ошибками по источникам
"""
deepseek: Optional[ScoringResult] = None
rag: Optional[ScoringResult] = None
errors: Dict[str, str] = field(default_factory=dict)

View File

@@ -9,6 +9,7 @@ import json
from typing import List, Optional
import httpx
from helper_bot.utils.metrics import track_errors, track_time
from logs.custom_logger import logger
@@ -180,7 +181,7 @@ class DeepSeekService:
text = response_text.strip()
# Убираем возможные обрамления
text = text.strip('"\'`')
text = text.strip("\"'`")
# Пробуем распарсить как число
score = float(text)
@@ -193,13 +194,18 @@ class DeepSeekService:
except ValueError:
# Пробуем найти число в тексте
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:
score = float(matches[0])
return max(0.0, min(1.0, score))
logger.error(f"DeepSeekService: Не удалось распарсить ответ: {response_text}")
raise DeepSeekAPIError(f"Не удалось распарсить скор из ответа: {response_text}")
logger.error(
f"DeepSeekService: Не удалось распарсить ответ: {response_text}"
)
raise DeepSeekAPIError(
f"Не удалось распарсить скор из ответа: {response_text}"
)
@track_time("calculate_score", "deepseek_service")
@track_errors("deepseek_service", "calculate_score")
@@ -256,7 +262,9 @@ class DeepSeekService:
# Экспоненциальная задержка
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:
"""

View File

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

View File

@@ -7,12 +7,12 @@ HTTP клиент для взаимодействия с внешним RAG се
from typing import Any, Dict, Optional
import httpx
from helper_bot.utils.metrics import track_errors, track_time
from logs.custom_logger import logger
from .base import ScoringResult
from .exceptions import (InsufficientExamplesError, ScoringError,
TextTooShortError)
from .exceptions import InsufficientExamplesError, ScoringError, TextTooShortError
class RagApiClient:
@@ -52,7 +52,7 @@ class RagApiClient:
enabled: Включен ли клиент
"""
# Убираем trailing slash если есть
self.api_url = api_url.rstrip('/')
self.api_url = api_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
self.test_mode = test_mode
@@ -64,10 +64,12 @@ class RagApiClient:
headers={
"X-API-Key": api_key,
"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
def source_name(self) -> str:
@@ -108,8 +110,7 @@ class RagApiClient:
try:
response = await self._client.post(
f"{self.api_url}/score",
json={"text": text.strip()}
f"{self.api_url}/score", json={"text": text.strip()}
)
# Обрабатываем различные статусы
@@ -122,7 +123,10 @@ class RagApiClient:
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)
if "коротк" in error_msg.lower() or "short" in error_msg.lower():
raise TextTooShortError(error_msg)
@@ -137,7 +141,9 @@ class RagApiClient:
raise ScoringError("RAG API endpoint не найден")
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}")
# Проверяем успешный статус
@@ -148,7 +154,11 @@ class RagApiClient:
# Парсим ответ
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
)
rag_score_pos_only_raw = data.get("rag_score_pos_only")
rag_score_pos_only = float(rag_score_pos_only_raw) if rag_score_pos_only_raw is not None else None
@@ -171,10 +181,14 @@ class RagApiClient:
model=data.get("meta", {}).get("model", "rag-service"),
confidence=confidence,
metadata={
"rag_score_pos_only": rag_score_pos_only,
"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"),
"negative_examples": data.get("meta", {}).get("negative_examples"),
}
},
)
except httpx.TimeoutException:
@@ -184,7 +198,9 @@ class RagApiClient:
logger.error(f"RagApiClient: Ошибка подключения к RAG API: {e}")
raise ScoringError(f"Ошибка подключения к RAG API: {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}")
except InsufficientExamplesError:
raise
@@ -195,7 +211,10 @@ class RagApiClient:
raise
except Exception as e:
# Только действительно неожиданные ошибки логируем здесь
logger.error(f"RagApiClient: Неожиданная ошибка при расчете скора: {e}", exc_info=True)
logger.error(
f"RagApiClient: Неожиданная ошибка при расчете скора: {e}",
exc_info=True,
)
raise ScoringError(f"Неожиданная ошибка: {e}")
@track_time("add_positive_example", "rag_client")
@@ -221,20 +240,28 @@ class RagApiClient:
response = await self._client.post(
f"{self.api_url}/examples/positive",
json={"text": text.strip()},
headers=headers
headers=headers,
)
if response.status_code == 200 or response.status_code == 201:
logger.info("RagApiClient: Положительный пример успешно добавлен")
elif response.status_code == 400:
logger.warning(f"RagApiClient: Ошибка валидации при добавлении положительного примера: {response.text}")
logger.warning(
f"RagApiClient: Ошибка валидации при добавлении положительного примера: {response.text}"
)
else:
logger.warning(f"RagApiClient: Неожиданный статус при добавлении положительного примера: {response.status_code}")
logger.warning(
f"RagApiClient: Неожиданный статус при добавлении положительного примера: {response.status_code}"
)
except httpx.TimeoutException:
logger.warning(f"RagApiClient: Таймаут при добавлении положительного примера")
logger.warning(
f"RagApiClient: Таймаут при добавлении положительного примера"
)
except httpx.RequestError as e:
logger.warning(f"RagApiClient: Ошибка подключения при добавлении положительного примера: {e}")
logger.warning(
f"RagApiClient: Ошибка подключения при добавлении положительного примера: {e}"
)
except Exception as e:
logger.error(f"RagApiClient: Ошибка добавления положительного примера: {e}")
@@ -261,20 +288,28 @@ class RagApiClient:
response = await self._client.post(
f"{self.api_url}/examples/negative",
json={"text": text.strip()},
headers=headers
headers=headers,
)
if response.status_code == 200 or response.status_code == 201:
logger.info("RagApiClient: Отрицательный пример успешно добавлен")
elif response.status_code == 400:
logger.warning(f"RagApiClient: Ошибка валидации при добавлении отрицательного примера: {response.text}")
logger.warning(
f"RagApiClient: Ошибка валидации при добавлении отрицательного примера: {response.text}"
)
else:
logger.warning(f"RagApiClient: Неожиданный статус при добавлении отрицательного примера: {response.status_code}")
logger.warning(
f"RagApiClient: Неожиданный статус при добавлении отрицательного примера: {response.status_code}"
)
except httpx.TimeoutException:
logger.warning(f"RagApiClient: Таймаут при добавлении отрицательного примера")
logger.warning(
f"RagApiClient: Таймаут при добавлении отрицательного примера"
)
except httpx.RequestError as e:
logger.warning(f"RagApiClient: Ошибка подключения при добавлении отрицательного примера: {e}")
logger.warning(
f"RagApiClient: Ошибка подключения при добавлении отрицательного примера: {e}"
)
except Exception as e:
logger.error(f"RagApiClient: Ошибка добавления отрицательного примера: {e}")
@@ -294,14 +329,18 @@ class RagApiClient:
if response.status_code == 200:
return response.json()
else:
logger.warning(f"RagApiClient: Неожиданный статус при получении статистики: {response.status_code}")
logger.warning(
f"RagApiClient: Неожиданный статус при получении статистики: {response.status_code}"
)
return {}
except httpx.TimeoutException:
logger.warning(f"RagApiClient: Таймаут при получении статистики")
return {}
except httpx.RequestError as e:
logger.warning(f"RagApiClient: Ошибка подключения при получении статистики: {e}")
logger.warning(
f"RagApiClient: Ошибка подключения при получении статистики: {e}"
)
return {}
except Exception as e:
logger.error(f"RagApiClient: Ошибка получения статистики: {e}")

View File

@@ -13,8 +13,7 @@ from logs.custom_logger import logger
from .base import CombinedScore, ScoringResult
from .deepseek_service import DeepSeekService
from .exceptions import (InsufficientExamplesError, ScoringError,
TextTooShortError)
from .exceptions import InsufficientExamplesError, ScoringError, TextTooShortError
from .rag_client import RagApiClient
@@ -55,7 +54,9 @@ class ScoringManager:
def is_any_enabled(self) -> bool:
"""Проверяет, включен ли хотя бы один сервис."""
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
@track_time("score_post", "scoring_manager")
@@ -197,7 +198,6 @@ class ScoringManager:
await asyncio.gather(*tasks, return_exceptions=True)
logger.info("ScoringManager: Добавлен отрицательный пример")
async def close(self) -> None:
"""Закрывает ресурсы всех сервисов."""
if self.deepseek_service:

View File

@@ -4,6 +4,7 @@ from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from helper_bot.utils.base_dependency_factory import get_global_instance
from logs.custom_logger import logger
@@ -41,16 +42,22 @@ class AutoUnbanScheduler:
# Получаем текущий UNIX 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:
logger.info("Нет пользователей для разблокировки сегодня")
return
logger.info(f"Найдено {len(users_to_unban)} пользователей для разблокировки")
logger.info(
f"Найдено {len(users_to_unban)} пользователей для разблокировки"
)
# Список для отслеживания результатов
success_count = 0
@@ -71,23 +78,30 @@ class AutoUnbanScheduler:
except Exception as e:
failed_count += 1
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)
logger.info(f"Автоматический разбан завершен. Успешно: {success_count}, Ошибок: {failed_count}")
logger.info(
f"Автоматический разбан завершен. Успешно: {success_count}, Ошибок: {failed_count}"
)
except Exception as e:
error_msg = f"Критическая ошибка в автоматическом разбане: {e}"
logger.error(error_msg)
await self._send_error_report(error_msg)
def _generate_report(self, success_count: int, failed_count: int,
failed_users: list, all_users: dict) -> str:
def _generate_report(
self, success_count: int, failed_count: int, failed_users: list, all_users: dict
) -> str:
"""Генерирует отчет о результатах автоматического разбана"""
report = f"🤖 <b>Отчет об автоматическом разбане</b>\n\n"
report += f"📅 Дата: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n"
@@ -114,11 +128,9 @@ class AutoUnbanScheduler:
"""Отправляет отчет в лог-канал"""
try:
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(
chat_id=group_for_logs,
text=report,
parse_mode='HTML'
chat_id=group_for_logs, text=report, parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка при отправке отчета: {e}")
@@ -129,11 +141,11 @@ class AutoUnbanScheduler:
"""Отправляет отчет об ошибке в важный лог-канал"""
try:
if self.bot:
important_logs = self.bdf.settings['Telegram']['important_logs']
important_logs = self.bdf.settings["Telegram"]["important_logs"]
await self.bot.send_message(
chat_id=important_logs,
text=f"🚨 <b>Ошибка автоматического разбана</b>\n\n{error_msg}",
parse_mode='HTML'
parse_mode="HTML",
)
except Exception as e:
logger.error(f"Ошибка при отправке отчета об ошибке: {e}")
@@ -144,15 +156,17 @@ class AutoUnbanScheduler:
# Добавляем задачу на ежедневное выполнение в 5:00 по Москве
self.scheduler.add_job(
self.auto_unban_users,
CronTrigger(hour=5, minute=0, timezone='Europe/Moscow'),
id='auto_unban_users',
name='Автоматический разбан пользователей',
replace_existing=True
CronTrigger(hour=5, minute=0, timezone="Europe/Moscow"),
id="auto_unban_users",
name="Автоматический разбан пользователей",
replace_existing=True,
)
# Запускаем планировщик
self.scheduler.start()
logger.info("Планировщик автоматического разбана запущен. Задача запланирована на 5:00 по Москве")
logger.info(
"Планировщик автоматического разбана запущен. Задача запланирована на 5:00 по Москве"
)
except Exception as e:
logger.error(f"Ошибка при запуске планировщика: {e}")

View File

@@ -2,23 +2,26 @@ import os
import sys
from typing import Optional
from database.async_db import AsyncBotDB
from dotenv import load_dotenv
from database.async_db import AsyncBotDB
from helper_bot.utils.s3_storage import S3StorageService
from logs.custom_logger import logger
class BaseDependencyFactory:
def __init__(self):
project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
env_path = os.path.join(project_dir, '.env')
project_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
env_path = os.path.join(project_dir, ".env")
if os.path.exists(env_path):
load_dotenv(env_path)
self.settings = {}
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):
database_path = os.path.join(project_dir, database_path)
@@ -32,72 +35,81 @@ class BaseDependencyFactory:
def _load_settings_from_env(self):
"""Загружает настройки из переменных окружения."""
self.settings['Telegram'] = {
'bot_token': os.getenv('BOT_TOKEN', ''),
'listen_bot_token': os.getenv('LISTEN_BOT_TOKEN', ''),
'test_bot_token': os.getenv('TEST_BOT_TOKEN', ''),
'preview_link': self._parse_bool(os.getenv('PREVIEW_LINK', 'false')),
'main_public': os.getenv('MAIN_PUBLIC', ''),
'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_logs': self._parse_int(os.getenv('GROUP_FOR_LOGS', '0')),
'important_logs': self._parse_int(os.getenv('IMPORTANT_LOGS', '0')),
'archive': self._parse_int(os.getenv('ARCHIVE', '0')),
'test_group': self._parse_int(os.getenv('TEST_GROUP', '0'))
self.settings["Telegram"] = {
"bot_token": os.getenv("BOT_TOKEN", ""),
"listen_bot_token": os.getenv("LISTEN_BOT_TOKEN", ""),
"test_bot_token": os.getenv("TEST_BOT_TOKEN", ""),
"preview_link": self._parse_bool(os.getenv("PREVIEW_LINK", "false")),
"main_public": os.getenv("MAIN_PUBLIC", ""),
"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_logs": self._parse_int(os.getenv("GROUP_FOR_LOGS", "0")),
"important_logs": self._parse_int(os.getenv("IMPORTANT_LOGS", "0")),
"archive": self._parse_int(os.getenv("ARCHIVE", "0")),
"test_group": self._parse_int(os.getenv("TEST_GROUP", "0")),
}
self.settings['Settings'] = {
'logs': self._parse_bool(os.getenv('LOGS', 'false')),
'test': self._parse_bool(os.getenv('TEST', 'false'))
self.settings["Settings"] = {
"logs": self._parse_bool(os.getenv("LOGS", "false")),
"test": self._parse_bool(os.getenv("TEST", "false")),
}
self.settings['Metrics'] = {
'host': os.getenv('METRICS_HOST', '0.0.0.0'),
'port': self._parse_int(os.getenv('METRICS_PORT', '8080'))
self.settings["Metrics"] = {
"host": os.getenv("METRICS_HOST", "0.0.0.0"),
"port": self._parse_int(os.getenv("METRICS_PORT", "8080")),
}
self.settings['S3'] = {
'enabled': self._parse_bool(os.getenv('S3_ENABLED', 'false')),
'endpoint_url': os.getenv('S3_ENDPOINT_URL', ''),
'access_key': os.getenv('S3_ACCESS_KEY', ''),
'secret_key': os.getenv('S3_SECRET_KEY', ''),
'bucket_name': os.getenv('S3_BUCKET_NAME', ''),
'region': os.getenv('S3_REGION', 'us-east-1')
self.settings["S3"] = {
"enabled": self._parse_bool(os.getenv("S3_ENABLED", "false")),
"endpoint_url": os.getenv("S3_ENDPOINT_URL", ""),
"access_key": os.getenv("S3_ACCESS_KEY", ""),
"secret_key": os.getenv("S3_SECRET_KEY", ""),
"bucket_name": os.getenv("S3_BUCKET_NAME", ""),
"region": os.getenv("S3_REGION", "us-east-1"),
}
# Настройки ML-скоринга
self.settings['Scoring'] = {
self.settings["Scoring"] = {
# RAG API
'rag_enabled': self._parse_bool(os.getenv('RAG_ENABLED', 'false')),
'rag_api_url': os.getenv('RAG_API_URL', ''),
'rag_api_key': os.getenv('RAG_API_KEY', ''),
'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_enabled": self._parse_bool(os.getenv("RAG_ENABLED", "false")),
"rag_api_url": os.getenv("RAG_API_URL", ""),
"rag_api_key": os.getenv("RAG_API_KEY", ""),
"rag_api_timeout": self._parse_int(os.getenv("RAG_API_TIMEOUT", "30")),
"rag_test_mode": self._parse_bool(os.getenv("RAG_TEST_MODE", "false")),
# DeepSeek
'deepseek_enabled': self._parse_bool(os.getenv('DEEPSEEK_ENABLED', 'false')),
'deepseek_api_key': os.getenv('DEEPSEEK_API_KEY', ''),
'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')),
"deepseek_enabled": self._parse_bool(
os.getenv("DEEPSEEK_ENABLED", "false")
),
"deepseek_api_key": os.getenv("DEEPSEEK_API_KEY", ""),
"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):
"""Инициализирует S3StorageService если S3 включен."""
self.s3_storage = None
if self.settings['S3']['enabled']:
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 self.settings["S3"]["enabled"]:
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"]
):
self.s3_storage = S3StorageService(
endpoint_url=s3_config['endpoint_url'],
access_key=s3_config['access_key'],
secret_key=s3_config['secret_key'],
bucket_name=s3_config['bucket_name'],
region=s3_config['region']
endpoint_url=s3_config["endpoint_url"],
access_key=s3_config["access_key"],
secret_key=s3_config["secret_key"],
bucket_name=s3_config["bucket_name"],
region=s3_config["region"],
)
def _parse_bool(self, value: str) -> bool:
"""Парсит строковое значение в boolean."""
return value.lower() in ('true', '1', 'yes', 'on')
return value.lower() in ("true", "1", "yes", "on")
def _parse_int(self, value: str) -> int:
"""Парсит строковое значение в integer."""
@@ -130,16 +142,19 @@ class BaseDependencyFactory:
Вызывается лениво при первом обращении к get_scoring_manager().
"""
from helper_bot.services.scoring import (DeepSeekService, RagApiClient,
ScoringManager)
from helper_bot.services.scoring import (
DeepSeekService,
RagApiClient,
ScoringManager,
)
scoring_config = self.settings['Scoring']
scoring_config = self.settings["Scoring"]
# Инициализация RAG API клиента
rag_client = None
if scoring_config['rag_enabled']:
api_url = scoring_config['rag_api_url']
api_key = scoring_config['rag_api_key']
if scoring_config["rag_enabled"]:
api_url = scoring_config["rag_api_url"]
api_key = scoring_config["rag_api_key"]
if not api_url or not api_key:
logger.warning("RAG включен, но не указаны RAG_API_URL или RAG_API_KEY")
@@ -147,23 +162,27 @@ class BaseDependencyFactory:
rag_client = RagApiClient(
api_url=api_url,
api_key=api_key,
timeout=scoring_config['rag_api_timeout'],
test_mode=scoring_config['rag_test_mode'],
timeout=scoring_config["rag_api_timeout"],
test_mode=scoring_config["rag_test_mode"],
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_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(
api_key=scoring_config['deepseek_api_key'],
api_url=scoring_config['deepseek_api_url'],
model=scoring_config['deepseek_model'],
timeout=scoring_config['deepseek_timeout'],
api_key=scoring_config["deepseek_api_key"],
api_url=scoring_config["deepseek_api_url"],
model=scoring_config["deepseek_model"],
timeout=scoring_config["deepseek_timeout"],
enabled=True,
)
logger.info(f"DeepSeekService инициализирован: {scoring_config['deepseek_model']}")
logger.info(
f"DeepSeekService инициализирован: {scoring_config['deepseek_model']}"
)
# Создаем менеджер
self._scoring_manager = ScoringManager(
@@ -183,11 +202,11 @@ class BaseDependencyFactory:
ScoringManager или 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)
deepseek_enabled = scoring_config.get('deepseek_enabled', False)
rag_enabled = scoring_config.get("rag_enabled", False)
deepseek_enabled = scoring_config.get("deepseek_enabled", False)
if not rag_enabled and not deepseek_enabled:
logger.info("Scoring полностью отключен (RAG и DeepSeek disabled)")
@@ -200,6 +219,7 @@ class BaseDependencyFactory:
_global_instance = None
def get_global_instance():
"""Возвращает глобальный экземпляр BaseDependencyFactory."""
global _global_instance

View File

@@ -10,35 +10,62 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
try:
import emoji as _emoji_lib
_emoji_lib_available = True
except ImportError:
_emoji_lib = None
_emoji_lib_available = False
from aiogram import types
from aiogram.types import (FSInputFile, InputMediaAudio, InputMediaDocument,
InputMediaPhoto, InputMediaVideo)
from aiogram.types import (
FSInputFile,
InputMediaAudio,
InputMediaDocument,
InputMediaPhoto,
InputMediaVideo,
)
from database.models import TelegramPost
from helper_bot.utils.base_dependency_factory import (BaseDependencyFactory,
get_global_instance)
from helper_bot.utils.base_dependency_factory import (
BaseDependencyFactory,
get_global_instance,
)
from logs.custom_logger import logger
# Local imports - metrics
from .metrics import (db_query_time, track_errors, track_file_operations,
track_media_processing, track_time)
from .metrics import (
db_query_time,
track_errors,
track_file_operations,
track_media_processing,
track_time,
)
bdf = get_global_instance()
# TODO: поменять архитектуру и подключить правильный BotDB
BotDB = bdf.get_db()
GROUP_FOR_LOGS = bdf.settings['Telegram']['group_for_logs']
GROUP_FOR_LOGS = bdf.settings["Telegram"]["group_for_logs"]
if _emoji_lib_available and _emoji_lib is not None:
emoji_list = list(_emoji_lib.EMOJI_DATA.keys())
else:
# Fallback minimal emoji set for environments without the 'emoji' package (e.g., CI tests)
emoji_list = [
"🙂", "😀", "😉", "😎", "🤖", "🦄", "🐱", "🐶", "🍀", "🔥",
"🌟", "🎉", "💡", "🚀", "🌈"
"🙂",
"😀",
"😉",
"😎",
"🤖",
"🦄",
"🐱",
"🐶",
"🍀",
"🔥",
"🌟",
"🎉",
"💡",
"🚀",
"🌈",
]
@@ -76,8 +103,8 @@ def get_first_name(message: types.Message) -> str:
# Дополнительная проверка на специальные символы, которые могут вызвать проблемы в HTML
first_name = str(message.from_user.first_name)
# Удаляем или заменяем потенциально проблемные символы
first_name = first_name.replace('\u0cc0', '') # Убираем символ "ೀ" (U+0CC0)
first_name = first_name.replace('\u0cc1', '') # Убираем символ "ೀ" (U+0CC1)
first_name = first_name.replace("\u0cc0", "") # Убираем символ "ೀ" (U+0CC0)
first_name = first_name.replace("\u0cc1", "") # Убираем символ "ೀ" (U+0CC1)
first_name = html.escape(first_name)
return first_name
return ""
@@ -154,20 +181,24 @@ def get_text_message(
# Если передан is_anonymous, используем его, иначе определяем по тексту (legacy)
if is_anonymous is not None:
if is_anonymous:
final_text = f'{safe_post_text}\n\nПост опубликован анонимно'
final_text = f"{safe_post_text}\n\nПост опубликован анонимно"
else:
final_text = f'{safe_post_text}\n\nАвтор поста: {author_info}'
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}"
else:
# Legacy: определяем по тексту
if "неанон" in post_text or "не анон" in post_text:
final_text = f'{safe_post_text}\n\nАвтор поста: {author_info}'
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}"
elif "анон" in post_text:
final_text = f'{safe_post_text}\n\nПост опубликован анонимно'
final_text = f"{safe_post_text}\n\nПост опубликован анонимно"
else:
final_text = f'{safe_post_text}\n\nАвтор поста: {author_info}'
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}"
# Добавляем блок со скорами если есть
if deepseek_score is not None or rag_score is not None or rag_score_pos_only is not None:
if (
deepseek_score is not None
or rag_score is not None
or rag_score_pos_only is not None
):
scores_lines = ["\n📊 Уверенность в одобрении:"]
if deepseek_score is not None:
scores_lines.append(f"DeepSeek: {deepseek_score:.2f}")
@@ -189,11 +220,13 @@ def get_text_message(
return final_text
@track_time("download_file", "helper_func")
@track_errors("helper_func", "download_file")
@track_file_operations("unknown")
async def download_file(message: types.Message, file_id: str, content_type: str = None,
s3_storage = None) -> Optional[str]:
async def download_file(
message: types.Message, file_id: str, content_type: str = None, s3_storage=None
) -> Optional[str]:
"""
Скачивает файл по file_id из Telegram и сохраняет в S3 или на локальный диск.
@@ -211,18 +244,22 @@ async def download_file(message: types.Message, file_id: str, content_type: str
try:
# Валидация параметров
if not file_id or not message or not message.bot:
logger.error("download_file: Неверные параметры - file_id, message или bot отсутствуют")
logger.error(
"download_file: Неверные параметры - file_id, message или bot отсутствуют"
)
return None
# Получаем информацию о файле
file = await message.bot.get_file(file_id)
if not file or not file.file_path:
logger.error(f"download_file: Не удалось получить информацию о файле {file_id}")
logger.error(
f"download_file: Не удалось получить информацию о файле {file_id}"
)
return None
# Определяем расширение
original_filename = os.path.basename(file.file_path)
file_extension = os.path.splitext(original_filename)[1] or '.bin'
file_extension = os.path.splitext(original_filename)[1] or ".bin"
if s3_storage:
# Сохраняем в S3
@@ -233,7 +270,9 @@ async def download_file(message: types.Message, file_id: str, content_type: str
try:
# Скачиваем из Telegram
await message.bot.download_file(file_path=file.file_path, destination=temp_path)
await message.bot.download_file(
file_path=file.file_path, destination=temp_path
)
# Генерируем S3 ключ
s3_key = s3_storage.generate_s3_key(content_type, file_id)
@@ -248,12 +287,16 @@ async def download_file(message: types.Message, file_id: str, content_type: str
pass
if success:
file_size = file.file_size if hasattr(file, 'file_size') else 0
file_size = file.file_size if hasattr(file, "file_size") else 0
download_time = time.time() - start_time
logger.info(f"download_file: Файл загружен в S3 - {s3_key}, размер: {file_size} байт, время: {download_time:.2f}с")
logger.info(
f"download_file: Файл загружен в S3 - {s3_key}, размер: {file_size} байт, время: {download_time:.2f}с"
)
return s3_key
else:
logger.error(f"download_file: Не удалось загрузить файл в S3: {s3_key}")
logger.error(
f"download_file: Не удалось загрузить файл в S3: {s3_key}"
)
return None
except Exception as e:
# Удаляем временный файл при ошибке
@@ -262,20 +305,22 @@ async def download_file(message: types.Message, file_id: str, content_type: str
except:
pass
download_time = time.time() - start_time
logger.error(f"download_file: Ошибка загрузки файла в S3 {file_id}: {e}, время: {download_time:.2f}с")
logger.error(
f"download_file: Ошибка загрузки файла в S3 {file_id}: {e}, время: {download_time:.2f}с"
)
return None
else:
# Старая логика - сохраняем на локальный диск
# Определяем папку по типу контента
type_folders = {
'photo': 'photos',
'video': 'videos',
'audio': 'music',
'voice': 'voice',
'video_note': 'video_notes'
"photo": "photos",
"video": "videos",
"audio": "music",
"voice": "voice",
"video_note": "video_notes",
}
folder = type_folders.get(content_type, 'other')
folder = type_folders.get(content_type, "other")
base_path = "files"
full_folder_path = os.path.join(base_path, folder)
@@ -283,14 +328,18 @@ async def download_file(message: types.Message, file_id: str, content_type: str
os.makedirs(base_path, exist_ok=True)
os.makedirs(full_folder_path, exist_ok=True)
logger.debug(f"download_file: Начинаю скачивание файла {file_id} типа {content_type} в папку {folder}")
logger.debug(
f"download_file: Начинаю скачивание файла {file_id} типа {content_type} в папку {folder}"
)
# Генерируем уникальное имя файла
safe_filename = f"{file_id}{file_extension}"
file_path = os.path.join(full_folder_path, safe_filename)
# Скачиваем файл
await message.bot.download_file(file_path=file.file_path, destination=file_path)
await message.bot.download_file(
file_path=file.file_path, destination=file_path
)
# Проверяем, что файл действительно скачался
if not os.path.exists(file_path):
@@ -300,19 +349,24 @@ async def download_file(message: types.Message, file_id: str, content_type: str
file_size = os.path.getsize(file_path)
download_time = time.time() - start_time
logger.info(f"download_file: Файл успешно скачан - {file_path}, размер: {file_size} байт, время: {download_time:.2f}с")
logger.info(
f"download_file: Файл успешно скачан - {file_path}, размер: {file_size} байт, время: {download_time:.2f}с"
)
return file_path
except Exception as e:
download_time = time.time() - start_time
logger.error(f"download_file: Ошибка скачивания файла {file_id}: {e}, время: {download_time:.2f}с")
logger.error(
f"download_file: Ошибка скачивания файла {file_id}: {e}, время: {download_time:.2f}с"
)
return None
@track_time("prepare_media_group_from_middlewares", "helper_func")
@track_errors("helper_func", "prepare_media_group_from_middlewares")
@track_media_processing("media_group")
async def prepare_media_group_from_middlewares(album, post_caption: str = ''):
async def prepare_media_group_from_middlewares(album, post_caption: str = ""):
"""
Создает MediaGroup согласно best practices aiogram 3.x.
@@ -333,28 +387,36 @@ async def prepare_media_group_from_middlewares(album, post_caption: str = ''):
file_id = message.photo[-1].file_id
# Для фото используем InputMediaPhoto
if i == 0: # Первое фото получает подпись
media_group.append(InputMediaPhoto(media=file_id, caption=safe_post_caption))
media_group.append(
InputMediaPhoto(media=file_id, caption=safe_post_caption)
)
else:
media_group.append(InputMediaPhoto(media=file_id))
elif message.video:
file_id = message.video.file_id
# Для видео используем InputMediaVideo
if i == 0: # Первое видео получает подпись
media_group.append(InputMediaVideo(media=file_id, caption=safe_post_caption))
media_group.append(
InputMediaVideo(media=file_id, caption=safe_post_caption)
)
else:
media_group.append(InputMediaVideo(media=file_id))
elif message.audio:
file_id = message.audio.file_id
# Для аудио используем InputMediaAudio
if i == 0: # Первое аудио получает подпись
media_group.append(InputMediaAudio(media=file_id, caption=safe_post_caption))
media_group.append(
InputMediaAudio(media=file_id, caption=safe_post_caption)
)
else:
media_group.append(InputMediaAudio(media=file_id))
elif message.document:
file_id = message.document.file_id
# Для документов используем InputMediaDocument (если поддерживается)
if i == 0: # Первый документ получает подпись
media_group.append(InputMediaDocument(media=file_id, caption=safe_post_caption))
media_group.append(
InputMediaDocument(media=file_id, caption=safe_post_caption)
)
else:
media_group.append(InputMediaDocument(media=file_id))
else:
@@ -363,21 +425,38 @@ async def prepare_media_group_from_middlewares(album, post_caption: str = ''):
return media_group
async def _save_media_group_background(sent_message: List[types.Message], bot_db: Any, main_post_id: Optional[int], s3_storage) -> None:
async def _save_media_group_background(
sent_message: List[types.Message],
bot_db: Any,
main_post_id: Optional[int],
s3_storage,
) -> None:
"""Сохраняет медиагруппу в фоне, чтобы не блокировать ответ пользователю"""
try:
success = await add_in_db_media_mediagroup(sent_message, bot_db, main_post_id, s3_storage)
success = await add_in_db_media_mediagroup(
sent_message, bot_db, main_post_id, s3_storage
)
if not success:
logger.warning(f"_save_media_group_background: Не удалось сохранить медиа для медиагруппы {sent_message[-1].message_id}")
logger.warning(
f"_save_media_group_background: Не удалось сохранить медиа для медиагруппы {sent_message[-1].message_id}"
)
except Exception as e:
logger.error(f"_save_media_group_background: Ошибка при сохранении медиа для медиагруппы {sent_message[-1].message_id}: {e}")
logger.error(
f"_save_media_group_background: Ошибка при сохранении медиа для медиагруппы {sent_message[-1].message_id}: {e}"
)
@track_time("add_in_db_media_mediagroup", "helper_func")
@track_errors("helper_func", "add_in_db_media_mediagroup")
@track_media_processing("media_group")
@db_query_time("add_in_db_media_mediagroup", "posts", "insert")
async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db: Any,
main_post_id: Optional[int] = None, s3_storage = None) -> bool:
async def add_in_db_media_mediagroup(
sent_message: List[types.Message],
bot_db: Any,
main_post_id: Optional[int] = None,
s3_storage=None,
) -> bool:
"""
Добавляет контент медиа-группы в базу данных
@@ -394,7 +473,9 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db:
try:
# Валидация параметров
if not sent_message or not bot_db or not isinstance(sent_message, list):
logger.error("add_in_db_media_mediagroup: Неверные параметры - sent_message, bot_db или sent_message не является списком")
logger.error(
"add_in_db_media_mediagroup: Неверные параметры - sent_message, bot_db или sent_message не является списком"
)
return False
if len(sent_message) == 0:
@@ -412,27 +493,31 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db:
file_id = None
if message.photo:
content_type = 'photo'
content_type = "photo"
file_id = message.photo[-1].file_id
elif message.video:
content_type = 'video'
content_type = "video"
file_id = message.video.file_id
elif message.audio:
content_type = 'audio'
content_type = "audio"
file_id = message.audio.file_id
elif message.voice:
content_type = 'voice'
content_type = "voice"
file_id = message.voice.file_id
elif message.video_note:
content_type = 'video_note'
content_type = "video_note"
file_id = message.video_note.file_id
else:
logger.warning(f"add_in_db_media_mediagroup: Неподдерживаемый тип контента в сообщении {i+1}/{len(sent_message)}")
logger.warning(
f"add_in_db_media_mediagroup: Неподдерживаемый тип контента в сообщении {i+1}/{len(sent_message)}"
)
failed_count += 1
continue
if not file_id:
logger.error(f"add_in_db_media_mediagroup: file_id отсутствует в сообщении {i+1}/{len(sent_message)}")
logger.error(
f"add_in_db_media_mediagroup: file_id отсутствует в сообщении {i+1}/{len(sent_message)}"
)
failed_count += 1
continue
@@ -440,50 +525,74 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db:
bdf = get_global_instance()
s3_storage = bdf.get_s3_storage()
file_path = await download_file(message, file_id=file_id, content_type=content_type, s3_storage=s3_storage)
file_path = await download_file(
message,
file_id=file_id,
content_type=content_type,
s3_storage=s3_storage,
)
if not file_path:
logger.error(f"add_in_db_media_mediagroup: Не удалось скачать файл {file_id} в сообщении {i+1}/{len(sent_message)}")
logger.error(
f"add_in_db_media_mediagroup: Не удалось скачать файл {file_id} в сообщении {i+1}/{len(sent_message)}"
)
failed_count += 1
continue
success = await bot_db.add_post_content(post_id, post_id, file_path, content_type)
success = await bot_db.add_post_content(
post_id, post_id, file_path, content_type
)
if not success:
logger.error(f"add_in_db_media_mediagroup: Не удалось добавить контент в БД для сообщения {i+1}/{len(sent_message)}")
if file_path.startswith('files/'):
logger.error(
f"add_in_db_media_mediagroup: Не удалось добавить контент в БД для сообщения {i+1}/{len(sent_message)}"
)
if file_path.startswith("files/"):
try:
os.remove(file_path)
except Exception as e:
logger.warning(f"add_in_db_media_mediagroup: Не удалось удалить файл {file_path}: {e}")
logger.warning(
f"add_in_db_media_mediagroup: Не удалось удалить файл {file_path}: {e}"
)
failed_count += 1
continue
processed_count += 1
except Exception as e:
logger.error(f"add_in_db_media_mediagroup: Ошибка обработки сообщения {i+1}/{len(sent_message)}: {e}")
logger.error(
f"add_in_db_media_mediagroup: Ошибка обработки сообщения {i+1}/{len(sent_message)}: {e}"
)
failed_count += 1
continue
if processed_count == 0:
logger.error(f"add_in_db_media_mediagroup: Не удалось обработать ни одного сообщения из медиагруппы {post_id}")
logger.error(
f"add_in_db_media_mediagroup: Не удалось обработать ни одного сообщения из медиагруппы {post_id}"
)
return False
if failed_count > 0:
logger.warning(f"add_in_db_media_mediagroup: Обработано {processed_count}/{len(sent_message)} сообщений медиагруппы {post_id}, ошибок: {failed_count}")
logger.warning(
f"add_in_db_media_mediagroup: Обработано {processed_count}/{len(sent_message)} сообщений медиагруппы {post_id}, ошибок: {failed_count}"
)
return failed_count == 0
except Exception as e:
processing_time = time.time() - start_time
logger.error(f"add_in_db_media_mediagroup: Критическая ошибка обработки медиагруппы: {e}, время: {processing_time:.2f}с")
logger.error(
f"add_in_db_media_mediagroup: Критическая ошибка обработки медиагруппы: {e}, время: {processing_time:.2f}с"
)
return False
@track_time("add_in_db_media", "helper_func")
@track_errors("helper_func", "add_in_db_media")
@track_media_processing("media_group")
@db_query_time("add_in_db_media", "posts", "insert")
@track_file_operations("media")
async def add_in_db_media(sent_message: types.Message, bot_db: Any, s3_storage = None) -> bool:
async def add_in_db_media(
sent_message: types.Message, bot_db: Any, s3_storage=None
) -> bool:
"""
Добавляет контент одиночного сообщения в базу данных
@@ -499,7 +608,9 @@ async def add_in_db_media(sent_message: types.Message, bot_db: Any, s3_storage =
try:
# Валидация параметров
if not sent_message or not bot_db:
logger.error("add_in_db_media: Неверные параметры - sent_message или bot_db отсутствуют")
logger.error(
"add_in_db_media: Неверные параметры - sent_message или bot_db отсутствуют"
)
return False
post_id = sent_message.message_id # ID поста (это же сообщение)
@@ -508,29 +619,35 @@ async def add_in_db_media(sent_message: types.Message, bot_db: Any, s3_storage =
# Определяем тип контента и file_id
if sent_message.photo:
content_type = 'photo'
content_type = "photo"
file_id = sent_message.photo[-1].file_id
elif sent_message.video:
content_type = 'video'
content_type = "video"
file_id = sent_message.video.file_id
elif sent_message.voice:
content_type = 'voice'
content_type = "voice"
file_id = sent_message.voice.file_id
elif sent_message.audio:
content_type = 'audio'
content_type = "audio"
file_id = sent_message.audio.file_id
elif sent_message.video_note:
content_type = 'video_note'
content_type = "video_note"
file_id = sent_message.video_note.file_id
else:
logger.warning(f"add_in_db_media: Неподдерживаемый тип контента для сообщения {post_id}")
logger.warning(
f"add_in_db_media: Неподдерживаемый тип контента для сообщения {post_id}"
)
return False
if not file_id:
logger.error(f"add_in_db_media: file_id отсутствует для сообщения {post_id}")
logger.error(
f"add_in_db_media: file_id отсутствует для сообщения {post_id}"
)
return False
logger.debug(f"add_in_db_media: Обрабатываю {content_type} для сообщения {post_id}")
logger.debug(
f"add_in_db_media: Обрабатываю {content_type} для сообщения {post_id}"
)
# Получаем s3_storage если не передан
if s3_storage is None:
@@ -538,40 +655,66 @@ async def add_in_db_media(sent_message: types.Message, bot_db: Any, s3_storage =
s3_storage = bdf.get_s3_storage()
# Скачиваем файл (в S3 или на локальный диск)
file_path = await download_file(sent_message, file_id=file_id, content_type=content_type, s3_storage=s3_storage)
file_path = await download_file(
sent_message,
file_id=file_id,
content_type=content_type,
s3_storage=s3_storage,
)
if not file_path:
logger.error(f"add_in_db_media: Не удалось скачать файл {file_id} для сообщения {post_id}")
logger.error(
f"add_in_db_media: Не удалось скачать файл {file_id} для сообщения {post_id}"
)
return False
# Добавляем в базу данных
success = await bot_db.add_post_content(post_id, sent_message.message_id, file_path, content_type)
success = await bot_db.add_post_content(
post_id, sent_message.message_id, file_path, content_type
)
if not success:
logger.error(f"add_in_db_media: Не удалось добавить контент в БД для сообщения {post_id}")
logger.error(
f"add_in_db_media: Не удалось добавить контент в БД для сообщения {post_id}"
)
# Удаляем скачанный файл при ошибке БД (только если это локальный файл, не S3)
if file_path.startswith('files/'):
if file_path.startswith("files/"):
try:
os.remove(file_path)
logger.debug(f"add_in_db_media: Удален файл {file_path} после ошибки БД")
logger.debug(
f"add_in_db_media: Удален файл {file_path} после ошибки БД"
)
except Exception as e:
logger.warning(f"add_in_db_media: Не удалось удалить файл {file_path}: {e}")
logger.warning(
f"add_in_db_media: Не удалось удалить файл {file_path}: {e}"
)
return False
processing_time = time.time() - start_time
logger.info(f"add_in_db_media: Контент успешно добавлен для сообщения {post_id}, тип: {content_type}, время: {processing_time:.2f}с")
logger.info(
f"add_in_db_media: Контент успешно добавлен для сообщения {post_id}, тип: {content_type}, время: {processing_time:.2f}с"
)
return True
except Exception as e:
processing_time = time.time() - start_time
logger.error(f"add_in_db_media: Ошибка обработки медиа для сообщения {post_id}: {e}, время: {processing_time:.2f}с")
logger.error(
f"add_in_db_media: Ошибка обработки медиа для сообщения {post_id}: {e}, время: {processing_time:.2f}с"
)
return False
@track_time("send_media_group_message_to_private_chat", "helper_func")
@track_errors("helper_func", "send_media_group_message_to_private_chat")
@track_media_processing("media_group")
@db_query_time("send_media_group_message_to_private_chat", "posts", "insert")
async def send_media_group_message_to_private_chat(chat_id: int, message: types.Message,
media_group: List, bot_db: Any, main_post_id: Optional[int] = None, s3_storage=None) -> List[int]:
async def send_media_group_message_to_private_chat(
chat_id: int,
message: types.Message,
media_group: List,
bot_db: Any,
main_post_id: Optional[int] = None,
s3_storage=None,
) -> List[int]:
"""
Отправляет медиагруппу в чат и возвращает все message_id отправленных сообщений.
@@ -594,14 +737,19 @@ async def send_media_group_message_to_private_chat(chat_id: int, message: types.
sent_message_ids = [msg.message_id for msg in sent_messages]
main_message_id = sent_message_ids[-1]
asyncio.create_task(_save_media_group_background(sent_messages, bot_db, main_message_id, s3_storage))
asyncio.create_task(
_save_media_group_background(sent_messages, bot_db, main_message_id, s3_storage)
)
return sent_message_ids
@track_time("send_media_group_to_channel", "helper_func")
@track_errors("helper_func", "send_media_group_to_channel")
@track_media_processing("media_group")
async def send_media_group_to_channel(bot, chat_id: int, post_content: List, post_text: str, s3_storage = None):
async def send_media_group_to_channel(
bot, chat_id: int, post_content: List, post_text: str, s3_storage=None
):
"""
Отправляет медиа-группу с подписью к последнему файлу.
@@ -612,7 +760,9 @@ async def send_media_group_to_channel(bot, chat_id: int, post_content: List, pos
post_text: Текст подписи.
s3_storage: опциональный S3StorageService для работы с S3.
"""
logger.info(f"Начинаю отправку медиа-группы в чат {chat_id}, количество файлов: {len(post_content)}")
logger.info(
f"Начинаю отправку медиа-группы в чат {chat_id}, количество файлов: {len(post_content)}"
)
# Получаем s3_storage если не передан
if s3_storage is None:
@@ -626,11 +776,17 @@ async def send_media_group_to_channel(bot, chat_id: int, post_content: List, pos
for i, file_path_tuple in enumerate(post_content):
try:
file_path, content_type = file_path_tuple
logger.debug(f"Обрабатываю файл {i+1}/{len(post_content)}: {file_path} (тип: {content_type})")
logger.debug(
f"Обрабатываю файл {i+1}/{len(post_content)}: {file_path} (тип: {content_type})"
)
# Проверяем, это S3 ключ или локальный путь
actual_path = file_path
if s3_storage and not file_path.startswith('files/') and not os.path.exists(file_path):
if (
s3_storage
and not file_path.startswith("files/")
and not os.path.exists(file_path)
):
# Это S3 ключ, скачиваем во временный файл
temp_path = await s3_storage.download_to_temp(file_path)
if not temp_path:
@@ -644,12 +800,14 @@ async def send_media_group_to_channel(bot, chat_id: int, post_content: List, pos
file = FSInputFile(path=actual_path)
if content_type == 'video':
if content_type == "video":
media.append(types.InputMediaVideo(media=file))
elif content_type == 'photo':
elif content_type == "photo":
media.append(types.InputMediaPhoto(media=file))
else:
logger.warning(f"Неизвестный тип файла: {content_type} для {file_path}")
logger.warning(
f"Неизвестный тип файла: {content_type} для {file_path}"
)
except FileNotFoundError:
logger.error(f"Файл не найден: {file_path_tuple[0]}")
continue
@@ -664,11 +822,15 @@ async def send_media_group_to_channel(bot, chat_id: int, post_content: List, pos
# Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else ""
media[-1].caption = safe_post_text
logger.debug(f"Добавлена подпись к последнему файлу: {safe_post_text[:50]}{'...' if len(safe_post_text) > 50 else ''}")
logger.debug(
f"Добавлена подпись к последнему файлу: {safe_post_text[:50]}{'...' if len(safe_post_text) > 50 else ''}"
)
try:
sent_messages = await bot.send_media_group(chat_id=chat_id, media=media)
logger.info(f"Медиа-группа успешно отправлена в чат {chat_id}, количество сообщений: {len(sent_messages)}")
logger.info(
f"Медиа-группа успешно отправлена в чат {chat_id}, количество сообщений: {len(sent_messages)}"
)
return sent_messages
except Exception as e:
logger.error(f"Ошибка при отправке медиа-группы в чат {chat_id}: {e}")
@@ -681,9 +843,15 @@ async def send_media_group_to_channel(bot, chat_id: int, post_content: List, pos
except:
pass
@track_time("send_text_message", "helper_func")
@track_errors("helper_func", "send_text_message")
async def send_text_message(chat_id, message: types.Message, post_text: str, markup: types.ReplyKeyboardMarkup = None):
async def send_text_message(
chat_id,
message: types.Message,
post_text: str,
markup: types.ReplyKeyboardMarkup = None,
):
from .rate_limiter import send_with_rate_limit
# Экранируем post_text для безопасного использования в HTML
@@ -691,131 +859,132 @@ async def send_text_message(chat_id, message: types.Message, post_text: str, mar
async def _send_message():
if markup is None:
return await message.bot.send_message(
chat_id=chat_id,
text=safe_post_text
)
return await message.bot.send_message(chat_id=chat_id, text=safe_post_text)
else:
return await message.bot.send_message(
chat_id=chat_id,
text=safe_post_text,
reply_markup=markup
chat_id=chat_id, text=safe_post_text, reply_markup=markup
)
sent_message = await send_with_rate_limit(_send_message, chat_id)
return sent_message
@track_time("send_photo_message", "helper_func")
@track_errors("helper_func", "send_photo_message")
async def send_photo_message(chat_id, message: types.Message, photo: str, post_text: str,
markup: types.ReplyKeyboardMarkup = None):
async def send_photo_message(
chat_id,
message: types.Message,
photo: str,
post_text: str,
markup: types.ReplyKeyboardMarkup = None,
):
# Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else ""
if markup is None:
sent_message = await message.bot.send_photo(
chat_id=chat_id,
caption=safe_post_text,
photo=photo
chat_id=chat_id, caption=safe_post_text, photo=photo
)
else:
sent_message = await message.bot.send_photo(
chat_id=chat_id,
caption=safe_post_text,
photo=photo,
reply_markup=markup
chat_id=chat_id, caption=safe_post_text, photo=photo, reply_markup=markup
)
return sent_message
@track_time("send_video_message", "helper_func")
@track_errors("helper_func", "send_video_message")
async def send_video_message(chat_id, message: types.Message, video: str, post_text: str = "",
markup: types.ReplyKeyboardMarkup = None):
async def send_video_message(
chat_id,
message: types.Message,
video: str,
post_text: str = "",
markup: types.ReplyKeyboardMarkup = None,
):
# Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else ""
if markup is None:
sent_message = await message.bot.send_video(
chat_id=chat_id,
caption=safe_post_text,
video=video
chat_id=chat_id, caption=safe_post_text, video=video
)
else:
sent_message = await message.bot.send_video(
chat_id=chat_id,
caption=safe_post_text,
video=video,
reply_markup=markup
chat_id=chat_id, caption=safe_post_text, video=video, reply_markup=markup
)
return sent_message
@track_time("send_video_note_message", "helper_func")
@track_errors("helper_func", "send_video_note_message")
async def send_video_note_message(chat_id, message: types.Message, video_note: str,
markup: types.ReplyKeyboardMarkup = None):
async def send_video_note_message(
chat_id,
message: types.Message,
video_note: str,
markup: types.ReplyKeyboardMarkup = None,
):
if markup is None:
sent_message = await message.bot.send_video_note(
chat_id=chat_id,
video_note=video_note
chat_id=chat_id, video_note=video_note
)
else:
sent_message = await message.bot.send_video_note(
chat_id=chat_id,
video_note=video_note,
reply_markup=markup
chat_id=chat_id, video_note=video_note, reply_markup=markup
)
return sent_message
@track_time("send_audio_message", "helper_func")
@track_errors("helper_func", "send_audio_message")
async def send_audio_message(chat_id, message: types.Message, audio: str, post_text: str,
markup: types.ReplyKeyboardMarkup = None):
async def send_audio_message(
chat_id,
message: types.Message,
audio: str,
post_text: str,
markup: types.ReplyKeyboardMarkup = None,
):
# Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else ""
if markup is None:
sent_message = await message.bot.send_audio(
chat_id=chat_id,
caption=safe_post_text,
audio=audio
chat_id=chat_id, caption=safe_post_text, audio=audio
)
else:
sent_message = await message.bot.send_audio(
chat_id=chat_id,
caption=safe_post_text,
audio=audio,
reply_markup=markup
chat_id=chat_id, caption=safe_post_text, audio=audio, reply_markup=markup
)
return sent_message
@track_time("send_voice_message", "helper_func")
@track_errors("helper_func", "send_voice_message")
async def send_voice_message(chat_id, message: types.Message, voice: str,
markup: types.ReplyKeyboardMarkup = None):
async def send_voice_message(
chat_id,
message: types.Message,
voice: str,
markup: types.ReplyKeyboardMarkup = None,
):
from .rate_limiter import send_with_rate_limit
async def _send_voice():
if markup is None:
return await message.bot.send_voice(
chat_id=chat_id,
voice=voice
)
return await message.bot.send_voice(chat_id=chat_id, voice=voice)
else:
return await message.bot.send_voice(
chat_id=chat_id,
voice=voice,
reply_markup=markup
chat_id=chat_id, voice=voice, reply_markup=markup
)
return await send_with_rate_limit(_send_voice, chat_id)
@track_time("check_access", "helper_func")
@track_errors("helper_func", "check_access")
@db_query_time("check_access", "users", "select")
async def check_access(user_id: int, bot_db):
"""Проверка прав на совершение действий"""
from logs.custom_logger import logger
result = await bot_db.is_admin(user_id)
logger.info(f"check_access: пользователь {user_id} - результат: {result}")
return result
@@ -827,6 +996,7 @@ def add_days_to_date(days: int):
future_date = current_date + timedelta(days=days)
return int(future_date.timestamp())
@track_time("get_banned_users_list", "helper_func")
@track_errors("helper_func", "get_banned_users_list")
@db_query_time("get_banned_users_list", "users", "select")
@@ -854,7 +1024,9 @@ async def get_banned_users_list(offset: int, bot_db):
# Экранируем пользовательские данные для безопасного использования
safe_user_name = html.escape(str(safe_user_name))
safe_ban_reason = html.escape(str(ban_reason)) if ban_reason else "Причина не указана"
safe_ban_reason = (
html.escape(str(ban_reason)) if ban_reason else "Причина не указана"
)
# Форматируем дату разбана в человекочитаемый формат
if unban_date:
@@ -873,7 +1045,7 @@ async def get_banned_users_list(offset: int, bot_db):
except (ValueError, TypeError):
# Если не удалось, показываем как есть
safe_unban_date = html.escape(str(unban_date))
elif hasattr(unban_date, 'strftime'):
elif hasattr(unban_date, "strftime"):
# Если это datetime объект
safe_unban_date = unban_date.strftime("%d-%m-%Y %H:%M")
else:
@@ -890,6 +1062,7 @@ async def get_banned_users_list(offset: int, bot_db):
message += f"**Дата разбана:** {safe_unban_date}\n\n"
return message
@track_time("get_banned_users_buttons", "helper_func")
@track_errors("helper_func", "get_banned_users_buttons")
@db_query_time("get_banned_users_buttons", "users", "select")
@@ -919,6 +1092,7 @@ async def get_banned_users_buttons(bot_db):
user_ids.append((safe_user_name, user_id))
return user_ids
@track_time("delete_user_blacklist", "helper_func")
@track_errors("helper_func", "delete_user_blacklist")
@db_query_time("delete_user_blacklist", "users", "delete")
@@ -929,7 +1103,9 @@ async def delete_user_blacklist(user_id: int, bot_db):
@track_time("check_username_and_full_name", "helper_func")
@track_errors("helper_func", "check_username_and_full_name")
@db_query_time("check_username_and_full_name", "users", "select")
async def check_username_and_full_name(user_id: int, username: str, full_name: str, bot_db):
async def check_username_and_full_name(
user_id: int, username: str, full_name: str, bot_db
):
"""Проверяет, изменились ли username или full_name пользователя"""
try:
username_db = await bot_db.get_username(user_id)
@@ -939,6 +1115,7 @@ async def check_username_and_full_name(user_id: int, username: str, full_name: s
logger.error(f"Ошибка при проверке username и full_name: {e}")
return False
@track_time("unban_notifier", "helper_func")
@track_errors("helper_func", "unban_notifier")
@db_query_time("unban_notifier", "users", "select")
@@ -980,6 +1157,7 @@ async def update_user_info(source: str, message: types.Message):
if not await BotDB.user_exists(user_id):
# Create User object with current timestamp
from database.models import User
current_timestamp = int(datetime.now().timestamp())
user = User(
user_id=user_id,
@@ -992,18 +1170,23 @@ async def update_user_info(source: str, message: types.Message):
has_stickers=False,
date_added=current_timestamp,
date_changed=current_timestamp,
voice_bot_welcome_received=False
voice_bot_welcome_received=False,
)
await BotDB.add_user(user)
else:
is_need_update = await check_username_and_full_name(user_id, username, full_name, BotDB)
is_need_update = await check_username_and_full_name(
user_id, username, full_name, BotDB
)
if is_need_update:
await BotDB.update_user_info(user_id, username, full_name)
if source != 'voice':
if source != "voice":
await message.answer(
f"Давно не виделись! Вижу что ты изменился;) Теперь буду звать тебя: {full_name}")
await message.bot.send_message(chat_id=GROUP_FOR_LOGS,
text=f'Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {full_name}\nНовый ник:{username}. Новый эмодзи:{user_emoji}')
f"Давно не виделись! Вижу что ты изменился;) Теперь буду звать тебя: {full_name}"
)
await message.bot.send_message(
chat_id=GROUP_FOR_LOGS,
text=f"Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {full_name}\nНовый ник:{username}. Новый эмодзи:{user_emoji}",
)
sleep(1)
await BotDB.update_user_date(user_id)
@@ -1014,7 +1197,11 @@ async def update_user_info(source: str, message: types.Message):
async def check_user_emoji(message: types.Message):
user_id = message.from_user.id
user_emoji = await BotDB.get_user_emoji(user_id=user_id)
if user_emoji is None or user_emoji in ("Смайл еще не определен", "Эмоджи не определен", ""):
if user_emoji is None or user_emoji in (
"Смайл еще не определен",
"Эмоджи не определен",
"",
):
user_emoji = await get_random_emoji()
await BotDB.update_user_emoji(user_id=user_id, emoji=user_emoji)
return user_emoji

View File

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

View File

@@ -10,8 +10,13 @@ from contextlib import asynccontextmanager
from functools import wraps
from typing import Any, Dict, Optional
from prometheus_client import (CONTENT_TYPE_LATEST, Counter, Gauge, Histogram,
generate_latest)
from prometheus_client import (
CONTENT_TYPE_LATEST,
Counter,
Gauge,
Histogram,
generate_latest,
)
from prometheus_client.core import CollectorRegistry
# Метрики rate limiter теперь создаются в основном классе
@@ -28,142 +33,140 @@ class BotMetrics:
# Bot commands counter
self.bot_commands_total = Counter(
'bot_commands_total',
'Total number of bot commands processed',
['command', 'status', 'handler_type', 'user_type'],
registry=self.registry
"bot_commands_total",
"Total number of bot commands processed",
["command", "status", "handler_type", "user_type"],
registry=self.registry,
)
# Method execution time histogram
self.method_duration_seconds = Histogram(
'method_duration_seconds',
'Time spent executing methods',
['method_name', 'handler_type', 'status'],
"method_duration_seconds",
"Time spent executing methods",
["method_name", "handler_type", "status"],
# Оптимизированные buckets для Telegram API (обычно < 1 сек)
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
self.errors_total = Counter(
'errors_total',
'Total number of errors',
['error_type', 'handler_type', 'method_name'],
registry=self.registry
"errors_total",
"Total number of errors",
["error_type", "handler_type", "method_name"],
registry=self.registry,
)
# Active users gauge
self.active_users = Gauge(
'active_users',
'Number of currently active users',
['user_type'],
registry=self.registry
"active_users",
"Number of currently active users",
["user_type"],
registry=self.registry,
)
# Total users gauge (отдельная метрика)
self.total_users = Gauge(
'total_users',
'Total number of users in database',
registry=self.registry
"total_users", "Total number of users in database", registry=self.registry
)
# Database query metrics
self.db_query_duration_seconds = Histogram(
'db_query_duration_seconds',
'Time spent executing database queries',
['query_type', 'table_name', 'operation'],
"db_query_duration_seconds",
"Time spent executing database queries",
["query_type", "table_name", "operation"],
# Оптимизированные buckets для SQLite/PostgreSQL
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
self.db_queries_total = Counter(
'db_queries_total',
'Total number of database queries executed',
['query_type', 'table_name', 'operation'],
registry=self.registry
"db_queries_total",
"Total number of database queries executed",
["query_type", "table_name", "operation"],
registry=self.registry,
)
# Database errors counter
self.db_errors_total = Counter(
'db_errors_total',
'Total number of database errors',
['error_type', 'query_type', 'table_name', 'operation'],
registry=self.registry
"db_errors_total",
"Total number of database errors",
["error_type", "query_type", "table_name", "operation"],
registry=self.registry,
)
# Message processing metrics
self.messages_processed_total = Counter(
'messages_processed_total',
'Total number of messages processed',
['message_type', 'chat_type', 'handler_type'],
registry=self.registry
"messages_processed_total",
"Total number of messages processed",
["message_type", "chat_type", "handler_type"],
registry=self.registry,
)
# Middleware execution metrics
self.middleware_duration_seconds = Histogram(
'middleware_duration_seconds',
'Time spent in middleware execution',
['middleware_name', 'status'],
"middleware_duration_seconds",
"Time spent in middleware execution",
["middleware_name", "status"],
# Middleware должен быть быстрым
buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.25],
registry=self.registry
registry=self.registry,
)
# Rate limiting metrics
self.rate_limit_hits_total = Counter(
'rate_limit_hits_total',
'Total number of rate limit hits',
['limit_type', 'user_id', 'action'],
registry=self.registry
"rate_limit_hits_total",
"Total number of rate limit hits",
["limit_type", "user_id", "action"],
registry=self.registry,
)
# User activity metrics
self.user_activity_total = Counter(
'user_activity_total',
'Total user activity events',
['activity_type', 'user_type', 'chat_type'],
registry=self.registry
"user_activity_total",
"Total user activity events",
["activity_type", "user_type", "chat_type"],
registry=self.registry,
)
# File download metrics
self.file_downloads_total = Counter(
'file_downloads_total',
'Total number of file downloads',
['content_type', 'status'],
registry=self.registry
"file_downloads_total",
"Total number of file downloads",
["content_type", "status"],
registry=self.registry,
)
self.file_download_duration_seconds = Histogram(
'file_download_duration_seconds',
'Time spent downloading files',
['content_type'],
"file_download_duration_seconds",
"Time spent downloading files",
["content_type"],
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(
'file_download_size_bytes',
'Size of downloaded files in bytes',
['content_type'],
"file_download_size_bytes",
"Size of downloaded files in bytes",
["content_type"],
buckets=[1024, 10240, 102400, 1048576, 10485760, 104857600, 1073741824],
registry=self.registry
registry=self.registry,
)
# Media processing metrics
self.media_processing_total = Counter(
'media_processing_total',
'Total number of media processing operations',
['content_type', 'status'],
registry=self.registry
"media_processing_total",
"Total number of media processing operations",
["content_type", "status"],
registry=self.registry,
)
self.media_processing_duration_seconds = Histogram(
'media_processing_duration_seconds',
'Time spent processing media',
['content_type'],
"media_processing_duration_seconds",
"Time spent processing media",
["content_type"],
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):
@@ -171,96 +174,110 @@ class BotMetrics:
try:
# Создаем метрики rate limiter в том же registry
self.rate_limit_requests_total = Counter(
'rate_limit_requests_total',
'Total number of rate limited requests',
['chat_id', 'status', 'error_type'],
registry=self.registry
"rate_limit_requests_total",
"Total number of rate limited requests",
["chat_id", "status", "error_type"],
registry=self.registry,
)
self.rate_limit_errors_total = Counter(
'rate_limit_errors_total',
'Total number of rate limit errors',
['error_type', 'chat_id'],
registry=self.registry
"rate_limit_errors_total",
"Total number of rate limit errors",
["error_type", "chat_id"],
registry=self.registry,
)
self.rate_limit_wait_duration_seconds = Histogram(
'rate_limit_wait_duration_seconds',
'Time spent waiting due to rate limiting',
['chat_id'],
"rate_limit_wait_duration_seconds",
"Time spent waiting due to rate limiting",
["chat_id"],
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(
'rate_limit_active_chats',
'Number of active chats with rate limiting',
registry=self.registry
"rate_limit_active_chats",
"Number of active chats with rate limiting",
registry=self.registry,
)
self.rate_limit_success_rate = Gauge(
'rate_limit_success_rate',
'Success rate of rate limited requests',
['chat_id'],
registry=self.registry
"rate_limit_success_rate",
"Success rate of rate limited requests",
["chat_id"],
registry=self.registry,
)
self.rate_limit_requests_per_minute = Gauge(
'rate_limit_requests_per_minute',
'Requests per minute',
['chat_id'],
registry=self.registry
"rate_limit_requests_per_minute",
"Requests per minute",
["chat_id"],
registry=self.registry,
)
self.rate_limit_total_requests = Gauge(
'rate_limit_total_requests',
'Total number of requests',
['chat_id'],
registry=self.registry
"rate_limit_total_requests",
"Total number of requests",
["chat_id"],
registry=self.registry,
)
self.rate_limit_total_errors = Gauge(
'rate_limit_total_errors',
'Total number of errors',
['chat_id', 'error_type'],
registry=self.registry
"rate_limit_total_errors",
"Total number of errors",
["chat_id", "error_type"],
registry=self.registry,
)
self.rate_limit_avg_wait_time_seconds = Gauge(
'rate_limit_avg_wait_time_seconds',
'Average wait time in seconds',
['chat_id'],
registry=self.registry
"rate_limit_avg_wait_time_seconds",
"Average wait time in seconds",
["chat_id"],
registry=self.registry,
)
except Exception as e:
# Логируем ошибку, но не прерываем инициализацию
import logging
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."""
self.bot_commands_total.labels(
command=command_type,
status=status,
handler_type=handler_type,
user_type=user_type
user_type=user_type,
).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."""
self.errors_total.labels(
error_type=error_type,
handler_type=handler_type,
method_name=method_name
error_type=error_type, handler_type=handler_type, method_name=method_name
).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."""
self.method_duration_seconds.labels(
method_name=method_name,
handler_type=handler_type,
status=status
method_name=method_name, handler_type=handler_type, status=status
).observe(duration)
def set_active_users(self, count: int, user_type: str = "daily"):
@@ -271,69 +288,74 @@ class BotMetrics:
"""Set the total number of users in database."""
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."""
self.db_query_duration_seconds.labels(
query_type=query_type,
table_name=table_name,
operation=operation
query_type=query_type, table_name=table_name, operation=operation
).observe(duration)
self.db_queries_total.labels(
query_type=query_type,
table_name=table_name,
operation=operation
query_type=query_type, table_name=table_name, operation=operation
).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."""
self.messages_processed_total.labels(
message_type=message_type,
chat_type=chat_type,
handler_type=handler_type
message_type=message_type, chat_type=chat_type, handler_type=handler_type
).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."""
self.middleware_duration_seconds.labels(
middleware_name=middleware_name,
status=status
middleware_name=middleware_name, status=status
).observe(duration)
def record_file_download(self, content_type: str, file_size: int, duration: float):
"""Record file download metrics."""
self.file_downloads_total.labels(
content_type=content_type,
status="success"
content_type=content_type, status="success"
).inc()
self.file_download_duration_seconds.labels(
content_type=content_type
).observe(duration)
self.file_download_duration_seconds.labels(content_type=content_type).observe(
duration
)
self.file_download_size_bytes.labels(
content_type=content_type
).observe(file_size)
self.file_download_size_bytes.labels(content_type=content_type).observe(
file_size
)
def record_file_download_error(self, content_type: str, error_message: str):
"""Record file download error metrics."""
self.file_downloads_total.labels(
content_type=content_type,
status="error"
content_type=content_type, status="error"
).inc()
self.errors_total.labels(
error_type="file_download_error",
handler_type="media_processing",
method_name="download_file"
method_name="download_file",
).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."""
status = "success" if success else "error"
self.media_processing_total.labels(
content_type=content_type,
status=status
content_type=content_type, status=status
).inc()
self.media_processing_duration_seconds.labels(
@@ -344,19 +366,31 @@ class BotMetrics:
self.errors_total.labels(
error_type="media_processing_error",
handler_type="media_processing",
method_name="add_in_db_media"
method_name="add_in_db_media",
).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."""
self.db_errors_total.labels(
error_type=error_type,
query_type=query_type,
table_name=table_name,
operation=operation
operation=operation,
).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."""
try:
# Определяем статус
@@ -364,9 +398,7 @@ class BotMetrics:
# Записываем счетчик запросов
self.rate_limit_requests_total.labels(
chat_id=str(chat_id),
status=status,
error_type=error_type or "none"
chat_id=str(chat_id), status=status, error_type=error_type or "none"
).inc()
# Записываем время ожидания
@@ -378,11 +410,11 @@ class BotMetrics:
# Записываем ошибки
if not success and error_type:
self.rate_limit_errors_total.labels(
error_type=error_type,
chat_id=str(chat_id)
error_type=error_type, chat_id=str(chat_id)
).inc()
except Exception as e:
import logging
logging.warning(f"Failed to record rate limit request: {e}")
def update_rate_limit_gauges(self):
@@ -398,39 +430,38 @@ class BotMetrics:
chat_id_str = str(chat_id)
# Процент успеха
self.rate_limit_success_rate.labels(
chat_id=chat_id_str
).set(chat_stats.success_rate)
self.rate_limit_success_rate.labels(chat_id=chat_id_str).set(
chat_stats.success_rate
)
# Запросов в минуту
self.rate_limit_requests_per_minute.labels(
chat_id=chat_id_str
).set(chat_stats.requests_per_minute)
self.rate_limit_requests_per_minute.labels(chat_id=chat_id_str).set(
chat_stats.requests_per_minute
)
# Общее количество запросов
self.rate_limit_total_requests.labels(
chat_id=chat_id_str
).set(chat_stats.total_requests)
self.rate_limit_total_requests.labels(chat_id=chat_id_str).set(
chat_stats.total_requests
)
# Среднее время ожидания
self.rate_limit_avg_wait_time_seconds.labels(
chat_id=chat_id_str
).set(chat_stats.average_wait_time)
self.rate_limit_avg_wait_time_seconds.labels(chat_id=chat_id_str).set(
chat_stats.average_wait_time
)
# Количество ошибок по типам
if chat_stats.retry_after_errors > 0:
self.rate_limit_total_errors.labels(
chat_id=chat_id_str,
error_type="RetryAfter"
chat_id=chat_id_str, error_type="RetryAfter"
).set(chat_stats.retry_after_errors)
if chat_stats.other_errors > 0:
self.rate_limit_total_errors.labels(
chat_id=chat_id_str,
error_type="Other"
chat_id=chat_id_str, error_type="Other"
).set(chat_stats.other_errors)
except Exception as e:
import logging
logging.warning(f"Failed to update rate limit gauges: {e}")
def get_metrics(self) -> bytes:
@@ -448,6 +479,7 @@ metrics = BotMetrics()
# Decorators for easy metric collection
def track_time(method_name: str = None, handler_type: str = "unknown"):
"""Decorator to track execution time of functions."""
def decorator(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
@@ -456,24 +488,16 @@ def track_time(method_name: str = None, handler_type: str = "unknown"):
result = await func(*args, **kwargs)
duration = time.time() - start_time
metrics.record_method_duration(
method_name or func.__name__,
duration,
handler_type,
"success"
method_name or func.__name__, duration, handler_type, "success"
)
return result
except Exception as e:
duration = time.time() - start_time
metrics.record_method_duration(
method_name or func.__name__,
duration,
handler_type,
"error"
method_name or func.__name__, duration, handler_type, "error"
)
metrics.record_error(
type(e).__name__,
handler_type,
method_name or func.__name__
type(e).__name__, handler_type, method_name or func.__name__
)
raise
@@ -484,35 +508,29 @@ def track_time(method_name: str = None, handler_type: str = "unknown"):
result = func(*args, **kwargs)
duration = time.time() - start_time
metrics.record_method_duration(
method_name or func.__name__,
duration,
handler_type,
"success"
method_name or func.__name__, duration, handler_type, "success"
)
return result
except Exception as e:
duration = time.time() - start_time
metrics.record_method_duration(
method_name or func.__name__,
duration,
handler_type,
"error"
method_name or func.__name__, duration, handler_type, "error"
)
metrics.record_error(
type(e).__name__,
handler_type,
method_name or func.__name__
type(e).__name__, handler_type, method_name or func.__name__
)
raise
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator
def track_errors(handler_type: str = "unknown", method_name: str = None):
"""Decorator to track errors in functions."""
def decorator(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
@@ -520,9 +538,7 @@ def track_errors(handler_type: str = "unknown", method_name: str = None):
return await func(*args, **kwargs)
except Exception as e:
metrics.record_error(
type(e).__name__,
handler_type,
method_name or func.__name__
type(e).__name__, handler_type, method_name or func.__name__
)
raise
@@ -532,20 +548,22 @@ def track_errors(handler_type: str = "unknown", method_name: str = None):
return func(*args, **kwargs)
except Exception as e:
metrics.record_error(
type(e).__name__,
handler_type,
method_name or func.__name__
type(e).__name__, handler_type, method_name or func.__name__
)
raise
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
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."""
def decorator(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
@@ -559,16 +577,9 @@ def db_query_time(query_type: str = "unknown", table_name: str = "unknown", oper
duration = time.time() - start_time
metrics.record_db_query(query_type, duration, table_name, operation)
metrics.record_db_error(
type(e).__name__,
query_type,
table_name,
operation
)
metrics.record_error(
type(e).__name__,
"database",
func.__name__
type(e).__name__, query_type, table_name, operation
)
metrics.record_error(type(e).__name__, "database", func.__name__)
raise
@wraps(func)
@@ -583,21 +594,15 @@ def db_query_time(query_type: str = "unknown", table_name: str = "unknown", oper
duration = time.time() - start_time
metrics.record_db_query(query_type, duration, table_name, operation)
metrics.record_db_error(
type(e).__name__,
query_type,
table_name,
operation
)
metrics.record_error(
type(e).__name__,
"database",
func.__name__
type(e).__name__, query_type, table_name, operation
)
metrics.record_error(type(e).__name__, "database", func.__name__)
raise
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator
@@ -612,16 +617,13 @@ async def track_middleware(middleware_name: str):
except Exception as e:
duration = time.time() - start_time
metrics.record_middleware(middleware_name, duration, "error")
metrics.record_error(
type(e).__name__,
"middleware",
middleware_name
)
metrics.record_error(type(e).__name__, "middleware", middleware_name)
raise
def track_media_processing(content_type: str = "unknown"):
"""Decorator to track media processing operations."""
def decorator(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
@@ -652,11 +654,13 @@ def track_media_processing(content_type: str = "unknown"):
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator
def track_file_operations(content_type: str = "unknown"):
"""Decorator to track file download/upload operations."""
def decorator(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
@@ -703,4 +707,5 @@ def track_file_operations(content_type: str = "unknown"):
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator

View File

@@ -1,6 +1,7 @@
"""
Мониторинг и статистика rate limiting
"""
import time
from collections import defaultdict, deque
from dataclasses import dataclass, field
@@ -12,6 +13,7 @@ from logs.custom_logger import logger
@dataclass
class RateLimitStats:
"""Статистика rate limiting для чата"""
chat_id: int
total_requests: int = 0
successful_requests: int = 0
@@ -51,7 +53,9 @@ class RateLimitStats:
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
@@ -64,7 +68,13 @@ class RateLimitMonitor:
self.max_history_size = 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()
@@ -86,12 +96,14 @@ class RateLimitMonitor:
chat_stats.other_errors += 1
# Записываем ошибку в историю
self.error_history.append({
'chat_id': chat_id,
'error_type': error_type,
'timestamp': current_time,
'wait_time': wait_time
})
self.error_history.append(
{
"chat_id": chat_id,
"error_type": error_type,
"timestamp": current_time,
"wait_time": wait_time,
}
)
# Обновляем глобальную статистику
self.global_stats.total_requests += 1
@@ -119,16 +131,15 @@ class RateLimitMonitor:
def get_top_chats_by_requests(self, limit: int = 10) -> List[tuple]:
"""Получает топ чатов по количеству запросов"""
sorted_chats = sorted(
self.stats.items(),
key=lambda x: x[1].total_requests,
reverse=True
self.stats.items(), key=lambda x: x[1].total_requests, reverse=True
)
return sorted_chats[:limit]
def get_chats_with_high_error_rate(self, threshold: float = 0.1) -> List[tuple]:
"""Получает чаты с высоким процентом ошибок"""
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
]
return sorted(high_error_chats, key=lambda x: x[1].error_rate, reverse=True)
@@ -139,8 +150,7 @@ class RateLimitMonitor:
cutoff_time = current_time - (minutes * 60)
return [
error for error in self.error_history
if error['timestamp'] > cutoff_time
error for error in self.error_history if error["timestamp"] > cutoff_time
]
def get_error_summary(self, minutes: int = 60) -> Dict[str, int]:
@@ -149,7 +159,7 @@ class RateLimitMonitor:
error_summary = defaultdict(int)
for error in recent_errors:
error_summary[error['error_type']] += 1
error_summary[error["error_type"]] += 1
return dict(error_summary)
@@ -179,9 +189,13 @@ class RateLimitMonitor:
# Логируем чаты с высоким процентом ошибок
high_error_chats = self.get_chats_with_high_error_rate(0.2)
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
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):
"""Сбрасывает статистику"""
@@ -200,7 +214,12 @@ class 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)
@@ -211,11 +230,11 @@ def get_rate_limit_summary() -> Dict:
recent_errors = rate_limit_monitor.get_recent_errors(60) # За последний час
return {
'total_requests': global_stats.total_requests,
'success_rate': global_stats.success_rate,
'error_rate': global_stats.error_rate,
'recent_errors_count': len(recent_errors),
'active_chats': len(rate_limit_monitor.stats),
'requests_per_minute': global_stats.requests_per_minute,
'average_wait_time': global_stats.average_wait_time
"total_requests": global_stats.total_requests,
"success_rate": global_stats.success_rate,
"error_rate": global_stats.error_rate,
"recent_errors_count": len(recent_errors),
"active_chats": len(rate_limit_monitor.stats),
"requests_per_minute": global_stats.requests_per_minute,
"average_wait_time": global_stats.average_wait_time,
}

View File

@@ -1,12 +1,14 @@
"""
Rate limiter для предотвращения Flood control ошибок в Telegram Bot API
"""
import asyncio
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, Optional
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
from logs.custom_logger import logger
from .metrics import metrics
@@ -15,6 +17,7 @@ from .metrics import metrics
@dataclass
class RateLimitConfig:
"""Конфигурация для rate limiting"""
messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат
burst_limit: int = 3 # Максимум 3 сообщения подряд
retry_after_multiplier: float = 1.2 # Множитель для увеличения задержки при retry
@@ -104,12 +107,7 @@ class RetryHandler:
self.config = config
async def execute_with_retry(
self,
func: Callable,
chat_id: int,
*args,
max_retries: int = 3,
**kwargs
self, func: Callable, chat_id: int, *args, max_retries: int = 3, **kwargs
) -> Any:
"""Выполняет функцию с повторными попытками при ошибках"""
retry_count = 0
@@ -127,7 +125,9 @@ class RetryHandler:
retry_count += 1
if retry_count > max_retries:
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
# Используем время ожидания от Telegram или наше увеличенное
@@ -135,7 +135,9 @@ class RetryHandler:
wait_time = min(wait_time, self.config.max_retry_delay)
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)
current_delay *= self.config.retry_after_multiplier
@@ -143,19 +145,25 @@ class RetryHandler:
retry_count += 1
if retry_count > max_retries:
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
wait_time = min(current_delay, self.config.max_retry_delay)
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)
current_delay *= self.config.retry_after_multiplier
except Exception as e:
# Для других ошибок не делаем retry
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
@@ -168,11 +176,7 @@ class TelegramRateLimiter:
self.retry_handler = RetryHandler(self.config)
async def send_with_rate_limit(
self,
send_func: Callable,
chat_id: int,
*args,
**kwargs
self, send_func: Callable, chat_id: int, *args, **kwargs
) -> Any:
"""Отправляет сообщение с соблюдением rate limit и retry логики"""
@@ -184,8 +188,7 @@ class TelegramRateLimiter:
# Глобальный экземпляр rate limiter
from helper_bot.config.rate_limit_config import (RateLimitSettings,
get_rate_limit_config)
from helper_bot.config.rate_limit_config import RateLimitSettings, get_rate_limit_config
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,
burst_limit=settings.burst_limit,
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")
_default_config = _create_rate_limit_config(_rate_limit_settings)
@@ -204,7 +208,9 @@ _default_config = _create_rate_limit_config(_rate_limit_settings)
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
@@ -216,4 +222,6 @@ async def send_with_rate_limit(send_func: Callable, chat_id: int, *args, **kwarg
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,20 +1,28 @@
"""
Сервис для работы с S3 хранилищем.
"""
import os
import tempfile
from pathlib import Path
from typing import Optional
import aioboto3
from logs.custom_logger import logger
class S3StorageService:
"""Сервис для работы с S3 хранилищем."""
def __init__(self, endpoint_url: str, access_key: str, secret_key: str,
bucket_name: str, region: str = "us-east-1"):
def __init__(
self,
endpoint_url: str,
access_key: str,
secret_key: str,
bucket_name: str,
region: str = "us-east-1",
):
self.endpoint_url = endpoint_url
self.access_key = access_key
self.secret_key = secret_key
@@ -22,26 +30,24 @@ class S3StorageService:
self.region = region
self.session = aioboto3.Session()
async def upload_file(self, file_path: str, s3_key: str,
content_type: Optional[str] = None) -> bool:
async def upload_file(
self, file_path: str, s3_key: str, content_type: Optional[str] = None
) -> bool:
"""Загружает файл в S3."""
try:
async with self.session.client(
's3',
"s3",
endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
region_name=self.region
region_name=self.region,
) as s3:
extra_args = {}
if content_type:
extra_args['ContentType'] = content_type
extra_args["ContentType"] = content_type
await s3.upload_file(
file_path,
self.bucket_name,
s3_key,
ExtraArgs=extra_args
file_path, self.bucket_name, s3_key, ExtraArgs=extra_args
)
logger.info(f"Файл загружен в S3: {s3_key}")
return True
@@ -49,26 +55,24 @@ class S3StorageService:
logger.error(f"Ошибка загрузки файла в S3 {s3_key}: {e}")
return False
async def upload_fileobj(self, file_obj, s3_key: str,
content_type: Optional[str] = None) -> bool:
async def upload_fileobj(
self, file_obj, s3_key: str, content_type: Optional[str] = None
) -> bool:
"""Загружает файл из объекта в S3."""
try:
async with self.session.client(
's3',
"s3",
endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
region_name=self.region
region_name=self.region,
) as s3:
extra_args = {}
if content_type:
extra_args['ContentType'] = content_type
extra_args["ContentType"] = content_type
await s3.upload_fileobj(
file_obj,
self.bucket_name,
s3_key,
ExtraArgs=extra_args
file_obj, self.bucket_name, s3_key, ExtraArgs=extra_args
)
logger.info(f"Файл загружен в S3 из объекта: {s3_key}")
return True
@@ -80,20 +84,16 @@ class S3StorageService:
"""Скачивает файл из S3 на локальный диск."""
try:
async with self.session.client(
's3',
"s3",
endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
region_name=self.region
region_name=self.region,
) as s3:
# Создаем директорию если её нет
os.makedirs(os.path.dirname(local_path), exist_ok=True)
await s3.download_file(
self.bucket_name,
s3_key,
local_path
)
await s3.download_file(self.bucket_name, s3_key, local_path)
logger.info(f"Файл скачан из S3: {s3_key} -> {local_path}")
return True
except Exception as e:
@@ -104,7 +104,7 @@ class S3StorageService:
"""Скачивает файл из S3 во временный файл. Возвращает путь к временному файлу."""
try:
# Определяем расширение из ключа
ext = Path(s3_key).suffix or '.bin'
ext = Path(s3_key).suffix or ".bin"
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
temp_path = temp_file.name
temp_file.close()
@@ -120,18 +120,20 @@ class S3StorageService:
pass
return None
except Exception as e:
logger.error(f"Ошибка скачивания файла из S3 во временный файл {s3_key}: {e}")
logger.error(
f"Ошибка скачивания файла из S3 во временный файл {s3_key}: {e}"
)
return None
async def file_exists(self, s3_key: str) -> bool:
"""Проверяет существование файла в S3."""
try:
async with self.session.client(
's3',
"s3",
endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
region_name=self.region
region_name=self.region,
) as s3:
await s3.head_object(Bucket=self.bucket_name, Key=s3_key)
return True
@@ -142,11 +144,11 @@ class S3StorageService:
"""Удаляет файл из S3."""
try:
async with self.session.client(
's3',
"s3",
endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
region_name=self.region
region_name=self.region,
) as s3:
await s3.delete_object(Bucket=self.bucket_name, Key=s3_key)
logger.info(f"Файл удален из S3: {s3_key}")
@@ -158,19 +160,31 @@ class S3StorageService:
def generate_s3_key(self, content_type: str, file_id: str) -> str:
"""Генерирует S3 ключ для файла. Один и тот же для всех постов с этим file_id."""
type_folders = {
'photo': 'photos',
'video': 'videos',
'audio': 'music',
'voice': 'voice',
'video_note': 'video_notes'
"photo": "photos",
"video": "videos",
"audio": "music",
"voice": "voice",
"video_note": "video_notes",
}
folder = type_folders.get(content_type, 'other')
folder = type_folders.get(content_type, "other")
# Определяем расширение из file_id или используем дефолтное
ext = '.jpg' if content_type == 'photo' else \
'.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'
ext = (
".jpg"
if content_type == "photo"
else (
".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}"

View File

@@ -8,7 +8,7 @@ from loguru import logger
logger.remove()
# 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:
# In container: log to stdout/stderr
@@ -16,13 +16,13 @@ if is_container:
sys.stdout,
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {line} | {message}",
level=os.getenv("LOG_LEVEL", "INFO"),
colorize=True
colorize=True,
)
logger.add(
sys.stderr,
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {line} | {message}",
level="ERROR",
colorize=True
colorize=True,
)
else:
# Local development: log to files
@@ -30,8 +30,8 @@ else:
if not os.path.exists(current_dir):
os.makedirs(current_dir)
today = datetime.date.today().strftime('%Y-%m-%d')
filename = f'{current_dir}/helper_bot_{today}.log'
today = datetime.date.today().strftime("%Y-%m-%d")
filename = f"{current_dir}/helper_bot_{today}.log"
logger.add(
filename,
@@ -42,4 +42,4 @@ else:
)
# 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"
requires-python = ">=3.11"
[tool.black]
line-length = 88
[tool.isort]
profile = "black"
line_length = 88
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]

View File

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

View File

@@ -25,9 +25,9 @@ async def main():
from aiogram.client.default import DefaultBotProperties
auto_unban_bot = Bot(
token=bdf.settings['Telegram']['bot_token'],
default=DefaultBotProperties(parse_mode='HTML'),
timeout=30.0
token=bdf.settings["Telegram"]["bot_token"],
default=DefaultBotProperties(parse_mode="HTML"),
timeout=30.0,
)
# Инициализируем планировщик автоматического разбана
@@ -68,8 +68,8 @@ async def main():
# Останавливаем планировщик метрик
try:
from helper_bot.utils.metrics_scheduler import \
stop_metrics_scheduler
from helper_bot.utils.metrics_scheduler import stop_metrics_scheduler
stop_metrics_scheduler()
logger.info("Планировщик метрик остановлен")
except Exception as e:
@@ -81,7 +81,6 @@ async def main():
# Отменяем задачу бота
bot_task.cancel()
# Ждем завершения задачи бота и получаем результат main bot
try:
results = await asyncio.gather(bot_task, return_exceptions=True)
@@ -92,7 +91,7 @@ async def main():
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:
await main_bot.session.close()
logger.info("Сессия основного бота корректно закрыта")
@@ -105,27 +104,31 @@ async def main():
await auto_unban_bot.session.close()
logger.info("Сессия бота автоматического разбана корректно закрыта")
except Exception as e:
logger.error(f"Ошибка при закрытии сессии бота автоматического разбана: {e}")
logger.error(
f"Ошибка при закрытии сессии бота автоматического разбана: {e}"
)
# Даем время на завершение всех aiohttp соединений
await asyncio.sleep(0.2)
logger.info("Бот корректно остановлен")
def init_db():
db_path = '/app/database/tg-bot-database.db'
schema_path = '/app/database/schema.sql'
db_path = "/app/database/tg-bot-database.db"
schema_path = "/app/database/schema.sql"
if not os.path.exists(db_path):
print("Initializing database...")
with open(schema_path, 'r') as f:
with open(schema_path, "r") as f:
schema = f.read()
with sqlite3.connect(db_path) as conn:
conn.executescript(schema)
print("Database initialized successfully")
if __name__ == '__main__':
if __name__ == "__main__":
try:
init_db()
asyncio.run(main())
@@ -142,6 +145,8 @@ if __name__ == '__main__':
# Ждем завершения всех задач
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
loop.run_until_complete(
asyncio.gather(*pending, return_exceptions=True)
)
loop.close()

View File

@@ -11,6 +11,7 @@
"rag": {"score": 0.90, "model": "rubert-base-cased", "ts": 1706198400}
}
"""
import argparse
import asyncio
import os
@@ -28,7 +29,10 @@ try:
from logs.custom_logger import logger
except ImportError:
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__)
DEFAULT_DB_PATH = "database/tg-bot-database.db"

View File

@@ -4,6 +4,7 @@
Сканирует папку scripts/ и применяет все новые миграции, которые еще не были применены.
"""
import argparse
import asyncio
import importlib.util
@@ -15,9 +16,9 @@ from typing import List, Tuple
# Исключаем служебные скрипты из миграций
EXCLUDED_SCRIPTS = {
'apply_migrations.py',
'test_s3_connection.py',
'voice_cleanup.py',
"apply_migrations.py",
"test_s3_connection.py",
"voice_cleanup.py",
}
DEFAULT_DB_PATH = "database/tg-bot-database.db"
@@ -51,12 +52,13 @@ async def is_migration_script(script_path: Path) -> bool:
spec.loader.exec_module(module)
# Проверяем наличие функции main
if hasattr(module, 'main'):
if hasattr(module, "main"):
import inspect
sig = inspect.signature(module.main)
# Проверяем, что функция принимает db_path
params = list(sig.parameters.keys())
return 'db_path' in params
return "db_path" in params
return False
except Exception:
# Если не удалось проверить, считаем что это не миграция
@@ -79,7 +81,7 @@ async def apply_migration(script_path: Path, db_path: str) -> bool:
cwd=script_path.parent.parent,
capture_output=True,
text=True,
timeout=300 # 5 минут максимум на миграцию
timeout=300, # 5 минут максимум на миграцию
)
if result.returncode == 0:
@@ -127,16 +129,23 @@ async def main(db_path: str, dry_run: bool = False) -> None:
from logs.custom_logger import logger
except ImportError:
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__)
# Импортируем 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():
print(f"❌ Файл migration_repository.py не найден: {migration_repo_path}")
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:
print("Не удалось загрузить модуль migration_repository")
sys.exit(1)
@@ -178,7 +187,8 @@ async def main(db_path: str, dry_run: bool = False) -> None:
# Находим новые миграции
new_migrations = [
(name, path) for name, path in migration_scripts
(name, path)
for name, path in migration_scripts
if name not in applied_migrations
]
@@ -224,9 +234,7 @@ async def main(db_path: str, dry_run: bool = False) -> None:
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Применение миграций базы данных"
)
parser = argparse.ArgumentParser(description="Применение миграций базы данных")
parser.add_argument(
"--db",
default=os.environ.get("DATABASE_PATH", DEFAULT_DB_PATH),

View File

@@ -8,6 +8,7 @@
SQLite не поддерживает DROP COLUMN напрямую (до версии 3.35.0),
поэтому используем пересоздание таблицы.
"""
import argparse
import asyncio
import os
@@ -25,7 +26,10 @@ try:
from logs.custom_logger import logger
except ImportError:
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__)
DEFAULT_DB_PATH = "database/tg-bot-database.db"
@@ -42,7 +46,7 @@ async def get_sqlite_version(conn: aiosqlite.Connection) -> tuple:
"""Возвращает версию SQLite."""
cursor = await conn.execute("SELECT sqlite_version()")
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:

View File

@@ -3,6 +3,7 @@
Скрипт для проверки подключения к S3 хранилищу.
Читает настройки из .env файла или переменных окружения.
"""
import asyncio
import os
import sys
@@ -14,7 +15,7 @@ sys.path.insert(0, str(project_root))
# Загружаем .env файл
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):
load_dotenv(env_path)
@@ -26,11 +27,12 @@ except ImportError:
sys.exit(1)
# Данные для подключения из .env или переменных окружения
S3_ACCESS_KEY = os.getenv('S3_ACCESS_KEY', 'j3tears100@gmail.com')
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_BUCKET_NAME = os.getenv('S3_BUCKET_NAME', 'telegram-helper-bot')
S3_REGION = os.getenv('S3_REGION', 'us-east-1')
S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY", "j3tears100@gmail.com")
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_BUCKET_NAME = os.getenv("S3_BUCKET_NAME", "telegram-helper-bot")
S3_REGION = os.getenv("S3_REGION", "us-east-1")
async def test_s3_connection():
"""Тестирует подключение к S3 хранилищу."""
@@ -45,23 +47,25 @@ async def test_s3_connection():
try:
async with session.client(
's3',
"s3",
endpoint_url=S3_ENDPOINT_URL,
aws_access_key_id=S3_ACCESS_KEY,
aws_secret_access_key=S3_SECRET_KEY,
region_name=S3_REGION
region_name=S3_REGION,
) as s3:
# Пытаемся получить список бакетов (может не иметь прав, пропускаем если ошибка)
print("📦 Получение списка бакетов...")
try:
response = await s3.list_buckets()
buckets = response.get('Buckets', [])
buckets = response.get("Buckets", [])
print(f"✅ Подключение успешно! Найдено бакетов: {len(buckets)}")
if buckets:
print("\n📋 Список бакетов:")
for bucket in buckets:
print(f" - {bucket['Name']} (создан: {bucket.get('CreationDate', 'неизвестно')})")
print(
f" - {bucket['Name']} (создан: {bucket.get('CreationDate', 'неизвестно')})"
)
else:
print("\n⚠️ Бакеты не найдены.")
except Exception as list_error:
@@ -75,14 +79,16 @@ async def test_s3_connection():
test_bucket = S3_BUCKET_NAME
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:
print(f"⚠️ Бакет '{test_bucket}' не найден в списке.")
print(f" Используем первый найденный бакет: '{buckets[0]['Name']}'")
test_bucket = buckets[0]['Name']
print(
f" Используем первый найденный бакет: '{buckets[0]['Name']}'"
)
test_bucket = buckets[0]["Name"]
test_key = 'test-connection.txt'
test_content = b'Test connection to S3 storage'
test_key = "test-connection.txt"
test_content = b"Test connection to S3 storage"
try:
# Проверяем существование бакета
@@ -94,17 +100,15 @@ async def test_s3_connection():
print(" Проверьте права доступа к бакету")
return False
await s3.put_object(
Bucket=test_bucket,
Key=test_key,
Body=test_content
await s3.put_object(Bucket=test_bucket, Key=test_key, Body=test_content)
print(
f"✅ Файл успешно записан в бакет '{test_bucket}' с ключом '{test_key}'"
)
print(f"✅ Файл успешно записан в бакет '{test_bucket}' с ключом '{test_key}'")
# Пытаемся прочитать файл
print("🧪 Тестирование чтения файла...")
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:
print("✅ Файл успешно прочитан, содержимое совпадает")
@@ -120,6 +124,7 @@ async def test_s3_connection():
print(f"❌ Ошибка при тестировании записи/чтения: {e}")
print(f" Тип ошибки: {type(e).__name__}")
import traceback
print(f" Полный traceback:")
traceback.print_exc()
print("\nВозможные причины:")

View File

@@ -2,6 +2,7 @@
"""
Скрипт для диагностики и очистки проблем с голосовыми файлами
"""
import asyncio
import os
import sys
@@ -44,22 +45,24 @@ async def main():
print(f"\n🗄️ База данных:")
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"\n📋 Статус: {diagnostic_result['status']}")
if diagnostic_result['status'] == 'issues_found':
if diagnostic_result["status"] == "issues_found":
print("\n⚠️ Найдены проблемы!")
if diagnostic_result['orphaned_db_records_count'] > 0:
if diagnostic_result["orphaned_db_records_count"] > 0:
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})")
if diagnostic_result['orphaned_files_count'] > 0:
if diagnostic_result["orphaned_files_count"] > 0:
print(f"\n📁 Файлы без записей в БД (первые 10):")
for file_path in diagnostic_result['orphaned_files']:
for file_path in diagnostic_result["orphaned_files"]:
print(f" - {file_path}")
# Предлагаем очистку
@@ -83,8 +86,12 @@ async def main():
elif choice == "3":
print("\n🧹 Полная очистка...")
db_deleted = await cleanup_utils.cleanup_orphaned_db_records(dry_run=False)
files_deleted = await cleanup_utils.cleanup_orphaned_files(dry_run=False)
db_deleted = await cleanup_utils.cleanup_orphaned_db_records(
dry_run=False
)
files_deleted = await cleanup_utils.cleanup_orphaned_files(
dry_run=False
)
print(f"✅ Удалено {db_deleted} записей в БД и {files_deleted} файлов")
elif choice == "4":

View File

@@ -13,13 +13,13 @@ if str(_project_root) not in sys.path:
import pytest
from aiogram.fsm.context import FSMContext
from aiogram.types import Chat, Message, User
from database.async_db import AsyncBotDB
# Импортируем моки в самом начале
import tests.mocks
from database.async_db import AsyncBotDB
# Настройка pytest-asyncio
pytest_plugins = ('pytest_asyncio',)
pytest_plugins = ("pytest_asyncio",)
@pytest.fixture(scope="session")
@@ -100,19 +100,16 @@ def mock_dispatcher():
def test_settings():
"""Возвращает тестовые настройки"""
return {
'Telegram': {
'bot_token': 'test_token_123',
'preview_link': False,
'group_for_posts': '-1001234567890',
'group_for_message': '-1001234567891',
'main_public': '-1001234567892',
'group_for_logs': '-1001234567893',
'important_logs': '-1001234567894'
"Telegram": {
"bot_token": "test_token_123",
"preview_link": False,
"group_for_posts": "-1001234567890",
"group_for_message": "-1001234567891",
"main_public": "-1001234567892",
"group_for_logs": "-1001234567893",
"important_logs": "-1001234567894",
},
'Settings': {
'logs': True,
'test': False
}
"Settings": {"logs": True, "test": False},
}
@@ -129,71 +126,71 @@ def mock_factory(test_settings, mock_db):
@pytest.fixture
def sample_photo_message(mock_message):
"""Создает сообщение с фото для тестов"""
mock_message.content_type = 'photo'
mock_message.caption = 'Тестовое фото'
mock_message.content_type = "photo"
mock_message.caption = "Тестовое фото"
mock_message.media_group_id = None
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
@pytest.fixture
def sample_video_message(mock_message):
"""Создает сообщение с видео для тестов"""
mock_message.content_type = 'video'
mock_message.caption = 'Тестовое видео'
mock_message.content_type = "video"
mock_message.caption = "Тестовое видео"
mock_message.media_group_id = None
mock_message.video = Mock()
mock_message.video.file_id = 'video_file_id'
mock_message.video.file_id = "video_file_id"
return mock_message
@pytest.fixture
def sample_audio_message(mock_message):
"""Создает сообщение с аудио для тестов"""
mock_message.content_type = 'audio'
mock_message.caption = 'Тестовое аудио'
mock_message.content_type = "audio"
mock_message.caption = "Тестовое аудио"
mock_message.media_group_id = None
mock_message.audio = Mock()
mock_message.audio.file_id = 'audio_file_id'
mock_message.audio.file_id = "audio_file_id"
return mock_message
@pytest.fixture
def sample_voice_message(mock_message):
"""Создает голосовое сообщение для тестов"""
mock_message.content_type = 'voice'
mock_message.content_type = "voice"
mock_message.media_group_id = None
mock_message.voice = Mock()
mock_message.voice.file_id = 'voice_file_id'
mock_message.voice.file_id = "voice_file_id"
return mock_message
@pytest.fixture
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.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
@pytest.fixture
def sample_media_group(mock_message):
"""Создает медиагруппу для тестов"""
mock_message.media_group_id = 'group_123'
mock_message.content_type = 'photo'
mock_message.media_group_id = "group_123"
mock_message.content_type = "photo"
album = [mock_message]
album[0].caption = 'Подпись к медиагруппе'
album[0].caption = "Подпись к медиагруппе"
return album
@pytest.fixture
def sample_text_message(mock_message):
"""Создает текстовое сообщение для тестов"""
mock_message.content_type = 'text'
mock_message.text = 'Тестовое текстовое сообщение'
mock_message.content_type = "text"
mock_message.text = "Тестовое текстовое сообщение"
mock_message.media_group_id = None
return mock_message
@@ -201,7 +198,7 @@ def sample_text_message(mock_message):
@pytest.fixture
def sample_document_message(mock_message):
"""Создает сообщение с документом для тестов"""
mock_message.content_type = 'document'
mock_message.content_type = "document"
mock_message.media_group_id = None
return mock_message
@@ -209,18 +206,10 @@ def sample_document_message(mock_message):
# Маркеры для категоризации тестов
def pytest_configure(config):
"""Настройка маркеров pytest"""
config.addinivalue_line(
"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(
"markers", "unit: mark test as unit test"
)
config.addinivalue_line("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("markers", "unit: mark test as unit test")
# Автоматическая маркировка тестов

View File

@@ -3,6 +3,7 @@ import tempfile
from datetime import datetime
import pytest
from database.models import UserMessage
from database.repositories.message_repository import MessageRepository
@@ -10,7 +11,7 @@ from database.repositories.message_repository import MessageRepository
@pytest.fixture(scope="session")
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
yield temp_path
@@ -38,20 +39,20 @@ def sample_messages():
message_text="Первое тестовое сообщение",
user_id=1001,
telegram_message_id=2001,
date=base_timestamp
date=base_timestamp,
),
UserMessage(
message_text="Второе тестовое сообщение",
user_id=1002,
telegram_message_id=2002,
date=base_timestamp + 1
date=base_timestamp + 1,
),
UserMessage(
message_text="Третье тестовое сообщение",
user_id=1003,
telegram_message_id=2003,
date=base_timestamp + 2
)
date=base_timestamp + 2,
),
]
@@ -62,7 +63,7 @@ def message_without_date():
message_text="Сообщение без даты",
user_id=1004,
telegram_message_id=2004,
date=None
date=None,
)
@@ -73,7 +74,7 @@ def message_with_zero_date():
message_text="Сообщение с нулевой датой",
user_id=1005,
telegram_message_id=2005,
date=0
date=0,
)
@@ -84,7 +85,7 @@ def message_with_special_chars():
message_text="Сообщение с 'кавычками', \"двойными кавычками\" и эмодзи 😊\nНовая строка",
user_id=1006,
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,
user_id=1007,
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: 你好世界 🌍 Привет мир",
user_id=1008,
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
import pytest
from database.models import MessageContentLink, PostContent, TelegramPost
from database.repositories.post_repository import PostRepository
@@ -37,7 +38,7 @@ def sample_telegram_post():
text="Тестовый пост для unit тестов",
author_id=67890,
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 сообщением",
author_id=67890,
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="Тестовый пост без даты",
author_id=67890,
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():
"""Создает тестовый объект PostContent"""
return PostContent(
message_id=12345,
content_name="/path/to/test/file.jpg",
content_type="photo"
message_id=12345, content_name="/path/to/test/file.jpg", content_type="photo"
)
@pytest.fixture
def sample_message_content_link():
"""Создает тестовый объект MessageContentLink"""
return MessageContentLink(
post_id=12345,
message_id=67890
)
return MessageContentLink(post_id=12345, message_id=67890)
@pytest.fixture
@@ -105,7 +101,7 @@ def mock_logger():
@pytest.fixture
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
yield db_path
@@ -132,22 +128,22 @@ def sample_posts_batch():
text="Первый тестовый пост",
author_id=11111,
helper_text_message_id=None,
created_at=int(datetime.now().timestamp())
created_at=int(datetime.now().timestamp()),
),
TelegramPost(
message_id=10002,
text="Второй тестовый пост",
author_id=22222,
helper_text_message_id=None,
created_at=int(datetime.now().timestamp())
created_at=int(datetime.now().timestamp()),
),
TelegramPost(
message_id=10003,
text="Третий тестовый пост",
author_id=33333,
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"),
(10003, "/path/to/audio1.mp3", "audio"),
(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():
"""Создает мок для SQL запросов"""
return {
'create_tables': [
"create_tables": [
"CREATE TABLE IF NOT EXISTS post_from_telegram_suggest",
"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_status': "status",
'update_helper': "UPDATE post_from_telegram_suggest SET helper_text_message_id",
'update_status': "UPDATE post_from_telegram_suggest SET status = ?",
'add_content': "INSERT OR IGNORE INTO content_post_from_telegram",
'add_link': "INSERT OR IGNORE INTO message_link_to_content",
'get_content': "SELECT cpft.content_name, cpft.content_type",
'get_text': "SELECT text FROM post_from_telegram_suggest",
'get_ids': "SELECT mltc.message_id",
'get_author': "SELECT author_id FROM post_from_telegram_suggest"
"add_post": "INSERT INTO post_from_telegram_suggest",
"add_post_status": "status",
"update_helper": "UPDATE post_from_telegram_suggest SET helper_text_message_id",
"update_status": "UPDATE post_from_telegram_suggest SET status = ?",
"add_content": "INSERT OR IGNORE INTO content_post_from_telegram",
"add_link": "INSERT OR IGNORE INTO message_link_to_content",
"get_content": "SELECT cpft.content_name, cpft.content_type",
"get_text": "SELECT text FROM post_from_telegram_suggest",
"get_ids": "SELECT mltc.message_id",
"get_author": "SELECT author_id FROM post_from_telegram_suggest",
}

View File

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

View File

@@ -3,6 +3,7 @@ from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from database.models import Admin
from database.repositories.admin_repository import AdminRepository
@@ -23,28 +24,25 @@ class TestAdminRepository:
def admin_repository(self, mock_db_connection):
"""Экземпляр AdminRepository для тестов"""
# Патчим наследование от DatabaseConnection
with patch.object(AdminRepository, '__init__', return_value=None):
with patch.object(AdminRepository, "__init__", return_value=None):
repo = AdminRepository()
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
return repo
@pytest.fixture
def sample_admin(self):
"""Тестовый администратор"""
return Admin(
user_id=12345,
role="admin"
)
return Admin(user_id=12345, role="admin")
@pytest.fixture
def sample_admin_with_created_at(self):
"""Тестовый администратор с датой создания"""
return Admin(
user_id=12345,
role="admin",
created_at="1705312200" # UNIX timestamp
user_id=12345, role="admin", created_at="1705312200" # UNIX timestamp
)
@pytest.mark.asyncio
@@ -64,11 +62,19 @@ class TestAdminRepository:
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 "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 "FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in create_table_call[0][0]
assert (
"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
async def test_add_admin(self, admin_repository, sample_admin):
@@ -164,7 +170,10 @@ class TestAdminRepository:
admin_repository._execute_query_with_result.assert_called_once()
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,)
# Проверяем результат
@@ -190,9 +199,7 @@ class TestAdminRepository:
"""Тест получения информации об администраторе без даты создания"""
user_id = 12345
# Мокаем результат запроса без created_at
admin_repository._execute_query_with_result.return_value = [
(12345, "admin")
]
admin_repository._execute_query_with_result.return_value = [(12345, "admin")]
result = await admin_repository.get_admin(user_id)
@@ -224,7 +231,9 @@ class TestAdminRepository:
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"):
await admin_repository.is_admin(12345)
@@ -233,7 +242,9 @@ class TestAdminRepository:
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"):
await admin_repository.get_admin(12345)

View File

@@ -1,6 +1,7 @@
from unittest.mock import AsyncMock, Mock, patch
import pytest
from database.async_db import AsyncBotDB
@@ -27,7 +28,7 @@ class TestAsyncBotDB:
@pytest.fixture
def async_bot_db(self, mock_factory):
"""Экземпляр 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
db = AsyncBotDB("test.db")
return db
@@ -40,39 +41,57 @@ class TestAsyncBotDB:
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
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"""
test_cases = [123, 456, 789, 99999]
for message_id in test_cases:
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
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
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"""
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"):
await async_bot_db.delete_audio_moderate_record(message_id)
@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 с другими методами"""
message_id = 12345
user_id = 67890
# Мокаем другие методы
mock_factory.audio.get_user_id_by_message_id_for_voice_bot = AsyncMock(return_value=user_id)
mock_factory.audio.set_user_id_and_message_id_for_voice_bot = AsyncMock(return_value=True)
mock_factory.audio.get_user_id_by_message_id_for_voice_bot = AsyncMock(
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)
@@ -80,36 +99,54 @@ class TestAsyncBotDB:
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.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)
mock_factory.audio.get_user_id_by_message_id_for_voice_bot.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
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"""
message_id = 0
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
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"""
message_id = -12345
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
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"""
message_id = 999999999
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
async def test_set_user_blacklist_calls_history(self, async_bot_db, mock_factory):
@@ -124,7 +161,7 @@ class TestAsyncBotDB:
user_name=None,
message_for_user=message_for_user,
date_to_unban=date_to_unban,
ban_author=ban_author
ban_author=ban_author,
)
# Проверяем, что сначала добавлен в blacklist
@@ -142,17 +179,21 @@ class TestAsyncBotDB:
assert history_call.ban_author == ban_author
@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
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(
user_id=user_id,
message_for_user="Тест",
date_to_unban=None,
ban_author=None
ban_author=None,
)
# Проверяем, что пользователь все равно добавлен в blacklist
@@ -162,7 +203,9 @@ class TestAsyncBotDB:
mock_factory.blacklist_history.add_record_on_ban.assert_called_once()
@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 вызывает обновление истории"""
user_id = 12345
@@ -181,10 +224,14 @@ class TestAsyncBotDB:
assert result is True
@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
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)
@@ -199,7 +246,9 @@ class TestAsyncBotDB:
assert result is True
@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"""
user_id = 12345
mock_factory.blacklist.remove_user.return_value = False

View File

@@ -3,8 +3,8 @@ from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
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
@@ -17,16 +17,19 @@ def mock_bot_db():
mock_db.add_audio_record_simple = AsyncMock()
return mock_db
@pytest.fixture
def audio_service(mock_bot_db):
"""Экземпляр AudioFileService для тестов"""
return AudioFileService(mock_bot_db)
@pytest.fixture
def sample_datetime():
"""Тестовая дата"""
return datetime(2025, 1, 15, 14, 30, 0)
@pytest.fixture
def mock_bot():
"""Мок для бота"""
@@ -35,6 +38,7 @@ def mock_bot():
bot.download_file = AsyncMock()
return bot
@pytest.fixture
def mock_message():
"""Мок для сообщения"""
@@ -43,6 +47,7 @@ def mock_message():
message.voice.file_id = "test_file_id"
return message
@pytest.fixture
def mock_file_info():
"""Мок для информации о файле"""
@@ -65,10 +70,14 @@ class TestGenerateFileName:
mock_bot_db.get_user_audio_records_count.assert_called_once_with(user_id=12345)
@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_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)
@@ -87,7 +96,9 @@ class TestGenerateFileName:
assert result == "message_from_12345_number_3"
@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_path_for_audio_record.return_value = "invalid_format"
@@ -97,9 +108,13 @@ class TestGenerateFileName:
assert result == "message_from_12345_number_3"
@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:
await audio_service.generate_file_name(12345)
@@ -111,17 +126,23 @@ class TestSaveAudioFile:
"""Тесты для метода save_audio_file"""
@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"
user_id = 12345
file_id = "test_file_id"
# Мокаем verify_file_exists чтобы он возвращал 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)
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
)
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
async def test_save_audio_file_with_string_date(self, audio_service, mock_bot_db):
@@ -132,20 +153,28 @@ class TestSaveAudioFile:
file_id = "test_file_id"
# Мокаем verify_file_exists чтобы он возвращал 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)
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
)
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
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")
# Мокаем 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:
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)
@@ -154,7 +183,9 @@ class TestDownloadAndSaveAudio:
"""Тесты для метода download_and_save_audio"""
@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
@@ -167,63 +198,92 @@ class TestDownloadAndSaveAudio:
# Настраиваем поведение tell() для получения размера файла
def mock_tell():
return 0 if mock_downloaded_file.seek.call_count == 0 else 1024
mock_downloaded_file.tell = Mock(side_effect=mock_tell)
mock_bot.download_file.return_value = mock_downloaded_file
with patch('builtins.open', mock_open()) as mock_file:
with patch('os.makedirs'):
with patch('os.path.exists', return_value=True):
with patch('os.path.getsize', return_value=1024):
await audio_service.download_and_save_audio(mock_bot, mock_message, "test_audio")
with patch("builtins.open", mock_open()) as mock_file:
with patch("os.makedirs"):
with patch("os.path.exists", return_value=True):
with patch("os.path.getsize", return_value=1024):
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()
@pytest.mark.asyncio
async def test_download_and_save_audio_no_message(self, audio_service, mock_bot):
"""Тест скачивания когда сообщение отсутствует"""
"""Тест скачивания когда сообщение отсутствует."""
with patch(
"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")
await audio_service.download_and_save_audio(
mock_bot, None, "test_audio"
)
assert "Сообщение или голосовое сообщение не найдено" in str(exc_info.value)
@pytest.mark.asyncio
async def test_download_and_save_audio_no_voice(self, audio_service, mock_bot):
"""Тест скачивания когда у сообщения нет voice атрибута"""
"""Тест скачивания когда у сообщения нет voice атрибута."""
message = Mock()
message.voice = None
with patch(
"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")
await audio_service.download_and_save_audio(
mock_bot, message, "test_audio"
)
assert "Сообщение или голосовое сообщение не найдено" in str(exc_info.value)
@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.download_file.return_value = None
with patch(
"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")
await audio_service.download_and_save_audio(
mock_bot, mock_message, "test_audio"
)
assert "Не удалось скачать файл" in str(exc_info.value)
@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")
with patch(
"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")
await audio_service.download_and_save_audio(
mock_bot, mock_message, "test_audio"
)
assert "Не удалось скачать и сохранить аудио" in str(exc_info.value)
class TestAudioFileServiceIntegration:
"""Интеграционные тесты для AudioFileService"""
@@ -234,7 +294,9 @@ class TestAudioFileServiceIntegration:
# Настраиваем моки
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()
# Тестируем генерацию имени файла
@@ -243,13 +305,15 @@ class TestAudioFileServiceIntegration:
# Тестируем сохранение в БД
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")
# Проверяем вызовы
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.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
async def test_file_name_generation_sequence(self, mock_bot_db):
@@ -263,16 +327,20 @@ class TestAudioFileServiceIntegration:
# Вторая запись
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)
assert file_name_2 == "message_from_12345_number_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)
assert file_name_3 == "message_from_12345_number_3"
if __name__ == '__main__':
if __name__ == "__main__":
pytest.main([__file__])

View File

@@ -1,8 +1,9 @@
import time
from datetime import datetime
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from database.models import AudioListenRecord, AudioMessage, AudioModerate
from database.repositories.audio_repository import AudioRepository
@@ -23,10 +24,12 @@ class TestAudioRepository:
def audio_repository(self, mock_db_connection):
"""Экземпляр AudioRepository для тестов"""
# Патчим наследование от DatabaseConnection
with patch.object(AudioRepository, '__init__', return_value=None):
with patch.object(AudioRepository, "__init__", return_value=None):
repo = AudioRepository()
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
return repo
@@ -38,7 +41,7 @@ class TestAudioRepository:
author_id=12345,
date_added="2025-01-15 14:30:00",
file_id="test_file_id",
listen_count=0
listen_count=0,
)
@pytest.fixture
@@ -56,7 +59,9 @@ class TestAudioRepository:
"""Тест включения внешних ключей"""
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
async def test_create_tables(self, audio_repository):
@@ -73,7 +78,9 @@ class TestAudioRepository:
assert any("audio_moderate" in str(call) for call in calls)
@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)
@@ -97,7 +104,7 @@ class TestAudioRepository:
author_id=67890,
date_added=datetime(2025, 1, 20, 10, 15, 0),
file_id="test_file_id_2",
listen_count=0
listen_count=0,
)
await audio_repository.add_audio_record(audio_msg)
@@ -115,7 +122,7 @@ class TestAudioRepository:
author_id=11111,
date_added=timestamp,
file_id="test_file_id_3",
listen_count=0
listen_count=0,
)
await audio_repository.add_audio_record(audio_msg)
@@ -127,7 +134,9 @@ class TestAudioRepository:
@pytest.mark.asyncio
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()
@@ -137,9 +146,13 @@ class TestAudioRepository:
assert isinstance(call_args[0][1][2], int) # timestamp
@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 датой"""
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
call_args = audio_repository._execute_query.call_args
@@ -149,7 +162,9 @@ class TestAudioRepository:
async def test_get_last_date_audio(self, audio_repository):
"""Тест получения даты последнего аудио"""
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()
@@ -191,7 +206,8 @@ class TestAudioRepository:
"""
SELECT file_name FROM audio_message_reference
WHERE author_id = ? ORDER BY date_added DESC LIMIT 1
""", (12345,)
""",
(12345,),
)
@pytest.mark.asyncio
@@ -209,7 +225,7 @@ class TestAudioRepository:
# Мокаем результаты запросов
audio_repository._execute_query_with_result.side_effect = [
[("audio1.ogg",), ("audio2.ogg",)], # прослушанные
[("audio1.ogg",), ("audio2.ogg",), ("audio3.ogg",)] # все аудио
[("audio1.ogg",), ("audio2.ogg",), ("audio3.ogg",)], # все аудио
]
result = await audio_repository.check_listen_audio(12345)
@@ -225,7 +241,7 @@ class TestAudioRepository:
audio_repository._execute_query.assert_called_once_with(
"INSERT OR IGNORE INTO user_audio_listens (file_name, user_id) VALUES (?, ?)",
("test_audio.ogg", 12345)
("test_audio.ogg", 12345),
)
@pytest.mark.asyncio
@@ -237,7 +253,8 @@ class TestAudioRepository:
assert result == 12345
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
@@ -251,16 +268,19 @@ class TestAudioRepository:
@pytest.mark.asyncio
async def test_get_date_by_file_name(self, audio_repository):
"""Тест получения даты по имени файла"""
timestamp = 1642404600 # 2022-01-17 10:30:00
"""Тест получения даты по имени файла (UTC, без зависимости от локали)."""
timestamp = 1642404600 # 2022-01-17 10:30:00 UTC
audio_repository._execute_query_with_result.return_value = [(timestamp,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg")
# Должна вернуться читаемая дата
assert result == "17.01.2022 10:30"
expected = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime(
"%d.%m.%Y %H:%M"
)
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",)
"SELECT date_added FROM audio_message_reference WHERE file_name = ?",
("test_audio.ogg",),
)
@pytest.mark.asyncio
@@ -291,22 +311,30 @@ class TestAudioRepository:
)
@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"""
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
audio_repository._execute_query.assert_called_once_with(
"INSERT OR IGNORE INTO audio_moderate (user_id, message_id) VALUES (?, ?)",
(456, 123)
(456, 123),
)
@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 при ошибке"""
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
@@ -323,7 +351,9 @@ class TestAudioRepository:
)
@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 когда связь не найдена"""
audio_repository._execute_query_with_result.return_value = []
@@ -346,7 +376,9 @@ class TestAudioRepository:
)
@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)
@@ -360,7 +392,9 @@ class TestAudioRepository:
@pytest.mark.asyncio
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()
@@ -371,17 +405,19 @@ class TestAudioRepository:
@pytest.mark.asyncio
async def test_get_date_by_file_name_logging(self, audio_repository):
"""Тест логирования при получении даты по имени файла"""
timestamp = 1642404600 # 2022-01-17 10:30:00
"""Тест логирования при получении даты по имени файла (UTC)."""
timestamp = 1642404600 # 2022-01-17 10:30:00 UTC
audio_repository._execute_query_with_result.return_value = [(timestamp,)]
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()
log_message = audio_repository.logger.info.call_args[0][0]
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

View File

@@ -1,8 +1,9 @@
import time
from datetime import datetime
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from database.repositories.audio_repository import AudioRepository
@@ -21,10 +22,12 @@ class TestAudioRepositoryNewSchema:
@pytest.fixture
def audio_repository(self, mock_db_connection):
"""Экземпляр AudioRepository для тестов"""
with patch.object(AudioRepository, '__init__', return_value=None):
with patch.object(AudioRepository, "__init__", return_value=None):
repo = AudioRepository()
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
return repo
@@ -40,26 +43,41 @@ class TestAudioRepositoryNewSchema:
calls = audio_repository._execute_query.call_args_list
# Проверяем таблицу 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 "file_name TEXT NOT NULL UNIQUE" 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 "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
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 "user_id INTEGER NOT NULL" 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
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 "message_id INTEGER" 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
async def test_add_audio_record_string_date_conversion(self, audio_repository):
@@ -71,7 +89,7 @@ class TestAudioRepositoryNewSchema:
author_id=12345,
date_added="2025-01-15 14:30:00",
file_id="test_file_id",
listen_count=0
listen_count=0,
)
await audio_repository.add_audio_record(audio_msg)
@@ -100,7 +118,7 @@ class TestAudioRepositoryNewSchema:
author_id=12345,
date_added=test_datetime,
file_id="test_file_id",
listen_count=0
listen_count=0,
)
await audio_repository.add_audio_record(audio_msg)
@@ -123,7 +141,7 @@ class TestAudioRepositoryNewSchema:
author_id=12345,
date_added=test_timestamp,
file_id="test_file_id",
listen_count=0
listen_count=0,
)
await audio_repository.add_audio_record(audio_msg)
@@ -137,7 +155,9 @@ class TestAudioRepositoryNewSchema:
@pytest.mark.asyncio
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
@@ -155,7 +175,9 @@ class TestAudioRepositoryNewSchema:
async def test_add_audio_record_simple_datetime(self, audio_repository):
"""Тест упрощенного добавления с datetime"""
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
@@ -166,52 +188,65 @@ class TestAudioRepositoryNewSchema:
@pytest.mark.asyncio
async def test_get_date_by_file_name_timestamp_conversion(self, audio_repository):
"""Тест преобразования UNIX timestamp в читаемую дату"""
test_timestamp = 1642248600 # 2022-01-17 10:30:00
"""Тест преобразования UNIX timestamp в читаемую дату (UTC)."""
test_timestamp = 1642248600 # 2022-01-15 12:10:00 UTC
audio_repository._execute_query_with_result.return_value = [(test_timestamp,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg")
# Должна вернуться читаемая дата в формате dd.mm.yyyy HH:MM
assert result == "15.01.2022 15:10"
expected = datetime.fromtimestamp(test_timestamp, tz=timezone.utc).strftime(
"%d.%m.%Y %H:%M"
)
assert result == expected
assert isinstance(result, str)
@pytest.mark.asyncio
async def test_get_date_by_file_name_different_timestamp(self, audio_repository):
"""Тест преобразования другого timestamp в читаемую дату"""
test_timestamp = 1705312800 # 2024-01-16 12:00:00
"""Тест преобразования другого timestamp в читаемую дату (UTC)."""
test_timestamp = 1705312800 # 2024-01-16 12:00:00 UTC
audio_repository._execute_query_with_result.return_value = [(test_timestamp,)]
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
async def test_get_date_by_file_name_midnight(self, audio_repository):
"""Тест преобразования timestamp для полуночи"""
test_timestamp = 1705190400 # 2024-01-15 00:00:00
"""Тест преобразования timestamp для полуночи (UTC)."""
test_timestamp = 1705190400 # 2024-01-14 00:00:00 UTC
audio_repository._execute_query_with_result.return_value = [(test_timestamp,)]
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
async def test_get_date_by_file_name_year_end(self, audio_repository):
"""Тест преобразования timestamp для конца года"""
test_timestamp = 1704067200 # 2023-12-31 23:59:59
"""Тест преобразования timestamp для конца года (UTC)."""
test_timestamp = 1704067200 # 2023-12-31 00:00:00 UTC
audio_repository._execute_query_with_result.return_value = [(test_timestamp,)]
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
async def test_foreign_keys_enabled_called(self, 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() # Этот метод не логирует
@pytest.mark.asyncio
@@ -220,7 +255,9 @@ class TestAudioRepositoryNewSchema:
await audio_repository.create_tables()
# Проверяем, что лог записан
audio_repository.logger.info.assert_called_once_with("Таблицы для аудио созданы")
audio_repository.logger.info.assert_called_once_with(
"Таблицы для аудио созданы"
)
@pytest.mark.asyncio
async def test_add_audio_record_logging_format(self, audio_repository):
@@ -232,7 +269,7 @@ class TestAudioRepositoryNewSchema:
author_id=12345,
date_added="2025-01-15 14:30:00",
file_id="test_file_id",
listen_count=0
listen_count=0,
)
await audio_repository.add_audio_record(audio_msg)
@@ -248,7 +285,9 @@ class TestAudioRepositoryNewSchema:
@pytest.mark.asyncio
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
@@ -260,18 +299,20 @@ class TestAudioRepositoryNewSchema:
@pytest.mark.asyncio
async def test_get_date_by_file_name_logging_format(self, audio_repository):
"""Тест формата лога при получении даты"""
test_timestamp = 1642248600 # 2022-01-17 10:30:00
"""Тест формата лога при получении даты (UTC)."""
test_timestamp = 1642248600 # 2022-01-15 12:10:00 UTC
audio_repository._execute_query_with_result.return_value = [(test_timestamp,)]
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_message = log_call[0][0]
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
@@ -281,7 +322,7 @@ class TestAudioRepositoryEdgeCases:
@pytest.fixture
def audio_repository(self):
"""Экземпляр AudioRepository для тестов"""
with patch.object(AudioRepository, '__init__', return_value=None):
with patch.object(AudioRepository, "__init__", return_value=None):
repo = AudioRepository()
repo._execute_query = AsyncMock()
repo._execute_query_with_result = AsyncMock()
@@ -298,7 +339,7 @@ class TestAudioRepositoryEdgeCases:
author_id=12345,
date_added="",
file_id="test_file_id",
listen_count=0
listen_count=0,
)
# Должно вызвать ValueError при парсинге пустой строки
@@ -315,7 +356,7 @@ class TestAudioRepositoryEdgeCases:
author_id=12345,
date_added="invalid_date",
file_id="test_file_id",
listen_count=0
listen_count=0,
)
# Должно вызвать ValueError при парсинге некорректной даты
@@ -332,7 +373,7 @@ class TestAudioRepositoryEdgeCases:
author_id=12345,
date_added=None,
file_id="test_file_id",
listen_count=0
listen_count=0,
)
# Метод обрабатывает None как timestamp без преобразования
@@ -355,7 +396,9 @@ class TestAudioRepositoryEdgeCases:
"""Тест упрощенного добавления с некорректной строковой датой"""
# Должно вызвать 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
async def test_add_audio_record_simple_none_date(self, audio_repository):
@@ -370,28 +413,38 @@ class TestAudioRepositoryEdgeCases:
@pytest.mark.asyncio
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,)]
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
async def test_get_date_by_file_name_negative_timestamp(self, audio_repository):
"""Тест получения даты для отрицательного timestamp"""
audio_repository._execute_query_with_result.return_value = [(-3600,)] # 1969-12-31 23:00:00
"""Тест получения даты для отрицательного timestamp (UTC)."""
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")
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
async def test_get_date_by_file_name_future_timestamp(self, audio_repository):
"""Тест получения даты для будущего timestamp"""
future_timestamp = int(datetime(2030, 12, 31, 23, 59, 59).timestamp())
"""Тест получения даты для будущего timestamp (UTC, без зависимости от локали)."""
future_timestamp = int(
datetime(2030, 12, 31, 23, 59, 59, tzinfo=timezone.utc).timestamp()
)
audio_repository._execute_query_with_result.return_value = [(future_timestamp,)]
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,6 +4,7 @@ from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, Mock, patch
import pytest
from helper_bot.utils.auto_unban_scheduler import AutoUnbanScheduler
@@ -13,7 +14,7 @@ class TestAutoUnbanIntegration:
@pytest.fixture
def test_db_path(self):
"""Путь к тестовой базе данных"""
return 'database/test_auto_unban.db'
return "database/test_auto_unban.db"
@pytest.fixture
def setup_test_db(self, test_db_path):
@@ -30,7 +31,7 @@ class TestAutoUnbanIntegration:
cursor.execute("PRAGMA foreign_keys = ON")
# Создаем таблицу our_users (нужна для внешних ключей)
cursor.execute('''
cursor.execute("""
CREATE TABLE IF NOT EXISTS our_users (
user_id INTEGER NOT NULL PRIMARY KEY,
first_name TEXT,
@@ -44,10 +45,10 @@ class TestAutoUnbanIntegration:
date_changed INTEGER NOT NULL,
voice_bot_welcome_received BOOLEAN DEFAULT 0
)
''')
""")
# Создаем таблицу blacklist
cursor.execute('''
cursor.execute("""
CREATE TABLE IF NOT EXISTS blacklist (
user_id INTEGER NOT NULL PRIMARY KEY,
message_for_user TEXT,
@@ -56,10 +57,10 @@ class TestAutoUnbanIntegration:
ban_author INTEGER,
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
)
''')
""")
# Создаем таблицу blacklist_history
cursor.execute('''
cursor.execute("""
CREATE TABLE IF NOT EXISTS blacklist_history (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
@@ -72,66 +73,168 @@ class TestAutoUnbanIntegration:
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
)
''')
""")
# Создаем индексы для blacklist_history
cursor.execute('''
cursor.execute("""
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)
''')
cursor.execute('''
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_unban ON blacklist_history(date_unban)
''')
""")
# Добавляем тестовых пользователей в our_users
current_time = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
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),
(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),
(
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,
),
(
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(
"""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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
users_data
users_data,
)
# Добавляем тестовые данные в blacklist
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 = [
(123, "Test ban 1", today_timestamp, current_time, None), # Разблокируется сегодня
(456, "Test ban 2", today_timestamp, current_time, None), # Разблокируется сегодня
(789, "Test ban 3", tomorrow_timestamp, current_time, None), # Разблокируется завтра
(
123,
"Test ban 1",
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(
"INSERT INTO blacklist (user_id, message_for_user, date_to_unban, created_at, ban_author) VALUES (?, ?, ?, ?, ?)",
blacklist_data
blacklist_data,
)
# Добавляем тестовые данные в blacklist_history
# Для пользователей 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 = [
(123, "Test ban 1", 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), # Навсегда заблокирован
(
123,
"Test ban 1",
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(
"""INSERT INTO blacklist_history
(user_id, message_for_user, date_ban, date_unban, ban_author, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
history_data
history_data,
)
conn.commit()
@@ -148,9 +251,9 @@ class TestAutoUnbanIntegration:
"""Создает мок фабрики зависимостей с тестовой базой"""
mock_factory = Mock()
mock_factory.settings = {
'Telegram': {
'group_for_logs': '-1001234567890',
'important_logs': '-1001234567891'
"Telegram": {
"group_for_logs": "-1001234567890",
"important_logs": "-1001234567891",
}
}
@@ -158,6 +261,7 @@ class TestAutoUnbanIntegration:
import os
from database.async_db import AsyncBotDB
mock_factory.database = AsyncBotDB(test_db_path)
return mock_factory
@@ -170,8 +274,10 @@ class TestAutoUnbanIntegration:
return mock_bot
@pytest.mark.asyncio
@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):
@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
):
"""Тест автоматического разбана с реальной базой данных"""
# Настройка моков
mock_get_instance.return_value = mock_bdf
@@ -189,29 +295,39 @@ class TestAutoUnbanIntegration:
assert initial_count == 4
# Проверяем начальное состояние истории: должно быть 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]
assert initial_open_history == 2
# Запоминаем время до разбана для проверки 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()
# Запоминаем время после разбана для проверки 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())
cursor.execute("SELECT COUNT(*) FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?",
(current_timestamp,))
cursor.execute(
"SELECT COUNT(*) FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?",
(current_timestamp,),
)
today_count = cursor.fetchone()[0]
assert today_count == 0
# Проверяем, что пользователи с завтрашней датой остались
cursor.execute("SELECT COUNT(*) FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban > ?",
(current_timestamp,))
cursor.execute(
"SELECT COUNT(*) FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban > ?",
(current_timestamp,),
)
tomorrow_count = cursor.fetchone()[0]
assert tomorrow_count == 1
@@ -226,30 +342,46 @@ class TestAutoUnbanIntegration:
assert final_count == 2 # Остались только завтрашние и навсегда заблокированные
# Проверяем историю банов: для 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()
assert len(history_records) == 2
for user_id, date_unban, updated_at in history_records:
# Проверяем, что date_unban установлен (не NULL)
assert 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}"
assert (
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)
assert before_unban_timestamp <= date_unban <= after_unban_timestamp, \
f"date_unban для user_id={user_id} должен быть между {before_unban_timestamp} и {after_unban_timestamp}, получен {date_unban}"
assert (
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 обновлен
assert updated_at is not None, f"updated_at должен быть установлен для user_id={user_id}"
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}"
assert (
updated_at is not None
), f"updated_at должен быть установлен для user_id={user_id}"
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)
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]
assert unchanged_history == 2, "Записи для user_id 789 и 999 должны остаться с date_unban = NULL"
assert (
unchanged_history == 2
), "Записи для user_id 789 и 999 должны остаться с date_unban = NULL"
conn.close()
@@ -257,8 +389,10 @@ class TestAutoUnbanIntegration:
mock_bot.send_message.assert_called_once()
@pytest.mark.asyncio
@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):
@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
):
"""Тест разбана когда нет пользователей для разблокировки сегодня"""
# Настройка моков
mock_get_instance.return_value = mock_bdf
@@ -267,10 +401,15 @@ class TestAutoUnbanIntegration:
conn = sqlite3.connect(setup_test_db)
cursor = conn.cursor()
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
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]
assert initial_open_history == 4 # Все 4 записи должны быть открытыми
@@ -288,17 +427,23 @@ class TestAutoUnbanIntegration:
# Проверяем, что история не изменилась (все записи все еще с date_unban = NULL)
conn = sqlite3.connect(setup_test_db)
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]
assert final_open_history == 4, "История не должна изменяться, если нет пользователей для разблокировки"
assert (
final_open_history == 4
), "История не должна изменяться, если нет пользователей для разблокировки"
conn.close()
# Проверяем, что отчет не был отправлен (нет пользователей для разблокировки)
mock_bot.send_message.assert_not_called()
@pytest.mark.asyncio
@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):
@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
):
"""Тест обработки ошибок базы данных"""
# Настройка моков
mock_get_instance.return_value = mock_bdf
@@ -321,12 +466,14 @@ class TestAutoUnbanIntegration:
# Проверяем, что отчет об ошибке был отправлен
mock_bot.send_message.assert_called_once()
call_args = mock_bot.send_message.call_args
assert call_args[1]['chat_id'] == '-1001234567891' # important_logs
assert "Ошибка автоматического разбана" in call_args[1]['text']
assert call_args[1]["chat_id"] == "-1001234567891" # important_logs
assert "Ошибка автоматического разбана" in call_args[1]["text"]
@pytest.mark.asyncio
@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):
@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
):
"""Тест что автоматический разбан обновляет историю банов"""
# Настройка моков
mock_get_instance.return_value = mock_bdf
@@ -347,13 +494,17 @@ class TestAutoUnbanIntegration:
ORDER BY user_id
""")
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
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()
@@ -361,7 +512,9 @@ class TestAutoUnbanIntegration:
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)
@@ -379,26 +532,39 @@ class TestAutoUnbanIntegration:
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 установлен
assert 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}"
assert (
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 находится в разумных пределах
assert before_unban_timestamp <= date_unban <= after_unban_timestamp, \
f"date_unban для user_id={user_id} должен быть между {before_unban_timestamp} и {after_unban_timestamp}"
assert (
before_unban_timestamp <= date_unban <= after_unban_timestamp
), f"date_unban для user_id={user_id} должен быть между {before_unban_timestamp} и {after_unban_timestamp}"
# Проверяем, что updated_at обновлен (должен быть больше начального значения)
assert updated_at is not None, f"updated_at должен быть установлен для user_id={user_id}"
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}"
assert (
updated_at is not None
), f"updated_at должен быть установлен для user_id={user_id}"
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 действительно обновлен (больше начального значения)
initial_updated_at = record_ids[record_id][1]
assert updated_at >= initial_updated_at, \
f"updated_at для user_id={user_id} должен быть больше или равен начальному значению"
assert (
updated_at >= initial_updated_at
), f"updated_at для user_id={user_id} должен быть больше или равен начальному значению"
# Проверяем, что обновлена только последняя запись для каждого пользователя
# (если бы было несколько записей, обновилась бы только последняя)
@@ -407,14 +573,18 @@ class TestAutoUnbanIntegration:
WHERE user_id IN (123, 456) AND date_unban IS NOT NULL
""")
closed_records = cursor.fetchone()[0]
assert closed_records == 2, "Должно быть закрыто 2 записи (по одной для каждого пользователя)"
assert (
closed_records == 2
), "Должно быть закрыто 2 записи (по одной для каждого пользователя)"
cursor.execute("""
SELECT COUNT(*) FROM blacklist_history
WHERE user_id IN (123, 456) AND date_unban IS NULL
""")
open_records = cursor.fetchone()[0]
assert open_records == 0, "Не должно быть открытых записей для user_id 123 и 456"
assert (
open_records == 0
), "Не должно быть открытых записей для user_id 123 и 456"
conn.close()
@@ -426,7 +596,9 @@ class TestAutoUnbanIntegration:
# Проверяем, что дата в базе соответствует ожидаемому формату (timestamp)
conn = sqlite3.connect(setup_test_db)
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()
conn.close()
@@ -459,7 +631,7 @@ class TestSchedulerLifecycle:
"""Тест создания задачи в планировщике"""
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()
# Проверяем, что задача была создана с правильными параметрами
@@ -471,9 +643,10 @@ class TestSchedulerLifecycle:
# Проверяем триггер (должен быть CronTrigger)
from apscheduler.triggers.cron import CronTrigger
assert isinstance(call_args[0][1], CronTrigger)
# Проверяем ID и имя задачи
assert call_args[1]['id'] == 'auto_unban_users'
assert call_args[1]['name'] == 'Автоматический разбан пользователей'
assert call_args[1]['replace_existing'] is True
assert call_args[1]["id"] == "auto_unban_users"
assert call_args[1]["name"] == "Автоматический разбан пользователей"
assert call_args[1]["replace_existing"] is True

View File

@@ -3,8 +3,11 @@ from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, Mock, patch
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:
@@ -19,10 +22,9 @@ class TestAutoUnbanScheduler:
def mock_bot_db(self):
"""Создает мок базы данных"""
mock_db = Mock()
mock_db.get_users_for_unblock_today = AsyncMock(return_value={
123: "test_user1",
456: "test_user2"
})
mock_db.get_users_for_unblock_today = AsyncMock(
return_value={123: "test_user1", 456: "test_user2"}
)
mock_db.delete_user_blacklist = AsyncMock(return_value=True)
return mock_db
@@ -31,9 +33,9 @@ class TestAutoUnbanScheduler:
"""Создает мок фабрики зависимостей"""
mock_factory = Mock()
mock_factory.settings = {
'Telegram': {
'group_for_logs': '-1001234567890',
'important_logs': '-1001234567891'
"Telegram": {
"group_for_logs": "-1001234567890",
"important_logs": "-1001234567891",
}
}
return mock_factory
@@ -57,8 +59,10 @@ class TestAutoUnbanScheduler:
assert scheduler.bot == mock_bot
@pytest.mark.asyncio
@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):
@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
):
"""Тест успешного выполнения автоматического разбана"""
# Настройка моков
mock_get_instance.return_value = mock_bdf
@@ -74,8 +78,10 @@ class TestAutoUnbanScheduler:
mock_bot.send_message.assert_called_once()
@pytest.mark.asyncio
@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):
@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
):
"""Тест разбана когда нет пользователей для разблокировки"""
# Настройка моков
mock_get_instance.return_value = mock_bdf
@@ -92,15 +98,16 @@ class TestAutoUnbanScheduler:
mock_bot.send_message.assert_not_called()
@pytest.mark.asyncio
@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):
@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
):
"""Тест разбана с частичными ошибками"""
# Настройка моков
mock_get_instance.return_value = mock_bdf
mock_bot_db.get_users_for_unblock_today = AsyncMock(return_value={
123: "test_user1",
456: "test_user2"
})
mock_bot_db.get_users_for_unblock_today = AsyncMock(
return_value={123: "test_user1", 456: "test_user2"}
)
# Первый вызов успешен, второй - ошибка
mock_bot_db.delete_user_blacklist = AsyncMock(side_effect=[True, False])
scheduler.bot_db = mock_bot_db
@@ -114,12 +121,16 @@ class TestAutoUnbanScheduler:
mock_bot.send_message.assert_called_once()
@pytest.mark.asyncio
@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):
@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
):
"""Тест разбана с исключением"""
# Настройка моков
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.set_bot(mock_bot)
@@ -130,7 +141,7 @@ class TestAutoUnbanScheduler:
mock_bot.send_message.assert_called_once()
# Проверяем, что сообщение об ошибке было отправлено
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):
"""Тест генерации отчета"""
@@ -146,7 +157,7 @@ class TestAutoUnbanScheduler:
assert "456 (test_user2)" in report
@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):
"""Тест отправки отчета"""
mock_get_instance.return_value = mock_bdf
@@ -160,12 +171,14 @@ class TestAutoUnbanScheduler:
# Проверяем аргументы вызова
call_args = mock_bot.send_message.call_args
assert call_args[1]['text'] == report
assert call_args[1]['parse_mode'] == 'HTML'
assert call_args[1]["text"] == report
assert call_args[1]["parse_mode"] == "HTML"
@pytest.mark.asyncio
@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):
@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
):
"""Тест отправки отчета об ошибке"""
mock_get_instance.return_value = mock_bdf
scheduler.set_bot(mock_bot)
@@ -178,14 +191,16 @@ class TestAutoUnbanScheduler:
# Проверяем аргументы вызова
call_args = mock_bot.send_message.call_args
assert "Ошибка автоматического разбана" in call_args[1]['text']
assert error_msg in call_args[1]['text']
assert call_args[1]['parse_mode'] == 'HTML'
assert "Ошибка автоматического разбана" in call_args[1]["text"]
assert error_msg in call_args[1]["text"]
assert call_args[1]["parse_mode"] == "HTML"
def test_start_scheduler(self, scheduler):
"""Тест запуска планировщика"""
with patch.object(scheduler.scheduler, 'add_job') as mock_add_job, \
patch.object(scheduler.scheduler, 'start') as mock_start:
with (
patch.object(scheduler.scheduler, "add_job") as mock_add_job,
patch.object(scheduler.scheduler, "start") as mock_start,
):
scheduler.start_scheduler()
@@ -207,8 +222,10 @@ class TestAutoUnbanScheduler:
# APScheduler может не сразу остановиться, но это нормально
@pytest.mark.asyncio
@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):
@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
):
"""Тест ручного запуска разбана"""
mock_get_instance.return_value = mock_bdf
mock_bot_db.get_users_for_unblock_today.return_value = {}
@@ -245,7 +262,7 @@ class TestDateHandling:
today = datetime.now(moscow_tz).strftime("%Y-%m-%d")
assert len(today) == 10 # YYYY-MM-DD
assert today.count('-') == 2
assert today.count("-") == 2
assert today[:4].isdigit() # Год
assert today[5:7].isdigit() # Месяц
assert today[8:10].isdigit() # День
@@ -255,21 +272,23 @@ class TestDateHandling:
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):
"""Тест полного асинхронного потока разбана"""
# Создаем моки
mock_bdf = Mock()
mock_bdf.settings = {
'Telegram': {
'group_for_logs': '-1001234567890',
'important_logs': '-1001234567891'
"Telegram": {
"group_for_logs": "-1001234567890",
"important_logs": "-1001234567891",
}
}
mock_get_instance.return_value = mock_bdf
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 = Mock()

View File

@@ -3,9 +3,11 @@ from datetime import datetime
from unittest.mock import AsyncMock, Mock, patch
import pytest
from database.models import BlacklistHistoryRecord
from database.repositories.blacklist_history_repository import \
BlacklistHistoryRepository
from database.repositories.blacklist_history_repository import (
BlacklistHistoryRepository,
)
class TestBlacklistHistoryRepository:
@@ -24,10 +26,12 @@ class TestBlacklistHistoryRepository:
def blacklist_history_repository(self, mock_db_connection):
"""Экземпляр BlacklistHistoryRepository для тестов"""
# Патчим наследование от DatabaseConnection
with patch.object(BlacklistHistoryRepository, '__init__', return_value=None):
with patch.object(BlacklistHistoryRepository, "__init__", return_value=None):
repo = BlacklistHistoryRepository()
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
return repo
@@ -71,16 +75,30 @@ class TestBlacklistHistoryRepository:
# Проверяем, что создается таблица с правильной структурой
create_table_call = calls[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 "message_for_user TEXT" 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 "ban_author INTEGER" in create_table_call[0][0]
assert "created_at INTEGER DEFAULT (strftime('%s', 'now'))" 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]
assert (
"created_at INTEGER DEFAULT (strftime('%s', 'now'))"
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]
@@ -95,7 +113,9 @@ class TestBlacklistHistoryRepository:
)
@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)
@@ -104,9 +124,12 @@ class TestBlacklistHistoryRepository:
call_args = blacklist_history_repository._execute_query.call_args
# Проверяем 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 "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]
@@ -157,7 +180,9 @@ class TestBlacklistHistoryRepository:
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)
@@ -170,7 +195,9 @@ class TestBlacklistHistoryRepository:
# Проверяем, что затем обновляется запись
assert blacklist_history_repository._execute_query.call_count == 1
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 "SET date_unban = ?" in update_query
assert "updated_at = ?" in update_query
@@ -224,7 +251,9 @@ class TestBlacklistHistoryRepository:
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)
@@ -245,7 +274,9 @@ class TestBlacklistHistoryRepository:
# Мокируем успешную проверку, но ошибку при обновлении
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)

View File

@@ -3,6 +3,7 @@ from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from database.models import BlacklistUser
from database.repositories.blacklist_repository import BlacklistRepository
@@ -23,10 +24,12 @@ class TestBlacklistRepository:
def blacklist_repository(self, mock_db_connection):
"""Экземпляр BlacklistRepository для тестов"""
# Патчим наследование от DatabaseConnection
with patch.object(BlacklistRepository, '__init__', return_value=None):
with patch.object(BlacklistRepository, "__init__", return_value=None):
repo = BlacklistRepository()
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
return repo
@@ -67,11 +70,19 @@ class TestBlacklistRepository:
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 "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 "FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in create_table_call[0][0]
assert (
"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
async def test_add_user(self, blacklist_repository, sample_blacklist_user):
@@ -83,12 +94,23 @@ class TestBlacklistRepository:
call_args = blacklist_repository._execute_query.call_args
# Проверяем 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 (?, ?, ?, ?)"
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(
@@ -96,7 +118,9 @@ class TestBlacklistRepository:
)
@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)
@@ -184,7 +208,13 @@ class TestBlacklistRepository:
async def test_get_user_success(self, blacklist_repository):
"""Тест успешного получения пользователя по 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]
result = await blacklist_repository.get_user(12345)
@@ -201,7 +231,10 @@ class TestBlacklistRepository:
blacklist_repository._execute_query_with_result.assert_called_once()
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,)
@pytest.mark.asyncio
@@ -221,7 +254,7 @@ class TestBlacklistRepository:
# Симулируем результат запроса
mock_rows = [
(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
@@ -240,7 +273,7 @@ class TestBlacklistRepository:
call_args = blacklist_repository._execute_query_with_result.call_args
# Нормализуем 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 ?, ?"
assert actual_query == expected_query
assert call_args[0][1] == (0, 10)
@@ -255,8 +288,14 @@ class TestBlacklistRepository:
"""Тест получения всех пользователей без лимитов"""
# Симулируем результат запроса (теперь включает ban_author)
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
@@ -270,7 +309,7 @@ class TestBlacklistRepository:
call_args = blacklist_repository._execute_query_with_result.call_args
# Нормализуем 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"
assert actual_query == expected_query
# Проверяем, что параметры пустые (без лимитов)
@@ -290,7 +329,9 @@ class TestBlacklistRepository:
mock_rows = [(12345,), (67890,)]
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
@@ -303,7 +344,10 @@ class TestBlacklistRepository:
blacklist_repository._execute_query_with_result.assert_called_once()
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,)
# Проверяем логирование
@@ -319,7 +363,9 @@ class TestBlacklistRepository:
# Симулируем пустой результат запроса
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 == {}
@@ -374,7 +420,9 @@ class TestBlacklistRepository:
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:
@@ -386,7 +434,9 @@ class TestBlacklistRepository:
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:
@@ -398,7 +448,9 @@ class TestBlacklistRepository:
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:
@@ -407,10 +459,14 @@ class TestBlacklistRepository:
assert "Database connection failed" in str(exc_info.value)
@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:

View File

@@ -3,13 +3,16 @@ from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from helper_bot.handlers.callback.callback_handlers import (
change_page,
delete_voice_message,
process_ban_user,
process_unlock_user,
return_to_main_menu,
save_voice_message,
,
)
from helper_bot.handlers.voice.constants import CALLBACK_DELETE, CALLBACK_SAVE
@@ -27,6 +30,7 @@ def mock_call():
call.answer = AsyncMock()
return call
@pytest.fixture
def mock_bot_db():
"""Мок для базы данных"""
@@ -35,20 +39,20 @@ def mock_bot_db():
mock_db.delete_audio_moderate_record = AsyncMock()
return mock_db
@pytest.fixture
def mock_settings():
"""Мок для настроек"""
return {
'Telegram': {
'group_for_posts': 'test_group_id'
}
}
return {"Telegram": {"group_for_posts": "test_group_id"}}
@pytest.fixture
def mock_audio_service():
"""Мок для AudioFileService"""
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.download_and_save_audio = AsyncMock()
return mock_service
@@ -58,15 +62,23 @@ class TestSaveVoiceMessage:
"""Тесты для функции save_voice_message"""
@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
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.save_audio_file.assert_called_once()
mock_audio_service.download_and_save_audio.assert_called_once_with(
@@ -75,23 +87,28 @@ class TestSaveVoiceMessage:
# Проверяем удаление сообщения из чата
mock_call.bot.delete_message.assert_called_once_with(
chat_id='test_group_id',
message_id=12345
chat_id="test_group_id", message_id=12345
)
# Проверяем удаление записи из audio_moderate
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
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
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_call_args = mock_audio_service.save_audio_file.call_args
@@ -101,97 +118,146 @@ class TestSaveVoiceMessage:
assert save_call_args[0][3] == "test_file_id_123" # file_id
@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)
# Проверяем, что при ошибке отправляется соответствующий ответ
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
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"""
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
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
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
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:
"""Тесты для функции delete_voice_message"""
@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(
chat_id='test_group_id',
message_id=12345
chat_id="test_group_id", message_id=12345
)
# Проверяем удаление записи из audio_moderate
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
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")
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
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:
"""Интеграционные тесты для callback handlers"""
@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.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.download_and_save_audio = AsyncMock()
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
@@ -203,9 +269,13 @@ class TestCallbackHandlersIntegration:
assert mock_call.answer.called
@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
@@ -213,32 +283,44 @@ class TestCallbackHandlersIntegration:
assert mock_call.answer.called
@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"""
# Тестируем, что в обоих случаях (сохранение и удаление)
# вызывается delete_audio_moderate_record
# Создаем отдельные моки для каждого теста
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_delete = Mock()
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.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.download_and_save_audio = AsyncMock()
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
# Тест для удаления
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
# Проверяем, что в обоих случаях вызывается очистка
@@ -250,7 +332,9 @@ class TestCallbackHandlersEdgeCases:
"""Тесты граничных случаев для callback handlers"""
@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 атрибута"""
call = Mock()
call.message = Mock()
@@ -260,25 +344,35 @@ class TestCallbackHandlersEdgeCases:
call.bot.delete_message = 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)
# Должна быть ошибка
call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3)
call.answer.assert_called_once_with(
text="Ошибка при сохранении!", cache_time=3
)
@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
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService'):
await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings)
with patch("helper_bot.handlers.callback.callback_handlers.AudioFileService"):
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
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"""
call = Mock()
call.message = Mock()
@@ -291,8 +385,7 @@ class TestCallbackHandlersEdgeCases:
# Проверяем, что используется правильный message_id
call.bot.delete_message.assert_called_once_with(
chat_id='test_group_id',
message_id=99999
chat_id="test_group_id", message_id=99999
)
mock_bot_db.delete_audio_moderate_record.assert_called_once_with(99999)
@@ -536,5 +629,5 @@ class TestProcessUnlockUser:
)
if __name__ == '__main__':
if __name__ == "__main__":
pytest.main([__file__])

View File

@@ -8,9 +8,13 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from aiogram import types
from helper_bot.utils.helper_func import (
add_in_db_media, add_in_db_media_mediagroup, download_file,
send_media_group_message_to_private_chat)
add_in_db_media,
add_in_db_media_mediagroup,
download_file,
send_media_group_message_to_private_chat,
)
class TestDownloadFile:
@@ -21,38 +25,48 @@ class TestDownloadFile:
"""Тест успешного скачивания фото"""
# Создаем временную директорию
with tempfile.TemporaryDirectory() as temp_dir:
with patch('helper_bot.utils.helper_func.os.makedirs'), \
patch('helper_bot.utils.helper_func.os.path.exists', return_value=True), \
patch('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')):
with (
patch("helper_bot.utils.helper_func.os.makedirs"),
patch("helper_bot.utils.helper_func.os.path.exists", return_value=True),
patch(
"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.bot = 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.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 'files/photos/test_file_id.jpg' in result
mock_message.bot.get_file.assert_called_once_with('test_file_id')
assert "files/photos/test_file_id.jpg" in result
mock_message.bot.get_file.assert_called_once_with("test_file_id")
mock_message.bot.download_file.assert_called_once()
@pytest.mark.asyncio
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
mock_message = Mock()
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
@pytest.mark.asyncio
@@ -62,7 +76,7 @@ class TestDownloadFile:
mock_message.bot = Mock()
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
@@ -76,7 +90,7 @@ class TestAddInDbMedia:
mock_message = Mock()
mock_message.message_id = 123
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.voice = None
mock_message.audio = None
@@ -86,11 +100,16 @@ class TestAddInDbMedia:
mock_db = AsyncMock()
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)
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
async def test_add_in_db_media_download_fails(self):
@@ -98,7 +117,7 @@ class TestAddInDbMedia:
mock_message = Mock()
mock_message.message_id = 123
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.voice = None
mock_message.audio = None
@@ -106,7 +125,7 @@ class TestAddInDbMedia:
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)
assert result is False
@@ -118,7 +137,7 @@ class TestAddInDbMedia:
mock_message = Mock()
mock_message.message_id = 123
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.voice = None
mock_message.audio = None
@@ -127,8 +146,13 @@ class TestAddInDbMedia:
mock_db = AsyncMock()
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'), \
patch('helper_bot.utils.helper_func.os.remove'):
with (
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)
@@ -164,7 +188,7 @@ class TestAddInDbMediaMediagroup:
mock_message1 = Mock()
mock_message1.message_id = 1
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.voice = None
mock_message1.audio = None
@@ -174,7 +198,7 @@ class TestAddInDbMediaMediagroup:
mock_message2.message_id = 2
mock_message2.photo = None
mock_message2.video = Mock()
mock_message2.video.file_id = 'video_1'
mock_message2.video.file_id = "video_1"
mock_message2.voice = None
mock_message2.audio = None
mock_message2.video_note = None
@@ -185,8 +209,12 @@ class TestAddInDbMediaMediagroup:
mock_db = AsyncMock()
mock_db.add_post_content = AsyncMock(return_value=True)
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, main_post_id=100)
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, main_post_id=100
)
assert result is True
assert mock_db.add_post_content.call_count == 2
@@ -208,7 +236,7 @@ class TestAddInDbMediaMediagroup:
mock_message1 = Mock()
mock_message1.message_id = 1
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.voice = None
mock_message1.audio = None
@@ -228,7 +256,9 @@ class TestAddInDbMediaMediagroup:
mock_db = AsyncMock()
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)
# Должен вернуть False, так как есть ошибки (второе сообщение не поддерживается)
@@ -256,8 +286,12 @@ class TestSendMediaGroupMessageToPrivateChat:
# Мокаем БД
mock_db = AsyncMock()
with patch('helper_bot.utils.helper_func.add_in_db_media_mediagroup', return_value=True):
with patch('asyncio.create_task'): # Мокаем create_task, чтобы фоновая задача не выполнялась
with patch(
"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(
100, mock_message, [], mock_db, main_post_id=789
)
@@ -282,8 +316,13 @@ class TestSendMediaGroupMessageToPrivateChat:
# Мокаем БД
mock_db = AsyncMock()
with patch('helper_bot.utils.helper_func.add_in_db_media_mediagroup', return_value=False):
with patch('asyncio.create_task'): # Мокаем create_task, чтобы фоновая задача не выполнялась
with patch(
"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(
100, mock_message, [], mock_db, main_post_id=789
)

View File

@@ -1,15 +1,22 @@
from unittest.mock import AsyncMock, Mock, patch
import pytest
from aiogram.types import (InlineKeyboardButton, InlineKeyboardMarkup,
KeyboardButton, ReplyKeyboardMarkup)
from aiogram.types import (
InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
ReplyKeyboardMarkup,
)
from database.async_db import AsyncBotDB
from helper_bot.filters.main import ChatTypeFilter
from helper_bot.keyboards.keyboards import (create_keyboard_with_pagination,
from helper_bot.keyboards.keyboards import (
create_keyboard_with_pagination,
get_reply_keyboard,
get_reply_keyboard_admin,
get_reply_keyboard_for_post,
get_reply_keyboard_leave_chat)
get_reply_keyboard_leave_chat,
)
class TestKeyboards:
@@ -19,10 +26,7 @@ class TestKeyboards:
def mock_db(self):
"""Создает мок базы данных"""
db = Mock(spec=AsyncBotDB)
db.get_user_info = Mock(return_value={
'stickers': True,
'admin': False
})
db.get_user_info = Mock(return_value={"stickers": True, "admin": False})
return db
@pytest.mark.asyncio
@@ -48,9 +52,9 @@ class TestKeyboards:
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
async def test_get_reply_keyboard_with_stickers(self, mock_db):
@@ -67,7 +71,7 @@ class TestKeyboards:
all_buttons.append(button.text)
# Проверяем наличие кнопки стикеров
assert '🤪Хочу стикеры' in all_buttons
assert "🤪Хочу стикеры" in all_buttons
@pytest.mark.asyncio
async def test_get_reply_keyboard_without_stickers(self, mock_db):
@@ -84,7 +88,7 @@ class TestKeyboards:
all_buttons.append(button.text)
# Проверяем отсутствие кнопки стикеров
assert '🤪Хочу стикеры' not in all_buttons
assert "🤪Хочу стикеры" not in all_buttons
@pytest.mark.asyncio
async def test_get_reply_keyboard_admin(self, mock_db):
@@ -101,9 +105,9 @@ class TestKeyboards:
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):
"""Тест админской клавиатуры"""
@@ -145,8 +149,8 @@ class TestKeyboards:
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):
"""Тест клавиатуры для выхода из чата"""
@@ -162,7 +166,7 @@ class TestKeyboards:
all_buttons.append(button.text)
# Проверяем наличие кнопки выхода
assert 'Выйти из чата' in all_buttons
assert "Выйти из чата" in all_buttons
def test_keyboard_resize(self):
"""Тест настройки resize клавиатуры"""
@@ -177,7 +181,7 @@ class TestKeyboards:
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
@@ -304,17 +308,17 @@ class TestKeyboardIntegration:
# Проверяем первую клавиатуру (ReplyKeyboardMarkup)
assert isinstance(keyboard1, ReplyKeyboardMarkup)
assert hasattr(keyboard1, 'keyboard')
assert hasattr(keyboard1, "keyboard")
assert isinstance(keyboard1.keyboard, list)
# Проверяем вторую клавиатуру (InlineKeyboardMarkup)
assert isinstance(keyboard2, InlineKeyboardMarkup)
assert hasattr(keyboard2, 'inline_keyboard')
assert hasattr(keyboard2, "inline_keyboard")
assert isinstance(keyboard2.inline_keyboard, list)
# Проверяем третью клавиатуру (ReplyKeyboardMarkup)
assert isinstance(keyboard3, ReplyKeyboardMarkup)
assert hasattr(keyboard3, 'keyboard')
assert hasattr(keyboard3, "keyboard")
assert isinstance(keyboard3.keyboard, list)
@pytest.mark.asyncio
@@ -345,17 +349,17 @@ class TestKeyboardIntegration:
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:
@@ -363,7 +367,7 @@ class TestPagination:
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 len(keyboard.inline_keyboard) == 1
@@ -372,10 +376,12 @@ class TestPagination:
def test_pagination_single_page(self):
"""Тест с одной страницей"""
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 пользователя + кнопка "Назад")
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 keyboard.inline_keyboard[1][0].text == "🏠 Назад"
@@ -385,10 +391,12 @@ class TestPagination:
def test_pagination_multiple_pages(self):
"""Тест с несколькими страницами"""
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) + кнопка "Следующая" + "Назад"
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[1]) == 3 # второй ряд: 3 пользователя
assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя
@@ -398,10 +406,12 @@ class TestPagination:
def test_pagination_second_page(self):
"""Тест второй страницы"""
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) + кнопки "Предыдущая" и "Назад"
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[1]) == 2 # второй ряд: 2 пользователя
assert keyboard.inline_keyboard[2][0].text == "⬅️ Предыдущая"
@@ -410,10 +420,12 @@ class TestPagination:
def test_pagination_middle_page(self):
"""Тест средней страницы"""
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) + кнопки "Предыдущая" и "Следующая"
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[1]) == 3 # второй ряд: 3 пользователя
assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя
@@ -423,7 +435,7 @@ class TestPagination:
def test_pagination_invalid_page_number(self):
"""Тест с некорректным номером страницы"""
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 ряда пользователей + назад
@@ -434,7 +446,9 @@ class TestPagination:
def test_pagination_page_out_of_range(self):
"""Тест с номером страницы больше максимального"""
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 ряда пользователей + назад
@@ -445,7 +459,7 @@ class TestPagination:
def test_pagination_callback_data_format(self):
"""Тест формата callback_data"""
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 для пользователей
assert keyboard.inline_keyboard[0][0].callback_data == "ban_123"
@@ -457,7 +471,7 @@ class TestPagination:
def test_pagination_navigation_callback_data(self):
"""Тест callback_data для кнопок навигации"""
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 для кнопки "Предыдущая"
assert keyboard.inline_keyboard[2][0].callback_data == "page_1"
@@ -468,7 +482,7 @@ class TestPagination:
def test_pagination_exactly_items_per_page(self):
"""Тест когда количество элементов точно равно items_per_page"""
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 ряда пользователей + назад
@@ -478,5 +492,5 @@ class TestPagination:
assert keyboard.inline_keyboard[3][0].text == "🏠 Назад"
if __name__ == '__main__':
pytest.main([__file__, '-v'])
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -3,6 +3,7 @@ from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import pytest
from database.models import UserMessage
from database.repositories.message_repository import MessageRepository
@@ -27,7 +28,7 @@ class TestMessageRepository:
message_text="Тестовое сообщение",
user_id=12345,
telegram_message_id=67890,
date=int(datetime.now().timestamp())
date=int(datetime.now().timestamp()),
)
@pytest.fixture
@@ -37,7 +38,7 @@ class TestMessageRepository:
message_text="Тестовое сообщение без даты",
user_id=12345,
telegram_message_id=67891,
date=None
date=None,
)
@pytest.mark.asyncio
@@ -53,7 +54,10 @@ class TestMessageRepository:
assert "CREATE TABLE IF NOT EXISTS user_messages" in call_args
assert "telegram_message_id 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
async def test_add_message_with_date(self, message_repository, sample_message):
@@ -74,11 +78,13 @@ class TestMessageRepository:
sample_message.message_text,
sample_message.user_id,
sample_message.telegram_message_id,
sample_message.date
sample_message.date,
)
@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
message_repository._execute_query = AsyncMock()
@@ -107,7 +113,9 @@ class TestMessageRepository:
message_repository.logger.info.assert_called_once()
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
async def test_get_user_by_message_id_found(self, message_repository):
@@ -125,7 +133,7 @@ class TestMessageRepository:
assert result == expected_user_id
message_repository._execute_query_with_result.assert_called_once_with(
"SELECT user_id FROM user_messages WHERE telegram_message_id = ?",
(message_id,)
(message_id,),
)
@pytest.mark.asyncio
@@ -141,7 +149,7 @@ class TestMessageRepository:
assert result is None
message_repository._execute_query_with_result.assert_called_once_with(
"SELECT user_id FROM user_messages WHERE telegram_message_id = ?",
(message_id,)
(message_id,),
)
@pytest.mark.asyncio
@@ -157,10 +165,14 @@ class TestMessageRepository:
assert result is None
@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 для вызова исключения
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"):
await message_repository.add_message(sample_message)
@@ -183,7 +195,7 @@ class TestMessageRepository:
message_text="Тестовое сообщение с нулевой датой",
user_id=12345,
telegram_message_id=67892,
date=0
date=0,
)
# Мокаем _execute_query

View File

@@ -4,6 +4,7 @@ import tempfile
from datetime import datetime
import pytest
from database.models import UserMessage
from database.repositories.message_repository import MessageRepository
@@ -14,7 +15,7 @@ class TestMessageRepositoryIntegration:
async def _setup_test_database(self, message_repository):
"""Вспомогательная функция для настройки тестовой БД."""
# Сначала создаем таблицу our_users для тестов
await message_repository._execute_query('''
await message_repository._execute_query("""
CREATE TABLE IF NOT EXISTS our_users (
user_id INTEGER NOT NULL PRIMARY KEY,
first_name TEXT,
@@ -28,12 +29,18 @@ class TestMessageRepositoryIntegration:
date_changed INTEGER NOT NULL,
voice_bot_welcome_received BOOLEAN DEFAULT 0
)
''')
""")
# Добавляем тестового пользователя
await message_repository._execute_query(
"INSERT OR REPLACE INTO our_users (user_id, first_name, full_name, date_added, date_changed) VALUES (?, ?, ?, ?, ?)",
(12345, "Test", "Test User", int(datetime.now().timestamp()), int(datetime.now().timestamp()))
(
12345,
"Test",
"Test User",
int(datetime.now().timestamp()),
int(datetime.now().timestamp()),
),
)
# Теперь создаем таблицу user_messages
@@ -42,7 +49,7 @@ class TestMessageRepositoryIntegration:
@pytest.fixture
def temp_db_path(self):
"""Фикстура для временного пути к БД."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
temp_path = f.name
yield temp_path
@@ -65,7 +72,7 @@ class TestMessageRepositoryIntegration:
message_text="Интеграционное тестовое сообщение",
user_id=12345,
telegram_message_id=67890,
date=int(datetime.now().timestamp())
date=int(datetime.now().timestamp()),
)
@pytest.fixture
@@ -75,7 +82,7 @@ class TestMessageRepositoryIntegration:
message_text="Интеграционное тестовое сообщение без даты",
user_id=12345,
telegram_message_id=67891,
date=None
date=None,
)
@pytest.mark.asyncio
@@ -89,14 +96,16 @@ class TestMessageRepositoryIntegration:
message_text="Тест создания таблиц",
user_id=12345,
telegram_message_id=67890,
date=int(datetime.now().timestamp())
date=int(datetime.now().timestamp()),
)
# Не должно вызывать ошибку
await message_repository.add_message(message)
@pytest.mark.asyncio
async def test_add_and_retrieve_message_integration(self, message_repository, sample_message):
async def test_add_and_retrieve_message_integration(
self, message_repository, sample_message
):
"""Интеграционный тест добавления и получения сообщения."""
# Настраиваем тестовую БД
await self._setup_test_database(message_repository)
@@ -105,13 +114,17 @@ class TestMessageRepositoryIntegration:
await message_repository.add_message(sample_message)
# Получаем пользователя по message_id
user_id = await message_repository.get_user_by_message_id(sample_message.telegram_message_id)
user_id = await message_repository.get_user_by_message_id(
sample_message.telegram_message_id
)
# Проверяем результат
assert user_id == sample_message.user_id
@pytest.mark.asyncio
async def test_add_message_without_date_integration(self, message_repository, sample_message_no_date):
async def test_add_message_without_date_integration(
self, message_repository, sample_message_no_date
):
"""Интеграционный тест добавления сообщения без даты."""
# Настраиваем тестовую БД
await self._setup_test_database(message_repository)
@@ -125,11 +138,15 @@ class TestMessageRepositoryIntegration:
assert sample_message_no_date.date > 0
# Проверяем, что сообщение можно найти
user_id = await message_repository.get_user_by_message_id(sample_message_no_date.telegram_message_id)
user_id = await message_repository.get_user_by_message_id(
sample_message_no_date.telegram_message_id
)
assert user_id == sample_message_no_date.user_id
@pytest.mark.asyncio
async def test_get_user_by_message_id_not_found_integration(self, message_repository):
async def test_get_user_by_message_id_not_found_integration(
self, message_repository
):
"""Интеграционный тест поиска несуществующего сообщения."""
# Настраиваем тестовую БД
await self._setup_test_database(message_repository)
@@ -152,7 +169,7 @@ class TestMessageRepositoryIntegration:
message_text=f"Сообщение {i}",
user_id=12345, # Используем существующий user_id
telegram_message_id=2000 + i,
date=int(datetime.now().timestamp()) + i
date=int(datetime.now().timestamp()) + i,
)
for i in range(1, 4)
]
@@ -162,11 +179,15 @@ class TestMessageRepositoryIntegration:
# Проверяем, что все сообщения можно найти
for message in messages:
user_id = await message_repository.get_user_by_message_id(message.telegram_message_id)
user_id = await message_repository.get_user_by_message_id(
message.telegram_message_id
)
assert user_id == message.user_id
@pytest.mark.asyncio
async def test_message_with_special_characters_integration(self, message_repository):
async def test_message_with_special_characters_integration(
self, message_repository
):
"""Интеграционный тест сообщения со специальными символами."""
# Настраиваем тестовую БД
await self._setup_test_database(message_repository)
@@ -176,14 +197,16 @@ class TestMessageRepositoryIntegration:
message_text="Сообщение с 'кавычками' и \"двойными кавычками\" и эмодзи 😊",
user_id=12345,
telegram_message_id=67892,
date=int(datetime.now().timestamp())
date=int(datetime.now().timestamp()),
)
# Добавляем сообщение
await message_repository.add_message(special_message)
# Проверяем, что можно найти
user_id = await message_repository.get_user_by_message_id(special_message.telegram_message_id)
user_id = await message_repository.get_user_by_message_id(
special_message.telegram_message_id
)
assert user_id == special_message.user_id
@pytest.mark.asyncio
@@ -197,7 +220,7 @@ class TestMessageRepositoryIntegration:
message_text="Сообщение с несуществующим пользователем",
user_id=99999, # Несуществующий пользователь
telegram_message_id=67893,
date=int(datetime.now().timestamp())
date=int(datetime.now().timestamp()),
)
# В SQLite с включенными внешними ключами это должно вызвать ошибку
@@ -205,7 +228,9 @@ class TestMessageRepositoryIntegration:
try:
await message_repository.add_message(invalid_message)
# Если не вызвало ошибку, проверяем что сообщение не добавилось
user_id = await message_repository.get_user_by_message_id(invalid_message.telegram_message_id)
user_id = await message_repository.get_user_by_message_id(
invalid_message.telegram_message_id
)
assert user_id is None
except Exception:
# Ожидаемое поведение при нарушении внешнего ключа

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