gendesign/tradein-mvp/DEPLOY.md
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

12 KiB
Raw Blame History

Trade-In MVP — деплой в gendesign репо

Архитектура

lekss361/-gendesign/
├── backend/           ← gendesign Python backend (НЕ ТРОГАЕМ)
├── frontend/          ← gendesign Next.js (НЕ ТРОГАЕМ)
├── docker-compose.prod.yml  ← gendesign-стек
├── Caddyfile          ← основной Caddy → ВКЛЮЧАЕТ tradein-mvp/deploy/Caddyfile.tradein-fragment
├── .github/workflows/
│   ├── deploy.yml             ← существующий gendesign deploy
│   └── deploy-tradein.yml     ← НАШ новый, триггерится только на tradein-mvp/**
└── tradein-mvp/       ← НАША подпапка (изолированный subproject)
    ├── backend/
    ├── frontend/
    ├── deploy/
    │   ├── Caddyfile.tradein-fragment  ← импортится в основной Caddyfile
    │   └── cron-scrape.sh
    ├── docker-compose.yml         ← локальный dev
    ├── docker-compose.prod.yml    ← production overlay (использует GHCR images)
    └── .github/workflows/deploy-tradein.yml

Прод URL routes

URL Сервис
gendsgn.ru/trade-in/ tradein-frontend:3000
gendsgn.ru/trade-in/api/v1/... tradein-backend:8000

Caddy в gendesign-стеке проксирует через handle_path /trade-in/api/* (вырезает префикс) и handle /trade-in/* (Next.js basePath).

Прод docker stacks

  • docker compose -p gendesign — основной gendesign (postgres + backend + frontend + caddy + couchdb)
  • docker compose -p gendesign-tradein — наш (tradein-postgres + tradein-backend + tradein-frontend)

Оба подключены к gendesign_shared external network — туда смотрит Caddy.

Что нужно положить в основной Caddyfile gendesign

В блок gendsgn.ru { ... } ПЕРЕД универсальным handle { reverse_proxy frontend:3000 }:

import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment

Или вставить содержимое фрагмента inline.

Что нужно положить в .env.runtime (на сервере, НЕ в git)

Два файла:

  1. /opt/gendesign/tradein-mvp/.env.runtime — переменные для docker compose substitution (${TRADEIN_POSTGRES_PASSWORD} и т.п. в compose-файле). Читается shell-скриптом deploy через source .env.runtime перед compose up.
  2. /opt/gendesign/tradein-mvp/backend/.env.runtime — переменные внутри контейнера tradein-backend (читаются через env_file: в compose). Сюда попадают YANDEX_GEOCODER_API_KEY, COOKIE_ENCRYPTION_KEY — всё, что нужно scripts/backfill_house_coords.py и application code внутри контейнера.
# /opt/gendesign/tradein-mvp/.env.runtime
TRADEIN_POSTGRES_USER=tradein
TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32>
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
YANDEX_GEOCODER_API_KEY=  # пусто пока, Nominatim fallback работает

# Encryption key for Cian session cookies (pgp_sym_encrypt / Stage 9 Calculator).
# Empty = Valuation Calculator scraper disabled + /api/v1/cookies/upload returns 503.
# Generate once on VPS:
#   openssl rand -hex 32
COOKIE_ENCRYPTION_KEY=<64-char hex>
# /opt/gendesign/tradein-mvp/backend/.env.runtime — те же ключи которые
# читаются ВНУТРИ container'а (scripts/backfill_house_coords.py, app/*).
# Может быть симлинком на ../.env.runtime если переменные совпадают:
#   ln -s ../.env.runtime /opt/gendesign/tradein-mvp/backend/.env.runtime
YANDEX_GEOCODER_API_KEY=<key или пусто>
COOKIE_ENCRYPTION_KEY=<64-char hex>
GENDESIGN_FDW_PASSWORD=<password или пусто>
GLITCHTIP_DSN=<dsn или пусто>
# DaData on-demand enrichment в /estimate flow (PR Q1).
# Demo tier: 100 req/день. Пусто = enrichment disabled (graceful).
# Регистрация ключей: https://dadata.ru/api/clean/
DADATA_API_TOKEN=<token или пусто>
DADATA_API_SECRET=<secret или пусто>

# Telegram support-bot bridge (сервис tgbot, docker-compose.prod.yml).
# Long-polling воркер (app/tgbot_main.py), тот же образ что backend/scraper,
# отдельный контейнер tradein-tgbot. Пусто TELEGRAM_BOT_TOKEN = бот НЕ падает
# и НЕ рестарт-лупится — процесс стартует, уходит в idle-блокировку и просто
# висит (это норма для окружений без токена, не сбой; см. tgbot_main.py).
#
# 1. TELEGRAM_BOT_TOKEN — токен от @BotFather (/newbot). Пусто = бот выключен
#    (idle, не polling).
TELEGRAM_BOT_TOKEN=<token или пусто>
# 2. TELEGRAM_SUPPORT_CHAT_ID — id супергруппы-форума (Topics включены в
#    настройках группы), вида -100XXXXXXXXXX. Получить: добавить бота в группу,
#    отправить любое сообщение в любой топик, дернуть
#    https://api.telegram.org/bot<token>/getUpdates — в ответе
#    message.chat.id (для супергруппы всегда отрицательный, начинается с -100).
TELEGRAM_SUPPORT_CHAT_ID=<-100... или пусто>
# 3. TELEGRAM_SUPPORT_TOPIC_ID — id конкретного топика (thread) внутри группы,
#    куда падают support-обращения. Открыть нужный топик в Telegram Desktop/Web →
#    в URL топика (t.me/c/<chat>/<topic_id>) последнее число — это topic_id.
#    Либо взять message_thread_id из того же getUpdates-ответа (п.2), отправив
#    тестовое сообщение именно в целевой топик.
TELEGRAM_SUPPORT_TOPIC_ID=<topic_id или пусто>

Оба файла создаются вручную при первом деплое.

Деплой / рестарт tgbot

# .env.runtime читается на старте container — `compose restart` НЕ перечитывает.
docker compose -p gendesign-tradein -f docker-compose.prod.yml \
  up -d --force-recreate --no-deps tgbot

restart: unless-stopped + stop_grace_period: 120s в compose (см. docker-compose.prod.yml) — автозапуск после ребута VPS гарантирован (on-failure сюда не годится: код выхода контейнера при ребуте — гонка с long-poll таймаутом 30с, unless-stopped/always не зависят от exit-кода). 120s grace даёт time докрутить long-poll + отработать кооперативный drain (_DRAIN_TIMEOUT_S=100s в tgbot_main.py) до docker SIGKILL — паттерн скопирован с scraper (см. комментарий там же).

После изменения backend/.env.runtime

# .env.runtime читается на старте container — `compose restart` НЕ перечитывает.
docker compose -p gendesign-tradein -f docker-compose.prod.yml \
  up -d --force-recreate --no-deps backend

См. .claude/rules/deploy.md — main backend pattern идентичный.

# На VPS, если COOKIE_ENCRYPTION_KEY ещё не задан:
echo "COOKIE_ENCRYPTION_KEY=$(openssl rand -hex 32)" >> /opt/gendesign/tradein-mvp/.env.runtime

# Применить без полного рестарта стека:
cd /opt/gendesign/tradein-mvp
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --force-recreate --no-deps backend

GitHub Secrets (уже должны быть от gendesign deploy)

  • DEPLOY_HOST — IP сервера (94.228.121.73)
  • DEPLOY_USER — root
  • DEPLOY_SSH_KEY — приватный SSH ключ
  • DEPLOY_PORT — 22 (или другой если меняли)

Первый деплой — пошагово

1. На анто́новой машине: подготовить tradein-mvp/ к слиянию

# clone gendesign репо (с PAT)
git clone https://<PAT>@github.com/lekss361/-gendesign.git
cd -gendesign

# скопировать tradein-mvp/ как подпапку
cp -r /Users/anton/Птица/tradein-mvp ./tradein-mvp
rm -rf ./tradein-mvp/postgres-data  # на всякий
rm -rf ./tradein-mvp/.git           # не нужен внутри monorepo

# скопировать workflow в правильное место
mkdir -p .github/workflows
cp ./tradein-mvp/.github/workflows/deploy-tradein.yml .github/workflows/

git add tradein-mvp/ .github/workflows/deploy-tradein.yml
git commit -m "feat: add tradein-mvp subproject + deploy workflow"
git push origin main

2. На сервере: первый раз вручную добавить Caddy include

ssh -i ~/.ssh/id_bot_server root@94.228.121.73

cd /opt/gendesign
# git reset --hard origin/main подтянет tradein-mvp/ автоматически

# Добавить в Caddyfile (ровно одну строку в блок gendsgn.ru { ... })
nano Caddyfile
# вставить ПЕРЕД последним handle { ... }:
#   import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment

# Создать .env.runtime — два файла:
# 1. tradein-mvp/.env.runtime — для compose substitution на host'е
cat > tradein-mvp/.env.runtime <<EOF
TRADEIN_POSTGRES_USER=tradein
TRADEIN_POSTGRES_PASSWORD=$(openssl rand -hex 32)
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
YANDEX_GEOCODER_API_KEY=
EOF
chmod 600 tradein-mvp/.env.runtime

# 2. tradein-mvp/backend/.env.runtime — для env_file внутри container'а.
# Симлинк работает если переменные одни и те же; иначе создать отдельный файл.
ln -sf ../.env.runtime tradein-mvp/backend/.env.runtime

# Запустить stack первый раз вручную (после этого GitHub Actions сам)
docker network inspect gendesign_shared >/dev/null 2>&1 || docker network create gendesign_shared
cd tradein-mvp
set -a; source .env.runtime; set +a
docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d

# Reload Caddy
cd /opt/gendesign
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy caddy reload --config /etc/caddy/Caddyfile

# Проверить
curl -sS https://gendsgn.ru/trade-in/api/health
curl -sI https://gendsgn.ru/trade-in/

3. Дальше — автодеплой

После первого ручного запуска все следующие git push origin main с изменениями в tradein-mvp/** будут:

  1. Триггерить .github/workflows/deploy-tradein.yml
  2. Билдить и пушить новые образы в ghcr.io/lekss361/gendesign-tradein-*
  3. SSH в сервер → docker compose pullup -d → Caddy reload
  4. Health check

Откат

ssh root@94.228.121.73
cd /opt/gendesign/tradein-mvp
# Откатиться на предыдущий SHA-тег
docker compose -p gendesign-tradein -f docker-compose.prod.yml down
IMAGE_TAG=<previous-sha> docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d