איך Claude בנה DM-ים 1:1 מוכנים לפרודקשן בערב אחד: סקופ, שלוש שכבות נגד הטרדה, Turbo לכל viewer והליטוש שהייתי צריך להוסיף אחר כך.
Pickful השיק היום הודעות ישירות. צ'אט אחד-על-אחד, טקסט בתוספת תמונות, מסירה בזמן אמת, חלון משיכה של 2 דקות, rate limit לפי דרגות נקודות, שלושה מצבי פרטיות, כיבוד חסימה — 38 קבצים, +1376 שורות, 41 spec, PR אחד.
המאמר הזה הוא לא "Claude מדהים". הוא על מה שקורה באמת כשמבקשים מ-Claude להוסיף תכונה למוצר חברתי שרץ יותר משנה, שבו משתמשים בהחלט ינסו להטריד זה את זה: אילו החלטות אתה חייב לקבל בעצמך, מה Claude מפספס במעבר הראשון, ומה הייתי צריך לטלא אחר כך.
DM-ים אינם תגובות. תגובות הן כיכר ציבורית — מה שאתה זורק, כולם רואים. DM-ים הם חדר פרטי עם בדיוק שני אנשים. כלומר כל חור במודל הנתונים, ב-policy של ההרשאות, או בערוץ ה-push מתורגם ישירות ל"זר יכול להדביק פרסומת לפרצוף של מישהו". לכן לא אמרתי "תבנה צ'אט". קודם נעלתי את הסקופ עם Claude:
"חדד תחילה את הלהב, אחר כך תחליט איזה עץ לכרות" — Claude קלט מיד. עוד תריסר פעמים החזרתי לעיקרון הזה: AI כותב קוד מהר, אבל גבולות המוצר זו החלטה אנושית.
(A,B) ו-(B,A)המלכודת הקלאסית במידול DM: 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 ליישוב תאומים, בלי איחוד רשומות מראה בשכבת היישום.
ה-lookup של peer(viewer) יוצא נקי:
def peer(viewer)
viewer.id == user_one_id ? user_two : user_one
end
"מי הצד השני" תמיד ביטוי טרינארי של שורה אחת. לדחוף את האינווריאנט לשכבת מסד הנתונים הרבה יותר חזק מלכפות אותו בקוד היישום.
שני חלקים של מצב השיחה הם במהותם per-user:
בטיוטה הראשונה 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 לא ראוי לטבלה משלו — זו החלטת עיצוב DB: Claude מציע, אתה מחליט.
כתבתי במפורש ב-prompt: "DM-ים יוצאים רק אם הגנות נגד שימוש לרעה פעילות כברירת מחדל, כל ה-toggles בכיוון הבטוח". 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 |
את השכבה השלישית ביקשתי אני. ספאם מחשבונות חד-פעמיים הוא אסון מובטח ביום הראשון של DM בכל מוצר חברתי. הגרסה הראשונה של 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 אמורה להיראות.
משיכה היא חובה (טעויות הקלדה, תמונה שגויה, אדם שגוי), אבל בפרטים נופלים בקלות:
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, שחוסם את ה-response.
ל-real-time אמרתי ל-Claude להשתמש ב-Hotwire/Turbo כי כל האפליקציה על ה-stack הזה. המעניין הוא איך לחתוך את ערוצי ה-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 — אותה הודעה משודרת פעמיים, לשני streams שונים, מתרנדרת עם viewer שונה בכל פעם.
למה? כי ה-partial של ההודעה מסתעף על "האם אני שלחתי?" ו"האם אני יכול למשוך?" — תוצאת הרינדור באמת שונה ל-viewer. אם נשדר payload אחד וניתן ל-client להחליט, או נחשוף sender_id ל-frontend או נכתוב הר של 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" של מישהו אחר. שורת השיחה נוצרה, אבל לא כתבתי עדיין כלום — והצד השני כבר ראה שיחה ריקה ב-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. כפתור "שלח DM" בפרופיל היה אנכית מתחת ל-Follow וצפוף
ה-layout של Claude ערם את הכפתורים אנכית, ובנוסף שם ה-tooltip של ה-heroicon "Chat-bubble-left-right" דלף החוצה. שיניתי לליד Follow, אייקון בלבד, עם title מפורש שמכסה את הדליפה.
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 לא יציע מיוזמתו לקצץ סקופ כי הוא לא יודע את לוח ה-release שלך.
2. דחוף אינווריאנטים ל-DB.
[a.id, b.id].sort בתוספת אינדקס ייחודי, ולידציית distinct_participants, FK cascade — הכל הצעות של Claude, אבל רק אחרי שאמרתי "אין שיחות מראה", "אין self-DM", "delete cascade נקי". ככל שה-constraint יותר הדוק, פחות באגים שורדים.
3. אנטי-שימוש לרעה מבוטא ב-spec.
מתוך 41 spec, ~60% הם בדיקות policy: חסימה עובדת, מצב פרטיות עובד, rate limit עובד. ה-specs האלה לא בודקים "האם הפיצ'ר עובד?" — הם בודקים "האם השימוש לרעה נחסם?". spec אחד לכל תרחיש. Claude יכול לפרט את הרשימה, אבל רק אחרי שאתה אומר לו אילו תרחישים אתה מגן עליהם.
4. ליטוש UX דורש ידיים על המוצר האמיתי.
שעתיים של אינטראקציה אמיתית חשפו שלושה דברים ש-code review או test suite ירוק לעולם לא היו תופסים. אין תחליף.
מ"אני רוצה את זה" ל"משתמשים משתמשים בו": ה-PR הראשי נחת ב-21:53, ה-polish ב-23:36 — ערב אחד. אבל המשאב הנדיר לא היה זמן לכתוב קוד, אלא הזמן לחשוב עד הסוף על סקופ, הרשאות והגנות מפני שימוש לרעה. לסיים את הקוד זו רק ההתחלה; להשתמש במוצר בעצמך ולהחליק את הצלקות — זה מה שהופך אותו לשליחה.