import asyncio import html import logging from asyncio import Queue from datetime import datetime from aiogram import Router, types from aiogram.utils.keyboard import InlineKeyboardBuilder from app import config from app.misc import bot from app.models import Review, Settings from app.services.sheets import add_rows_to_sheet logger = logging.getLogger(__name__) router = Router() send_queue = Queue() sheet_queue = Queue() def add_to_message_queue(review: Review) -> None: sheet_queue.put_nowait(review) # Отправляем в Telegram и отзывы с текстом, и пустые (только оценка) send_queue.put_nowait(review) def render_review_to_sheet(review: Review) -> list[str]: """Безопасный рендер: None-поля не вызывают падение.""" if review.published_at: formatted_date = review.published_at.astimezone(config.TIMEZONE).strftime( "%d.%m.%Y %H:%M:%S" ) else: formatted_date = "-" bables_str = "-" if review.bables and isinstance(review.bables, list): bables_str = ", ".join(str(x).strip() for x in review.bables if x is not None).strip() or "-" elif review.bables: bables_str = str(review.bables) tone_str = ( {"negative": "негативная", "neutral": "нейтральная", "positive": "положительная"}.get( review.tone, "-" ) if review.tone else "-" ) critical_str = "критичный" if review.is_critical else "-" # Порядок столбцов таблицы (12 колонок): 1–8 данные, 9 тональность, 10 причина, 11 критичность, 12 Тэги return [ formatted_date, "Ozon" if review.platform == "ozon" else "Wildberries", review.product_name or "-", review.product_category or "-", review.product_subcategory or "-", review.product_id or "-", review.rating if review.rating is not None else "-", review.text or "-", tone_str, review.tone_reason or "-", critical_str, bables_str, # 12-й столбец — «Тэги» (WB bables) ] _SHEETS_REQUEST_TIMEOUT = 60 # сек — защита от зависания при неотвечающем Google API def _requeue_batch(batch: list[Review]) -> None: """Возвращает отзывы в очередь при ошибке.""" for review in batch: try: sheet_queue.put_nowait(review) except asyncio.QueueFull: logger.exception( "SHEETS_QUEUE_FULL: очередь переполнена при возврате отзывов, потерян отзыв id=%s", getattr(review, "id", "?") if review else "?", ) async def sheet_queue_worker() -> None: """Send queued reviews to Google Sheet. Puts items back on failure so nothing is lost. Возможные причины сбоев (ищи в логах SHEETS_API_ERROR / SHEETS_QUEUE_ERROR): - 429: превышен лимит Google Sheets API (60 запросов/мин на пользователя) - 503/504: временная недоступность Google API - Сетевые ошибки: timeout, DNS, обрыв соединения - 401/403: проблемы с service account / доступом к таблице """ retry_delay = 5 consecutive_failures = 0 loop = asyncio.get_running_loop() while True: if sheet_queue.empty(): await asyncio.sleep(1) continue batch: list[Review] = [] rows: list[list] = [] try: for _ in range(sheet_queue.qsize()): review = sheet_queue.get_nowait() batch.append(review) rows.append(render_review_to_sheet(review)) except Exception as e: logger.exception( "SHEETS_RENDER_ERROR: ошибка при подготовке данных для таблицы: %s. " "Возвращаю %d отзывов в очередь.", e, len(batch), ) _requeue_batch(batch) await asyncio.sleep(retry_delay) retry_delay = min(60, retry_delay * 2) continue try: await asyncio.wait_for( loop.run_in_executor( None, add_rows_to_sheet, config.REVIEWS_SHEET_LINK, rows, ), timeout=_SHEETS_REQUEST_TIMEOUT, ) if consecutive_failures > 0: logger.info( "SHEETS_RECOVERY: запись в Google Sheets восстановлена после %d неудачных попыток, " "записано %d строк", consecutive_failures, len(rows), ) consecutive_failures = 0 retry_delay = 5 except asyncio.TimeoutError: consecutive_failures += 1 logger.exception( "SHEETS_TIMEOUT: запрос к Google Sheets завис более %d сек. " "Попытка=%d, возвращаю в очередь %d отзывов.", _SHEETS_REQUEST_TIMEOUT, consecutive_failures, len(batch), ) _requeue_batch(batch) await asyncio.sleep(retry_delay) retry_delay = min(60, retry_delay * 2) except Exception as e: consecutive_failures += 1 logger.exception( "SHEETS_QUEUE_ERROR: ошибка при записи в Google Sheets: %s. " "Попытка=%d, перезапуск через %d сек. Возвращаю в очередь %d отзывов.", e, consecutive_failures, retry_delay, len(batch), ) _requeue_batch(batch) await asyncio.sleep(retry_delay) retry_delay = min(60, retry_delay * 2) def _is_empty_review(review: Review) -> bool: """Отзыв без текста — только оценка.""" return not (review.text and review.text.strip()) def _get_thread_id_for_review(review: Review) -> int: """Топик для отзыва: для пустых — отдельные топики 1–3 и 4–5 звёзд, для остальных — хорошие/плохие.""" if _is_empty_review(review): if review.rating <= 3: return config.EMPTY_LOW_RATING_THREAD_ID or config.BAD_REVIEWS_THREAD_ID return config.EMPTY_HIGH_RATING_THREAD_ID or config.GOOD_REVIEWS_THREAD_ID return ( config.GOOD_REVIEWS_THREAD_ID if review.is_good else config.BAD_REVIEWS_THREAD_ID ) async def send_review(review: Review) -> None: escaped_text = html.escape(review.text) if review.text else "—" escaped_product_id = html.escape(review.product_id) formatted_date = review.published_at.astimezone(config.TIMEZONE).strftime( "%d.%m.%Y %H:%M:%S МСК" ) if review.platform == "ozon": product_url = f"https://www.ozon.ru/product/{review.product_id}/" else: product_url = ( f"https://www.wildberries.ru/catalog/{review.product_id}/detail.aspx" ) now = datetime.now(config.TIMEZONE) today = now.date() count = await Review.filter( is_good=review.is_good, published_at__gte=datetime.combine( today, datetime.min.time(), tzinfo=config.TIMEZONE ), message_id__isnull=False, ).count() category_hashtag = (review.product_category or "неизвестная категория").lower().replace(" ", "_") message = ( ( "🚨 Требует обработки #требует_обработки\n" if review.is_critical else "" ) + f"{'✅ Положительный' if review.is_good else '⚠️ Отрицательный'} отзыв №{count + 1}\n" f"📅 Дата: {formatted_date}\n" f"📦 Товар: {escaped_product_id} #{category_hashtag}\n" f"{'🟪 #Wildberries' if review.platform == 'wb' else '🟦 #Ozon'}\n" f"🔗 Ссылка\n" f"⭐ Оценка: {review.rating}/5\n" f"💬 Отзыв: {escaped_text}\n\n" ) if review.response_text: message += f"\nАвтоответ: {html.escape(review.response_text)}\n" if review.tone: tone_name = { "negative": "Негативная", "neutral": "Нейтральная", "positive": "Позитивная", }.get(review.tone, "неизвестно") message += f"📊 Анализ отзыва:\nТональность: #{tone_name}\n" if review.tone_reason: message += f"Причина: #{review.tone_reason}\n" thread_id = _get_thread_id_for_review(review) markup = InlineKeyboardBuilder() if not review.response_text: if _is_empty_review(review): # Пустой отзыв (только оценка): одна кнопка шаблона по рейтингу + написать свой ответ settings = await Settings.get_from_context(review=review) is_low_rating = review.rating is not None and review.rating <= 3 if is_low_rating: template_text = ( settings.template_empty_low_rating or config.TEMPLATE_EMPTY_LOW_RATING ) markup.row( types.InlineKeyboardButton( text=f"Ответить (1–3 ★): {template_text}", callback_data=f"quick_reply:{review.id}:low", ), ) else: template_text = ( settings.template_empty_high_rating or config.TEMPLATE_EMPTY_HIGH_RATING ) markup.row( types.InlineKeyboardButton( text=f"Ответить (4–5 ★): {template_text}", callback_data=f"quick_reply:{review.id}:high", ), ) markup.row( types.InlineKeyboardButton( text="Написать свой ответ", callback_data=f"custom_reply:{review.id}", ), ) else: markup.row( types.InlineKeyboardButton( text="Сгенерировать ответ", callback_data=f"generate_answer:{review.id}", ), types.InlineKeyboardButton( text="Написать свой ответ", callback_data=f"custom_reply:{review.id}", ), ) if review.response_id: markup.row( types.InlineKeyboardButton( text="Удалить ответ", callback_data=f"{review.platform}_delete:{review.response_id}", ) ) # Send images if they exist if review.images: media = [] for image_url in review.images: media.append(types.InputMediaPhoto(media=image_url)) if media: try: await bot.send_media_group( chat_id=config.TG_CHAT_ID, message_thread_id=thread_id, media=media, ) except Exception as e: logger.error(f"Error sending review images: {e}") message += "\n⚠️ Не удалось загрузить изображения\n" sent = await bot.send_message( chat_id=config.TG_CHAT_ID, text=message, message_thread_id=thread_id, parse_mode="HTML", reply_markup=markup.as_markup(), ) review.chat_id = sent.chat.id review.message_id = sent.message_id await review.save() async def send_queue_worker() -> None: while True: review = await send_queue.get() try: await send_review(review) except Exception as e: logger.error(f"Error sending message: {e=} {review.id=}") await asyncio.sleep(3) send_queue.task_done()