/** * chatbot.service.ts * Logic utama chatbot — mengelola conversation, tool execution, dan confirmation flow. */ import { chatCompletion, type PesanChat } from '../utils/ai-client'; import { bangunSystemPrompt } from '../utils/chatbot-context'; import { TOOL_HANDLERS, getToolsForRole } from '../utils/chatbot-tools'; // ═══════════════════════════════════ // TYPES // ═══════════════════════════════════ interface UserInfo { id: number; nama: string; role: string; } interface PendingAction { toolName: string; params: any; userId: number; preview: string; createdAt: Date; } interface ChatbotResponse { balasan: string; konfirmasi?: { aksiId: string; preview: string; toolName: string; }; } // ═══════════════════════════════════ // IN-MEMORY STORES // ═══════════════════════════════════ // Conversation history per user (max 20 messages) const conversationStore = new Map(); const MAX_HISTORY = 20; // Pending confirmations per user const pendingActions = new Map(); // ═══════════════════════════════════ // HELPERS // ═══════════════════════════════════ function getConversation(userId: number): PesanChat[] { return conversationStore.get(userId) || []; } function addToConversation(userId: number, message: PesanChat) { const history = getConversation(userId); history.push(message); // Trim to max history (keep system prompt if exists) if (history.length > MAX_HISTORY) { history.splice(0, history.length - MAX_HISTORY); } conversationStore.set(userId, history); } function generateAksiId(): string { return `aksi_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; } // ═══════════════════════════════════ // MAIN SERVICE // ═══════════════════════════════════ export class ChatbotService { /** * Proses pesan dari user dan dapatkan jawaban AI. */ static async kirimPesan( user: UserInfo, pesan: string, ip: string, userAgent: string ): Promise { // 1. Bangun system prompt dengan konteks toko const systemPrompt = await bangunSystemPrompt(user.nama, user.role); // 2. Siapkan messages const history = getConversation(user.id); const messages: PesanChat[] = [ { role: 'system', content: systemPrompt }, ...history, { role: 'user', content: pesan }, ]; // 3. Simpan pesan user ke history addToConversation(user.id, { role: 'user', content: pesan }); // 4. Ambil tools berdasarkan role const tools = getToolsForRole(user.role); // 5. Kirim ke AI try { const result = await chatCompletion(messages, tools); // 6. Jika AI mau panggil tool if (result.tool_calls && result.tool_calls.length > 0) { return await this.handleToolCalls(result, messages, user, ip, userAgent); } // 7. Jika jawaban langsung (text) const balasan = result.balasan || 'Maaf, saya tidak bisa merespons saat ini. Coba lagi nanti.'; addToConversation(user.id, { role: 'assistant', content: balasan }); return { balasan }; } catch (err: any) { console.error('Chatbot AI Error:', err); const errorMsg = 'Maaf, terjadi gangguan pada sistem AI. Silakan coba lagi dalam beberapa saat. 🔄'; addToConversation(user.id, { role: 'assistant', content: errorMsg }); return { balasan: errorMsg }; } } /** * Handle tool calls dari AI response. */ private static async handleToolCalls( aiResult: any, messages: PesanChat[], user: UserInfo, ip: string, userAgent: string ): Promise { const toolCall = aiResult.tool_calls![0]; // Handle satu tool call pertama const handler = TOOL_HANDLERS[toolCall.nama]; if (!handler) { const balasan = `Tool "${toolCall.nama}" tidak ditemukan.`; addToConversation(user.id, { role: 'assistant', content: balasan }); return { balasan }; } // Cek akses admin if (handler.adminOnly && user.role !== 'admin') { const balasan = `Maaf, fitur ini hanya bisa diakses oleh admin. Silakan hubungi admin toko Anda. 🔒`; addToConversation(user.id, { role: 'assistant', content: balasan }); return { balasan }; } // Jika butuh konfirmasi → return preview dulu if (handler.butuhKonfirmasi) { const aksiId = generateAksiId(); // Buat preview text const preview = this.buildPreview(toolCall.nama, toolCall.argumen); pendingActions.set(aksiId, { toolName: toolCall.nama, params: toolCall.argumen, userId: user.id, preview, createdAt: new Date(), }); // Auto-expire after 5 minutes setTimeout(() => pendingActions.delete(aksiId), 5 * 60 * 1000); const balasan = `${preview}\n\nKonfirmasi aksi di atas?`; addToConversation(user.id, { role: 'assistant', content: balasan }); return { balasan, konfirmasi: { aksiId, preview, toolName: toolCall.nama, }, }; } // Tidak butuh konfirmasi → eksekusi langsung return await this.executeToolAndRespond(toolCall, messages, user, ip, userAgent); } /** * Eksekusi tool dan dapatkan response final dari AI. */ private static async executeToolAndRespond( toolCall: any, messages: PesanChat[], user: UserInfo, ip: string, userAgent: string ): Promise { const handler = TOOL_HANDLERS[toolCall.nama]; // Eksekusi tool let toolResult: string; try { toolResult = await handler.handler(toolCall.argumen, user.id, ip, userAgent); } catch (err: any) { toolResult = `Error saat menjalankan tool: ${err.message}`; } // Kirim hasil tool ke AI untuk format jawaban const updatedMessages: PesanChat[] = [ ...messages, { role: 'assistant', content: null as any, tool_calls: [ { id: toolCall.id, type: 'function', function: { name: toolCall.nama, arguments: JSON.stringify(toolCall.argumen), }, }, ], }, { role: 'tool', content: toolResult, tool_call_id: toolCall.id, }, ]; try { const finalResult = await chatCompletion(updatedMessages); const balasan = finalResult.balasan || toolResult; addToConversation(user.id, { role: 'assistant', content: balasan }); return { balasan }; } catch (err) { // Fallback: return tool result directly addToConversation(user.id, { role: 'assistant', content: toolResult }); return { balasan: toolResult }; } } /** * Konfirmasi dan eksekusi aksi pending. */ static async konfirmasiAksi( aksiId: string, setuju: boolean, user: UserInfo, ip: string, userAgent: string ): Promise { const action = pendingActions.get(aksiId); if (!action) { return { balasan: 'Aksi ini sudah kedaluwarsa atau tidak valid. Silakan ulangi permintaan. ⏰' }; } if (action.userId !== user.id) { return { balasan: 'Anda tidak memiliki akses ke aksi ini. 🔒' }; } // Hapus pending action pendingActions.delete(aksiId); if (!setuju) { const balasan = 'Aksi dibatalkan. ❌ Ada yang lain yang bisa saya bantu?'; addToConversation(user.id, { role: 'assistant', content: balasan }); return { balasan }; } // Eksekusi tool const handler = TOOL_HANDLERS[action.toolName]; if (!handler) { return { balasan: 'Tool tidak ditemukan. Silakan ulangi permintaan.' }; } try { const result = await handler.handler(action.params, user.id, ip, userAgent); const balasan = `✅ ${result}`; addToConversation(user.id, { role: 'assistant', content: balasan }); return { balasan }; } catch (err: any) { const balasan = `❌ Gagal mengeksekusi aksi: ${err.message}`; addToConversation(user.id, { role: 'assistant', content: balasan }); return { balasan }; } } /** * Hapus riwayat chat user. */ static hapusRiwayat(userId: number): void { conversationStore.delete(userId); // Hapus pending actions user for (const [key, action] of pendingActions) { if (action.userId === userId) { pendingActions.delete(key); } } } /** * Build preview text untuk konfirmasi. */ private static buildPreview(toolName: string, params: any): string { const formatRupiah = (n: number) => 'Rp ' + n.toLocaleString('id-ID'); switch (toolName) { case 'tambah_produk': return [ '📦 Menambahkan produk baru:', ` • Nama: ${params.nama}`, ` • Harga Jual: ${formatRupiah(params.harga_jual)}`, ` • Harga Beli: ${formatRupiah(params.harga_beli)}`, ` • Kategori ID: ${params.kategori_id}`, ` • Satuan: ${params.satuan || 'pcs'}`, params.stok ? ` • Stok Awal: ${params.stok}` : '', ].filter(Boolean).join('\n'); case 'update_produk': const fields = Object.entries(params) .filter(([k, v]) => k !== 'id' && v !== undefined) .map(([k, v]) => ` • ${k}: ${typeof v === 'number' && k.includes('harga') ? formatRupiah(v as number) : v}`); return [ `✏️ Mengupdate produk ID ${params.id}:`, ...fields, ].join('\n'); case 'hapus_produk': return `🗑️ Menghapus produk dengan ID ${params.id}`; case 'void_transaksi': return `⚠️ Membatalkan (void) Transaksi: ${params.no_transaksi || `ID ${params.id}`}\n • Alasan: ${params.alasan}`; case 'tambah_stok_masuk': return [ '📥 Menambah stok masuk:', ` • Produk: ${params.nama_produk || `ID ${params.produk_id}`}`, ` • Qty: ${params.qty}`, ` • Harga Beli: ${formatRupiah(params.harga_beli)}`, params.catatan ? ` • Catatan: ${params.catatan}` : '', ].filter(Boolean).join('\n'); case 'tambah_member': return [ '👤 Mendaftarkan member baru:', ` • Nama: ${params.nama}`, ` • HP: ${params.nomor_hp}`, params.alamat ? ` • Alamat: ${params.alamat}` : '', ` • Limit Bon: ${formatRupiah(params.limit_bon || 0)}`, ].filter(Boolean).join('\n'); case 'tambah_supplier': return [ '🚚 Menambah supplier baru:', ` • Nama: ${params.nama}`, params.kontak ? ` • Kontak: ${params.kontak}` : '', params.nomor_hp ? ` • HP: ${params.nomor_hp}` : '', ].filter(Boolean).join('\n'); case 'tambah_kategori': return [ '📂 Menambah kategori baru:', ` • Nama: ${params.nama}`, params.icon ? ` • Icon: ${params.icon}` : '', ].filter(Boolean).join('\n'); case 'update_kategori': const katFields = Object.entries(params) .filter(([k, v]) => k !== 'id' && v !== undefined) .map(([k, v]) => ` • ${k}: ${v}`); return [ `✏️ Mengupdate kategori ID ${params.id}:`, ...katFields, ].join('\n'); case 'hapus_kategori': return `🗑️ Menghapus kategori produk: ${params.nama || `ID ${params.id}`}`; case 'tambah_diskon': return [ '🏷️ Membuat promosi diskon baru:', ` • Nama: ${params.nama}`, ` • Tipe: ${params.tipe}`, ` • Nilai: ${params.nilai}`, ` • Min Belanja: ${formatRupiah(params.min_belanja || 0)}`, ` • Berlaku: ${params.tgl_mulai} s/d ${params.tgl_selesai}`, ` • Khusus Member: ${params.member_only === 1 ? 'Ya' : 'Tidak'}`, ].join('\n'); case 'update_diskon': const discFields = Object.entries(params) .filter(([k, v]) => k !== 'id' && v !== undefined) .map(([k, v]) => ` • ${k}: ${k === 'nilai' || k === 'min_belanja' ? formatRupiah(Number(v)) : v}`); return [ `✏️ Mengupdate diskon ID ${params.id}:`, ...discFields, ].join('\n'); case 'hapus_diskon': return `🗑️ Menghapus promosi diskon dengan ID ${params.id}`; default: return `Menjalankan aksi: ${toolName} dengan parameter: ${JSON.stringify(params)}`; } } }