Free

Claude に 1:1 DM 機能を本番投入できるレベルまで作らせる

Claude が一晩で本番投入レベルの 1:1 DM を組んだ話:スコープ・三層の嫌がらせ対策・Turbo 即時配信、そして後から自分で補ったポリッシュ。


Pickful は今日 DM をリリースした。1 対 1 のチャット、テキスト+画像、リアルタイム配信、2 分間の撤回ウィンドウ、ポイント階層別のレート制限、3 段階のプライバシー設定、ブロック対応——38 ファイル、+1376 行、41 spec、PR 一本。

この記事は「Claude すごい」ではない。1 年動いている SNS プロダクトに、ユーザーが本気で嫌がらせを試みる類の機能を追加するよう Claude に依頼したとき、自分で決めなきゃいけない判断、Claude が初回で漏らす細部、後から自分で埋めた穴——そういう話を書く。

コードを書く前にスコープを切る

DM はコメントとは違う。コメントは広場で、放り投げれば誰でも見る。DM は密室で、入れるのは 2 人だけ。つまりデータモデル、認可ポリシー、プッシュチャネルの小さな抜けは、すべて「他人があなたの顔に広告を貼れる」に直結する。だから「チャットを作って」とは言わず、まず Claude とスコープを固めた:

  • 1:1 のみ——グループはメンバー管理、@mention、誰が誰を見るか、複雑度が 3 倍以上
  • 既読なし——プライバシーに敏感、後でオプトインのトグルを足せる余地だけ残す
  • emoji reaction なし——1:1 ならスタンプ送ればいい、reaction は優先度低

「まず刃を研いでから、どれだけの木を切るか決める」を Claude は即理解した。後で何度も使う原則だ——AI はコードを速く書くが、プロダクトの境界は人間が決めるしかない。

一組 = 一会話:(A,B) と (B,A) の二重化をどう防ぐか

DM モデリングの定番の落とし穴:A が B に送って (A→B) を作る、B が返信して (B→A) を作る、結果として同じ会話に対して 2 行存在し同期が取れない。

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

「相手は誰」は永遠に三項演算子一行。不変量を DB 層に押し込む書き方は、アプリ層判定よりはるかに堅牢だ。

状態は両側にそれぞれ持たせる

会話の状態のうち本質的に「片側」のものが二つある:

  • 既読——A が読んだことは B が読んだことを意味しない
  • 非表示——A が会話を消しても B 側には残る

初稿で Claude は conversation_states テーブルを生やそうとした。止めた——このテーブルは会話ごとに必ず 2 行、永遠に 2 行。テーブルを増やして join を一段挟む価値はない。conversations に 4 列直接生やした:

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 が one か two かでカラム名を分岐:

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

コード側に少しの viewer dispatch、スキーマからは join テーブル一つとインデックス一式が消える。行数が永久に N で固定される関係には独立テーブルを作るな——これは DB 設計の判断問題。Claude は案を出す、まとめるかどうかは人間が決める。

嫌がらせ対策は初日から厚く

プロンプトに明記した:「DM のリリース条件は デフォルトで嫌がらせ耐性、すべてのトグルを安全側に倒すこと」。Claude が返してきた三層防御は一字も変えなかった:

仕組み 実装場所
ユーザー主導のブロック blocked_users.exists?(id: other.id) でショートサーキット can_be_dmed_by?
プライバシー 3 段階 enum :dm_privacy, { everyone: 0, followers_only: 1, nobody: 2 } User concern
ポイント階層別レート制限 <50pts→10/h、<500pts→60/h、それ以外 300/h dm_hourly_limit

3 層目は私の追加要求。SNS プロダクトの DM 機能初日に確実に来る災害は捨てアカウントによるスパム。Claude の初版は「全員 60/h 一律」だった。ポイント階層別に変えさせた——ゼロコストの新アカウントには声量を与えず、本物のアクティブユーザーには十分な余裕を持たせる。「コード層で機械的な公平を保つのではなく、ユーザー階層という設計で公平を作る」——これはプロダクト判断であってエンジニアリング判断ではない。

ゲートは 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 分間の撤回、ソフトデリート、監査ログ

撤回は必須機能だ(誤字、誤画像、誤送信先)。だが細部で躓きやすい:

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

要点:

  • ソフトデリート——deleted_at にタイムスタンプ、本文を空に、行は残す
  • 画像は完全削除——purge_later で添付クリーンアップをバックグラウンドへ
  • 2 分のハードウィンドウ——半年前のメッセージを掘り返して撤回はさせない
  • 行を残す——コンプライアンスと監査のため。同時に Turbo broadcast でメッセージを「撤回済み」プレースホルダーに差し替える

「本文は消すが記録は残す」が DM の標準パターン。Claude は初回でこのロジックを正しく書いた。ただし image.purge_later は私が直した——元のコードは image.purge で、レスポンスをブロックしてしまう。

Turbo は「会話単位」ではなく「人単位」でブロードキャスト

リアルタイム部分は Hotwire/Turbo を指定(アプリ全体がそれだから)。面白いのはチャネルの切り方:

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 を含める——同じメッセージを 2 回ブロードキャスト、別々のストリームに、viewer を変えて描画する。

なぜか?メッセージのパーシャル内に「これは自分が送ったものか」「撤回できるか」の分岐があり、描画結果が viewer ごとに本質的に違うからだ。一回だけブロードキャストしてフロント側で判定するなら、sender_id をフロントに出すか CSS の条件分岐を書く——どちらもサーバ側で 2 回描画する方が綺麗。

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 }
  )
)

ユーザー 1 人につきストリーム 1 本、それぞれ自分の未読数を更新する。

Claude の PR がマージされた後、自分で埋めた穴

PR が入った後、実機で数時間使い込んだら Claude が想定していなかった問題が 3 つ出てきた。ポリッシュコミットでまとめて修正:

1. 空の会話は両側から見えてはいけない

他人の「DM を送る」ボタンを押すと会話の行が作られる。が、自分はまだ何も書いていない——なのに相手の inbox には空の会話が出る。社交的に変だ:「何も言わないなら邪魔するな」。

スコープを足した:

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! がタイムスタンプを打ち、会話が浮上する。Claude はこれを思いつかなかった——「動くか」だけ気にして、「途中で気が変わったらどうなる」を見ていない。

2. プロフィールの「DM を送る」ボタンが Follow と縦に積まれて窮屈

Claude のレイアウトは縦積み、加えて「Chat-bubble-left-right」という heroicon の tooltip 名がそのまま漏れていた。Follow と横並びの icon-only に変更、tooltip 漏れを潰すため明示的に title を指定。

3. アバターが inbox とスレッドヘッダで潰れる

.avatar-wrapper の width lock が Tailwind の w-X h-X と衝突。user_card パーシャルの慣習に揃え、wrapper を外して w-X h-X rounded-full object-cover を直接当てる形に。

3 つとも単独では小さい。合わせると「動く」と「ちゃんと馴染む」の差になる。Claude はコードを速く書くが、実際に使ってみないと気づかないタイプの瑕疵は委任できない——目も指も、同じアプリ他ページの筋肉記憶もないからだ。

この機能を作って学んだこと

1. スコープ決めは人間の仕事、AI の仕事ではない

「グループ無し、既読無し、reaction 無し」の判断は、Claude が書いたコード全行より価値があった。どれかを緩めれば工数倍、リリース 2 週ずれる。Claude はスコープ縮小を提案しない——あなたのリリーススケジュールを知らないから。

2. 不変量を DB に押し込む

[a.id, b.id].sort + ユニークインデックス、distinct_participants バリデーション、外部キーカスケード——どれも Claude の提案。ただし前提として私が「ミラー会話は許さない」「自分宛て DM は許さない」「ユーザー削除時はクリーンに連動」と言い切った。制約をきつく書くほど後続のバグが減る。

3. 嫌がらせ対策は spec で表現する

41 spec のうち約 60% が policy テスト:ブロックが効くか、プライバシー設定が効くか、レート制限が効くか。これらの spec は「機能が動くか」を見ていない、「悪用を阻止できるか」を見ている。嫌がらせシナリオごとに spec を 1 本書く——Claude にリスト化を任せられるが、まず「防ぐべきシナリオはどれか」を伝える必要がある。

4. UX のポリッシュは自分で触らないと見えない

PR マージ後の 2 時間の実利用で、コードレビューやテストパスでは絶対に拾えなかった 3 件を発見した。代替手段は無い。


DM 機能は「作りたい」から「ユーザーが使える」まで、本流 PR が 21:53、ポリッシュが 23:36——一晩で完了。だが本当に希少だったのはコードを書く時間ではない、スコープ・認可・嫌がらせ対策を考え切る時間だ。コードを書き終えるのはスタートで、自分で使ってバリを取って初めて出荷できる。