From ca1015bd4e7990f6f9c2f9c35c7757b798cb61e5 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 26 Jul 2026 20:43:47 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(tradein/support):=20=D0=B2=D0=B5=D0=B1?= =?UTF-8?q?-=D1=87=D0=B0=D1=82=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=BA=D0=B8=20=E2=80=94=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=B0=D1=8F=20=D1=87=D0=B0=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B2=D0=B5=D1=80=D1=85=20Telegram-=D0=BC=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=20(#2532)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/support.py | 264 +++++++++ tradein-mvp/backend/app/core/ratelimit.py | 57 ++ tradein-mvp/backend/app/main.py | 38 +- .../backend/app/services/tgbot/bridge.py | 258 ++++++--- .../backend/app/services/tgbot/client.py | 20 +- .../app/services/tgbot/web_support_storage.py | 216 +++++++ .../backend/data/sql/187_web_support_chat.sql | 122 ++++ .../data/sql/188_tg_support_chat_id_scope.sql | 40 ++ .../tests/services/tgbot/test_bridge.py | 214 ++++++- tradein-mvp/backend/tests/test_ratelimit.py | 57 +- tradein-mvp/backend/tests/test_support.py | 536 ++++++++++++++++++ 11 files changed, 1749 insertions(+), 73 deletions(-) create mode 100644 tradein-mvp/backend/app/api/v1/support.py create mode 100644 tradein-mvp/backend/app/services/tgbot/web_support_storage.py create mode 100644 tradein-mvp/backend/data/sql/187_web_support_chat.sql create mode 100644 tradein-mvp/backend/data/sql/188_tg_support_chat_id_scope.sql create mode 100644 tradein-mvp/backend/tests/test_support.py diff --git a/tradein-mvp/backend/app/api/v1/support.py b/tradein-mvp/backend/app/api/v1/support.py new file mode 100644 index 00000000..0a8c1914 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/support.py @@ -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) — чужой +тред прочитать/отметить нельзя ни при каких параметрах запроса, потому что +параметра, которым можно было бы адресовать чужой тред, попросту не существует. + +Копия зеркала в топике всегда помечена "[С САЙТА] : ..." — оператор +не должен путать веб-обращение с 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() diff --git a/tradein-mvp/backend/app/core/ratelimit.py b/tradein-mvp/backend/app/core/ratelimit.py index acc98b2f..2b809ddb 100644 --- a/tradein-mvp/backend/app/core/ratelimit.py +++ b/tradein-mvp/backend/app/core/ratelimit.py @@ -80,6 +80,63 @@ class RateLimitMiddleware(BaseHTTPMiddleware): 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: """Честный клиентский IP при РОВНО ОДНОМ доверенном прокси (Caddy) перед нами. diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 7e69155f..7ccf4b12 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -23,7 +23,18 @@ from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration 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.config import settings from app.core.db import SessionLocal @@ -39,6 +50,13 @@ logging.basicConfig( 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/...) — то же самое +# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь. +logging.getLogger("httpx").setLevel(logging.WARNING) + # Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396). # DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP. # Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs), @@ -46,13 +64,28 @@ logging.basicConfig( # worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/ # frontend), отдельного broker нет → мониторить нечего. 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( dsn=settings.glitchtip_dsn, environment=settings.environment, release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown", traces_sample_rate=0.0, # только ошибки, без performance-трейсов 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=[ StarletteIntegration(), 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(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(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(search.router, prefix="/api/v1", tags=["search"]) app.include_router(me.router, prefix="/api/v1", tags=["me"]) diff --git a/tradein-mvp/backend/app/services/tgbot/bridge.py b/tradein-mvp/backend/app/services/tgbot/bridge.py index ed034c9c..6cbc8f3a 100644 --- a/tradein-mvp/backend/app/services/tgbot/bridge.py +++ b/tradein-mvp/backend/app/services/tgbot/bridge.py @@ -1,4 +1,4 @@ -"""Маршрутизация Telegram-апдейтов для support-моста (#tgsupport). +"""Маршрутизация Telegram-апдейтов для support-моста (#tgsupport, #tgsupport-web). Поток: A) Клиент пишет боту в личку (chat.type == 'private') → @@ -6,11 +6,28 @@ с идентификацией клиента в топик) → copyMessage контента в support-топик → запись в tg_support_messages (direction='in', topic_message_id — ключ маршрутизации ответа). + A') Пользователь сайта пишет через `app.api.v1.support` (веб-чат поддержки, + #tgsupport-web) → тот эндпоинт САМ зеркалит sendMessage'ом в топик и пишет + web_support_messages (direction='in') — этот модуль в этой ветке не участвует, + только в разборе ответа (B ниже). B) Оператор отвечает РЕПЛАЕМ в support-группе на зеркало клиента → - находим chat_id по topic_message_id → copyMessage ответа в личку клиента → - запись (direction='out'). Реплай не на зеркало (или не реплай вообще) — - обычная болтовня в топике, тихий игнор. Telegram 403 (клиент заблокировал - бота) → is_blocked=true + уведомление в топике. + резолвим topic_message_id ОБЕ стороны (tg_support_messages И + web_support_messages), скоупя к ТЕКУЩЕМУ TELEGRAM_SUPPORT_CHAT_ID + (#tgsupport-web review M1 — если группу когда-нибудь сменят/пересоздадут, + 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 сохраняется И коммитится в той же транзакции, что и запись сообщения (см. `process_update` `finally`), после КАЖДОГО апдейта — рестарт воркера не переигрывает уже @@ -22,7 +39,11 @@ Персистентность вынесена за `BridgeStorage`-протокол — маршрутизирующая логика (`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 @@ -38,6 +59,7 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.core.shutdown import shutdown_requested +from app.services.tgbot import web_support_storage from app.services.tgbot.client import TelegramApiError, TelegramClient logger = logging.getLogger(__name__) @@ -65,6 +87,11 @@ SERVICE_UNAVAILABLE_TEXT = ( # tg_support_messages.kind): "text | photo | document | video | voice | other". _KNOWN_KINDS = ("text", "photo", "document", "video", "voice") +# #tgsupport-web review M2: реплай оператора медиа-типом (в т.ч. фото С ПОДПИСЬЮ) +# на веб-зеркало НЕ доставляется частично — веб-чат текстовый MVP, оператор +# получает это уведомление в топике вместо тихого игнора (иначе уверен, что ответил). +_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT = "Веб-чат поддерживает только текст, сообщение не доставлено." + # ── Storage abstraction (testable без реальной БД) ────────────────────────── class BridgeStorage(Protocol): @@ -101,12 +128,23 @@ class BridgeStorage(Protocol): kind: str, text_body: str | None, operator_tg_id: int | None, + support_chat_id: int | None = 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 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: """`BridgeStorage` поверх SQLAlchemy Session (psycopg v3), tg_support_* таблицы. @@ -223,17 +261,19 @@ class SqlBridgeStorage: kind: str, text_body: str | None, operator_tg_id: int | None, + support_chat_id: int | None = None, ) -> int | None: row = self._db.execute( text( """ INSERT INTO tg_support_messages (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 (CAST(:chat_id AS bigint), CAST(:direction AS text), 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 """ ), @@ -245,11 +285,17 @@ class SqlBridgeStorage: "kind": kind, "text_body": text_body, "operator_tg_id": operator_tg_id, + "support_chat_id": support_chat_id, }, ).fetchone() 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( text( """ @@ -257,11 +303,13 @@ class SqlBridgeStorage: FROM tg_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}, + {"topic_message_id": topic_message_id, "support_chat_id": support_chat_id}, ).fetchone() return int(row[0]) if row is not None else None @@ -274,6 +322,25 @@ class SqlBridgeStorage: {"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 ────────────────────────────────────────────────────────────── def _infer_kind(message: dict[str, Any]) -> str: @@ -365,13 +432,15 @@ async def _handle_private_message( kind=_infer_kind(message), text_body=text_body or message.get("caption"), operator_tg_id=None, + support_chat_id=settings.telegram_support_chat_id, ) async def _handle_group_reply( message: dict[str, Any], client: TelegramClient, storage: BridgeStorage ) -> None: - """B) Реплай оператора в support-группе → доставка ответа клиенту.""" + """B) Реплай оператора в support-группе → доставка ответа клиенту (Telegram + ЛИБО веб-чат, #tgsupport-web — см. модульный docstring).""" reply_to = message.get("reply_to_message") if not isinstance(reply_to, dict): return # не реплай вообще — обычная болтовня в топике, тихий игнор @@ -380,67 +449,126 @@ async def _handle_group_reply( if not isinstance(mirror_message_id, int): return - target_chat_id = storage.find_chat_by_topic_message(mirror_message_id) - if target_chat_id is None: - # Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем, - # это ожидаемый шум. НО реплай на сообщение, отправленное САМИМ БОТОМ - # (is_bot=True) и при этом отсутствующее в tg_support_messages — подозрительно: - # вероятная причина — осиротевшее зеркало (воркер упал МЕЖДУ copyMessage и - # storage.commit() в `_handle_private_message` — зеркало ушло в Telegram, а - # запись в БД потерялась). Дискриминатор неидеальный (шапка-идентификация - # тоже от бота, но не 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 как зеркало клиента — возможно, осиротевшее " - "зеркало (крах между copyMessage и commit) или шапка-идентификация; " - "ответ оператора НЕ доставлен клиенту", - mirror_message_id, - ) - return + # #tgsupport-web review M1: резолвим ОБЕ стороны с текущим support_chat_id + # (НЕ short-circuit на первом найденном) — если topic_message_id совпал в + # ОБЕИХ таблицах одновременно, это значит инвариант "уникален в пределах + # текущей support-группы" нарушен (баг/ручная правка данных) — отказываем в + # доставке ГРОМКО, вместо того чтобы молча выбрать tg-путь и отправить ответ + # постороннему Telegram-клиенту (152-ФЗ misroute risk). + current_chat_id = settings.telegram_support_chat_id + target_chat_id = storage.find_chat_by_topic_message(mirror_message_id, current_chat_id) + web_thread_id = storage.find_web_thread_by_topic_message(mirror_message_id, current_chat_id) - message_id = message.get("message_id") - if not isinstance(message_id, int): - return - - operator = message.get("from") or {} - operator_id = operator.get("id") - - try: - delivered = await client.copy_message( - chat_id=target_chat_id, - from_chat_id=settings.telegram_support_chat_id, - message_id=message_id, + if target_chat_id is not None and web_thread_id is not None: + logger.error( + "tgbot bridge: topic_message_id=%d резолвится ОДНОВРЕМЕННО в Telegram " + "(chat_id=%d) и веб-чат (thread_id=%d) под support_chat_id=%d — отказ в " + "доставке, требуется ручной разбор tg_support_messages/web_support_messages", + mirror_message_id, + target_chat_id, + web_thread_id, + current_chat_id, ) - except TelegramApiError as exc: - if exc.error_code == 403: - # Клиент заблокировал бота — фиксируем и уведомляем оператора в топике. - storage.mark_blocked(target_chat_id) + return + + if target_chat_id is not None: + # Существующий Telegram-путь — НЕ ТРОНУТ. + message_id = message.get("message_id") + if not isinstance(message_id, int): + return + + operator = message.get("from") or {} + operator_id = operator.get("id") + + try: + delivered = await client.copy_message( + chat_id=target_chat_id, + from_chat_id=settings.telegram_support_chat_id, + message_id=message_id, + ) + except TelegramApiError as exc: + if exc.error_code == 403: + # Клиент заблокировал бота — фиксируем и уведомляем оператора в топике. + storage.mark_blocked(target_chat_id) + await client.send_message( + chat_id=settings.telegram_support_chat_id, + text=( + f"Не удалось доставить сообщение клиенту (chat_id={target_chat_id}) — " + "бот заблокирован." + ), + message_thread_id=settings.telegram_support_topic_id or None, + reply_to_message_id=message_id, + ) + return + raise + + tg_message_id = delivered.get("message_id") if isinstance(delivered, dict) else None + storage.record_message( + chat_id=target_chat_id, + direction="out", + tg_message_id=tg_message_id, + topic_message_id=None, + kind=_infer_kind(message), + text_body=message.get("text") or message.get("caption"), + 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=( - f"Не удалось доставить сообщение клиенту (chat_id={target_chat_id}) — " - "бот заблокирован." - ), + text=_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT, message_thread_id=settings.telegram_support_topic_id or None, - reply_to_message_id=message_id, + reply_to_message_id=message_id if isinstance(message_id, int) else None, ) return - raise - tg_message_id = delivered.get("message_id") if isinstance(delivered, dict) else None - storage.record_message( - chat_id=target_chat_id, - direction="out", - tg_message_id=tg_message_id, - topic_message_id=None, - kind=_infer_kind(message), - text_body=message.get("text") or message.get("caption"), - operator_tg_id=operator_id, - ) + 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( diff --git a/tradein-mvp/backend/app/services/tgbot/client.py b/tradein-mvp/backend/app/services/tgbot/client.py index 5909d0b3..faeeee66 100644 --- a/tradein-mvp/backend/app/services/tgbot/client.py +++ b/tradein-mvp/backend/app/services/tgbot/client.py @@ -238,12 +238,28 @@ class TelegramClient: text: str, message_thread_id: int | None = None, reply_to_message_id: int | None = None, + timeout: float | None = None, + max_retries: int | None = None, ) -> 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} if message_thread_id: payload["message_thread_id"] = message_thread_id if 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 {} diff --git a/tradein-mvp/backend/app/services/tgbot/web_support_storage.py b/tradein-mvp/backend/app/services/tgbot/web_support_storage.py new file mode 100644 index 00000000..42334482 --- /dev/null +++ b/tradein-mvp/backend/app/services/tgbot/web_support_storage.py @@ -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}, + ) diff --git a/tradein-mvp/backend/data/sql/187_web_support_chat.sql b/tradein-mvp/backend/data/sql/187_web_support_chat.sql new file mode 100644 index 00000000..1f23cc92 --- /dev/null +++ b/tradein-mvp/backend/data/sql/187_web_support_chat.sql @@ -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; diff --git a/tradein-mvp/backend/data/sql/188_tg_support_chat_id_scope.sql b/tradein-mvp/backend/data/sql/188_tg_support_chat_id_scope.sql new file mode 100644 index 00000000..753ce2e9 --- /dev/null +++ b/tradein-mvp/backend/data/sql/188_tg_support_chat_id_scope.sql @@ -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; diff --git a/tradein-mvp/backend/tests/services/tgbot/test_bridge.py b/tradein-mvp/backend/tests/services/tgbot/test_bridge.py index 38ca65b6..fdc82334 100644 --- a/tradein-mvp/backend/tests/services/tgbot/test_bridge.py +++ b/tradein-mvp/backend/tests/services/tgbot/test_bridge.py @@ -5,6 +5,15 @@ Coverage (per task spec + review follow-up): сообщение за throttle-окно, без шапки на повторное В окне, и снова с шапкой после истечения окна — #6 review) - реплай оператора → 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 про осиротевшее зеркало (#4 review) @@ -65,6 +74,12 @@ class FakeBridgeStorage: self._next_id = 1 self.clock_s: float = 0.0 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: return self._offset @@ -112,6 +127,7 @@ class FakeBridgeStorage: kind: str, text_body: str | None, operator_tg_id: int | None, + support_chat_id: int | None = None, ) -> int: if self.fail_next_record_message: self.fail_next_record_message = False @@ -128,20 +144,51 @@ class FakeBridgeStorage: "kind": kind, "text_body": text_body, "operator_tg_id": operator_tg_id, + "support_chat_id": support_chat_id, "recorded_at_s": self.clock_s, } ) 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): - if m["direction"] == "in" and m["topic_message_id"] == topic_message_id: - return m["chat_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 None def mark_blocked(self, chat_id: int) -> None: 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) ─────────── _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 +# ── 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) дедуп ────────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_ratelimit.py b/tradein-mvp/backend/tests/test_ratelimit.py index bf5fba49..4d1cd8dc 100644 --- a/tradein-mvp/backend/tests/test_ratelimit.py +++ b/tradein-mvp/backend/tests/test_ratelimit.py @@ -19,7 +19,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from app.core import config -from app.core.ratelimit import RateLimitMiddleware +from app.core.ratelimit import RateLimitMiddleware, SlidingWindowLimiter @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 == 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 diff --git a/tradein-mvp/backend/tests/test_support.py b/tradein-mvp/backend/tests/test_support.py new file mode 100644 index 00000000..4b87d446 --- /dev/null +++ b/tradein-mvp/backend/tests/test_support.py @@ -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 From a0647a53a955ce0fc5f79d47d9395de52fb32b17 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 26 Jul 2026 20:52:30 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(tradein/ui):=20=D0=BE=D0=BA=D0=BD?= =?UTF-8?q?=D0=BE=20=D1=87=D0=B0=D1=82=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=BA=D0=B8=20=D0=BD=D0=B0=20=D1=81=D0=B0?= =?UTF-8?q?=D0=B9=D1=82=D0=B5=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D1=83=D1=85=D0=BE=D0=B4=D0=B0=20=D0=B2=20Telegram=20(#2533)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/frontend/src/app/v2/layout.tsx | 17 +- .../components/trade-in/v2/SupportButton.tsx | 94 ++-- .../trade-in/v2/SupportChatContext.tsx | 62 +++ .../trade-in/v2/SupportChatPanel.tsx | 475 ++++++++++++++++++ .../src/components/trade-in/v2/TopNav.tsx | 43 +- .../frontend/src/lib/useSupportChat.ts | 138 +++++ 6 files changed, 776 insertions(+), 53 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/SupportChatContext.tsx create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx create mode 100644 tradein-mvp/frontend/src/lib/useSupportChat.ts diff --git a/tradein-mvp/frontend/src/app/v2/layout.tsx b/tradein-mvp/frontend/src/app/v2/layout.tsx index ecc487d8..bf603195 100644 --- a/tradein-mvp/frontend/src/app/v2/layout.tsx +++ b/tradein-mvp/frontend/src/app/v2/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { IBM_Plex_Mono, Manrope } from "next/font/google"; import { SupportButton } from "@/components/trade-in/v2/SupportButton"; +import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext"; import { pageBg } from "@/components/trade-in/v2/tokens"; // Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled @@ -41,11 +42,17 @@ export default function TradeInV2Layout({ alignItems: "flex-start", }} > - {children} - {/* Mounted here (not the global app/layout.tsx) — that layout also - covers the admin `/scrapers/**` area and `/sale-share`, unrelated - products without the МЕРА brand that don't need a support link. */} - + {/* SupportChatProvider wraps both {children} (TopNav's "Помощь" item + lives deep inside it, in page.tsx) and (mounted + here as a sibling, portaled to document.body) — they share one + open/closed chat state via context, see SupportChatContext.tsx. */} + + {children} + {/* Mounted here (not the global app/layout.tsx) — that layout also + covers the admin `/scrapers/**` area and `/sale-share`, unrelated + products without the МЕРА brand that don't need a support link. */} + + ); } diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx index 2a41b0f4..e3cd0b8b 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SupportButton.tsx @@ -1,39 +1,39 @@ "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. -// `LeadForm` (LeadForm.tsx) only mounts once `hasEstimate` is true +// WHY (original v1, before this window): before the plain-link version of +// 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) -// an estimate has no contact path at all. The only other link anywhere in the -// UI is NoAccessScreen.tsx:84, and that only renders on the "access expired" -// screen. The support bot itself is already live on the backend (support -// bridge, PR #2526) — this button is purely the missing entry point on the -// site side. Plain external link, no chat widget / no state / no API calls. +// an estimate had no contact path. This was upgraded from a plain external +// Telegram link to an in-page chat window (SupportChatPanel.tsx) so a visitor +// answers/receives replies without leaving the site — the message still +// mirrors into the same Telegram support topic and the operator still +// 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 // BuildingListingsDrawer.tsx): `/v2` renders its HUD inside a fixed // 1536×1024 "artboard" that gets `transform: scale(...)` on narrow viewports // (app/v2/page.tsx:860-871). A `position: fixed` descendant of a // `transform`-ed ancestor is positioned relative to that ancestor, not the -// viewport — so without a portal the button would drift/scale with the HUD -// instead of staying pinned to the real viewport corner. +// viewport — so without a portal the button (and the chat panel it opens) +// would drift/scale with the HUD instead of staying pinned to the real +// viewport corner. import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; 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 -// 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 { accent, accentDeep, onAccent, surface, font, danger } = tokens; const styles = ` .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); 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; return createPortal( <> - Поддержка - + {!open && unread > 0 && ( + + )} + + , document.body, ); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatContext.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatContext.tsx new file mode 100644 index 00000000..0e1552ee --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatContext.tsx @@ -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 +// ``, 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(null); + +export function SupportChatProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [open, setOpen] = useState(false); + + const value = useMemo( + () => ({ + open, + openChat: () => setOpen(true), + closeChat: () => setOpen(false), + toggleChat: () => setOpen((o) => !o), + }), + [open], + ); + + return ( + + {children} + + ); +} + +export function useSupportChat(): SupportChatContextValue { + const ctx = useContext(SupportChatContext); + if (!ctx) { + throw new Error( + "useSupportChat must be used within (app/v2/layout.tsx)", + ); + } + return ctx; +} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx new file mode 100644 index 00000000..dc00586a --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SupportChatPanel.tsx @@ -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(null); + const inputRef = useRef(null); + const listEndRef = useRef(null); + const lastFocused = useRef(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( + '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) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + } + + return ( + <> + +
+ {/* header */} +
+
+ Поддержка МЕРА +
+ +
+ + {/* message list */} +
+ {messagesQuery.isLoading ? ( +
+ Загрузка сообщений… +
+ ) : messagesQuery.isError ? ( +
+ {describeSupportError(messagesQuery.error)} + +
+ ) : messages.length === 0 ? ( +
+ Здравствуйте! Опишите вопрос — оператор отвечает в рабочее время, + обычно в течение часа. +
+ ) : ( + messages.map((m) => ) + )} +
+
+ + {/* send-error banner */} + {sendMessage.isError && ( +
+ {describeSupportError(sendMessage.error)} +
+ )} + + {/* composer */} +
+