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

Merged
lekss361 merged 3 commits from feat/tradein-web-chat-backend into main 2026-07-26 20:43:48 +00:00
Owner

Summary

Задача владельца продукта: клиент должен общаться с поддержкой в окне на сайте, не уходя в Telegram, при этом под капотом всё работает как сейчас — оператор отвечает в том же топике супергруппы и ничего нового не учит.

Раньше «вход в поддержку» был просто внешней ссылкой на @MERAsupport_bot. Мост клиент↔оператор уже существовал (PR #2526), но только для тех, кто писал боту в личку.

Как работает: клиент пишет из окна → sendMessage в тот же топик с пометкой, что это с сайта и от какого пользователя → оператор отвечает реплаем как обычно → ответ доставляется в веб-тред. Маршрутизация по паре (support_chat_id, topic_message_id).

Схема (187_web_support_chat.sql): отдельные таблицы web_support_threads / web_support_messages, а не колонка channel в существующих tg_support_*. Обоснование в комментарии миграции: у веб-клиента нет Telegram chat_id, который является первичным ключом tg_support_users — пришлось бы городить синтетический id или nullable-поля с XOR-семантикой в уже боевой таблице.

Эндпоинты (/api/v1/trade-in/support/*): отправить, получить с since, счётчик непрочитанного, отметить прочитанным. Тред резолвится только по X-Authenticated-Userthread_id снаружи не принимается ни одной ручкой, чужой тред недостижим по любым параметрам.

Правки по глубокому ревью

🟠 Заморозка всего API (было бы в проде). Ручка держала незакоммиченную запись и блокировку строки на время вызова Telegram. Бэкенд работает одним процессом с одним event loop (uvicorn без --workers), сессия синхронная. Второй запрос того же пользователя (двойной клик, вторая вкладка) вставал бы на этой блокировке внутри синхронного вызова psycopg и останавливал event loop целиком — не чат, а весь tradein API, включая оценку и PDF. При лимите Telegram (штатные 30-60 с для группы, до 5 ретраев) это минуты. Причём сработало бы ровно при флуде, против которого и ставили ограничение частоты.
Исправлено: thread_id до отправки не нужен вообще — создание треда перенесено после успешной отправки, окно блокировки исчезло. Плюс интерактивный путь получил свой бюджет ретраев (10 с, 1 попытка) вместо унаследованной воркерной политики.

🟡 Тихая доставка ответа постороннему. Маршрутизация опиралась на негласное допущение «группа поддержки никогда не сменится»: topic_message_id уникален в пределах чата, но между двумя таблицами constraint'а нет. При переезде в другую группу счётчик message_id начинается заново, и совпадение дало бы молчаливую отправку ответа чужому человеку в личку — инцидент по 152-ФЗ, который никак не детектируется.
Исправлено, пока таблицы пустые: добавлена колонка support_chat_id в обе таблицы (188_tg_support_chat_id_scope.sql для уже применённой 186), матч идёт по паре. При двойном совпадении — отказ в доставке с logger.error вместо тихого выбора.

🟡 Медиа-ответ уходил в пустоту. Реплай картинкой на веб-зеркало писал WARNING в лог, оператор считал, что ответил. Фото с подписью доставляло подпись без картинки — тихая частичная доставка. Теперь отказ целиком с уведомлением в топик, по образцу уже существующей обработки 403.

Также: три ручки переведены с async def на def (синхронные запросы к БД не должны исполняться в event loop), LIMIT на выдачу треда, очистка корзин в ограничителе частоты, бюджет попыток не расходуется на неудачную отправку, .strip() на имени пользователя.

Порядок выката

⚠️ Этот PR должен быть смержен и задеплоен РАНЬШЕ фронтового (feat/tradein-web-chat-ui) — иначе окно чата откроется в 404.

Бот на проде уже настроен и работает (контейнер tradein-tgbot поднят 10 дней, токен и чат заданы в runtime-env).

Test plan

  • uv run pytest -q2658 passed, 8 skipped
  • uv run ruff check с проектным конфигом — чисто
  • Тесты на изоляцию тредов, валидацию, 503/502/429, порядок операций, отказ при двойном совпадении чатов, отказ медиа, whitespace в имени
  • Реальный Telegram и реальная миграция — только после деплоя (все тесты на заглушках)
  • Smoke после деплоя: отправить сообщение из окна, ответить реплаем в топике, убедиться, что дошло
## Summary Задача владельца продукта: клиент должен общаться с поддержкой **в окне на сайте**, не уходя в Telegram, при этом под капотом всё работает как сейчас — оператор отвечает в том же топике супергруппы и ничего нового не учит. Раньше «вход в поддержку» был просто внешней ссылкой на `@MERAsupport_bot`. Мост клиент↔оператор уже существовал (PR #2526), но только для тех, кто писал боту в личку. **Как работает:** клиент пишет из окна → `sendMessage` в тот же топик с пометкой, что это с сайта и от какого пользователя → оператор отвечает реплаем как обычно → ответ доставляется в веб-тред. Маршрутизация по паре (`support_chat_id`, `topic_message_id`). **Схема** (`187_web_support_chat.sql`): отдельные таблицы `web_support_threads` / `web_support_messages`, а не колонка `channel` в существующих `tg_support_*`. Обоснование в комментарии миграции: у веб-клиента нет Telegram `chat_id`, который является первичным ключом `tg_support_users` — пришлось бы городить синтетический id или nullable-поля с XOR-семантикой в уже боевой таблице. **Эндпоинты** (`/api/v1/trade-in/support/*`): отправить, получить с `since`, счётчик непрочитанного, отметить прочитанным. Тред резолвится **только** по `X-Authenticated-User` — `thread_id` снаружи не принимается ни одной ручкой, чужой тред недостижим по любым параметрам. ## Правки по глубокому ревью **🟠 Заморозка всего API (было бы в проде).** Ручка держала незакоммиченную запись и блокировку строки на время вызова Telegram. Бэкенд работает **одним процессом с одним event loop** (`uvicorn` без `--workers`), сессия синхронная. Второй запрос того же пользователя (двойной клик, вторая вкладка) вставал бы на этой блокировке внутри синхронного вызова psycopg и останавливал event loop целиком — не чат, а весь tradein API, включая оценку и PDF. При лимите Telegram (штатные 30-60 с для группы, до 5 ретраев) это минуты. Причём сработало бы ровно при флуде, против которого и ставили ограничение частоты. Исправлено: `thread_id` до отправки не нужен вообще — создание треда перенесено **после** успешной отправки, окно блокировки исчезло. Плюс интерактивный путь получил свой бюджет ретраев (10 с, 1 попытка) вместо унаследованной воркерной политики. **🟡 Тихая доставка ответа постороннему.** Маршрутизация опиралась на негласное допущение «группа поддержки никогда не сменится»: `topic_message_id` уникален в пределах чата, но между двумя таблицами constraint'а нет. При переезде в другую группу счётчик message_id начинается заново, и совпадение дало бы **молчаливую** отправку ответа чужому человеку в личку — инцидент по 152-ФЗ, который никак не детектируется. Исправлено, пока таблицы пустые: добавлена колонка `support_chat_id` в обе таблицы (`188_tg_support_chat_id_scope.sql` для уже применённой 186), матч идёт по паре. При двойном совпадении — отказ в доставке с `logger.error` вместо тихого выбора. **🟡 Медиа-ответ уходил в пустоту.** Реплай картинкой на веб-зеркало писал WARNING в лог, оператор считал, что ответил. Фото с подписью доставляло подпись без картинки — тихая частичная доставка. Теперь отказ целиком с уведомлением в топик, по образцу уже существующей обработки 403. Также: три ручки переведены с `async def` на `def` (синхронные запросы к БД не должны исполняться в event loop), `LIMIT` на выдачу треда, очистка корзин в ограничителе частоты, бюджет попыток не расходуется на неудачную отправку, `.strip()` на имени пользователя. ## Порядок выката ⚠️ **Этот PR должен быть смержен и задеплоен РАНЬШЕ фронтового** (`feat/tradein-web-chat-ui`) — иначе окно чата откроется в 404. Бот на проде уже настроен и работает (контейнер `tradein-tgbot` поднят 10 дней, токен и чат заданы в runtime-env). ## Test plan - [x] `uv run pytest -q` — **2658 passed**, 8 skipped - [x] `uv run ruff check` с проектным конфигом — чисто - [x] Тесты на изоляцию тредов, валидацию, 503/502/429, порядок операций, отказ при двойном совпадении чатов, отказ медиа, whitespace в имени - [ ] Реальный Telegram и реальная миграция — только после деплоя (все тесты на заглушках) - [ ] Smoke после деплоя: отправить сообщение из окна, ответить реплаем в топике, убедиться, что дошло
lekss361 added 3 commits 2026-07-26 20:35:24 +00:00
Сайт закрыт 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.
H1 (critical): reorder send_support_message — get_or_create_thread now runs
AFTER a successful Telegram send, not before. Prod runs a single uvicorn
process with no --workers on a sync SQLAlchemy engine sharing one event
loop; holding a row-lock across the Telegram call (which can legally take
minutes on 429/5xx worker-grade retries) risked freezing the entire API on
a second concurrent request from the same user. send_message now also
accepts explicit timeout/max_retries so the interactive endpoint uses a
bounded budget instead of inheriting the long-polling worker's retry policy.

M1: web_support_messages and tg_support_messages both gain a support_chat_id
column (188 migration for the already-applied tg_support_messages table;
187 edited in place since it hasn't shipped yet). bridge._handle_group_reply
now resolves BOTH the Telegram and web candidate under the CURRENT
support_chat_id and refuses delivery loudly (logger.error) if both match,
instead of silently preferring the Telegram path — closing a cross-table
misroute risk that would surface if the support group is ever recreated.

M2: a media reply to a web-mirrored message (including photos with a
caption) is now refused in full with an explicit notice back in the topic,
instead of silently delivering just the caption text or logging a WARNING
nobody sees.

M3: list_support_messages/get_support_unread/mark_support_read switched
from async def to def — their bodies are pure sync psycopg calls; running
them as async def executed blocking DB work directly on the event loop.

M5: list_messages now takes a bounded LIMIT (last N, chronological) so a
long-lived thread doesn't return its entire history on every poll.

L1-L4: warn when Telegram doesn't return a message_id (routing dead-end),
rate limit is peeked before send and only recorded on success (failed
attempts no longer burn the budget), SlidingWindowLimiter gained the same
empty-bucket cleanup as RateLimitMiddleware, and _require_username now
strips whitespace so a proxy-injected space can't fork a second thread.

L5: removed 187 from _manifest_applied.txt — the deploy pipeline tracks
applied migrations via _schema_migrations, not this file, and keeping an
unmerged migration name out of it preserves the option to rename before
merge without tripping the "can't rename applied migrations" test.
Merge remote-tracking branch 'forgejo/main' into feat/tradein-web-chat-backend
All checks were successful
CI / changes (pull_request) Successful in 17s
CI Trade-In / changes (pull_request) Successful in 18s
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 / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m12s
ed12d9e657
lekss361 merged commit ca1015bd4e into main 2026-07-26 20:43:48 +00:00
lekss361 deleted branch feat/tradein-web-chat-backend 2026-07-26 20:43:48 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2532
No description provided.