gendesign/tradein-mvp/backend/data/sql/186_tg_support.sql
bot-backend b579fa4ced
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 5m4s
feat(tradein/tgbot): Telegram support-мост @MERAsupport_bot
Клиент пишет боту в личку → воркер зеркалит сообщение через copyMessage
в топик супергруппы-форума → оператор отвечает реплаем на зеркало → бот
доставляет ответ клиенту. Полный лог переписки в Postgres.

Отдельный контейнер на long-polling, а не webhook в tradein-backend:
не нужно пробивать дырку в auth-middleware (_PUBLIC_PATHS, #2213) и
маршрут в Caddy, нулевая внешняя поверхность, падение бота не задевает API.
Без aiogram — httpx уже в зависимостях, нужны только getUpdates/copyMessage.

Маршрутизация ответа — по topic_message_id: message_id в Telegram уникален
в пределах чата сквозь все топики, а все зеркала лежат в одном support-чате,
поэтому спутать адресата нельзя. Реплай на шапку/на ответ другого оператора
не резолвится (у direction='out' topic_message_id IS NULL) → тихий игнор.

Безопасность (найдено ревью, воспроизведено эмпирически):
- токен Telegram живёт в PATH URL, поэтому sanitize_url его не режет;
  утекал в GlitchTip через locals стек-фреймов (include_local_variables
  по умолчанию True) и через span data HttpxIntegration. Закрыто
  include_local_variables=False + regex-редактор в before_send (обе формы:
  /bot<id>:<secret> и голая <id>:<secret>), поверх существующего PII-scrub.
- httpx-логгер печатает полный URL на INFO → боевой токен уходил бы в
  docker logs каждые 30с. Приглушён до WARNING.

Надёжность:
- kill-switch при пустом токене — idle-блокировка, не exit(0): при
  restart: unless-stopped выход с любым кодом даёт рестарт-луп.
  unless-stopped выбран сознательно — только он гарантирует автозапуск
  после ребута VPS.
- stop_grace_period: 120s — дефолтные 10с убивали бы контейнер раньше,
  чем докрутится long-poll (30с) и отработает drain (100с).
- сбой SQL теперь ловится отдельно и делает rollback перед сдвигом offset:
  иначе сессия в failed-transaction не давала сохранить offset, апдейт
  переигрывался и зеркалился в топик по кругу.

152-ФЗ: переписка — ПДн, ON DELETE CASCADE по chat_id, удаление клиента
одним DELETE. Ретенция — follow-up.

Бот не включается автоматически: TELEGRAM_* задаются в runtime-env на VPS,
без них воркер штатно висит в idle. Порядок — в DEPLOY.md.

Тесты: 51 passed (маршрутизация обоих направлений, дедуп, 403→is_blocked,
throttle-окно шапки, redaction токена во всех формах event).
2026-07-16 16:58:53 +03:00

93 lines
5.4 KiB
PL/PgSQL
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.

-- 186_tg_support.sql
-- Telegram support bridge: @MERAsupport_bot mirrors client DMs into a
-- support-group topic; an operator replies in-thread; the bot relays the
-- reply back to the client's private chat.
--
-- WHY:
-- No durable state existed for this flow. Two things are required to make
-- it work reliably:
-- 1. A mapping from "message mirrored into the support topic" back to
-- "which client chat_id it came from" — this is how an operator's
-- reply (a Telegram reply-to a topic message) gets routed to the
-- right client. `topic_message_id` on tg_support_messages is that
-- routing key.
-- 2. A durable long-polling offset (`tg_support_state`) so a worker
-- restart does not replay already-processed Telegram updates.
--
-- WHAT:
-- - tg_support_users — one row per client Telegram private chat
-- (chat_id is the Telegram chat id, stable per
-- client, used directly as PK — no surrogate key
-- needed).
-- - tg_support_messages — full conversation log, both directions.
-- - tg_support_state — singleton key/value store for worker offsets
-- (e.g. key='last_update_id').
--
-- 152-FZ:
-- tg_support_users / tg_support_messages hold personal data (Telegram
-- username/name + free-text conversation content). ON DELETE CASCADE from
-- tg_support_users -> tg_support_messages makes client erasure a single
-- `DELETE FROM tg_support_users WHERE chat_id = :chat_id` statement, no
-- separate cleanup pass needed.
--
-- IDEMPOTENCY / SAFETY:
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS throughout —
-- safe re-run. Purely additive: no existing table/view/column touched.
--
-- Dependencies: none (new standalone tables). Auto-applied on deploy via
-- _schema_migrations tracking (tradein-mvp/backend/data/sql convention).
BEGIN;
CREATE TABLE IF NOT EXISTS tg_support_users (
chat_id bigint PRIMARY KEY,
username text,
first_name text,
last_name text,
language_code text,
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
is_blocked boolean NOT NULL DEFAULT false
);
COMMENT ON TABLE tg_support_users IS '152-ФЗ: ПДн клиентов Telegram-поддержки (@MERAsupport_bot). Удаление клиента — DELETE FROM tg_support_users WHERE chat_id=...; ON DELETE CASCADE в tg_support_messages подчищает переписку одной операцией.';
COMMENT ON COLUMN tg_support_users.chat_id IS 'Telegram private chat id клиента (стабильный, используется как PK напрямую).';
COMMENT ON COLUMN tg_support_users.is_blocked IS 'true, если клиент заблокировал бота (Telegram 403 на отправку) — бот перестаёт пытаться слать сообщения.';
CREATE TABLE IF NOT EXISTS tg_support_messages (
id bigserial PRIMARY KEY,
chat_id bigint NOT NULL REFERENCES tg_support_users (chat_id) ON DELETE CASCADE,
direction text NOT NULL CHECK (direction IN ('in', 'out')),
tg_message_id bigint,
topic_message_id bigint,
kind text NOT NULL,
text_body text,
operator_tg_id bigint,
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE tg_support_messages IS '152-ФЗ: полный лог переписки Telegram-поддержки (ПДн, содержимое сообщений). Каскадно удаляется вместе с tg_support_users по chat_id.';
COMMENT ON COLUMN tg_support_messages.direction IS '''in'' — сообщение от клиента боту; ''out'' — ответ бота/оператора клиенту.';
COMMENT ON COLUMN tg_support_messages.tg_message_id IS 'id сообщения в личном чате с клиентом (Telegram message_id в chat_id).';
COMMENT ON COLUMN tg_support_messages.topic_message_id IS 'id зеркала сообщения в support-топике группы — ключ маршрутизации: реплай оператора на это сообщение адресуется данному chat_id.';
COMMENT ON COLUMN tg_support_messages.kind IS 'text | photo | document | video | voice | other.';
COMMENT ON COLUMN tg_support_messages.text_body IS 'Текст сообщения или caption медиа; NULL для медиа без подписи.';
COMMENT ON COLUMN tg_support_messages.operator_tg_id IS 'Telegram user id оператора, ответившего в топике; заполняется только для direction=''out''.';
CREATE UNIQUE INDEX IF NOT EXISTS tg_support_messages_topic_message_id_uq
ON tg_support_messages (topic_message_id)
WHERE topic_message_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS tg_support_messages_chat_id_created_at_idx
ON tg_support_messages (chat_id, created_at DESC);
CREATE TABLE IF NOT EXISTS tg_support_state (
key text PRIMARY KEY,
value text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE tg_support_state IS 'Singleton key/value store для состояния Telegram-поддержки (например last_update_id для long-polling), переживает рестарт воркера.';
COMMENT ON COLUMN tg_support_state.key IS 'e.g. ''last_update_id''.';
COMMIT;