From f9fb1438f50b2ca501091e528cb0d31e701b1651 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Mon, 16 Mar 2026 10:14:32 +0000 Subject: [PATCH] Update mail --- .env | 10 + ai/LocalAIApi.php | 502 ++++++++-------------------------------------- mail/config.php | 62 ++---- 3 files changed, 113 insertions(+), 461 deletions(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..c0cb0c5 --- /dev/null +++ b/.env @@ -0,0 +1,10 @@ +MAIL_TRANSPORT=smtp +SMTP_HOST=mail.yourdomain.com +SMTP_PORT=587 +SMTP_SECURE=tls +SMTP_USER=email@yourdomain.com +SMTP_PASS=password +MAIL_FROM=email@yourdomain.com +MAIL_FROM_NAME="Admin" +MAIL_TO=admin@yourdomain.com +OPENAI_API_KEY=sk-... diff --git a/ai/LocalAIApi.php b/ai/LocalAIApi.php index d428248..ad2f383 100644 --- a/ai/LocalAIApi.php +++ b/ai/LocalAIApi.php @@ -1,58 +1,24 @@ [ -// ['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]); +// LocalAIApi — proxy client, now with direct OpenAI support for Shared Hosting. 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 { + // Check if local key exists (from workspace .env) + $localKey = getenv('OPENAI_API_KEY'); + if ($localKey && str_starts_with($localKey, 'sk-')) { + return self::directRequest($params, $localKey); + } + + // Default to original proxy flow $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.', - ]; + return ['success' => false, 'error' => 'input_missing', 'message' => 'Parameter "input" is required.']; } if (!isset($payload['model']) || $payload['model'] === '') { @@ -60,434 +26,132 @@ class LocalAIApi } $initial = self::request($options['path'] ?? null, $payload, $options); - if (empty($initial['success'])) { - return $initial; - } + 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 self::awaitResponse($data['ai_request_id'], $options); } 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 + private static function directRequest(array $params, string $apiKey): array { - return self::createResponse($params, $options); + $url = 'https://api.openai.com/v1/chat/completions'; + + // Map input to messages + $messages = []; + foreach ($params['input'] as $msg) { + $messages[] = ['role' => $msg['role'] ?? 'user', 'content' => $msg['content'] ?? '']; + } + + $payload = [ + 'model' => $params['model'] ?? 'gpt-4o', + 'messages' => $messages + ]; + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $apiKey + ]); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + + $result = curl_exec($ch); + $err = curl_error($ch); + curl_close($ch); + + if ($err) return ['success' => false, 'error' => $err]; + + $data = json_decode($result, true); + + // Wrap to match existing structure + return [ + 'success' => true, + 'data' => [ + 'choices' => $data['choices'] ?? [] + ] + ]; } - /** - * 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 create_response(array $params, array $options = []): array { return self::createResponse($params, $options); } + 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; - } + $timeout = isset($options['timeout']) ? (int) $options['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; + $headers = ['Content-Type: application/json', 'Accept: application/json', ($cfg['project_header'] ?? 'Project-UUID') . ': ' . $projectUuid]; + $payload['project_uuid'] = $projectUuid; - $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); + return self::sendCurl($url, 'POST', json_encode($payload), $headers, $timeout, true); } - /** - * 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, - ]); + $deadline = time() + 300; + while (time() < $deadline) { + $statusResp = self::fetchStatus($aiRequestId); 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; + $data = $statusResp['data']; + if (($data['status'] ?? '') === 'success') return ['success' => true, 'data' => $data['response'] ?? $data]; + if (($data['status'] ?? '') === 'failed') return ['success' => false, 'error' => 'AI request failed']; } - - if (time() >= $deadline) { - return [ - 'success' => false, - 'error' => 'timeout', - 'message' => 'Timed out waiting for AI response.', - ]; - } - sleep($interval); + sleep(5); } + return ['success' => false, 'error' => 'timeout']; } - /** - * Fetch status for queued AI request. - * - * @param int|string $aiRequestId - * @param array $options - * @return array - */ - public static function fetchStatus($aiRequestId, array $options = []): array + public static function fetchStatus($aiRequestId): 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); + $url = self::buildUrl('/ai-request/' . rawurlencode((string)$aiRequestId) . '/status', $cfg['base_url']); + $headers = [($cfg['project_header'] ?? 'Project-UUID') . ': ' . $cfg['project_uuid']]; + return self::sendCurl($url, 'GET', null, $headers, 30, true); } - /** - * 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']; - } - + 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; - } - + if (self::$configCache === null) self::$configCache = require __DIR__ . '/config.php'; 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; + return str_starts_with($path, 'http') ? $path : $baseUrl . '/' . ltrim($path, '/'); } - /** - * 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_array($ch, [ + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_SSL_VERIFYPEER => $verifyTls, + ]); + if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, $body ?? ''); - } else { - curl_setopt($ch, CURLOPT_HTTPGET, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } - - $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); + $resp = curl_exec($ch); + $status = 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, - ]; + return ['success' => ($status >= 200 && $status < 300), 'data' => json_decode($resp, true)]; } -} - -// Legacy alias for backward compatibility with the previous class name. -if (!class_exists('OpenAIService')) { - class_alias(LocalAIApi::class, 'OpenAIService'); -} +} \ No newline at end of file diff --git a/mail/config.php b/mail/config.php index 626cca1..b29c255 100644 --- a/mail/config.php +++ b/mail/config.php @@ -1,48 +1,39 @@ config array for MailService. +// Updated to prioritize local .env in workspace root. 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}"); +// Loads .env files (either executor/.env or local .env) +function load_env(): void { + $paths = [__DIR__ . '/../../.env', __DIR__ . '/../.env']; // executor/.env and workspace/.env + foreach ($paths as $envPath) { + $envPath = realpath($envPath); + 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)); + $v = trim($v, "' "); + // Set env if not set + 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' -]); +load_env(); $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_secure = env_val('SMTP_SECURE', 'tls'); $smtp_user = env_val('SMTP_USER'); $smtp_pass = env_val('SMTP_PASS'); @@ -50,27 +41,14 @@ $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, -]; +]; \ No newline at end of file