текущая версия (рабочая)
This commit is contained in:
+147
@@ -0,0 +1,147 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiogram import Bot, Dispatcher, F
|
||||
from tortoise import Tortoise
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
|
||||
from app import config
|
||||
from app.handlers import callbacks, commands, prompt
|
||||
from app.handlers.reviews import send_queue_worker, sheet_queue_worker
|
||||
from app.models import Settings
|
||||
from app.services.ozon import OzonClient
|
||||
from app.services.wb import WildberriesClient
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def ozon_worker():
|
||||
ozon_client = OzonClient()
|
||||
|
||||
while True:
|
||||
try:
|
||||
logger.info("Checking for new Ozon reviews...")
|
||||
ozon_result = await ozon_client.process_new_reviews()
|
||||
logger.info(
|
||||
f"Processed {ozon_result['processed']} Ozon reviews, {ozon_result['new']} new"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during Ozon review check: {e.__class__.__name__}: {e}")
|
||||
|
||||
logger.info(f"Next check in {config.OZON_CHECK_INTERVAL} seconds")
|
||||
await asyncio.sleep(config.OZON_CHECK_INTERVAL)
|
||||
|
||||
|
||||
async def wb_worker():
|
||||
wb_client = WildberriesClient()
|
||||
|
||||
while True:
|
||||
try:
|
||||
logger.info("Checking for new Wildberries reviews...")
|
||||
wb_result = await wb_client.process_new_reviews()
|
||||
logger.info(
|
||||
f"Processed {wb_result['processed']} Wildberries reviews, {wb_result['new']} new"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error during Wildberries review check: {e.__class__.__name__}: {e}"
|
||||
)
|
||||
|
||||
logger.info(f"Next check in {config.WB_CHECK_INTERVAL} seconds")
|
||||
await asyncio.sleep(config.WB_CHECK_INTERVAL)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Set up and run the bot."""
|
||||
session = (
|
||||
AiohttpSession(proxy=(config.TG_PROXY,))
|
||||
if config.TG_PROXY
|
||||
else AiohttpSession()
|
||||
)
|
||||
bot = Bot(token=config.BOT_TOKEN, session=session)
|
||||
dp = Dispatcher()
|
||||
|
||||
dp.include_router(commands.router)
|
||||
dp.include_router(callbacks.router)
|
||||
dp.include_router(prompt.prompt_router)
|
||||
allowed_threads = [
|
||||
config.BAD_REVIEWS_THREAD_ID,
|
||||
config.GOOD_REVIEWS_THREAD_ID,
|
||||
]
|
||||
if config.EMPTY_LOW_RATING_THREAD_ID:
|
||||
allowed_threads.append(config.EMPTY_LOW_RATING_THREAD_ID)
|
||||
if config.EMPTY_HIGH_RATING_THREAD_ID:
|
||||
allowed_threads.append(config.EMPTY_HIGH_RATING_THREAD_ID)
|
||||
dp.message.filter(
|
||||
(F.chat.id == config.TG_CHAT_ID)
|
||||
& F.message_thread_id.in_(allowed_threads)
|
||||
)
|
||||
|
||||
await Tortoise.init(
|
||||
db_url=config.TORTOISE_ORM["connections"]["default"],
|
||||
modules={"models": ["app.models"]},
|
||||
)
|
||||
await Tortoise.generate_schemas()
|
||||
|
||||
# Миграция: добавить колонку автоответа без текста, если её ещё нет
|
||||
try:
|
||||
conn = Tortoise.get_connection("default")
|
||||
await conn.execute_query(
|
||||
"ALTER TABLE settings ADD COLUMN auto_response_empty_enabled INTEGER DEFAULT 0"
|
||||
)
|
||||
except Exception as e:
|
||||
if "duplicate column" not in str(e).lower():
|
||||
logger.warning(f"Migration auto_response_empty_enabled: {e}")
|
||||
|
||||
# Миграция: шаблоны ответа на отзывы без текста (1–3 и 4–5 звёзд)
|
||||
for col in ("template_empty_low_rating", "template_empty_high_rating"):
|
||||
try:
|
||||
conn = Tortoise.get_connection("default")
|
||||
await conn.execute_query(
|
||||
f"ALTER TABLE settings ADD COLUMN {col} TEXT"
|
||||
)
|
||||
except Exception as e:
|
||||
if "duplicate column" not in str(e).lower():
|
||||
logger.warning(f"Migration {col}: {e}")
|
||||
|
||||
# Миграция: WB bables (теги отзыва) в таблицу review
|
||||
try:
|
||||
conn = Tortoise.get_connection("default")
|
||||
await conn.execute_query(
|
||||
"ALTER TABLE review ADD COLUMN bables TEXT"
|
||||
)
|
||||
except Exception as e:
|
||||
if "duplicate column" not in str(e).lower():
|
||||
logger.warning(f"Migration review.bables: {e}")
|
||||
|
||||
for is_good in [True, False]:
|
||||
settings, _ = await Settings.get_or_create(
|
||||
is_good=is_good,
|
||||
defaults={
|
||||
"analysis_enabled": not is_good,
|
||||
},
|
||||
)
|
||||
settings.auto_response_enabled = False
|
||||
settings.auto_response_empty_enabled = False
|
||||
await settings.save()
|
||||
|
||||
workers = asyncio.ensure_future(
|
||||
asyncio.gather(
|
||||
ozon_worker(),
|
||||
wb_worker(),
|
||||
send_queue_worker(),
|
||||
sheet_queue_worker(),
|
||||
)
|
||||
) # future must be saved to prevent GC
|
||||
print(f"Workers future: {workers}")
|
||||
|
||||
await dp.start_polling(bot)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,91 @@
|
||||
from datetime import timedelta, timezone
|
||||
|
||||
from envparse import env
|
||||
|
||||
env.read_envfile()
|
||||
|
||||
BOT_TOKEN = env.str("BOT_TOKEN", default="")
|
||||
TG_PROXY = env.str("TG_PROXY", default="")
|
||||
TG_CHAT_ID = env.int("TG_CHAT_ID", default=0)
|
||||
GOOD_REVIEWS_THREAD_ID = env.int("GOOD_REVIEWS_THREAD_ID", default=0)
|
||||
BAD_REVIEWS_THREAD_ID = env.int("BAD_REVIEWS_THREAD_ID", default=0)
|
||||
# Топики для отзывов без текста (только оценка): 1–3 звезды и 4–5 звезд
|
||||
EMPTY_LOW_RATING_THREAD_ID = env.int("EMPTY_LOW_RATING_THREAD_ID", default=0)
|
||||
EMPTY_HIGH_RATING_THREAD_ID = env.int("EMPTY_HIGH_RATING_THREAD_ID", default=0)
|
||||
NEGATIVE_RATING = env.int("NEGATIVE_RATING", default=3)
|
||||
|
||||
REVIEWS_SHEET_LINK = env.str("REVIEWS_SHEET_LINK", default="")
|
||||
|
||||
PRODUCTS_WB_SHEET_LINK = env.str("PRODUCTS_WB_SHEET_LINK", default="")
|
||||
PRODUCTS_OZON_SHEET_LINK = env.str("PRODUCTS_OZON_SHEET_LINK", default="")
|
||||
PRODUCTS_CACHE_TTL = env.int("PRODUCTS_CACHE_TTL", default=60 * 5)
|
||||
|
||||
OZON_API_KEY = env.str("OZON_API_KEY", default="")
|
||||
OZON_CLIENT_ID = env.str("OZON_CLIENT_ID", default="")
|
||||
WB_API_KEY = env.str("WB_API_KEY", default="")
|
||||
GPTUNNEL_API_KEY = env.str("GPTUNNEL_API_KEY", default="")
|
||||
|
||||
OZON_BASE_URL = "https://api-seller.ozon.ru"
|
||||
WB_BASE_URL = "https://feedbacks-api.wildberries.ru"
|
||||
GPT_API_URL = "https://gptunnel.ru/v1/chat/completions"
|
||||
|
||||
OZON_CHECK_INTERVAL = env.int("OZON_CHECK_INTERVAL", default=60)
|
||||
WB_CHECK_INTERVAL = env.int("WB_CHECK_INTERVAL", default=300)
|
||||
|
||||
ANALYSIS_PROMPT = """
|
||||
Вы - представитель бренда Magic Stories и анализируете отзывы клиентов.
|
||||
Классифицируйте отзыв по следующим критериям:
|
||||
|
||||
1. Тональность отзыва (позитивная, нейтральная, негативная).
|
||||
|
||||
2. Причина тональности:
|
||||
- Для негативных отзывов одна из категорий: Эргонолептика, Качество, Повреждённая упаковка, Недокомплект, Маркировка, Цена, Недовес, Проблемы со здоровьем, Жалобы на ЖКТ, Инородное тело, Жалобы на эффект, Жалобы на доставку, Общее, Эффект.
|
||||
- Для позитивных отзывов одна из категорий: Эргонолептика, Качество товара, Упаковка, Комплектация, Цена, Польза для здоровья, Эффективность, Доставка, Общее, Эффект.
|
||||
- Для нейтральных отзывов причина не требуется.
|
||||
|
||||
3. Критичность
|
||||
Отзыв считается критичным, если он классифицируется как любой тип из списка:
|
||||
- Здоровье, Жалобы на Жкт, Недокомплект(Недовес), Поврежденная упаковка, Инородное тело, Маркировка
|
||||
|
||||
Ответ должен быть СТРОГО в формате JSON:
|
||||
```json
|
||||
{
|
||||
"tone": "positive|negative|neutral",
|
||||
"reason": "причина тональности из списка или пустая строка для нейтральных отзывов",
|
||||
"is_critical": false // true если отзыв критичный
|
||||
}
|
||||
```
|
||||
|
||||
ОБЯЗАТЕЛЬНО используйте ТОЛЬКО указанные категории из списка. Никаких других категорий или вариантов.
|
||||
""".strip()
|
||||
|
||||
ANSWER_PROMPT = """
|
||||
Вы — профессиональный работник службы поддержки с многолетним опытом и чутким пониманием психологии человека,
|
||||
официальный представитель бренда Magic Stories. Ваша задача — вежливо и профессионально отвечать на отзывы клиентов.
|
||||
Ответ должен быть коротким, информативным и соответствовать тональности отзыва:
|
||||
- Негативный отзыв: выразите сожаление за произошедшее, предложите решение или помощь, объясните, как можно исправить ситуацию.
|
||||
- Позитивный отзыв: искренне поблагодарите за выбор бренда и положительный опыт, подтвердив, что вам важна обратная связь.
|
||||
- Нейтральный отзыв: подтвердите получение обратной связи и предложите дополнительную помощь, если это нужно.
|
||||
Не забывайте, что лесть перед клиентом люди хорошо считывают, поэтому избегайте чрезмерных похвал и канцеляризмов.
|
||||
Опишите решение проблемы или благодарность за положительный отзыв простым и понятным языком, который будет одинаково понятен любому клиенту. Также не отправляй почту
|
||||
В конец отзыва всегда добавляйте "С уважением, команда Magic Stories." с новой строки.
|
||||
""".strip()
|
||||
|
||||
# Шаблоны автоответа на отзывы без текста (только оценка)
|
||||
# 1–3 звезды — низкий рейтинг, 4–5 — высокий
|
||||
TEMPLATE_EMPTY_LOW_RATING = "Спасибо за отзыв, извините"
|
||||
TEMPLATE_EMPTY_HIGH_RATING = "Спасибо за отзыв, рады что вам понравилось"
|
||||
|
||||
DB_PATH = env.str("DB_PATH", default="db.sqlite3")
|
||||
|
||||
TORTOISE_ORM = {
|
||||
"connections": {"default": f"sqlite://{DB_PATH}"},
|
||||
"apps": {
|
||||
"models": {
|
||||
"models": ["app.models"],
|
||||
"default_connection": "default",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
TIMEZONE = timezone(timedelta(hours=3))
|
||||
@@ -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()
|
||||
@@ -0,0 +1,18 @@
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BannedMiddleware(BaseMiddleware):
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
return await handler(event, data)
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
|
||||
from app import config
|
||||
|
||||
session = (
|
||||
AiohttpSession(proxy=(config.TG_PROXY,))
|
||||
if config.TG_PROXY
|
||||
else AiohttpSession()
|
||||
)
|
||||
bot = Bot(config.BOT_TOKEN, session=session)
|
||||
dp = Dispatcher()
|
||||
@@ -0,0 +1,4 @@
|
||||
from app.models.review import Review
|
||||
from app.models.settings import Settings
|
||||
|
||||
__all__ = ["Settings", "Review"]
|
||||
@@ -0,0 +1,30 @@
|
||||
from tortoise import Model, fields
|
||||
|
||||
|
||||
class Review(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
external_id = fields.TextField()
|
||||
platform = fields.TextField() # "ozon" or "wb"
|
||||
rating = fields.IntField()
|
||||
is_good = fields.BooleanField()
|
||||
text = fields.TextField()
|
||||
published_at = fields.DatetimeField()
|
||||
|
||||
product_id = fields.TextField()
|
||||
product_name = fields.TextField()
|
||||
product_category = fields.TextField()
|
||||
product_subcategory = fields.TextField()
|
||||
|
||||
tone = fields.TextField(null=True) # positive, neutral, negative
|
||||
tone_reason = fields.TextField(null=True)
|
||||
is_critical = fields.BooleanField(null=True)
|
||||
|
||||
response_text = fields.TextField(null=True)
|
||||
response_id = fields.TextField(null=True)
|
||||
responded_at = fields.DatetimeField(null=True)
|
||||
|
||||
chat_id = fields.IntField(null=True)
|
||||
message_id = fields.IntField(null=True)
|
||||
images = fields.JSONField(null=True) # Store image URLs as JSON array
|
||||
# WB: теги отзыва из API (bables), например ["цена"]
|
||||
bables = fields.JSONField(null=True)
|
||||
@@ -0,0 +1,36 @@
|
||||
from tortoise import Model, fields
|
||||
|
||||
from app.config import ANALYSIS_PROMPT, ANSWER_PROMPT, GOOD_REVIEWS_THREAD_ID
|
||||
from app.models import Review
|
||||
|
||||
|
||||
class Settings(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
is_good = fields.BooleanField(unique=True)
|
||||
|
||||
analysis_prompt = fields.TextField(default=ANALYSIS_PROMPT)
|
||||
analysis_enabled = fields.BooleanField(default=False)
|
||||
|
||||
answer_prompt = fields.TextField(default=ANSWER_PROMPT)
|
||||
auto_response_enabled = fields.BooleanField(default=False) # автоответ на отзывы с текстом (GPT)
|
||||
auto_response_empty_enabled = fields.BooleanField(default=False) # автоответ на отзывы без текста (шаблон)
|
||||
|
||||
# Шаблоны ответа на отзывы без текста (редактируются из бота)
|
||||
template_empty_low_rating = fields.TextField(null=True)
|
||||
template_empty_high_rating = fields.TextField(null=True)
|
||||
|
||||
gpt_model = fields.CharField(max_length=20, default="gpt-4o-mini")
|
||||
max_tokens = fields.IntField(default=100)
|
||||
|
||||
@classmethod
|
||||
async def get_from_context(
|
||||
cls, *, review: Review | None = None, message_thread_id: int | None = None
|
||||
) -> "Settings":
|
||||
if review is not None:
|
||||
is_good = review.is_good
|
||||
elif message_thread_id is not None:
|
||||
is_good = message_thread_id == GOOD_REVIEWS_THREAD_ID
|
||||
else:
|
||||
raise ValueError("Either review or message_thread_id must be provided")
|
||||
|
||||
return await cls.get(is_good=is_good)
|
||||
@@ -0,0 +1,6 @@
|
||||
from app.services.gpt import GPTClient
|
||||
from app.services.ozon import OzonClient
|
||||
from app.services.stats import StatsService
|
||||
from app.services.wb import WildberriesClient
|
||||
|
||||
__all__ = ["OzonClient", "WildberriesClient", "GPTClient", "StatsService"]
|
||||
@@ -0,0 +1,102 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app import config
|
||||
from app.models import Settings, Review
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GPTClient:
|
||||
def __init__(self):
|
||||
self.api_url = config.GPT_API_URL
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {config.GPTUNNEL_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def _make_request(
|
||||
self, messages: list, model: str = "gpt-4o", max_tokens: int = 100, retry: bool = False
|
||||
) -> str:
|
||||
data = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
self.api_url, headers=self.headers, json=data
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
logger.info("GPT response: %s", result)
|
||||
answer = (
|
||||
result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
)
|
||||
return answer
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Error making GPT request: {e.__class__.__name__}: {e}")
|
||||
if retry:
|
||||
return ""
|
||||
logger.info("Waiting 15 seconds and retrying")
|
||||
await asyncio.sleep(15)
|
||||
return await self._make_request(messages, model, max_tokens, retry=True)
|
||||
|
||||
async def analyze_review(self, settings: Settings, review: Review) -> str:
|
||||
review_text = (
|
||||
f"Комментарий: {review.text}\n"
|
||||
f"Название товара: {review.product_name or '-'}\n"
|
||||
f"Оценка пользователя: {review.rating}"
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": settings.analysis_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": review_text,
|
||||
},
|
||||
]
|
||||
|
||||
return await self._make_request(messages, model=settings.gpt_model, max_tokens=settings.max_tokens)
|
||||
|
||||
async def generate_answer(self, settings: Settings, review: Review) -> str:
|
||||
review_text = (
|
||||
f"Комментарий: {review.text}\n"
|
||||
f"Название товара: {review.product_name or '-'}\n"
|
||||
f"Оценка пользователя: {review.rating}"
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": settings.answer_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{review_text}\nОтвет представителя бренда:",
|
||||
},
|
||||
]
|
||||
|
||||
return await self._make_request(
|
||||
messages, model=settings.gpt_model, max_tokens=settings.max_tokens
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def extract_analysis_data(analysis_text: str) -> dict[str, Any]:
|
||||
try:
|
||||
analysis_text = analysis_text.replace("```json", "").replace("```", "")
|
||||
data = json.loads(analysis_text)
|
||||
|
||||
tone = (data.get("tone") or "").lower()
|
||||
tone_reason = data.get("reason") or ""
|
||||
is_critical = data.get("is_critical") or None
|
||||
except (json.JSONDecodeError, ValueError, AttributeError) as e:
|
||||
logger.error(f"Error parsing analysis JSON: {e}. Raw text: {analysis_text}")
|
||||
raise e
|
||||
|
||||
return {
|
||||
"tone": tone,
|
||||
"tone_reason": tone_reason,
|
||||
"is_critical": is_critical,
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app import config
|
||||
from app.handlers.reviews import add_to_message_queue
|
||||
from app.models import Review, Settings
|
||||
from app.services.gpt import GPTClient
|
||||
from app.services.sheets import read_sheet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_cache_key() -> int:
|
||||
return int(time.time()) // config.PRODUCTS_CACHE_TTL
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_products_dict(_: int) -> dict[str, dict[str, Any]]:
|
||||
logger.info("Requesting Ozon products list")
|
||||
sheet = read_sheet(config.PRODUCTS_OZON_SHEET_LINK)
|
||||
return {
|
||||
str(product.get("ozon id акутальный") or product.get("Ozon ID")): product
|
||||
for product in sheet
|
||||
}
|
||||
|
||||
|
||||
class OzonClient:
|
||||
def __init__(self):
|
||||
self.base_url = config.OZON_BASE_URL
|
||||
self.headers = {
|
||||
"Client-Id": config.OZON_CLIENT_ID,
|
||||
"Api-Key": config.OZON_API_KEY,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def get_reviews(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
url = f"{self.base_url}/v1/review/list"
|
||||
data = {"limit": limit, "sort_dir": "DESC", "status": "ALL"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=self.headers, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json().get("reviews", [])
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Error fetching Ozon reviews: {e}")
|
||||
return []
|
||||
|
||||
async def post_comment(
|
||||
self, review_id: str, text: str, mark_processed: bool = True
|
||||
) -> dict[str, Any]:
|
||||
url = f"{self.base_url}/v1/review/comment/create"
|
||||
data = {
|
||||
"review_id": review_id,
|
||||
"text": text,
|
||||
"mark_review_as_processed": mark_processed,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=self.headers, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Error posting comment to Ozon: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
async def delete_comment(self, comment_id: str) -> dict[str, Any]:
|
||||
url = f"{self.base_url}/v1/review/comment/delete"
|
||||
data = {"comment_id": comment_id}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=self.headers, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Error deleting comment from Ozon: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
@staticmethod
|
||||
def format_datetime(iso_datetime: str) -> str:
|
||||
dt = datetime.fromisoformat(iso_datetime.replace("Z", "+00:00"))
|
||||
dt = dt.astimezone(config.TIMEZONE)
|
||||
return dt.strftime("%d.%m.%Y %H:%M:%S") + " МСК"
|
||||
|
||||
async def process_new_reviews(self) -> dict[str, int]:
|
||||
gpt_client = GPTClient()
|
||||
|
||||
try:
|
||||
latest_review = (
|
||||
await Review.filter(platform="ozon").order_by("-published_at").first()
|
||||
)
|
||||
latest_time = (
|
||||
latest_review.published_at.isoformat()
|
||||
if latest_review
|
||||
else "2000-01-01T00:00:00Z"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting latest review: {e}")
|
||||
latest_time = "2000-01-01T00:00:00Z"
|
||||
|
||||
reviews = await self.get_reviews()
|
||||
if not reviews:
|
||||
logger.info("No reviews fetched from Ozon")
|
||||
return {"processed": 0, "new": 0}
|
||||
|
||||
new_reviews = [r for r in reviews if r.get("published_at", "") > latest_time]
|
||||
if not new_reviews:
|
||||
logger.info("No new reviews found on Ozon")
|
||||
return {"processed": 0, "new": 0}
|
||||
|
||||
new_reviews.sort(key=lambda r: r.get("published_at", ""))
|
||||
|
||||
processed = 0
|
||||
for review in new_reviews:
|
||||
review_id = review.get("id")
|
||||
|
||||
existing = await Review.filter(
|
||||
external_id=review_id, platform="ozon"
|
||||
).exists()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
logger.info(f"Ozon: processing review {review}")
|
||||
|
||||
published_at = datetime.fromisoformat(
|
||||
review.get("published_at").replace("Z", "+00:00")
|
||||
).astimezone(config.TIMEZONE)
|
||||
|
||||
product_id = str(review.get("sku", ""))
|
||||
if not product_id:
|
||||
logger.error(f"Ozon: no product id for review {review}")
|
||||
continue
|
||||
|
||||
product = get_products_dict(get_cache_key()).get(product_id)
|
||||
if product:
|
||||
product.setdefault("Название", product.get("Товары"))
|
||||
product.setdefault("Категория", product.get("Тип товара"))
|
||||
product.setdefault("Подкатегория", product.get("Категория 2-го уровня"))
|
||||
else:
|
||||
logger.warning(f"Ozon: no product found for id {product_id}")
|
||||
product = {
|
||||
"Название": "неизвестно",
|
||||
"Категория": "неизвестно",
|
||||
"Подкатегория": "неизвестно",
|
||||
}
|
||||
|
||||
review_data = {
|
||||
"external_id": review_id,
|
||||
"platform": "ozon",
|
||||
"product_id": str(review.get("sku", "")),
|
||||
"rating": review.get("rating", 0),
|
||||
"is_good": review.get("rating", 0) > config.NEGATIVE_RATING,
|
||||
"text": review.get("text", ""),
|
||||
"product_name": product.get("Название", ""),
|
||||
"product_category": product.get("Категория", ""),
|
||||
"product_subcategory": product.get("Подкатегория", ""),
|
||||
"published_at": published_at,
|
||||
"processed": False,
|
||||
}
|
||||
|
||||
review_obj = await Review.create(**review_data)
|
||||
|
||||
settings = await Settings.get_from_context(review=review_obj)
|
||||
|
||||
if not review_obj.text and review_obj.is_good and not settings.auto_response_empty_enabled:
|
||||
add_to_message_queue(review_obj)
|
||||
await asyncio.sleep(0.1)
|
||||
processed += 1
|
||||
continue
|
||||
|
||||
if settings.analysis_enabled and review_obj.text:
|
||||
try:
|
||||
analysis = await gpt_client.analyze_review(settings, review_obj)
|
||||
|
||||
await review_obj.update_from_dict(
|
||||
gpt_client.extract_analysis_data(analysis)
|
||||
)
|
||||
await review_obj.save()
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing review {review_id}: {e}")
|
||||
|
||||
if settings.auto_response_enabled and review_obj.text:
|
||||
try:
|
||||
response = await gpt_client.generate_answer(settings, review_obj)
|
||||
if response:
|
||||
result = await self.post_comment(review_id, response)
|
||||
if "error" not in result:
|
||||
review_obj.response_text = response
|
||||
review_obj.response_id = result.get("comment_id")
|
||||
review_obj.responded_at = datetime.now(config.TIMEZONE)
|
||||
await review_obj.save()
|
||||
except Exception as e:
|
||||
logger.error(f"Error auto-responding to review {review_id}: {e}")
|
||||
|
||||
if settings.auto_response_empty_enabled and not review_obj.text:
|
||||
try:
|
||||
template = (
|
||||
(settings.template_empty_high_rating or config.TEMPLATE_EMPTY_HIGH_RATING)
|
||||
if review_obj.rating > config.NEGATIVE_RATING
|
||||
else (settings.template_empty_low_rating or config.TEMPLATE_EMPTY_LOW_RATING)
|
||||
)
|
||||
result = await self.post_comment(review_id, template)
|
||||
if "error" not in result:
|
||||
review_obj.response_text = template
|
||||
review_obj.response_id = result.get("comment_id")
|
||||
review_obj.responded_at = datetime.now(config.TIMEZONE)
|
||||
await review_obj.save()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error auto-responding (template) to review {review_id}: {e}"
|
||||
)
|
||||
|
||||
add_to_message_queue(review_obj)
|
||||
processed += 1
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return {"processed": processed, "new": len(new_reviews)}
|
||||
@@ -0,0 +1,79 @@
|
||||
import logging
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import gspread
|
||||
from gspread.utils import InsertDataOption, ValueInputOption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
sheets_api = gspread.service_account(filename="google-auth/key.json")
|
||||
# Таймаут HTTP-запросов (сек): защита от зависания при проблемах с сетью/Google API
|
||||
sheets_api.set_timeout(30)
|
||||
|
||||
# HTTP коды, при которых стоит сбросить кэш листа (устаревший объект)
|
||||
_CACHE_INVALIDATE_CODES = frozenset({401, 403, 404, 429})
|
||||
|
||||
|
||||
def parse_google_sheets_url(url: str) -> tuple[str, int | None]:
|
||||
spreadsheet_match = re.search(r"/d/([a-zA-Z0-9-_]+)", url)
|
||||
if not spreadsheet_match:
|
||||
raise ValueError("Invalid Google Sheets URL: Could not find spreadsheet ID")
|
||||
spreadsheet_id = spreadsheet_match.group(1)
|
||||
|
||||
worksheet_match = re.search(r"[?&]gid=(\d+)", url)
|
||||
worksheet_id = int(worksheet_match.group(1)) if worksheet_match else None
|
||||
|
||||
return spreadsheet_id, worksheet_id
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_sheet(sheet_link: str) -> gspread.Worksheet:
|
||||
spreadsheet_id, worksheet_id = parse_google_sheets_url(sheet_link)
|
||||
|
||||
spreadsheet = sheets_api.open_by_key(spreadsheet_id)
|
||||
if worksheet_id is not None:
|
||||
return spreadsheet.get_worksheet_by_id(worksheet_id)
|
||||
return spreadsheet.sheet1
|
||||
|
||||
|
||||
def add_rows_to_sheet(sheet_link: str, rows: list[list]) -> None:
|
||||
"""Append rows to Google Sheet. Raises on failure so caller can retry."""
|
||||
sheet = get_sheet(sheet_link)
|
||||
try:
|
||||
sheet.append_rows(
|
||||
rows,
|
||||
value_input_option=ValueInputOption.user_entered,
|
||||
insert_data_option=InsertDataOption.insert_rows,
|
||||
table_range="A1",
|
||||
)
|
||||
except gspread.exceptions.APIError as e:
|
||||
http_code = getattr(e, "response", None)
|
||||
status_code = http_code.status_code if http_code else None
|
||||
error_detail = getattr(e, "error", {})
|
||||
error_msg = error_detail.get("message", str(e)) if isinstance(error_detail, dict) else str(e)
|
||||
logger.exception(
|
||||
"SHEETS_API_ERROR: type=APIError http_code=%s message=%r error=%s",
|
||||
status_code,
|
||||
error_msg,
|
||||
error_detail,
|
||||
extra={"sheet_link": sheet_link, "rows_count": len(rows)},
|
||||
)
|
||||
if status_code in _CACHE_INVALIDATE_CODES:
|
||||
get_sheet.cache_clear()
|
||||
logger.info("SHEETS_CACHE_CLEARED: сброшен кэш get_sheet после ошибки %s", status_code)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"SHEETS_ERROR: type=%s message=%s sheet_link=%s rows_count=%s",
|
||||
type(e).__name__,
|
||||
str(e),
|
||||
sheet_link,
|
||||
len(rows),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def read_sheet(sheet_link: str) -> list[dict[str, Any]]:
|
||||
sheet = get_sheet(sheet_link)
|
||||
return sheet.get_all_records()
|
||||
@@ -0,0 +1,80 @@
|
||||
import logging
|
||||
from datetime import datetime, time
|
||||
|
||||
from app import config
|
||||
from app.models import Review
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StatsService:
|
||||
async def get_daily_stats(self, platform: str) -> dict:
|
||||
target_date = datetime.now(config.TIMEZONE).date()
|
||||
|
||||
day_start = datetime.combine(target_date, time.min, tzinfo=config.TIMEZONE)
|
||||
day_end = datetime.combine(target_date, time.max, tzinfo=config.TIMEZONE)
|
||||
|
||||
reviews = await Review.filter(
|
||||
platform=platform, published_at__gte=day_start, published_at__lte=day_end
|
||||
)
|
||||
|
||||
return self._calculate_stats(platform, reviews)
|
||||
|
||||
async def get_all_time_stats(self, platform: str) -> dict:
|
||||
reviews = await Review.filter(platform=platform)
|
||||
return self._calculate_stats(platform, reviews)
|
||||
|
||||
def _calculate_stats(self, platform: str, reviews: list) -> dict:
|
||||
good_reviews = 0
|
||||
bad_reviews = 0
|
||||
critical_reviews = 0
|
||||
|
||||
rating_counts = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
|
||||
tone_counts = {"positive": 0, "neutral": 0, "negative": 0}
|
||||
|
||||
for review in reviews:
|
||||
rating = min(max(review.rating, 1), 5)
|
||||
rating_counts[rating] += 1
|
||||
|
||||
if rating <= 3:
|
||||
bad_reviews += 1
|
||||
if review.is_critical:
|
||||
critical_reviews += 1
|
||||
else:
|
||||
good_reviews += 1
|
||||
|
||||
if review.tone in tone_counts:
|
||||
tone_counts[review.tone] += 1
|
||||
|
||||
total_reviews = good_reviews + bad_reviews
|
||||
bad_ratio = bad_reviews / total_reviews if total_reviews > 0 else 0
|
||||
critical_ratio = critical_reviews / total_reviews if total_reviews > 0 else 0
|
||||
critical_to_bad_ratio = critical_reviews / bad_reviews if bad_reviews > 0 else 0
|
||||
|
||||
total_ratings = sum(rating_counts.values())
|
||||
weighted_sum = sum(count * rating for rating, count in rating_counts.items())
|
||||
average_rating = weighted_sum / total_ratings if total_ratings > 0 else 0
|
||||
|
||||
return {
|
||||
"platform": platform,
|
||||
"good_reviews": good_reviews,
|
||||
"bad_reviews": bad_reviews,
|
||||
"total_reviews": total_reviews,
|
||||
"bad_ratio": bad_ratio,
|
||||
"critical_reviews": critical_reviews,
|
||||
"critical_ratio": critical_ratio,
|
||||
"critical_to_bad_ratio": critical_to_bad_ratio,
|
||||
"ratings": {
|
||||
"1": rating_counts[1],
|
||||
"2": rating_counts[2],
|
||||
"3": rating_counts[3],
|
||||
"4": rating_counts[4],
|
||||
"5": rating_counts[5],
|
||||
},
|
||||
"tone": {
|
||||
"positive": tone_counts["positive"],
|
||||
"neutral": tone_counts["neutral"],
|
||||
"negative": tone_counts["negative"],
|
||||
},
|
||||
"average_rating": average_rating,
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app import config
|
||||
from app.handlers.reviews import add_to_message_queue
|
||||
from app.models import Review, Settings
|
||||
from app.services.gpt import GPTClient
|
||||
from app.services.sheets import read_sheet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_cache_key() -> int:
|
||||
return int(time.time()) // config.PRODUCTS_CACHE_TTL
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_products_dict(_: int) -> dict[str, dict[str, Any]]:
|
||||
logger.info("Requesting WB products list")
|
||||
sheet = read_sheet(config.PRODUCTS_WB_SHEET_LINK)
|
||||
return {
|
||||
str(product.get("SKU") or product.get("Артикул WB")): product
|
||||
for product in sheet
|
||||
}
|
||||
|
||||
|
||||
class WildberriesClient:
|
||||
def __init__(self):
|
||||
self.base_url = config.WB_BASE_URL
|
||||
self.headers = {
|
||||
"Authorization": config.WB_API_KEY,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def get_reviews(
|
||||
self, date_from: int, date_to: int, limit: int = 30
|
||||
) -> list[dict[str, Any]]:
|
||||
url = f"{self.base_url}/api/v1/feedbacks"
|
||||
all_feedbacks = []
|
||||
|
||||
for is_answered in [True, False]:
|
||||
params = {
|
||||
"isAnswered": is_answered,
|
||||
"take": limit,
|
||||
"skip": 0,
|
||||
"order": "dateDesc",
|
||||
"dateFrom": date_from,
|
||||
"dateTo": date_to,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
url, headers=self.headers, params=params
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
feedbacks = data.get("data", {}).get("feedbacks", [])
|
||||
all_feedbacks.extend(feedbacks)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Error fetching Wildberries reviews: {e}")
|
||||
|
||||
return all_feedbacks
|
||||
|
||||
async def post_comment(self, review_id: str, text: str) -> dict[str, Any]:
|
||||
url = f"{self.base_url}/api/v1/feedbacks/answer"
|
||||
data = {"id": review_id, "text": text}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=self.headers, json=data)
|
||||
response.raise_for_status()
|
||||
|
||||
if response.status_code == 204:
|
||||
return {"success": True}
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Status code: {response.status_code}",
|
||||
}
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Error posting comment to Wildberries: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def delete_comment(self, reply_id: str) -> dict[str, Any]:
|
||||
url = f"https://www.wildberries.ru/api/comments/replies/{reply_id}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.delete(url, headers=self.headers)
|
||||
|
||||
if response.status_code == 204:
|
||||
return {"success": True}
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Status code: {response.status_code}",
|
||||
}
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Error deleting comment from Wildberries: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@staticmethod
|
||||
def format_datetime(iso_datetime: str) -> str:
|
||||
"""Format ISO datetime to a human-readable format."""
|
||||
try:
|
||||
dt = datetime.strptime(iso_datetime, "%Y-%m-%dT%H:%M:%SZ")
|
||||
dt = dt.replace(tzinfo=None) # Make naive before applying timezone
|
||||
dt = dt.astimezone(config.TIMEZONE)
|
||||
return dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||
except ValueError:
|
||||
return iso_datetime
|
||||
|
||||
async def process_new_reviews(self) -> dict[str, int]:
|
||||
gpt_client = GPTClient()
|
||||
|
||||
current_time = int(datetime.now(config.TIMEZONE).timestamp())
|
||||
try:
|
||||
latest_review = (
|
||||
await Review.filter(platform="wb").order_by("-published_at").first()
|
||||
)
|
||||
start_time = (
|
||||
int(latest_review.published_at.timestamp())
|
||||
if latest_review
|
||||
else int(datetime(2000, 1, 1, tzinfo=config.TIMEZONE).timestamp())
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting latest review: {e}")
|
||||
start_time = int(datetime(2000, 1, 1, tzinfo=config.TIMEZONE).timestamp())
|
||||
|
||||
reviews = await self.get_reviews(start_time, current_time)
|
||||
if not reviews:
|
||||
logger.info("No reviews fetched from Wildberries")
|
||||
return {"processed": 0, "new": 0}
|
||||
|
||||
processed = 0
|
||||
for feedback in reviews:
|
||||
review_id = feedback.get("id")
|
||||
|
||||
existing = await Review.filter(
|
||||
external_id=review_id, platform="wb"
|
||||
).exists()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
logger.info(f"WB: processing review {feedback}")
|
||||
|
||||
text = (
|
||||
(feedback.get("text", "") or "")
|
||||
+ "\n"
|
||||
+ (feedback.get("pros", "") or "")
|
||||
+ "\n"
|
||||
+ (feedback.get("cons", "") or "")
|
||||
).strip()
|
||||
|
||||
try:
|
||||
published_at = datetime.strptime(
|
||||
feedback.get("createdDate"), "%Y-%m-%dT%H:%M:%SZ"
|
||||
)
|
||||
published_at = published_at.replace(
|
||||
tzinfo=None
|
||||
) # Make naive before applying timezone
|
||||
published_at = published_at.astimezone(config.TIMEZONE)
|
||||
except (ValueError, TypeError):
|
||||
published_at = datetime.now(config.TIMEZONE)
|
||||
|
||||
product_id = str(feedback.get("productDetails", {}).get("nmId", ""))
|
||||
if not product_id:
|
||||
logger.error(f"WB: no product id in review {feedback}")
|
||||
continue
|
||||
|
||||
product = get_products_dict(get_cache_key()).get(product_id)
|
||||
if product:
|
||||
product.setdefault("Название", product.get("Наименование"))
|
||||
product.setdefault("Категория", product.get("Категория продавца"))
|
||||
product.setdefault("Подкатегория", "неизвестно")
|
||||
else:
|
||||
logger.warning(f"WB: no product found for id {product_id}")
|
||||
product = {
|
||||
"Название": feedback.get("productDetails", {}).get(
|
||||
"productName", ""
|
||||
),
|
||||
"Категория": "неизвестно",
|
||||
"Подкатегория": "неизвестно",
|
||||
}
|
||||
|
||||
# Extract image URLs from the feedback
|
||||
images = []
|
||||
if feedback.get("photoLinks"):
|
||||
for photo in feedback["photoLinks"]:
|
||||
if photo.get("fullSize"):
|
||||
images.append(photo["fullSize"])
|
||||
|
||||
bables_raw = feedback.get("bables")
|
||||
bables = (
|
||||
list(bables_raw)
|
||||
if isinstance(bables_raw, list)
|
||||
else ([bables_raw] if bables_raw is not None else None)
|
||||
)
|
||||
|
||||
review_data = {
|
||||
"external_id": review_id,
|
||||
"platform": "wb",
|
||||
"product_id": product_id,
|
||||
"product_name": product.get("Название", ""),
|
||||
"product_category": product.get("Категория", ""),
|
||||
"product_subcategory": product.get("Подкатегория", ""),
|
||||
"rating": feedback.get("productValuation", 0),
|
||||
"is_good": feedback.get("productValuation", 0) > config.NEGATIVE_RATING,
|
||||
"text": text,
|
||||
"published_at": published_at,
|
||||
"processed": False,
|
||||
"images": images if images else None,
|
||||
"bables": bables,
|
||||
}
|
||||
|
||||
review_obj = await Review.create(**review_data)
|
||||
|
||||
settings = await Settings.get_from_context(review=review_obj)
|
||||
|
||||
if not review_obj.text and review_obj.is_good and not settings.auto_response_empty_enabled:
|
||||
add_to_message_queue(review_obj)
|
||||
await asyncio.sleep(0.1)
|
||||
processed += 1
|
||||
continue
|
||||
|
||||
if settings.analysis_enabled and review_obj.text:
|
||||
try:
|
||||
analysis = await gpt_client.analyze_review(settings, review_obj)
|
||||
|
||||
await review_obj.update_from_dict(
|
||||
gpt_client.extract_analysis_data(analysis)
|
||||
)
|
||||
await review_obj.save()
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing review {review_id}: {e}")
|
||||
|
||||
if settings.auto_response_enabled and review_obj.text:
|
||||
try:
|
||||
response = await gpt_client.generate_answer(settings, review_obj)
|
||||
if response:
|
||||
result = await self.post_comment(review_id, response)
|
||||
if result.get("success"):
|
||||
review_obj.response_text = response
|
||||
review_obj.responded_at = datetime.now(config.TIMEZONE)
|
||||
await review_obj.save()
|
||||
except Exception as e:
|
||||
logger.error(f"Error auto-responding to review {review_id}: {e}")
|
||||
|
||||
if settings.auto_response_empty_enabled and not review_obj.text:
|
||||
try:
|
||||
template = (
|
||||
(settings.template_empty_high_rating or config.TEMPLATE_EMPTY_HIGH_RATING)
|
||||
if review_obj.rating > config.NEGATIVE_RATING
|
||||
else (settings.template_empty_low_rating or config.TEMPLATE_EMPTY_LOW_RATING)
|
||||
)
|
||||
result = await self.post_comment(review_id, template)
|
||||
if result.get("success"):
|
||||
review_obj.response_text = template
|
||||
review_obj.responded_at = datetime.now(config.TIMEZONE)
|
||||
await review_obj.save()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error auto-responding (template) to review {review_id}: {e}"
|
||||
)
|
||||
|
||||
add_to_message_queue(review_obj)
|
||||
processed += 1
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return {"processed": processed, "new": len(reviews)}
|
||||
Reference in New Issue
Block a user