- app/services/ym.py: клиент Partner API — getGoodsFeedbacks с пагинацией и фильтром по дате, updateGoodsFeedbackComment (ответ), deleteGoodsFeedbackComment (удаление), обработка новых отзывов по аналогии с WB/Ozon - воркер ym_worker, включается при заданных YM_API_KEY и YM_BUSINESS_ID - кнопки отправки/удаления ответа, платформа в сообщении и в таблице, статистика /stat и /allstat - переменные YM_API_KEY, YM_BUSINESS_ID, YM_CHECK_INTERVAL, PRODUCTS_YM_SHEET_LINK - README и .env.example Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
464 lines
18 KiB
Python
464 lines
18 KiB
Python
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, YandexMarketClient
|
|
|
|
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("ym_delete:"))
|
|
async def callback_ym_delete(call: CallbackQuery) -> None:
|
|
comment_id = call.data.split(":", 1)[1]
|
|
|
|
ym_client = YandexMarketClient()
|
|
result = await ym_client.delete_comment(comment_id)
|
|
|
|
if "error" not in result:
|
|
try:
|
|
review = await Review.filter(response_id=comment_id, platform="ym").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("Комментарий успешно удалён на Яндекс Маркете.")
|
|
await call.message.reply("🗑 Ответ успешно удален с Яндекс Маркета")
|
|
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')}"
|
|
)
|
|
|
|
elif review.platform == "ym":
|
|
ym_client = YandexMarketClient()
|
|
result = await ym_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"ym_delete:{result['comment_id']}",
|
|
)
|
|
)
|
|
|
|
await call.message.answer(
|
|
f"✅ Ответ на #review{review_id} успешно отправлен на Яндекс Маркет",
|
|
reply_to_message_id=review.message_id,
|
|
reply_markup=markup.as_markup(),
|
|
)
|
|
else:
|
|
await call.message.reply(
|
|
f"❌ Ошибка отправки ответа на Яндекс Маркет: {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))
|