текущая версия (рабочая)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from app.handlers import callbacks, commands, reviews
|
||||
|
||||
__all__ = ["commands", "callbacks", "reviews"]
|
||||
@@ -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))
|
||||
@@ -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>1–3 ★:</b> <code>{low}</code>\n\n"
|
||||
f"<b>4–5 ★:</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 число, чтобы обновить его"
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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"Ответить (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()
|
||||
Reference in New Issue
Block a user