Compare commits
2
Commits
1098ff4991
...
1ef71a0982
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ef71a0982 | ||
|
|
4534ab2683 |
+11
-1
@@ -18,7 +18,7 @@ NEGATIVE_RATING=3
|
|||||||
# --- Прокси (необязательно) ---
|
# --- Прокси (необязательно) ---
|
||||||
# Прокси для запросов к Telegram, например socks5://user:pass@host:1080
|
# Прокси для запросов к Telegram, например socks5://user:pass@host:1080
|
||||||
TG_PROXY=
|
TG_PROXY=
|
||||||
# Системные прокси для остальных запросов (Ozon, WB, GPT, Google)
|
# Системные прокси для остальных запросов (Ozon, WB, Яндекс Маркет, GPT, Google)
|
||||||
ALL_PROXY=
|
ALL_PROXY=
|
||||||
HTTPS_PROXY=
|
HTTPS_PROXY=
|
||||||
HTTP_PROXY=
|
HTTP_PROXY=
|
||||||
@@ -39,6 +39,7 @@ REVIEWS_SHEET_LINK=
|
|||||||
# Справочники товаров (название, категория) по артикулам
|
# Справочники товаров (название, категория) по артикулам
|
||||||
PRODUCTS_WB_SHEET_LINK=
|
PRODUCTS_WB_SHEET_LINK=
|
||||||
PRODUCTS_OZON_SHEET_LINK=
|
PRODUCTS_OZON_SHEET_LINK=
|
||||||
|
PRODUCTS_YM_SHEET_LINK=
|
||||||
|
|
||||||
# --- Ozon Seller API ---
|
# --- Ozon Seller API ---
|
||||||
OZON_API_KEY=
|
OZON_API_KEY=
|
||||||
@@ -51,6 +52,15 @@ WB_API_KEY=
|
|||||||
# Интервал опроса в секундах (по умолчанию 300)
|
# Интервал опроса в секундах (по умолчанию 300)
|
||||||
WB_CHECK_INTERVAL=300
|
WB_CHECK_INTERVAL=300
|
||||||
|
|
||||||
|
# --- Яндекс Маркет (Partner API, goods-feedback) ---
|
||||||
|
# Api-Key с доступом «Общение с покупателями» (communication) и идентификатор
|
||||||
|
# кабинета (businessId, виден в URL кабинета партнёра). Если не заданы, опрос
|
||||||
|
# Яндекс Маркета не запускается.
|
||||||
|
YM_API_KEY=
|
||||||
|
YM_BUSINESS_ID=
|
||||||
|
# Интервал опроса в секундах (по умолчанию 300)
|
||||||
|
YM_CHECK_INTERVAL=300
|
||||||
|
|
||||||
# --- GPT (через gptunnel.ru, OpenAI-совместимый API) ---
|
# --- GPT (через gptunnel.ru, OpenAI-совместимый API) ---
|
||||||
GPTUNNEL_API_KEY=
|
GPTUNNEL_API_KEY=
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# Бот для отзывов Ozon и Wildberries
|
# Бот для отзывов Ozon, Wildberries и Яндекс Маркета
|
||||||
|
|
||||||
Telegram-бот, который собирает новые отзывы с Ozon и Wildberries, публикует их в группу с топиками, анализирует через GPT и помогает на них отвечать: автоматически или по кнопке.
|
Telegram-бот, который собирает новые отзывы с Ozon, Wildberries и Яндекс Маркета, публикует их в группу с топиками, анализирует через GPT и помогает на них отвечать: автоматически или по кнопке.
|
||||||
|
|
||||||
## Что умеет
|
## Что умеет
|
||||||
|
|
||||||
- **Опрос маркетплейсов.** Фоновые воркеры по таймеру запрашивают новые отзывы через Ozon Seller API и WB Feedbacks API. Каждый отзыв сохраняется в SQLite, повторно не обрабатывается.
|
- **Опрос маркетплейсов.** Фоновые воркеры по таймеру запрашивают новые отзывы через Ozon Seller API, WB Feedbacks API и Яндекс Маркет Partner API (goods-feedback). Каждый отзыв сохраняется в SQLite, повторно не обрабатывается.
|
||||||
- **Раскладка по топикам.** Отзывы с оценкой выше порога (`NEGATIVE_RATING`, по умолчанию 3) уходят в топик хороших, остальные в топик плохих. Отзывы без текста (только оценка) можно направлять в отдельные топики.
|
- **Раскладка по топикам.** Отзывы с оценкой выше порога (`NEGATIVE_RATING`, по умолчанию 3) уходят в топик хороших, остальные в топик плохих. Отзывы без текста (только оценка) можно направлять в отдельные топики.
|
||||||
- **Анализ через GPT.** Определяет тональность, причину и критичность отзыва по промпту. Критичные отзывы помечаются `#требует_обработки`.
|
- **Анализ через GPT.** Определяет тональность, причину и критичность отзыва по промпту. Критичные отзывы помечаются `#требует_обработки`.
|
||||||
- **Ответы.** Три варианта: автоответ через GPT, автоответ по шаблону для отзывов без текста, либо ручной режим с кнопками «Сгенерировать ответ» / «Написать свой ответ» / «Отправить» / «Удалить ответ».
|
- **Ответы.** Три варианта: автоответ через GPT, автоответ по шаблону для отзывов без текста, либо ручной режим с кнопками «Сгенерировать ответ» / «Написать свой ответ» / «Отправить» / «Удалить ответ».
|
||||||
- **Google Sheets.** Каждый отзыв дописывается строкой в таблицу отзывов. Названия и категории товаров подтягиваются из справочников товаров WB и Ozon.
|
- **Google Sheets.** Каждый отзыв дописывается строкой в таблицу отзывов. Названия и категории товаров подтягиваются из справочников товаров WB, Ozon и Яндекс Маркета.
|
||||||
- **Статистика.** Команды `/stat` (за сегодня) и `/allstat` (за всё время): количество, доля плохих, средний рейтинг, критичные, тональность.
|
- **Статистика.** Команды `/stat` (за сегодня) и `/allstat` (за всё время): количество, доля плохих, средний рейтинг, критичные, тональность.
|
||||||
|
|
||||||
Настройки (промпты, автоответы, модель, шаблоны) хранятся в базе отдельно для хороших и плохих отзывов и меняются прямо из бота. Они сохраняются между перезапусками и редеплоями, поэтому база должна лежать на постоянном томе.
|
Настройки (промпты, автоответы, модель, шаблоны) хранятся в базе отдельно для хороших и плохих отзывов и меняются прямо из бота. Они сохраняются между перезапусками и редеплоями, поэтому база должна лежать на постоянном томе.
|
||||||
@@ -17,12 +17,12 @@ Telegram-бот, который собирает новые отзывы с Ozon
|
|||||||
|
|
||||||
```
|
```
|
||||||
Ozon API ─┐ ┌─> Telegram (топики хороших / плохих / без текста)
|
Ozon API ─┐ ┌─> Telegram (топики хороших / плохих / без текста)
|
||||||
├─> воркер ─> SQLite ─> очереди ─┤
|
WB API ──┼─> воркер ─> SQLite ─> очереди ─┤
|
||||||
WB API ──┘ │ └─> Google Sheets (таблица отзывов)
|
YM API ──┘ │ └─> Google Sheets (таблица отзывов)
|
||||||
└─> GPT: анализ + генерация ответа
|
└─> GPT: анализ + генерация ответа
|
||||||
```
|
```
|
||||||
|
|
||||||
Точка входа `app/__main__.py` поднимает четыре бесконечные задачи: опрос Ozon, опрос WB, отправка сообщений в Telegram, запись в Google Sheets. Затем запускает polling Telegram. Бот реагирует только на сообщения из чата `TG_CHAT_ID` в разрешённых топиках.
|
Точка входа `app/__main__.py` поднимает бесконечные задачи: опрос Ozon, опрос WB, опрос Яндекс Маркета (если заданы `YM_API_KEY` и `YM_BUSINESS_ID`), отправка сообщений в Telegram, запись в Google Sheets. Затем запускает polling Telegram. Бот реагирует только на сообщения из чата `TG_CHAT_ID` в разрешённых топиках.
|
||||||
|
|
||||||
Структура кода:
|
Структура кода:
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ WB API ──┘ │ └─> Google Sheets (таб
|
|||||||
| `app/handlers/callbacks.py` | Кнопки: генерация, отправка, удаление ответа, переключатели настроек |
|
| `app/handlers/callbacks.py` | Кнопки: генерация, отправка, удаление ответа, переключатели настроек |
|
||||||
| `app/handlers/commands.py` | Команды `/stat`, `/allstat`, `/settings`, шаблоны, `/max_tokens` |
|
| `app/handlers/commands.py` | Команды `/stat`, `/allstat`, `/settings`, шаблоны, `/max_tokens` |
|
||||||
| `app/handlers/prompt.py` | Просмотр, изменение и сброс промптов |
|
| `app/handlers/prompt.py` | Просмотр, изменение и сброс промптов |
|
||||||
| `app/services/ozon.py`, `wb.py` | Клиенты API маркетплейсов и обработка новых отзывов |
|
| `app/services/ozon.py`, `wb.py`, `ym.py` | Клиенты API маркетплейсов и обработка новых отзывов |
|
||||||
| `app/services/gpt.py` | Запросы к GPT, разбор JSON анализа |
|
| `app/services/gpt.py` | Запросы к GPT, разбор JSON анализа |
|
||||||
| `app/services/sheets.py` | Работа с Google Sheets через gspread |
|
| `app/services/sheets.py` | Работа с Google Sheets через gspread |
|
||||||
| `app/services/stats.py` | Подсчёт статистики |
|
| `app/services/stats.py` | Подсчёт статистики |
|
||||||
@@ -45,7 +45,7 @@ WB API ──┘ │ └─> Google Sheets (таб
|
|||||||
- [uv](https://docs.astral.sh/uv/) (рекомендуется) или pip
|
- [uv](https://docs.astral.sh/uv/) (рекомендуется) или pip
|
||||||
- Telegram-бот, добавленный в супергруппу с включёнными топиками
|
- Telegram-бот, добавленный в супергруппу с включёнными топиками
|
||||||
- Сервисный аккаунт Google с доступом к Google Sheets API
|
- Сервисный аккаунт Google с доступом к Google Sheets API
|
||||||
- Ключи API Ozon Seller, Wildberries (категория «Отзывы и вопросы») и gptunnel.ru
|
- Ключи API Ozon Seller, Wildberries (категория «Отзывы и вопросы»), Яндекс Маркета (Api-Key с доступом «Общение с покупателями», необязательно) и gptunnel.ru
|
||||||
|
|
||||||
## Установка и запуск
|
## Установка и запуск
|
||||||
|
|
||||||
@@ -94,8 +94,8 @@ WB API ──┘ │ └─> Google Sheets (таб
|
|||||||
| `BOT_TOKEN` | Токен бота от @BotFather |
|
| `BOT_TOKEN` | Токен бота от @BotFather |
|
||||||
| `TG_CHAT_ID`, `GOOD_REVIEWS_THREAD_ID`, `BAD_REVIEWS_THREAD_ID` | Группа и топики |
|
| `TG_CHAT_ID`, `GOOD_REVIEWS_THREAD_ID`, `BAD_REVIEWS_THREAD_ID` | Группа и топики |
|
||||||
| `GOOGLE_SERVICE_ACCOUNT_JSON` | Содержимое `key.json` одной строкой или в base64 |
|
| `GOOGLE_SERVICE_ACCOUNT_JSON` | Содержимое `key.json` одной строкой или в base64 |
|
||||||
| `REVIEWS_SHEET_LINK`, `PRODUCTS_WB_SHEET_LINK`, `PRODUCTS_OZON_SHEET_LINK` | Ссылки на таблицы |
|
| `REVIEWS_SHEET_LINK`, `PRODUCTS_WB_SHEET_LINK`, `PRODUCTS_OZON_SHEET_LINK`, `PRODUCTS_YM_SHEET_LINK` | Ссылки на таблицы |
|
||||||
| `OZON_API_KEY`, `OZON_CLIENT_ID`, `WB_API_KEY`, `GPTUNNEL_API_KEY` | Ключи API |
|
| `OZON_API_KEY`, `OZON_CLIENT_ID`, `WB_API_KEY`, `YM_API_KEY`, `YM_BUSINESS_ID`, `GPTUNNEL_API_KEY` | Ключи API |
|
||||||
| `BRAND_NAME` | Название бренда для промптов |
|
| `BRAND_NAME` | Название бренда для промптов |
|
||||||
|
|
||||||
Ключ Google удобнее передавать в base64, тогда в значении нет кавычек и переносов строк:
|
Ключ Google удобнее передавать в base64, тогда в значении нет кавычек и переносов строк:
|
||||||
@@ -136,6 +136,12 @@ Compose читает `.env` из корня, если файл есть, и хр
|
|||||||
|
|
||||||
**Справочник товаров Ozon** (`PRODUCTS_OZON_SHEET_LINK`). Ключевой столбец `Ozon ID` или `ozon id акутальный`. Используются `Товары`, `Тип товара`, `Категория 2-го уровня`.
|
**Справочник товаров Ozon** (`PRODUCTS_OZON_SHEET_LINK`). Ключевой столбец `Ozon ID` или `ozon id акутальный`. Используются `Товары`, `Тип товара`, `Категория 2-го уровня`.
|
||||||
|
|
||||||
|
**Справочник товаров Яндекс Маркета** (`PRODUCTS_YM_SHEET_LINK`, необязательно). Ключевой столбец `SKU`, `Ваш SKU`, `offerId` или `Артикул` — это SKU продавца (`offerId` в API). Используются столбцы `Название`/`Наименование`/`Название товара` и `Категория`/`Категория продавца`. Если таблица не задана или товар не найден, название и категория записываются как «неизвестно».
|
||||||
|
|
||||||
|
## Яндекс Маркет
|
||||||
|
|
||||||
|
Используются методы Partner API `getGoodsFeedbacks` (список отзывов, с пагинацией и фильтром по дате), `updateGoodsFeedbackComment` (создание комментария к отзыву) и `deleteGoodsFeedbackComment` (удаление ответа по кнопке). Текст отзыва собирается из комментария, достоинств и недостатков. Фото из отзыва отправляются в Telegram вместе с сообщением. API отдаёт отзывы не старше 6 месяцев, поэтому при первом запуске бот заберёт отзывы за этот период.
|
||||||
|
|
||||||
Если в ссылке на таблицу есть `gid=`, берётся этот лист, иначе первый. Справочники кэшируются на 5 минут.
|
Если в ссылке на таблицу есть `gid=`, берётся этот лист, иначе первый. Справочники кэшируются на 5 минут.
|
||||||
|
|
||||||
## Команды бота
|
## Команды бота
|
||||||
|
|||||||
+33
-6
@@ -11,6 +11,7 @@ from app.handlers.reviews import send_queue_worker, sheet_queue_worker
|
|||||||
from app.models import Settings
|
from app.models import Settings
|
||||||
from app.services.ozon import OzonClient
|
from app.services.ozon import OzonClient
|
||||||
from app.services.wb import WildberriesClient
|
from app.services.wb import WildberriesClient
|
||||||
|
from app.services.ym import YandexMarketClient
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -56,6 +57,25 @@ async def wb_worker():
|
|||||||
await asyncio.sleep(config.WB_CHECK_INTERVAL)
|
await asyncio.sleep(config.WB_CHECK_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
async def ym_worker():
|
||||||
|
ym_client = YandexMarketClient()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
logger.info("Checking for new Yandex Market reviews...")
|
||||||
|
ym_result = await ym_client.process_new_reviews()
|
||||||
|
logger.info(
|
||||||
|
f"Processed {ym_result['processed']} Yandex Market reviews, {ym_result['new']} new"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error during Yandex Market review check: {e.__class__.__name__}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Next check in {config.YM_CHECK_INTERVAL} seconds")
|
||||||
|
await asyncio.sleep(config.YM_CHECK_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
"""Set up and run the bot."""
|
"""Set up and run the bot."""
|
||||||
session = (
|
session = (
|
||||||
@@ -130,13 +150,20 @@ async def main():
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
worker_tasks = [
|
||||||
|
ozon_worker(),
|
||||||
|
wb_worker(),
|
||||||
|
send_queue_worker(),
|
||||||
|
sheet_queue_worker(),
|
||||||
|
]
|
||||||
|
# Опрос Яндекс Маркета включается только при заданных ключе и businessId
|
||||||
|
if config.YM_API_KEY and config.YM_BUSINESS_ID:
|
||||||
|
worker_tasks.append(ym_worker())
|
||||||
|
else:
|
||||||
|
logger.info("YM_API_KEY / YM_BUSINESS_ID not set, Yandex Market polling disabled")
|
||||||
|
|
||||||
workers = asyncio.ensure_future(
|
workers = asyncio.ensure_future(
|
||||||
asyncio.gather(
|
asyncio.gather(*worker_tasks)
|
||||||
ozon_worker(),
|
|
||||||
wb_worker(),
|
|
||||||
send_queue_worker(),
|
|
||||||
sheet_queue_worker(),
|
|
||||||
)
|
|
||||||
) # future must be saved to prevent GC
|
) # future must be saved to prevent GC
|
||||||
print(f"Workers future: {workers}")
|
print(f"Workers future: {workers}")
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ REVIEWS_SHEET_LINK = env.str("REVIEWS_SHEET_LINK", default="")
|
|||||||
|
|
||||||
PRODUCTS_WB_SHEET_LINK = env.str("PRODUCTS_WB_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_OZON_SHEET_LINK = env.str("PRODUCTS_OZON_SHEET_LINK", default="")
|
||||||
|
PRODUCTS_YM_SHEET_LINK = env.str("PRODUCTS_YM_SHEET_LINK", default="")
|
||||||
PRODUCTS_CACHE_TTL = env.int("PRODUCTS_CACHE_TTL", default=60 * 5)
|
PRODUCTS_CACHE_TTL = env.int("PRODUCTS_CACHE_TTL", default=60 * 5)
|
||||||
|
|
||||||
# Ключ сервисного аккаунта Google: либо содержимое JSON (или его base64)
|
# Ключ сервисного аккаунта Google: либо содержимое JSON (или его base64)
|
||||||
@@ -30,6 +31,9 @@ GOOGLE_SERVICE_ACCOUNT_FILE = env.str(
|
|||||||
OZON_API_KEY = env.str("OZON_API_KEY", default="")
|
OZON_API_KEY = env.str("OZON_API_KEY", default="")
|
||||||
OZON_CLIENT_ID = env.str("OZON_CLIENT_ID", default="")
|
OZON_CLIENT_ID = env.str("OZON_CLIENT_ID", default="")
|
||||||
WB_API_KEY = env.str("WB_API_KEY", default="")
|
WB_API_KEY = env.str("WB_API_KEY", default="")
|
||||||
|
# Яндекс Маркет: Api-Key и идентификатор кабинета (businessId)
|
||||||
|
YM_API_KEY = env.str("YM_API_KEY", default="")
|
||||||
|
YM_BUSINESS_ID = env.int("YM_BUSINESS_ID", default=0)
|
||||||
GPTUNNEL_API_KEY = env.str("GPTUNNEL_API_KEY", default="")
|
GPTUNNEL_API_KEY = env.str("GPTUNNEL_API_KEY", default="")
|
||||||
|
|
||||||
# Название бренда, от имени которого бот анализирует отзывы и отвечает на них
|
# Название бренда, от имени которого бот анализирует отзывы и отвечает на них
|
||||||
@@ -37,10 +41,12 @@ BRAND_NAME = env.str("BRAND_NAME", default="Magic Stories")
|
|||||||
|
|
||||||
OZON_BASE_URL = "https://api-seller.ozon.ru"
|
OZON_BASE_URL = "https://api-seller.ozon.ru"
|
||||||
WB_BASE_URL = "https://feedbacks-api.wildberries.ru"
|
WB_BASE_URL = "https://feedbacks-api.wildberries.ru"
|
||||||
|
YM_BASE_URL = "https://api.partner.market.yandex.ru"
|
||||||
GPT_API_URL = "https://gptunnel.ru/v1/chat/completions"
|
GPT_API_URL = "https://gptunnel.ru/v1/chat/completions"
|
||||||
|
|
||||||
OZON_CHECK_INTERVAL = env.int("OZON_CHECK_INTERVAL", default=60)
|
OZON_CHECK_INTERVAL = env.int("OZON_CHECK_INTERVAL", default=60)
|
||||||
WB_CHECK_INTERVAL = env.int("WB_CHECK_INTERVAL", default=300)
|
WB_CHECK_INTERVAL = env.int("WB_CHECK_INTERVAL", default=300)
|
||||||
|
YM_CHECK_INTERVAL = env.int("YM_CHECK_INTERVAL", default=300)
|
||||||
|
|
||||||
ANALYSIS_PROMPT = f"""
|
ANALYSIS_PROMPT = f"""
|
||||||
Вы - представитель бренда {BRAND_NAME} и анализируете отзывы клиентов.
|
Вы - представитель бренда {BRAND_NAME} и анализируете отзывы клиентов.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from tortoise.exceptions import DoesNotExist
|
|||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
from app.models import Review, Settings
|
from app.models import Review, Settings
|
||||||
from app.services import GPTClient, OzonClient, WildberriesClient
|
from app.services import GPTClient, OzonClient, WildberriesClient, YandexMarketClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -147,6 +147,33 @@ async def callback_wb_delete(call: CallbackQuery) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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:"))
|
@router.callback_query(F.data.startswith("generate_answer:"))
|
||||||
async def callback_generate_answer(call: CallbackQuery) -> None:
|
async def callback_generate_answer(call: CallbackQuery) -> None:
|
||||||
review_id = call.data.split(":", 1)[1]
|
review_id = call.data.split(":", 1)[1]
|
||||||
@@ -344,6 +371,34 @@ async def callback_send_answer(call: CallbackQuery) -> None:
|
|||||||
f"❌ Ошибка отправки ответа на Wildberries: {result.get('error')}"
|
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("Ответ отправлен")
|
await call.answer("Ответ отправлен")
|
||||||
review.responded_at = datetime.now()
|
review.responded_at = datetime.now()
|
||||||
await review.save()
|
await review.save()
|
||||||
|
|||||||
@@ -28,12 +28,21 @@ async def cmd_stats(message: Message) -> None:
|
|||||||
if is_all_time
|
if is_all_time
|
||||||
else await stats_service.get_daily_stats("wb")
|
else await stats_service.get_daily_stats("wb")
|
||||||
)
|
)
|
||||||
|
ym_stats = (
|
||||||
|
await stats_service.get_all_time_stats("ym")
|
||||||
|
if is_all_time
|
||||||
|
else await stats_service.get_daily_stats("ym")
|
||||||
|
)
|
||||||
|
|
||||||
period_text = "за все время" if is_all_time else "за сегодня"
|
period_text = "за все время" if is_all_time else "за сегодня"
|
||||||
|
|
||||||
stats_text = f"📊 <b>Статистика {period_text}</b>\n\n"
|
stats_text = f"📊 <b>Статистика {period_text}</b>\n\n"
|
||||||
|
|
||||||
for platform_name, stats in [("Ozon", ozon_stats), ("Wildberries", wb_stats)]:
|
for platform_name, stats in [
|
||||||
|
("Ozon", ozon_stats),
|
||||||
|
("Wildberries", wb_stats),
|
||||||
|
("Яндекс Маркет", ym_stats),
|
||||||
|
]:
|
||||||
stats_text += (
|
stats_text += (
|
||||||
f"<b>{platform_name}:</b>\n"
|
f"<b>{platform_name}:</b>\n"
|
||||||
f"Всего отзывов: {stats['total_reviews']}\n"
|
f"Всего отзывов: {stats['total_reviews']}\n"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
from asyncio import Queue
|
from asyncio import Queue
|
||||||
|
from urllib.parse import quote
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from aiogram import Router, types
|
from aiogram import Router, types
|
||||||
@@ -15,6 +16,9 @@ from app.services.sheets import add_rows_to_sheet
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
|
PLATFORM_NAMES = {"ozon": "Ozon", "wb": "Wildberries", "ym": "Яндекс Маркет"}
|
||||||
|
PLATFORM_TAGS = {"ozon": "🟦 #Ozon", "wb": "🟪 #Wildberries", "ym": "🟨 #ЯндексМаркет"}
|
||||||
|
|
||||||
send_queue = Queue()
|
send_queue = Queue()
|
||||||
sheet_queue = Queue()
|
sheet_queue = Queue()
|
||||||
|
|
||||||
@@ -52,7 +56,7 @@ def render_review_to_sheet(review: Review) -> list[str]:
|
|||||||
# Порядок столбцов таблицы (12 колонок): 1–8 данные, 9 тональность, 10 причина, 11 критичность, 12 Тэги
|
# Порядок столбцов таблицы (12 колонок): 1–8 данные, 9 тональность, 10 причина, 11 критичность, 12 Тэги
|
||||||
return [
|
return [
|
||||||
formatted_date,
|
formatted_date,
|
||||||
"Ozon" if review.platform == "ozon" else "Wildberries",
|
PLATFORM_NAMES.get(review.platform, review.platform),
|
||||||
review.product_name or "-",
|
review.product_name or "-",
|
||||||
review.product_category or "-",
|
review.product_category or "-",
|
||||||
review.product_subcategory or "-",
|
review.product_subcategory or "-",
|
||||||
@@ -192,6 +196,9 @@ async def send_review(review: Review) -> None:
|
|||||||
)
|
)
|
||||||
if review.platform == "ozon":
|
if review.platform == "ozon":
|
||||||
product_url = f"https://www.ozon.ru/product/{review.product_id}/"
|
product_url = f"https://www.ozon.ru/product/{review.product_id}/"
|
||||||
|
elif review.platform == "ym":
|
||||||
|
# У Маркета нет публичной ссылки по SKU продавца — ведём в поиск по нему
|
||||||
|
product_url = f"https://market.yandex.ru/search?text={quote(review.product_id)}"
|
||||||
else:
|
else:
|
||||||
product_url = (
|
product_url = (
|
||||||
f"https://www.wildberries.ru/catalog/{review.product_id}/detail.aspx"
|
f"https://www.wildberries.ru/catalog/{review.product_id}/detail.aspx"
|
||||||
@@ -219,7 +226,7 @@ async def send_review(review: Review) -> None:
|
|||||||
+ f"{'✅ Положительный' if review.is_good else '⚠️ Отрицательный'} отзыв №{count + 1}\n"
|
+ f"{'✅ Положительный' if review.is_good else '⚠️ Отрицательный'} отзыв №{count + 1}\n"
|
||||||
f"📅 Дата: {formatted_date}\n"
|
f"📅 Дата: {formatted_date}\n"
|
||||||
f"📦 Товар: <code>{escaped_product_id}</code> #{category_hashtag}\n"
|
f"📦 Товар: <code>{escaped_product_id}</code> #{category_hashtag}\n"
|
||||||
f"{'🟪 #Wildberries' if review.platform == 'wb' else '🟦 #Ozon'}\n"
|
f"{PLATFORM_TAGS.get(review.platform, '#' + review.platform)}\n"
|
||||||
f"🔗 <a href='{product_url}'>Ссылка</a>\n"
|
f"🔗 <a href='{product_url}'>Ссылка</a>\n"
|
||||||
f"⭐ Оценка: <code>{review.rating}/5</code>\n"
|
f"⭐ Оценка: <code>{review.rating}/5</code>\n"
|
||||||
f"💬 Отзыв: {escaped_text}\n\n"
|
f"💬 Отзыв: {escaped_text}\n\n"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from tortoise import Model, fields
|
|||||||
class Review(Model):
|
class Review(Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(pk=True)
|
||||||
external_id = fields.TextField()
|
external_id = fields.TextField()
|
||||||
platform = fields.TextField() # "ozon" or "wb"
|
platform = fields.TextField() # "ozon", "wb" or "ym" (Яндекс Маркет)
|
||||||
rating = fields.IntField()
|
rating = fields.IntField()
|
||||||
is_good = fields.BooleanField()
|
is_good = fields.BooleanField()
|
||||||
text = fields.TextField()
|
text = fields.TextField()
|
||||||
|
|||||||
@@ -2,5 +2,12 @@ from app.services.gpt import GPTClient
|
|||||||
from app.services.ozon import OzonClient
|
from app.services.ozon import OzonClient
|
||||||
from app.services.stats import StatsService
|
from app.services.stats import StatsService
|
||||||
from app.services.wb import WildberriesClient
|
from app.services.wb import WildberriesClient
|
||||||
|
from app.services.ym import YandexMarketClient
|
||||||
|
|
||||||
__all__ = ["OzonClient", "WildberriesClient", "GPTClient", "StatsService"]
|
__all__ = [
|
||||||
|
"OzonClient",
|
||||||
|
"WildberriesClient",
|
||||||
|
"YandexMarketClient",
|
||||||
|
"GPTClient",
|
||||||
|
"StatsService",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
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__)
|
||||||
|
|
||||||
|
# API отдаёт отзывы максимум за 6 месяцев; берём чуть меньше, чтобы не упереться в границу
|
||||||
|
_MAX_LOOKBACK = timedelta(days=180 - 1)
|
||||||
|
# Защита от бесконечной пагинации при первом запуске
|
||||||
|
_MAX_PAGES = 40
|
||||||
|
|
||||||
|
|
||||||
|
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 Yandex Market products list")
|
||||||
|
if not config.PRODUCTS_YM_SHEET_LINK:
|
||||||
|
return {}
|
||||||
|
sheet = read_sheet(config.PRODUCTS_YM_SHEET_LINK)
|
||||||
|
return {
|
||||||
|
str(
|
||||||
|
product.get("SKU")
|
||||||
|
or product.get("Ваш SKU")
|
||||||
|
or product.get("offerId")
|
||||||
|
or product.get("Артикул")
|
||||||
|
): product
|
||||||
|
for product in sheet
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_datetime(value: str | None) -> datetime:
|
||||||
|
"""createdAt приходит в ISO 8601 (с Z или смещением)."""
|
||||||
|
if not value:
|
||||||
|
return datetime.now(config.TIMEZONE)
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=config.TIMEZONE)
|
||||||
|
return dt.astimezone(config.TIMEZONE)
|
||||||
|
except ValueError:
|
||||||
|
return datetime.now(config.TIMEZONE)
|
||||||
|
|
||||||
|
|
||||||
|
class YandexMarketClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = config.YM_BASE_URL
|
||||||
|
self.business_id = config.YM_BUSINESS_ID
|
||||||
|
self.headers = {
|
||||||
|
"Api-Key": config.YM_API_KEY,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _error_from_response(response: httpx.Response) -> str | None:
|
||||||
|
"""Возвращает текст ошибки, если API ответил status=ERROR."""
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if data.get("status") == "ERROR":
|
||||||
|
errors = data.get("errors") or []
|
||||||
|
return "; ".join(
|
||||||
|
f"{e.get('code')}: {e.get('message')}" for e in errors
|
||||||
|
) or "unknown error"
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_reviews(
|
||||||
|
self, date_from: datetime | None = None, limit: int = 50
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Все отзывы, начиная с date_from (не включительно), с обходом страниц."""
|
||||||
|
url = f"{self.base_url}/v2/businesses/{self.business_id}/goods-feedback"
|
||||||
|
body: dict[str, Any] = {"reactionStatus": "ALL"}
|
||||||
|
if date_from is not None:
|
||||||
|
body["dateTimeFrom"] = date_from.astimezone(config.TIMEZONE).isoformat(
|
||||||
|
timespec="seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
all_feedbacks: list[dict[str, Any]] = []
|
||||||
|
page_token: str | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
for _ in range(_MAX_PAGES):
|
||||||
|
params: dict[str, Any] = {"limit": limit}
|
||||||
|
if page_token:
|
||||||
|
params["page_token"] = page_token
|
||||||
|
response = await client.post(
|
||||||
|
url, headers=self.headers, params=params, json=body
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if data.get("status") == "ERROR":
|
||||||
|
logger.error(
|
||||||
|
f"Yandex Market returned error: {data.get('errors')}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
result = data.get("result", {}) or {}
|
||||||
|
all_feedbacks.extend(result.get("feedbacks", []) or [])
|
||||||
|
page_token = (result.get("paging") or {}).get("nextPageToken")
|
||||||
|
if not page_token:
|
||||||
|
break
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(f"Error fetching Yandex Market reviews: {e}")
|
||||||
|
|
||||||
|
return all_feedbacks
|
||||||
|
|
||||||
|
async def post_comment(self, review_id: str, text: str) -> dict[str, Any]:
|
||||||
|
"""Создаёт комментарий продавца к отзыву. Возвращает {"comment_id": ...} или {"error": ...}."""
|
||||||
|
url = (
|
||||||
|
f"{self.base_url}/v2/businesses/{self.business_id}"
|
||||||
|
f"/goods-feedback/comments/update"
|
||||||
|
)
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return {"error": "empty comment text"}
|
||||||
|
data = {"feedbackId": int(review_id), "comment": {"text": text[:4096]}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.post(url, headers=self.headers, json=data)
|
||||||
|
error = self._error_from_response(response)
|
||||||
|
if error:
|
||||||
|
logger.error(f"Error posting comment to Yandex Market: {error}")
|
||||||
|
return {"error": error}
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json().get("result", {}) or {}
|
||||||
|
comment_id = result.get("id")
|
||||||
|
if comment_id is None:
|
||||||
|
return {"error": "no comment id in response"}
|
||||||
|
return {"comment_id": str(comment_id), "status": result.get("status")}
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(f"Error posting comment to Yandex Market: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
async def delete_comment(self, comment_id: str) -> dict[str, Any]:
|
||||||
|
url = (
|
||||||
|
f"{self.base_url}/v2/businesses/{self.business_id}"
|
||||||
|
f"/goods-feedback/comments/delete"
|
||||||
|
)
|
||||||
|
data = {"id": int(comment_id)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.post(url, headers=self.headers, json=data)
|
||||||
|
error = self._error_from_response(response)
|
||||||
|
if error:
|
||||||
|
logger.error(f"Error deleting comment from Yandex Market: {error}")
|
||||||
|
return {"error": error}
|
||||||
|
response.raise_for_status()
|
||||||
|
return {"success": True}
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(f"Error deleting comment from Yandex Market: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_datetime(iso_datetime: str) -> str:
|
||||||
|
return _parse_datetime(iso_datetime).strftime("%d.%m.%Y %H:%M:%S") + " МСК"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _review_text(feedback: dict[str, Any]) -> str:
|
||||||
|
description = feedback.get("description") or {}
|
||||||
|
parts = []
|
||||||
|
if description.get("comment"):
|
||||||
|
parts.append(str(description["comment"]).strip())
|
||||||
|
if description.get("advantages"):
|
||||||
|
parts.append("Достоинства: " + str(description["advantages"]).strip())
|
||||||
|
if description.get("disadvantages"):
|
||||||
|
parts.append("Недостатки: " + str(description["disadvantages"]).strip())
|
||||||
|
return "\n".join(parts).strip()
|
||||||
|
|
||||||
|
async def process_new_reviews(self) -> dict[str, int]:
|
||||||
|
gpt_client = GPTClient()
|
||||||
|
|
||||||
|
now = datetime.now(config.TIMEZONE)
|
||||||
|
date_from: datetime | None = None
|
||||||
|
try:
|
||||||
|
latest_review = (
|
||||||
|
await Review.filter(platform="ym").order_by("-published_at").first()
|
||||||
|
)
|
||||||
|
if latest_review:
|
||||||
|
date_from = latest_review.published_at.astimezone(config.TIMEZONE)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting latest review: {e}")
|
||||||
|
|
||||||
|
min_from = now - _MAX_LOOKBACK
|
||||||
|
if date_from is None or date_from < min_from:
|
||||||
|
date_from = min_from
|
||||||
|
|
||||||
|
reviews = await self.get_reviews(date_from)
|
||||||
|
if not reviews:
|
||||||
|
logger.info("No reviews fetched from Yandex Market")
|
||||||
|
return {"processed": 0, "new": 0}
|
||||||
|
|
||||||
|
# API отдаёт от новых к старым; обрабатываем от старых к новым
|
||||||
|
reviews.sort(key=lambda r: r.get("createdAt", ""))
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
for feedback in reviews:
|
||||||
|
review_id = str(feedback.get("feedbackId", "") or feedback.get("id", ""))
|
||||||
|
if not review_id:
|
||||||
|
logger.error(f"YM: no feedback id in review {feedback}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = await Review.filter(
|
||||||
|
external_id=review_id, platform="ym"
|
||||||
|
).exists()
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"YM: processing review {feedback}")
|
||||||
|
|
||||||
|
text = self._review_text(feedback)
|
||||||
|
published_at = _parse_datetime(feedback.get("createdAt"))
|
||||||
|
|
||||||
|
identifiers = feedback.get("identifiers") or {}
|
||||||
|
product_id = str(
|
||||||
|
identifiers.get("offerId") or identifiers.get("modelId") or ""
|
||||||
|
)
|
||||||
|
if not product_id:
|
||||||
|
logger.error(f"YM: no product id in review {feedback}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
product = get_products_dict(get_cache_key()).get(product_id)
|
||||||
|
if product:
|
||||||
|
product.setdefault(
|
||||||
|
"Название",
|
||||||
|
product.get("Наименование") or product.get("Название товара"),
|
||||||
|
)
|
||||||
|
product.setdefault("Категория", product.get("Категория продавца"))
|
||||||
|
product.setdefault("Подкатегория", "неизвестно")
|
||||||
|
else:
|
||||||
|
logger.warning(f"YM: no product found for id {product_id}")
|
||||||
|
product = {
|
||||||
|
"Название": "неизвестно",
|
||||||
|
"Категория": "неизвестно",
|
||||||
|
"Подкатегория": "неизвестно",
|
||||||
|
}
|
||||||
|
|
||||||
|
media = feedback.get("media") or {}
|
||||||
|
images = [url for url in (media.get("photos") or []) if url]
|
||||||
|
|
||||||
|
statistics = feedback.get("statistics") or {}
|
||||||
|
rating = int(statistics.get("rating") or 0)
|
||||||
|
|
||||||
|
review_data = {
|
||||||
|
"external_id": review_id,
|
||||||
|
"platform": "ym",
|
||||||
|
"product_id": product_id,
|
||||||
|
"product_name": product.get("Название") or "неизвестно",
|
||||||
|
"product_category": product.get("Категория") or "неизвестно",
|
||||||
|
"product_subcategory": product.get("Подкатегория") or "неизвестно",
|
||||||
|
"rating": rating,
|
||||||
|
"is_good": rating > config.NEGATIVE_RATING,
|
||||||
|
"text": text,
|
||||||
|
"published_at": published_at,
|
||||||
|
"processed": False,
|
||||||
|
"images": images if images else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(reviews)}
|
||||||
Reference in New Issue
Block a user