Free

السماح لـ Claude ببناء ميزة رسائل خاصة 1:1 جاهزة للإطلاق

كيف بنى Claude ميزة رسائل خاصة 1:1 جاهزة للإطلاق في ليلة واحدة: النطاق، ثلاث طبقات لمكافحة الإساءة، بث Turbo لكل مشاهد، والصقل الذي أضفته لاحقًا.


أطلقت Pickful الرسائل المباشرة اليوم. محادثة فردية واحد لواحد، نص + صور، تسليم فوري، نافذة استرجاع 2 دقيقة، تحديد معدّل حسب درجات النقاط، ثلاث حالات خصوصية، احترام الحظر — 38 ملفًا، +1376 سطرًا، 41 spec، PR واحد.

هذه المقالة ليست "Claude مذهل". إنها عن ما يحدث فعلاً عندما تطلب من Claude إضافة ميزة إلى منتج اجتماعي يعمل منذ أكثر من عام، حيث سيحاول المستخدمون فعلاً مضايقة بعضهم: أي قرارات يجب أن تتخذها بنفسك، وما الذي يفوّته Claude في المرة الأولى، وما الذي اضطررت إلى ترقيعه لاحقًا.

حدّد النطاق قبل كتابة سطر واحد

الرسائل المباشرة ليست تعليقات. التعليقات ساحة عامة — أي شيء ترميه يراه الجميع. الرسائل المباشرة غرفة خاصة بشخصين بالضبط. أي ثغرة في نموذج البيانات أو سياسة التفويض أو قناة الإشعارات تُترجم مباشرة إلى "غريب يستطيع لصق إعلانات على وجه أحدهم". لذلك لم أقل "ابنِ دردشة". أحكمت النطاق مع Claude أولاً:

  • 1:1 فقط — المجموعات تجلب إدارة الأعضاء و@mention ومن يرى من؛ تعقيد ×3 على الأقل
  • بدون إشعار قراءة — حساس للخصوصية، نؤجله، لكن نترك مكانًا لمفتاح opt-in لاحقًا
  • بدون reaction إيموجي — في غرفة 1:1 يرسل الناس ملصقات، الـreactions أولوية منخفضة

استوعب Claude فورًا "اشحذ النصل أولاً ثم قرر أي شجرة تقطع". سأعيد استخدام هذا المبدأ عشرات المرات: الذكاء الاصطناعي يكتب الكود بسرعة، لكن حدود المنتج قرار بشري.

زوج واحد = محادثة واحدة: تجنّب تكرار (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) — مهما كان من يبدأ الحديث، كلاهما يحطّان على الصف نفسه. بسيط، بدون migration للتوفيق بين التوائم، بدون ربط السجلات المتطابقة في طبقة التطبيق.

استدعاء peer(viewer) يأتي نظيفًا:

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

"من الطرف الآخر" دائمًا تعبير ثلاثي من سطر واحد. دفع الثوابت إلى طبقة قاعدة البيانات أكثر متانة من فرضها في كود التطبيق.

الحالة موزّعة على الجانبين

جزآن من حالة المحادثة هما بطبيعتهما لكل مستخدم:

  • القراءة — قراءة 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
تحديد معدّل بحسب درجات النقاط <50pts → 10/ساعة، <500pts → 60/ساعة، الباقي 300/ساعة dm_hourly_limit

الطبقة الثالثة طلبتها أنا. spam الحسابات المؤقتة كارثة مضمونة في اليوم الأول لأي منتج اجتماعي. الإصدار الأول من Claude كان "60/ساعة للجميع" ثابتًا — جعلته متدرجًا بحسب نقاط السمعة: شبه صامت للحسابات الجديدة عديمة التكلفة، كريم للمستخدمين النشطين فعلاً. منطق "لا تفرض عدالة حسابية في الكود، صمّم العدالة بين شرائح المستخدمين" قرار منتج، لا هندسة.

البوابة الكاملة في سياسة Pundit، بدون تجاوز:

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 يقابل سيناريو مضايقة محدد. الكود يُقرأ كقائمة تحقق — وهكذا يجب أن تبدو السياسة.

استرجاع 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 دقيقة — لا يوجد نبش لرسائل قبل 6 أشهر للاسترجاع
  • الصف يبقى — للامتثال والتدقيق؛ بث Turbo يستبدل التصيير بـplaceholder "تم الاسترجاع"

"امسح المحتوى، احفظ السجل" هو النمط القياسي للمراسلة الخاصة. كتب Claude المنطق صحيحًا من المرة الأولى، لكن image.purge_later كان تصحيحي — النسخة الأصلية كانت image.purge، التي تحجب الاستجابة.

بثّ Turbo حسب المشاهد، لا حسب المحادثة

في الزمن الحقيقي طلبت من Claude استخدام Hotwire/Turbo لأن التطبيق كله على هذه الـstack. المثير للاهتمام كيفية تقطيع قنوات البث:

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 — نفس الرسالة تُبثّ مرتين، إلى streams مختلفين، تُصيَّر بـviewer مختلف في كل مرة.

لماذا؟ لأن partial الرسالة يتفرع على "هل أنا أرسلتها؟" و"هل أستطيع استرجاعها؟" — نتيجة التصيير مختلفة جوهريًا حسب المشاهد. إذا بثثنا حمولة واحدة وتركنا العميل يقرر، فإما نكشف 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. جمعتها في commit polish:

1. المحادثات الفارغة لا يجب أن تظهر للجانبين

ضغطت زر "إرسال DM" لشخص آخر. أُنشئ صف المحادثة، لكنني لم أكتب أي شيء بعد — والشخص الآخر يرى محادثة فارغة في صندوق الوارد. غريب اجتماعيًا: "إن لم تقل شيئًا، فلا تزعجني".

أضفت 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!، الطابع الزمني يُختم، وتظهر المحادثة. لم يفكر Claude في ذلك — انشغل بـ"هل يعمل؟"، لا بـ"ماذا لو غيّر المستخدم رأيه في منتصف التدفق؟".

2. زر "إرسال DM" في الملف الشخصي مكوّم تحت Follow وضيّق

تخطيط Claude كان يكوّم الأزرار عموديًا، إضافة إلى تسرب اسم tooltip للـheroicon "Chat-bubble-left-right". غيّرته ليكون بجانب Follow، أيقونة فقط، مع title صريح يطمس tooltip المسرّب.

3. صور البروفايل تنهار في حجم الـinbox ورأس الـthread

.avatar-wrapper فيه width lock يتعارض مع utilities w-X h-X من Tailwind. وفّقت مع اتفاقية partial user_card: أزل الـwrapper، استخدم w-X h-X rounded-full object-cover مباشرة.

كلٌّ من الثلاثة صغير منفردًا. مجتمعة هي المسافة بين "يعمل" و"يستقر فعلاً". Claude يكتب الكود بسرعة، لكن نوع التلميع الذي تكتشفه فقط باستخدام المنتج لا يمكن تفويضه — لا عيون له، ولا أصابع، ولا ذاكرة عضلية من صفحات أخرى في التطبيق نفسه.

ما تعلمته من بناء هذا

1. تحديد النطاق وظيفة بشرية، لا وظيفة AI.

"لا مجموعات، لا قراءة، لا reactions" كان أثمن من كل سطر كود كتبه Claude. حلّ أيًا منها يضاعف الجدول. Claude لا يطوع نفسه لتقليص النطاق لأنه لا يعرف جدول إصدارك.

2. ادفع الثوابت إلى قاعدة البيانات.

[a.id, b.id].sort مع فهرس فريد، تحقق distinct_participants، cascade FK — كل ذلك اقتراح Claude، لكن فقط بعد أن قلت "لا توجد محادثات متطابقة"، "لا DM إلى الذات"، "حذف cascade نظيف". كلما كان القيد أصرم قلّ ما يبقى من bugs.

3. مكافحة الإساءة تعبير في الـspec.

من 41 spec، ~60% اختبارات policy: الحظر يعمل، وضع الخصوصية يعمل، تحديد المعدل يعمل. هذه الـspecs لا تختبر "هل تعمل الميزة؟" — تختبر "هل الإساءة مصدودة؟". spec واحد لكل سيناريو. Claude قادر على تعداد القائمة، لكن فقط بعد أن تخبره أي السيناريوهات تدافع عنها.

4. تلميع UX يتطلب يدًا على المنتج الفعلي.

ساعتان من التفاعل الحقيقي كشفتا ثلاثة أشياء لن يلتقطها مراجعة الكود أو مجموعة اختبار خضراء أبدًا. لا بديل.


من "أريد هذا" إلى "المستخدمون يستخدمونه": الـPR الرئيسي وصل 21:53، الـpolish 23:36 — ليلة واحدة. لكن المورد النادر لم يكن وقت كتابة الكود، بل وقت التفكير الكامل في النطاق والتفويض ودفاعات مكافحة الإساءة. الانتهاء من الكود مجرد بداية؛ استخدام المنتج بنفسك وكشط الحواف هو ما يجعله قابلاً للإطلاق.