feat: add weekly personal digest inbox and scheduler
This commit is contained in:
parent
c68c9e0774
commit
df3093ff6c
7 changed files with 351 additions and 0 deletions
21
database/migrate_add_weekly_digest.py
Normal file
21
database/migrate_add_weekly_digest.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -829,3 +829,20 @@ class Speedtest(Base):
|
|||
def __repr__(self):
|
||||
return f"<Speedtest #{self.id} DL={self.download} UP={self.upload} Server={self.server_name}>"
|
||||
|
||||
|
||||
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"<WeeklyDigestInbox #{self.id} user={self.user_id} status={self.status}>"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
126
handlers/personal_inbox.py
Normal file
126
handlers/personal_inbox.py
Normal file
|
|
@ -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("🔄 <b>Собираю еженедельный дайджест...</b>", 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"📥 <b>Текущие записи в инбоксе ({len(items)}):</b>\n"]
|
||||
for idx, item in enumerate(items, 1):
|
||||
date_str = item.created_at.strftime('%d.%m %H:%M')
|
||||
line = f"<b>{idx}.</b> [{date_str}] "
|
||||
if item.url:
|
||||
line += f'<a href="{item.url}">Ссылка</a>'
|
||||
else:
|
||||
line += "Текст"
|
||||
if item.message_text:
|
||||
# Обрезаем длинный текст
|
||||
short_text = item.message_text[:60] + "..." if len(item.message_text) > 60 else item.message_text
|
||||
line += f": <i>{short_text}</i>"
|
||||
lines.append(line)
|
||||
|
||||
lines.append("\n✍️ <i>Для ручной отправки дайджеста введите /digest. Для очистки - /digest_clear.</i>")
|
||||
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 = "📥 <b>Добавлено в инбокс дайджеста!</b>"
|
||||
if primary_url:
|
||||
reply_msg += f"\n🔗 Распознана ссылка: <code>{primary_url}</code>"
|
||||
reply_msg += f"\n\n📂 Всего записей в очереди: <b>{count}</b>"
|
||||
|
||||
await message.answer(reply_msg, parse_mode='HTML', disable_web_page_preview=True)
|
||||
else:
|
||||
await message.answer("❌ Произошла ошибка при сохранении сообщения в инбокс.")
|
||||
3
main.py
3
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('🤖 Бот запускается...')
|
||||
|
|
|
|||
162
services/personal_inbox_service.py
Normal file
162
services/personal_inbox_service.py
Normal file
|
|
@ -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 (используй теги <b>, <i>, <code>, <a>). Не используй markdown-символы типа ** или *.
|
||||
3. Раздели материалы по логическим категориям, используя эмодзи. Подходящие категории:
|
||||
- 📚 <b>Выучить / Изучить!</b> (для обучающих материалов, статей, документации, гайдов)
|
||||
- 👀 <b>Проверить / Тестировать!</b> (для технических инструментов, репозиториев github, настроек серверов, команд)
|
||||
- 📤 <b>Отправить / Переслать!</b> (если Александр прямо написал переслать кому-то, или это интересная новость для других)
|
||||
- 📝 <b>Заметки и Идеи</b> (для текстовых заметок без ссылок, мыслей, набросков)
|
||||
4. Для каждого пункта сделай ОЧЕНЬ краткую выжимку (буквально одно предложение) и укажи дату добавления.
|
||||
5. Если есть ссылка (URL), ОБЯЗАТЕЛЬНО сделай её кликабельной в формате <a href="URL">Название/Источник</a>.
|
||||
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 = ["📅 <b>ЕЖЕНЕДЕЛЬНАЯ СВОДКА (Резервная копия)</b>\n"]
|
||||
for idx, item in enumerate(items, 1):
|
||||
date_str = item.created_at.strftime('%d.%m.%Y')
|
||||
line = f"<b>{idx}.</b> "
|
||||
if item.url:
|
||||
line += f'<a href="{item.url}">Ссылка</a>'
|
||||
else:
|
||||
line += "Заметка"
|
||||
line += f" ({date_str}):\n"
|
||||
if item.message_text:
|
||||
line += f"<i>{item.message_text[:100]}</i>"
|
||||
lines.append(line)
|
||||
lines.append("\n💡 <i>Модуль ИИ временно недоступен. Выше представлен простой список ваших ссылок. Приятных выходных!</i>")
|
||||
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
|
||||
|
|
@ -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}')
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue