diff --git a/api/chat.php b/api/chat.php index dbe026c..8652a7e 100644 --- a/api/chat.php +++ b/api/chat.php @@ -1,64 +1,159 @@ "I didn't catch that. Could you repeat?"]); + foreach ($faqs as $faq) { + $keywords = isset($faq['keywords']) && is_string($faq['keywords']) + ? preg_split('/\s*,\s*/', $faq['keywords']) + : []; + + foreach ($keywords as $keyword) { + if (!is_string($keyword) || $keyword === '') { + continue; + } + + $needle = function_exists('mb_strtolower') + ? mb_strtolower($keyword, 'UTF-8') + : strtolower($keyword); + + $found = function_exists('mb_strpos') + ? mb_strpos($normalizedMessage, $needle, 0, 'UTF-8') !== false + : strpos($normalizedMessage, $needle) !== false; + + if ($needle !== '' && $found) { + return (string) ($faq['answer'] ?? ''); + } + } + } + + return ''; +} + +$rawInput = file_get_contents('php://input'); +$input = json_decode($rawInput ?: '', true); +$message = ''; + +if (is_array($input) && isset($input['message']) && is_string($input['message'])) { + $message = trim($input['message']); +} + +if ($message === '') { + echo json_encode(['reply' => 'Silakan tulis pertanyaan Anda terlebih dahulu.'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } try { - // 1. Fetch Knowledge Base (FAQs) - $stmt = db()->query("SELECT keywords, answer FROM faqs"); - $faqs = $stmt->fetchAll(PDO::FETCH_ASSOC); + ensureSupportSchema(); - $knowledgeBase = "Here is the knowledge base for this website:\n\n"; - foreach ($faqs as $faq) { - $knowledgeBase .= "Q: " . $faq['keywords'] . "\nA: " . $faq['answer'] . "\n---\n"; + $faqs = []; + try { + $stmt = db()->query('SELECT keywords, answer FROM faqs ORDER BY id ASC'); + $faqs = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $faqError) { + error_log('FAQ Load Error: ' . $faqError->getMessage()); } - // 2. Construct Prompt for AI - $systemPrompt = "You are a helpful, friendly AI assistant for this website. " . - "Use the provided Knowledge Base to answer user questions accurately. " . - "If the answer is found in the Knowledge Base, rephrase it naturally. " . - "If the answer is NOT in the Knowledge Base, use your general knowledge to help, " . - "but politely mention that you don't have specific information about that if it seems like a site-specific question. " . - "Keep answers concise and professional.\n\n" . - $knowledgeBase; + $faqReply = findFaqReply($faqs, $message); + if ($faqReply !== '') { + try { + $stmt = db()->prepare('INSERT INTO messages (user_message, ai_response) VALUES (:user_message, :ai_response)'); + $stmt->bindValue(':user_message', $message); + $stmt->bindValue(':ai_response', $faqReply); + $stmt->execute(); + } catch (Throwable $saveError) { + error_log('DB Save Error: ' . $saveError->getMessage()); + } - // 3. Call AI API - $response = LocalAIApi::createResponse([ - 'model' => 'gpt-4o-mini', - 'input' => [ - ['role' => 'system', 'content' => $systemPrompt], - ['role' => 'user', 'content' => $message], + echo json_encode(['reply' => $faqReply], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + + $knowledgeBase = "Berikut knowledge base website ini: + +"; + if ($faqs === []) { + $knowledgeBase .= "- Belum ada FAQ khusus yang dikonfigurasi. +"; + } else { + foreach ($faqs as $faq) { + $knowledgeBase .= 'Q: ' . ($faq['keywords'] ?? '') . " +"; + $knowledgeBase .= 'A: ' . ($faq['answer'] ?? '') . " +--- +"; + } + } + + $systemPrompt = "Anda adalah asisten AI yang ramah untuk website ini. " + . "Gunakan knowledge base bila jawabannya tersedia. " + . "Jika pertanyaan tidak ada di knowledge base, bantu secara umum dan jelaskan dengan singkat. " + . "Jawab singkat, jelas, dan profesional dalam Bahasa Indonesia. + +" + . $knowledgeBase; + + $response = LocalAIApi::createResponse( + [ + 'input' => [ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $message], + ], + ], + [ + 'poll_interval' => 2, + 'poll_timeout' => 45, ] - ]); + ); if (!empty($response['success'])) { $aiReply = LocalAIApi::extractText($response); - - // 4. Save to Database - try { - $stmt = db()->prepare("INSERT INTO messages (user_message, ai_response) VALUES (?, ?)"); - $stmt->execute([$message, $aiReply]); - } catch (Exception $e) { - error_log("DB Save Error: " . $e->getMessage()); - // Continue even if save fails, so the user still gets a reply + if ($aiReply === '') { + $decoded = LocalAIApi::decodeJsonFromResponse($response); + if ($decoded) { + $aiReply = json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + } + if ($aiReply === '') { + $aiReply = 'Pesan Anda sudah diterima, tetapi balasan otomatis belum tersedia saat ini.'; } - echo json_encode(['reply' => $aiReply]); - } else { - // Fallback if AI fails - error_log("AI Error: " . ($response['error'] ?? 'Unknown')); - echo json_encode(['reply' => "I'm having trouble connecting to my brain right now. Please try again later."]); + try { + $stmt = db()->prepare('INSERT INTO messages (user_message, ai_response) VALUES (:user_message, :ai_response)'); + $stmt->bindValue(':user_message', $message); + $stmt->bindValue(':ai_response', $aiReply); + $stmt->execute(); + } catch (Throwable $saveError) { + error_log('DB Save Error: ' . $saveError->getMessage()); + } + + echo json_encode(['reply' => $aiReply], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; } -} catch (Exception $e) { - error_log("Chat Error: " . $e->getMessage()); - echo json_encode(['reply' => "An internal error occurred."]); + error_log('AI Error: ' . ($response['error'] ?? 'Unknown')); + $fallbackReply = 'Saya belum bisa membalas otomatis saat ini. Silakan coba lagi sebentar lagi.'; + + try { + $stmt = db()->prepare('INSERT INTO messages (user_message, ai_response) VALUES (:user_message, :ai_response)'); + $stmt->bindValue(':user_message', $message); + $stmt->bindValue(':ai_response', $fallbackReply); + $stmt->execute(); + } catch (Throwable $saveError) { + error_log('DB Save Error: ' . $saveError->getMessage()); + } + + echo json_encode(['reply' => $fallbackReply], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} catch (Throwable $error) { + error_log('Chat Error: ' . $error->getMessage()); + echo json_encode(['reply' => 'Terjadi kesalahan internal. Silakan coba lagi.'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } diff --git a/api/telegram_webhook.php b/api/telegram_webhook.php index fa4899c..1b6c6b8 100644 --- a/api/telegram_webhook.php +++ b/api/telegram_webhook.php @@ -1,91 +1,180 @@ $chatId, + 'text' => $text, + ]; -if (!$update || !isset($update['message'])) { + $options = [ + 'http' => [ + 'header' => "Content-type: application/x-www-form-urlencoded +", + 'method' => 'POST', + 'content' => http_build_query($data), + 'timeout' => 20, + ], + ]; + + $context = stream_context_create($options); + $result = @file_get_contents($url, false, $context); + + return $result !== false; +} + +function findFaqReply(array $faqs, string $message): string +{ + $normalizedMessage = function_exists('mb_strtolower') + ? mb_strtolower($message, 'UTF-8') + : strtolower($message); + + foreach ($faqs as $faq) { + $keywords = isset($faq['keywords']) && is_string($faq['keywords']) + ? preg_split('/\s*,\s*/', $faq['keywords']) + : []; + + foreach ($keywords as $keyword) { + if (!is_string($keyword) || $keyword === '') { + continue; + } + + $needle = function_exists('mb_strtolower') + ? mb_strtolower($keyword, 'UTF-8') + : strtolower($keyword); + + $found = function_exists('mb_strpos') + ? mb_strpos($normalizedMessage, $needle, 0, 'UTF-8') !== false + : strpos($normalizedMessage, $needle) !== false; + + if ($needle !== '' && $found) { + return (string) ($faq['answer'] ?? ''); + } + } + } + + return ''; +} + +$content = file_get_contents('php://input'); +$update = json_decode($content ?: '', true); + +if (!is_array($update) || !isset($update['message']) || !is_array($update['message'])) { exit; } $message = $update['message']; -$chatId = $message['chat']['id']; -$text = $message['text'] ?? ''; +$chatId = $message['chat']['id'] ?? null; +$text = isset($message['text']) && is_string($message['text']) ? trim($message['text']) : ''; -if (empty($text)) { +if ($chatId === null || $text === '') { exit; } -// Get Telegram Token from DB -$stmt = db()->query("SELECT setting_value FROM settings WHERE setting_key = 'telegram_token'"); -$token = $stmt->fetchColumn(); - -if (!$token) { - error_log("Telegram Error: No bot token found in settings."); - exit; -} - -function sendTelegramMessage($chatId, $text, $token) { - $url = "https://api.telegram.org/bot$token/sendMessage"; - $data = [ - 'chat_id' => $chatId, - 'text' => $text, - 'parse_mode' => 'Markdown' - ]; - - $options = [ - 'http' => [ - 'header' => "Content-type: application/x-www-form-urlencoded\r\n", - 'method' => 'POST', - 'content' => http_build_query($data), - ], - ]; - $context = stream_context_create($options); - return file_get_contents($url, false, $context); -} - -// Process with AI (Similar logic to api/chat.php) try { - // 1. Fetch Knowledge Base - $stmt = db()->query("SELECT keywords, answer FROM faqs"); - $faqs = $stmt->fetchAll(PDO::FETCH_ASSOC); + ensureSupportSchema(); - $knowledgeBase = "Here is the knowledge base for this website:\n\n"; - foreach ($faqs as $faq) { - $knowledgeBase .= "Q: " . $faq['keywords'] . "\nA: " . $faq['answer'] . "\n---\n"; + $stmt = db()->prepare('SELECT setting_value FROM settings WHERE setting_key = :setting_key LIMIT 1'); + $stmt->bindValue(':setting_key', 'telegram_token'); + $stmt->execute(); + $token = (string) ($stmt->fetchColumn() ?: ''); + + if ($token === '') { + error_log('Telegram Error: No bot token found in settings.'); + exit; } - $systemPrompt = "You are a helpful AI assistant integrated with Telegram. " . - "Use the provided Knowledge Base to answer user questions. " . - "Keep answers concise for mobile reading. Use Markdown for formatting.\n\n" . - $knowledgeBase; + $faqs = []; + try { + $stmt = db()->query('SELECT keywords, answer FROM faqs ORDER BY id ASC'); + $faqs = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $faqError) { + error_log('Telegram FAQ Load Error: ' . $faqError->getMessage()); + } - // 2. Call AI - $response = LocalAIApi::createResponse([ - 'model' => 'gpt-4o-mini', - 'input' => [ - ['role' => 'system', 'content' => $systemPrompt], - ['role' => 'user', 'content' => $text], + $faqReply = findFaqReply($faqs, $text); + if ($faqReply !== '') { + try { + $stmt = db()->prepare('INSERT INTO messages (user_message, ai_response) VALUES (:user_message, :ai_response)'); + $stmt->bindValue(':user_message', '[Telegram] ' . $text); + $stmt->bindValue(':ai_response', $faqReply); + $stmt->execute(); + } catch (Throwable $saveError) { + error_log('Telegram Save Error: ' . $saveError->getMessage()); + } + + sendTelegramMessage($chatId, $faqReply, $token); + exit; + } + + $knowledgeBase = "Berikut knowledge base website ini: + +"; + if ($faqs === []) { + $knowledgeBase .= "- Belum ada FAQ khusus yang dikonfigurasi. +"; + } else { + foreach ($faqs as $faq) { + $knowledgeBase .= 'Q: ' . ($faq['keywords'] ?? '') . " +"; + $knowledgeBase .= 'A: ' . ($faq['answer'] ?? '') . " +--- +"; + } + } + + $systemPrompt = "Anda adalah asisten AI Telegram untuk website ini. " + . "Gunakan knowledge base bila relevan. " + . "Jawab ringkas, jelas, dan profesional dalam Bahasa Indonesia. + +" + . $knowledgeBase; + + $response = LocalAIApi::createResponse( + [ + 'input' => [ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $text], + ], + ], + [ + 'poll_interval' => 2, + 'poll_timeout' => 45, ] - ]); + ); if (!empty($response['success'])) { $aiReply = LocalAIApi::extractText($response); - - // 3. Save History - try { - $stmt = db()->prepare("INSERT INTO messages (user_message, ai_response) VALUES (?, ?)"); - $stmt->execute(["[Telegram] " . $text, $aiReply]); - } catch (Exception $e) {} + if ($aiReply === '') { + $decoded = LocalAIApi::decodeJsonFromResponse($response); + if ($decoded) { + $aiReply = json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + } + if ($aiReply === '') { + $aiReply = 'Pesan Anda sudah diterima, tetapi balasan otomatis belum tersedia saat ini.'; + } + + try { + $stmt = db()->prepare('INSERT INTO messages (user_message, ai_response) VALUES (:user_message, :ai_response)'); + $stmt->bindValue(':user_message', '[Telegram] ' . $text); + $stmt->bindValue(':ai_response', $aiReply); + $stmt->execute(); + } catch (Throwable $saveError) { + error_log('Telegram Save Error: ' . $saveError->getMessage()); + } - // 4. Send back to Telegram sendTelegramMessage($chatId, $aiReply, $token); - } else { - sendTelegramMessage($chatId, "I'm sorry, I encountered an error processing your request.", $token); + exit; } -} catch (Exception $e) { - error_log("Telegram Webhook Error: " . $e->getMessage()); + error_log('Telegram AI Error: ' . ($response['error'] ?? 'Unknown')); + sendTelegramMessage($chatId, 'Maaf, saya belum bisa memproses permintaan Anda saat ini.', $token); +} catch (Throwable $error) { + error_log('Telegram Webhook Error: ' . $error->getMessage()); } diff --git a/db/migrations/20260512_001_support_tables.sql b/db/migrations/20260512_001_support_tables.sql new file mode 100644 index 0000000..49c48da --- /dev/null +++ b/db/migrations/20260512_001_support_tables.sql @@ -0,0 +1,23 @@ +-- Bootstrap tables required by the built-in chat and Telegram integrations. +CREATE TABLE IF NOT EXISTS faqs ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + keywords VARCHAR(255) NOT NULL, + answer TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS messages ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_message TEXT NOT NULL, + ai_response MEDIUMTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_messages_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS settings ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + setting_key VARCHAR(100) NOT NULL, + setting_value TEXT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_settings_key (setting_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/db/schema.php b/db/schema.php new file mode 100644 index 0000000..fbce400 --- /dev/null +++ b/db/schema.php @@ -0,0 +1,81 @@ +exec($sql); + + $faqCount = (int) db()->query('SELECT COUNT(*) FROM faqs')->fetchColumn(); + if ($faqCount === 0) { + $seedFaqs = [ + [ + 'keywords' => 'penawaran, quotation, quote, harga', + 'answer' => 'Gunakan modul penawaran untuk membuat penawaran harga barang maupun jasa sebelum pekerjaan dimulai.', + ], + [ + 'keywords' => 'spk, surat perintah kerja, work order', + 'answer' => 'SPK dipakai untuk menerbitkan perintah kerja berdasarkan penawaran yang sudah disetujui pelanggan.', + ], + [ + 'keywords' => 'surat jalan, delivery note, pengiriman', + 'answer' => 'Surat jalan dipakai untuk mencatat barang yang dikirim atau pekerjaan yang dikirimkan ke pelanggan.', + ], + [ + 'keywords' => 'invoice, faktur, tagihan, pembayaran', + 'answer' => 'Invoice diterbitkan sebagai tagihan akhir setelah pengiriman barang atau penyelesaian jasa.', + ], + ]; + + $stmt = db()->prepare('INSERT INTO faqs (keywords, answer) VALUES (:keywords, :answer)'); + foreach ($seedFaqs as $faq) { + $stmt->bindValue(':keywords', $faq['keywords']); + $stmt->bindValue(':answer', $faq['answer']); + $stmt->execute(); + } + } + + $bootstrapped = true; +}