Merge remote-tracking branch 'forgejo/main' into fix/tradein-audit-analogs
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m5s

This commit is contained in:
bot-backend 2026-07-26 23:57:32 +03:00
commit 18dbca4cd7
17 changed files with 2525 additions and 126 deletions

View file

@ -0,0 +1,264 @@
"""Веб-чат поддержки (#tgsupport-web) — поверх уже существующего Telegram
support-моста (`app.services.tgbot.bridge`, data/sql/186_tg_support.sql).
Источник обращения сайт (не Telegram-личка клиента): пользователь пишет через
это API, сообщение зеркалится `sendMessage`-ом в тот же support-топик, оператор
отвечает РЕПЛАЕМ ровно так же, как на Telegram-клиента маршрутизация ответа
обратно реализована в `bridge._handle_group_reply` (ветка добавлена там же, без
изменения существующего Telegram-пути).
Изоляция тредов: все 4 ручки резолвят тред ИСКЛЮЧИТЕЛЬНО по `X-Authenticated-User`
(rbac_guard в app/main.py гарантирует его наличие и валидность для non-public
путей). thread_id НИКОГДА не принимается снаружи (ни в query, ни в body) чужой
тред прочитать/отметить нельзя ни при каких параметрах запроса, потому что
параметра, которым можно было бы адресовать чужой тред, попросту не существует.
Копия зеркала в топике всегда помечена "[С САЙТА] <username>: ..." оператор
не должен путать веб-обращение с Telegram-клиентом (#tgsupport-web AC).
КРИТИЧНО (review H1) порядок операций в `send_support_message`:
БД-запись (`get_or_create_thread`) идёт ПОСЛЕ успешного `send_message`, не до.
Прод один uvicorn-процесс БЕЗ `--workers` (docker-compose.prod.yml) с
синхронным SQLAlchemy engine (пул 5+10 overflow) на ОДНОМ event loop. Если бы
`INSERT ... ON CONFLICT DO UPDATE` уходил ДО Telegram-вызова, строка/row-lock
держались бы всё время, пока `send_message` ждёт Telegram (секунды-минуты при
429/5xx на воркерных ретраях) второй параллельный запрос ТОГО ЖЕ юзера
(двойной клик, вторая вкладка) упёрся бы в этот lock ВНУТРИ синхронного
psycopg-вызова внутри `async def`, останавливая event loop целиком (весь API
встаёт, не только этот эндпоинт). `_format_mirror_text` использует только
`username` thread_id для отправки не нужен вообще, поэтому эту БД-операцию
можно безопасно отложить до после успешного sendMessage. Бонус: неудачная
отправка больше не создаёт тред.
"""
from __future__ import annotations
import logging
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter
from app.services.tgbot import web_support_storage as storage
from app.services.tgbot.bridge import SERVICE_UNAVAILABLE_TEXT
from app.services.tgbot.client import TelegramApiError, TelegramClient
logger = logging.getLogger(__name__)
router = APIRouter()
# Лимит Telegram sendMessage (4096) с запасом — см. #tgsupport-web AC ("~4000").
MAX_MESSAGE_LENGTH = 4000
# Жёстче общего RateLimitMiddleware (300 req/60с на пользователя, app/main.py):
# бот-токен общий на ВСЕХ клиентов веб-чата, флуд одного клиента иначе может
# упереться в Telegram-лимиты (`sendMessage` 429, лимит группы ~20 msg/min) и
# застопорить доставку всем остальным (см. задачу, п.6). 12 сообщений/минуту —
# щедро для живого диалога, но режет скрипт-флуд на порядок раньше общего API-лимита.
_SEND_RATE_LIMIT = 12
_SEND_RATE_WINDOW_S = 60.0
_send_limiter = SlidingWindowLimiter(limit=_SEND_RATE_LIMIT, window_s=_SEND_RATE_WINDOW_S)
# #tgsupport-web review H1: интерактивный HTTP-запрос НЕ МОЖЕТ наследовать
# воркерную политику ретраев `TelegramClient` (по умолчанию — до 5 попыток, на
# 429 спит `retry_after` Telegram'а — для группы штатно 30-60с, на 5xx backoff до
# 30с — легальный суммарный бюджет минуты). Узкий бюджет здесь: 1 повтор, короткий
# timeout — интерактивный клиент должен получить ответ (даже если это ошибка)
# за секунды, а не висеть до исчерпания воркерных ретраев.
_INTERACTIVE_SEND_TIMEOUT_S = 10.0
_INTERACTIVE_SEND_MAX_RETRIES = 1
# #tgsupport-web review M5: без LIMIT каждое монтирование виджета на старом
# треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`.
_LIST_MESSAGES_LIMIT = 200
def _require_username(request: Request) -> str:
"""Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его
наличие в проде для non-public путей этот guard здесь defence-in-depth и
делает роутер тестируемым без поднятия всего app.main (см. tests/test_support.py,
как test_trade_in_lead.py для /lead). `.strip()` (review L4) лишний пробел
от прокси иначе завёл бы ВТОРОЙ тред на, по сути, того же пользователя
(username UNIQUE ключ треда, "alice" != "alice ")."""
username = (request.headers.get("x-authenticated-user") or "").strip()
if not username:
raise HTTPException(status_code=401, detail="no authenticated user")
return username
def _bot_configured() -> bool:
"""TELEGRAM_BOT_TOKEN и TELEGRAM_SUPPORT_CHAT_ID оба обязательны — без них
зеркалировать в топик некуда (см. app/tgbot_main.py._should_run для токена,
bridge.py для chat_id)."""
return bool(settings.telegram_bot_token) and bool(settings.telegram_support_chat_id)
class SupportMessageInput(BaseModel):
text: str = Field(min_length=1, max_length=MAX_MESSAGE_LENGTH)
@field_validator("text")
@classmethod
def _not_blank(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("text must not be blank")
return stripped
class SupportMessageOut(BaseModel):
id: int
direction: Literal["in", "out"]
text_body: str
operator_tg_id: int | None = None
created_at: str
@field_validator("created_at", mode="before")
@classmethod
def _isoformat(cls, value: object) -> str:
if hasattr(value, "isoformat"):
return value.isoformat() # type: ignore[no-any-return]
return str(value)
class UnreadOut(BaseModel):
unread: int
class StatusOut(BaseModel):
status: Literal["ok"] = "ok"
def _format_mirror_text(username: str, message_text: str) -> str:
"""Помечает зеркало как пришедшее С САЙТА, от какого пользователя — оператор
иначе не отличит веб-обращение от Telegram-клиента (#tgsupport-web AC).
Использует ТОЛЬКО username thread_id здесь не нужен (см. H1 в docstring
модуля), это то, что делает возможным отложить БД-запись до после отправки."""
return f"[С САЙТА] {username}:\n{message_text}"
@router.post("/support/messages", response_model=SupportMessageOut)
async def send_support_message(
payload: SupportMessageInput,
username: Annotated[str, Depends(_require_username)],
db: Annotated[Session, Depends(get_db)],
) -> SupportMessageOut:
"""Отправляет сообщение от лица *username* в support-топик (`sendMessage` —
не `copyMessage`: у веб-сообщения нет исходного Telegram-сообщения для копии).
Порядок операций см. H1 в docstring модуля: rate-limit проверяется (но НЕ
расходуется, review L3) до отправки, thread создаётся ТОЛЬКО после успешного
`send_message` до этого момента с БД не происходит ничего.
"""
if not _bot_configured():
# Предсказуемое поведение вместо 500 (#tgsupport-web AC): бот не настроен
# (пустой TELEGRAM_BOT_TOKEN, dev/staging) или support-топик не задан —
# мирроринг невозможен физически, ничего не пишем в БД.
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_TEXT)
# review L3: peek без расхода бюджета — неудачная отправка НЕ должна стоить
# пользователю попытки (расходуем `.record()` только на успех, ниже).
retry_after = _send_limiter.retry_after(username)
if retry_after is not None:
raise HTTPException(
status_code=429,
detail="Слишком много сообщений. Попробуйте через минуту.",
headers={"Retry-After": str(int(retry_after) + 1)},
)
client = TelegramClient(settings.telegram_bot_token)
try:
mirrored = await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=_format_mirror_text(username, payload.text),
message_thread_id=settings.telegram_support_topic_id or None,
# review H1: узкий интерактивный бюджет — НЕ воркерные 5 ретраев/минуты.
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
)
except TelegramApiError:
# НЕ логируем payload.text (переписка — ПДн) и НЕ логируем токен (его в
# TelegramApiError и не бывает — см. client.py docstring про redaction).
logger.exception(
"web support: не удалось отправить зеркало в топик (username=%s)", username
)
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
_send_limiter.record(username)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
if topic_message_id is None:
# review L1: без topic_message_id реплай оператора на это сообщение
# НИКОГДА не смаршрутизируется обратно (find_thread_by_topic_message ищет
# именно по этому полю) — тихая, но зафиксированная в логе деградация.
logger.warning(
"web support: Telegram sendMessage не вернул message_id (username=%s) — "
"ответ оператора на это сообщение не будет смаршрутизирован",
username,
)
# review H1: БД-операция ПОСЛЕ успешной отправки — см. docstring модуля.
thread_id = storage.get_or_create_thread(db, username)
row = storage.record_inbound(
db,
thread_id=thread_id,
text_body=payload.text,
topic_message_id=topic_message_id,
support_chat_id=settings.telegram_support_chat_id,
)
db.commit()
logger.info("web support: message sent username=%s thread_id=%d", username, thread_id)
return SupportMessageOut(**row)
@router.get("/support/messages", response_model=list[SupportMessageOut])
def list_support_messages(
username: Annotated[str, Depends(_require_username)],
db: Annotated[Session, Depends(get_db)],
since: Annotated[int, Query(ge=0)] = 0,
) -> list[SupportMessageOut]:
"""Сообщения СВОЕГО треда с id > since. Тред резолвится по username — чужой
тред недостижим (нет параметра, которым его можно адресовать).
Обычный (sync) `def`, не `async def` (review M3): тело только синхронные
psycopg-вызовы, ни одного `await`; как `async def` это исполнялось бы прямо в
event loop (а фронт поллит эту ручку постоянно). Starlette гонит sync-handlers
в threadpool автоматически тот же паттерн, что `trade_in.py:get_estimate`.
"""
thread_id = storage.find_thread_id(db, username)
if thread_id is None:
return []
rows = storage.list_messages(
db, thread_id=thread_id, since_id=since, limit=_LIST_MESSAGES_LIMIT
)
return [SupportMessageOut(**r) for r in rows]
@router.get("/support/unread", response_model=UnreadOut)
def get_support_unread(
username: Annotated[str, Depends(_require_username)],
db: Annotated[Session, Depends(get_db)],
) -> UnreadOut:
"""Sync `def` (review M3) — см. `list_support_messages`."""
thread_id = storage.find_thread_id(db, username)
if thread_id is None:
return UnreadOut(unread=0)
return UnreadOut(unread=storage.count_unread(db, thread_id=thread_id))
@router.post("/support/read", response_model=StatusOut)
def mark_support_read(
username: Annotated[str, Depends(_require_username)],
db: Annotated[Session, Depends(get_db)],
) -> StatusOut:
"""Sync `def` (review M3) — см. `list_support_messages`."""
thread_id = storage.find_thread_id(db, username)
if thread_id is not None:
storage.mark_read(db, thread_id=thread_id)
db.commit()
return StatusOut()

View file

@ -80,6 +80,63 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
return await call_next(request) return await call_next(request)
class SlidingWindowLimiter:
"""Reusable in-process sliding-window limiter — тот же алгоритм, что
`RateLimitMiddleware.dispatch` (deque per key, отбрасываем протухшие метки),
вынесенный для feature-специфичных лимитов, которые нужны ЖЁСТЧЕ общего
per-user порога `/api/*` (напр. отправка сообщений в веб-чат поддержки,
#tgsupport-web — общий лимит 300/60с не спасёт support-топик от заливки
одним флудящим клиентом, т.к. Telegram Bot API токен общий на всех).
Не заменяет `RateLimitMiddleware` (тот остаётся общим гейтом на `/api/*`),
а даёт отдельный, более узкий бюджет для конкретного эндпоинта/действия.
"""
def __init__(self, limit: int, window_s: float) -> None:
self._limit = limit
self._window_s = window_s
self._hits: dict[str, deque[float]] = defaultdict(deque)
def _prune(self, bucket: deque[float], now: float) -> None:
cutoff = now - self._window_s
while bucket and bucket[0] < cutoff:
bucket.popleft()
def retry_after(self, key: str) -> float | None:
"""Non-destructive проверка: сколько секунд ждать, если *key* СЕЙЧАС за
лимитом, иначе None. НЕ регистрирует попытку вызывающая сторона решает
сама, когда звать `record()` (обычно только на успех действия, #tgsupport-web
review L3: неудачная попытка не должна съедать бюджет)."""
now = time.monotonic()
bucket = self._hits[key]
self._prune(bucket, now)
if len(bucket) >= self._limit:
return self._window_s - (now - bucket[0])
return None
def record(self, key: str) -> None:
"""Регистрирует одну успешную попытку под *key*."""
now = time.monotonic()
bucket = self._hits[key]
self._prune(bucket, now)
bucket.append(now)
# Лёгкая защита от утечки памяти — чистим пустые корзины изредка (тот же
# паттерн, что RateLimitMiddleware.dispatch).
if len(self._hits) > 10000:
for k in [k for k, v in self._hits.items() if not v]:
del self._hits[k]
def check(self, key: str) -> float | None:
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
для вызывающих, которым не нужно различать "попытка"/"успех" (см.
`retry_after`/`record` для раздельного варианта)."""
retry_after = self.retry_after(key)
if retry_after is not None:
return retry_after
self.record(key)
return None
def _client_ip(request: Request) -> str: def _client_ip(request: Request) -> str:
"""Честный клиентский IP при РОВНО ОДНОМ доверенном прокси (Caddy) перед нами. """Честный клиентский IP при РОВНО ОДНОМ доверенном прокси (Caddy) перед нами.

View file

@ -23,7 +23,18 @@ from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration from sentry_sdk.integrations.starlette import StarletteIntegration
from app.api.v1 import admin, audit, brand, buildings, geocode, lead, me, search, trade_in from app.api.v1 import (
admin,
audit,
brand,
buildings,
geocode,
lead,
me,
search,
support,
trade_in,
)
from app.core.auth import get_role, is_path_allowed from app.core.auth import get_role, is_path_allowed
from app.core.config import settings from app.core.config import settings
from app.core.db import SessionLocal from app.core.db import SessionLocal
@ -39,6 +50,13 @@ logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s: %(message)s", format="%(asctime)s %(levelname)s %(name)s: %(message)s",
) )
# #tgsupport-web: этот процесс теперь тоже зовёт Telegram Bot API напрямую
# (app/api/v1/support.py — sendMessage при отправке веб-сообщения в топик), не
# только изолированный tgbot_main.py. httpx-INFO логирует ПОЛНЫЙ request URL,
# включая токен в пути (https://api.telegram.org/bot<TOKEN>/...) — то же самое
# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь.
logging.getLogger("httpx").setLevel(logging.WARNING)
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396). # Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP. # DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs), # Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
@ -46,13 +64,28 @@ logging.basicConfig(
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/ # worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
# frontend), отдельного broker нет → мониторить нечего. # frontend), отдельного broker нет → мониторить нечего.
if settings.glitchtip_dsn: if settings.glitchtip_dsn:
from app.observability.sentry_scrub import redact_telegram_bot_token
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) —
см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск:
теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке
sendMessage, а include_local_variables=False ниже первый рубеж защиты)."""
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
sentry_sdk.init( sentry_sdk.init(
dsn=settings.glitchtip_dsn, dsn=settings.glitchtip_dsn,
environment=settings.environment, environment=settings.environment,
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown", release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
traces_sample_rate=0.0, # только ошибки, без performance-трейсов traces_sample_rate=0.0, # только ошибки, без performance-трейсов
send_default_pii=False, # не шлём client_name / client_phone в отчёты send_default_pii=False, # не шлём client_name / client_phone в отчёты
before_send=scrub_pii_event, # дочищаем consumer-PII из тела error events include_local_variables=False, # #tgsupport-web: TelegramClient._request
# держит base URL с токеном в локальных переменных стек-фрейма — default
# sentry_sdk (True) приложил бы их к traceback открытым текстом.
before_send=_before_send,
integrations=[ integrations=[
StarletteIntegration(), StarletteIntegration(),
FastApiIntegration(), FastApiIntegration(),
@ -229,6 +262,7 @@ app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"]) app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"]) app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"]) app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
app.include_router(search.router, prefix="/api/v1", tags=["search"]) app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"]) app.include_router(me.router, prefix="/api/v1", tags=["me"])

View file

@ -1,4 +1,4 @@
"""Маршрутизация Telegram-апдейтов для support-моста (#tgsupport). """Маршрутизация Telegram-апдейтов для support-моста (#tgsupport, #tgsupport-web).
Поток: Поток:
A) Клиент пишет боту в личку (chat.type == 'private') A) Клиент пишет боту в личку (chat.type == 'private')
@ -6,11 +6,28 @@
с идентификацией клиента в топик) copyMessage контента в support-топик с идентификацией клиента в топик) copyMessage контента в support-топик
запись в tg_support_messages (direction='in', topic_message_id ключ запись в tg_support_messages (direction='in', topic_message_id ключ
маршрутизации ответа). маршрутизации ответа).
A') Пользователь сайта пишет через `app.api.v1.support` (веб-чат поддержки,
#tgsupport-web) → тот эндпоинт САМ зеркалит sendMessage'ом в топик и пишет
web_support_messages (direction='in') этот модуль в этой ветке не участвует,
только в разборе ответа (B ниже).
B) Оператор отвечает РЕПЛАЕМ в support-группе на зеркало клиента B) Оператор отвечает РЕПЛАЕМ в support-группе на зеркало клиента
находим chat_id по topic_message_id copyMessage ответа в личку клиента резолвим topic_message_id ОБЕ стороны (tg_support_messages И
запись (direction='out'). Реплай не на зеркало (или не реплай вообще) web_support_messages), скоупя к ТЕКУЩЕМУ TELEGRAM_SUPPORT_CHAT_ID
обычная болтовня в топике, тихий игнор. Telegram 403 (клиент заблокировал (#tgsupport-web review M1 — если группу когда-нибудь сменят/пересоздадут,
бота) is_blocked=true + уведомление в топике. Telegram message_id стартует заново и может совпасть со старым числом из
другой таблицы; без скоупинга это была бы ТИХАЯ доставка постороннему
клиенту). Совпадение НА ОБЕИХ сторонах одновременно громкий отказ
(`logger.error`, ничего не доставляем) вместо произвольного выбора одной из
них. Иначе: chat_id найден copyMessage ответа в личку клиента запись
(direction='out'); thread_id найден (веб-зеркало) доставка идёт НЕ в
Telegram (у веб-клиента нет личного чата с ботом), а записью direction='out'
в web_support_messages (веб-фронт вычитывает её обычным polling'ом); реплай
медиа-типом на веб-зеркало веб-чат текстовый MVP, доставка целиком
отклоняется (не частично фото с подписью НЕ превращается в "ответ = только
подпись"), оператор получает уведомление в топике (review M2). Реплай не на
зеркало (или не реплай вообще) обычная болтовня в топике, тихий игнор.
Telegram 403 (клиент заблокировал бота) is_blocked=true + уведомление в
топике (только для Telegram-ветки у веб-клиента нет "заблокировал бота").
C) Дедуп: update_id <= сохранённого offset skip. Offset сохраняется И C) Дедуп: update_id <= сохранённого offset skip. Offset сохраняется И
коммитится в той же транзакции, что и запись сообщения (см. `process_update` коммитится в той же транзакции, что и запись сообщения (см. `process_update`
`finally`), после КАЖДОГО апдейта рестарт воркера не переигрывает уже `finally`), после КАЖДОГО апдейта рестарт воркера не переигрывает уже
@ -22,7 +39,11 @@
Персистентность вынесена за `BridgeStorage`-протокол маршрутизирующая логика Персистентность вынесена за `BridgeStorage`-протокол маршрутизирующая логика
(`process_update` и приватные `_handle_*`) не завязана на реальную БД, тестируется (`process_update` и приватные `_handle_*`) не завязана на реальную БД, тестируется
на in-memory fake storage + mock httpx (см. tests/services/tgbot/). на in-memory fake storage + mock httpx (см. tests/services/tgbot/). Веб-чат
таблицы (web_support_threads/web_support_messages) сознательно ОТДЕЛЬНЫ от
tg_support_* обоснование в data/sql/187_web_support_chat.sql; здесь `BridgeStorage`
несёт два дополнительных метода (`find_web_thread_by_topic_message`,
`record_web_out_message`), делегирующих в `web_support_storage`.
""" """
from __future__ import annotations from __future__ import annotations
@ -38,6 +59,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
from app.core.shutdown import shutdown_requested from app.core.shutdown import shutdown_requested
from app.services.tgbot import web_support_storage
from app.services.tgbot.client import TelegramApiError, TelegramClient from app.services.tgbot.client import TelegramApiError, TelegramClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -65,6 +87,11 @@ SERVICE_UNAVAILABLE_TEXT = (
# tg_support_messages.kind): "text | photo | document | video | voice | other". # tg_support_messages.kind): "text | photo | document | video | voice | other".
_KNOWN_KINDS = ("text", "photo", "document", "video", "voice") _KNOWN_KINDS = ("text", "photo", "document", "video", "voice")
# #tgsupport-web review M2: реплай оператора медиа-типом (в т.ч. фото С ПОДПИСЬЮ)
# на веб-зеркало НЕ доставляется частично — веб-чат текстовый MVP, оператор
# получает это уведомление в топике вместо тихого игнора (иначе уверен, что ответил).
_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT = "Веб-чат поддерживает только текст, сообщение не доставлено."
# ── Storage abstraction (testable без реальной БД) ────────────────────────── # ── Storage abstraction (testable без реальной БД) ──────────────────────────
class BridgeStorage(Protocol): class BridgeStorage(Protocol):
@ -101,12 +128,23 @@ class BridgeStorage(Protocol):
kind: str, kind: str,
text_body: str | None, text_body: str | None,
operator_tg_id: int | None, operator_tg_id: int | None,
support_chat_id: int | None = None,
) -> int | None: ... ) -> int | None: ...
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None: ... def find_chat_by_topic_message(
self, topic_message_id: int, support_chat_id: int
) -> int | None: ...
def mark_blocked(self, chat_id: int) -> None: ... def mark_blocked(self, chat_id: int) -> None: ...
def find_web_thread_by_topic_message(
self, topic_message_id: int, support_chat_id: int
) -> int | None: ...
def record_web_out_message(
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
) -> None: ...
class SqlBridgeStorage: class SqlBridgeStorage:
"""`BridgeStorage` поверх SQLAlchemy Session (psycopg v3), tg_support_* таблицы. """`BridgeStorage` поверх SQLAlchemy Session (psycopg v3), tg_support_* таблицы.
@ -223,17 +261,19 @@ class SqlBridgeStorage:
kind: str, kind: str,
text_body: str | None, text_body: str | None,
operator_tg_id: int | None, operator_tg_id: int | None,
support_chat_id: int | None = None,
) -> int | None: ) -> int | None:
row = self._db.execute( row = self._db.execute(
text( text(
""" """
INSERT INTO tg_support_messages INSERT INTO tg_support_messages
(chat_id, direction, tg_message_id, topic_message_id, kind, (chat_id, direction, tg_message_id, topic_message_id, kind,
text_body, operator_tg_id, created_at) text_body, operator_tg_id, support_chat_id, created_at)
VALUES VALUES
(CAST(:chat_id AS bigint), CAST(:direction AS text), (CAST(:chat_id AS bigint), CAST(:direction AS text),
CAST(:tg_message_id AS bigint), CAST(:topic_message_id AS bigint), CAST(:tg_message_id AS bigint), CAST(:topic_message_id AS bigint),
CAST(:kind AS text), :text_body, CAST(:operator_tg_id AS bigint), NOW()) CAST(:kind AS text), :text_body, CAST(:operator_tg_id AS bigint),
CAST(:support_chat_id AS bigint), NOW())
RETURNING id RETURNING id
""" """
), ),
@ -245,11 +285,17 @@ class SqlBridgeStorage:
"kind": kind, "kind": kind,
"text_body": text_body, "text_body": text_body,
"operator_tg_id": operator_tg_id, "operator_tg_id": operator_tg_id,
"support_chat_id": support_chat_id,
}, },
).fetchone() ).fetchone()
return int(row[0]) if row is not None else None return int(row[0]) if row is not None else None
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None: def find_chat_by_topic_message(self, topic_message_id: int, support_chat_id: int) -> int | None:
"""Скоупим к ТЕКУЩЕМУ support_chat_id (#tgsupport-web review M1) — строка
со ЧУЖИМ (не NULL, не текущим) support_chat_id исторический артефакт
ротации support-группы, не валидный маршрут сегодня. NULL (строки до
миграции 188, если есть) лениентный wildcard-матч (единственный
действовавший чат на тот момент)."""
row = self._db.execute( row = self._db.execute(
text( text(
""" """
@ -257,11 +303,13 @@ class SqlBridgeStorage:
FROM tg_support_messages FROM tg_support_messages
WHERE topic_message_id = CAST(:topic_message_id AS bigint) WHERE topic_message_id = CAST(:topic_message_id AS bigint)
AND direction = 'in' AND direction = 'in'
AND (support_chat_id = CAST(:support_chat_id AS bigint)
OR support_chat_id IS NULL)
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1 LIMIT 1
""" """
), ),
{"topic_message_id": topic_message_id}, {"topic_message_id": topic_message_id, "support_chat_id": support_chat_id},
).fetchone() ).fetchone()
return int(row[0]) if row is not None else None return int(row[0]) if row is not None else None
@ -274,6 +322,25 @@ class SqlBridgeStorage:
{"chat_id": chat_id}, {"chat_id": chat_id},
) )
def find_web_thread_by_topic_message(
self, topic_message_id: int, support_chat_id: int
) -> int | None:
"""Делегирует в `web_support_storage` (#tgsupport-web) — то же соединение/
транзакцию, что и tg-путь, коммитится вместе offset'ом в `process_update`."""
return web_support_storage.find_thread_by_topic_message(
self._db, topic_message_id, support_chat_id
)
def record_web_out_message(
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
) -> None:
web_support_storage.record_outbound(
self._db,
thread_id=thread_id,
text_body=text_body,
operator_tg_id=operator_tg_id,
)
# ── Pure helpers ────────────────────────────────────────────────────────────── # ── Pure helpers ──────────────────────────────────────────────────────────────
def _infer_kind(message: dict[str, Any]) -> str: def _infer_kind(message: dict[str, Any]) -> str:
@ -365,13 +432,15 @@ async def _handle_private_message(
kind=_infer_kind(message), kind=_infer_kind(message),
text_body=text_body or message.get("caption"), text_body=text_body or message.get("caption"),
operator_tg_id=None, operator_tg_id=None,
support_chat_id=settings.telegram_support_chat_id,
) )
async def _handle_group_reply( async def _handle_group_reply(
message: dict[str, Any], client: TelegramClient, storage: BridgeStorage message: dict[str, Any], client: TelegramClient, storage: BridgeStorage
) -> None: ) -> None:
"""B) Реплай оператора в support-группе → доставка ответа клиенту.""" """B) Реплай оператора в support-группе → доставка ответа клиенту (Telegram
ЛИБО веб-чат, #tgsupport-web — см. модульный docstring)."""
reply_to = message.get("reply_to_message") reply_to = message.get("reply_to_message")
if not isinstance(reply_to, dict): if not isinstance(reply_to, dict):
return # не реплай вообще — обычная болтовня в топике, тихий игнор return # не реплай вообще — обычная болтовня в топике, тихий игнор
@ -380,67 +449,126 @@ async def _handle_group_reply(
if not isinstance(mirror_message_id, int): if not isinstance(mirror_message_id, int):
return return
target_chat_id = storage.find_chat_by_topic_message(mirror_message_id) # #tgsupport-web review M1: резолвим ОБЕ стороны с текущим support_chat_id
if target_chat_id is None: # (НЕ short-circuit на первом найденном) — если topic_message_id совпал в
# Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем, # ОБЕИХ таблицах одновременно, это значит инвариант "уникален в пределах
# это ожидаемый шум. НО реплай на сообщение, отправленное САМИМ БОТОМ # текущей support-группы" нарушен (баг/ручная правка данных) — отказываем в
# (is_bot=True) и при этом отсутствующее в tg_support_messages — подозрительно: # доставке ГРОМКО, вместо того чтобы молча выбрать tg-путь и отправить ответ
# вероятная причина — осиротевшее зеркало (воркер упал МЕЖДУ copyMessage и # постороннему Telegram-клиенту (152-ФЗ misroute risk).
# storage.commit() в `_handle_private_message` — зеркало ушло в Telegram, а current_chat_id = settings.telegram_support_chat_id
# запись в БД потерялась). Дискриминатор неидеальный (шапка-идентификация target_chat_id = storage.find_chat_by_topic_message(mirror_message_id, current_chat_id)
# тоже от бота, но не routing-ключ — тоже даст этот WARNING), но лучше редкий web_thread_id = storage.find_web_thread_by_topic_message(mirror_message_id, current_chat_id)
# ложный WARNING, чем оператор молча решает, что ответ клиенту доставлен,
# хотя реплай тихо утонул (#4 review — двухфазный протокол НЕ делаем, overkill).
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
message_id = message.get("message_id") if target_chat_id is not None and web_thread_id is not None:
if not isinstance(message_id, int): logger.error(
return "tgbot bridge: topic_message_id=%d резолвится ОДНОВРЕМЕННО в Telegram "
"(chat_id=%d) и веб-чат (thread_id=%d) под support_chat_id=%d — отказ в "
operator = message.get("from") or {} "доставке, требуется ручной разбор tg_support_messages/web_support_messages",
operator_id = operator.get("id") mirror_message_id,
target_chat_id,
try: web_thread_id,
delivered = await client.copy_message( current_chat_id,
chat_id=target_chat_id,
from_chat_id=settings.telegram_support_chat_id,
message_id=message_id,
) )
except TelegramApiError as exc: return
if exc.error_code == 403:
# Клиент заблокировал бота — фиксируем и уведомляем оператора в топике. if target_chat_id is not None:
storage.mark_blocked(target_chat_id) # Существующий 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( await client.send_message(
chat_id=settings.telegram_support_chat_id, chat_id=settings.telegram_support_chat_id,
text=( text=_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT,
f"Не удалось доставить сообщение клиенту (chat_id={target_chat_id}) — "
"бот заблокирован."
),
message_thread_id=settings.telegram_support_topic_id or None, 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 return
raise
tg_message_id = delivered.get("message_id") if isinstance(delivered, dict) else None text_body = message.get("text")
storage.record_message( if not text_body:
chat_id=target_chat_id, # Текстовый kind, но пустой text (защитный edge case) — нечего доставлять.
direction="out", return
tg_message_id=tg_message_id,
topic_message_id=None, operator = message.get("from") or {}
kind=_infer_kind(message), operator_id = operator.get("id")
text_body=message.get("text") or message.get("caption"), storage.record_web_out_message(
operator_tg_id=operator_id, thread_id=web_thread_id,
) text_body=text_body,
operator_tg_id=operator_id,
)
return
# Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем,
# это ожидаемый шум. НО реплай на сообщение, отправленное САМИМ БОТОМ
# (is_bot=True) и при этом отсутствующее ни в tg_support_messages, ни в
# web_support_messages — подозрительно: вероятная причина — осиротевшее
# зеркало (воркер/API упал МЕЖДУ отправкой зеркала и commit'ом записи в БД).
# Дискриминатор неидеальный (шапка-идентификация тоже от бота, но не
# routing-ключ — тоже даст этот WARNING), но лучше редкий ложный WARNING, чем
# оператор молча решает, что ответ доставлен, хотя реплай тихо утонул
# (#4 review — двухфазный протокол НЕ делаем, overkill).
reply_from = reply_to.get("from") or {}
if reply_from.get("is_bot"):
logger.warning(
"tgbot bridge: реплай на сообщение бота (message_id=%d) не найден ни в "
"tg_support_messages, ни в web_support_messages как зеркало — возможно, "
"осиротевшее зеркало (крах между отправкой и commit'ом) или "
"шапка-идентификация; ответ оператора НЕ доставлен",
mirror_message_id,
)
async def process_update( async def process_update(

View file

@ -238,12 +238,28 @@ class TelegramClient:
text: str, text: str,
message_thread_id: int | None = None, message_thread_id: int | None = None,
reply_to_message_id: int | None = None, reply_to_message_id: int | None = None,
timeout: float | None = None,
max_retries: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке).""" """sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке).
`timeout`/`max_retries` по умолчанию наследуют воркерную политику
(`_DEFAULT_TIMEOUT_S`/`_DEFAULT_MAX_RETRIES`: на 429 спим Telegram-овский
`retry_after` для группы это штатные 30-60с, на 5xx backoff до 30с).
Это ПРИЕМЛЕМО для `tgbot_main.py` (изолированный long-polling воркер), но
ФАТАЛЬНО для интерактивного HTTP-запроса (#tgsupport-web review H1) —
синхронный request/response путь не может легально висеть минуты. Вызывающая
сторона на interactive-пути ОБЯЗАНА передать узкий бюджет явно (см.
`app.api.v1.support.send_support_message`)."""
payload: dict[str, Any] = {"chat_id": chat_id, "text": text} payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
if message_thread_id: if message_thread_id:
payload["message_thread_id"] = message_thread_id payload["message_thread_id"] = message_thread_id
if reply_to_message_id: if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id payload["reply_to_message_id"] = reply_to_message_id
result = await self._request("sendMessage", payload) kwargs: dict[str, Any] = {}
if timeout is not None:
kwargs["timeout"] = timeout
if max_retries is not None:
kwargs["max_retries"] = max_retries
result = await self._request("sendMessage", payload, **kwargs)
return result if isinstance(result, dict) else {} return result if isinstance(result, dict) else {}

View file

@ -0,0 +1,216 @@
"""Persistence для веб-чата поддержки (#tgsupport-web) — web_support_threads /
web_support_messages (см. data/sql/187_web_support_chat.sql, комментарий там же
объясняет, почему отдельные таблицы, а не `channel`-колонка в tg_support_*).
Чистые SQL-функции поверх SQLAlchemy `Session` (psycopg v3) никакой
Telegram-логики здесь. Используется ДВУМЯ вызывающими сторонами:
- `app.api.v1.support` (FastAPI-роутер) создание/поиск треда, запись
inbound-сообщения, чтение истории/unread/read.
- `app.services.tgbot.bridge._handle_group_reply` резолвит
topic_message_id реплая оператора в web-тред и пишет outbound-ответ.
Тред резолвится ТОЛЬКО по username (X-Authenticated-User) ни один метод
здесь не принимает thread_id снаружи, поэтому IDOR (чтение чужого треда)
структурно невозможен на уровне API (см. app/api/v1/support.py).
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def find_thread_id(db: Session, username: str) -> int | None:
"""Возвращает id треда для *username*, если он уже существует, иначе None.
Используется read-путями (GET .../messages, .../unread) они НЕ должны
создавать тред просто фактом обращения (иначе каждое открытие виджета
поддержки создавало бы пустой тред для любого пользователя сайта).
"""
row = db.execute(
text("SELECT id FROM web_support_threads WHERE username = CAST(:username AS text)"),
{"username": username},
).fetchone()
return int(row[0]) if row is not None else None
def get_or_create_thread(db: Session, username: str) -> int:
"""Гарантирует существование треда для *username*, обновляя last_seen_at.
Вызывается ТОЛЬКО из send-пути (POST .../messages) отправка сообщения
это единственное действие, которое должно "создавать" тред.
"""
row = db.execute(
text(
"""
INSERT INTO web_support_threads (username, created_at, last_seen_at, last_read_at)
VALUES (CAST(:username AS text), NOW(), NOW(), NOW())
ON CONFLICT (username) DO UPDATE
SET last_seen_at = NOW()
RETURNING id
"""
),
{"username": username},
).fetchone()
assert row is not None # INSERT ... RETURNING всегда отдаёт строку
return int(row[0])
def record_inbound(
db: Session,
*,
thread_id: int,
text_body: str,
topic_message_id: int | None,
support_chat_id: int | None,
) -> dict[str, Any]:
"""Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` —
id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора.
`support_chat_id` TELEGRAM_SUPPORT_CHAT_ID В МОМЕНТ отправки (#tgsupport-web
review M1): скоупит будущий резолв `find_thread_by_topic_message` к ТЕКУЩЕЙ
support-группе если группу когда-нибудь сменят/пересоздадут, Telegram
message_id стартует заново с 1 в новом чате и может совпасть с числом из
старого без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188)."""
row = (
db.execute(
text(
"""
INSERT INTO web_support_messages
(thread_id, direction, text_body, topic_message_id,
support_chat_id, operator_tg_id, created_at)
VALUES
(CAST(:thread_id AS bigint), 'in', :text_body,
CAST(:topic_message_id AS bigint),
CAST(:support_chat_id AS bigint), NULL, NOW())
RETURNING id, direction, text_body, operator_tg_id, created_at
"""
),
{
"thread_id": thread_id,
"text_body": text_body,
"topic_message_id": topic_message_id,
"support_chat_id": support_chat_id,
},
)
.mappings()
.one()
)
return dict(row)
def find_thread_by_topic_message(
db: Session, topic_message_id: int, support_chat_id: int
) -> int | None:
"""Резолвит id зеркала (сообщения оператора reply_to) в thread_id — только
среди direction='in' записей, зеркало-конвенция как в tg_support_messages (186).
Скоупим к ТЕКУЩЕМУ `support_chat_id` (#tgsupport-web review M1): строка со
ЧУЖИМ (не NULL и не текущим) support_chat_id это исторический артефакт
ротации support-группы, НЕ валидный маршрут для сегодняшнего реплая. NULL
(легаси-строки до этой колонки, если такие есть) лениентно матчатся как
"любой чат", т.к. до введения этого поля был ровно один действующий чат."""
row = db.execute(
text(
"""
SELECT thread_id
FROM web_support_messages
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
AND direction = 'in'
AND (support_chat_id = CAST(:support_chat_id AS bigint) OR support_chat_id IS NULL)
ORDER BY created_at DESC
LIMIT 1
"""
),
{"topic_message_id": topic_message_id, "support_chat_id": support_chat_id},
).fetchone()
return int(row[0]) if row is not None else None
def record_outbound(
db: Session, *, thread_id: int, text_body: str, operator_tg_id: int | None
) -> int | None:
"""Записывает ответ оператора (реплай на веб-зеркало) как direction='out'.
`topic_message_id` всегда NULL маршрутизирующий ключ живёт только на
inbound-записи (см. tg_support_messages-конвенцию, 186)."""
row = db.execute(
text(
"""
INSERT INTO web_support_messages
(thread_id, direction, text_body, topic_message_id, operator_tg_id, created_at)
VALUES
(CAST(:thread_id AS bigint), 'out', :text_body, NULL,
CAST(:operator_tg_id AS bigint), NOW())
RETURNING id
"""
),
{
"thread_id": thread_id,
"text_body": text_body,
"operator_tg_id": operator_tg_id,
},
).fetchone()
return int(row[0]) if row is not None else None
def list_messages(
db: Session, *, thread_id: int, since_id: int, limit: int = 200
) -> list[dict[str, Any]]:
"""Сообщения треда с id > since_id, по возрастанию (обычный polling с фронта).
`limit` (#tgsupport-web review M5): без него КАЖДОЕ монтирование виджета на
старом треде отдавало бы ВЕСЬ лог переписки. Берём последние `limit` (ORDER
BY id DESC + LIMIT), потом разворачиваем в хронологический порядок так
incremental-polling (`since_id` = последний известный id, обычно единицы
новых строк) не страдает, а первый холодный load длинного треда получает
последние `limit`, а не самые старые."""
rows = (
db.execute(
text(
"""
SELECT id, direction, text_body, operator_tg_id, created_at
FROM web_support_messages
WHERE thread_id = CAST(:thread_id AS bigint)
AND id > CAST(:since_id AS bigint)
ORDER BY id DESC
LIMIT CAST(:limit AS integer)
"""
),
{"thread_id": thread_id, "since_id": since_id, "limit": limit},
)
.mappings()
.all()
)
return [dict(r) for r in reversed(rows)]
def count_unread(db: Session, *, thread_id: int) -> int:
"""Кол-во ответов оператора (direction='out'), пришедших после last_read_at."""
row = db.execute(
text(
"""
SELECT count(*)
FROM web_support_messages m
JOIN web_support_threads t ON t.id = m.thread_id
WHERE m.thread_id = CAST(:thread_id AS bigint)
AND m.direction = 'out'
AND m.created_at > t.last_read_at
"""
),
{"thread_id": thread_id},
).scalar()
return int(row or 0)
def mark_read(db: Session, *, thread_id: int) -> None:
db.execute(
text(
"UPDATE web_support_threads SET last_read_at = NOW() "
"WHERE id = CAST(:thread_id AS bigint)"
),
{"thread_id": thread_id},
)

View file

@ -0,0 +1,122 @@
-- 187_web_support_chat.sql
-- Web-чат поддержки (сайт МЕРА) поверх УЖЕ существующего Telegram support-моста
-- (data/sql/186_tg_support.sql, app/services/tgbot/bridge.py). Источник обращения
-- меняется (сайт вместо Telegram-лички клиента), маршрутизация ответа оператора
-- (реплай на зеркало в топике супергруппы) остаётся ТОЙ ЖЕ — оператор ничего
-- нового не учит.
--
-- ПОЧЕМУ ОТДЕЛЬНЫЕ ТАБЛИЦЫ, А НЕ "tg_support_* + channel"
-- (взвешено явно, per code review requirement):
--
-- Вариант А (отклонён) — добавить channel text ('telegram'|'web') в
-- tg_support_users/tg_support_messages:
-- - tg_support_users.chat_id bigint PRIMARY KEY — это Telegram private
-- chat id клиента. У веб-пользователя сайта ЕГО НЕТ (клиент никогда не
-- писал боту в личку) — пришлось бы либо (а) городить синтетический
-- chat_id для веб-юзера (напр. отрицательный hash от username) — это
-- вводит ВТОРУЮ систему идентификации внутри одной PK-колонки,
-- семантика которой документирована как "Telegram chat id" (186:54),
-- либо (б) делать chat_id NULLABLE и городить ещё одну колонку
-- username NULLABLE рядом — таблица с двумя взаимоисключающими
-- identity-схемами и кучей CHECK-ограничений вида
-- "chat_id XOR username NOT NULL".
-- - Доставка обратно ТОЖЕ разная: Telegram-путь шлёт copyMessage в личку
-- клиента, веб-путь просто пишет строку в БД (personal chat не
-- существует) — код в bridge.py и так ветвится по каналу, общая
-- таблица не убирает эту ветку, только добавляет NULL-поля.
-- - Риск регрессии: tg_support_* уже покрыты test_bridge.py (14+
-- кейсов) и работают в проде (PR #2526) — трогать рабочую, протестированную
-- схему ради ещё не запущенной фичи повышает blast radius без выгоды.
--
-- Вариант Б (выбран) — новые web_support_threads/web_support_messages:
-- - Идентификатор клиента — username (X-Authenticated-User, сайт закрыт
-- Caddy basic_auth, публичного доступа нет — см. app/main.py rbac_guard)
-- — чистый, не smoke-и-зеркала не переиспользующий Telegram identity.
-- - topic_message_id-маршрутизация (ключевой механизм моста) СОХРАНЕНА
-- 1-в-1 по конвенции 186: partial UNIQUE на topic_message_id,
-- заполняется только для direction='in', NULL для direction='out'.
-- - bridge.py меняется МИНИМАЛЬНО: _handle_group_reply получает одну
-- дополнительную ветку (резолвит tg-путь И web-путь, потом
-- existing orphan-warning) — существующий Telegram-путь не трогается.
--
-- ⚠️ CROSS-TABLE КОЛЛИЗИЯ topic_message_id (review M1, зафиксировано ДО
-- первого прод-использования, пока обе таблицы пусты):
-- Инвариант "topic_message_id уникален между tg_support_messages и
-- web_support_messages" на самом деле звучит так: "уникален, ПОКА
-- TELEGRAM_SUPPORT_CHAT_ID не менялся". Это OPS-инвариант, а НЕ DB-инвариант —
-- ничем не гарантирован. Смена/пересоздание support-группы обнуляет счётчик
-- Telegram message_id в новом чате; когда он дорастёт до диапазона,
-- использованного старым чатом, — number, ранее занятый ОДНОЙ таблицей,
-- может совпасть с числом, занятым ДРУГОЙ. Внутри одной таблицы partial
-- UNIQUE превращает такую коллизию в громкий отказ INSERT — это ок. МЕЖДУ
-- таблицами constraint'а нет: без доп. скоупинга бот молча доставил бы ответ
-- оператора НЕ ТОМУ клиенту (152-ФЗ-инцидент, происходящий тихо).
-- Фикс: колонка `support_chat_id` на web_support_messages (симметричная
-- колонка для УЖЕ применённой tg_support_messages — отдельная миграция
-- 188_tg_support_chat_id_scope.sql, эту таблицу нельзя трогать здесь, она
-- уже применена/задеплоена как часть 186). Резолв (bridge.py) матчит ПАРУ
-- (support_chat_id, topic_message_id), а не topic_message_id в одиночку;
-- NULL (легаси-строки без этой колонки) — лениентный wildcard, т.к. на тот
-- момент действовал ровно один чат.
--
-- ЧТО:
-- - web_support_threads — один тред на username (сайт = 1 логин = 1 линия
-- переписки с поддержкой, без под-тредов).
-- - web_support_messages — лог переписки, direction='in' (от юзера) |
-- 'out' (ответ оператора, реплай из bridge.py).
--
-- 152-ФЗ:
-- Переписка (text_body) — ПДн (может содержать любые данные, которые юзер
-- решит написать). ON DELETE CASCADE от web_support_threads делает erasure
-- ОДНОЙ операцией (DELETE FROM web_support_threads WHERE username = :u) ДЛЯ
-- КОПИИ В ЭТОЙ БД. Копия того же текста уже ушла в Telegram-топик (sendMessage
-- зеркало) и живёт ТАМ вне зоны действия этого DELETE — реальное "право на
-- забвение" по всей цепочке требует ОТДЕЛЬНОЙ процедуры (удаление сообщений в
-- Telegram-супергруппе через Bot API deleteMessage, вне scope этой миграции).
-- Не ссылаться на этот комментарий как на доказательство полного erasure.
--
-- IDEMPOTENCY: CREATE TABLE/INDEX IF NOT EXISTS — безопасный re-run.
-- Зависимости: нет (новые standalone таблицы, никакие существующие
-- tg_support_*/иные таблицы не трогаются — см. 188 для ALTER на tg_support_messages).
BEGIN;
CREATE TABLE IF NOT EXISTS web_support_threads (
id bigserial PRIMARY KEY,
username text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
last_read_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE web_support_threads IS '152-ФЗ: одна строка на username (сайт МЕРА, X-Authenticated-User) — единый тред переписки с поддержкой через веб-чат. Удаление клиента — DELETE FROM web_support_threads WHERE username=...; ON DELETE CASCADE в web_support_messages подчищает переписку одной операцией.';
COMMENT ON COLUMN web_support_threads.username IS 'X-Authenticated-User (Caddy basic_auth) — сайт закрыт, анонимов нет, см. app/main.py rbac_guard.';
COMMENT ON COLUMN web_support_threads.last_seen_at IS 'Обновляется при отправке юзером нового сообщения (send-активность, НЕ на чтение истории).';
COMMENT ON COLUMN web_support_threads.last_read_at IS 'Отметка "прочитано до" (POST /api/v1/trade-in/support/read) — используется для счётчика непрочитанного (GET /support/unread).';
CREATE TABLE IF NOT EXISTS web_support_messages (
id bigserial PRIMARY KEY,
thread_id bigint NOT NULL REFERENCES web_support_threads (id) ON DELETE CASCADE,
direction text NOT NULL CHECK (direction IN ('in', 'out')),
text_body text NOT NULL CHECK (char_length(btrim(text_body)) > 0),
topic_message_id bigint,
support_chat_id bigint,
operator_tg_id bigint,
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE web_support_messages IS '152-ФЗ: полный лог веб-чата поддержки (ПДн — содержимое сообщений; удаление подчищает КОПИЮ В ЭТОЙ БД, не Telegram-топик — см. блок 152-ФЗ выше). Каскадно удаляется вместе с web_support_threads по username.';
COMMENT ON COLUMN web_support_messages.direction IS '''in'' — сообщение от пользователя сайта; ''out'' — ответ оператора (доставлен через реплай в Telegram-топике, см. bridge.py _handle_group_reply).';
COMMENT ON COLUMN web_support_messages.text_body IS 'Текст сообщения. Веб-чат — текстовый MVP, медиа не поддерживается (в отличие от tg_support_messages.kind).';
COMMENT ON COLUMN web_support_messages.topic_message_id IS 'id зеркала (sendMessage) в support-топике — ключ маршрутизации ответа, только для direction=''in''. NULL для ''out'' (конвенция 186: маршрутизирующий ключ живёт исключительно на inbound-записи).';
COMMENT ON COLUMN web_support_messages.support_chat_id IS 'TELEGRAM_SUPPORT_CHAT_ID в момент отправки — скоупит резолв topic_message_id к ТЕКУЩЕЙ support-группе (review M1: без этого поля ротация группы даёт тихую cross-table коллизию, см. блок выше). NULL — лениентный wildcard для строк без этого поля.';
COMMENT ON COLUMN web_support_messages.operator_tg_id IS 'Telegram user id оператора, ответившего в топике; заполняется только для direction=''out''.';
CREATE UNIQUE INDEX IF NOT EXISTS web_support_messages_topic_message_id_uq
ON web_support_messages (topic_message_id)
WHERE topic_message_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS web_support_messages_thread_id_created_at_idx
ON web_support_messages (thread_id, created_at DESC);
COMMIT;

View file

@ -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;

View file

@ -5,6 +5,15 @@ Coverage (per task spec + review follow-up):
сообщение за throttle-окно, без шапки на повторное В окне, и снова с шапкой сообщение за throttle-окно, без шапки на повторное В окне, и снова с шапкой
после истечения окна #6 review) после истечения окна #6 review)
- реплай оператора user (доставка ответа клиенту + запись direction='out') - реплай оператора user (доставка ответа клиенту + запись direction='out')
- реплай оператора веб-чат (#tgsupport-web): зеркало веб-сообщения резолвится
в web-тред (скоуп по (topic_message_id, support_chat_id) review M1), ответ
пишется direction='out' БЕЗ Telegram-доставки; медиа-реплай (в т.ч. фото С
ПОДПИСЬЮ) на веб-зеркало отказ ЦЕЛИКОМ + уведомление оператору в топике
(review M2, никакой частичной доставки одной подписи); зеркало от ЧУЖОГО/
устаревшего support_chat_id не матчится (ротация группы); NULL
support_chat_id (легаси) wildcard-матч; совпадение ОБЕИХ сторон
одновременно (tg И web) громкий отказ (logger.error), а не молчаливый
выбор tg-пути (152-ФЗ misroute risk)
- реплай не на зеркало (или не реплай вообще) тихий игнор, не мусорим в чат; - реплай не на зеркало (или не реплай вообще) тихий игнор, не мусорим в чат;
реплай на СООБЩЕНИЕ БОТА без записи в БД WARNING про осиротевшее зеркало реплай на СООБЩЕНИЕ БОТА без записи в БД WARNING про осиротевшее зеркало
(#4 review) (#4 review)
@ -65,6 +74,12 @@ class FakeBridgeStorage:
self._next_id = 1 self._next_id = 1
self.clock_s: float = 0.0 self.clock_s: float = 0.0
self.fail_next_record_message = False self.fail_next_record_message = False
# #tgsupport-web: web_support_messages-эквивалент, topic_message_id ->
# (thread_id, support_chat_id) — второй элемент моделирует колонку
# web_support_messages.support_chat_id (review M1); None = легаси wildcard.
# + журнал outbound-записей, записанных через реплай оператора.
self.web_topic_to_thread: dict[int, tuple[int, int | None]] = {}
self.web_out_messages: list[dict[str, Any]] = []
def get_offset(self) -> int: def get_offset(self) -> int:
return self._offset return self._offset
@ -112,6 +127,7 @@ class FakeBridgeStorage:
kind: str, kind: str,
text_body: str | None, text_body: str | None,
operator_tg_id: int | None, operator_tg_id: int | None,
support_chat_id: int | None = None,
) -> int: ) -> int:
if self.fail_next_record_message: if self.fail_next_record_message:
self.fail_next_record_message = False self.fail_next_record_message = False
@ -128,20 +144,51 @@ class FakeBridgeStorage:
"kind": kind, "kind": kind,
"text_body": text_body, "text_body": text_body,
"operator_tg_id": operator_tg_id, "operator_tg_id": operator_tg_id,
"support_chat_id": support_chat_id,
"recorded_at_s": self.clock_s, "recorded_at_s": self.clock_s,
} }
) )
return row_id return row_id
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None: def find_chat_by_topic_message(self, topic_message_id: int, support_chat_id: int) -> int | None:
"""support_chat_id-скоуп (review M1): запись со ЧУЖИМ (не None, не текущим)
support_chat_id не матчится None (легаси/дефолт) матчится всегда."""
for m in reversed(self.messages): for m in reversed(self.messages):
if m["direction"] == "in" and m["topic_message_id"] == topic_message_id: if m["direction"] != "in" or m["topic_message_id"] != topic_message_id:
return m["chat_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 return None
def mark_blocked(self, chat_id: int) -> None: def mark_blocked(self, chat_id: int) -> None:
self.blocked.add(chat_id) self.blocked.add(chat_id)
# ── #tgsupport-web ────────────────────────────────────────────────────
def find_web_thread_by_topic_message(
self, topic_message_id: int, support_chat_id: int
) -> int | None:
"""Тот же support_chat_id-скоуп, что и `find_chat_by_topic_message` (review M1)."""
entry = self.web_topic_to_thread.get(topic_message_id)
if entry is None:
return None
thread_id, entry_chat_id = entry
if entry_chat_id is not None and entry_chat_id != support_chat_id:
return None
return thread_id
def record_web_out_message(
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
) -> None:
self.web_out_messages.append(
{
"thread_id": thread_id,
"text_body": text_body,
"operator_tg_id": operator_tg_id,
}
)
# ── httpx mocking helpers (mirrors tests/services/test_dadata.py) ─────────── # ── httpx mocking helpers (mirrors tests/services/test_dadata.py) ───────────
_REAL_ASYNC_CLIENT = httpx.AsyncClient _REAL_ASYNC_CLIENT = httpx.AsyncClient
@ -528,6 +575,167 @@ async def test_group_reply_403_marks_blocked_and_notifies_topic() -> None:
assert storage.get_offset() == 23 assert storage.get_offset() == 23
# ── B') реплай оператора → веб-чат (#tgsupport-web) ──────────────────────────
async def test_group_reply_to_web_mirror_records_outbound_web_message() -> None:
"""Реплай на зеркало веб-сообщения (не найдено в tg_support_messages, найдено
среди web_support_messages) записывается в веб-тред, БЕЗ Telegram-доставки
(у веб-клиента нет личного чата с ботом)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
# topic_message_id=300 -> thread_id=42, под ТЕКУЩИМ support_chat_id.
storage.web_topic_to_thread[300] = (42, SUPPORT_CHAT_ID)
update = {
"update_id": 60,
"message": _group_reply_message(reply_to_message_id=300, text="Ответ по веб-чату"),
}
await bridge.process_update(update, client, storage)
# Никакого Telegram API вызова — веб-клиент не имеет личного чата с ботом.
assert calls == []
assert len(storage.web_out_messages) == 1
rec = storage.web_out_messages[0]
assert rec["thread_id"] == 42
assert rec["text_body"] == "Ответ по веб-чату"
assert rec["operator_tg_id"] == 777
# tg-путь тоже не тронут — ни одной записи в tg_support_messages.
assert storage.messages == []
assert storage.get_offset() == 60
async def test_group_reply_to_web_mirror_with_null_support_chat_id_matches_current_chat() -> None:
"""Легаси-строка (до 187/188, support_chat_id=None) — лениентный wildcard,
матчится под ЛЮБЫМ текущим support_chat_id (review M1)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
storage.web_topic_to_thread[305] = (46, None)
update = {
"update_id": 63,
"message": _group_reply_message(reply_to_message_id=305, text="Ответ по легаси-зеркалу"),
}
await bridge.process_update(update, client, storage)
assert len(storage.web_out_messages) == 1
assert storage.web_out_messages[0]["thread_id"] == 46
async def test_group_reply_to_web_mirror_from_stale_support_chat_is_not_matched() -> None:
"""#tgsupport-web review M1: зеркало, записанное под ДРУГИМ (не текущим,
не None) support_chat_id исторический артефакт ротации группы, НЕ валидный
маршрут сегодня. Не матчится падает в orphan-check (не-bot реплай тихий
игнор, никакой доставки в чужой/устаревший тред)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
stale_chat_id = -999999999999
storage.web_topic_to_thread[306] = (47, stale_chat_id)
update = {
"update_id": 64,
"message": _group_reply_message(reply_to_message_id=306),
}
await bridge.process_update(update, client, storage)
assert calls == []
assert storage.web_out_messages == [] # НЕ доставлено в устаревший тред
async def test_group_reply_to_web_mirror_without_text_is_refused_with_operator_notice(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Веб-чат — текстовый MVP: реплай медиа-типом (нет text/caption) на веб-зеркало
не может быть доставлен WARNING в лог И явное уведомление оператору в топике
(review M2: раньше был тихий игнор, оператор был уверен что ответил)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
storage.web_topic_to_thread[301] = (43, SUPPORT_CHAT_ID)
message = _group_reply_message(reply_to_message_id=301, message_id=201)
del message["text"] # медиа-реплай без текста/caption
message["voice"] = {"file_id": "x"}
update = {"update_id": 61, "message": message}
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
await bridge.process_update(update, client, storage)
assert storage.web_out_messages == []
assert "не текст" in caplog.text
assert storage.get_offset() == 61
methods = [m for m, _ in calls]
assert methods == ["sendMessage"]
notice_call = calls[0][1]
assert notice_call["chat_id"] == SUPPORT_CHAT_ID
assert notice_call["text"] == bridge._WEB_UNSUPPORTED_MEDIA_REPLY_TEXT
assert notice_call["reply_to_message_id"] == 201
async def test_group_reply_to_web_mirror_with_photo_and_caption_is_refused_not_partial() -> None:
"""Фото С ПОДПИСЬЮ на веб-зеркало — НЕ доставляем только подпись молча
(клиент решил бы, что подпись весь ответ): отказ целиком, как и без caption
(review M2)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
storage.web_topic_to_thread[302] = (44, SUPPORT_CHAT_ID)
message = _group_reply_message(reply_to_message_id=302, message_id=202)
del message["text"]
message["photo"] = [{"file_id": "x"}]
message["caption"] = "Смотрите скриншот"
update = {"update_id": 65, "message": message}
await bridge.process_update(update, client, storage)
assert storage.web_out_messages == [] # подпись НЕ доставлена как "весь ответ"
methods = [m for m, _ in calls]
assert methods == ["sendMessage"]
assert calls[0][1]["text"] == bridge._WEB_UNSUPPORTED_MEDIA_REPLY_TEXT
async def test_group_reply_refuses_delivery_when_both_tg_and_web_match(
caplog: pytest.LogCaptureFixture,
) -> None:
"""#tgsupport-web review M1: если topic_message_id одновременно резолвится и в
tg_support_messages, И в web_support_messages (под ОДНИМ и тем же
support_chat_id целостность нарушена) ГРОМКИЙ отказ (logger.error), НИКАКОЙ
доставки ни в Telegram-личку, ни в веб-тред. Раньше tg-путь выбирался молча
misroute постороннему Telegram-клиенту (152-ФЗ risk)."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({"copyMessage": {"message_id": 999}}, calls)
storage = FakeBridgeStorage()
storage.record_message(
chat_id=555,
direction="in",
tg_message_id=1,
topic_message_id=400,
kind="text",
text_body="вопрос клиента",
operator_tg_id=None,
support_chat_id=SUPPORT_CHAT_ID,
)
storage.web_topic_to_thread[400] = (99, SUPPORT_CHAT_ID)
update = {
"update_id": 62,
"message": _group_reply_message(reply_to_message_id=400),
}
with caplog.at_level(logging.ERROR, logger="app.services.tgbot.bridge"):
await bridge.process_update(update, client, storage)
assert calls == [] # ничего не доставлено НИ В ОДНУ сторону
assert storage.web_out_messages == []
assert len(storage.messages) == 1 # только исходное 'in', никакого 'out'
assert "ОДНОВРЕМЕННО" in caplog.text
assert storage.get_offset() == 62
# ── C) дедуп ────────────────────────────────────────────────────────────────── # ── C) дедуп ──────────────────────────────────────────────────────────────────

View file

@ -19,7 +19,7 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.core import config from app.core import config
from app.core.ratelimit import RateLimitMiddleware from app.core.ratelimit import RateLimitMiddleware, SlidingWindowLimiter
@pytest.fixture @pytest.fixture
@ -112,3 +112,58 @@ def test_anonymous_key_from_rightmost_xff(client):
client.get("/api/v1/ping", headers={"X-Forwarded-For": "8.8.8.8, 2.2.2.2"}).status_code client.get("/api/v1/ping", headers={"X-Forwarded-For": "8.8.8.8, 2.2.2.2"}).status_code
== 200 == 200
) )
# ── SlidingWindowLimiter (#tgsupport-web) — reusable narrower per-feature limit ──
def test_sliding_window_limiter_retry_after_does_not_record():
"""`.retry_after()` — non-destructive peek: не расходует бюджет сам по себе
(review L3 вызывающая сторона решает, когда `.record()`)."""
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
assert limiter.retry_after("alice") is None
# Повторный peek БЕЗ record() — бюджет не тронут, всё ещё None.
assert limiter.retry_after("alice") is None
def test_sliding_window_limiter_record_then_retry_after_blocks():
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
assert limiter.retry_after("alice") is None
limiter.record("alice")
retry_after = limiter.retry_after("alice")
assert retry_after is not None
assert retry_after > 0
def test_sliding_window_limiter_check_combines_peek_and_record():
"""`.check()` — комбинированный (обратной совместимости ради) peek+record."""
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
assert limiter.check("alice") is None # первый — проходит и сразу учитывается
assert limiter.check("alice") is not None # второй — уже за лимитом
def test_sliding_window_limiter_per_key_isolation():
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
limiter.record("alice")
assert limiter.retry_after("alice") is not None
assert limiter.retry_after("bob") is None # свой ключ — не задет alice
def test_sliding_window_limiter_prunes_empty_buckets_past_threshold():
"""review L2: пустые корзины чистятся при накоплении >10000 ключей (тот же
паттерн, что `RateLimitMiddleware.dispatch`) не бесконечная утечка памяти.
`retry_after()`-peek на новом ключе создаёт ПУСТУЮ корзину как побочный эффект
`defaultdict` (даже если вызывающая сторона так и не позвала `.record()`
напр. запрос отклонён по другой причине выше по стеку). Это главный источник
"мусорных" пустых корзин, которые cleanup обязан подбирать.
"""
limiter = SlidingWindowLimiter(limit=1000, window_s=60.0)
for i in range(10001):
limiter.retry_after(f"user-{i}")
assert len(limiter._hits) == 10001
# Следующий record() создаёт СВОЮ (непустую) корзину и заодно подчищает
# все чужие пустые — тот же порог (>10000), что и RateLimitMiddleware.
limiter.record("trigger-cleanup")
assert len(limiter._hits) < 10001

View file

@ -0,0 +1,536 @@
"""Offline-тесты веб-чата поддержки (#tgsupport-web) —
POST/GET /api/v1/trade-in/support/{messages,unread,read}.
Storage-слой (`app.services.tgbot.web_support_storage`) мокается целиком (как
mock'ается DB в test_trade_in_lead.py) — эти тесты проверяют РОУТЕР: изоляцию
тредов (нет параметра, которым можно адресовать чужой тред), валидацию
входа, поведение при несконфигурированном боте, rate-limit и то, что реплай
парсится в `bridge.py` в web-ветку (см. tests/services/tgbot/test_bridge.py
маршрутизация реплая тестируется ТАМ; здесь только HTTP-контракт отправки/
чтения).
NEVER touches real DB / real Telegram API.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from typing import Any, ClassVar
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.v1 import support as support_module
from app.core.db import get_db
from app.core.ratelimit import SlidingWindowLimiter
from app.services.tgbot.client import TelegramApiError
@pytest.fixture(autouse=True)
def _bot_configured(monkeypatch: pytest.MonkeyPatch) -> None:
"""По умолчанию бот считается сконфигурированным — отдельные тесты переопределяют."""
monkeypatch.setattr(support_module.settings, "telegram_bot_token", "fake-token")
monkeypatch.setattr(support_module.settings, "telegram_support_chat_id", -100123456789)
monkeypatch.setattr(support_module.settings, "telegram_support_topic_id", 42)
@pytest.fixture(autouse=True)
def _fresh_rate_limiter(monkeypatch: pytest.MonkeyPatch) -> None:
"""Модульный `_send_limiter` иначе накапливает состояние МЕЖДУ тестами (один
процесс pytest) свежий лимитер на каждый тест, щедрый дефолт (rate-limit
тестируется отдельно на СВОЁМ, явно узком экземпляре)."""
monkeypatch.setattr(
support_module, "_send_limiter", SlidingWindowLimiter(limit=1000, window_s=60.0)
)
class _FakeTelegramClient:
"""Подменяет `TelegramClient` внутри `support` модуля — никакого httpx/сети."""
calls: ClassVar[list[dict[str, Any]]] = []
_response: ClassVar[dict[str, Any] | Exception] = {"message_id": 555}
def __init__(self, _token: str) -> None:
pass
async def send_message(self, **kwargs: Any) -> dict[str, Any]:
_FakeTelegramClient.calls.append(kwargs)
if isinstance(_FakeTelegramClient._response, Exception):
raise _FakeTelegramClient._response
return _FakeTelegramClient._response
@pytest.fixture(autouse=True)
def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any:
_FakeTelegramClient.calls = []
_FakeTelegramClient._response = {"message_id": 555}
monkeypatch.setattr(support_module, "TelegramClient", _FakeTelegramClient)
return _FakeTelegramClient
@pytest.fixture
def db() -> MagicMock:
return MagicMock()
@pytest.fixture
def client(db: MagicMock) -> TestClient:
app = FastAPI()
app.include_router(support_module.router, prefix="/api/v1/trade-in")
def fake_db() -> Any:
yield db
app.dependency_overrides[get_db] = fake_db
return TestClient(app)
def _auth(username: str = "alice") -> dict[str, str]:
return {"x-authenticated-user": username}
# ── auth guard ────────────────────────────────────────────────────────────────
def test_send_message_without_auth_header_401(client: TestClient) -> None:
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"})
assert r.status_code == 401
def test_list_messages_without_auth_header_401(client: TestClient) -> None:
r = client.get("/api/v1/trade-in/support/messages")
assert r.status_code == 401
def test_whitespace_only_auth_header_401(client: TestClient) -> None:
"""review L4: заголовок из одних пробелов после `.strip()` пуст — не должен
считаться валидным auth (не проваливается тихо в "" как username)."""
r = client.get("/api/v1/trade-in/support/messages", headers={"x-authenticated-user": " "})
assert r.status_code == 401
def test_auth_header_with_surrounding_whitespace_is_stripped(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""review L4: лишний пробел от прокси не должен заводить ВТОРОЙ тред для, по
сути, того же пользователя username резолвится в тред по .strip()-нутому
значению."""
seen_usernames = []
def fake_find_thread_id(db, username):
seen_usernames.append(username)
return None
monkeypatch.setattr(support_module.storage, "find_thread_id", fake_find_thread_id)
r = client.get(
"/api/v1/trade-in/support/messages", headers={"x-authenticated-user": " alice "}
)
assert r.status_code == 200
assert seen_usernames == ["alice"]
# ── validation ────────────────────────────────────────────────────────────────
def test_send_message_blank_text_422(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
called = []
monkeypatch.setattr(
support_module.storage, "get_or_create_thread", lambda *a, **kw: called.append(1) or 1
)
r = client.post("/api/v1/trade-in/support/messages", json={"text": " "}, headers=_auth())
assert r.status_code == 422, r.text
assert called == [] # ничего не персистится на невалидном вводе
def test_send_message_empty_text_422(client: TestClient) -> None:
r = client.post("/api/v1/trade-in/support/messages", json={"text": ""}, headers=_auth())
assert r.status_code == 422
def test_send_message_too_long_422(client: TestClient) -> None:
too_long = "a" * (support_module.MAX_MESSAGE_LENGTH + 1)
r = client.post("/api/v1/trade-in/support/messages", json={"text": too_long}, headers=_auth())
assert r.status_code == 422
def test_send_message_at_max_length_ok(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda *a, **kw: 1)
monkeypatch.setattr(
support_module.storage,
"record_inbound",
lambda *a, **kw: {
"id": 1,
"direction": "in",
"text_body": kw["text_body"],
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
},
)
at_limit = "a" * support_module.MAX_MESSAGE_LENGTH
r = client.post("/api/v1/trade-in/support/messages", json={"text": at_limit}, headers=_auth())
assert r.status_code == 200, r.text
# ── bot not configured ──────────────────────────────────────────────────────
def test_send_message_bot_token_empty_returns_503_not_500(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(support_module.settings, "telegram_bot_token", "")
thread_created = []
monkeypatch.setattr(
support_module.storage, "get_or_create_thread", lambda *a, **kw: thread_created.append(1)
)
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
assert r.status_code == 503
assert r.status_code != 500
assert thread_created == [] # ничего не создаём/не пишем, если зеркалировать некуда
def test_send_message_support_chat_id_unset_returns_503(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(support_module.settings, "telegram_support_chat_id", 0)
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
assert r.status_code == 503
# ── happy path + mirror content ──────────────────────────────────────────────
def test_send_message_happy_path_mirrors_with_website_marker(
client: TestClient, db: MagicMock, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
thread_calls: list[str] = []
def fake_get_or_create_thread(db, username):
thread_calls.append(username)
return 7
monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake_get_or_create_thread)
recorded = {}
def fake_record_inbound(db, *, thread_id, text_body, topic_message_id, support_chat_id):
recorded.update(
thread_id=thread_id,
text_body=text_body,
topic_message_id=topic_message_id,
support_chat_id=support_chat_id,
)
return {
"id": 100,
"direction": "in",
"text_body": text_body,
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
}
monkeypatch.setattr(support_module.storage, "record_inbound", fake_record_inbound)
r = client.post(
"/api/v1/trade-in/support/messages",
json={"text": "У меня вопрос про trade-in"},
headers=_auth("kopylov"),
)
assert r.status_code == 200, r.text
body = r.json()
assert body["id"] == 100
assert body["direction"] == "in"
# Зеркало помечено "С САЙТА" + username — оператор не путает с TG-клиентом.
assert len(_fake_telegram_client.calls) == 1
mirror_call = _fake_telegram_client.calls[0]
assert "С САЙТА" in mirror_call["text"]
assert "kopylov" in mirror_call["text"]
assert "У меня вопрос про trade-in" in mirror_call["text"]
assert mirror_call["chat_id"] == support_module.settings.telegram_support_chat_id
assert mirror_call["message_thread_id"] == 42
# review H1: интерактивный узкий бюджет ретраев/timeout, не воркерный дефолт.
assert mirror_call["max_retries"] == support_module._INTERACTIVE_SEND_MAX_RETRIES
assert mirror_call["timeout"] == support_module._INTERACTIVE_SEND_TIMEOUT_S
assert recorded["thread_id"] == 7
assert recorded["topic_message_id"] == 555 # из FakeTelegramClient.send_message result
assert recorded["support_chat_id"] == support_module.settings.telegram_support_chat_id
assert thread_calls == ["kopylov"]
assert db.commit.called
def test_send_message_telegram_failure_returns_502_and_does_not_persist(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
_fake_telegram_client._response = TelegramApiError("sendMessage", 400, "chat not found")
thread_created = []
monkeypatch.setattr(
support_module.storage,
"get_or_create_thread",
lambda db, username: thread_created.append(1),
)
record_called = []
monkeypatch.setattr(
support_module.storage,
"record_inbound",
lambda *a, **kw: record_called.append(1),
)
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
assert r.status_code == 502
assert record_called == [] # неотправленное сообщение не персистится
# review H1: thread создаётся ПОСЛЕ успешной отправки — на неудаче до БД не доходит вообще.
assert thread_created == []
# ── rate limit ────────────────────────────────────────────────────────────────
def test_send_message_rate_limited_429(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
)
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 1)
monkeypatch.setattr(
support_module.storage,
"record_inbound",
lambda *a, **kw: {
"id": 1,
"direction": "in",
"text_body": kw["text_body"],
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
},
)
first = client.post("/api/v1/trade-in/support/messages", json={"text": "one"}, headers=_auth())
assert first.status_code == 200, first.text
second = client.post("/api/v1/trade-in/support/messages", json={"text": "two"}, headers=_auth())
assert second.status_code == 429
assert "Retry-After" in second.headers
def test_send_message_failed_attempts_do_not_consume_rate_limit(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
"""review L3: неудачная отправка НЕ должна расходовать rate-limit бюджет —
иначе клиент, которому не повезло с транзиентной Telegram-ошибкой, терял бы
попытки, не доставив НИ ОДНОГО сообщения."""
monkeypatch.setattr(
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
)
_fake_telegram_client._response = TelegramApiError("sendMessage", 500, "boom")
for _ in range(3):
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
assert r.status_code == 502
# "Телеграм" снова работает — бюджет (лимит=1) всё ещё цел, ни одна неудачная
# попытка выше его не тронула.
_fake_telegram_client._response = {"message_id": 555}
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 1)
monkeypatch.setattr(
support_module.storage,
"record_inbound",
lambda *a, **kw: {
"id": 1,
"direction": "in",
"text_body": kw["text_body"],
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
},
)
ok = client.post("/api/v1/trade-in/support/messages", json={"text": "ok"}, headers=_auth())
assert ok.status_code == 200, ok.text
def test_send_message_rate_limit_is_per_user(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
)
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 1)
monkeypatch.setattr(
support_module.storage,
"record_inbound",
lambda *a, **kw: {
"id": 1,
"direction": "in",
"text_body": kw["text_body"],
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
},
)
alice = client.post(
"/api/v1/trade-in/support/messages", json={"text": "one"}, headers=_auth("alice")
)
assert alice.status_code == 200
bob = client.post(
"/api/v1/trade-in/support/messages", json={"text": "one"}, headers=_auth("bob")
)
assert bob.status_code == 200 # свой ключ лимита — не затронут alice-лимитом
# ── thread isolation (own thread only, no thread_id param exists) ───────────
def test_list_messages_resolves_by_own_username_ignores_foreign_params(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Никакого способа адресовать чужой тред: даже если запрос несёт посторонний
`thread_id`/`username` в query эндпоинт их не читает, резолвит ИСКЛЮЧИТЕЛЬНО
по X-Authenticated-User."""
seen_usernames = []
def fake_find_thread_id(db, username):
seen_usernames.append(username)
return None
monkeypatch.setattr(support_module.storage, "find_thread_id", fake_find_thread_id)
r = client.get(
"/api/v1/trade-in/support/messages",
params={"thread_id": 999, "username": "someone-else", "since": 0},
headers=_auth("alice"),
)
assert r.status_code == 200
assert r.json() == []
assert seen_usernames == ["alice"] # username из заголовка — параметры query проигнорированы
def test_list_messages_returns_thread_scoped_rows(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
monkeypatch.setattr(
support_module.storage,
"list_messages",
lambda db, *, thread_id, since_id, limit: [
{
"id": 1,
"direction": "in",
"text_body": "hi",
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
}
]
if thread_id == 7
else [],
)
r = client.get("/api/v1/trade-in/support/messages", params={"since": 0}, headers=_auth("alice"))
assert r.status_code == 200
body = r.json()
assert len(body) == 1
assert body[0]["text_body"] == "hi"
def test_list_messages_passes_bounded_limit_to_storage(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""review M5: без LIMIT каждое монтирование виджета отдавало бы весь тред."""
seen_limits = []
def fake_list_messages(db, *, thread_id, since_id, limit):
seen_limits.append(limit)
return []
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
monkeypatch.setattr(support_module.storage, "list_messages", fake_list_messages)
r = client.get("/api/v1/trade-in/support/messages", headers=_auth())
assert r.status_code == 200
assert seen_limits == [support_module._LIST_MESSAGES_LIMIT]
def test_two_users_get_independent_threads(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
thread_by_user = {"alice": 1, "bob": 2}
monkeypatch.setattr(
support_module.storage, "find_thread_id", lambda db, username: thread_by_user.get(username)
)
monkeypatch.setattr(
support_module.storage,
"list_messages",
lambda db, *, thread_id, since_id, limit: [
{
"id": 1,
"direction": "in",
"text_body": f"secret-of-thread-{thread_id}",
"operator_tg_id": None,
"created_at": "2026-07-26T00:00:00+00:00",
}
],
)
alice_resp = client.get("/api/v1/trade-in/support/messages", headers=_auth("alice")).json()
bob_resp = client.get("/api/v1/trade-in/support/messages", headers=_auth("bob")).json()
assert alice_resp[0]["text_body"] == "secret-of-thread-1"
assert bob_resp[0]["text_body"] == "secret-of-thread-2"
assert alice_resp != bob_resp
# ── unread / read ─────────────────────────────────────────────────────────────
def test_unread_no_thread_returns_zero_without_querying_count(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: None)
count_called = []
monkeypatch.setattr(
support_module.storage, "count_unread", lambda *a, **kw: count_called.append(1)
)
r = client.get("/api/v1/trade-in/support/unread", headers=_auth())
assert r.status_code == 200
assert r.json() == {"unread": 0}
assert count_called == []
def test_unread_delegates_to_storage(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
monkeypatch.setattr(support_module.storage, "count_unread", lambda db, *, thread_id: 3)
r = client.get("/api/v1/trade-in/support/unread", headers=_auth())
assert r.status_code == 200
assert r.json() == {"unread": 3}
def test_mark_read_noop_when_no_thread(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: None)
mark_called = []
monkeypatch.setattr(support_module.storage, "mark_read", lambda *a, **kw: mark_called.append(1))
r = client.post("/api/v1/trade-in/support/read", headers=_auth())
assert r.status_code == 200
assert r.json() == {"status": "ok"}
assert mark_called == []
def test_mark_read_calls_storage_when_thread_exists(
client: TestClient, db: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
mark_called = []
monkeypatch.setattr(
support_module.storage, "mark_read", lambda db, *, thread_id: mark_called.append(thread_id)
)
r = client.post("/api/v1/trade-in/support/read", headers=_auth())
assert r.status_code == 200
assert mark_called == [7]
assert db.commit.called

View file

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { IBM_Plex_Mono, Manrope } from "next/font/google"; import { IBM_Plex_Mono, Manrope } from "next/font/google";
import { SupportButton } from "@/components/trade-in/v2/SupportButton"; import { SupportButton } from "@/components/trade-in/v2/SupportButton";
import { SupportChatProvider } from "@/components/trade-in/v2/SupportChatContext";
import { pageBg } from "@/components/trade-in/v2/tokens"; import { pageBg } from "@/components/trade-in/v2/tokens";
// Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled // Manrope — primary sans typeface of the МЕРА HUD. next/font is bundled
@ -41,11 +42,17 @@ export default function TradeInV2Layout({
alignItems: "flex-start", alignItems: "flex-start",
}} }}
> >
{children} {/* SupportChatProvider wraps both {children} (TopNav's "Помощь" item
{/* Mounted here (not the global app/layout.tsx) that layout also lives deep inside it, in page.tsx) and <SupportButton /> (mounted
covers the admin `/scrapers/**` area and `/sale-share`, unrelated here as a sibling, portaled to document.body) they share one
products without the МЕРА brand that don't need a support link. */} open/closed chat state via context, see SupportChatContext.tsx. */}
<SupportButton /> <SupportChatProvider>
{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. */}
<SupportButton />
</SupportChatProvider>
</div> </div>
); );
} }

View file

@ -1,39 +1,39 @@
"use client"; "use client";
// SupportButton — floating link to the МЕРА Telegram support bot. // SupportButton — floating trigger for the on-site МЕРА support chat.
// //
// WHY: before this, there was no way to reach support from the site. // WHY (original v1, before this window): before the plain-link version of
// `LeadForm` (LeadForm.tsx) only mounts once `hasEstimate` is true // this button existed, there was no way to reach support from the site at
// all. `LeadForm` (LeadForm.tsx) only mounts once `hasEstimate` is true
// (app/v2/page.tsx:1064), so a visitor who hasn't finished (or can't finish) // (app/v2/page.tsx:1064), so a visitor who hasn't finished (or can't finish)
// an estimate has no contact path at all. The only other link anywhere in the // an estimate had no contact path. This was upgraded from a plain external
// UI is NoAccessScreen.tsx:84, and that only renders on the "access expired" // Telegram link to an in-page chat window (SupportChatPanel.tsx) so a visitor
// screen. The support bot itself is already live on the backend (support // answers/receives replies without leaving the site — the message still
// bridge, PR #2526) — this button is purely the missing entry point on the // mirrors into the same Telegram support topic and the operator still
// site side. Plain external link, no chat widget / no state / no API calls. // answers from Telegram exactly as before (backend `feat/tradein-web-chat-
// backend`, itself layered on the Telegram support bridge, backend PR #2526).
// The Telegram link survives as a small fallback INSIDE the chat panel
// (SupportChatPanel.tsx) — deliberate, since there's no push notification for
// the web chat: a visitor who leaves the page won't see a reply that arrives
// an hour later, so the Telegram escape hatch stays available.
// //
// Portal to document.body (pattern mirrors MapPicker.tsx:104-108 and // Portal to document.body (pattern mirrors MapPicker.tsx:104-108 and
// BuildingListingsDrawer.tsx): `/v2` renders its HUD inside a fixed // BuildingListingsDrawer.tsx): `/v2` renders its HUD inside a fixed
// 1536×1024 "artboard" that gets `transform: scale(...)` on narrow viewports // 1536×1024 "artboard" that gets `transform: scale(...)` on narrow viewports
// (app/v2/page.tsx:860-871). A `position: fixed` descendant of a // (app/v2/page.tsx:860-871). A `position: fixed` descendant of a
// `transform`-ed ancestor is positioned relative to that ancestor, not the // `transform`-ed ancestor is positioned relative to that ancestor, not the
// viewport — so without a portal the button would drift/scale with the HUD // viewport — so without a portal the button (and the chat panel it opens)
// instead of staying pinned to the real viewport corner. // would drift/scale with the HUD instead of staying pinned to the real
// viewport corner.
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { tokens } from "./tokens"; import { tokens } from "./tokens";
import { useSupportChat } from "./SupportChatContext";
import { SupportChatPanel } from "./SupportChatPanel";
import { useSupportUnread } from "@/lib/useSupportChat";
// Same bot as the backend support bridge (PR #2526). A plain module constant const { accent, accentDeep, onAccent, surface, font, danger } = tokens;
// rather than `NEXT_PUBLIC_*`: the URL is public and stable, and env vars are
// inlined at build time — swapping the value would need a rebuild for no
// benefit over just editing this line.
//
// Exported: this is the single source of truth for the bot URL. The «Помощь»
// item in TopNav.tsx's user menu links to the same bot and imports this
// constant rather than duplicating the string.
export const SUPPORT_BOT_URL = "https://t.me/MERAsupport_bot";
const { accent, accentDeep, onAccent, surface, font } = tokens;
const styles = ` const styles = `
.support-btn{background:linear-gradient(90deg,${accentDeep},${accent});transition:box-shadow .18s,transform .18s,background .18s;} .support-btn{background:linear-gradient(90deg,${accentDeep},${accent});transition:box-shadow .18s,transform .18s,background .18s;}
@ -51,29 +51,35 @@ export function SupportButton() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
const { open, toggleChat, closeChat } = useSupportChat();
// Unread badge only matters while the panel is closed — see
// useSupportUnread's docstring for why polling stops entirely once open.
const unreadQuery = useSupportUnread(!open);
const unread = unreadQuery.data?.unread ?? 0;
if (!mounted) return null; if (!mounted) return null;
return createPortal( return createPortal(
<> <>
<style>{styles}</style> <style>{styles}</style>
<a <button
href={SUPPORT_BOT_URL} type="button"
target="_blank" onClick={toggleChat}
rel="noopener noreferrer"
className="support-btn" className="support-btn"
aria-label="Написать в поддержку МЕРА в Telegram" aria-label={open ? "Закрыть чат поддержки МЕРА" : "Открыть чат поддержки МЕРА"}
title="Поддержка МЕРА в Telegram" aria-expanded={open}
title="Поддержка МЕРА"
style={{ style={{
position: "fixed", position: "fixed",
right: 20, right: 20,
bottom: 20, bottom: 20,
// z-index 25 — deliberately BELOW every v2 HUD overlay: SectionOverlay // z-index 25 — deliberately BELOW every v2 HUD overlay: SectionOverlay
// (29/30), LocationDrawer (40/41), TopNav (50), UserMenu (200), and // (29/30), LocationDrawer (40/41), the chat panel this button opens
// the legacy MapPicker dialog (1000). When a modal/drawer is open the // (45), TopNav (50), UserMenu (200). When a modal/drawer is open the
// button should disappear under it, not float on top and steal // button should disappear under it, not float on top and steal
// clicks/tab order. It never collides with TopNav geographically // clicks/tab order. It never collides with TopNav geographically
// (top of screen vs. bottom-right corner here), so this only matters // (top of screen vs. bottom-right corner here), so this only matters
// for the bottom-sheet-style overlays. // for the bottom-sheet-style overlays and the chat panel itself.
zIndex: 25, zIndex: 25,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@ -90,7 +96,6 @@ export function SupportButton() {
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 600,
letterSpacing: 0.4, letterSpacing: 0.4,
textDecoration: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",
cursor: "pointer", cursor: "pointer",
}} }}
@ -109,7 +114,32 @@ export function SupportButton() {
/> />
</svg> </svg>
<span>Поддержка</span> <span>Поддержка</span>
</a> {!open && unread > 0 && (
<span
aria-hidden="true"
style={{
position: "absolute",
top: -5,
right: -5,
minWidth: 18,
height: 18,
padding: "0 4px",
borderRadius: 999,
background: danger,
color: onAccent,
fontSize: 10,
fontWeight: 700,
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 0 0 2px #fff",
}}
>
{unread > 9 ? "9+" : unread}
</span>
)}
</button>
<SupportChatPanel open={open} onClose={closeChat} />
</>, </>,
document.body, document.body,
); );

View file

@ -0,0 +1,62 @@
"use client";
// Shared open/closed state for the on-site support chat window. It needs a
// single source of truth reachable from two components that are NOT parent/
// child of each other in the render tree: `SupportButton` (mounted once in
// app/v2/layout.tsx, portals the floating trigger + the chat panel to
// document.body) and `TopNav`'s "Помощь" menu item (mounted deep inside
// app/v2/page.tsx, i.e. inside `{children}` of that same layout). A React
// Context provided by the layout, wrapping both `{children}` and
// `<SupportButton />`, is the plain way to bridge that without prop-drilling
// through everything page.tsx passes down to TopNav.
import { createContext, useContext, useMemo, useState } from "react";
// Same bot as the Telegram support bridge (backend PR #2526). Kept here (not
// in SupportButton.tsx) so SupportChatPanel can import it without a
// SupportButton <-> SupportChatPanel circular import (SupportButton renders
// the panel). This is still the single source of truth for the URL — the
// panel's small "написать в Telegram" fallback link imports it from here.
export const SUPPORT_BOT_URL = "https://t.me/MERAsupport_bot";
interface SupportChatContextValue {
open: boolean;
openChat: () => void;
closeChat: () => void;
toggleChat: () => void;
}
const SupportChatContext = createContext<SupportChatContextValue | null>(null);
export function SupportChatProvider({
children,
}: {
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const value = useMemo<SupportChatContextValue>(
() => ({
open,
openChat: () => setOpen(true),
closeChat: () => setOpen(false),
toggleChat: () => setOpen((o) => !o),
}),
[open],
);
return (
<SupportChatContext.Provider value={value}>
{children}
</SupportChatContext.Provider>
);
}
export function useSupportChat(): SupportChatContextValue {
const ctx = useContext(SupportChatContext);
if (!ctx) {
throw new Error(
"useSupportChat must be used within <SupportChatProvider> (app/v2/layout.tsx)",
);
}
return ctx;
}

View file

@ -0,0 +1,475 @@
"use client";
// SupportChatPanel — the actual chat window opened by SupportButton's
// floating trigger / TopNav's "Помощь" item. Messages are mirrored to the
// same Telegram support topic as before this feature (backend
// `feat/tradein-web-chat-backend`, app/api/v1/support.py — a mirror of the
// pre-existing Telegram support bridge, backend PR #2526): the operator still
// answers from Telegram exactly as before, only the client-facing surface
// moved on-site.
//
// Modal-dialog plumbing (focus trap / Escape / restore-focus-on-close)
// mirrors LocationDrawer.tsx, with one difference: initial focus goes to the
// message textarea, not the dialog shell — visitors open this panel to type,
// not to read a landing focus target.
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { tokens } from "./tokens";
import { SUPPORT_BOT_URL } from "./SupportChatContext";
import {
MAX_SUPPORT_MESSAGE_LENGTH,
describeSupportError,
useMarkSupportRead,
useSendSupportMessage,
useSupportMessages,
} from "@/lib/useSupportChat";
import type { SupportMessage } from "@/lib/useSupportChat";
interface SupportChatPanelProps {
open: boolean;
onClose: () => void;
}
const TIME_FORMAT = new Intl.DateTimeFormat("ru-RU", {
hour: "2-digit",
minute: "2-digit",
});
function formatTime(iso: string): string {
const parsed = new Date(iso);
if (Number.isNaN(parsed.getTime())) return "";
return TIME_FORMAT.format(parsed);
}
// Show the char counter once the visitor is close enough to the limit that it
// becomes actionable — no point cluttering the footer for a 20-char message.
const COUNTER_THRESHOLD = MAX_SUPPORT_MESSAGE_LENGTH - 500;
const PANEL_STYLES = `
.support-chat-send:disabled { opacity: .45; cursor: default; }
.support-chat-send:not(:disabled):hover { background: ${tokens.accentDeep}; }
.support-chat-retry:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
.support-chat-textarea:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(46,139,255,.3); }
.support-chat-close:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
`;
export function SupportChatPanel({ open, onClose }: SupportChatPanelProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const listEndRef = useRef<HTMLDivElement>(null);
const lastFocused = useRef<Element | null>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const [draft, setDraft] = useState("");
const messagesQuery = useSupportMessages(open);
const sendMessage = useSendSupportMessage();
const markRead = useMarkSupportRead();
// Mark the thread read on BOTH the closed->open and open->closed edges (the
// effect fires on mount-while-open and its cleanup fires on the reverse
// transition/unmount) — see useMarkSupportRead docstring for why both
// directions matter. Read via a ref so this doesn't need `markRead.mutate`
// in the dep array (a fresh function identity every render would otherwise
// re-fire the effect every render).
const markReadRef = useRef(markRead.mutate);
markReadRef.current = markRead.mutate;
useEffect(() => {
if (!open) return;
markReadRef.current();
return () => {
markReadRef.current();
};
}, [open]);
// Focus trap + Escape + initial focus into the textarea (req: focus goes to
// the input on open, not the dialog shell). Mirrors LocationDrawer.tsx.
useEffect(() => {
if (!open) return;
const dialog = dialogRef.current;
lastFocused.current = document.activeElement;
inputRef.current?.focus();
function getFocusable(): HTMLElement[] {
if (!dialog) return [];
const nodes = dialog.querySelectorAll<HTMLElement>(
'button, textarea, [href], input, select, [tabindex]:not([tabindex="-1"])',
);
return Array.from(nodes).filter((el) => !el.hasAttribute("disabled"));
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onCloseRef.current();
return;
}
if (e.key !== "Tab" || !dialog) return;
const focusable = getFocusable();
if (focusable.length === 0) {
e.preventDefault();
dialog.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const activeEl = document.activeElement;
const inside = dialog.contains(activeEl);
if (e.shiftKey) {
if (activeEl === first || activeEl === dialog || !inside) {
e.preventDefault();
last.focus();
}
} else if (activeEl === last || !inside) {
e.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
(lastFocused.current as HTMLElement | null)?.focus?.();
};
}, [open]);
const messages = messagesQuery.data ?? [];
// Autoscroll to the latest message on open and whenever the transcript
// grows (new poll result / own message appended after send).
useEffect(() => {
if (!open) return;
listEndRef.current?.scrollIntoView({ block: "end" });
}, [open, messages.length]);
const trimmedLength = draft.trim().length;
const overLimit = draft.length > MAX_SUPPORT_MESSAGE_LENGTH;
const canSend = trimmedLength > 0 && !overLimit && !sendMessage.isPending;
function handleSend() {
if (!canSend) return;
sendMessage.mutate(draft.trim(), {
onSuccess: () => setDraft(""),
});
}
function handleTextareaKeyDown(e: ReactKeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
return (
<>
<style>{PANEL_STYLES}</style>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Чат поддержки МЕРА"
tabIndex={-1}
// Off-screen when closed → keep it out of the a11y tree and inert
// (mirrors LocationDrawer.tsx — same axe "aria-hidden-focus" concern).
aria-hidden={!open}
inert={!open ? true : undefined}
style={{
position: "fixed",
right: 20,
bottom: 76,
width: 360,
height: 520,
maxHeight: "calc(100vh - 96px)",
// Below TopNav (50) / UserMenu (200) so it never steals their
// clicks, but above the floating trigger button (25) and the
// LocationDrawer scrim/panel (40/41) — the chat window should sit
// on top of everything except the persistent top chrome.
zIndex: 45,
transform: open ? "translateY(0)" : "translateY(10px)",
opacity: open ? 1 : 0,
pointerEvents: open ? "auto" : "none",
transition: "opacity .18s ease, transform .18s ease",
display: "flex",
flexDirection: "column",
background: tokens.surface.w98,
border: `1px solid ${tokens.line}`,
borderRadius: 14,
boxShadow: "0 20px 60px rgba(23,38,58,.28)",
overflow: "hidden",
fontFamily: tokens.font.sans,
}}
>
{/* header */}
<div
style={{
flex: "0 0 auto",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 14px",
borderBottom: `1px solid ${tokens.lineSoft}`,
background: tokens.surface.w70,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: tokens.ink2,
letterSpacing: ".2px",
}}
>
Поддержка МЕРА
</div>
<button
type="button"
className="support-chat-close"
onClick={onClose}
aria-label="Закрыть чат поддержки"
style={{
width: 28,
height: 28,
flex: "0 0 auto",
padding: 0,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: tokens.muted,
background: tokens.surface.w60,
fontFamily: "inherit",
fontSize: 14,
lineHeight: 1,
transition: "all .15s",
}}
>
</button>
</div>
{/* message list */}
<div
style={{
flex: "1 1 auto",
overflowY: "auto",
padding: "12px 14px",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{messagesQuery.isLoading ? (
<div
style={{ fontSize: 12, color: tokens.muted3, textAlign: "center", marginTop: 24 }}
>
Загрузка сообщений
</div>
) : messagesQuery.isError ? (
<div
style={{
fontSize: 12,
color: tokens.body2,
background: tokens.infoSoftBg,
border: `1px solid ${tokens.infoSoftBorder}`,
borderRadius: 8,
padding: "10px 12px",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<span>{describeSupportError(messagesQuery.error)}</span>
<button
type="button"
className="support-chat-retry"
onClick={() => messagesQuery.refetch()}
style={{
alignSelf: "flex-start",
border: `1px solid ${tokens.line}`,
borderRadius: 6,
padding: "4px 10px",
fontSize: 11,
color: tokens.body2,
background: tokens.surface.w80,
cursor: "pointer",
fontFamily: "inherit",
transition: "all .15s",
}}
>
Повторить
</button>
</div>
) : messages.length === 0 ? (
<div
style={{
fontSize: 12.5,
lineHeight: 1.6,
color: tokens.body2,
background: tokens.infoSoftBg,
border: `1px solid ${tokens.infoSoftBorder}`,
borderRadius: 8,
padding: "12px 14px",
}}
>
Здравствуйте! Опишите вопрос оператор отвечает в рабочее время,
обычно в течение часа.
</div>
) : (
messages.map((m) => <SupportMessageBubble key={m.id} message={m} />)
)}
<div ref={listEndRef} />
</div>
{/* send-error banner */}
{sendMessage.isError && (
<div
style={{
flex: "0 0 auto",
margin: "0 14px 8px",
fontSize: 11.5,
color: tokens.danger,
background: "rgba(205,104,104,.08)",
border: "1px solid rgba(205,104,104,.3)",
borderRadius: 7,
padding: "7px 10px",
}}
>
{describeSupportError(sendMessage.error)}
</div>
)}
{/* composer */}
<div
style={{
flex: "0 0 auto",
borderTop: `1px solid ${tokens.lineSoft}`,
padding: "10px 12px",
background: tokens.surface.w80,
}}
>
<textarea
ref={inputRef}
className="support-chat-textarea"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleTextareaKeyDown}
placeholder="Напишите сообщение…"
aria-label="Сообщение в поддержку"
rows={2}
style={{
width: "100%",
resize: "none",
boxSizing: "border-box",
border: `1px solid ${overLimit ? tokens.danger : tokens.line}`,
borderRadius: 8,
padding: "8px 10px",
fontFamily: "inherit",
fontSize: 12.5,
color: tokens.ink2,
background: tokens.surface.w98,
}}
/>
<div
style={{
marginTop: 8,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<a
href={SUPPORT_BOT_URL}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: 10.5,
color: tokens.muted3,
textDecoration: "underline",
}}
>
написать в Telegram
</a>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{draft.length > COUNTER_THRESHOLD && (
<span
style={{
fontSize: 10,
fontFamily: tokens.font.mono,
color: overLimit ? tokens.danger : tokens.muted3,
}}
>
{draft.length}/{MAX_SUPPORT_MESSAGE_LENGTH}
</span>
)}
<button
type="button"
className="support-chat-send"
onClick={handleSend}
disabled={!canSend}
aria-label="Отправить сообщение"
style={{
border: "none",
borderRadius: 7,
padding: "7px 16px",
fontSize: 12,
fontWeight: 600,
color: tokens.onAccent,
background: tokens.accent,
cursor: canSend ? "pointer" : "default",
fontFamily: "inherit",
transition: "background .15s",
}}
>
Отправить
</button>
</div>
</div>
</div>
</div>
</>
);
}
function SupportMessageBubble({ message }: { message: SupportMessage }) {
const isOwn = message.direction === "in";
return (
<div style={{ display: "flex", justifyContent: isOwn ? "flex-end" : "flex-start" }}>
<div
style={{
maxWidth: "78%",
background: isOwn ? tokens.accent : tokens.surface.w80,
color: isOwn ? tokens.onAccent : tokens.ink2,
border: isOwn ? "none" : `1px solid ${tokens.lineSoft}`,
borderRadius: 10,
padding: "8px 10px",
}}
>
{/* Plain text node operator replies are untrusted input, never
rendered as HTML. `pre-wrap` preserves newlines/whitespace without
needing dangerouslySetInnerHTML. */}
<div style={{ fontSize: 12.5, lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{message.text_body}
</div>
<div
style={{
marginTop: 4,
fontSize: 9,
opacity: 0.75,
textAlign: isOwn ? "right" : "left",
}}
>
{formatTime(message.created_at)}
</div>
</div>
</div>
);
}

View file

@ -12,7 +12,7 @@ import type { CSSProperties } from "react";
import { tokens } from "./tokens"; import { tokens } from "./tokens";
import { navLabels, version } from "./fixtures"; import { navLabels, version } from "./fixtures";
import { SUPPORT_BOT_URL } from "./SupportButton"; import { useSupportChat } from "./SupportChatContext";
// Real logged-in user identity, derived by the page from useMe() // Real logged-in user identity, derived by the page from useMe()
// ({username, role, brand}). Deliberately excludes `reports` — the «Мои // ({username, role, brand}). Deliberately excludes `reports` — the «Мои
@ -59,8 +59,9 @@ const menuItemStyle: CSSProperties = {
// Профиль / Настройки have no pages yet — render them dimmed and // Профиль / Настройки have no pages yet — render them dimmed and
// non-interactive (no hover class, default cursor) so they read as disabled. // non-interactive (no hover class, default cursor) so they read as disabled.
// «Помощь» used to be in this group too, but now links out to the Telegram // «Помощь» used to be in this group too, then linked out to the Telegram
// support bot (see SUPPORT_BOT_URL below) — it renders as a live item. // support bot; it now opens the on-site chat window (SupportChatPanel.tsx via
// SupportChatContext) — it renders as a live item.
const menuItemDisabledStyle: CSSProperties = { const menuItemDisabledStyle: CSSProperties = {
...menuItemStyle, ...menuItemStyle,
cursor: "default", cursor: "default",
@ -75,6 +76,7 @@ export default function TopNav({
onLogout, onLogout,
}: TopNavProps) { }: TopNavProps) {
const [userOpen, setUserOpen] = useState(false); const [userOpen, setUserOpen] = useState(false);
const { openChat } = useSupportChat();
const u = user ?? GUEST_USER; const u = user ?? GUEST_USER;
return ( return (
@ -447,19 +449,28 @@ export default function TopNav({
</div> </div>
{/* Live item (issue: two support entry points the other is {/* Live item (issue: two support entry points the other is
SupportButton.tsx's floating button). Same bot, imported SupportButton.tsx's floating button). Both open the same
constant single source of truth for the URL. Styled like chat window (SupportChatContext) no more external
the other live row above ("Мои отчёты"), not the dimmed Telegram navigation from here. Styled like the other live
Профиль/Настройки rows. */} row above ("Мои отчёты"), not the dimmed Профиль/Настройки
<a rows. */}
href={SUPPORT_BOT_URL} <button
target="_blank" type="button"
rel="noopener noreferrer"
className="tnav-menuitem" className="tnav-menuitem"
style={{ ...menuItemStyle, textDecoration: "none" }} style={{
aria-label="Написать в поддержку МЕРА в Telegram" ...menuItemStyle,
title="Написать в поддержку МЕРА в Telegram" width: "100%",
onClick={() => setUserOpen(false)} border: "none",
background: "none",
fontFamily: "inherit",
textAlign: "left",
}}
aria-label="Открыть чат поддержки МЕРА"
title="Открыть чат поддержки МЕРА"
onClick={() => {
setUserOpen(false);
openChat();
}}
> >
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true"> <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<circle <circle
@ -476,7 +487,7 @@ export default function TopNav({
/> />
</svg> </svg>
Помощь Помощь
</a> </button>
<div <div
style={{ style={{

View file

@ -0,0 +1,138 @@
"use client";
/**
* Data layer for the on-site МЕРА support chat (issue: "окно чата поддержки на
* сайте МЕРА"). Talks to `feat/tradein-web-chat-backend` (app/api/v1/support.py,
* not yet in main at the time this was written contract is stable/reviewed):
*
* POST /api/v1/trade-in/support/messages {text} -> SupportMessage
* GET /api/v1/trade-in/support/messages?since=<id> -> SupportMessage[]
* GET /api/v1/trade-in/support/unread -> {unread}
* POST /api/v1/trade-in/support/read -> {status:"ok"}
*
* Thread is resolved server-side from X-Authenticated-User (injected by Caddy
* basic_auth upstream) no thread_id is ever sent from the client.
*
* Polling model (see SupportChatPanel.tsx / SupportButton.tsx for the two call
* sites):
* - Messages: polled only while the chat panel is OPEN (`enabled` gate below)
* no point polling a transcript nobody is looking at.
* - Unread count: polled only while the panel is CLOSED it feeds the badge
* on the floating button; once open, the transcript itself is the "seen"
* signal (see markSupportRead below) and the badge doesn't render.
* - Tab-inactive pause is NOT reimplemented here `refetchInterval`'s
* `refetchIntervalInBackground` defaults to `false` in TanStack Query, so
* polling already stops while `document` is hidden/backgrounded without
* any extra visibilitychange plumbing. Only the panel-open/closed gate
* above is bespoke.
*
* Always re-fetches the FULL thread (`since=0`) rather than incrementally
* merging `since=<lastId>` pages into the query cache: at MVP support-chat
* volume (a handful to a few dozen messages per thread) the bandwidth cost of
* refetching everything every 6s is negligible, and it avoids a manual
* setQueryData-merge path that would be pure incidental complexity here.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch, HTTPError } from "@/lib/api";
const BASE = "/api/v1/trade-in/support";
// Mirrors backend `MAX_MESSAGE_LENGTH` (app/api/v1/support.py) — enforced
// client-side too so the send button disables before the round-trip 422/400.
export const MAX_SUPPORT_MESSAGE_LENGTH = 4000;
export type SupportMessageDirection = "in" | "out";
export interface SupportMessage {
id: number;
direction: SupportMessageDirection;
text_body: string;
operator_tg_id: number | null;
created_at: string;
}
export interface SupportUnread {
unread: number;
}
const SUPPORT_MESSAGES_KEY = ["trade-in", "support", "messages"] as const;
const SUPPORT_UNREAD_KEY = ["trade-in", "support", "unread"] as const;
const MESSAGES_POLL_MS = 6_000;
const UNREAD_POLL_MS = 20_000;
/**
* Polls the caller's own thread. `enabled` should be the chat-panel `open`
* flag see module docstring.
*/
export function useSupportMessages(enabled: boolean) {
return useQuery<SupportMessage[]>({
queryKey: SUPPORT_MESSAGES_KEY,
queryFn: () => apiFetch<SupportMessage[]>(`${BASE}/messages?since=0`),
enabled,
staleTime: 0,
refetchInterval: enabled ? MESSAGES_POLL_MS : false,
});
}
/**
* Feeds the unread badge on the closed floating button. `enabled` should be
* `!open` see module docstring.
*/
export function useSupportUnread(enabled: boolean) {
return useQuery<SupportUnread>({
queryKey: SUPPORT_UNREAD_KEY,
queryFn: () => apiFetch<SupportUnread>(`${BASE}/unread`),
enabled,
staleTime: 0,
refetchInterval: enabled ? UNREAD_POLL_MS : false,
});
}
export function useSendSupportMessage() {
const queryClient = useQueryClient();
return useMutation<SupportMessage, Error, string>({
mutationFn: (text) =>
apiFetch<SupportMessage>(`${BASE}/messages`, {
method: "POST",
body: JSON.stringify({ text }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
},
});
}
/**
* Marks the thread read (moves backend `last_read_at` forward). Called on the
* open->closed AND closed->open transition (see SupportChatPanel) so the
* unread badge never counts messages the visitor demonstrably already saw
* while the panel was open.
*/
export function useMarkSupportRead() {
const queryClient = useQueryClient();
return useMutation<void, Error, void>({
mutationFn: async () => {
await apiFetch<{ status: string }>(`${BASE}/read`, { method: "POST" });
},
onSuccess: () => {
queryClient.setQueryData<SupportUnread>(SUPPORT_UNREAD_KEY, { unread: 0 });
},
});
}
/**
* Human-readable error text for both the messages-query error state and the
* send-mutation error banner. Backend already returns RU human text in
* `detail` for 429/503/502 (SERVICE_UNAVAILABLE_TEXT / rate-limit message)
* `HTTPError.message` is that `detail` (see lib/api.ts). A non-HTTPError means
* `fetch` itself threw (offline / DNS / CORS) apiFetch never got a response.
*/
export function describeSupportError(error: unknown): string {
if (error instanceof HTTPError) {
return error.message;
}
return "Не удалось связаться с сервером. Проверьте подключение и повторите.";
}