gendesign/tradein-mvp/backend/app/api/v1/support.py
bot-backend d708f15019
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 4m50s
fix(tradein/support): один повтор терял каждое одиннадцатое сообщение в поддержку
Замер прода 01.09.2026 из контейнера бота: канал до api.telegram.org рвётся
всплесками, доля отказов на попытку 15-38% (пять проб: 3/8, 15/40, 5/20, 3/20,
1/25), в логе long-polling'а 353 ConnectTimeout за сутки. Транспорт ни при чём —
httpx и сырой сокет отваливаются одинаково (25% против 35% в чередующемся
замере), и прокси не помогает, а мешает: через SCRAPER_PROXY_URL 0 из 20.

Ручка веб-поддержки ходила с max_retries=1, то есть двумя попытками. При 30%
отказов на попытку до пользователя доходило ~9% отказов — каждое одиннадцатое
сообщение возвращало 502 «сервис недоступен».

Два других числа из того же замера задают конструкцию. Успешный запрос отвечает
за 0.13с (максимум из 25 проб — 0.18с), а неудачный НИКОГДА не отваливается
быстро: все отказы упираются в таймаут целиком (10.02с при timeout=10.0). Значит
десятисекундный таймаут не покупал ничего, кроме цены за неудачу, — снижен до 5с,
это ~28-кратный запас к измеренному максимуму. И экспоненциальная пауза 2→4→8с
здесь бессмысленна: отказ — неустановленное соединение, а не троттлинг, пережидать
нечего; она лишь добавляла 14с к ожиданию.

Правка: бюджет ручки — 3 повтора, таймаут 5с, потолок паузы 1с. Худший случай
4 попытки × 5с + 3 паузы × 1с = 23с и требует четырёх отказов подряд; типичный
случай не меняется (0.13с). Расчётная потеря падает с ~9% до ~0.8%.

В TelegramClient добавлен необязательный max_backoff. Воркерная политика НЕ
меняется: без явного потолка откат прежний экспоненциальный до 30с, а retry_after
из 429 уважается целиком — эту границу держит отдельный тест, потому что первая
версия правки её сломала (капала 60с до 30с и для воркера тоже). Потолок на
retry_after применяется только когда его передали явно: интерактивному пути
нельзя ждать Telegram-овские 30-60с, за ним стоит открытый запрос от браузера.

Тесты: 8 новых (потолок на network/429/5xx, неизменность воркерного пути,
арифметика «max_retries=N → N+1 попыток», границы бюджета ручки).
2026-09-01 09:53:21 +03:00

517 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Веб-чат поддержки (#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. Бонус: неудачная
отправка больше не создаёт тред.
Анонимная ветка (`/support/anon/*`, инцидент 2026-07-31)
-------------------------------------------------------
Ровно те же 4 действия, но БЕЗ авторизации — доступны с экрана входа. Причина:
после cutover'а на свою авторизацию (#2558) единственным каналом в поддержку был
чат ЗА логином, а самая частая причина писать в поддержку — как раз «не могу
войти». 2026-07-31 «Практика» весь день билась в форму (5 login_failed, 0
успешных) и достучаться из продукта не могла ничем.
Идентичность анонима — opaque-токен в httpOnly-куке (`_ANON_COOKIE_NAME`),
тред живёт в тех же `web_support_threads` под ключом `anon:<token>`. Двоеточие
делает коллизию с реальным логином структурно невозможной: `tradein_users`
допускает только `^[A-Za-z0-9._-]{3,64}$` (CHECK из миграции 193 + Pydantic),
двоеточия там быть не может — аноним НИКОГДА не попадёт в чужой тред и не
«станет» существующим юзером.
Изоляция тредов та же, что у авторизованной ветки, и по той же причине:
thread_id не принимается снаружи ни в каком виде, тред резолвится
ИСКЛЮЧИТЕЛЬНО из куки. Кука здесь — bearer-токен своего треда, поэтому
httpOnly+Secure+SameSite=Lax (как session-cookie) и `token_urlsafe(18)`
(144 бита) вместо чего-то угадываемого.
В Telegram-топик уходит НЕ сам токен, а `anon-<6 hex от sha256(токен)>`
(`_anon_display_id`): оператору нужен стабильный ярлык треда, а не bearer —
зеркало топика читают люди и пересылают дальше.
"""
from __future__ import annotations
import hashlib
import logging
import re
import secrets
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
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, _client_ip
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с — легальный суммарный бюджет минуты). Узкий бюджет здесь: интерактивный
# клиент должен получить ответ (даже если это ошибка) за секунды, а не висеть до
# исчерпания воркерных ретраев.
#
# Числа подобраны по замеру прода 01.09.2026 (#tgsupport-retry), а не на глаз.
# Что измерено на самом хосте, из контейнера бота:
# - канал до api.telegram.org рвётся постоянно: 353 ConnectTimeout за сутки в
# логе long-polling'а; доля отказов на попытку 15-38% всплесками (пять проб:
# 3/8, 15/40, 5/20, 3/20, 1/25). Транспорт ни при чём — httpx и сырой сокет
# отваливаются одинаково (25% против 35% в чередующемся замере);
# - успешный запрос отвечает за 0.13с, максимум из 25 проб — 0.18с;
# - неудачный НИКОГДА не отваливается быстро: все отказы упираются в таймаут
# целиком (10.02с при timeout=10.0), быстрых — ноль;
# - через `SCRAPER_PROXY_URL` не легчает, а хуже: 0 из 20. Прокси не решение.
#
# Отсюда три следствия. Таймаут 10с был чистой платой за неудачу (успех не занимает
# и половины секунды) — снижен до 5с, это ~28-кратный запас к измеренному максимуму.
# Одного повтора мало: при 30% отказов на попытку до пользователя доходило ~9%
# отказов, то есть каждое одиннадцатое сообщение терялось с 502; три повтора уводят
# это к ~0.8%. Экспоненциальная пауза 2→4→8с осмысленна против троттлинга, но здесь
# отказ — неустановленное соединение, паузе нечего пережидать, поэтому потолок 1с:
# он же не даёт `retry_after` из 429 (для группы штатные 30-60с) умножиться на число
# попыток. Худший случай: 4 попытки × 5с + 3 паузы × 1с = 23с, и он требует четырёх
# отказов подряд. Типичный случай не меняется — 0.13с.
_INTERACTIVE_SEND_TIMEOUT_S = 5.0
_INTERACTIVE_SEND_MAX_RETRIES = 3
_INTERACTIVE_SEND_MAX_BACKOFF_S = 1.0
# #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,
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
)
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()
# ---------------------------------------------------------------------------
# Анонимная ветка — поддержка без входа (см. блок в докстринге модуля)
# ---------------------------------------------------------------------------
_ANON_COOKIE_NAME = "tradein_support_anon"
# 30 дней: тред должен пережить «напишу вечером — отвечут утром», но не жить вечно.
_ANON_COOKIE_MAX_AGE_S = 30 * 24 * 3600
# Двоеточие → структурная невозможность коллизии с реальным логином (докстринг).
_ANON_THREAD_PREFIX = "anon:"
# Форма того, что МЫ выдаём (`token_urlsafe(18)` → 24 символа из [A-Za-z0-9_-]).
# Кука клиент-контролируема: без этой проверки в ключ треда (а значит в SQL-параметр
# и в лог) уехала бы произвольная строка из браузера. Не матчится — считаем куку
# отсутствующей и выдаём новую, а не пытаемся «починить» присланное.
_ANON_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}\Z")
# Публичная ручка записи в общий Telegram-топик — поверхность для спама, которой у
# авторизованной ветки нет. Два независимых бюджета:
# 1) per-token (`_send_limiter`, 12/мин — тот же объект, ключи не пересекаются:
# анонимные начинаются с "anon:", что невозможно для username);
# 2) per-IP — именно он ловит обход ротацией куки (сбросил куку → новый токен →
# бюджет (1) снова пуст). Окно широкое и щедрое для живого диалога: реальный
# сценарий — «не могу войти, помогите», несколько сообщений подряд.
_ANON_IP_RATE_LIMIT = 10
_ANON_IP_RATE_WINDOW_S = 600.0
_anon_ip_limiter = SlidingWindowLimiter(limit=_ANON_IP_RATE_LIMIT, window_s=_ANON_IP_RATE_WINDOW_S)
def _anon_display_id(token: str) -> str:
"""Стабильный НЕсекретный ярлык треда для оператора — см. докстринг модуля.
sha256, а не префикс токена: префикс — это часть bearer'а, а зеркало уходит
в Telegram-топик, который читают люди и пересылают дальше.
"""
return f"anon-{hashlib.sha256(token.encode('utf-8')).hexdigest()[:6]}"
def _read_anon_token(request: Request) -> str | None:
"""Токен из куки, если он валидной формы; иначе None (кука считается отсутствующей)."""
raw = request.cookies.get(_ANON_COOKIE_NAME)
if raw is None or not _ANON_TOKEN_RE.match(raw):
return None
return raw
def _anon_thread_key(token: str) -> str:
return f"{_ANON_THREAD_PREFIX}{token}"
def _set_anon_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=_ANON_COOKIE_NAME,
value=token,
max_age=_ANON_COOKIE_MAX_AGE_S,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
@router.post("/support/anon/messages", response_model=SupportMessageOut)
async def send_anon_support_message(
payload: SupportMessageInput,
request: Request,
response: Response,
db: Annotated[Session, Depends(get_db)],
) -> SupportMessageOut:
"""Сообщение в поддержку БЕЗ входа. Порядок операций — как в авторизованной
ветке (H1 в докстринге модуля): БД трогаем только после успешного sendMessage.
Кука выставляется тоже только на успехе — иначе первая же неудачная попытка
(бот не настроен / Telegram лёг) закрепляла бы за посетителем пустой тред.
"""
if not _bot_configured():
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_TEXT)
token = _read_anon_token(request)
is_new_token = token is None
if token is None:
token = secrets.token_urlsafe(18)
thread_key = _anon_thread_key(token)
ip = _client_ip(request)
# Оба бюджета — non-destructive peek (review L3): неудачная отправка не
# должна стоить посетителю попытки. `.record()` только на успех, ниже.
for retry_after in (_send_limiter.retry_after(thread_key), _anon_ip_limiter.retry_after(ip)):
if retry_after is not None:
raise HTTPException(
status_code=429,
detail="Слишком много сообщений. Попробуйте позже.",
headers={"Retry-After": str(int(retry_after) + 1)},
)
display_id = _anon_display_id(token)
client = TelegramClient(settings.telegram_bot_token)
try:
mirrored = await client.send_message(
chat_id=settings.telegram_support_chat_id,
text=_format_anon_mirror_text(display_id, payload.text),
message_thread_id=settings.telegram_support_topic_id or None,
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
max_backoff=_INTERACTIVE_SEND_MAX_BACKOFF_S,
)
except TelegramApiError:
# Ни текст сообщения (ПДн), ни токен (bearer треда) в лог не попадают.
logger.exception(
"web support (anon): не удалось отправить зеркало в топик (%s)", display_id
)
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
_send_limiter.record(thread_key)
_anon_ip_limiter.record(ip)
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
if topic_message_id is None:
logger.warning(
"web support (anon): Telegram sendMessage не вернул message_id (%s) — "
"ответ оператора на это сообщение не будет смаршрутизирован",
display_id,
)
thread_id = storage.get_or_create_thread(db, thread_key)
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()
if is_new_token:
_set_anon_cookie(response, token)
logger.info("web support (anon): message sent %s thread_id=%d", display_id, thread_id)
return SupportMessageOut(**row)
def _format_anon_mirror_text(display_id: str, message_text: str) -> str:
"""Помечает зеркало как пришедшее с сайта ОТ НЕЗАЛОГИНЕННОГО посетителя.
Оператору это ключевой контекст: у такого обращения нет аккаунта, по которому
можно посмотреть историю, и самая вероятная причина написать — как раз
невозможность войти (инцидент 2026-07-31).
"""
return f"[С САЙТА · БЕЗ ВХОДА] {display_id}:\n{message_text}"
@router.get("/support/anon/messages", response_model=list[SupportMessageOut])
def list_anon_support_messages(
request: Request,
db: Annotated[Session, Depends(get_db)],
since: Annotated[int, Query(ge=0)] = 0,
) -> list[SupportMessageOut]:
"""Свой тред по куке. Нет куки / нет треда → пустой список, НЕ 401: виджет
поллит эту ручку и до первого сообщения, 401 там был бы ложной ошибкой.
Sync `def` (review M3) — см. `list_support_messages`.
"""
token = _read_anon_token(request)
if token is None:
return []
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
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/anon/unread", response_model=UnreadOut)
def get_anon_support_unread(
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> UnreadOut:
"""Sync `def` (review M3) — см. `list_support_messages`."""
token = _read_anon_token(request)
if token is None:
return UnreadOut(unread=0)
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
if thread_id is None:
return UnreadOut(unread=0)
return UnreadOut(unread=storage.count_unread(db, thread_id=thread_id))
@router.post("/support/anon/read", response_model=StatusOut)
def mark_anon_support_read(
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> StatusOut:
"""Sync `def` (review M3) — см. `list_support_messages`."""
token = _read_anon_token(request)
if token is None:
return StatusOut()
thread_id = storage.find_thread_id(db, _anon_thread_key(token))
if thread_id is not None:
storage.mark_read(db, thread_id=thread_id)
db.commit()
return StatusOut()