diff --git a/database/migrate_add_weekly_digest.py b/database/migrate_add_weekly_digest.py new file mode 100644 index 0000000..c27fd99 --- /dev/null +++ b/database/migrate_add_weekly_digest.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Скрипт для создания новых таблиц в БД (включая weekly_digest_inbox) +""" +import sys +from pathlib import Path +import asyncio + +BASE_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BASE_DIR)) + +from database.db import init_db + +async def main(): + print("🔄 Запуск инициализации новых таблиц в базе данных...") + await init_db() + print("✅ Инициализация успешно завершена!") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/database/models.py b/database/models.py index c6870da..b793cbf 100644 --- a/database/models.py +++ b/database/models.py @@ -829,3 +829,20 @@ class Speedtest(Base): def __repr__(self): return f"" + +class WeeklyDigestInbox(Base): + """Личный инбокс для еженедельного дайджеста""" + __tablename__ = 'weekly_digest_inbox' + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, nullable=False) + message_text = Column(Text, nullable=True) + url = Column(Text, nullable=True) + media_type = Column(String(50), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + status = Column(String(50), default='pending') # 'pending', 'sent' + + def __repr__(self): + return f"" + + diff --git a/handlers/__init__.py b/handlers/__init__.py index 3341bfb..db6ad6a 100644 --- a/handlers/__init__.py +++ b/handlers/__init__.py @@ -23,3 +23,5 @@ from .achievements import router as achievements_router from .thanks import router as thanks_router from .events import router as events_router from .initiative_group import router as initiative_group_router +from .personal_inbox import router as personal_inbox_router + diff --git a/handlers/personal_inbox.py b/handlers/personal_inbox.py new file mode 100644 index 0000000..9d5eef8 --- /dev/null +++ b/handlers/personal_inbox.py @@ -0,0 +1,126 @@ +import logging +import re +from aiogram import Router, F +from aiogram.types import Message +from aiogram.filters import Command +from services.personal_inbox_service import PersonalInboxService +import config + +logger = logging.getLogger(__name__) +router = Router() + +# Регулярка для поиска URL +URL_REGEX = re.compile(r'(https?://[^\s]+)') + +@router.message(F.chat.type == 'private', Command('digest')) +@router.message(F.chat.type == 'private', Command('digest_send')) +async def cmd_send_digest(message: Message): + """Ручной запуск отправки дайджеста""" + if message.from_user.id != config.ADMIN_USER_ID: + return + + await message.answer("🔄 Собираю еженедельный дайджест...", parse_mode='HTML') + success = await PersonalInboxService.send_weekly_digest(message.bot) + if not success: + await message.answer("ℹ️ Нет активных (неотправленных) записей в вашем инбоксе.") + +@router.message(F.chat.type == 'private', Command('digest_list')) +async def cmd_list_digest(message: Message): + """Показать список текущих записей в инбоксе""" + if message.from_user.id != config.ADMIN_USER_ID: + return + + items = await PersonalInboxService.get_pending_items(config.ADMIN_USER_ID) + if not items: + await message.answer("📥 Ваш инбокс дайджеста пуст.") + return + + lines = [f"📥 Текущие записи в инбоксе ({len(items)}):\n"] + for idx, item in enumerate(items, 1): + date_str = item.created_at.strftime('%d.%m %H:%M') + line = f"{idx}. [{date_str}] " + if item.url: + line += f'Ссылка' + else: + line += "Текст" + if item.message_text: + # Обрезаем длинный текст + short_text = item.message_text[:60] + "..." if len(item.message_text) > 60 else item.message_text + line += f": {short_text}" + lines.append(line) + + lines.append("\n✍️ Для ручной отправки дайджеста введите /digest. Для очистки - /digest_clear.") + await message.answer("\n".join(lines), parse_mode='HTML', disable_web_page_preview=True) + +@router.message(F.chat.type == 'private', Command('digest_clear')) +async def cmd_clear_digest(message: Message): + """Очистить инбокс""" + if message.from_user.id != config.ADMIN_USER_ID: + return + + success = await PersonalInboxService.clear_pending_items(config.ADMIN_USER_ID) + if success: + await message.answer("🧹 Ваш инбокс успешно очищен (все записи помечены как отправленные).") + else: + await message.answer("❌ Произошла ошибка при очистке инбокса.") + +@router.message(F.chat.type == 'private') +async def handle_private_inbox(message: Message): + """Обработчик всех входящих личных сообщений от админа для сохранения в инбокс""" + if message.from_user.id != config.ADMIN_USER_ID: + # Для других пользователей пропускаем или выдаем стандартный ответ + return + + # Игнорируем команды, которые могли проскочить + if message.text and message.text.startswith('/'): + return + + # Собираем текст + text = message.text or message.caption or "" + + # Пытаемся вытащить URL из сущностей aiogram + urls = [] + entities = message.entities or message.caption_entities or [] + for entity in entities: + if entity.type == 'url': + urls.append(text[entity.offset:entity.offset+entity.length]) + elif entity.type == 'text_link': + urls.append(entity.url) + + # Если aiogram не нашел сущностей, ищем регуляркой + if not urls: + found = URL_REGEX.findall(text) + if found: + urls.extend(found) + + # Определяем тип медиа + media_type = 'text' + if message.photo: + media_type = 'photo' + elif message.video: + media_type = 'video' + elif message.document: + media_type = 'document' + + primary_url = urls[0] if urls else None + + # Добавляем в инбокс + success = await PersonalInboxService.add_to_inbox( + user_id=config.ADMIN_USER_ID, + text=text, + url=primary_url, + media_type=media_type + ) + + if success: + items = await PersonalInboxService.get_pending_items(config.ADMIN_USER_ID) + count = len(items) + + reply_msg = "📥 Добавлено в инбокс дайджеста!" + if primary_url: + reply_msg += f"\n🔗 Распознана ссылка: {primary_url}" + reply_msg += f"\n\n📂 Всего записей в очереди: {count}" + + await message.answer(reply_msg, parse_mode='HTML', disable_web_page_preview=True) + else: + await message.answer("❌ Произошла ошибка при сохранении сообщения в инбокс.") diff --git a/main.py b/main.py index 23738de..d82993e 100644 --- a/main.py +++ b/main.py @@ -79,8 +79,11 @@ async def main(): from handlers.smart_broadcast import router as smart_broadcast_router from handlers.digest import router as digest_router + from handlers.personal_inbox import router as personal_inbox_router dp.include_router(smart_broadcast_router) dp.include_router(digest_router) + dp.include_router(personal_inbox_router) + dp.startup.register(on_startup) logger.info('🤖 Бот запускается...') diff --git a/services/personal_inbox_service.py b/services/personal_inbox_service.py new file mode 100644 index 0000000..73023f0 --- /dev/null +++ b/services/personal_inbox_service.py @@ -0,0 +1,162 @@ +import logging +from datetime import datetime +from sqlalchemy import select, update +from database.db import AsyncSessionLocal +from database.models import WeeklyDigestInbox +import google.generativeai as genai +import os +import config + +logger = logging.getLogger(__name__) + +class PersonalInboxService: + @staticmethod + async def add_to_inbox(user_id: int, text: str, url: str = None, media_type: str = 'text') -> bool: + """Добавить запись в личный инбокс""" + try: + async with AsyncSessionLocal() as session: + item = WeeklyDigestInbox( + user_id=user_id, + message_text=text, + url=url, + media_type=media_type, + created_at=datetime.utcnow(), + status='pending' + ) + session.add(item) + await session.commit() + logger.info(f"Добавлена запись в инбокс для пользователя {user_id}: {text[:30]}... [URL: {url}]") + return True + except Exception as e: + logger.error(f"Ошибка при добавлении в инбокс: {e}") + return False + + @staticmethod + async def get_pending_items(user_id: int): + """Получить все необработанные записи пользователя""" + try: + async with AsyncSessionLocal() as session: + stmt = select(WeeklyDigestInbox).where( + WeeklyDigestInbox.user_id == user_id, + WeeklyDigestInbox.status == 'pending' + ).order_by(WeeklyDigestInbox.created_at.asc()) + result = await session.execute(stmt) + return result.scalars().all() + except Exception as e: + logger.error(f"Ошибка при получении записей из инбокса: {e}") + return [] + + @staticmethod + async def clear_pending_items(user_id: int) -> bool: + """Очистить (пометить отправленными) все записи пользователя""" + try: + async with AsyncSessionLocal() as session: + stmt = update(WeeklyDigestInbox).where( + WeeklyDigestInbox.user_id == user_id, + WeeklyDigestInbox.status == 'pending' + ).values(status='sent') + await session.execute(stmt) + await session.commit() + return True + except Exception as e: + logger.error(f"Ошибка при очистке инбокса: {e}") + return False + + @staticmethod + async def generate_weekly_digest(items) -> str: + """Сгенерировать сводный дайджест с помощью Gemini API""" + api_key = os.environ.get("GOOGLE_API_KEY") + if not api_key: + logger.warning("GOOGLE_API_KEY не задан. Генерация дайджеста через ИИ невозможна.") + return PersonalInboxService._format_fallback_digest(items) + + try: + genai.configure(api_key=api_key) + model = genai.GenerativeModel('gemini-1.5-flash') + except Exception as e: + logger.error(f"Ошибка инициализации Gemini: {e}") + return PersonalInboxService._format_fallback_digest(items) + + # Подготовка списка для промпта + items_list = [] + for idx, item in enumerate(items, 1): + date_str = item.created_at.strftime('%d.%m.%Y %H:%M') + content = f"Запись #{idx} [Дата: {date_str}, Тип: {item.media_type}]:\n" + if item.message_text: + content += f"Текст/Описание: {item.message_text}\n" + if item.url: + content += f"Ссылка: {item.url}\n" + items_list.append(content) + + items_text = "\n---\n".join(items_list) + + prompt = f""" +Ты - умный персональный ИИ-ассистент Александра. Твоя задача - составить еженедельную субботнюю сводку (дайджест) по материалам, которые он прислал тебе за неделю. + +Список присланных материалов: +{items_text} + +ПРАВИЛА ОФОРМЛЕНИЯ СВОДКИ (ОЧЕНЬ ВАЖНО): +1. Отвечай строго на РУССКОМ языке. +2. Используй красивую HTML-разметку, подходящую для Telegram (используй теги , , , ). Не используй markdown-символы типа ** или *. +3. Раздели материалы по логическим категориям, используя эмодзи. Подходящие категории: + - 📚 Выучить / Изучить! (для обучающих материалов, статей, документации, гайдов) + - 👀 Проверить / Тестировать! (для технических инструментов, репозиториев github, настроек серверов, команд) + - 📤 Отправить / Переслать! (если Александр прямо написал переслать кому-то, или это интересная новость для других) + - 📝 Заметки и Идеи (для текстовых заметок без ссылок, мыслей, набросков) +4. Для каждого пункта сделай ОЧЕНЬ краткую выжимку (буквально одно предложение) и укажи дату добавления. +5. Если есть ссылка (URL), ОБЯЗАТЕЛЬНО сделай её кликабельной в формате Название/Источник. +6. В конце добавь краткое бодрое пожелание отличных выходных. +""" + + try: + response = model.generate_content(prompt) + return response.text.strip() + except Exception as e: + logger.error(f"Ошибка генерации дайджеста в Gemini: {e}") + return PersonalInboxService._format_fallback_digest(items) + + @staticmethod + def _format_fallback_digest(items) -> str: + """Форматирование резервного дайджеста, если Gemini недоступен""" + lines = ["📅 ЕЖЕНЕДЕЛЬНАЯ СВОДКА (Резервная копия)\n"] + for idx, item in enumerate(items, 1): + date_str = item.created_at.strftime('%d.%m.%Y') + line = f"{idx}. " + if item.url: + line += f'Ссылка' + else: + line += "Заметка" + line += f" ({date_str}):\n" + if item.message_text: + line += f"{item.message_text[:100]}" + lines.append(line) + lines.append("\n💡 Модуль ИИ временно недоступен. Выше представлен простой список ваших ссылок. Приятных выходных!") + return "\n".join(lines) + + @staticmethod + async def send_weekly_digest(bot) -> bool: + """Собрать и отправить еженедельный дайджест администратору""" + user_id = config.ADMIN_USER_ID + items = await PersonalInboxService.get_pending_items(user_id) + if not items: + logger.info("Нет записей в инбоксе для формирования дайджеста.") + return False + + logger.info(f"Формирование еженедельного дайджеста из {len(items)} записей для {user_id}...") + digest_text = await PersonalInboxService.generate_weekly_digest(items) + + try: + await bot.send_message( + chat_id=user_id, + text=digest_text, + parse_mode='HTML', + disable_web_page_preview=True + ) + # Помечаем отправленными + await PersonalInboxService.clear_pending_items(user_id) + logger.info("Еженедельный дайджест успешно отправлен и записи очищены.") + return True + except Exception as e: + logger.error(f"Ошибка при отправке дайджеста: {e}") + return False diff --git a/services/scheduler.py b/services/scheduler.py index 902df9f..3bc0920 100644 --- a/services/scheduler.py +++ b/services/scheduler.py @@ -160,6 +160,16 @@ class Scheduler: replace_existing=True ) logger.info('✅ Добавлена задача прогноза погоды в чат по четвергам') + + # Еженедельный дайджест Александра (суббота в 08:00) + self.scheduler.add_job( + self.send_personal_digest, + CronTrigger.from_crontab('0 8 * * 6'), # Каждую субботу в 08:00 + id='personal_digest', + name='Еженедельный дайджест Александра', + replace_existing=True + ) + logger.info('✅ Добавлена задача еженедельного дайджеста Александра (суббота 08:00)') def stop(self): """Остановка планировщика""" @@ -565,3 +575,13 @@ class Scheduler: logger.info("✅ Thursday weather forecast broadcasted to chat") except Exception as e: logger.error(f"Error in thursday_chat_weather: {e}") + + async def send_personal_digest(self): + """Еженедельный дайджест для администратора (суббота в 08:00)""" + try: + from services.personal_inbox_service import PersonalInboxService + await PersonalInboxService.send_weekly_digest(self.bot) + logger.info('✅ Личный еженедельный дайджест отправлен') + except Exception as e: + logger.error(f'Ошибка отправки личного дайджеста: {e}') +