Free

Как Claude собрал 1:1 личные сообщения до боевой готовности

Как Claude собрал 1:1 ЛС до боевой готовности за один вечер: scope, три слоя анти-абьюза, Turbo по viewer и polish, который пришлось добавить мне.


Pickful сегодня выкатил личные сообщения. Чат один-на-один, текст плюс картинки, доставка в реальном времени, окно отзыва 2 минуты, rate limit по уровням репутации, три режима приватности, учёт блокировок — 38 файлов, +1376 строк, 41 spec, один PR.

Эта статья не про "Claude великолепен". Она про то, что реально происходит, когда вы просите Claude добавить функцию в социальный продукт, работающий больше года, где пользователи точно будут пытаться травить друг друга: какие решения вам приходится принимать самим, что Claude упускает на первом проходе, что мне пришлось заштопывать потом.

Зафиксируйте scope до первой строчки кода

ЛС — не комментарии. Комментарии — общественная площадь, бросаешь что-то — видят все. ЛС — приватная комната ровно с двумя людьми. Это значит, любая дыра в модели данных, политике авторизации или канале push напрямую превращается в "незнакомец может прилепить рекламу кому-то в лицо". Поэтому я не говорил "сделай чат". Сначала зафиксировал scope с Claude:

  • Только 1:1 — группы тянут управление участниками, @mention, кто-кого-видит; сложность минимум ×3
  • Без read receipts — чувствительно по приватности, откладываем, но оставляем место под opt-in переключатель позже
  • Без emoji reaction — в комнате 1:1 шлют стикеры, reactions — низкий приоритет

"Сначала наточи лезвие, потом решай какое дерево валить" Claude понял сразу. Этот принцип я использовал ещё дюжину раз: AI пишет код быстро, но границы продукта — человеческое решение.

Одна пара = одна беседа: избегаем близнецов (A,B) и (B,A)

Классическая ловушка моделирования ЛС: A пишет B и создаёт (A→B); B отвечает и создаёт (B→A); теперь две строки для одной беседы и ничего не синхронизировано.

Решение Claude взял целиком:

def self.between!(a, b)
  raise ArgumentError, "cannot create conversation with self" if a.id == b.id
  one_id, two_id = [a.id, b.id].sort
  find_or_create_by!(user_one_id: one_id, user_two_id: two_id)
end

[a.id, b.id].sort плюс уникальный индекс на (user_one_id, user_two_id) — кто бы ни заговорил первым, оба приземляются в одну строку. Просто, без миграций для примирения близнецов, без склейки зеркальных записей на уровне приложения.

Лукап peer(viewer) выходит чисто:

def peer(viewer)
  viewer.id == user_one_id ? user_two : user_one
end

"Кто другой" — всегда тернарник в одну строку. Затолкать инвариант в слой БД существенно надёжнее, чем форсить его в коде приложения.

Состояние разделено по обеим сторонам

Два куска состояния беседы по сути per-user:

  • Прочитано — A прочитал не значит, что B прочитал
  • Скрыто — A удалил беседу не должно убирать её у B

В первом черновике Claude писал таблицу conversation_states по строке на пользователя. Я остановил — эта таблица навсегда ограничена 2 строками на беседу, всегда 2. Лишняя таблица плюс join не оправданы. Мы положили четыре колонки прямо в conversations:

t.datetime :user_one_last_read_at
t.datetime :user_two_last_read_at
t.datetime :user_one_hidden_at
t.datetime :user_two_hidden_at

Чтение и запись диспатчатся по тому, на какой стороне viewer:

def column_name(viewer, suffix)
  viewer.id == user_one_id ? :"user_one_#{suffix}" : :"user_two_#{suffix}"
end

Чуть viewer dispatch в коде, минус одна таблица join и комплект индексов в схеме. Отношение с навсегда ограниченной кардинальностью N не заслуживает собственной таблицы — это решение по дизайну БД: Claude предлагает, вы решаете.

Защита от травли — толстая с первого дня

Я прямо написал в prompt: "ЛС выкатываются только если защита от злоупотреблений включена по умолчанию, все переключатели в безопасную сторону." Claude вернул три слоя защиты, в которых я не изменил ни буквы:

Слой Механизм Где живёт
Блокировка пользователем blocked_users.exists?(id: other.id) короткое замыкание can_be_dmed_by?
Три режима приватности enum :dm_privacy, { everyone: 0, followers_only: 1, nobody: 2 } User concern
Rate limit по уровням репутации <50pts → 10/ч, <500pts → 60/ч, остальные 300/ч dm_hourly_limit

Третий слой попросил я. Спам с одноразовых аккаунтов — гарантированная катастрофа первого дня ЛС в любом социальном продукте. Первая версия Claude была "60/ч для всех" плоско — я попросил сделать ступенчато по очкам репутации: почти немые для бесплатных новых аккаунтов, щедро для действительно активных пользователей. Эта логика "не вколачивайте арифметическую справедливость в код, проектируйте справедливость между уровнями пользователей" — это решение продукта, не инженерное.

Полный шлюз в Pundit policy, без обхода:

def create?
  return false unless user
  return false unless record.conversation&.participant?(user)
  recipient = record.conversation.peer(user)
  return false unless recipient
  return false unless recipient.can_be_dmed_by?(user)
  return false if user.dm_rate_limited?
  true
end

Каждое return false соответствует конкретному сценарию злоупотребления. Код читается как чеклист — так и должна выглядеть policy.

Отзыв 2 минуты, soft delete, аудит-след

Отзыв обязателен (опечатки, не та картинка, не тот человек), но в деталях легко споткнуться:

RETRACT_WINDOW = 2.minutes

def retractable_by?(user)
  !deleted? && sender_id == user&.id && created_at >= RETRACT_WINDOW.ago
end

def retract!
  update!(deleted_at: Time.current, content: nil)
  image.purge_later if image.attached?
end

Куски:

  • Soft delete — штамп deleted_at, контент очищаем, строку оставляем
  • Картинку удаляем по-настоящемуpurge_later ставит очистку вложения в фон, экономит storage
  • Жёсткое окно 2 минуты — нельзя выкапывать сообщение полугодовой давности для отзыва
  • Строка остаётся — для compliance и аудита; Turbo broadcast меняет рендер на плейсхолдер "отозвано"

"Сотри контент, сохрани запись" — стандартный шаблон личной переписки. Claude правильно написал логику с первого раза, но image.purge_later — это уже моя правка: оригинал был image.purge, который блокирует response.

Turbo broadcast по viewer, а не по беседе

Для real-time я сказал Claude использовать Hotwire/Turbo, потому что вся апка на этом стеке. Интересно — как нарезать broadcast-каналы:

def broadcast_to_thread
  [conversation.user_one, conversation.user_two].each do |viewer|
    broadcast_append_to(
      "conversation_#{conversation_id}_user_#{viewer.id}",
      target: "conversation_#{conversation_id}_messages",
      partial: "direct_messages/direct_message",
      locals: { message: self, viewer: viewer }
    )
  end
end

Имя канала содержит viewer.id — одно и то же сообщение броадкастится дважды, на два разных stream, каждый раз рендерится с другим viewer.

Почему? Потому что partial сообщения ветвится по "я это отправил?" и "могу ли отозвать?" — результат рендера по сути разный для каждого viewer. Если broadcast одно payload и пускай клиент решает — либо выкатываем sender_id во фронт, либо пишем кучу условных CSS — ни один вариант не настолько чистый, как два рендера на сервере.

Бэйдж inbox использует ту же модель:

Turbo::StreamsChannel.broadcast_replace_to(
  "user_#{recipient_id}_inbox",
  target: "dm_inbox_badge",
  html: ApplicationController.render(
    partial: "shared/dm_inbox_badge",
    locals: { unread: recipient.total_unread_dm_count }
  )
)

Один stream на пользователя, каждый обновляет свой счётчик непрочитанных.

Что мне пришлось заштопывать после мерджа PR Claude

После того как PR вошёл, я сел с реальным продуктом часа на два и нашёл три вещи, которых Claude не предусмотрел. Заштопал их в polish-коммите:

1. Пустые беседы не должны показываться ни с одной стороны

Я нажал кнопку "Отправить ЛС" чужого профиля. Строка беседы создана, но я ещё ничего не написал — и другая сторона уже видела пустую беседу в своём inbox. Социально странно: "если ничего не скажешь — не беспокой меня".

Добавил scope:

scope :visible_to, ->(user) {
  for_user(user)
    .where.not(last_message_at: nil)
    .where(...hidden_at IS NULL...)
}

Беседы с last_message_at IS NULL не видны ни одной стороне. Первое реальное сообщение зовёт bump_last_message!, ставит timestamp, и беседа всплывает. Claude об этом не подумал — он был сосредоточен на "работает ли?", а не на "что если пользователь передумает посреди flow?".

2. Кнопка "Отправить ЛС" в профиле была вертикально под Follow и тесно

Layout Claude складывал кнопки вертикально, плюс имя tooltip heroicon "Chat-bubble-left-right" протекало. Я переделал на рядом с Follow, только иконка, с явным title, перекрывающим утечку.

3. Аватары ломались по размеру в inbox и заголовке треда

.avatar-wrapper имел width lock в конфликте с утилитами w-X h-X Tailwind. Привёл к конвенции partial user_card: убрать wrapper, использовать w-X h-X rounded-full object-cover напрямую.

Каждое из трёх по отдельности мелочь. Вместе — это расстояние между "работает" и "реально садится". Claude пишет код быстро, но тот сорт полировки, который замечаешь только при использовании продукта, нельзя делегировать — у него нет глаз, пальцев, мышечной памяти других страниц того же приложения.

Что меня научил этот build

1. Scope — работа человека, не AI.

"Без групп, без read receipts, без reactions" стоило больше каждой строки кода, что написал Claude. Ослабь одно из них — график удваивается. Claude сам не предложит резать scope, потому что не знает вашего календаря релизов.

2. Толкайте инварианты в БД.

[a.id, b.id].sort плюс уникальный индекс, валидация distinct_participants, FK cascade — всё предложил Claude, но только после того как я сказал "никаких зеркальных бесед", "никаких self-DM", "delete cascade чисто". Чем строже constraint, тем меньше багов выживает.

3. Анти-абьюз выражается в spec.

Из 41 spec ~60% — тесты policy: блок работает, режим приватности работает, rate limit работает. Эти spec не проверяют "фича работает?" — они проверяют "блокируется ли злоупотребление?". Один spec на сценарий. Claude может перечислить список, но только после того, как вы скажете какие сценарии вы защищаете.

4. UX polish требует рук на реальном продукте.

Два часа реального взаимодействия выкатили три вещи, которые ни code review, ни зелёная test suite никогда бы не поймали. Замены нет.


От "я хочу это" до "пользователи это используют": основной PR в 21:53, polish в 23:36 — один вечер. Но дефицитный ресурс был не время на написание кода, а время до конца продумать scope, авторизацию и анти-абьюз защиты. Закончить код — это только начало; использовать продукт самому и шлифовать заусенцы — то, что делает его пригодным к выкладке.