Tokiva-backend-Node / src /services /chatbot.service.ts
anoderb
feat: implement chatbot service, active shift logic corrections, audit logs, and void transaction tool
48013ee
Raw
History Blame Contribute Delete
13.1 kB
/**
* 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)}`;
}
}
}