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
Клиент пишет боту в личку → воркер зеркалит сообщение через 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).
93 lines
5.4 KiB
PL/PgSQL
93 lines
5.4 KiB
PL/PgSQL
-- 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;
|