Bladeren bron

Initial commit

Miłosz Semenov 4 weken geleden
commit
f76eb2b75d

+ 25 - 0
.claude/settings.local.json

@@ -0,0 +1,25 @@
+{
+  "permissions": {
+    "allow": [
+      "Bash(composer dump-autoload:*)",
+      "Bash(test:*)",
+      "Bash(curl:*)",
+      "Bash(php -r \"require ''vendor/autoload.php''; EKSRelay\\\\Core\\\\Env::load\\(''.env''\\); echo EKSRelay\\\\Core\\\\Env::get\\(''RELAY_SHARED_SECRET''\\);\")",
+      "Bash(php -r:*)",
+      "Bash(kill:*)",
+      "Bash(SECRET=\"7d0f3ac177584451ac9467596a2db73b35433eb155fdd74f90b7e0d0f90e4e94\")",
+      "Bash(__NEW_LINE_d4b6491cbcb0b248__ echo \"=== get_shipping_data ===\")",
+      "Bash(__NEW_LINE_ec49fa22f9a61747__ echo \"=== get_payment_methods ===\")",
+      "Bash(__NEW_LINE_bb631b89dd2ca4ea__ echo \"=== get_product_data \\(search\\) ===\")",
+      "Bash(__NEW_LINE_46505577f9a1dd5f__ echo \"=== get_car_data ===\")",
+      "Bash(__NEW_LINE_11d09eeb9e0d5e90__ echo \"=== get_product_data \\(search ''czynnik''\\) ===\")",
+      "Bash(__NEW_LINE_623cb699fbbfb242__ echo \"=== get_product_compatibility ===\")",
+      "Bash(__NEW_LINE_5f66c8cea0e53922__ echo \"=== get_order_data \\(orderNumber\\) ===\")",
+      "Bash(__NEW_LINE_fb1e6f6680550bf4__ echo \"=== get_order_data \\(orderNumber 1000\\) ===\")",
+      "Bash(__NEW_LINE_ffccb5a4b679154c__ echo \"=== get_product_compatibility \\(real product ID\\) ===\")",
+      "Bash(__NEW_LINE_26d6e1db87405b7e__ echo \"=== get_shipping_data \\(z zoneId=1 Polska\\) ===\")",
+      "Bash(__NEW_LINE_253b002283f4af47__ echo \"=== get_car_data \\(Toyota Corolla 2015\\) ===\")",
+      "Bash(__NEW_LINE_c2940dd3ea62fc3d__ echo \"=== get_product_compatibility ===\")"
+    ]
+  }
+}

+ 21 - 0
.env.example

@@ -0,0 +1,21 @@
+# === Chatwoot ===
+CHATWOOT_BASE_URL=https://app.chatwoot.com
+CHATWOOT_API_TOKEN=your_chatwoot_api_token
+CHATWOOT_ACCOUNT_ID=1
+CHATWOOT_BOT_AGENT_ID=
+CHATWOOT_TICKET_LABEL=ticket
+
+# === Flowise ===
+FLOWISE_PREDICT_URL=http://localhost:3000/api/v1/prediction/your-chatflow-id
+FLOWISE_API_KEY=
+
+# === WordPress AJAX (mu-plugin endpoints) ===
+WP_AJAX_URL=https://your-shop.com/wp-admin/admin-ajax.php
+
+# === WooCommerce REST API ===
+WOOCOMMERCE_BASE_URL=https://your-shop.com
+WOOCOMMERCE_CONSUMER_KEY=ck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+WOOCOMMERCE_CONSUMER_SECRET=cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+
+# === Relay Auth ===
+RELAY_SHARED_SECRET=change-me-to-a-random-string

+ 25 - 0
.gitignore

@@ -0,0 +1,25 @@
+# Dependencies
+/vendor/
+
+# Environment (zawiera sekrety — NIE commitować)
+.env
+
+# Lokalny konfig AI asystenta
+.claude/
+
+# Pliki testowe
+test5a.json
+
+# Logi
+*.log
+/logs/
+
+# Edytory
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# System
+.DS_Store
+Thumbs.db

+ 121 - 0
README.md

@@ -0,0 +1,121 @@
+# EKSRelay
+
+PHP relay pomiędzy Chatwoot, Flowise i WooCommerce. Odbiera webhooki z Chatwoot, przekazuje wiadomości do Flowise (LLM agent) i wystawia narzędzia (`/tools/*`) wywoływane przez agenta.
+
+## Architektura
+
+```
+Klient → CHATWOOT → EKSRELAY /webhooks/chatwoot → FLOWISE (Tool Agent)
+                                                        ↓ narzędzia
+                                              EKSRELAY /tools/*
+                                                        ↓
+                                                  WOOCOMMERCE REST API
+                                                  WP AJAX (mu-plugin)
+                    ← odpowiedź ← EKSRELAY ← FLOWISE
+```
+
+## Uruchomienie lokalne
+
+```bash
+composer install
+cp .env.example .env
+# uzupełnij .env
+php -S 0.0.0.0:8080 -t public
+```
+
+## Zmienne środowiskowe (`.env`)
+
+| Zmienna | Opis |
+|---|---|
+| `CHATWOOT_BASE_URL` | URL instancji Chatwoot (np. `https://eksupport.easyklima.com`) |
+| `CHATWOOT_API_TOKEN` | Token API Chatwoot (Settings → Account → API) |
+| `CHATWOOT_ACCOUNT_ID` | ID konta Chatwoot (z URL `/accounts/X/`) |
+| `CHATWOOT_BOT_AGENT_ID` | ID agenta-bota (opcjonalne) |
+| `CHATWOOT_TICKET_LABEL` | Label oznaczający ręczną obsługę (domyślnie: `ticket`) |
+| `FLOWISE_PREDICT_URL` | URL endpointu `/api/v1/prediction/<chatflow-id>` |
+| `FLOWISE_API_KEY` | Klucz API Flowise (jeśli włączone) |
+| `WP_AJAX_URL` | URL WordPress AJAX: `https://sklep.pl/wp-admin/admin-ajax.php` |
+| `WOOCOMMERCE_BASE_URL` | URL sklepu WooCommerce |
+| `WOOCOMMERCE_CONSUMER_KEY` | Klucz WooCommerce REST API (`ck_...`) |
+| `WOOCOMMERCE_CONSUMER_SECRET` | Secret WooCommerce REST API (`cs_...`) |
+| `RELAY_SHARED_SECRET` | Losowy token chroniący endpointy `/tools/*` |
+
+## Endpointy
+
+### Webhook
+| Endpoint | Opis |
+|---|---|
+| `POST /webhooks/chatwoot` | Odbiera eventy z Chatwoot. Przetwarza tylko `message_created` + `incoming`. |
+
+### Tools (wywoływane przez Flowise)
+Wszystkie wymagają nagłówka `Authorization: Bearer <RELAY_SHARED_SECRET>`.
+
+| Endpoint | Źródło danych | Opis |
+|---|---|---|
+| `POST /tools/get_order_data` | WooCommerce REST API | Zamówienie po numerze lub emailu |
+| `POST /tools/get_product_data` | WooCommerce REST API | Produkt po ID, SKU lub frazie |
+| `POST /tools/get_shipping_data` | WooCommerce REST API | Strefy i metody wysyłki |
+| `POST /tools/get_payment_methods` | WooCommerce REST API | Dostępne metody płatności |
+| `POST /tools/get_car_data` | **WP AJAX (mu-plugin)** | Dane auta z bazy pojazdów WP |
+| `POST /tools/get_product_compatibility` | **WP AJAX (mu-plugin)** | Kompatybilność produktu z autem |
+| `POST /tools/new_ticket` | Chatwoot API | Tworzy ticket, dodaje label `ticket` |
+
+## Zależność: mu-plugin WordPress (`aiac_chat_api.php`)
+
+Dwa endpointy — `get_car_data` i `get_product_compatibility` — **nie korzystają z WooCommerce REST API**, lecz bezpośrednio z WordPress AJAX udostępnianego przez mu-plugin `aiac_chat_api.php`.
+
+### Dlaczego
+
+Dane o samochodach (`marka/model/rok/silnik → typ gazu, ilość, adapter`) i sprawdzanie kompatybilności produktu z autem są przechowywane w WordPressie jako:
+- custom post type: `car`
+- taxonomie: `car_model`, `car_production_year`
+- pola ACF: `ac_gas_type`, `ac_gas_amount`, `adapters`, itp.
+
+Standardowe WooCommerce REST API nie wystawia tych danych — stąd konieczność korzystania z pluginu.
+
+### Aktualny stan (tymczasowy)
+
+Plugin `aiac_chat_api.php` jest **starym rozwiązaniem** z poprzedniego chatbota. EKSRelay wywołuje jego publiczne AJAX endpointy:
+
+```
+GET https://easyklima.pl/wp-admin/admin-ajax.php?action=chat_get_car_data&car_brand=Toyota&...
+GET https://easyklima.pl/wp-admin/admin-ajax.php?action=chat_get_product_compatibility&...
+```
+
+Endpointy są publiczne (`nopriv`) i nie wymagają uwierzytelnienia.
+
+### Docelowe rozwiązanie (TODO)
+
+Plugin powinien zostać **przebudowany** tak, żeby:
+1. Wystawiał dedykowane REST API zamiast AJAX (`register_rest_route` → `/wp-json/aiac/v1/car-data`)
+2. Wprowadzał uwierzytelnienie (shared secret lub wp-nonce)
+3. Był niezależny od starego kodu chatbota (usunięcie funkcji ticketów, Baselinker itp.)
+4. Ewentualnie obsługiwał też `get_product_data` z wariantami i wieloma walutami (WCML), czego obecne rozwiązanie przez WC REST API nie pokrywa w pełni
+
+Do tego czasu EKSRelay korzysta ze starych endpointów AJAX — działa, ale jest kruche (bez auth, zależne od legacy kodu).
+
+## Flowise — konfiguracja
+
+Narzędzia Flowise znajdują się w katalogu `flowise-tools/`. Każdy plik JSON to definicja do zaimportowania w Flowise UI (Tools → Add Tool → Import).
+
+W chatflow Flowise należy ustawić zmienne:
+
+| Zmienna | Wartość |
+|---|---|
+| `relay_base` | URL EKSRelay (np. `http://localhost:8080` lub produkcyjny) |
+| `relay_shared_secret` | Wartość `RELAY_SHARED_SECRET` z `.env` |
+
+## Konfiguracja Chatwoot
+
+W Chatwoot → Settings → Integrations → Webhooks → Add:
+- URL: `https://<adres-relay>/webhooks/chatwoot`
+- Zdarzenia: ✅ `message_created`
+
+## Wdrożenie
+
+1. Sklonuj repo na serwer (najlepiej ten sam co Flowise)
+2. `composer install --no-dev`
+3. Skonfiguruj `.env` z produkcyjnymi danymi
+4. Ustaw PHP-FPM + nginx lub uruchom `php -S 0.0.0.0:8080 -t public`
+5. Zaktualizuj `relay_base` w zmiennych Flowise na publiczny URL
+6. Dodaj webhook w Chatwoot

+ 13 - 0
composer.json

@@ -0,0 +1,13 @@
+{
+    "name": "aiac/eks-relay",
+    "description": "EKSRelay – stateless communication relay between Chatwoot, Flowise and WooCommerce",
+    "type": "project",
+    "require": {
+        "php": ">=8.2"
+    },
+    "autoload": {
+        "psr-4": {
+            "EKSRelay\\": "src/"
+        }
+    }
+}

+ 8 - 0
flowise-tools/get_car_data.json

@@ -0,0 +1,8 @@
+{
+  "name": "get_car_data",
+  "description": "Wyszukaj dane pojazdu lub produkty pasujące do danego pojazdu. Podaj markę, model i/lub rok.",
+  "color": "linear-gradient(rgb(150,150,150), rgb(100,100,100))",
+  "iconSrc": "",
+  "schema": "[{\"id\":0,\"property\":\"make\",\"description\":\"Marka pojazdu (np. Toyota, BMW)\",\"type\":\"string\",\"required\":false},{\"id\":1,\"property\":\"model\",\"description\":\"Model pojazdu (np. Corolla, E46)\",\"type\":\"string\",\"required\":false},{\"id\":2,\"property\":\"year\",\"description\":\"Rok produkcji pojazdu\",\"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 make = typeof $make !== 'undefined' && $make ? String($make) : ''\nconst model = typeof $model !== 'undefined' && $model ? String($model) : ''\nconst year = typeof $year !== 'undefined' && $year ? String($year) : ''\n\nif (!make && !model) return 'Błąd: podaj przynajmniej markę (make) lub model pojazdu'\nif (!secret) return 'Błąd: brak $vars.relay_shared_secret'\n\ntry {\n  const res = await fetch(`${base}/tools/get_car_data`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify({ make, model, year })\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"
+}

+ 8 - 0
flowise-tools/get_order_data.json

@@ -0,0 +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.",
+  "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"
+}

+ 8 - 0
flowise-tools/get_payment_methods.json

@@ -0,0 +1,8 @@
+{
+  "name": "get_payment_methods",
+  "description": "Pobierz dostępne metody płatności ze sklepu WooCommerce.",
+  "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"
+}

+ 8 - 0
flowise-tools/get_product_compatibility.json

@@ -0,0 +1,8 @@
+{
+  "name": "get_product_compatibility",
+  "description": "Sprawdź kompatybilność produktu z innymi produktami lub pojazdami. Wymaga ID produktu.",
+  "color": "linear-gradient(rgb(180,120,200), rgb(140,60,180))",
+  "iconSrc": "",
+  "schema": "[{\"id\":0,\"property\":\"productId\",\"description\":\"ID produktu WooCommerce do sprawdzenia kompatybilności\",\"type\":\"number\",\"required\":true}]",
+  "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' ? Number($productId) : 0\n\nif (!productId || productId <= 0) return 'Błąd: podaj productId'\nif (!secret) return 'Błąd: brak $vars.relay_shared_secret'\n\ntry {\n  const res = await fetch(`${base}/tools/get_product_compatibility`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify({ productId })\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"
+}

+ 8 - 0
flowise-tools/get_product_data.json

@@ -0,0 +1,8 @@
+{
+  "name": "get_product_data",
+  "description": "Pobierz dane produktu ze sklepu WooCommerce. Podaj ID produktu, SKU lub frazę do wyszukania.",
+  "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"
+}

+ 8 - 0
flowise-tools/get_shipping_data.json

@@ -0,0 +1,8 @@
+{
+  "name": "get_shipping_data",
+  "description": "Pobierz informacje o dostępnych metodach i strefach wysyłki ze sklepu WooCommerce.",
+  "color": "linear-gradient(rgb(120,180,120), rgb(60,140,80))",
+  "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_shipping_data`, {\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"
+}

+ 8 - 0
flowise-tools/new_ticket.json

@@ -0,0 +1,8 @@
+{
+  "name": "new_ticket",
+  "description": "Utwórz ticket supportowy i przekieruj rozmowę do zespołu wsparcia. Używaj gdy klient wyraźnie prosi o kontakt z człowiekiem lub gdy nie jesteś w stanie rozwiązać problemu.",
+  "color": "linear-gradient(rgb(189,154,166), rgb(167,81,91))",
+  "iconSrc": "",
+  "schema": "[{\"id\":0,\"property\":\"conversationId\",\"description\":\"ID konwersacji Chatwoot (z overrideConfig.conversationId)\",\"type\":\"number\",\"required\":true},{\"id\":1,\"property\":\"summary\",\"description\":\"Krótkie podsumowanie problemu klienta\",\"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 conversationId = typeof $conversationId !== 'undefined' ? Number($conversationId) : Number(($flow && $flow.sessionId || '').replace('chatwoot:',''))\nconst summary = typeof $summary !== 'undefined' ? String($summary) : ''\n\nif (!conversationId || conversationId <= 0) return 'Błąd: brak conversationId'\nif (!secret) return 'Błąd: brak $vars.relay_shared_secret'\n\ntry {\n  const res = await fetch(`${base}/tools/new_ticket`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify({ conversationId, summary })\n  })\n  const data = await res.json()\n  if (data.ok) {\n    return `Ticket utworzony: ${data.ticketNumber} (status: ${data.status})`\n  }\n  return `Błąd tworzenia ticketu: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Błąd połączenia: ${error?.message || String(error)}`\n}\n"
+}

+ 44 - 0
public/index.php

@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * EKSRelay – single entry point.
+ *
+ * Run: php -S 0.0.0.0:8080 -t public
+ */
+
+require_once __DIR__ . '/../vendor/autoload.php';
+
+use EKSRelay\Core\Env;
+use EKSRelay\Core\Logger;
+use EKSRelay\Core\Router;
+use EKSRelay\Handlers\ChatwootWebhookHandler;
+use EKSRelay\Handlers\NewTicketHandler;
+use EKSRelay\Handlers\WooToolsHandler;
+
+// ── Bootstrap ──────────────────────────────────────────────────────
+Env::load(__DIR__ . '/../.env');
+Logger::init();
+
+// ── Routes ─────────────────────────────────────────────────────────
+$router = new Router();
+
+// Chatwoot webhook
+$router->post('/webhooks/chatwoot', [ChatwootWebhookHandler::class, 'handle']);
+
+// Tools (called by Flowise or external)
+$router->post('/tools/new_ticket',             [NewTicketHandler::class, 'handle']);
+$router->post('/tools/get_order_data',         [WooToolsHandler::class, 'getOrderData']);
+$router->post('/tools/get_product_data',       [WooToolsHandler::class, 'getProductData']);
+$router->post('/tools/get_shipping_data',      [WooToolsHandler::class, 'getShippingData']);
+$router->post('/tools/get_payment_methods',    [WooToolsHandler::class, 'getPaymentMethods']);
+$router->post('/tools/get_product_compatibility', [WooToolsHandler::class, 'getProductCompatibility']);
+$router->post('/tools/get_car_data',           [WooToolsHandler::class, 'getCarData']);
+
+// ── Dispatch ───────────────────────────────────────────────────────
+$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
+$uri    = $_SERVER['REQUEST_URI'] ?? '/';
+
+Logger::info('Request received', ['method' => $method, 'uri' => $uri]);
+$router->dispatch($method, $uri);

+ 177 - 0
src/Clients/ChatwootClient.php

@@ -0,0 +1,177 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EKSRelay\Clients;
+
+use EKSRelay\Core\Env;
+use EKSRelay\Core\HttpClient;
+use EKSRelay\Core\HttpException;
+use EKSRelay\Core\Logger;
+
+final class ChatwootClient
+{
+    private string $baseUrl;
+    private string $token;
+    private int $accountId;
+
+    public function __construct()
+    {
+        $this->baseUrl   = rtrim(Env::get('CHATWOOT_BASE_URL'), '/');
+        $this->token     = Env::get('CHATWOOT_API_TOKEN');
+        $this->accountId = Env::getInt('CHATWOOT_ACCOUNT_ID', 1);
+    }
+
+    // ---------------------------------------------------------------
+    // API helpers
+    // ---------------------------------------------------------------
+
+    private function apiUrl(string $path): string
+    {
+        // Chatwoot v2/v3 API: /api/v1/accounts/{account_id}/...
+        return "{$this->baseUrl}/api/v1/accounts/{$this->accountId}{$path}";
+    }
+
+    private function authHeaders(): array
+    {
+        return ["api_access_token: {$this->token}"];
+    }
+
+    /**
+     * @return array Decoded JSON response
+     */
+    private function api(string $method, string $path, ?array $body = null): array
+    {
+        $url = $this->apiUrl($path);
+        $res = HttpClient::request($method, $url, $this->authHeaders(), $body);
+
+        if ($res['status'] >= 400) {
+            Logger::warn("Chatwoot API error", [
+                'method' => $method,
+                'path'   => $path,
+                'status' => $res['status'],
+                'body'   => mb_substr($res['body'], 0, 500),
+            ]);
+            throw new HttpException(502, 'CHATWOOT_API_ERROR', "Chatwoot returned HTTP {$res['status']}");
+        }
+
+        return $res['json'] ?? [];
+    }
+
+    // ---------------------------------------------------------------
+    // Public methods
+    // ---------------------------------------------------------------
+
+    /**
+     * Get full conversation details (labels, custom_attributes, etc.).
+     */
+    public function getConversation(int $conversationId): array
+    {
+        return $this->api('GET', "/conversations/{$conversationId}");
+    }
+
+    /**
+     * Add a label to a conversation.
+     * Chatwoot API: POST /conversations/{id}/labels  (Chatwoot >= v2.14)
+     * The endpoint expects { "labels": ["label1","label2"] } and REPLACES all labels,
+     * so we first fetch existing labels and merge.
+     *
+     * NOTE: Chatwoot label API behaviour may vary across versions.
+     * In v3.x the endpoint path/format is the same, but verify if you upgrade.
+     */
+    public function addLabel(int $conversationId, string $label): void
+    {
+        $conv = $this->getConversation($conversationId);
+        $existing = $conv['labels'] ?? [];
+        if (in_array($label, $existing, true)) {
+            return; // already present
+        }
+        $existing[] = $label;
+
+        // Chatwoot expects a JSON body with the complete labels array
+        $this->api('POST', "/conversations/{$conversationId}/labels", [
+            'labels' => $existing,
+        ]);
+
+        Logger::info("Label added", ['conversation_id' => $conversationId, 'label' => $label]);
+    }
+
+    /**
+     * Set custom attributes on a conversation.
+     * PATCH /conversations/{id}/  with { "custom_attributes": { ... } }
+     *
+     * Chatwoot merges provided keys into existing custom_attributes.
+     */
+    public function setCustomAttributes(int $conversationId, array $attrs): void
+    {
+        $this->api('PATCH', "/conversations/{$conversationId}", [
+            'custom_attributes' => $attrs,
+        ]);
+        Logger::info("Custom attributes set", ['conversation_id' => $conversationId, 'attrs' => $attrs]);
+    }
+
+    /**
+     * Remove the assigned agent from a conversation (un-assign).
+     *
+     * Chatwoot API: POST /conversations/{id}/assignments
+     * with { "assignee_id": null } to un-assign.
+     *
+     * NOTE (Chatwoot version): In v2.x/v3.x the assignments endpoint accepts
+     * assignee_id=null to clear the assignment. If your version behaves
+     * differently, adjust accordingly.
+     */
+    public function unassignConversation(int $conversationId): void
+    {
+        // Attempt to un-assign by setting assignee_id to null
+        $url = $this->apiUrl("/conversations/{$conversationId}/assignments");
+        $res = HttpClient::request('POST', $url, $this->authHeaders(), [
+            'assignee_id' => null,
+        ]);
+
+        if ($res['status'] >= 400) {
+            Logger::warn("Unassign may have failed", [
+                'conversation_id' => $conversationId,
+                'status'          => $res['status'],
+            ]);
+        } else {
+            Logger::info("Conversation unassigned", ['conversation_id' => $conversationId]);
+        }
+    }
+
+    /**
+     * Send an outgoing message in a conversation.
+     * POST /conversations/{id}/messages
+     *
+     * message_type: 1 = outgoing
+     */
+    public function sendOutgoingMessage(int $conversationId, string $text): array
+    {
+        return $this->api('POST', "/conversations/{$conversationId}/messages", [
+            'content'      => $text,
+            'message_type' => 'outgoing',
+            'private'      => false,
+        ]);
+    }
+
+    /**
+     * Check if conversation is in "ticket/manual" mode.
+     * Returns true if label=ticket or custom_attributes.handoff=true.
+     */
+    public function isTicketMode(int $conversationId, ?array $convData = null): bool
+    {
+        $conv = $convData ?? $this->getConversation($conversationId);
+
+        $ticketLabel = Env::get('CHATWOOT_TICKET_LABEL', 'ticket');
+        $labels = $conv['labels'] ?? [];
+        if (in_array($ticketLabel, $labels, true)) {
+            return true;
+        }
+
+        $ca = $conv['custom_attributes'] ?? [];
+        if (isset($ca['handoff']) && ($ca['handoff'] === true || $ca['handoff'] === 'true')) {
+            return true;
+        }
+
+        return false;
+    }
+}

+ 88 - 0
src/Clients/FlowiseClient.php

@@ -0,0 +1,88 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EKSRelay\Clients;
+
+use EKSRelay\Core\Env;
+use EKSRelay\Core\HttpClient;
+use EKSRelay\Core\HttpException;
+use EKSRelay\Core\Logger;
+
+final class FlowiseClient
+{
+    private string $predictUrl;
+    private string $apiKey;
+
+    public function __construct()
+    {
+        $this->predictUrl = Env::get('FLOWISE_PREDICT_URL');
+        $this->apiKey     = Env::get('FLOWISE_API_KEY');
+    }
+
+    /**
+     * Call Flowise prediction endpoint and return a normalised response.
+     *
+     * @param array $payload Full request body (question, overrideConfig, metadata, …)
+     * @return array{type: string, text: string|null, actions: array|null}
+     *   type = "reply" | "handoff" | "unknown"
+     */
+    public function predict(array $payload): array
+    {
+        $headers = [];
+        if ($this->apiKey !== '') {
+            $headers[] = "Authorization: Bearer {$this->apiKey}";
+        }
+
+        Logger::info('Calling Flowise', ['url' => $this->predictUrl]);
+
+        $res = HttpClient::request('POST', $this->predictUrl, $headers, $payload, 60);
+
+        if ($res['status'] >= 400) {
+            Logger::error('Flowise error', ['status' => $res['status'], 'body' => mb_substr($res['body'], 0, 500)]);
+            throw new HttpException(502, 'FLOWISE_ERROR', "Flowise returned HTTP {$res['status']}");
+        }
+
+        return $this->normalise($res);
+    }
+
+    /**
+     * Normalise various Flowise response formats into a predictable structure.
+     *
+     * Flowise may return:
+     *  - plain text string (the body IS the answer)
+     *  - JSON { "text": "...", "question": "..." }
+     *  - JSON with actions: { "text": "...", "actions": [ { "type": "handoff", ... } ] }
+     */
+    private function normalise(array $res): array
+    {
+        $json = $res['json'];
+        $body = trim($res['body']);
+
+        // Case 1: JSON object
+        if (is_array($json)) {
+            $text    = $json['text'] ?? $json['response'] ?? $json['answer'] ?? null;
+            $actions = $json['actions'] ?? null;
+
+            // Detect handoff in actions
+            if (is_array($actions)) {
+                foreach ($actions as $action) {
+                    if (isset($action['type']) && $action['type'] === 'handoff') {
+                        return ['type' => 'handoff', 'text' => $text, 'actions' => $actions];
+                    }
+                }
+            }
+
+            if ($text !== null) {
+                return ['type' => 'reply', 'text' => (string)$text, 'actions' => $actions];
+            }
+        }
+
+        // Case 2: plain text
+        if ($body !== '') {
+            return ['type' => 'reply', 'text' => $body, 'actions' => null];
+        }
+
+        return ['type' => 'unknown', 'text' => null, 'actions' => null];
+    }
+}

+ 188 - 0
src/Clients/WooCommerceClient.php

@@ -0,0 +1,188 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EKSRelay\Clients;
+
+use EKSRelay\Core\Env;
+use EKSRelay\Core\HttpClient;
+use EKSRelay\Core\HttpException;
+use EKSRelay\Core\Logger;
+
+/**
+ * WooCommerce REST API client.
+ * Uses Consumer Key / Consumer Secret authentication (query-string method for HTTPS).
+ */
+final class WooCommerceClient
+{
+    private string $baseUrl;
+    private string $ck;
+    private string $cs;
+
+    public function __construct()
+    {
+        $this->baseUrl = rtrim(Env::get('WOOCOMMERCE_BASE_URL'), '/');
+        $this->ck      = Env::get('WOOCOMMERCE_CONSUMER_KEY');
+        $this->cs      = Env::get('WOOCOMMERCE_CONSUMER_SECRET');
+    }
+
+    // ---------------------------------------------------------------
+    // Internal helpers
+    // ---------------------------------------------------------------
+
+    private function url(string $endpoint, array $params = []): string
+    {
+        $params['consumer_key']    = $this->ck;
+        $params['consumer_secret'] = $this->cs;
+
+        return $this->baseUrl . '/wp-json/wc/v3/' . ltrim($endpoint, '/') . '?' . http_build_query($params);
+    }
+
+    /**
+     * @return array Decoded JSON
+     */
+    private function get(string $endpoint, array $params = []): array
+    {
+        $url = $this->url($endpoint, $params);
+        $res = HttpClient::request('GET', $url);
+
+        if ($res['status'] >= 400) {
+            Logger::warn('WooCommerce API error', [
+                'endpoint' => $endpoint,
+                'status'   => $res['status'],
+                'body'     => mb_substr($res['body'], 0, 500),
+            ]);
+            throw new HttpException(502, 'WOOCOMMERCE_API_ERROR', "WooCommerce returned HTTP {$res['status']}");
+        }
+
+        return $res['json'] ?? [];
+    }
+
+    // ---------------------------------------------------------------
+    // Orders
+    // ---------------------------------------------------------------
+
+    /**
+     * Find order by order number or by customer email.
+     * Priority: orderNumber > email.
+     */
+    public function getOrder(?string $orderNumber = null, ?string $email = null): array
+    {
+        if ($orderNumber !== null && $orderNumber !== '') {
+            // WooCommerce stores order number as the post ID by default.
+            // Try direct fetch first.
+            try {
+                return $this->get("orders/{$orderNumber}");
+            } catch (HttpException) {
+                // If direct fetch fails (e.g. custom order numbers plugin),
+                // fall back to search.
+                $results = $this->get('orders', ['search' => $orderNumber, 'per_page' => 1]);
+                if (!empty($results)) {
+                    return $results[0];
+                }
+                throw new HttpException(404, 'ORDER_NOT_FOUND', "Order #{$orderNumber} not found.");
+            }
+        }
+
+        if ($email !== null && $email !== '') {
+            $results = $this->get('orders', ['search' => $email, 'per_page' => 5, 'orderby' => 'date', 'order' => 'desc']);
+            if (empty($results)) {
+                throw new HttpException(404, 'ORDER_NOT_FOUND', "No orders found for email {$email}.");
+            }
+            return $results[0]; // most recent
+        }
+
+        throw new HttpException(400, 'MISSING_PARAMS', 'Provide orderNumber or email.');
+    }
+
+    /**
+     * Get multiple orders by email (for listing).
+     */
+    public function getOrdersByEmail(string $email, int $limit = 5): array
+    {
+        return $this->get('orders', ['search' => $email, 'per_page' => $limit, 'orderby' => 'date', 'order' => 'desc']);
+    }
+
+    // ---------------------------------------------------------------
+    // Products
+    // ---------------------------------------------------------------
+
+    /**
+     * Get product by ID or by SKU.
+     */
+    public function getProduct(?int $productId = null, ?string $sku = null): array
+    {
+        if ($productId !== null) {
+            return $this->get("products/{$productId}");
+        }
+
+        if ($sku !== null && $sku !== '') {
+            $results = $this->get('products', ['sku' => $sku, 'per_page' => 1]);
+            if (empty($results)) {
+                throw new HttpException(404, 'PRODUCT_NOT_FOUND', "Product with SKU {$sku} not found.");
+            }
+            return $results[0];
+        }
+
+        throw new HttpException(400, 'MISSING_PARAMS', 'Provide productId or sku.');
+    }
+
+    /**
+     * Search products by name/keyword.
+     */
+    public function searchProducts(string $query, int $limit = 5): array
+    {
+        return $this->get('products', ['search' => $query, 'per_page' => $limit]);
+    }
+
+    // ---------------------------------------------------------------
+    // Shipping
+    // ---------------------------------------------------------------
+
+    /**
+     * Get shipping zones and methods.
+     * WooCommerce REST API provides: GET /shipping/zones and
+     * GET /shipping/zones/{zone_id}/methods
+     */
+    public function getShippingZones(): array
+    {
+        return $this->get('shipping/zones');
+    }
+
+    public function getShippingMethods(int $zoneId): array
+    {
+        return $this->get("shipping/zones/{$zoneId}/methods");
+    }
+
+    /**
+     * Get all shipping info (zones + methods per zone).
+     */
+    public function getAllShippingData(): array
+    {
+        $zones = $this->getShippingZones();
+        $result = [];
+        foreach ($zones as $zone) {
+            $zid = (int)($zone['id'] ?? 0);
+            $methods = $this->getShippingMethods($zid);
+            $result[] = [
+                'zone'    => $zone,
+                'methods' => $methods,
+            ];
+        }
+        return $result;
+    }
+
+    // ---------------------------------------------------------------
+    // Payment gateways
+    // ---------------------------------------------------------------
+
+    /**
+     * Get payment gateways.
+     * WooCommerce REST API: GET /payment_gateways
+     * NOTE: This endpoint requires admin-level consumer keys.
+     */
+    public function getPaymentGateways(): array
+    {
+        return $this->get('payment_gateways');
+    }
+}

+ 27 - 0
src/Core/Auth.php

@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EKSRelay\Core;
+
+final class Auth
+{
+    /**
+     * Verify Bearer token against RELAY_SHARED_SECRET.
+     * Throws HttpException(401) on failure.
+     */
+    public static function requireBearer(): void
+    {
+        $header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
+        if (!str_starts_with($header, 'Bearer ')) {
+            throw new HttpException(401, 'UNAUTHORIZED', 'Missing or invalid Authorization header.');
+        }
+
+        $token = substr($header, 7);
+        $secret = Env::get('RELAY_SHARED_SECRET');
+
+        if ($secret === '' || !hash_equals($secret, $token)) {
+            throw new HttpException(401, 'UNAUTHORIZED', 'Invalid shared secret.');
+        }
+    }
+}

+ 71 - 0
src/Core/Env.php

@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EKSRelay\Core;
+
+/**
+ * Minimal .env file loader. Reads KEY=VALUE lines, supports # comments and
+ * double-quoted values. Does NOT overwrite existing env vars.
+ */
+final class Env
+{
+    private static bool $loaded = false;
+
+    public static function load(string $path): void
+    {
+        if (self::$loaded) {
+            return;
+        }
+
+        if (!is_file($path)) {
+            return;
+        }
+
+        $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
+        if ($lines === false) {
+            return;
+        }
+
+        foreach ($lines as $line) {
+            $line = trim($line);
+            if ($line === '' || str_starts_with($line, '#')) {
+                continue;
+            }
+            $eqPos = strpos($line, '=');
+            if ($eqPos === false) {
+                continue;
+            }
+            $key = trim(substr($line, 0, $eqPos));
+            $value = trim(substr($line, $eqPos + 1));
+
+            // Strip surrounding quotes
+            if (strlen($value) >= 2 && $value[0] === '"' && str_ends_with($value, '"')) {
+                $value = substr($value, 1, -1);
+            }
+
+            // Don't overwrite existing env vars
+            if (getenv($key) === false && !isset($_ENV[$key])) {
+                putenv("{$key}={$value}");
+                $_ENV[$key] = $value;
+            }
+        }
+
+        self::$loaded = true;
+    }
+
+    public static function get(string $key, string $default = ''): string
+    {
+        $val = getenv($key);
+        if ($val !== false && $val !== '') {
+            return $val;
+        }
+        return $_ENV[$key] ?? $default;
+    }
+
+    public static function getInt(string $key, int $default = 0): int
+    {
+        $val = self::get($key);
+        return $val !== '' ? (int)$val : $default;
+    }
+}

+ 90 - 0
src/Core/HttpClient.php

@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EKSRelay\Core;
+
+/**
+ * Thin cURL wrapper for JSON APIs.
+ */
+final class HttpClient
+{
+    /**
+     * @return array{status: int, body: string, json: mixed}
+     */
+    public static function request(
+        string $method,
+        string $url,
+        array $headers = [],
+        ?array $jsonBody = null,
+        int $timeoutSeconds = 30,
+    ): array {
+        $ch = curl_init();
+
+        $opts = [
+            CURLOPT_URL            => $url,
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_TIMEOUT        => $timeoutSeconds,
+            CURLOPT_CONNECTTIMEOUT => 10,
+            CURLOPT_FOLLOWLOCATION => true,
+            CURLOPT_MAXREDIRS      => 3,
+        ];
+
+        // Use bundled CA certs if PHP/system doesn't have them configured
+        $caBundle = Env::get('CURL_CA_BUNDLE');
+        if ($caBundle === '') {
+            // Common fallback locations
+            foreach ([
+                dirname(__DIR__, 2) . '/cacert.pem',
+                (getenv('APPDATA') ?: '') . '/php/cacert.pem',
+            ] as $candidate) {
+                if ($candidate !== '' && is_file($candidate)) {
+                    $caBundle = $candidate;
+                    break;
+                }
+            }
+        }
+        if ($caBundle !== '') {
+            $opts[CURLOPT_CAINFO] = $caBundle;
+        }
+
+        curl_setopt_array($ch, $opts);
+
+        $headers[] = 'Accept: application/json';
+
+        if ($jsonBody !== null) {
+            $encoded = json_encode($jsonBody, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+            curl_setopt($ch, CURLOPT_POSTFIELDS, $encoded);
+            $headers[] = 'Content-Type: application/json';
+        }
+
+        $method = strtoupper($method);
+        match ($method) {
+            'GET'    => null,
+            'POST'   => curl_setopt($ch, CURLOPT_POST, true),
+            'PUT'    => curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'),
+            'PATCH'  => curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH'),
+            'DELETE' => curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'),
+            default  => curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method),
+        };
+
+        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+
+        $body   = curl_exec($ch);
+        $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        $error  = curl_error($ch);
+        curl_close($ch);
+
+        if ($body === false) {
+            throw new HttpException(502, 'UPSTREAM_ERROR', "cURL error: {$error}");
+        }
+
+        $json = json_decode((string)$body, true);
+
+        return [
+            'status' => $status,
+            'body'   => (string)$body,
+            'json'   => $json,
+        ];
+    }
+}

+ 30 - 0
src/Core/HttpException.php

@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EKSRelay\Core;
+
+final class HttpException extends \RuntimeException
+{
+    public function __construct(
+        public readonly int $httpCode,
+        public readonly string $errorCode,
+        string $message,
+        public readonly ?array $details = null,
+    ) {
+        parent::__construct($message, $httpCode);
+    }
+
+    public function toArray(): array
+    {
+        $out = [
+            'ok'      => false,
+            'code'    => $this->errorCode,
+            'message' => $this->getMessage(),
+        ];
+        if ($this->details !== null) {
+            $out['details'] = $this->details;
+        }
+        return $out;
+    }
+}

+ 66 - 0
src/Core/Logger.php

@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EKSRelay\Core;
+
+final class Logger
+{
+    private static ?string $requestId = null;
+
+    public static function init(): void
+    {
+        self::$requestId = substr(bin2hex(random_bytes(8)), 0, 16);
+    }
+
+    public static function getRequestId(): string
+    {
+        return self::$requestId ?? 'unknown';
+    }
+
+    public static function info(string $message, array $context = []): void
+    {
+        self::log('info', $message, $context);
+    }
+
+    public static function warn(string $message, array $context = []): void
+    {
+        self::log('warn', $message, $context);
+    }
+
+    public static function error(string $message, array $context = []): void
+    {
+        self::log('error', $message, $context);
+    }
+
+    private static function log(string $level, string $message, array $context): void
+    {
+        $entry = [
+            'timestamp'  => gmdate('Y-m-d\TH:i:s\Z'),
+            'level'      => $level,
+            'request_id' => self::$requestId ?? 'unknown',
+            'message'    => $message,
+        ];
+
+        if (isset($context['conversation_id'])) {
+            $entry['conversation_id'] = $context['conversation_id'];
+            unset($context['conversation_id']);
+        }
+        if (isset($context['message_id'])) {
+            $entry['message_id'] = $context['message_id'];
+            unset($context['message_id']);
+        }
+
+        if ($context !== []) {
+            $entry['context'] = $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);
+        if (defined('STDOUT')) {
+            fwrite(\STDOUT, $line . "\n");
+        } else {
+            error_log($line);
+        }
+    }
+}

+ 78 - 0
src/Core/Router.php

@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EKSRelay\Core;
+
+final class Router
+{
+    /** @var array<string, array<string, callable>> */
+    private array $routes = [];
+
+    public function post(string $path, callable $handler): void
+    {
+        $this->routes['POST'][$path] = $handler;
+    }
+
+    public function dispatch(string $method, string $uri): void
+    {
+        // Strip query string
+        $path = parse_url($uri, PHP_URL_PATH);
+        $path = '/' . trim((string)$path, '/');
+
+        if (!isset($this->routes[$method][$path])) {
+            self::sendJson(404, [
+                'ok'      => false,
+                'code'    => 'NOT_FOUND',
+                'message' => "No route for {$method} {$path}",
+            ]);
+            return;
+        }
+
+        $handler = $this->routes[$method][$path];
+
+        try {
+            $handler();
+        } catch (HttpException $e) {
+            Logger::warn("HttpException: {$e->getMessage()}", [
+                'http_code'  => $e->httpCode,
+                'error_code' => $e->errorCode,
+            ]);
+            self::sendJson($e->httpCode, $e->toArray());
+        } catch (\Throwable $e) {
+            Logger::error("Unhandled exception: {$e->getMessage()}", [
+                'exception' => get_class($e),
+                'file'      => $e->getFile(),
+                'line'      => $e->getLine(),
+            ]);
+            self::sendJson(500, [
+                'ok'      => false,
+                'code'    => 'INTERNAL_ERROR',
+                'message' => 'An unexpected error occurred.',
+            ]);
+        }
+    }
+
+    public static function sendJson(int $statusCode, array $data): void
+    {
+        http_response_code($statusCode);
+        header('Content-Type: application/json; charset=utf-8');
+        echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
+    }
+
+    /**
+     * Read and decode JSON request body.
+     */
+    public static function jsonBody(): array
+    {
+        $raw = file_get_contents('php://input');
+        if ($raw === '' || $raw === false) {
+            throw new HttpException(400, 'INVALID_BODY', 'Request body is empty.');
+        }
+        $data = json_decode($raw, true);
+        if (!is_array($data)) {
+            throw new HttpException(400, 'INVALID_JSON', 'Request body is not valid JSON.');
+        }
+        return $data;
+    }
+}

+ 159 - 0
src/Handlers/ChatwootWebhookHandler.php

@@ -0,0 +1,159 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EKSRelay\Handlers;
+
+use EKSRelay\Clients\ChatwootClient;
+use EKSRelay\Clients\FlowiseClient;
+use EKSRelay\Core\HttpException;
+use EKSRelay\Core\Logger;
+use EKSRelay\Core\Router;
+
+final class ChatwootWebhookHandler
+{
+    public static function handle(): void
+    {
+        $payload = Router::jsonBody();
+
+        $event       = $payload['event'] ?? '';
+        $messageType = $payload['message_type'] ?? '';
+
+        // Only process incoming messages
+        if ($event !== 'message_created' || $messageType !== 'incoming') {
+            Logger::info('Ignored webhook event', ['event' => $event, 'message_type' => $messageType]);
+            Router::sendJson(200, ['ok' => true, 'skipped' => true, 'reason' => 'not_incoming_message']);
+            return;
+        }
+
+        $conversation   = $payload['conversation'] ?? [];
+        $conversationId = (int)($conversation['id'] ?? 0);
+        $inboxId        = (int)($conversation['inbox_id'] ?? 0);
+        $content        = trim((string)($payload['content'] ?? ''));
+        $sender         = $payload['sender'] ?? [];
+        $messageId      = $payload['id'] ?? null;
+
+        if ($conversationId === 0) {
+            throw new HttpException(400, 'MISSING_CONVERSATION_ID', 'Payload is missing conversation.id');
+        }
+
+        $logCtx = ['conversation_id' => $conversationId, 'message_id' => $messageId];
+        Logger::info('Processing incoming message', $logCtx);
+
+        $chatwoot = new ChatwootClient();
+
+        // -----------------------------------------------------------------
+        // Fetch full conversation details if labels/attributes incomplete
+        // -----------------------------------------------------------------
+        $labels = $conversation['labels'] ?? null;
+        $additionalAttrs = $conversation['additional_attributes'] ?? [];
+        $customAttrs = $conversation['custom_attributes'] ?? null;
+
+        $convData = null;
+        if ($labels === null || $customAttrs === null) {
+            Logger::info('Fetching full conversation from Chatwoot API', $logCtx);
+            $convData = $chatwoot->getConversation($conversationId);
+            $labels       = $convData['labels'] ?? [];
+            $customAttrs  = $convData['custom_attributes'] ?? [];
+            $additionalAttrs = array_merge($additionalAttrs, $convData['additional_attributes'] ?? []);
+        }
+
+        // -----------------------------------------------------------------
+        // STOP check: ticket label or handoff=true
+        // -----------------------------------------------------------------
+        if ($chatwoot->isTicketMode($conversationId, $convData ?? array_merge($conversation, [
+            'labels'            => $labels,
+            'custom_attributes' => $customAttrs,
+        ]))) {
+            Logger::info('Conversation is in ticket/manual mode — skipping', $logCtx);
+            Router::sendJson(200, ['ok' => true, 'skipped' => true, 'reason' => 'ticket_mode']);
+            return;
+        }
+
+        // -----------------------------------------------------------------
+        // Build Flowise request
+        // -----------------------------------------------------------------
+        $flowisePayload = [
+            'question'       => $content,
+            'overrideConfig' => [
+                'sessionId'      => "chatwoot:{$conversationId}",
+                'conversationId' => $conversationId,
+                'inboxId'        => $inboxId,
+            ],
+            'metadata' => [
+                'source'      => 'chatwoot',
+                'event'       => $event,
+                'messageType' => $messageType,
+                'sender'      => [
+                    'id'    => $sender['id'] ?? null,
+                    'email' => $sender['email'] ?? null,
+                    'name'  => $sender['name'] ?? null,
+                ],
+                'labels'  => $labels,
+                'subject' => $additionalAttrs['mail_subject'] ?? ($additionalAttrs['subject'] ?? null),
+            ],
+        ];
+
+        if ($messageId !== null) {
+            $flowisePayload['overrideConfig']['messageId'] = $messageId;
+        }
+
+        // -----------------------------------------------------------------
+        // Call Flowise
+        // -----------------------------------------------------------------
+        $flowise  = new FlowiseClient();
+        $response = $flowise->predict($flowisePayload);
+
+        Logger::info('Flowise response', array_merge($logCtx, ['type' => $response['type']]));
+
+        // -----------------------------------------------------------------
+        // Handle handoff
+        // -----------------------------------------------------------------
+        if ($response['type'] === 'handoff') {
+            $ticketResult = NewTicketHandler::createTicket($conversationId);
+            $ticketNumber = $ticketResult['ticketNumber'];
+
+            $replyText = $response['text']
+                ?? "Twoje zgłoszenie zostało utworzone. Numer ticketu: {$ticketNumber}. Nasz zespół wkrótce się z Tobą skontaktuje.";
+
+            // Include ticket number if Flowise text doesn't already contain it
+            if ($response['text'] !== null && !str_contains($response['text'], $ticketNumber)) {
+                $replyText .= "\n\nNumer ticketu: {$ticketNumber}";
+            }
+
+            // Re-check ticket mode before sending (race condition guard)
+            if (!$chatwoot->isTicketMode($conversationId)) {
+                Logger::warn('Conversation became ticket during handoff — message might duplicate', $logCtx);
+            }
+
+            $chatwoot->sendOutgoingMessage($conversationId, $replyText);
+            Logger::info('Handoff completed', array_merge($logCtx, ['ticket' => $ticketNumber]));
+
+            Router::sendJson(200, ['ok' => true, 'action' => 'handoff', 'ticketNumber' => $ticketNumber]);
+            return;
+        }
+
+        // -----------------------------------------------------------------
+        // 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;
+            }
+
+            $chatwoot->sendOutgoingMessage($conversationId, $response['text']);
+            Logger::info('Reply sent', $logCtx);
+            Router::sendJson(200, ['ok' => true, 'action' => 'reply']);
+            return;
+        }
+
+        // -----------------------------------------------------------------
+        // Unknown / empty response
+        // -----------------------------------------------------------------
+        Logger::warn('Flowise returned no usable response', array_merge($logCtx, ['response' => $response]));
+        Router::sendJson(200, ['ok' => true, 'action' => 'no_reply', 'reason' => 'empty_flowise_response']);
+    }
+}

+ 109 - 0
src/Handlers/NewTicketHandler.php

@@ -0,0 +1,109 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EKSRelay\Handlers;
+
+use EKSRelay\Clients\ChatwootClient;
+use EKSRelay\Core\Auth;
+use EKSRelay\Core\Env;
+use EKSRelay\Core\HttpException;
+use EKSRelay\Core\Logger;
+use EKSRelay\Core\Router;
+
+final class NewTicketHandler
+{
+    /**
+     * HTTP handler for POST /tools/new_ticket
+     */
+    public static function handle(): void
+    {
+        Auth::requireBearer();
+
+        $body           = Router::jsonBody();
+        $conversationId = (int)($body['conversationId'] ?? 0);
+
+        if ($conversationId === 0) {
+            throw new HttpException(400, 'MISSING_PARAMS', 'conversationId is required.');
+        }
+
+        $result = self::createTicket($conversationId);
+        Router::sendJson(200, $result);
+    }
+
+    /**
+     * Core ticket creation logic — also called internally from webhook handler.
+     *
+     * @return array{ok: bool, ticketNumber: string, status: string}
+     */
+    public static function createTicket(int $conversationId): array
+    {
+        $chatwoot    = new ChatwootClient();
+        $ticketLabel = Env::get('CHATWOOT_TICKET_LABEL', 'ticket');
+
+        $logCtx = ['conversation_id' => $conversationId];
+
+        // -----------------------------------------------------------------
+        // Idempotency: check if already a ticket
+        // -----------------------------------------------------------------
+        $conv        = $chatwoot->getConversation($conversationId);
+        $labels      = $conv['labels'] ?? [];
+        $customAttrs = $conv['custom_attributes'] ?? [];
+
+        $alreadyTicket = in_array($ticketLabel, $labels, true)
+            || (isset($customAttrs['handoff']) && ($customAttrs['handoff'] === true || $customAttrs['handoff'] === 'true'));
+
+        if ($alreadyTicket) {
+            $existingNumber = $customAttrs['ticket_number'] ?? null;
+
+            if ($existingNumber === null || $existingNumber === '') {
+                // Label exists but no ticket_number — generate and set it
+                $existingNumber = self::generateTicketNumber($conversationId);
+                $chatwoot->setCustomAttributes($conversationId, ['ticket_number' => $existingNumber]);
+            }
+
+            Logger::info('Ticket already exists', array_merge($logCtx, ['ticket' => $existingNumber]));
+
+            return [
+                'ok'           => true,
+                'ticketNumber' => (string)$existingNumber,
+                'status'       => 'existing',
+            ];
+        }
+
+        // -----------------------------------------------------------------
+        // Create new ticket
+        // -----------------------------------------------------------------
+        $ticketNumber = self::generateTicketNumber($conversationId);
+
+        // 1. Unassign bot from conversation
+        $chatwoot->unassignConversation($conversationId);
+
+        // 2. Set custom attributes
+        $chatwoot->setCustomAttributes($conversationId, [
+            'handoff'       => true,
+            'ticket_number' => $ticketNumber,
+        ]);
+
+        // 3. Add label
+        $chatwoot->addLabel($conversationId, $ticketLabel);
+
+        Logger::info('Ticket created', array_merge($logCtx, ['ticket' => $ticketNumber]));
+
+        return [
+            'ok'           => true,
+            'ticketNumber' => $ticketNumber,
+            'status'       => 'created',
+        ];
+    }
+
+    /**
+     * Generate a deterministic, human-readable ticket number without a database.
+     * Format: TCK-YYYYMMDD-HHMMSS-{conversationId}
+     */
+    private static function generateTicketNumber(int $conversationId): string
+    {
+        $now = gmdate('Ymd-His');
+        return "TCK-{$now}-{$conversationId}";
+    }
+}

+ 311 - 0
src/Handlers/WooToolsHandler.php

@@ -0,0 +1,311 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EKSRelay\Handlers;
+
+use EKSRelay\Clients\WooCommerceClient;
+use EKSRelay\Core\Auth;
+use EKSRelay\Core\Env;
+use EKSRelay\Core\HttpClient;
+use EKSRelay\Core\HttpException;
+use EKSRelay\Core\Logger;
+use EKSRelay\Core\Router;
+
+/**
+ * Handlers for all WooCommerce-backed tool endpoints.
+ */
+final class WooToolsHandler
+{
+    // =================================================================
+    // POST /tools/get_order_data
+    // =================================================================
+    public static function getOrderData(): void
+    {
+        Auth::requireBearer();
+        $body = Router::jsonBody();
+
+        $orderNumber = $body['orderNumber'] ?? ($body['order_number'] ?? null);
+        $email       = $body['email'] ?? null;
+
+        if ($orderNumber !== null) {
+            $orderNumber = (string)$orderNumber;
+        }
+
+        $woo = new WooCommerceClient();
+
+        if ($orderNumber !== null && $orderNumber !== '') {
+            $order = $woo->getOrder(orderNumber: $orderNumber);
+        } elseif ($email !== null && $email !== '') {
+            $order = $woo->getOrder(email: $email);
+        } else {
+            throw new HttpException(400, 'MISSING_PARAMS', 'Provide orderNumber or email.');
+        }
+
+        // Return a curated subset useful for the LLM agent
+        Router::sendJson(200, [
+            'ok'   => true,
+            'data' => self::formatOrder($order),
+        ]);
+    }
+
+    // =================================================================
+    // POST /tools/get_product_data
+    // =================================================================
+    public static function getProductData(): void
+    {
+        Auth::requireBearer();
+        $body = Router::jsonBody();
+
+        $productId = isset($body['productId']) ? (int)$body['productId'] : (isset($body['product_id']) ? (int)$body['product_id'] : null);
+        $sku       = $body['sku'] ?? null;
+        $search    = $body['search'] ?? null;
+
+        $woo = new WooCommerceClient();
+
+        if ($productId !== null && $productId > 0) {
+            $product = $woo->getProduct(productId: $productId);
+            Router::sendJson(200, ['ok' => true, 'data' => self::formatProduct($product)]);
+            return;
+        }
+
+        if ($sku !== null && $sku !== '') {
+            $product = $woo->getProduct(sku: $sku);
+            Router::sendJson(200, ['ok' => true, 'data' => self::formatProduct($product)]);
+            return;
+        }
+
+        if ($search !== null && $search !== '') {
+            $products = $woo->searchProducts($search);
+            $formatted = array_map(fn($p) => self::formatProduct($p), $products);
+            Router::sendJson(200, ['ok' => true, 'data' => $formatted]);
+            return;
+        }
+
+        throw new HttpException(400, 'MISSING_PARAMS', 'Provide productId, sku, or search.');
+    }
+
+    // =================================================================
+    // POST /tools/get_shipping_data
+    // =================================================================
+    public static function getShippingData(): void
+    {
+        Auth::requireBearer();
+        $body = Router::jsonBody();
+
+        $woo = new WooCommerceClient();
+
+        // If zoneId provided — return methods for that specific zone
+        $zoneId = $body['zoneId'] ?? ($body['zone_id'] ?? null);
+
+        try {
+            if ($zoneId !== null) {
+                $methods = $woo->getShippingMethods((int)$zoneId);
+                Router::sendJson(200, ['ok' => true, 'data' => ['zone_id' => (int)$zoneId, 'methods' => $methods]]);
+                return;
+            }
+
+            // Default: return list of zones (without methods to avoid 29+ API calls)
+            $zones = $woo->getShippingZones();
+            $formatted = array_map(fn($z) => [
+                'id'   => $z['id'] ?? 0,
+                'name' => $z['name'] ?? '',
+            ], $zones);
+
+            Router::sendJson(200, [
+                'ok'   => true,
+                'data' => $formatted,
+                'hint' => 'Pass {"zoneId": <id>} to get shipping methods for a specific zone.',
+            ]);
+        } catch (HttpException $e) {
+            if ($e->httpCode === 502) {
+                Router::sendJson(200, [
+                    'ok'      => false,
+                    'code'    => 'NOT_IMPLEMENTED',
+                    'message' => 'Shipping data is not available via WooCommerce REST API. '
+                        . 'Ensure shipping zones are configured and the consumer key has read access to shipping endpoints.',
+                ]);
+                return;
+            }
+            throw $e;
+        }
+    }
+
+    // =================================================================
+    // POST /tools/get_payment_methods
+    // =================================================================
+    public static function getPaymentMethods(): void
+    {
+        Auth::requireBearer();
+
+        $woo = new WooCommerceClient();
+
+        try {
+            $gateways = $woo->getPaymentGateways();
+            // Filter to enabled gateways only
+            $enabled = array_values(array_filter($gateways, fn($g) => ($g['enabled'] ?? false) === true));
+
+            $formatted = array_map(fn($g) => [
+                'id'          => $g['id'] ?? '',
+                'title'       => $g['title'] ?? '',
+                'description' => $g['description'] ?? '',
+                'enabled'     => $g['enabled'] ?? false,
+            ], $enabled);
+
+            Router::sendJson(200, ['ok' => true, 'data' => $formatted]);
+        } catch (HttpException $e) {
+            if ($e->httpCode === 502) {
+                Router::sendJson(200, [
+                    'ok'      => false,
+                    'code'    => 'NOT_IMPLEMENTED',
+                    'message' => 'Payment gateways endpoint not accessible. '
+                        . 'Ensure the WooCommerce consumer key has admin-level (read/write) permissions '
+                        . 'to access GET /payment_gateways.',
+                ]);
+                return;
+            }
+            throw $e;
+        }
+    }
+
+    // =================================================================
+    // POST /tools/get_product_compatibility
+    // =================================================================
+    public static function getProductCompatibility(): void
+    {
+        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'];
+
+        $productName = $body['product_name'] ?? ($body['productName'] ?? null);
+        $carBrand    = $body['car_brand']    ?? ($body['carBrand']   ?? null);
+        $carModel    = $body['car_model']    ?? ($body['carModel']   ?? null);
+        $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;
+
+        $url = $ajaxUrl . '?' . http_build_query($params);
+        $res = HttpClient::request('GET', $url);
+
+        Router::sendJson(200, ['ok' => true, 'data' => $res['json'] ?? $res['body']]);
+    }
+
+    // =================================================================
+    // POST /tools/get_car_data
+    // =================================================================
+    public static function getCarData(): void
+    {
+        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'];
+
+        $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);
+
+        Router::sendJson(200, ['ok' => true, 'data' => $res['json'] ?? $res['body']]);
+    }
+
+    // =================================================================
+    // Formatters (curate data for LLM consumption)
+    // =================================================================
+
+    private static function formatOrder(array $order): array
+    {
+        return [
+            'id'               => $order['id'] ?? null,
+            'number'           => $order['number'] ?? null,
+            'status'           => $order['status'] ?? null,
+            'date_created'     => $order['date_created'] ?? null,
+            'total'            => $order['total'] ?? null,
+            'currency'         => $order['currency'] ?? null,
+            'billing'          => [
+                'first_name' => $order['billing']['first_name'] ?? '',
+                'last_name'  => $order['billing']['last_name'] ?? '',
+                'email'      => $order['billing']['email'] ?? '',
+                'phone'      => $order['billing']['phone'] ?? '',
+                'city'       => $order['billing']['city'] ?? '',
+                'country'    => $order['billing']['country'] ?? '',
+            ],
+            'shipping'         => [
+                'first_name' => $order['shipping']['first_name'] ?? '',
+                'last_name'  => $order['shipping']['last_name'] ?? '',
+                'city'       => $order['shipping']['city'] ?? '',
+                'country'    => $order['shipping']['country'] ?? '',
+                'address_1'  => $order['shipping']['address_1'] ?? '',
+            ],
+            'payment_method'       => $order['payment_method_title'] ?? null,
+            'shipping_total'       => $order['shipping_total'] ?? null,
+            'line_items'           => array_map(fn($item) => [
+                'name'     => $item['name'] ?? '',
+                'sku'      => $item['sku'] ?? '',
+                'quantity' => $item['quantity'] ?? 0,
+                'total'    => $item['total'] ?? '0',
+            ], $order['line_items'] ?? []),
+            'shipping_lines'       => array_map(fn($sl) => [
+                'method_title' => $sl['method_title'] ?? '',
+                'total'        => $sl['total'] ?? '0',
+            ], $order['shipping_lines'] ?? []),
+            'customer_note'        => $order['customer_note'] ?? '',
+        ];
+    }
+
+    private static function formatProduct(array $product): array
+    {
+        return [
+            'id'                => $product['id'] ?? null,
+            'name'              => $product['name'] ?? '',
+            'sku'               => $product['sku'] ?? '',
+            'slug'              => $product['slug'] ?? '',
+            'status'            => $product['status'] ?? '',
+            'price'             => $product['price'] ?? '',
+            'regular_price'     => $product['regular_price'] ?? '',
+            'sale_price'        => $product['sale_price'] ?? '',
+            'stock_status'      => $product['stock_status'] ?? '',
+            'stock_quantity'    => $product['stock_quantity'] ?? null,
+            'short_description' => strip_tags((string)($product['short_description'] ?? '')),
+            'categories'        => array_map(fn($c) => $c['name'] ?? '', $product['categories'] ?? []),
+            'attributes'        => array_map(fn($a) => [
+                'name'    => $a['name'] ?? '',
+                'options' => $a['options'] ?? [],
+            ], $product['attributes'] ?? []),
+            'permalink'         => $product['permalink'] ?? '',
+        ];
+    }
+}

+ 17 - 0
test5a.json

@@ -0,0 +1,17 @@
+{
+  "event": "message_created",
+  "message_type": "incoming",
+  "content": "Jaki jest status mojego zamowienia 12345?",
+  "id": 9999,
+  "conversation": {
+    "id": 1310,
+    "labels": [],
+    "inbox_id": 1,
+    "additional_attributes": { "mail_subject": "Pytanie o zamowienie" }
+  },
+  "sender": {
+    "id": 332,
+    "name": "Jan Kowalski",
+    "email": "jan@example.com"
+  }
+}