Spaces:
Sleeping
Sleeping
anoderb
feat: implement chatbot service, active shift logic corrections, audit logs, and void transaction tool
48013ee | /** | |
| * 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<number, PesanChat[]>(); | |
| const MAX_HISTORY = 20; | |
| // Pending confirmations per user | |
| const pendingActions = new Map<string, PendingAction>(); | |
| // βββββββββββββββββββββββββββββββββββ | |
| // 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<ChatbotResponse> { | |
| // 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<ChatbotResponse> { | |
| 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<ChatbotResponse> { | |
| 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<ChatbotResponse> { | |
| 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)}`; | |
| } | |
| } | |
| } | |