كيف بنى Claude ميزة رسائل خاصة 1:1 جاهزة للإطلاق في ليلة واحدة: النطاق، ثلاث طبقات لمكافحة الإساءة، بث Turbo لكل مشاهد، والصقل الذي أضفته لاحقًا.
أطلقت Pickful الرسائل المباشرة اليوم. محادثة فردية واحد لواحد، نص + صور، تسليم فوري، نافذة استرجاع 2 دقيقة، تحديد معدّل حسب درجات النقاط، ثلاث حالات خصوصية، احترام الحظر — 38 ملفًا، +1376 سطرًا، 41 spec، PR واحد.
هذه المقالة ليست "Claude مذهل". إنها عن ما يحدث فعلاً عندما تطلب من Claude إضافة ميزة إلى منتج اجتماعي يعمل منذ أكثر من عام، حيث سيحاول المستخدمون فعلاً مضايقة بعضهم: أي قرارات يجب أن تتخذها بنفسك، وما الذي يفوّته Claude في المرة الأولى، وما الذي اضطررت إلى ترقيعه لاحقًا.
الرسائل المباشرة ليست تعليقات. التعليقات ساحة عامة — أي شيء ترميه يراه الجميع. الرسائل المباشرة غرفة خاصة بشخصين بالضبط. أي ثغرة في نموذج البيانات أو سياسة التفويض أو قناة الإشعارات تُترجم مباشرة إلى "غريب يستطيع لصق إعلانات على وجه أحدهم". لذلك لم أقل "ابنِ دردشة". أحكمت النطاق مع Claude أولاً:
استوعب 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
"من الطرف الآخر" دائمًا تعبير ثلاثي من سطر واحد. دفع الثوابت إلى طبقة قاعدة البيانات أكثر متانة من فرضها في كود التطبيق.
جزآن من حالة المحادثة هما بطبيعتهما لكل مستخدم:
في المسودة الأولى أنشأ 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 يقابل سيناريو مضايقة محدد. الكود يُقرأ كقائمة تحقق — وهكذا يجب أن تبدو السياسة.
الاسترجاع إلزامي (خطأ مطبعي، صورة خاطئة، شخص خاطئ)، لكن التفاصيل تتعثر بسهولة:
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 يجدول تنظيف المرفقات في الخلفية، يوفر التخزين"امسح المحتوى، احفظ السجل" هو النمط القياسي للمراسلة الخاصة. كتب Claude المنطق صحيحًا من المرة الأولى، لكن image.purge_later كان تصحيحي — النسخة الأصلية كانت image.purge، التي تحجب الاستجابة.
في الزمن الحقيقي طلبت من 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. جمعتها في 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 — ليلة واحدة. لكن المورد النادر لم يكن وقت كتابة الكود، بل وقت التفكير الكامل في النطاق والتفويض ودفاعات مكافحة الإساءة. الانتهاء من الكود مجرد بداية؛ استخدام المنتج بنفسك وكشط الحواف هو ما يجعله قابلاً للإطلاق.