commit 7030aad1959d633ae6a84cf1c84e3a08747d5a2e Author: Flatlogic Bot Date: Sun Mar 29 03:24:37 2026 +0000 Initial version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e427ff3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*/node_modules/ +*/build/ diff --git a/.perm_test_apache b/.perm_test_apache new file mode 100644 index 0000000..e69de29 diff --git a/.perm_test_exec b/.perm_test_exec new file mode 100644 index 0000000..e69de29 diff --git a/ai/LocalAIApi.php b/ai/LocalAIApi.php new file mode 100644 index 0000000..d428248 --- /dev/null +++ b/ai/LocalAIApi.php @@ -0,0 +1,493 @@ + [ +// ['role' => 'system', 'content' => 'You are a helpful assistant.'], +// ['role' => 'user', 'content' => 'Tell me a bedtime story.'], +// ], +// ]); +// if (!empty($response['success'])) { +// // response['data'] contains full payload, e.g.: +// // { +// // "id": "resp_xxx", +// // "status": "completed", +// // "output": [ +// // {"type": "reasoning", "summary": []}, +// // {"type": "message", "content": [{"type": "output_text", "text": "Your final answer here."}]} +// // ] +// // } +// $decoded = LocalAIApi::decodeJsonFromResponse($response); // or inspect $response['data'] / extractText(...) +// } +// Poll settings override: +// LocalAIApi::createResponse($payload, ['poll_interval' => 5, 'poll_timeout' => 300]); + +class LocalAIApi +{ + /** @var array|null */ + private static ?array $configCache = null; + + /** + * Signature compatible with the OpenAI Responses API. + * + * @param array $params Request body (model, input, text, reasoning, metadata, etc.). + * @param array $options Extra options (timeout, verify_tls, headers, path, project_uuid). + * @return array{ + * success:bool, + * status?:int, + * data?:mixed, + * error?:string, + * response?:mixed, + * message?:string + * } + */ + public static function createResponse(array $params, array $options = []): array + { + $cfg = self::config(); + $payload = $params; + + if (empty($payload['input']) || !is_array($payload['input'])) { + return [ + 'success' => false, + 'error' => 'input_missing', + 'message' => 'Parameter "input" is required and must be an array.', + ]; + } + + if (!isset($payload['model']) || $payload['model'] === '') { + $payload['model'] = $cfg['default_model']; + } + + $initial = self::request($options['path'] ?? null, $payload, $options); + if (empty($initial['success'])) { + return $initial; + } + + // Async flow: if backend returns ai_request_id, poll status until ready + $data = $initial['data'] ?? null; + if (is_array($data) && isset($data['ai_request_id'])) { + $aiRequestId = $data['ai_request_id']; + $pollTimeout = isset($options['poll_timeout']) ? (int) $options['poll_timeout'] : 300; // seconds + $pollInterval = isset($options['poll_interval']) ? (int) $options['poll_interval'] : 5; // seconds + return self::awaitResponse($aiRequestId, [ + 'timeout' => $pollTimeout, + 'interval' => $pollInterval, + 'headers' => $options['headers'] ?? [], + 'timeout_per_call' => $options['timeout'] ?? null, + ]); + } + + return $initial; + } + + /** + * Snake_case alias for createResponse (matches the provided example). + * + * @param array $params + * @param array $options + * @return array + */ + public static function create_response(array $params, array $options = []): array + { + return self::createResponse($params, $options); + } + + /** + * Perform a raw request to the AI proxy. + * + * @param string $path Endpoint (may be an absolute URL). + * @param array $payload JSON payload. + * @param array $options Additional request options. + * @return array + */ + public static function request(?string $path = null, array $payload = [], array $options = []): array + { + $cfg = self::config(); + + $projectUuid = $cfg['project_uuid']; + if (empty($projectUuid)) { + return [ + 'success' => false, + 'error' => 'project_uuid_missing', + 'message' => 'PROJECT_UUID is not defined; aborting AI request.', + ]; + } + + $defaultPath = $cfg['responses_path'] ?? null; + $resolvedPath = $path ?? ($options['path'] ?? $defaultPath); + if (empty($resolvedPath)) { + return [ + 'success' => false, + 'error' => 'project_id_missing', + 'message' => 'PROJECT_ID is not defined; cannot resolve AI proxy endpoint.', + ]; + } + + $url = self::buildUrl($resolvedPath, $cfg['base_url']); + $baseTimeout = isset($cfg['timeout']) ? (int) $cfg['timeout'] : 30; + $timeout = isset($options['timeout']) ? (int) $options['timeout'] : $baseTimeout; + if ($timeout <= 0) { + $timeout = 30; + } + + $baseVerifyTls = array_key_exists('verify_tls', $cfg) ? (bool) $cfg['verify_tls'] : true; + $verifyTls = array_key_exists('verify_tls', $options) + ? (bool) $options['verify_tls'] + : $baseVerifyTls; + + $projectHeader = $cfg['project_header']; + + $headers = [ + 'Content-Type: application/json', + 'Accept: application/json', + ]; + $headers[] = $projectHeader . ': ' . $projectUuid; + if (!empty($options['headers']) && is_array($options['headers'])) { + foreach ($options['headers'] as $header) { + if (is_string($header) && $header !== '') { + $headers[] = $header; + } + } + } + + if (!empty($projectUuid) && !array_key_exists('project_uuid', $payload)) { + $payload['project_uuid'] = $projectUuid; + } + + $body = json_encode($payload, JSON_UNESCAPED_UNICODE); + if ($body === false) { + return [ + 'success' => false, + 'error' => 'json_encode_failed', + 'message' => 'Failed to encode request body to JSON.', + ]; + } + + return self::sendCurl($url, 'POST', $body, $headers, $timeout, $verifyTls); + } + + /** + * Poll AI request status until ready or timeout. + * + * @param int|string $aiRequestId + * @param array $options + * @return array + */ + public static function awaitResponse($aiRequestId, array $options = []): array + { + $cfg = self::config(); + + $timeout = isset($options['timeout']) ? (int) $options['timeout'] : 300; // seconds + $interval = isset($options['interval']) ? (int) $options['interval'] : 5; // seconds + if ($interval <= 0) { + $interval = 5; + } + $perCallTimeout = isset($options['timeout_per_call']) ? (int) $options['timeout_per_call'] : null; + + $deadline = time() + max($timeout, $interval); + $headers = $options['headers'] ?? []; + + while (true) { + $statusResp = self::fetchStatus($aiRequestId, [ + 'headers' => $headers, + 'timeout' => $perCallTimeout, + ]); + if (!empty($statusResp['success'])) { + $data = $statusResp['data'] ?? []; + if (is_array($data)) { + $statusValue = $data['status'] ?? null; + if ($statusValue === 'success') { + return [ + 'success' => true, + 'status' => 200, + 'data' => $data['response'] ?? $data, + ]; + } + if ($statusValue === 'failed') { + return [ + 'success' => false, + 'status' => 500, + 'error' => isset($data['error']) ? (string)$data['error'] : 'AI request failed', + 'data' => $data, + ]; + } + } + } else { + return $statusResp; + } + + if (time() >= $deadline) { + return [ + 'success' => false, + 'error' => 'timeout', + 'message' => 'Timed out waiting for AI response.', + ]; + } + sleep($interval); + } + } + + /** + * Fetch status for queued AI request. + * + * @param int|string $aiRequestId + * @param array $options + * @return array + */ + public static function fetchStatus($aiRequestId, array $options = []): array + { + $cfg = self::config(); + $projectUuid = $cfg['project_uuid']; + if (empty($projectUuid)) { + return [ + 'success' => false, + 'error' => 'project_uuid_missing', + 'message' => 'PROJECT_UUID is not defined; aborting status check.', + ]; + } + + $statusPath = self::resolveStatusPath($aiRequestId, $cfg); + $url = self::buildUrl($statusPath, $cfg['base_url']); + + $baseTimeout = isset($cfg['timeout']) ? (int) $cfg['timeout'] : 30; + $timeout = isset($options['timeout']) ? (int) $options['timeout'] : $baseTimeout; + if ($timeout <= 0) { + $timeout = 30; + } + + $baseVerifyTls = array_key_exists('verify_tls', $cfg) ? (bool) $cfg['verify_tls'] : true; + $verifyTls = array_key_exists('verify_tls', $options) + ? (bool) $options['verify_tls'] + : $baseVerifyTls; + + $projectHeader = $cfg['project_header']; + $headers = [ + 'Accept: application/json', + $projectHeader . ': ' . $projectUuid, + ]; + if (!empty($options['headers']) && is_array($options['headers'])) { + foreach ($options['headers'] as $header) { + if (is_string($header) && $header !== '') { + $headers[] = $header; + } + } + } + + return self::sendCurl($url, 'GET', null, $headers, $timeout, $verifyTls); + } + + /** + * Extract plain text from a Responses API payload. + * + * @param array $response Result of LocalAIApi::createResponse|request. + * @return string + */ + public static function extractText(array $response): string + { + $payload = $response['data'] ?? $response; + if (!is_array($payload)) { + return ''; + } + + if (!empty($payload['output']) && is_array($payload['output'])) { + $combined = ''; + foreach ($payload['output'] as $item) { + if (!isset($item['content']) || !is_array($item['content'])) { + continue; + } + foreach ($item['content'] as $block) { + if (is_array($block) && ($block['type'] ?? '') === 'output_text' && !empty($block['text'])) { + $combined .= $block['text']; + } + } + } + if ($combined !== '') { + return $combined; + } + } + + if (!empty($payload['choices'][0]['message']['content'])) { + return (string) $payload['choices'][0]['message']['content']; + } + + return ''; + } + + /** + * Attempt to decode JSON emitted by the model (handles markdown fences). + * + * @param array $response + * @return array|null + */ + public static function decodeJsonFromResponse(array $response): ?array + { + $text = self::extractText($response); + if ($text === '') { + return null; + } + + $decoded = json_decode($text, true); + if (is_array($decoded)) { + return $decoded; + } + + $stripped = preg_replace('/^```json|```$/m', '', trim($text)); + if ($stripped !== null && $stripped !== $text) { + $decoded = json_decode($stripped, true); + if (is_array($decoded)) { + return $decoded; + } + } + + return null; + } + + /** + * Load configuration from ai/config.php. + * + * @return array + */ + private static function config(): array + { + if (self::$configCache === null) { + $configPath = __DIR__ . '/config.php'; + if (!file_exists($configPath)) { + throw new RuntimeException('AI config file not found: ai/config.php'); + } + $cfg = require $configPath; + if (!is_array($cfg)) { + throw new RuntimeException('Invalid AI config format: expected array'); + } + self::$configCache = $cfg; + } + + return self::$configCache; + } + + /** + * Build an absolute URL from base_url and a path. + */ + private static function buildUrl(string $path, string $baseUrl): string + { + $trimmed = trim($path); + if ($trimmed === '') { + return $baseUrl; + } + if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) { + return $trimmed; + } + if ($trimmed[0] === '/') { + return $baseUrl . $trimmed; + } + return $baseUrl . '/' . $trimmed; + } + + /** + * Resolve status path based on configured responses_path and ai_request_id. + * + * @param int|string $aiRequestId + * @param array $cfg + * @return string + */ + private static function resolveStatusPath($aiRequestId, array $cfg): string + { + $basePath = $cfg['responses_path'] ?? ''; + $trimmed = rtrim($basePath, '/'); + if ($trimmed === '') { + return '/ai-request/' . rawurlencode((string)$aiRequestId) . '/status'; + } + if (substr($trimmed, -11) !== '/ai-request') { + $trimmed .= '/ai-request'; + } + return $trimmed . '/' . rawurlencode((string)$aiRequestId) . '/status'; + } + + /** + * Shared CURL sender for GET/POST requests. + * + * @param string $url + * @param string $method + * @param string|null $body + * @param array $headers + * @param int $timeout + * @param bool $verifyTls + * @return array + */ + private static function sendCurl(string $url, string $method, ?string $body, array $headers, int $timeout, bool $verifyTls): array + { + if (!function_exists('curl_init')) { + return [ + 'success' => false, + 'error' => 'curl_missing', + 'message' => 'PHP cURL extension is missing. Install or enable it on the VM.', + ]; + } + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifyTls); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verifyTls ? 2 : 0); + curl_setopt($ch, CURLOPT_FAILONERROR, false); + + $upper = strtoupper($method); + if ($upper === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body ?? ''); + } else { + curl_setopt($ch, CURLOPT_HTTPGET, true); + } + + $responseBody = curl_exec($ch); + if ($responseBody === false) { + $error = curl_error($ch) ?: 'Unknown cURL error'; + curl_close($ch); + return [ + 'success' => false, + 'error' => 'curl_error', + 'message' => $error, + ]; + } + + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $decoded = null; + if ($responseBody !== '' && $responseBody !== null) { + $decoded = json_decode($responseBody, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $decoded = null; + } + } + + if ($status >= 200 && $status < 300) { + return [ + 'success' => true, + 'status' => $status, + 'data' => $decoded ?? $responseBody, + ]; + } + + $errorMessage = 'AI proxy request failed'; + if (is_array($decoded)) { + $errorMessage = $decoded['error'] ?? $decoded['message'] ?? $errorMessage; + } elseif (is_string($responseBody) && $responseBody !== '') { + $errorMessage = $responseBody; + } + + return [ + 'success' => false, + 'status' => $status, + 'error' => $errorMessage, + 'response' => $decoded ?? $responseBody, + ]; + } +} + +// Legacy alias for backward compatibility with the previous class name. +if (!class_exists('OpenAIService')) { + class_alias(LocalAIApi::class, 'OpenAIService'); +} diff --git a/ai/config.php b/ai/config.php new file mode 100644 index 0000000..c890698 --- /dev/null +++ b/ai/config.php @@ -0,0 +1,52 @@ + $baseUrl, + 'responses_path' => $responsesPath, + 'project_id' => $projectId, + 'project_uuid' => $projectUuid, + 'project_header' => 'project-uuid', + 'default_model' => 'gpt-5-mini', + 'timeout' => 30, + 'verify_tls' => true, +]; diff --git a/api/chat.php b/api/chat.php new file mode 100644 index 0000000..dbe026c --- /dev/null +++ b/api/chat.php @@ -0,0 +1,64 @@ + "I didn't catch that. Could you repeat?"]); + exit; +} + +try { + // 1. Fetch Knowledge Base (FAQs) + $stmt = db()->query("SELECT keywords, answer FROM faqs"); + $faqs = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $knowledgeBase = "Here is the knowledge base for this website:\n\n"; + foreach ($faqs as $faq) { + $knowledgeBase .= "Q: " . $faq['keywords'] . "\nA: " . $faq['answer'] . "\n---\n"; + } + + // 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; + + // 3. Call AI API + $response = LocalAIApi::createResponse([ + 'model' => 'gpt-4o-mini', + 'input' => [ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $message], + ] + ]); + + 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 + } + + 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."]); + } + +} catch (Exception $e) { + error_log("Chat Error: " . $e->getMessage()); + echo json_encode(['reply' => "An internal error occurred."]); +} diff --git a/api/telegram_webhook.php b/api/telegram_webhook.php new file mode 100644 index 0000000..fa4899c --- /dev/null +++ b/api/telegram_webhook.php @@ -0,0 +1,91 @@ +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); + + $knowledgeBase = "Here is the knowledge base for this website:\n\n"; + foreach ($faqs as $faq) { + $knowledgeBase .= "Q: " . $faq['keywords'] . "\nA: " . $faq['answer'] . "\n---\n"; + } + + $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; + + // 2. Call AI + $response = LocalAIApi::createResponse([ + 'model' => 'gpt-4o-mini', + 'input' => [ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $text], + ] + ]); + + 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) {} + + // 4. Send back to Telegram + sendTelegramMessage($chatId, $aiReply, $token); + } else { + sendTelegramMessage($chatId, "I'm sorry, I encountered an error processing your request.", $token); + } + +} catch (Exception $e) { + error_log("Telegram Webhook Error: " . $e->getMessage()); +} diff --git a/assets/css/custom.css b/assets/css/custom.css new file mode 100644 index 0000000..789132e --- /dev/null +++ b/assets/css/custom.css @@ -0,0 +1,403 @@ +body { + background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); + background-size: 400% 400%; + animation: gradient 15s ease infinite; + color: #212529; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 14px; + margin: 0; + min-height: 100vh; +} + +.main-wrapper { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + width: 100%; + padding: 20px; + box-sizing: border-box; + position: relative; + z-index: 1; +} + +@keyframes gradient { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} + +.chat-container { + width: 100%; + max-width: 600px; + background: rgba(255, 255, 255, 0.85); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 20px; + display: flex; + flex-direction: column; + height: 85vh; + box-shadow: 0 20px 40px rgba(0,0,0,0.2); + backdrop-filter: blur(15px); + -webkit-backdrop-filter: blur(15px); + overflow: hidden; +} + +.chat-header { + padding: 1.5rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + background: rgba(255, 255, 255, 0.5); + font-weight: 700; + font-size: 1.1rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.3); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.5); +} + +.message { + max-width: 85%; + padding: 0.85rem 1.1rem; + border-radius: 16px; + line-height: 1.5; + font-size: 0.95rem; + box-shadow: 0 4px 15px rgba(0,0,0,0.05); + animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(20px) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.message.visitor { + align-self: flex-end; + background: linear-gradient(135deg, #212529 0%, #343a40 100%); + color: #fff; + border-bottom-right-radius: 4px; +} + +.message.bot { + align-self: flex-start; + background: #ffffff; + color: #212529; + border-bottom-left-radius: 4px; +} + +.chat-input-area { + padding: 1.25rem; + background: rgba(255, 255, 255, 0.5); + border-top: 1px solid rgba(0, 0, 0, 0.05); +} + +.chat-input-area form { + display: flex; + gap: 0.75rem; +} + +.chat-input-area input { + flex: 1; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 12px; + padding: 0.75rem 1rem; + outline: none; + background: rgba(255, 255, 255, 0.9); + transition: all 0.3s ease; +} + +.chat-input-area input:focus { + border-color: #23a6d5; + box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); +} + +.chat-input-area button { + background: #212529; + color: #fff; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 12px; + cursor: pointer; + font-weight: 600; + transition: all 0.3s ease; +} + +.chat-input-area button:hover { + background: #000; + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(0,0,0,0.2); +} + +/* Background Animations */ +.bg-animations { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; + overflow: hidden; + pointer-events: none; +} + +.blob { + position: absolute; + width: 500px; + height: 500px; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + filter: blur(80px); + animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); +} + +.blob-1 { + top: -10%; + left: -10%; + background: rgba(238, 119, 82, 0.4); +} + +.blob-2 { + bottom: -10%; + right: -10%; + background: rgba(35, 166, 213, 0.4); + animation-delay: -7s; + width: 600px; + height: 600px; +} + +.blob-3 { + top: 40%; + left: 30%; + background: rgba(231, 60, 126, 0.3); + animation-delay: -14s; + width: 450px; + height: 450px; +} + +@keyframes move { + 0% { transform: translate(0, 0) rotate(0deg) scale(1); } + 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); } + 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); } + 100% { transform: translate(0, 0) rotate(360deg) scale(1); } +} + +.header-link { + font-size: 14px; + color: #fff; + text-decoration: none; + background: rgba(0, 0, 0, 0.2); + padding: 0.5rem 1rem; + border-radius: 8px; + transition: all 0.3s ease; +} + +.header-link:hover { + background: rgba(0, 0, 0, 0.4); + text-decoration: none; +} + +/* Admin Styles */ +.admin-container { + max-width: 900px; + margin: 3rem auto; + padding: 2.5rem; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-radius: 24px; + box-shadow: 0 20px 50px rgba(0,0,0,0.15); + border: 1px solid rgba(255, 255, 255, 0.4); + position: relative; + z-index: 1; +} + +.admin-container h1 { + margin-top: 0; + color: #212529; + font-weight: 800; +} + +.table { + width: 100%; + border-collapse: separate; + border-spacing: 0 8px; + margin-top: 1.5rem; +} + +.table th { + background: transparent; + border: none; + padding: 1rem; + color: #6c757d; + font-weight: 600; + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 1px; +} + +.table td { + background: #fff; + padding: 1rem; + border: none; +} + +.table tr td:first-child { border-radius: 12px 0 0 12px; } +.table tr td:last-child { border-radius: 0 12px 12px 0; } + +.form-group { + margin-bottom: 1.25rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + font-size: 0.9rem; +} + +.form-control { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 12px; + background: #fff; + transition: all 0.3s ease; + box-sizing: border-box; +} + +.form-control:focus { + outline: none; + border-color: #23a6d5; + box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); +} + +.header-container { + display: flex; + justify-content: space-between; + align-items: center; +} + +.header-links { + display: flex; + gap: 1rem; +} + +.admin-card { + background: rgba(255, 255, 255, 0.6); + padding: 2rem; + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.5); + margin-bottom: 2.5rem; + box-shadow: 0 10px 30px rgba(0,0,0,0.05); +} + +.admin-card h3 { + margin-top: 0; + margin-bottom: 1.5rem; + font-weight: 700; +} + +.btn-delete { + background: #dc3545; + color: white; + border: none; + padding: 0.25rem 0.5rem; + border-radius: 4px; + cursor: pointer; +} + +.btn-add { + background: #212529; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + margin-top: 1rem; +} + +.btn-save { + background: #0088cc; + color: white; + border: none; + padding: 0.8rem 1.5rem; + border-radius: 12px; + cursor: pointer; + font-weight: 600; + width: 100%; + transition: all 0.3s ease; +} + +.webhook-url { + font-size: 0.85em; + color: #555; + margin-top: 0.5rem; +} + +.history-table-container { + overflow-x: auto; + background: rgba(255, 255, 255, 0.4); + padding: 1rem; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.history-table { + width: 100%; +} + +.history-table-time { + width: 15%; + white-space: nowrap; + font-size: 0.85em; + color: #555; +} + +.history-table-user { + width: 35%; + background: rgba(255, 255, 255, 0.3); + border-radius: 8px; + padding: 8px; +} + +.history-table-ai { + width: 50%; + background: rgba(255, 255, 255, 0.5); + border-radius: 8px; + padding: 8px; +} + +.no-messages { + text-align: center; + color: #777; +} \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..d349598 --- /dev/null +++ b/assets/js/main.js @@ -0,0 +1,39 @@ +document.addEventListener('DOMContentLoaded', () => { + const chatForm = document.getElementById('chat-form'); + const chatInput = document.getElementById('chat-input'); + const chatMessages = document.getElementById('chat-messages'); + + const appendMessage = (text, sender) => { + const msgDiv = document.createElement('div'); + msgDiv.classList.add('message', sender); + msgDiv.textContent = text; + chatMessages.appendChild(msgDiv); + chatMessages.scrollTop = chatMessages.scrollHeight; + }; + + chatForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const message = chatInput.value.trim(); + if (!message) return; + + appendMessage(message, 'visitor'); + chatInput.value = ''; + + try { + const response = await fetch('api/chat.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }) + }); + const data = await response.json(); + + // Artificial delay for realism + setTimeout(() => { + appendMessage(data.reply, 'bot'); + }, 500); + } catch (error) { + console.error('Error:', error); + appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + } + }); +}); diff --git a/db/config.php b/db/config.php new file mode 100644 index 0000000..4c7e5d4 --- /dev/null +++ b/db/config.php @@ -0,0 +1,17 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + } + return $pdo; +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..7205f3d --- /dev/null +++ b/index.php @@ -0,0 +1,150 @@ + + + + + + + New Style + + + + + + + + + + + + + + + + + + + + + +
+
+

Analyzing your requirements and generating your website…

+
+ Loading… +
+

AI is collecting your requirements and applying the first changes.

+

This page will update automatically as the plan is implemented.

+

Runtime: PHP — UTC

+
+
+
+ Page updated: (UTC) +
+ + diff --git a/mail/MailService.php b/mail/MailService.php new file mode 100644 index 0000000..d801067 --- /dev/null +++ b/mail/MailService.php @@ -0,0 +1,235 @@ + false, 'error' => 'PHPMailer not available' ]; + } + + $mail = new PHPMailer\PHPMailer\PHPMailer(true); + try { + $mail->isSMTP(); + $mail->Host = $cfg['smtp_host'] ?? ''; + $mail->Port = (int)($cfg['smtp_port'] ?? 587); + $secure = $cfg['smtp_secure'] ?? 'tls'; + if ($secure === 'ssl') $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS; + elseif ($secure === 'tls') $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS; + else $mail->SMTPSecure = false; + $mail->SMTPAuth = true; + $mail->Username = $cfg['smtp_user'] ?? ''; + $mail->Password = $cfg['smtp_pass'] ?? ''; + + $fromEmail = $opts['from_email'] ?? ($cfg['from_email'] ?? 'no-reply@localhost'); + $fromName = $opts['from_name'] ?? ($cfg['from_name'] ?? 'App'); + $mail->setFrom($fromEmail, $fromName); + if (!empty($opts['reply_to']) && filter_var($opts['reply_to'], FILTER_VALIDATE_EMAIL)) { + $mail->addReplyTo($opts['reply_to']); + } elseif (!empty($cfg['reply_to'])) { + $mail->addReplyTo($cfg['reply_to']); + } + + // Recipients + $toList = []; + if ($to) { + if (is_string($to)) $toList = array_map('trim', explode(',', $to)); + elseif (is_array($to)) $toList = $to; + } elseif (!empty(getenv('MAIL_TO'))) { + $toList = array_map('trim', explode(',', getenv('MAIL_TO'))); + } + $added = 0; + foreach ($toList as $addr) { + if (filter_var($addr, FILTER_VALIDATE_EMAIL)) { $mail->addAddress($addr); $added++; } + } + if ($added === 0) { + return [ 'success' => false, 'error' => 'No recipients defined (set MAIL_TO or pass $to)' ]; + } + + foreach ((array)($opts['cc'] ?? []) as $cc) { if (filter_var($cc, FILTER_VALIDATE_EMAIL)) $mail->addCC($cc); } + foreach ((array)($opts['bcc'] ?? []) as $bcc){ if (filter_var($bcc, FILTER_VALIDATE_EMAIL)) $mail->addBCC($bcc); } + + // Optional DKIM + if (!empty($cfg['dkim_domain']) && !empty($cfg['dkim_selector']) && !empty($cfg['dkim_private_key_path'])) { + $mail->DKIM_domain = $cfg['dkim_domain']; + $mail->DKIM_selector = $cfg['dkim_selector']; + $mail->DKIM_private = $cfg['dkim_private_key_path']; + } + + $mail->isHTML(true); + $mail->Subject = $subject; + $mail->Body = $htmlBody; + $mail->AltBody = $textBody ?? strip_tags($htmlBody); + $ok = $mail->send(); + return [ 'success' => $ok ]; + } catch (\Throwable $e) { + return [ 'success' => false, 'error' => 'PHPMailer error: ' . $e->getMessage() ]; + } + } + private static function loadConfig(): array + { + $configPath = __DIR__ . '/config.php'; + if (!file_exists($configPath)) { + throw new \RuntimeException('Mail config not found. Copy mail/config.sample.php to mail/config.php and fill in credentials.'); + } + $cfg = require $configPath; + if (!is_array($cfg)) { + throw new \RuntimeException('Invalid mail config format: expected array'); + } + return $cfg; + } + + // Send a contact message + // $to can be: a single email string, a comma-separated list, an array of emails, or null (fallback to MAIL_TO/MAIL_FROM) + public static function sendContactMessage(string $name, string $email, string $message, $to = null, string $subject = 'New contact form') + { + $cfg = self::loadConfig(); + + // Try Composer autoload if available (for PHPMailer) + $autoload = __DIR__ . '/../vendor/autoload.php'; + if (file_exists($autoload)) { + require_once $autoload; + } + // Fallback to system-wide PHPMailer (installed via apt: libphp-phpmailer) + if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + // Debian/Ubuntu package layout (libphp-phpmailer) + @require_once 'libphp-phpmailer/autoload.php'; + if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + @require_once 'libphp-phpmailer/src/Exception.php'; + @require_once 'libphp-phpmailer/src/SMTP.php'; + @require_once 'libphp-phpmailer/src/PHPMailer.php'; + } + // Alternative layout (older PHPMailer package names) + if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + @require_once 'PHPMailer/src/Exception.php'; + @require_once 'PHPMailer/src/SMTP.php'; + @require_once 'PHPMailer/src/PHPMailer.php'; + } + if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + @require_once 'PHPMailer/Exception.php'; + @require_once 'PHPMailer/SMTP.php'; + @require_once 'PHPMailer/PHPMailer.php'; + } + } + + $transport = $cfg['transport'] ?? 'smtp'; + if ($transport === 'smtp' && class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + return self::sendViaPHPMailer($cfg, $name, $email, $message, $to, $subject); + } + + // Fallback: attempt native mail() — works only if MTA is configured on the VM + return self::sendViaNativeMail($cfg, $name, $email, $message, $to, $subject); + } + + private static function sendViaPHPMailer(array $cfg, string $name, string $email, string $body, $to, string $subject) + { + $mail = new PHPMailer\PHPMailer\PHPMailer(true); + try { + $mail->isSMTP(); + $mail->Host = $cfg['smtp_host'] ?? ''; + $mail->Port = (int)($cfg['smtp_port'] ?? 587); + $secure = $cfg['smtp_secure'] ?? 'tls'; + if ($secure === 'ssl') $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS; + elseif ($secure === 'tls') $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS; + else $mail->SMTPSecure = false; + $mail->SMTPAuth = true; + $mail->Username = $cfg['smtp_user'] ?? ''; + $mail->Password = $cfg['smtp_pass'] ?? ''; + + $fromEmail = $cfg['from_email'] ?? 'no-reply@localhost'; + $fromName = $cfg['from_name'] ?? 'App'; + $mail->setFrom($fromEmail, $fromName); + + // Use Reply-To for the user's email to avoid spoofing From + if (filter_var($email, FILTER_VALIDATE_EMAIL)) { + $mail->addReplyTo($email, $name ?: $email); + } + if (!empty($cfg['reply_to'])) { + $mail->addReplyTo($cfg['reply_to']); + } + + // Destination: prefer dynamic recipients ($to), fallback to MAIL_TO; no silent FROM fallback + $toList = []; + if ($to) { + if (is_string($to)) { + // allow comma-separated list + $toList = array_map('trim', explode(',', $to)); + } elseif (is_array($to)) { + $toList = $to; + } + } elseif (!empty(getenv('MAIL_TO'))) { + $toList = array_map('trim', explode(',', getenv('MAIL_TO'))); + } + $added = 0; + foreach ($toList as $addr) { + if (filter_var($addr, FILTER_VALIDATE_EMAIL)) { + $mail->addAddress($addr); + $added++; + } + } + if ($added === 0) { + return [ 'success' => false, 'error' => 'No recipients defined (set MAIL_TO or pass $to)' ]; + } + + // DKIM (optional) + if (!empty($cfg['dkim_domain']) && !empty($cfg['dkim_selector']) && !empty($cfg['dkim_private_key_path'])) { + $mail->DKIM_domain = $cfg['dkim_domain']; + $mail->DKIM_selector = $cfg['dkim_selector']; + $mail->DKIM_private = $cfg['dkim_private_key_path']; + } + + $mail->isHTML(true); + $mail->Subject = $subject; + $safeName = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $safeEmail = htmlspecialchars($email, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $safeBody = nl2br(htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); + $mail->Body = "

Name: {$safeName}

Email: {$safeEmail}


{$safeBody}"; + $mail->AltBody = "Name: {$name}\nEmail: {$email}\n\n{$body}"; + + $ok = $mail->send(); + return [ 'success' => $ok ]; + } catch (\Throwable $e) { + return [ 'success' => false, 'error' => 'PHPMailer error: ' . $e->getMessage() ]; + } + } + + private static function sendViaNativeMail(array $cfg, string $name, string $email, string $body, $to, string $subject) + { + $opts = ['reply_to' => $email]; + $html = nl2br(htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); + return self::sendMail($to, $subject, $html, $body, $opts); + } +} diff --git a/mail/config.php b/mail/config.php new file mode 100644 index 0000000..626cca1 --- /dev/null +++ b/mail/config.php @@ -0,0 +1,76 @@ + config array for MailService. + +function env_val(string $key, $default = null) { + $v = getenv($key); + return ($v === false || $v === null || $v === '') ? $default : $v; +} + +// Fallback: if critical vars are missing from process env, try to parse executor/.env +// This helps in web/Apache contexts where .env is not exported. +// Supports simple KEY=VALUE lines; ignores quotes and comments. +function load_dotenv_if_needed(array $keys): void { + $missing = array_filter($keys, fn($k) => getenv($k) === false || getenv($k) === ''); + if (empty($missing)) return; + static $loaded = false; + if ($loaded) return; + $envPath = realpath(__DIR__ . '/../../.env'); // executor/.env + if ($envPath && is_readable($envPath)) { + $lines = @file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + foreach ($lines as $line) { + if ($line[0] === '#' || trim($line) === '') continue; + if (!str_contains($line, '=')) continue; + [$k, $v] = array_map('trim', explode('=', $line, 2)); + // Strip potential surrounding quotes + $v = trim($v, "\"' "); + // Do not override existing env + if ($k !== '' && (getenv($k) === false || getenv($k) === '')) { + putenv("{$k}={$v}"); + } + } + $loaded = true; + } +} + +load_dotenv_if_needed([ + 'MAIL_TRANSPORT','SMTP_HOST','SMTP_PORT','SMTP_SECURE','SMTP_USER','SMTP_PASS', + 'MAIL_FROM','MAIL_FROM_NAME','MAIL_REPLY_TO','MAIL_TO', + 'DKIM_DOMAIN','DKIM_SELECTOR','DKIM_PRIVATE_KEY_PATH' +]); + +$transport = env_val('MAIL_TRANSPORT', 'smtp'); +$smtp_host = env_val('SMTP_HOST'); +$smtp_port = (int) env_val('SMTP_PORT', 587); +$smtp_secure = env_val('SMTP_SECURE', 'tls'); // tls | ssl | null +$smtp_user = env_val('SMTP_USER'); +$smtp_pass = env_val('SMTP_PASS'); + +$from_email = env_val('MAIL_FROM', 'no-reply@localhost'); +$from_name = env_val('MAIL_FROM_NAME', 'App'); +$reply_to = env_val('MAIL_REPLY_TO'); + +$dkim_domain = env_val('DKIM_DOMAIN'); +$dkim_selector = env_val('DKIM_SELECTOR'); +$dkim_private_key_path = env_val('DKIM_PRIVATE_KEY_PATH'); + +return [ + 'transport' => $transport, + + // SMTP + 'smtp_host' => $smtp_host, + 'smtp_port' => $smtp_port, + 'smtp_secure' => $smtp_secure, + 'smtp_user' => $smtp_user, + 'smtp_pass' => $smtp_pass, + + // From / Reply-To + 'from_email' => $from_email, + 'from_name' => $from_name, + 'reply_to' => $reply_to, + + // DKIM (optional) + 'dkim_domain' => $dkim_domain, + 'dkim_selector' => $dkim_selector, + 'dkim_private_key_path' => $dkim_private_key_path, +];