Alur WhatsApp Masuk & Balasan AI
Alur paling sering dijalankan sistem: pasien mengirim pesan → tersimpan → AI membalas.
Gambaran besar
Kalau CRM menunggu AI selesai berpikir sebelum menjawab Jasnita, gerbang akan menganggap webhook timeout dan mengirim ulang pesan yang sama. Karena itu pesan dititipkan ke antrean pekerjaan dan webhook langsung menjawab. Konsekuensinya: harus ada queue worker yang hidup, kalau tidak pesan tersimpan tapi tidak pernah dibalas.
Tahap 1 — Webhook menerima pesan
Endpoint: POST /api/webhooks/whatsapp/{account} (WhatsappWebhookController@handle)
Verifikasi token
Token diambil dari kolom webhook_verify_token akun, dengan fallback ke WHATSAPP_WEBHOOK_VERIFY_TOKEN di .env. Kalau kedua-duanya kosong, verifikasi dilewati (tidak ada penjagaan).
Token dicari di tiga tempat, berurutan: header X-Webhook-Token → body token → query string ?token=.
Toschat tidak mengirim header autentikasi apa pun pada callback-nya. Karena itu URL webhook yang didaftarkan sistem berbentuk https://…/api/webhooks/whatsapp/{id}?token=XXXX.
Parser yang toleran
Bentuk payload gerbang WhatsApp Indonesia tidak seragam, jadi parser mencoba banyak jalur sekaligus:
| Yang dicari | Jalur yang dicoba |
|---|---|
| Simpul pesan | data.messages.0 → messages.0 → entry.0.changes.0.value.messages.0 → data.message → data → message → result |
| Nomor pengirim | from, sender, phone, number, wa_number, remoteJid, chatId, author |
| Isi teks | text.body, message, body, text, content, caption, conversation, message.conversation, message.extendedTextMessage.text |
| ID pesan | id, message_id, msg_id, wa_msg_id, key.id |
| Nama pengirim | name, sender_name, pushName, notify_name, contact_name |
| Media | media_filepath, media_mime_type, media_filename, media_caption |
Sufiks JID (628xxx@s.whatsapp.net, 628xxx@c.us) dipotong otomatis. Kalau isi teks kosong tapi ada caption media, caption dipakai sebagai teks.
Verifikasi kepemilikan (GET)
GET /api/webhooks/whatsapp/{account} mendukung dua gaya: gaya Meta (mengembalikan hub.challenge apa adanya) dan token sederhana (mengembalikan OK).
Tahap 2 — Job memproses pesan
Kelas: App\Jobs\ProcessInboundWhatsappMessage — 3 percobaan, jeda 10 detik.
Cara pesan disimpan
| Kolom | Nilai |
|---|---|
phone_number | dinormalkan: karakter non-digit dibuang, 08… → 62… |
direction | IN |
message_type | Media kalau ada lampiran, selain itu Text |
status | Delivered |
wa_msg_id | ID pesan dari gerbang — dipakai mencegah duplikat |
related_type / related_id | ai_agent / id AI Agent akun tersebut |
sent_by | kosong untuk pesan masuk (kolom ini hanya mengatribusi pesan keluar) |
Kalau gerbang mengirim webhook yang sama dua kali, baris kedua ditolak karena kombinasi wa_msg_id + direction='IN' sudah ada.
Pencatatan kontak sengaja dibungkus penjagaan sendiri: kegagalannya tidak boleh menjatuhkan job, karena pesannya sudah tersimpan dan balasan otomatis jauh lebih penting daripada satu baris kontak (yang toh akan disusul sapuan terjadwal).
Tahap 3 — Enam gerbang sebelum AI membalas
Gerbang 2 dijelaskan: takeover eksplisit
ChatTakeoverService::isHandledByHuman() memeriksa kolom contacts.handled_by. Kalau bernilai human, AI tidak membalas sama sekali — terlepas dari toggle "Stop AI When Human Chat" pada AI Agent.
- Eksplisit (gerbang 2): operator memutuskan mengambil alih. Terjadi otomatis begitu agen mengirim pesan manual dari Chat Rooms, atau lewat tombol ambil-alih. Ini keputusan sadar, jadi ia menimpa semua inferensi dan tidak bisa dibatalkan toggle mana pun.
- Implisit (gerbang 6): agen membalas tanpa menekan tombol apa pun, dan toggle "Stop AI When Human Chat" yang mengurusnya.
Percakapan dikembalikan ke AI lewat releaseToAi(), yang mengosongkan handled_by, assigned_user_id, dan chat_assigned_at.
Gerbang 6 dijelaskan: AiAgentBehaviorService
| Pemeriksaan | Aturan |
|---|---|
| Agent aktif | is_active harus true |
| AI Timer | Kalau timer nonaktif → selalu lolos. Kalau aktif tapi jam mulai/selesai belum lengkap → selalu ditolak. Rentang melewati tengah malam (mis. 22:00–06:00) didukung |
| Stop AI saat human chat | Kalau opsi ini nyala DAN ada pesan berlabel human setelah balasan bot terakhir → AI diam |
Bagaimana "human" dikenali di riwayat
Riwayat percakapan dipetakan tiga arah, memakai kolom whatsapp_messages.sent_by:
| Kondisi baris | Peran untuk behavior |
|---|---|
direction = IN | user |
sent_by = human | human |
lainnya (ai, system, kosong) | bot |
Dulu setiap pesan OUT dipetakan jadi bot, sehingga isHumanHandlingActive() tidak pernah bisa aktif — "Stop AI When Human Chat" hanya bekerja di simulator. Kolom sent_by inilah yang menghidupkannya.
Tahap 4 — Menyusun prompt
Prompt yang dikirim ke LLM terdiri dari maksimal 4 blok system + riwayat percakapan.
Hanya model READ (Dokter, Poliklinik, Paket) yang di-dump jadi teks. Model WRITE (Janji Temu, Keluhan, Survei) hanya dijadikan tool — kalau di-dump, keluhan dan survei pasien lain akan terbaca AI. Model sensitif (User, Pasien, Kontak, Pengaturan, Follow-up) tidak ada sama sekali di katalog.
Riwayat percakapan dibaca dari whatsapp_messages untuk nomor tersebut: 30 baris terakhir diambil, lalu dipotong ke 15 giliran. Pesan IN jadi peran user, pesan OUT jadi peran assistant.
Tahap 5 — Tool: AI yang bisa menulis data
Tool aktif hanya kalau admin mencentang key WRITE pada AI Agent.
Dua lapis penjagaan
Dulu penjagaannya hanya "skemanya tidak diiklankan ke model". Itu tidak cukup: model bisa menyebut nama tool yang tidak diiklankan (halusinasi, riwayat percakapan dari konfigurasi lama, atau prompt injection). Sekarang create_appointment menerbitkan invoice Duitku sungguhan — jadi nama tool yang salah berarti pasien ditagih untuk booking yang tak pernah ia minta.
Aturan keamanan tool
| Aturan | Alasan |
|---|---|
Tidak ada tool yang menerima patient_id | Identitas pasien selalu di-resolve server dari nomor percakapan. AI mustahil menyentuh data pelanggan lain |
| Tidak ada argumen nominal/harga | Tarif dibaca server dari DUITKU_RESERVATION_FEE. Prompt injection tidak bisa menawar biaya |
check_payment_status tanpa argumen | Booking mana yang dibaca ditentukan nomor percakapan, bukan model |
| Pasien dibuat otomatis bila belum ada | firstOrCreate berdasarkan nomor telepon, nama diambil dari pushName WhatsApp |
Isi playbook tool
Blok system tambahan yang ikut dikirim saat ada tool aktif:
- Booking: kumpulkan dokter, poliklinik, tanggal (YYYY-MM-DD), dan jam (HH:MM) dulu. Jangan menebak.
- Setelah
create_appointmentberhasil, salinpayment_urlPERSIS apa adanya. Dilarang memendekkan, mengubah, atau mengarang link.- Sebutkan nominal dan batas waktu dari hasil tool. DILARANG mengarang nominal, nomor virtual account, atau status pembayaran.
- Janji temu BELUM berlaku sebelum dibayar.
- Kalau pelanggan mengaku sudah membayar, panggil
check_payment_status.- Kalau hasil tool
okbernilaifalse, sampaikan kendalanya apa adanya dan tawarkan bantuan agen manusia.
Tahap 6 — Mengirim balasan
Setelah balasan selesai, sistem mencatat:
- Snapshot pemakaian terakhir ke kolom
llm_last_usage_snapshotpada AI Agent (untuk monitoring di UI). - Satu baris
ai_token_usagesberisi: agent, platform (whatsapp), nomor klien, pesan user, balasan, token input/output/total, provider, model. Kegagalan pencatatan hanya dilog dan tidak mengganggu balasan.
Mode testing (WHATSAPP_TEST_MODE)
Saklar global yang mengubah perilaku pengiriman dan balasan.
Whitelist diisi per akun di kolom "Whitelist Balasan AI" (ai_reply_whitelist), satu nomor per baris atau dipisah koma/titik-koma. Nomor dinormalkan sama seperti di seluruh sistem (08… → 62…).
false di produksiKalau mode testing menyala di produksi dengan whitelist kosong, semua pesan keluar diblokir — termasuk link pembayaran dan konfirmasi lunas.
Penyedia LLM yang didukung
| Provider | Nilai llm_provider | Model default | Halaman API key |
|---|---|---|---|
| OpenAI (ChatGPT) | openai | gpt-4o-mini | platform.openai.com/api-keys |
| Anthropic (Claude) | anthropic | claude-3-5-sonnet-20241022 | console.anthropic.com/settings/keys |
| Google Gemini | google_gemini | gemini-2.5-flash-lite | aistudio.google.com/apikey |
| Mistral AI | mistral | mistral-small-latest | console.mistral.ai/api-keys |
| Groq | groq | llama-3.3-70b-versatile | console.groq.com/keys |
API key disimpan terenkripsi di kolom llm_api_key, dan dikecualikan dari dump knowledge.
Semua titik gagal & penanganannya
| Titik gagal | Perilaku sistem | Jejak yang ditinggalkan |
|---|---|---|
| Token webhook salah | 403, pesan dibuang | — |
| Payload tidak bisa diurai | 200 + ignored:true | — |
| Akun tidak ditemukan di job | Job berhenti | Log::warning "akun tidak ditemukan" |
| Percakapan sudah diambil alih agen | Tidak dibalas AI | Log::info "percakapan dipegang agen human" |
| Pencatatan kontak gagal | Tetap lanjut ke AI | Log::warning "Gagal mencatat kontak" |
| Salah satu dari 5 gerbang menolak | Tidak dibalas, pesan tetap tersimpan | Log::info dengan alasannya |
| Panggilan LLM error | Balasan kosong, tidak ada pesan keluar | Log::warning "gagal memanggil LLM" |
| Tool di luar allowlist | Ditolak sebelum eksekusi | Log::warning "Tool AI di luar allowlist" |
| Eksekusi tool error | Balik ok:false ke model | Log::warning "AiAgentDataToolExecutor gagal" |
| Gerbang menolak kirim | Gelembung itu tidak dicatat | Log::warning "gagal mengirim pesan" |
| Pencatatan token gagal | Balasan tetap terkirim | Log::warning "Gagal mencatat token usage" |