محرّك flow بحالة لواتساب، في Laravel
تحليل سجل المحادثة لمعرفة موضع العميل يبدو مرناً وهو فخ. هذه آلة الحالة التي استخدمتها بدلاً منه، وما الذي كسبته.
عملُ ملاحم وبقالة متعدد الفروع كان يستقبل كل طلب يدوياً على واتساب. الطلبات كانت تصل بشكل جيد — العملاء يعرفون استخدامه أصلاً. الناقص كان أي تسجيل لها: الأسعار تأتي من ذاكرة موظف، و«ماذا باع الفرع الثالث أمس؟» تعني تمرير محادثة.
الحل البديهي كان بناء تطبيق. وكان الحل الخاطئ. قناتهم تعمل أصلاً؛ ومطالبة العملاء بتثبيت شيء كانت ستبدّل قناةً ناجحة بمسار تحويل فيه تسرّب عند كل خطوة. فانقلب المطلب: أبقِ واتساب كامل الواجهة، وضَع نظاماً حقيقياً خلفه.
وهذا يطرح السؤال الذي تتحدث عنه هذه المقالة. محادثة واتساب طويلة العمر ويمكن تركها في منتصفها. فكيف يعرف الباكإند أين يقف العميل؟
الجواب المغري
اقرأ المحادثة من جديد واستنتج.
يبدو مرناً. سجل الرسائل موجود عندك، وتستطيع النظر في ما قيل، وتتجنب تخزين أي شيء إضافي. ومع قائمة من خمسة أصناف، ينجح فعلاً.
لكنه ينهار لثلاثة أسباب.
كل رد يصبح تحليلاً غامضاً. يكتب العميل ٢. هل هي كمية اثنين، أم الخيار الثاني في القائمة، أم الفرع الثاني؟ تفكّ الغموض بالاعتماد على الرسالة السابقة، ثم التي قبلها، فيصبح معنى أي رد يعتمد على قدر غير محدود من التاريخ.
يسوء مع نمو الكتالوج. كل منتج جديد نصٌّ جديد قد يتعارض مع نص قائم. ومنطق التحليل يتراكم فيه حالات خاصة موجودة فقط لأن شيئين لا علاقة بينهما يتشابهان في الشكل.
لا يمكن اختباره بمعزل. لتختبر «العميل يؤكد الطلب» يجب أولاً أن تبني تاريخاً معقولاً يؤدي إلى هناك. صار الاختبار مرتبطاً بكل خطوة قبله، وتغييرٌ في الخطوة الثانية يكسر اختبار الخطوة التاسعة.
ما فعلته بدلاً من ذلك
كل خطوة في المحادثة صنف. وموضع العميل الحالي سجلٌّ في قاعدة البيانات.
abstract class Step
{
/** يُعرض عندما يصل العميل إلى هذه الخطوة. */
abstract public function prompt(Conversation $conversation): OutboundMessage;
/**
* يفسّر رسالة واردة واحدة *في سياق هذه الخطوة فقط*، ويعيد
* إلى أين تتجه المحادثة بعدها.
*/
abstract public function handle(
Conversation $conversation,
InboundMessage $message,
): StepTransition;
}والمحرّك نفسه ممل تقريباً، وهذا هو المقصود:
final class FlowEngine
{
public function advance(Conversation $conversation, InboundMessage $message): void
{
$step = $this->resolve($conversation->current_step);
$transition = $step->handle($conversation, $message);
if ($transition->isInvalid()) {
// أعِد السؤال دون التحرك. العميل ليس ضائعاً،
// والنظام كذلك.
$this->dispatch($step->prompt($conversation), $conversation);
return;
}
$conversation->update(['current_step' => $transition->nextStep]);
$this->dispatch(
$this->resolve($transition->nextStep)->prompt($conversation),
$conversation,
);
}
}handle يستقبل رسالة واحدة، وعليه فهمها داخل خطوته وحدها. الرقم ٢ داخل ChooseBranch يعني الفرع الثاني. وداخل ChooseQuantity يعني وحدتين. لا شيء لفكّ غموضه، لأن السياق هو الخطوة لا التاريخ.
ما الذي كسبته
الطلبات المتروكة تُستأنف مجاناً. العميل الذي يبتعد في منتصف الطلب ويعود بعد أربع ساعات هو عملية بحث في سجل، لا إعادة بناء. ولا يوجد مسار كود لـ«الاستئناف» إطلاقاً — الاستئناف هو ببساطة وصول الرسالة التالية.
الخطوات قابلة للاختبار بمفردها. كل اختبار يبني محادثة عند خطوة واحدة معروفة، ويرسل رسالة واحدة، ويتحقق من الانتقال. لا تاريخ يُصطنع. وهذا جزء كبير من سبب وصول مجموعة الاختبارات إلى ٥٣ ملفاً، مُرجَّحة نحو مسارات الدفع والـ flow حيث يكلّف الخلل مالاً حقيقياً.
المدخل غير الصالح يتوقف عن كونه استثناءً. الردّ الذي لا معنى له عند الخطوة الحالية يعيد السؤال، والموضع لا يتحرك. ولا توجد حالة «متقدمة نصف تقدّم» يجب التعافي منها.
القاعدة التي كان يجب أن تسكن في الكود
تفرض Meta نافذة خدمة عميل مدتها ٢٤ ساعة: خارجها لا يُسمح إلا بقوالب معتمدة مسبقاً. وخرقها عقوبته ليست خطأ تحقّق — بل ضرر في تقييم جودة رقم هاتف العمل، وفقدانه في النهاية.
ولمتجرٍ قناته الوحيدة واتساب، هذا خطر وجودي. فلم يكن ممكناً أن تكون سطراً في دليل موظفين.
كل رسالة صادرة تمرّ عبر خدمة نافذة تفحص آخر توقيت لرسالة واردة في المحادثة وتقرر: رسالة حرة، أم إرسال قالب معتمد. النظام لا يستطيع فيزيائياً إرسال النوع الخاطئ. صار الالتزام مسار كود له اختبار، بدل شيء على شخص متعب أن يتذكره في الحادية عشرة ليلاً.
أين وُضع الحدّ
قرار آخر يستحق الذكر. التعامل مع webhook الخاص بـ Meta لا يسكن في Laravel إطلاقاً — بل في خدمة Node منفصلة تتحقق من التوقيع، وتفك تشفير حِزم WhatsApp Flow، وتوحّد شكل الرسالة، وتمرّرها للداخل عبر حدٍّ موقّع بـ HMAC. أربعة عشر ملفاً، بلا قاعدة بيانات وبلا قواعد أعمال.
مسار Laravel واحد كان يعني بنية تحتية أقل. لكن ثلاثة أمور دفعت للاتجاه الآخر: Meta تطلب التحقق من التوقيع على الـ body الخام، وهذا يتعارض مع middleware الإطار الذي حلّل الطلب سابقاً؛ وتشفير Flow يحتاج التعامل مع مفاتيح RSA و AES، ولا شأن لهذا بأن يجاور منطق الفواتير؛ وتسليم الـ webhook يجب أن يُقَر بسرعة، وهذا ما تستطيعه خدمة نحيفة تتحقق وتمرّر، بغضّ النظر عن بطء العمل خلفها.
والمردود أنه عندما تُغيّر Meta مظروفاً، لا يُمَس كود الأعمال.
الخلاصة
إذا كانت المحادثة ذات خطوات، فنمذِج الخطوات. النسخة التي تقرأ السجل لتخمّن أين أنت ستبدو ذكية لأسبوع تقريباً، ثم سيجعلها كل منتج جديد أسوأ قليلاً إلى الأبد.
