Alur Chat Rooms (Agen Manusia)
Panel agen di /admin/chat-rooms. Dari sisi teknis, halaman ini adalah live proxy ke ChatPanel Jasnita — bukan pembaca database lokal.
Arsitektur: baca dari Jasnita, tulis ke Jasnita
| Lapisan | Tanggung jawab | Perilaku saat error |
|---|---|---|
ChatPanelService | Baca daftar percakapan, thread, jumlah per tab, tandai dibaca; menempelkan pemegang & label | Menelan semua error → kembalikan kosong, panel tidak pernah fatal |
ChatRoomService | Kirim teks / media / template, catat ke whatsapp_messages, klaim percakapan | Melempar ke UI → notifikasi merah ke agen |
ChatHandlerResolver | Menyimpulkan siapa yang terakhir membalas: AI, agen, bot Jasnita, atau belum ada | Menelan error → "belum dibalas" |
ChatTakeoverService | Menyimpan siapa yang memegang percakapan (contacts.handled_by) | — |
ChatLabelService | Label percakapan (global untuk seluruh tim) | — |
ToschatClient | OAuth2, HTTP, retry 401, endpoint | Melempar RuntimeException dengan potongan body |
Pemuatan halaman: sengaja ditunda
Kalau data ditarik saat render pertama, halaman memblokir menunggu balasan API Jasnita — agen melihat layar putih beberapa detik. Sekarang kerangka halaman muncul instan, data menyusul.
Cache: daftar percakapan & thread 5 detik, jumlah per tab 20 detik. Ini mencegah puluhan agen yang polling membanjiri API Jasnita.
Membaca percakapan
Tiga tab
| Tab UI | Status Jasnita |
|---|---|
| Queue | queue |
| Assigned | assigned |
| Solved | solved |
Ganti tab akan mereset percakapan terpilih, thread, halaman, dan panel detail — lalu memuat ulang.
Bentuk data yang dinormalkan
Respons ChatPanel diubah jadi bentuk siap-render:
| Field | Sumber | Catatan |
|---|---|---|
id | conversation_id | — |
name | contact.name, fallback ke nomor | — |
number | contact.contact_id | — |
last_message | message, fallback 📎 Media | — |
last_time | created_at | Sudah WIB dari Jasnita — tidak digeser lagi |
unread | unread_count | — |
responded | last_chat_by > 0 atau is_read = '1' | — |
handled_by | ChatTakeoverService → fallback ChatHandlerResolver | Lihat bagian di bawah |
labels | ChatLabelService::labelsFor() | Satu query untuk seluruh halaman |
Pesan dalam thread ditandai outbound: true kalau sender sama dengan gateway_account_id akun kita, lalu diurutkan berdasarkan created_at.
Siapa yang memegang percakapan
Dua lapisan berbeda, dan urutannya penting.
Kenapa serumit ini
Tiga fakta dari API Jasnita yang sudah diverifikasi langsung:
- Daftar percakapan tidak membawa identitas pengirim sama sekali.
last_chat_byhanya bernilai 0/1, dan0berarti "pelanggan yang bicara terakhir" — bukan "belum pernah dibalas". Data nyata membuktikannya: ada percakapan berisi "baik kak terima kasih" dari pelanggan (jelas sudah dilayani agen) yang tetap bernilai 0. Karena itu identitas hanya bisa diambil dariget-detail. user_idtidak bisa memisahkan AI dari manusia. Kiriman lewat API kita tercatat denganuser_idakun API, dan agen manusia yang login ke panel Jasnita memakai akun API yang sama menghasilkanuser_idyang persis sama.user_id = 0pada pesan keluar adalah autoresponder Jasnita, bukan AI kita — AI kita selalu lewat API sehingga selalu punyauser_idakun API.
Yang bisa dipercaya adalah id pesan: setiap pesan yang kita kirim tersimpan lokal beserta wa_msg_id-nya, jadi pesan keluar yang tidak punya padanan lokal pasti diketik orang di panel Jasnita.
Sebelumnya ketiadaan data diam-diam diklaim sebagai "AI" — itu yang membuat setiap percakapan tampak dipegang AI. Sekarang ia dilaporkan sebagai "belum dibalas".
Cache per id pesan terakhir
resolve() harus memanggil get-detail untuk setiap baris percakapan — mahal kalau dilakukan tiap polling. Karena itu hasilnya di-cache dengan kunci conversation_id + id pesan terakhir (TTL 1 jam):
Ambil alih percakapan (takeover)
| Sifat | Keterangan |
|---|---|
| Otomatis | Mengirim pesan manual dari Chat Rooms langsung mengklaim percakapan — tidak ada langkah tambahan yang harus diingat |
| Menimpa semua | Takeover eksplisit memblokir auto-reply terlepas dari toggle "Stop AI When Human Chat" |
| Lokal saja | Tidak disinkronkan ke Jasnita. Tab Queue/Assigned/Solved tetap milik Jasnita; lapisan takeover berdiri sendiri |
| Tidak ada baris = dipegang AI | Kontak yang belum pernah ada tidak perlu dibuat saat melepas ke AI |
Label percakapan
Label buatan pengguna sendiri (mis. "sudah dibalas" / "belum dibalas"), global untuk seluruh tim.
| Keputusan desain | Alasan |
|---|---|
Menempel ke contacts.contact_id, bukan conversation_id Jasnita | Nomor kontak unik dan stabil; conversation_id bisa berganti antar sesi sehingga label akan "hilang" |
| Nama label unik (case-insensitive) | Dua agen yang mengetik "Sudah dibalas" harus berakhir di label yang sama, bukan dua label kembar yang membuat filter setengah-setengah |
| Palet warna dibatasi 7 pilihan | Tampilan daftar percakapan tetap konsisten |
| Kelas warna didefinisikan di model | Daftar percakapan, header thread, dan pemilih label memakai palet yang persis sama |
Label dikelola lewat resource Chat Labels (/admin/chat-labels), dan bisa dipasang/dilepas langsung dari panel Chat Rooms.
Pencarian
Pencarian bersifat client-side atas daftar yang sudah dimuat — tidak memanggil API. Cocokkan pada nama (case-insensitive) atau nomor. Tombol "muat lebih banyak" menambah halaman dan memuat ulang halaman 1..N.
Menandai sudah dibaca
Saat agen memilih percakapan, sistem otomatis memanggil mark-as-read-by-conversation-id ke Jasnita, lalu menyegarkan daftar & jumlah. Kegagalannya hanya dilog — tidak mengganggu agen.
Mengirim pesan
Meta hanya mengizinkan pesan bebas kalau pelanggan mengirim pesan dalam 24 jam terakhir. Di luar jendela itu Meta membuang pesan sementara Toschat tetap membalas status "pending" — jadi sistem tidak bisa tahu pesannya gagal. Untuk memulai percakapan setelah lewat 24 jam, satu-satunya cara sah adalah template.
Semua pesan keluar dicatat lokal
| Jalur | Metode | sent_by |
|---|---|---|
| Balas cepat di thread | sendTextToNumber / sendMediaToNumber | human + sent_by_user_id |
| Template & media ke Contact | sendTemplate / sendMedia | human + sent_by_user_id |
| Balasan otomatis AI | WhatsappAutoReplyService | ai |
| Notifikasi pembayaran | BookingConfirmationNotifier | (kosong — diperlakukan sebagai bot) |
Panel membaca tampilannya dari Jasnita, jadi mencatat lokal terasa mubazir. Tapi whatsapp_messages adalah satu-satunya riwayat yang dilihat AI. Tanpa catatan ini AI tidak pernah tahu ada agen human yang menjawab, lalu ikut membalas di percakapan yang sudah diambil alih.
Nomornya juga harus disimpan dalam bentuk ternormalisasi (08… → 62…), karena itulah bentuk yang dicari WhatsappAutoReplyService::loadHistory(). Bentuk lain tidak akan terbaca.
Media: kenapa lewat proksi
Berkas media di Jasnita hanya bisa diunduh dengan Bearer token. Kalau <img src> menunjuk langsung ke Jasnita, token harus ditempel di URL dan bocor ke riwayat browser. Proksi ini menjaga token tetap server-side, sekaligus memaksa pemeriksaan izin per permintaan.
Buat pembayaran booking manual
Tombol di panel detail. Berbeda dengan booking lewat AI: tidak membawa jadwal.
Karena tidak ada BookingRequest, saat callback lunas nanti listener akan berakhir di outcome none → pasien menerima pesan "Pembayaran reservasi Anda telah kami terima" tanpa jadwal, dan Log::warning "Pembayaran lunas tanpa draft booking" ditulis.
none punya dua artiSelain reservasi manual yang wajar, none juga tanda tangan jendela sempit "invoice Duitku berhasil, insert draft gagal" — yaitu uang masuk tanpa booking. Keduanya tidak bisa dibedakan dari listener, jadi semuanya dilog supaya ops bisa memeriksanya.
Autentikasi ke Jasnita
Token berlaku 24 jam. Kredensial disimpan per akun WhatsApp dan terenkripsi di database (gateway_client_secret, gateway_password).
Endpoint Toschat yang dipakai
| Endpoint | Fungsi |
|---|---|
POST /api/v1/oauth/token | Ambil access token |
POST /api/v1/chat/send | Kirim teks / media / template |
GET /api/v1/message-template/get | Daftar template |
POST /api/v1/chat/mark-as-read | Tandai satu pesan dibaca |
POST /api/v1/chat/mark-as-read-by-conversation-id | Tandai satu percakapan dibaca |
GET /api/v1/file?p= | Unduh media |
POST /api/v1/webhook/update | Daftarkan URL webhook |
GET /api/v1/subscriptions | Daftar channel aktif (uji koneksi) |
GET /api/v1/contact | Daftar kontak (paginasi) |
GET /api/v1/chatpanel/get-with-filter/{status}/... | Daftar percakapan per status |
POST /api/v1/chatpanel/get-detail | Isi thread percakapan |
GET /api/v1/chatpanel/get-status-count-with-status-filter/... | Jumlah per status |
Endpoint chatpanel/* dan GET /api/v1/contact adalah endpoint internal ChatPanel yang tidak ada di koleksi Postman publik Toschat, tetapi bisa dipanggil dengan token OAuth kita. Folder "Contacts" yang terdokumentasi hanya menyediakan active-conversation per nomor, sehingga tidak bisa dipakai sinkronisasi massal.
Kontrol akses
Halaman Chat Rooms dijaga permission Shield View:ChatRoomsPage. super_admin selalu punya akses. Detail di Hak Akses.
Prasyarat agar Chat Rooms berfungsi
Kalau salah satu tidak terpenuhi, gejalanya ada di tabel troubleshooting Alur Harian Agen Chat.