Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59fd789a91 | |||
| cab85ecbf5 | |||
| f398274655 | |||
| a5d221ecad | |||
| 2ee6ea2b38 | |||
| 118189da82 | |||
| d963ea83ad | |||
| 937c54ecfb | |||
| c3b75a0eb7 | |||
| b8428a5bac | |||
| 3d6b4353f9 | |||
| d0c8dab24a | |||
| 31314c9c9b | |||
| b3cdadfd8e | |||
| 694cf1c106 | |||
|
|
e2a6944ed8 |
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: [ 'dev-*', 'feature-*', 'fix-*' ]
|
||||
pull_request:
|
||||
branches: [ 'dev-*', 'feature-*', 'fix-*', 'main' ]
|
||||
branches: [ 'dev-*', 'feature-*', 'fix-*', 'master' ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
|
||||
✅ All tests passed! Code quality checks completed successfully.
|
||||
|
||||
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
🔗 View details: ${{ vars.GITEA_PUBLIC_URL || github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Send test failure notification
|
||||
@@ -91,5 +91,5 @@ jobs:
|
||||
|
||||
❌ Tests failed! Deployment blocked. Please fix the issues and try again.
|
||||
|
||||
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
🔗 View details: ${{ vars.GITEA_PUBLIC_URL || github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
continue-on-error: true
|
||||
101
.github/workflows/deploy.yml
vendored
101
.github/workflows/deploy.yml
vendored
@@ -2,7 +2,7 @@ name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ master ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
@@ -16,6 +16,14 @@ on:
|
||||
description: 'Commit hash to rollback to (optional, uses last successful if empty)'
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'Dry run (only for deploy — no SSH, only show planned steps)'
|
||||
required: true
|
||||
type: choice
|
||||
default: no
|
||||
options:
|
||||
- no
|
||||
- yes
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -24,6 +32,8 @@ jobs:
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'deploy')
|
||||
env:
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run == 'yes' }}
|
||||
concurrency:
|
||||
group: production-deploy-telegram-helper-bot
|
||||
cancel-in-progress: false
|
||||
@@ -34,9 +44,28 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
ref: master
|
||||
|
||||
- name: Dry run (simulate deploy steps)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'yes'
|
||||
run: |
|
||||
echo "🔍 DRY RUN — no SSH, no changes on server"
|
||||
echo "Would run on server:"
|
||||
echo " 1. cd /home/prod/bots/telegram-helper-bot"
|
||||
echo " 2. Backup DB → database/tg-bot-database_YYYYMMDD-HHMMSS.db (удаляется при успехе)"
|
||||
echo " 3. CURRENT_COMMIT + history; git fetch origin master && git reset --hard origin/master"
|
||||
echo " 4. apply_migrations.py (бэкап БД делается в шаге 1, при успехе удаляется в конце)"
|
||||
echo " 5. docker-compose -f /home/prod/docker-compose.yml config (validate)"
|
||||
echo " 6. docker-compose stop telegram-bot; build --pull telegram-bot; up -d telegram-bot"
|
||||
echo " 7. sleep 10; check container bots_telegram_bot"
|
||||
echo ""
|
||||
echo "Secrets/vars required: SERVER_HOST, SERVER_USER, SSH_PRIVATE_KEY, SSH_PORT, TELEGRAM_BOT_TOKEN, TELEGRAM_TEST_BOT_TOKEN"
|
||||
if [ -f docker-compose.yml ]; then
|
||||
echo "✅ docker-compose.yml present in repo (validation would run on server from /home/prod)"
|
||||
fi
|
||||
|
||||
- name: Deploy to server
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'yes'
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
with:
|
||||
host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }}
|
||||
@@ -50,9 +79,22 @@ jobs:
|
||||
|
||||
echo "🚀 Starting deployment to production..."
|
||||
|
||||
cd /home/prod
|
||||
DB_PATH="/home/prod/bots/telegram-helper-bot/database/tg-bot-database.db"
|
||||
DB_DIR="/home/prod/bots/telegram-helper-bot/database"
|
||||
BACKUP_FILE=""
|
||||
|
||||
# Сохраняем информацию о коммите
|
||||
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
|
||||
cd /home/prod/bots/telegram-helper-bot
|
||||
|
||||
# Бэкап БД в самом начале; при успешном деплое удалим в конце
|
||||
if [ -f "$DB_PATH" ]; then
|
||||
echo "💾 Creating database backup (before any changes)..."
|
||||
BACKUP_NAME="tg-bot-database_$(date +%Y%m%d-%H%M%S).db"
|
||||
BACKUP_FILE="${DB_DIR}/${BACKUP_NAME}"
|
||||
cp "$DB_PATH" "$BACKUP_FILE" && echo "✅ Backup: $BACKUP_FILE" || { echo "❌ Backup failed!"; exit 1; }
|
||||
fi
|
||||
|
||||
# Сохраняем информацию о коммите (до pull) — из репо telegram-helper-bot
|
||||
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")
|
||||
@@ -69,23 +111,24 @@ jobs:
|
||||
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
|
||||
echo "📥 Pulling latest changes from master..."
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
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"
|
||||
|
||||
# Применяем миграции БД перед перезапуском контейнера
|
||||
# Применяем миграции БД (нужен venv с зависимостями: aiosqlite и др.)
|
||||
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" || {
|
||||
if [ ! -d .venv ]; then
|
||||
echo "📦 Creating .venv for migrations..."
|
||||
python3 -m venv .venv
|
||||
fi
|
||||
.venv/bin/pip install -q -r requirements.txt
|
||||
.venv/bin/python scripts/apply_migrations.py --db "$DB_PATH" || {
|
||||
echo "❌ Ошибка при применении миграций!"
|
||||
exit 1
|
||||
}
|
||||
@@ -127,6 +170,10 @@ jobs:
|
||||
|
||||
if docker ps | grep -q bots_telegram_bot; then
|
||||
echo "✅ Container is running"
|
||||
# Успешный деплой — удаляем бэкап (при ошибке на любом шаге бэкап остаётся для rollback)
|
||||
if [ -n "${BACKUP_FILE:-}" ] && [ -f "$BACKUP_FILE" ]; then
|
||||
rm -f "$BACKUP_FILE" && echo "✅ Backup removed (deploy success)"
|
||||
fi
|
||||
else
|
||||
echo "❌ Container failed to start!"
|
||||
docker logs bots_telegram_bot --tail 50 || true
|
||||
@@ -134,7 +181,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Update deploy history
|
||||
if: always()
|
||||
if: always() && env.DRY_RUN != 'true'
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
with:
|
||||
host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }}
|
||||
@@ -155,7 +202,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Send deployment notification
|
||||
if: always()
|
||||
if: always() && env.DRY_RUN != 'true'
|
||||
uses: appleboy/telegram-action@v1.0.0
|
||||
with:
|
||||
to: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
@@ -164,30 +211,30 @@ jobs:
|
||||
${{ job.status == 'success' && '✅' || '❌' }} Deployment: ${{ job.status }}
|
||||
|
||||
📦 Repository: telegram-helper-bot
|
||||
🌿 Branch: main
|
||||
🌿 Branch: master
|
||||
📝 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 }}
|
||||
🔗 View details: ${{ vars.GITEA_PUBLIC_URL || 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'
|
||||
if: job.status == 'success' && github.event_name == 'push' && env.DRY_RUN != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
echo "🔍 Searching for merged PR associated with commit ${{ github.sha }}..."
|
||||
|
||||
# Находим последний мерженный PR для main ветки по merge commit SHA
|
||||
# Находим последний мерженный PR для master по 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)
|
||||
PR_NUMBER=$(gh pr list --state merged --base master --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')
|
||||
PR_NUMBER=$(gh pr list --state merged --base master --limit 1 --json number --jq '.[0].number')
|
||||
fi
|
||||
|
||||
if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then
|
||||
@@ -209,7 +256,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Send PR body to important logs
|
||||
if: job.status == 'success' && github.event_name == 'push' && env.PR_BODY != ''
|
||||
if: job.status == 'success' && github.event_name == 'push' && env.DRY_RUN != 'true' && env.PR_BODY != ''
|
||||
uses: appleboy/telegram-action@v1.0.0
|
||||
with:
|
||||
to: ${{ secrets.IMPORTANT_LOGS_CHAT }}
|
||||
@@ -219,7 +266,7 @@ jobs:
|
||||
|
||||
${{ env.PR_BODY }}
|
||||
|
||||
🔗 PR: ${{ github.server_url }}/${{ github.repository }}/pull/${{ env.PR_NUMBER }}
|
||||
🔗 PR: ${{ vars.GITEA_PUBLIC_URL || github.server_url }}/${{ github.repository }}/pull/${{ env.PR_NUMBER }}
|
||||
📝 Commit: ${{ github.sha }}
|
||||
continue-on-error: true
|
||||
|
||||
@@ -236,7 +283,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
ref: master
|
||||
|
||||
- name: Rollback on server
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
@@ -294,7 +341,7 @@ jobs:
|
||||
|
||||
# Откатываем код
|
||||
echo "🔄 Rolling back code..."
|
||||
git fetch origin main
|
||||
git fetch origin master
|
||||
git reset --hard "$ROLLBACK_COMMIT"
|
||||
|
||||
# Исправляем права после отката
|
||||
@@ -346,12 +393,12 @@ jobs:
|
||||
${{ job.status == 'success' && '🔄' || '❌' }} Rollback: ${{ job.status }}
|
||||
|
||||
📦 Repository: telegram-helper-bot
|
||||
🌿 Branch: main
|
||||
🌿 Branch: master
|
||||
📝 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 }}
|
||||
🔗 View details: ${{ vars.GITEA_PUBLIC_URL || github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
@@ -279,6 +279,34 @@ class AsyncBotDB:
|
||||
"""Получает тексты отклоненных постов для обучения RAG."""
|
||||
return await self.factory.posts.get_declined_posts_texts(limit)
|
||||
|
||||
async def get_user_posts_stats(self, user_id: int) -> Tuple[int, int, int]:
|
||||
"""
|
||||
Получает статистику постов пользователя.
|
||||
|
||||
Returns:
|
||||
Tuple (approved_count, declined_count, suggest_count)
|
||||
"""
|
||||
return await self.factory.posts.get_user_posts_stats(user_id)
|
||||
|
||||
async def get_last_post_by_author(self, user_id: int) -> Optional[str]:
|
||||
"""Получает текст последнего поста пользователя."""
|
||||
return await self.factory.posts.get_last_post_by_author(user_id)
|
||||
|
||||
async def get_user_ban_count(self, user_id: int) -> int:
|
||||
"""Получает количество банов пользователя за все время."""
|
||||
return await self.factory.blacklist_history.get_ban_count(user_id)
|
||||
|
||||
async def get_last_ban_info(
|
||||
self, user_id: int
|
||||
) -> Optional[Tuple[int, str, Optional[int]]]:
|
||||
"""
|
||||
Получает информацию о последнем бане пользователя.
|
||||
|
||||
Returns:
|
||||
Tuple (date_ban, reason, date_unban) или None
|
||||
"""
|
||||
return await self.factory.blacklist_history.get_last_ban_info(user_id)
|
||||
|
||||
# Методы для работы с черным списком
|
||||
async def set_user_blacklist(
|
||||
self,
|
||||
@@ -361,7 +389,8 @@ class AsyncBotDB:
|
||||
"""Возвращает список пользователей в черном списке с учетом смещения и ограничения."""
|
||||
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
|
||||
(user.user_id, user.message_for_user, user.date_to_unban, user.created_at)
|
||||
for user in users
|
||||
]
|
||||
|
||||
async def get_blacklist_users_by_id(self, user_id: int) -> Optional[tuple]:
|
||||
@@ -543,3 +572,32 @@ class AsyncBotDB:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error executing query: {e}")
|
||||
return None
|
||||
|
||||
# Методы для работы с настройками бота
|
||||
async def get_auto_moderation_settings(self) -> Dict[str, Any]:
|
||||
"""Получает все настройки авто-модерации."""
|
||||
return await self.factory.bot_settings.get_auto_moderation_settings()
|
||||
|
||||
async def get_bool_setting(self, key: str, default: bool = False) -> bool:
|
||||
"""Получает булево значение настройки."""
|
||||
return await self.factory.bot_settings.get_bool_setting(key, default)
|
||||
|
||||
async def get_float_setting(self, key: str, default: float = 0.0) -> float:
|
||||
"""Получает числовое значение настройки."""
|
||||
return await self.factory.bot_settings.get_float_setting(key, default)
|
||||
|
||||
async def set_setting(self, key: str, value: str) -> None:
|
||||
"""Устанавливает значение настройки."""
|
||||
await self.factory.bot_settings.set_setting(key, value)
|
||||
|
||||
async def set_float_setting(self, key: str, value: float) -> None:
|
||||
"""Устанавливает числовое значение настройки."""
|
||||
await self.factory.bot_settings.set_float_setting(key, value)
|
||||
|
||||
async def toggle_auto_publish(self) -> bool:
|
||||
"""Переключает состояние авто-публикации."""
|
||||
return await self.factory.bot_settings.toggle_auto_publish()
|
||||
|
||||
async def toggle_auto_decline(self) -> bool:
|
||||
"""Переключает состояние авто-отклонения."""
|
||||
return await self.factory.bot_settings.toggle_auto_decline()
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
- admin_repository: работа с администраторами
|
||||
- audio_repository: работа с аудио
|
||||
- migration_repository: работа с миграциями БД
|
||||
- bot_settings_repository: работа с настройками бота
|
||||
"""
|
||||
|
||||
from .admin_repository import AdminRepository
|
||||
from .audio_repository import AudioRepository
|
||||
from .blacklist_history_repository import BlacklistHistoryRepository
|
||||
from .blacklist_repository import BlacklistRepository
|
||||
from .bot_settings_repository import BotSettingsRepository
|
||||
from .message_repository import MessageRepository
|
||||
from .migration_repository import MigrationRepository
|
||||
from .post_repository import PostRepository
|
||||
@@ -30,4 +32,5 @@ __all__ = [
|
||||
"AdminRepository",
|
||||
"AudioRepository",
|
||||
"MigrationRepository",
|
||||
"BotSettingsRepository",
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Optional
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from database.base import DatabaseConnection
|
||||
from database.models import BlacklistHistoryRecord
|
||||
@@ -120,3 +120,55 @@ class BlacklistHistoryRepository(DatabaseConnection):
|
||||
f"Ошибка обновления даты разбана в истории для user_id={user_id}: {str(e)}"
|
||||
)
|
||||
return False
|
||||
|
||||
async def get_ban_count(self, user_id: int) -> int:
|
||||
"""
|
||||
Получает количество банов пользователя за все время.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Количество банов
|
||||
"""
|
||||
query = "SELECT COUNT(*) FROM blacklist_history WHERE user_id = ?"
|
||||
rows = await self._execute_query_with_result(query, (user_id,))
|
||||
row = rows[0] if rows else None
|
||||
|
||||
count = row[0] if row else 0
|
||||
self.logger.info(f"Количество банов для user_id={user_id}: {count}")
|
||||
return count
|
||||
|
||||
async def get_last_ban_info(
|
||||
self, user_id: int
|
||||
) -> Optional[Tuple[int, str, Optional[int]]]:
|
||||
"""
|
||||
Получает информацию о последнем бане пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Tuple (date_ban, reason, date_unban) или None, если банов не было
|
||||
"""
|
||||
query = """
|
||||
SELECT date_ban, reason, date_unban FROM blacklist_history
|
||||
WHERE user_id = ?
|
||||
ORDER BY date_ban DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
rows = await self._execute_query_with_result(query, (user_id,))
|
||||
row = rows[0] if rows else None
|
||||
|
||||
if row:
|
||||
date_ban = row[0]
|
||||
reason = row[1]
|
||||
date_unban = row[2]
|
||||
self.logger.info(
|
||||
f"Последний бан для user_id={user_id}: "
|
||||
f"date_ban={date_ban}, reason={reason}, date_unban={date_unban}"
|
||||
)
|
||||
return (date_ban, reason, date_unban)
|
||||
|
||||
self.logger.info(f"Банов для user_id={user_id} не найдено")
|
||||
return None
|
||||
|
||||
@@ -87,13 +87,14 @@ class BlacklistRepository(DatabaseConnection):
|
||||
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
|
||||
FROM blacklist
|
||||
LIMIT ?, ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
rows = await self._execute_query_with_result(query, (offset, limit))
|
||||
rows = await self._execute_query_with_result(query, (limit, offset))
|
||||
|
||||
users = []
|
||||
for row in rows:
|
||||
@@ -113,10 +114,11 @@ class BlacklistRepository(DatabaseConnection):
|
||||
return users
|
||||
|
||||
async def get_all_users_no_limit(self) -> List[BlacklistUser]:
|
||||
"""Возвращает список всех пользователей в черном списке без лимитов."""
|
||||
"""Возвращает список всех пользователей в черном списке без лимитов, отсортированных по дате бана (новые первые)."""
|
||||
query = """
|
||||
SELECT user_id, message_for_user, date_to_unban, created_at, ban_author
|
||||
FROM blacklist
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
rows = await self._execute_query_with_result(query)
|
||||
|
||||
|
||||
160
database/repositories/bot_settings_repository.py
Normal file
160
database/repositories/bot_settings_repository.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Репозиторий для работы с настройками бота."""
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
from database.base import DatabaseConnection
|
||||
|
||||
|
||||
class BotSettingsRepository(DatabaseConnection):
|
||||
"""Репозиторий для управления настройками бота в таблице bot_settings."""
|
||||
|
||||
async def create_table(self) -> None:
|
||||
"""Создает таблицу bot_settings, если она не существует."""
|
||||
query = """
|
||||
CREATE TABLE IF NOT EXISTS bot_settings (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
)
|
||||
"""
|
||||
await self._execute_query(query)
|
||||
self.logger.info("Таблица bot_settings создана или уже существует")
|
||||
|
||||
async def get_setting(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Получает значение настройки по ключу.
|
||||
|
||||
Args:
|
||||
key: Ключ настройки
|
||||
|
||||
Returns:
|
||||
Значение настройки или None, если не найдено
|
||||
"""
|
||||
query = "SELECT value FROM bot_settings WHERE key = ?"
|
||||
rows = await self._execute_query_with_result(query, (key,))
|
||||
if rows and len(rows) > 0:
|
||||
return rows[0][0]
|
||||
return None
|
||||
|
||||
async def set_setting(self, key: str, value: str) -> None:
|
||||
"""
|
||||
Устанавливает значение настройки.
|
||||
|
||||
Args:
|
||||
key: Ключ настройки
|
||||
value: Значение настройки
|
||||
"""
|
||||
query = """
|
||||
INSERT INTO bot_settings (key, value, updated_at)
|
||||
VALUES (?, ?, strftime('%s', 'now'))
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = strftime('%s', 'now')
|
||||
"""
|
||||
await self._execute_query(query, (key, value))
|
||||
self.logger.debug(f"Настройка {key} установлена: {value}")
|
||||
|
||||
async def get_bool_setting(self, key: str, default: bool = False) -> bool:
|
||||
"""
|
||||
Получает булево значение настройки.
|
||||
|
||||
Args:
|
||||
key: Ключ настройки
|
||||
default: Значение по умолчанию
|
||||
|
||||
Returns:
|
||||
True если значение 'true', иначе False
|
||||
"""
|
||||
value = await self.get_setting(key)
|
||||
if value is None:
|
||||
return default
|
||||
return value.lower() == "true"
|
||||
|
||||
async def set_bool_setting(self, key: str, value: bool) -> None:
|
||||
"""
|
||||
Устанавливает булево значение настройки.
|
||||
|
||||
Args:
|
||||
key: Ключ настройки
|
||||
value: Булево значение
|
||||
"""
|
||||
await self.set_setting(key, "true" if value else "false")
|
||||
|
||||
async def get_float_setting(self, key: str, default: float = 0.0) -> float:
|
||||
"""
|
||||
Получает числовое значение настройки.
|
||||
|
||||
Args:
|
||||
key: Ключ настройки
|
||||
default: Значение по умолчанию
|
||||
|
||||
Returns:
|
||||
Числовое значение или default
|
||||
"""
|
||||
value = await self.get_setting(key)
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
self.logger.warning(
|
||||
f"Невозможно преобразовать значение '{value}' в float для ключа '{key}'"
|
||||
)
|
||||
return default
|
||||
|
||||
async def set_float_setting(self, key: str, value: float) -> None:
|
||||
"""
|
||||
Устанавливает числовое значение настройки.
|
||||
|
||||
Args:
|
||||
key: Ключ настройки
|
||||
value: Числовое значение
|
||||
"""
|
||||
await self.set_setting(key, str(value))
|
||||
|
||||
async def get_auto_moderation_settings(self) -> Dict[str, any]:
|
||||
"""
|
||||
Получает все настройки авто-модерации.
|
||||
|
||||
Returns:
|
||||
Словарь с настройками авто-модерации
|
||||
"""
|
||||
return {
|
||||
"auto_publish_enabled": await self.get_bool_setting(
|
||||
"auto_publish_enabled", False
|
||||
),
|
||||
"auto_decline_enabled": await self.get_bool_setting(
|
||||
"auto_decline_enabled", False
|
||||
),
|
||||
"auto_publish_threshold": await self.get_float_setting(
|
||||
"auto_publish_threshold", 0.8
|
||||
),
|
||||
"auto_decline_threshold": await self.get_float_setting(
|
||||
"auto_decline_threshold", 0.4
|
||||
),
|
||||
}
|
||||
|
||||
async def toggle_auto_publish(self) -> bool:
|
||||
"""
|
||||
Переключает состояние авто-публикации.
|
||||
|
||||
Returns:
|
||||
Новое состояние (True/False)
|
||||
"""
|
||||
current = await self.get_bool_setting("auto_publish_enabled", False)
|
||||
new_value = not current
|
||||
await self.set_bool_setting("auto_publish_enabled", new_value)
|
||||
return new_value
|
||||
|
||||
async def toggle_auto_decline(self) -> bool:
|
||||
"""
|
||||
Переключает состояние авто-отклонения.
|
||||
|
||||
Returns:
|
||||
Новое состояние (True/False)
|
||||
"""
|
||||
current = await self.get_bool_setting("auto_decline_enabled", False)
|
||||
new_value = not current
|
||||
await self.set_bool_setting("auto_decline_enabled", new_value)
|
||||
return new_value
|
||||
@@ -545,3 +545,67 @@ class PostRepository(DatabaseConnection):
|
||||
texts = [row[0] for row in rows if row[0]]
|
||||
self.logger.info(f"Получено {len(texts)} отклоненных постов для обучения")
|
||||
return texts
|
||||
|
||||
async def get_user_posts_stats(self, user_id: int) -> Tuple[int, int, int]:
|
||||
"""
|
||||
Получает статистику постов пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Tuple (approved_count, declined_count, suggest_count)
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved,
|
||||
SUM(CASE WHEN status = 'declined' THEN 1 ELSE 0 END) as declined,
|
||||
SUM(CASE WHEN status = 'suggest' THEN 1 ELSE 0 END) as suggest
|
||||
FROM post_from_telegram_suggest
|
||||
WHERE author_id = ? AND text != '^'
|
||||
"""
|
||||
rows = await self._execute_query_with_result(query, (user_id,))
|
||||
row = rows[0] if rows else None
|
||||
|
||||
if row:
|
||||
approved = row[0] or 0
|
||||
declined = row[1] or 0
|
||||
suggest = row[2] or 0
|
||||
self.logger.info(
|
||||
f"Статистика постов для user_id={user_id}: "
|
||||
f"approved={approved}, declined={declined}, suggest={suggest}"
|
||||
)
|
||||
return (approved, declined, suggest)
|
||||
|
||||
return (0, 0, 0)
|
||||
|
||||
async def get_last_post_by_author(self, user_id: int) -> Optional[str]:
|
||||
"""
|
||||
Получает текст последнего поста пользователя.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Текст последнего поста или None, если постов нет
|
||||
"""
|
||||
query = """
|
||||
SELECT text FROM post_from_telegram_suggest
|
||||
WHERE author_id = ? AND text IS NOT NULL AND text != '' AND text != '^'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
rows = await self._execute_query_with_result(query, (user_id,))
|
||||
row = rows[0] if rows else None
|
||||
|
||||
if row:
|
||||
text = row[0]
|
||||
self.logger.info(
|
||||
f"Последний пост для user_id={user_id}: '{text[:50]}...'"
|
||||
if len(text) > 50
|
||||
else f"Последний пост для user_id={user_id}: '{text}'"
|
||||
)
|
||||
return text
|
||||
|
||||
self.logger.info(f"Постов для user_id={user_id} не найдено")
|
||||
return None
|
||||
|
||||
@@ -6,6 +6,7 @@ from database.repositories.blacklist_history_repository import (
|
||||
BlacklistHistoryRepository,
|
||||
)
|
||||
from database.repositories.blacklist_repository import BlacklistRepository
|
||||
from database.repositories.bot_settings_repository import BotSettingsRepository
|
||||
from database.repositories.message_repository import MessageRepository
|
||||
from database.repositories.migration_repository import MigrationRepository
|
||||
from database.repositories.post_repository import PostRepository
|
||||
@@ -25,6 +26,7 @@ class RepositoryFactory:
|
||||
self._admin_repo: Optional[AdminRepository] = None
|
||||
self._audio_repo: Optional[AudioRepository] = None
|
||||
self._migration_repo: Optional[MigrationRepository] = None
|
||||
self._bot_settings_repo: Optional[BotSettingsRepository] = None
|
||||
|
||||
@property
|
||||
def users(self) -> UserRepository:
|
||||
@@ -82,6 +84,13 @@ class RepositoryFactory:
|
||||
self._migration_repo = MigrationRepository(self.db_path)
|
||||
return self._migration_repo
|
||||
|
||||
@property
|
||||
def bot_settings(self) -> BotSettingsRepository:
|
||||
"""Возвращает репозиторий настроек бота."""
|
||||
if self._bot_settings_repo is None:
|
||||
self._bot_settings_repo = BotSettingsRepository(self.db_path)
|
||||
return self._bot_settings_repo
|
||||
|
||||
async def create_all_tables(self):
|
||||
"""Создает все таблицы в базе данных."""
|
||||
await self.migrations.create_table() # Сначала создаем таблицу миграций
|
||||
@@ -92,6 +101,7 @@ class RepositoryFactory:
|
||||
await self.posts.create_tables()
|
||||
await self.admins.create_tables()
|
||||
await self.audio.create_tables()
|
||||
await self.bot_settings.create_table()
|
||||
|
||||
async def check_database_integrity(self):
|
||||
"""Проверяет целостность базы данных."""
|
||||
|
||||
@@ -21,6 +21,7 @@ from helper_bot.keyboards.keyboards import (
|
||||
create_keyboard_for_ban_days,
|
||||
create_keyboard_for_ban_reason,
|
||||
create_keyboard_with_pagination,
|
||||
get_auto_moderation_keyboard,
|
||||
get_reply_keyboard_admin,
|
||||
)
|
||||
from helper_bot.utils.base_dependency_factory import get_global_instance
|
||||
@@ -138,7 +139,9 @@ async def get_banned_users(
|
||||
keyboard = create_keyboard_with_pagination(
|
||||
1, len(buttons_list), buttons_list, "unlock"
|
||||
)
|
||||
await message.answer(text=message_text, reply_markup=keyboard)
|
||||
await message.answer(
|
||||
text=message_text, reply_markup=keyboard, parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
text="В списке заблокированных пользователей никого нет"
|
||||
@@ -216,9 +219,11 @@ async def get_ml_stats(message: types.Message, state: FSMContext, **kwargs):
|
||||
# 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 '❌ Отключен'}"
|
||||
)
|
||||
if rag.get("enabled"):
|
||||
lines.append(f" • Статус: ⚠️ Включен, но API не отвечает")
|
||||
lines.append(f" • Проверьте доступность сервиса и API ключ")
|
||||
else:
|
||||
lines.append(f" • Статус: ❌ Отключен")
|
||||
|
||||
lines.append("")
|
||||
|
||||
@@ -244,6 +249,266 @@ async def get_ml_stats(message: types.Message, state: FSMContext, **kwargs):
|
||||
await message.answer(f"❌ Ошибка получения статистики: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ХЕНДЛЕРЫ АВТО-МОДЕРАЦИИ
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@admin_router.message(
|
||||
ChatTypeFilter(chat_type=["private"]),
|
||||
StateFilter("ADMIN"),
|
||||
F.text == "⚙️ Авто-модерация",
|
||||
)
|
||||
@track_time("auto_moderation_menu", "admin_handlers")
|
||||
@track_errors("admin_handlers", "auto_moderation_menu")
|
||||
async def auto_moderation_menu(
|
||||
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
|
||||
):
|
||||
"""Меню управления авто-модерацией"""
|
||||
try:
|
||||
logger.info(
|
||||
f"Открытие меню авто-модерации пользователем: {message.from_user.full_name}"
|
||||
)
|
||||
|
||||
settings = await bot_db.get_auto_moderation_settings()
|
||||
|
||||
text = _format_auto_moderation_status(settings)
|
||||
keyboard = get_auto_moderation_keyboard(settings)
|
||||
|
||||
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка открытия меню авто-модерации: {e}")
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
def _format_auto_moderation_status(settings: dict) -> str:
|
||||
"""Форматирует текст статуса авто-модерации."""
|
||||
auto_publish = settings.get("auto_publish_enabled", False)
|
||||
auto_decline = settings.get("auto_decline_enabled", False)
|
||||
publish_threshold = settings.get("auto_publish_threshold", 0.8)
|
||||
decline_threshold = settings.get("auto_decline_threshold", 0.4)
|
||||
|
||||
publish_status = "✅ Включена" if auto_publish else "❌ Выключена"
|
||||
decline_status = "✅ Включено" if auto_decline else "❌ Выключено"
|
||||
|
||||
return (
|
||||
"⚙️ <b>Авто-модерация постов</b>\n\n"
|
||||
f"🤖 <b>Авто-публикация:</b> {publish_status}\n"
|
||||
f" Порог: RAG score ≥ <b>{publish_threshold}</b>\n\n"
|
||||
f"🚫 <b>Авто-отклонение:</b> {decline_status}\n"
|
||||
f" Порог: RAG score ≤ <b>{decline_threshold}</b>"
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "auto_mod_toggle_publish")
|
||||
@track_time("toggle_auto_publish", "admin_handlers")
|
||||
@track_errors("admin_handlers", "toggle_auto_publish")
|
||||
async def toggle_auto_publish(call: types.CallbackQuery, bot_db: MagicData("bot_db")):
|
||||
"""Переключение авто-публикации"""
|
||||
try:
|
||||
new_state = await bot_db.toggle_auto_publish()
|
||||
logger.info(
|
||||
f"Авто-публикация {'включена' if new_state else 'выключена'} "
|
||||
f"пользователем {call.from_user.full_name}"
|
||||
)
|
||||
|
||||
settings = await bot_db.get_auto_moderation_settings()
|
||||
text = _format_auto_moderation_status(settings)
|
||||
keyboard = get_auto_moderation_keyboard(settings)
|
||||
|
||||
await call.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
await call.answer(
|
||||
f"Авто-публикация {'включена ✅' if new_state else 'выключена ❌'}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка переключения авто-публикации: {e}")
|
||||
await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "auto_mod_toggle_decline")
|
||||
@track_time("toggle_auto_decline", "admin_handlers")
|
||||
@track_errors("admin_handlers", "toggle_auto_decline")
|
||||
async def toggle_auto_decline(call: types.CallbackQuery, bot_db: MagicData("bot_db")):
|
||||
"""Переключение авто-отклонения"""
|
||||
try:
|
||||
new_state = await bot_db.toggle_auto_decline()
|
||||
logger.info(
|
||||
f"Авто-отклонение {'включено' if new_state else 'выключено'} "
|
||||
f"пользователем {call.from_user.full_name}"
|
||||
)
|
||||
|
||||
settings = await bot_db.get_auto_moderation_settings()
|
||||
text = _format_auto_moderation_status(settings)
|
||||
keyboard = get_auto_moderation_keyboard(settings)
|
||||
|
||||
await call.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
await call.answer(
|
||||
f"Авто-отклонение {'включено ✅' if new_state else 'выключено ❌'}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка переключения авто-отклонения: {e}")
|
||||
await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "auto_mod_refresh")
|
||||
@track_time("refresh_auto_moderation", "admin_handlers")
|
||||
@track_errors("admin_handlers", "refresh_auto_moderation")
|
||||
async def refresh_auto_moderation(
|
||||
call: types.CallbackQuery, bot_db: MagicData("bot_db")
|
||||
):
|
||||
"""Обновление статуса авто-модерации"""
|
||||
try:
|
||||
settings = await bot_db.get_auto_moderation_settings()
|
||||
text = _format_auto_moderation_status(settings)
|
||||
keyboard = get_auto_moderation_keyboard(settings)
|
||||
|
||||
try:
|
||||
await call.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
except Exception as edit_error:
|
||||
if "message is not modified" in str(edit_error):
|
||||
pass # Сообщение не изменилось - это нормально
|
||||
else:
|
||||
raise
|
||||
await call.answer("🔄 Обновлено")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления статуса авто-модерации: {e}")
|
||||
await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "auto_mod_threshold_publish")
|
||||
@track_time("change_publish_threshold", "admin_handlers")
|
||||
@track_errors("admin_handlers", "change_publish_threshold")
|
||||
async def change_publish_threshold(
|
||||
call: types.CallbackQuery, state: FSMContext, bot_db: MagicData("bot_db")
|
||||
):
|
||||
"""Начало изменения порога авто-публикации"""
|
||||
try:
|
||||
await state.set_state("AWAIT_PUBLISH_THRESHOLD")
|
||||
await call.message.answer(
|
||||
"📈 <b>Изменение порога авто-публикации</b>\n\n"
|
||||
"Введите новое значение порога (от 0.0 до 1.0).\n"
|
||||
"Посты с RAG score ≥ этого значения будут автоматически публиковаться.\n\n"
|
||||
"Текущее рекомендуемое значение: <b>0.8</b>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await call.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка начала изменения порога публикации: {e}")
|
||||
await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "auto_mod_threshold_decline")
|
||||
@track_time("change_decline_threshold", "admin_handlers")
|
||||
@track_errors("admin_handlers", "change_decline_threshold")
|
||||
async def change_decline_threshold(
|
||||
call: types.CallbackQuery, state: FSMContext, bot_db: MagicData("bot_db")
|
||||
):
|
||||
"""Начало изменения порога авто-отклонения"""
|
||||
try:
|
||||
await state.set_state("AWAIT_DECLINE_THRESHOLD")
|
||||
await call.message.answer(
|
||||
"📉 <b>Изменение порога авто-отклонения</b>\n\n"
|
||||
"Введите новое значение порога (от 0.0 до 1.0).\n"
|
||||
"Посты с RAG score ≤ этого значения будут автоматически отклоняться.\n\n"
|
||||
"Текущее рекомендуемое значение: <b>0.4</b>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await call.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка начала изменения порога отклонения: {e}")
|
||||
await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@admin_router.message(
|
||||
ChatTypeFilter(chat_type=["private"]),
|
||||
StateFilter("AWAIT_PUBLISH_THRESHOLD"),
|
||||
)
|
||||
@track_time("process_publish_threshold", "admin_handlers")
|
||||
@track_errors("admin_handlers", "process_publish_threshold")
|
||||
async def process_publish_threshold(
|
||||
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
|
||||
):
|
||||
"""Обработка нового порога авто-публикации"""
|
||||
try:
|
||||
value = float(message.text.strip().replace(",", "."))
|
||||
if not 0.0 <= value <= 1.0:
|
||||
raise ValueError("Значение должно быть от 0.0 до 1.0")
|
||||
|
||||
await bot_db.set_float_setting("auto_publish_threshold", value)
|
||||
logger.info(
|
||||
f"Порог авто-публикации изменен на {value} "
|
||||
f"пользователем {message.from_user.full_name}"
|
||||
)
|
||||
|
||||
await state.set_state("ADMIN")
|
||||
await message.answer(
|
||||
f"✅ Порог авто-публикации изменен на <b>{value}</b>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
settings = await bot_db.get_auto_moderation_settings()
|
||||
text = _format_auto_moderation_status(settings)
|
||||
keyboard = get_auto_moderation_keyboard(settings)
|
||||
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
|
||||
except ValueError as e:
|
||||
await message.answer(
|
||||
f"❌ Неверное значение: {e}\n" "Введите число от 0.0 до 1.0 (например: 0.8)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения порога публикации: {e}")
|
||||
await state.set_state("ADMIN")
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@admin_router.message(
|
||||
ChatTypeFilter(chat_type=["private"]),
|
||||
StateFilter("AWAIT_DECLINE_THRESHOLD"),
|
||||
)
|
||||
@track_time("process_decline_threshold", "admin_handlers")
|
||||
@track_errors("admin_handlers", "process_decline_threshold")
|
||||
async def process_decline_threshold(
|
||||
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
|
||||
):
|
||||
"""Обработка нового порога авто-отклонения"""
|
||||
try:
|
||||
value = float(message.text.strip().replace(",", "."))
|
||||
if not 0.0 <= value <= 1.0:
|
||||
raise ValueError("Значение должно быть от 0.0 до 1.0")
|
||||
|
||||
await bot_db.set_float_setting("auto_decline_threshold", value)
|
||||
logger.info(
|
||||
f"Порог авто-отклонения изменен на {value} "
|
||||
f"пользователем {message.from_user.full_name}"
|
||||
)
|
||||
|
||||
await state.set_state("ADMIN")
|
||||
await message.answer(
|
||||
f"✅ Порог авто-отклонения изменен на <b>{value}</b>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
settings = await bot_db.get_auto_moderation_settings()
|
||||
text = _format_auto_moderation_status(settings)
|
||||
keyboard = get_auto_moderation_keyboard(settings)
|
||||
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
|
||||
except ValueError as e:
|
||||
await message.answer(
|
||||
f"❌ Неверное значение: {e}\n" "Введите число от 0.0 до 1.0 (например: 0.4)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения порога отклонения: {e}")
|
||||
await state.set_state("ADMIN")
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ХЕНДЛЕРЫ ПРОЦЕССА БАНА
|
||||
# ============================================================================
|
||||
|
||||
@@ -255,6 +255,8 @@ async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs
|
||||
|
||||
logger.info(f"Переход на страницу {page_number}")
|
||||
|
||||
items_per_page = 9
|
||||
|
||||
if call.message.text == "Список пользователей которые последними обращались к боту":
|
||||
list_users = await bot_db.get_last_users(30)
|
||||
keyboard = create_keyboard_with_pagination(
|
||||
@@ -266,11 +268,13 @@ async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
message_user = await get_banned_users_list(int(page_number) * 7 - 7, bot_db)
|
||||
offset = (page_number - 1) * items_per_page
|
||||
message_user = await get_banned_users_list(offset, bot_db)
|
||||
await call.bot.edit_message_text(
|
||||
chat_id=call.message.chat.id,
|
||||
message_id=call.message.message_id,
|
||||
text=message_user,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
buttons = await get_banned_users_buttons(bot_db)
|
||||
|
||||
@@ -8,7 +8,7 @@ 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,
|
||||
get_publish_text,
|
||||
send_audio_message,
|
||||
send_media_group_to_channel,
|
||||
send_photo_message,
|
||||
@@ -137,7 +137,7 @@ class PostPublishService:
|
||||
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
|
||||
|
||||
# Формируем финальный текст с учетом is_anonymous
|
||||
formatted_text = get_text_message(
|
||||
formatted_text = get_publish_text(
|
||||
raw_text, user.first_name, user.username, is_anonymous
|
||||
)
|
||||
|
||||
@@ -188,7 +188,7 @@ class PostPublishService:
|
||||
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
|
||||
|
||||
# Формируем финальный текст с учетом is_anonymous
|
||||
formatted_text = get_text_message(
|
||||
formatted_text = get_publish_text(
|
||||
raw_text, user.first_name, user.username, is_anonymous
|
||||
)
|
||||
|
||||
@@ -247,7 +247,7 @@ class PostPublishService:
|
||||
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
|
||||
|
||||
# Формируем финальный текст с учетом is_anonymous
|
||||
formatted_text = get_text_message(
|
||||
formatted_text = get_publish_text(
|
||||
raw_text, user.first_name, user.username, is_anonymous
|
||||
)
|
||||
|
||||
@@ -340,7 +340,7 @@ class PostPublishService:
|
||||
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
|
||||
|
||||
# Формируем финальный текст с учетом is_anonymous
|
||||
formatted_text = get_text_message(
|
||||
formatted_text = get_publish_text(
|
||||
raw_text, user.first_name, user.username, is_anonymous
|
||||
)
|
||||
|
||||
@@ -452,7 +452,7 @@ class PostPublishService:
|
||||
f"Пользователь {author_id} не найден в базе данных"
|
||||
)
|
||||
|
||||
formatted_text = get_text_message(
|
||||
formatted_text = get_publish_text(
|
||||
raw_text, user.first_name, user.username, is_anonymous
|
||||
)
|
||||
|
||||
@@ -838,7 +838,7 @@ class BanService:
|
||||
await self.db.set_user_blacklist(
|
||||
user_id=author_id,
|
||||
user_name=None,
|
||||
message_for_user="Спам",
|
||||
message_for_user="Последний пост",
|
||||
date_to_unban=date_to_unban,
|
||||
ban_author=ban_author_id,
|
||||
)
|
||||
|
||||
@@ -31,7 +31,13 @@ from helper_bot.utils.metrics import db_query_time, track_errors, track_time
|
||||
# Local imports - modular components
|
||||
from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES
|
||||
from .decorators import error_handler
|
||||
from .services import BotSettings, PostService, StickerService, UserService
|
||||
from .services import (
|
||||
AutoModerationService,
|
||||
BotSettings,
|
||||
PostService,
|
||||
StickerService,
|
||||
UserService,
|
||||
)
|
||||
|
||||
# Expose sleep for tests (tests patch helper_bot.handlers.private.private_handlers.sleep)
|
||||
sleep = asyncio.sleep
|
||||
@@ -50,7 +56,12 @@ class PrivateHandlers:
|
||||
self.db = db
|
||||
self.settings = settings
|
||||
self.user_service = UserService(db, settings)
|
||||
self.post_service = PostService(db, settings, s3_storage, scoring_manager)
|
||||
self.auto_moderation_service = AutoModerationService(
|
||||
db, settings, scoring_manager, s3_storage
|
||||
)
|
||||
self.post_service = PostService(
|
||||
db, settings, s3_storage, scoring_manager, self.auto_moderation_service
|
||||
)
|
||||
self.sticker_service = StickerService(settings)
|
||||
|
||||
self.router = Router()
|
||||
@@ -291,12 +302,33 @@ class PrivateHandlers:
|
||||
"""Handle messages in admin chat states"""
|
||||
# User service operations with metrics
|
||||
await self.user_service.update_user_activity(message.from_user.id)
|
||||
await message.forward(chat_id=self.settings.group_for_message)
|
||||
|
||||
# Формируем обогащённое сообщение для админов
|
||||
user_id = message.from_user.id
|
||||
full_name = message.from_user.full_name
|
||||
username = message.from_user.username
|
||||
message_text = message.text or ""
|
||||
|
||||
enriched_message = await self.user_service.format_user_message_for_admins(
|
||||
user_id=user_id,
|
||||
full_name=full_name,
|
||||
username=username,
|
||||
message_text=message_text,
|
||||
)
|
||||
|
||||
# Отправляем обогащённое сообщение вместо forward
|
||||
sent_message = await message.bot.send_message(
|
||||
chat_id=self.settings.group_for_message,
|
||||
text=enriched_message,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
current_date = datetime.now()
|
||||
date = int(current_date.timestamp())
|
||||
|
||||
# Сохраняем message_id из результата send_message
|
||||
await self.db.add_message(
|
||||
message.text, message.from_user.id, message.message_id + 1, date
|
||||
message.text, message.from_user.id, sent_message.message_id, date
|
||||
)
|
||||
|
||||
question = messages.get_message(get_first_name(message), "QUESTION")
|
||||
|
||||
@@ -22,6 +22,7 @@ from helper_bot.utils.helper_func import (
|
||||
check_username_and_full_name,
|
||||
determine_anonymity,
|
||||
get_first_name,
|
||||
get_publish_text,
|
||||
get_text_message,
|
||||
prepare_media_group_from_middlewares,
|
||||
send_audio_message,
|
||||
@@ -156,6 +157,96 @@ class UserService:
|
||||
username = message.from_user.username or "Без никнейма"
|
||||
return html.escape(full_name), html.escape(username)
|
||||
|
||||
async def format_user_message_for_admins(
|
||||
self, user_id: int, full_name: str, username: str, message_text: str
|
||||
) -> str:
|
||||
"""
|
||||
Форматирует сообщение пользователя для отправки админам с обогащёнными данными.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
full_name: Полное имя пользователя
|
||||
username: Username пользователя (может быть None)
|
||||
message_text: Текст сообщения пользователя
|
||||
|
||||
Returns:
|
||||
Отформатированное сообщение для админов
|
||||
"""
|
||||
safe_full_name = (
|
||||
html.escape(full_name) if full_name else "Неизвестный пользователь"
|
||||
)
|
||||
safe_username = html.escape(username) if username else None
|
||||
safe_message_text = html.escape(message_text) if message_text else ""
|
||||
|
||||
# Формируем строку с информацией об авторе
|
||||
if safe_username:
|
||||
author_info = f"{safe_full_name} (@{safe_username})"
|
||||
else:
|
||||
author_info = f"{safe_full_name} (Ник не указан)"
|
||||
|
||||
# Получаем статистику постов
|
||||
approved, declined, suggest = await self.db.get_user_posts_stats(user_id)
|
||||
total_posts = approved + declined + suggest
|
||||
|
||||
# Получаем последний пост
|
||||
last_post = await self.db.get_last_post_by_author(user_id)
|
||||
if last_post:
|
||||
if len(last_post) > 80:
|
||||
last_post_display = f'"{html.escape(last_post[:80])}..."'
|
||||
else:
|
||||
last_post_display = f'"{html.escape(last_post)}"'
|
||||
else:
|
||||
last_post_display = "Нет постов"
|
||||
|
||||
# Получаем дату регистрации
|
||||
user_info = await self.db.get_user_by_id(user_id)
|
||||
if user_info and user_info.date_added:
|
||||
date_added = datetime.fromtimestamp(user_info.date_added).strftime(
|
||||
"%d.%m.%Y"
|
||||
)
|
||||
else:
|
||||
date_added = "Неизвестно"
|
||||
|
||||
# Получаем информацию о банах
|
||||
ban_count = await self.db.get_user_ban_count(user_id)
|
||||
ban_section = ""
|
||||
if ban_count > 0:
|
||||
last_ban = await self.db.get_last_ban_info(user_id)
|
||||
if last_ban:
|
||||
date_ban, reason, date_unban = last_ban
|
||||
ban_date_str = datetime.fromtimestamp(date_ban).strftime("%d.%m.%Y")
|
||||
reason_display = html.escape(reason) if reason else "Не указана"
|
||||
|
||||
if date_unban:
|
||||
unban_date_str = datetime.fromtimestamp(date_unban).strftime(
|
||||
"%d.%m.%Y %H:%M"
|
||||
)
|
||||
last_ban_info = (
|
||||
f" Последний: {ban_date_str}, причина «{reason_display}», "
|
||||
f"истёк {unban_date_str}"
|
||||
)
|
||||
else:
|
||||
last_ban_info = (
|
||||
f" Последний: {ban_date_str}, причина «{reason_display}», "
|
||||
f"активен"
|
||||
)
|
||||
|
||||
ban_section = f"\n\n🚫 Банов: {ban_count}\n{last_ban_info}"
|
||||
|
||||
# Формируем итоговое сообщение
|
||||
formatted_message = (
|
||||
f"👤 От: {author_info} | ID: {user_id}\n\n"
|
||||
f"📊 Постов в базе: {total_posts}\n"
|
||||
f"📝 Последний пост: {last_post_display}\n"
|
||||
f"📅 В боте с: {date_added}"
|
||||
f"{ban_section}\n\n"
|
||||
f"---\n"
|
||||
f"<b>Сообщение пользователя:</b>\n\n"
|
||||
f"<b>{safe_message_text}</b>"
|
||||
)
|
||||
|
||||
return formatted_message
|
||||
|
||||
|
||||
class PostService:
|
||||
"""Service for post-related operations"""
|
||||
@@ -166,11 +257,13 @@ class PostService:
|
||||
settings: BotSettings,
|
||||
s3_storage=None,
|
||||
scoring_manager=None,
|
||||
auto_moderation_service: "AutoModerationService" = None,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.settings = settings
|
||||
self.s3_storage = s3_storage
|
||||
self.scoring_manager = scoring_manager
|
||||
self.auto_moderation = auto_moderation_service
|
||||
|
||||
async def _save_media_background(
|
||||
self, sent_message: types.Message, bot_db: Any, s3_storage
|
||||
@@ -236,6 +329,16 @@ class PostService:
|
||||
f"PostService: Ошибка сохранения скоров для {message_id}: {e}"
|
||||
)
|
||||
|
||||
async def _add_submitted_post_background(
|
||||
self, text: str, post_id: int, rag_score: float = None
|
||||
) -> None:
|
||||
"""Индексирует пост в RAG submitted collection в фоне."""
|
||||
try:
|
||||
if self.scoring_manager:
|
||||
await self.scoring_manager.add_submitted_post(text, post_id, rag_score)
|
||||
except Exception as e:
|
||||
logger.warning(f"PostService: Ошибка добавления поста в submitted: {e}")
|
||||
|
||||
async def _get_scores_with_error_handling(self, text: str) -> tuple:
|
||||
"""
|
||||
Получает скоры для текста поста с обработкой ошибок.
|
||||
@@ -281,6 +384,206 @@ class PostService:
|
||||
error_message = "Не удалось рассчитать скоры"
|
||||
return None, None, None, None, None, error_message
|
||||
|
||||
@track_time("_handle_auto_action", "post_service")
|
||||
@track_errors("post_service", "_handle_auto_action")
|
||||
async def _handle_auto_action(
|
||||
self,
|
||||
auto_action: str,
|
||||
message: types.Message,
|
||||
content_type: str,
|
||||
original_raw_text: str,
|
||||
first_name: str,
|
||||
is_anonymous: bool,
|
||||
rag_score: float,
|
||||
ml_scores_json: str = None,
|
||||
album: Union[list, None] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Обрабатывает автоматическое действие (публикация или отклонение).
|
||||
|
||||
Args:
|
||||
auto_action: 'publish' или 'decline'
|
||||
message: Сообщение пользователя
|
||||
content_type: Тип контента
|
||||
original_raw_text: Оригинальный текст поста
|
||||
first_name: Имя автора
|
||||
is_anonymous: Флаг анонимности
|
||||
rag_score: Скор RAG модели
|
||||
ml_scores_json: JSON со скорами для БД
|
||||
album: Медиагруппа (если есть)
|
||||
"""
|
||||
author_id = message.from_user.id
|
||||
author_name = message.from_user.full_name or first_name
|
||||
author_username = message.from_user.username or ""
|
||||
|
||||
try:
|
||||
if auto_action == "publish":
|
||||
await self._auto_publish(
|
||||
message=message,
|
||||
content_type=content_type,
|
||||
original_raw_text=original_raw_text,
|
||||
first_name=first_name,
|
||||
is_anonymous=is_anonymous,
|
||||
rag_score=rag_score,
|
||||
ml_scores_json=ml_scores_json,
|
||||
album=album,
|
||||
)
|
||||
else: # decline
|
||||
await self._auto_decline(message=message, author_id=author_id)
|
||||
|
||||
# Логируем действие
|
||||
if self.auto_moderation:
|
||||
await self.auto_moderation.log_auto_action(
|
||||
bot=message.bot,
|
||||
action=auto_action,
|
||||
author_id=author_id,
|
||||
author_name=author_name,
|
||||
author_username=author_username,
|
||||
rag_score=rag_score,
|
||||
post_text=original_raw_text,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"PostService: Ошибка авто-{auto_action} для message_id={message.message_id}: {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
@track_time("_auto_publish", "post_service")
|
||||
@track_errors("post_service", "_auto_publish")
|
||||
async def _auto_publish(
|
||||
self,
|
||||
message: types.Message,
|
||||
content_type: str,
|
||||
original_raw_text: str,
|
||||
first_name: str,
|
||||
is_anonymous: bool,
|
||||
rag_score: float,
|
||||
ml_scores_json: str = None,
|
||||
album: Union[list, None] = None,
|
||||
) -> None:
|
||||
"""Автоматически публикует пост в канал."""
|
||||
author_id = message.from_user.id
|
||||
username = message.from_user.username
|
||||
|
||||
# Формируем текст для публикации (без скоров и разметки)
|
||||
formatted_text = get_publish_text(
|
||||
original_raw_text, first_name, username, is_anonymous
|
||||
)
|
||||
|
||||
sent_message = None
|
||||
|
||||
# Публикуем в зависимости от типа контента
|
||||
if content_type == "text":
|
||||
sent_message = await message.bot.send_message(
|
||||
chat_id=self.settings.main_public,
|
||||
text=formatted_text,
|
||||
)
|
||||
elif content_type == "photo":
|
||||
sent_message = await message.bot.send_photo(
|
||||
chat_id=self.settings.main_public,
|
||||
photo=message.photo[-1].file_id,
|
||||
caption=formatted_text,
|
||||
)
|
||||
elif content_type == "video":
|
||||
sent_message = await message.bot.send_video(
|
||||
chat_id=self.settings.main_public,
|
||||
video=message.video.file_id,
|
||||
caption=formatted_text,
|
||||
)
|
||||
elif content_type == "audio":
|
||||
sent_message = await message.bot.send_audio(
|
||||
chat_id=self.settings.main_public,
|
||||
audio=message.audio.file_id,
|
||||
caption=formatted_text,
|
||||
)
|
||||
elif content_type == "voice":
|
||||
sent_message = await message.bot.send_voice(
|
||||
chat_id=self.settings.main_public,
|
||||
voice=message.voice.file_id,
|
||||
)
|
||||
elif content_type == "video_note":
|
||||
sent_message = await message.bot.send_video_note(
|
||||
chat_id=self.settings.main_public,
|
||||
video_note=message.video_note.file_id,
|
||||
)
|
||||
elif content_type == "media_group" and album:
|
||||
# TODO: Реализовать авто-публикацию медиагрупп при необходимости
|
||||
logger.warning(
|
||||
"PostService: Авто-публикация медиагрупп пока не поддерживается"
|
||||
)
|
||||
return
|
||||
|
||||
if sent_message:
|
||||
# Сохраняем пост в БД со статусом approved
|
||||
post = TelegramPost(
|
||||
message_id=sent_message.message_id,
|
||||
text=original_raw_text,
|
||||
author_id=author_id,
|
||||
created_at=int(datetime.now().timestamp()),
|
||||
is_anonymous=is_anonymous,
|
||||
status="approved",
|
||||
)
|
||||
await self.db.add_post(post)
|
||||
|
||||
# Сохраняем скоры если есть
|
||||
if ml_scores_json:
|
||||
asyncio.create_task(
|
||||
self._save_scores_background(
|
||||
sent_message.message_id, ml_scores_json
|
||||
)
|
||||
)
|
||||
|
||||
# Индексируем пост в RAG
|
||||
if self.scoring_manager and original_raw_text and original_raw_text.strip():
|
||||
asyncio.create_task(
|
||||
self._add_submitted_post_background(
|
||||
original_raw_text, sent_message.message_id, rag_score
|
||||
)
|
||||
)
|
||||
|
||||
# Уведомляем автора
|
||||
try:
|
||||
await message.bot.send_message(
|
||||
chat_id=author_id,
|
||||
text="Твой пост был выложен🥰",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"PostService: Не удалось уведомить автора {author_id}: {e}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"PostService: Пост авто-опубликован в {self.settings.main_public}, "
|
||||
f"author_id={author_id}, rag_score={rag_score:.2f}"
|
||||
)
|
||||
|
||||
@track_time("_auto_decline", "post_service")
|
||||
@track_errors("post_service", "_auto_decline")
|
||||
async def _auto_decline(self, message: types.Message, author_id: int) -> None:
|
||||
"""Автоматически отклоняет пост."""
|
||||
# Обучаем RAG на отклоненном посте
|
||||
if self.scoring_manager:
|
||||
original_text = message.text or message.caption or ""
|
||||
if original_text and original_text.strip():
|
||||
try:
|
||||
await self.scoring_manager.on_post_declined(original_text)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"PostService: Ошибка обучения RAG на отклоненном посте: {e}"
|
||||
)
|
||||
|
||||
# Уведомляем автора
|
||||
try:
|
||||
await message.bot.send_message(
|
||||
chat_id=author_id,
|
||||
text="Твой пост был отклонен😔",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"PostService: Не удалось уведомить автора {author_id}: {e}")
|
||||
|
||||
logger.info(f"PostService: Пост авто-отклонен, author_id={author_id}")
|
||||
|
||||
@track_time("_process_post_background", "post_service")
|
||||
@track_errors("post_service", "_process_post_background")
|
||||
async def _process_post_background(
|
||||
@@ -321,6 +624,37 @@ class PostService:
|
||||
error_message,
|
||||
) = await self._get_scores_with_error_handling(original_raw_text)
|
||||
|
||||
# Проверяем похожие посты (до добавления текущего в submitted)
|
||||
similar_warning = ""
|
||||
if self.scoring_manager and original_raw_text and original_raw_text.strip():
|
||||
try:
|
||||
similar_result = await self.scoring_manager.find_similar_posts(
|
||||
original_raw_text, threshold=0.9, hours=24
|
||||
)
|
||||
if similar_result and similar_result.similar_count > 0:
|
||||
# Формируем предупреждение с текстом похожего поста
|
||||
similar_text = ""
|
||||
if similar_result.similar_posts:
|
||||
first_similar = similar_result.similar_posts[0]
|
||||
if first_similar.text:
|
||||
truncated_text = first_similar.text[:150]
|
||||
if len(first_similar.text) > 150:
|
||||
truncated_text += "..."
|
||||
similar_text = f'\n<b>Текст поста:</b>\n"{html.escape(truncated_text)}"'
|
||||
|
||||
similar_warning = (
|
||||
f"\n\n⚠️ <b>Похожий пост за последние 24ч</b> "
|
||||
f"(совпадение {similar_result.max_similarity:.0%})"
|
||||
f"{similar_text}"
|
||||
)
|
||||
logger.info(
|
||||
f"PostService: Найден похожий пост для message_id={message.message_id}, "
|
||||
f"similar_count={similar_result.similar_count}, "
|
||||
f"max_similarity={similar_result.max_similarity:.2%}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"PostService: Ошибка поиска похожих постов: {e}")
|
||||
|
||||
# Формируем текст для поста (с сообщением об ошибке если есть)
|
||||
text_for_post = original_raw_text
|
||||
if error_message:
|
||||
@@ -347,13 +681,42 @@ class PostService:
|
||||
message.from_user.username,
|
||||
deepseek_score=deepseek_score,
|
||||
rag_score=rag_score,
|
||||
rag_confidence=rag_confidence,
|
||||
rag_score_pos_only=rag_score_pos_only,
|
||||
user_id=message.from_user.id,
|
||||
)
|
||||
# Добавляем предупреждение о похожем посте
|
||||
if similar_warning:
|
||||
post_text += similar_warning
|
||||
|
||||
# Определяем анонимность по исходному тексту (без сообщения об ошибке)
|
||||
is_anonymous = determine_anonymity(original_raw_text)
|
||||
|
||||
# Проверяем авто-модерацию
|
||||
logger.debug(
|
||||
f"PostService: Проверка авто-модерации - "
|
||||
f"auto_moderation={self.auto_moderation is not None}, "
|
||||
f"rag_score={rag_score}"
|
||||
)
|
||||
if self.auto_moderation and rag_score is not None:
|
||||
auto_action = await self.auto_moderation.check_auto_action(rag_score)
|
||||
logger.info(
|
||||
f"PostService: Авто-модерация решение - "
|
||||
f"rag_score={rag_score:.2f}, action={auto_action}"
|
||||
)
|
||||
|
||||
if auto_action in ("publish", "decline"):
|
||||
await self._handle_auto_action(
|
||||
auto_action=auto_action,
|
||||
message=message,
|
||||
content_type=content_type,
|
||||
original_raw_text=original_raw_text,
|
||||
first_name=first_name,
|
||||
is_anonymous=is_anonymous,
|
||||
rag_score=rag_score,
|
||||
ml_scores_json=ml_scores_json,
|
||||
album=album,
|
||||
)
|
||||
return
|
||||
|
||||
markup = get_reply_keyboard_for_post()
|
||||
sent_message = None
|
||||
|
||||
@@ -401,8 +764,11 @@ class PostService:
|
||||
markup,
|
||||
)
|
||||
elif content_type == "media_group":
|
||||
# Добавляем предупреждение о похожем посте в caption медиагруппы
|
||||
if similar_warning:
|
||||
post_text += similar_warning
|
||||
# Для медиагруппы используем специальную обработку
|
||||
# Передаем ml_scores_json для сохранения в БД
|
||||
# Передаем ml_scores_json и rag_score для сохранения в БД
|
||||
await self._process_media_group_background(
|
||||
message,
|
||||
album,
|
||||
@@ -411,6 +777,7 @@ class PostService:
|
||||
is_anonymous,
|
||||
original_raw_text,
|
||||
ml_scores_json,
|
||||
rag_score,
|
||||
)
|
||||
return
|
||||
else:
|
||||
@@ -448,6 +815,14 @@ class PostService:
|
||||
)
|
||||
)
|
||||
|
||||
# Индексируем пост в RAG submitted collection (после успешной отправки)
|
||||
if self.scoring_manager and original_raw_text and original_raw_text.strip():
|
||||
asyncio.create_task(
|
||||
self._add_submitted_post_background(
|
||||
original_raw_text, sent_message.message_id, rag_score
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"PostService: Критическая ошибка в _process_post_background для {content_type}: {e}"
|
||||
@@ -462,6 +837,7 @@ class PostService:
|
||||
is_anonymous: bool,
|
||||
original_raw_text: str,
|
||||
ml_scores_json: str = None,
|
||||
rag_score: float = None,
|
||||
) -> None:
|
||||
"""Обрабатывает медиагруппу в фоне"""
|
||||
try:
|
||||
@@ -495,6 +871,14 @@ class PostService:
|
||||
self._save_scores_background(main_post_id, ml_scores_json)
|
||||
)
|
||||
|
||||
# Индексируем пост в RAG submitted collection
|
||||
if self.scoring_manager and original_raw_text and original_raw_text.strip():
|
||||
asyncio.create_task(
|
||||
self._add_submitted_post_background(
|
||||
original_raw_text, main_post_id, rag_score
|
||||
)
|
||||
)
|
||||
|
||||
for msg_id in media_group_message_ids:
|
||||
await self.db.add_message_link(main_post_id, msg_id)
|
||||
|
||||
@@ -552,8 +936,7 @@ class PostService:
|
||||
message.from_user.username,
|
||||
deepseek_score=deepseek_score,
|
||||
rag_score=rag_score,
|
||||
rag_confidence=rag_confidence,
|
||||
rag_score_pos_only=rag_score_pos_only,
|
||||
user_id=message.from_user.id,
|
||||
)
|
||||
markup = get_reply_keyboard_for_post()
|
||||
|
||||
@@ -611,8 +994,7 @@ class PostService:
|
||||
message.from_user.username,
|
||||
deepseek_score=deepseek_score,
|
||||
rag_score=rag_score,
|
||||
rag_confidence=rag_confidence,
|
||||
rag_score_pos_only=rag_score_pos_only,
|
||||
user_id=message.from_user.id,
|
||||
)
|
||||
|
||||
markup = get_reply_keyboard_for_post()
|
||||
@@ -677,8 +1059,7 @@ class PostService:
|
||||
message.from_user.username,
|
||||
deepseek_score=deepseek_score,
|
||||
rag_score=rag_score,
|
||||
rag_confidence=rag_confidence,
|
||||
rag_score_pos_only=rag_score_pos_only,
|
||||
user_id=message.from_user.id,
|
||||
)
|
||||
|
||||
markup = get_reply_keyboard_for_post()
|
||||
@@ -770,8 +1151,7 @@ class PostService:
|
||||
message.from_user.username,
|
||||
deepseek_score=deepseek_score,
|
||||
rag_score=rag_score,
|
||||
rag_confidence=rag_confidence,
|
||||
rag_score_pos_only=rag_score_pos_only,
|
||||
user_id=message.from_user.id,
|
||||
)
|
||||
|
||||
markup = get_reply_keyboard_for_post()
|
||||
@@ -869,8 +1249,7 @@ class PostService:
|
||||
message.from_user.username,
|
||||
deepseek_score=deepseek_score,
|
||||
rag_score=rag_score,
|
||||
rag_confidence=rag_confidence,
|
||||
rag_score_pos_only=rag_score_pos_only,
|
||||
user_id=message.from_user.id,
|
||||
)
|
||||
|
||||
is_anonymous = determine_anonymity(raw_caption)
|
||||
@@ -981,3 +1360,126 @@ class StickerService:
|
||||
random_stick_bye = random.choice(name_stick_bye)
|
||||
random_stick_bye = FSInputFile(path=random_stick_bye)
|
||||
await message.answer_sticker(random_stick_bye)
|
||||
|
||||
|
||||
class AutoModerationService:
|
||||
"""
|
||||
Сервис автоматической модерации постов на основе RAG score.
|
||||
|
||||
Автоматически публикует посты с высоким скором и отклоняет с низким.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: DatabaseProtocol,
|
||||
settings: BotSettings,
|
||||
scoring_manager=None,
|
||||
s3_storage=None,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.settings = settings
|
||||
self.scoring_manager = scoring_manager
|
||||
self.s3_storage = s3_storage
|
||||
|
||||
@track_time("check_auto_action", "auto_moderation_service")
|
||||
async def check_auto_action(self, rag_score: float) -> str:
|
||||
"""
|
||||
Проверяет, требуется ли автоматическое действие.
|
||||
|
||||
Args:
|
||||
rag_score: Скор от RAG модели (0.0 - 1.0)
|
||||
|
||||
Returns:
|
||||
'publish' - автопубликация
|
||||
'decline' - автоотклонение
|
||||
'manual' - ручная модерация
|
||||
"""
|
||||
if rag_score is None:
|
||||
return "manual"
|
||||
|
||||
settings = await self.db.get_auto_moderation_settings()
|
||||
|
||||
auto_publish_enabled = settings.get("auto_publish_enabled", False)
|
||||
auto_decline_enabled = settings.get("auto_decline_enabled", False)
|
||||
auto_publish_threshold = settings.get("auto_publish_threshold", 0.8)
|
||||
auto_decline_threshold = settings.get("auto_decline_threshold", 0.4)
|
||||
|
||||
logger.info(
|
||||
f"AutoModeration: Настройки из БД - "
|
||||
f"publish_enabled={auto_publish_enabled}, decline_enabled={auto_decline_enabled}, "
|
||||
f"publish_threshold={auto_publish_threshold}, decline_threshold={auto_decline_threshold}, "
|
||||
f"rag_score={rag_score:.2f}"
|
||||
)
|
||||
|
||||
if auto_publish_enabled and rag_score >= auto_publish_threshold:
|
||||
logger.info(
|
||||
f"AutoModeration: score {rag_score:.2f} >= {auto_publish_threshold} → auto_publish"
|
||||
)
|
||||
return "publish"
|
||||
|
||||
if auto_decline_enabled and rag_score <= auto_decline_threshold:
|
||||
logger.info(
|
||||
f"AutoModeration: score {rag_score:.2f} <= {auto_decline_threshold} → auto_decline"
|
||||
)
|
||||
return "decline"
|
||||
|
||||
return "manual"
|
||||
|
||||
@track_time("log_auto_action", "auto_moderation_service")
|
||||
async def log_auto_action(
|
||||
self,
|
||||
bot,
|
||||
action: str,
|
||||
author_id: int,
|
||||
author_name: str,
|
||||
author_username: str,
|
||||
rag_score: float,
|
||||
post_text: str,
|
||||
) -> None:
|
||||
"""
|
||||
Отправляет лог автоматического действия в IMPORTANT_LOGS.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота для отправки сообщений
|
||||
action: Тип действия ('publish' или 'decline')
|
||||
author_id: ID автора поста
|
||||
author_name: Имя автора
|
||||
author_username: Username автора
|
||||
rag_score: Скор модели
|
||||
post_text: Текст поста
|
||||
"""
|
||||
try:
|
||||
safe_name = html.escape(author_name or "Без имени")
|
||||
safe_username = html.escape(author_username or "нет")
|
||||
|
||||
truncated_text = post_text[:200] if post_text else ""
|
||||
if len(post_text or "") > 200:
|
||||
truncated_text += "..."
|
||||
safe_text = html.escape(truncated_text)
|
||||
|
||||
if action == "publish":
|
||||
emoji = "🤖"
|
||||
action_title = "АВТО-ПУБЛИКАЦИЯ"
|
||||
action_result = "✅ Пост автоматически опубликован"
|
||||
else:
|
||||
emoji = "🚫"
|
||||
action_title = "АВТО-ОТКЛОНЕНИЕ"
|
||||
action_result = "❌ Пост автоматически отклонён"
|
||||
|
||||
message_text = (
|
||||
f"{emoji} <b>{action_title}</b>\n\n"
|
||||
f"👤 <b>Автор:</b> {safe_name} (@{safe_username}) | ID: {author_id}\n"
|
||||
f"📊 <b>RAG Score:</b> {rag_score:.2f}\n\n"
|
||||
f"📝 <b>Текст поста:</b>\n"
|
||||
f'"{safe_text}"\n\n'
|
||||
f"{action_result}"
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=self.settings.important_logs,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
logger.info(f"AutoModeration: Лог отправлен в IMPORTANT_LOGS ({action})")
|
||||
except Exception as e:
|
||||
logger.error(f"AutoModeration: Ошибка отправки лога: {e}")
|
||||
|
||||
@@ -46,11 +46,64 @@ def get_reply_keyboard_admin():
|
||||
types.KeyboardButton(text="Разбан (список)"),
|
||||
types.KeyboardButton(text="📊 ML Статистика"),
|
||||
)
|
||||
builder.row(types.KeyboardButton(text="⚙️ Авто-модерация"))
|
||||
builder.row(types.KeyboardButton(text="Вернуться в бота"))
|
||||
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
|
||||
return markup
|
||||
|
||||
|
||||
def get_auto_moderation_keyboard(settings: dict) -> types.InlineKeyboardMarkup:
|
||||
"""
|
||||
Создает inline клавиатуру для управления авто-модерацией.
|
||||
|
||||
Args:
|
||||
settings: Словарь с текущими настройками авто-модерации
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup с кнопками управления
|
||||
"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
auto_publish = settings.get("auto_publish_enabled", False)
|
||||
auto_decline = settings.get("auto_decline_enabled", False)
|
||||
publish_threshold = settings.get("auto_publish_threshold", 0.8)
|
||||
decline_threshold = settings.get("auto_decline_threshold", 0.4)
|
||||
|
||||
publish_status = "✅" if auto_publish else "❌"
|
||||
decline_status = "✅" if auto_decline else "❌"
|
||||
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{publish_status} Авто-публикация (≥{publish_threshold})",
|
||||
callback_data="auto_mod_toggle_publish",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{decline_status} Авто-отклонение (≤{decline_threshold})",
|
||||
callback_data="auto_mod_toggle_decline",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="📈 Изменить порог публикации",
|
||||
callback_data="auto_mod_threshold_publish",
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="📉 Изменить порог отклонения",
|
||||
callback_data="auto_mod_threshold_decline",
|
||||
),
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Обновить",
|
||||
callback_data="auto_mod_refresh",
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@track_time("create_keyboard_with_pagination", "keyboard_service")
|
||||
@track_errors("keyboard_service", "create_keyboard_with_pagination")
|
||||
def create_keyboard_with_pagination(
|
||||
|
||||
@@ -4,7 +4,8 @@ HTTP клиент для взаимодействия с внешним RAG се
|
||||
Использует REST API для получения скоров и отправки примеров.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -15,6 +16,30 @@ from .base import ScoringResult
|
||||
from .exceptions import InsufficientExamplesError, ScoringError, TextTooShortError
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimilarPost:
|
||||
"""Данные о похожем посте."""
|
||||
|
||||
similarity: float
|
||||
created_at: int
|
||||
post_id: Optional[int]
|
||||
text: str
|
||||
rag_score: Optional[float]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimilarPostsResult:
|
||||
"""Результат поиска похожих постов."""
|
||||
|
||||
similar_count: int
|
||||
similar_posts: List[SimilarPost]
|
||||
max_similarity: float = 0.0
|
||||
|
||||
def __post_init__(self):
|
||||
if self.similar_posts:
|
||||
self.max_similarity = max(p.similarity for p in self.similar_posts)
|
||||
|
||||
|
||||
class RagApiClient:
|
||||
"""
|
||||
HTTP клиент для взаимодействия с внешним RAG сервисом.
|
||||
@@ -329,21 +354,39 @@ class RagApiClient:
|
||||
Словарь со статистикой или пустой словарь при ошибке
|
||||
"""
|
||||
if not self._enabled:
|
||||
logger.debug("RagApiClient: get_stats пропущен - клиент отключен")
|
||||
return {}
|
||||
|
||||
try:
|
||||
logger.debug(f"RagApiClient: Запрос статистики от {self.api_url}/stats")
|
||||
response = await self._client.get(f"{self.api_url}/stats")
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
data = response.json()
|
||||
logger.info(
|
||||
f"RagApiClient: Статистика получена успешно: "
|
||||
f"model_loaded={data.get('model_loaded')}, "
|
||||
f"model_name={data.get('model_name')}, "
|
||||
f"vector_store={data.get('vector_store', {}).get('total_count', 'N/A')} примеров"
|
||||
)
|
||||
return data
|
||||
elif response.status_code == 401 or response.status_code == 403:
|
||||
logger.warning(
|
||||
f"RagApiClient: Ошибка авторизации при получении статистики: "
|
||||
f"status={response.status_code}, body={response.text[:200]}"
|
||||
)
|
||||
return {}
|
||||
else:
|
||||
logger.warning(
|
||||
f"RagApiClient: Неожиданный статус при получении статистики: {response.status_code}"
|
||||
f"RagApiClient: Неожиданный статус при получении статистики: "
|
||||
f"status={response.status_code}, body={response.text[:200]}"
|
||||
)
|
||||
return {}
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(f"RagApiClient: Таймаут при получении статистики")
|
||||
logger.warning(
|
||||
f"RagApiClient: Таймаут при получении статистики (timeout={self.timeout}s)"
|
||||
)
|
||||
return {}
|
||||
except httpx.RequestError as e:
|
||||
logger.warning(
|
||||
@@ -365,3 +408,138 @@ class RagApiClient:
|
||||
"api_url": self.api_url,
|
||||
"timeout": self.timeout,
|
||||
}
|
||||
|
||||
@track_time("find_similar_posts", "rag_client")
|
||||
async def find_similar_posts(
|
||||
self, text: str, threshold: float = 0.9, hours: int = 24
|
||||
) -> Optional[SimilarPostsResult]:
|
||||
"""
|
||||
Ищет похожие посты за последние N часов.
|
||||
|
||||
Args:
|
||||
text: Текст поста для поиска похожих
|
||||
threshold: Порог схожести (0.0-1.0), по умолчанию 0.9
|
||||
hours: За сколько часов искать (1-168), по умолчанию 24
|
||||
|
||||
Returns:
|
||||
SimilarPostsResult с информацией о похожих постах или None при ошибке
|
||||
"""
|
||||
if not self._enabled:
|
||||
return None
|
||||
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
|
||||
try:
|
||||
response = await self._client.post(
|
||||
f"{self.api_url}/similar",
|
||||
json={"text": text.strip(), "threshold": threshold, "hours": hours},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
similar_posts = []
|
||||
|
||||
for post_data in data.get("similar_posts", []):
|
||||
similar_posts.append(
|
||||
SimilarPost(
|
||||
similarity=float(post_data.get("similarity", 0.0)),
|
||||
created_at=int(post_data.get("created_at", 0)),
|
||||
post_id=post_data.get("post_id"),
|
||||
text=post_data.get("text", ""),
|
||||
rag_score=post_data.get("rag_score"),
|
||||
)
|
||||
)
|
||||
|
||||
result = SimilarPostsResult(
|
||||
similar_count=data.get("similar_count", 0),
|
||||
similar_posts=similar_posts,
|
||||
)
|
||||
|
||||
if result.similar_count > 0:
|
||||
logger.info(
|
||||
f"RagApiClient: Найдено {result.similar_count} похожих постов "
|
||||
f"(max_similarity={result.max_similarity:.2%})"
|
||||
)
|
||||
|
||||
return result
|
||||
else:
|
||||
logger.warning(
|
||||
f"RagApiClient: Неожиданный статус при поиске похожих постов: "
|
||||
f"{response.status_code}, body: {response.text}"
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("RagApiClient: Таймаут при поиске похожих постов")
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
logger.warning(
|
||||
f"RagApiClient: Ошибка подключения при поиске похожих постов: {e}"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"RagApiClient: Ошибка поиска похожих постов: {e}")
|
||||
return None
|
||||
|
||||
@track_time("add_submitted_post", "rag_client")
|
||||
async def add_submitted_post(
|
||||
self,
|
||||
text: str,
|
||||
post_id: Optional[int] = None,
|
||||
rag_score: Optional[float] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Добавляет пост в коллекцию submitted для поиска похожих.
|
||||
|
||||
Args:
|
||||
text: Текст поста
|
||||
post_id: ID поста (опционально)
|
||||
rag_score: RAG скор на момент добавления (опционально)
|
||||
|
||||
Returns:
|
||||
True если пост успешно добавлен
|
||||
"""
|
||||
if not self._enabled:
|
||||
return False
|
||||
|
||||
if not text or not text.strip():
|
||||
return False
|
||||
|
||||
try:
|
||||
payload = {"text": text.strip()}
|
||||
if post_id is not None:
|
||||
payload["post_id"] = post_id
|
||||
if rag_score is not None:
|
||||
payload["rag_score"] = rag_score
|
||||
|
||||
response = await self._client.post(
|
||||
f"{self.api_url}/submitted",
|
||||
json=payload,
|
||||
)
|
||||
|
||||
if response.status_code in (200, 201):
|
||||
data = response.json()
|
||||
logger.debug(
|
||||
f"RagApiClient: Пост добавлен в submitted "
|
||||
f"(post_id={post_id}, submitted_count={data.get('submitted_count', 'N/A')})"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f"RagApiClient: Неожиданный статус при добавлении в submitted: "
|
||||
f"{response.status_code}"
|
||||
)
|
||||
return False
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("RagApiClient: Таймаут при добавлении в submitted")
|
||||
return False
|
||||
except httpx.RequestError as e:
|
||||
logger.warning(
|
||||
f"RagApiClient: Ошибка подключения при добавлении в submitted: {e}"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"RagApiClient: Ошибка добавления в submitted: {e}")
|
||||
return False
|
||||
|
||||
@@ -221,3 +221,46 @@ class ScoringManager:
|
||||
stats["deepseek"] = self.deepseek_service.get_stats()
|
||||
|
||||
return stats
|
||||
|
||||
@track_time("find_similar_posts", "scoring_manager")
|
||||
async def find_similar_posts(
|
||||
self, text: str, threshold: float = 0.9, hours: int = 24
|
||||
):
|
||||
"""
|
||||
Ищет похожие посты через RAG API.
|
||||
|
||||
Args:
|
||||
text: Текст для поиска похожих
|
||||
threshold: Порог схожести (0.0-1.0)
|
||||
hours: За сколько часов искать
|
||||
|
||||
Returns:
|
||||
SimilarPostsResult или None
|
||||
"""
|
||||
if not self.rag_client or not self.rag_client.is_enabled:
|
||||
return None
|
||||
|
||||
return await self.rag_client.find_similar_posts(text, threshold, hours)
|
||||
|
||||
@track_time("add_submitted_post", "scoring_manager")
|
||||
async def add_submitted_post(
|
||||
self,
|
||||
text: str,
|
||||
post_id: Optional[int] = None,
|
||||
rag_score: Optional[float] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Добавляет пост в коллекцию submitted для поиска похожих.
|
||||
|
||||
Args:
|
||||
text: Текст поста
|
||||
post_id: ID поста (опционально)
|
||||
rag_score: RAG скор на момент добавления (опционально)
|
||||
|
||||
Returns:
|
||||
True если успешно добавлен
|
||||
"""
|
||||
if not self.rag_client or not self.rag_client.is_enabled:
|
||||
return False
|
||||
|
||||
return await self.rag_client.add_submitted_post(text, post_id, rag_score)
|
||||
|
||||
@@ -138,6 +138,52 @@ def determine_anonymity(post_text: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def get_publish_text(
|
||||
post_text: str,
|
||||
first_name: str,
|
||||
username: str = None,
|
||||
is_anonymous: Optional[bool] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Форматирует текст для финальной публикации в канал.
|
||||
Только текст поста + подпись автора или анон.
|
||||
|
||||
Args:
|
||||
post_text: Текст сообщения
|
||||
first_name: Имя автора поста
|
||||
username: Юзернейм автора поста (может быть None)
|
||||
is_anonymous: Флаг анонимности (True - анонимно, False - не анонимно, None - legacy)
|
||||
|
||||
Returns:
|
||||
str: Текст для публикации в канал
|
||||
"""
|
||||
safe_post_text = post_text or ""
|
||||
safe_first_name = first_name or "Пользователь"
|
||||
|
||||
# Формируем строку с информацией об авторе
|
||||
if username:
|
||||
author_info = f"{safe_first_name} @{username}"
|
||||
else:
|
||||
author_info = f"{safe_first_name}"
|
||||
|
||||
# Определяем анонимность и формируем финальный текст
|
||||
if is_anonymous is not None:
|
||||
if is_anonymous:
|
||||
final_text = f"{safe_post_text}\n\nПост опубликован анонимно"
|
||||
else:
|
||||
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}"
|
||||
else:
|
||||
# Legacy: определяем по тексту
|
||||
if "неанон" in post_text.lower() or "не анон" in post_text.lower():
|
||||
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}"
|
||||
elif "анон" in post_text.lower():
|
||||
final_text = f"{safe_post_text}\n\nПост опубликован анонимно"
|
||||
else:
|
||||
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}"
|
||||
|
||||
return final_text
|
||||
|
||||
|
||||
def get_text_message(
|
||||
post_text: str,
|
||||
first_name: str,
|
||||
@@ -147,10 +193,10 @@ def get_text_message(
|
||||
rag_score: Optional[float] = None,
|
||||
rag_confidence: Optional[float] = None,
|
||||
rag_score_pos_only: Optional[float] = None,
|
||||
user_id: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
Форматирует текст сообщения для публикации в зависимости от наличия ключевых слов "анон" и "неанон"
|
||||
или переданного параметра is_anonymous.
|
||||
Форматирует текст сообщения для модерации (с полной информацией об авторе и скорами).
|
||||
|
||||
Args:
|
||||
post_text: Текст сообщения
|
||||
@@ -161,64 +207,69 @@ def get_text_message(
|
||||
rag_score: Скор от RAG/ruBERT neg/pos (0.0-1.0, опционально)
|
||||
rag_confidence: Уверенность RAG модели (0.0-1.0, зависит от количества примеров)
|
||||
rag_score_pos_only: Скор RAG только по положительным примерам (0.0-1.0, опционально)
|
||||
user_id: ID пользователя Telegram (опционально)
|
||||
|
||||
Returns:
|
||||
str: - Сформированный текст сообщения.
|
||||
str: - Сформированный текст сообщения для модерации.
|
||||
"""
|
||||
# Экранируем post_text для безопасного использования в HTML
|
||||
safe_post_text = html.escape(str(post_text)) if post_text else ""
|
||||
|
||||
# Экранируем username для безопасного использования в HTML
|
||||
safe_username = html.escape(username) if username else None
|
||||
safe_first_name = html.escape(first_name) if first_name else "Пользователь"
|
||||
|
||||
# Формируем строку с информацией об авторе
|
||||
# Формируем шапку с информацией об авторе
|
||||
if safe_username:
|
||||
author_info = f"{first_name} @{safe_username}"
|
||||
header = f"👤 От: {safe_first_name} (@{safe_username})"
|
||||
else:
|
||||
author_info = f"{first_name} (Ник не указан)"
|
||||
header = f"👤 От: {safe_first_name} (Ник не указан)"
|
||||
|
||||
# Формируем базовый текст
|
||||
# Если передан is_anonymous, используем его, иначе определяем по тексту (legacy)
|
||||
if user_id:
|
||||
header += f" | ID: {user_id}"
|
||||
|
||||
# Формируем строку с информацией об авторе для подвала
|
||||
if safe_username:
|
||||
author_info = f"{safe_first_name} @{safe_username}"
|
||||
else:
|
||||
author_info = f"{safe_first_name} (Ник не указан)"
|
||||
|
||||
# Формируем блок с текстом поста
|
||||
separator = "=" * 32
|
||||
post_block = f"{header}\n<b>Текст поста:</b>\n{separator}\n{safe_post_text}"
|
||||
|
||||
# Определяем анонимность и формируем подвал
|
||||
if is_anonymous is not None:
|
||||
if is_anonymous:
|
||||
final_text = f"{safe_post_text}\n\nПост опубликован анонимно"
|
||||
post_block += f"\n\nПост опубликован анонимно"
|
||||
else:
|
||||
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}"
|
||||
post_block += f"\n\n<b>Автор поста:</b> {author_info}"
|
||||
else:
|
||||
# Legacy: определяем по тексту
|
||||
if "неанон" in post_text or "не анон" in post_text:
|
||||
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}"
|
||||
post_block += f"\n\n<b>Автор поста:</b> {author_info}"
|
||||
elif "анон" in post_text:
|
||||
final_text = f"{safe_post_text}\n\nПост опубликован анонимно"
|
||||
post_block += f"\n\nПост опубликован анонимно"
|
||||
else:
|
||||
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}"
|
||||
post_block += f"\n\n<b>Автор поста:</b> {author_info}"
|
||||
|
||||
# Добавляем блок со скорами если есть
|
||||
if (
|
||||
deepseek_score is not None
|
||||
or rag_score is not None
|
||||
or rag_score_pos_only is not None
|
||||
):
|
||||
scores_lines = ["\n📊 Уверенность в одобрении:"]
|
||||
post_block += f"\n{separator}"
|
||||
|
||||
# Добавляем блок со скорами если есть (без RAG pos only и уверенности)
|
||||
if deepseek_score is not None or rag_score is not None:
|
||||
scores_lines = ["📊 <b>Уверенность в одобрении:</b>"]
|
||||
if deepseek_score is not None:
|
||||
scores_lines.append(f"DeepSeek: {deepseek_score:.2f}")
|
||||
if rag_score is not None:
|
||||
logger.debug(
|
||||
f"get_text_message: Форматирование rag_score - "
|
||||
f"rag_score={rag_score} (type: {type(rag_score).__name__}), "
|
||||
f"rag_score_pos_only={rag_score_pos_only} (type: {type(rag_score_pos_only).__name__ if rag_score_pos_only is not None else 'None'}), "
|
||||
f"rag_confidence={rag_confidence} (type: {type(rag_confidence).__name__ if rag_confidence is not None else 'None'}), "
|
||||
f"formatted_value={rag_score:.2f}"
|
||||
)
|
||||
rag_line = f"RAG neg/pos: {rag_score:.2f}"
|
||||
if rag_confidence is not None:
|
||||
rag_line += f" (уверенность: {rag_confidence:.0%})"
|
||||
scores_lines.append(rag_line)
|
||||
if rag_score_pos_only is not None:
|
||||
scores_lines.append(f"RAG pos only: {rag_score_pos_only:.2f}")
|
||||
final_text += "\n" + "\n".join(scores_lines)
|
||||
scores_lines.append(f"RAG neg/pos: {rag_score:.2f}")
|
||||
post_block += "\n" + "\n".join(scores_lines)
|
||||
|
||||
return final_text
|
||||
return post_block
|
||||
|
||||
|
||||
@track_time("download_file", "helper_func")
|
||||
@@ -854,15 +905,14 @@ async def send_text_message(
|
||||
):
|
||||
from .rate_limiter import send_with_rate_limit
|
||||
|
||||
# Экранируем post_text для безопасного использования в HTML
|
||||
safe_post_text = html.escape(str(post_text)) if post_text else ""
|
||||
|
||||
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=post_text, parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
return await message.bot.send_message(
|
||||
chat_id=chat_id, text=safe_post_text, reply_markup=markup
|
||||
chat_id=chat_id, text=post_text, reply_markup=markup, parse_mode="HTML"
|
||||
)
|
||||
|
||||
sent_message = await send_with_rate_limit(_send_message, chat_id)
|
||||
@@ -878,16 +928,17 @@ async def send_photo_message(
|
||||
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=post_text, photo=photo, parse_mode="HTML"
|
||||
)
|
||||
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=post_text,
|
||||
photo=photo,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return sent_message
|
||||
|
||||
@@ -901,16 +952,17 @@ async def send_video_message(
|
||||
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=post_text, video=video, parse_mode="HTML"
|
||||
)
|
||||
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=post_text,
|
||||
video=video,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return sent_message
|
||||
|
||||
@@ -943,16 +995,17 @@ async def send_audio_message(
|
||||
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=post_text, audio=audio, parse_mode="HTML"
|
||||
)
|
||||
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=post_text,
|
||||
audio=audio,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return sent_message
|
||||
|
||||
@@ -1012,11 +1065,14 @@ async def get_banned_users_list(offset: int, bot_db):
|
||||
message - текст сообщения
|
||||
user_ids - лист кортежей [(user_name: user_id)]
|
||||
"""
|
||||
users = await bot_db.get_banned_users_from_db_with_limits(limit=7, offset=offset)
|
||||
items_per_page = 9
|
||||
users = await bot_db.get_banned_users_from_db_with_limits(
|
||||
limit=items_per_page, offset=offset
|
||||
)
|
||||
message = "Список заблокированных пользователей:\n"
|
||||
|
||||
for user in users:
|
||||
user_id, ban_reason, unban_date = user
|
||||
user_id, ban_reason, unban_date, ban_date = user
|
||||
# Получаем имя пользователя из таблицы users
|
||||
username = await bot_db.get_username(user_id)
|
||||
full_name = await bot_db.get_full_name_by_id(user_id)
|
||||
@@ -1028,41 +1084,42 @@ async def get_banned_users_list(offset: int, bot_db):
|
||||
html.escape(str(ban_reason)) if ban_reason else "Причина не указана"
|
||||
)
|
||||
|
||||
# Форматируем дату разбана в человекочитаемый формат
|
||||
if unban_date:
|
||||
try:
|
||||
# Предполагаем, что unban_date это UNIX timestamp
|
||||
if isinstance(unban_date, (int, float)):
|
||||
unban_datetime = datetime.fromtimestamp(unban_date)
|
||||
safe_unban_date = unban_datetime.strftime("%d-%m-%Y %H:%M")
|
||||
elif isinstance(unban_date, str):
|
||||
# Если это строка, попытаемся её обработать
|
||||
try:
|
||||
# Попробуем преобразовать строку в timestamp
|
||||
timestamp = int(unban_date)
|
||||
unban_datetime = datetime.fromtimestamp(timestamp)
|
||||
safe_unban_date = unban_datetime.strftime("%d-%m-%Y %H:%M")
|
||||
except (ValueError, TypeError):
|
||||
# Если не удалось, показываем как есть
|
||||
safe_unban_date = html.escape(str(unban_date))
|
||||
elif hasattr(unban_date, "strftime"):
|
||||
# Если это datetime объект
|
||||
safe_unban_date = unban_date.strftime("%d-%m-%Y %H:%M")
|
||||
else:
|
||||
# Для всех остальных случаев
|
||||
safe_unban_date = html.escape(str(unban_date))
|
||||
except (ValueError, TypeError, OSError):
|
||||
# В случае ошибки показываем исходное значение
|
||||
safe_unban_date = html.escape(str(unban_date))
|
||||
else:
|
||||
safe_unban_date = "Дата не указана"
|
||||
# Форматируем дату бана в человекочитаемый формат
|
||||
safe_ban_date = _format_timestamp_to_date(ban_date)
|
||||
|
||||
message += f"**Пользователь:** {safe_user_name}\n"
|
||||
message += f"**Причина бана:** {safe_ban_reason}\n"
|
||||
message += f"**Дата разбана:** {safe_unban_date}\n\n"
|
||||
# Форматируем дату разбана в человекочитаемый формат
|
||||
safe_unban_date = _format_timestamp_to_date(unban_date, default="Навсегда")
|
||||
|
||||
message += f"<b>Пользователь:</b> {safe_user_name}\n"
|
||||
message += f"<b>Причина бана:</b> {safe_ban_reason}\n"
|
||||
message += f"<b>Дата бана:</b> {safe_ban_date}\n"
|
||||
message += f"<b>Дата разбана:</b> {safe_unban_date}\n\n"
|
||||
return message
|
||||
|
||||
|
||||
def _format_timestamp_to_date(timestamp, default: str = "Дата не указана") -> str:
|
||||
"""Форматирует timestamp в читаемую дату."""
|
||||
if not timestamp:
|
||||
return default
|
||||
try:
|
||||
if isinstance(timestamp, (int, float)):
|
||||
dt = datetime.fromtimestamp(timestamp)
|
||||
return dt.strftime("%d.%m.%Y %H:%M")
|
||||
elif isinstance(timestamp, str):
|
||||
try:
|
||||
ts = int(timestamp)
|
||||
dt = datetime.fromtimestamp(ts)
|
||||
return dt.strftime("%d.%m.%Y %H:%M")
|
||||
except (ValueError, TypeError):
|
||||
return html.escape(str(timestamp))
|
||||
elif hasattr(timestamp, "strftime"):
|
||||
return timestamp.strftime("%d.%m.%Y %H:%M")
|
||||
else:
|
||||
return html.escape(str(timestamp))
|
||||
except (ValueError, TypeError, OSError):
|
||||
return html.escape(str(timestamp))
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
115
scripts/create_bot_settings_table.py
Normal file
115
scripts/create_bot_settings_table.py
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Миграция: Создание таблицы bot_settings для хранения настроек бота.
|
||||
|
||||
Создает таблицу с ключ-значение для хранения:
|
||||
- auto_publish_enabled: Включена ли авто-публикация (default: false)
|
||||
- auto_decline_enabled: Включено ли авто-отклонение (default: false)
|
||||
- auto_publish_threshold: Порог для авто-публикации (default: 0.8)
|
||||
- auto_decline_threshold: Порог для авто-отклонения (default: 0.4)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
import aiosqlite
|
||||
|
||||
try:
|
||||
from logs.custom_logger import logger
|
||||
except ImportError:
|
||||
import logging
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DB_PATH = "database/tg-bot-database.db"
|
||||
|
||||
DEFAULT_SETTINGS = [
|
||||
("auto_publish_enabled", "false"),
|
||||
("auto_decline_enabled", "false"),
|
||||
("auto_publish_threshold", "0.8"),
|
||||
("auto_decline_threshold", "0.4"),
|
||||
]
|
||||
|
||||
|
||||
async def table_exists(conn: aiosqlite.Connection, table_name: str) -> bool:
|
||||
"""Проверяет существование таблицы."""
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||||
(table_name,),
|
||||
)
|
||||
result = await cursor.fetchone()
|
||||
return result is not None
|
||||
|
||||
|
||||
async def main(db_path: str) -> None:
|
||||
"""
|
||||
Основная функция миграции.
|
||||
|
||||
Создает таблицу bot_settings и добавляет дефолтные настройки.
|
||||
Миграция идемпотентна - можно запускать повторно без ошибок.
|
||||
"""
|
||||
db_path = os.path.abspath(db_path)
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
logger.error(f"База данных не найдена: {db_path}")
|
||||
return
|
||||
|
||||
async with aiosqlite.connect(db_path) as conn:
|
||||
await conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
if not await table_exists(conn, "bot_settings"):
|
||||
await conn.execute("""
|
||||
CREATE TABLE bot_settings (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
)
|
||||
""")
|
||||
logger.info("Таблица bot_settings создана")
|
||||
|
||||
for key, value in DEFAULT_SETTINGS:
|
||||
await conn.execute(
|
||||
"INSERT INTO bot_settings (key, value) VALUES (?, ?)",
|
||||
(key, value),
|
||||
)
|
||||
logger.info(f"Добавлена настройка: {key} = {value}")
|
||||
else:
|
||||
logger.info("Таблица bot_settings уже существует")
|
||||
|
||||
for key, value in DEFAULT_SETTINGS:
|
||||
cursor = await conn.execute(
|
||||
"SELECT COUNT(*) FROM bot_settings WHERE key = ?", (key,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row[0] == 0:
|
||||
await conn.execute(
|
||||
"INSERT INTO bot_settings (key, value) VALUES (?, ?)",
|
||||
(key, value),
|
||||
)
|
||||
logger.info(f"Добавлена отсутствующая настройка: {key} = {value}")
|
||||
|
||||
await conn.commit()
|
||||
logger.info("Миграция create_bot_settings_table завершена успешно")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Создание таблицы bot_settings для настроек авто-модерации"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.environ.get("DATABASE_PATH", DEFAULT_DB_PATH),
|
||||
help="Путь к БД",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.db))
|
||||
219
tests/test_auto_moderation_service.py
Normal file
219
tests/test_auto_moderation_service.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""Тесты для AutoModerationService."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from helper_bot.handlers.private.services import AutoModerationService, BotSettings
|
||||
|
||||
|
||||
class TestAutoModerationService:
|
||||
"""Тесты для сервиса авто-модерации."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db(self):
|
||||
"""Создает мок базы данных."""
|
||||
db = MagicMock()
|
||||
db.get_auto_moderation_settings = AsyncMock()
|
||||
return db
|
||||
|
||||
@pytest.fixture
|
||||
def settings(self):
|
||||
"""Создает настройки бота."""
|
||||
return BotSettings(
|
||||
group_for_posts="-123",
|
||||
group_for_message="-456",
|
||||
main_public="@test_channel",
|
||||
group_for_logs="-789",
|
||||
important_logs="-999",
|
||||
preview_link="false",
|
||||
logs="false",
|
||||
test="false",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def service(self, mock_db, settings):
|
||||
"""Создает экземпляр сервиса."""
|
||||
return AutoModerationService(mock_db, settings)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_auto_action_returns_manual_when_score_is_none(
|
||||
self, service, mock_db
|
||||
):
|
||||
"""Тест: возвращает manual когда score равен None."""
|
||||
result = await service.check_auto_action(None)
|
||||
assert result == "manual"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_auto_action_returns_publish_when_score_above_threshold(
|
||||
self, service, mock_db
|
||||
):
|
||||
"""Тест: возвращает publish когда score выше порога."""
|
||||
mock_db.get_auto_moderation_settings.return_value = {
|
||||
"auto_publish_enabled": True,
|
||||
"auto_decline_enabled": False,
|
||||
"auto_publish_threshold": 0.8,
|
||||
"auto_decline_threshold": 0.4,
|
||||
}
|
||||
|
||||
result = await service.check_auto_action(0.9)
|
||||
|
||||
assert result == "publish"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_auto_action_returns_decline_when_score_below_threshold(
|
||||
self, service, mock_db
|
||||
):
|
||||
"""Тест: возвращает decline когда score ниже порога."""
|
||||
mock_db.get_auto_moderation_settings.return_value = {
|
||||
"auto_publish_enabled": False,
|
||||
"auto_decline_enabled": True,
|
||||
"auto_publish_threshold": 0.8,
|
||||
"auto_decline_threshold": 0.4,
|
||||
}
|
||||
|
||||
result = await service.check_auto_action(0.3)
|
||||
|
||||
assert result == "decline"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_auto_action_returns_manual_when_disabled(
|
||||
self, service, mock_db
|
||||
):
|
||||
"""Тест: возвращает manual когда авто-действия отключены."""
|
||||
mock_db.get_auto_moderation_settings.return_value = {
|
||||
"auto_publish_enabled": False,
|
||||
"auto_decline_enabled": False,
|
||||
"auto_publish_threshold": 0.8,
|
||||
"auto_decline_threshold": 0.4,
|
||||
}
|
||||
|
||||
result = await service.check_auto_action(0.9)
|
||||
|
||||
assert result == "manual"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_auto_action_returns_manual_when_score_in_middle(
|
||||
self, service, mock_db
|
||||
):
|
||||
"""Тест: возвращает manual когда score между порогами."""
|
||||
mock_db.get_auto_moderation_settings.return_value = {
|
||||
"auto_publish_enabled": True,
|
||||
"auto_decline_enabled": True,
|
||||
"auto_publish_threshold": 0.8,
|
||||
"auto_decline_threshold": 0.4,
|
||||
}
|
||||
|
||||
result = await service.check_auto_action(0.6)
|
||||
|
||||
assert result == "manual"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_auto_action_publish_at_exact_threshold(self, service, mock_db):
|
||||
"""Тест: возвращает publish когда score равен порогу."""
|
||||
mock_db.get_auto_moderation_settings.return_value = {
|
||||
"auto_publish_enabled": True,
|
||||
"auto_decline_enabled": False,
|
||||
"auto_publish_threshold": 0.8,
|
||||
"auto_decline_threshold": 0.4,
|
||||
}
|
||||
|
||||
result = await service.check_auto_action(0.8)
|
||||
|
||||
assert result == "publish"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_auto_action_decline_at_exact_threshold(self, service, mock_db):
|
||||
"""Тест: возвращает decline когда score равен порогу."""
|
||||
mock_db.get_auto_moderation_settings.return_value = {
|
||||
"auto_publish_enabled": False,
|
||||
"auto_decline_enabled": True,
|
||||
"auto_publish_threshold": 0.8,
|
||||
"auto_decline_threshold": 0.4,
|
||||
}
|
||||
|
||||
result = await service.check_auto_action(0.4)
|
||||
|
||||
assert result == "decline"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_auto_action_publish(self, service, settings):
|
||||
"""Тест отправки лога для авто-публикации."""
|
||||
mock_bot = MagicMock()
|
||||
mock_bot.send_message = AsyncMock()
|
||||
|
||||
await service.log_auto_action(
|
||||
bot=mock_bot,
|
||||
action="publish",
|
||||
author_id=12345,
|
||||
author_name="Test User",
|
||||
author_username="testuser",
|
||||
rag_score=0.85,
|
||||
post_text="Test post text",
|
||||
)
|
||||
|
||||
mock_bot.send_message.assert_called_once()
|
||||
call_kwargs = mock_bot.send_message.call_args[1]
|
||||
assert call_kwargs["chat_id"] == settings.important_logs
|
||||
assert "АВТО-ПУБЛИКАЦИЯ" in call_kwargs["text"]
|
||||
assert "Test User" in call_kwargs["text"]
|
||||
assert "0.85" in call_kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_auto_action_decline(self, service, settings):
|
||||
"""Тест отправки лога для авто-отклонения."""
|
||||
mock_bot = MagicMock()
|
||||
mock_bot.send_message = AsyncMock()
|
||||
|
||||
await service.log_auto_action(
|
||||
bot=mock_bot,
|
||||
action="decline",
|
||||
author_id=12345,
|
||||
author_name="Test User",
|
||||
author_username="testuser",
|
||||
rag_score=0.25,
|
||||
post_text="Test post text",
|
||||
)
|
||||
|
||||
mock_bot.send_message.assert_called_once()
|
||||
call_kwargs = mock_bot.send_message.call_args[1]
|
||||
assert "АВТО-ОТКЛОНЕНИЕ" in call_kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_auto_action_handles_exception(self, service):
|
||||
"""Тест обработки исключения при отправке лога."""
|
||||
mock_bot = MagicMock()
|
||||
mock_bot.send_message = AsyncMock(side_effect=Exception("Network error"))
|
||||
|
||||
# Не должно выбрасывать исключение
|
||||
await service.log_auto_action(
|
||||
bot=mock_bot,
|
||||
action="publish",
|
||||
author_id=12345,
|
||||
author_name="Test User",
|
||||
author_username="testuser",
|
||||
rag_score=0.85,
|
||||
post_text="Test post text",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_auto_action_truncates_long_text(self, service):
|
||||
"""Тест обрезки длинного текста в логе."""
|
||||
mock_bot = MagicMock()
|
||||
mock_bot.send_message = AsyncMock()
|
||||
|
||||
long_text = "a" * 300
|
||||
|
||||
await service.log_auto_action(
|
||||
bot=mock_bot,
|
||||
action="publish",
|
||||
author_id=12345,
|
||||
author_name="Test User",
|
||||
author_username="testuser",
|
||||
rag_score=0.85,
|
||||
post_text=long_text,
|
||||
)
|
||||
|
||||
call_kwargs = mock_bot.send_message.call_args[1]
|
||||
# Текст должен быть обрезан до 200 символов + "..."
|
||||
assert "..." in call_kwargs["text"]
|
||||
@@ -274,9 +274,9 @@ class TestBlacklistRepository:
|
||||
|
||||
# Нормализуем SQL запрос (убираем лишние пробелы и переносы строк)
|
||||
actual_query = " ".join(call_args[0][0].split())
|
||||
expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist LIMIT ?, ?"
|
||||
expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
assert actual_query == expected_query
|
||||
assert call_args[0][1] == (0, 10)
|
||||
assert call_args[0][1] == (10, 0)
|
||||
|
||||
# Проверяем логирование
|
||||
blacklist_repository.logger.info.assert_called_once_with(
|
||||
@@ -310,7 +310,7 @@ class TestBlacklistRepository:
|
||||
|
||||
# Нормализуем SQL запрос (убираем лишние пробелы и переносы строк)
|
||||
actual_query = " ".join(call_args[0][0].split())
|
||||
expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist"
|
||||
expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist ORDER BY created_at DESC"
|
||||
assert actual_query == expected_query
|
||||
# Проверяем, что параметры пустые (без лимитов)
|
||||
assert len(call_args[0]) == 1 # Только SQL запрос, без параметров
|
||||
|
||||
171
tests/test_bot_settings_repository.py
Normal file
171
tests/test_bot_settings_repository.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Тесты для BotSettingsRepository."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from database.repositories.bot_settings_repository import BotSettingsRepository
|
||||
|
||||
|
||||
class TestBotSettingsRepository:
|
||||
"""Тесты для репозитория настроек бота."""
|
||||
|
||||
@pytest.fixture
|
||||
def repository(self):
|
||||
"""Создает экземпляр репозитория с замоканным путем к БД."""
|
||||
return BotSettingsRepository("test.db")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_setting_returns_value(self, repository):
|
||||
"""Тест получения настройки по ключу."""
|
||||
with patch.object(
|
||||
repository, "_execute_query_with_result", new_callable=AsyncMock
|
||||
) as mock_query:
|
||||
mock_query.return_value = [("true",)]
|
||||
|
||||
result = await repository.get_setting("auto_publish_enabled")
|
||||
|
||||
assert result == "true"
|
||||
mock_query.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_setting_returns_none_when_not_found(self, repository):
|
||||
"""Тест получения несуществующей настройки."""
|
||||
with patch.object(
|
||||
repository, "_execute_query_with_result", new_callable=AsyncMock
|
||||
) as mock_query:
|
||||
mock_query.return_value = []
|
||||
|
||||
result = await repository.get_setting("nonexistent_key")
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_setting(self, repository):
|
||||
"""Тест установки настройки."""
|
||||
with patch.object(
|
||||
repository, "_execute_query", new_callable=AsyncMock
|
||||
) as mock_query:
|
||||
await repository.set_setting("auto_publish_enabled", "true")
|
||||
|
||||
mock_query.assert_called_once()
|
||||
call_args = mock_query.call_args[0]
|
||||
assert "auto_publish_enabled" in str(call_args)
|
||||
assert "true" in str(call_args)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_bool_setting_true(self, repository):
|
||||
"""Тест получения булевой настройки со значением true."""
|
||||
with patch.object(
|
||||
repository, "get_setting", new_callable=AsyncMock
|
||||
) as mock_get:
|
||||
mock_get.return_value = "true"
|
||||
|
||||
result = await repository.get_bool_setting("auto_publish_enabled")
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_bool_setting_false(self, repository):
|
||||
"""Тест получения булевой настройки со значением false."""
|
||||
with patch.object(
|
||||
repository, "get_setting", new_callable=AsyncMock
|
||||
) as mock_get:
|
||||
mock_get.return_value = "false"
|
||||
|
||||
result = await repository.get_bool_setting("auto_publish_enabled")
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_bool_setting_default(self, repository):
|
||||
"""Тест получения булевой настройки с дефолтным значением."""
|
||||
with patch.object(
|
||||
repository, "get_setting", new_callable=AsyncMock
|
||||
) as mock_get:
|
||||
mock_get.return_value = None
|
||||
|
||||
result = await repository.get_bool_setting("auto_publish_enabled", True)
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_float_setting(self, repository):
|
||||
"""Тест получения числовой настройки."""
|
||||
with patch.object(
|
||||
repository, "get_setting", new_callable=AsyncMock
|
||||
) as mock_get:
|
||||
mock_get.return_value = "0.8"
|
||||
|
||||
result = await repository.get_float_setting("auto_publish_threshold")
|
||||
|
||||
assert result == 0.8
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_float_setting_invalid_value(self, repository):
|
||||
"""Тест получения числовой настройки с некорректным значением."""
|
||||
with patch.object(
|
||||
repository, "get_setting", new_callable=AsyncMock
|
||||
) as mock_get:
|
||||
mock_get.return_value = "invalid"
|
||||
|
||||
result = await repository.get_float_setting("auto_publish_threshold", 0.5)
|
||||
|
||||
assert result == 0.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_auto_moderation_settings(self, repository):
|
||||
"""Тест получения всех настроек авто-модерации."""
|
||||
with (
|
||||
patch.object(
|
||||
repository, "get_bool_setting", new_callable=AsyncMock
|
||||
) as mock_bool,
|
||||
patch.object(
|
||||
repository, "get_float_setting", new_callable=AsyncMock
|
||||
) as mock_float,
|
||||
):
|
||||
mock_bool.side_effect = [True, False]
|
||||
mock_float.side_effect = [0.8, 0.4]
|
||||
|
||||
result = await repository.get_auto_moderation_settings()
|
||||
|
||||
assert result["auto_publish_enabled"] is True
|
||||
assert result["auto_decline_enabled"] is False
|
||||
assert result["auto_publish_threshold"] == 0.8
|
||||
assert result["auto_decline_threshold"] == 0.4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_auto_publish(self, repository):
|
||||
"""Тест переключения авто-публикации."""
|
||||
with (
|
||||
patch.object(
|
||||
repository, "get_bool_setting", new_callable=AsyncMock
|
||||
) as mock_get,
|
||||
patch.object(
|
||||
repository, "set_bool_setting", new_callable=AsyncMock
|
||||
) as mock_set,
|
||||
):
|
||||
mock_get.return_value = False
|
||||
|
||||
result = await repository.toggle_auto_publish()
|
||||
|
||||
assert result is True
|
||||
mock_set.assert_called_once_with("auto_publish_enabled", True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_auto_decline(self, repository):
|
||||
"""Тест переключения авто-отклонения."""
|
||||
with (
|
||||
patch.object(
|
||||
repository, "get_bool_setting", new_callable=AsyncMock
|
||||
) as mock_get,
|
||||
patch.object(
|
||||
repository, "set_bool_setting", new_callable=AsyncMock
|
||||
) as mock_set,
|
||||
):
|
||||
mock_get.return_value = True
|
||||
|
||||
result = await repository.toggle_auto_decline()
|
||||
|
||||
assert result is False
|
||||
mock_set.assert_called_once_with("auto_decline_enabled", False)
|
||||
@@ -85,7 +85,7 @@ class TestPostPublishService:
|
||||
return call
|
||||
|
||||
@patch("helper_bot.handlers.callback.services.send_text_message")
|
||||
@patch("helper_bot.handlers.callback.services.get_text_message")
|
||||
@patch("helper_bot.handlers.callback.services.get_publish_text")
|
||||
async def test_publish_post_text_success(
|
||||
self, mock_get_text, mock_send_text, service, mock_call_text, mock_db
|
||||
):
|
||||
@@ -214,7 +214,7 @@ class TestPostPublishService:
|
||||
|
||||
@patch("helper_bot.handlers.callback.services.send_photo_message")
|
||||
@patch("helper_bot.handlers.callback.services.send_text_message")
|
||||
@patch("helper_bot.handlers.callback.services.get_text_message")
|
||||
@patch("helper_bot.handlers.callback.services.get_publish_text")
|
||||
async def test_publish_post_photo_success(
|
||||
self, mock_get_text, mock_send_text, mock_send_photo, service, mock_db
|
||||
):
|
||||
@@ -239,7 +239,7 @@ class TestPostPublishService:
|
||||
|
||||
@patch("helper_bot.handlers.callback.services.send_video_message")
|
||||
@patch("helper_bot.handlers.callback.services.send_text_message")
|
||||
@patch("helper_bot.handlers.callback.services.get_text_message")
|
||||
@patch("helper_bot.handlers.callback.services.get_publish_text")
|
||||
async def test_publish_post_video_success(
|
||||
self, mock_get_text, mock_send_text, mock_send_video, service, mock_db
|
||||
):
|
||||
@@ -285,7 +285,7 @@ class TestPostPublishService:
|
||||
|
||||
@patch("helper_bot.handlers.callback.services.send_audio_message")
|
||||
@patch("helper_bot.handlers.callback.services.send_text_message")
|
||||
@patch("helper_bot.handlers.callback.services.get_text_message")
|
||||
@patch("helper_bot.handlers.callback.services.get_publish_text")
|
||||
async def test_publish_post_audio_success(
|
||||
self, mock_get_text, mock_send_text, mock_send_audio, service, mock_db
|
||||
):
|
||||
@@ -499,7 +499,7 @@ class TestPostPublishService:
|
||||
|
||||
@patch("helper_bot.handlers.callback.services.send_media_group_to_channel")
|
||||
@patch("helper_bot.handlers.callback.services.send_text_message")
|
||||
@patch("helper_bot.handlers.callback.services.get_text_message")
|
||||
@patch("helper_bot.handlers.callback.services.get_publish_text")
|
||||
async def test_publish_media_group_success(
|
||||
self, mock_get_text, mock_send_text, mock_send_media, service, mock_db
|
||||
):
|
||||
|
||||
@@ -115,7 +115,7 @@ class TestKeyboards:
|
||||
|
||||
assert isinstance(keyboard, ReplyKeyboardMarkup)
|
||||
assert keyboard.keyboard is not None
|
||||
assert len(keyboard.keyboard) == 3 # Три строки
|
||||
assert len(keyboard.keyboard) == 4 # Четыре строки
|
||||
|
||||
# Проверяем первую строку (3 кнопки)
|
||||
first_row = keyboard.keyboard[0]
|
||||
@@ -130,10 +130,15 @@ class TestKeyboards:
|
||||
assert second_row[0].text == "Разбан (список)"
|
||||
assert second_row[1].text == "📊 ML Статистика"
|
||||
|
||||
# Проверяем третью строку (1 кнопка)
|
||||
# Проверяем третью строку (1 кнопка - авто-модерация)
|
||||
third_row = keyboard.keyboard[2]
|
||||
assert len(third_row) == 1
|
||||
assert third_row[0].text == "Вернуться в бота"
|
||||
assert third_row[0].text == "⚙️ Авто-модерация"
|
||||
|
||||
# Проверяем четвертую строку (1 кнопка)
|
||||
fourth_row = keyboard.keyboard[3]
|
||||
assert len(fourth_row) == 1
|
||||
assert fourth_row[0].text == "Вернуться в бота"
|
||||
|
||||
def test_get_reply_keyboard_for_post(self):
|
||||
"""Тест клавиатуры для постов"""
|
||||
|
||||
@@ -29,6 +29,11 @@ class TestPrivateHandlers:
|
||||
db.add_message = AsyncMock()
|
||||
db.update_helper_message = AsyncMock()
|
||||
db.update_user_activity = AsyncMock()
|
||||
db.get_user_posts_stats = AsyncMock(return_value=(5, 2, 3))
|
||||
db.get_last_post_by_author = AsyncMock(return_value="Last post text")
|
||||
db.get_user_by_id = AsyncMock(return_value=Mock(date_added=1704067200))
|
||||
db.get_user_ban_count = AsyncMock(return_value=0)
|
||||
db.get_last_ban_info = AsyncMock(return_value=None)
|
||||
return db
|
||||
|
||||
@pytest.fixture
|
||||
@@ -257,6 +262,7 @@ class TestPrivateHandlers:
|
||||
"""resend_message_in_group при PRE_CHAT переводит в START и отправляет question."""
|
||||
handlers = create_private_handlers(mock_db, mock_settings)
|
||||
mock_state.get_state = AsyncMock(return_value=FSM_STATES["PRE_CHAT"])
|
||||
mock_message.bot.send_message = AsyncMock(return_value=Mock(message_id=100))
|
||||
with pytest.MonkeyPatch().context() as m:
|
||||
m.setattr(
|
||||
"helper_bot.handlers.private.private_handlers.get_reply_keyboard",
|
||||
@@ -267,9 +273,7 @@ class TestPrivateHandlers:
|
||||
lambda x, y: "Question?",
|
||||
)
|
||||
await handlers.resend_message_in_group_for_message(mock_message, mock_state)
|
||||
mock_message.forward.assert_called_once_with(
|
||||
chat_id=mock_settings.group_for_message
|
||||
)
|
||||
mock_message.bot.send_message.assert_called_once()
|
||||
mock_state.set_state.assert_called_once_with(FSM_STATES["START"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -279,6 +283,7 @@ class TestPrivateHandlers:
|
||||
"""resend_message_in_group при CHAT оставляет в CHAT и отправляет question с leave markup."""
|
||||
handlers = create_private_handlers(mock_db, mock_settings)
|
||||
mock_state.get_state = AsyncMock(return_value=FSM_STATES["CHAT"])
|
||||
mock_message.bot.send_message = AsyncMock(return_value=Mock(message_id=100))
|
||||
with pytest.MonkeyPatch().context() as m:
|
||||
m.setattr(
|
||||
"helper_bot.handlers.private.private_handlers.get_reply_keyboard_leave_chat",
|
||||
@@ -289,9 +294,7 @@ class TestPrivateHandlers:
|
||||
lambda x, y: "Question?",
|
||||
)
|
||||
await handlers.resend_message_in_group_for_message(mock_message, mock_state)
|
||||
mock_message.forward.assert_called_once_with(
|
||||
chat_id=mock_settings.group_for_message
|
||||
)
|
||||
mock_message.bot.send_message.assert_called_once()
|
||||
mock_message.answer.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -665,7 +665,7 @@ class TestSendMessageFunctions:
|
||||
|
||||
assert result == mock_sent_message
|
||||
mock_message.bot.send_photo.assert_called_once_with(
|
||||
chat_id=123, caption="Подпись к фото", photo="photo.jpg"
|
||||
chat_id=123, caption="Подпись к фото", photo="photo.jpg", parse_mode="HTML"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -684,7 +684,7 @@ class TestSendMessageFunctions:
|
||||
|
||||
assert result == mock_sent_message
|
||||
mock_message.bot.send_video.assert_called_once_with(
|
||||
chat_id=123, caption="Подпись к видео", video="video.mp4"
|
||||
chat_id=123, caption="Подпись к видео", video="video.mp4", parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@@ -722,8 +722,9 @@ class TestUtilityFunctions:
|
||||
"""Тест получения списка заблокированных пользователей"""
|
||||
mock_db = AsyncMock()
|
||||
mock_db.get_banned_users_from_db_with_limits.return_value = [
|
||||
(123, "Spam", 1704067200), # user_id, ban_reason, unban_date (timestamp)
|
||||
(456, "Violation", 1704153600),
|
||||
# user_id, ban_reason, unban_date (timestamp), ban_date (timestamp)
|
||||
(123, "Spam", 1704067200, 1703980800),
|
||||
(456, "Violation", 1704153600, 1704067200),
|
||||
]
|
||||
mock_db.get_username.return_value = None
|
||||
mock_db.get_full_name_by_id.return_value = "Test User"
|
||||
@@ -734,18 +735,16 @@ class TestUtilityFunctions:
|
||||
assert "Test User" in result
|
||||
assert "Spam" in result
|
||||
assert "Violation" in result
|
||||
assert "<b>Дата бана:</b>" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_banned_users_list_with_string_timestamp(self):
|
||||
"""Тест получения списка заблокированных пользователей со строковым timestamp"""
|
||||
mock_db = AsyncMock()
|
||||
mock_db.get_banned_users_from_db_with_limits.return_value = [
|
||||
(
|
||||
123,
|
||||
"Spam",
|
||||
"1704067200",
|
||||
), # user_id, ban_reason, unban_date (string timestamp)
|
||||
(456, "Violation", "1704153600"),
|
||||
# user_id, ban_reason, unban_date (string timestamp), ban_date (string timestamp)
|
||||
(123, "Spam", "1704067200", "1703980800"),
|
||||
(456, "Violation", "1704153600", "1704067200"),
|
||||
]
|
||||
mock_db.get_username.return_value = None
|
||||
mock_db.get_full_name_by_id.return_value = "Test User"
|
||||
@@ -756,6 +755,7 @@ class TestUtilityFunctions:
|
||||
assert "Test User" in result
|
||||
assert "Spam" in result
|
||||
assert "Violation" in result
|
||||
assert "<b>Дата бана:</b>" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_banned_users_buttons(self):
|
||||
|
||||
Reference in New Issue
Block a user