Free

Để Claude xây tính năng nhắn tin riêng 1:1 đến mức có thể go-live

Cách Claude xây DM 1:1 sẵn sàng go-live trong một đêm: phạm vi, ba lớp chống quấy rối, Turbo theo viewer, và phần polish tôi phải thêm sau đó.


Pickful ra mắt tin nhắn riêng hôm nay. Chat 1 đối 1, văn bản + ảnh, gửi thời gian thực, cửa sổ thu hồi 2 phút, giới hạn tốc độ theo bậc điểm, ba chế độ riêng tư, tôn trọng chặn — 38 file, +1376 dòng, 41 spec, một PR.

Bài này không phải "Claude tuyệt vời". Đây là chuyện thực tế xảy ra khi bạn yêu cầu Claude thêm tính năng vào một sản phẩm xã hội đã chạy hơn một năm — nơi người dùng chắc chắn sẽ thử quấy rối nhau: những quyết định bạn phải tự ra, những chi tiết Claude bỏ sót ở lượt đầu, và những lỗ tôi phải vá sau đó.

Định phạm vi trước khi viết một dòng

Tin nhắn riêng khác với bình luận. Bình luận là quảng trường — ném ra ai cũng thấy. Tin nhắn riêng là phòng kín đúng hai người. Nghĩa là mọi lỗ hổng trong mô hình dữ liệu, policy phân quyền, hay kênh push đều dịch trực tiếp thành "người lạ có thể dán quảng cáo lên mặt người khác". Vì vậy tôi không bảo "làm chat đi". Tôi chốt phạm vi với Claude trước:

  • Chỉ 1:1 — nhóm kéo theo quản lý thành viên, @mention, ai-thấy-ai; phức tạp tối thiểu ×3
  • Không có đã đọc — nhạy cảm riêng tư, gác lại, nhưng chừa chỗ cho toggle opt-in về sau
  • Không reaction emoji — trong phòng 1:1 mọi người gửi sticker là đủ, reaction ưu tiên thấp

Claude hiểu ngay "mài dao trước rồi mới quyết chặt cây nào". Tôi sẽ quay lại nguyên tắc này hàng chục lần: AI viết code nhanh, nhưng ranh giới sản phẩm là việc của con người.

Một cặp = một cuộc trò chuyện: tránh (A,B)(B,A) trùng lặp

Cái bẫy kinh điển của mô hình DM: A nhắn B và tạo (A→B); B trả lời và tạo (B→A); giờ có hai dòng cho cùng một cuộc trò chuyện và không có gì đồng bộ.

Giải pháp Claude đề xuất tôi lấy thẳng:

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 cộng chỉ mục duy nhất trên (user_one_id, user_two_id) — ai mở lời trước cũng vậy, cả hai đáp xuống cùng một dòng. Đơn giản, không cần migration để hòa giải sinh đôi, không cần ghép record gương ở tầng ứng dụng.

Tra cứu peer(viewer) rơi xuống gọn gàng:

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

"Đối phương là ai" luôn là một dòng toán tử ba ngôi. Đẩy bất biến xuống tầng database chắc chắn hơn nhiều so với ép buộc trong code ứng dụng.

State chia đôi cho hai bên

Hai mảng state của cuộc trò chuyện về bản chất là theo từng người:

  • Đã đọc — A đọc không có nghĩa B đọc
  • Đã ẩn — A xoá cuộc trò chuyện không thể làm nó biến mất ở phía B

Bản nháp đầu Claude viết một bảng conversation_states mỗi user một dòng. Tôi dừng lại — bảng đó vĩnh viễn tối đa 2 dòng mỗi cuộc trò chuyện, luôn luôn 2. Thêm bảng cộng thêm join không đáng. Bốn cột tôi gắn thẳng vào 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

Đọc/ghi dispatch theo viewer là one hay two:

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

Code có chút viewer dispatch, schema mất một bảng join và một bộ index. Một quan hệ có số dòng cố định vĩnh viễn ở N không đáng có bảng riêng — đó là một quyết định thiết kế DB: Claude đề xuất, bạn quyết định.

Chống quấy rối dày từ ngày đầu

Tôi viết thẳng trong prompt: "DM chỉ ra mắt nếu phòng thủ chống lạm dụng mặc định bật, mọi toggle hướng về phía an toàn." Claude trả lại ba lớp phòng thủ mà tôi không sửa một chữ:

Lớp Cơ chế Vị trí
Người dùng chủ động chặn blocked_users.exists?(id: other.id) chặn luôn can_be_dmed_by?
Ba chế độ riêng tư enum :dm_privacy, { everyone: 0, followers_only: 1, nobody: 2 } User concern
Giới hạn tốc độ theo bậc điểm <50pts → 10/h, <500pts → 60/h, còn lại 300/h dm_hourly_limit

Lớp thứ ba do tôi yêu cầu thêm. Spam từ tài khoản dùng-một-lần là tai họa chắc chắn xảy ra ngày đầu của DM trong bất kỳ sản phẩm xã hội nào. Phiên bản đầu của Claude có "60/h cho tất cả mọi người" cố định — tôi yêu cầu phân bậc theo điểm uy tín: gần như câm với tài khoản mới chi phí 0, rộng rãi với người dùng thực sự năng động. Cái logic "đừng ép sự công bằng số học trong code, hãy thiết kế công bằng giữa các bậc người dùng" là quyết định sản phẩm, không phải kỹ thuật.

Cổng đầy đủ nằm trong Pundit policy, không có bypass:

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

Mỗi return false tương ứng với một kịch bản lạm dụng cụ thể. Code đọc như một checklist — đó chính là cách một policy nên trông như thế.

Thu hồi 2 phút, soft delete, lưu vết kiểm toán

Thu hồi là bắt buộc (typo, gửi nhầm ảnh, nhầm người), nhưng chi tiết dễ vấp:

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

Các điểm:

  • Soft delete — đánh dấu deleted_at, xoá nội dung, giữ dòng
  • Ảnh xoá thậtpurge_later đẩy việc dọn attachment xuống background, tiết kiệm storage
  • Cửa sổ cứng 2 phút — không có chuyện đào lại tin nhắn 6 tháng trước để thu hồi
  • Dòng giữ lại — cho tuân thủ/kiểm toán; Turbo broadcast thay render bằng placeholder "đã thu hồi"

"Xoá nội dung, giữ bản ghi" là mẫu chuẩn cho nhắn tin riêng. Claude viết đúng logic ngay lần đầu, nhưng image.purge_later là tôi sửa — bản gốc viết image.purge, sẽ chặn response.

Turbo broadcast theo người, không theo cuộc trò chuyện

Cho phần real-time tôi bảo Claude dùng Hotwire/Turbo vì cả app trên stack đó. Điều thú vị là cách cắt kênh 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

Tên kênh chứa viewer.id — cùng một tin nhắn được broadcast hai lần, tới hai stream khác nhau, render với viewer khác nhau mỗi lần.

Tại sao? Vì partial của tin nhắn rẽ nhánh theo "tin này tôi gửi không?" và "tôi có thể thu hồi không?" — kết quả render về bản chất khác nhau theo viewer. Nếu chỉ broadcast một payload rồi để client tự quyết, thì hoặc phải để lộ sender_id ra front-end hoặc phải viết một mớ CSS có điều kiện — không cách nào sạch sẽ bằng render hai lần ở server.

Badge inbox dùng cùng mô hình:

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

Một stream cho mỗi người dùng, mỗi cái tự cập nhật bộ đếm chưa đọc của mình.

Những gì tôi phải vá sau khi PR của Claude merge

Sau khi PR vào, tôi ngồi với sản phẩm thật vài tiếng và tìm ra ba thứ Claude không lường trước. Gom vào một commit polish:

1. Cuộc trò chuyện rỗng không được hiện ở cả hai bên

Tôi nhấn nút "Gửi DM" của người khác. Dòng cuộc trò chuyện được tạo, nhưng tôi chưa viết gì — và bên kia đã thấy một cuộc trò chuyện rỗng trong inbox của họ. Kỳ cục về mặt xã hội: "nếu bạn không nói gì thì đừng làm phiền tôi".

Thêm một scope:

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

Cuộc trò chuyện có last_message_at IS NULL không thấy được ở cả hai bên. Tin nhắn thật đầu tiên gọi bump_last_message!, đóng dấu thời gian, và cuộc trò chuyện nổi lên. Claude không nghĩ tới — chỉ quan tâm "có chạy không", không quan tâm "nếu người dùng đổi ý giữa luồng thì sao".

2. Nút "Gửi DM" trên profile xếp chồng dưới Follow và quá chật

Layout của Claude xếp các nút theo chiều dọc, cộng thêm tên tooltip heroicon "Chat-bubble-left-right" rò ra. Tôi đổi thành sát cạnh Follow, chỉ icon, với title rõ ràng đè tooltip rò.

3. Avatar vỡ kích thước ở inbox và header thread

.avatar-wrapper có width lock đánh nhau với utility w-X h-X của Tailwind. Theo quy ước của partial user_card: bỏ wrapper, dùng w-X h-X rounded-full object-cover trực tiếp.

Cả ba riêng lẻ đều nhỏ. Cộng lại là khoảng cách giữa "chạy" và "thực sự khớp". Claude viết code nhanh, nhưng kiểu polish chỉ thấy được khi dùng sản phẩm không thể uỷ thác — nó không có mắt, ngón tay, hay trí nhớ cơ từ các trang khác của cùng app.

Những gì tôi học được khi xây tính năng này

1. Phạm vi là việc của người, không phải AI.

"Không nhóm, không đã đọc, không reaction" có giá trị hơn từng dòng code Claude viết. Nới lỏng bất kỳ điều nào thì timeline gấp đôi. Claude không tự đề xuất cắt phạm vi vì nó không biết lịch release của bạn.

2. Đẩy bất biến xuống database.

[a.id, b.id].sort cộng chỉ mục duy nhất, validation distinct_participants, cascade FK — tất cả Claude đề xuất, nhưng chỉ sau khi tôi nói "không có cuộc trò chuyện gương", "cấm tự DM chính mình", "xoá cascade sạch". Constraint càng chặt, bug sống sót càng ít.

3. Chống lạm dụng nằm trong spec.

Trong 41 spec, ~60% là test policy: chặn hoạt động, chế độ riêng tư hoạt động, rate limit hoạt động. Những spec đó không kiểm tra "tính năng chạy không" — kiểm tra "lạm dụng có bị chặn không". Một kịch bản một spec. Claude có thể liệt kê danh sách, nhưng chỉ sau khi bạn nói những kịch bản nào bạn đang phòng thủ.

4. Polish UX cần tay đặt lên sản phẩm thật.

Hai tiếng tương tác thật phát hiện ba điều mà code review hay test suite xanh không bao giờ tìm ra. Không có thay thế.


Từ "tôi muốn cái này" tới "người dùng có thể dùng": PR chính vào lúc 21:53, polish lúc 23:36 — một đêm. Nhưng tài nguyên khan hiếm không phải là thời gian viết code, mà là thời gian suy nghĩ thấu đáo phạm vi, phân quyền, và phòng thủ chống lạm dụng. Viết xong code chỉ là khởi đầu; tự mình dùng sản phẩm và mài bavia mới là cái khiến nó có thể go-live.