текущая версия (рабочая)

This commit is contained in:
N8N
2026-09-04 15:10:24 +03:00
commit 161fd6576b
31 changed files with 3420 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
from app.handlers import callbacks, commands, reviews
__all__ = ["commands", "callbacks", "reviews"]
+408
View File
@@ -0,0 +1,408 @@
import logging
from datetime import datetime
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from tortoise.exceptions import DoesNotExist
from app import config
from app.models import Review, Settings
from app.services import GPTClient, OzonClient, WildberriesClient
logger = logging.getLogger(__name__)
router = Router()
def _settings_keyboard(settings: Settings) -> types.InlineKeyboardMarkup:
"""Клавиатура настроек: автоответ с текстом, автоответ без текста, анализ, модель."""
return types.InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=f"Авто-ответ (с текстом): {'' if settings.auto_response_enabled else ''}",
callback_data="toggle_auto_response",
)
],
[
InlineKeyboardButton(
text=f"Авто-ответ (без текста): {'' if settings.auto_response_empty_enabled else ''}",
callback_data="toggle_auto_response_empty",
)
],
[
InlineKeyboardButton(
text=f"Анализ отзывов: {'' if settings.analysis_enabled else ''}",
callback_data="toggle_analyze",
)
],
[
InlineKeyboardButton(
text=f"GPT модель: {settings.gpt_model}",
callback_data="change_model",
)
],
]
)
class CustomReplyStates(StatesGroup):
waiting_for_text = State()
@router.callback_query(F.data.startswith("auto:"))
async def callback_auto(call: CallbackQuery) -> None:
action = call.data.split(":", 1)[1]
settings = await Settings.get_from_context(
message_thread_id=call.message.message_thread_id
)
if action == "enable":
settings.auto_response_enabled = True
await settings.save()
await call.answer("Автоответы включены.")
elif action == "disable":
settings.auto_response_enabled = False
await settings.save()
await call.answer("Автоответы выключены.")
status_text = "Включены" if settings.auto_response_enabled else "Выключены"
await call.message.edit_text(
f"Статус автоответов: <b>{status_text}</b>",
parse_mode="HTML",
reply_markup=call.message.reply_markup,
)
@router.callback_query(F.data.startswith("model:"))
async def callback_model(call: CallbackQuery) -> None:
model = call.data.split(":", 1)[1]
settings = await Settings.get_from_context(
message_thread_id=call.message.message_thread_id
)
settings.gpt_model = model
await settings.save()
await call.answer(f"Модель изменена на {model}")
await call.message.edit_text(
f"Модель изменена на <b>{model}</b>", parse_mode="HTML"
)
@router.callback_query(F.data.startswith("ozon_delete:"))
async def callback_ozon_delete(call: CallbackQuery) -> None:
comment_id = call.data.split(":", 1)[1]
ozon_client = OzonClient()
result = await ozon_client.delete_comment(comment_id)
if "error" not in result:
try:
review = await Review.filter(response_id=comment_id).first()
if review:
review.response_text = None
review.response_id = None
review.responded_at = None
await review.save()
except Exception as e:
logger.error(f"Error updating review after comment deletion: {e}")
await call.answer("Комментарий успешно удалён на Ozon.")
await call.message.reply("🗑 Ответ успешно удален с Ozon")
else:
await call.answer("Не удалось удалить комментарий.")
await call.message.reply(
f"❌ Ошибка удаления комментария: {result.get('error')}"
)
@router.callback_query(F.data.startswith("wb_delete:"))
async def callback_wb_delete(call: CallbackQuery) -> None:
reply_id = call.data.split(":", 1)[1]
wb_client = WildberriesClient()
result = await wb_client.delete_comment(reply_id)
if result.get("success"):
try:
review = await Review.filter(response_id=reply_id).first()
if review:
review.response_text = None
review.response_id = None
review.responded_at = None
await review.save()
except Exception as e:
logger.error(f"Error updating review after comment deletion: {e}")
await call.answer("Комментарий успешно удалён на Wildberries.")
await call.message.reply("🗑 Ответ успешно удален с Wildberries")
else:
await call.answer("Не удалось удалить комментарий.")
await call.message.reply(
f"❌ Ошибка удаления комментария: {result.get('error')}"
)
@router.callback_query(F.data.startswith("generate_answer:"))
async def callback_generate_answer(call: CallbackQuery) -> None:
review_id = call.data.split(":", 1)[1]
try:
review = await Review.get(id=review_id)
settings = await Settings.get_from_context(
message_thread_id=call.message.message_thread_id
)
gpt_client = GPTClient()
answer = await gpt_client.generate_answer(settings, review)
markup = InlineKeyboardBuilder()
markup.row(
InlineKeyboardButton(
text="Редактировать",
callback_data=f"custom_reply:{review_id}",
),
InlineKeyboardButton(
text="Отправить",
callback_data=f"send_answer:{review_id}",
),
)
await call.message.reply(
f"#review{review_id}\n\n<blockquote>{answer}</blockquote>",
parse_mode="HTML",
reply_markup=markup.as_markup(),
)
# Store the generated answer temporarily
review.response_text = answer
await review.save()
await call.answer("Ответ сгенерирован")
except DoesNotExist:
await call.answer("Отзыв не найден.")
except Exception as e:
logger.error(f"Error generating answer: {e}")
await call.answer("Произошла ошибка при генерации ответа.")
@router.callback_query(F.data.startswith("quick_reply:"))
async def callback_quick_reply(call: CallbackQuery) -> None:
"""Подставить шаблонный ответ для пустого отзыва и показать кнопку «Отправить ответ»."""
parts = call.data.split(":", 2)
if len(parts) != 3:
await call.answer("Неверные данные.")
return
_, review_id, template = parts
try:
review = await Review.get(id=review_id)
settings = await Settings.get_from_context(review=review)
text = (
(settings.template_empty_high_rating or config.TEMPLATE_EMPTY_HIGH_RATING)
if template == "high"
else (settings.template_empty_low_rating or config.TEMPLATE_EMPTY_LOW_RATING)
)
review.response_text = text
await review.save()
markup = InlineKeyboardBuilder()
markup.row(
InlineKeyboardButton(
text="Отправить ответ",
callback_data=f"send_answer:{review_id}",
),
)
await call.message.reply(
f"#review{review_id}\n\nБудет отправлено:\n<blockquote>{text}</blockquote>\n\nНажмите «Отправить ответ».",
parse_mode="HTML",
reply_markup=markup.as_markup(),
)
await call.answer("Текст подставлен. Нажмите «Отправить ответ».")
except DoesNotExist:
await call.answer("Отзыв не найден.")
except Exception as e:
logger.error(f"Error in quick_reply: {e}")
await call.answer("Ошибка при подстановке ответа.")
@router.callback_query(F.data.startswith("custom_reply:"))
async def callback_custom_reply(call: CallbackQuery, state: FSMContext) -> None:
"""Initiate custom reply for a review."""
review_id = call.data.split(":", 1)[1]
await state.update_data(review_id=review_id)
await state.set_state(CustomReplyStates.waiting_for_text)
await call.message.reply(
f"✏️ Введите ваш ответ на отзыв #review{review_id}:", parse_mode="HTML"
)
await call.answer("Введите ваш ответ")
@router.message(CustomReplyStates.waiting_for_text)
async def process_custom_reply(message: Message, state: FSMContext) -> None:
custom_text = message.text
state_data = await state.get_data()
review_id = state_data.get("review_id")
try:
review = await Review.get(id=review_id)
review.response_text = custom_text
await review.save()
await state.clear()
markup = InlineKeyboardBuilder()
markup.row(
InlineKeyboardButton(
text="Редактировать",
callback_data=f"custom_reply:{review_id}",
),
InlineKeyboardButton(
text="Отправить",
callback_data=f"send_answer:{review_id}",
),
)
await message.answer(
f"#review{review_id}\n\n<blockquote>{custom_text}</blockquote>\n\nВыберите действие:",
parse_mode="HTML",
reply_markup=markup.as_markup(),
reply_to_message_id=review.message_id,
)
except DoesNotExist:
await message.reply("Отзыв не найден.")
await state.clear()
except Exception as e:
logger.error(f"Error processing custom reply: {e}")
await message.reply("Произошла ошибка при обработке ответа.")
await state.clear()
@router.callback_query(F.data.startswith("send_answer:"))
async def callback_send_answer(call: CallbackQuery) -> None:
review_id = call.data.split(":")[1]
try:
review = await Review.get(id=review_id)
if not review.response_text:
await call.answer("Сначала выберите или введите ответ.", show_alert=True)
return
if review.responded_at:
await call.message.edit_text("Ответ на отзыв уже отправлен.")
await call.answer("Ответ уже отправлен.", show_alert=True)
return
if review.platform == "ozon":
ozon_client = OzonClient()
result = await ozon_client.post_comment(
review.external_id, review.response_text
)
if "comment_id" in result:
review.response_id = result["comment_id"]
await review.save()
markup = InlineKeyboardBuilder()
markup.add(
InlineKeyboardButton(
text="Удалить",
callback_data=f"ozon_delete:{result['comment_id']}",
)
)
await call.message.answer(
f"✅ Ответ на #review{review_id} успешно отправлен на Ozon",
reply_to_message_id=review.message_id,
reply_markup=markup.as_markup(),
)
else:
await call.message.reply(
f"❌ Ошибка отправки ответа на Ozon: {result.get('error')}"
)
elif review.platform == "wb":
wb_client = WildberriesClient()
result = await wb_client.post_comment(
review.external_id, review.response_text
)
if result.get("success"):
await call.message.answer(
f"✅ Ответ на #review{review_id} успешно отправлен на Wildberries!",
reply_to_message_id=review.message_id,
)
else:
await call.message.reply(
f"❌ Ошибка отправки ответа на Wildberries: {result.get('error')}"
)
await call.answer("Ответ отправлен")
review.responded_at = datetime.now()
await review.save()
except DoesNotExist:
await call.answer("Отзыв не найден.")
except Exception as e:
logger.error(f"Error sending answer: {e}")
await call.answer("Произошла ошибка при отправке ответа.")
@router.callback_query(F.data == "toggle_auto_response")
async def toggle_auto_response(callback: types.CallbackQuery) -> None:
settings = await Settings.get_from_context(
message_thread_id=callback.message.message_thread_id
)
settings.auto_response_enabled = not settings.auto_response_enabled
await settings.save()
status_text = "Включен" if settings.auto_response_enabled else "Выключен"
await callback.answer(f"Авто-ответ (с текстом): {status_text}")
await callback.message.edit_reply_markup(reply_markup=_settings_keyboard(settings))
@router.callback_query(F.data == "toggle_auto_response_empty")
async def toggle_auto_response_empty(callback: types.CallbackQuery) -> None:
settings = await Settings.get_from_context(
message_thread_id=callback.message.message_thread_id
)
settings.auto_response_empty_enabled = not settings.auto_response_empty_enabled
await settings.save()
status_text = "Включен" if settings.auto_response_empty_enabled else "Выключен"
await callback.answer(f"Авто-ответ (без текста): {status_text}")
await callback.message.edit_reply_markup(reply_markup=_settings_keyboard(settings))
@router.callback_query(F.data == "change_model")
async def change_model(callback: types.CallbackQuery) -> None:
settings = await Settings.get_from_context(
message_thread_id=callback.message.message_thread_id
)
if settings.gpt_model == "gpt-4o":
settings.gpt_model = "gpt-4o-mini"
else:
settings.gpt_model = "gpt-4o"
await settings.save()
await callback.answer(f"Модель изменена на {settings.gpt_model}")
await callback.message.edit_reply_markup(reply_markup=_settings_keyboard(settings))
@router.callback_query(F.data == "toggle_analyze")
async def toggle_analyze(callback: types.CallbackQuery) -> None:
settings = await Settings.get_from_context(
message_thread_id=callback.message.message_thread_id
)
settings.analysis_enabled = not settings.analysis_enabled
await settings.save()
status_text = "Включен" if settings.analysis_enabled else "Выключен"
await callback.answer(f"Анализ отзывов: {status_text}")
await callback.message.edit_reply_markup(reply_markup=_settings_keyboard(settings))
+150
View File
@@ -0,0 +1,150 @@
import logging
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message
from app import config
from app.handlers.callbacks import _settings_keyboard
from app.models import Settings
from app.services import StatsService
router = Router()
logger = logging.getLogger(__name__)
stats_service = StatsService()
@router.message(Command("allstat", "stat"))
async def cmd_stats(message: Message) -> None:
is_all_time = message.text.startswith("/allstat")
ozon_stats = (
await stats_service.get_all_time_stats("ozon")
if is_all_time
else await stats_service.get_daily_stats("ozon")
)
wb_stats = (
await stats_service.get_all_time_stats("wb")
if is_all_time
else await stats_service.get_daily_stats("wb")
)
period_text = "за все время" if is_all_time else "за сегодня"
stats_text = f"📊 <b>Статистика {period_text}</b>\n\n"
for platform_name, stats in [("Ozon", ozon_stats), ("Wildberries", wb_stats)]:
stats_text += (
f"<b>{platform_name}:</b>\n"
f"Всего отзывов: {stats['total_reviews']}\n"
f"Хороших: {stats['good_reviews']}\n"
f"Плохих: {stats['bad_reviews']}\n"
f"Доля плохих: {stats['bad_ratio']:.1%}\n"
f"Средний рейтинг: {stats['average_rating']:.1f}\n"
f"Критичных: {stats['critical_reviews']} ({stats['critical_ratio']:.1%} от всех, {stats['critical_to_bad_ratio']:.1%} от плохих)\n\n"
f"<b>Оценки:</b>\n"
f"⭐️ 1: {stats['ratings']['1']}\n"
f"⭐️ 2: {stats['ratings']['2']}\n"
f"⭐️ 3: {stats['ratings']['3']}\n"
f"⭐️ 4: {stats['ratings']['4']}\n"
f"⭐️ 5: {stats['ratings']['5']}\n\n"
f"<b>Тональность:</b>\n"
f"😊 Позитивная: {stats['tone']['positive']}\n"
f"😐 Нейтральная: {stats['tone']['neutral']}\n"
f"😠 Негативная: {stats['tone']['negative']}\n\n"
)
await message.answer(stats_text, parse_mode="HTML")
@router.message(Command("settings"))
async def cmd_settings(message: Message) -> None:
settings = await Settings.get_from_context(
message_thread_id=message.message_thread_id
)
await message.answer(
(
f"⚙️ <b>Настройки {'положительных' if settings.is_good else 'отрицательных'} отзывов</b>\n\n"
"/show_prompt - просмотр промптов\n"
"/change_prompt - изменить промпт для аналитики\n"
"/reset_prompt - сбросить промпт для аналитики\n"
"/change_answer_prompt - изменить промпт для ответов на отзывы\n"
"/reset_answer_prompt - сбросить промпт для ответов на отзывы\n\n"
"<b>Шаблоны для отзывов без текста:</b>\n"
"/template_low текст — шаблон для 1–3 ★\n"
"/template_high текст — шаблон для 4–5 ★\n"
"/show_templates — показать текущие шаблоны\n"
),
reply_markup=_settings_keyboard(settings),
parse_mode="HTML",
)
@router.message(Command("show_templates"))
async def cmd_show_templates(message: Message) -> None:
"""Показать текущие шаблоны ответа на отзывы без текста."""
settings = await Settings.get_from_context(
message_thread_id=message.message_thread_id
)
low = settings.template_empty_low_rating or config.TEMPLATE_EMPTY_LOW_RATING
high = settings.template_empty_high_rating or config.TEMPLATE_EMPTY_HIGH_RATING
await message.answer(
"📝 <b>Шаблоны для отзывов без текста</b>\n\n"
f"<b>13 ★:</b> <code>{low}</code>\n\n"
f"<b>45 ★:</b> <code>{high}</code>\n\n"
"Изменить: /template_low текст или /template_high текст",
parse_mode="HTML",
)
@router.message(Command("template_low"))
async def cmd_template_low(message: Message) -> None:
"""Установить шаблон ответа для отзывов без текста с оценкой 1–3 звезды."""
text = (message.text or "").replace("/template_low", "").strip()
if not text:
await message.answer(
"Введите текст шаблона после команды.\n"
"Пример: /template_low Спасибо за отзыв, извините за неудобства."
)
return
for s in await Settings.all():
s.template_empty_low_rating = text
await s.save()
await message.answer(f"✅ Шаблон для 1–3 ★ сохранён:\n<code>{text}</code>", parse_mode="HTML")
@router.message(Command("template_high"))
async def cmd_template_high(message: Message) -> None:
"""Установить шаблон ответа для отзывов без текста с оценкой 4–5 звёзд."""
text = (message.text or "").replace("/template_high", "").strip()
if not text:
await message.answer(
"Введите текст шаблона после команды.\n"
"Пример: /template_high Спасибо за отзыв, рады что вам понравилось!"
)
return
for s in await Settings.all():
s.template_empty_high_rating = text
await s.save()
await message.answer(f"✅ Шаблон для 4–5 ★ сохранён:\n<code>{text}</code>", parse_mode="HTML")
@router.message(Command("max_tokens"))
async def cmd_max_tokens(message: Message) -> None:
settings = await Settings.get_from_context(
message_thread_id=message.message_thread_id
)
args = message.text.split()
if len(args) > 1 and args[1].isdigit():
value = int(args[1])
settings.max_tokens = value
await settings.save()
await message.answer(f"Лимит токенов установлен на {value}")
return
await message.answer(
f"Текущий лимит токенов: {settings.max_tokens}\n"
"Введите /max_tokens число, чтобы обновить его"
)
+99
View File
@@ -0,0 +1,99 @@
from aiogram import Router, types
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from app.config import ANALYSIS_PROMPT, ANSWER_PROMPT
from app.models.settings import Settings
prompt_router = Router()
@prompt_router.message(Command("cancel"))
async def cancel_cmd(message: types.Message, state: FSMContext) -> None:
await message.answer("Отменено")
await state.clear()
@prompt_router.message(Command("show_prompt"))
async def show_prompt(message: types.Message) -> None:
settings = await Settings.get_from_context(
message_thread_id=message.message_thread_id
)
await message.reply(
f"""
Промпт для анализа отзыва:
<blockquote expandable>{settings.analysis_prompt}</blockquote>
/change_prompt - изменить промпт
/reset_prompt - сбросить промпт
""",
parse_mode="HTML",
)
await message.reply(
f"""
Промпт для ответов:
<blockquote expandable>{settings.answer_prompt}</blockquote>
/change_answer_prompt - изменить промпт
/reset_answer_prompt - сбросить промпт
""",
parse_mode="HTML",
)
@prompt_router.message(Command("change_prompt"))
async def change_prompt_cmd(message: types.Message, state: FSMContext) -> None:
await message.reply("Введите новый промпт для анализа отзыва. /cancel для отмены")
await state.set_state("change_prompt")
@prompt_router.message(StateFilter("change_prompt"))
async def change_prompt(message: types.Message, state: FSMContext) -> None:
settings = await Settings.get_from_context(
message_thread_id=message.message_thread_id
)
settings.analysis_prompt = message.text
await settings.save()
await message.reply("Промпт обновлен")
await state.clear()
@prompt_router.message(Command("reset_prompt"))
async def reset_prompt(message: types.Message, state: FSMContext) -> None:
settings = await Settings.get_from_context(
message_thread_id=message.message_thread_id
)
settings.analysis_prompt = ANALYSIS_PROMPT
await settings.save()
await message.reply("Промпт для анализа отзыва сброшен к значению по умолчанию.")
await state.clear()
@prompt_router.message(Command("change_answer_prompt"))
async def change_answer_prompt_cmd(message: types.Message, state: FSMContext) -> None:
await message.reply("Введите новый промпт для ответов. /cancel для отмены")
await state.set_state("change_answer_prompt")
@prompt_router.message(StateFilter("change_answer_prompt"))
async def change_answer_prompt(message: types.Message, state: FSMContext) -> None:
settings = await Settings.get_from_context(
message_thread_id=message.message_thread_id
)
settings.answer_prompt = message.text
await settings.save()
await message.reply("Промпт для ответов обновлен")
await state.clear()
@prompt_router.message(Command("reset_answer_prompt"))
async def reset_answer_prompt(message: types.Message, state: FSMContext) -> None:
settings = await Settings.get_from_context(
message_thread_id=message.message_thread_id
)
settings.answer_prompt = ANSWER_PROMPT
await settings.save()
await message.reply("Промпт для ответов сброшен к значению по умолчанию.")
await state.clear()
+333
View File
@@ -0,0 +1,333 @@
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 = (
(
"🚨 <b>Требует обработки</b> #требует_обработки\n"
if review.is_critical
else ""
)
+ f"{'✅ Положительный' if review.is_good else '⚠️ Отрицательный'} отзыв №{count + 1}\n"
f"📅 Дата: {formatted_date}\n"
f"📦 Товар: <code>{escaped_product_id}</code> #{category_hashtag}\n"
f"{'🟪 #Wildberries' if review.platform == 'wb' else '🟦 #Ozon'}\n"
f"🔗 <a href='{product_url}'>Ссылка</a>\n"
f"⭐ Оценка: <code>{review.rating}/5</code>\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Тональность: <b>#{tone_name}</b>\n"
if review.tone_reason:
message += f"Причина: <b>#{review.tone_reason}</b>\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"Ответить (13 ★): {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"Ответить (45 ★): {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()