Lewati ke konten utama

Alur WhatsApp Masuk & Balasan AI

Alur paling sering dijalankan sistem: pasien mengirim pesan → tersimpan → AI membalas.


Gambaran besar

Kenapa webhook langsung menjawab 200?

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=.

Kenapa lewat query string?

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 dicariJalur yang dicoba
Simpul pesandata.messages.0messages.0entry.0.changes.0.value.messages.0data.messagedatamessageresult
Nomor pengirimfrom, sender, phone, number, wa_number, remoteJid, chatId, author
Isi tekstext.body, message, body, text, content, caption, conversation, message.conversation, message.extendedTextMessage.text
ID pesanid, message_id, msg_id, wa_msg_id, key.id
Nama pengirimname, sender_name, pushName, notify_name, contact_name
Mediamedia_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

KolomNilai
phone_numberdinormalkan: karakter non-digit dibuang, 08…62…
directionIN
message_typeMedia kalau ada lampiran, selain itu Text
statusDelivered
wa_msg_idID pesan dari gerbang — dipakai mencegah duplikat
related_type / related_idai_agent / id AI Agent akun tersebut
sent_bykosong untuk pesan masuk (kolom ini hanya mengatribusi pesan keluar)
Anti-duplikat

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.

Takeover eksplisit vs implisit
  • 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

PemeriksaanAturan
Agent aktifis_active harus true
AI TimerKalau 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 chatKalau 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 barisPeran untuk behavior
direction = INuser
sent_by = humanhuman
lainnya (ai, system, kosong)bot
Sebelumnya fitur ini mati total di WhatsApp

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.

Data model WRITE tidak pernah masuk knowledge

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

Kenapa guard() ada

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

AturanAlasan
Tidak ada tool yang menerima patient_idIdentitas pasien selalu di-resolve server dari nomor percakapan. AI mustahil menyentuh data pelanggan lain
Tidak ada argumen nominal/hargaTarif dibaca server dari DUITKU_RESERVATION_FEE. Prompt injection tidak bisa menawar biaya
check_payment_status tanpa argumenBooking mana yang dibaca ditentukan nomor percakapan, bukan model
Pasien dibuat otomatis bila belum adafirstOrCreate 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_appointment berhasil, salin payment_url PERSIS 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 ok bernilai false, sampaikan kendalanya apa adanya dan tawarkan bantuan agen manusia.

Tahap 6 — Mengirim balasan

Setelah balasan selesai, sistem mencatat:

  1. Snapshot pemakaian terakhir ke kolom llm_last_usage_snapshot pada AI Agent (untuk monitoring di UI).
  2. Satu baris ai_token_usages berisi: 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…).

Wajib false di produksi

Kalau mode testing menyala di produksi dengan whitelist kosong, semua pesan keluar diblokir — termasuk link pembayaran dan konfirmasi lunas.


Penyedia LLM yang didukung

ProviderNilai llm_providerModel defaultHalaman API key
OpenAI (ChatGPT)openaigpt-4o-miniplatform.openai.com/api-keys
Anthropic (Claude)anthropicclaude-3-5-sonnet-20241022console.anthropic.com/settings/keys
Google Geminigoogle_geminigemini-2.5-flash-liteaistudio.google.com/apikey
Mistral AImistralmistral-small-latestconsole.mistral.ai/api-keys
Groqgroqllama-3.3-70b-versatileconsole.groq.com/keys

API key disimpan terenkripsi di kolom llm_api_key, dan dikecualikan dari dump knowledge.


Semua titik gagal & penanganannya

Titik gagalPerilaku sistemJejak yang ditinggalkan
Token webhook salah403, pesan dibuang
Payload tidak bisa diurai200 + ignored:true
Akun tidak ditemukan di jobJob berhentiLog::warning "akun tidak ditemukan"
Percakapan sudah diambil alih agenTidak dibalas AILog::info "percakapan dipegang agen human"
Pencatatan kontak gagalTetap lanjut ke AILog::warning "Gagal mencatat kontak"
Salah satu dari 5 gerbang menolakTidak dibalas, pesan tetap tersimpanLog::info dengan alasannya
Panggilan LLM errorBalasan kosong, tidak ada pesan keluarLog::warning "gagal memanggil LLM"
Tool di luar allowlistDitolak sebelum eksekusiLog::warning "Tool AI di luar allowlist"
Eksekusi tool errorBalik ok:false ke modelLog::warning "AiAgentDataToolExecutor gagal"
Gerbang menolak kirimGelembung itu tidak dicatatLog::warning "gagal mengirim pesan"
Pencatatan token gagalBalasan tetap terkirimLog::warning "Gagal mencatat token usage"