Сайт закрыт 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.
200 lines
9.3 KiB
Python
200 lines
9.3 KiB
Python
"""Веб-чат поддержки (#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()
|