Skip to content

10.3 Chatbot WhatsApp CS

Topologi: A — Agent tunggal Materi yang dipakai: 3.1, 1.3, 9.2Prasyarat: 10.2

Masalahnya

Asisten HR dari kasus sebelumnya bekerja, tapi hanya lewat skrip. Sekarang ia harus dipakai 300 karyawan lewat WhatsApp.

Dua masalah baru muncul sekaligus.

Pertama, agent pelupa. Di WhatsApp orang tidak mengetik NIK setiap pesan. Percakapan nyatanya begini:

text
Karyawan : Halo, sisa cuti saya berapa? NIK saya K-001
Bot      : Sisa cuti kamu 7 hari.
Karyawan : kalau reimburse gimana?          ← NIK tidak diulang

Pesan kedua tidak bisa dijawab agent dari kasus 10.2 — ia tidak tahu "saya" itu siapa.

Kedua, ada webhook. WhatsApp tidak memanggil agent.generate(). Ia mengirim HTTP POST ke endpoint-mu, dan menunggu balasan cepat.

Keputusan arsitektur

Keputusan 1 — Nomor telepon jadi thread atau resource?

Ini keputusan yang paling sering salah, dan paling mahal diperbaiki.

Ingat dari 3.1: resource adalah pemilik, thread adalah satu sesi percakapan.

PilihanAkibatnya
thread = nomor, resource = nomorSatu percakapan abadi per nomor. Sederhana
thread = nomor + tanggal, resource = nomorPercakapan direset harian, profil tetap
thread = nomor, resource = 'user'Semua karyawan berbagi ingatan

Pilihan: thread = nomor, resource = nomor untuk versi pertama.

Alasannya: WhatsApp sendiri tidak punya konsep "sesi berakhir". Pengguna menganggap chat-nya satu utas berkelanjutan, dan sistem sebaiknya cocok dengan harapan itu.

UntungRugi
Sesuai model mental pengguna WhatsAppThread tumbuh tanpa batas
Tidak ada logika "kapan sesi baru"Percakapan lama bisa mengganggu konteks
Konteks tidak pernah hilang di tengahButuh strategi saat thread jadi sangat panjang

Baris ketiga tabel itu bukan hipotetis

Memakai nilai resource yang sama untuk semua orang — misalnya 'user' atau 'default' — adalah kebocoran data. Semua karyawan akan berbagi satu ingatan, dan begitu working memory aktif di 10.4, profil satu orang terbaca orang lain.

resource harus unik per pengguna, dan harus berasal dari sumber yang tidak bisa dipalsukan. Di webhook WhatsApp, nomor pengirim datang dari penyedia — bukan dari isi pesan yang bisa diketik siapa saja.

Keputusan 2 — Balas langsung di webhook, atau balas belakangan?

Ini yang menentukan apakah sistemmu bertahan saat ramai.

Balas di dalam webhookTerima dulu, balas asinkron
KerumitanRendahPerlu antrian / task terpisah
Risiko timeoutTinggi — agent bisa 5–10 detikNol
Penyedia retry kalau lambatYa, dan pesan bisa diproses gandaTidak
Cocok untukPrototipe, trafik rendahProduksi

Pilihan: terima dulu, balas asinkron.

Alasannya konkret: penyedia WhatsApp umumnya memberi batas waktu beberapa detik untuk webhook. Agent yang memanggil dua tool bisa memakan lebih dari itu — dan saat webhook timeout, penyedia mengirim ulang pesan yang sama, sehingga karyawan menerima dua balasan.

Polanya: webhook langsung membalas 200 OK, lalu pekerjaan berjalan di belakang dan hasilnya dikirim lewat API WhatsApp.

Keputusan 3 — Perlu streaming?

Pilihan: tidak.

Ini penting disebut karena mudah tergoda memakai stream() — kita sudah belajar itu di 1.3.

WhatsApp mengirim pesan sebagai satu blok utuh. Tidak ada mekanisme menampilkan teks yang mengalir. Streaming di sini hanya menambah kerumitan tanpa manfaat apa pun — pakai generate().

Streaming baru berguna kalau kanalnya web chat, tempat teks bisa muncul bertahap di layar.

Struktur direktori

text
hr-whatsapp/
├── src/
│   ├── domain/
│   │   └── karyawan.ts          ← dari kasus 10.2, tidak berubah
│   ├── infra/
│   │   └── whatsapp.ts          ← BARU: kirim pesan keluar
│   ├── server/
│   │   └── webhook.ts           ← BARU: terima pesan masuk
│   └── mastra/
│       ├── agents/
│       │   └── wa-agent.ts      ← agent + memory
│       ├── tools/
│       │   ├── cek-sisa-cuti.ts
│       │   └── cek-status-reimburse.ts
│       └── index.ts             ← + storage
├── .env
└── package.json

Dua direktori baru, dan pemisahannya disengaja:

  • infra/ — bicara dengan dunia luar (API WhatsApp). Kalau besok pindah ke penyedia lain, hanya berkas ini yang berubah.
  • server/ — menerima dari dunia luar. Ia tidak tahu cara memanggil API WhatsApp, dan infra/ tidak tahu ada webhook.

Alur

Perhatikan 200 OK dikirim sebelum agent dipanggil. Itu inti keputusan 2.

Implementasi

src/mastra/agents/wa-agent.ts

ts
import { Agent } from '@mastra/core/agent'
import { Memory } from '@mastra/memory'
import { cekSisaCuti } from '../tools/cek-sisa-cuti.ts'
import { cekStatusReimburse } from '../tools/cek-status-reimburse.ts'

export const waAgent = new Agent({
  id: 'wa-agent',
  name: 'WA Agent',
  instructions: `Kamu asisten HR yang melayani karyawan lewat WhatsApp.

Jawab ringkas — ini chat, bukan email. Maksimal 3 kalimat kecuali diminta rinci.

Pakai cekSisaCuti untuk kuota cuti, cekStatusReimburse untuk reimbursement.

Kalau NIK sudah disebut sebelumnya dalam percakapan ini, pakai NIK itu tanpa
bertanya lagi. Kalau belum pernah disebut sama sekali, tanyakan sekali.`,
  model: 'openai/gpt-5-mini',
  tools: { cekSisaCuti, cekStatusReimburse },
  memory: new Memory({
    options: { lastMessages: 10 },
  }),
})

Dua kalimat instruksi terakhir yang membuat percakapan terasa wajar. Tanpa itu, agent cenderung menanyakan NIK ulang meski sudah ada di riwayat.

lastMessages: 10 cukup untuk WhatsApp — orang jarang merujuk sesuatu dari lebih dari lima giliran ke belakang dalam chat.

src/mastra/index.ts

ts
import { Mastra } from '@mastra/core'
import { LibSQLStore } from '@mastra/libsql'
import { waAgent } from './agents/wa-agent.ts'

export const mastra = new Mastra({
  agents: { waAgent },
  storage: new LibSQLStore({
    id: 'mastra-storage',
    url: process.env.TURSO_DATABASE_URL || 'file:./mastra.db',
    authToken: process.env.TURSO_AUTH_TOKEN || undefined,
  }),
  server: {
    port: Number(process.env.PORT) || 4111,
    host: '0.0.0.0',
  },
})

Storage sekarang wajib — tanpa itu memory tidak punya tempat tinggal. Pola process.env || file: dari 9.1 memberi berkas lokal saat pengembangan dan Turso saat produksi.

host: '0.0.0.0' diperlukan karena ini akan berjalan di container.

src/infra/whatsapp.ts

ts
// Satu-satunya berkas yang tahu bentuk API WhatsApp.
const API = 'https://graph.facebook.com/v21.0'

export async function kirimPesan(nomor: string, teks: string): Promise<void> {
  const res = await fetch(`${API}/${process.env.WA_PHONE_ID}/messages`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.WA_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      messaging_product: 'whatsapp',
      to: nomor,
      type: 'text',
      text: { body: teks },
    }),
  })

  if (!res.ok) {
    // Dicatat, tidak dilempar — kegagalan kirim tidak boleh
    // menjatuhkan proses yang sudah selesai bekerja.
    console.error('gagal kirim WA', res.status, await res.text())
  }
}

/** Ambil nomor & teks dari payload webhook. Bentuknya bersarang dalam. */
export function bacaPesanMasuk(body: unknown): { nomor: string; teks: string } | null {
  const pesan = (body as any)?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]
  if (!pesan || pesan.type !== 'text') return null
  return { nomor: pesan.from, teks: pesan.text.body }
}

bacaPesanMasuk mengembalikan null alih-alih melempar. Webhook menerima banyak jenis notifikasi — status terkirim, status dibaca — yang bukan pesan teks. Semua itu normal dan harus diabaikan diam-diam.

src/server/webhook.ts

ts
import { mastra } from '../mastra/index.ts'
import { kirimPesan, bacaPesanMasuk } from '../infra/whatsapp.ts'

/** Diproses setelah 200 OK dikirim. */
async function prosesPesan(nomor: string, teks: string): Promise<void> {
  const agent = mastra.getAgentById('wa-agent')

  const hasil = await agent.generate(teks, {
    memory: {
      resource: `wa:${nomor}`,   // pemilik  — dari penyedia, bukan isi pesan
      thread: `wa:${nomor}`,     // percakapan
    },
    maxSteps: 5,
  })

  await kirimPesan(nomor, hasil.text)
}

export async function handleWebhook(body: unknown): Promise<void> {
  const pesan = bacaPesanMasuk(body)
  if (!pesan) return

  // Sengaja TIDAK di-await: pemanggil harus segera membalas 200.
  void prosesPesan(pesan.nomor, pesan.teks).catch(err => {
    console.error('gagal memproses pesan', err)
  })
}

Tiga hal penting di berkas ini.

void ... .catch(...) — pekerjaan dilepas tanpa ditunggu, tapi kegagalannya tetap ditangkap. Tanpa .catch(), kegagalan menjadi unhandled rejection yang bisa menjatuhkan proses.

resource dan thread diberi awalan wa: — supaya kalau nanti ada kanal Telegram, pengenalnya tidak bertabrakan.

maxSteps: 5 — batas loop dari 1.3. Di kanal yang dipakai 300 orang, agent yang berputar tanpa batas adalah masalah biaya nyata.

Hasil nyata

Dijalankan sungguhan dengan storage libSQL. Perhatikan pesan kedua:

text
> Halo, sisa cuti saya berapa? NIK saya K-001
      [tool cek-sisa-cuti(K-001)]
< Halo Sari, sisa cuti kamu 7 hari. Butuh bantuan lain?

> kalau reimburse gimana?
      [tool cek-reimburse(K-001)]
< Untuk NIK K-001 (Sari): status reimbursement kamu "diproses",
  jumlah Rp450.000. Estimasi selesai biasanya 3-5 hari kerja.

Pesan kedua tidak menyebut NIK sama sekali, tapi tool dipanggil dengan K-001. Itu bukti message history bekerja: agent membaca NIK dari riwayat percakapan, bukan dari pesan yang baru masuk.

Bandingkan dengan kasus 10.2 tanpa memory — pesan kedua di sana akan dijawab dengan pertanyaan balik "NIK Anda berapa?".

Best practice yang dipakai di sini

1. Identitas dari penyedia, bukan dari isi pesan. Nomor pengirim datang dari payload webhook yang ditandatangani penyedia. Kalau resource diambil dari NIK yang diketik pengguna, siapa pun bisa membaca data orang lain.

2. Satu berkas untuk satu batas sistem. infra/whatsapp.ts adalah satu-satunya tempat yang tahu bentuk API WhatsApp. Pindah penyedia berarti mengganti satu berkas.

3. Webhook tidak boleh lambat. Balas dulu, kerja belakangan. Ini pola umum semua integrasi webhook, bukan khas Mastra.

4. Awalan pada pengenal. wa:628123 alih-alih 628123 mencegah tabrakan saat kanal bertambah — dan membuat pengenalnya bisa dibaca manusia saat menelusuri trace.

Kapan pola ini salah

SituasiKenapa gagalKe mana
Perlu ingat preferensi lintas hari, bukan cuma percakapanlastMessages bergulir, yang lama hilang10.4 Telegram
Thread jadi ribuan pesanKonteks membengkak, biaya naik3.4 Observational memory
Pengguna menanyakan isi dokumen kebijakan panjangTool tidak cocok untuk pencarian teks10.5 Helpdesk
Bot boleh mengajukan cutiMengubah keadaan, butuh persetujuan10.9 Approval
300 orang mengirim bersamaanlibSQL berkas tidak cukup untuk multi-proses10.11 Skala besar

Coba sendiri

Tambahkan kanal kedua — Telegram — ke agent yang sama, tanpa menduplikasi kode agent.

Selesai kalau

  • [ ] Satu agent melayani dua kanal
  • [ ] Pengenal tidak bertabrakan: wa:628123 dan tg:9911 terpisah
  • [ ] Hanya ada satu berkas baru di infra/ dan satu di server/
  • [ ] Kode agent tidak berubah sama sekali
  • [ ] Percakapan di WhatsApp tidak terlihat di Telegram meski orangnya sama

Kalau macet

Agent tetap bertanya NIK padahal sudah disebut? Periksa memory benar diteruskan di generate(), dan thread bernilai sama antar pesan. Kalau thread diisi nilai acak tiap pesan, tiap pesan mendarat di percakapan berbeda.

Balasan datang dua kali? Webhook-mu terlalu lambat dan penyedia mengirim ulang. Pastikan 200 OK dikirim sebelum agent dipanggil.

Lanjut ke mana

Agent ini ingat percakapan, tapi lupa orangnya begitu topik berganti. Kasus berikutnya membuatnya mengingat preferensi lintas sesi — 10.4 Bot Telegram Personal.

Materi belajar mandiri. Bukan dokumentasi resmi Mastra.