feat(tradein/support): веб-чат поддержки поверх Telegram support-моста

Сайт закрыт Caddy basic_auth, тред привязывается к X-Authenticated-User (нет
анонимов). Новые web_support_threads/web_support_messages (миграция 187) —
отдельно от tg_support_* (186): у веб-клиента нет Telegram chat_id, смешение
identity-схем в одной таблице потребовало бы NULLABLE chat_id/username и XOR
CHECK-ограничений без реальной выгоды (обоснование в самой миграции).

API (app/api/v1/support.py, /api/v1/trade-in/support/*):
  POST /messages  — отправка (sendMessage-зеркало в топик, "[С САЙТА] user: ...")
  GET  /messages   — polling своего треда (?since=id)
  GET  /unread     — счётчик непрочитанного
  POST /read       — отметить прочитанным
Тред резолвится ИСКЛЮЧИТЕЛЬНО по username — нет параметра, которым можно
адресовать чужой тред (структурная защита от IDOR, не только access-check).

bridge.py: _handle_group_reply получил ветку резолва reply в web-тред (после
существующего tg-резолва, без изменения Telegram-пути) — оператор отвечает
одинаково, вне зависимости от канала клиента.

Rate-limit: новый SlidingWindowLimiter (ratelimit.py) — 12 msg/60s per user,
жёстче общего RateLimitMiddleware (общий бот-токен, флуд одного клиента иначе
бьёт по доставке всем).

Security: этот процесс (app/main.py) теперь тоже зовёт Telegram Bot API
напрямую (раньше — только изолированный tgbot_main.py) — реплицированы обе
защиты токена: httpx-INFO подавлен, include_local_variables=False +
redact_telegram_bot_token в sentry before_send.

Бот не сконфигурирован (пустой TELEGRAM_BOT_TOKEN/chat_id) → 503, не 500.
This commit is contained in:
bot-backend 2026-07-26 22:58:54 +03:00
parent d56103219a
commit 7377fb61e5
9 changed files with 1220 additions and 61 deletions

View file

@ -0,0 +1,200 @@
"""Веб-чат поддержки (#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).
"""
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) и застопорить доставку всем
# остальным (см. задачу, п.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)
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)."""
username = request.headers.get("x-authenticated-user")
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)."""
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-сообщения для копии)."""
if not _bot_configured():
# Предсказуемое поведение вместо 500 (#tgsupport-web AC): бот не настроен
# (пустой TELEGRAM_BOT_TOKEN, dev/staging) или support-топик не задан —
# мирроринг невозможен физически, ничего не пишем в БД.
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_TEXT)
retry_after = _send_limiter.check(username)
if retry_after is not None:
raise HTTPException(
status_code=429,
detail="Слишком много сообщений. Попробуйте через минуту.",
headers={"Retry-After": str(int(retry_after) + 1)},
)
thread_id = storage.get_or_create_thread(db, username)
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,
)
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
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
row = storage.record_inbound(
db,
thread_id=thread_id,
text_body=payload.text,
topic_message_id=topic_message_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])
async 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 — чужой
тред недостижим (нет параметра, которым его можно адресовать)."""
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)
return [SupportMessageOut(**r) for r in rows]
@router.get("/support/unread", response_model=UnreadOut)
async def get_support_unread(
username: Annotated[str, Depends(_require_username)],
db: Annotated[Session, Depends(get_db)],
) -> UnreadOut:
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)
async def mark_support_read(
username: Annotated[str, Depends(_require_username)],
db: Annotated[Session, Depends(get_db)],
) -> StatusOut:
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,39 @@ 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 check(self, key: str) -> float | None:
"""Регистрирует попытку под *key*. Возвращает None, если она уложилась в
лимит (и учтена), иначе сколько секунд ждать до следующей попытки."""
now = time.monotonic()
bucket = self._hits[key]
cutoff = now - self._window_s
while bucket and bucket[0] < cutoff:
bucket.popleft()
if len(bucket) >= self._limit:
return self._window_s - (now - bucket[0])
bucket.append(now)
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,20 @@
с идентификацией клиента в топик) 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 ответа в личку клиента находим chat_id по topic_message_id copyMessage ответа в личку клиента
запись (direction='out'). Реплай не на зеркало (или не реплай вообще) запись (direction='out'). Если зеркало НЕ Telegram-клиента, а веб-чата
обычная болтовня в топике, тихий игнор. Telegram 403 (клиент заблокировал (data/sql/187_web_support_chat.sql) доставка идёт НЕ в Telegram (у веб-
бота) is_blocked=true + уведомление в топике. клиента нет личного чата с ботом), а записью direction='out' в
web_support_messages (веб-фронт вычитывает её обычным polling'ом). Реплай
не на зеркало (или не реплай вообще) обычная болтовня в топике, тихий
игнор. 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 +31,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 +51,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__)
@ -107,6 +121,12 @@ class BridgeStorage(Protocol):
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) -> 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_* таблицы.
@ -274,6 +294,21 @@ class SqlBridgeStorage:
{"chat_id": chat_id}, {"chat_id": chat_id},
) )
def find_web_thread_by_topic_message(self, topic_message_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)
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:
@ -371,7 +406,8 @@ async def _handle_private_message(
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 # не реплай вообще — обычная болтовня в топике, тихий игнор
@ -381,66 +417,93 @@ async def _handle_group_reply(
return return
target_chat_id = storage.find_chat_by_topic_message(mirror_message_id) target_chat_id = storage.find_chat_by_topic_message(mirror_message_id)
if target_chat_id is None: if target_chat_id is not None:
# Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем, # Существующий Telegram-путь — НЕ ТРОНУТ, только обёрнут в explicit if
# это ожидаемый шум. НО реплай на сообщение, отправленное САМИМ БОТОМ # (раньше было `if target_chat_id is None: ...; return`, теперь после
# (is_bot=True) и при этом отсутствующее в tg_support_messages — подозрительно: # этой ветки идёт ещё веб-резолв, см. ниже).
# вероятная причина — осиротевшее зеркало (воркер упал МЕЖДУ copyMessage и message_id = message.get("message_id")
# storage.commit() в `_handle_private_message` — зеркало ушло в Telegram, а if not isinstance(message_id, int):
# запись в БД потерялась). Дискриминатор неидеальный (шапка-идентификация return
# тоже от бота, но не routing-ключ — тоже даст этот WARNING), но лучше редкий
# ложный WARNING, чем оператор молча решает, что ответ клиенту доставлен, operator = message.get("from") or {}
# хотя реплай тихо утонул (#4 review — двухфазный протокол НЕ делаем, overkill). operator_id = operator.get("id")
reply_from = reply_to.get("from") or {}
if reply_from.get("is_bot"): try:
logger.warning( delivered = await client.copy_message(
"tgbot bridge: реплай на сообщение бота (message_id=%d) не найден в " chat_id=target_chat_id,
"tg_support_messages как зеркало клиента — возможно, осиротевшее " from_chat_id=settings.telegram_support_chat_id,
"зеркало (крах между copyMessage и commit) или шапка-идентификация; " message_id=message_id,
"ответ оператора НЕ доставлен клиенту",
mirror_message_id,
) )
return 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
message_id = message.get("message_id") tg_message_id = delivered.get("message_id") if isinstance(delivered, dict) else None
if not isinstance(message_id, int): storage.record_message(
return
operator = message.get("from") or {}
operator_id = operator.get("id")
try:
delivered = await client.copy_message(
chat_id=target_chat_id, chat_id=target_chat_id,
from_chat_id=settings.telegram_support_chat_id, direction="out",
message_id=message_id, 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,
) )
except TelegramApiError as exc: return
if exc.error_code == 403:
# Клиент заблокировал бота — фиксируем и уведомляем оператора в топике. # #tgsupport-web: не найдено среди tg_support_messages — пробуем веб-чат.
storage.mark_blocked(target_chat_id) web_thread_id = storage.find_web_thread_by_topic_message(mirror_message_id)
await client.send_message( if web_thread_id is not None:
chat_id=settings.telegram_support_chat_id, text_body = message.get("text") or message.get("caption")
text=( if not text_body:
f"Не удалось доставить сообщение клиенту (chat_id={target_chat_id}) — " # Веб-чат — текстовый MVP (web_support_messages.text_body NOT NULL,
"бот заблокирован." # нет kind/file_id колонок как у tg_support_messages) — доставить
), # фото/документ/voice некуда, фронт это не отрендерит.
message_thread_id=settings.telegram_support_topic_id or None, logger.warning(
reply_to_message_id=message_id, "tgbot bridge: реплай на веб-зеркало (thread_id=%d) без текста "
"(медиа?) — веб-чат текстовый, доставка невозможна, игнор",
web_thread_id,
) )
return return
raise
tg_message_id = delivered.get("message_id") if isinstance(delivered, dict) else None operator = message.get("from") or {}
storage.record_message( operator_id = operator.get("id")
chat_id=target_chat_id, storage.record_web_out_message(
direction="out", thread_id=web_thread_id,
tg_message_id=tg_message_id, text_body=text_body,
topic_message_id=None, operator_tg_id=operator_id,
kind=_infer_kind(message), )
text_body=message.get("text") or message.get("caption"), return
operator_tg_id=operator_id,
) # Обычная болтовня в топике (реплай на чьё-то ещё сообщение) — не логируем,
# это ожидаемый шум. НО реплай на сообщение, отправленное САМИМ БОТОМ
# (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

@ -0,0 +1,184 @@
"""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
) -> dict[str, Any]:
"""Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` —
id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора."""
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), 'in', :text_body,
CAST(:topic_message_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,
},
)
.mappings()
.one()
)
return dict(row)
def find_thread_by_topic_message(db: Session, topic_message_id: int) -> int | None:
"""Резолвит id зеркала (сообщения оператора reply_to) в thread_id — только
среди direction='in' записей, зеркало-конвенция как в tg_support_messages (186)."""
row = db.execute(
text(
"""
SELECT thread_id
FROM web_support_messages
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
AND direction = 'in'
ORDER BY created_at DESC
LIMIT 1
"""
),
{"topic_message_id": topic_message_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) -> list[dict[str, Any]]:
"""Сообщения треда с id > since_id, по возрастанию (обычный polling с фронта)."""
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 ASC
"""
),
{"thread_id": thread_id, "since_id": since_id},
)
.mappings()
.all()
)
return [dict(r) for r in 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,100 @@
-- 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'.
-- Коллизий между web_support_messages.topic_message_id и
-- tg_support_messages.topic_message_id НЕ возникает: оба — Telegram
-- message_id ОДНОЙ и той же support-супергруппы, а Telegram message_id
-- в пределах одного чата монотонно возрастает и никогда не переиспользуется
-- — значит конкретное значение окажется ровно в одной из двух таблиц.
-- - bridge.py меняется МИНИМАЛЬНО: _handle_group_reply получает одну
-- дополнительную ветку (пробуем tg-резолв, потом web-резолв, потом
-- existing orphan-warning) — существующий Telegram-путь не трогается.
--
-- ЧТО:
-- - 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.
--
-- IDEMPOTENCY: CREATE TABLE/INDEX IF NOT EXISTS — безопасный re-run.
-- Зависимости: нет (новые standalone таблицы, никакие существующие
-- tg_support_*/иные таблицы не трогаются).
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,
operator_tg_id bigint,
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE web_support_messages IS '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.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

@ -176,3 +176,4 @@
169_osm_poi_ekb_local.sql 169_osm_poi_ekb_local.sql
170_scrape_schedules_seed_osm_poi_ekb_refresh.sql 170_scrape_schedules_seed_osm_poi_ekb_refresh.sql
172_trade_in_leads.sql 172_trade_in_leads.sql
187_web_support_chat.sql

View file

@ -5,6 +5,10 @@ Coverage (per task spec + review follow-up):
сообщение за throttle-окно, без шапки на повторное В окне, и снова с шапкой сообщение за throttle-окно, без шапки на повторное В окне, и снова с шапкой
после истечения окна #6 review) после истечения окна #6 review)
- реплай оператора user (доставка ответа клиенту + запись direction='out') - реплай оператора user (доставка ответа клиенту + запись direction='out')
- реплай оператора веб-чат (#tgsupport-web): зеркало веб-сообщения резолвится
в web-тред, ответ пишется direction='out' БЕЗ Telegram-доставки; медиа-реплай
на веб-зеркало (нет text/caption) тихий игнор (веб-чат текстовый MVP);
tg-резолв имеет приоритет над веб-резолвом при (искусственной) коллизии
- реплай не на зеркало (или не реплай вообще) тихий игнор, не мусорим в чат; - реплай не на зеркало (или не реплай вообще) тихий игнор, не мусорим в чат;
реплай на СООБЩЕНИЕ БОТА без записи в БД WARNING про осиротевшее зеркало реплай на СООБЩЕНИЕ БОТА без записи в БД WARNING про осиротевшее зеркало
(#4 review) (#4 review)
@ -65,6 +69,10 @@ 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) + журнал outbound-записей, записанных через реплай оператора.
self.web_topic_to_thread: dict[int, int] = {}
self.web_out_messages: list[dict[str, Any]] = []
def get_offset(self) -> int: def get_offset(self) -> int:
return self._offset return self._offset
@ -142,6 +150,21 @@ class FakeBridgeStorage:
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) -> int | None:
return self.web_topic_to_thread.get(topic_message_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 +551,94 @@ 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()
storage.web_topic_to_thread[300] = 42 # topic_message_id=300 -> thread_id=42
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_without_text_is_ignored(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Веб-чат — текстовый MVP: реплай медиа-типом (нет text/caption) на веб-зеркало
не может быть доставлен тихий (WARNING, не error) игнор, ничего не пишем."""
calls: list[tuple[str, dict[str, Any]]] = []
client = _make_client({}, calls)
storage = FakeBridgeStorage()
storage.web_topic_to_thread[301] = 43
message = _group_reply_message(reply_to_message_id=301)
del message["text"] # медиа-реплай без текста/caption
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 calls == []
assert storage.web_out_messages == []
assert "текстовый" in caplog.text
assert storage.get_offset() == 61
async def test_group_reply_prefers_tg_thread_when_both_would_match() -> None:
"""Приоритет резолва — tg СНАЧАЛА: если topic_message_id найден среди
tg_support_messages, веб-резолв даже не вызывается (существующий Telegram-путь
работает как раньше, не деградирует из-за новой ветки)."""
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,
)
# Тот же topic_message_id "случайно" тоже был бы в web-мапе — не должен
# переопределять tg-резолв (defensive, в реальности Telegram message_id не
# повторяется в пределах чата).
storage.web_topic_to_thread[400] = 99
update = {
"update_id": 62,
"message": _group_reply_message(reply_to_message_id=400),
}
await bridge.process_update(update, client, storage)
methods = [m for m, _ in calls]
assert methods == ["copyMessage"]
assert storage.web_out_messages == []
out_rec = storage.messages[-1]
assert out_rec["direction"] == "out"
assert out_rec["chat_id"] == 555
# ── C) дедуп ────────────────────────────────────────────────────────────────── # ── C) дедуп ──────────────────────────────────────────────────────────────────

View file

@ -0,0 +1,433 @@
"""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
# ── 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:
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 7)
recorded = {}
def fake_record_inbound(db, *, thread_id, text_body, topic_message_id):
recorded.update(thread_id=thread_id, text_body=text_body, topic_message_id=topic_message_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
assert recorded["thread_id"] == 7
assert recorded["topic_message_id"] == 555 # из FakeTelegramClient.send_message result
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")
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 7)
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 == [] # неотправленное сообщение не персистится
# ── 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_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: [
{
"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_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: [
{
"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