Compare commits
4 commits
c9ba1b15ba
...
77dafed63e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77dafed63e | ||
|
|
0db7a026bd | ||
| a0647a53a9 | |||
| ca1015bd4e |
25 changed files with 3220 additions and 170 deletions
264
tradein-mvp/backend/app/api/v1/support.py
Normal file
264
tradein-mvp/backend/app/api/v1/support.py
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
"""Веб-чат поддержки (#tgsupport-web) — поверх уже существующего Telegram
|
||||||
|
support-моста (`app.services.tgbot.bridge`, data/sql/186_tg_support.sql).
|
||||||
|
|
||||||
|
Источник обращения — сайт (не Telegram-личка клиента): пользователь пишет через
|
||||||
|
это API, сообщение зеркалится `sendMessage`-ом в тот же support-топик, оператор
|
||||||
|
отвечает РЕПЛАЕМ ровно так же, как на Telegram-клиента — маршрутизация ответа
|
||||||
|
обратно реализована в `bridge._handle_group_reply` (ветка добавлена там же, без
|
||||||
|
изменения существующего Telegram-пути).
|
||||||
|
|
||||||
|
Изоляция тредов: все 4 ручки резолвят тред ИСКЛЮЧИТЕЛЬНО по `X-Authenticated-User`
|
||||||
|
(rbac_guard в app/main.py гарантирует его наличие и валидность для non-public
|
||||||
|
путей). thread_id НИКОГДА не принимается снаружи (ни в query, ни в body) — чужой
|
||||||
|
тред прочитать/отметить нельзя ни при каких параметрах запроса, потому что
|
||||||
|
параметра, которым можно было бы адресовать чужой тред, попросту не существует.
|
||||||
|
|
||||||
|
Копия зеркала в топике всегда помечена "[С САЙТА] <username>: ..." — оператор
|
||||||
|
не должен путать веб-обращение с Telegram-клиентом (#tgsupport-web AC).
|
||||||
|
|
||||||
|
КРИТИЧНО (review H1) — порядок операций в `send_support_message`:
|
||||||
|
БД-запись (`get_or_create_thread`) идёт ПОСЛЕ успешного `send_message`, не до.
|
||||||
|
Прод — один uvicorn-процесс БЕЗ `--workers` (docker-compose.prod.yml) с
|
||||||
|
синхронным SQLAlchemy engine (пул 5+10 overflow) на ОДНОМ event loop. Если бы
|
||||||
|
`INSERT ... ON CONFLICT DO UPDATE` уходил ДО Telegram-вызова, строка/row-lock
|
||||||
|
держались бы всё время, пока `send_message` ждёт Telegram (секунды-минуты при
|
||||||
|
429/5xx на воркерных ретраях) — второй параллельный запрос ТОГО ЖЕ юзера
|
||||||
|
(двойной клик, вторая вкладка) упёрся бы в этот lock ВНУТРИ синхронного
|
||||||
|
psycopg-вызова внутри `async def`, останавливая event loop целиком (весь API
|
||||||
|
встаёт, не только этот эндпоинт). `_format_mirror_text` использует только
|
||||||
|
`username` — thread_id для отправки не нужен вообще, поэтому эту БД-операцию
|
||||||
|
можно безопасно отложить до после успешного sendMessage. Бонус: неудачная
|
||||||
|
отправка больше не создаёт тред.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.ratelimit import SlidingWindowLimiter
|
||||||
|
from app.services.tgbot import web_support_storage as storage
|
||||||
|
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
|
||||||
|
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Лимит Telegram sendMessage (4096) с запасом — см. #tgsupport-web AC ("~4000").
|
||||||
|
MAX_MESSAGE_LENGTH = 4000
|
||||||
|
|
||||||
|
# Жёстче общего RateLimitMiddleware (300 req/60с на пользователя, app/main.py):
|
||||||
|
# бот-токен общий на ВСЕХ клиентов веб-чата, флуд одного клиента иначе может
|
||||||
|
# упереться в Telegram-лимиты (`sendMessage` 429, лимит группы ~20 msg/min) и
|
||||||
|
# застопорить доставку всем остальным (см. задачу, п.6). 12 сообщений/минуту —
|
||||||
|
# щедро для живого диалога, но режет скрипт-флуд на порядок раньше общего API-лимита.
|
||||||
|
_SEND_RATE_LIMIT = 12
|
||||||
|
_SEND_RATE_WINDOW_S = 60.0
|
||||||
|
_send_limiter = SlidingWindowLimiter(limit=_SEND_RATE_LIMIT, window_s=_SEND_RATE_WINDOW_S)
|
||||||
|
|
||||||
|
# #tgsupport-web review H1: интерактивный HTTP-запрос НЕ МОЖЕТ наследовать
|
||||||
|
# воркерную политику ретраев `TelegramClient` (по умолчанию — до 5 попыток, на
|
||||||
|
# 429 спит `retry_after` Telegram'а — для группы штатно 30-60с, на 5xx backoff до
|
||||||
|
# 30с — легальный суммарный бюджет минуты). Узкий бюджет здесь: 1 повтор, короткий
|
||||||
|
# timeout — интерактивный клиент должен получить ответ (даже если это ошибка)
|
||||||
|
# за секунды, а не висеть до исчерпания воркерных ретраев.
|
||||||
|
_INTERACTIVE_SEND_TIMEOUT_S = 10.0
|
||||||
|
_INTERACTIVE_SEND_MAX_RETRIES = 1
|
||||||
|
|
||||||
|
# #tgsupport-web review M5: без LIMIT каждое монтирование виджета на старом
|
||||||
|
# треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`.
|
||||||
|
_LIST_MESSAGES_LIMIT = 200
|
||||||
|
|
||||||
|
|
||||||
|
def _require_username(request: Request) -> str:
|
||||||
|
"""Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его
|
||||||
|
наличие в проде для non-public путей — этот guard здесь defence-in-depth и
|
||||||
|
делает роутер тестируемым без поднятия всего app.main (см. tests/test_support.py,
|
||||||
|
как test_trade_in_lead.py для /lead). `.strip()` (review L4) — лишний пробел
|
||||||
|
от прокси иначе завёл бы ВТОРОЙ тред на, по сути, того же пользователя
|
||||||
|
(username — UNIQUE ключ треда, "alice" != "alice ")."""
|
||||||
|
username = (request.headers.get("x-authenticated-user") or "").strip()
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=401, detail="no authenticated user")
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
def _bot_configured() -> bool:
|
||||||
|
"""TELEGRAM_BOT_TOKEN и TELEGRAM_SUPPORT_CHAT_ID оба обязательны — без них
|
||||||
|
зеркалировать в топик некуда (см. app/tgbot_main.py._should_run для токена,
|
||||||
|
bridge.py для chat_id)."""
|
||||||
|
return bool(settings.telegram_bot_token) and bool(settings.telegram_support_chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
class SupportMessageInput(BaseModel):
|
||||||
|
text: str = Field(min_length=1, max_length=MAX_MESSAGE_LENGTH)
|
||||||
|
|
||||||
|
@field_validator("text")
|
||||||
|
@classmethod
|
||||||
|
def _not_blank(cls, value: str) -> str:
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
raise ValueError("text must not be blank")
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
|
class SupportMessageOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
direction: Literal["in", "out"]
|
||||||
|
text_body: str
|
||||||
|
operator_tg_id: int | None = None
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
@field_validator("created_at", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _isoformat(cls, value: object) -> str:
|
||||||
|
if hasattr(value, "isoformat"):
|
||||||
|
return value.isoformat() # type: ignore[no-any-return]
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class UnreadOut(BaseModel):
|
||||||
|
unread: int
|
||||||
|
|
||||||
|
|
||||||
|
class StatusOut(BaseModel):
|
||||||
|
status: Literal["ok"] = "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_mirror_text(username: str, message_text: str) -> str:
|
||||||
|
"""Помечает зеркало как пришедшее С САЙТА, от какого пользователя — оператор
|
||||||
|
иначе не отличит веб-обращение от Telegram-клиента (#tgsupport-web AC).
|
||||||
|
Использует ТОЛЬКО username — thread_id здесь не нужен (см. H1 в docstring
|
||||||
|
модуля), это то, что делает возможным отложить БД-запись до после отправки."""
|
||||||
|
return f"[С САЙТА] {username}:\n{message_text}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/support/messages", response_model=SupportMessageOut)
|
||||||
|
async def send_support_message(
|
||||||
|
payload: SupportMessageInput,
|
||||||
|
username: Annotated[str, Depends(_require_username)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> SupportMessageOut:
|
||||||
|
"""Отправляет сообщение от лица *username* в support-топик (`sendMessage` —
|
||||||
|
не `copyMessage`: у веб-сообщения нет исходного Telegram-сообщения для копии).
|
||||||
|
|
||||||
|
Порядок операций см. H1 в docstring модуля: rate-limit проверяется (но НЕ
|
||||||
|
расходуется, review L3) до отправки, thread создаётся ТОЛЬКО после успешного
|
||||||
|
`send_message` — до этого момента с БД не происходит ничего.
|
||||||
|
"""
|
||||||
|
if not _bot_configured():
|
||||||
|
# Предсказуемое поведение вместо 500 (#tgsupport-web AC): бот не настроен
|
||||||
|
# (пустой TELEGRAM_BOT_TOKEN, dev/staging) или support-топик не задан —
|
||||||
|
# мирроринг невозможен физически, ничего не пишем в БД.
|
||||||
|
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_TEXT)
|
||||||
|
|
||||||
|
# review L3: peek без расхода бюджета — неудачная отправка НЕ должна стоить
|
||||||
|
# пользователю попытки (расходуем `.record()` только на успех, ниже).
|
||||||
|
retry_after = _send_limiter.retry_after(username)
|
||||||
|
if retry_after is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Слишком много сообщений. Попробуйте через минуту.",
|
||||||
|
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TelegramClient(settings.telegram_bot_token)
|
||||||
|
try:
|
||||||
|
mirrored = await client.send_message(
|
||||||
|
chat_id=settings.telegram_support_chat_id,
|
||||||
|
text=_format_mirror_text(username, payload.text),
|
||||||
|
message_thread_id=settings.telegram_support_topic_id or None,
|
||||||
|
# review H1: узкий интерактивный бюджет — НЕ воркерные 5 ретраев/минуты.
|
||||||
|
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||||
|
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||||
|
)
|
||||||
|
except TelegramApiError:
|
||||||
|
# НЕ логируем payload.text (переписка — ПДн) и НЕ логируем токен (его в
|
||||||
|
# TelegramApiError и не бывает — см. client.py docstring про redaction).
|
||||||
|
logger.exception(
|
||||||
|
"web support: не удалось отправить зеркало в топик (username=%s)", username
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
||||||
|
|
||||||
|
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
|
||||||
|
_send_limiter.record(username)
|
||||||
|
|
||||||
|
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
||||||
|
if topic_message_id is None:
|
||||||
|
# review L1: без topic_message_id реплай оператора на это сообщение
|
||||||
|
# НИКОГДА не смаршрутизируется обратно (find_thread_by_topic_message ищет
|
||||||
|
# именно по этому полю) — тихая, но зафиксированная в логе деградация.
|
||||||
|
logger.warning(
|
||||||
|
"web support: Telegram sendMessage не вернул message_id (username=%s) — "
|
||||||
|
"ответ оператора на это сообщение не будет смаршрутизирован",
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
|
||||||
|
# review H1: БД-операция ПОСЛЕ успешной отправки — см. docstring модуля.
|
||||||
|
thread_id = storage.get_or_create_thread(db, username)
|
||||||
|
row = storage.record_inbound(
|
||||||
|
db,
|
||||||
|
thread_id=thread_id,
|
||||||
|
text_body=payload.text,
|
||||||
|
topic_message_id=topic_message_id,
|
||||||
|
support_chat_id=settings.telegram_support_chat_id,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info("web support: message sent username=%s thread_id=%d", username, thread_id)
|
||||||
|
return SupportMessageOut(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/support/messages", response_model=list[SupportMessageOut])
|
||||||
|
def list_support_messages(
|
||||||
|
username: Annotated[str, Depends(_require_username)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
since: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
) -> list[SupportMessageOut]:
|
||||||
|
"""Сообщения СВОЕГО треда с id > since. Тред резолвится по username — чужой
|
||||||
|
тред недостижим (нет параметра, которым его можно адресовать).
|
||||||
|
|
||||||
|
Обычный (sync) `def`, не `async def` (review M3): тело — только синхронные
|
||||||
|
psycopg-вызовы, ни одного `await`; как `async def` это исполнялось бы прямо в
|
||||||
|
event loop (а фронт поллит эту ручку постоянно). Starlette гонит sync-handlers
|
||||||
|
в threadpool автоматически — тот же паттерн, что `trade_in.py:get_estimate`.
|
||||||
|
"""
|
||||||
|
thread_id = storage.find_thread_id(db, username)
|
||||||
|
if thread_id is None:
|
||||||
|
return []
|
||||||
|
rows = storage.list_messages(
|
||||||
|
db, thread_id=thread_id, since_id=since, limit=_LIST_MESSAGES_LIMIT
|
||||||
|
)
|
||||||
|
return [SupportMessageOut(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/support/unread", response_model=UnreadOut)
|
||||||
|
def get_support_unread(
|
||||||
|
username: Annotated[str, Depends(_require_username)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> UnreadOut:
|
||||||
|
"""Sync `def` (review M3) — см. `list_support_messages`."""
|
||||||
|
thread_id = storage.find_thread_id(db, username)
|
||||||
|
if thread_id is None:
|
||||||
|
return UnreadOut(unread=0)
|
||||||
|
return UnreadOut(unread=storage.count_unread(db, thread_id=thread_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/support/read", response_model=StatusOut)
|
||||||
|
def mark_support_read(
|
||||||
|
username: Annotated[str, Depends(_require_username)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> StatusOut:
|
||||||
|
"""Sync `def` (review M3) — см. `list_support_messages`."""
|
||||||
|
thread_id = storage.find_thread_id(db, username)
|
||||||
|
if thread_id is not None:
|
||||||
|
storage.mark_read(db, thread_id=thread_id)
|
||||||
|
db.commit()
|
||||||
|
return StatusOut()
|
||||||
|
|
@ -80,6 +80,63 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
class SlidingWindowLimiter:
|
||||||
|
"""Reusable in-process sliding-window limiter — тот же алгоритм, что
|
||||||
|
`RateLimitMiddleware.dispatch` (deque per key, отбрасываем протухшие метки),
|
||||||
|
вынесенный для feature-специфичных лимитов, которые нужны ЖЁСТЧЕ общего
|
||||||
|
per-user порога `/api/*` (напр. отправка сообщений в веб-чат поддержки,
|
||||||
|
#tgsupport-web — общий лимит 300/60с не спасёт support-топик от заливки
|
||||||
|
одним флудящим клиентом, т.к. Telegram Bot API токен общий на всех).
|
||||||
|
|
||||||
|
Не заменяет `RateLimitMiddleware` (тот остаётся общим гейтом на `/api/*`),
|
||||||
|
а даёт отдельный, более узкий бюджет для конкретного эндпоинта/действия.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, limit: int, window_s: float) -> None:
|
||||||
|
self._limit = limit
|
||||||
|
self._window_s = window_s
|
||||||
|
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
|
def _prune(self, bucket: deque[float], now: float) -> None:
|
||||||
|
cutoff = now - self._window_s
|
||||||
|
while bucket and bucket[0] < cutoff:
|
||||||
|
bucket.popleft()
|
||||||
|
|
||||||
|
def retry_after(self, key: str) -> float | None:
|
||||||
|
"""Non-destructive проверка: сколько секунд ждать, если *key* СЕЙЧАС за
|
||||||
|
лимитом, иначе None. НЕ регистрирует попытку — вызывающая сторона решает
|
||||||
|
сама, когда звать `record()` (обычно — только на успех действия, #tgsupport-web
|
||||||
|
review L3: неудачная попытка не должна съедать бюджет)."""
|
||||||
|
now = time.monotonic()
|
||||||
|
bucket = self._hits[key]
|
||||||
|
self._prune(bucket, now)
|
||||||
|
if len(bucket) >= self._limit:
|
||||||
|
return self._window_s - (now - bucket[0])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def record(self, key: str) -> None:
|
||||||
|
"""Регистрирует одну успешную попытку под *key*."""
|
||||||
|
now = time.monotonic()
|
||||||
|
bucket = self._hits[key]
|
||||||
|
self._prune(bucket, now)
|
||||||
|
bucket.append(now)
|
||||||
|
# Лёгкая защита от утечки памяти — чистим пустые корзины изредка (тот же
|
||||||
|
# паттерн, что RateLimitMiddleware.dispatch).
|
||||||
|
if len(self._hits) > 10000:
|
||||||
|
for k in [k for k, v in self._hits.items() if not v]:
|
||||||
|
del self._hits[k]
|
||||||
|
|
||||||
|
def check(self, key: str) -> float | None:
|
||||||
|
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
|
||||||
|
для вызывающих, которым не нужно различать "попытка"/"успех" (см.
|
||||||
|
`retry_after`/`record` для раздельного варианта)."""
|
||||||
|
retry_after = self.retry_after(key)
|
||||||
|
if retry_after is not None:
|
||||||
|
return retry_after
|
||||||
|
self.record(key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _client_ip(request: Request) -> str:
|
def _client_ip(request: Request) -> str:
|
||||||
"""Честный клиентский IP при РОВНО ОДНОМ доверенном прокси (Caddy) перед нами.
|
"""Честный клиентский IP при РОВНО ОДНОМ доверенном прокси (Caddy) перед нами.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,18 @@ from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||||
|
|
||||||
from app.api.v1 import admin, audit, brand, buildings, geocode, lead, me, search, trade_in
|
from app.api.v1 import (
|
||||||
|
admin,
|
||||||
|
audit,
|
||||||
|
brand,
|
||||||
|
buildings,
|
||||||
|
geocode,
|
||||||
|
lead,
|
||||||
|
me,
|
||||||
|
search,
|
||||||
|
support,
|
||||||
|
trade_in,
|
||||||
|
)
|
||||||
from app.core.auth import get_role, is_path_allowed
|
from app.core.auth import get_role, is_path_allowed
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
|
|
@ -39,6 +50,13 @@ logging.basicConfig(
|
||||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# #tgsupport-web: этот процесс теперь тоже зовёт Telegram Bot API напрямую
|
||||||
|
# (app/api/v1/support.py — sendMessage при отправке веб-сообщения в топик), не
|
||||||
|
# только изолированный tgbot_main.py. httpx-INFO логирует ПОЛНЫЙ request URL,
|
||||||
|
# включая токен в пути (https://api.telegram.org/bot<TOKEN>/...) — то же самое
|
||||||
|
# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь.
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
|
||||||
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
|
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
|
||||||
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
|
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
|
||||||
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
|
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
|
||||||
|
|
@ -46,13 +64,28 @@ logging.basicConfig(
|
||||||
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
|
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
|
||||||
# frontend), отдельного broker нет → мониторить нечего.
|
# frontend), отдельного broker нет → мониторить нечего.
|
||||||
if settings.glitchtip_dsn:
|
if settings.glitchtip_dsn:
|
||||||
|
from app.observability.sentry_scrub import redact_telegram_bot_token
|
||||||
|
|
||||||
|
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
|
||||||
|
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) —
|
||||||
|
см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск:
|
||||||
|
теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке
|
||||||
|
sendMessage, а include_local_variables=False ниже — первый рубеж защиты)."""
|
||||||
|
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
|
||||||
|
if scrubbed is None:
|
||||||
|
return None
|
||||||
|
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
|
||||||
|
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn=settings.glitchtip_dsn,
|
dsn=settings.glitchtip_dsn,
|
||||||
environment=settings.environment,
|
environment=settings.environment,
|
||||||
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
|
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
|
||||||
traces_sample_rate=0.0, # только ошибки, без performance-трейсов
|
traces_sample_rate=0.0, # только ошибки, без performance-трейсов
|
||||||
send_default_pii=False, # не шлём client_name / client_phone в отчёты
|
send_default_pii=False, # не шлём client_name / client_phone в отчёты
|
||||||
before_send=scrub_pii_event, # дочищаем consumer-PII из тела error events
|
include_local_variables=False, # #tgsupport-web: TelegramClient._request
|
||||||
|
# держит base URL с токеном в локальных переменных стек-фрейма — default
|
||||||
|
# sentry_sdk (True) приложил бы их к traceback открытым текстом.
|
||||||
|
before_send=_before_send,
|
||||||
integrations=[
|
integrations=[
|
||||||
StarletteIntegration(),
|
StarletteIntegration(),
|
||||||
FastApiIntegration(),
|
FastApiIntegration(),
|
||||||
|
|
@ -229,6 +262,7 @@ app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
|
||||||
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
||||||
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||||
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||||
|
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
|
||||||
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
||||||
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
||||||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,24 @@ _MFE_AUTH = "header-frontend"
|
||||||
# Callers that need to distinguish ban from valid auth should check state.get("_ban").
|
# Callers that need to distinguish ban from valid auth should check state.get("_ban").
|
||||||
VERIFY_BAN_SENTINEL: dict[str, Any] = {"_ban": True}
|
VERIFY_BAN_SENTINEL: dict[str, Any] = {"_ban": True}
|
||||||
|
|
||||||
|
# audit-scrapers finding 4: verify_session раньше сводило 5xx / сетевой сбой /
|
||||||
|
# смену вёрстки к тому же None, что и реальный логаут (401 / isAuthenticated=false) —
|
||||||
|
# вызывающие (_cian_pre_claim, admin upload/auto-login) реагировали "куки протухли,
|
||||||
|
# перезалей" там, где куки ни при чём (Cian недоступен ИЛИ scraper_kit.cian_state_parser
|
||||||
|
# больше не находит header-frontend initialState). Два отдельных сигнала ниже НЕ
|
||||||
|
# триггерят "cookies expired" алерт у вызывающих.
|
||||||
|
|
||||||
|
# Cian источник недоступен прямо сейчас (5xx-ответ ИЛИ сетевой/транспортный сбой —
|
||||||
|
# timeout, DNS, connection reset). Cookies могут быть абсолютно валидны — просто
|
||||||
|
# нечем было их проверить. Retry позже, БЕЗ пометки session invalid.
|
||||||
|
VERIFY_SOURCE_UNAVAILABLE_SENTINEL: dict[str, Any] = {"_source_unavailable": True}
|
||||||
|
|
||||||
|
# HTTP 200 получен, но ожидаемый auth-state (header-frontend/initialState с
|
||||||
|
# user.isAuthenticated) не найден/не распарсился — Cian изменил вёрстку/MFE-схему.
|
||||||
|
# Это engineering-проблема (extract_state/_MFE_AUTH нужно обновить), НЕ протухшие
|
||||||
|
# cookies — переставлять куки здесь бесполезно.
|
||||||
|
VERIFY_MARKUP_CHANGED_SENTINEL: dict[str, Any] = {"_markup_changed": True}
|
||||||
|
|
||||||
|
|
||||||
def _classify_verify_response(
|
def _classify_verify_response(
|
||||||
status_code: int,
|
status_code: int,
|
||||||
|
|
@ -79,19 +97,25 @@ def _classify_verify_response(
|
||||||
"""Pure classifier — maps (status_code, html) to verify_session outcome.
|
"""Pure classifier — maps (status_code, html) to verify_session outcome.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
VERIFY_BAN_SENTINEL — 403/TLS ban (cookies may be fine, server is blocking)
|
VERIFY_BAN_SENTINEL — 403/TLS ban (cookies могут быть в порядке,
|
||||||
None — 401 or isAuthenticated=false (cookies genuinely expired)
|
блокирует сервер)
|
||||||
|
VERIFY_SOURCE_UNAVAILABLE_SENTINEL — 5xx/иной non-200 без содержимого —
|
||||||
|
источник недоступен, НЕ cookies
|
||||||
|
VERIFY_MARKUP_CHANGED_SENTINEL — HTTP 200, но auth-state не найден/не
|
||||||
|
распарсился — вёрстка/схема изменилась
|
||||||
|
None — 401 ИЛИ isAuthenticated=false — cookies
|
||||||
|
ДЕЙСТВИТЕЛЬНО протухли/разлогинены
|
||||||
state dict — authenticated successfully
|
state dict — authenticated successfully
|
||||||
"""
|
"""
|
||||||
if status_code == 403:
|
if status_code == 403:
|
||||||
return VERIFY_BAN_SENTINEL
|
return VERIFY_BAN_SENTINEL
|
||||||
if status_code == 401:
|
if status_code == 401:
|
||||||
return None
|
return None
|
||||||
if html is None:
|
if status_code != 200 or html is None:
|
||||||
return None
|
return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||||
state = extract_state(html, mfe=_MFE_AUTH, key="initialState")
|
state = extract_state(html, mfe=_MFE_AUTH, key="initialState")
|
||||||
if state is None:
|
if state is None:
|
||||||
return None
|
return VERIFY_MARKUP_CHANGED_SENTINEL
|
||||||
user = state.get("user", {}) or {}
|
user = state.get("user", {}) or {}
|
||||||
if not user.get("isAuthenticated"):
|
if not user.get("isAuthenticated"):
|
||||||
return None
|
return None
|
||||||
|
|
@ -104,11 +128,18 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
|
||||||
Uses curl_cffi with impersonate='chrome120' (same as prod scrapers) to avoid
|
Uses curl_cffi with impersonate='chrome120' (same as prod scrapers) to avoid
|
||||||
TLS-fingerprint bans that httpx would trigger.
|
TLS-fingerprint bans that httpx would trigger.
|
||||||
|
|
||||||
Returns:
|
Returns (проверяй через `is`, НЕ `==` — это sentinel-объекты):
|
||||||
state dict — authenticated (contains user.isAuthenticated + userId)
|
state dict — authenticated (user.isAuthenticated + userId)
|
||||||
VERIFY_BAN_SENTINEL — HTTP 403 TLS/bot ban; cookies may still be valid —
|
VERIFY_BAN_SENTINEL — HTTP 403 TLS/bot ban; cookies могут быть
|
||||||
callers should NOT trigger a cookie-refresh alert
|
валидны — НЕ триггерить cookie-refresh alert
|
||||||
None — HTTP 401 or isAuthenticated=false; cookies expired
|
VERIFY_SOURCE_UNAVAILABLE_SENTINEL — 5xx/network/timeout; источник недоступен,
|
||||||
|
НЕ триггерить cookie-refresh alert, retry позже
|
||||||
|
VERIFY_MARKUP_CHANGED_SENTINEL — HTTP 200 но auth-state не распарсился;
|
||||||
|
Cian изменил вёрстку — НЕ cookie-проблема,
|
||||||
|
нужен engineering-фикс extract_state/_MFE_AUTH
|
||||||
|
None — HTTP 401 или isAuthenticated=false; cookies
|
||||||
|
ДЕЙСТВИТЕЛЬНО протухли — здесь и только здесь
|
||||||
|
имеет смысл просить re-upload
|
||||||
|
|
||||||
Никогда не логирует сырые значения cookies.
|
Никогда не логирует сырые значения cookies.
|
||||||
"""
|
"""
|
||||||
|
|
@ -134,6 +165,18 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Cian cookies verify: HTTP 403 TLS/bot ban — cookies NOT marked expired"
|
"Cian cookies verify: HTTP 403 TLS/bot ban — cookies NOT marked expired"
|
||||||
)
|
)
|
||||||
|
elif result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL:
|
||||||
|
logger.warning(
|
||||||
|
"Cian cookies verify: source unavailable (status=%d) — "
|
||||||
|
"cookies NOT marked expired, retry later",
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
elif result is VERIFY_MARKUP_CHANGED_SENTINEL:
|
||||||
|
logger.error(
|
||||||
|
"Cian cookies verify: HTTP 200 but auth-state not found/parseable "
|
||||||
|
"(mfe=%s) — markup/schema changed, cookies NOT marked expired",
|
||||||
|
_MFE_AUTH,
|
||||||
|
)
|
||||||
elif result is None:
|
elif result is None:
|
||||||
logger.warning("Cian cookies verify: expired/unauthenticated (status=%d)", status)
|
logger.warning("Cian cookies verify: expired/unauthenticated (status=%d)", status)
|
||||||
else:
|
else:
|
||||||
|
|
@ -142,8 +185,11 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
|
||||||
|
|
||||||
return result
|
return result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Cian cookies verify failed: %s", exc)
|
# Сетевой/транспортный сбой (timeout, DNS, connection reset и т.п.) — источник
|
||||||
return None
|
# недоступен, НЕ признак протухших cookies (finding 4). Раньше здесь везде
|
||||||
|
# возвращался None, конфлируя с реальным логаутом.
|
||||||
|
logger.warning("Cian cookies verify: transport/network error — %s", exc)
|
||||||
|
return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||||
|
|
||||||
|
|
||||||
def save_session(
|
def save_session(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Маршрутизация Telegram-апдейтов для support-моста (#tgsupport).
|
"""Маршрутизация Telegram-апдейтов для support-моста (#tgsupport, #tgsupport-web).
|
||||||
|
|
||||||
Поток:
|
Поток:
|
||||||
A) Клиент пишет боту в личку (chat.type == 'private') →
|
A) Клиент пишет боту в личку (chat.type == 'private') →
|
||||||
|
|
@ -6,11 +6,28 @@
|
||||||
с идентификацией клиента в топик) → copyMessage контента в support-топик →
|
с идентификацией клиента в топик) → copyMessage контента в support-топик →
|
||||||
запись в tg_support_messages (direction='in', topic_message_id — ключ
|
запись в tg_support_messages (direction='in', topic_message_id — ключ
|
||||||
маршрутизации ответа).
|
маршрутизации ответа).
|
||||||
|
A') Пользователь сайта пишет через `app.api.v1.support` (веб-чат поддержки,
|
||||||
|
#tgsupport-web) → тот эндпоинт САМ зеркалит sendMessage'ом в топик и пишет
|
||||||
|
web_support_messages (direction='in') — этот модуль в этой ветке не участвует,
|
||||||
|
только в разборе ответа (B ниже).
|
||||||
B) Оператор отвечает РЕПЛАЕМ в support-группе на зеркало клиента →
|
B) Оператор отвечает РЕПЛАЕМ в support-группе на зеркало клиента →
|
||||||
находим chat_id по topic_message_id → copyMessage ответа в личку клиента →
|
резолвим topic_message_id ОБЕ стороны (tg_support_messages И
|
||||||
запись (direction='out'). Реплай не на зеркало (или не реплай вообще) —
|
web_support_messages), скоупя к ТЕКУЩЕМУ TELEGRAM_SUPPORT_CHAT_ID
|
||||||
обычная болтовня в топике, тихий игнор. Telegram 403 (клиент заблокировал
|
(#tgsupport-web review M1 — если группу когда-нибудь сменят/пересоздадут,
|
||||||
бота) → is_blocked=true + уведомление в топике.
|
Telegram message_id стартует заново и может совпасть со старым числом из
|
||||||
|
другой таблицы; без скоупинга это была бы ТИХАЯ доставка постороннему
|
||||||
|
клиенту). Совпадение НА ОБЕИХ сторонах одновременно — громкий отказ
|
||||||
|
(`logger.error`, ничего не доставляем) вместо произвольного выбора одной из
|
||||||
|
них. Иначе: chat_id найден → copyMessage ответа в личку клиента → запись
|
||||||
|
(direction='out'); thread_id найден (веб-зеркало) → доставка идёт НЕ в
|
||||||
|
Telegram (у веб-клиента нет личного чата с ботом), а записью direction='out'
|
||||||
|
в web_support_messages (веб-фронт вычитывает её обычным polling'ом); реплай
|
||||||
|
медиа-типом на веб-зеркало — веб-чат текстовый MVP, доставка целиком
|
||||||
|
отклоняется (не частично — фото с подписью НЕ превращается в "ответ = только
|
||||||
|
подпись"), оператор получает уведомление в топике (review M2). Реплай не на
|
||||||
|
зеркало (или не реплай вообще) — обычная болтовня в топике, тихий игнор.
|
||||||
|
Telegram 403 (клиент заблокировал бота) → is_blocked=true + уведомление в
|
||||||
|
топике (только для Telegram-ветки — у веб-клиента нет "заблокировал бота").
|
||||||
C) Дедуп: update_id <= сохранённого offset — skip. Offset сохраняется И
|
C) Дедуп: update_id <= сохранённого offset — skip. Offset сохраняется И
|
||||||
коммитится в той же транзакции, что и запись сообщения (см. `process_update`
|
коммитится в той же транзакции, что и запись сообщения (см. `process_update`
|
||||||
`finally`), после КАЖДОГО апдейта — рестарт воркера не переигрывает уже
|
`finally`), после КАЖДОГО апдейта — рестарт воркера не переигрывает уже
|
||||||
|
|
@ -22,7 +39,11 @@
|
||||||
|
|
||||||
Персистентность вынесена за `BridgeStorage`-протокол — маршрутизирующая логика
|
Персистентность вынесена за `BridgeStorage`-протокол — маршрутизирующая логика
|
||||||
(`process_update` и приватные `_handle_*`) не завязана на реальную БД, тестируется
|
(`process_update` и приватные `_handle_*`) не завязана на реальную БД, тестируется
|
||||||
на in-memory fake storage + mock httpx (см. tests/services/tgbot/).
|
на in-memory fake storage + mock httpx (см. tests/services/tgbot/). Веб-чат
|
||||||
|
таблицы (web_support_threads/web_support_messages) сознательно ОТДЕЛЬНЫ от
|
||||||
|
tg_support_* — обоснование в data/sql/187_web_support_chat.sql; здесь `BridgeStorage`
|
||||||
|
несёт два дополнительных метода (`find_web_thread_by_topic_message`,
|
||||||
|
`record_web_out_message`), делегирующих в `web_support_storage`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -38,6 +59,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.shutdown import shutdown_requested
|
from app.core.shutdown import shutdown_requested
|
||||||
|
from app.services.tgbot import web_support_storage
|
||||||
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
from app.services.tgbot.client import TelegramApiError, TelegramClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -65,6 +87,11 @@ SERVICE_UNAVAILABLE_TEXT = (
|
||||||
# tg_support_messages.kind): "text | photo | document | video | voice | other".
|
# tg_support_messages.kind): "text | photo | document | video | voice | other".
|
||||||
_KNOWN_KINDS = ("text", "photo", "document", "video", "voice")
|
_KNOWN_KINDS = ("text", "photo", "document", "video", "voice")
|
||||||
|
|
||||||
|
# #tgsupport-web review M2: реплай оператора медиа-типом (в т.ч. фото С ПОДПИСЬЮ)
|
||||||
|
# на веб-зеркало НЕ доставляется частично — веб-чат текстовый MVP, оператор
|
||||||
|
# получает это уведомление в топике вместо тихого игнора (иначе уверен, что ответил).
|
||||||
|
_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT = "Веб-чат поддерживает только текст, сообщение не доставлено."
|
||||||
|
|
||||||
|
|
||||||
# ── Storage abstraction (testable без реальной БД) ──────────────────────────
|
# ── Storage abstraction (testable без реальной БД) ──────────────────────────
|
||||||
class BridgeStorage(Protocol):
|
class BridgeStorage(Protocol):
|
||||||
|
|
@ -101,12 +128,23 @@ class BridgeStorage(Protocol):
|
||||||
kind: str,
|
kind: str,
|
||||||
text_body: str | None,
|
text_body: str | None,
|
||||||
operator_tg_id: int | None,
|
operator_tg_id: int | None,
|
||||||
|
support_chat_id: int | None = None,
|
||||||
) -> int | None: ...
|
) -> int | None: ...
|
||||||
|
|
||||||
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None: ...
|
def find_chat_by_topic_message(
|
||||||
|
self, topic_message_id: int, support_chat_id: int
|
||||||
|
) -> int | None: ...
|
||||||
|
|
||||||
def mark_blocked(self, chat_id: int) -> None: ...
|
def mark_blocked(self, chat_id: int) -> None: ...
|
||||||
|
|
||||||
|
def find_web_thread_by_topic_message(
|
||||||
|
self, topic_message_id: int, support_chat_id: int
|
||||||
|
) -> int | None: ...
|
||||||
|
|
||||||
|
def record_web_out_message(
|
||||||
|
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
class SqlBridgeStorage:
|
class SqlBridgeStorage:
|
||||||
"""`BridgeStorage` поверх SQLAlchemy Session (psycopg v3), tg_support_* таблицы.
|
"""`BridgeStorage` поверх SQLAlchemy Session (psycopg v3), tg_support_* таблицы.
|
||||||
|
|
@ -223,17 +261,19 @@ class SqlBridgeStorage:
|
||||||
kind: str,
|
kind: str,
|
||||||
text_body: str | None,
|
text_body: str | None,
|
||||||
operator_tg_id: int | None,
|
operator_tg_id: int | None,
|
||||||
|
support_chat_id: int | None = None,
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
row = self._db.execute(
|
row = self._db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO tg_support_messages
|
INSERT INTO tg_support_messages
|
||||||
(chat_id, direction, tg_message_id, topic_message_id, kind,
|
(chat_id, direction, tg_message_id, topic_message_id, kind,
|
||||||
text_body, operator_tg_id, created_at)
|
text_body, operator_tg_id, support_chat_id, created_at)
|
||||||
VALUES
|
VALUES
|
||||||
(CAST(:chat_id AS bigint), CAST(:direction AS text),
|
(CAST(:chat_id AS bigint), CAST(:direction AS text),
|
||||||
CAST(:tg_message_id AS bigint), CAST(:topic_message_id AS bigint),
|
CAST(:tg_message_id AS bigint), CAST(:topic_message_id AS bigint),
|
||||||
CAST(:kind AS text), :text_body, CAST(:operator_tg_id AS bigint), NOW())
|
CAST(:kind AS text), :text_body, CAST(:operator_tg_id AS bigint),
|
||||||
|
CAST(:support_chat_id AS bigint), NOW())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -245,11 +285,17 @@ class SqlBridgeStorage:
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"text_body": text_body,
|
"text_body": text_body,
|
||||||
"operator_tg_id": operator_tg_id,
|
"operator_tg_id": operator_tg_id,
|
||||||
|
"support_chat_id": support_chat_id,
|
||||||
},
|
},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return int(row[0]) if row is not None else None
|
return int(row[0]) if row is not None else None
|
||||||
|
|
||||||
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None:
|
def find_chat_by_topic_message(self, topic_message_id: int, support_chat_id: int) -> int | None:
|
||||||
|
"""Скоупим к ТЕКУЩЕМУ support_chat_id (#tgsupport-web review M1) — строка
|
||||||
|
со ЧУЖИМ (не NULL, не текущим) support_chat_id — исторический артефакт
|
||||||
|
ротации support-группы, не валидный маршрут сегодня. NULL (строки до
|
||||||
|
миграции 188, если есть) — лениентный wildcard-матч (единственный
|
||||||
|
действовавший чат на тот момент)."""
|
||||||
row = self._db.execute(
|
row = self._db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -257,11 +303,13 @@ class SqlBridgeStorage:
|
||||||
FROM tg_support_messages
|
FROM tg_support_messages
|
||||||
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
|
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
|
||||||
AND direction = 'in'
|
AND direction = 'in'
|
||||||
|
AND (support_chat_id = CAST(:support_chat_id AS bigint)
|
||||||
|
OR support_chat_id IS NULL)
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"topic_message_id": topic_message_id},
|
{"topic_message_id": topic_message_id, "support_chat_id": support_chat_id},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return int(row[0]) if row is not None else None
|
return int(row[0]) if row is not None else None
|
||||||
|
|
||||||
|
|
@ -274,6 +322,25 @@ class SqlBridgeStorage:
|
||||||
{"chat_id": chat_id},
|
{"chat_id": chat_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def find_web_thread_by_topic_message(
|
||||||
|
self, topic_message_id: int, support_chat_id: int
|
||||||
|
) -> int | None:
|
||||||
|
"""Делегирует в `web_support_storage` (#tgsupport-web) — то же соединение/
|
||||||
|
транзакцию, что и tg-путь, коммитится вместе offset'ом в `process_update`."""
|
||||||
|
return web_support_storage.find_thread_by_topic_message(
|
||||||
|
self._db, topic_message_id, support_chat_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_web_out_message(
|
||||||
|
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||||||
|
) -> None:
|
||||||
|
web_support_storage.record_outbound(
|
||||||
|
self._db,
|
||||||
|
thread_id=thread_id,
|
||||||
|
text_body=text_body,
|
||||||
|
operator_tg_id=operator_tg_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Pure helpers ──────────────────────────────────────────────────────────────
|
# ── Pure helpers ──────────────────────────────────────────────────────────────
|
||||||
def _infer_kind(message: dict[str, Any]) -> str:
|
def _infer_kind(message: dict[str, Any]) -> str:
|
||||||
|
|
@ -365,13 +432,15 @@ async def _handle_private_message(
|
||||||
kind=_infer_kind(message),
|
kind=_infer_kind(message),
|
||||||
text_body=text_body or message.get("caption"),
|
text_body=text_body or message.get("caption"),
|
||||||
operator_tg_id=None,
|
operator_tg_id=None,
|
||||||
|
support_chat_id=settings.telegram_support_chat_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_group_reply(
|
async def _handle_group_reply(
|
||||||
message: dict[str, Any], client: TelegramClient, storage: BridgeStorage
|
message: dict[str, Any], client: TelegramClient, storage: BridgeStorage
|
||||||
) -> None:
|
) -> None:
|
||||||
"""B) Реплай оператора в support-группе → доставка ответа клиенту."""
|
"""B) Реплай оператора в support-группе → доставка ответа клиенту (Telegram
|
||||||
|
ЛИБО веб-чат, #tgsupport-web — см. модульный docstring)."""
|
||||||
reply_to = message.get("reply_to_message")
|
reply_to = message.get("reply_to_message")
|
||||||
if not isinstance(reply_to, dict):
|
if not isinstance(reply_to, dict):
|
||||||
return # не реплай вообще — обычная болтовня в топике, тихий игнор
|
return # не реплай вообще — обычная болтовня в топике, тихий игнор
|
||||||
|
|
@ -380,28 +449,30 @@ async def _handle_group_reply(
|
||||||
if not isinstance(mirror_message_id, int):
|
if not isinstance(mirror_message_id, int):
|
||||||
return
|
return
|
||||||
|
|
||||||
target_chat_id = storage.find_chat_by_topic_message(mirror_message_id)
|
# #tgsupport-web review M1: резолвим ОБЕ стороны с текущим support_chat_id
|
||||||
if target_chat_id is None:
|
# (НЕ short-circuit на первом найденном) — если topic_message_id совпал в
|
||||||
# Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем,
|
# ОБЕИХ таблицах одновременно, это значит инвариант "уникален в пределах
|
||||||
# это ожидаемый шум. НО реплай на сообщение, отправленное САМИМ БОТОМ
|
# текущей support-группы" нарушен (баг/ручная правка данных) — отказываем в
|
||||||
# (is_bot=True) и при этом отсутствующее в tg_support_messages — подозрительно:
|
# доставке ГРОМКО, вместо того чтобы молча выбрать tg-путь и отправить ответ
|
||||||
# вероятная причина — осиротевшее зеркало (воркер упал МЕЖДУ copyMessage и
|
# постороннему Telegram-клиенту (152-ФЗ misroute risk).
|
||||||
# storage.commit() в `_handle_private_message` — зеркало ушло в Telegram, а
|
current_chat_id = settings.telegram_support_chat_id
|
||||||
# запись в БД потерялась). Дискриминатор неидеальный (шапка-идентификация
|
target_chat_id = storage.find_chat_by_topic_message(mirror_message_id, current_chat_id)
|
||||||
# тоже от бота, но не routing-ключ — тоже даст этот WARNING), но лучше редкий
|
web_thread_id = storage.find_web_thread_by_topic_message(mirror_message_id, current_chat_id)
|
||||||
# ложный WARNING, чем оператор молча решает, что ответ клиенту доставлен,
|
|
||||||
# хотя реплай тихо утонул (#4 review — двухфазный протокол НЕ делаем, overkill).
|
if target_chat_id is not None and web_thread_id is not None:
|
||||||
reply_from = reply_to.get("from") or {}
|
logger.error(
|
||||||
if reply_from.get("is_bot"):
|
"tgbot bridge: topic_message_id=%d резолвится ОДНОВРЕМЕННО в Telegram "
|
||||||
logger.warning(
|
"(chat_id=%d) и веб-чат (thread_id=%d) под support_chat_id=%d — отказ в "
|
||||||
"tgbot bridge: реплай на сообщение бота (message_id=%d) не найден в "
|
"доставке, требуется ручной разбор tg_support_messages/web_support_messages",
|
||||||
"tg_support_messages как зеркало клиента — возможно, осиротевшее "
|
|
||||||
"зеркало (крах между copyMessage и commit) или шапка-идентификация; "
|
|
||||||
"ответ оператора НЕ доставлен клиенту",
|
|
||||||
mirror_message_id,
|
mirror_message_id,
|
||||||
|
target_chat_id,
|
||||||
|
web_thread_id,
|
||||||
|
current_chat_id,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if target_chat_id is not None:
|
||||||
|
# Существующий Telegram-путь — НЕ ТРОНУТ.
|
||||||
message_id = message.get("message_id")
|
message_id = message.get("message_id")
|
||||||
if not isinstance(message_id, int):
|
if not isinstance(message_id, int):
|
||||||
return
|
return
|
||||||
|
|
@ -441,6 +512,63 @@ async def _handle_group_reply(
|
||||||
text_body=message.get("text") or message.get("caption"),
|
text_body=message.get("text") or message.get("caption"),
|
||||||
operator_tg_id=operator_id,
|
operator_tg_id=operator_id,
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if web_thread_id is not None:
|
||||||
|
message_id = message.get("message_id")
|
||||||
|
kind = _infer_kind(message)
|
||||||
|
if kind != "text":
|
||||||
|
# #tgsupport-web review M2: НЕ доставляем частично (фото С ПОДПИСЬЮ
|
||||||
|
# молча превратилось бы в "ответ = только текст подписи", клиент решил
|
||||||
|
# бы что это весь ответ) — отказ целиком + явное уведомление оператору
|
||||||
|
# в топике (тот же паттерн, что 403-уведомление выше), иначе оператор
|
||||||
|
# уверен, что ответ доставлен, хотя веб-чат не поддерживает медиа.
|
||||||
|
logger.warning(
|
||||||
|
"tgbot bridge: реплай на веб-зеркало (thread_id=%d) содержит %s, "
|
||||||
|
"не текст — веб-чат поддерживает только текст, доставка отклонена",
|
||||||
|
web_thread_id,
|
||||||
|
kind,
|
||||||
|
)
|
||||||
|
await client.send_message(
|
||||||
|
chat_id=settings.telegram_support_chat_id,
|
||||||
|
text=_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT,
|
||||||
|
message_thread_id=settings.telegram_support_topic_id or None,
|
||||||
|
reply_to_message_id=message_id if isinstance(message_id, int) else None,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
text_body = message.get("text")
|
||||||
|
if not text_body:
|
||||||
|
# Текстовый kind, но пустой text (защитный edge case) — нечего доставлять.
|
||||||
|
return
|
||||||
|
|
||||||
|
operator = message.get("from") or {}
|
||||||
|
operator_id = operator.get("id")
|
||||||
|
storage.record_web_out_message(
|
||||||
|
thread_id=web_thread_id,
|
||||||
|
text_body=text_body,
|
||||||
|
operator_tg_id=operator_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем,
|
||||||
|
# это ожидаемый шум. НО реплай на сообщение, отправленное САМИМ БОТОМ
|
||||||
|
# (is_bot=True) и при этом отсутствующее ни в tg_support_messages, ни в
|
||||||
|
# web_support_messages — подозрительно: вероятная причина — осиротевшее
|
||||||
|
# зеркало (воркер/API упал МЕЖДУ отправкой зеркала и commit'ом записи в БД).
|
||||||
|
# Дискриминатор неидеальный (шапка-идентификация тоже от бота, но не
|
||||||
|
# routing-ключ — тоже даст этот WARNING), но лучше редкий ложный WARNING, чем
|
||||||
|
# оператор молча решает, что ответ доставлен, хотя реплай тихо утонул
|
||||||
|
# (#4 review — двухфазный протокол НЕ делаем, overkill).
|
||||||
|
reply_from = reply_to.get("from") or {}
|
||||||
|
if reply_from.get("is_bot"):
|
||||||
|
logger.warning(
|
||||||
|
"tgbot bridge: реплай на сообщение бота (message_id=%d) не найден ни в "
|
||||||
|
"tg_support_messages, ни в web_support_messages как зеркало — возможно, "
|
||||||
|
"осиротевшее зеркало (крах между отправкой и commit'ом) или "
|
||||||
|
"шапка-идентификация; ответ оператора НЕ доставлен",
|
||||||
|
mirror_message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def process_update(
|
async def process_update(
|
||||||
|
|
|
||||||
|
|
@ -238,12 +238,28 @@ class TelegramClient:
|
||||||
text: str,
|
text: str,
|
||||||
message_thread_id: int | None = None,
|
message_thread_id: int | None = None,
|
||||||
reply_to_message_id: int | None = None,
|
reply_to_message_id: int | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
max_retries: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке)."""
|
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке).
|
||||||
|
|
||||||
|
`timeout`/`max_retries` — по умолчанию наследуют воркерную политику
|
||||||
|
(`_DEFAULT_TIMEOUT_S`/`_DEFAULT_MAX_RETRIES`: на 429 спим Telegram-овский
|
||||||
|
`retry_after` — для группы это штатные 30-60с, на 5xx backoff до 30с).
|
||||||
|
Это ПРИЕМЛЕМО для `tgbot_main.py` (изолированный long-polling воркер), но
|
||||||
|
ФАТАЛЬНО для интерактивного HTTP-запроса (#tgsupport-web review H1) —
|
||||||
|
синхронный request/response путь не может легально висеть минуты. Вызывающая
|
||||||
|
сторона на interactive-пути ОБЯЗАНА передать узкий бюджет явно (см.
|
||||||
|
`app.api.v1.support.send_support_message`)."""
|
||||||
payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
|
payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
|
||||||
if message_thread_id:
|
if message_thread_id:
|
||||||
payload["message_thread_id"] = message_thread_id
|
payload["message_thread_id"] = message_thread_id
|
||||||
if reply_to_message_id:
|
if reply_to_message_id:
|
||||||
payload["reply_to_message_id"] = reply_to_message_id
|
payload["reply_to_message_id"] = reply_to_message_id
|
||||||
result = await self._request("sendMessage", payload)
|
kwargs: dict[str, Any] = {}
|
||||||
|
if timeout is not None:
|
||||||
|
kwargs["timeout"] = timeout
|
||||||
|
if max_retries is not None:
|
||||||
|
kwargs["max_retries"] = max_retries
|
||||||
|
result = await self._request("sendMessage", payload, **kwargs)
|
||||||
return result if isinstance(result, dict) else {}
|
return result if isinstance(result, dict) else {}
|
||||||
|
|
|
||||||
216
tradein-mvp/backend/app/services/tgbot/web_support_storage.py
Normal file
216
tradein-mvp/backend/app/services/tgbot/web_support_storage.py
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
"""Persistence для веб-чата поддержки (#tgsupport-web) — web_support_threads /
|
||||||
|
web_support_messages (см. data/sql/187_web_support_chat.sql, комментарий там же
|
||||||
|
объясняет, почему отдельные таблицы, а не `channel`-колонка в tg_support_*).
|
||||||
|
|
||||||
|
Чистые SQL-функции поверх SQLAlchemy `Session` (psycopg v3) — никакой
|
||||||
|
Telegram-логики здесь. Используется ДВУМЯ вызывающими сторонами:
|
||||||
|
- `app.api.v1.support` (FastAPI-роутер) — создание/поиск треда, запись
|
||||||
|
inbound-сообщения, чтение истории/unread/read.
|
||||||
|
- `app.services.tgbot.bridge._handle_group_reply` — резолвит
|
||||||
|
topic_message_id реплая оператора в web-тред и пишет outbound-ответ.
|
||||||
|
|
||||||
|
Тред резолвится ТОЛЬКО по username (X-Authenticated-User) — ни один метод
|
||||||
|
здесь не принимает thread_id снаружи, поэтому IDOR (чтение чужого треда)
|
||||||
|
структурно невозможен на уровне API (см. app/api/v1/support.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def find_thread_id(db: Session, username: str) -> int | None:
|
||||||
|
"""Возвращает id треда для *username*, если он уже существует, иначе None.
|
||||||
|
|
||||||
|
Используется read-путями (GET .../messages, .../unread) — они НЕ должны
|
||||||
|
создавать тред просто фактом обращения (иначе каждое открытие виджета
|
||||||
|
поддержки создавало бы пустой тред для любого пользователя сайта).
|
||||||
|
"""
|
||||||
|
row = db.execute(
|
||||||
|
text("SELECT id FROM web_support_threads WHERE username = CAST(:username AS text)"),
|
||||||
|
{"username": username},
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0]) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_thread(db: Session, username: str) -> int:
|
||||||
|
"""Гарантирует существование треда для *username*, обновляя last_seen_at.
|
||||||
|
|
||||||
|
Вызывается ТОЛЬКО из send-пути (POST .../messages) — отправка сообщения
|
||||||
|
это единственное действие, которое должно "создавать" тред.
|
||||||
|
"""
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO web_support_threads (username, created_at, last_seen_at, last_read_at)
|
||||||
|
VALUES (CAST(:username AS text), NOW(), NOW(), NOW())
|
||||||
|
ON CONFLICT (username) DO UPDATE
|
||||||
|
SET last_seen_at = NOW()
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"username": username},
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None # INSERT ... RETURNING всегда отдаёт строку
|
||||||
|
return int(row[0])
|
||||||
|
|
||||||
|
|
||||||
|
def record_inbound(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
thread_id: int,
|
||||||
|
text_body: str,
|
||||||
|
topic_message_id: int | None,
|
||||||
|
support_chat_id: int | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` —
|
||||||
|
id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора.
|
||||||
|
`support_chat_id` — TELEGRAM_SUPPORT_CHAT_ID В МОМЕНТ отправки (#tgsupport-web
|
||||||
|
review M1): скоупит будущий резолв `find_thread_by_topic_message` к ТЕКУЩЕЙ
|
||||||
|
support-группе — если группу когда-нибудь сменят/пересоздадут, Telegram
|
||||||
|
message_id стартует заново с 1 в новом чате и может совпасть с числом из
|
||||||
|
старого — без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188)."""
|
||||||
|
row = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO web_support_messages
|
||||||
|
(thread_id, direction, text_body, topic_message_id,
|
||||||
|
support_chat_id, operator_tg_id, created_at)
|
||||||
|
VALUES
|
||||||
|
(CAST(:thread_id AS bigint), 'in', :text_body,
|
||||||
|
CAST(:topic_message_id AS bigint),
|
||||||
|
CAST(:support_chat_id AS bigint), NULL, NOW())
|
||||||
|
RETURNING id, direction, text_body, operator_tg_id, created_at
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"text_body": text_body,
|
||||||
|
"topic_message_id": topic_message_id,
|
||||||
|
"support_chat_id": support_chat_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def find_thread_by_topic_message(
|
||||||
|
db: Session, topic_message_id: int, support_chat_id: int
|
||||||
|
) -> int | None:
|
||||||
|
"""Резолвит id зеркала (сообщения оператора reply_to) в thread_id — только
|
||||||
|
среди direction='in' записей, зеркало-конвенция как в tg_support_messages (186).
|
||||||
|
|
||||||
|
Скоупим к ТЕКУЩЕМУ `support_chat_id` (#tgsupport-web review M1): строка со
|
||||||
|
ЧУЖИМ (не NULL и не текущим) support_chat_id — это исторический артефакт
|
||||||
|
ротации support-группы, НЕ валидный маршрут для сегодняшнего реплая. NULL
|
||||||
|
(легаси-строки до этой колонки, если такие есть) — лениентно матчатся как
|
||||||
|
"любой чат", т.к. до введения этого поля был ровно один действующий чат."""
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT thread_id
|
||||||
|
FROM web_support_messages
|
||||||
|
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
|
||||||
|
AND direction = 'in'
|
||||||
|
AND (support_chat_id = CAST(:support_chat_id AS bigint) OR support_chat_id IS NULL)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"topic_message_id": topic_message_id, "support_chat_id": support_chat_id},
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0]) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def record_outbound(
|
||||||
|
db: Session, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||||||
|
) -> int | None:
|
||||||
|
"""Записывает ответ оператора (реплай на веб-зеркало) как direction='out'.
|
||||||
|
`topic_message_id` всегда NULL — маршрутизирующий ключ живёт только на
|
||||||
|
inbound-записи (см. tg_support_messages-конвенцию, 186)."""
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO web_support_messages
|
||||||
|
(thread_id, direction, text_body, topic_message_id, operator_tg_id, created_at)
|
||||||
|
VALUES
|
||||||
|
(CAST(:thread_id AS bigint), 'out', :text_body, NULL,
|
||||||
|
CAST(:operator_tg_id AS bigint), NOW())
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"text_body": text_body,
|
||||||
|
"operator_tg_id": operator_tg_id,
|
||||||
|
},
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0]) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_messages(
|
||||||
|
db: Session, *, thread_id: int, since_id: int, limit: int = 200
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Сообщения треда с id > since_id, по возрастанию (обычный polling с фронта).
|
||||||
|
|
||||||
|
`limit` (#tgsupport-web review M5): без него КАЖДОЕ монтирование виджета на
|
||||||
|
старом треде отдавало бы ВЕСЬ лог переписки. Берём последние `limit` (ORDER
|
||||||
|
BY id DESC + LIMIT), потом разворачиваем в хронологический порядок — так
|
||||||
|
incremental-polling (`since_id` = последний известный id, обычно единицы
|
||||||
|
новых строк) не страдает, а первый холодный load длинного треда получает
|
||||||
|
последние `limit`, а не самые старые."""
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT id, direction, text_body, operator_tg_id, created_at
|
||||||
|
FROM web_support_messages
|
||||||
|
WHERE thread_id = CAST(:thread_id AS bigint)
|
||||||
|
AND id > CAST(:since_id AS bigint)
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT CAST(:limit AS integer)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"thread_id": thread_id, "since_id": since_id, "limit": limit},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [dict(r) for r in reversed(rows)]
|
||||||
|
|
||||||
|
|
||||||
|
def count_unread(db: Session, *, thread_id: int) -> int:
|
||||||
|
"""Кол-во ответов оператора (direction='out'), пришедших после last_read_at."""
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT count(*)
|
||||||
|
FROM web_support_messages m
|
||||||
|
JOIN web_support_threads t ON t.id = m.thread_id
|
||||||
|
WHERE m.thread_id = CAST(:thread_id AS bigint)
|
||||||
|
AND m.direction = 'out'
|
||||||
|
AND m.created_at > t.last_read_at
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"thread_id": thread_id},
|
||||||
|
).scalar()
|
||||||
|
return int(row or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_read(db: Session, *, thread_id: int) -> None:
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE web_support_threads SET last_read_at = NOW() "
|
||||||
|
"WHERE id = CAST(:thread_id AS bigint)"
|
||||||
|
),
|
||||||
|
{"thread_id": thread_id},
|
||||||
|
)
|
||||||
122
tradein-mvp/backend/data/sql/187_web_support_chat.sql
Normal file
122
tradein-mvp/backend/data/sql/187_web_support_chat.sql
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
-- 187_web_support_chat.sql
|
||||||
|
-- Web-чат поддержки (сайт МЕРА) поверх УЖЕ существующего Telegram support-моста
|
||||||
|
-- (data/sql/186_tg_support.sql, app/services/tgbot/bridge.py). Источник обращения
|
||||||
|
-- меняется (сайт вместо Telegram-лички клиента), маршрутизация ответа оператора
|
||||||
|
-- (реплай на зеркало в топике супергруппы) остаётся ТОЙ ЖЕ — оператор ничего
|
||||||
|
-- нового не учит.
|
||||||
|
--
|
||||||
|
-- ПОЧЕМУ ОТДЕЛЬНЫЕ ТАБЛИЦЫ, А НЕ "tg_support_* + channel"
|
||||||
|
-- (взвешено явно, per code review requirement):
|
||||||
|
--
|
||||||
|
-- Вариант А (отклонён) — добавить channel text ('telegram'|'web') в
|
||||||
|
-- tg_support_users/tg_support_messages:
|
||||||
|
-- - tg_support_users.chat_id bigint PRIMARY KEY — это Telegram private
|
||||||
|
-- chat id клиента. У веб-пользователя сайта ЕГО НЕТ (клиент никогда не
|
||||||
|
-- писал боту в личку) — пришлось бы либо (а) городить синтетический
|
||||||
|
-- chat_id для веб-юзера (напр. отрицательный hash от username) — это
|
||||||
|
-- вводит ВТОРУЮ систему идентификации внутри одной PK-колонки,
|
||||||
|
-- семантика которой документирована как "Telegram chat id" (186:54),
|
||||||
|
-- либо (б) делать chat_id NULLABLE и городить ещё одну колонку
|
||||||
|
-- username NULLABLE рядом — таблица с двумя взаимоисключающими
|
||||||
|
-- identity-схемами и кучей CHECK-ограничений вида
|
||||||
|
-- "chat_id XOR username NOT NULL".
|
||||||
|
-- - Доставка обратно ТОЖЕ разная: Telegram-путь шлёт copyMessage в личку
|
||||||
|
-- клиента, веб-путь просто пишет строку в БД (personal chat не
|
||||||
|
-- существует) — код в bridge.py и так ветвится по каналу, общая
|
||||||
|
-- таблица не убирает эту ветку, только добавляет NULL-поля.
|
||||||
|
-- - Риск регрессии: tg_support_* уже покрыты test_bridge.py (14+
|
||||||
|
-- кейсов) и работают в проде (PR #2526) — трогать рабочую, протестированную
|
||||||
|
-- схему ради ещё не запущенной фичи повышает blast radius без выгоды.
|
||||||
|
--
|
||||||
|
-- Вариант Б (выбран) — новые web_support_threads/web_support_messages:
|
||||||
|
-- - Идентификатор клиента — username (X-Authenticated-User, сайт закрыт
|
||||||
|
-- Caddy basic_auth, публичного доступа нет — см. app/main.py rbac_guard)
|
||||||
|
-- — чистый, не smoke-и-зеркала не переиспользующий Telegram identity.
|
||||||
|
-- - topic_message_id-маршрутизация (ключевой механизм моста) СОХРАНЕНА
|
||||||
|
-- 1-в-1 по конвенции 186: partial UNIQUE на topic_message_id,
|
||||||
|
-- заполняется только для direction='in', NULL для direction='out'.
|
||||||
|
-- - bridge.py меняется МИНИМАЛЬНО: _handle_group_reply получает одну
|
||||||
|
-- дополнительную ветку (резолвит tg-путь И web-путь, потом
|
||||||
|
-- existing orphan-warning) — существующий Telegram-путь не трогается.
|
||||||
|
--
|
||||||
|
-- ⚠️ CROSS-TABLE КОЛЛИЗИЯ topic_message_id (review M1, зафиксировано ДО
|
||||||
|
-- первого прод-использования, пока обе таблицы пусты):
|
||||||
|
-- Инвариант "topic_message_id уникален между tg_support_messages и
|
||||||
|
-- web_support_messages" на самом деле звучит так: "уникален, ПОКА
|
||||||
|
-- TELEGRAM_SUPPORT_CHAT_ID не менялся". Это OPS-инвариант, а НЕ DB-инвариант —
|
||||||
|
-- ничем не гарантирован. Смена/пересоздание support-группы обнуляет счётчик
|
||||||
|
-- Telegram message_id в новом чате; когда он дорастёт до диапазона,
|
||||||
|
-- использованного старым чатом, — number, ранее занятый ОДНОЙ таблицей,
|
||||||
|
-- может совпасть с числом, занятым ДРУГОЙ. Внутри одной таблицы partial
|
||||||
|
-- UNIQUE превращает такую коллизию в громкий отказ INSERT — это ок. МЕЖДУ
|
||||||
|
-- таблицами constraint'а нет: без доп. скоупинга бот молча доставил бы ответ
|
||||||
|
-- оператора НЕ ТОМУ клиенту (152-ФЗ-инцидент, происходящий тихо).
|
||||||
|
-- Фикс: колонка `support_chat_id` на web_support_messages (симметричная
|
||||||
|
-- колонка для УЖЕ применённой tg_support_messages — отдельная миграция
|
||||||
|
-- 188_tg_support_chat_id_scope.sql, эту таблицу нельзя трогать здесь, она
|
||||||
|
-- уже применена/задеплоена как часть 186). Резолв (bridge.py) матчит ПАРУ
|
||||||
|
-- (support_chat_id, topic_message_id), а не topic_message_id в одиночку;
|
||||||
|
-- NULL (легаси-строки без этой колонки) — лениентный wildcard, т.к. на тот
|
||||||
|
-- момент действовал ровно один чат.
|
||||||
|
--
|
||||||
|
-- ЧТО:
|
||||||
|
-- - web_support_threads — один тред на username (сайт = 1 логин = 1 линия
|
||||||
|
-- переписки с поддержкой, без под-тредов).
|
||||||
|
-- - web_support_messages — лог переписки, direction='in' (от юзера) |
|
||||||
|
-- 'out' (ответ оператора, реплай из bridge.py).
|
||||||
|
--
|
||||||
|
-- 152-ФЗ:
|
||||||
|
-- Переписка (text_body) — ПДн (может содержать любые данные, которые юзер
|
||||||
|
-- решит написать). ON DELETE CASCADE от web_support_threads делает erasure
|
||||||
|
-- ОДНОЙ операцией (DELETE FROM web_support_threads WHERE username = :u) ДЛЯ
|
||||||
|
-- КОПИИ В ЭТОЙ БД. Копия того же текста уже ушла в Telegram-топик (sendMessage
|
||||||
|
-- зеркало) и живёт ТАМ вне зоны действия этого DELETE — реальное "право на
|
||||||
|
-- забвение" по всей цепочке требует ОТДЕЛЬНОЙ процедуры (удаление сообщений в
|
||||||
|
-- Telegram-супергруппе через Bot API deleteMessage, вне scope этой миграции).
|
||||||
|
-- Не ссылаться на этот комментарий как на доказательство полного erasure.
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY: CREATE TABLE/INDEX IF NOT EXISTS — безопасный re-run.
|
||||||
|
-- Зависимости: нет (новые standalone таблицы, никакие существующие
|
||||||
|
-- tg_support_*/иные таблицы не трогаются — см. 188 для ALTER на tg_support_messages).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS web_support_threads (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
username text NOT NULL UNIQUE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_read_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE web_support_threads IS '152-ФЗ: одна строка на username (сайт МЕРА, X-Authenticated-User) — единый тред переписки с поддержкой через веб-чат. Удаление клиента — DELETE FROM web_support_threads WHERE username=...; ON DELETE CASCADE в web_support_messages подчищает переписку одной операцией.';
|
||||||
|
COMMENT ON COLUMN web_support_threads.username IS 'X-Authenticated-User (Caddy basic_auth) — сайт закрыт, анонимов нет, см. app/main.py rbac_guard.';
|
||||||
|
COMMENT ON COLUMN web_support_threads.last_seen_at IS 'Обновляется при отправке юзером нового сообщения (send-активность, НЕ на чтение истории).';
|
||||||
|
COMMENT ON COLUMN web_support_threads.last_read_at IS 'Отметка "прочитано до" (POST /api/v1/trade-in/support/read) — используется для счётчика непрочитанного (GET /support/unread).';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS web_support_messages (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
thread_id bigint NOT NULL REFERENCES web_support_threads (id) ON DELETE CASCADE,
|
||||||
|
direction text NOT NULL CHECK (direction IN ('in', 'out')),
|
||||||
|
text_body text NOT NULL CHECK (char_length(btrim(text_body)) > 0),
|
||||||
|
topic_message_id bigint,
|
||||||
|
support_chat_id bigint,
|
||||||
|
operator_tg_id bigint,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE web_support_messages IS '152-ФЗ: полный лог веб-чата поддержки (ПДн — содержимое сообщений; удаление подчищает КОПИЮ В ЭТОЙ БД, не Telegram-топик — см. блок 152-ФЗ выше). Каскадно удаляется вместе с web_support_threads по username.';
|
||||||
|
COMMENT ON COLUMN web_support_messages.direction IS '''in'' — сообщение от пользователя сайта; ''out'' — ответ оператора (доставлен через реплай в Telegram-топике, см. bridge.py _handle_group_reply).';
|
||||||
|
COMMENT ON COLUMN web_support_messages.text_body IS 'Текст сообщения. Веб-чат — текстовый MVP, медиа не поддерживается (в отличие от tg_support_messages.kind).';
|
||||||
|
COMMENT ON COLUMN web_support_messages.topic_message_id IS 'id зеркала (sendMessage) в support-топике — ключ маршрутизации ответа, только для direction=''in''. NULL для ''out'' (конвенция 186: маршрутизирующий ключ живёт исключительно на inbound-записи).';
|
||||||
|
COMMENT ON COLUMN web_support_messages.support_chat_id IS 'TELEGRAM_SUPPORT_CHAT_ID в момент отправки — скоупит резолв topic_message_id к ТЕКУЩЕЙ support-группе (review M1: без этого поля ротация группы даёт тихую cross-table коллизию, см. блок выше). NULL — лениентный wildcard для строк без этого поля.';
|
||||||
|
COMMENT ON COLUMN web_support_messages.operator_tg_id IS 'Telegram user id оператора, ответившего в топике; заполняется только для direction=''out''.';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS web_support_messages_topic_message_id_uq
|
||||||
|
ON web_support_messages (topic_message_id)
|
||||||
|
WHERE topic_message_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS web_support_messages_thread_id_created_at_idx
|
||||||
|
ON web_support_messages (thread_id, created_at DESC);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
-- 188_tg_support_chat_id_scope.sql
|
||||||
|
-- Симметричная колонка для web_support_messages.support_chat_id (см.
|
||||||
|
-- data/sql/187_web_support_chat.sql — полный разбор проблемы в блоке "CROSS-TABLE
|
||||||
|
-- КОЛЛИЗИЯ topic_message_id" там же).
|
||||||
|
--
|
||||||
|
-- ПОЧЕМУ ОТДЕЛЬНАЯ МИГРАЦИЯ, А НЕ ПРАВКА 186:
|
||||||
|
-- tg_support_messages создана в data/sql/186_tg_support.sql — миграция, которая
|
||||||
|
-- к моменту написания этого файла уже смержена в main отдельным PR (#2526) и,
|
||||||
|
-- по конвенции проекта (deploy-tradein.yml применяет каждый data/sql/*.sql РОВНО
|
||||||
|
-- ОДИН РАЗ по bare filename через _schema_migrations), скорее всего уже
|
||||||
|
-- применена на проде. Редактирование СОДЕРЖИМОГО уже применённого файла НЕ
|
||||||
|
-- долетает до прода повторным прогоном — прод просто пропустит файл с тем же
|
||||||
|
-- именем. Единственный корректный способ добавить колонку в уже существующую
|
||||||
|
-- таблицу — новый ALTER-файл.
|
||||||
|
--
|
||||||
|
-- ЧТО: tg_support_messages.support_chat_id bigint (nullable) — TELEGRAM_SUPPORT_
|
||||||
|
-- CHAT_ID в момент записи 'in'-сообщения. bridge.py.find_chat_by_topic_message
|
||||||
|
-- матчит (support_chat_id, topic_message_id) вместо topic_message_id в одиночку;
|
||||||
|
-- NULL (все строки ДО этой миграции) — лениентный wildcard-матч, т.к. до
|
||||||
|
-- появления этой колонки действовал ровно один support-чат за раз.
|
||||||
|
--
|
||||||
|
-- Бэкфилл существующих строк текущим TELEGRAM_SUPPORT_CHAT_ID НЕ делаем: значение
|
||||||
|
-- живёт в Python `settings`/env, разное на каждом окружении (dev/staging/prod), а
|
||||||
|
-- plain-SQL миграция не имеет доступа к процессным env vars — хардкодить
|
||||||
|
-- конкретный chat_id в SQL-файл было бы хрупко и окружение-специфично. NULL
|
||||||
|
-- (wildcard) для существующих строк — безопасный дефолт: они писались, когда
|
||||||
|
-- support-чат был ровно один, коллизии из-за смены чата у НИХ по определению
|
||||||
|
-- невозможны (см. 187 — только смена чата ПОСЛЕ появления этой колонки создаёт
|
||||||
|
-- сценарий, который она защищает).
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY: ADD COLUMN IF NOT EXISTS — безопасный re-run. Не трогает
|
||||||
|
-- существующие данные/constraints tg_support_messages.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE tg_support_messages ADD COLUMN IF NOT EXISTS support_chat_id bigint;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN tg_support_messages.support_chat_id IS 'TELEGRAM_SUPPORT_CHAT_ID в момент записи ''in''-сообщения — скоупит резолв topic_message_id к ТЕКУЩЕЙ support-группе (review M1, см. data/sql/187_web_support_chat.sql). NULL — строки до этой колонки (лениентный wildcard-матч).';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -5,6 +5,15 @@ Coverage (per task spec + review follow-up):
|
||||||
сообщение за throttle-окно, без шапки на повторное В окне, и снова с шапкой
|
сообщение за throttle-окно, без шапки на повторное В окне, и снова с шапкой
|
||||||
после истечения окна — #6 review)
|
после истечения окна — #6 review)
|
||||||
- реплай оператора → user (доставка ответа клиенту + запись direction='out')
|
- реплай оператора → user (доставка ответа клиенту + запись direction='out')
|
||||||
|
- реплай оператора → веб-чат (#tgsupport-web): зеркало веб-сообщения резолвится
|
||||||
|
в web-тред (скоуп по (topic_message_id, support_chat_id) — review M1), ответ
|
||||||
|
пишется direction='out' БЕЗ Telegram-доставки; медиа-реплай (в т.ч. фото С
|
||||||
|
ПОДПИСЬЮ) на веб-зеркало — отказ ЦЕЛИКОМ + уведомление оператору в топике
|
||||||
|
(review M2, никакой частичной доставки одной подписи); зеркало от ЧУЖОГО/
|
||||||
|
устаревшего support_chat_id — не матчится (ротация группы); NULL
|
||||||
|
support_chat_id (легаси) — wildcard-матч; совпадение ОБЕИХ сторон
|
||||||
|
одновременно (tg И web) — громкий отказ (logger.error), а не молчаливый
|
||||||
|
выбор tg-пути (152-ФЗ misroute risk)
|
||||||
- реплай не на зеркало (или не реплай вообще) — тихий игнор, не мусорим в чат;
|
- реплай не на зеркало (или не реплай вообще) — тихий игнор, не мусорим в чат;
|
||||||
реплай на СООБЩЕНИЕ БОТА без записи в БД — WARNING про осиротевшее зеркало
|
реплай на СООБЩЕНИЕ БОТА без записи в БД — WARNING про осиротевшее зеркало
|
||||||
(#4 review)
|
(#4 review)
|
||||||
|
|
@ -65,6 +74,12 @@ class FakeBridgeStorage:
|
||||||
self._next_id = 1
|
self._next_id = 1
|
||||||
self.clock_s: float = 0.0
|
self.clock_s: float = 0.0
|
||||||
self.fail_next_record_message = False
|
self.fail_next_record_message = False
|
||||||
|
# #tgsupport-web: web_support_messages-эквивалент, topic_message_id ->
|
||||||
|
# (thread_id, support_chat_id) — второй элемент моделирует колонку
|
||||||
|
# web_support_messages.support_chat_id (review M1); None = легаси wildcard.
|
||||||
|
# + журнал outbound-записей, записанных через реплай оператора.
|
||||||
|
self.web_topic_to_thread: dict[int, tuple[int, int | None]] = {}
|
||||||
|
self.web_out_messages: list[dict[str, Any]] = []
|
||||||
|
|
||||||
def get_offset(self) -> int:
|
def get_offset(self) -> int:
|
||||||
return self._offset
|
return self._offset
|
||||||
|
|
@ -112,6 +127,7 @@ class FakeBridgeStorage:
|
||||||
kind: str,
|
kind: str,
|
||||||
text_body: str | None,
|
text_body: str | None,
|
||||||
operator_tg_id: int | None,
|
operator_tg_id: int | None,
|
||||||
|
support_chat_id: int | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
if self.fail_next_record_message:
|
if self.fail_next_record_message:
|
||||||
self.fail_next_record_message = False
|
self.fail_next_record_message = False
|
||||||
|
|
@ -128,20 +144,51 @@ class FakeBridgeStorage:
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"text_body": text_body,
|
"text_body": text_body,
|
||||||
"operator_tg_id": operator_tg_id,
|
"operator_tg_id": operator_tg_id,
|
||||||
|
"support_chat_id": support_chat_id,
|
||||||
"recorded_at_s": self.clock_s,
|
"recorded_at_s": self.clock_s,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return row_id
|
return row_id
|
||||||
|
|
||||||
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None:
|
def find_chat_by_topic_message(self, topic_message_id: int, support_chat_id: int) -> int | None:
|
||||||
|
"""support_chat_id-скоуп (review M1): запись со ЧУЖИМ (не None, не текущим)
|
||||||
|
support_chat_id не матчится — None (легаси/дефолт) матчится всегда."""
|
||||||
for m in reversed(self.messages):
|
for m in reversed(self.messages):
|
||||||
if m["direction"] == "in" and m["topic_message_id"] == topic_message_id:
|
if m["direction"] != "in" or m["topic_message_id"] != topic_message_id:
|
||||||
|
continue
|
||||||
|
entry_chat_id = m.get("support_chat_id")
|
||||||
|
if entry_chat_id is not None and entry_chat_id != support_chat_id:
|
||||||
|
continue
|
||||||
return m["chat_id"]
|
return m["chat_id"]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def mark_blocked(self, chat_id: int) -> None:
|
def mark_blocked(self, chat_id: int) -> None:
|
||||||
self.blocked.add(chat_id)
|
self.blocked.add(chat_id)
|
||||||
|
|
||||||
|
# ── #tgsupport-web ────────────────────────────────────────────────────
|
||||||
|
def find_web_thread_by_topic_message(
|
||||||
|
self, topic_message_id: int, support_chat_id: int
|
||||||
|
) -> int | None:
|
||||||
|
"""Тот же support_chat_id-скоуп, что и `find_chat_by_topic_message` (review M1)."""
|
||||||
|
entry = self.web_topic_to_thread.get(topic_message_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
thread_id, entry_chat_id = entry
|
||||||
|
if entry_chat_id is not None and entry_chat_id != support_chat_id:
|
||||||
|
return None
|
||||||
|
return thread_id
|
||||||
|
|
||||||
|
def record_web_out_message(
|
||||||
|
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||||||
|
) -> None:
|
||||||
|
self.web_out_messages.append(
|
||||||
|
{
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"text_body": text_body,
|
||||||
|
"operator_tg_id": operator_tg_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── httpx mocking helpers (mirrors tests/services/test_dadata.py) ───────────
|
# ── httpx mocking helpers (mirrors tests/services/test_dadata.py) ───────────
|
||||||
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||||||
|
|
@ -528,6 +575,167 @@ async def test_group_reply_403_marks_blocked_and_notifies_topic() -> None:
|
||||||
assert storage.get_offset() == 23
|
assert storage.get_offset() == 23
|
||||||
|
|
||||||
|
|
||||||
|
# ── B') реплай оператора → веб-чат (#tgsupport-web) ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_reply_to_web_mirror_records_outbound_web_message() -> None:
|
||||||
|
"""Реплай на зеркало веб-сообщения (не найдено в tg_support_messages, найдено
|
||||||
|
среди web_support_messages) → записывается в веб-тред, БЕЗ Telegram-доставки
|
||||||
|
(у веб-клиента нет личного чата с ботом)."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
# topic_message_id=300 -> thread_id=42, под ТЕКУЩИМ support_chat_id.
|
||||||
|
storage.web_topic_to_thread[300] = (42, SUPPORT_CHAT_ID)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 60,
|
||||||
|
"message": _group_reply_message(reply_to_message_id=300, text="Ответ по веб-чату"),
|
||||||
|
}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
# Никакого Telegram API вызова — веб-клиент не имеет личного чата с ботом.
|
||||||
|
assert calls == []
|
||||||
|
assert len(storage.web_out_messages) == 1
|
||||||
|
rec = storage.web_out_messages[0]
|
||||||
|
assert rec["thread_id"] == 42
|
||||||
|
assert rec["text_body"] == "Ответ по веб-чату"
|
||||||
|
assert rec["operator_tg_id"] == 777
|
||||||
|
# tg-путь тоже не тронут — ни одной записи в tg_support_messages.
|
||||||
|
assert storage.messages == []
|
||||||
|
assert storage.get_offset() == 60
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_reply_to_web_mirror_with_null_support_chat_id_matches_current_chat() -> None:
|
||||||
|
"""Легаси-строка (до 187/188, support_chat_id=None) — лениентный wildcard,
|
||||||
|
матчится под ЛЮБЫМ текущим support_chat_id (review M1)."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
storage.web_topic_to_thread[305] = (46, None)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 63,
|
||||||
|
"message": _group_reply_message(reply_to_message_id=305, text="Ответ по легаси-зеркалу"),
|
||||||
|
}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
assert len(storage.web_out_messages) == 1
|
||||||
|
assert storage.web_out_messages[0]["thread_id"] == 46
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_reply_to_web_mirror_from_stale_support_chat_is_not_matched() -> None:
|
||||||
|
"""#tgsupport-web review M1: зеркало, записанное под ДРУГИМ (не текущим,
|
||||||
|
не None) support_chat_id — исторический артефакт ротации группы, НЕ валидный
|
||||||
|
маршрут сегодня. Не матчится → падает в orphan-check (не-bot реплай — тихий
|
||||||
|
игнор, никакой доставки в чужой/устаревший тред)."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
stale_chat_id = -999999999999
|
||||||
|
storage.web_topic_to_thread[306] = (47, stale_chat_id)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 64,
|
||||||
|
"message": _group_reply_message(reply_to_message_id=306),
|
||||||
|
}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
assert storage.web_out_messages == [] # НЕ доставлено в устаревший тред
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_reply_to_web_mirror_without_text_is_refused_with_operator_notice(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Веб-чат — текстовый MVP: реплай медиа-типом (нет text/caption) на веб-зеркало
|
||||||
|
не может быть доставлен — WARNING в лог И явное уведомление оператору в топике
|
||||||
|
(review M2: раньше был тихий игнор, оператор был уверен что ответил)."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
storage.web_topic_to_thread[301] = (43, SUPPORT_CHAT_ID)
|
||||||
|
|
||||||
|
message = _group_reply_message(reply_to_message_id=301, message_id=201)
|
||||||
|
del message["text"] # медиа-реплай без текста/caption
|
||||||
|
message["voice"] = {"file_id": "x"}
|
||||||
|
update = {"update_id": 61, "message": message}
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
assert storage.web_out_messages == []
|
||||||
|
assert "не текст" in caplog.text
|
||||||
|
assert storage.get_offset() == 61
|
||||||
|
|
||||||
|
methods = [m for m, _ in calls]
|
||||||
|
assert methods == ["sendMessage"]
|
||||||
|
notice_call = calls[0][1]
|
||||||
|
assert notice_call["chat_id"] == SUPPORT_CHAT_ID
|
||||||
|
assert notice_call["text"] == bridge._WEB_UNSUPPORTED_MEDIA_REPLY_TEXT
|
||||||
|
assert notice_call["reply_to_message_id"] == 201
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_reply_to_web_mirror_with_photo_and_caption_is_refused_not_partial() -> None:
|
||||||
|
"""Фото С ПОДПИСЬЮ на веб-зеркало — НЕ доставляем только подпись молча
|
||||||
|
(клиент решил бы, что подпись — весь ответ): отказ целиком, как и без caption
|
||||||
|
(review M2)."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
storage.web_topic_to_thread[302] = (44, SUPPORT_CHAT_ID)
|
||||||
|
|
||||||
|
message = _group_reply_message(reply_to_message_id=302, message_id=202)
|
||||||
|
del message["text"]
|
||||||
|
message["photo"] = [{"file_id": "x"}]
|
||||||
|
message["caption"] = "Смотрите скриншот"
|
||||||
|
update = {"update_id": 65, "message": message}
|
||||||
|
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
assert storage.web_out_messages == [] # подпись НЕ доставлена как "весь ответ"
|
||||||
|
methods = [m for m, _ in calls]
|
||||||
|
assert methods == ["sendMessage"]
|
||||||
|
assert calls[0][1]["text"] == bridge._WEB_UNSUPPORTED_MEDIA_REPLY_TEXT
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_reply_refuses_delivery_when_both_tg_and_web_match(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""#tgsupport-web review M1: если topic_message_id одновременно резолвится и в
|
||||||
|
tg_support_messages, И в web_support_messages (под ОДНИМ и тем же
|
||||||
|
support_chat_id — целостность нарушена) — ГРОМКИЙ отказ (logger.error), НИКАКОЙ
|
||||||
|
доставки ни в Telegram-личку, ни в веб-тред. Раньше tg-путь выбирался молча —
|
||||||
|
misroute постороннему Telegram-клиенту (152-ФЗ risk)."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({"copyMessage": {"message_id": 999}}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
storage.record_message(
|
||||||
|
chat_id=555,
|
||||||
|
direction="in",
|
||||||
|
tg_message_id=1,
|
||||||
|
topic_message_id=400,
|
||||||
|
kind="text",
|
||||||
|
text_body="вопрос клиента",
|
||||||
|
operator_tg_id=None,
|
||||||
|
support_chat_id=SUPPORT_CHAT_ID,
|
||||||
|
)
|
||||||
|
storage.web_topic_to_thread[400] = (99, SUPPORT_CHAT_ID)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 62,
|
||||||
|
"message": _group_reply_message(reply_to_message_id=400),
|
||||||
|
}
|
||||||
|
with caplog.at_level(logging.ERROR, logger="app.services.tgbot.bridge"):
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
assert calls == [] # ничего не доставлено НИ В ОДНУ сторону
|
||||||
|
assert storage.web_out_messages == []
|
||||||
|
assert len(storage.messages) == 1 # только исходное 'in', никакого 'out'
|
||||||
|
assert "ОДНОВРЕМЕННО" in caplog.text
|
||||||
|
assert storage.get_offset() == 62
|
||||||
|
|
||||||
|
|
||||||
# ── C) дедуп ──────────────────────────────────────────────────────────────────
|
# ── C) дедуп ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""Audit-scrapers finding 3: Avito detail publish_date year-boundary rollover.
|
||||||
|
|
||||||
|
Avito не показывает год для дат текущего года («20 декабря в 15:30»). Раньше
|
||||||
|
`_extract_meta` всегда брал ТЕКУЩИЙ год момента парсинга — объявлению, опубликованному
|
||||||
|
в декабре и прочитанному в январе следующего года, ставился год парсинга (будущая
|
||||||
|
дата), завышая свежесть лота. Фикс: если получившаяся дата оказалась в будущем
|
||||||
|
относительно момента парсинга — откатываем на год назад.
|
||||||
|
|
||||||
|
Refs: audit-scrapers 2026-07-26, finding 3 (low).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import date as real_date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from selectolax.parser import HTMLParser
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from scraper_kit.providers.avito import detail as kit_detail
|
||||||
|
|
||||||
|
|
||||||
|
def _freeze_today(monkeypatch: pytest.MonkeyPatch, frozen: real_date) -> None:
|
||||||
|
"""Подменяет `date` в scraper_kit.providers.avito.detail так, что date.today()
|
||||||
|
детерминированно возвращает `frozen` (date — immutable C-тип, .today нельзя
|
||||||
|
monkeypatch'нуть напрямую — подменяем ссылку на класс в модуле)."""
|
||||||
|
|
||||||
|
class _FrozenDate(real_date):
|
||||||
|
@classmethod
|
||||||
|
def today(cls) -> real_date: # type: ignore[override]
|
||||||
|
return frozen
|
||||||
|
|
||||||
|
monkeypatch.setattr(kit_detail, "date", _FrozenDate)
|
||||||
|
|
||||||
|
|
||||||
|
def _tree_with_publish_text(text: str) -> HTMLParser:
|
||||||
|
html = f'<html><body><div data-marker="item-view/item-id">{text}</div></body></html>'
|
||||||
|
return HTMLParser(html)
|
||||||
|
|
||||||
|
|
||||||
|
def test_december_publish_date_read_in_january_rolls_back_a_year(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Объявление '20 декабря' парсится 5 января СЛЕДУЮЩЕГО года: без фикса
|
||||||
|
дата была бы 2027-12-20 (в будущем относительно today=2027-01-05) — теперь
|
||||||
|
откатывается на 2026-12-20."""
|
||||||
|
_freeze_today(monkeypatch, real_date(2027, 1, 5))
|
||||||
|
tree = _tree_with_publish_text("№ 4291500000 · 20 декабря в 15:30")
|
||||||
|
|
||||||
|
publish_date, _, _ = kit_detail._extract_meta(tree)
|
||||||
|
|
||||||
|
assert publish_date == real_date(2026, 12, 20)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_year_past_publish_date_not_rolled_back(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Control: дата в прошлом (не будущем) в том же году — год НЕ откатывается."""
|
||||||
|
_freeze_today(monkeypatch, real_date(2027, 1, 5))
|
||||||
|
tree = _tree_with_publish_text("№ 4291500001 · 3 января в 09:00")
|
||||||
|
|
||||||
|
publish_date, _, _ = kit_detail._extract_meta(tree)
|
||||||
|
|
||||||
|
assert publish_date == real_date(2027, 1, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_date_equal_to_today_not_rolled_back(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Control: дата ровно = today (не строго будущее) — год НЕ откатывается."""
|
||||||
|
_freeze_today(monkeypatch, real_date(2027, 1, 5))
|
||||||
|
tree = _tree_with_publish_text("№ 4291500002 · 5 января в 12:00")
|
||||||
|
|
||||||
|
publish_date, _, _ = kit_detail._extract_meta(tree)
|
||||||
|
|
||||||
|
assert publish_date == real_date(2027, 1, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mid_year_publish_date_not_rolled_back(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Обычный случай вдали от границы года — поведение не меняется."""
|
||||||
|
_freeze_today(monkeypatch, real_date(2027, 6, 15))
|
||||||
|
tree = _tree_with_publish_text("№ 4291500003 · 20 марта в 10:00")
|
||||||
|
|
||||||
|
publish_date, _, _ = kit_detail._extract_meta(tree)
|
||||||
|
|
||||||
|
assert publish_date == real_date(2027, 3, 20)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_publish_date_in_text_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Regression guard: отсутствие даты в тексте по-прежнему даёт None (не падает)."""
|
||||||
|
_freeze_today(monkeypatch, real_date(2027, 1, 5))
|
||||||
|
tree = _tree_with_publish_text("№ 4291500004")
|
||||||
|
|
||||||
|
publish_date, _, _ = kit_detail._extract_meta(tree)
|
||||||
|
|
||||||
|
assert publish_date is None
|
||||||
206
tradein-mvp/backend/tests/test_avito_sweep_dom_drift.py
Normal file
206
tradein-mvp/backend/tests/test_avito_sweep_dom_drift.py
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
"""Audit-scrapers finding 1: Avito citywide/byrooms/exhaustive sweep DOM-drift detection.
|
||||||
|
|
||||||
|
Раньше 0 карточек на page=1 (обход всего города / категории комнатности / ценового
|
||||||
|
бакета exhaustive-сбора) молча трактовалось как «объявлений действительно нет» —
|
||||||
|
неотличимо от content-block/captcha или дрейфа DOM-маркера карточки (`data-marker=
|
||||||
|
"item-*"`). Фикс переиспользует существующий механизм `AvitoContentBlockedError`
|
||||||
|
(см. `fetch_around`, #754/#779) + новый `_is_unexpected_empty_page()` — независимый
|
||||||
|
сигнал `_extract_total_count` (счётчик `page-title/count` либо no-results маркер):
|
||||||
|
|
||||||
|
- page=1, 0 карточек, НЕТ no-results маркера/счётчика → аномалия → raise.
|
||||||
|
- page=1, 0 карточек, ЕСТЬ no-results маркер (total=0) → валидная пустая выборка.
|
||||||
|
- page>1, 0 карточек → всегда graceful end-of-pagination (не regressed).
|
||||||
|
- exhaustive leaf-бакет: probe независимо утверждал total>0, но после пагинации
|
||||||
|
всех страниц собрано 0 карточек → аномалия → raise (даже без per-page проверки
|
||||||
|
внутри _paginate_leaf_bucket, т.к. там нет break-on-empty цикла).
|
||||||
|
|
||||||
|
Refs: audit-scrapers 2026-07-26, finding 1 (medium).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from scraper_kit.avito_exceptions import AvitoContentBlockedError
|
||||||
|
from scraper_kit.base import ScrapedLot
|
||||||
|
from scraper_kit.providers.avito.serp import ROOM_SLUGS, AvitoScraper
|
||||||
|
|
||||||
|
from app.services.scraper_adapters import RealScraperConfig
|
||||||
|
|
||||||
|
# HTML "успешно получен, разумного размера", но БЕЗ data-marker="item-*" карточек
|
||||||
|
# И без no-results маркера/счётчика — неотличимо от content-block/DOM-drift.
|
||||||
|
_NO_MARKER_HTML = "<html><body>" + ("x" * 500) + "</body></html>"
|
||||||
|
|
||||||
|
# Валидная пустая выборка: no-results маркер присутствует (_AVITO_NO_RESULTS_MARKERS).
|
||||||
|
_NO_RESULTS_HTML = (
|
||||||
|
"<html><body>По вашему запросу ничего не найдено. Попробуйте изменить фильтры."
|
||||||
|
+ ("y" * 200)
|
||||||
|
+ "</body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Firewall/captcha-страница (переиспользуем существующий fixture-паттерн из #754) —
|
||||||
|
# используется только для проверки, что page>1 остаётся graceful независимо от
|
||||||
|
# содержимого (проверка применяется ТОЛЬКО к page==1).
|
||||||
|
_BLOCKPAGE_HTML = "<html><body><h1>Доступ ограничен</h1></body></html>"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_lot(source_id: str) -> ScrapedLot:
|
||||||
|
return ScrapedLot(
|
||||||
|
source="avito",
|
||||||
|
source_url=f"https://www.avito.ru/ekaterinburg/kvartiry/{source_id}",
|
||||||
|
source_id=source_id,
|
||||||
|
price_rub=6_000_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── fetch_city_wide (_paginate_sweep) ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_citywide_page1_zero_cards_no_marker_raises() -> None:
|
||||||
|
s = AvitoScraper(RealScraperConfig())
|
||||||
|
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)):
|
||||||
|
with pytest.raises(AvitoContentBlockedError):
|
||||||
|
await s.fetch_city_wide(pages=5, delay_override_sec=0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_citywide_page1_zero_cards_with_no_results_marker_is_valid_empty() -> None:
|
||||||
|
s = AvitoScraper(RealScraperConfig())
|
||||||
|
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_RESULTS_HTML)):
|
||||||
|
result = await s.fetch_city_wide(pages=5, delay_override_sec=0)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_citywide_page_gt1_zero_cards_stays_graceful() -> None:
|
||||||
|
"""page=1 реально возвращает карточки (mock _parse_html) — page=2 пустой
|
||||||
|
firewall-текст без карточек НЕ должен поднимать исключение (только page==1)."""
|
||||||
|
s = AvitoScraper(RealScraperConfig())
|
||||||
|
call_n = 0
|
||||||
|
|
||||||
|
async def _fetch(url: str, page: int) -> str:
|
||||||
|
return "<html>page1</html>" if page == 1 else _BLOCKPAGE_HTML
|
||||||
|
|
||||||
|
def _parse(html: str, source_url_base: str) -> list[ScrapedLot]:
|
||||||
|
nonlocal call_n
|
||||||
|
call_n += 1
|
||||||
|
return [_make_lot("A"), _make_lot("B")] if call_n == 1 else []
|
||||||
|
|
||||||
|
with patch.object(s, "_fetch_serp_html", AsyncMock(side_effect=_fetch)):
|
||||||
|
with patch.object(s, "_parse_html", side_effect=_parse):
|
||||||
|
with patch.object(s, "sleep_between_requests", AsyncMock(return_value=None)):
|
||||||
|
result = await s.fetch_city_wide(pages=5, delay_override_sec=0)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert call_n == 2 # page1(2 lots) + page2(0 lots) → stop, no raise
|
||||||
|
|
||||||
|
|
||||||
|
# ── fetch_by_rooms ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_byrooms_category_page1_zero_cards_no_marker_raises() -> None:
|
||||||
|
s = AvitoScraper(RealScraperConfig())
|
||||||
|
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)):
|
||||||
|
with pytest.raises(AvitoContentBlockedError):
|
||||||
|
await s.fetch_by_rooms(pages=5, delay_override_sec=0, room_slugs=ROOM_SLUGS[:1])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_byrooms_category_page1_zero_cards_with_marker_is_valid_empty() -> None:
|
||||||
|
s = AvitoScraper(RealScraperConfig())
|
||||||
|
with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_RESULTS_HTML)):
|
||||||
|
result = await s.fetch_by_rooms(pages=5, delay_override_sec=0, room_slugs=ROOM_SLUGS[:1])
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── _paginate_leaf_bucket (exhaustive/fetch_all_secondary) ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_leaf_bucket_expected_total_positive_but_zero_parsed_raises() -> None:
|
||||||
|
"""Probe независимо утверждал total=5 (bucket не может быть легитимно пустым),
|
||||||
|
но парсинг всех страниц дал 0 карточек — DOM-drift, не пустой бакет."""
|
||||||
|
s = AvitoScraper(RealScraperConfig())
|
||||||
|
seen: dict[str, ScrapedLot] = {}
|
||||||
|
|
||||||
|
with patch.object(s, "_parse_html", return_value=[]):
|
||||||
|
with pytest.raises(AvitoContentBlockedError):
|
||||||
|
await s._paginate_leaf_bucket(
|
||||||
|
room_slug="studii-ASgBAgICAUSSA8YQ",
|
||||||
|
room_label="studio",
|
||||||
|
lo=0,
|
||||||
|
hi=3_000_000,
|
||||||
|
html="<html>probe-page-1</html>",
|
||||||
|
max_pages=1,
|
||||||
|
seen=seen,
|
||||||
|
price_cap_per_bucket=1400,
|
||||||
|
max_pages_per_bucket=100,
|
||||||
|
concurrency=5,
|
||||||
|
secondary_only=True,
|
||||||
|
on_bucket=None,
|
||||||
|
skip_buckets=None,
|
||||||
|
expected_total=5,
|
||||||
|
)
|
||||||
|
assert seen == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_leaf_bucket_expected_total_none_zero_parsed_no_raise() -> None:
|
||||||
|
"""Probe провалился (expected_total=None, best-effort пагинация) — 0 карточек
|
||||||
|
здесь НЕ аномалия (мы не знаем, есть ли реально данные в бакете)."""
|
||||||
|
s = AvitoScraper(RealScraperConfig())
|
||||||
|
seen: dict[str, ScrapedLot] = {}
|
||||||
|
|
||||||
|
with patch.object(s, "_parse_html", return_value=[]):
|
||||||
|
# Не должно поднимать исключение.
|
||||||
|
await s._paginate_leaf_bucket(
|
||||||
|
room_slug="studii-ASgBAgICAUSSA8YQ",
|
||||||
|
room_label="studio",
|
||||||
|
lo=0,
|
||||||
|
hi=3_000_000,
|
||||||
|
html=None,
|
||||||
|
max_pages=1,
|
||||||
|
seen=seen,
|
||||||
|
price_cap_per_bucket=1400,
|
||||||
|
max_pages_per_bucket=100,
|
||||||
|
concurrency=5,
|
||||||
|
secondary_only=True,
|
||||||
|
on_bucket=None,
|
||||||
|
skip_buckets=None,
|
||||||
|
expected_total=None,
|
||||||
|
)
|
||||||
|
assert seen == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_leaf_bucket_expected_total_matches_collected_no_raise() -> None:
|
||||||
|
"""Нормальный путь: probe total=2, парсинг реально даёт 2 карточки — не аномалия."""
|
||||||
|
s = AvitoScraper(RealScraperConfig())
|
||||||
|
seen: dict[str, ScrapedLot] = {}
|
||||||
|
lots = [_make_lot("L1"), _make_lot("L2")]
|
||||||
|
|
||||||
|
with patch.object(s, "_parse_html", return_value=lots):
|
||||||
|
await s._paginate_leaf_bucket(
|
||||||
|
room_slug="studii-ASgBAgICAUSSA8YQ",
|
||||||
|
room_label="studio",
|
||||||
|
lo=0,
|
||||||
|
hi=3_000_000,
|
||||||
|
html="<html>probe-page-1</html>",
|
||||||
|
max_pages=1,
|
||||||
|
seen=seen,
|
||||||
|
price_cap_per_bucket=1400,
|
||||||
|
max_pages_per_bucket=100,
|
||||||
|
concurrency=5,
|
||||||
|
secondary_only=True,
|
||||||
|
on_bucket=None,
|
||||||
|
skip_buckets=None,
|
||||||
|
expected_total=2,
|
||||||
|
)
|
||||||
|
assert set(seen.keys()) == {"L1", "L2"}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
"""Audit-scrapers finding 2: Cian totalOffers vs results.offers length mismatch.
|
||||||
|
|
||||||
|
`_parse_serp_html` извлекает `totalOffers` и `results.offers` из ОДНОГО Redux
|
||||||
|
state-блоба (одна SSR-выдача). Раньше `results.offers` пустой при `totalOffers>0`
|
||||||
|
логировался WARNING'ом и тихо возвращался `[]` — не считался schema-regression, не
|
||||||
|
попадал в мониторинг (`_report_schema_regression`/Glitchtip).
|
||||||
|
|
||||||
|
Порог: 0 vs >0 — единственный позиционно-независимый сигнал, который можно
|
||||||
|
проверить без номера страницы внутри `_parse_serp_html` (эта функция не знает,
|
||||||
|
какая это страница пагинации — дробный порог типа "< 50% от totalOffers" ложно
|
||||||
|
сработал бы на легитимной последней частичной странице exhaustive-пагинации,
|
||||||
|
которую эта функция не различает). totalOffers=0 (реально пустой поиск) НЕ
|
||||||
|
считается регрессией.
|
||||||
|
|
||||||
|
Refs: audit-scrapers 2026-07-26, finding 2 (low).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from scraper_kit.providers.cian.serp import CianScraper
|
||||||
|
|
||||||
|
from app.services.scraper_adapters import RealScraperConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _scraper() -> CianScraper:
|
||||||
|
return CianScraper(RealScraperConfig())
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_offers_positive_but_offers_empty_reports_regression() -> None:
|
||||||
|
"""totalOffers=5, results.offers=[] — internal contradiction, must report."""
|
||||||
|
s = _scraper()
|
||||||
|
state = {"results": {"totalOffers": 5, "offers": []}}
|
||||||
|
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
|
||||||
|
with patch.object(s, "_report_schema_regression") as mock_report:
|
||||||
|
lots = s._parse_serp_html("<html>irrelevant</html>")
|
||||||
|
|
||||||
|
assert lots == []
|
||||||
|
mock_report.assert_called_once()
|
||||||
|
(msg,), _ = mock_report.call_args
|
||||||
|
assert "totalOffers=5" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_offers_zero_and_offers_empty_is_valid_empty_search() -> None:
|
||||||
|
"""totalOffers=0, offers=[] — легитимная пустая выборка, НЕ регрессия."""
|
||||||
|
s = _scraper()
|
||||||
|
state = {"results": {"totalOffers": 0, "offers": []}}
|
||||||
|
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
|
||||||
|
with patch.object(s, "_report_schema_regression") as mock_report:
|
||||||
|
lots = s._parse_serp_html("<html>irrelevant</html>")
|
||||||
|
|
||||||
|
assert lots == []
|
||||||
|
mock_report.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_offers_none_and_offers_empty_is_not_reported_as_regression() -> None:
|
||||||
|
"""totalOffers отсутствует/None в state — недостаточно сигнала для regression-репорта
|
||||||
|
(могла быть частично битая state-структура без явного totalOffers>0 контр-сигнала)."""
|
||||||
|
s = _scraper()
|
||||||
|
state = {"results": {"offers": []}}
|
||||||
|
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
|
||||||
|
with patch.object(s, "_report_schema_regression") as mock_report:
|
||||||
|
lots = s._parse_serp_html("<html>irrelevant</html>")
|
||||||
|
|
||||||
|
assert lots == []
|
||||||
|
mock_report.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_offers_present_normal_path_unaffected() -> None:
|
||||||
|
"""totalOffers=1, offers содержит 1 запись без cianId/id — не проходит
|
||||||
|
_offer_to_lot, но это уже существующая (0/N offer-level) охрана, не finding 2."""
|
||||||
|
s = _scraper()
|
||||||
|
state = {"results": {"totalOffers": 1, "offers": [{"noId": True}]}}
|
||||||
|
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
|
||||||
|
with patch.object(s, "_report_schema_regression") as mock_report:
|
||||||
|
lots = s._parse_serp_html("<html>irrelevant</html>")
|
||||||
|
|
||||||
|
# offers_data непустой → finding 2 guard не участвует; существующая offer-level
|
||||||
|
# охрана (raw_count>0 and saved_count==0) должна отработать вместо неё.
|
||||||
|
assert lots == []
|
||||||
|
mock_report.assert_called_once()
|
||||||
|
(msg,), _ = mock_report.call_args
|
||||||
|
assert "_offer_to_lot" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_none_extraction_failed_no_regression_report() -> None:
|
||||||
|
"""extract_state вернул None (captcha/структура целиком не найдена) — уже
|
||||||
|
существующая ветка, НЕ должна триггерить finding-2 regression report."""
|
||||||
|
s = _scraper()
|
||||||
|
with patch("scraper_kit.providers.cian.serp.extract_state", return_value=None):
|
||||||
|
with patch.object(s, "_report_schema_regression") as mock_report:
|
||||||
|
lots = s._parse_serp_html("<html>irrelevant</html>")
|
||||||
|
|
||||||
|
assert lots == []
|
||||||
|
mock_report.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_schema_regression_swallows_missing_glitchtip_dsn() -> None:
|
||||||
|
"""_report_schema_regression не должен падать, если glitchtip_dsn не настроен."""
|
||||||
|
s = _scraper()
|
||||||
|
s._config = MagicMock(glitchtip_dsn=None)
|
||||||
|
s._report_schema_regression("test message") # не должно бросить исключение
|
||||||
|
|
@ -10,6 +10,8 @@ import pytest
|
||||||
from app.services.cian_session import (
|
from app.services.cian_session import (
|
||||||
CIAN_REQUIRED_COOKIES,
|
CIAN_REQUIRED_COOKIES,
|
||||||
VERIFY_BAN_SENTINEL,
|
VERIFY_BAN_SENTINEL,
|
||||||
|
VERIFY_MARKUP_CHANGED_SENTINEL,
|
||||||
|
VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
|
||||||
_classify_verify_response,
|
_classify_verify_response,
|
||||||
load_session,
|
load_session,
|
||||||
mark_session_invalid,
|
mark_session_invalid,
|
||||||
|
|
@ -211,14 +213,40 @@ def test_classify_200_authenticated_returns_state(monkeypatch: pytest.MonkeyPatc
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
def test_classify_200_state_missing_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_classify_200_state_missing_returns_markup_changed_sentinel(
|
||||||
"""200 but extract_state returns None → None."""
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""audit-scrapers finding 4: HTTP 200 но extract_state не нашёл auth-state
|
||||||
|
(Cian сменил вёрстку/MFE-схему header-frontend) → VERIFY_MARKUP_CHANGED_SENTINEL,
|
||||||
|
НЕ None. Раньше это конфлировалось с "cookies expired" (реальный логаут)."""
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"app.services.cian_session.extract_state",
|
"app.services.cian_session.extract_state",
|
||||||
lambda html, mfe, key: None,
|
lambda html, mfe, key: None,
|
||||||
)
|
)
|
||||||
result = _classify_verify_response(200, "<html></html>")
|
result = _classify_verify_response(200, "<html></html>")
|
||||||
assert result is None
|
assert result is VERIFY_MARKUP_CHANGED_SENTINEL
|
||||||
|
assert result is not None # НЕ должно триггерить cookie-refresh alert
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_5xx_returns_source_unavailable_sentinel() -> None:
|
||||||
|
"""audit-scrapers finding 4: HTTP 500 (источник недоступен) →
|
||||||
|
VERIFY_SOURCE_UNAVAILABLE_SENTINEL, НЕ None (cookies тут ни при чём)."""
|
||||||
|
result = _classify_verify_response(500, None)
|
||||||
|
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_502_returns_source_unavailable_sentinel() -> None:
|
||||||
|
"""Любой non-200/403/401 статус (напр. 502 bad gateway) — источник недоступен."""
|
||||||
|
result = _classify_verify_response(502, None)
|
||||||
|
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_status_200_html_none_returns_source_unavailable_sentinel() -> None:
|
||||||
|
"""Defensive: status=200 но html=None (не должно случаться в проде, но
|
||||||
|
classifier не должен молча вернуть None='expired') → source-unavailable."""
|
||||||
|
result = _classify_verify_response(200, None)
|
||||||
|
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||||
|
|
||||||
|
|
||||||
def test_classify_403_is_distinct_from_401() -> None:
|
def test_classify_403_is_distinct_from_401() -> None:
|
||||||
|
|
@ -230,6 +258,28 @@ def test_classify_403_is_distinct_from_401() -> None:
|
||||||
assert expired is None
|
assert expired is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_all_four_outcomes_are_mutually_distinct() -> None:
|
||||||
|
"""audit-scrapers finding 4: expired (401) / ban (403) / source-unavailable (5xx)
|
||||||
|
/ markup-changed (200+extract_state=None) — четыре РАЗНЫХ сигнала, ни один не
|
||||||
|
коллапсирует в другой. Только expired (None) должен триггерить re-login alert."""
|
||||||
|
expired = _classify_verify_response(401, None)
|
||||||
|
ban = _classify_verify_response(403, None)
|
||||||
|
source_down = _classify_verify_response(500, None)
|
||||||
|
|
||||||
|
with pytest.MonkeyPatch.context() as mp:
|
||||||
|
mp.setattr("app.services.cian_session.extract_state", lambda html, mfe, key: None)
|
||||||
|
markup_changed = _classify_verify_response(200, "<html></html>")
|
||||||
|
|
||||||
|
outcomes = [expired, ban, source_down, markup_changed]
|
||||||
|
# None встречается ровно один раз (только expired) — остальные три truthy sentinel'а
|
||||||
|
# и все различны между собой (identity, не equality — это разные dict-объекты).
|
||||||
|
assert outcomes.count(None) == 1
|
||||||
|
assert expired is None
|
||||||
|
non_none = [o for o in outcomes if o is not None]
|
||||||
|
assert len(non_none) == 3
|
||||||
|
assert len({id(o) for o in non_none}) == 3
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# verify_session (async) — integration with curl_cffi mock
|
# verify_session (async) — integration with curl_cffi mock
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -317,10 +367,12 @@ async def test_verify_session_not_authenticated_returns_none(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_verify_session_state_missing_returns_none(
|
async def test_verify_session_state_missing_returns_markup_changed_sentinel(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""200 + extract_state returns None → None."""
|
"""audit-scrapers finding 4: 200 + extract_state returns None (markup changed)
|
||||||
|
→ VERIFY_MARKUP_CHANGED_SENTINEL, НЕ None. Раньше ложно триггерило "cookies
|
||||||
|
expired, please re-upload" для реальной причины "Cian сменил вёрстку"."""
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"app.services.cian_session.extract_state",
|
"app.services.cian_session.extract_state",
|
||||||
lambda html, mfe, key: None,
|
lambda html, mfe, key: None,
|
||||||
|
|
@ -334,7 +386,42 @@ async def test_verify_session_state_missing_returns_none(
|
||||||
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
|
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
|
||||||
result = await verify_session({"DMIR_AUTH": "x"})
|
result = await verify_session({"DMIR_AUTH": "x"})
|
||||||
|
|
||||||
assert result is None
|
assert result is VERIFY_MARKUP_CHANGED_SENTINEL
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verify_session_5xx_returns_source_unavailable_sentinel() -> None:
|
||||||
|
"""audit-scrapers finding 4: HTTP 500 → VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
|
||||||
|
НЕ None. Источник временно недоступен — cookies тут ни при чём, вызывающий
|
||||||
|
не должен помечать сессию invalid / просить re-upload."""
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_session.get = AsyncMock(return_value=_make_cffi_resp(500))
|
||||||
|
|
||||||
|
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
|
||||||
|
result = await verify_session({"DMIR_AUTH": "x"})
|
||||||
|
|
||||||
|
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verify_session_network_error_returns_source_unavailable_sentinel() -> None:
|
||||||
|
"""audit-scrapers finding 4: сетевой/транспортный сбой (timeout, connection
|
||||||
|
reset и т.п.) → VERIFY_SOURCE_UNAVAILABLE_SENTINEL, НЕ None. Раньше generic
|
||||||
|
except возвращал None — конфлировал сетевой сбой с протухшими cookies."""
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_session.get = AsyncMock(side_effect=ConnectionError("connection reset by peer"))
|
||||||
|
|
||||||
|
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
|
||||||
|
result = await verify_session({"DMIR_AUTH": "x"})
|
||||||
|
|
||||||
|
assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.core import config
|
from app.core import config
|
||||||
from app.core.ratelimit import RateLimitMiddleware
|
from app.core.ratelimit import RateLimitMiddleware, SlidingWindowLimiter
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -112,3 +112,58 @@ def test_anonymous_key_from_rightmost_xff(client):
|
||||||
client.get("/api/v1/ping", headers={"X-Forwarded-For": "8.8.8.8, 2.2.2.2"}).status_code
|
client.get("/api/v1/ping", headers={"X-Forwarded-For": "8.8.8.8, 2.2.2.2"}).status_code
|
||||||
== 200
|
== 200
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── SlidingWindowLimiter (#tgsupport-web) — reusable narrower per-feature limit ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_sliding_window_limiter_retry_after_does_not_record():
|
||||||
|
"""`.retry_after()` — non-destructive peek: не расходует бюджет сам по себе
|
||||||
|
(review L3 — вызывающая сторона решает, когда `.record()`)."""
|
||||||
|
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||||
|
assert limiter.retry_after("alice") is None
|
||||||
|
# Повторный peek БЕЗ record() — бюджет не тронут, всё ещё None.
|
||||||
|
assert limiter.retry_after("alice") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sliding_window_limiter_record_then_retry_after_blocks():
|
||||||
|
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||||
|
assert limiter.retry_after("alice") is None
|
||||||
|
limiter.record("alice")
|
||||||
|
retry_after = limiter.retry_after("alice")
|
||||||
|
assert retry_after is not None
|
||||||
|
assert retry_after > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sliding_window_limiter_check_combines_peek_and_record():
|
||||||
|
"""`.check()` — комбинированный (обратной совместимости ради) peek+record."""
|
||||||
|
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||||
|
assert limiter.check("alice") is None # первый — проходит и сразу учитывается
|
||||||
|
assert limiter.check("alice") is not None # второй — уже за лимитом
|
||||||
|
|
||||||
|
|
||||||
|
def test_sliding_window_limiter_per_key_isolation():
|
||||||
|
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||||
|
limiter.record("alice")
|
||||||
|
assert limiter.retry_after("alice") is not None
|
||||||
|
assert limiter.retry_after("bob") is None # свой ключ — не задет alice
|
||||||
|
|
||||||
|
|
||||||
|
def test_sliding_window_limiter_prunes_empty_buckets_past_threshold():
|
||||||
|
"""review L2: пустые корзины чистятся при накоплении >10000 ключей (тот же
|
||||||
|
паттерн, что `RateLimitMiddleware.dispatch`) — не бесконечная утечка памяти.
|
||||||
|
|
||||||
|
`retry_after()`-peek на новом ключе создаёт ПУСТУЮ корзину как побочный эффект
|
||||||
|
`defaultdict` (даже если вызывающая сторона так и не позвала `.record()` —
|
||||||
|
напр. запрос отклонён по другой причине выше по стеку). Это главный источник
|
||||||
|
"мусорных" пустых корзин, которые cleanup обязан подбирать.
|
||||||
|
"""
|
||||||
|
limiter = SlidingWindowLimiter(limit=1000, window_s=60.0)
|
||||||
|
for i in range(10001):
|
||||||
|
limiter.retry_after(f"user-{i}")
|
||||||
|
assert len(limiter._hits) == 10001
|
||||||
|
|
||||||
|
# Следующий record() создаёт СВОЮ (непустую) корзину и заодно подчищает
|
||||||
|
# все чужие пустые — тот же порог (>10000), что и RateLimitMiddleware.
|
||||||
|
limiter.record("trigger-cleanup")
|
||||||
|
assert len(limiter._hits) < 10001
|
||||||
|
|
|
||||||
536
tradein-mvp/backend/tests/test_support.py
Normal file
536
tradein-mvp/backend/tests/test_support.py
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
"""Offline-тесты веб-чата поддержки (#tgsupport-web) —
|
||||||
|
POST/GET /api/v1/trade-in/support/{messages,unread,read}.
|
||||||
|
|
||||||
|
Storage-слой (`app.services.tgbot.web_support_storage`) мокается целиком (как
|
||||||
|
mock'ается DB в test_trade_in_lead.py) — эти тесты проверяют РОУТЕР: изоляцию
|
||||||
|
тредов (нет параметра, которым можно адресовать чужой тред), валидацию
|
||||||
|
входа, поведение при несконфигурированном боте, rate-limit и то, что реплай
|
||||||
|
парсится в `bridge.py` в web-ветку (см. tests/services/tgbot/test_bridge.py —
|
||||||
|
маршрутизация реплая тестируется ТАМ; здесь — только HTTP-контракт отправки/
|
||||||
|
чтения).
|
||||||
|
|
||||||
|
NEVER touches real DB / real Telegram API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.v1 import support as support_module
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.ratelimit import SlidingWindowLimiter
|
||||||
|
from app.services.tgbot.client import TelegramApiError
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _bot_configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""По умолчанию бот считается сконфигурированным — отдельные тесты переопределяют."""
|
||||||
|
monkeypatch.setattr(support_module.settings, "telegram_bot_token", "fake-token")
|
||||||
|
monkeypatch.setattr(support_module.settings, "telegram_support_chat_id", -100123456789)
|
||||||
|
monkeypatch.setattr(support_module.settings, "telegram_support_topic_id", 42)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _fresh_rate_limiter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Модульный `_send_limiter` иначе накапливает состояние МЕЖДУ тестами (один
|
||||||
|
процесс pytest) — свежий лимитер на каждый тест, щедрый дефолт (rate-limit
|
||||||
|
тестируется отдельно на СВОЁМ, явно узком экземпляре)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module, "_send_limiter", SlidingWindowLimiter(limit=1000, window_s=60.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeTelegramClient:
|
||||||
|
"""Подменяет `TelegramClient` внутри `support` модуля — никакого httpx/сети."""
|
||||||
|
|
||||||
|
calls: ClassVar[list[dict[str, Any]]] = []
|
||||||
|
_response: ClassVar[dict[str, Any] | Exception] = {"message_id": 555}
|
||||||
|
|
||||||
|
def __init__(self, _token: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send_message(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
_FakeTelegramClient.calls.append(kwargs)
|
||||||
|
if isinstance(_FakeTelegramClient._response, Exception):
|
||||||
|
raise _FakeTelegramClient._response
|
||||||
|
return _FakeTelegramClient._response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any:
|
||||||
|
_FakeTelegramClient.calls = []
|
||||||
|
_FakeTelegramClient._response = {"message_id": 555}
|
||||||
|
monkeypatch.setattr(support_module, "TelegramClient", _FakeTelegramClient)
|
||||||
|
return _FakeTelegramClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db() -> MagicMock:
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(db: MagicMock) -> TestClient:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(support_module.router, prefix="/api/v1/trade-in")
|
||||||
|
|
||||||
|
def fake_db() -> Any:
|
||||||
|
yield db
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = fake_db
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(username: str = "alice") -> dict[str, str]:
|
||||||
|
return {"x-authenticated-user": username}
|
||||||
|
|
||||||
|
|
||||||
|
# ── auth guard ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_message_without_auth_header_401(client: TestClient) -> None:
|
||||||
|
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_messages_without_auth_header_401(client: TestClient) -> None:
|
||||||
|
r = client.get("/api/v1/trade-in/support/messages")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_whitespace_only_auth_header_401(client: TestClient) -> None:
|
||||||
|
"""review L4: заголовок из одних пробелов после `.strip()` пуст — не должен
|
||||||
|
считаться валидным auth (не проваливается тихо в "" как username)."""
|
||||||
|
r = client.get("/api/v1/trade-in/support/messages", headers={"x-authenticated-user": " "})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_header_with_surrounding_whitespace_is_stripped(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""review L4: лишний пробел от прокси не должен заводить ВТОРОЙ тред для, по
|
||||||
|
сути, того же пользователя — username резолвится в тред по .strip()-нутому
|
||||||
|
значению."""
|
||||||
|
seen_usernames = []
|
||||||
|
|
||||||
|
def fake_find_thread_id(db, username):
|
||||||
|
seen_usernames.append(username)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(support_module.storage, "find_thread_id", fake_find_thread_id)
|
||||||
|
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/trade-in/support/messages", headers={"x-authenticated-user": " alice "}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert seen_usernames == ["alice"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── validation ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_message_blank_text_422(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module.storage, "get_or_create_thread", lambda *a, **kw: called.append(1) or 1
|
||||||
|
)
|
||||||
|
r = client.post("/api/v1/trade-in/support/messages", json={"text": " "}, headers=_auth())
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
assert called == [] # ничего не персистится на невалидном вводе
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_message_empty_text_422(client: TestClient) -> None:
|
||||||
|
r = client.post("/api/v1/trade-in/support/messages", json={"text": ""}, headers=_auth())
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_message_too_long_422(client: TestClient) -> None:
|
||||||
|
too_long = "a" * (support_module.MAX_MESSAGE_LENGTH + 1)
|
||||||
|
r = client.post("/api/v1/trade-in/support/messages", json={"text": too_long}, headers=_auth())
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_message_at_max_length_ok(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda *a, **kw: 1)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module.storage,
|
||||||
|
"record_inbound",
|
||||||
|
lambda *a, **kw: {
|
||||||
|
"id": 1,
|
||||||
|
"direction": "in",
|
||||||
|
"text_body": kw["text_body"],
|
||||||
|
"operator_tg_id": None,
|
||||||
|
"created_at": "2026-07-26T00:00:00+00:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
at_limit = "a" * support_module.MAX_MESSAGE_LENGTH
|
||||||
|
r = client.post("/api/v1/trade-in/support/messages", json={"text": at_limit}, headers=_auth())
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ── bot not configured ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_message_bot_token_empty_returns_503_not_500(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(support_module.settings, "telegram_bot_token", "")
|
||||||
|
thread_created = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module.storage, "get_or_create_thread", lambda *a, **kw: thread_created.append(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
|
||||||
|
|
||||||
|
assert r.status_code == 503
|
||||||
|
assert r.status_code != 500
|
||||||
|
assert thread_created == [] # ничего не создаём/не пишем, если зеркалировать некуда
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_message_support_chat_id_unset_returns_503(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(support_module.settings, "telegram_support_chat_id", 0)
|
||||||
|
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
|
||||||
|
assert r.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
# ── happy path + mirror content ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_message_happy_path_mirrors_with_website_marker(
|
||||||
|
client: TestClient, db: MagicMock, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
||||||
|
) -> None:
|
||||||
|
thread_calls: list[str] = []
|
||||||
|
|
||||||
|
def fake_get_or_create_thread(db, username):
|
||||||
|
thread_calls.append(username)
|
||||||
|
return 7
|
||||||
|
|
||||||
|
monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake_get_or_create_thread)
|
||||||
|
recorded = {}
|
||||||
|
|
||||||
|
def fake_record_inbound(db, *, thread_id, text_body, topic_message_id, support_chat_id):
|
||||||
|
recorded.update(
|
||||||
|
thread_id=thread_id,
|
||||||
|
text_body=text_body,
|
||||||
|
topic_message_id=topic_message_id,
|
||||||
|
support_chat_id=support_chat_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": 100,
|
||||||
|
"direction": "in",
|
||||||
|
"text_body": text_body,
|
||||||
|
"operator_tg_id": None,
|
||||||
|
"created_at": "2026-07-26T00:00:00+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(support_module.storage, "record_inbound", fake_record_inbound)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/trade-in/support/messages",
|
||||||
|
json={"text": "У меня вопрос про trade-in"},
|
||||||
|
headers=_auth("kopylov"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["id"] == 100
|
||||||
|
assert body["direction"] == "in"
|
||||||
|
|
||||||
|
# Зеркало помечено "С САЙТА" + username — оператор не путает с TG-клиентом.
|
||||||
|
assert len(_fake_telegram_client.calls) == 1
|
||||||
|
mirror_call = _fake_telegram_client.calls[0]
|
||||||
|
assert "С САЙТА" in mirror_call["text"]
|
||||||
|
assert "kopylov" in mirror_call["text"]
|
||||||
|
assert "У меня вопрос про trade-in" in mirror_call["text"]
|
||||||
|
assert mirror_call["chat_id"] == support_module.settings.telegram_support_chat_id
|
||||||
|
assert mirror_call["message_thread_id"] == 42
|
||||||
|
# review H1: интерактивный узкий бюджет ретраев/timeout, не воркерный дефолт.
|
||||||
|
assert mirror_call["max_retries"] == support_module._INTERACTIVE_SEND_MAX_RETRIES
|
||||||
|
assert mirror_call["timeout"] == support_module._INTERACTIVE_SEND_TIMEOUT_S
|
||||||
|
|
||||||
|
assert recorded["thread_id"] == 7
|
||||||
|
assert recorded["topic_message_id"] == 555 # из FakeTelegramClient.send_message result
|
||||||
|
assert recorded["support_chat_id"] == support_module.settings.telegram_support_chat_id
|
||||||
|
assert thread_calls == ["kopylov"]
|
||||||
|
assert db.commit.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_message_telegram_failure_returns_502_and_does_not_persist(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
||||||
|
) -> None:
|
||||||
|
_fake_telegram_client._response = TelegramApiError("sendMessage", 400, "chat not found")
|
||||||
|
thread_created = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module.storage,
|
||||||
|
"get_or_create_thread",
|
||||||
|
lambda db, username: thread_created.append(1),
|
||||||
|
)
|
||||||
|
record_called = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module.storage,
|
||||||
|
"record_inbound",
|
||||||
|
lambda *a, **kw: record_called.append(1),
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
|
||||||
|
|
||||||
|
assert r.status_code == 502
|
||||||
|
assert record_called == [] # неотправленное сообщение не персистится
|
||||||
|
# review H1: thread создаётся ПОСЛЕ успешной отправки — на неудаче до БД не доходит вообще.
|
||||||
|
assert thread_created == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── rate limit ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_message_rate_limited_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 1)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module.storage,
|
||||||
|
"record_inbound",
|
||||||
|
lambda *a, **kw: {
|
||||||
|
"id": 1,
|
||||||
|
"direction": "in",
|
||||||
|
"text_body": kw["text_body"],
|
||||||
|
"operator_tg_id": None,
|
||||||
|
"created_at": "2026-07-26T00:00:00+00:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
first = client.post("/api/v1/trade-in/support/messages", json={"text": "one"}, headers=_auth())
|
||||||
|
assert first.status_code == 200, first.text
|
||||||
|
|
||||||
|
second = client.post("/api/v1/trade-in/support/messages", json={"text": "two"}, headers=_auth())
|
||||||
|
assert second.status_code == 429
|
||||||
|
assert "Retry-After" in second.headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_message_failed_attempts_do_not_consume_rate_limit(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
||||||
|
) -> None:
|
||||||
|
"""review L3: неудачная отправка НЕ должна расходовать rate-limit бюджет —
|
||||||
|
иначе клиент, которому не повезло с транзиентной Telegram-ошибкой, терял бы
|
||||||
|
попытки, не доставив НИ ОДНОГО сообщения."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||||
|
)
|
||||||
|
_fake_telegram_client._response = TelegramApiError("sendMessage", 500, "boom")
|
||||||
|
|
||||||
|
for _ in range(3):
|
||||||
|
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
|
||||||
|
assert r.status_code == 502
|
||||||
|
|
||||||
|
# "Телеграм" снова работает — бюджет (лимит=1) всё ещё цел, ни одна неудачная
|
||||||
|
# попытка выше его не тронула.
|
||||||
|
_fake_telegram_client._response = {"message_id": 555}
|
||||||
|
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 1)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module.storage,
|
||||||
|
"record_inbound",
|
||||||
|
lambda *a, **kw: {
|
||||||
|
"id": 1,
|
||||||
|
"direction": "in",
|
||||||
|
"text_body": kw["text_body"],
|
||||||
|
"operator_tg_id": None,
|
||||||
|
"created_at": "2026-07-26T00:00:00+00:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ok = client.post("/api/v1/trade-in/support/messages", json={"text": "ok"}, headers=_auth())
|
||||||
|
assert ok.status_code == 200, ok.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_message_rate_limit_is_per_user(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 1)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module.storage,
|
||||||
|
"record_inbound",
|
||||||
|
lambda *a, **kw: {
|
||||||
|
"id": 1,
|
||||||
|
"direction": "in",
|
||||||
|
"text_body": kw["text_body"],
|
||||||
|
"operator_tg_id": None,
|
||||||
|
"created_at": "2026-07-26T00:00:00+00:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
alice = client.post(
|
||||||
|
"/api/v1/trade-in/support/messages", json={"text": "one"}, headers=_auth("alice")
|
||||||
|
)
|
||||||
|
assert alice.status_code == 200
|
||||||
|
|
||||||
|
bob = client.post(
|
||||||
|
"/api/v1/trade-in/support/messages", json={"text": "one"}, headers=_auth("bob")
|
||||||
|
)
|
||||||
|
assert bob.status_code == 200 # свой ключ лимита — не затронут alice-лимитом
|
||||||
|
|
||||||
|
|
||||||
|
# ── thread isolation (own thread only, no thread_id param exists) ───────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_messages_resolves_by_own_username_ignores_foreign_params(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Никакого способа адресовать чужой тред: даже если запрос несёт посторонний
|
||||||
|
`thread_id`/`username` в query — эндпоинт их не читает, резолвит ИСКЛЮЧИТЕЛЬНО
|
||||||
|
по X-Authenticated-User."""
|
||||||
|
seen_usernames = []
|
||||||
|
|
||||||
|
def fake_find_thread_id(db, username):
|
||||||
|
seen_usernames.append(username)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(support_module.storage, "find_thread_id", fake_find_thread_id)
|
||||||
|
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/trade-in/support/messages",
|
||||||
|
params={"thread_id": 999, "username": "someone-else", "since": 0},
|
||||||
|
headers=_auth("alice"),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == []
|
||||||
|
assert seen_usernames == ["alice"] # username из заголовка — параметры query проигнорированы
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_messages_returns_thread_scoped_rows(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module.storage,
|
||||||
|
"list_messages",
|
||||||
|
lambda db, *, thread_id, since_id, limit: [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"direction": "in",
|
||||||
|
"text_body": "hi",
|
||||||
|
"operator_tg_id": None,
|
||||||
|
"created_at": "2026-07-26T00:00:00+00:00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if thread_id == 7
|
||||||
|
else [],
|
||||||
|
)
|
||||||
|
r = client.get("/api/v1/trade-in/support/messages", params={"since": 0}, headers=_auth("alice"))
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert len(body) == 1
|
||||||
|
assert body[0]["text_body"] == "hi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_messages_passes_bounded_limit_to_storage(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""review M5: без LIMIT каждое монтирование виджета отдавало бы весь тред."""
|
||||||
|
seen_limits = []
|
||||||
|
|
||||||
|
def fake_list_messages(db, *, thread_id, since_id, limit):
|
||||||
|
seen_limits.append(limit)
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
|
||||||
|
monkeypatch.setattr(support_module.storage, "list_messages", fake_list_messages)
|
||||||
|
|
||||||
|
r = client.get("/api/v1/trade-in/support/messages", headers=_auth())
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert seen_limits == [support_module._LIST_MESSAGES_LIMIT]
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_users_get_independent_threads(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
thread_by_user = {"alice": 1, "bob": 2}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module.storage, "find_thread_id", lambda db, username: thread_by_user.get(username)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module.storage,
|
||||||
|
"list_messages",
|
||||||
|
lambda db, *, thread_id, since_id, limit: [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"direction": "in",
|
||||||
|
"text_body": f"secret-of-thread-{thread_id}",
|
||||||
|
"operator_tg_id": None,
|
||||||
|
"created_at": "2026-07-26T00:00:00+00:00",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
alice_resp = client.get("/api/v1/trade-in/support/messages", headers=_auth("alice")).json()
|
||||||
|
bob_resp = client.get("/api/v1/trade-in/support/messages", headers=_auth("bob")).json()
|
||||||
|
|
||||||
|
assert alice_resp[0]["text_body"] == "secret-of-thread-1"
|
||||||
|
assert bob_resp[0]["text_body"] == "secret-of-thread-2"
|
||||||
|
assert alice_resp != bob_resp
|
||||||
|
|
||||||
|
|
||||||
|
# ── unread / read ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_unread_no_thread_returns_zero_without_querying_count(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: None)
|
||||||
|
count_called = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module.storage, "count_unread", lambda *a, **kw: count_called.append(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.get("/api/v1/trade-in/support/unread", headers=_auth())
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"unread": 0}
|
||||||
|
assert count_called == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unread_delegates_to_storage(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
|
||||||
|
monkeypatch.setattr(support_module.storage, "count_unread", lambda db, *, thread_id: 3)
|
||||||
|
|
||||||
|
r = client.get("/api/v1/trade-in/support/unread", headers=_auth())
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"unread": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_read_noop_when_no_thread(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: None)
|
||||||
|
mark_called = []
|
||||||
|
monkeypatch.setattr(support_module.storage, "mark_read", lambda *a, **kw: mark_called.append(1))
|
||||||
|
|
||||||
|
r = client.post("/api/v1/trade-in/support/read", headers=_auth())
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"status": "ok"}
|
||||||
|
assert mark_called == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_read_calls_storage_when_thread_exists(
|
||||||
|
client: TestClient, db: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
|
||||||
|
mark_called = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module.storage, "mark_read", lambda db, *, thread_id: mark_called.append(thread_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.post("/api/v1/trade-in/support/read", headers=_auth())
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert mark_called == [7]
|
||||||
|
assert db.commit.called
|
||||||
|
|
@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||||
import { IBM_Plex_Mono, Manrope } from "next/font/google";
|
import { IBM_Plex_Mono, Manrope } from "next/font/google";
|
||||||
|
|
||||||
import { SupportButton } from "@/components/trade-in/v2/SupportButton";
|
import { SupportButton } from "@/components/trade-in/v2/SupportButton";
|
||||||
|
import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext";
|
||||||
import { pageBg } from "@/components/trade-in/v2/tokens";
|
import { pageBg } from "@/components/trade-in/v2/tokens";
|
||||||
|
|
||||||
// Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled
|
// Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled
|
||||||
|
|
@ -41,11 +42,17 @@ export default function TradeInV2Layout({
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* SupportChatProvider wraps both {children} (TopNav's "Помощь" item
|
||||||
|
lives deep inside it, in page.tsx) and <SupportButton /> (mounted
|
||||||
|
here as a sibling, portaled to document.body) — they share one
|
||||||
|
open/closed chat state via context, see SupportChatContext.tsx. */}
|
||||||
|
<SupportChatProvider>
|
||||||
{children}
|
{children}
|
||||||
{/* Mounted here (not the global app/layout.tsx) — that layout also
|
{/* Mounted here (not the global app/layout.tsx) — that layout also
|
||||||
covers the admin `/scrapers/**` area and `/sale-share`, unrelated
|
covers the admin `/scrapers/**` area and `/sale-share`, unrelated
|
||||||
products without the МЕРА brand that don't need a support link. */}
|
products without the МЕРА brand that don't need a support link. */}
|
||||||
<SupportButton />
|
<SupportButton />
|
||||||
|
</SupportChatProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,39 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// SupportButton — floating link to the МЕРА Telegram support bot.
|
// SupportButton — floating trigger for the on-site МЕРА support chat.
|
||||||
//
|
//
|
||||||
// WHY: before this, there was no way to reach support from the site.
|
// WHY (original v1, before this window): before the plain-link version of
|
||||||
// `LeadForm` (LeadForm.tsx) only mounts once `hasEstimate` is true
|
// this button existed, there was no way to reach support from the site at
|
||||||
|
// all. `LeadForm` (LeadForm.tsx) only mounts once `hasEstimate` is true
|
||||||
// (app/v2/page.tsx:1064), so a visitor who hasn't finished (or can't finish)
|
// (app/v2/page.tsx:1064), so a visitor who hasn't finished (or can't finish)
|
||||||
// an estimate has no contact path at all. The only other link anywhere in the
|
// an estimate had no contact path. This was upgraded from a plain external
|
||||||
// UI is NoAccessScreen.tsx:84, and that only renders on the "access expired"
|
// Telegram link to an in-page chat window (SupportChatPanel.tsx) so a visitor
|
||||||
// screen. The support bot itself is already live on the backend (support
|
// answers/receives replies without leaving the site — the message still
|
||||||
// bridge, PR #2526) — this button is purely the missing entry point on the
|
// mirrors into the same Telegram support topic and the operator still
|
||||||
// site side. Plain external link, no chat widget / no state / no API calls.
|
// answers from Telegram exactly as before (backend `feat/tradein-web-chat-
|
||||||
|
// backend`, itself layered on the Telegram support bridge, backend PR #2526).
|
||||||
|
// The Telegram link survives as a small fallback INSIDE the chat panel
|
||||||
|
// (SupportChatPanel.tsx) — deliberate, since there's no push notification for
|
||||||
|
// the web chat: a visitor who leaves the page won't see a reply that arrives
|
||||||
|
// an hour later, so the Telegram escape hatch stays available.
|
||||||
//
|
//
|
||||||
// Portal to document.body (pattern mirrors MapPicker.tsx:104-108 and
|
// Portal to document.body (pattern mirrors MapPicker.tsx:104-108 and
|
||||||
// BuildingListingsDrawer.tsx): `/v2` renders its HUD inside a fixed
|
// BuildingListingsDrawer.tsx): `/v2` renders its HUD inside a fixed
|
||||||
// 1536×1024 "artboard" that gets `transform: scale(...)` on narrow viewports
|
// 1536×1024 "artboard" that gets `transform: scale(...)` on narrow viewports
|
||||||
// (app/v2/page.tsx:860-871). A `position: fixed` descendant of a
|
// (app/v2/page.tsx:860-871). A `position: fixed` descendant of a
|
||||||
// `transform`-ed ancestor is positioned relative to that ancestor, not the
|
// `transform`-ed ancestor is positioned relative to that ancestor, not the
|
||||||
// viewport — so without a portal the button would drift/scale with the HUD
|
// viewport — so without a portal the button (and the chat panel it opens)
|
||||||
// instead of staying pinned to the real viewport corner.
|
// would drift/scale with the HUD instead of staying pinned to the real
|
||||||
|
// viewport corner.
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
|
import { useSupportChat } from "./SupportChatContext";
|
||||||
|
import { SupportChatPanel } from "./SupportChatPanel";
|
||||||
|
import { useSupportUnread } from "@/lib/useSupportChat";
|
||||||
|
|
||||||
// Same bot as the backend support bridge (PR #2526). A plain module constant
|
const { accent, accentDeep, onAccent, surface, font, danger } = tokens;
|
||||||
// rather than `NEXT_PUBLIC_*`: the URL is public and stable, and env vars are
|
|
||||||
// inlined at build time — swapping the value would need a rebuild for no
|
|
||||||
// benefit over just editing this line.
|
|
||||||
//
|
|
||||||
// Exported: this is the single source of truth for the bot URL. The «Помощь»
|
|
||||||
// item in TopNav.tsx's user menu links to the same bot and imports this
|
|
||||||
// constant rather than duplicating the string.
|
|
||||||
export const SUPPORT_BOT_URL = "https://t.me/MERAsupport_bot";
|
|
||||||
|
|
||||||
const { accent, accentDeep, onAccent, surface, font } = tokens;
|
|
||||||
|
|
||||||
const styles = `
|
const styles = `
|
||||||
.support-btn{background:linear-gradient(90deg,${accentDeep},${accent});transition:box-shadow .18s,transform .18s,background .18s;}
|
.support-btn{background:linear-gradient(90deg,${accentDeep},${accent});transition:box-shadow .18s,transform .18s,background .18s;}
|
||||||
|
|
@ -51,29 +51,35 @@ export function SupportButton() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
const { open, toggleChat, closeChat } = useSupportChat();
|
||||||
|
// Unread badge only matters while the panel is closed — see
|
||||||
|
// useSupportUnread's docstring for why polling stops entirely once open.
|
||||||
|
const unreadQuery = useSupportUnread(!open);
|
||||||
|
const unread = unreadQuery.data?.unread ?? 0;
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<>
|
<>
|
||||||
<style>{styles}</style>
|
<style>{styles}</style>
|
||||||
<a
|
<button
|
||||||
href={SUPPORT_BOT_URL}
|
type="button"
|
||||||
target="_blank"
|
onClick={toggleChat}
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="support-btn"
|
className="support-btn"
|
||||||
aria-label="Написать в поддержку МЕРА в Telegram"
|
aria-label={open ? "Закрыть чат поддержки МЕРА" : "Открыть чат поддержки МЕРА"}
|
||||||
title="Поддержка МЕРА в Telegram"
|
aria-expanded={open}
|
||||||
|
title="Поддержка МЕРА"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
right: 20,
|
right: 20,
|
||||||
bottom: 20,
|
bottom: 20,
|
||||||
// z-index 25 — deliberately BELOW every v2 HUD overlay: SectionOverlay
|
// z-index 25 — deliberately BELOW every v2 HUD overlay: SectionOverlay
|
||||||
// (29/30), LocationDrawer (40/41), TopNav (50), UserMenu (200), and
|
// (29/30), LocationDrawer (40/41), the chat panel this button opens
|
||||||
// the legacy MapPicker dialog (1000). When a modal/drawer is open the
|
// (45), TopNav (50), UserMenu (200). When a modal/drawer is open the
|
||||||
// button should disappear under it, not float on top and steal
|
// button should disappear under it, not float on top and steal
|
||||||
// clicks/tab order. It never collides with TopNav geographically
|
// clicks/tab order. It never collides with TopNav geographically
|
||||||
// (top of screen vs. bottom-right corner here), so this only matters
|
// (top of screen vs. bottom-right corner here), so this only matters
|
||||||
// for the bottom-sheet-style overlays.
|
// for the bottom-sheet-style overlays and the chat panel itself.
|
||||||
zIndex: 25,
|
zIndex: 25,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|
@ -90,7 +96,6 @@ export function SupportButton() {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
letterSpacing: 0.4,
|
letterSpacing: 0.4,
|
||||||
textDecoration: "none",
|
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
|
|
@ -109,7 +114,32 @@ export function SupportButton() {
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Поддержка</span>
|
<span>Поддержка</span>
|
||||||
</a>
|
{!open && unread > 0 && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -5,
|
||||||
|
right: -5,
|
||||||
|
minWidth: 18,
|
||||||
|
height: 18,
|
||||||
|
padding: "0 4px",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: danger,
|
||||||
|
color: onAccent,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
boxShadow: "0 0 0 2px #fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{unread > 9 ? "9+" : unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<SupportChatPanel open={open} onClose={closeChat} />
|
||||||
</>,
|
</>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
// Shared open/closed state for the on-site support chat window. It needs a
|
||||||
|
// single source of truth reachable from two components that are NOT parent/
|
||||||
|
// child of each other in the render tree: `SupportButton` (mounted once in
|
||||||
|
// app/v2/layout.tsx, portals the floating trigger + the chat panel to
|
||||||
|
// document.body) and `TopNav`'s "Помощь" menu item (mounted deep inside
|
||||||
|
// app/v2/page.tsx, i.e. inside `{children}` of that same layout). A React
|
||||||
|
// Context provided by the layout, wrapping both `{children}` and
|
||||||
|
// `<SupportButton />`, is the plain way to bridge that without prop-drilling
|
||||||
|
// through everything page.tsx passes down to TopNav.
|
||||||
|
import { createContext, useContext, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
// Same bot as the Telegram support bridge (backend PR #2526). Kept here (not
|
||||||
|
// in SupportButton.tsx) so SupportChatPanel can import it without a
|
||||||
|
// SupportButton <-> SupportChatPanel circular import (SupportButton renders
|
||||||
|
// the panel). This is still the single source of truth for the URL — the
|
||||||
|
// panel's small "написать в Telegram" fallback link imports it from here.
|
||||||
|
export const SUPPORT_BOT_URL = "https://t.me/MERAsupport_bot";
|
||||||
|
|
||||||
|
interface SupportChatContextValue {
|
||||||
|
open: boolean;
|
||||||
|
openChat: () => void;
|
||||||
|
closeChat: () => void;
|
||||||
|
toggleChat: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SupportChatContext = createContext<SupportChatContextValue | null>(null);
|
||||||
|
|
||||||
|
export function SupportChatProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const value = useMemo<SupportChatContextValue>(
|
||||||
|
() => ({
|
||||||
|
open,
|
||||||
|
openChat: () => setOpen(true),
|
||||||
|
closeChat: () => setOpen(false),
|
||||||
|
toggleChat: () => setOpen((o) => !o),
|
||||||
|
}),
|
||||||
|
[open],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SupportChatContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</SupportChatContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSupportChat(): SupportChatContextValue {
|
||||||
|
const ctx = useContext(SupportChatContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"useSupportChat must be used within <SupportChatProvider> (app/v2/layout.tsx)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,475 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
// SupportChatPanel — the actual chat window opened by SupportButton's
|
||||||
|
// floating trigger / TopNav's "Помощь" item. Messages are mirrored to the
|
||||||
|
// same Telegram support topic as before this feature (backend
|
||||||
|
// `feat/tradein-web-chat-backend`, app/api/v1/support.py — a mirror of the
|
||||||
|
// pre-existing Telegram support bridge, backend PR #2526): the operator still
|
||||||
|
// answers from Telegram exactly as before, only the client-facing surface
|
||||||
|
// moved on-site.
|
||||||
|
//
|
||||||
|
// Modal-dialog plumbing (focus trap / Escape / restore-focus-on-close)
|
||||||
|
// mirrors LocationDrawer.tsx, with one difference: initial focus goes to the
|
||||||
|
// message textarea, not the dialog shell — visitors open this panel to type,
|
||||||
|
// not to read a landing focus target.
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||||
|
|
||||||
|
import { tokens } from "./tokens";
|
||||||
|
import { SUPPORT_BOT_URL } from "./SupportChatContext";
|
||||||
|
import {
|
||||||
|
MAX_SUPPORT_MESSAGE_LENGTH,
|
||||||
|
describeSupportError,
|
||||||
|
useMarkSupportRead,
|
||||||
|
useSendSupportMessage,
|
||||||
|
useSupportMessages,
|
||||||
|
} from "@/lib/useSupportChat";
|
||||||
|
import type { SupportMessage } from "@/lib/useSupportChat";
|
||||||
|
|
||||||
|
interface SupportChatPanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_FORMAT = new Intl.DateTimeFormat("ru-RU", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
const parsed = new Date(iso);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return "";
|
||||||
|
return TIME_FORMAT.format(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the char counter once the visitor is close enough to the limit that it
|
||||||
|
// becomes actionable — no point cluttering the footer for a 20-char message.
|
||||||
|
const COUNTER_THRESHOLD = MAX_SUPPORT_MESSAGE_LENGTH - 500;
|
||||||
|
|
||||||
|
const PANEL_STYLES = `
|
||||||
|
.support-chat-send:disabled { opacity: .45; cursor: default; }
|
||||||
|
.support-chat-send:not(:disabled):hover { background: ${tokens.accentDeep}; }
|
||||||
|
.support-chat-retry:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
|
||||||
|
.support-chat-textarea:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(46,139,255,.3); }
|
||||||
|
.support-chat-close:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function SupportChatPanel({ open, onClose }: SupportChatPanelProps) {
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const listEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastFocused = useRef<Element | null>(null);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
|
||||||
|
const messagesQuery = useSupportMessages(open);
|
||||||
|
const sendMessage = useSendSupportMessage();
|
||||||
|
const markRead = useMarkSupportRead();
|
||||||
|
|
||||||
|
// Mark the thread read on BOTH the closed->open and open->closed edges (the
|
||||||
|
// effect fires on mount-while-open and its cleanup fires on the reverse
|
||||||
|
// transition/unmount) — see useMarkSupportRead docstring for why both
|
||||||
|
// directions matter. Read via a ref so this doesn't need `markRead.mutate`
|
||||||
|
// in the dep array (a fresh function identity every render would otherwise
|
||||||
|
// re-fire the effect every render).
|
||||||
|
const markReadRef = useRef(markRead.mutate);
|
||||||
|
markReadRef.current = markRead.mutate;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
markReadRef.current();
|
||||||
|
return () => {
|
||||||
|
markReadRef.current();
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Focus trap + Escape + initial focus into the textarea (req: focus goes to
|
||||||
|
// the input on open, not the dialog shell). Mirrors LocationDrawer.tsx.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
lastFocused.current = document.activeElement;
|
||||||
|
inputRef.current?.focus();
|
||||||
|
|
||||||
|
function getFocusable(): HTMLElement[] {
|
||||||
|
if (!dialog) return [];
|
||||||
|
const nodes = dialog.querySelectorAll<HTMLElement>(
|
||||||
|
'button, textarea, [href], input, select, [tabindex]:not([tabindex="-1"])',
|
||||||
|
);
|
||||||
|
return Array.from(nodes).filter((el) => !el.hasAttribute("disabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onCloseRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab" || !dialog) return;
|
||||||
|
|
||||||
|
const focusable = getFocusable();
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
dialog.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
const activeEl = document.activeElement;
|
||||||
|
const inside = dialog.contains(activeEl);
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (activeEl === first || activeEl === dialog || !inside) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else if (activeEl === last || !inside) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
(lastFocused.current as HTMLElement | null)?.focus?.();
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const messages = messagesQuery.data ?? [];
|
||||||
|
|
||||||
|
// Autoscroll to the latest message on open and whenever the transcript
|
||||||
|
// grows (new poll result / own message appended after send).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
listEndRef.current?.scrollIntoView({ block: "end" });
|
||||||
|
}, [open, messages.length]);
|
||||||
|
|
||||||
|
const trimmedLength = draft.trim().length;
|
||||||
|
const overLimit = draft.length > MAX_SUPPORT_MESSAGE_LENGTH;
|
||||||
|
const canSend = trimmedLength > 0 && !overLimit && !sendMessage.isPending;
|
||||||
|
|
||||||
|
function handleSend() {
|
||||||
|
if (!canSend) return;
|
||||||
|
sendMessage.mutate(draft.trim(), {
|
||||||
|
onSuccess: () => setDraft(""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTextareaKeyDown(e: ReactKeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{PANEL_STYLES}</style>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Чат поддержки МЕРА"
|
||||||
|
tabIndex={-1}
|
||||||
|
// Off-screen when closed → keep it out of the a11y tree and inert
|
||||||
|
// (mirrors LocationDrawer.tsx — same axe "aria-hidden-focus" concern).
|
||||||
|
aria-hidden={!open}
|
||||||
|
inert={!open ? true : undefined}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
right: 20,
|
||||||
|
bottom: 76,
|
||||||
|
width: 360,
|
||||||
|
height: 520,
|
||||||
|
maxHeight: "calc(100vh - 96px)",
|
||||||
|
// Below TopNav (50) / UserMenu (200) so it never steals their
|
||||||
|
// clicks, but above the floating trigger button (25) and the
|
||||||
|
// LocationDrawer scrim/panel (40/41) — the chat window should sit
|
||||||
|
// on top of everything except the persistent top chrome.
|
||||||
|
zIndex: 45,
|
||||||
|
transform: open ? "translateY(0)" : "translateY(10px)",
|
||||||
|
opacity: open ? 1 : 0,
|
||||||
|
pointerEvents: open ? "auto" : "none",
|
||||||
|
transition: "opacity .18s ease, transform .18s ease",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: tokens.surface.w98,
|
||||||
|
border: `1px solid ${tokens.line}`,
|
||||||
|
borderRadius: 14,
|
||||||
|
boxShadow: "0 20px 60px rgba(23,38,58,.28)",
|
||||||
|
overflow: "hidden",
|
||||||
|
fontFamily: tokens.font.sans,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: "0 0 auto",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "12px 14px",
|
||||||
|
borderBottom: `1px solid ${tokens.lineSoft}`,
|
||||||
|
background: tokens.surface.w70,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: tokens.ink2,
|
||||||
|
letterSpacing: ".2px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Поддержка МЕРА
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="support-chat-close"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Закрыть чат поддержки"
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
flex: "0 0 auto",
|
||||||
|
padding: 0,
|
||||||
|
border: `1px solid ${tokens.line}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: tokens.muted,
|
||||||
|
background: tokens.surface.w60,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1,
|
||||||
|
transition: "all .15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* message list */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: "1 1 auto",
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "12px 14px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{messagesQuery.isLoading ? (
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 12, color: tokens.muted3, textAlign: "center", marginTop: 24 }}
|
||||||
|
>
|
||||||
|
Загрузка сообщений…
|
||||||
|
</div>
|
||||||
|
) : messagesQuery.isError ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: tokens.body2,
|
||||||
|
background: tokens.infoSoftBg,
|
||||||
|
border: `1px solid ${tokens.infoSoftBorder}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 12px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{describeSupportError(messagesQuery.error)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="support-chat-retry"
|
||||||
|
onClick={() => messagesQuery.refetch()}
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
border: `1px solid ${tokens.line}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "4px 10px",
|
||||||
|
fontSize: 11,
|
||||||
|
color: tokens.body2,
|
||||||
|
background: tokens.surface.w80,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
transition: "all .15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Повторить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: tokens.body2,
|
||||||
|
background: tokens.infoSoftBg,
|
||||||
|
border: `1px solid ${tokens.infoSoftBorder}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "12px 14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Здравствуйте! Опишите вопрос — оператор отвечает в рабочее время,
|
||||||
|
обычно в течение часа.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((m) => <SupportMessageBubble key={m.id} message={m} />)
|
||||||
|
)}
|
||||||
|
<div ref={listEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* send-error banner */}
|
||||||
|
{sendMessage.isError && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: "0 0 auto",
|
||||||
|
margin: "0 14px 8px",
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: tokens.danger,
|
||||||
|
background: "rgba(205,104,104,.08)",
|
||||||
|
border: "1px solid rgba(205,104,104,.3)",
|
||||||
|
borderRadius: 7,
|
||||||
|
padding: "7px 10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{describeSupportError(sendMessage.error)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* composer */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: "0 0 auto",
|
||||||
|
borderTop: `1px solid ${tokens.lineSoft}`,
|
||||||
|
padding: "10px 12px",
|
||||||
|
background: tokens.surface.w80,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
className="support-chat-textarea"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={handleTextareaKeyDown}
|
||||||
|
placeholder="Напишите сообщение…"
|
||||||
|
aria-label="Сообщение в поддержку"
|
||||||
|
rows={2}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
resize: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
border: `1px solid ${overLimit ? tokens.danger : tokens.line}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "8px 10px",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: tokens.ink2,
|
||||||
|
background: tokens.surface.w98,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={SUPPORT_BOT_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
fontSize: 10.5,
|
||||||
|
color: tokens.muted3,
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
написать в Telegram
|
||||||
|
</a>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
{draft.length > COUNTER_THRESHOLD && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
color: overLimit ? tokens.danger : tokens.muted3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{draft.length}/{MAX_SUPPORT_MESSAGE_LENGTH}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="support-chat-send"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!canSend}
|
||||||
|
aria-label="Отправить сообщение"
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 7,
|
||||||
|
padding: "7px 16px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: tokens.onAccent,
|
||||||
|
background: tokens.accent,
|
||||||
|
cursor: canSend ? "pointer" : "default",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
transition: "background .15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SupportMessageBubble({ message }: { message: SupportMessage }) {
|
||||||
|
const isOwn = message.direction === "in";
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", justifyContent: isOwn ? "flex-end" : "flex-start" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: "78%",
|
||||||
|
background: isOwn ? tokens.accent : tokens.surface.w80,
|
||||||
|
color: isOwn ? tokens.onAccent : tokens.ink2,
|
||||||
|
border: isOwn ? "none" : `1px solid ${tokens.lineSoft}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "8px 10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Plain text node — operator replies are untrusted input, never
|
||||||
|
rendered as HTML. `pre-wrap` preserves newlines/whitespace without
|
||||||
|
needing dangerouslySetInnerHTML. */}
|
||||||
|
<div style={{ fontSize: 12.5, lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||||
|
{message.text_body}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 9,
|
||||||
|
opacity: 0.75,
|
||||||
|
textAlign: isOwn ? "right" : "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(message.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ import type { CSSProperties } from "react";
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { navLabels, version } from "./fixtures";
|
import { navLabels, version } from "./fixtures";
|
||||||
import { SUPPORT_BOT_URL } from "./SupportButton";
|
import { useSupportChat } from "./SupportChatContext";
|
||||||
|
|
||||||
// Real logged-in user identity, derived by the page from useMe()
|
// Real logged-in user identity, derived by the page from useMe()
|
||||||
// ({username, role, brand}). Deliberately excludes `reports` — the «Мои
|
// ({username, role, brand}). Deliberately excludes `reports` — the «Мои
|
||||||
|
|
@ -59,8 +59,9 @@ const menuItemStyle: CSSProperties = {
|
||||||
|
|
||||||
// Профиль / Настройки have no pages yet — render them dimmed and
|
// Профиль / Настройки have no pages yet — render them dimmed and
|
||||||
// non-interactive (no hover class, default cursor) so they read as disabled.
|
// non-interactive (no hover class, default cursor) so they read as disabled.
|
||||||
// «Помощь» used to be in this group too, but now links out to the Telegram
|
// «Помощь» used to be in this group too, then linked out to the Telegram
|
||||||
// support bot (see SUPPORT_BOT_URL below) — it renders as a live item.
|
// support bot; it now opens the on-site chat window (SupportChatPanel.tsx via
|
||||||
|
// SupportChatContext) — it renders as a live item.
|
||||||
const menuItemDisabledStyle: CSSProperties = {
|
const menuItemDisabledStyle: CSSProperties = {
|
||||||
...menuItemStyle,
|
...menuItemStyle,
|
||||||
cursor: "default",
|
cursor: "default",
|
||||||
|
|
@ -75,6 +76,7 @@ export default function TopNav({
|
||||||
onLogout,
|
onLogout,
|
||||||
}: TopNavProps) {
|
}: TopNavProps) {
|
||||||
const [userOpen, setUserOpen] = useState(false);
|
const [userOpen, setUserOpen] = useState(false);
|
||||||
|
const { openChat } = useSupportChat();
|
||||||
const u = user ?? GUEST_USER;
|
const u = user ?? GUEST_USER;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -447,19 +449,28 @@ export default function TopNav({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Live item (issue: two support entry points — the other is
|
{/* Live item (issue: two support entry points — the other is
|
||||||
SupportButton.tsx's floating button). Same bot, imported
|
SupportButton.tsx's floating button). Both open the same
|
||||||
constant — single source of truth for the URL. Styled like
|
chat window (SupportChatContext) — no more external
|
||||||
the other live row above ("Мои отчёты"), not the dimmed
|
Telegram navigation from here. Styled like the other live
|
||||||
Профиль/Настройки rows. */}
|
row above ("Мои отчёты"), not the dimmed Профиль/Настройки
|
||||||
<a
|
rows. */}
|
||||||
href={SUPPORT_BOT_URL}
|
<button
|
||||||
target="_blank"
|
type="button"
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="tnav-menuitem"
|
className="tnav-menuitem"
|
||||||
style={{ ...menuItemStyle, textDecoration: "none" }}
|
style={{
|
||||||
aria-label="Написать в поддержку МЕРА в Telegram"
|
...menuItemStyle,
|
||||||
title="Написать в поддержку МЕРА в Telegram"
|
width: "100%",
|
||||||
onClick={() => setUserOpen(false)}
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
aria-label="Открыть чат поддержки МЕРА"
|
||||||
|
title="Открыть чат поддержки МЕРА"
|
||||||
|
onClick={() => {
|
||||||
|
setUserOpen(false);
|
||||||
|
openChat();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||||
<circle
|
<circle
|
||||||
|
|
@ -476,7 +487,7 @@ export default function TopNav({
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Помощь
|
Помощь
|
||||||
</a>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
138
tradein-mvp/frontend/src/lib/useSupportChat.ts
Normal file
138
tradein-mvp/frontend/src/lib/useSupportChat.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data layer for the on-site МЕРА support chat (issue: "окно чата поддержки на
|
||||||
|
* сайте МЕРА"). Talks to `feat/tradein-web-chat-backend` (app/api/v1/support.py,
|
||||||
|
* not yet in main at the time this was written — contract is stable/reviewed):
|
||||||
|
*
|
||||||
|
* POST /api/v1/trade-in/support/messages {text} -> SupportMessage
|
||||||
|
* GET /api/v1/trade-in/support/messages?since=<id> -> SupportMessage[]
|
||||||
|
* GET /api/v1/trade-in/support/unread -> {unread}
|
||||||
|
* POST /api/v1/trade-in/support/read -> {status:"ok"}
|
||||||
|
*
|
||||||
|
* Thread is resolved server-side from X-Authenticated-User (injected by Caddy
|
||||||
|
* basic_auth upstream) — no thread_id is ever sent from the client.
|
||||||
|
*
|
||||||
|
* Polling model (see SupportChatPanel.tsx / SupportButton.tsx for the two call
|
||||||
|
* sites):
|
||||||
|
* - Messages: polled only while the chat panel is OPEN (`enabled` gate below)
|
||||||
|
* — no point polling a transcript nobody is looking at.
|
||||||
|
* - Unread count: polled only while the panel is CLOSED — it feeds the badge
|
||||||
|
* on the floating button; once open, the transcript itself is the "seen"
|
||||||
|
* signal (see markSupportRead below) and the badge doesn't render.
|
||||||
|
* - Tab-inactive pause is NOT reimplemented here — `refetchInterval`'s
|
||||||
|
* `refetchIntervalInBackground` defaults to `false` in TanStack Query, so
|
||||||
|
* polling already stops while `document` is hidden/backgrounded without
|
||||||
|
* any extra visibilitychange plumbing. Only the panel-open/closed gate
|
||||||
|
* above is bespoke.
|
||||||
|
*
|
||||||
|
* Always re-fetches the FULL thread (`since=0`) rather than incrementally
|
||||||
|
* merging `since=<lastId>` pages into the query cache: at MVP support-chat
|
||||||
|
* volume (a handful to a few dozen messages per thread) the bandwidth cost of
|
||||||
|
* refetching everything every 6s is negligible, and it avoids a manual
|
||||||
|
* setQueryData-merge path that would be pure incidental complexity here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiFetch, HTTPError } from "@/lib/api";
|
||||||
|
|
||||||
|
const BASE = "/api/v1/trade-in/support";
|
||||||
|
|
||||||
|
// Mirrors backend `MAX_MESSAGE_LENGTH` (app/api/v1/support.py) — enforced
|
||||||
|
// client-side too so the send button disables before the round-trip 422/400.
|
||||||
|
export const MAX_SUPPORT_MESSAGE_LENGTH = 4000;
|
||||||
|
|
||||||
|
export type SupportMessageDirection = "in" | "out";
|
||||||
|
|
||||||
|
export interface SupportMessage {
|
||||||
|
id: number;
|
||||||
|
direction: SupportMessageDirection;
|
||||||
|
text_body: string;
|
||||||
|
operator_tg_id: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportUnread {
|
||||||
|
unread: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPORT_MESSAGES_KEY = ["trade-in", "support", "messages"] as const;
|
||||||
|
const SUPPORT_UNREAD_KEY = ["trade-in", "support", "unread"] as const;
|
||||||
|
|
||||||
|
const MESSAGES_POLL_MS = 6_000;
|
||||||
|
const UNREAD_POLL_MS = 20_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls the caller's own thread. `enabled` should be the chat-panel `open`
|
||||||
|
* flag — see module docstring.
|
||||||
|
*/
|
||||||
|
export function useSupportMessages(enabled: boolean) {
|
||||||
|
return useQuery<SupportMessage[]>({
|
||||||
|
queryKey: SUPPORT_MESSAGES_KEY,
|
||||||
|
queryFn: () => apiFetch<SupportMessage[]>(`${BASE}/messages?since=0`),
|
||||||
|
enabled,
|
||||||
|
staleTime: 0,
|
||||||
|
refetchInterval: enabled ? MESSAGES_POLL_MS : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feeds the unread badge on the closed floating button. `enabled` should be
|
||||||
|
* `!open` — see module docstring.
|
||||||
|
*/
|
||||||
|
export function useSupportUnread(enabled: boolean) {
|
||||||
|
return useQuery<SupportUnread>({
|
||||||
|
queryKey: SUPPORT_UNREAD_KEY,
|
||||||
|
queryFn: () => apiFetch<SupportUnread>(`${BASE}/unread`),
|
||||||
|
enabled,
|
||||||
|
staleTime: 0,
|
||||||
|
refetchInterval: enabled ? UNREAD_POLL_MS : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSendSupportMessage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<SupportMessage, Error, string>({
|
||||||
|
mutationFn: (text) =>
|
||||||
|
apiFetch<SupportMessage>(`${BASE}/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the thread read (moves backend `last_read_at` forward). Called on the
|
||||||
|
* open->closed AND closed->open transition (see SupportChatPanel) so the
|
||||||
|
* unread badge never counts messages the visitor demonstrably already saw
|
||||||
|
* while the panel was open.
|
||||||
|
*/
|
||||||
|
export function useMarkSupportRead() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<void, Error, void>({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await apiFetch<{ status: string }>(`${BASE}/read`, { method: "POST" });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.setQueryData<SupportUnread>(SUPPORT_UNREAD_KEY, { unread: 0 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable error text for both the messages-query error state and the
|
||||||
|
* send-mutation error banner. Backend already returns RU human text in
|
||||||
|
* `detail` for 429/503/502 (SERVICE_UNAVAILABLE_TEXT / rate-limit message) —
|
||||||
|
* `HTTPError.message` is that `detail` (see lib/api.ts). A non-HTTPError means
|
||||||
|
* `fetch` itself threw (offline / DNS / CORS) — apiFetch never got a response.
|
||||||
|
*/
|
||||||
|
export function describeSupportError(error: unknown): string {
|
||||||
|
if (error instanceof HTTPError) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "Не удалось связаться с сервером. Проверьте подключение и повторите.";
|
||||||
|
}
|
||||||
|
|
@ -994,12 +994,18 @@ def _extract_meta(tree: HTMLParser) -> tuple[date | None, int | None, int | None
|
||||||
day = int(m_date.group(1))
|
day = int(m_date.group(1))
|
||||||
month_word = m_date.group(2).lower()
|
month_word = m_date.group(2).lower()
|
||||||
month = RUS_MONTHS.get(month_word)
|
month = RUS_MONTHS.get(month_word)
|
||||||
# Год — текущий (Avito не показывает год для свежих объявлений)
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
current_year = datetime.date.today().year
|
|
||||||
if month:
|
if month:
|
||||||
publish_date = date(current_year, month, day)
|
# Avito не показывает год для свежих объявлений — берём текущий.
|
||||||
|
# audit-scrapers finding 3: если объявление опубликовано в декабре,
|
||||||
|
# а страница парсится в январе СЛЕДУЮЩЕГО года, "текущий год" даёт
|
||||||
|
# дату в будущем (завышает свежесть лота). Если получившаяся дата
|
||||||
|
# оказалась в будущем относительно момента парсинга — откатываем
|
||||||
|
# на год назад (это дата из прошлого года).
|
||||||
|
today = date.today()
|
||||||
|
candidate = date(today.year, month, day)
|
||||||
|
if candidate > today:
|
||||||
|
candidate = date(today.year - 1, month, day)
|
||||||
|
publish_date = candidate
|
||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -953,6 +953,29 @@ class AvitoScraper(BaseScraper):
|
||||||
return 0
|
return 0
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _is_unexpected_empty_page(self, html: str) -> bool:
|
||||||
|
"""Отличить «в выборке реально 0 объявлений» от DOM-дрейфа/content-block.
|
||||||
|
|
||||||
|
Вызывается ТОЛЬКО когда `_parse_html` уже вернул 0 карточек на page=1
|
||||||
|
(обход всего города/категории/бакета) — само по себе это неотличимо от
|
||||||
|
«объявлений действительно нет» (#audit-scrapers finding 1).
|
||||||
|
|
||||||
|
`_extract_total_count(html)` даёт независимый от DOM-карточек сигнал
|
||||||
|
(счётчик `page-title/count` либо no-results-маркер):
|
||||||
|
- total_hint == 0 → валидный no-results-маркер найден — НЕ аномалия.
|
||||||
|
- total_hint > 0 → счётчик утверждает, что результаты есть, но карточки
|
||||||
|
(`data-marker="item-*"`) не распознаны — DOM-маркер
|
||||||
|
карточки разошёлся со счётчиком (drift).
|
||||||
|
- total_hint is None → ни счётчика, ни no-results-маркера — тоже
|
||||||
|
подозрительно (captcha/firewall без ожидаемой
|
||||||
|
структуры страницы).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True — 0 карточек считается аномалией (нужно поднять
|
||||||
|
``AvitoContentBlockedError``); False — валидная пустая выборка.
|
||||||
|
"""
|
||||||
|
return self._extract_total_count(html) != 0
|
||||||
|
|
||||||
async def _fetch_rooms_page_html(
|
async def _fetch_rooms_page_html(
|
||||||
self,
|
self,
|
||||||
room_slug: str,
|
room_slug: str,
|
||||||
|
|
@ -1282,6 +1305,7 @@ class AvitoScraper(BaseScraper):
|
||||||
secondary_only=secondary_only,
|
secondary_only=secondary_only,
|
||||||
on_bucket=on_bucket,
|
on_bucket=on_bucket,
|
||||||
skip_buckets=skip_buckets,
|
skip_buckets=skip_buckets,
|
||||||
|
expected_total=total,
|
||||||
)
|
)
|
||||||
|
|
||||||
await walk_price_range(
|
await walk_price_range(
|
||||||
|
|
@ -1309,6 +1333,7 @@ class AvitoScraper(BaseScraper):
|
||||||
secondary_only: bool,
|
secondary_only: bool,
|
||||||
on_bucket: Callable[..., Any] | None,
|
on_bucket: Callable[..., Any] | None,
|
||||||
skip_buckets: set[str] | None,
|
skip_buckets: set[str] | None,
|
||||||
|
expected_total: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Параллельная пагинация одного leaf-бакета + фильтр + дедуп + on_bucket.
|
"""Параллельная пагинация одного leaf-бакета + фильтр + дедуп + on_bucket.
|
||||||
|
|
||||||
|
|
@ -1320,6 +1345,14 @@ class AvitoScraper(BaseScraper):
|
||||||
bucket_key = "room_label:lo:hi" (закрытый) либо "room_label:lo:open" (открытый).
|
bucket_key = "room_label:lo:hi" (закрытый) либо "room_label:lo:open" (открытый).
|
||||||
skip_buckets: если bucket_key в skip_buckets — пагинация и on_bucket пропускаются.
|
skip_buckets: если bucket_key в skip_buckets — пагинация и on_bucket пропускаются.
|
||||||
AvitoBlockedError/AvitoRateLimitedError из page-фетчей пробрасываются наверх.
|
AvitoBlockedError/AvitoRateLimitedError из page-фетчей пробрасываются наверх.
|
||||||
|
|
||||||
|
expected_total: total из probe (``_extract_total_count``), известный ДО вызова
|
||||||
|
(см. finding 1 audit-scrapers). Если задан и > 0, а после пагинации всех
|
||||||
|
``max_pages`` страниц собрано 0 карточек — это противоречие (тот же probe-html
|
||||||
|
независимо утверждал total>0), т.е. DOM-маркер карточки разошёлся со счётчиком
|
||||||
|
(drift), а не легитимно пустой бакет (тот даёт expected_total=0 и сюда даже не
|
||||||
|
доходит — вызывающий _leaf не паджинирует пустые бакеты). None — probe провалился
|
||||||
|
(best-effort пагинация, отсутствие данных ожидаемо, проверка пропускается).
|
||||||
"""
|
"""
|
||||||
_lo_param = lo if lo > 0 else None
|
_lo_param = lo if lo > 0 else None
|
||||||
_hi_param = hi # None → _build_rooms_url не ставит pmax
|
_hi_param = hi # None → _build_rooms_url не ставит pmax
|
||||||
|
|
@ -1378,6 +1411,23 @@ class AvitoScraper(BaseScraper):
|
||||||
|
|
||||||
collected_this_bucket = len(bucket_lots)
|
collected_this_bucket = len(bucket_lots)
|
||||||
|
|
||||||
|
# ── Guard: probe утверждал total>0, но парсинг всех страниц дал 0 карточек ──
|
||||||
|
# (finding 1 audit-scrapers). Тот же probe-html независимо подтвердил, что
|
||||||
|
# результаты есть (_extract_total_count) — 0 карточек здесь не может быть
|
||||||
|
# легитимной пустой выдачей, значит DOM-маркер карточки разошёлся со счётчиком.
|
||||||
|
if expected_total is not None and expected_total > 0 and collected_this_bucket == 0:
|
||||||
|
logger.error(
|
||||||
|
"avito: bucket %s probe expected_total=%d but 0 cards parsed across "
|
||||||
|
"%d page(s) — content-block/DOM-drift suspected",
|
||||||
|
bucket_key,
|
||||||
|
expected_total,
|
||||||
|
max_pages,
|
||||||
|
)
|
||||||
|
raise AvitoContentBlockedError(
|
||||||
|
f"Avito bucket {bucket_key}: probe total={expected_total} but 0 cards "
|
||||||
|
"parsed — content-block/DOM-drift suspected"
|
||||||
|
)
|
||||||
|
|
||||||
# ── Фильтр новостроек (secondary_only) ────────────────────────────────
|
# ── Фильтр новостроек (secondary_only) ────────────────────────────────
|
||||||
dropped_nb = 0
|
dropped_nb = 0
|
||||||
if secondary_only:
|
if secondary_only:
|
||||||
|
|
@ -1596,6 +1646,18 @@ class AvitoScraper(BaseScraper):
|
||||||
|
|
||||||
lots = self._parse_html(html, source_url_base=url)
|
lots = self._parse_html(html, source_url_base=url)
|
||||||
if not lots:
|
if not lots:
|
||||||
|
if page == 1 and self._is_unexpected_empty_page(html):
|
||||||
|
logger.error(
|
||||||
|
"avito %s SERP page=1 returned HTTP 200 but 0 cards "
|
||||||
|
"(no no-results marker) — likely content-block/captcha or "
|
||||||
|
"DOM-marker drift url=%s",
|
||||||
|
label,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
raise AvitoContentBlockedError(
|
||||||
|
f"Avito {label} sweep: HTTP 200 with 0 cards on page=1 — "
|
||||||
|
"content-block/DOM-drift suspected"
|
||||||
|
)
|
||||||
logger.info("avito %s page=%d: 0 lots — end of pagination", label, page)
|
logger.info("avito %s page=%d: 0 lots — end of pagination", label, page)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -1706,6 +1768,18 @@ class AvitoScraper(BaseScraper):
|
||||||
|
|
||||||
lots = self._parse_html(html, source_url_base=url)
|
lots = self._parse_html(html, source_url_base=url)
|
||||||
if not lots:
|
if not lots:
|
||||||
|
if page == 1 and self._is_unexpected_empty_page(html):
|
||||||
|
logger.error(
|
||||||
|
"avito byrooms category=%s page=1 returned HTTP 200 but 0 cards "
|
||||||
|
"(no no-results marker) — likely content-block/captcha or "
|
||||||
|
"DOM-marker drift url=%s",
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
raise AvitoContentBlockedError(
|
||||||
|
f"Avito byrooms category={name}: HTTP 200 with 0 cards on "
|
||||||
|
"page=1 — content-block/DOM-drift suspected"
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"avito byrooms category=%s page=%d: 0 lots — end of category",
|
"avito byrooms category=%s page=%d: 0 lots — end of category",
|
||||||
name,
|
name,
|
||||||
|
|
|
||||||
|
|
@ -639,6 +639,25 @@ class CianScraper(BaseScraper):
|
||||||
if inspect.isawaitable(res_cb):
|
if inspect.isawaitable(res_cb):
|
||||||
await res_cb
|
await res_cb
|
||||||
|
|
||||||
|
def _report_schema_regression(self, message: str) -> None:
|
||||||
|
"""Отправить сигнал schema-regression в Glitchtip (если настроен).
|
||||||
|
|
||||||
|
Общий механизм для silent-failure guard'ов `_parse_serp_html` (offer-level
|
||||||
|
parse-failure и totalOffers/results.offers mismatch, audit-scrapers finding 2)
|
||||||
|
— переиспользуется, чтобы обе проверки одинаково попадали в мониторинг, а не
|
||||||
|
только в текстовый лог.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self._config.glitchtip_dsn:
|
||||||
|
# Ленивый импорт: sentry_sdk — app-side error-reporting, не dep
|
||||||
|
# scraper_kit. Только когда glitchtip настроен И случилась
|
||||||
|
# schema-regression.
|
||||||
|
import sentry_sdk
|
||||||
|
|
||||||
|
sentry_sdk.capture_message(message, level="error")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("sentry_sdk report failed (not installed/initialised)", exc_info=True)
|
||||||
|
|
||||||
def _parse_serp_html(self, html: str) -> list[ScrapedLot]:
|
def _parse_serp_html(self, html: str) -> list[ScrapedLot]:
|
||||||
"""Извлечь offers из Cian Redux state.
|
"""Извлечь offers из Cian Redux state.
|
||||||
|
|
||||||
|
|
@ -655,18 +674,41 @@ class CianScraper(BaseScraper):
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
offers_data: list[dict[str, Any]] = state.get("results", {}).get("offers", [])
|
results = state.get("results", {})
|
||||||
|
offers_data: list[dict[str, Any]] = results.get("offers", [])
|
||||||
|
total_offers = results.get("totalOffers")
|
||||||
|
|
||||||
if not offers_data:
|
if not offers_data:
|
||||||
|
# audit-scrapers finding 2: totalOffers и results.offers приходят из ОДНОГО
|
||||||
|
# state-блоба (одна SSR-выдача) — если totalOffers>0, а offers пуст, это
|
||||||
|
# внутреннее противоречие payload'а, а не легитимная пагинация (probe/leaf
|
||||||
|
# запрашивают только max_pages = ceil(totalOffers/28), посчитанные из ТОГО ЖЕ
|
||||||
|
# totalOffers, так что «сходили за последнюю страницу» здесь не объясняет 0).
|
||||||
|
# Порог: 0 vs >0 — единственный позиционно-независимый сигнал, который можно
|
||||||
|
# проверить без номера страницы; дробный порог (напр. «< 50% от expected»)
|
||||||
|
# ложно сработал бы на легитимной последней частичной странице пагинации,
|
||||||
|
# которую эта функция не различает.
|
||||||
|
if isinstance(total_offers, int) and total_offers > 0:
|
||||||
|
logger.error(
|
||||||
|
"cian SERP: totalOffers=%d но results.offers пуст — schema "
|
||||||
|
"regression suspected (counter/offers mismatch, not empty search)",
|
||||||
|
total_offers,
|
||||||
|
)
|
||||||
|
self._report_schema_regression(
|
||||||
|
f"cian SERP: totalOffers={total_offers} but results.offers is "
|
||||||
|
"empty — possible schema regression (counter/offers mismatch)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"cian state found but results.offers пуст (totalOffers=%s)",
|
"cian state found but results.offers пуст (totalOffers=%s)",
|
||||||
state.get("results", {}).get("totalOffers", "?"),
|
total_offers if total_offers is not None else "?",
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"cian SERP state ok: %d offers (totalOffers=%s)",
|
"cian SERP state ok: %d offers (totalOffers=%s)",
|
||||||
len(offers_data),
|
len(offers_data),
|
||||||
state.get("results", {}).get("totalOffers", "?"),
|
total_offers if total_offers is not None else "?",
|
||||||
)
|
)
|
||||||
|
|
||||||
lots: list[ScrapedLot] = []
|
lots: list[ScrapedLot] = []
|
||||||
|
|
@ -684,20 +726,10 @@ class CianScraper(BaseScraper):
|
||||||
"cian SERP: 0/%d offers прошли _offer_to_lot — возможна schema regression",
|
"cian SERP: 0/%d offers прошли _offer_to_lot — возможна schema regression",
|
||||||
raw_count,
|
raw_count,
|
||||||
)
|
)
|
||||||
try:
|
self._report_schema_regression(
|
||||||
if self._config.glitchtip_dsn:
|
|
||||||
# Ленивый импорт: sentry_sdk — app-side error-reporting, не dep
|
|
||||||
# scraper_kit. Только когда glitchtip настроен И случилась
|
|
||||||
# schema-regression. ImportError глотается общим except ниже.
|
|
||||||
import sentry_sdk
|
|
||||||
|
|
||||||
sentry_sdk.capture_message(
|
|
||||||
f"cian SERP: {raw_count}/{raw_count} offers failed _offer_to_lot"
|
f"cian SERP: {raw_count}/{raw_count} offers failed _offer_to_lot"
|
||||||
" — possible schema regression",
|
" — possible schema regression"
|
||||||
level="error",
|
|
||||||
)
|
)
|
||||||
except Exception:
|
|
||||||
pass # sentry_sdk not installed/initialised in dev
|
|
||||||
|
|
||||||
return lots
|
return lots
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue