Files
mp-answer-bot/app/handlers/callbacks.py
KirillandClaude Fable 5.1 4534ab2683 Добавить поддержку Яндекс Маркета (goods-feedback API)
- 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>
2026-09-04 20:13:38 +03:00

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))