Enhance bot functionality and refactor database interactions

- Added `ca-certificates` installation to Dockerfile for improved network security.
- Updated health check command in Dockerfile to include better timeout handling.
- Refactored `run_helper.py` to implement proper signal handling and logging during shutdown.
- Transitioned database operations to an asynchronous model in `async_db.py`, improving performance and responsiveness.
- Updated database schema to support new foreign key relationships and optimized indexing for better query performance.
- Enhanced various bot handlers to utilize async database methods, improving overall efficiency and user experience.
- Removed obsolete database and fix scripts to streamline the project structure.
This commit is contained in:
2025-09-02 18:22:02 +03:00
parent 013892dcb7
commit 1c6a37bc12
59 changed files with 5682 additions and 4204 deletions

View File

@@ -34,14 +34,13 @@ class AutoUnbanScheduler:
try:
logger.info("Запуск автоматического разбана пользователей")
# Получаем сегодняшнюю дату в формате YYYY-MM-DD
moscow_tz = timezone(timedelta(hours=3)) # UTC+3 для Москвы
today = datetime.now(moscow_tz).strftime("%Y-%m-%d")
# Получаем текущий UNIX timestamp
current_timestamp = int(datetime.now().timestamp())
logger.info(f"Поиск пользователей для разблокировки на дату: {today}")
logger.info(f"Поиск пользователей для разблокировки на timestamp: {current_timestamp}")
# Получаем список пользователей для разблокировки
users_to_unban = self.bot_db.get_users_for_unblock_today(today)
users_to_unban = await self.bot_db.get_users_for_unblock_today(current_timestamp)
if not users_to_unban:
logger.info("Нет пользователей для разблокировки сегодня")
@@ -55,20 +54,20 @@ class AutoUnbanScheduler:
failed_users = []
# Разблокируем каждого пользователя
for user_id, username in users_to_unban.items():
for user_id in users_to_unban:
try:
result = self.bot_db.delete_user_blacklist(user_id)
result = await self.bot_db.delete_user_blacklist(user_id)
if result:
success_count += 1
logger.info(f"Пользователь {user_id} ({username}) успешно разблокирован")
logger.info(f"Пользователь {user_id} успешно разблокирован")
else:
failed_count += 1
failed_users.append(f"{user_id} ({username})")
logger.error(f"Ошибка при разблокировке пользователя {user_id} ({username})")
failed_users.append(f"{user_id}")
logger.error(f"Ошибка при разблокировке пользователя {user_id}")
except Exception as e:
failed_count += 1
failed_users.append(f"{user_id} ({username})")
logger.error(f"Исключение при разблокировке пользователя {user_id} ({username}): {e}")
failed_users.append(f"{user_id}")
logger.error(f"Исключение при разблокировке пользователя {user_id}: {e}")
# Формируем отчет
report = self._generate_report(success_count, failed_count, failed_users, users_to_unban)
@@ -93,10 +92,9 @@ class AutoUnbanScheduler:
if success_count > 0:
report += "✅ <b>Разблокированные пользователи:</b>\n"
for user_id, username in all_users.items():
if f"{user_id} ({username})" not in failed_users:
safe_username = username if username else "Неизвестный пользователь"
report += f"• ID: {user_id}, Имя: {safe_username}\n"
for user_id in all_users:
if str(user_id) not in failed_users:
report += f"• ID: {user_id}\n"
report += "\n"
if failed_users:

View File

@@ -2,7 +2,7 @@ import os
import sys
from dotenv import load_dotenv
from database.db import BotDB
from database.async_db import AsyncBotDB
class BaseDependencyFactory:
@@ -18,10 +18,7 @@ class BaseDependencyFactory:
if not os.path.isabs(database_path):
database_path = os.path.join(project_dir, database_path)
database_dir = project_dir
database_name = database_path.replace(project_dir + '/', '')
self.database = BotDB(database_dir, database_name)
self.database = AsyncBotDB(database_path)
self._load_settings_from_env()
@@ -60,7 +57,7 @@ class BaseDependencyFactory:
def get_settings(self):
return self.settings
def get_db(self) -> BotDB:
def get_db(self) -> AsyncBotDB:
"""Возвращает подключение к базе данных."""
return self.database

View File

@@ -3,17 +3,21 @@ import os
import random
from datetime import datetime, timedelta
from time import sleep
from typing import List, Dict, Any, Optional
from typing import List, Dict, Any, Optional, TYPE_CHECKING
try:
import emoji as _emoji_lib
except Exception:
_emoji_lib_available = True
except ImportError:
_emoji_lib = None
_emoji_lib_available = False
from aiogram import types
from aiogram.types import InputMediaPhoto, FSInputFile, InputMediaVideo, InputMediaAudio
from helper_bot.utils.base_dependency_factory import BaseDependencyFactory, get_global_instance
from logs.custom_logger import logger
from database.models import TelegramPost
# Local imports - metrics
from .metrics import (
@@ -24,10 +28,11 @@ from .metrics import (
)
bdf = get_global_instance()
#TODO: поменять архитектуру и подключить правильный BotDB
BotDB = bdf.get_db()
GROUP_FOR_LOGS = bdf.settings['Telegram']['group_for_logs']
if _emoji_lib is not None:
if _emoji_lib_available and _emoji_lib is not None:
emoji_list = list(_emoji_lib.EMOJI_DATA.keys())
else:
# Fallback minimal emoji set for environments without the 'emoji' package (e.g., CI tests)
@@ -189,25 +194,25 @@ async def prepare_media_group_from_middlewares(album, post_caption: str = ''):
async def add_in_db_media_mediagroup(sent_message, bot_db):
"""
Идентификатор медиа-группы
Добавляет контент медиа-группы в базу данных
Args:
sent_message: sent_message объект из Telegram API
bot_db: Экземпляр базы данных
Returns:
Список InputFile (FSInputFile).
None
"""
media_group_message_id = sent_message[-1].message_id # Получаем идентификатор медиа-группы
post_id = sent_message[-1].message_id # ID поста (первое сообщение в медиа-группе)
for i, message in enumerate(sent_message):
if message.photo:
file_id = message.photo[-1].file_id
file_path = await download_file(message, file_id=file_id)
bot_db.add_post_content_in_db(media_group_message_id, message.message_id, file_path, 'photo')
await bot_db.add_post_content(post_id, message.message_id, file_path, 'photo')
elif message.video:
file_id = message.video.file_id
file_path = await download_file(message, file_id=file_id)
bot_db.add_post_content_in_db(media_group_message_id, message.message_id, file_path, 'video')
await bot_db.add_post_content(post_id, message.message_id, file_path, 'video')
else:
# Если нет фото, видео или аудио, или другой контент, пропускаем сообщение
continue
@@ -215,33 +220,36 @@ async def add_in_db_media_mediagroup(sent_message, bot_db):
async def add_in_db_media(sent_message, bot_db):
"""
Добавляет контент одиночного сообщения в базу данных
Args:
sent_message: sent_message объект из Telegram API
bot_db: Экземпляр базы данных
Returns:
Список InputFile (FSInputFile).
None
"""
post_id = sent_message.message_id # ID поста (это же сообщение)
if sent_message.photo:
file_id = sent_message.photo[-1].file_id
file_path = await download_file(sent_message, file_id=file_id)
bot_db.add_post_content_in_db(sent_message.message_id, sent_message.message_id, file_path, 'photo')
await bot_db.add_post_content(post_id, sent_message.message_id, file_path, 'photo')
elif sent_message.video:
file_id = sent_message.video.file_id
file_path = await download_file(sent_message, file_id=file_id)
bot_db.add_post_content_in_db(sent_message.message_id, sent_message.message_id, file_path, 'video')
await bot_db.add_post_content(post_id, sent_message.message_id, file_path, 'video')
elif sent_message.voice:
file_id = sent_message.voice.file_id
file_path = await download_file(sent_message, file_id=file_id)
bot_db.add_post_content_in_db(sent_message.message_id, sent_message.message_id, file_path, 'voice')
await bot_db.add_post_content(post_id, sent_message.message_id, file_path, 'voice')
elif sent_message.audio:
file_id = sent_message.audio.file_id
file_path = await download_file(sent_message, file_id=file_id)
bot_db.add_post_content_in_db(sent_message.message_id, sent_message.message_id, file_path, 'audio')
await bot_db.add_post_content(post_id, sent_message.message_id, file_path, 'audio')
elif sent_message.video_note:
file_id = sent_message.video_note.file_id
file_path = await download_file(sent_message, file_id=file_id)
bot_db.add_post_content_in_db(sent_message.message_id, sent_message.message_id, file_path, 'video_note')
await bot_db.add_post_content(post_id, sent_message.message_id, file_path, 'video_note')
async def send_media_group_message_to_private_chat(chat_id: int, message: types.Message,
@@ -250,7 +258,13 @@ async def send_media_group_message_to_private_chat(chat_id: int, message: types.
chat_id=chat_id,
media=media_group,
)
bot_db.add_post_in_db(sent_message[-1].message_id, sent_message[-1].caption, message.from_user.id)
post = TelegramPost(
message_id=sent_message[-1].message_id,
text=sent_message[-1].caption or "",
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp())
)
await bot_db.add_post(post)
await add_in_db_media_mediagroup(sent_message, bot_db)
message_id = sent_message[-1].message_id
return message_id
@@ -404,20 +418,22 @@ async def send_voice_message(chat_id, message: types.Message, voice: str,
return sent_message
def check_access(user_id: int, bot_db):
async def check_access(user_id: int, bot_db):
"""Проверка прав на совершение действий"""
return bot_db.is_admin(user_id)
from logs.custom_logger import logger
result = await bot_db.is_admin(user_id)
logger.info(f"check_access: пользователь {user_id} - результат: {result}")
return result
def add_days_to_date(days: int):
"""Прибавляет указанное количество дней к текущей дате и возвращает дату в формате DD-MM-YYYY."""
"""Прибавляет указанное количество дней к текущей дате и возвращает UNIX timestamp."""
current_date = datetime.now()
future_date = current_date + timedelta(days=days)
formatted_date = future_date.strftime("%d-%m-%Y")
return formatted_date
return int(future_date.timestamp())
def get_banned_users_list(offset: int, bot_db):
async def get_banned_users_list(offset: int, bot_db):
"""
Возвращает сообщение со списком пользователей и словарь с ником + идентификатором
@@ -429,22 +445,43 @@ def get_banned_users_list(offset: int, bot_db):
message - текст сообщения
user_ids - лист кортежей [(user_name: user_id)]
"""
users = bot_db.get_banned_users_from_db_with_limits(limit=7, offset=offset)
users = await bot_db.get_banned_users_from_db_with_limits(limit=7, offset=offset)
message = "Список заблокированных пользователей:\n"
for user in users:
# Экранируем пользовательские данные для безопасного использования
safe_user_name = html.escape(str(user[0])) if user[0] else "Неизвестный пользователь"
safe_ban_reason = html.escape(str(user[2])) if user[2] else "Причина не указана"
safe_unban_date = html.escape(str(user[3])) if user[3] else "Дата не указана"
user_id, ban_reason, unban_date = user
# Получаем имя пользователя из таблицы users
username = await bot_db.get_username(user_id)
full_name = await bot_db.get_full_name_by_id(user_id)
safe_user_name = username or full_name or f"User_{user_id}"
message += f"Пользователь: {safe_user_name}\n"
message += f"Причина бана: {safe_ban_reason}\n"
message += f"Дата разбана: {safe_unban_date}\n\n"
# Экранируем пользовательские данные для безопасного использования
safe_user_name = html.escape(str(safe_user_name))
safe_ban_reason = 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")
else:
# Если это уже datetime объект
safe_unban_date = unban_date.strftime("%d-%m-%Y %H:%M")
except (ValueError, TypeError, OSError):
# В случае ошибки показываем исходное значение
safe_unban_date = html.escape(str(unban_date))
else:
safe_unban_date = "Дата не указана"
message += f"**Пользователь:** {safe_user_name}\n"
message += f"**Причина бана:** {safe_ban_reason}\n"
message += f"**Дата разбана:** {safe_unban_date}\n\n"
return message
def get_banned_users_buttons(bot_db):
async def get_banned_users_buttons(bot_db):
"""
Возвращает сообщение со списком пользователей и словарь с ником + идентификатором
@@ -455,42 +492,58 @@ def get_banned_users_buttons(bot_db):
message - текст сообщения
user_ids - лист кортежей [(user_name: user_id)]
"""
users = bot_db.get_banned_users_from_db()
users = await bot_db.get_banned_users_from_db()
user_ids = []
for user in users:
user_id, ban_reason, unban_date = user
# Получаем имя пользователя из таблицы users
username = await bot_db.get_username(user_id)
full_name = await bot_db.get_full_name_by_id(user_id)
safe_user_name = username or full_name or f"User_{user_id}"
# Экранируем user_name для безопасного использования
safe_user_name = html.escape(str(user[0])) if user[0] else "Неизвестный пользователь"
user_ids.append((safe_user_name, user[1]))
safe_user_name = html.escape(str(safe_user_name))
user_ids.append((safe_user_name, user_id))
return user_ids
def delete_user_blacklist(user_id: int, bot_db):
return bot_db.delete_user_blacklist(user_id=user_id)
async def delete_user_blacklist(user_id: int, bot_db):
return await bot_db.delete_user_blacklist(user_id=user_id)
@track_time("check_username_and_full_name", "helper_func")
@track_errors("helper_func", "check_username_and_full_name")
@db_query_time("get_username_and_full_name", "users", "select")
def check_username_and_full_name(user_id: int, username: str, full_name: str, bot_db):
username_db, full_name_db = bot_db.get_username_and_full_name(user_id=user_id)
return username != username_db or full_name != full_name_db
@db_query_time("check_username_and_full_name", "users", "select")
async def check_username_and_full_name(user_id: int, username: str, full_name: str, bot_db):
"""Проверяет, изменились ли username или full_name пользователя"""
try:
username_db = await bot_db.get_username(user_id)
full_name_db = await bot_db.get_full_name_by_id(user_id)
return username != username_db or full_name != full_name_db
except Exception as e:
logger.error(f"Ошибка при проверке username и full_name: {e}")
return False
def unban_notifier(self):
# Получение сегодняшней даты в формате DD-MM-YYYY
async def unban_notifier(bot, BotDB, GROUP_FOR_MESSAGE):
# Получение текущего UNIX timestamp
current_date = datetime.now()
today = current_date.strftime("%d-%m-%Y")
current_timestamp = int(current_date.timestamp())
# Получение списка разблокированных пользователей
unblocked_users = self.BotDB.get_users_for_unblock_today(today)
unblocked_users = await BotDB.get_users_for_unblock_today(current_timestamp)
message = "Разблокированные пользователи:\n"
for user_id, user_name in unblocked_users.items():
for user_id in unblocked_users:
# Получаем имя пользователя из таблицы users
username = await BotDB.get_username(user_id)
full_name = await BotDB.get_full_name_by_id(user_id)
user_name = username or full_name or f"User_{user_id}"
# Экранируем user_name для безопасного использования
safe_user_name = html.escape(str(user_name)) if user_name else "Неизвестный пользователь"
safe_user_name = html.escape(str(user_name))
message += f"ID: {user_id}, Имя: {safe_user_name}\n"
# Отправка сообщения в канал
self.bot.send_message(self.GROUP_FOR_MESSAGE, message)
await bot.send_message(GROUP_FOR_MESSAGE, message)
@track_time("update_user_info", "helper_func")
@@ -503,51 +556,65 @@ async def update_user_info(source: str, message: types.Message):
is_bot = message.from_user.is_bot
language_code = message.from_user.language_code
user_id = message.from_user.id
current_date = datetime.now()
date = current_date.strftime("%Y-%m-%d %H:%M:%S")
# Выбираем эмодзю, пробегаемся циклом и смотрим что в базе такого еще не было
user_emoji = get_random_emoji()
if not BotDB.user_exists(user_id):
BotDB.add_new_user_in_db(user_id, first_name, full_name, username, is_bot, language_code, user_emoji, date,
date)
metrics.record_db_query("add_new_user_in_db", 0.0, "users", "insert")
user_emoji = await get_random_emoji()
if not await BotDB.user_exists(user_id):
# Create User object with current timestamp
from database.models import User
current_timestamp = int(datetime.now().timestamp())
user = User(
user_id=user_id,
first_name=first_name,
full_name=full_name,
username=username,
is_bot=is_bot,
language_code=language_code,
emoji=user_emoji,
has_stickers=False,
date_added=current_timestamp,
date_changed=current_timestamp,
voice_bot_welcome_received=False
)
await BotDB.add_user(user)
metrics.record_db_query("add_user", 0.0, "users", "insert")
else:
is_need_update = check_username_and_full_name(user_id, username, full_name, BotDB)
is_need_update = await check_username_and_full_name(user_id, username, full_name, BotDB)
if is_need_update:
BotDB.update_username_and_full_name(user_id, username, full_name)
metrics.record_db_query("update_username_and_full_name", 0.0, "users", "update")
await BotDB.update_user_info(user_id, username, full_name)
metrics.record_db_query("update_user_info", 0.0, "users", "update")
if source != 'voice':
await message.answer(
f"Давно не виделись! Вижу что ты изменился;) Теперь буду звать тебя: {full_name}")
await message.bot.send_message(chat_id=GROUP_FOR_LOGS,
text=f'Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {full_name}\nНовый ник:{username}. Новый эмодзи:{user_emoji}')
sleep(1)
BotDB.update_date_for_user(date, user_id)
metrics.record_db_query("update_date_for_user", 0.0, "users", "update")
await BotDB.update_user_date(user_id)
metrics.record_db_query("update_user_date", 0.0, "users", "update")
@track_time("check_user_emoji", "helper_func")
@track_errors("helper_func", "check_user_emoji")
@db_query_time("check_emoji_for_user", "users", "select")
def check_user_emoji(message: types.Message):
async def check_user_emoji(message: types.Message):
user_id = message.from_user.id
user_emoji = BotDB.check_emoji_for_user(user_id=user_id)
user_emoji = await BotDB.get_stickers_info(user_id=user_id)
if user_emoji is None or user_emoji in ("Смайл еще не определен", "Эмоджи не определен", ""):
user_emoji = get_random_emoji()
BotDB.update_emoji_for_user(user_id=user_id, emoji=user_emoji)
metrics.record_db_query("update_emoji_for_user", 0.0, "users", "update")
user_emoji = await get_random_emoji()
await BotDB.update_user_emoji(user_id=user_id, emoji=user_emoji)
metrics.record_db_query("update_user_emoji", 0.0, "users", "update")
return user_emoji
@track_time("get_random_emoji", "helper_func")
@track_errors("helper_func", "get_random_emoji")
@db_query_time("check_emoji", "users", "select")
def get_random_emoji():
async def get_random_emoji():
attempts = 0
while attempts < 100:
user_emoji = random.choice(emoji_list)
if not BotDB.check_emoji(user_emoji):
if not await BotDB.check_emoji_exists(user_emoji):
return user_emoji
attempts += 1
logger.error("Не удалось найти уникальный эмодзи после нескольких попыток.")

View File

@@ -8,12 +8,13 @@ import logging
from aiohttp import web
from typing import Optional, Dict, Any, Protocol
from .metrics import metrics
import time
class DatabaseProvider(Protocol):
"""Protocol for database operations."""
async def fetch_one(self, query: str) -> Optional[Dict[str, Any]]:
async def fetch_one(self, query: str, params: tuple = ()) -> Optional[Dict[str, Any]]:
"""Execute query and return single result."""
...
@@ -37,12 +38,16 @@ class UserMetricsCollector:
try:
# Проверяем, есть ли метод fetch_one (асинхронная БД)
if hasattr(db, 'fetch_one'):
# Используем UNIX timestamp для сравнения с date_changed
current_timestamp = int(time.time())
one_day_ago = current_timestamp - (24 * 60 * 60) # 24 часа назад
active_users_query = """
SELECT COUNT(DISTINCT user_id) as active_users
FROM our_users
WHERE date_changed > datetime('now', '-1 day')
WHERE date_changed > ?
"""
result = await db.fetch_one(active_users_query)
result = await db.fetch_one(active_users_query, (one_day_ago,))
if result:
metrics.set_active_users(result['active_users'], 'daily')
self.logger.debug(f"Updated active users: {result['active_users']}")
@@ -55,16 +60,19 @@ class UserMetricsCollector:
import asyncio
from concurrent.futures import ThreadPoolExecutor
current_timestamp = int(time.time())
one_day_ago = current_timestamp - (24 * 60 * 60) # 24 часа назад
active_users_query = """
SELECT COUNT(DISTINCT user_id) as active_users
FROM our_users
WHERE date_changed > datetime('now', '-1 day')
WHERE date_changed > ?
"""
def sync_db_query():
try:
db.connect()
db.cursor.execute(active_users_query)
db.cursor.execute(active_users_query, (one_day_ago,))
result = db.cursor.fetchone()
return result[0] if result else 0
finally:
@@ -172,11 +180,24 @@ class MetricsExporter:
async def stop(self):
"""Stop the metrics server."""
if self.site:
await self.site.stop()
if self.runner:
await self.runner.cleanup()
self.logger.info("Metrics server stopped")
try:
if self.site:
await self.site.stop()
self.logger.info("Metrics server site stopped")
if self.runner:
await self.runner.cleanup()
self.logger.info("Metrics server runner cleaned up")
except Exception as e:
self.logger.error(f"Error stopping metrics server: {e}")
finally:
# Очищаем ссылки
self.site = None
self.runner = None
# Даем время на закрытие всех соединений
await asyncio.sleep(0.1)
self.logger.info("Metrics server stopped")
async def metrics_handler(self, request: web.Request) -> web.Response:
"""Handle /metrics endpoint for Prometheus."""
@@ -249,10 +270,21 @@ class MetricsManager:
async def stop(self):
"""Stop metrics collection and export."""
try:
await self.collector.stop()
await self.exporter.stop()
self.logger.info("Metrics manager stopped successfully")
# Останавливаем background collector
if hasattr(self, 'collector'):
await self.collector.stop()
self.logger.info("Background metrics collector stopped")
# Останавливаем exporter
if hasattr(self, 'exporter'):
await self.exporter.stop()
self.logger.info("Metrics exporter stopped")
except Exception as e:
self.logger.error(f"Error stopping metrics manager: {e}")
raise
# Не вызываем raise, чтобы не прерывать процесс завершения
finally:
# Очищаем ссылки
self.collector = None
self.exporter = None
self.logger.info("Metrics manager stopped successfully")