Procházet zdrojové kódy

All tools working, ready for testing and multicurrency prep

Miłosz Semenov před 3 týdny
rodič
revize
69906c93f6

+ 10 - 1
.env.example

@@ -9,7 +9,11 @@ CHATWOOT_TICKET_LABEL=ticket
 FLOWISE_PREDICT_URL=http://localhost:3000/api/v1/prediction/your-chatflow-id
 FLOWISE_API_KEY=
 
-# === WordPress AJAX (mu-plugin endpoints) ===
+# === WordPress REST API (eksrelay_api.php mu-plugin) ===
+# URL bazowy sklepu WP – trasa REST budowana jako {WOOCOMMERCE_BASE_URL}/wp-json/eksrelay/v1
+# Endpointy są otwarte (bez autoryzacji) – wystarczy wgrać eksrelay_api.php jako mu-plugin.
+
+# === WordPress AJAX (stare endpointy – zachowane dla kompatybilności) ===
 WP_AJAX_URL=https://your-shop.com/wp-admin/admin-ajax.php
 
 # === WooCommerce REST API ===
@@ -19,3 +23,8 @@ WOOCOMMERCE_CONSUMER_SECRET=cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
 
 # === Relay Auth ===
 RELAY_SHARED_SECRET=change-me-to-a-random-string
+
+# === Logging ===
+# Poziom logowania: debug | info | warn | error  (domyślnie: info)
+# Ustaw debug tymczasowo podczas diagnozowania problemów.
+LOG_LEVEL=info

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 2 - 3
flowise-tools/get_car_data.json


+ 3 - 3
flowise-tools/get_order_data.json

@@ -1,8 +1,8 @@
 {
   "name": "get_order_data",
-  "description": "Pobierz dane zamówienia z WooCommerce. Podaj numer zamówienia lub adres e-mail klienta. Priorytet ma numer zamówienia.",
+  "description": "Pobierz dane zamówienia z WooCommerce. Zawsze podaj email klienta (używany do weryfikacji tożsamości). Opcjonalnie podaj numer zamówienia. Email pobierz z danych konwersacji — NIE pytaj użytkownika o email jeśli piszą z maila.",
   "color": "linear-gradient(rgb(100,150,200), rgb(50,100,170))",
   "iconSrc": "",
-  "schema": "[{\"id\":0,\"property\":\"orderNumber\",\"description\":\"Numer zamówienia WooCommerce\",\"type\":\"string\",\"required\":false},{\"id\":1,\"property\":\"email\",\"description\":\"Adres e-mail klienta (jeśli nie masz numeru zamówienia)\",\"type\":\"string\",\"required\":false}]",
-  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\n\nconst orderNumber = typeof $orderNumber !== 'undefined' && $orderNumber ? String($orderNumber) : ''\nconst email = typeof $email !== 'undefined' && $email ? String($email) : ''\n\nif (!orderNumber && !email) return 'Błąd: podaj orderNumber lub email'\nif (!secret) return 'Błąd: brak $vars.relay_shared_secret'\n\ntry {\n  const body = {}\n  if (orderNumber) body.orderNumber = orderNumber\n  if (email) body.email = email\n\n  const res = await fetch(`${base}/tools/get_order_data`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify(body)\n  })\n  const data = await res.json()\n  if (data.ok) {\n    return JSON.stringify(data.data, null, 2)\n  }\n  return `Błąd: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Błąd połączenia: ${error?.message || String(error)}`\n}\n"
+  "schema": "[{\"id\":0,\"property\":\"orderNumber\",\"description\":\"Numer zamówienia WooCommerce (opcjonalny)\",\"type\":\"string\",\"required\":false},{\"id\":1,\"property\":\"email\",\"description\":\"Adres e-mail klienta — wymagany do weryfikacji. Jeśli nieznany, zostanie pobrany automatycznie z danych konwersacji.\",\"type\":\"string\",\"required\":false}]",
+  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\n\nconst orderNumber = typeof $orderNumber !== 'undefined' && $orderNumber ? String($orderNumber) : ''\n// Email: explicit parameter OR auto-filled from conversation sender\nconst email = (typeof $email !== 'undefined' && $email)\n  ? String($email)\n  : String(($vars && $vars.sender_email) || '')\n\nif (!email) return 'Błąd: nie można ustalić adresu e-mail klienta potrzebnego do weryfikacji tożsamości.'\nif (!secret) return 'Błąd: brak $vars.relay_shared_secret'\n\ntry {\n  const body = { email }\n  if (orderNumber) body.orderNumber = orderNumber\n\n  const res = await fetch(`${base}/tools/get_order_data`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify(body)\n  })\n  const data = await res.json()\n  if (data.ok) {\n    return JSON.stringify(data.data, null, 2)\n  }\n  if (data.code === 'UNAUTHORIZED') {\n    return 'Brak autoryzacji: podany adres e-mail nie pasuje do zamówienia.'\n  }\n  return `Błąd: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Błąd połączenia: ${error?.message || String(error)}`\n}\n"
 }

+ 1 - 1
flowise-tools/get_payment_methods.json

@@ -4,5 +4,5 @@
   "color": "linear-gradient(rgb(200,180,100), rgb(180,140,50))",
   "iconSrc": "",
   "schema": "[]",
-  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\n\nif (!secret) return 'Błąd: brak $vars.relay_shared_secret'\n\ntry {\n  const res = await fetch(`${base}/tools/get_payment_methods`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify({})\n  })\n  const data = await res.json()\n  if (data.ok) {\n    return JSON.stringify(data.data, null, 2)\n  }\n  return `Info: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Błąd połączenia: ${error?.message || String(error)}`\n}\n"
+  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\n\nif (!secret) return 'Błąd: brak $vars.relay_shared_secret'\n\ntry {\n  const res = await fetch(`${base}/tools/get_payment_methods`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify({})\n  })\n  const data = await res.json()\n  if (data.ok) {\n    const gateways = data.data\n    if (!Array.isArray(gateways) || !gateways.length) return 'Brak dostępnych metod płatności.'\n    const lines = gateways.map(g => '- ' + g.title).join('\\n')\n    return 'Dostępne metody płatności:\\n' + lines\n  }\n  return `Info: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Błąd połączenia: ${error?.message || String(error)}`\n}\n"
 }

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 2 - 3
flowise-tools/get_product_compatibility.json


+ 3 - 3
flowise-tools/get_product_data.json

@@ -1,8 +1,8 @@
 {
   "name": "get_product_data",
-  "description": "Pobierz dane produktu ze sklepu WooCommerce. Podaj ID produktu, SKU lub frazę do wyszukania.",
+  "description": "Pobierz dane produktu ze sklepu WooCommerce. Podaj ID produktu, SKU lub frazę do wyszukania. Gdy wyszukiwanie zwróci wiele wyników, przedstaw nazwy użytkownikowi i zapytaj o konkretny produkt.",
   "color": "linear-gradient(rgb(100,180,200), rgb(50,130,170))",
   "iconSrc": "",
-  "schema": "[{\"id\":0,\"property\":\"productId\",\"description\":\"ID produktu WooCommerce\",\"type\":\"number\",\"required\":false},{\"id\":1,\"property\":\"sku\",\"description\":\"SKU produktu\",\"type\":\"string\",\"required\":false},{\"id\":2,\"property\":\"search\",\"description\":\"Fraza do wyszukania produktu po nazwie\",\"type\":\"string\",\"required\":false}]",
-  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\n\nconst productId = typeof $productId !== 'undefined' && $productId ? Number($productId) : 0\nconst sku = typeof $sku !== 'undefined' && $sku ? String($sku) : ''\nconst search = typeof $search !== 'undefined' && $search ? String($search) : ''\n\nif (!productId && !sku && !search) return 'Błąd: podaj productId, sku lub search'\nif (!secret) return 'Błąd: brak $vars.relay_shared_secret'\n\ntry {\n  const body = {}\n  if (productId > 0) body.productId = productId\n  if (sku) body.sku = sku\n  if (search) body.search = search\n\n  const res = await fetch(`${base}/tools/get_product_data`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify(body)\n  })\n  const data = await res.json()\n  if (data.ok) {\n    return JSON.stringify(data.data, null, 2)\n  }\n  return `Błąd: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Błąd połączenia: ${error?.message || String(error)}`\n}\n"
+  "schema": "[{\"id\":0,\"property\":\"productId\",\"description\":\"ID produktu WooCommerce\",\"type\":\"number\",\"required\":false},{\"id\":1,\"property\":\"sku\",\"description\":\"SKU produktu\",\"type\":\"string\",\"required\":false},{\"id\":2,\"property\":\"search\",\"description\":\"Fraza do wyszukania produktu po nazwie lub opisie\",\"type\":\"string\",\"required\":false}]",
+  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\n\nconst productId = typeof $productId !== 'undefined' && $productId ? Number($productId) : 0\nconst sku = typeof $sku !== 'undefined' && $sku ? String($sku) : ''\nconst search = typeof $search !== 'undefined' && $search ? String($search) : ''\n\nif (!productId && !sku && !search) return 'Błąd: podaj productId, sku lub search'\nif (!secret) return 'Błąd: brak $vars.relay_shared_secret'\n\ntry {\n  const body = {}\n  if (productId > 0) body.productId = productId\n  if (sku) body.sku = sku\n  if (search) body.search = search\n\n  const res = await fetch(`${base}/tools/get_product_data`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify(body)\n  })\n  const data = await res.json()\n  if (data.ok) {\n    const result = data.data\n    if (Array.isArray(result)) {\n      if (result.length === 0) return 'Nie znaleziono produktów pasujących do podanej frazy.'\n      if (result.length === 1) return JSON.stringify(result[0], null, 2)\n      const idMap = result.map(p => p.name + ': ' + p.id).join(', ')\n      const list = result.map((p, i) => `${i + 1}. ${p.name} — ${p.price ? p.price + ' ' + (data.currency_symbol || 'PLN') : 'brak ceny'}`).join('\\n')\n      return `Znaleziono ${result.length} produktów:\\n${list}\\n\\n[INSTRUKCJA: Zapytaj użytkownika, który produkt go interesuje (pokaż numery i nazwy, nie ID). Gdy wybierze, wywołaj narzędzie ponownie z odpowiednim productId. Mapowanie: ${idMap}]`\n    }\n    return JSON.stringify(result, null, 2)\n  }\n  return `Błąd: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Błąd połączenia: ${error?.message || String(error)}`\n}\n"
 }

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 2 - 3
flowise-tools/get_shipping_data.json


+ 25 - 3
public/index.php

@@ -1,6 +1,9 @@
 <?php
 
 declare(strict_types=1);
+header('X-EKSRelay: hit');
+file_put_contents(__DIR__ . '/../logs/_probe.txt', "probe " . date('c') . "\n", FILE_APPEND);
+
 
 /**
  * EKSRelay – single entry point.
@@ -38,7 +41,26 @@ $router->post('/tools/get_car_data',           [WooToolsHandler::class, 'getCarD
 
 // ── Dispatch ───────────────────────────────────────────────────────
 $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
-$uri    = $_SERVER['REQUEST_URI'] ?? '/';
 
-Logger::info('Request received', ['method' => $method, 'uri' => $uri]);
-$router->dispatch($method, $uri);
+$path = $_GET['__path']
+    ?? (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
+
+$path = '/' . trim((string)$path, '/');
+
+$rawBody = file_get_contents('php://input') ?: '';
+
+Logger::info('Incoming request', [
+    'method' => $method,
+    'uri'    => $_SERVER['REQUEST_URI'] ?? null,
+    'ip'     => $_SERVER['REMOTE_ADDR'] ?? null,
+    'ua'     => $_SERVER['HTTP_USER_AGENT'] ?? null,
+]);
+
+if ($rawBody !== '') {
+    // UWAGA: czasem payload jest duży; możesz obciąć do np. 50k
+    Logger::debug('Incoming body', [
+        'body' => json_decode($rawBody, true) ?? $rawBody
+    ]);
+}
+
+$router->dispatch($method, $path);

+ 18 - 2
src/Clients/ChatwootClient.php

@@ -98,16 +98,32 @@ final class ChatwootClient
 
     /**
      * Set custom attributes on a conversation.
-     * PATCH /conversations/{id}/  with { "custom_attributes": { ... } }
+     * POST /conversations/{id}/custom_attributes with { "custom_attributes": { ... } }
      *
      * Chatwoot merges provided keys into existing custom_attributes.
      */
     public function setCustomAttributes(int $conversationId, array $attrs): void
     {
-        $this->api('PATCH', "/conversations/{$conversationId}", [
+        $result = $this->api('POST', "/conversations/{$conversationId}/custom_attributes", [
             'custom_attributes' => $attrs,
         ]);
         Logger::info("Custom attributes set", ['conversation_id' => $conversationId, 'attrs' => $attrs]);
+        Logger::debug("Custom attributes API response", [
+            'conversation_id'       => $conversationId,
+            'returned_custom_attrs' => $result['custom_attributes'] ?? '(missing)',
+        ]);
+    }
+
+    /**
+     * Assign a specific agent to a conversation.
+     * POST /conversations/{id}/assignments with { "assignee_id": agent_id }
+     */
+    public function assignConversation(int $conversationId, int $agentId): void
+    {
+        $this->api('POST', "/conversations/{$conversationId}/assignments", [
+            'assignee_id' => $agentId,
+        ]);
+        Logger::info("Conversation assigned", ['conversation_id' => $conversationId, 'agent_id' => $agentId]);
     }
 
     /**

+ 110 - 4
src/Core/Logger.php

@@ -8,9 +8,37 @@ final class Logger
 {
     private static ?string $requestId = null;
 
+    private static string $channel = 'stderr'; // stderr|file
+    private static string $path = '';
+    private static string $level = 'info';
+    private static int $maxBytes = 5242880; // 5MB
+    private static array $redactKeys = [];
+
+    private const LEVELS = [
+        'debug' => 10,
+        'info'  => 20,
+        'warn'  => 30,
+        'error' => 40,
+    ];
+
     public static function init(): void
     {
         self::$requestId = substr(bin2hex(random_bytes(8)), 0, 16);
+
+        // Env jest już załadowany u Ciebie przed Logger::init()
+        self::$channel = 'file';
+        self::$path = 'logs/eksrelay.log';
+        $level = strtolower(trim((string)(Env::get('LOG_LEVEL') ?? 'info')));
+        self::$level = $level !== '' ? $level : 'info';
+        self::$maxBytes = (int)(Env::get('LOG_MAX_BYTES') ?? self::$maxBytes);
+
+        $rk = (string)(Env::get('LOG_REDACT_KEYS') ?? '');
+        self::$redactKeys = array_values(array_filter(array_map('trim', explode(',', strtolower($rk)))));
+
+        // jeśli file wybrane, ale brak ścieżki => fallback
+        if (self::$channel === 'file' && self::$path === '') {
+            self::$channel = 'stderr';
+        }
     }
 
     public static function getRequestId(): string
@@ -18,6 +46,11 @@ final class Logger
         return self::$requestId ?? 'unknown';
     }
 
+    public static function debug(string $message, array $context = []): void
+    {
+        self::log('debug', $message, $context);
+    }
+
     public static function info(string $message, array $context = []): void
     {
         self::log('info', $message, $context);
@@ -33,8 +66,19 @@ final class Logger
         self::log('error', $message, $context);
     }
 
+    private static function shouldLog(string $level): bool
+    {
+        $min = self::LEVELS[self::$level] ?? 20;
+        $cur = self::LEVELS[$level] ?? 20;
+        return $cur >= $min;
+    }
+
     private static function log(string $level, string $message, array $context): void
     {
+        if (!self::shouldLog($level)) {
+            return;
+        }
+
         $entry = [
             'timestamp'  => gmdate('Y-m-d\TH:i:s\Z'),
             'level'      => $level,
@@ -52,15 +96,77 @@ final class Logger
         }
 
         if ($context !== []) {
-            $entry['context'] = $context;
+            $entry['context'] = self::redact($context);
         }
 
-        // JSON on stderr (visible in php -S server output; STDOUT is not defined in web SAPI)
         $line = json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+        self::writeLine($line . "\n");
+    }
+
+    private static function redact(array $data): array
+    {
+        if (self::$redactKeys === []) return $data;
+
+        $out = [];
+        foreach ($data as $k => $v) {
+            $key = strtolower((string)$k);
+
+            if (in_array($key, self::$redactKeys, true)) {
+                $out[$k] = '[REDACTED]';
+                continue;
+            }
+
+            if (is_array($v)) {
+                $out[$k] = self::redact($v);
+            } else {
+                $out[$k] = $v;
+            }
+        }
+        return $out;
+    }
+
+    private static function writeLine(string $line): void
+    {
+        if (self::$channel === 'file') {
+            self::writeToFile($line);
+            return;
+        }
+
+        // stderr/stdout fallback
         if (defined('STDOUT')) {
-            fwrite(\STDOUT, $line . "\n");
+            fwrite(\STDOUT, $line);
         } else {
-            error_log($line);
+            error_log(rtrim($line, "\n"));
+        }
+    }
+
+    private static function writeToFile(string $line): void
+    {
+        $path = self::resolvePath(self::$path);
+
+        $dir = dirname($path);
+        if (!is_dir($dir)) {
+            if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
+                error_log("EKSRelay Logger: cannot mkdir {$dir}");
+                return;
+            }
+        }
+
+        $ok = file_put_contents($path, $line, FILE_APPEND | LOCK_EX);
+        if ($ok === false) {
+            error_log("EKSRelay Logger: cannot write {$path}");
+        }
+    }
+
+
+    private static function resolvePath(string $path): string
+    {
+        // Pozwól na ścieżkę względną względem katalogu /public
+        if ($path === '') return $path;
+        if ($path[0] === '/' || preg_match('~^[A-Za-z]:\\\\~', $path) === 1) {
+            return $path;
         }
+        return rtrim(__DIR__ . '/../../', '/') . '/' . ltrim($path, '/');
     }
 }

+ 39 - 8
src/Handlers/ChatwootWebhookHandler.php

@@ -6,6 +6,7 @@ namespace EKSRelay\Handlers;
 
 use EKSRelay\Clients\ChatwootClient;
 use EKSRelay\Clients\FlowiseClient;
+use EKSRelay\Core\Env;
 use EKSRelay\Core\HttpException;
 use EKSRelay\Core\Logger;
 use EKSRelay\Core\Router;
@@ -40,6 +41,23 @@ final class ChatwootWebhookHandler
         $logCtx = ['conversation_id' => $conversationId, 'message_id' => $messageId];
         Logger::info('Processing incoming message', $logCtx);
 
+        // -----------------------------------------------------------------
+        // Extract contact email from multiple sources (most reliable first)
+        // -----------------------------------------------------------------
+        $meta       = $conversation['meta'] ?? [];
+        $metaSender = $meta['sender'] ?? [];
+        $contactInbox = $conversation['contact_inbox'] ?? [];
+
+        $rawEmail   = (string)($metaSender['email']
+                        ?? $sender['email']
+                        ?? $contactInbox['source_id']
+                        ?? '');
+        // source_id may not be an email (e.g. on non-email channels)
+        $senderEmail = filter_var($rawEmail, FILTER_VALIDATE_EMAIL) ? $rawEmail : '';
+        $senderName  = (string)($metaSender['name'] ?? $sender['name'] ?? '');
+
+        Logger::debug('Resolved sender', ['email' => $senderEmail, 'name' => $senderName]);
+
         $chatwoot = new ChatwootClient();
 
         // -----------------------------------------------------------------
@@ -73,8 +91,22 @@ final class ChatwootWebhookHandler
         // -----------------------------------------------------------------
         // Build Flowise request
         // -----------------------------------------------------------------
+
+        // Prepend contact context so the LLM can access it directly
+        // (avoids the need for Variable nodes in Flowise)
+        $contextLines = [];
+        if ($senderEmail !== '') {
+            $contextLines[] = "Email klienta: {$senderEmail}";
+        }
+        if ($senderName !== '') {
+            $contextLines[] = "Imię klienta: {$senderName}";
+        }
+        $question = $contextLines
+            ? implode("\n", $contextLines) . "\n\n---\n" . $content
+            : $content;
+
         $flowisePayload = [
-            'question'       => $content,
+            'question'       => $question,
             'overrideConfig' => [
                 'sessionId'      => "chatwoot:{$conversationId}",
                 'conversationId' => $conversationId,
@@ -101,6 +133,8 @@ final class ChatwootWebhookHandler
         // -----------------------------------------------------------------
         // Call Flowise
         // -----------------------------------------------------------------
+        Logger::debug('Flowise payload', ['payload' => $flowisePayload]);
+
         $flowise  = new FlowiseClient();
         $response = $flowise->predict($flowisePayload);
 
@@ -137,13 +171,10 @@ final class ChatwootWebhookHandler
         // Handle normal reply
         // -----------------------------------------------------------------
         if ($response['type'] === 'reply' && $response['text'] !== null && $response['text'] !== '') {
-            // Re-check ticket mode before sending (race condition guard)
-            if ($chatwoot->isTicketMode($conversationId)) {
-                Logger::info('Conversation became ticket before reply — not sending', $logCtx);
-                Router::sendJson(200, ['ok' => true, 'skipped' => true, 'reason' => 'became_ticket']);
-                return;
-            }
-
+            // Note: we intentionally do NOT re-check isTicketMode here.
+            // The conversation was confirmed NOT a ticket before calling Flowise.
+            // If a ticket was created by the agent's tool during this call,
+            // the agent's confirmation message should still reach the user.
             $chatwoot->sendOutgoingMessage($conversationId, $response['text']);
             Logger::info('Reply sent', $logCtx);
             Router::sendJson(200, ['ok' => true, 'action' => 'reply']);

+ 3 - 8
src/Handlers/NewTicketHandler.php

@@ -76,10 +76,7 @@ final class NewTicketHandler
         // -----------------------------------------------------------------
         $ticketNumber = self::generateTicketNumber($conversationId);
 
-        // 1. Unassign bot from conversation
-        $chatwoot->unassignConversation($conversationId);
-
-        // 2. Set custom attributes
+        // 1. Set custom attributes
         $chatwoot->setCustomAttributes($conversationId, [
             'handoff'       => true,
             'ticket_number' => $ticketNumber,
@@ -98,12 +95,10 @@ final class NewTicketHandler
     }
 
     /**
-     * Generate a deterministic, human-readable ticket number without a database.
-     * Format: TCK-YYYYMMDD-HHMMSS-{conversationId}
+     * Ticket number = conversation ID (always unique in Chatwoot scope).
      */
     private static function generateTicketNumber(int $conversationId): string
     {
-        $now = gmdate('Ymd-His');
-        return "TCK-{$now}-{$conversationId}";
+        return (string)$conversationId;
     }
 }

+ 37 - 39
src/Handlers/WooToolsHandler.php

@@ -11,6 +11,7 @@ use EKSRelay\Core\HttpClient;
 use EKSRelay\Core\HttpException;
 use EKSRelay\Core\Logger;
 use EKSRelay\Core\Router;
+use EKSRelay\Core\StoreLocales;
 
 /**
  * Handlers for all WooCommerce-backed tool endpoints.
@@ -32,14 +33,21 @@ final class WooToolsHandler
             $orderNumber = (string)$orderNumber;
         }
 
+        if ($email === null || $email === '') {
+            throw new HttpException(400, 'MISSING_PARAMS', 'email is required to verify order ownership.');
+        }
+
         $woo = new WooCommerceClient();
 
         if ($orderNumber !== null && $orderNumber !== '') {
             $order = $woo->getOrder(orderNumber: $orderNumber);
-        } elseif ($email !== null && $email !== '') {
-            $order = $woo->getOrder(email: $email);
+            // Security: verify that the caller's email matches the billing email on the order.
+            $billingEmail = strtolower(trim($order['billing']['email'] ?? ''));
+            if ($billingEmail === '' || $billingEmail !== strtolower(trim($email))) {
+                throw new HttpException(403, 'UNAUTHORIZED', 'Provided email does not match the order.');
+            }
         } else {
-            throw new HttpException(400, 'MISSING_PARAMS', 'Provide orderNumber or email.');
+            $order = $woo->getOrder(email: $email);
         }
 
         // Return a curated subset useful for the LLM agent
@@ -176,17 +184,7 @@ final class WooToolsHandler
         Auth::requireBearer();
         $body = Router::jsonBody();
 
-        $ajaxUrl = Env::get('WP_AJAX_URL');
-        if ($ajaxUrl === '') {
-            Router::sendJson(200, [
-                'ok'      => false,
-                'code'    => 'NOT_CONFIGURED',
-                'message' => 'WP_AJAX_URL is not set in .env.',
-            ]);
-            return;
-        }
-
-        $params = ['action' => 'chat_get_product_compatibility'];
+        $restBase = rtrim(Env::get('WOOCOMMERCE_BASE_URL'), '/') . '/wp-json/eksrelay/v1';
 
         $productName = $body['product_name'] ?? ($body['productName'] ?? null);
         $carBrand    = $body['car_brand']    ?? ($body['carBrand']   ?? null);
@@ -194,14 +192,19 @@ final class WooToolsHandler
         $carYear     = $body['car_year']     ?? ($body['carYear']    ?? null);
         $carEngine   = $body['car_engine']   ?? ($body['carEngine']  ?? null);
 
-        if ($productName !== null && $productName !== '') $params['product_name'] = $productName;
-        if ($carBrand    !== null && $carBrand    !== '') $params['car_brand']    = $carBrand;
-        if ($carModel    !== null && $carModel    !== '') $params['car_model']    = $carModel;
-        if ($carYear     !== null && $carYear     !== '') $params['car_year']     = $carYear;
-        if ($carEngine   !== null && $carEngine   !== '') $params['car_engine']   = $carEngine;
+        $payload = [];
+        if ($productName !== null && $productName !== '') $payload['product_name'] = $productName;
+        if ($carBrand    !== null && $carBrand    !== '') $payload['car_brand']    = $carBrand;
+        if ($carModel    !== null && $carModel    !== '') $payload['car_model']    = $carModel;
+        if ($carYear     !== null && $carYear     !== '') $payload['car_year']     = $carYear;
+        if ($carEngine   !== null && $carEngine   !== '') $payload['car_engine']   = $carEngine;
 
-        $url = $ajaxUrl . '?' . http_build_query($params);
-        $res = HttpClient::request('GET', $url);
+        $res = HttpClient::request(
+            'POST',
+            $restBase . '/product-compatibility',
+            [],
+            $payload
+        );
 
         Router::sendJson(200, ['ok' => true, 'data' => $res['json'] ?? $res['body']]);
     }
@@ -214,30 +217,25 @@ final class WooToolsHandler
         Auth::requireBearer();
         $body = Router::jsonBody();
 
-        $ajaxUrl = Env::get('WP_AJAX_URL');
-        if ($ajaxUrl === '') {
-            Router::sendJson(200, [
-                'ok'      => false,
-                'code'    => 'NOT_CONFIGURED',
-                'message' => 'WP_AJAX_URL is not set in .env.',
-            ]);
-            return;
-        }
-
-        $params = ['action' => 'chat_get_car_data'];
+        $restBase = rtrim(Env::get('WOOCOMMERCE_BASE_URL'), '/') . '/wp-json/eksrelay/v1';
 
         $carBrand  = $body['car_brand']  ?? ($body['make']   ?? null);
         $carModel  = $body['car_model']  ?? ($body['model']  ?? null);
         $carYear   = $body['car_year']   ?? ($body['year']   ?? null);
         $carEngine = $body['car_engine'] ?? ($body['engine'] ?? null);
 
-        if ($carBrand  !== null && $carBrand  !== '') $params['car_brand']  = $carBrand;
-        if ($carModel  !== null && $carModel  !== '') $params['car_model']  = $carModel;
-        if ($carYear   !== null && $carYear   !== '') $params['car_year']   = $carYear;
-        if ($carEngine !== null && $carEngine !== '') $params['car_engine'] = $carEngine;
-
-        $url = $ajaxUrl . '?' . http_build_query($params);
-        $res = HttpClient::request('GET', $url);
+        $payload = [];
+        if ($carBrand  !== null && $carBrand  !== '') $payload['car_brand']  = $carBrand;
+        if ($carModel  !== null && $carModel  !== '') $payload['car_model']  = $carModel;
+        if ($carYear   !== null && $carYear   !== '') $payload['car_year']   = $carYear;
+        if ($carEngine !== null && $carEngine !== '') $payload['car_engine'] = $carEngine;
+
+        $res = HttpClient::request(
+            'POST',
+            $restBase . '/car-data',
+            [],
+            $payload
+        );
 
         Router::sendJson(200, ['ok' => true, 'data' => $res['json'] ?? $res['body']]);
     }

+ 684 - 0
wp-plugins/eksrelay_api.php

@@ -0,0 +1,684 @@
+<?php
+/**
+ * Plugin Name: EKSRelay API
+ * Description: Dedykowane REST API dla EKSRelay – get_car_data i get_product_compatibility.
+ *              Otwarte endpointy REST (bez autoryzacji) – odpowiedniki nopriv AJAX.
+ *              Wdróż ten plik na serwer WP jako: /wp-content/mu-plugins/eksrelay_api.php
+ * Version:     1.0.0
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+    exit;
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// Rejestracja tras REST
+// ═══════════════════════════════════════════════════════════════════
+
+add_action( 'rest_api_init', function () {
+    register_rest_route( 'eksrelay/v1', '/car-data', [
+        'methods'             => 'POST',
+        'callback'            => 'eksrelay_car_data',
+        'permission_callback' => '__return_true',
+    ] );
+
+    register_rest_route( 'eksrelay/v1', '/product-compatibility', [
+        'methods'             => 'POST',
+        'callback'            => 'eksrelay_product_compatibility',
+        'permission_callback' => '__return_true',
+    ] );
+} );
+
+// ═══════════════════════════════════════════════════════════════════
+// Handler: POST /wp-json/eksrelay/v1/car-data
+//
+// Body JSON: { car_year, car_brand?, car_model?, car_engine? }
+// ═══════════════════════════════════════════════════════════════════
+
+function eksrelay_car_data( WP_REST_Request $request ) {
+    $body = $request->get_json_params() ?: [];
+
+    if ( empty( $body['car_year'] ) ) {
+        return [ 'error' => 'car_year jest wymagany' ];
+    }
+
+    $car_year  = intval( sanitize_text_field( $body['car_year'] ) );
+    $has_brand = ! empty( $body['car_brand'] );
+    $has_model = ! empty( $body['car_model'] );
+    $car_brand = false;
+    $car_model = false;
+
+    if ( ! $has_brand && ! $has_model ) {
+        return [ 'error' => 'car_brand lub car_model jest wymagany' ];
+    }
+
+    // ── Tylko brand, bez modelu ──────────────────────────────────────
+    if ( $has_brand && ! $has_model ) {
+        $car_brand = eksrelay_replace_ascii( trim( sanitize_text_field( $body['car_brand'] ) ) );
+        $termIds   = get_terms( [ 'name__like' => $car_brand, 'parent' => 0, 'fields' => 'ids' ] );
+
+        if ( count( $termIds ) === 1 ) {
+            $car_models = eksrelay_models_for_brand_year( $termIds, $car_year );
+            return [ 'error' => 'options', 'field' => 'car_model', 'options' => $car_models, 'filtered' => false ];
+        }
+
+        return [ 'error' => 'options', 'field' => 'car_brand', 'options' => eksrelay_all_brands(), 'filtered' => false ];
+    }
+
+    // ── Tylko model, bez brandu ─────────────────────────────────────
+    if ( $has_model && ! $has_brand ) {
+        $car_model = eksrelay_replace_ascii( trim( sanitize_text_field( $body['car_model'] ) ) );
+        $termIds   = get_terms( [ 'name__like' => $car_model, 'fields' => 'ids' ] );
+
+        // exact match → auto-wybierz
+        foreach ( $termIds as $tid ) {
+            $term = get_term_by( 'id', $tid, 'car_model' );
+            if ( $term && eksrelay_replace_ascii( $term->name ) === $car_model ) {
+                $termIds = [ $term->term_id ];
+                break;
+            }
+        }
+
+        if ( count( $termIds ) === 1 ) {
+            $term = get_term( $termIds[0] );
+            if ( ! is_wp_error( $term ) && $term->parent !== 0 ) {
+                $parent = get_term( $term->parent );
+                if ( ! is_wp_error( $parent ) ) {
+                    $car_brand = $parent->name;
+                }
+            } else {
+                return [ 'error' => 'nie znaleziono takiego modelu', 'car_model' => $car_model ];
+            }
+        } else {
+            $models = [];
+            foreach ( $termIds as $tid ) {
+                $t = get_term_by( 'id', $tid, 'car_model' );
+                if ( $t ) {
+                    $models[] = $t->name;
+                }
+            }
+            if ( $models ) {
+                return [ 'error' => 'options', 'field' => 'car_model', 'options' => $models, 'filtered' => true ];
+            }
+            return [ 'error' => 'options', 'field' => 'car_brand', 'options' => eksrelay_all_brands(), 'filtered' => false ];
+        }
+    }
+
+    // ── Mamy oba ────────────────────────────────────────────────────
+    if ( ! $car_brand ) {
+        $car_brand = eksrelay_replace_ascii( trim( sanitize_text_field( $body['car_brand'] ) ) );
+    }
+    if ( ! $car_model ) {
+        $car_model = eksrelay_replace_ascii( trim( sanitize_text_field( $body['car_model'] ) ) );
+    }
+    $car_engine = eksrelay_replace_ascii( trim( sanitize_text_field( $body['car_engine'] ?? '' ) ) );
+
+    $termIds  = get_terms( [ 'name__like' => $car_brand, 'fields' => 'ids' ] );
+    $termIds2 = get_terms( [ 'name__like' => $car_model, 'fields' => 'ids' ] );
+
+    if ( ! count( $termIds ) ) {
+        return [ 'error' => 'options', 'field' => 'car_brand', 'options' => eksrelay_all_brands(), 'filtered' => false ];
+    }
+
+    if ( ! count( $termIds2 ) ) {
+        if ( count( $termIds ) === 1 ) {
+            $car_models = eksrelay_models_for_brand_year( $termIds, $car_year );
+            return [ 'error' => 'options', 'field' => 'car_model', 'options' => $car_models, 'filtered' => false ];
+        }
+        return [ 'error' => 'options', 'field' => 'car_brand', 'options' => eksrelay_all_brands(), 'filtered' => false ];
+    }
+
+    $query = new WP_Query( [
+        'post_type'      => 'car',
+        'posts_per_page' => -1,
+        'tax_query'      => [
+            'relation' => 'AND',
+            [ 'taxonomy' => 'car_model',           'field' => 'id',   'terms' => $termIds ],
+            [ 'taxonomy' => 'car_model',           'field' => 'id',   'terms' => $termIds2 ],
+            [ 'taxonomy' => 'car_production_year', 'field' => 'slug', 'terms' => (string) $car_year ],
+        ],
+    ] );
+
+    $cars        = $query->get_posts();
+    $engine_info = eksrelay_get_engines_info( $cars, true, $car_year );
+
+    if ( ! $cars ) {
+        return [
+            'error'     => 'nie znaleziono auta, ' . get_option(
+                'aiac_tool_get_car_data_no_car',
+                'Orientacyjnie do 1994 roku włącznie powinien pasować gaz R12, dla aut 1995-2016 gaz R134A a dla aut od 2016 roku gaz R1234YF'
+            ),
+            'car_brand' => $car_brand,
+            'car_model' => $car_model,
+        ];
+    }
+
+    $engine_names = array_column( $engine_info, 'name' );
+
+    if ( count( $engine_info ) === 1 ) {
+        $car_engine = $engine_info[0]['name'];
+    }
+
+    if ( count( $engine_info ) > 1 && ! $car_engine ) {
+        return [ 'error' => 'options', 'field' => 'car_engine', 'options' => $engine_names, 'filtered' => false ];
+    }
+
+    foreach ( $engine_info as $eng ) {
+        if ( strtolower( trim( $eng['name'] ) ) !== strtolower( trim( $car_engine ) ) ) {
+            continue;
+        }
+
+        $car_id     = intval( $eng['id'] );
+        $maybe_diff = get_field( 'custom_ac', $car_id );
+
+        $gas_types = array_unique( array_filter( [
+            get_field( 'ac_gas_type', $car_id ),
+            $maybe_diff ? get_field( 'ac_gas_type_2', $car_id ) : '',
+        ] ) );
+
+        $year_term  = get_terms( [ 'taxonomy' => 'car_production_year', 'name__like' => $car_year ] );
+        $brand_term = $termIds  ? get_term( $termIds[0] )  : false;
+        $model_term = $termIds2 ? get_term( $termIds2[0] ) : false;
+
+        $data = [
+            'success'      => true,
+            'selected_car' => [
+                'ek_ac_gas_type' => implode( ',', $gas_types ),
+                'ek_brand_id'    => $termIds[0]  ?? '',
+                'ek_brand'       => ( $brand_term && ! is_wp_error( $brand_term ) ) ? $brand_term->name : $car_brand,
+                'ek_car_id'      => $car_id,
+                'ek_engine_type' => $eng['name'],
+                'ek_info'        => '',
+                'ek_model_id'    => $termIds2[0] ?? '',
+                'ek_model'       => ( $model_term && ! is_wp_error( $model_term ) ) ? $model_term->name : $car_model,
+                'ek_year_id'     => ( ! is_wp_error( $year_term ) && $year_term ) ? $year_term[0]->term_id : '',
+                'ek_year'        => $car_year,
+            ],
+            'shop_url'               => $eng['url'],
+            'adapters'               => get_field( 'adapters', $car_id ) ?: false,
+            'ac_gas_type'            => get_field( 'ac_gas_type', $car_id ),
+            'ac_gas_amount'          => ( get_field( 'ac_gas_amount', $car_id ) ?: '-' ) . ' g',
+            'ac_ports_count'         => intval( get_field( 'ac_ports_count', $car_id ) ),
+            'ac_oil'                 => get_field( 'ac_oil', $car_id ),
+            'ac_oil_amount'          => ( get_field( 'ac_oil_amount', $car_id ) ?: '-' ) . ' cm3',
+            'ac_clutch'              => get_field( 'ac_clutch', $car_id ),
+            'maybe_different_layout' => $maybe_diff,
+        ];
+
+        if ( $maybe_diff ) {
+            $data['different_layout_info']            = get_field( 'custom_ac_info', $car_id ) ?: '';
+            $data['different_layout_adapters']        = get_field( 'adapters_2', $car_id ) ?: false;
+            $data['different_layout_ac_gas_type']     = get_field( 'ac_gas_type_2', $car_id );
+            $data['different_layout_ac_gas_amount']   = ( get_field( 'ac_gas_amount_2', $car_id ) ?: '-' ) . ' g';
+            $data['different_layout_ac_ports_count']  = get_field( 'ac_ports_count_2', $car_id );
+            $data['different_layout_ac_oil']          = get_field( 'ac_oil_2', $car_id );
+            $data['different_layout_ac_oil_amount']   = ( get_field( 'ac_oil_amount_2', $car_id ) ?: '-' ) . ' cm3';
+            $data['different_layout_ac_clutch']       = get_field( 'ac_clutch_2', $car_id );
+        }
+
+        return $data;
+    }
+
+    // podany silnik nie pasuje do żadnego na liście → pokaż opcje
+    return [ 'error' => 'options', 'field' => 'car_engine', 'options' => $engine_names, 'filtered' => false ];
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// Handler: POST /wp-json/eksrelay/v1/product-compatibility
+//
+// Body JSON: { car_year, car_brand?, car_model?, car_engine?, product_name? }
+// ═══════════════════════════════════════════════════════════════════
+
+function eksrelay_product_compatibility( WP_REST_Request $request ) {
+    $body = $request->get_json_params() ?: [];
+
+    if ( empty( $body['car_year'] ) ) {
+        return [ 'error' => 'car_year jest wymagany' ];
+    }
+
+    $lang      = sanitize_text_field( $body['lang'] ?? 'pl' );
+    $car_year  = intval( sanitize_text_field( $body['car_year'] ) );
+    $has_brand = ! empty( $body['car_brand'] );
+    $has_model = ! empty( $body['car_model'] );
+    $car_brand = false;
+    $car_model = false;
+
+    // ── Tylko brand ─────────────────────────────────────────────────
+    if ( $has_brand && ! $has_model ) {
+        $car_brand = eksrelay_replace_ascii( trim( sanitize_text_field( $body['car_brand'] ) ) );
+        $termIds   = get_terms( [ 'name__like' => $car_brand, 'parent' => 0, 'fields' => 'ids' ] );
+
+        if ( count( $termIds ) === 1 ) {
+            $car_models = eksrelay_models_for_brand_year( $termIds, $car_year );
+            return [ 'error' => 'options', 'field' => 'car_model', 'options' => $car_models, 'filtered' => false ];
+        }
+
+        return [ 'error' => 'options', 'field' => 'car_brand', 'options' => eksrelay_all_brands(), 'filtered' => false ];
+    }
+
+    // ── Tylko model ─────────────────────────────────────────────────
+    if ( $has_model && ! $has_brand ) {
+        $car_model = eksrelay_replace_ascii( trim( sanitize_text_field( $body['car_model'] ) ) );
+        $termIds   = get_terms( [ 'name__like' => $car_model, 'fields' => 'ids' ] );
+
+        if ( count( $termIds ) === 1 ) {
+            $term = get_term( $termIds[0] );
+            if ( ! is_wp_error( $term ) && $term->parent !== 0 ) {
+                $parent = get_term( $term->parent );
+                if ( ! is_wp_error( $parent ) ) {
+                    $car_brand = $parent->name;
+                }
+            } else {
+                return [ 'error' => 'nie znaleziono takiego modelu', 'car_model' => $car_model ];
+            }
+        } else {
+            $models = [];
+            foreach ( $termIds as $tid ) {
+                $t = get_term_by( 'id', $tid, 'car_model' );
+                if ( $t ) {
+                    $models[] = $t->name;
+                }
+            }
+            if ( $models ) {
+                return [ 'error' => 'options', 'field' => 'car_model', 'options' => $models, 'filtered' => true ];
+            }
+            return [ 'error' => 'options', 'field' => 'car_brand', 'options' => eksrelay_all_brands(), 'filtered' => false ];
+        }
+    }
+
+    if ( ! $has_brand && ! $has_model ) {
+        return [ 'error' => 'car_brand lub car_model jest wymagany' ];
+    }
+
+    if ( ! $car_brand ) {
+        $car_brand = eksrelay_replace_ascii( trim( sanitize_text_field( $body['car_brand'] ) ) );
+    }
+    if ( ! $car_model ) {
+        $car_model = eksrelay_replace_ascii( trim( sanitize_text_field( $body['car_model'] ) ) );
+    }
+
+    $car_engine   = eksrelay_replace_ascii( trim( sanitize_text_field( $body['car_engine']   ?? '' ) ) );
+    $product_name = eksrelay_replace_ascii( trim( sanitize_text_field( $body['product_name'] ?? '' ) ) );
+
+    // ── Znajdź produkt ──────────────────────────────────────────────
+    $product = false;
+
+    if ( $product_name ) {
+        $products = eksrelay_search_products_by_name( $product_name );
+
+        if ( count( $products ) > 1 ) {
+            // exact match → auto-wybierz
+            foreach ( $products as $p ) {
+                if ( eksrelay_replace_ascii( strtolower( trim( $p->get_title() ) ) ) === strtolower( $product_name ) ) {
+                    $product = $p;
+                    break;
+                }
+            }
+            if ( ! $product ) {
+                return [
+                    'error'    => 'options',
+                    'field'    => 'product_name',
+                    'options'  => array_map( fn( $p ) => $p->get_title(), $products ),
+                    'filtered' => true,
+                ];
+            }
+        } elseif ( count( $products ) === 1 ) {
+            $product = $products[0];
+        }
+    }
+
+    // ── Znajdź auto ─────────────────────────────────────────────────
+    $termIds  = get_terms( [ 'name__like' => $car_brand, 'fields' => 'ids' ] );
+    $termIds2 = get_terms( [ 'name__like' => $car_model, 'fields' => 'ids' ] );
+
+    // Uwaga: celowo bez filtra roku – engine_info filtruje rok wewnętrznie
+    $query = new WP_Query( [
+        'post_type'      => 'car',
+        'posts_per_page' => -1,
+        'tax_query'      => [
+            'relation' => 'AND',
+            [ 'taxonomy' => 'car_model', 'field' => 'id', 'terms' => $termIds ],
+            [ 'taxonomy' => 'car_model', 'field' => 'id', 'terms' => $termIds2 ],
+        ],
+    ] );
+
+    $cars        = $query->get_posts();
+    $engine_info = eksrelay_get_engines_info( $cars, false, $car_year );
+
+    if ( ! $cars ) {
+        return [ 'error' => 'nie znaleziono takiego samochodu', 'car_brand' => $car_brand, 'car_model' => $car_model ];
+    }
+
+    $engine_names = array_column( $engine_info, 'name' );
+
+    if ( count( $engine_info ) === 1 ) {
+        $car_engine = $engine_info[0]['name'];
+    }
+
+    if ( count( $engine_info ) > 1 && ! $car_engine ) {
+        return [ 'error' => 'options', 'field' => 'car_engine', 'options' => $engine_names, 'filtered' => false ];
+    }
+
+    foreach ( $engine_info as $eng ) {
+        if ( strtolower( trim( $eng['name'] ) ) !== strtolower( trim( $car_engine ) ) ) {
+            continue;
+        }
+
+        $engine_gas      = array_values( array_filter( [ $eng['ac_gas_type'] ?? '', $eng['ac_gas_type_2'] ?? '' ] ) );
+        $engine_adapters = array_values( array_filter( [ $eng['adapters'] ?? '', $eng['adapters_2'] ?? '' ] ) );
+        if ( ! $engine_gas )      $engine_gas      = [ 'no-gas' ];
+        if ( ! $engine_adapters ) $engine_adapters = [ 'no' ];
+
+        if ( $product ) {
+            // ── Sprawdzenie kompatybilności konkretnego produktu ─────────
+            $attributes = eksrelay_product_attributes( $product );
+            foreach ( $attributes as $k => $arr ) {
+                $attributes[ $k ] = array_map( fn( $slug ) => str_replace( "-{$lang}", '', $slug ), $arr );
+            }
+
+            if ( ! isset( $attributes['pa_rg'] ) || empty( $attributes['pa_rg'] ) ) $attributes['pa_rg'] = [ 'no-gas' ];
+            if ( ! isset( $attributes['pa_ra'] ) || empty( $attributes['pa_ra'] ) ) $attributes['pa_ra'] = [ 'no' ];
+
+            $gas_fit     = count( array_intersect( $engine_gas,      $attributes['pa_rg'] ) ) > 0;
+            $adapter_fit = count( array_intersect( $engine_adapters, $attributes['pa_ra'] ) ) > 0;
+
+            return [
+                'success'          => true,
+                'fit'              => $gas_fit && $adapter_fit,
+                'gas_fit'          => $gas_fit,
+                'adapter_fit'      => $adapter_fit,
+                'selected_engine'  => $eng,
+                'selected_product' => [
+                    'id'         => $product->get_id(),
+                    'title'      => $product->get_title(),
+                    'type'       => $product->get_type(),
+                    'attributes' => $attributes,
+                ],
+            ];
+        }
+
+        // ── Brak konkretnego produktu – lista wszystkich kompatybilnych ─
+        $all_products = wc_get_products( [
+            'status'     => 'publish',
+            'visibility' => 'visible',
+            'limit'      => -1,
+            'orderby'    => 'name',
+        ] );
+
+        $compatible = [];
+        foreach ( $all_products as $p ) {
+            $attrs = eksrelay_product_attributes( $p );
+            foreach ( $attrs as $k => $arr ) {
+                $attrs[ $k ] = array_map( fn( $slug ) => str_replace( "-{$lang}", '', $slug ), $arr );
+            }
+            $p_gas      = ( isset( $attrs['pa_rg'] ) && ! empty( $attrs['pa_rg'] ) ) ? $attrs['pa_rg'] : [ 'no-gas' ];
+            $p_adapters = ( isset( $attrs['pa_ra'] ) && ! empty( $attrs['pa_ra'] ) ) ? $attrs['pa_ra'] : [ 'no' ];
+
+            if (
+                count( array_intersect( $engine_gas,      $p_gas      ) ) > 0 &&
+                count( array_intersect( $engine_adapters, $p_adapters ) ) > 0
+            ) {
+                $compatible[] = [
+                    'id'        => $p->get_id(),
+                    'title'     => $p->get_title(),
+                    'price'     => $p->get_price(),
+                    'permalink' => get_permalink( $p->get_id() ),
+                ];
+            }
+        }
+
+        return [
+            'success'             => true,
+            'compatible_products' => $compatible,
+            'selected_engine'     => $eng,
+        ];
+    }
+
+    return [ 'error' => 'options', 'field' => 'car_engine', 'options' => $engine_names, 'filtered' => false ];
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// Pomocnicze funkcje wewnętrzne
+// ═══════════════════════════════════════════════════════════════════
+
+/**
+ * Zwraca listę modeli dla danej marki (termIds) i roku produkcji.
+ */
+function eksrelay_models_for_brand_year( array $termIds, int $car_year ): array {
+    $query = new WP_Query( [
+        'post_type'      => 'car',
+        'posts_per_page' => -1,
+        'tax_query'      => [
+            'relation' => 'AND',
+            [ 'taxonomy' => 'car_model',           'field' => 'id',   'terms' => $termIds ],
+            [ 'taxonomy' => 'car_production_year', 'field' => 'slug', 'terms' => [ (string) $car_year ] ],
+        ],
+    ] );
+
+    $car_models = [];
+    foreach ( $query->get_posts() as $car ) {
+        foreach ( wp_get_post_terms( $car->ID, 'car_model' ) as $term ) {
+            if ( ! in_array( $term->name, $car_models, true ) && $term->parent !== 0 ) {
+                $car_models[] = $term->name;
+            }
+        }
+    }
+
+    return $car_models;
+}
+
+/**
+ * Zwraca listę wszystkich marek aut (terminy car_model z parent=0).
+ */
+function eksrelay_all_brands(): array {
+    return (array) get_terms( [
+        'taxonomy'   => 'car_model',
+        'parent'     => 0,
+        'fields'     => 'names',
+        'hide_empty' => true,
+    ] );
+}
+
+/**
+ * Zwraca info o silnikach dla podanych postów 'car'.
+ *
+ * Parametr $prefiltered:
+ *   true  – WP_Query już przefiltrował po roku przez taxonomy (get_car_data)
+ *   false – rok musi być sprawdzony wewnętrznie przez pola ACF year_from / year_to (get_product_compatibility)
+ *
+ * Nazwa silnika jest budowana z pól ACF: engine_type, engine_size (ccm), engine_power_kw, engine_power_km.
+ * Format: "B 1.8 - 141 kW / 192 KM"
+ *
+ * Zakres roku pochodzi z pól ACF year_from / year_to (przechowywane jako term_id taksonomii car_production_year).
+ *
+ * Dodatkowe warianty silnika mogą być przechowywane w ACF repeaterze 'engines' na poście car.
+ */
+function eksrelay_get_engines_info( array $cars, bool $prefiltered, int $car_year ): array {
+    $engines = [];
+    $seen    = [];
+
+    foreach ( $cars as $car ) {
+        $car_id = $car->ID;
+
+        // ── Filtrowanie zakresu roku przez pola ACF year_from / year_to ─
+        if ( $car_year ) {
+            $year_from_id = get_field( 'year_from', $car_id );
+            $year_to_id   = get_field( 'year_to',   $car_id );
+
+            $year_from = 0;
+            $year_to   = 9999;
+
+            if ( $year_from_id ) {
+                $t = get_term( (int) $year_from_id, 'car_production_year' );
+                if ( $t && ! is_wp_error( $t ) ) {
+                    $year_from = (int) $t->slug;
+                }
+            }
+            if ( $year_to_id ) {
+                $t = get_term( (int) $year_to_id, 'car_production_year' );
+                if ( $t && ! is_wp_error( $t ) ) {
+                    $year_to = (int) $t->slug;
+                }
+            }
+
+            if ( $car_year < $year_from || $car_year > $year_to ) {
+                continue;
+            }
+        }
+
+        // ── Budowanie nazwy silnika z pól ACF ────────────────────────────
+        $engine_type    = (string) ( get_field( 'engine_type',     $car_id ) ?: '' );
+        $engine_size_cc = (float)  ( get_field( 'engine_size',     $car_id ) ?: 0 );
+        $power_kw       = (string) ( get_field( 'engine_power_kw', $car_id ) ?: '' );
+        $power_km       = (string) ( get_field( 'engine_power_km', $car_id ) ?: '' );
+        $engine_size    = number_format( $engine_size_cc / 1000, 1 );
+        $name           = "{$engine_type} {$engine_size} - {$power_kw} kW / {$power_km} KM";
+
+        // ── Typ gazu i adaptery ──────────────────────────────────────────
+        $ac_gas_type   = (string) ( get_field( 'ac_gas_type',   $car_id ) ?: '' );
+        $ac_gas_type_2 = (string) ( get_field( 'ac_gas_type_2', $car_id ) ?: '' );
+        $adapters_raw  = get_field( 'adapters',   $car_id );
+        $adapters_raw2 = get_field( 'adapters_2', $car_id );
+        $adapters      = is_array( $adapters_raw  ) ? ( implode( ',', $adapters_raw  ) ?: 'no' ) : (string) ( $adapters_raw  ?: 'no' );
+        $adapters_2    = is_array( $adapters_raw2 ) ? implode( ',', $adapters_raw2 ) : (string) ( $adapters_raw2 ?: '' );
+
+        // ── Korekty gazu na podstawie roku (sanity check) ────────────────
+        if ( $car_year ) {
+            if ( $car_year <= 1994 && $ac_gas_type === '' ) {
+                $ac_gas_type = 'r12';
+            } elseif ( $car_year >= 2017 && $ac_gas_type === '' ) {
+                $ac_gas_type = 'r1234yf';
+            } elseif ( $car_year >= 1995 && $car_year <= 2016 && $ac_gas_type === '' ) {
+                $ac_gas_type = 'r134a';
+            }
+        }
+
+        // ── Główny silnik ────────────────────────────────────────────────
+        $key = mb_strtolower( $name );
+        if ( ! isset( $seen[ $key ] ) ) {
+            $seen[ $key ] = true;
+            $engines[] = [
+                'name'          => $name,
+                'id'            => $car_id,
+                'url'           => get_permalink( $car_id ),
+                'ac_gas_type'   => $ac_gas_type,
+                'ac_gas_type_2' => $ac_gas_type_2,
+                'adapters'      => $adapters,
+                'adapters_2'    => $adapters_2,
+            ];
+        }
+
+        // ── ACF repeater 'engines' – dodatkowe warianty silnika ──────────
+        $extra_engines = get_field( 'engines', $car_id );
+        if ( is_array( $extra_engines ) ) {
+            foreach ( $extra_engines as $extra ) {
+                $e_type    = (string) ( $extra['engine_type']     ?? '' );
+                $e_size_cc = (float)  ( $extra['engine_size']     ?? 0 );
+                $e_kw      = (string) ( $extra['engine_power_kw'] ?? '' );
+                $e_km      = (string) ( $extra['engine_power_km'] ?? '' );
+                $e_size    = number_format( $e_size_cc / 1000, 1 );
+                $e_name    = "{$e_type} {$e_size} - {$e_kw} kW / {$e_km} KM";
+
+                $e_gas     = (string) ( $extra['ac_gas_type']   ?? $ac_gas_type );
+                $e_gas_2   = (string) ( $extra['ac_gas_type_2'] ?? $ac_gas_type_2 );
+                $e_adp_r   = $extra['adapters']   ?? null;
+                $e_adp_r2  = $extra['adapters_2'] ?? null;
+                $e_adp     = is_array( $e_adp_r  ) ? implode( ',', $e_adp_r  ) : (string) ( $e_adp_r  ?: $adapters );
+                $e_adp_2   = is_array( $e_adp_r2 ) ? implode( ',', $e_adp_r2 ) : (string) ( $e_adp_r2 ?: $adapters_2 );
+
+                $e_key = mb_strtolower( $e_name );
+                if ( ! isset( $seen[ $e_key ] ) ) {
+                    $seen[ $e_key ] = true;
+                    $engines[] = [
+                        'name'          => $e_name,
+                        'id'            => $car_id,
+                        'url'           => get_permalink( $car_id ),
+                        'ac_gas_type'   => $e_gas,
+                        'ac_gas_type_2' => $e_gas_2,
+                        'adapters'      => $e_adp,
+                        'adapters_2'    => $e_adp_2,
+                    ];
+                }
+            }
+        }
+    }
+
+    return $engines;
+}
+
+/**
+ * Zwraca atrybuty produktu WC jako [ slug_atrybutu => [ slug_wartości, ... ] ].
+ * Używane do sprawdzania pa_rg (typ gazu) i pa_ra (adapter).
+ *
+ * Implementacja wzorowana na aiac_get_product_attributes_as_str_arr() z aiac_chat_api.php:
+ * – dla wariantów (variation): [ $attr_val ] (pojedyncza wartość ze zmiennej produktowej)
+ * – dla pozostałych typów: $attr_obj->get_slugs()
+ */
+function eksrelay_product_attributes( WC_Product $product ): array {
+    $result     = [];
+    $attributes = $product->get_attributes();
+
+    if ( $product->is_type( 'variation' ) ) {
+        foreach ( $attributes as $attr_key => $attr_val ) {
+            $result[ $attr_key ] = [ $attr_val ];
+        }
+    } else {
+        foreach ( $attributes as $attr_key => $attr_obj ) {
+            $result[ $attr_key ] = $attr_obj->get_slugs();
+        }
+    }
+
+    return $result;
+}
+
+/**
+ * Wyszukuje produkty WooCommerce po fragmencie nazwy (fulltext search).
+ */
+function eksrelay_search_products_by_name( string $product_name ): array {
+    add_filter( 'woocommerce_product_data_store_cpt_get_products_query', 'eksrelay_wc_query_like', 10, 2 );
+
+    $products = wc_get_products( [
+        'status' => 'publish',
+        'limit'  => -1,
+        'like'   => $product_name,
+    ] );
+
+    remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', 'eksrelay_wc_query_like', 10 );
+
+    return $products;
+}
+
+function eksrelay_wc_query_like( array $query, array $query_vars ): array {
+    if ( ! empty( $query_vars['like'] ) ) {
+        $query['s'] = esc_attr( $query_vars['like'] );
+    }
+    return $query;
+}
+
+/**
+ * Usuwa znaki diakrytyczne z ciągu znaków.
+ * Mapowanie identyczne z replace_ascii() w aiac_chat_api.php.
+ */
+function eksrelay_replace_ascii( string $string ): string {
+    $map = [
+        'Š' => 'S', 'š' => 's', 'ë' => 'e', 'Ë' => 'E',
+        'ä' => 'a', 'Ä' => 'A', 'ö' => 'o', 'Ö' => 'O',
+        'ü' => 'u', 'Ü' => 'U', 'ß' => 'ss',
+        'ó' => 'o', 'Ó' => 'O', 'ł' => 'l', 'Ł' => 'L',
+        'ń' => 'n', 'Ń' => 'N', 'ć' => 'c', 'Ć' => 'C',
+        'ę' => 'e', 'Ę' => 'E', 'ź' => 'z', 'Ź' => 'Z',
+        'ż' => 'z', 'Ż' => 'Z', 'á' => 'a', 'Á' => 'A',
+        'č' => 'c', 'Č' => 'C', 'ď' => 'd', 'Ď' => 'D',
+        'é' => 'e', 'É' => 'E', 'ě' => 'e', 'Ě' => 'E',
+        'í' => 'i', 'Í' => 'I', 'ň' => 'n', 'Ň' => 'N',
+        'ř' => 'r', 'Ř' => 'R', 'ś' => 's', 'Ś' => 'S',
+        'ť' => 't', 'Ť' => 'T', 'ů' => 'u', 'Ů' => 'U',
+        'ý' => 'y', 'Ý' => 'Y', 'ą' => 'a', 'Ą' => 'A',
+        'ș' => 's', 'Ș' => 'S', 'î' => 'i', 'Î' => 'I',
+        'â' => 'a', 'Â' => 'A', 'ț' => 't', 'Ț' => 'T',
+        'ğ' => 'g', 'Ğ' => 'G', 'İ' => 'I', 'ı' => 'i',
+        'ç' => 'c', 'Ç' => 'C',
+    ];
+
+    return str_replace( array_keys( $map ), array_values( $map ), $string );
+}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů