PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.13
MxChat – AI Chatbot & Content Generation for WordPress v3.2.13
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / includes / class-mxchat-integrator.php

class-mxchat-integrator.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.13, at includes/class-mxchat-integrator.php

12,612 lines 535.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 class MxChat_Integrator {
7 private $options;
8 private $prompts_options;
9 private $chat_count;
10 private $fallbackResponse;
11 private $productCardHtml;
12 // plan-mxchat-20260617-48a57a — function-calling UI payload capture. When a
13 // model-invoked tool yields a UI element (generated image, woo product card,
14 // image-search gallery), the FC loop stashes its html here so the FC outcome
15 // handler can SURFACE it to the frontend the same way the intent path does,
16 // instead of stripping it to text for the model (the bug: UI-bearing actions
17 // rendered nothing under function calling).
18 private $fc_ui_html = '';
19 private $fc_ui_images = array();
20 private $fc_ui_captured = false;
21 private $word_handler;
22 private $last_similarity_analysis = null;
23 private $current_valid_urls = [];
24 private $last_vectorstore_error = null;
25 private $is_streaming = false; // ADDED: Track if current request is streaming
26 private $streaming_headers_sent = false; // Track if streaming headers have been sent
27 private $pending_originating_page = null; // Originating page captured at session start, consumed on row insert
28 private $current_action_instruction = null; // Success-message instruction injected into the next system context
29 private $last_action_analysis = null; // Last action-match analysis for testing_data payloads
30
31 /**
32 * Setup streaming headers - call this right before actually streaming
33 * This delays header setup to allow actions/forms to return JSON responses
34 */
35 /**
36 * Auto-retry wrapper around wp_remote_post for chat-send provider calls.
37 *
38 * Retries up to twice (750ms then 2000ms backoff) when the upstream provider
39 * returns a TRANSIENT error: WP timeout, 429, 502, 503, 504, or a provider-
40 * specific "overloaded" / "rate limit" body string. Returns immediately on
41 * permanent errors (401/403/404/422) so misconfiguration surfaces fast.
42 *
43 * Drop-in replacement for wp_remote_post — returns the same shape
44 * (WP_Error or response array) so the caller's existing error-handling
45 * code path is unchanged.
46 *
47 * STREAMING PATH NOTE: this helper is ONLY for non-streaming chat-send
48 * paths (the *_response_openai / *_response_claude / etc functions).
49 * For the *_stream variants, the cURL initial-connect happens inside a
50 * read-chunks loop — retrying there safely (without re-emitting partial
51 * stream chunks to the client) is a separate problem. Streaming paths
52 * are NOT wrapped in this build; tracked as a follow-on.
53 *
54 * Honors the `mxchat_options['auto_retry_on_transient_error']` toggle
55 * (default true). When false, behavior is identical to plain wp_remote_post.
56 */
57 private function mxchat_provider_call_with_retry($url, $args, $provider_hint = '') {
58 $opts = is_array($this->options ?? null) ? $this->options : array();
59 $enabled = !isset($opts['auto_retry_on_transient_error']) ||
60 (string) $opts['auto_retry_on_transient_error'] !== '0';
61
62 if (!$enabled) {
63 return wp_remote_post($url, $args);
64 }
65
66 $backoffs = array(0, 750, 2000); // ms — first attempt 0, then retry waits
67 $last_response = null;
68
69 foreach ($backoffs as $i => $delay_ms) {
70 if ($delay_ms > 0) {
71 usleep($delay_ms * 1000);
72 }
73 $response = wp_remote_post($url, $args);
74 $last_response = $response;
75
76 if (!$this->mxchat_is_transient_provider_error($response, $provider_hint)) {
77 return $response;
78 }
79
80 if (defined('WP_DEBUG') && WP_DEBUG) {
81 $code_for_log = is_wp_error($response) ? 'wp_error:' . $response->get_error_code()
82 : (int) wp_remote_retrieve_response_code($response);
83 error_log(sprintf(
84 '[MxChat] Transient provider error (provider=%s, attempt=%d/3, status=%s). %s',
85 $provider_hint ?: 'unknown',
86 $i + 1,
87 $code_for_log,
88 ($i + 1) < count($backoffs) ? 'Retrying.' : 'Giving up.'
89 ));
90 }
91 }
92
93 return $last_response;
94 }
95
96 /**
97 * Returns true if a wp_remote_post response represents a TRANSIENT
98 * provider error worth retrying. Conservative — only retries on signals
99 * that are very likely to clear within a few seconds.
100 *
101 * Transient signals:
102 * - WP_Error with timeout / connection / dns / ssl
103 * - HTTP 429, 502, 503, 504
104 * - Provider-specific overload bodies (gemini "overloaded", openai
105 * "server_error", anthropic "overloaded_error", xai/grok "Rate limit")
106 *
107 * NOT transient (return false — fail-fast):
108 * - 200/2xx (success)
109 * - 401, 403, 404, 422 (auth / config errors — retrying wastes the
110 * budget; the user needs to fix something)
111 * - Any other 4xx (assume permanent unless explicitly listed above)
112 * - 5xx other than the four listed above (e.g. 500 generic server error
113 * is often a malformed request on our side, not a transient outage)
114 */
115 private function mxchat_is_transient_provider_error($response, $provider_hint = '') {
116 if (is_wp_error($response)) {
117 $code = $response->get_error_code();
118 return in_array($code, array('http_request_failed', 'connection_failed', 'connection_timeout'), true)
119 || stripos((string) $response->get_error_message(), 'timed out') !== false
120 || stripos((string) $response->get_error_message(), 'timeout') !== false;
121 }
122
123 $status = (int) wp_remote_retrieve_response_code($response);
124 if (in_array($status, array(429, 502, 503, 504), true)) {
125 return true;
126 }
127 if ($status >= 200 && $status < 300) {
128 return false;
129 }
130 // Permanent 4xx that should fail fast — even with no body.
131 if (in_array($status, array(401, 403, 404, 405, 422), true)) {
132 return false;
133 }
134
135 // Provider-specific body inspection for the cases where the upstream
136 // returns 200 with an error envelope (gemini does this for overload).
137 $body = (string) wp_remote_retrieve_body($response);
138 if ($body === '') {
139 return false;
140 }
141 $lower = strtolower($body);
142 $hint = strtolower((string) $provider_hint);
143
144 if ($hint === 'gemini' && (strpos($lower, 'overloaded') !== false
145 || strpos($lower, 'high demand') !== false
146 || strpos($lower, 'model is overloaded') !== false)) {
147 return true;
148 }
149 if ($hint === 'openai' && (strpos($lower, 'rate limit reached') !== false
150 || strpos($lower, '"type":"server_error"') !== false
151 || strpos($lower, '"code":"server_error"') !== false)) {
152 return true;
153 }
154 if ($hint === 'anthropic' && (strpos($lower, '"type":"overloaded_error"') !== false
155 || strpos($lower, 'overloaded_error') !== false)) {
156 return true;
157 }
158 if (($hint === 'xai' || $hint === 'grok') && strpos($lower, 'rate limit') !== false) {
159 return true;
160 }
161
162 return false;
163 }
164
165 /**
166 * Streaming-path classifier: same rules as mxchat_is_transient_provider_error
167 * but takes a raw (http_code, body, provider_hint, curl_errno) tuple as
168 * captured during a cURL streaming exec. cURL's WRITEFUNCTION/HEADERFUNCTION
169 * collect status separately from a plain wp_remote_post array shape, so the
170 * non-streaming helper above can't be called directly. This delegate keeps
171 * the classification rules identical across both paths.
172 */
173 private function mxchat_is_transient_provider_error_raw($http_code, $body, $provider_hint = '', $curl_errno = 0) {
174 if ($curl_errno) {
175 // cURL transport-level error (timeout, connection failure, DNS, etc.)
176 // Match the same WP_Error timeout/connection signals the array variant treats as transient.
177 return in_array($curl_errno, array(
178 CURLE_OPERATION_TIMEDOUT,
179 CURLE_COULDNT_CONNECT,
180 CURLE_COULDNT_RESOLVE_HOST,
181 CURLE_SSL_CONNECT_ERROR,
182 CURLE_GOT_NOTHING,
183 CURLE_SEND_ERROR,
184 CURLE_RECV_ERROR,
185 ), true);
186 }
187
188 $status = (int) $http_code;
189 if (in_array($status, array(429, 502, 503, 504), true)) {
190 return true;
191 }
192 if ($status >= 200 && $status < 300) {
193 return false;
194 }
195 if (in_array($status, array(401, 403, 404, 405, 422), true)) {
196 return false;
197 }
198
199 $body = (string) $body;
200 if ($body === '') {
201 return false;
202 }
203 $lower = strtolower($body);
204 $hint = strtolower((string) $provider_hint);
205
206 if ($hint === 'gemini' && (strpos($lower, 'overloaded') !== false
207 || strpos($lower, 'high demand') !== false
208 || strpos($lower, 'model is overloaded') !== false)) {
209 return true;
210 }
211 if ($hint === 'openai' && (strpos($lower, 'rate limit reached') !== false
212 || strpos($lower, '"type":"server_error"') !== false
213 || strpos($lower, '"code":"server_error"') !== false)) {
214 return true;
215 }
216 if ($hint === 'anthropic' && (strpos($lower, '"type":"overloaded_error"') !== false
217 || strpos($lower, 'overloaded_error') !== false)) {
218 return true;
219 }
220 if (($hint === 'xai' || $hint === 'grok') && strpos($lower, 'rate limit') !== false) {
221 return true;
222 }
223
224 return false;
225 }
226
227 /**
228 * Whether transient-error auto-retry is enabled in admin settings.
229 * Default true unless explicitly set to '0'. Used by both wp_remote_post
230 * (mxchat_provider_call_with_retry) and cURL streaming paths.
231 */
232 private function mxchat_retry_enabled() {
233 $opts = is_array($this->options ?? null) ? $this->options : array();
234 return !isset($opts['auto_retry_on_transient_error']) ||
235 (string) $opts['auto_retry_on_transient_error'] !== '0';
236 }
237
238 private function setup_streaming_headers() {
239 if ($this->streaming_headers_sent || headers_sent()) {
240 return false;
241 }
242
243 // Disable output buffering
244 while (ob_get_level()) {
245 ob_end_flush();
246 }
247
248 // Set headers for SSE
249 header('Content-Type: text/event-stream');
250 header('Cache-Control: no-cache');
251 header('Connection: keep-alive');
252 header('X-Accel-Buffering: no');
253
254 ob_implicit_flush(true);
255 flush();
256
257 $this->streaming_headers_sent = true;
258 return true;
259 }
260
261 /**
262 * Class constructor
263 */
264 public function __construct() {
265 $this->options = get_option('mxchat_options');
266 $this->prompts_options = get_option('mxchat_prompts_options', array());
267 $this->chat_count = get_option('mxchat_chat_count', 0);
268 $this->word_handler = new MXChat_Word_Handler($this->options);
269
270 // Add all action hooks
271 add_action('wp_enqueue_scripts', array($this, 'mxchat_enqueue_scripts_styles'));
272 add_action('wp_ajax_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
273 add_action('wp_ajax_nopriv_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
274 add_action('wp_ajax_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
275 add_action('wp_ajax_nopriv_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
276
277 // Add the AJAX actions for checking if the pre-chat message was dismissed
278 add_action('wp_ajax_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
279 add_action('wp_ajax_nopriv_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
280 add_action('wp_ajax_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
281 add_action('wp_ajax_nopriv_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
282 add_action('wp_ajax_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
283 add_action('wp_ajax_nopriv_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
284
285 // Add REST API routes registration
286 add_action('rest_api_init', array($this, 'register_routes'));
287 add_action('wp_ajax_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
288 add_action('wp_ajax_nopriv_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
289
290 // Rate limit action - notice we removed the old schedule setup
291 add_action('mxchat_reset_rate_limits', array($this, 'mxchat_reset_rate_limits'));
292
293 // File upload and handling actions
294 add_action('wp_ajax_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
295 add_action('wp_ajax_nopriv_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
296 add_action('wp_ajax_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
297 add_action('wp_ajax_nopriv_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
298
299 // Word document handling actions
300 add_action('wp_ajax_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
301 add_action('wp_ajax_nopriv_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
302 add_action('wp_ajax_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
303 add_action('wp_ajax_nopriv_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
304 add_action('wp_ajax_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
305 add_action('wp_ajax_nopriv_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
306
307 // Email handling actions
308 add_action('wp_ajax_nopriv_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
309 add_action('wp_ajax_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
310 add_action('wp_ajax_nopriv_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
311 add_action('wp_ajax_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
312
313 add_action('wp_ajax_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
314 add_action('wp_ajax_nopriv_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
315
316 // Testing panel AJAX actions
317 add_action('wp_ajax_mxchat_get_system_info', array($this, 'mxchat_get_system_info'));
318 add_action('wp_ajax_mxchat_get_similarity_threshold', array($this, 'mxchat_get_similarity_threshold'));
319 add_action('wp_ajax_mxchat_get_kb_status', array($this, 'mxchat_get_kb_status'));
320 add_action('wp_ajax_mxchat_start_fresh_session', array($this, 'mxchat_start_fresh_session'));
321 // Add to your existing constructor, in the section with other AJAX actions:
322 add_action('wp_ajax_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
323 add_action('wp_ajax_nopriv_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
324 add_action('wp_ajax_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
325 add_action('wp_ajax_nopriv_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
326 // Add chat mode checking actions
327 add_action('wp_ajax_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
328 add_action('wp_ajax_nopriv_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
329
330 // Nonce refresh for page-cache compatibility (WP Rocket, LiteSpeed, etc.)
331 add_action('wp_ajax_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
332 add_action('wp_ajax_nopriv_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
333
334 // Auto-email transcript action
335 add_action('mxchat_send_delayed_transcript', array($this, 'mxchat_send_delayed_transcript'), 10, 1);
336
337 add_filter('mxchat_check_actions_only', array($this, 'check_actions_for_addons'), 10, 4);
338
339
340 }
341
342 /**
343 * Return a fresh nonce so cached pages can replace the stale one.
344 * With `with_settings`, also returns the current behavior-gate settings so
345 * the widget can correct stale inline-localized values (plan-32db95).
346 */
347 public function mxchat_refresh_nonce() {
348 nocache_headers();
349 $payload = array('nonce' => wp_create_nonce('mxchat_chat_nonce'));
350 if (!empty($_REQUEST['with_settings'])) {
351 $payload['settings'] = $this->get_dynamic_widget_settings(true);
352 }
353 wp_send_json_success($payload);
354 }
355
356 /**
357 * Behavior-gate settings the widget may re-fetch at runtime (plan-32db95).
358 *
359 * Every widget setting ships inline in page HTML via wp_localize_script, so
360 * full-page caches (host caches, WP Rocket, LiteSpeed, W3TC, FlyingPress,
361 * WP Super Cache, Cloudflare APO, the browser itself) keep serving a stale
362 * snapshot after an admin changes a setting. MxChat_Cache_Purge clears the
363 * caches PHP can reach; this payload covers the rest — the widget requests
364 * it on first open (via the nonce-refresh endpoints) and merges it over
365 * `mxchatChat`, the same distrust-cached-HTML pattern the 3.2.7 per-request
366 * nonce uses.
367 *
368 * Behavior gates + labels ONLY — colors stay inline because they're also
369 * server-inline-styled, and a runtime swap would visibly flash.
370 *
371 * Both wp_localize_script blocks merge this exact array, so the inline and
372 * refreshed payloads cannot drift.
373 *
374 * @param bool $fresh Re-read mxchat_options from the DB (endpoint paths)
375 * instead of trusting the instance copy.
376 * @return array
377 */
378 public function get_dynamic_widget_settings($fresh = false) {
379 $options = $fresh ? get_option('mxchat_options', array()) : $this->options;
380 if (!is_array($options)) {
381 $options = array();
382 }
383 return array(
384 'model' => isset($options['model']) ? $options['model'] : 'gpt-5.1-chat-latest',
385 'enable_streaming_toggle' => isset($options['enable_streaming_toggle']) ? $options['enable_streaming_toggle'] : 'on',
386 'rate_limit_message' => $options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
387 'chat_toolbar_toggle' => $options['chat_toolbar_toggle'] ?? 'off',
388 'print_button_enabled' => $options['print_button_enabled'] ?? 'on',
389 'print_button_label' => esc_html__('Download Transcript', 'mxchat'),
390 // "Start new chat" header-menu item (plan ac2e81). Default OFF.
391 'reset_chat_enabled' => $options['reset_chat_enabled'] ?? 'off',
392 'reset_chat_label' => !empty($options['reset_chat_label']) ? esc_html($options['reset_chat_label']) : esc_html__('Start new chat', 'mxchat'),
393 'reset_chat_confirm' => esc_html__('Start a new chat? This clears the current conversation.', 'mxchat'),
394 'stop_button_label' => esc_html__('Stop response', 'mxchat'),
395 'print_header_title' => esc_html(get_bloginfo('name')) . ' — ' . esc_html__('Chat transcript', 'mxchat'),
396 // Emit 'on'/'off' STRINGS, never booleans: wp_localize_script casts
397 // scalars to string, and (string) false === '' — which the widget's
398 // old gate read as enabled (plan-4bba64). The filter keeps its
399 // boolean contract; only the emitted value is stringified.
400 'satisfaction_rating_enabled' => apply_filters(
401 'mxchat_satisfaction_rating_enabled',
402 ($options['satisfaction_rating_enabled'] ?? 'off') === 'on'
403 ) ? 'on' : 'off',
404 'satisfaction_rating_idle_seconds' => max(5, min(600, intval($options['satisfaction_rating_idle_seconds'] ?? 60))),
405 'satisfaction_rating_copy' => array(
406 'question' => !empty($options['satisfaction_rating_question']) ? esc_html($options['satisfaction_rating_question']) : esc_html__('Was this helpful?', 'mxchat'),
407 'helpful' => esc_html__('Helpful', 'mxchat'),
408 'not_helpful' => esc_html__('Not helpful', 'mxchat'),
409 'dismiss' => esc_html__('Dismiss', 'mxchat'),
410 'thanks' => !empty($options['satisfaction_rating_thanks']) ? esc_html($options['satisfaction_rating_thanks']) : esc_html__('Thanks! Anything we should improve? (optional)', 'mxchat'),
411 'placeholder' => !empty($options['satisfaction_rating_placeholder']) ? esc_html($options['satisfaction_rating_placeholder']) : esc_html__('Tell us what could be better…', 'mxchat'),
412 'send' => esc_html__('Send', 'mxchat'),
413 'skip' => esc_html__('Skip', 'mxchat'),
414 'saved' => !empty($options['satisfaction_rating_saved']) ? esc_html($options['satisfaction_rating_saved']) : esc_html__('Thanks for the feedback.', 'mxchat'),
415 ),
416 );
417 }
418
419 // In your core plugin's check_actions_for_addons method:
420 public function check_actions_for_addons($default, $message, $user_id, $session_id) {
421 //error_log('MxChat Core: check_actions_for_addons called with message: ' . $message);
422
423 $result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
424
425 //error_log('MxChat Core: Intent check result = ' . ($result === false ? 'false' : 'true'));
426
427 return $result;
428 }
429
430 private function mxchat_increment_chat_count() {
431 $chat_count = get_option('mxchat_chat_count', 0);
432 $chat_count++;
433 update_option('mxchat_chat_count', $chat_count);
434 }
435
436 function mxchat_fetch_conversation_history() {
437 if (empty($_POST['session_id'])) {
438 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
439 wp_die();
440 }
441
442 $session_id = sanitize_text_field($_POST['session_id']);
443
444 // SECURITY FIX: Verify session ownership before retrieving data
445 // If IP/user changed, signal frontend to reset session instead of blocking
446 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
447
448 // Check if this session has an owner recorded
449 $session_owner = get_option("mxchat_session_owner_{$session_id}");
450
451 // Update session owner if it changed (e.g. IP changed due to network switch)
452 // The session ID itself is the authentication — if the client has it, they own it
453 if (!$session_owner || $session_owner !== $current_user_identifier) {
454 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
455 }
456
457 $history = get_option("mxchat_history_{$session_id}", []); // Retrieve stored history
458 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai'); // Get current chat mode
459
460 if (empty($history)) {
461 // Even if history is empty, return the chat mode
462 wp_send_json_success([
463 'conversation' => [],
464 'chat_mode' => $chat_mode
465 ]);
466 wp_die();
467 }
468
469 wp_send_json_success([
470 'conversation' => $history,
471 'chat_mode' => $chat_mode
472 ]);
473 wp_die();
474 }
475 private function mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp = 0) {
476 $history = get_option("mxchat_history_{$session_id}", []);
477
478 // Check persistence setting - when OFF, only include messages from current page load
479 $options = get_option('mxchat_options', []);
480 $persistence_enabled = isset($options['chat_persistence_toggle']) && $options['chat_persistence_toggle'] === 'on';
481
482 // Filter history when persistence is OFF to match what the user sees
483 if (!$persistence_enabled && $session_start_timestamp > 0) {
484 $history = array_filter($history, function($entry) use ($session_start_timestamp) {
485 // Include messages from this page load onwards
486 return isset($entry['timestamp']) && $entry['timestamp'] >= $session_start_timestamp;
487 });
488 // Re-index array after filtering
489 $history = array_values($history);
490 }
491
492 $formatted_history = [];
493
494 // Adjusted for code-heavy conversations
495 $max_tokens = 120000; // Context window size
496 $reserved_tokens = 5000; // Space for system prompts + current query
497 $current_token_count = 0;
498
499 // Allowed HTML tags for content sanitization
500 $allowed_tags = [
501 'pre' => ['class' => true],
502 'code' => ['class' => true],
503 'span' => ['class' => true],
504 'div' => ['class' => true],
505 'strong' => [],
506 'em' => []
507 ];
508
509 foreach (array_reverse($history) as $entry) {
510 // Preserve code blocks while sanitizing other HTML
511 $clean_content = wp_kses($entry['content'], $allowed_tags);
512
513 // Detect code blocks in content
514 $has_code = false;
515 // Replace the HTML check with:
516 // Allow messages that contain code blocks or are plain text
517 if (strpos($clean_content, '<pre') === false &&
518 strpos($clean_content, '<code') === false &&
519 $clean_content !== strip_tags($entry['content'])) {
520 continue;
521 }
522
523 // Skip entries that lost significant content during sanitization
524 if (!$has_code && $clean_content !== strip_tags($entry['content'])) {
525 continue;
526 }
527
528 // More accurate token estimation (1 token ≈ 4 characters)
529 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
530
531 // Check token budget with the new estimate
532 if (($current_token_count + $token_estimate + $reserved_tokens) > $max_tokens) {
533 // Try to fit partial content if it's the first entry
534 if (empty($formatted_history)) {
535 $clean_content = mb_substr($clean_content, 0, ($max_tokens - $reserved_tokens) * 4);
536 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
537 } else {
538 break;
539 }
540 }
541
542 // Add to formatted history
543 $formatted_history[] = [
544 'role' => $entry['role'],
545 'content' => $clean_content
546 ];
547
548 $current_token_count += $token_estimate;
549 }
550
551 // Reverse back to maintain chronological order
552 $formatted_history = array_reverse($formatted_history);
553
554 // Add system message about code context
555 array_unshift($formatted_history, [
556 'role' => 'system',
557 'content' => 'Preserved code blocks are marked with [CODE BLOCK PRESERVED]. '
558 . 'Maintain formatting and syntax highlighting when referencing code.'
559 ]);
560
561 return $formatted_history;
562 }
563
564 public function register_routes() {
565 //error_log(esc_html__('Registering MxChat REST routes', 'mxchat'));
566
567 // Per-request chat-send nonce endpoint — issues a fresh nonce on demand
568 // so the chat widget never depends on a stale nonce embedded in cached HTML.
569 // Public (no auth), rate-limited (1 call / IP / second via a transient).
570 register_rest_route('mxchat/v1', '/nonce', [
571 'methods' => 'GET',
572 'callback' => [$this, 'mxchat_issue_chat_send_nonce'],
573 'permission_callback' => '__return_true',
574 ]);
575
576 register_rest_route('mxchat/v1', '/stream', [
577 'methods' => 'GET',
578 'callback' => [$this, 'mxchat_stream_events'],
579 'permission_callback' => [$this, 'verify_chat_session'],
580 ]);
581
582 register_rest_route('mxchat/v1', '/agent-response', [
583 'methods' => 'POST',
584 'callback' => [$this, 'mxchat_handle_agent_response'],
585 'permission_callback' => [$this, 'verify_slack_request'],
586 ]);
587
588 register_rest_route('mxchat/v1', '/slack-interaction', [
589 'methods' => 'POST',
590 'callback' => [$this, 'handle_slack_interaction'],
591 'permission_callback' => [$this, 'verify_slack_request'],
592 ]);
593
594 register_rest_route('mxchat/v1', '/slack-messages', [
595 'methods' => 'POST',
596 'callback' => [$this, 'handle_slack_messages'],
597 'permission_callback' => [$this, 'verify_slack_request'],
598 ]);
599
600 // Telegram webhook endpoint
601 register_rest_route('mxchat/v1', '/telegram-webhook', [
602 'methods' => 'POST',
603 'callback' => [$this, 'handle_telegram_webhook'],
604 'permission_callback' => [$this, 'verify_telegram_request'],
605 ]);
606
607 //error_log(esc_html__('MxChat REST routes registered', 'mxchat'));
608 }
609
610 /**
611 * Issue a fresh per-request nonce for chat-send. Returned to the widget which
612 * caches it for the session and includes it on every chat-send / stream-send /
613 * upload call. By moving the nonce out of inline `window.mxchatChat = {...}` HTML
614 * we eliminate the entire class of "first-message Access denied" failures that
615 * plague WP installs behind a full-page cache (WP Rocket, LiteSpeed, FlyingPress,
616 * W3 Total Cache, Cloudflare APO) — the nonce is never cached because it never
617 * lives in the HTML body.
618 *
619 * Public endpoint. Rate-limited to 1 call / IP / 1s via a transient so a single
620 * client browser can't be used to flood the nonce-issuance path.
621 *
622 * Nonce action: `mxchat_chat_send` (new). The chat-send AJAX handlers accept
623 * BOTH this action AND the legacy `mxchat_chat_nonce` action for a 30-day
624 * backwards-compat window so cached pages still in users' browsers don't break
625 * mid-session.
626 *
627 * @since 3.2.7
628 */
629 public function mxchat_issue_chat_send_nonce(WP_REST_Request $request) {
630 $ip = '';
631 if (!empty($_SERVER['REMOTE_ADDR'])) {
632 $ip = preg_replace('#[^0-9a-fA-F:\.]#', '', wp_unslash((string) $_SERVER['REMOTE_ADDR']));
633 }
634 if ($ip !== '') {
635 // Best-effort rate limit. WP transients with sub-second TTL are racy
636 // (parallel bursts can squeak through before set_transient completes);
637 // we use 2s to make the gate slightly more reliable. Real production
638 // rate-limiting at sub-second granularity needs Redis or DB row locks
639 // — out of scope for this endpoint, which is already cheap.
640 $key = 'mxchat_nonce_rl_' . md5($ip);
641 if (get_transient($key)) {
642 return new WP_REST_Response(array(
643 'error' => 'rate_limited',
644 'message' => __('Too many nonce requests. Try again shortly.', 'mxchat'),
645 ), 429);
646 }
647 set_transient($key, 1, 2);
648 }
649
650 // The widget calls this endpoint without an X-WP-Nonce header, so WordPress does not
651 // honor the auth cookie and the request runs as uid=0 even for logged-in users. That
652 // makes wp_create_nonce() bind the nonce to uid=0, which then fails wp_verify_nonce()
653 // at admin-ajax (which runs as the real uid) -> logged-in users get a 403 on upload.
654 // Resolve the real user from the logged_in cookie so the nonce binds to the correct uid.
655 if ( ! is_user_logged_in() ) {
656 $maybe_uid = wp_validate_auth_cookie( '', 'logged_in' );
657 if ( $maybe_uid ) {
658 wp_set_current_user( $maybe_uid );
659 }
660 }
661
662 $payload = array(
663 'nonce' => wp_create_nonce('mxchat_chat_send'),
664 'expires_in' => 86400, // WP nonces live 24h; widget caches for 12h conservatively.
665 );
666
667 // plan-32db95: the widget's first-open refresh asks for current behavior
668 // settings in the same round-trip, so stale inline-localized values on
669 // cached pages get corrected without a second request. All values in
670 // this payload already ship in public page HTML — nothing sensitive.
671 if ($request->get_param('with_settings')) {
672 $payload['settings'] = $this->get_dynamic_widget_settings(true);
673 }
674
675 return new WP_REST_Response($payload, 200);
676 }
677
678 /**
679 * Verify a chat-send nonce. Accepts BOTH the new `mxchat_chat_send` action
680 * (issued by /wp-json/mxchat/v1/nonce) AND the legacy `mxchat_chat_nonce`
681 * action (inline-localized in older cached HTML). The legacy acceptance is
682 * a 30-day backwards-compat window — to be removed in a follow-up release
683 * after 2026-06-27.
684 *
685 * @param string $posted_nonce
686 * @return bool
687 */
688 public static function mxchat_verify_chat_send_nonce($posted_nonce) {
689 if (!is_string($posted_nonce) || $posted_nonce === '') {
690 return false;
691 }
692 return (bool) wp_verify_nonce($posted_nonce, 'mxchat_chat_send')
693 || (bool) wp_verify_nonce($posted_nonce, 'mxchat_chat_nonce');
694 }
695
696 /**
697 * Verify valid chat session
698 */
699 public function verify_chat_session($request) {
700 $session_id = $request->get_param('session_id');
701 if (empty($session_id)) {
702 //error_log(esc_html__('Empty session ID in chat request', 'mxchat'));
703 return false;
704 }
705
706 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
707 return $chat_mode === 'agent';
708 }
709
710 /**
711 * Verify request is coming from Slack.
712 *
713 * @param WP_REST_Request $request
714 * @return bool True if valid, false otherwise.
715 */
716 public function verify_slack_request($request) {
717 // Get the Slack signing secret from your plugin options
718 $valid_key = $this->options['live_agent_secret_key'] ?? '';
719
720 if (empty($valid_key)) {
721 //error_log(esc_html__('Slack signing secret not configured', 'mxchat'));
722 return false;
723 }
724
725 $timestamp = $request->get_header('X-Slack-Request-Timestamp');
726 $slack_signature = $request->get_header('X-Slack-Signature');
727
728 // Verify timestamp to prevent replay attacks
729 if (abs(time() - intval($timestamp)) > 300) {
730 //error_log(esc_html__('Slack request timestamp too old', 'mxchat'));
731 return false;
732 }
733
734 // Get raw request body from the WP_REST_Request object
735 // (php://input may already be consumed by WordPress at this point)
736 $request_body = $request->get_body();
737
738 // Create the signature base string
739 $sig_basestring = "v0:{$timestamp}:{$request_body}";
740
741 // Calculate expected signature
742 $my_signature = 'v0=' . hash_hmac('sha256', $sig_basestring, $valid_key);
743
744 // Compare signatures
745 return hash_equals($my_signature, $slack_signature);
746 }
747
748 /**
749 * Verify request is coming from Telegram.
750 *
751 * @param WP_REST_Request $request
752 * @return bool True if valid, false otherwise.
753 */
754 public function verify_telegram_request($request) {
755 $secret_token = $this->options['telegram_webhook_secret'] ?? '';
756
757 //error_log('[MxChat Telegram DEBUG] verify_telegram_request called');
758 //error_log('[MxChat Telegram DEBUG] Stored secret: ' . (empty($secret_token) ? 'EMPTY' : substr($secret_token, 0, 10) . '...'));
759
760 if (empty($secret_token)) {
761 // If no secret is configured, allow the request (for initial setup)
762 //error_log('[MxChat Telegram DEBUG] No secret configured, allowing request');
763 return true;
764 }
765
766 // Telegram sends the secret token in the X-Telegram-Bot-Api-Secret-Token header
767 $request_token = $request->get_header('X-Telegram-Bot-Api-Secret-Token');
768
769 //error_log('[MxChat Telegram DEBUG] Request token: ' . (empty($request_token) ? 'EMPTY' : substr($request_token, 0, 10) . '...'));
770
771 if (empty($request_token)) {
772 //error_log('[MxChat Telegram DEBUG] Request rejected: No token in header');
773 return false;
774 }
775
776 // Timing-safe comparison
777 $result = hash_equals($secret_token, $request_token);
778 //error_log('[MxChat Telegram DEBUG] Token comparison result: ' . ($result ? 'MATCH' : 'MISMATCH'));
779 return $result;
780 }
781
782 public function mxchat_stream_events(WP_REST_Request $request) {
783 header('Content-Type: text/event-stream');
784 header('Cache-Control: no-cache');
785 header('Connection: keep-alive');
786
787 $session_id = sanitize_text_field($request->get_param('session_id'));
788 $last_seen_id = sanitize_text_field($request->get_param('last_seen_id')) ?: '';
789
790 if (empty($session_id)) {
791 echo esc_html__("event: error\ndata: ", 'mxchat') . esc_html__('Missing session_id', 'mxchat') . "\n\n";
792 flush();
793 exit;
794 }
795
796 $history = get_option("mxchat_history_{$session_id}", []);
797
798 // Filter only new messages
799 $new_messages = array_filter($history, function ($message) use ($last_seen_id) {
800 return !empty($message['id']) && $message['id'] > $last_seen_id;
801 });
802
803 // Send new messages if available
804 if (!empty($new_messages)) {
805 echo esc_html__("event: newMessages\ndata: ", 'mxchat') . json_encode(array_values($new_messages)) . "\n\n";
806 } else {
807 // Keep the connection alive
808 echo esc_html__("event: keepAlive\ndata: ", 'mxchat') . "{}\n\n";
809 }
810 flush();
811 exit;
812 }
813
814
815
816
817 private function mxchat_save_chat_message($session_id, $role, $message, $originating_page = null, $rag_context = null) {
818 global $wpdb;
819 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
820 //error_log("[DEBUG] mxchat_save_chat_message -> START for session_id: {$session_id}, role: {$role}");
821
822 // Check if this is the first message in a new session (before any other database operations)
823 $is_new_session = false;
824 if ($role === 'user') { // Only check for user messages, not bot responses
825 $existing_messages = $wpdb->get_var($wpdb->prepare(
826 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
827 $session_id
828 ));
829 $is_new_session = ($existing_messages == 0);
830
831 // Log for debugging
832 if ($is_new_session) {
833 //error_log("[DEBUG] This is a NEW session - first message");
834 }
835 }
836
837 // SECURITY FIX: Set session ownership for new sessions
838 if ($is_new_session && $role === 'user') {
839 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
840 $session_owner_key = "mxchat_session_owner_{$session_id}";
841
842 // Only set ownership if not already set
843 if (!get_option($session_owner_key)) {
844 update_option($session_owner_key, $current_user_identifier, 'no');
845 //error_log("[DEBUG] Set session ownership for {$session_id} to {$current_user_identifier}");
846 }
847 }
848
849 // 1) Extract agent name if present
850 $agent_name = '';
851 if (preg_match('/^Agent: (.*?) - /', $message, $matches)) {
852 $agent_name = $matches[1];
853 $message = str_replace("Agent: $agent_name - ", '', $message);
854 $session_meta_key = "mxchat_agent_name_{$session_id}";
855 if (empty(get_option($session_meta_key))) {
856 update_option($session_meta_key, $agent_name);
857 //error_log("[DEBUG] mxchat_save_chat_message -> Stored agent_name in option: {$session_meta_key} => {$agent_name}");
858 }
859 }
860
861 // 2) Generate unique message_id
862 $message_id = uniqid();
863 //error_log("[DEBUG] mxchat_save_chat_message -> Generated message_id: {$message_id}");
864
865 // 3) Determine user_id
866 $user_id = is_user_logged_in() ? get_current_user_id() : 0;
867
868 // 4) Determine user_identifier
869 $user_identifier = $agent_name
870 ? $agent_name
871 : MxChat_User::mxchat_get_user_identifier();
872
873 // 5) Determine displayed_name
874 $user_email = MxChat_User::mxchat_get_user_email();
875 $displayed_name = $agent_name ? $agent_name : ($user_email ?: $user_identifier);
876
877 // 6) Check for a saved email in wp_options
878 $email_option_key = "mxchat_email_{$session_id}";
879 $saved_email = get_option($email_option_key);
880 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for email_option_key: {$email_option_key}, found: {$saved_email}");
881
882 // Check for a saved name in wp_options
883 $name_option_key = "mxchat_name_{$session_id}";
884 $saved_name = get_option($name_option_key);
885 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for name_option_key: {$name_option_key}, found: {$saved_name}");
886
887 // If found, update DB user_email and user_name
888 if ($saved_email || $saved_name) {
889 $update_data = [];
890 if ($saved_email) {
891 $update_data['user_email'] = $saved_email;
892 }
893 if ($saved_name) {
894 $update_data['user_name'] = $saved_name;
895 }
896
897 if (!empty($update_data)) {
898 $update_res = $wpdb->update(
899 $table_name,
900 $update_data,
901 ['session_id' => $session_id],
902 array_fill(0, count($update_data), '%s'),
903 ['%s']
904 );
905 //error_log("[DEBUG] mxchat_save_chat_message -> Attempted DB user_email/user_name update for session_id {$session_id}. update_res: {$update_res}");
906 }
907 }
908
909 // 7) Save to session history in wp_options
910 $history_key = "mxchat_history_{$session_id}";
911 $history = get_option($history_key, []);
912 $history[] = [
913 'id' => $message_id,
914 'role' => $role,
915 'content' => $message,
916 'timestamp' => round(microtime(true) * 1000),
917 'agent_name' => $displayed_name,
918 ];
919 update_option($history_key, $history, 'no');
920 //error_log("[DEBUG] mxchat_save_chat_message -> Updated session history in option: {$history_key}");
921
922 // 8) Save the message to DB (INSERT)
923 $insert_data = [
924 'user_id' => $user_id,
925 'user_identifier'=> $user_identifier,
926 'user_email' => $saved_email ?: $user_email,
927 'user_name' => $saved_name ?: '', // Add name to insert data
928 'session_id' => $session_id,
929 'role' => $role,
930 'message' => $message,
931 'timestamp' => current_time('mysql', 1),
932 ];
933
934 // IMPROVED: Handle originating page data
935 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
936
937 if ($columns_exist) {
938 if ($is_new_session && $role === 'user') {
939 // For the first user message, set originating page data
940
941 // First check if we have it from the parameter
942 if ($originating_page && !empty($originating_page['url'])) {
943 $insert_data['originating_page_url'] = $originating_page['url'];
944 $insert_data['originating_page_title'] = $originating_page['title'] ?? '';
945
946 //error_log("[DEBUG] Setting originating page from parameter: " . $originating_page['url']);
947 }
948 // Otherwise check if it's stored in the instance property
949 else if (isset($this->pending_originating_page) && !empty($this->pending_originating_page['url'])) {
950 $insert_data['originating_page_url'] = $this->pending_originating_page['url'];
951 $insert_data['originating_page_title'] = $this->pending_originating_page['title'] ?? '';
952
953 //error_log("[DEBUG] Setting originating page from pending_originating_page: " . $this->pending_originating_page['url']);
954
955 // Clear after using (= null, not unset(): unset() undeclares the property
956 // and the next assignment recreates it dynamic, re-triggering the PHP 8.2 deprecation)
957 $this->pending_originating_page = null;
958 }
959 // Fallback to HTTP_REFERER if nothing else is available
960 else if (isset($_SERVER['HTTP_REFERER'])) {
961 $referer_url = esc_url_raw($_SERVER['HTTP_REFERER']);
962 $insert_data['originating_page_url'] = $referer_url;
963
964 // Generate title from URL
965 $parsed_url = parse_url($referer_url);
966 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
967
968 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
969 $insert_data['originating_page_title'] = 'Homepage';
970 } else {
971 $title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
972 $insert_data['originating_page_title'] = ucwords(trim($title));
973 }
974
975 //error_log("[DEBUG] Setting originating page from HTTP_REFERER: " . $referer_url);
976 }
977
978 // Store for this session so all messages have the same originating page
979 if (!empty($insert_data['originating_page_url'])) {
980 update_option("mxchat_originating_page_{$session_id}", [
981 'url' => $insert_data['originating_page_url'],
982 'title' => $insert_data['originating_page_title']
983 ], 'no');
984 }
985 } else {
986 // For subsequent messages in the session, use the stored originating page
987 $stored_originating = get_option("mxchat_originating_page_{$session_id}");
988 if ($stored_originating && !empty($stored_originating['url'])) {
989 $insert_data['originating_page_url'] = $stored_originating['url'];
990 $insert_data['originating_page_title'] = $stored_originating['title'] ?? '';
991 }
992 }
993 }
994
995 // Add RAG context if provided (for bot messages)
996 if ($rag_context !== null && $role === 'bot') {
997 $rag_context_column_exists = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'rag_context'");
998 if ($rag_context_column_exists) {
999 $insert_data['rag_context'] = is_array($rag_context) ? wp_json_encode($rag_context) : $rag_context;
1000 }
1001 }
1002
1003 $wpdb->insert($table_name, $insert_data);
1004 //error_log("[DEBUG] mxchat_save_chat_message -> Inserted message into DB. row_id: {$wpdb->insert_id}, data: " . print_r($insert_data, true));
1005
1006 // 9) Send notification email if this is the first user message in a new session
1007 if ($wpdb->insert_id && $is_new_session && $role === 'user') {
1008 $this->send_new_chat_notification($session_id, array(
1009 'identifier' => $user_identifier,
1010 'email' => $saved_email ?: $user_email,
1011 'ip' => $_SERVER['REMOTE_ADDR']
1012 ));
1013 }
1014
1015 // 10) Schedule delayed transcript email if enabled and message is from user
1016 if ($wpdb->insert_id && $role === 'user') {
1017 $this->schedule_delayed_transcript_email($session_id);
1018 }
1019
1020 //error_log("[DEBUG] mxchat_save_chat_message -> END for session_id: {$session_id}");
1021 return $message_id;
1022 }
1023
1024 private function send_new_chat_notification($session_id, $user_info = array()) {
1025 $options = get_option('mxchat_transcripts_options');
1026
1027 // Check if notifications are enabled
1028 if (empty($options['mxchat_enable_notifications'])) {
1029 return false;
1030 }
1031
1032 // Get notification email
1033 $to = !empty($options['mxchat_notification_email']) ?
1034 $options['mxchat_notification_email'] :
1035 get_option('admin_email');
1036
1037 if (!is_email($to)) {
1038 return false;
1039 }
1040
1041 // Prepare email content
1042 $subject = sprintf('[%s] New Chat Session Started', get_bloginfo('name'));
1043
1044 $user_identifier = isset($user_info['identifier']) ? $user_info['identifier'] : 'Guest';
1045 $user_email = isset($user_info['email']) ? $user_info['email'] : 'Not provided';
1046 $user_ip = isset($user_info['ip']) ? $user_info['ip'] : $_SERVER['REMOTE_ADDR'];
1047
1048 $message = sprintf(
1049 "A new chat session has started on your website.\n\n" .
1050 "Session ID: %s\n" .
1051 "User: %s\n" .
1052 "Email: %s\n" .
1053 "IP Address: %s\n" .
1054 "Time: %s\n\n" .
1055 "View transcripts: %s",
1056 $session_id,
1057 $user_identifier,
1058 $user_email,
1059 $user_ip,
1060 current_time('mysql'),
1061 admin_url('admin.php?page=mxchat-transcripts')
1062 );
1063
1064 // Send email
1065 return wp_mail($to, $subject, $message);
1066 }
1067
1068 /**
1069 * Schedule delayed transcript email for a session
1070 * Reschedules if a new user message is received
1071 */
1072 private function schedule_delayed_transcript_email($session_id) {
1073 $options = get_option('mxchat_transcripts_options');
1074
1075 // Check if auto-email is enabled
1076 if (empty($options['mxchat_auto_email_transcript_enabled'])) {
1077 return;
1078 }
1079
1080 // Get notification email
1081 $email = !empty($options['mxchat_notification_email']) ?
1082 $options['mxchat_notification_email'] :
1083 get_option('admin_email');
1084
1085 if (!is_email($email)) {
1086 return;
1087 }
1088
1089 // Get delay in minutes (default 30)
1090 $delay_minutes = isset($options['mxchat_auto_email_transcript_delay']) ?
1091 intval($options['mxchat_auto_email_transcript_delay']) : 30;
1092
1093 // Clear any existing scheduled event for this session
1094 $hook = 'mxchat_send_delayed_transcript';
1095 $args = array($session_id);
1096 $timestamp = wp_next_scheduled($hook, $args);
1097
1098 if ($timestamp) {
1099 wp_unschedule_event($timestamp, $hook, $args);
1100 }
1101
1102 // Schedule new event
1103 $schedule_time = time() + ($delay_minutes * 60);
1104 wp_schedule_single_event($schedule_time, $hook, $args);
1105 }
1106
1107 /**
1108 * Check if chat messages contain contact information (email or phone number)
1109 *
1110 * @param array $messages Array of message objects with 'message' property
1111 * @param object|null $session_data Session data object with user_email property
1112 * @return bool True if contact info found, false otherwise
1113 */
1114 private function chat_contains_contact_info($messages, $session_data = null) {
1115 // Check if session already has a stored email
1116 if ($session_data && !empty($session_data->user_email)) {
1117 return true;
1118 }
1119
1120 // Email regex pattern
1121 $email_pattern = '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/';
1122
1123 // Phone number patterns (covers various formats including international, WhatsApp style)
1124 // Matches: +1234567890, (123) 456-7890, 123-456-7890, 123.456.7890, 1234567890, +1 234 567 8900, etc.
1125 $phone_pattern = '/(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{2,4}[-.\s]?\d{2,4}(?:[-.\s]?\d{1,4})?/';
1126
1127 // Only check user messages (not assistant responses)
1128 foreach ($messages as $msg) {
1129 if ($msg->role !== 'user') {
1130 continue;
1131 }
1132
1133 $message_text = $msg->message;
1134
1135 // Check for email
1136 if (preg_match($email_pattern, $message_text)) {
1137 return true;
1138 }
1139
1140 // Check for phone number (must be at least 7 digits total to avoid false positives)
1141 if (preg_match($phone_pattern, $message_text, $matches)) {
1142 // Count actual digits to avoid matching short numbers
1143 $digits_only = preg_replace('/\D/', '', $matches[0]);
1144 if (strlen($digits_only) >= 7) {
1145 return true;
1146 }
1147 }
1148 }
1149
1150 return false;
1151 }
1152
1153 /**
1154 * Send the delayed transcript email with .txt attachment
1155 */
1156 public function mxchat_send_delayed_transcript($session_id) {
1157 global $wpdb;
1158
1159 $options = get_option('mxchat_transcripts_options');
1160
1161 // Get notification email
1162 $to = !empty($options['mxchat_notification_email']) ?
1163 $options['mxchat_notification_email'] :
1164 get_option('admin_email');
1165
1166 if (!is_email($to)) {
1167 return false;
1168 }
1169
1170 // Get all messages for this session
1171 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1172 $messages = $wpdb->get_results($wpdb->prepare(
1173 "SELECT role, message, timestamp FROM {$table_name}
1174 WHERE session_id = %s
1175 ORDER BY timestamp ASC",
1176 $session_id
1177 ));
1178
1179 if (empty($messages)) {
1180 return false;
1181 }
1182
1183 // Get session metadata
1184 $sessions_table = $wpdb->prefix . 'mxchat_sessions';
1185 $session_data = $wpdb->get_row($wpdb->prepare(
1186 "SELECT * FROM {$sessions_table} WHERE session_id = %s",
1187 $session_id
1188 ));
1189
1190 // Check if contact info is required and if it's present
1191 $require_contact = !empty($options['mxchat_auto_email_transcript_require_contact']);
1192 if ($require_contact && !$this->chat_contains_contact_info($messages, $session_data)) {
1193 // Contact info required but not found - skip sending
1194 return false;
1195 }
1196
1197 // Build transcript content
1198 $transcript_content = "Chat Transcript\n";
1199 $transcript_content .= "================\n\n";
1200 $transcript_content .= "Session ID: " . $session_id . "\n";
1201
1202 if ($session_data) {
1203 $transcript_content .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
1204 $transcript_content .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
1205 $transcript_content .= "Started: " . $session_data->created_at . "\n";
1206 }
1207
1208 $transcript_content .= "\n" . str_repeat("=", 50) . "\n\n";
1209
1210 // Add messages
1211 foreach ($messages as $msg) {
1212 $role_label = ($msg->role === 'user') ? 'User' : 'Assistant';
1213 $transcript_content .= "[{$msg->timestamp}] {$role_label}:\n";
1214 $transcript_content .= $msg->message . "\n\n";
1215 }
1216
1217 // Create temporary file for attachment using WP_Filesystem
1218 $upload_dir = wp_upload_dir();
1219 $temp_file = $upload_dir['basedir'] . '/mxchat-transcript-' . $session_id . '.txt';
1220 global $wp_filesystem;
1221 if (empty($wp_filesystem)) {
1222 require_once ABSPATH . 'wp-admin/includes/file.php';
1223 WP_Filesystem();
1224 }
1225 $wp_filesystem->put_contents($temp_file, $transcript_content, FS_CHMOD_FILE);
1226
1227 // Prepare email
1228 $subject = sprintf('[%s] Chat Transcript - Session %s', get_bloginfo('name'), substr($session_id, 0, 8));
1229
1230 $message = "Please find attached the full chat transcript.\n\n";
1231 $message .= "Session ID: {$session_id}\n";
1232
1233 if ($session_data) {
1234 $message .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
1235 $message .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
1236 }
1237
1238 $message .= "\nView online: " . admin_url('admin.php?page=mxchat-transcripts');
1239
1240 // Send email with attachment
1241 $attachments = array($temp_file);
1242 $result = wp_mail($to, $subject, $message, '', $attachments);
1243
1244 // Clean up temporary file
1245 if (file_exists($temp_file)) {
1246 unlink($temp_file);
1247 }
1248
1249 return $result;
1250 }
1251
1252
1253
1254 public function mxchat_handle_save_email_and_response() {
1255 //error_log('[DEBUG] ---------- mxchat_handle_save_email_and_response START ----------');
1256 //error_log('DEBUG: POST data: ' . print_r($_POST, true));
1257
1258 nocache_headers();
1259
1260 // Validate nonce
1261 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
1262 //error_log(esc_html__('[ERROR] Invalid nonce in mxchat_handle_save_email_and_response', 'mxchat'));
1263 wp_send_json_error(['message' => esc_html__('Invalid nonce.', 'mxchat')]);
1264 wp_die();
1265 }
1266
1267 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1268 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
1269 $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
1270
1271 //error_log("[DEBUG] handle_save_email_and_response -> session_id: {$session_id}, email: {$email}, name: {$name}");
1272
1273 if (empty($session_id) || $session_id === 'null' || empty($email)) {
1274 //error_log("[ERROR] Missing session_id or email: session_id={$session_id}, email={$email}");
1275 wp_send_json_error(['message' => esc_html__('Session ID or email is missing.', 'mxchat')]);
1276 wp_die();
1277 }
1278
1279 // Validate name if provided (check if name field is enabled and name is required)
1280 $options = get_option('mxchat_options', []);
1281 $name_field_enabled = isset($options['enable_name_field']) &&
1282 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
1283
1284 if ($name_field_enabled && (empty($name) || strlen(trim($name)) < 2 || strlen(trim($name)) > 100)) {
1285 //error_log("[ERROR] Invalid name: {$name} (enabled: {$name_field_enabled})");
1286 wp_send_json_error(['message' => esc_html__('Name must be between 2 and 100 characters.', 'mxchat')]);
1287 wp_die();
1288 }
1289
1290 // 1) Always store email in wp_options
1291 $email_option_key = "mxchat_email_{$session_id}";
1292 update_option($email_option_key, $email, 'no');
1293 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$email_option_key} => {$email}");
1294
1295 // Store name in wp_options if provided
1296 if (!empty($name)) {
1297 $name_option_key = "mxchat_name_{$session_id}";
1298 update_option($name_option_key, $name, 'no');
1299 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$name_option_key} => {$name}");
1300 }
1301
1302 // 2) (Optional) Also store in DB if a row already exists
1303 global $wpdb;
1304 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1305
1306 // Make sure we have a valid placeholder in prepare
1307 $sql = $wpdb->prepare("SELECT COUNT(*) FROM {$table_name} WHERE session_id = %s", $session_id);
1308 $session_count = $wpdb->get_var($sql);
1309
1310 //error_log("[DEBUG] handle_save_email_and_response -> session_count for {$session_id}: {$session_count} (SQL: {$sql})");
1311
1312 if ($session_count) {
1313 // Update both user_email and user_name if row(s) exist
1314 if (!empty($name)) {
1315 $update_sql = $wpdb->prepare(
1316 "UPDATE {$table_name} SET user_email = %s, user_name = %s WHERE session_id = %s",
1317 $email,
1318 $name,
1319 $session_id
1320 );
1321 } else {
1322 $update_sql = $wpdb->prepare(
1323 "UPDATE {$table_name} SET user_email = %s WHERE session_id = %s",
1324 $email,
1325 $session_id
1326 );
1327 }
1328 $wpdb->query($update_sql);
1329 //error_log("[DEBUG] handle_save_email_and_response -> DB updated: {$update_sql}");
1330 } else {
1331 //error_log("[INFO] handle_save_email_and_response -> No DB entry for {$session_id}, so email/name is only in wp_options.");
1332 }
1333
1334 // Provide success response (same as original)
1335 $bot_message = __('Thanks for providing your email! You can continue chatting now.', 'mxchat');
1336 //error_log("[DEBUG] handle_save_email_and_response -> success, returning bot_message: {$bot_message}");
1337 wp_send_json_success(['message' => $bot_message]);
1338 wp_die();
1339 }
1340
1341 public function mxchat_check_email_provided() {
1342 //error_log('[DEBUG] ---------- mxchat_check_email_provided START ----------');
1343
1344 nocache_headers();
1345
1346 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
1347 //error_log('[ERROR] Invalid nonce in mxchat_check_email_provided');
1348 wp_send_json_error(['message' => esc_html__('Invalid nonce', 'mxchat')]);
1349 }
1350
1351 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1352 if (empty($session_id) || $session_id === 'null') {
1353 //error_log('[ERROR] No session ID provided in mxchat_check_email_provided');
1354 wp_send_json_error(['message' => esc_html__('No session ID provided', 'mxchat')]);
1355 }
1356
1357 // Check if the user is logged in
1358 if (is_user_logged_in()) {
1359 $current_user = wp_get_current_user();
1360 //error_log("[DEBUG] User is logged in as {$current_user->user_email}");
1361
1362 // Get user's display name for logged in users
1363 $user_name = !empty($current_user->display_name) ? $current_user->display_name :
1364 (!empty($current_user->first_name) ? $current_user->first_name : '');
1365
1366 $response_data = ['logged_in' => true, 'email' => $current_user->user_email];
1367 if (!empty($user_name)) {
1368 $response_data['name'] = $user_name;
1369 }
1370
1371 wp_send_json_success($response_data);
1372 }
1373
1374 // Check if name field is required
1375 $options = get_option('mxchat_options', []);
1376 $name_field_enabled = isset($options['enable_name_field']) &&
1377 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
1378
1379 $email_option_key = "mxchat_email_{$session_id}";
1380 $stored_email = get_option($email_option_key, '');
1381
1382 // Check for stored name
1383 $name_option_key = "mxchat_name_{$session_id}";
1384 $stored_name = get_option($name_option_key, '');
1385
1386 //error_log("[DEBUG] mxchat_check_email_provided -> Checking email option: {$email_option_key}, found: {$stored_email}");
1387 //error_log("[DEBUG] mxchat_check_email_provided -> Checking name option: {$name_option_key}, found: {$stored_name}, required: " . ($name_field_enabled ? 'yes' : 'no'));
1388
1389 // Check if we have email and name (if name is required)
1390 $has_required_info = !empty($stored_email);
1391
1392 if ($name_field_enabled) {
1393 $has_required_info = $has_required_info && !empty($stored_name);
1394 }
1395
1396 if ($has_required_info) {
1397 //error_log("[DEBUG] mxchat_check_email_provided -> Required info found, returning success");
1398
1399 $response_data = ['email' => $stored_email];
1400 if (!empty($stored_name)) {
1401 $response_data['name'] = $stored_name;
1402 }
1403
1404 wp_send_json_success($response_data);
1405 } else {
1406 //error_log("[DEBUG] mxchat_check_email_provided -> Required info missing, returning error");
1407 wp_send_json_error(['message' => esc_html__('No email found', 'mxchat')]);
1408 }
1409 }
1410
1411 /**
1412 * Send error response in appropriate format based on streaming mode
1413 * ADDED: Helper method to consistently handle errors in both streaming and non-streaming modes
1414 *
1415 * @param string $error_message The error message to display
1416 * @param string $error_code Optional error code for debugging
1417 */
1418 private function send_error_response($error_message, $error_code = 'api_error') {
1419 if ($this->is_streaming) {
1420 echo "data: " . json_encode([
1421 'error' => true,
1422 'error_message' => $error_message,
1423 'error_code' => $error_code,
1424 'text' => $error_message,
1425 'message' => $error_message
1426 ]) . "\n\n";
1427 echo "data: [DONE]\n\n";
1428 flush();
1429 } else {
1430 wp_send_json_error([
1431 'error_message' => $error_message,
1432 'error_code' => $error_code
1433 ]);
1434 }
1435 wp_die();
1436 }
1437
1438 public function mxchat_handle_chat_request() {
1439 global $wpdb;
1440
1441 // Debug: Log incoming bot_id
1442 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1443 //error_log("=== MXCHAT DEBUG: Starting chat request ===");
1444 //error_log("MXCHAT DEBUG: Bot ID received: " . $bot_id);
1445
1446 // Get bot-specific options
1447 $bot_options = $this->get_bot_options($bot_id);
1448 $current_options = !empty($bot_options) ? $bot_options : $this->options;
1449
1450 // Check if this is a streaming request
1451 // Allow force_streaming_test parameter to bypass the setting check (for admin compatibility testing)
1452 $force_streaming_test = isset($_POST['force_streaming_test']) && $_POST['force_streaming_test'] === '1' && current_user_can('administrator');
1453 $is_streaming = isset($_POST['action']) && $_POST['action'] === 'mxchat_stream_chat' &&
1454 ($force_streaming_test || (isset($current_options['enable_streaming_toggle']) && $current_options['enable_streaming_toggle'] === 'on'));
1455
1456 // ADDED: Store streaming state in class property for use in private methods
1457 $this->is_streaming = $is_streaming;
1458
1459 // NOTE: Streaming headers are now set later via setup_streaming_headers()
1460 // This allows actions/forms to return JSON responses without header conflicts
1461
1462 // Check if MX Chat Moderation is active
1463 if (class_exists('MX_Chat_Moderation')) {
1464 // Get user email and IP
1465 $user_email = '';
1466 $user_ip = $_SERVER['REMOTE_ADDR'];
1467
1468 // If user is logged in, get their email
1469 if (is_user_logged_in()) {
1470 $current_user = wp_get_current_user();
1471 $user_email = $current_user->user_email;
1472 }
1473
1474 // Create ban handler instance
1475 $ban_handler = new MX_Chat_Ban_Handler();
1476
1477 // Check if user is banned by IP
1478 if ($ban_handler->check_ban($user_ip, 'ip')) {
1479 wp_send_json([
1480 'success' => false,
1481 'message' => esc_html__('Access denied. Your IP address has been banned.', 'mxchat'),
1482 'status' => 'banned'
1483 ]);
1484 wp_die();
1485 }
1486
1487 // If user is logged in, also check email
1488 if (!empty($user_email) && $ban_handler->check_ban($user_email, 'email')) {
1489 wp_send_json([
1490 'success' => false,
1491 'message' => esc_html__('Access denied. Your email address has been banned.', 'mxchat'),
1492 'status' => 'banned'
1493 ]);
1494 wp_die();
1495 }
1496 }
1497
1498 $this->fallbackResponse = ['text' => '', 'html' => '', 'images' => []];
1499 $this->productCardHtml = '';
1500 // Reset the per-turn function-calling UI capture (plan 48a57a).
1501 $this->fc_ui_html = '';
1502 $this->fc_ui_images = array();
1503 $this->fc_ui_captured = false;
1504
1505 // Get the actual WordPress user ID if logged in
1506 $is_logged_in = is_user_logged_in();
1507 if ($is_logged_in) {
1508 $user_id = get_current_user_id(); // This will get the actual WordPress user ID
1509 } else {
1510 // For logged-out users, use your existing identifier method
1511 $user_id = $this->mxchat_get_user_identifier();
1512 }
1513
1514 // Get and sanitize the user identifier
1515 $user_id = sanitize_key($user_id);
1516
1517 // Check rate limit using new settings structure
1518 $rate_limit_result = $this->check_rate_limit();
1519
1520 if ($rate_limit_result !== true) {
1521 wp_send_json([
1522 'success' => false,
1523 'message' => $rate_limit_result['message'],
1524 'status' => 'rate_limit_exceeded'
1525 ]);
1526 wp_die();
1527 }
1528
1529 // Rest of your existing code...
1530 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1531
1532 // Treat the literal strings 'null' / 'undefined' as missing too. Browser edge cases
1533 // (Safari ITP, private mode, cross-origin iframes with partitioned storage) can cause
1534 // the frontend FormData.append() to stringify a null session_id into the literal
1535 // "null", which would otherwise pass empty() and pollute the transcripts table with
1536 // ghost sessions that group every visitor's first message under one row.
1537 if ($session_id === 'null' || $session_id === 'undefined') {
1538 $session_id = '';
1539 }
1540
1541 if (empty($session_id)) {
1542 wp_send_json_error(esc_html__('Session ID is missing.', 'mxchat'));
1543 wp_die();
1544 }
1545
1546 // Update session owner if it changed (e.g. IP changed due to network switch)
1547 // The session ID itself is the authentication — if the client has it, they own it
1548 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
1549 $session_owner = get_option("mxchat_session_owner_{$session_id}");
1550
1551 if (!$session_owner || $session_owner !== $current_user_identifier) {
1552 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
1553 }
1554
1555 // Validate and sanitize the incoming message
1556 if (empty($_POST['message'])) {
1557 wp_send_json_error(esc_html__('No message received.', 'mxchat'));
1558 wp_die();
1559 }
1560
1561 // Enforce the configurable max input length (plan a3fae2 part C). 0 = unlimited.
1562 // Server-side guard backing the textarea's client-side maxlength (which is bypassable).
1563 // Reads the global core setting and measures characters (mb_strlen on the unslashed
1564 // raw POST), matching the maxlength semantics.
1565 $mxchat_max_input_length = isset($this->options['max_input_length']) ? intval($this->options['max_input_length']) : 0;
1566 if ($mxchat_max_input_length > 0) {
1567 $mxchat_incoming_raw = is_string($_POST['message']) ? wp_unslash($_POST['message']) : '';
1568 if (mb_strlen($mxchat_incoming_raw) > $mxchat_max_input_length) {
1569 wp_send_json([
1570 'success' => false,
1571 /* translators: %d: maximum allowed characters */
1572 'message' => sprintf(esc_html__('Your message is too long. Please keep it under %d characters.', 'mxchat'), $mxchat_max_input_length),
1573 'status' => 'message_too_long'
1574 ]);
1575 wp_die();
1576 }
1577 }
1578
1579
1580 // Track originating page for first message in session
1581 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1582
1583 // Check if originating page columns exist
1584 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
1585
1586 if ($columns_exist) {
1587 // Check if this session already has messages
1588 $message_count = $wpdb->get_var($wpdb->prepare(
1589 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
1590 $session_id
1591 ));
1592
1593 // If this is the first message in the session
1594 if ($message_count == 0) {
1595 // Get originating page from JavaScript (preferred) or HTTP_REFERER (fallback)
1596 $originating_url = '';
1597 $originating_title = '';
1598
1599 // Try to get from POST data first (sent by JavaScript)
1600 if (isset($_POST['current_page_url'])) {
1601 $originating_url = esc_url_raw($_POST['current_page_url']);
1602 $originating_title = isset($_POST['current_page_title'])
1603 ? sanitize_text_field($_POST['current_page_title'])
1604 : '';
1605 }
1606 // Fallback to HTTP_REFERER if not provided by JavaScript
1607 else if (isset($_SERVER['HTTP_REFERER'])) {
1608 $originating_url = esc_url_raw($_SERVER['HTTP_REFERER']);
1609 }
1610
1611 // Generate title if we have URL but no title
1612 if ($originating_url && empty($originating_title)) {
1613 $parsed_url = parse_url($originating_url);
1614 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
1615
1616 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
1617 $originating_title = 'Homepage';
1618 } else {
1619 // Clean up the path to make a readable title
1620 $originating_title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
1621 $originating_title = ucwords(trim($originating_title));
1622 }
1623 }
1624
1625 // Store for later use when saving the message
1626 $this->pending_originating_page = [
1627 'url' => $originating_url,
1628 'title' => $originating_title
1629 ];
1630 }
1631 }
1632
1633
1634
1635 // Get page context if provided
1636 $page_context = null;
1637 if (isset($_POST['page_context']) && !empty($_POST['page_context'])) {
1638 $page_context_raw = stripslashes($_POST['page_context']);
1639 $page_context = json_decode($page_context_raw, true);
1640
1641 // Validate page context structure
1642 if (is_array($page_context) &&
1643 isset($page_context['url']) &&
1644 isset($page_context['title']) &&
1645 isset($page_context['content'])) {
1646
1647 // Sanitize page context
1648 $page_context['url'] = esc_url_raw($page_context['url']);
1649 $page_context['title'] = sanitize_text_field($page_context['title']);
1650 $page_context['content'] = wp_kses_post($page_context['content']);
1651 } else {
1652 $page_context = null;
1653 }
1654 }
1655
1656 // Modify the message sanitization to preserve PHP tags in code blocks
1657 $allowed_tags = [
1658 'pre' => [],
1659 'code' => ['class' => true],
1660 'span' => ['class' => true],
1661 'div' => ['class' => true],
1662 ];
1663
1664 // First preserve code blocks
1665 $message = preg_replace_callback('/<pre><code.*?>.*?<\/code><\/pre>/s', function($matches) {
1666 return htmlspecialchars_decode($matches[0]);
1667 }, $_POST['message']);
1668
1669 // Then apply sanitization
1670 $message = wp_kses($message, $allowed_tags);
1671
1672 // Preserve code blocks from markdown conversion
1673 $message = preg_replace('/```(\w+)?\s*([\s\S]+?)```/s', '<pre><code class="$1">$2</code></pre>', $message);
1674 $message = apply_filters('mxchat_filter_message', $message, 'prompt', $session_id);
1675
1676 // ===== SIMPLIFIED TESTING PANEL INITIALIZATION =====
1677 // Always initialize testing data for admins (no toggle needed)
1678 $testing_data = null;
1679 if (current_user_can('administrator')) {
1680 // For vision messages, use the original user message for the query display
1681 $query_for_testing = $message;
1682 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1683 $query_for_testing = sanitize_textarea_field($_POST['original_user_message']);
1684 }
1685
1686 $testing_data = [
1687 'query' => $query_for_testing,
1688 'timestamp' => time(),
1689 'top_matches' => [],
1690 'action_matches' => [], // Initialize action matches array
1691 'page_context' => $page_context, // Include page context in testing data
1692 'is_vision' => isset($_POST['vision_processed']) && $_POST['vision_processed'],
1693 'bot_id' => $bot_id // Include bot ID in testing data
1694 ];
1695
1696 // Get similarity threshold from bot options or default options
1697 $similarity_threshold = isset($current_options['similarity_threshold'])
1698 ? ((int) $current_options['similarity_threshold']) / 100
1699 : 0.35;
1700
1701 $testing_data['similarity_threshold'] = $similarity_threshold;
1702
1703 // Determine knowledge base type using bot-specific config
1704 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
1705 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
1706 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
1707 }
1708 // ===== END SIMPLIFIED TESTING INITIALIZATION =====
1709
1710 // Add debug before and after:
1711 //error_log('MxChat Core: About to call mxchat_pre_process_message filter with message: ' . $message);
1712 $pre_processed_result = apply_filters('mxchat_pre_process_message', $message, $user_id, $session_id);
1713 //error_log('MxChat Core: Filter returned: ' . (is_array($pre_processed_result) ? 'array' : $pre_processed_result));
1714
1715
1716 // If the pre-processing returned a result (not the original message), use it directly
1717 if (is_array($pre_processed_result) && isset($pre_processed_result['text'])) {
1718 // Save the AI response
1719 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['text']);
1720
1721 // Save HTML content if provided
1722 if (!empty($pre_processed_result['html'])) {
1723 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['html']);
1724 }
1725
1726 // Add testing data if admin
1727 $response_data = [
1728 'text' => $pre_processed_result['text'],
1729 'html' => $pre_processed_result['html'] ?? '',
1730 'session_id' => $session_id
1731 ];
1732
1733 if ($testing_data !== null) {
1734 $response_data['testing_data'] = $testing_data;
1735 }
1736
1737 wp_send_json($response_data);
1738 wp_die();
1739 }
1740
1741 // Save the user's message - handle vision processed messages differently
1742 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1743 // For vision messages, save the original user message with image indicator
1744 $original_message = sanitize_textarea_field($_POST['original_user_message']);
1745 if (isset($_POST['vision_images_count']) && $_POST['vision_images_count'] > 0) {
1746 $image_count = intval($_POST['vision_images_count']);
1747 $original_message .= " [{$image_count} image(s)]";
1748 }
1749 $this->mxchat_save_chat_message($session_id, 'user', $original_message);
1750 } else {
1751 // Regular message - save as normal
1752 $this->mxchat_save_chat_message($session_id, 'user', $message);
1753 }
1754
1755
1756 if (is_email($message)) {
1757 // Add the email to Loops
1758 $this->add_email_to_loops($message);
1759
1760 // Get the user's success message instruction using current_options
1761 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1762
1763 // Set instruction for AI using the user's success message
1764 $this->current_action_instruction = $user_success_message;
1765
1766 // Clear the email capture transient since we got the email
1767 delete_transient('mxchat_email_capture_' . $user_id);
1768 }
1769
1770 // Check if we're in an email capture flow but user hasn't provided email yet
1771 elseif (get_transient('mxchat_email_capture_' . $user_id)) {
1772 // Check if the message contains an email (not the whole message being an email)
1773 if (preg_match('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', $message, $matches)) {
1774 $extracted_email = $matches[0];
1775
1776 // Add the extracted email to Loops
1777 $this->add_email_to_loops($extracted_email);
1778
1779 // Get the user's success message instruction using current_options
1780 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1781
1782 // Set instruction for AI using the user's success message
1783 $this->current_action_instruction = $user_success_message;
1784
1785 // Clear the email capture transient since we got the email
1786 delete_transient('mxchat_email_capture_' . $user_id);
1787 }
1788 // If no email found but we're in capture mode, remind them
1789 else {
1790 // Get the original instruction to remind them using current_options
1791 $original_instruction = $current_options['triggered_phrase_response'] ?? __("Please provide your email address.", 'mxchat');
1792 $this->current_action_instruction = $original_instruction;
1793 }
1794 }
1795
1796 $intent_info = '';
1797
1798 // Check chat mode
1799 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1800
1801 // Handle agent mode
1802 // Handle agent mode
1803 if ($chat_mode === 'agent') {
1804 // First, check for switch intent before doing anything else
1805 $intent_matched = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1806
1807 // Capture action analysis for testing panel after intent check
1808 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1809 $testing_data['action_matches'] = $this->last_action_analysis;
1810 }
1811
1812 // Around line 506, in the agent mode handling section:
1813 if ($intent_matched && !empty($this->fallbackResponse['text'])) {
1814 // Update chat mode first
1815 update_option("mxchat_mode_{$session_id}", 'ai');
1816
1817 // Clear any existing PDF context to start fresh
1818 $this->clear_pdf_transients($session_id);
1819
1820 // Prepare clean switch response with explicit chat_mode
1821 $response_data = [
1822 'text' => $this->fallbackResponse['text'],
1823 'html' => $this->fallbackResponse['html'] ?? '',
1824 'session_id' => $session_id,
1825 'chat_mode' => 'ai' // EXPLICITLY SET THIS
1826 ];
1827
1828 if ($testing_data !== null) {
1829 $response_data['testing_data'] = $testing_data;
1830 }
1831
1832 // Save the mode switch message
1833 $this->mxchat_save_chat_message($session_id, 'system', esc_html__('Switched to AI chat mode', 'mxchat'));
1834 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1835
1836 // Send response and exit
1837 wp_send_json($response_data);
1838 wp_die();
1839 } elseif (!$intent_matched) {
1840 // No intent matched, handle live agent message
1841 try {
1842 $this->mxchat_send_user_message_to_agent($message, $user_id, $session_id);
1843
1844 $agent_response = [
1845 'status' => 'waiting_for_agent',
1846 'message' => esc_html__('Message sent to live agent.', 'mxchat')
1847 ];
1848
1849 if ($testing_data !== null) {
1850 $agent_response['testing_data'] = $testing_data;
1851 }
1852
1853 wp_send_json_success($agent_response);
1854 } catch (\Exception $e) {
1855 wp_send_json_error(esc_html__('Failed to send message to agent', 'mxchat'));
1856 }
1857 wp_die();
1858 }
1859 }
1860
1861 // Step 1: Check for new PDF URL in the message
1862 if (!isset($_POST['vision_processed']) && preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
1863 $new_pdf_url = $matches[0];
1864
1865 // Check if this is likely a PDF-related request
1866 $pdf_keywords = ['pdf', 'document', 'read', 'analyze'];
1867 $is_pdf_request = false;
1868
1869 foreach ($pdf_keywords as $keyword) {
1870 if (stripos($message, $keyword) !== false) {
1871 $is_pdf_request = true;
1872 break;
1873 }
1874 }
1875
1876 // If it looks like a PDF request or we're waiting for a PDF URL
1877 if ($is_pdf_request || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
1878 // Validate HTTPS
1879 if (wp_http_validate_url($new_pdf_url) && parse_url($new_pdf_url, PHP_URL_SCHEME) === 'https') {
1880 // Extract filename from URL
1881 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1882
1883 // Clear previous PDF transients
1884 $this->clear_pdf_transients($session_id);
1885
1886 // Process new PDF using current_options
1887 $max_pages = $current_options['pdf_max_pages'] ?? 69;
1888 $embeddings = $this->fetch_and_split_pdf_pages($new_pdf_url, $max_pages);
1889
1890 if ($embeddings === 'too_many_pages') {
1891 $error_text = sprintf(
1892 $current_options['pdf_intent_error_text'] ??
1893 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
1894 $max_pages
1895 );
1896 $this->fallbackResponse['text'] = $error_text;
1897 } elseif ($embeddings) {
1898 // Store new PDF information
1899 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1900
1901 // If the filename is generic, create a more descriptive one
1902 if (in_array($pdf_filename, ['results_download.php', 'download.php', 'view.php', 'pdf.php']) ||
1903 strpos($pdf_filename, '.php') !== false) {
1904 $pdf_filename = 'Document_' . date('Y-m-d_H-i') . '.pdf';
1905 }
1906
1907 set_transient('mxchat_pdf_url_' . $session_id, $new_pdf_url, HOUR_IN_SECONDS);
1908 set_transient('mxchat_pdf_filename_' . $session_id, $pdf_filename, HOUR_IN_SECONDS);
1909 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
1910 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
1911
1912 $success_text = $current_options['pdf_intent_success_text'] ??
1913 esc_html__("I've processed the new PDF '{$pdf_filename}'. What questions do you have about it?", 'mxchat');
1914
1915 $pdf_response = [
1916 'success' => true,
1917 'message' => $success_text,
1918 'data' => [
1919 'filename' => $pdf_filename
1920 ]
1921 ];
1922
1923 if ($testing_data !== null) {
1924 $pdf_response['testing_data'] = $testing_data;
1925 }
1926
1927 wp_send_json($pdf_response);
1928 wp_die();
1929 } else {
1930 $error_text = $current_options['pdf_intent_error_text'] ??
1931 esc_html__("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
1932 $this->fallbackResponse['text'] = $error_text;
1933 }
1934
1935 $pdf_error_response = [
1936 'success' => false,
1937 'message' => $this->fallbackResponse['text']
1938 ];
1939
1940 if ($testing_data !== null) {
1941 $pdf_error_response['testing_data'] = $testing_data;
1942 }
1943
1944 wp_send_json($pdf_error_response);
1945 wp_die();
1946 }
1947 }
1948 }
1949
1950
1951 // Step 2: Detect intent and handle intent-based responses
1952 $intent_result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1953
1954 // Capture action analysis for testing panel after intent check
1955 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1956 $testing_data['action_matches'] = $this->last_action_analysis;
1957 }
1958
1959 // Step 3: Handle the intent result appropriately
1960 if ($intent_result !== false) {
1961 // Intent was matched - ALWAYS send as JSON response, never streaming
1962
1963 if (is_array($intent_result) && (isset($intent_result['text']) || isset($intent_result['html']))) {
1964 // Intent returned a direct response array
1965 $response_data = [
1966 'text' => $intent_result['text'] ?? '',
1967 'html' => $intent_result['html'] ?? '',
1968 'session_id' => $session_id
1969 ];
1970
1971 // IMPORTANT: Include chat_mode if present (for WhatsApp, Slack, etc.)
1972 if (isset($intent_result['chat_mode'])) {
1973 $response_data['chat_mode'] = $intent_result['chat_mode'];
1974 }
1975
1976 if ($testing_data !== null) {
1977 $response_data['testing_data'] = $testing_data;
1978 }
1979
1980 wp_send_json($response_data);
1981 wp_die();
1982 } else if ($intent_result === true && (!empty($this->fallbackResponse['text']) || !empty($this->fallbackResponse['html']))) {
1983 // Intent returned true and set fallbackResponse
1984
1985 // SAVE TO TRANSCRIPT
1986 if (!empty($this->fallbackResponse['text'])) {
1987 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1988 }
1989 // Save action HTML (product cards, featured products, etc.) so it renders in transcripts
1990 if (!empty($this->fallbackResponse['html'])) {
1991 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1992 }
1993
1994 $response_data = [
1995 'text' => $this->fallbackResponse['text'] ?? '',
1996 'html' => $this->fallbackResponse['html'] ?? '',
1997 'session_id' => $session_id
1998 ];
1999
2000 if (isset($this->fallbackResponse['chat_mode'])) {
2001 $response_data['chat_mode'] = $this->fallbackResponse['chat_mode'];
2002 }
2003
2004 if ($testing_data !== null) {
2005 $response_data['testing_data'] = $testing_data;
2006 }
2007
2008 wp_send_json($response_data);
2009 wp_die();
2010 }
2011 }
2012
2013 // If we get here, no intent matched OR the intent didn't provide a usable response
2014
2015 // Step 4: Generate AI response
2016 // Get session start timestamp - when persistence is OFF, only include messages from this page load
2017 $session_start_timestamp = isset($_POST['session_start_timestamp']) ? intval($_POST['session_start_timestamp']) : 0;
2018 $conversation_history = $this->mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp);
2019 $this->mxchat_increment_chat_count();
2020
2021 // Generate embedding for the user's query - USE BOT-SPECIFIC API KEY
2022 $api_key = $current_options['api_key'] ?? $this->options['api_key'];
2023 $user_message_embedding = $this->mxchat_generate_embedding($message, $api_key);
2024
2025 // Check if the embedding generation returned an error
2026 if (is_array($user_message_embedding) && isset($user_message_embedding['error'])) {
2027 $error_message = $user_message_embedding['error'];
2028 $error_code = $user_message_embedding['error_code'] ?? 'embedding_error';
2029
2030 // FIXED: Send error in appropriate format based on streaming mode
2031 if ($is_streaming) {
2032 echo "data: " . json_encode([
2033 'error' => true,
2034 'error_message' => $error_message,
2035 'error_code' => $error_code,
2036 'text' => $error_message,
2037 'message' => $error_message
2038 ]) . "\n\n";
2039 echo "data: [DONE]\n\n";
2040 flush();
2041 } else {
2042 wp_send_json_error([
2043 'error_message' => $error_message,
2044 'error_code' => $error_code
2045 ]);
2046 }
2047 wp_die();
2048 }
2049
2050 // Check if the embedding is valid
2051 if (!is_array($user_message_embedding) || empty($user_message_embedding)) {
2052 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
2053
2054 // FIXED: Send error in appropriate format based on streaming mode
2055 if ($is_streaming) {
2056 echo "data: " . json_encode([
2057 'error' => true,
2058 'error_message' => $error_message,
2059 'error_code' => 'invalid_embedding',
2060 'text' => $error_message,
2061 'message' => $error_message
2062 ]) . "\n\n";
2063 echo "data: [DONE]\n\n";
2064 flush();
2065 } else {
2066 wp_send_json_error([
2067 'error_message' => $error_message,
2068 'error_code' => 'invalid_embedding'
2069 ]);
2070 }
2071 wp_die();
2072 }
2073
2074 // Build context with both knowledge base and PDF content if available
2075 $context_content = "User asked: '{$message}'\n\n";
2076
2077 // Add action instruction if present (add this right after the above line)
2078 if (!empty($this->current_action_instruction)) {
2079 $context_content .= "===== SPECIAL INSTRUCTION =====\n";
2080 $context_content .= "IMPORTANT: " . $this->current_action_instruction . "\n";
2081 $context_content .= "Respond naturally and conversationally while following this instruction.\n";
2082 $context_content .= "===== END SPECIAL INSTRUCTION =====\n\n";
2083
2084 // Clear the instruction after using it
2085 $this->current_action_instruction = null;
2086 }
2087
2088
2089 // Add page context if available and contextual awareness is enabled using current_options
2090 if ($page_context && isset($current_options['contextual_awareness_toggle']) && $current_options['contextual_awareness_toggle'] === 'on') {
2091 $context_content .= "===== CURRENT PAGE CONTEXT =====\n";
2092 $context_content .= "Page URL: " . $page_context['url'] . "\n";
2093 $context_content .= "Page Title: " . $page_context['title'] . "\n";
2094 $context_content .= "Page Content: " . $page_context['content'] . "\n";
2095 $context_content .= "===== END CURRENT PAGE CONTEXT =====\n\n";
2096 }
2097
2098 // Get relevant content from knowledge base - PASS BOT_ID and MESSAGE for Vector Store
2099 $relevant_content = $this->mxchat_find_relevant_content($user_message_embedding, $bot_id, $message);
2100
2101 // NEW: Also extract URLs from system instructions (only if citation links enabled)
2102 // Use fresh options to ensure we get the latest setting value
2103 $fresh_options = get_option('mxchat_options', []);
2104 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
2105
2106 $system_instructions = $this->get_system_instructions($bot_id, $session_id);
2107 if ($citation_links_enabled && !empty($system_instructions)) {
2108 preg_match_all(
2109 '#\bhttps?://[^\s<>"\']+#i',
2110 $system_instructions,
2111 $system_instruction_urls
2112 );
2113
2114 if (!empty($system_instruction_urls[0])) {
2115 // Merge with existing valid URLs
2116 $this->current_valid_urls = array_merge(
2117 $this->current_valid_urls,
2118 $system_instruction_urls[0]
2119 );
2120 // Remove duplicates
2121 $this->current_valid_urls = array_unique($this->current_valid_urls);
2122
2123 //error_log("Added " . count($system_instruction_urls[0]) . " URLs from system instructions");
2124 }
2125 }
2126
2127 // ===== CAPTURE REAL SIMILARITY DATA FOR ADMINS =====
2128 if ($testing_data !== null && $this->last_similarity_analysis !== null) {
2129 // Update testing data with the REAL similarity analysis
2130 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
2131 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
2132 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
2133 $testing_data['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
2134 $testing_data['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
2135 }
2136 // ===== END SIMILARITY DATA CAPTURE =====
2137
2138 // NEW: Add valid URLs to testing data for admin panel display (AFTER similarity data)
2139 if ($testing_data !== null && !empty($this->current_valid_urls)) {
2140 $testing_data['approved_urls'] = array_values($this->current_valid_urls);
2141 //error_log("Added " . count($this->current_valid_urls) . " approved URLs to testing data");
2142 }
2143
2144 if (!empty($relevant_content)) {
2145 $context_content .= "===== OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n" . $relevant_content . "\n===== END OF OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n\n";
2146 } else {
2147 $context_content .= "===== NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE =====\n";
2148 }
2149
2150 // NEW: Add approved URLs list to context for AI (only if citation links enabled)
2151 if ($citation_links_enabled && !empty($this->current_valid_urls)) {
2152 $context_content .= "===== APPROVED URLS FOR CITATIONS =====\n";
2153 $context_content .= "You may ONLY use these exact URLs in your response:\n";
2154 foreach ($this->current_valid_urls as $url) {
2155 $context_content .= "- " . $url . "\n";
2156 }
2157 $context_content .= "\nCRITICAL: Do NOT create, modify, extend, or invent any other URLs. ";
2158 $context_content .= "===== END APPROVED URLS =====\n\n";
2159 }
2160
2161 // Check for and include PDF content
2162 $pdf_url = get_transient('mxchat_pdf_url_' . $session_id);
2163 $pdf_embeddings = get_transient('mxchat_pdf_embeddings_' . $session_id);
2164 $pdf_filename = get_transient('mxchat_pdf_filename_' . $session_id);
2165 if ($pdf_url && $pdf_embeddings && get_transient('mxchat_include_pdf_in_context_' . $session_id)) {
2166 $relevant_pdf_pages = $this->find_relevant_pdf_pages($user_message_embedding, $pdf_embeddings);
2167 if (!empty($relevant_pdf_pages)) {
2168 $context_content .= "Relevant content from PDF document '{$pdf_filename}':\n";
2169 foreach ($relevant_pdf_pages as $page_data) {
2170 $context_content .= "Page {$page_data['page_number']} of '{$pdf_filename}': {$page_data['text']}\n";
2171 }
2172 $context_content .= "\n";
2173 }
2174 }
2175
2176 // Check for and include Word content
2177 $word_url = get_transient('mxchat_word_url_' . $session_id);
2178 $word_embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
2179 $word_filename = get_transient('mxchat_word_filename_' . $session_id);
2180 if ($word_url && $word_embeddings && get_transient('mxchat_include_word_in_context_' . $session_id)) {
2181 $relevant_word_chunks = $this->word_handler->mxchat_find_relevant_word_chunks($user_message_embedding, $word_embeddings);
2182 if (!empty($relevant_word_chunks)) {
2183 $context_content .= "Relevant content from Word document '{$word_filename}':\n";
2184 foreach ($relevant_word_chunks as $chunk_data) {
2185 $context_content .= "Section {$chunk_data['chunk_number']} of '{$word_filename}': {$chunk_data['text']}\n";
2186 }
2187 $context_content .= "\n";
2188 }
2189 }
2190
2191 $context_content = apply_filters('mxchat_prepare_context', $context_content, $session_id);
2192
2193 // Extract model from current options for bot-specific model support
2194 $selected_model = isset($current_options['model']) ? $current_options['model'] : 'gpt-5.1-chat-latest';
2195
2196 // ===== Native function-calling fallback (plan-mxchat-20260617-a41dee) =====
2197 // Intents already missed (we're past the intent router). If function
2198 // calling is enabled and the active model is tool-capable, let the model
2199 // SELECT and run registered callbacks as tools — independent of intents,
2200 // works with zero Actions. The tool round is buffered; the final answer is
2201 // emitted via the SAME envelopes the normal path uses. Default-off, so
2202 // existing installs never enter this branch.
2203 if ($this->mxchat_fc_should_run($selected_model)) {
2204 $fc_outcome = $this->mxchat_fc_attempt(
2205 $message,
2206 $context_content,
2207 $conversation_history,
2208 $selected_model,
2209 $current_options,
2210 $session_id,
2211 $user_id
2212 );
2213 if (is_array($fc_outcome) && !empty($fc_outcome['handled'])) {
2214 $fc_text = isset($fc_outcome['text']) ? $fc_outcome['text'] : '';
2215 if (!empty($this->current_valid_urls)) {
2216 $fc_text = $this->validate_and_clean_urls($fc_text, $this->current_valid_urls);
2217 }
2218 // plan-mxchat-20260617-48a57a — surface any UI element a tool
2219 // produced (generated image / product card / image gallery) so the
2220 // widget RENDERS it, instead of emitting only the model's text.
2221 // The html was already saved to the transcript in
2222 // mxchat_fc_execute_tool (or by the callback itself for self-saving
2223 // core tools), so we persist ONLY the model's caption text here.
2224 $fc_html = isset($this->fc_ui_html) ? $this->fc_ui_html : '';
2225
2226 if ($fc_text !== '') {
2227 $this->mxchat_save_chat_message($session_id, 'bot', $fc_text, null, null);
2228 }
2229
2230 if ($is_streaming) {
2231 // The frontend SSE reader routes any event carrying text/html
2232 // to handleNonStreamResponse(), which renders text + html in a
2233 // single bot message — so emit one complete event (mirrors the
2234 // intent path's text/html envelope).
2235 $sse = array('session_id' => $session_id);
2236 if ($fc_text !== '') $sse['text'] = $fc_text;
2237 if ($fc_html !== '') $sse['html'] = $fc_html;
2238 if ($fc_text === '' && $fc_html === '') $sse['text'] = $this->mxchat_fc_giveup_text();
2239 echo "data: " . wp_json_encode($sse) . "\n\n";
2240 echo "data: [DONE]\n\n";
2241 flush();
2242 } else {
2243 $fc_response_data = array('text' => $fc_text, 'html' => $fc_html, 'session_id' => $session_id);
2244 if ($testing_data !== null) {
2245 $fc_response_data['testing_data'] = $testing_data;
2246 }
2247 wp_send_json($fc_response_data);
2248 }
2249 wp_die();
2250 }
2251 }
2252 // ===== end function-calling fallback =====
2253
2254 $response = $this->mxchat_generate_response(
2255 $context_content,
2256 $current_options['api_key'] ?? $this->options['api_key'],
2257 $current_options['xai_api_key'] ?? $this->options['xai_api_key'],
2258 $current_options['claude_api_key'] ?? $this->options['claude_api_key'],
2259 $current_options['deepseek_api_key'] ?? $this->options['deepseek_api_key'],
2260 $current_options['gemini_api_key'] ?? $this->options['gemini_api_key'],
2261 $current_options['openrouter_api_key'] ?? $this->options['openrouter_api_key'],
2262 $conversation_history,
2263 $is_streaming,
2264 $session_id,
2265 $testing_data,
2266 $selected_model
2267 );
2268
2269 // Handle streaming vs non-streaming responses
2270 if ($is_streaming) {
2271 // Check if streaming actually happened or if it fell back to regular response
2272 if ($response === true) {
2273 wp_die();
2274 }
2275 // If we get here, streaming fell back to regular response, continue
2276 // But if there's an error, we need to send it as SSE format since headers are already set
2277 if (is_array($response) && isset($response['error'])) {
2278 $error_message = $response['error'];
2279 $error_code = $response['error_code'] ?? 'api_error';
2280 // Send error in SSE format that the client JS can handle
2281 echo "data: " . json_encode([
2282 'error' => true,
2283 'error_message' => $error_message,
2284 'error_code' => $error_code,
2285 'text' => $error_message, // Also include as text for fallback handling
2286 'message' => $error_message
2287 ]) . "\n\n";
2288 echo "data: [DONE]\n\n";
2289 flush();
2290 wp_die();
2291 }
2292 }
2293
2294 // Check if the response is an error array (non-streaming mode)
2295 if (is_array($response) && isset($response['error'])) {
2296 wp_send_json_error([
2297 'error_message' => $response['error'],
2298 'error_code' => $response['error_code'] ?? 'api_error'
2299 ]);
2300 wp_die();
2301 }
2302
2303 // DEBUG: Check what we have
2304 //error_log("=== BEFORE URL VALIDATION ===");
2305 //error_log("current_valid_urls is empty? " . (empty($this->current_valid_urls) ? 'YES' : 'NO'));
2306 //error_log("current_valid_urls count: " . count($this->current_valid_urls));
2307 //error_log("current_valid_urls content: " . print_r($this->current_valid_urls, true));
2308
2309 // If we get here, the response is valid text - now validate URLs
2310 if (!empty($this->current_valid_urls)) {
2311 //error_log("CALLING validate_and_clean_urls");
2312 $response = $this->validate_and_clean_urls($response, $this->current_valid_urls);
2313 } else {
2314 //error_log("SKIPPING validation - current_valid_urls is empty");
2315 }
2316 // ===== END URL VALIDATION =====
2317
2318 // Prepare RAG context data for storage (only include documents used for context)
2319 $rag_context_for_storage = null;
2320 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
2321 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
2322
2323 if ($has_rag_data || $has_action_data) {
2324 $rag_context_for_storage = [];
2325
2326 // Add RAG/source data if available
2327 if ($has_rag_data) {
2328 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
2329 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
2330 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
2331 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
2332 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
2333 $rag_context_for_storage['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
2334 $rag_context_for_storage['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
2335 }
2336
2337 // Add action analysis data if available
2338 if ($has_action_data) {
2339 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
2340 }
2341 }
2342
2343 // Save the cleaned response with RAG context
2344 $this->mxchat_save_chat_message($session_id, 'bot', $response, null, $rag_context_for_storage);
2345
2346 // Step 5: Save additional content if available
2347 if (!empty($this->productCardHtml)) {
2348 $this->mxchat_save_chat_message($session_id, 'bot', $this->productCardHtml);
2349 }
2350
2351 if (!empty($this->fallbackResponse['html'])) {
2352 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
2353 }
2354
2355 // Step 6: Return the response
2356 // DEBUG: Check if newlines exist in the response
2357 //error_log("=== MXCHAT NON-STREAMING RESPONSE DEBUG ===");
2358 //error_log("Response has newlines: " . (strpos($response, "\n") !== false ? 'YES' : 'NO'));
2359 //error_log("Response first 500 chars: " . substr($response, 0, 500));
2360
2361 $response_data = [
2362 'text' => $response,
2363 'html' => !empty($this->productCardHtml) ? $this->productCardHtml : ($this->fallbackResponse['html'] ?? ''),
2364 'session_id' => $session_id
2365 ];
2366
2367 // Include vectorstore error info for admin debugging (only visible to admins via testing_data)
2368 if (!empty($this->last_vectorstore_error) && $testing_data !== null) {
2369 $testing_data['vectorstore_error'] = $this->last_vectorstore_error;
2370 }
2371
2372 // Also pass it as a top-level field so JS can show a better error message to admins
2373 if (!empty($this->last_vectorstore_error) && current_user_can('manage_options')) {
2374 $response_data['vectorstore_error'] = $this->last_vectorstore_error;
2375 }
2376
2377 // Always add testing data for admins (no toggle needed)
2378 if ($testing_data !== null) {
2379 $response_data['testing_data'] = $testing_data;
2380 }
2381
2382 wp_send_json($response_data);
2383 wp_die();
2384 }
2385
2386 /**
2387 * Get bot-specific options for multi-bot functionality
2388 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
2389 */
2390 // Also debug the bot options retrieval
2391 private function get_bot_options($bot_id = 'default') {
2392 //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
2393
2394 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2395 //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
2396 return array();
2397 }
2398
2399 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
2400
2401 if (!empty($bot_options)) {
2402 //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
2403 if (isset($bot_options['similarity_threshold'])) {
2404 //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
2405 }
2406 }
2407
2408 return is_array($bot_options) ? $bot_options : array();
2409 }
2410
2411 /**
2412 * Get bot-specific Pinecone configuration
2413 * Used in the knowledge retrieval functions
2414 */
2415 // Also add debugging to your get_bot_pinecone_config function
2416 private function get_bot_pinecone_config($bot_id = 'default') {
2417 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
2418
2419 // If default bot or multi-bot add-on not active, use default Pinecone config
2420 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2421 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
2422 $addon_options = get_option('mxchat_pinecone_addon_options', array());
2423 $config = array(
2424 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
2425 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
2426 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
2427 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
2428 );
2429 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
2430 return $config;
2431 }
2432
2433 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
2434
2435 // Hook for multi-bot add-on to provide bot-specific Pinecone config
2436 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
2437
2438 if (!empty($bot_pinecone_config)) {
2439 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
2440 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
2441 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
2442 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
2443 } else {
2444 //error_log("MXCHAT DEBUG: Filter returned empty config!");
2445 }
2446
2447 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
2448 }
2449
2450
2451 // Updated function to check intents and invoke the callback function
2452 private function mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id) {
2453 global $wpdb;
2454 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
2455
2456 // Get the current bot_id
2457 $current_bot_id = $this->get_current_bot_id($session_id);
2458
2459 // Generate the user embedding
2460 $user_embedding = $this->mxchat_generate_embedding($message, $this->options['api_key']);
2461
2462 // Check if embedding generation returned an error
2463 if (is_array($user_embedding) && isset($user_embedding['error'])) {
2464 $error_message = $user_embedding['error'];
2465 $error_code = $user_embedding['error_code'] ?? 'embedding_error';
2466
2467 // FIXED: Send error in appropriate format based on streaming mode
2468 if ($this->is_streaming) {
2469 echo "data: " . json_encode([
2470 'error' => true,
2471 'error_message' => $error_message,
2472 'error_code' => $error_code,
2473 'text' => $error_message,
2474 'message' => $error_message
2475 ]) . "\n\n";
2476 echo "data: [DONE]\n\n";
2477 flush();
2478 } else {
2479 wp_send_json_error([
2480 'error_message' => $error_message,
2481 'error_code' => $error_code
2482 ]);
2483 }
2484 wp_die();
2485 }
2486
2487 // Check if embedding is valid
2488 if (!is_array($user_embedding) || empty($user_embedding)) {
2489 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
2490
2491 // FIXED: Send error in appropriate format based on streaming mode
2492 if ($this->is_streaming) {
2493 echo "data: " . json_encode([
2494 'error' => true,
2495 'error_message' => $error_message,
2496 'error_code' => 'invalid_embedding',
2497 'text' => $error_message,
2498 'message' => $error_message
2499 ]) . "\n\n";
2500 echo "data: [DONE]\n\n";
2501 flush();
2502 } else {
2503 wp_send_json_error([
2504 'error_message' => $error_message,
2505 'error_code' => 'invalid_embedding'
2506 ]);
2507 }
2508 wp_die();
2509 }
2510
2511 // Fetch intents from the database
2512 $table_name = $wpdb->prefix . 'mxchat_intents';
2513 if ($chat_mode === 'agent') {
2514 $query = $wpdb->prepare(
2515 "SELECT * FROM $table_name WHERE callback_function = %s AND (enabled = 1 OR enabled IS NULL)",
2516 'mxchat_handle_switch_to_chatbot_intent'
2517 );
2518 $intents = $wpdb->get_results($query);
2519 } else {
2520 $intents = $wpdb->get_results("SELECT * FROM $table_name WHERE enabled = 1 OR enabled IS NULL");
2521 }
2522
2523 if (empty($intents)) {
2524 return false;
2525 }
2526
2527 // Prefetch individual phrase vectors from wp_mxchat_intent_phrases (grouped by intent_id)
2528 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
2529 $phrases_by_intent = [];
2530 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
2531 $all_phrases = $wpdb->get_results("SELECT intent_id, phrase, embedding_vector FROM $phrases_table");
2532 foreach ($all_phrases as $p) {
2533 $phrases_by_intent[$p->intent_id][] = $p;
2534 }
2535 }
2536
2537 $highest_similarity = -INF;
2538 $matched_intent = null;
2539
2540 // Array to store action analysis for testing panel
2541 $action_analysis = [];
2542
2543 foreach ($intents as $intent) {
2544 // Additional check for enabled state
2545 $is_enabled = isset($intent->enabled) ? (bool)$intent->enabled : true;
2546 if (!$is_enabled) {
2547 continue;
2548 }
2549
2550 // Check if this action is enabled for the current bot
2551 if (!$this->is_action_enabled_for_bot($intent, $current_bot_id)) {
2552 continue;
2553 }
2554
2555 $best_similarity = -INF;
2556 $matched_phrase_text = '';
2557
2558 // Check legacy embedding vector (existing behavior)
2559 $intent_embedding_serialized = $intent->embedding_vector;
2560 $intent_embedding = $intent_embedding_serialized
2561 ? unserialize($intent_embedding_serialized, ['allowed_classes' => false])
2562 : null;
2563
2564 if (is_array($intent_embedding) && !empty($intent_embedding)) {
2565 $legacy_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $intent_embedding);
2566 if ($legacy_similarity > $best_similarity) {
2567 $best_similarity = $legacy_similarity;
2568 $matched_phrase_text = 'legacy';
2569 }
2570 }
2571
2572 // Check individual phrase vectors
2573 if (isset($phrases_by_intent[$intent->id])) {
2574 foreach ($phrases_by_intent[$intent->id] as $phrase_row) {
2575 $phrase_embedding = $phrase_row->embedding_vector
2576 ? unserialize($phrase_row->embedding_vector, ['allowed_classes' => false])
2577 : null;
2578 if (!is_array($phrase_embedding)) {
2579 continue;
2580 }
2581 $phrase_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $phrase_embedding);
2582 if ($phrase_similarity > $best_similarity) {
2583 $best_similarity = $phrase_similarity;
2584 $matched_phrase_text = $phrase_row->phrase;
2585 }
2586 }
2587 }
2588
2589 // Skip if no valid embedding was found at all
2590 if ($best_similarity === -INF) {
2591 continue;
2592 }
2593
2594 $similarity = $best_similarity;
2595 $intent_threshold = isset($intent->similarity_threshold) ? $intent->similarity_threshold : 0.85;
2596
2597 // Store action analysis data for testing panel
2598 $action_analysis[] = [
2599 'intent_label' => $intent->intent_label,
2600 'callback_function' => $intent->callback_function,
2601 'similarity' => round($similarity, 4),
2602 'similarity_percentage' => round($similarity * 100, 2),
2603 'threshold' => $intent_threshold,
2604 'threshold_percentage' => round($intent_threshold * 100, 2),
2605 'above_threshold' => $similarity >= $intent_threshold,
2606 'matched_phrase' => $matched_phrase_text,
2607 'triggered' => false // Will be updated below if this intent is triggered
2608 ];
2609
2610 if ($similarity >= $intent_threshold && $similarity > $highest_similarity) {
2611 $highest_similarity = $similarity;
2612 $matched_intent = $intent;
2613 }
2614 }
2615
2616 // Mark the triggered action if any
2617 if ($matched_intent) {
2618 foreach ($action_analysis as &$action) {
2619 if ($action['intent_label'] === $matched_intent->intent_label) {
2620 $action['triggered'] = true;
2621 break;
2622 }
2623 }
2624 }
2625
2626 // Sort actions by similarity (highest first) and store for testing panel
2627 usort($action_analysis, function($a, $b) {
2628 return $b['similarity'] <=> $a['similarity'];
2629 });
2630
2631 // Store action analysis for testing panel capture
2632 $this->last_action_analysis = $action_analysis;
2633
2634 // Around line 715 in your mxchat_check_intent_and_invoke_callback function
2635 if ($matched_intent) {
2636 // If the callback is a method on this instance (core callback), call it directly
2637 if (method_exists($this, $matched_intent->callback_function)) {
2638 $callback_result = call_user_func(
2639 [$this, $matched_intent->callback_function],
2640 $message,
2641 $user_id,
2642 $session_id,
2643 $matched_intent,
2644 $user_context ?? null
2645 );
2646 } else {
2647 // Otherwise, use apply_filters for add-on callbacks
2648 $callback_result = apply_filters(
2649 $matched_intent->callback_function,
2650 false,
2651 $message,
2652 $user_id,
2653 $session_id,
2654 $matched_intent
2655 );
2656 }
2657
2658 // Handle the callback result properly
2659 if ($callback_result !== false) {
2660 // If callback returned an array with chat_mode, use it directly
2661 if (is_array($callback_result) && isset($callback_result['chat_mode'])) {
2662 $this->fallbackResponse = $callback_result;
2663 return $callback_result; // Return the full array
2664 } else {
2665 $this->fallbackResponse = $callback_result;
2666 return true;
2667 }
2668 }
2669 }
2670
2671 return false;
2672 }
2673
2674 /**
2675 * Check if an action is enabled for a specific bot
2676 */
2677 private function is_action_enabled_for_bot($intent, $bot_id) {
2678 // If enabled_bots column doesn't exist or is null, assume it's enabled for all bots (backward compatibility)
2679 if (!isset($intent->enabled_bots) || empty($intent->enabled_bots)) {
2680 return true;
2681 }
2682
2683 $enabled_bots = json_decode($intent->enabled_bots, true);
2684
2685 // If JSON decode fails or returns empty array, assume enabled for all (backward compatibility)
2686 if (!is_array($enabled_bots) || empty($enabled_bots)) {
2687 return true;
2688 }
2689
2690 // Admin testing tab uses bot_id "testing" — treat it as "default" so all
2691 // default-bot actions are testable from the admin panel
2692 if ($bot_id === 'testing') {
2693 $bot_id = 'default';
2694 }
2695
2696 // Check if the current bot is in the enabled bots list
2697 return in_array($bot_id, $enabled_bots);
2698 }
2699
2700 // Helper function to clear PDF and Word document related transients
2701 private function clear_pdf_transients($session_id) {
2702 // PDF transients
2703 delete_transient('mxchat_pdf_url_' . $session_id);
2704 delete_transient('mxchat_pdf_embeddings_' . $session_id);
2705 delete_transient('mxchat_include_pdf_in_context_' . $session_id);
2706 delete_transient('mxchat_waiting_for_pdf_url_' . $session_id);
2707
2708 // Word document transients
2709 delete_transient('mxchat_word_url_' . $session_id);
2710 delete_transient('mxchat_word_filename_' . $session_id);
2711 delete_transient('mxchat_word_embeddings_' . $session_id);
2712 delete_transient('mxchat_include_word_in_context_' . $session_id);
2713 delete_transient('mxchat_waiting_for_word_' . $session_id);
2714 }
2715
2716
2717
2718 //verified good
2719 public function mxchat_handle_email_capture($message, $user_id, $session_id) {
2720 // Get the user's original instruction/message
2721 $user_instruction = esc_html($this->options['triggered_phrase_response'] ?? esc_html__("Please provide your email address.", 'mxchat'));
2722
2723 // Set instruction for AI - just pass along what the user wanted to say
2724 $this->current_action_instruction = $user_instruction;
2725
2726 // Set the transient to track email capture flow
2727 set_transient('mxchat_email_capture_' . $user_id, true, 5 * MINUTE_IN_SECONDS);
2728
2729 // Return false to let the AI generate the response
2730 return false;
2731 }
2732
2733 public function mxchat_generate_image($message, $user_id, $session_id) {
2734 //error_log("Starting image generation for message: " . $message);
2735
2736 // Prepare a prompt for OpenAI image generation
2737 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2738
2739 // Opt-in routing: when 'custom_provider_for_images' is on, route image gen
2740 // through the configured Custom (OpenAI-compatible) /images/generations route.
2741 if (!empty($this->options['custom_provider_for_images']) && $this->options['custom_provider_for_images'] === 'on') {
2742 $image_response = $this->mxchat_generate_custom_image($prompt);
2743 } else {
2744 // Use the existing OpenAI API key
2745 $openai_api_key = sanitize_text_field($this->options['api_key']);
2746 // Call OpenAI GPT Image to generate an image
2747 $image_response = $this->mxchat_generate_openai_image($prompt, $openai_api_key);
2748 }
2749
2750 // Check if the response contains an image URL
2751 if (isset($image_response['imageUrl'])) {
2752 $image_url = esc_url_raw($image_response['imageUrl']);
2753
2754 // Construct the HTML with a CSS class instead of inline styles
2755 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2756 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2757
2758 // Save the bot message with both text and HTML
2759 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2760 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2761
2762 // Set the fallback response for the chat handler
2763 $this->fallbackResponse = [
2764 'text' => $response_text,
2765 'html' => $response_html,
2766 'images' => [$image_url]
2767 ];
2768
2769 // For debugging/verification - Use json_encode to verify what's being set
2770 //error_log("Image generation successful - fallbackResponse set: " . json_encode($this->fallbackResponse));
2771
2772 // Return the response directly instead of relying on the property
2773 return $this->fallbackResponse;
2774 } else {
2775 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2776
2777 // Save the error message
2778 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2779
2780 // Set the fallback response for the chat handler
2781 $this->fallbackResponse = [
2782 'text' => $response_text,
2783 'html' => '',
2784 'images' => []
2785 ];
2786
2787 //error_log("DALL-E image generation error: " . esc_html($image_response['error'] ?? 'Unknown error.'));
2788 //error_log("Error fallbackResponse set: " . json_encode($this->fallbackResponse));
2789
2790 // Return the response directly instead of relying on the property
2791 return $this->fallbackResponse;
2792 }
2793 }
2794
2795 public function mxchat_generate_gemini_image($message, $user_id, $session_id) {
2796 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2797
2798 $gemini_api_key = sanitize_text_field($this->options['gemini_api_key'] ?? '');
2799 if (empty($gemini_api_key)) {
2800 $response_text = esc_html__("Gemini API key is not configured.", 'mxchat');
2801 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2802 return ['text' => $response_text, 'html' => '', 'images' => []];
2803 }
2804
2805 $image_response = $this->mxchat_generate_imagen_image($prompt, $gemini_api_key);
2806
2807 if (isset($image_response['imageUrl'])) {
2808 $image_url = esc_url_raw($image_response['imageUrl']);
2809
2810 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2811 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2812
2813 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2814 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2815
2816 $this->fallbackResponse = [
2817 'text' => $response_text,
2818 'html' => $response_html,
2819 'images' => [$image_url]
2820 ];
2821
2822 return $this->fallbackResponse;
2823 } else {
2824 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2825
2826 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2827
2828 $this->fallbackResponse = [
2829 'text' => $response_text,
2830 'html' => '',
2831 'images' => []
2832 ];
2833
2834 return $this->fallbackResponse;
2835 }
2836 }
2837
2838 private function mxchat_save_generated_image($base64_data, $mime_type = 'image/png', $prefix = 'mxchat-generated') {
2839 // Map the real mime type to a matching file extension so the saved file's
2840 // extension always agrees with its bytes. A mismatch (e.g. Imagen returning
2841 // webp bytes that were written into a ".png" file) makes the browser refuse
2842 // to render the image even though the file saved successfully and the bot
2843 // reported success — that was the Gemini/Imagen "image never renders" bug.
2844 // OpenAI + custom-provider paths pass 'image/png' explicitly, so they are
2845 // unaffected; this only matters for providers that return another type.
2846 $mime_to_ext = [
2847 'image/jpeg' => 'jpg',
2848 'image/jpg' => 'jpg',
2849 'image/png' => 'png',
2850 'image/webp' => 'webp',
2851 'image/gif' => 'gif',
2852 ];
2853 $mime_type = strtolower(trim((string) $mime_type));
2854 if (isset($mime_to_ext[$mime_type])) {
2855 $extension = $mime_to_ext[$mime_type];
2856 } else {
2857 // Unknown/unsupported type: fall back to png and normalize the stored
2858 // mime so the attachment record and the file extension stay consistent.
2859 $extension = 'png';
2860 $mime_type = 'image/png';
2861 }
2862 $filename = sanitize_file_name($prefix . '-' . wp_generate_uuid4() . '.' . $extension);
2863 $decoded = base64_decode($base64_data);
2864
2865 if ($decoded === false) {
2866 return new \WP_Error('decode_failed', esc_html__('Failed to decode image data.', 'mxchat'));
2867 }
2868
2869 $upload = wp_upload_bits($filename, null, $decoded);
2870
2871 if (!empty($upload['error'])) {
2872 return new \WP_Error('upload_failed', $upload['error']);
2873 }
2874
2875 $attach_id = wp_insert_attachment([
2876 'post_mime_type' => $mime_type,
2877 'post_title' => $prefix,
2878 'post_content' => '',
2879 'post_status' => 'inherit',
2880 ], $upload['file']);
2881
2882 if (is_wp_error($attach_id)) {
2883 return $attach_id;
2884 }
2885
2886 require_once ABSPATH . 'wp-admin/includes/image.php';
2887 $metadata = wp_generate_attachment_metadata($attach_id, $upload['file']);
2888 wp_update_attachment_metadata($attach_id, $metadata);
2889
2890 return esc_url_raw(wp_get_attachment_url($attach_id));
2891 }
2892
2893 private function mxchat_generate_openai_image($prompt, $api_key, $model = 'gpt-image-1', $timeout = 60) {
2894 $api_url = 'https://api.openai.com/v1/images/generations';
2895 $body = json_encode([
2896 'prompt' => sanitize_text_field($prompt),
2897 'n' => 1,
2898 'size' => '1024x1024',
2899 'quality' => 'medium',
2900 'output_format' => 'png',
2901 'model' => sanitize_text_field($model),
2902 ]);
2903
2904 $args = [
2905 'body' => $body,
2906 'headers' => [
2907 'Content-Type' => 'application/json',
2908 'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
2909 ],
2910 'method' => 'POST',
2911 'timeout' => absint($timeout),
2912 ];
2913
2914 $response = wp_remote_post($api_url, $args);
2915
2916 if (is_wp_error($response)) {
2917 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
2918 }
2919
2920 $response_body = json_decode(wp_remote_retrieve_body($response), true);
2921
2922 $b64 = $response_body['data'][0]['b64_json'] ?? $response_body['data'][0]['b64'] ?? null;
2923 if ($b64) {
2924 $saved_url = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-openai');
2925 if (is_wp_error($saved_url)) {
2926 return ['error' => $saved_url->get_error_message()];
2927 }
2928 return ['imageUrl' => $saved_url];
2929 } else {
2930 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
2931 }
2932 }
2933
2934 /**
2935 * Generate an image via a Custom (OpenAI-compatible) provider's /images/generations route.
2936 * Only called when the opt-in 'custom_provider_for_images' setting is on.
2937 */
2938 private function mxchat_generate_custom_image($prompt, $timeout = 90) {
2939 $cfg = $this->mxchat_resolve_custom_provider();
2940 if (empty($cfg['base_url'])) {
2941 return ['error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat')];
2942 }
2943 $url = $cfg['base_url'] . '/images/generations';
2944 if (!empty($cfg['api_version'])) {
2945 $url .= (strpos($url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($cfg['api_version']);
2946 }
2947 $body = wp_json_encode([
2948 'prompt' => sanitize_text_field($prompt),
2949 'n' => 1,
2950 'size' => '1024x1024',
2951 'model' => $cfg['model'],
2952 ]);
2953 $response = wp_remote_post($url, [
2954 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
2955 'body' => $body,
2956 'method' => 'POST',
2957 'timeout' => absint($timeout),
2958 ]);
2959 if (is_wp_error($response)) {
2960 return ['error' => esc_html__('Error generating image (custom provider): ', 'mxchat') . $response->get_error_message()];
2961 }
2962 $resp = json_decode(wp_remote_retrieve_body($response), true);
2963 // Try b64 first (matches OpenAI shape), then url-based fallback.
2964 $b64 = $resp['data'][0]['b64_json'] ?? $resp['data'][0]['b64'] ?? null;
2965 if ($b64) {
2966 $saved = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-custom');
2967 if (is_wp_error($saved)) {
2968 return ['error' => $saved->get_error_message()];
2969 }
2970 return ['imageUrl' => $saved];
2971 }
2972 $remote_url = $resp['data'][0]['url'] ?? null;
2973 if ($remote_url) {
2974 return ['imageUrl' => esc_url_raw($remote_url)];
2975 }
2976 $err_msg = $resp['error']['message'] ?? esc_html__('Custom provider did not return an image.', 'mxchat');
2977 return ['error' => esc_html($err_msg)];
2978 }
2979
2980 private function mxchat_generate_imagen_image($prompt, $api_key, $timeout = 60) {
2981 $api_url = 'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict';
2982
2983 $body = json_encode([
2984 'instances' => [['prompt' => sanitize_text_field($prompt)]],
2985 'parameters' => [
2986 'sampleCount' => 1,
2987 'aspectRatio' => '1:1',
2988 ],
2989 ]);
2990
2991 $args = [
2992 'body' => $body,
2993 'headers' => [
2994 'Content-Type' => 'application/json',
2995 'x-goog-api-key' => sanitize_text_field($api_key),
2996 ],
2997 'method' => 'POST',
2998 'timeout' => absint($timeout),
2999 ];
3000
3001 $response = wp_remote_post($api_url, $args);
3002
3003 if (is_wp_error($response)) {
3004 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
3005 }
3006
3007 $response_body = json_decode(wp_remote_retrieve_body($response), true);
3008
3009 $b64 = $response_body['predictions'][0]['bytesBase64Encoded'] ?? $response_body['predictions'][0]['imageBytes'] ?? null;
3010 if ($b64) {
3011 $mime = $response_body['predictions'][0]['mimeType'] ?? 'image/png';
3012 $saved_url = $this->mxchat_save_generated_image($b64, $mime, 'mxchat-gemini');
3013 if (is_wp_error($saved_url)) {
3014 return ['error' => $saved_url->get_error_message()];
3015 }
3016 return ['imageUrl' => $saved_url];
3017 } else {
3018 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
3019 }
3020 }
3021
3022 /**
3023 * Handle web search requests.
3024 *
3025 * Sends the refined search query to the Brave Search API and uses the
3026 * results to generate a conversational response with the AI model.
3027 *
3028 * @since 1.0.0
3029 * @param string $message The user's search query.
3030 * @param string $user_id The user identifier.
3031 * @param string $session_id The current session ID.
3032 * @return array Response array containing text with embedded HTML links
3033 */
3034 public function mxchat_handle_search_request($message, $user_id, $session_id) {
3035 // Step 1: Interpret and refine the search query
3036 $refined_search_query = $this->mxchat_interpret_search_query($message);
3037 if (empty($refined_search_query)) {
3038 return array(
3039 'text' => esc_html__('I apologize, but could you please rephrase your search request?', 'mxchat'),
3040 'html' => ''
3041 );
3042 }
3043
3044 // Retrieve and validate API settings
3045 $options = get_option('mxchat_options');
3046 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
3047 $results_count = isset($options['brave_results_count']) ? absint($options['brave_results_count']) : 5;
3048
3049 if (empty($api_key)) {
3050 return array(
3051 'text' => esc_html__('Search functionality is temporarily unavailable. Please try again later.', 'mxchat'),
3052 'html' => ''
3053 );
3054 }
3055
3056 // Build the API request URL
3057 $api_url = add_query_arg(
3058 array(
3059 'q' => rawurlencode($refined_search_query),
3060 'count' => $results_count,
3061 'text_decorations' => 'true',
3062 'rich_data' => 'true',
3063 ),
3064 'https://api.search.brave.com/res/v1/web/search'
3065 );
3066
3067 // Attempt to retrieve cached results first
3068 $transient_key = 'mxchat_search_' . md5($refined_search_query);
3069 $results = get_transient($transient_key);
3070
3071 if (false === $results) {
3072 // SECURITY FIX: Changed to wp_safe_remote_get
3073 $response = wp_safe_remote_get(
3074 $api_url,
3075 array(
3076 'headers' => array(
3077 'Accept' => 'application/json',
3078 'Accept-Encoding' => 'gzip',
3079 'X-Subscription-Token'=> $api_key,
3080 ),
3081 'timeout' => 10,
3082 )
3083 );
3084
3085 if (is_wp_error($response)) {
3086 return array(
3087 'text' => esc_html__('I encountered an error while searching. Please try again.', 'mxchat'),
3088 'html' => ''
3089 );
3090 }
3091
3092 $results = json_decode(wp_remote_retrieve_body($response), true);
3093
3094 if (json_last_error() !== JSON_ERROR_NONE) {
3095 return array(
3096 'text' => esc_html__('I received an invalid response from the search service.', 'mxchat'),
3097 'html' => ''
3098 );
3099 }
3100
3101 // Cache results for one hour
3102 set_transient($transient_key, $results, HOUR_IN_SECONDS);
3103 }
3104
3105 // Process results
3106 if (!empty($results['web']['results']) && is_array($results['web']['results'])) {
3107 // Create a more straightforward summary with HTML links
3108 $search_results_text = '';
3109
3110 // Add a simple intro
3111 $search_results_text .= sprintf(
3112 esc_html__("Here's what I found about '%s':", 'mxchat'),
3113 esc_html($refined_search_query)
3114 );
3115
3116 // Add the top results with HTML links
3117 foreach (array_slice($results['web']['results'], 0, 5) as $result) {
3118 $title = isset($result['title']) ? wp_strip_all_tags($result['title']) : '';
3119 $url = isset($result['url']) ? esc_url($result['url']) : '';
3120 $description = isset($result['description']) ? wp_strip_all_tags($result['description']) : '';
3121
3122 // Add a line break after the intro
3123 $search_results_text .= '<br><br>';
3124
3125 // Add title as a link
3126 $search_results_text .= sprintf(
3127 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a><br>',
3128 $url,
3129 $title
3130 );
3131
3132 // Add a condensed description
3133 $search_results_text .= sprintf("%s", $description);
3134 }
3135
3136 // Save to chat history
3137 $this->mxchat_save_chat_message($session_id, 'bot', $search_results_text);
3138
3139 // Return the formatted text with embedded HTML links
3140 return array(
3141 'text' => $search_results_text,
3142 'html' => ''
3143 );
3144 } else {
3145 return array(
3146 'text' => sprintf(
3147 esc_html__('I searched for "%s" but couldn\'t find any relevant results. Would you like to try different search terms?', 'mxchat'),
3148 esc_html($refined_search_query)
3149 ),
3150 'html' => ''
3151 );
3152 }
3153 }
3154
3155 //very good
3156 /**
3157 * Handle image search requests from the chatbot
3158 *
3159 * @param string $message The user's search query
3160 * @param int $user_id The user's ID
3161 * @param string $session_id The chat session ID
3162 * @return array Response array with text and HTML content
3163 */
3164 public function mxchat_handle_image_search_request($message, $user_id, $session_id) {
3165 // Step 1: Interpret the search query using the user's selected AI model
3166 $refined_search_query = $this->mxchat_interpret_search_query($message);
3167
3168 // If no query was interpreted, return a fallback message
3169 if (empty($refined_search_query)) {
3170 return array(
3171 'text' => __("I'm sorry, I couldn't interpret your search query. Please specify what you'd like to see images of.", 'mxchat'),
3172 'html' => "",
3173 );
3174 }
3175
3176 // Brave API URL
3177 $api_url = 'https://api.search.brave.com/res/v1/images/search';
3178
3179 // Retrieve Brave API settings
3180 $options = get_option('mxchat_options');
3181 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
3182
3183 if (empty($api_key)) {
3184 return array(
3185 'text' => __("API key is not configured. Please set it in the Brave Search Settings.", 'mxchat'),
3186 'html' => "",
3187 );
3188 }
3189
3190 $image_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
3191 $safe_search = isset($options['brave_safe_search']) ? sanitize_text_field($options['brave_safe_search']) : 'strict';
3192
3193 // Append query parameters based on settings
3194 $api_url = add_query_arg([
3195 'q' => rawurlencode($refined_search_query),
3196 'count' => $image_count,
3197 'safesearch' => $safe_search,
3198 ], $api_url);
3199
3200 // Implement caching
3201 $transient_key = 'mxchat_image_search_' . md5($refined_search_query);
3202 $body = get_transient($transient_key);
3203
3204 if (false === $body) {
3205 $args = [
3206 'headers' => [
3207 'Accept' => 'application/json',
3208 'Accept-Encoding' => 'gzip',
3209 'X-Subscription-Token' => $api_key,
3210 ],
3211 'timeout' => 10,
3212 ];
3213
3214 // SECURITY FIX: Changed to wp_safe_remote_get
3215 $response = wp_safe_remote_get($api_url, $args);
3216
3217 if (is_wp_error($response)) {
3218 return array(
3219 'text' => __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat'),
3220 'html' => "",
3221 );
3222 }
3223
3224 $body = json_decode(wp_remote_retrieve_body($response), true);
3225 set_transient($transient_key, $body, HOUR_IN_SECONDS);
3226 }
3227
3228 // Process the API response
3229 if (isset($body['results']) && is_array($body['results']) && count($body['results']) > 0) {
3230 $html_output = '<div class="mxchat-image-gallery">';
3231
3232 // Get the configured image count (1-6)
3233 $display_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
3234 $display_count = min($display_count, count($body['results'])); // Make sure we don't exceed available images
3235
3236 // Use only the requested number of images
3237 for ($i = 0; $i < $display_count; $i++) {
3238 $image = $body['results'][$i];
3239 $image_url = isset($image['url']) ? esc_url($image['url']) : '';
3240 $thumbnail_url = isset($image['thumbnail']['src']) ? esc_url($image['thumbnail']['src']) : '';
3241 $title = isset($image['title']) ? esc_html($image['title']) : esc_html__('Image', 'mxchat');
3242
3243 if ($image_url && $thumbnail_url) {
3244 $html_output .= '<div class="mxchat-image-item">';
3245 $html_output .= '<strong class="mxchat-image-title">' . $title . '</strong>';
3246 $html_output .= '<a href="' . $image_url . '" target="_blank" rel="noopener noreferrer" class="mxchat-image-link">';
3247 $html_output .= '<img src="' . $thumbnail_url . '" alt="' . $title . '" class="mxchat-image-thumbnail">';
3248 $html_output .= '</a></div>';
3249 }
3250 }
3251
3252 $html_output .= '</div>';
3253
3254 // Create response text
3255 $response_text = sprintf(__("Here are some images of %s:", 'mxchat'), $refined_search_query);
3256
3257 // Save both response text and HTML to chat history
3258 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
3259 $this->mxchat_save_chat_message($session_id, 'bot', $html_output);
3260
3261 // Return the combined response
3262 return array(
3263 'text' => $response_text,
3264 'html' => $html_output,
3265 );
3266 } else {
3267 $response_text = __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat');
3268
3269 // Save the error message to chat history
3270 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
3271
3272 return array(
3273 'text' => $response_text,
3274 'html' => "",
3275 );
3276 }
3277 }
3278
3279 /**
3280 * Interpret the search query using the user's selected AI model
3281 *
3282 * @param string $user_query The original query from the user
3283 * @return string The refined search query
3284 */
3285 public function mxchat_interpret_search_query($user_query) {
3286 $system_prompt = esc_html__("Interpret the user's request to provide only the essential keywords or phrases for image searching. Remove conversational language, politeness, or extra context. Return a concise search query that doesn't lose any of the original meaning.", 'mxchat');
3287
3288 // Get options and determine the selected model
3289 $options = $this->options ?? get_option('mxchat_options');
3290 $selected_model = isset($options['model']) ? $options['model'] : 'gpt-5.1-chat-latest';
3291
3292 // Custom (OpenAI-compatible) provider routes by model id, not prefix.
3293 if ($selected_model === 'custom-provider') {
3294 return $this->interpret_query_with_custom($user_query, $system_prompt);
3295 }
3296
3297 // Extract model prefix to determine the provider
3298 $model_parts = explode('-', $selected_model);
3299 $provider = strtolower($model_parts[0]);
3300
3301 // Determine which API key to use based on the provider
3302 switch ($provider) {
3303 case 'gemini':
3304 $api_key = isset($options['gemini_api_key']) ? sanitize_text_field($options['gemini_api_key']) : '';
3305 if (empty($api_key)) {
3306 return sanitize_text_field($user_query); // Default to original query if API key missing
3307 }
3308 return $this->interpret_query_with_gemini($user_query, $system_prompt, $api_key, $selected_model);
3309
3310 case 'claude':
3311 $api_key = isset($options['claude_api_key']) ? sanitize_text_field($options['claude_api_key']) : '';
3312 if (empty($api_key)) {
3313 return sanitize_text_field($user_query);
3314 }
3315 return $this->interpret_query_with_claude($user_query, $system_prompt, $api_key, $selected_model);
3316
3317 case 'grok':
3318 $api_key = isset($options['xai_api_key']) ? sanitize_text_field($options['xai_api_key']) : '';
3319 if (empty($api_key)) {
3320 return sanitize_text_field($user_query);
3321 }
3322 return $this->interpret_query_with_xai($user_query, $system_prompt, $api_key, $selected_model);
3323
3324 case 'deepseek':
3325 $api_key = isset($options['deepseek_api_key']) ? sanitize_text_field($options['deepseek_api_key']) : '';
3326 if (empty($api_key)) {
3327 return sanitize_text_field($user_query);
3328 }
3329 return $this->interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $selected_model);
3330
3331 case 'gpt':
3332 default:
3333 // Default to OpenAI for custom models or unrecognized prefixes
3334 $api_key = isset($options['api_key']) ? sanitize_text_field($options['api_key']) : '';
3335 if (empty($api_key)) {
3336 return sanitize_text_field($user_query);
3337 }
3338 return $this->interpret_query_with_openai($user_query, $system_prompt, $api_key, $selected_model);
3339 }
3340 }
3341
3342 /**
3343 * Interpret query against the configured Custom (OpenAI-compatible) provider.
3344 * Uses the same base URL + auth scheme as the chat dispatcher.
3345 */
3346 private function interpret_query_with_custom($user_query, $system_prompt) {
3347 $cfg = $this->mxchat_resolve_custom_provider();
3348 if (empty($cfg['base_url'])) {
3349 return sanitize_text_field($user_query);
3350 }
3351 $args = [
3352 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
3353 'body' => wp_json_encode([
3354 'model' => $cfg['model'],
3355 'messages' => [
3356 ['role' => 'system', 'content' => $system_prompt],
3357 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3358 ],
3359 'temperature' => 0.2,
3360 'max_tokens' => 20,
3361 ]),
3362 'method' => 'POST',
3363 'timeout' => 15,
3364 ];
3365 $response = wp_remote_post($cfg['chat_url'], $args);
3366 if (is_wp_error($response)) {
3367 return sanitize_text_field($user_query);
3368 }
3369 $body = json_decode(wp_remote_retrieve_body($response), true);
3370 return isset($body['choices'][0]['message']['content'])
3371 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
3372 : sanitize_text_field($user_query);
3373 }
3374
3375 /**
3376 * Convert the colon-style header list returned by mxchat_resolve_custom_provider
3377 * into the assoc-array form wp_remote_post expects.
3378 */
3379 private function mxchat_custom_provider_assoc_headers($cfg) {
3380 $headers = ['Content-Type' => 'application/json'];
3381 if (!empty($cfg['api_key'])) {
3382 if (($cfg['auth_scheme'] ?? 'bearer') === 'api-key') {
3383 $headers['api-key'] = $cfg['api_key'];
3384 } else {
3385 $headers['Authorization'] = 'Bearer ' . $cfg['api_key'];
3386 }
3387 }
3388 return $headers;
3389 }
3390
3391 /**
3392 * Interpret query using OpenAI models
3393 */
3394 private function interpret_query_with_openai($user_query, $system_prompt, $api_key, $model = 'gpt-5.1-chat-latest') {
3395 $url = 'https://api.openai.com/v1/chat/completions';
3396 $args = [
3397 'headers' => [
3398 'Authorization' => 'Bearer ' . $api_key,
3399 'Content-Type' => 'application/json',
3400 ],
3401 'body' => wp_json_encode([
3402 'model' => $model,
3403 'messages' => [
3404 ['role' => 'system', 'content' => $system_prompt],
3405 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3406 ],
3407 'temperature' => 0.2,
3408 'max_tokens' => 20,
3409 ]),
3410 'method' => 'POST',
3411 'timeout' => 15,
3412 ];
3413
3414 $response = wp_remote_post($url, $args);
3415 if (is_wp_error($response)) {
3416 return sanitize_text_field($user_query);
3417 }
3418
3419 $body = json_decode(wp_remote_retrieve_body($response), true);
3420 return isset($body['choices'][0]['message']['content'])
3421 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
3422 : sanitize_text_field($user_query);
3423 }
3424
3425 /**
3426 * Anthropic removed temperature/top_p/top_k starting with Opus 4.7 (the API
3427 * returns 400 if sent) — add new flagship model ids here. (We don't send
3428 * top_p/top_k in any Claude body, so the list only needs to gate temperature
3429 * stripping. We never send a `thinking` param either, which is required for
3430 * claude-fable-5: it rejects an explicit thinking "disabled" — omit only.)
3431 */
3432 private function mxchat_claude_omits_temperature($model) {
3433 $no_temp = array('claude-opus-4-7', 'claude-opus-4-8', 'claude-fable-5', 'claude-sonnet-5');
3434 return in_array($model, $no_temp, true);
3435 }
3436
3437 /**
3438 * Interpret query using Claude models
3439 */
3440 private function interpret_query_with_claude($user_query, $system_prompt, $api_key, $model) {
3441 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
3442 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
3443 if ($model === 'claude-opus-4-20250514') { $model = 'claude-opus-4-8'; }
3444 elseif ($model === 'claude-sonnet-4-20250514') { $model = 'claude-sonnet-4-6'; }
3445 $url = 'https://api.anthropic.com/v1/messages';
3446
3447 $payload = [
3448 'model' => $model,
3449 'system' => $system_prompt,
3450 'messages' => [
3451 ['role' => 'user', 'content' => sanitize_text_field($user_query)]
3452 ],
3453 'max_tokens' => 20,
3454 'temperature' => 0.2,
3455 ];
3456 if ($this->mxchat_claude_omits_temperature($model)) { unset($payload['temperature']); }
3457
3458 $args = [
3459 'headers' => [
3460 'Content-Type' => 'application/json',
3461 'x-api-key' => $api_key,
3462 'anthropic-version' => '2023-06-01',
3463 ],
3464 'body' => wp_json_encode($payload),
3465 'method' => 'POST',
3466 'timeout' => 15,
3467 ];
3468
3469 $response = wp_remote_post($url, $args);
3470 if (is_wp_error($response)) {
3471 return sanitize_text_field($user_query);
3472 }
3473
3474 $body = json_decode(wp_remote_retrieve_body($response), true);
3475 // claude-fable-5 prepends a thinking block to content — take the first
3476 // TEXT block, not content[0].
3477 foreach ((array) ($body['content'] ?? array()) as $block) {
3478 if (isset($block['type'], $block['text']) && $block['type'] === 'text' && trim($block['text']) !== '') {
3479 return sanitize_text_field(trim($block['text']));
3480 }
3481 }
3482
3483 return sanitize_text_field($user_query);
3484 }
3485
3486 /**
3487 * Interpret query using Gemini models
3488 */
3489 private function interpret_query_with_gemini($user_query, $system_prompt, $api_key, $model) {
3490 if ($model === 'gemini-3-pro-preview') {
3491 $model = 'gemini-3.1-pro-preview';
3492 }
3493 // Use v1beta for preview models, v1 for stable models
3494 $api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
3495
3496 $url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key=" . urlencode($api_key);
3497
3498 $args = [
3499 'headers' => [
3500 'Content-Type' => 'application/json',
3501 ],
3502 'body' => wp_json_encode([
3503 'contents' => [
3504 [
3505 'role' => 'user',
3506 'parts' => [
3507 ['text' => $system_prompt . "\n\nQuery: " . sanitize_text_field($user_query)]
3508 ]
3509 ]
3510 ],
3511 'generationConfig' => [
3512 'temperature' => 0.2,
3513 'maxOutputTokens' => 20,
3514 ],
3515 ]),
3516 'method' => 'POST',
3517 'timeout' => 15,
3518 ];
3519
3520 $response = wp_remote_post($url, $args);
3521 if (is_wp_error($response)) {
3522 return sanitize_text_field($user_query);
3523 }
3524
3525 $body = json_decode(wp_remote_retrieve_body($response), true);
3526 if (!empty($body['candidates'][0]['content']['parts'][0]['text'])) {
3527 return sanitize_text_field(trim($body['candidates'][0]['content']['parts'][0]['text']));
3528 }
3529
3530 return sanitize_text_field($user_query);
3531 }
3532
3533 /**
3534 * Interpret query using X.AI (Grok) models
3535 */
3536 private function interpret_query_with_xai($user_query, $system_prompt, $api_key, $model) {
3537 $url = 'https://api.xai.com/v1/chat/completions';
3538
3539 $args = [
3540 'headers' => [
3541 'Content-Type' => 'application/json',
3542 'Authorization' => 'Bearer ' . $api_key,
3543 ],
3544 'body' => wp_json_encode([
3545 'model' => $model,
3546 'messages' => [
3547 ['role' => 'system', 'content' => $system_prompt],
3548 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3549 ],
3550 'temperature' => 0.2,
3551 'max_tokens' => 20,
3552 ]),
3553 'method' => 'POST',
3554 'timeout' => 15,
3555 ];
3556
3557 $response = wp_remote_post($url, $args);
3558 if (is_wp_error($response)) {
3559 return sanitize_text_field($user_query);
3560 }
3561
3562 $body = json_decode(wp_remote_retrieve_body($response), true);
3563 if (isset($body['choices'][0]['message']['content'])) {
3564 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
3565 }
3566
3567 return sanitize_text_field($user_query);
3568 }
3569
3570 /**
3571 * Interpret query using DeepSeek models
3572 */
3573 private function interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $model) {
3574 $url = 'https://api.deepseek.com/v1/chat/completions';
3575
3576 $args = [
3577 'headers' => [
3578 'Content-Type' => 'application/json',
3579 'Authorization' => 'Bearer ' . $api_key,
3580 ],
3581 'body' => wp_json_encode([
3582 'model' => $model,
3583 'messages' => [
3584 ['role' => 'system', 'content' => $system_prompt],
3585 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3586 ],
3587 'temperature' => 0.2,
3588 'max_tokens' => 20,
3589 ]),
3590 'method' => 'POST',
3591 'timeout' => 15,
3592 ];
3593
3594 $response = wp_remote_post($url, $args);
3595 if (is_wp_error($response)) {
3596 return sanitize_text_field($user_query);
3597 }
3598
3599 $body = json_decode(wp_remote_retrieve_body($response), true);
3600 if (isset($body['choices'][0]['message']['content'])) {
3601 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
3602 }
3603
3604 return sanitize_text_field($user_query);
3605 }
3606
3607 //very good
3608 private function add_email_to_loops($email) {
3609 // Sanitize the email
3610 $email = sanitize_email($email);
3611
3612 // Retrieve and sanitize options
3613 $api_key = isset($this->options['loops_api_key']) ? sanitize_text_field($this->options['loops_api_key']) : '';
3614 $mailing_list_id = isset($this->options['loops_mailing_list']) ? sanitize_text_field($this->options['loops_mailing_list']) : '';
3615
3616 // Check for missing API key or mailing list ID
3617 if (empty($api_key) || empty($mailing_list_id)) {
3618 //error_log(esc_html__('Loops API key or mailing list ID is missing.', 'mxchat'));
3619 return;
3620 }
3621
3622 $data = array(
3623 'email' => $email,
3624 'subscribed' => true,
3625 'source' => __('MxChat AI Chatbot', 'mxchat'),
3626 'mailingLists' => array($mailing_list_id => true),
3627 );
3628
3629 $url = 'https://app.loops.so/api/v1/contacts/create';
3630 $args = array(
3631 'body' => wp_json_encode($data),
3632 'headers' => array(
3633 'Authorization' => 'Bearer ' . $api_key,
3634 'Content-Type' => 'application/json',
3635 ),
3636 'method' => 'POST',
3637 'timeout' => 45,
3638 );
3639
3640 $response = wp_remote_post($url, $args);
3641
3642 // Handle errors in the API request
3643 if (is_wp_error($response)) {
3644 //error_log(esc_html__('Error adding email to Loops: ', 'mxchat') . $response->get_error_message());
3645 return;
3646 }
3647
3648 // Check for non-200 HTTP responses
3649 $response_code = wp_remote_retrieve_response_code($response);
3650 if ($response_code != 200) {
3651 $response_body = wp_remote_retrieve_body($response);
3652 //error_log(esc_html__('Loops API responded with code ', 'mxchat') . $response_code . ': ' . $response_body);
3653 }
3654 }
3655
3656 public function mxchat_handle_pdf_discussion($message, $user_id, $session_id) {
3657 // Get the maximum number of pages allowed from admin settings
3658 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3659
3660 // Retrieve options for dynamic texts
3661 $trigger_text = $this->options['pdf_intent_trigger_text'] ?? __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
3662 $success_text = $this->options['pdf_intent_success_text'] ?? __("I've processed the PDF. What questions do you have about it?", 'mxchat');
3663 $error_text = $this->options['pdf_intent_error_text'] ?? __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
3664
3665 // Check for explicit request for new PDF
3666 $new_pdf_requested = stripos($message, 'new') !== false ||
3667 stripos($message, 'another') !== false ||
3668 stripos($message, 'different') !== false;
3669
3670 // If user mentions adding/reading a PDF, set waiting flag
3671 if (stripos($message, 'pdf') !== false ||
3672 stripos($message, 'document') !== false ||
3673 stripos($message, 'read') !== false) {
3674 set_transient('mxchat_waiting_for_pdf_url_' . $session_id, true, HOUR_IN_SECONDS);
3675 $this->fallbackResponse['text'] = $trigger_text;
3676 return;
3677 }
3678
3679 // If we're waiting for a URL or user requested new PDF
3680 if ($new_pdf_requested || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
3681 if (preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
3682 // Process URL... (rest of your existing URL processing code)
3683 } else {
3684 $this->fallbackResponse['text'] = $trigger_text;
3685 }
3686 return;
3687 }
3688
3689 // Default to proceeding with conversation if no specific PDF action is needed
3690 $this->fallbackResponse['text'] = '';
3691 }
3692
3693
3694 /**
3695 * Enhanced fetch_and_split_pdf_pages with SSRF protection
3696 */
3697 private function fetch_and_split_pdf_pages($pdf_source, $max_pages) {
3698 // CLEAR DEBUG LOGGING
3699 //error_log("=== MXCHAT PDF PROCESSING START ===");
3700 //error_log("PDF Source: " . $pdf_source);
3701 //error_log("Max Pages: " . $max_pages);
3702 //error_log("Session ID: " . ($this->session_id ?? 'not set'));
3703
3704 // Check if Advanced Claude Toolbar is available and enabled
3705 $claude_available = function_exists('mxchatACT_is_advanced_claude_enabled');
3706 $claude_enabled = $claude_available ? mxchatACT_is_advanced_claude_enabled() : false;
3707
3708 //error_log("Claude Function Available: " . ($claude_available ? 'YES' : 'NO'));
3709 //error_log("Claude Enabled: " . ($claude_enabled ? 'YES' : 'NO'));
3710
3711 if ($claude_available && $claude_enabled) {
3712 //error_log("🚀 ATTEMPTING CLAUDE PROCESSING...");
3713
3714 // Attempt Claude processing first
3715 $claude_result = apply_filters('mxchat_process_pdf_advanced', false, $pdf_source, $max_pages, $this->session_id);
3716
3717 if ($claude_result !== false && is_array($claude_result) && !empty($claude_result)) {
3718 //error_log("✅ CLAUDE PROCESSING SUCCESSFUL!");
3719 //error_log("Claude returned " . count($claude_result) . " processed pages");
3720
3721 // Log first page details for verification
3722 if (isset($claude_result[0])) {
3723 $first_page = $claude_result[0];
3724 //error_log("First page enhanced: " . (isset($first_page['enhanced']) && $first_page['enhanced'] ? 'YES' : 'NO'));
3725 //error_log("Processing method: " . ($first_page['processing_method'] ?? 'not set'));
3726 //error_log("First page text preview: " . substr($first_page['text'] ?? '', 0, 100) . "...");
3727 }
3728
3729 //error_log("=== MXCHAT PDF PROCESSING END (CLAUDE) ===");
3730 return $claude_result;
3731 } else {
3732 //error_log("❌ CLAUDE PROCESSING FAILED or returned invalid result");
3733 //error_log("Claude result type: " . gettype($claude_result));
3734 if (is_array($claude_result)) {
3735 //error_log("Claude result count: " . count($claude_result));
3736 }
3737 }
3738 }
3739
3740 // Fallback to basic processing
3741 //error_log("🔄 FALLING BACK TO BASIC PDF PROCESSING...");
3742
3743 $upload_dir = wp_upload_dir();
3744 $temp_file = null;
3745
3746 try {
3747 // Your existing basic processing code here...
3748 // (I'll include the key parts with debug logging)
3749
3750 if (filter_var($pdf_source, FILTER_VALIDATE_URL)) {
3751 //error_log("Downloading PDF from URL...");
3752
3753 // SECURITY FIX: Validate URL before processing
3754 if (!$this->mxchat_is_safe_pdf_url($pdf_source)) {
3755 //error_log("❌ SECURITY: Blocked unsafe PDF URL");
3756 return false;
3757 }
3758
3759 $temp_file = wp_tempnam($pdf_source);
3760
3761 // SECURITY FIX: Changed from wp_remote_get to wp_safe_remote_get
3762 // Route through the shared MXChat crawler identity (plan bae78f/b6d93c) so
3763 // every remote-content fetch presents one honest, versioned, filterable,
3764 // allowlistable User-Agent. function_exists guard keeps the front-end/nopriv
3765 // path safe if the helper (in the always-loaded main file) is ever unavailable.
3766 $response = wp_safe_remote_get($pdf_source, [
3767 'timeout' => 60,
3768 'headers' => ['User-Agent' => function_exists('mxchat_ingest_user_agent') ? mxchat_ingest_user_agent() : 'MxChat PDF Processor']
3769 ]);
3770
3771 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
3772 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code($response);
3773 //error_log("❌ BASIC PROCESSING: Failed to download PDF: " . $error_message);
3774 return false;
3775 }
3776
3777 global $wp_filesystem;
3778 if (empty($wp_filesystem)) {
3779 require_once ABSPATH . 'wp-admin/includes/file.php';
3780 WP_Filesystem();
3781 }
3782 $wp_filesystem->put_contents($temp_file, wp_remote_retrieve_body($response), FS_CHMOD_FILE);
3783 //error_log("✅ PDF downloaded successfully");
3784 } else {
3785 $temp_file = $pdf_source;
3786 //error_log("Using local PDF file: " . $temp_file);
3787 }
3788
3789 // Parse PDF
3790 //error_log("Parsing PDF with basic parser...");
3791 mxchat_load_pdf_parser();
3792 $parser = new \Smalot\PdfParser\Parser();
3793 $pdf = $parser->parseFile($temp_file);
3794 $pages = $pdf->getPages();
3795
3796 //error_log("PDF contains " . count($pages) . " pages");
3797
3798 if (count($pages) > $max_pages) {
3799 //error_log("❌ BASIC PROCESSING: Too many pages (" . count($pages) . " > " . $max_pages . ")");
3800 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file) {
3801 unlink($temp_file);
3802 }
3803 return 'too_many_pages';
3804 }
3805
3806 $embeddings = [];
3807 $processed_pages = 0;
3808
3809 foreach ($pages as $page_number => $page) {
3810 $text = $page->getText();
3811
3812 if (empty(trim($text))) {
3813 //error_log("Skipping empty page: " . ($page_number + 1));
3814 continue;
3815 }
3816
3817 $text = $this->mxchat_clean_text($text);
3818
3819 $embedding = $this->mxchat_generate_embedding(
3820 __("Page ", 'mxchat') . ($page_number + 1) . ": " . $text,
3821 $this->options['api_key']
3822 );
3823
3824 if ($embedding) {
3825 $embeddings[] = [
3826 'page_number' => $page_number + 1,
3827 'embedding' => $embedding,
3828 'text' => $text,
3829 'enhanced' => false, // CLEARLY MARK AS BASIC
3830 'processing_method' => 'basic_pdf_parser'
3831 ];
3832 $processed_pages++;
3833 }
3834 }
3835
3836 //error_log("✅ BASIC PROCESSING COMPLETE: " . $processed_pages . " pages processed");
3837
3838 // Cleanup
3839 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3840 unlink($temp_file);
3841 }
3842
3843 //error_log("=== MXCHAT PDF PROCESSING END (BASIC) ===");
3844 return $embeddings;
3845
3846 } catch (\Exception $e) {
3847 //error_log("❌ BASIC PROCESSING ERROR: " . $e->getMessage());
3848 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3849 unlink($temp_file);
3850 }
3851 //error_log("=== MXCHAT PDF PROCESSING END (ERROR) ===");
3852 return false;
3853 }
3854 }
3855
3856
3857 /**
3858 * Validate PDF URL for security
3859 * Prevents SSRF attacks by blocking dangerous URLs
3860 */
3861
3862 private function mxchat_is_safe_pdf_url($url) {
3863 // Use WordPress core function for comprehensive validation
3864 // This blocks localhost, private IPs, and reserved IP ranges
3865 $validated_url = wp_http_validate_url($url);
3866
3867 if ($validated_url === false) {
3868 return false;
3869 }
3870
3871 // Additional check: only allow HTTP/HTTPS schemes
3872 $parsed = parse_url($url);
3873 if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], ['http', 'https'], true)) {
3874 return false;
3875 }
3876
3877 return true;
3878 }
3879
3880
3881 private function mxchat_clean_text($text) {
3882 // Remove excessive whitespace
3883 $text = preg_replace('/\s+/', ' ', $text);
3884
3885 // Remove control characters except newlines and tabs
3886 $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
3887
3888 // Normalize line endings
3889 $text = str_replace(["\r\n", "\r"], "\n", $text);
3890
3891 // Trim whitespace
3892 $text = trim($text);
3893
3894 return $text;
3895 }
3896
3897 private function find_relevant_pdf_pages($query_embedding, $embeddings) {
3898 //error_log(esc_html__("find_relevant_pdf_pages called.", 'mxchat'));
3899
3900 $most_relevant = null;
3901 $highest_similarity = -INF;
3902
3903 foreach ($embeddings as $page_data) {
3904 $similarity = $this->mxchat_calculate_cosine_similarity($query_embedding, $page_data['embedding']);
3905
3906 if ($similarity > $highest_similarity) {
3907 $highest_similarity = $similarity;
3908 $most_relevant = $page_data['page_number'];
3909 }
3910 }
3911
3912 if (!is_null($most_relevant)) {
3913 $page_numbers = range(max(1, $most_relevant - 1), min(count($embeddings), $most_relevant + 1));
3914 return array_filter($embeddings, function ($page) use ($page_numbers) {
3915 return in_array($page['page_number'], $page_numbers);
3916 });
3917 }
3918
3919 return [];
3920 }
3921
3922
3923 public function handle_pdf_upload() {
3924 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce(wp_unslash((string) $_POST['nonce']))) {
3925 wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'mxchat')), 403);
3926 }
3927
3928 if (!isset($_FILES['pdf_file']) || !isset($_POST['session_id'])) {
3929 wp_send_json_error(esc_html__('Missing required parameters.', 'mxchat'));
3930 return;
3931 }
3932
3933 // SECURITY FIX: Check if PDF uploads are enabled in settings
3934 $options = get_option('mxchat_options', array());
3935 $show_pdf_button = isset($options['show_pdf_upload_button']) ? $options['show_pdf_upload_button'] : 'on';
3936
3937 if ($show_pdf_button !== 'on') {
3938 wp_send_json_error(esc_html__('PDF uploads are currently disabled.', 'mxchat'));
3939 return;
3940 }
3941
3942 $file = $_FILES['pdf_file'];
3943 $session_id = sanitize_text_field($_POST['session_id']);
3944 $original_filename = sanitize_text_field($file['name']);
3945
3946 // Update session owner if it changed (e.g. IP changed due to network switch)
3947 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
3948 $session_owner = get_option("mxchat_session_owner_{$session_id}");
3949
3950 if (!$session_owner || $session_owner !== $current_user_identifier) {
3951 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
3952 }
3953
3954 $file_type = wp_check_filetype($file['name'], ['pdf' => 'application/pdf']);
3955 if ($file_type['type'] !== 'application/pdf') {
3956 wp_send_json_error(esc_html__('Invalid file type. Only PDF files are allowed.', 'mxchat'));
3957 return;
3958 }
3959
3960 $upload_dir = wp_upload_dir();
3961
3962 // SECURITY FIX: Generate random filename without exposing session_id
3963 $random_string = wp_generate_password(20, false, false); // 20 char alphanumeric string
3964 $pdf_filename = 'mxchat_' . $random_string . '_' . time() . '.pdf';
3965 $pdf_path = $upload_dir['path'] . '/' . $pdf_filename;
3966
3967 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
3968 wp_send_json_error(esc_html__('Failed to upload file.', 'mxchat'));
3969 return;
3970 }
3971
3972 $this->clear_pdf_transients($session_id);
3973
3974 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3975 $embeddings = $this->fetch_and_split_pdf_pages($pdf_path, $max_pages);
3976
3977 if ($embeddings === 'too_many_pages') {
3978 unlink($pdf_path);
3979 $error_message = sprintf(
3980 $this->options['pdf_intent_error_text'] ??
3981 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
3982 $max_pages
3983 );
3984 wp_send_json_error($error_message);
3985 return;
3986 }
3987
3988 if ($embeddings === false || empty($embeddings)) {
3989 unlink($pdf_path);
3990 $error_message = $this->options['pdf_intent_error_text'] ??
3991 esc_html__('The uploaded PDF appears to be empty or contains unsupported content.', 'mxchat');
3992 wp_send_json_error($error_message);
3993 return;
3994 }
3995
3996 if (!empty($embeddings)) {
3997 // Store the mapping between session and the random filename
3998 set_transient('mxchat_pdf_url_' . $session_id, $pdf_path, HOUR_IN_SECONDS);
3999 set_transient('mxchat_pdf_filename_' . $session_id, $original_filename, HOUR_IN_SECONDS);
4000 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
4001 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
4002
4003 $success_message = $this->options['pdf_intent_success_text'] ??
4004 esc_html__("I've processed the PDF. What questions do you have about it?", 'mxchat');
4005
4006 wp_send_json_success([
4007 'message' => $success_message,
4008 'filename' => $original_filename
4009 ]);
4010 return;
4011 }
4012
4013 unlink($pdf_path);
4014 $error_message = $this->options['pdf_intent_error_text'] ??
4015 esc_html__('Sorry, I couldn\'t process the PDF. Please ensure it\'s a valid file.', 'mxchat');
4016 wp_send_json_error($error_message);
4017 return;
4018 }
4019 public function handle_pdf_remove() {
4020 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce(wp_unslash((string) $_POST['nonce']))) {
4021 wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'mxchat')), 403);
4022 }
4023
4024 if (empty($_POST['session_id'])) {
4025 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
4026 wp_die();
4027 }
4028
4029 $session_id = sanitize_text_field($_POST['session_id']);
4030 $pdf_path = get_transient('mxchat_pdf_url_' . $session_id);
4031
4032 if ($pdf_path && file_exists($pdf_path)) {
4033 unlink($pdf_path);
4034 }
4035
4036 $this->clear_pdf_transients($session_id);
4037
4038 wp_send_json_success([
4039 'message' => esc_html__('PDF removed successfully.', 'mxchat')
4040 ]);
4041 wp_die();
4042 }
4043
4044
4045 function mxchat_fetch_new_messages() {
4046 $session_id = sanitize_text_field($_POST['session_id']);
4047 $last_seen_id = sanitize_text_field($_POST['last_seen_id']);
4048 $persistence_enabled = $_POST['persistence_enabled'] === 'true';
4049 $initial_timestamp = isset($_POST['initial_timestamp']) ? intval($_POST['initial_timestamp']) : 0;
4050
4051 if (empty($session_id)) {
4052 //error_log(esc_html__('Fetch new messages error: Session ID missing.', 'mxchat'));
4053 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
4054 wp_die();
4055 }
4056
4057 $history = get_option("mxchat_history_{$session_id}", []);
4058
4059 //error_log("MxChat WhatsApp DEBUG: Fetch new messages for session {$session_id}");
4060 //error_log("MxChat WhatsApp DEBUG: last_seen_id = " . var_export($last_seen_id, true));
4061 //error_log("MxChat WhatsApp DEBUG: History count = " . count($history));
4062 //error_log("MxChat WhatsApp DEBUG: Full history = " . print_r($history, true));
4063
4064 $new_messages = array_filter($history, function ($message) use ($last_seen_id, $persistence_enabled, $initial_timestamp) {
4065 //error_log("MxChat WhatsApp DEBUG: Checking message - ID: " . ($message['id'] ?? 'NO_ID') . ", Role: " . ($message['role'] ?? 'NO_ROLE'));
4066
4067 // If persistence is enabled, show all new messages
4068 if ($persistence_enabled) {
4069 $has_id = !empty($message['id']);
4070 $is_agent = $message['role'] === 'agent';
4071
4072 // If last_seen_id is empty, 'NaN', or invalid, show all agent messages
4073 if (empty($last_seen_id) || $last_seen_id === 'NaN' || $last_seen_id === 'undefined') {
4074 $is_newer = true;
4075 } else {
4076 $is_newer = strcmp($message['id'] ?? '', $last_seen_id) > 0;
4077 }
4078
4079 //error_log("MxChat WhatsApp DEBUG: has_id={$has_id}, is_newer={$is_newer}, is_agent={$is_agent}");
4080
4081 return $has_id && $is_newer && $is_agent;
4082 }
4083
4084 // If persistence is disabled, only show messages after initial timestamp
4085 return !empty($message['id']) &&
4086 $message['role'] === 'agent' &&
4087 $message['timestamp'] > $initial_timestamp;
4088 });
4089
4090 //error_log("MxChat WhatsApp DEBUG: Filtered messages count = " . count($new_messages));
4091
4092 // Include current chat mode so frontend can detect agent→AI transitions
4093 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
4094
4095 wp_send_json_success([
4096 'new_messages' => array_values($new_messages),
4097 'chat_mode' => $chat_mode
4098 ]);
4099 wp_die();
4100 }
4101 public function mxchat_live_agent_handover($message, $user_id, $session_id) {
4102 // First check if live agents are available
4103 $live_agent_available = $this->options['live_agent_status'] ?? 'off';
4104 if ($live_agent_available !== 'on') {
4105 $away_message = $this->options['live_agent_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
4106 $this->fallbackResponse = [
4107 'text' => $away_message,
4108 'html' => '',
4109 'images' => [],
4110 'chat_mode' => 'ai'
4111 ];
4112 wp_send_json([
4113 'text' => $away_message,
4114 'html' => '',
4115 'chat_mode' => 'ai',
4116 'session_id' => $session_id
4117 ]);
4118 wp_die();
4119 }
4120
4121 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4122
4123 if (empty($slack_bot_token)) {
4124 return false;
4125 }
4126
4127 // Check if channel already exists for this session
4128 $channel_id = get_option("mxchat_channel_{$session_id}", '');
4129
4130 if (empty($channel_id)) {
4131 // Create new channel with session ID as name
4132 $channel_name = $this->generate_channel_name($session_id);
4133
4134 //error_log("Attempting to create channel: $channel_name");
4135
4136 $response = wp_remote_post('https://slack.com/api/conversations.create', [
4137 'headers' => [
4138 'Content-Type' => 'application/json',
4139 'Authorization' => 'Bearer ' . $slack_bot_token
4140 ],
4141 'body' => json_encode([
4142 'name' => $channel_name,
4143 'is_private' => false // Public channel - anyone in workspace can join
4144 ])
4145 ]);
4146
4147 if (!is_wp_error($response)) {
4148 $response_body = wp_remote_retrieve_body($response);
4149 $response_data = json_decode($response_body, true);
4150
4151 //error_log("Channel creation response: " . $response_body);
4152
4153 if (isset($response_data['ok']) && $response_data['ok']) {
4154 $channel_id = $response_data['channel']['id'];
4155 $actual_channel_name = $response_data['channel']['name'] ?? 'unknown';
4156 //error_log("Channel created successfully: ID=$channel_id, Name=$actual_channel_name");
4157 update_option("mxchat_channel_{$session_id}", $channel_id);
4158
4159 // Auto-invite agents to the channel
4160 $agent_user_ids = $this->options['live_agent_user_ids'] ?? '';
4161
4162 if (!empty($agent_user_ids)) {
4163 // Parse user IDs (one per line)
4164 $user_ids = array_filter(array_map('trim', explode("\n", $agent_user_ids)));
4165
4166 foreach ($user_ids as $user_id_to_invite) {
4167 //error_log("Inviting user to channel: $user_id_to_invite");
4168
4169 $invite_response = wp_remote_post('https://slack.com/api/conversations.invite', [
4170 'headers' => [
4171 'Content-Type' => 'application/json',
4172 'Authorization' => 'Bearer ' . $slack_bot_token
4173 ],
4174 'body' => json_encode([
4175 'channel' => $channel_id,
4176 'users' => $user_id_to_invite
4177 ])
4178 ]);
4179
4180 if (!is_wp_error($invite_response)) {
4181 $invite_body = wp_remote_retrieve_body($invite_response);
4182 $invite_data = json_decode($invite_body, true);
4183 //error_log("Invite response for $user_id_to_invite: " . $invite_body);
4184
4185 if (isset($invite_data['ok']) && $invite_data['ok']) {
4186 //error_log("Successfully invited user $user_id_to_invite to channel");
4187 } else {
4188 //error_log("Failed to invite user $user_id_to_invite: " . ($invite_data['error'] ?? 'Unknown error'));
4189 }
4190 } else {
4191 //error_log("WP Error inviting user $user_id_to_invite: " . $invite_response->get_error_message());
4192 }
4193 }
4194 } else {
4195 //error_log("No agent user IDs configured for auto-invite");
4196 }
4197 } else {
4198 //error_log("Channel creation failed: " . ($response_data['error'] ?? 'Unknown error'));
4199 }
4200 } else {
4201 //error_log("WP Error creating channel: " . $response->get_error_message());
4202 }
4203
4204 if (empty($channel_id)) {
4205 return false; // Failed to create channel
4206 }
4207 }
4208
4209 // Get recent chat history
4210 $history = get_option("mxchat_history_{$session_id}", []);
4211 $recent_history = array_slice($history, -5);
4212
4213 // Format conversation context
4214 $conversation_context = "";
4215 if (!empty($recent_history)) {
4216 $conversation_context = "*Recent Conversation:*\n";
4217 foreach ($recent_history as $hist_message) {
4218 $role_display = $hist_message['role'] === 'user' ? 'User' : 'AI';
4219 $conversation_context .= ">{$role_display}: {$hist_message['content']}\n";
4220 }
4221 $conversation_context .= "\n";
4222 }
4223
4224 update_option("mxchat_mode_{$session_id}", 'agent');
4225
4226 // Send message to channel
4227 $channel_message = "🔔 *New Live Agent Request*\n\n";
4228 $channel_message .= "*Session ID:* `{$session_id}`\n";
4229 $channel_message .= "*User ID:* `{$user_id}`\n";
4230
4231 // Surface the captured visitor identity so the agent knows who they're talking to —
4232 // guest User IDs are 0, but the pre-chat gate / login / transcript often has name+email (plan-e2195b).
4233 $visitor = $this->mxchat_get_visitor_identity($session_id);
4234 if (!empty($visitor['name']) && !empty($visitor['email'])) {
4235 $channel_message .= "*Visitor:* {$visitor['name']} <{$visitor['email']}>\n";
4236 } elseif (!empty($visitor['email'])) {
4237 $channel_message .= "*Visitor:* <{$visitor['email']}>\n";
4238 } elseif (!empty($visitor['name'])) {
4239 $channel_message .= "*Visitor:* {$visitor['name']}\n";
4240 }
4241 $channel_message .= "\n";
4242
4243 if (!empty($conversation_context)) {
4244 $channel_message .= $conversation_context;
4245 }
4246
4247 $channel_message .= "*Current Message:*\n{$message}\n\n";
4248 $channel_message .= "_Reply directly in this channel - all messages will go to the user_";
4249
4250 wp_remote_post('https://slack.com/api/chat.postMessage', [
4251 'headers' => [
4252 'Content-Type' => 'application/json',
4253 'Authorization' => 'Bearer ' . $slack_bot_token
4254 ],
4255 'body' => json_encode([
4256 'channel' => $channel_id,
4257 'text' => $channel_message,
4258 'mrkdwn' => true
4259 ])
4260 ]);
4261
4262 $success_message = $this->options['live_agent_notification_message'] ?? 'Live agent has been notified.';
4263 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
4264
4265 $this->fallbackResponse = [
4266 'text' => $success_message,
4267 'html' => '',
4268 'images' => [],
4269 'chat_mode' => 'agent'
4270 ];
4271
4272 wp_send_json([
4273 'success' => true,
4274 'text' => $success_message,
4275 'html' => '',
4276 'chat_mode' => 'agent',
4277 'session_id' => $session_id,
4278 'fallbackResponse' => $this->fallbackResponse
4279 ]);
4280 wp_die();
4281 }
4282
4283 private function generate_channel_name($session_id) {
4284 $email = null;
4285 $name = null;
4286
4287 // 1. First priority: Check if user is logged in and get their info
4288 if (is_user_logged_in()) {
4289 $current_user = wp_get_current_user();
4290 if (!empty($current_user->user_email)) {
4291 $email = $current_user->user_email;
4292 //error_log("[DEBUG] Using logged-in user email for channel: {$email}");
4293 }
4294 if (!empty($current_user->display_name)) {
4295 $name = $current_user->display_name;
4296 //error_log("[DEBUG] Using logged-in user name for channel: {$name}");
4297 }
4298 }
4299
4300 // 2. Second priority: Check for saved email/name from "require email to chat" option
4301 if (empty($email)) {
4302 $email_option_key = "mxchat_email_{$session_id}";
4303 $saved_email = get_option($email_option_key);
4304 if (!empty($saved_email)) {
4305 $email = $saved_email;
4306 //error_log("[DEBUG] Using saved email from session for channel: {$email}");
4307 }
4308 }
4309
4310 if (empty($name)) {
4311 $name_option_key = "mxchat_name_{$session_id}";
4312 $saved_name = get_option($name_option_key);
4313 if (!empty($saved_name)) {
4314 $name = $saved_name;
4315 //error_log("[DEBUG] Using saved name from session for channel: {$name}");
4316 }
4317 }
4318
4319 // 3. Third priority: Check existing chat transcript for email/name
4320 if (empty($email) || empty($name)) {
4321 global $wpdb;
4322 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
4323 $existing_data = $wpdb->get_row($wpdb->prepare(
4324 "SELECT user_email, user_name FROM $table_name WHERE session_id = %s AND (user_email IS NOT NULL OR user_name IS NOT NULL) LIMIT 1",
4325 $session_id
4326 ));
4327
4328 if ($existing_data) {
4329 if (empty($email) && !empty($existing_data->user_email)) {
4330 $email = $existing_data->user_email;
4331 //error_log("[DEBUG] Using email from chat transcript for channel: {$email}");
4332 }
4333 if (empty($name) && !empty($existing_data->user_name)) {
4334 $name = $existing_data->user_name;
4335 //error_log("[DEBUG] Using name from chat transcript for channel: {$name}");
4336 }
4337 }
4338 }
4339
4340 // 4. Generate channel name based on priority: Name > Email > Session ID
4341 $channel_name = '';
4342
4343 if (!empty($name)) {
4344 // Convert name to valid Slack channel name
4345 $base_name = strtolower(trim($name));
4346 // Replace spaces and invalid characters
4347 $base_name = preg_replace('/[^a-z0-9\s]/', '', $base_name);
4348 $base_name = preg_replace('/\s+/', '-', $base_name);
4349 $base_name = trim($base_name, '-');
4350
4351 // Get last 4 characters of session ID for uniqueness
4352 $session_suffix = substr($session_id, -4);
4353 $channel_name = 'chat-' . $base_name . '-' . strtolower($session_suffix);
4354
4355 // Slack channel names have a 21 character limit
4356 if (strlen($channel_name) > 21) {
4357 // Calculate available space for name (21 - 'chat-' - '-' - session_suffix)
4358 $available_space = 21 - 5 - 1 - strlen($session_suffix); // 'chat-' = 5, '-' = 1
4359 $truncated_name = substr($base_name, 0, $available_space);
4360 $truncated_name = rtrim($truncated_name, '-'); // Remove trailing hyphen
4361 $channel_name = 'chat-' . $truncated_name . '-' . strtolower($session_suffix);
4362 }
4363
4364 //error_log("[DEBUG] Using name for channel: {$channel_name} (from name: {$name})");
4365
4366 } elseif (!empty($email)) {
4367 // Convert email to valid Slack channel name (your existing logic)
4368 $channel_name = 'chat-' . strtolower(str_replace(['@', '.', '+', '_'], ['-at-', '-', '-plus-', '-'], $email));
4369 // Remove any remaining invalid characters
4370 $channel_name = preg_replace('/[^a-z0-9\-]/', '', $channel_name);
4371 // Ensure it doesn't end with a hyphen
4372 $channel_name = rtrim($channel_name, '-');
4373 // Slack channel names have a 21 character limit, so truncate if needed
4374 if (strlen($channel_name) > 21) {
4375 $channel_name = substr($channel_name, 0, 21);
4376 $channel_name = rtrim($channel_name, '-'); // Remove trailing hyphen if truncation created one
4377 }
4378
4379 //error_log("[DEBUG] Using email for channel: {$channel_name} (from email: {$email})");
4380
4381 } else {
4382 // Fallback to session ID if no name or email found
4383 $channel_name = 'chat-' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $session_id));
4384 //error_log("[DEBUG] No name or email found, using session ID for channel: {$channel_name}");
4385 }
4386
4387 // Final validation - ensure channel name meets Slack requirements
4388 if (strlen($channel_name) > 21) {
4389 $channel_name = substr($channel_name, 0, 21);
4390 $channel_name = rtrim($channel_name, '-');
4391 }
4392
4393 //error_log("[DEBUG] Generated channel name: {$channel_name}");
4394 return $channel_name;
4395 }
4396
4397 /**
4398 * Telegram Live Agent Handover
4399 * Creates a forum topic in the Telegram group and notifies agents
4400 */
4401 public function mxchat_telegram_live_agent_handover($message, $user_id, $session_id) {
4402 // Check if Telegram agents are available
4403 $telegram_available = $this->options['telegram_status'] ?? 'off';
4404 if ($telegram_available !== 'on') {
4405 $away_message = $this->options['telegram_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
4406 $this->fallbackResponse = [
4407 'text' => $away_message,
4408 'html' => '',
4409 'images' => [],
4410 'chat_mode' => 'ai'
4411 ];
4412 wp_send_json([
4413 'text' => $away_message,
4414 'html' => '',
4415 'chat_mode' => 'ai',
4416 'session_id' => $session_id
4417 ]);
4418 wp_die();
4419 }
4420
4421 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4422 $telegram_group_id = $this->options['telegram_group_id'] ?? '';
4423
4424 if (empty($telegram_bot_token) || empty($telegram_group_id)) {
4425 return false;
4426 }
4427
4428 // Check if topic already exists for this session
4429 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
4430
4431 if (empty($topic_id)) {
4432 // Generate topic name
4433 $topic_name = $this->generate_telegram_topic_name($session_id);
4434
4435 // Random icon color (Telegram forum topic colors)
4436 $icon_colors = [0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F];
4437 $icon_color = $icon_colors[array_rand($icon_colors)];
4438
4439 // Create forum topic
4440 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/createForumTopic", [
4441 'headers' => ['Content-Type' => 'application/json'],
4442 'body' => json_encode([
4443 'chat_id' => $telegram_group_id,
4444 'name' => $topic_name,
4445 'icon_color' => $icon_color
4446 ])
4447 ]);
4448
4449 if (!is_wp_error($response)) {
4450 $response_body = wp_remote_retrieve_body($response);
4451 $response_data = json_decode($response_body, true);
4452
4453 if (isset($response_data['ok']) && $response_data['ok']) {
4454 $topic_id = $response_data['result']['message_thread_id'];
4455 update_option("mxchat_telegram_topic_{$session_id}", $topic_id);
4456 update_option("mxchat_telegram_group_{$session_id}", $telegram_group_id);
4457 }
4458 }
4459
4460 if (empty($topic_id)) {
4461 return false; // Failed to create topic
4462 }
4463 }
4464
4465 // Get recent chat history
4466 $history = get_option("mxchat_history_{$session_id}", []);
4467 $recent_history = array_slice($history, -5);
4468
4469 // Format conversation context for Telegram (HTML format)
4470 $conversation_context = "";
4471 if (!empty($recent_history)) {
4472 $conversation_context = "<b>Recent Conversation:</b>\n";
4473 foreach ($recent_history as $hist_message) {
4474 $role_display = $hist_message['role'] === 'user' ? '👤 User' : '🤖 AI';
4475 $escaped_content = htmlspecialchars($hist_message['content'], ENT_QUOTES, 'UTF-8');
4476 $conversation_context .= "{$role_display}: {$escaped_content}\n";
4477 }
4478 $conversation_context .= "\n";
4479 }
4480
4481 // Get user info
4482 $user_email = get_option("mxchat_email_{$session_id}", 'Not provided');
4483 $user_name = get_option("mxchat_name_{$session_id}", 'Anonymous');
4484
4485 // Update session mode
4486 update_option("mxchat_mode_{$session_id}", 'agent');
4487
4488 // Send initial message to topic
4489 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
4490 $topic_message = "🔔 <b>New Live Agent Request</b>\n\n";
4491 $topic_message .= "<b>Session ID:</b> <code>{$session_id}</code>\n";
4492 $topic_message .= "<b>User:</b> {$user_name}\n";
4493 $topic_message .= "<b>Email:</b> {$user_email}\n\n";
4494
4495 if (!empty($conversation_context)) {
4496 $topic_message .= $conversation_context;
4497 }
4498
4499 $topic_message .= "<b>Current Message:</b>\n{$escaped_message}\n\n";
4500 $topic_message .= "<i>Reply in this topic - messages will be sent to the user</i>\n";
4501 $topic_message .= "<i>Type #close, #end, #disconnect, or #done to end the session</i>";
4502
4503 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4504 'headers' => ['Content-Type' => 'application/json'],
4505 'body' => json_encode([
4506 'chat_id' => $telegram_group_id,
4507 'message_thread_id' => $topic_id,
4508 'text' => $topic_message,
4509 'parse_mode' => 'HTML'
4510 ])
4511 ]);
4512
4513 $success_message = $this->options['telegram_notification_message'] ?? "I've notified a support agent. Please allow a moment for them to respond.";
4514 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
4515
4516 $this->fallbackResponse = [
4517 'text' => $success_message,
4518 'html' => '',
4519 'images' => [],
4520 'chat_mode' => 'agent'
4521 ];
4522
4523 wp_send_json([
4524 'success' => true,
4525 'text' => $success_message,
4526 'html' => '',
4527 'chat_mode' => 'agent',
4528 'session_id' => $session_id,
4529 'fallbackResponse' => $this->fallbackResponse
4530 ]);
4531 wp_die();
4532 }
4533
4534 /**
4535 * Generate topic name for Telegram forum
4536 */
4537 private function generate_telegram_topic_name($session_id) {
4538 $name = null;
4539 $email = null;
4540
4541 // Check logged in user
4542 if (is_user_logged_in()) {
4543 $current_user = wp_get_current_user();
4544 if (!empty($current_user->display_name)) {
4545 $name = $current_user->display_name;
4546 }
4547 if (!empty($current_user->user_email)) {
4548 $email = $current_user->user_email;
4549 }
4550 }
4551
4552 // Check session data
4553 if (empty($name)) {
4554 $name = get_option("mxchat_name_{$session_id}");
4555 }
4556 if (empty($email)) {
4557 $email = get_option("mxchat_email_{$session_id}");
4558 }
4559
4560 // Generate topic name
4561 $session_suffix = substr($session_id, -6);
4562
4563 if (!empty($name)) {
4564 // Clean name for topic (max 128 chars in Telegram)
4565 $clean_name = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $name);
4566 $clean_name = trim($clean_name);
4567 if (strlen($clean_name) > 50) {
4568 $clean_name = substr($clean_name, 0, 50);
4569 }
4570 return "Chat - {$clean_name} ({$session_suffix})";
4571 } elseif (!empty($email)) {
4572 // Use email prefix
4573 $email_prefix = explode('@', $email)[0];
4574 if (strlen($email_prefix) > 30) {
4575 $email_prefix = substr($email_prefix, 0, 30);
4576 }
4577 return "Chat - {$email_prefix} ({$session_suffix})";
4578 }
4579
4580 return "Chat - {$session_suffix}";
4581 }
4582
4583 /**
4584 * Send user message to Telegram agent
4585 */
4586 public function mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id) {
4587 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4588 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
4589 $group_id = get_option("mxchat_telegram_group_{$session_id}", '');
4590
4591 if (empty($telegram_bot_token) || empty($topic_id) || empty($group_id)) {
4592 return false;
4593 }
4594
4595 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
4596 $user_message = "👤 <b>User:</b> {$escaped_message}";
4597
4598 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4599 'headers' => ['Content-Type' => 'application/json'],
4600 'body' => json_encode([
4601 'chat_id' => $group_id,
4602 'message_thread_id' => $topic_id,
4603 'text' => $user_message,
4604 'parse_mode' => 'HTML'
4605 ])
4606 ]);
4607
4608 return !is_wp_error($response);
4609 }
4610
4611 /**
4612 * Handle incoming Telegram webhook
4613 */
4614 public function handle_telegram_webhook(WP_REST_Request $request) {
4615 $body = $request->get_body();
4616 $data = json_decode($body, true);
4617
4618 //error_log('[MxChat Telegram DEBUG] Webhook received: ' . $body);
4619
4620 // Handle message events from forum topics
4621 if (isset($data['message'])) {
4622 $message_data = $data['message'];
4623
4624 // Skip if not from a forum topic
4625 if (!isset($message_data['message_thread_id'])) {
4626 //error_log('[MxChat Telegram DEBUG] Skipped: No message_thread_id (not a forum topic message)');
4627 return new WP_REST_Response(['ok' => true]);
4628 }
4629
4630 // Skip bot messages
4631 if (isset($message_data['from']['is_bot']) && $message_data['from']['is_bot']) {
4632 //error_log('[MxChat Telegram DEBUG] Skipped: Message from bot');
4633 return new WP_REST_Response(['ok' => true]);
4634 }
4635
4636 $chat_id = $message_data['chat']['id'] ?? '';
4637 $topic_id = $message_data['message_thread_id'];
4638 $message_text = $message_data['text'] ?? '';
4639 $message_id = $message_data['message_id'] ?? '';
4640 $from = $message_data['from'] ?? [];
4641 $agent_name = trim(($from['first_name'] ?? '') . ' ' . ($from['last_name'] ?? ''));
4642 if (empty($agent_name)) {
4643 $agent_name = $from['username'] ?? 'Agent';
4644 }
4645
4646 //error_log("[MxChat Telegram DEBUG] Parsed: chat_id={$chat_id}, topic_id={$topic_id}, agent={$agent_name}, text={$message_text}");
4647
4648 // Skip empty messages
4649 if (empty($message_text)) {
4650 //error_log('[MxChat Telegram DEBUG] Skipped: Empty message text');
4651 return new WP_REST_Response(['ok' => true]);
4652 }
4653
4654 // Find session ID by topic ID - cast to string for comparison
4655 global $wpdb;
4656 $topic_id_str = strval($topic_id);
4657 $session_option = $wpdb->get_var(
4658 $wpdb->prepare(
4659 "SELECT option_name FROM {$wpdb->options}
4660 WHERE option_name LIKE %s
4661 AND option_value = %s",
4662 'mxchat_telegram_topic_%',
4663 $topic_id_str
4664 )
4665 );
4666
4667 //error_log("[MxChat Telegram DEBUG] Looking for topic_id={$topic_id_str} in options, found: " . ($session_option ?: 'NULL'));
4668
4669 if ($session_option) {
4670 $session_id = str_replace('mxchat_telegram_topic_', '', $session_option);
4671 //error_log("[MxChat Telegram DEBUG] Session ID: {$session_id}");
4672
4673 // Verify the group ID matches
4674 $stored_group_id = get_option("mxchat_telegram_group_{$session_id}", '');
4675 //error_log("[MxChat Telegram DEBUG] Stored group_id={$stored_group_id}, received chat_id={$chat_id}");
4676
4677 if (strval($stored_group_id) != strval($chat_id)) {
4678 //error_log('[MxChat Telegram DEBUG] Skipped: Group ID mismatch');
4679 return new WP_REST_Response(['ok' => true]);
4680 }
4681
4682 // Check for closure commands
4683 $lower_text = strtolower(trim($message_text));
4684 if (in_array($lower_text, ['#close', '#end', '#disconnect', '#done'])) {
4685 //error_log("[MxChat Telegram DEBUG] Closure command received: {$lower_text}");
4686 // End the live agent session
4687 update_option("mxchat_mode_{$session_id}", 'ai');
4688
4689 // Save disconnect message
4690 $disconnect_message = "Live agent session ended. You're now chatting with the AI assistant.";
4691 $this->mxchat_save_chat_message($session_id, 'bot', $disconnect_message);
4692
4693 // Notify in Telegram
4694 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4695 if (!empty($telegram_bot_token)) {
4696 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4697 'headers' => ['Content-Type' => 'application/json'],
4698 'body' => json_encode([
4699 'chat_id' => $chat_id,
4700 'message_thread_id' => $topic_id,
4701 'text' => "✅ Session closed. User returned to AI chatbot.",
4702 'parse_mode' => 'HTML'
4703 ])
4704 ]);
4705
4706 // Optionally close the topic
4707 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/closeForumTopic", [
4708 'headers' => ['Content-Type' => 'application/json'],
4709 'body' => json_encode([
4710 'chat_id' => $chat_id,
4711 'message_thread_id' => $topic_id
4712 ])
4713 ]);
4714 }
4715
4716 return new WP_REST_Response(['ok' => true]);
4717 }
4718
4719 // Deduplicate messages
4720 $message_key = md5($session_id . $message_id . $message_text);
4721 $processed_messages = get_transient('mxchat_telegram_messages_' . $session_id) ?: [];
4722
4723 if (in_array($message_key, $processed_messages)) {
4724 //error_log('[MxChat Telegram DEBUG] Skipped: Duplicate message');
4725 return new WP_REST_Response(['ok' => true]);
4726 }
4727
4728 $processed_messages[] = $message_key;
4729 if (count($processed_messages) > 50) {
4730 $processed_messages = array_slice($processed_messages, -50);
4731 }
4732 set_transient('mxchat_telegram_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
4733
4734 // Save the agent message - format with agent name prefix for proper parsing
4735 $formatted_message = "Agent: {$agent_name} - {$message_text}";
4736 //error_log("[MxChat Telegram DEBUG] Saving agent message: {$formatted_message}");
4737
4738 $this->mxchat_save_chat_message($session_id, 'agent', $formatted_message);
4739
4740 // Verify the message was saved to history
4741 $history = get_option("mxchat_history_{$session_id}", []);
4742 $last_message = end($history);
4743 //error_log("[MxChat Telegram DEBUG] History after save - count: " . count($history) . ", last message role: " . ($last_message['role'] ?? 'none'));
4744
4745 // Send confirmation back to Telegram
4746 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4747 if (!empty($telegram_bot_token)) {
4748 $confirm_key = 'mxchat_telegram_confirm_' . $message_key;
4749 if (!get_transient($confirm_key)) {
4750 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4751 'headers' => ['Content-Type' => 'application/json'],
4752 'body' => json_encode([
4753 'chat_id' => $chat_id,
4754 'message_thread_id' => $topic_id,
4755 'text' => "✅ <i>Message sent to user</i>",
4756 'parse_mode' => 'HTML',
4757 'reply_to_message_id' => $message_id
4758 ])
4759 ]);
4760 set_transient($confirm_key, true, 300);
4761 }
4762 }
4763 } else {
4764 //error_log("[MxChat Telegram DEBUG] No session found for topic_id={$topic_id}");
4765 }
4766 } else {
4767 //error_log('[MxChat Telegram DEBUG] No message in webhook data');
4768 }
4769
4770 return new WP_REST_Response(['ok' => true]);
4771 }
4772
4773 public function mxchat_send_user_message_to_agent($message, $user_id, $session_id) {
4774 // Check if this is a Telegram agent session
4775 $telegram_topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
4776 if (!empty($telegram_topic_id)) {
4777 return $this->mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id);
4778 }
4779
4780 // Otherwise, try Slack
4781 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4782 $channel_id = get_option("mxchat_channel_{$session_id}", '');
4783
4784 if (empty($slack_bot_token) || empty($channel_id)) {
4785 return false;
4786 }
4787
4788 $user_message = "💬 *User:* {$message}";
4789
4790 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
4791 'headers' => [
4792 'Content-Type' => 'application/json',
4793 'Authorization' => 'Bearer ' . $slack_bot_token
4794 ],
4795 'body' => json_encode([
4796 'channel' => $channel_id,
4797 'text' => $user_message,
4798 'mrkdwn' => true
4799 ])
4800 ]);
4801
4802 return !is_wp_error($response);
4803 }
4804 public function handle_slack_interaction(WP_REST_Request $request) {
4805 //error_log('Received Slack interaction');
4806
4807 $payload = json_decode($request->get_param('payload'), true);
4808 //error_log('Payload: ' . print_r($payload, true));
4809
4810 // Handle button click
4811 if ($payload['type'] === 'block_actions' && $payload['actions'][0]['action_id'] === 'reply_to_user') {
4812 $session_id = $payload['actions'][0]['value'];
4813 $trigger_id = $payload['trigger_id'];
4814
4815 // Get Bot Token from settings
4816 $slack_token = $this->options['live_agent_bot_token'] ?? '';
4817
4818 if (empty($slack_token)) {
4819 //error_log('Slack Bot Token not configured');
4820 return new WP_REST_Response(['error' => esc_html__('Bot token not configured', 'mxchat')], 400);
4821 }
4822 $response = wp_remote_post('https://slack.com/api/views.open', [
4823 'headers' => [
4824 'Content-Type' => 'application/json',
4825 'Authorization' => 'Bearer ' . $slack_token
4826 ],
4827 'body' => json_encode([
4828 'trigger_id' => $trigger_id,
4829 'view' => [
4830 'type' => 'modal',
4831 'callback_id' => 'reply_modal',
4832 'title' => [
4833 'type' => 'plain_text',
4834 'text' => __('Reply to User', 'mxchat')
4835 ],
4836 'submit' => [
4837 'type' => 'plain_text',
4838 'text' => __('Send', 'mxchat')
4839 ],
4840 'close' => [
4841 'type' => 'plain_text',
4842 'text' => __('Cancel', 'mxchat')
4843 ],
4844 'blocks' => [
4845 [
4846 'type' => 'input',
4847 'block_id' => 'reply_block',
4848 'label' => [
4849 'type' => 'plain_text',
4850 'text' => sprintf(__('Reply to session: %s', 'mxchat'), $session_id)
4851 ],
4852 'element' => [
4853 'type' => 'plain_text_input',
4854 'action_id' => 'message',
4855 'multiline' => true,
4856 'placeholder' => [
4857 'type' => 'plain_text',
4858 'text' => __('Type your message here...', 'mxchat')
4859 ]
4860 ]
4861 ]
4862 ],
4863 'private_metadata' => $session_id
4864 ]
4865 ])
4866 ]);
4867
4868 //error_log('Views.open response: ' . print_r($response, true));
4869
4870 // Return immediate acknowledgment
4871 return new WP_REST_Response(['ok' => true]);
4872 }
4873
4874 // Handle modal submission
4875 // Handle modal submission
4876 if ($payload['type'] === 'view_submission') {
4877 $session_id = $payload['view']['private_metadata'];
4878 $message = $payload['view']['state']['values']['reply_block']['message']['value'];
4879
4880 // Save the message (keep the message_id but don't include in response)
4881 $this->mxchat_save_chat_message($session_id, 'agent', $message);
4882
4883 // Keep the original response format for Slack
4884 return new WP_REST_Response([
4885 'response_action' => 'clear'
4886 ]);
4887 }
4888
4889 // Default acknowledgment
4890 return new WP_REST_Response(['ok' => true]);
4891 }
4892 public function mxchat_handle_agent_response(WP_REST_Request $request) {
4893 //error_log('Received agent response request');
4894 //error_log('Request data: ' . print_r($request->get_params(), true));
4895 // //error_log('Raw body: ' . file_get_contents('php://input'));
4896
4897 // Get the data from Slack's slash command format
4898 $command_text = $request->get_param('text');
4899 // //error_log('Command text: ' . $command_text);
4900
4901 if (empty($command_text)) {
4902 //error_log(esc_html__('Agent response error: No command text received', 'mxchat'));
4903 return new WP_REST_Response([
4904 'error' => esc_html__('Command text is required. Format: /reply session_id message', 'mxchat')
4905 ], 400);
4906 }
4907
4908 // Split the command text into session_id and message
4909 $parts = explode(' ', $command_text, 2);
4910 if (count($parts) !== 2) {
4911 //error_log('Agent response error: Invalid command format');
4912 return new WP_REST_Response([
4913 'error' => esc_html__('Invalid format. Use: /reply session_id message', 'mxchat')
4914 ], 400);
4915 }
4916
4917 $session_id = sanitize_text_field($parts[0]);
4918 $message = sanitize_text_field($parts[1]);
4919
4920 //error_log("Processing agent response - Session ID: $session_id, Message: $message");
4921
4922 // Save the message
4923 $message_id = $this->mxchat_save_chat_message($session_id, 'agent', $message);
4924
4925 if (!$message_id) {
4926 // //error_log('Failed to save agent message');
4927 return new WP_REST_Response([
4928 'error' => esc_html__('Failed to save message', 'mxchat')
4929 ], 500);
4930 }
4931
4932 // Return success response in Slack's expected format
4933 return new WP_REST_Response([
4934 'response_type' => 'in_channel',
4935 'text' => esc_html__("Message sent successfully to session $session_id", 'mxchat')
4936 ], 200);
4937 }
4938 public function mxchat_handle_switch_to_chatbot_intent($message, $user_id, $session_id) {
4939 // Update mode to AI
4940 update_option("mxchat_mode_{$session_id}", 'ai');
4941
4942 // Clear any existing PDF context to start fresh
4943 $this->clear_pdf_transients($session_id);
4944
4945 // Set the response with explicit chat_mode
4946 $this->fallbackResponse = [
4947 'text' => esc_html__('You are now chatting with the AI chatbot.', 'mxchat'),
4948 'html' => '',
4949 'images' => [],
4950 'chat_mode' => 'ai' // Ensure this is set
4951 ];
4952
4953 // Return the complete response array instead of just true
4954 return $this->fallbackResponse;
4955 }
4956
4957 /**
4958 * Normalize Slack mrkdwn before relaying an agent's message to the web visitor.
4959 * Slack's Events API auto-wraps URLs as <https://url> or <https://url|Label>, wraps
4960 * mentions as <@U…>/<#C…|name>, and HTML-escapes &, <, >. Relayed raw, the visitor
4961 * sees a broken/doubled link with a trailing > (plan-e2195b). Unwrap links FIRST, then
4962 * unescape entities LAST so extracted URLs (which can contain &amp;) are not corrupted.
4963 */
4964 private function normalize_slack_text($text) {
4965 if (!is_string($text) || $text === '') {
4966 return $text;
4967 }
4968
4969 $text = preg_replace_callback('/<([^>|]+)(?:\|([^>]*))?>/', function ($m) {
4970 $target = $m[1];
4971 $label = isset($m[2]) ? $m[2] : '';
4972
4973 // User/channel mentions: <@U…> or <#C…|name> — prefer the human label, else drop the id.
4974 if (isset($target[0]) && ($target[0] === '@' || $target[0] === '#')) {
4975 return $label !== '' ? $label : '';
4976 }
4977 // mailto:/tel: — strip the scheme for display.
4978 if (stripos($target, 'mailto:') === 0) {
4979 $addr = substr($target, 7);
4980 return ($label !== '' && $label !== $addr) ? "{$label} ({$addr})" : $addr;
4981 }
4982 if (stripos($target, 'tel:') === 0) {
4983 $num = substr($target, 4);
4984 return ($label !== '' && $label !== $num) ? "{$label} ({$num})" : $num;
4985 }
4986 // Regular URL: <url|Label> -> "Label (url)"; bare <url> -> "url".
4987 if ($label !== '' && $label !== $target) {
4988 return "{$label} ({$target})";
4989 }
4990 return $target;
4991 }, $text);
4992
4993 // Entity-unescape LAST (after link extraction) so &amp; inside URLs is repaired too.
4994 $text = str_replace(array('&amp;', '&lt;', '&gt;'), array('&', '<', '>'), $text);
4995
4996 return $text;
4997 }
4998
4999 /**
5000 * Resolve the visitor's name + email for a session, mirroring generate_channel_name()'s
5001 * priority order: logged-in user, then the pre-chat gate options (mxchat_email_/mxchat_name_),
5002 * then the chat transcript. Returns ['name' => ..., 'email' => ...] (either may be ''). plan-e2195b.
5003 */
5004 private function mxchat_get_visitor_identity($session_id) {
5005 $email = '';
5006 $name = '';
5007
5008 if (is_user_logged_in()) {
5009 $current_user = wp_get_current_user();
5010 if (!empty($current_user->user_email)) { $email = $current_user->user_email; }
5011 if (!empty($current_user->display_name)) { $name = $current_user->display_name; }
5012 }
5013
5014 if (empty($email)) {
5015 $saved_email = get_option("mxchat_email_{$session_id}", '');
5016 if (!empty($saved_email)) { $email = $saved_email; }
5017 }
5018 if (empty($name)) {
5019 $saved_name = get_option("mxchat_name_{$session_id}", '');
5020 if (!empty($saved_name)) { $name = $saved_name; }
5021 }
5022
5023 if (empty($email) || empty($name)) {
5024 global $wpdb;
5025 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
5026 $existing_data = $wpdb->get_row($wpdb->prepare(
5027 "SELECT user_email, user_name FROM $table_name WHERE session_id = %s AND (user_email IS NOT NULL OR user_name IS NOT NULL) LIMIT 1",
5028 $session_id
5029 ));
5030 if ($existing_data) {
5031 if (empty($email) && !empty($existing_data->user_email)) { $email = $existing_data->user_email; }
5032 if (empty($name) && !empty($existing_data->user_name)) { $name = $existing_data->user_name; }
5033 }
5034 }
5035
5036 return array('name' => $name, 'email' => $email);
5037 }
5038
5039 public function handle_slack_messages(WP_REST_Request $request) {
5040 // Log the incoming request for debugging
5041 //error_log('Slack events request received: ' . $request->get_body());
5042
5043 $body = $request->get_body();
5044 $data = json_decode($body, true);
5045
5046 // Handle Slack URL verification
5047 if (isset($data['type']) && $data['type'] === 'url_verification') {
5048 //error_log('Slack URL verification challenge: ' . $data['challenge']);
5049 return new WP_REST_Response($data['challenge'], 200, ['Content-Type' => 'text/plain']);
5050 }
5051
5052 // IMPORTANT: Handle Slack's event deduplication
5053 if (isset($data['event_id'])) {
5054 $event_id = $data['event_id'];
5055 $processed_events = get_transient('mxchat_slack_events') ?: [];
5056
5057 // Check if we've already processed this event
5058 if (in_array($event_id, $processed_events)) {
5059 //error_log("Duplicate event detected: $event_id");
5060 return new WP_REST_Response(['ok' => true]);
5061 }
5062
5063 // Add this event to processed list
5064 $processed_events[] = $event_id;
5065 // Keep only last 100 events to prevent memory issues
5066 if (count($processed_events) > 100) {
5067 $processed_events = array_slice($processed_events, -100);
5068 }
5069 // Store for 1 hour
5070 set_transient('mxchat_slack_events', $processed_events, HOUR_IN_SECONDS);
5071 }
5072
5073 // Handle message events
5074 if (isset($data['event']) && $data['event']['type'] === 'message') {
5075 $event = $data['event'];
5076
5077 // Skip bot messages and messages with subtypes (like bot_message)
5078 if (isset($event['bot_id']) || isset($event['subtype'])) {
5079 return new WP_REST_Response(['ok' => true]);
5080 }
5081
5082 // Additional check: Skip if this is a threaded reply to our confirmation
5083 if (isset($event['thread_ts']) && $event['thread_ts'] !== $event['ts']) {
5084 return new WP_REST_Response(['ok' => true]);
5085 }
5086
5087 $channel_id = $event['channel'];
5088 $message_text = $event['text'] ?? '';
5089 $message_ts = $event['ts'] ?? '';
5090
5091 // Find session ID by looking for matching channel
5092 global $wpdb;
5093 $session_option = $wpdb->get_var(
5094 $wpdb->prepare(
5095 "SELECT option_name FROM {$wpdb->options}
5096 WHERE option_name LIKE 'mxchat_channel_%'
5097 AND option_value = %s",
5098 $channel_id
5099 )
5100 );
5101
5102 if ($session_option) {
5103 $session_id = str_replace('mxchat_channel_', '', $session_option);
5104
5105 // Create a unique key for this specific message
5106 $message_key = md5($session_id . $message_ts . $message_text);
5107 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
5108
5109 // Check if we've already processed this exact message
5110 if (in_array($message_key, $processed_messages)) {
5111 //error_log("Duplicate message detected for session $session_id");
5112 return new WP_REST_Response(['ok' => true]);
5113 }
5114
5115 // Add to processed messages
5116 $processed_messages[] = $message_key;
5117 // Keep only last 50 messages per session
5118 if (count($processed_messages) > 50) {
5119 $processed_messages = array_slice($processed_messages, -50);
5120 }
5121 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
5122
5123 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
5124
5125 // Handle agent ending the chat — transfer back to AI
5126 // Format: "!endchat" or "!endchat <custom message to user>"
5127 if (preg_match('/^!endchat\b/i', trim($message_text))) {
5128 update_option("mxchat_mode_{$session_id}", 'ai');
5129
5130 // Extract custom message after !endchat, or use empty string
5131 $custom_message = trim(preg_replace('/^!endchat\s*/i', '', trim($message_text)));
5132
5133 // Send the agent's custom farewell message if provided
5134 if (!empty($custom_message)) {
5135 $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($custom_message));
5136 }
5137
5138 // Confirm in Slack channel
5139 if (!empty($slack_bot_token)) {
5140 wp_remote_post('https://slack.com/api/chat.postMessage', [
5141 'headers' => [
5142 'Content-Type' => 'application/json',
5143 'Authorization' => 'Bearer ' . $slack_bot_token
5144 ],
5145 'body' => json_encode([
5146 'channel' => $channel_id,
5147 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.",
5148 'mrkdwn' => true
5149 ])
5150 ]);
5151 }
5152
5153 return new WP_REST_Response(['ok' => true]);
5154 }
5155
5156 // Save the agent message (normalize Slack link/entity formatting first — plan-e2195b)
5157 $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($message_text));
5158
5159 // Send confirmation back to Slack (only once)
5160 if (!empty($slack_bot_token)) {
5161 // Use a transient to prevent duplicate confirmations
5162 $confirm_key = 'mxchat_confirm_' . $message_key;
5163 if (!get_transient($confirm_key)) {
5164 wp_remote_post('https://slack.com/api/chat.postMessage', [
5165 'headers' => [
5166 'Content-Type' => 'application/json',
5167 'Authorization' => 'Bearer ' . $slack_bot_token
5168 ],
5169 'body' => json_encode([
5170 'channel' => $channel_id,
5171 'text' => "✅ _Message sent to user_",
5172 'thread_ts' => $event['ts'] // Reply in thread
5173 ])
5174 ]);
5175 // Set transient to prevent duplicate confirmations
5176 set_transient($confirm_key, true, 300); // 5 minutes
5177 }
5178 }
5179 }
5180 }
5181
5182 return new WP_REST_Response(['ok' => true]);
5183 }
5184
5185 // For the word upload handler
5186 public function mxchat_handle_word_upload() {
5187 // Delegate to word handler
5188 $this->word_handler->mxchat_handle_word_upload();
5189 }
5190
5191 // For the word removal handler
5192 public function mxchat_handle_word_remove() {
5193 // Delegate to word handler
5194 $this->word_handler->mxchat_handle_word_remove();
5195 }
5196
5197 // For the word status check
5198 public function mxchat_check_word_status() {
5199 // Delegate to word handler
5200 $this->word_handler->mxchat_check_word_status();
5201 }
5202
5203
5204 private function mxchat_get_user_identifier() {
5205 return MxChat_User::mxchat_get_user_identifier();
5206 }
5207
5208 private function mxchat_generate_embedding($text, $api_key) {
5209 try {
5210 // Get options and selected model
5211 $options = get_option('mxchat_options');
5212 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5213
5214 // Opt-in: route embeddings through the Custom (OpenAI-compatible) provider.
5215 // Off by default so existing sites see byte-identical behavior.
5216 if (!empty($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
5217 return $this->mxchat_generate_embedding_custom($text);
5218 }
5219
5220 // Determine endpoint and API key based on model
5221 if (strpos($selected_model, 'voyage') === 0) {
5222 $endpoint = 'https://api.voyageai.com/v1/embeddings';
5223 $api_key = $options['voyage_api_key'] ?? '';
5224
5225 // Check if Voyage API key is missing
5226 if (empty($api_key)) {
5227 //error_log('Voyage API key is missing');
5228 return [
5229 'error' => esc_html__('Voyage AI API key is not configured', 'mxchat'),
5230 'error_code' => 'missing_voyage_api_key'
5231 ];
5232 }
5233 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5234 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
5235 $api_key = $options['gemini_api_key'] ?? '';
5236
5237 // Check if Gemini API key is missing
5238 if (empty($api_key)) {
5239 //error_log('Gemini API key is missing');
5240 return [
5241 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
5242 'error_code' => 'missing_gemini_api_key'
5243 ];
5244 }
5245 } else {
5246 $endpoint = 'https://api.openai.com/v1/embeddings';
5247 // Use the passed API key for OpenAI
5248
5249 // Check if OpenAI API key is missing
5250 if (empty($api_key)) {
5251 //error_log('OpenAI API key is missing');
5252 return [
5253 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
5254 'error_code' => 'missing_openai_api_key'
5255 ];
5256 }
5257 }
5258
5259 // Check if text is empty
5260 if (empty($text)) {
5261 //error_log('Empty text provided for embedding generation');
5262 return [
5263 'error' => esc_html__('No text provided for embedding generation', 'mxchat'),
5264 'error_code' => 'empty_embedding_text'
5265 ];
5266 }
5267
5268 // Prepare request body based on provider
5269 if (strpos($selected_model, 'gemini-embedding') === 0) {
5270 // Gemini API format
5271 $request_body = [
5272 'model' => 'models/' . $selected_model,
5273 'content' => [
5274 'parts' => [
5275 ['text' => $text]
5276 ]
5277 ],
5278 'outputDimensionality' => 1536
5279 ];
5280
5281 // Prepare headers for Gemini (API key as query parameter)
5282 $endpoint .= '?key=' . $api_key;
5283 $headers = [
5284 'Content-Type' => 'application/json'
5285 ];
5286 } else {
5287 // OpenAI/Voyage API format
5288 $request_body = [
5289 'input' => $text,
5290 'model' => $selected_model
5291 ];
5292
5293 // Add output_dimension for voyage-3-large
5294 if ($selected_model === 'voyage-3-large') {
5295 $request_body['output_dimension'] = 2048;
5296 }
5297
5298 // Prepare headers for OpenAI/Voyage
5299 $headers = [
5300 'Content-Type' => 'application/json',
5301 'Authorization' => 'Bearer ' . $api_key
5302 ];
5303 }
5304
5305 // Prepare request arguments
5306 $args = [
5307 'body' => wp_json_encode($request_body),
5308 'headers' => $headers,
5309 'timeout' => 60,
5310 'redirection' => 5,
5311 'blocking' => true,
5312 'httpversion' => '1.0',
5313 'sslverify' => true,
5314 ];
5315
5316 // Make the request
5317 $response = wp_remote_post($endpoint, $args);
5318
5319 // Handle WordPress errors
5320 if (is_wp_error($response)) {
5321 $error_message = $response->get_error_message();
5322 //error_log('Embedding Generation Error: ' . $error_message);
5323 return [
5324 'error' => esc_html__('Connection error when generating embeddings: ', 'mxchat') . esc_html($error_message),
5325 'error_code' => 'embedding_connection_error'
5326 ];
5327 }
5328
5329 // Check HTTP status code
5330 $status_code = wp_remote_retrieve_response_code($response);
5331 if ($status_code !== 200) {
5332 $response_body = json_decode(wp_remote_retrieve_body($response), true);
5333
5334 $error_message = isset($response_body['error']['message'])
5335 ? $response_body['error']['message']
5336 : 'HTTP Error ' . $status_code;
5337
5338 $error_type = isset($response_body['error']['type'])
5339 ? $response_body['error']['type']
5340 : 'unknown';
5341
5342 //error_log('Embedding API HTTP Error: ' . $status_code . ' - ' . $error_message);
5343
5344 // Handle specific error types
5345 switch ($error_type) {
5346 case 'invalid_request_error':
5347 if (strpos($error_message, 'API key') !== false) {
5348 return [
5349 'error' => esc_html__('Invalid API key for embedding generation. Please check your API key configuration.', 'mxchat'),
5350 'error_code' => 'embedding_invalid_api_key'
5351 ];
5352 }
5353 break;
5354
5355 case 'authentication_error':
5356 return [
5357 'error' => esc_html__('Authentication failed for embedding generation. Please check your API key.', 'mxchat'),
5358 'error_code' => 'embedding_auth_error'
5359 ];
5360
5361 case 'rate_limit_exceeded':
5362 return [
5363 'error' => esc_html__('Rate limit exceeded for embedding generation. Please try again later.', 'mxchat'),
5364 'error_code' => 'embedding_rate_limit'
5365 ];
5366
5367 case 'quota_exceeded':
5368 return [
5369 'error' => esc_html__('API quota exceeded for embedding generation. Please check your billing details.', 'mxchat'),
5370 'error_code' => 'embedding_quota_exceeded'
5371 ];
5372 }
5373
5374 // Generic error fallback
5375 return [
5376 'error' => esc_html__('Embedding API error - check embedding API key.: ', 'mxchat') . esc_html($error_message),
5377 'error_code' => 'embedding_api_error',
5378 'status_code' => $status_code
5379 ];
5380 }
5381
5382 $response_body = json_decode(wp_remote_retrieve_body($response), true);
5383
5384 // Handle different response formats based on provider
5385 if (strpos($selected_model, 'gemini-embedding') === 0) {
5386 // Gemini API response format
5387 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
5388 return $response_body['embedding']['values'];
5389 } else {
5390 //error_log('Invalid Gemini embedding response: ' . wp_json_encode($response_body));
5391 return [
5392 'error' => esc_html__('Received invalid embedding data from the Gemini API.', 'mxchat'),
5393 'error_code' => 'invalid_gemini_embedding_response'
5394 ];
5395 }
5396 } else {
5397 // OpenAI/Voyage API response format
5398 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
5399 return $response_body['data'][0]['embedding'];
5400 } else {
5401 //error_log('Invalid embedding response: ' . wp_json_encode($response_body));
5402 return [
5403 'error' => esc_html__('Received invalid embedding data from the API.', 'mxchat'),
5404 'error_code' => 'invalid_embedding_response'
5405 ];
5406 }
5407 }
5408 } catch (Exception $e) {
5409 //error_log('Embedding Exception: ' . $e->getMessage());
5410 return [
5411 'error' => esc_html__('System error when generating embeddings: ', 'mxchat') . esc_html($e->getMessage()),
5412 'error_code' => 'embedding_exception'
5413 ];
5414 }
5415 }
5416
5417
5418 /**
5419 * Generate embedding via a Custom (OpenAI-compatible) provider's /embeddings route.
5420 * Only called when the opt-in 'custom_provider_for_embeddings' setting is on.
5421 * Returns a numeric array (the embedding vector) on success, or ['error','error_code'] on failure.
5422 */
5423 private function mxchat_generate_embedding_custom($text) {
5424 if (empty($text)) {
5425 return ['error' => esc_html__('No text provided for embedding generation', 'mxchat'), 'error_code' => 'empty_embedding_text'];
5426 }
5427 $cfg = $this->mxchat_resolve_custom_provider();
5428 if (empty($cfg['base_url'])) {
5429 return ['error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url'];
5430 }
5431
5432 $options = get_option('mxchat_options');
5433 $embed_url = $cfg['base_url'] . '/embeddings';
5434 if (!empty($cfg['api_version'])) {
5435 $embed_url .= (strpos($embed_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($cfg['api_version']);
5436 }
5437 $model = isset($options['custom_provider_embedding_model']) && trim((string) $options['custom_provider_embedding_model']) !== ''
5438 ? trim((string) $options['custom_provider_embedding_model'])
5439 : $cfg['model'];
5440
5441 $response = wp_remote_post($embed_url, [
5442 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
5443 'body' => wp_json_encode(['input' => $text, 'model' => $model]),
5444 'timeout' => 60,
5445 ]);
5446 if (is_wp_error($response)) {
5447 return [
5448 'error' => esc_html__('Connection error when generating embeddings (custom provider): ', 'mxchat') . esc_html($response->get_error_message()),
5449 'error_code' => 'embedding_custom_connection_error',
5450 ];
5451 }
5452 $status = wp_remote_retrieve_response_code($response);
5453 $body = json_decode(wp_remote_retrieve_body($response), true);
5454 if ($status !== 200) {
5455 $msg = isset($body['error']['message']) ? $body['error']['message'] : 'HTTP ' . $status;
5456 return [
5457 'error' => esc_html__('Custom embedding endpoint error: ', 'mxchat') . esc_html($msg),
5458 'error_code' => 'embedding_custom_api_error',
5459 'status_code' => $status,
5460 ];
5461 }
5462 if (isset($body['data'][0]['embedding']) && is_array($body['data'][0]['embedding'])) {
5463 return $body['data'][0]['embedding'];
5464 }
5465 return [
5466 'error' => esc_html__('Invalid embedding response from custom provider.', 'mxchat'),
5467 'error_code' => 'embedding_custom_invalid_response',
5468 ];
5469 }
5470
5471 private function mxchat_find_relevant_content($user_embedding, $bot_id = 'default', $user_query = '') {
5472 //error_log("MXCHAT DEBUG: find_relevant_content called with bot_id: " . $bot_id);
5473
5474 // Check for OpenAI Vector Store first (takes priority when enabled)
5475 $bot_vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
5476
5477 if ($bot_vectorstore_config['use_vectorstore']) {
5478 // Get current model to verify it's an OpenAI model
5479 $bot_options = $this->get_bot_options($bot_id);
5480 $mxchat_options = get_option('mxchat_options', array());
5481 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
5482 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
5483
5484 if ($this->is_openai_chat_model($selected_model)) {
5485 //error_log("MXCHAT DEBUG: Using OpenAI Vector Store for knowledge retrieval");
5486 return $this->find_relevant_content_openai_vectorstore($user_query, $bot_id, $bot_vectorstore_config);
5487 } else {
5488 //error_log("MXCHAT DEBUG: Vector Store enabled but model is not OpenAI (" . $selected_model . "), skipping Vector Store");
5489 }
5490 }
5491
5492 // Get bot-specific Pinecone configuration
5493 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
5494
5495 // Debug: Log the Pinecone configuration
5496 //error_log("MXCHAT DEBUG: Pinecone config for bot '$bot_id':");
5497 //error_log(" - use_pinecone: " . ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false'));
5498 //error_log(" - api_key: " . (empty($bot_pinecone_config['api_key']) ? 'EMPTY' : 'SET (hidden)'));
5499 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'NOT SET'));
5500 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'NOT SET'));
5501
5502 // Determine whether to use Pinecone based on bot configuration
5503 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
5504
5505 //error_log("MXCHAT DEBUG: Using " . ($use_pinecone ? "Pinecone" : "WordPress Database") . " for knowledge retrieval");
5506
5507 if ($use_pinecone) {
5508 return $this->find_relevant_content_pinecone($user_embedding, $bot_id, $bot_pinecone_config);
5509 } else {
5510 return $this->find_relevant_content_wordpress($user_embedding, $bot_id);
5511 }
5512 }
5513
5514 private function find_relevant_content_wordpress($user_embedding, $bot_id = 'default') {
5515 global $wpdb;
5516 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
5517 // Initialize similarity analysis storage
5518 $this->last_similarity_analysis = [
5519 'knowledge_base_type' => 'WordPress Database',
5520 'bot_id' => $bot_id,
5521 'top_matches' => [],
5522 'threshold_used' => 0,
5523 'total_checked' => 0
5524 ];
5525
5526 // NEW: Initialize valid URLs array
5527 $valid_urls = [];
5528
5529 // Get bot-specific options for similarity threshold
5530 $bot_options = $this->get_bot_options($bot_id);
5531 $current_options = !empty($bot_options) ? $bot_options : $this->options;
5532
5533 // Get knowledge manager instance for role checking
5534 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
5535
5536 // Get base similarity threshold from bot options or default options
5537 $similarity_threshold = isset($current_options['similarity_threshold'])
5538 ? ((int) $current_options['similarity_threshold']) / 100
5539 : 0.35;
5540 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
5541
5542 // Precompute bot_filter once, outside the streaming loop
5543 $bot_filter = '';
5544 if ($bot_id !== 'default') {
5545 $column_exists = $wpdb->get_var("SHOW COLUMNS FROM {$system_prompt_table} LIKE 'bot_metadata'");
5546 if ($column_exists) {
5547 $bot_filter = $wpdb->prepare(" AND (bot_metadata = %s OR bot_metadata IS NULL OR bot_metadata = '')", $bot_id);
5548 }
5549 }
5550
5551 // ===== STREAMING TOP-K PASS =====
5552 // Stream rows in small batches, compute cosine similarity per row, and keep only:
5553 // - top 10 by raw similarity (for the testing/debug display panel)
5554 // - candidates above threshold with access (capped) for context assembly
5555 // This bounds peak memory regardless of knowledge base size and avoids loading
5556 // article_content for every row. article_content is fetched in Phase 2 for winners only.
5557 $batch_size = 250;
5558 $max_candidates = 200; // safety cap, well above rag_sources_limit * max_chunks_per_source
5559 $top_display = [];
5560 $candidates = [];
5561 $total_checked = 0;
5562 $offset = 0;
5563
5564 do {
5565 $batch = $wpdb->get_results($wpdb->prepare(
5566 "SELECT id, embedding_vector, source_url, role_restriction
5567 FROM {$system_prompt_table}
5568 WHERE 1=1 {$bot_filter}
5569 LIMIT %d OFFSET %d",
5570 $batch_size,
5571 $offset
5572 ));
5573
5574 if (empty($batch)) {
5575 break;
5576 }
5577
5578 foreach ($batch as $row) {
5579 $database_embedding = $row->embedding_vector
5580 ? unserialize($row->embedding_vector, ['allowed_classes' => false])
5581 : null;
5582
5583 if (!is_array($database_embedding) || !is_array($user_embedding)) {
5584 unset($database_embedding);
5585 continue;
5586 }
5587
5588 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
5589 unset($database_embedding);
5590
5591 $role_restriction = $row->role_restriction ?? 'public';
5592 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5593 $source_url = $row->source_url ?? '';
5594
5595 // Maintain top 10 display buffer (insert-if-beats-worst)
5596 if (count($top_display) < 10) {
5597 $top_display[] = [
5598 'id' => $row->id,
5599 'similarity' => $similarity,
5600 'source_url' => $source_url,
5601 'role_restriction' => $role_restriction,
5602 'has_access' => $has_access,
5603 ];
5604 usort($top_display, function ($a, $b) {
5605 return $b['similarity'] <=> $a['similarity'];
5606 });
5607 } elseif ($similarity > $top_display[9]['similarity']) {
5608 $top_display[9] = [
5609 'id' => $row->id,
5610 'similarity' => $similarity,
5611 'source_url' => $source_url,
5612 'role_restriction' => $role_restriction,
5613 'has_access' => $has_access,
5614 ];
5615 usort($top_display, function ($a, $b) {
5616 return $b['similarity'] <=> $a['similarity'];
5617 });
5618 }
5619
5620 // Track candidates for context assembly (above threshold + has access)
5621 if ($similarity >= $similarity_threshold && $has_access) {
5622 $candidates[] = [
5623 'id' => $row->id,
5624 'similarity' => $similarity,
5625 'source_url' => $source_url,
5626 ];
5627 }
5628
5629 $total_checked++;
5630 }
5631
5632 unset($batch);
5633
5634 // Trim candidates periodically to cap memory during long scans
5635 if (count($candidates) > $max_candidates) {
5636 usort($candidates, function ($a, $b) {
5637 return $b['similarity'] <=> $a['similarity'];
5638 });
5639 $candidates = array_slice($candidates, 0, $max_candidates);
5640 }
5641
5642 $offset += $batch_size;
5643 } while (true);
5644
5645 if ($total_checked === 0) {
5646 $this->current_valid_urls = [];
5647 return '';
5648 }
5649
5650 // Final candidates sort (best first)
5651 if (count($candidates) > 1) {
5652 usort($candidates, function ($a, $b) {
5653 return $b['similarity'] <=> $a['similarity'];
5654 });
5655 }
5656
5657 // ===== PHASE 2: FETCH ARTICLE CONTENT ONLY FOR WINNERS =====
5658 // Gather unique IDs we actually need (top_display + candidates) and pull
5659 // article_content in bounded IN() batches. This avoids loading content for
5660 // every row during the similarity scan.
5661 $needed_ids = [];
5662 foreach ($top_display as $item) {
5663 $needed_ids[$item['id']] = true;
5664 }
5665 foreach ($candidates as $item) {
5666 $needed_ids[$item['id']] = true;
5667 }
5668 $needed_ids = array_keys($needed_ids);
5669
5670 $content_map = [];
5671 if (!empty($needed_ids)) {
5672 foreach (array_chunk($needed_ids, 250) as $chunk_ids) {
5673 $placeholders = implode(',', array_fill(0, count($chunk_ids), '%d'));
5674 $rows = $wpdb->get_results($wpdb->prepare(
5675 "SELECT id, article_content FROM {$system_prompt_table} WHERE id IN ($placeholders)",
5676 ...$chunk_ids
5677 ));
5678 foreach ($rows as $r) {
5679 $content_map[$r->id] = $r->article_content;
5680 }
5681 unset($rows);
5682 }
5683 }
5684
5685 // Build the all_similarities display array from the top 10
5686 $all_similarities = [];
5687 foreach ($top_display as $item) {
5688 $article_content_for_parse = $content_map[$item['id']] ?? '';
5689 $parsed_for_display = MxChat_Chunker::parse_stored_chunk($article_content_for_parse);
5690 $is_chunk = $parsed_for_display['is_chunked'];
5691 $chunk_meta = $parsed_for_display['metadata'];
5692
5693 if (!empty($item['source_url']) && $item['source_url'] !== '#') {
5694 $source_display = $item['source_url'];
5695 } else {
5696 $content_preview = strip_tags($article_content_for_parse);
5697 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
5698 $source_display = substr(trim($content_preview), 0, 50) . '...';
5699 }
5700
5701 $all_similarities[] = [
5702 'document_id' => $item['id'],
5703 'similarity' => $item['similarity'],
5704 'similarity_percentage' => round($item['similarity'] * 100, 2),
5705 'above_threshold' => $item['similarity'] >= $similarity_threshold,
5706 'source_display' => $source_display,
5707 'content_preview' => substr(strip_tags($parsed_for_display['text'] ?? ''), 0, 100) . '...',
5708 'used_for_context' => false,
5709 'role_restriction' => $item['role_restriction'],
5710 'has_access' => $item['has_access'],
5711 'filtered_out' => !$item['has_access'],
5712 'is_chunk' => $is_chunk,
5713 'chunk_index' => $is_chunk ? ($chunk_meta['chunk_index'] ?? 0) : null,
5714 'total_chunks' => $is_chunk ? ($chunk_meta['total_chunks'] ?? 1) : null
5715 ];
5716 }
5717
5718 // Build url_groups from candidates for chunk reassembly
5719 $url_groups = array();
5720 foreach ($candidates as $cand) {
5721 $article_content = $content_map[$cand['id']] ?? '';
5722 $parsed = MxChat_Chunker::parse_stored_chunk($article_content);
5723 $is_chunked = $parsed['is_chunked'];
5724 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
5725 $text_content = $parsed['text'];
5726
5727 $source_url = $cand['source_url'];
5728 $group_key = !empty($source_url) ? $source_url : '_manual_' . $cand['id'];
5729
5730 if (!isset($url_groups[$group_key])) {
5731 $url_groups[$group_key] = array(
5732 'source_url' => $source_url,
5733 'best_score' => 0,
5734 'is_chunked' => $is_chunked,
5735 'chunks' => array(),
5736 'single_text' => '',
5737 'single_id' => null
5738 );
5739 }
5740
5741 if ($cand['similarity'] > $url_groups[$group_key]['best_score']) {
5742 $url_groups[$group_key]['best_score'] = $cand['similarity'];
5743 }
5744
5745 if ($is_chunked) {
5746 $url_groups[$group_key]['is_chunked'] = true;
5747 $url_groups[$group_key]['chunks'][] = array(
5748 'id' => $cand['id'],
5749 'score' => $cand['similarity'],
5750 'chunk_index' => $chunk_index,
5751 'text' => $text_content
5752 );
5753 } else {
5754 $url_groups[$group_key]['single_text'] = $text_content;
5755 $url_groups[$group_key]['single_id'] = $cand['id'];
5756 }
5757 }
5758
5759 // Sort ALL similarities for testing display (highest first)
5760 usort($all_similarities, function ($a, $b) {
5761 return $b['similarity'] <=> $a['similarity'];
5762 });
5763
5764 // Sort URL groups by best score (highest first)
5765 uasort($url_groups, function($a, $b) {
5766 return $b['best_score'] <=> $a['best_score'];
5767 });
5768
5769 // Get RAG sources limit from options (default 6, min 3, max 10)
5770 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
5771 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
5772 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
5773
5774 // Take top N unique URLs based on user setting
5775 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
5776
5777 // Track which document IDs are used for context
5778 $used_document_ids = [];
5779 foreach ($top_urls as $group) {
5780 if ($group['is_chunked']) {
5781 foreach ($group['chunks'] as $chunk) {
5782 $used_document_ids[] = $chunk['id'];
5783 }
5784 } elseif ($group['single_id']) {
5785 $used_document_ids[] = $group['single_id'];
5786 }
5787 }
5788
5789 // Update the all_similarities array to mark which were actually used
5790 foreach ($all_similarities as &$similarity_item) {
5791 $similarity_item['used_for_context'] = in_array($similarity_item['document_id'], $used_document_ids);
5792 }
5793
5794 // Store top 10 for testing panel
5795 $this->last_similarity_analysis['top_matches'] = array_slice($all_similarities, 0, 10);
5796 $this->last_similarity_analysis['total_checked'] = $total_checked;
5797
5798 // Initialize final content
5799 $content = '';
5800 $matches_used = 0;
5801 $total_chunks_used = 0;
5802 $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
5803 if ($max_total_chunks < 8) $max_total_chunks = 8;
5804 if ($max_total_chunks > 20) $max_total_chunks = 20;
5805 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
5806
5807 // Check if citation links are enabled (default to 'on' for backwards compatibility)
5808 // Use fresh options to ensure we get the latest setting value
5809 $fresh_options = get_option('mxchat_options', []);
5810 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
5811
5812 // Build content from top sources
5813 foreach ($top_urls as $group_key => $group) {
5814 $source_url = $group['source_url']; // Use actual source_url, not the group key
5815
5816 // Stop if we've hit the total chunk limit
5817 if ($total_chunks_used >= $max_total_chunks) {
5818 break;
5819 }
5820
5821 $full_text = '';
5822 $chunks_in_this_source = 1; // Default for non-chunked content
5823
5824 if ($group['is_chunked']) {
5825 // Calculate how many chunks we can still use (respect both total and per-source caps)
5826 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
5827
5828 // Fetch chunks for this URL with limit
5829 $full_text = $this->reassemble_chunks_from_wordpress($source_url, $chunks_remaining, $chunks_in_this_source);
5830
5831 // If fetching all chunks fails, fall back to matched chunks
5832 if (empty($full_text)) {
5833 // Sort matched chunks by index and concatenate
5834 usort($group['chunks'], function($a, $b) {
5835 return $a['chunk_index'] <=> $b['chunk_index'];
5836 });
5837
5838 $chunk_texts = array();
5839 $chunks_in_this_source = 0;
5840 foreach ($group['chunks'] as $chunk) {
5841 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
5842 break;
5843 }
5844 $chunk_texts[] = $chunk['text'];
5845 $chunks_in_this_source++;
5846 }
5847 $full_text = implode("\n\n", $chunk_texts);
5848 }
5849 } else {
5850 $full_text = $group['single_text'];
5851 $chunks_in_this_source = 1;
5852 }
5853
5854 if (!empty($full_text)) {
5855 // Strip URLs from content if citation links are disabled
5856 if (!$citation_links_enabled) {
5857 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
5858 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
5859 }
5860
5861 // Use numbered reference for URL-based entries, plain info label for manual entries
5862 // Manual entries are stored with an internal mxchat:// placeholder URL — never expose them as citations
5863 if (!empty($source_url) && $source_url !== '#' && strpos($source_url, 'mxchat://') !== 0) {
5864 $matches_used++;
5865 $content .= "## Reference " . $matches_used . " ##\n";
5866 $content .= $full_text . "\n\n";
5867
5868 // Only include citation URLs if citation links are enabled
5869 if ($citation_links_enabled) {
5870 $valid_urls[] = $source_url;
5871 $content .= "URL: " . $source_url . "\n\n";
5872 }
5873 } else {
5874 // Manual entry — no reference number, no citation
5875 $content .= "## Information ##\n";
5876 $content .= $full_text . "\n\n";
5877 }
5878
5879 // Extract any URLs from the text content itself (only if citation links enabled)
5880 if ($citation_links_enabled) {
5881 preg_match_all(
5882 '#\bhttps?://[^\s<>"\']+#i',
5883 $full_text,
5884 $content_urls
5885 );
5886 if (!empty($content_urls[0])) {
5887 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5888 }
5889 }
5890
5891 $total_chunks_used += $chunks_in_this_source;
5892 }
5893 }
5894
5895 // NEW: Store unique valid URLs for validation
5896 $this->current_valid_urls = array_unique($valid_urls);
5897
5898 // Store sources and chunks counts for testing/transcript display
5899 $this->last_similarity_analysis['sources_used'] = $matches_used;
5900 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
5901
5902 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
5903 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
5904
5905 // Add response guidelines
5906 if (empty($top_urls)) {
5907 $content = "No reference information was found for this query.\n\n";
5908 } else {
5909 // Build response guidelines based on citation links setting
5910 $content .= "\n## Response Guidelines ##\n" .
5911 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5912 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5913 "If you don't have specific information or are uncertain about any details, it's always " .
5914 "better to honestly say you don't know rather than making up or guessing at answers. " .
5915 "When information is incomplete, let them know you are unsure.\n\n";
5916
5917 // Only add hyperlink instructions if citation links are enabled
5918 if ($citation_links_enabled) {
5919 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5920 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
5921 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
5922 } else {
5923 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5924 "Simply provide helpful answers based on the reference information without citing sources.";
5925 }
5926 }
5927
5928 return trim($content);
5929 }
5930
5931 /**
5932 * Fetch and reassemble chunks for a URL from WordPress database
5933 *
5934 * @param string $source_url The source URL to fetch chunks for
5935 * @param int $max_chunks Maximum number of chunks to return (0 = unlimited)
5936 * @param int &$chunk_count Reference to store the actual number of chunks returned
5937 * @return string Reassembled content from chunks
5938 */
5939 private function reassemble_chunks_from_wordpress($source_url, $max_chunks = 0, &$chunk_count = 0) {
5940 global $wpdb;
5941 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
5942
5943 // Fetch all rows with this source_url
5944 $rows = $wpdb->get_results($wpdb->prepare(
5945 "SELECT article_content FROM {$table}
5946 WHERE source_url = %s
5947 ORDER BY id ASC",
5948 $source_url
5949 ));
5950
5951 if (empty($rows)) {
5952 $chunk_count = 0;
5953 return '';
5954 }
5955
5956 // Parse and sort chunks by index
5957 $chunks = array();
5958 foreach ($rows as $row) {
5959 $parsed = MxChat_Chunker::parse_stored_chunk($row->article_content);
5960
5961 if ($parsed['is_chunked']) {
5962 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
5963 $chunks[$chunk_index] = $parsed['text'];
5964 } else {
5965 // Non-chunked content - just return it
5966 $chunks[] = $parsed['text'];
5967 }
5968 }
5969
5970 // Sort by chunk index
5971 ksort($chunks);
5972
5973 // Apply chunk limit if specified
5974 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
5975 $chunks = array_slice($chunks, 0, $max_chunks, true);
5976 }
5977
5978 // Store actual chunk count
5979 $chunk_count = count($chunks);
5980
5981 // Reassemble content
5982 return implode("\n\n", $chunks);
5983 }
5984
5985 private function find_relevant_content_pinecone($user_embedding, $bot_id = 'default', $bot_config = null) {
5986 global $wpdb;
5987
5988 //error_log("MXCHAT DEBUG: find_relevant_content_pinecone called");
5989 //error_log(" - bot_id: " . $bot_id);
5990 //error_log(" - user_embedding is array: " . (is_array($user_embedding) ? 'yes' : 'no'));
5991 //error_log(" - user_embedding count: " . (is_array($user_embedding) ? count($user_embedding) : 'N/A'));
5992
5993 // Use bot-specific config or fall back to default
5994 if ($bot_config === null) {
5995 $bot_config = $this->get_bot_pinecone_config($bot_id);
5996 }
5997
5998 $api_key = $bot_config['api_key'] ?? '';
5999 $host = $bot_config['host'] ?? '';
6000 $namespace = $bot_config['namespace'] ?? '';
6001
6002 //error_log("MXCHAT DEBUG: Pinecone query parameters:");
6003 //error_log(" - API Key: " . (empty($api_key) ? 'EMPTY - ERROR!' : 'Present (length: ' . strlen($api_key) . ')'));
6004 //error_log(" - Host: " . (empty($host) ? 'EMPTY - ERROR!' : $host));
6005 //error_log(" - Namespace: " . (empty($namespace) ? 'EMPTY (will use default)' : $namespace));
6006
6007 // Initialize similarity analysis storage
6008 $this->last_similarity_analysis = [
6009 'knowledge_base_type' => 'Pinecone',
6010 'bot_id' => $bot_id,
6011 'namespace' => $namespace,
6012 'top_matches' => [],
6013 'threshold_used' => 0,
6014 'total_checked' => 0
6015 ];
6016
6017 // NEW: Initialize valid URLs array
6018 $valid_urls = [];
6019
6020 if (empty($host) || empty($api_key)) {
6021 //error_log("MXCHAT DEBUG ERROR: Missing Pinecone host or API key!");
6022 //error_log(" - Host empty: " . (empty($host) ? 'YES' : 'NO'));
6023 //error_log(" - API key empty: " . (empty($api_key) ? 'YES' : 'NO'));
6024 // Store empty array for valid URLs since we can't proceed
6025 $this->current_valid_urls = [];
6026 return '';
6027 }
6028
6029 // Get knowledge manager instance for role checking
6030 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
6031
6032 // Get the similarity threshold from the bot options or main options
6033 $bot_options = $this->get_bot_options($bot_id);
6034 $current_options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', []);
6035
6036 $similarity_threshold = isset($current_options['similarity_threshold'])
6037 ? ((int) $current_options['similarity_threshold']) / 100
6038 : 0.35;
6039
6040 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
6041
6042 // Prepare the query request for Pinecone
6043 $api_endpoint = "https://{$host}/query";
6044
6045 $request_body = array(
6046 'vector' => $user_embedding,
6047 'topK' => 50, // Increased for chunked content grouping - need more candidates to find top N unique URLs
6048 'includeMetadata' => true,
6049 'includeValues' => true
6050 );
6051
6052 // Add namespace if specified for this bot
6053 if (!empty($namespace)) {
6054 $request_body['namespace'] = $namespace;
6055 }
6056
6057 //error_log("MXCHAT DEBUG: About to call Pinecone API");
6058 //error_log(" - Endpoint: " . $api_endpoint);
6059 //error_log(" - Namespace in request: " . (!empty($namespace) ? $namespace : 'NOT SET'));
6060
6061 $response = wp_remote_post($api_endpoint, array(
6062 'headers' => array(
6063 'Api-Key' => $api_key,
6064 'accept' => 'application/json',
6065 'content-type' => 'application/json'
6066 ),
6067 'body' => wp_json_encode($request_body),
6068 'timeout' => 30
6069 ));
6070
6071 if (is_wp_error($response)) {
6072 //error_log("MXCHAT DEBUG ERROR: WP Error in Pinecone request: " . $response->get_error_message());
6073 // Store empty array for valid URLs
6074 $this->current_valid_urls = [];
6075 return '';
6076 }
6077
6078 $response_code = wp_remote_retrieve_response_code($response);
6079 //error_log("MXCHAT DEBUG: Pinecone response code: " . $response_code);
6080
6081 if ($response_code !== 200) {
6082 $response_body = wp_remote_retrieve_body($response);
6083 //error_log("MXCHAT DEBUG ERROR: Pinecone API error response: " . substr($response_body, 0, 500));
6084 // Store empty array for valid URLs
6085 $this->current_valid_urls = [];
6086 return '';
6087 }
6088
6089 // ADD DETAILED DEBUG SECTION HERE
6090 $response_body = wp_remote_retrieve_body($response);
6091 //error_log("MXCHAT DEBUG: Raw Pinecone response length: " . strlen($response_body));
6092
6093 $results = json_decode($response_body, true);
6094
6095 if (json_last_error() !== JSON_ERROR_NONE) {
6096 //error_log("MXCHAT DEBUG ERROR: JSON decode error: " . json_last_error_msg());
6097 //error_log("MXCHAT DEBUG: First 500 chars of response: " . substr($response_body, 0, 500));
6098 // Store empty array for valid URLs
6099 $this->current_valid_urls = [];
6100 return '';
6101 }
6102
6103 //error_log("MXCHAT DEBUG: Pinecone response structure:");
6104 //error_log(" - Has 'matches' key: " . (isset($results['matches']) ? 'yes' : 'no'));
6105 //error_log(" - Has 'namespace' key: " . (isset($results['namespace']) ? 'yes (' . $results['namespace'] . ')' : 'no'));
6106
6107 if (empty($results['matches'])) {
6108 //error_log("MXCHAT DEBUG: No matches found in Pinecone response");
6109 //error_log("MXCHAT DEBUG: Response keys: " . implode(', ', array_keys($results)));
6110 // Store empty array for valid URLs
6111 $this->current_valid_urls = [];
6112 return '';
6113 }
6114
6115 //error_log("MXCHAT DEBUG: Found " . count($results['matches']) . " matches in Pinecone");
6116
6117 // Log first match details for debugging
6118 if (!empty($results['matches'][0])) {
6119 $first_match = $results['matches'][0];
6120 //error_log("MXCHAT DEBUG: First match details:");
6121 //error_log(" - Score: " . ($first_match['score'] ?? 'no score'));
6122 //error_log(" - Has metadata: " . (isset($first_match['metadata']) ? 'yes' : 'no'));
6123 if (isset($first_match['metadata'])) {
6124 //error_log(" - Metadata keys: " . implode(', ', array_keys($first_match['metadata'])));
6125 }
6126 }
6127
6128 // Initialize the final content
6129 $content = '';
6130 $matches_used = 0;
6131 $matches_used_for_context = [];
6132 $total_chunks_used = 0;
6133 $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
6134 if ($max_total_chunks < 8) $max_total_chunks = 8;
6135 if ($max_total_chunks > 20) $max_total_chunks = 20;
6136 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
6137
6138 // Check if citation links are enabled (default to 'on' for backwards compatibility)
6139 // Use fresh options to ensure we get the latest setting value
6140 $fresh_options = get_option('mxchat_options', []);
6141 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6142
6143 // NEW CHUNKING LOGIC: Group results by source_url for chunk reassembly
6144 $url_groups = array();
6145
6146 foreach ($results['matches'] as $index => $match) {
6147 // Skip if similarity is below threshold
6148 if ($match['score'] < $similarity_threshold) {
6149 continue;
6150 }
6151
6152 $metadata = $match['metadata'] ?? array();
6153 $source_url = $metadata['source_url'] ?? '';
6154 $match_id = $match['id'] ?? '';
6155
6156 // LAZY ROLE CHECK: Only check role for content we're actually considering
6157 $role_restriction = $this->get_single_vector_role($match_id, $metadata);
6158 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
6159
6160 // Skip if user doesn't have access
6161 if (!$has_access) {
6162 continue;
6163 }
6164
6165 // Use a unique key for manual entries without a source URL
6166 $group_key = !empty($source_url) ? $source_url : '_manual_' . $match_id;
6167
6168 // Group by source URL (or unique key for manual entries)
6169 if (!isset($url_groups[$group_key])) {
6170 $url_groups[$group_key] = array(
6171 'source_url' => $source_url,
6172 'best_score' => 0,
6173 'is_chunked' => isset($metadata['is_chunked']) && $metadata['is_chunked'],
6174 'chunks' => array(),
6175 'single_text' => ''
6176 );
6177 }
6178
6179 // Track best score for this group
6180 if ($match['score'] > $url_groups[$group_key]['best_score']) {
6181 $url_groups[$group_key]['best_score'] = $match['score'];
6182 }
6183
6184 // Store chunk info or single text
6185 if ($url_groups[$group_key]['is_chunked']) {
6186 $url_groups[$group_key]['chunks'][] = array(
6187 'id' => $match_id,
6188 'score' => $match['score'],
6189 'chunk_index' => $metadata['chunk_index'] ?? 0,
6190 'text' => $metadata['text'] ?? ''
6191 );
6192 } else {
6193 // Non-chunked content - just store the text
6194 $url_groups[$group_key]['single_text'] = $metadata['text'] ?? '';
6195 $url_groups[$group_key]['single_id'] = $match_id;
6196 }
6197 }
6198
6199 // Sort URL groups by best score (highest first)
6200 uasort($url_groups, function($a, $b) {
6201 return $b['best_score'] <=> $a['best_score'];
6202 });
6203
6204 // Get RAG sources limit from options (default 6, min 3, max 10)
6205 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
6206 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
6207 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
6208
6209 // Take top N unique URLs based on user setting
6210 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
6211
6212 // Track which match IDs are actually used for context
6213 foreach ($top_urls as $group) {
6214 if ($group['is_chunked']) {
6215 foreach ($group['chunks'] as $chunk) {
6216 $matches_used_for_context[] = $chunk['id'];
6217 }
6218 } elseif (!empty($group['single_id'])) {
6219 $matches_used_for_context[] = $group['single_id'];
6220 }
6221 }
6222
6223 // Build content from top sources
6224 foreach ($top_urls as $group_key => $group) {
6225 $source_url = $group['source_url']; // Use actual source_url, not the group key
6226
6227 // Stop if we've hit the total chunk limit
6228 if ($total_chunks_used >= $max_total_chunks) {
6229 break;
6230 }
6231
6232 $full_text = '';
6233 $chunks_in_this_source = 1; // Default for non-chunked content
6234
6235 if ($group['is_chunked']) {
6236 // Calculate how many chunks we can still use (respect both total and per-source caps)
6237 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
6238
6239 // Fetch chunks for this URL with limit
6240 $full_text = $this->reassemble_chunks_from_pinecone($source_url, $bot_config, $chunks_remaining, $chunks_in_this_source);
6241
6242 // If fetching all chunks fails, fall back to matched chunks
6243 if (empty($full_text)) {
6244 // Sort matched chunks by index and concatenate
6245 usort($group['chunks'], function($a, $b) {
6246 return $a['chunk_index'] <=> $b['chunk_index'];
6247 });
6248
6249 $chunk_texts = array();
6250 $chunks_in_this_source = 0;
6251 foreach ($group['chunks'] as $chunk) {
6252 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
6253 break;
6254 }
6255 $chunk_texts[] = $chunk['text'];
6256 $chunks_in_this_source++;
6257 }
6258 $full_text = implode("\n\n", $chunk_texts);
6259 }
6260 } else {
6261 $full_text = $group['single_text'];
6262 $chunks_in_this_source = 1;
6263 }
6264
6265 if (!empty($full_text)) {
6266 // Strip URLs from content if citation links are disabled
6267 if (!$citation_links_enabled) {
6268 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
6269 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
6270 }
6271
6272 // Use numbered reference for URL-based entries, plain info label for manual entries
6273 // Manual entries are stored with an internal mxchat:// placeholder URL — never expose them as citations
6274 if (!empty($source_url) && $source_url !== '#' && strpos($source_url, 'mxchat://') !== 0) {
6275 $matches_used++;
6276 $content .= "## Reference " . $matches_used . " ##\n";
6277 $content .= $full_text . "\n\n";
6278
6279 // Only include citation URLs if citation links are enabled
6280 if ($citation_links_enabled) {
6281 $valid_urls[] = $source_url;
6282 $content .= "URL: " . $source_url . "\n\n";
6283 }
6284 } else {
6285 // Manual entry — no reference number, no citation. Count it as a USED
6286 // source (plan-mxchat-20260622-c1fe6a): without this, manual/Direct-Content
6287 // entries (empty or mxchat:// source_url) never increment $matches_used, so
6288 // the gate below (`if ($matches_used === 0)`) discards manual-only context on
6289 // the Pinecone backend and the model is told "No reference information was
6290 // found" — even though the testing panel reports used_for_context:true. It
6291 // also corrects the cosmetic sources_used:0 the panel/transcript showed. The
6292 // sibling local/WP-DB builder gates on empty($top_urls), so it never had this
6293 // bug; this brings Pinecone to parity. Manual entries are still uncited (not
6294 // added to $valid_urls, no "URL:" line).
6295 $matches_used++;
6296 $content .= "## Information ##\n";
6297 $content .= $full_text . "\n\n";
6298 }
6299
6300 // Extract any URLs from the text content itself (only if citation links enabled)
6301 if ($citation_links_enabled) {
6302 preg_match_all(
6303 '#\bhttps?://[^\s<>"\']+#i',
6304 $full_text,
6305 $content_urls
6306 );
6307 if (!empty($content_urls[0])) {
6308 $valid_urls = array_merge($valid_urls, $content_urls[0]);
6309 }
6310 }
6311
6312 $total_chunks_used += $chunks_in_this_source;
6313 }
6314 }
6315
6316 // Process ALL matches for testing data (top 10) - with role checking for testing display
6317 $all_matches = [];
6318 foreach ($results['matches'] as $index => $match) {
6319 if ($index >= 10) break; // Limit to top 10 for testing
6320
6321 $match_id = $match['id'] ?? '';
6322
6323 // Check role access for testing display (use cache if available)
6324 $role_restriction = $this->get_single_vector_role($match_id, $match['metadata']);
6325 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
6326
6327 $source_display = '';
6328 if (!empty($match['metadata']['source_url'])) {
6329 $source_display = $match['metadata']['source_url'];
6330 } else {
6331 $content_preview = strip_tags($match['metadata']['text'] ?? '');
6332 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
6333 $source_display = substr(trim($content_preview), 0, 50) . '...';
6334 }
6335
6336 $match_id_for_display = $match['id'] ?? $index;
6337
6338 // Check for chunk metadata in Pinecone
6339 $is_chunk = isset($match['metadata']['is_chunked']) && $match['metadata']['is_chunked'];
6340 $chunk_index = isset($match['metadata']['chunk_index']) ? intval($match['metadata']['chunk_index']) : null;
6341 $total_chunks = isset($match['metadata']['total_chunks']) ? intval($match['metadata']['total_chunks']) : null;
6342
6343 // Also detect chunk from vector ID pattern: {hash}_chunk_{index}
6344 if (!$is_chunk && MxChat_Chunker::is_chunk_vector_id($match_id_for_display)) {
6345 $is_chunk = true;
6346 }
6347
6348 $all_matches[] = [
6349 'document_id' => $match_id_for_display,
6350 'similarity' => $match['score'],
6351 'similarity_percentage' => round($match['score'] * 100, 2),
6352 'above_threshold' => $match['score'] >= $similarity_threshold,
6353 'source_display' => $source_display,
6354 'content_preview' => substr(strip_tags($match['metadata']['text'] ?? ''), 0, 100) . '...',
6355 'used_for_context' => in_array($match_id_for_display, $matches_used_for_context),
6356 'role_restriction' => $role_restriction,
6357 'has_access' => $has_access,
6358 'filtered_out' => !$has_access,
6359 'is_chunk' => $is_chunk,
6360 'chunk_index' => $chunk_index,
6361 'total_chunks' => $total_chunks
6362 ];
6363 }
6364
6365 // Store for testing panel
6366 $this->last_similarity_analysis['top_matches'] = $all_matches;
6367 $this->last_similarity_analysis['total_checked'] = count($results['matches']);
6368 $this->last_similarity_analysis['sources_used'] = $matches_used;
6369 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
6370
6371 // NEW: Store unique valid URLs for validation
6372 $this->current_valid_urls = array_unique($valid_urls);
6373
6374 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
6375 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
6376
6377 // Add response guidelines
6378 if ($matches_used === 0) {
6379 $content = "No reference information was found for this query.\n\n";
6380 } else {
6381 // Build response guidelines based on citation links setting
6382 $content .= "\n## Response Guidelines ##\n" .
6383 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
6384 "Be conversational and friendly, but never mention your knowledge base or training data. " .
6385 "If you don't have specific information or are uncertain about any details, it's always " .
6386 "better to honestly say you don't know rather than making up or guessing at answers. " .
6387 "When information is incomplete, let them know you are unsure.\n\n";
6388
6389 // Only add hyperlink instructions if citation links are enabled
6390 if ($citation_links_enabled) {
6391 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
6392 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
6393 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
6394 } else {
6395 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
6396 "Simply provide helpful answers based on the reference information without citing sources.";
6397 }
6398 }
6399
6400 return trim($content);
6401 }
6402
6403 /**
6404 * Get role restriction for a single vector (with caching)
6405 */
6406 private function get_single_vector_role($vector_id, $metadata = array()) {
6407 global $wpdb;
6408
6409 if (empty($vector_id)) {
6410 return 'public';
6411 }
6412
6413 // Check cache first
6414 $cache_key = 'mxchat_vector_role_' . $vector_id;
6415 $cached_role = wp_cache_get($cache_key, 'mxchat_vector_roles');
6416
6417 if ($cached_role !== false) {
6418 return $cached_role;
6419 }
6420
6421 $role_restriction = 'public';
6422
6423 // First try Pinecone metadata
6424 if (!empty($metadata['role_restriction'])) {
6425 $role_restriction = $metadata['role_restriction'];
6426 } else {
6427 // Check WordPress table for user-modified roles
6428 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
6429 $stored_role = $wpdb->get_var($wpdb->prepare(
6430 "SELECT role_restriction FROM {$roles_table} WHERE vector_id = %s",
6431 $vector_id
6432 ));
6433
6434 if ($stored_role) {
6435 $role_restriction = $stored_role;
6436 }
6437 }
6438
6439 // Cache individual role for 1 hour
6440 wp_cache_set($cache_key, $role_restriction, 'mxchat_vector_roles', 3600);
6441
6442 return $role_restriction;
6443 }
6444
6445 /**
6446 * Fetch and reassemble all chunks for a URL from Pinecone
6447 *
6448 * @param string $source_url The source URL to fetch chunks for
6449 * @param array $bot_config Bot-specific Pinecone configuration
6450 * @return string Reassembled content from all chunks
6451 */
6452 private function reassemble_chunks_from_pinecone($source_url, $bot_config, $max_chunks = 0, &$chunk_count = 0) {
6453 $api_key = $bot_config['api_key'] ?? '';
6454 $host = $bot_config['host'] ?? '';
6455 $namespace = $bot_config['namespace'] ?? '';
6456
6457 if (empty($host) || empty($api_key)) {
6458 $chunk_count = 0;
6459 return '';
6460 }
6461
6462 $base_hash = md5($source_url);
6463
6464 // Use Pinecone list API to find all chunk vectors with this prefix
6465 $list_url = "https://{$host}/vectors/list";
6466
6467 // Limit to max_chunks if specified, otherwise fetch up to 100
6468 $fetch_limit = ($max_chunks > 0 && $max_chunks < 100) ? $max_chunks : 100;
6469
6470 $list_body = array(
6471 'prefix' => $base_hash . '_chunk_',
6472 'limit' => $fetch_limit
6473 );
6474
6475 if (!empty($namespace)) {
6476 $list_body['namespace'] = $namespace;
6477 }
6478
6479 $list_response = wp_remote_post($list_url, array(
6480 'headers' => array(
6481 'Api-Key' => $api_key,
6482 'accept' => 'application/json',
6483 'content-type' => 'application/json'
6484 ),
6485 'body' => wp_json_encode($list_body),
6486 'timeout' => 30
6487 ));
6488
6489 if (is_wp_error($list_response)) {
6490 //error_log('[MXCHAT-CHUNK] List API error: ' . $list_response->get_error_message());
6491 return '';
6492 }
6493
6494 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
6495
6496 if (empty($list_data['vectors'])) {
6497 //error_log('[MXCHAT-CHUNK] No chunk vectors found for URL: ' . $source_url);
6498 return '';
6499 }
6500
6501 // Extract vector IDs
6502 $vector_ids = array();
6503 foreach ($list_data['vectors'] as $vector) {
6504 if (isset($vector['id'])) {
6505 $vector_ids[] = $vector['id'];
6506 }
6507 }
6508
6509 if (empty($vector_ids)) {
6510 return '';
6511 }
6512
6513 // Fetch all chunk content
6514 $fetch_url = "https://{$host}/vectors/fetch";
6515
6516 $fetch_body = array(
6517 'ids' => $vector_ids
6518 );
6519
6520 if (!empty($namespace)) {
6521 $fetch_body['namespace'] = $namespace;
6522 }
6523
6524 $fetch_response = wp_remote_post($fetch_url, array(
6525 'headers' => array(
6526 'Api-Key' => $api_key,
6527 'accept' => 'application/json',
6528 'content-type' => 'application/json'
6529 ),
6530 'body' => wp_json_encode($fetch_body),
6531 'timeout' => 30
6532 ));
6533
6534 if (is_wp_error($fetch_response)) {
6535 //error_log('[MXCHAT-CHUNK] Fetch API error: ' . $fetch_response->get_error_message());
6536 return '';
6537 }
6538
6539 $fetch_data = json_decode(wp_remote_retrieve_body($fetch_response), true);
6540
6541 if (empty($fetch_data['vectors'])) {
6542 return '';
6543 }
6544
6545 // Sort chunks by index and reassemble
6546 $chunks = array();
6547 foreach ($fetch_data['vectors'] as $id => $vector) {
6548 $metadata = $vector['metadata'] ?? array();
6549 $chunk_index = $metadata['chunk_index'] ?? 0;
6550 $text = $metadata['text'] ?? '';
6551
6552 // Store chunk with its index
6553 $chunks[$chunk_index] = $text;
6554 }
6555
6556 // Sort by chunk index
6557 ksort($chunks);
6558
6559 // Apply chunk limit if specified
6560 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
6561 $chunks = array_slice($chunks, 0, $max_chunks, true);
6562 }
6563
6564 // Store actual chunk count
6565 $chunk_count = count($chunks);
6566
6567 // Reassemble content
6568 return implode("\n\n", $chunks);
6569 }
6570
6571 /**
6572 * Search for relevant content using OpenAI Vector Store (File Search)
6573 *
6574 * @param string $user_query The user's query text
6575 * @param string $bot_id The bot ID
6576 * @param array $vectorstore_config Vector Store configuration
6577 * @return string Formatted context string with references
6578 */
6579 private function find_relevant_content_openai_vectorstore($user_query, $bot_id = 'default', $vectorstore_config = array()) {
6580 //error_log("MXCHAT DEBUG: find_relevant_content_openai_vectorstore called");
6581 //error_log(" - bot_id: " . $bot_id);
6582 //error_log(" - user_query length: " . strlen($user_query));
6583
6584 // Get OpenAI API key
6585 $mxchat_options = get_option('mxchat_options', array());
6586 $api_key = $mxchat_options['api_key'] ?? '';
6587
6588 // Reset vectorstore error tracking
6589 $this->last_vectorstore_error = null;
6590
6591 if (empty($api_key)) {
6592 //error_log("MXCHAT DEBUG ERROR: OpenAI API key not configured");
6593 $this->last_vectorstore_error = 'Vector Store search failed: OpenAI API key is not configured.';
6594 $this->current_valid_urls = [];
6595 return '';
6596 }
6597
6598 // Get Vector Store configuration
6599 if (empty($vectorstore_config)) {
6600 $vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
6601 }
6602
6603 $vectorstore_ids_string = $vectorstore_config['vectorstore_ids'] ?? '';
6604 $max_results = $vectorstore_config['max_results'] ?? 5;
6605
6606 if (empty($vectorstore_ids_string)) {
6607 //error_log("MXCHAT DEBUG ERROR: No Vector Store IDs configured");
6608 $this->last_vectorstore_error = 'Vector Store search failed: No Vector Store IDs are configured for this bot.';
6609 $this->current_valid_urls = [];
6610 return '';
6611 }
6612
6613 // Parse Vector Store IDs
6614 $vectorstore_ids = array_map('trim', explode(',', $vectorstore_ids_string));
6615 $vectorstore_ids = array_filter($vectorstore_ids); // Remove empty values
6616
6617 //error_log("MXCHAT DEBUG: Vector Store IDs: " . implode(', ', $vectorstore_ids));
6618 //error_log("MXCHAT DEBUG: Max results: " . $max_results);
6619
6620 // Initialize similarity analysis storage
6621 $this->last_similarity_analysis = [
6622 'knowledge_base_type' => 'OpenAI Vector Store',
6623 'bot_id' => $bot_id,
6624 'vectorstore_ids' => $vectorstore_ids,
6625 'top_matches' => [],
6626 'threshold_used' => 0,
6627 'total_checked' => 0
6628 ];
6629
6630 $valid_urls = [];
6631
6632 // Get the selected model
6633 $bot_options = $this->get_bot_options($bot_id);
6634 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
6635 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
6636
6637 // Verify it's an OpenAI model
6638 if (!$this->is_openai_chat_model($selected_model)) {
6639 //error_log("MXCHAT DEBUG ERROR: Vector Store search requires OpenAI model. Current: " . $selected_model);
6640 $this->last_vectorstore_error = 'Vector Store search requires an OpenAI model. Current model: ' . $selected_model;
6641 $this->current_valid_urls = [];
6642 return '';
6643 }
6644
6645 // Use OpenAI Responses API with file_search tool
6646 $request_body = array(
6647 'model' => $selected_model,
6648 'input' => $user_query,
6649 'tools' => array(
6650 array(
6651 'type' => 'file_search',
6652 'vector_store_ids' => $vectorstore_ids,
6653 'max_num_results' => intval($max_results)
6654 )
6655 ),
6656 'include' => array('output[*].file_search_call.search_results')
6657 );
6658
6659 //error_log("MXCHAT VECTORSTORE: ========== REQUEST START ==========");
6660 //error_log("MXCHAT VECTORSTORE: Model: " . $selected_model);
6661 //error_log("MXCHAT VECTORSTORE: Query: " . substr($user_query, 0, 200));
6662 //error_log("MXCHAT VECTORSTORE: Vector Store IDs: " . implode(', ', $vectorstore_ids));
6663 //error_log("MXCHAT VECTORSTORE: Max Results: " . $max_results);
6664 //error_log("MXCHAT VECTORSTORE: Request body: " . wp_json_encode($request_body));
6665
6666 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
6667 'headers' => array(
6668 'Authorization' => 'Bearer ' . $api_key,
6669 'Content-Type' => 'application/json'
6670 ),
6671 'body' => wp_json_encode($request_body),
6672 'timeout' => 60
6673 ));
6674
6675 if (is_wp_error($response)) {
6676 //error_log("MXCHAT VECTORSTORE ERROR: WP Error: " . $response->get_error_message());
6677 $this->last_vectorstore_error = 'Vector Store API request failed: ' . $response->get_error_message();
6678 $this->current_valid_urls = [];
6679 return '';
6680 }
6681
6682 $response_code = wp_remote_retrieve_response_code($response);
6683 //error_log("MXCHAT VECTORSTORE: Response code: " . $response_code);
6684
6685 $response_body = wp_remote_retrieve_body($response);
6686 //error_log("MXCHAT VECTORSTORE: Raw response (first 2000 chars): " . substr($response_body, 0, 2000));
6687
6688 if ($response_code !== 200) {
6689 //error_log("MXCHAT VECTORSTORE ERROR: API error response: " . $response_body);
6690 $api_error_detail = '';
6691 $decoded_error = json_decode($response_body, true);
6692 if (isset($decoded_error['error']['message'])) {
6693 $api_error_detail = $decoded_error['error']['message'];
6694 }
6695 $this->last_vectorstore_error = 'Vector Store API returned HTTP ' . $response_code . ($api_error_detail ? ': ' . $api_error_detail : '');
6696 $this->current_valid_urls = [];
6697 return '';
6698 }
6699 $result = json_decode($response_body, true);
6700
6701 if (json_last_error() !== JSON_ERROR_NONE) {
6702 //error_log("MXCHAT VECTORSTORE ERROR: JSON decode error: " . json_last_error_msg());
6703 $this->last_vectorstore_error = 'Vector Store response could not be parsed: ' . json_last_error_msg();
6704 $this->current_valid_urls = [];
6705 return '';
6706 }
6707
6708 // Debug: Log the structure of the result
6709 //error_log("MXCHAT VECTORSTORE: Result keys: " . implode(', ', array_keys($result)));
6710 if (isset($result['output'])) {
6711 //error_log("MXCHAT VECTORSTORE: Output count: " . count($result['output']));
6712 foreach ($result['output'] as $idx => $out) {
6713 //error_log("MXCHAT VECTORSTORE: Output[$idx] type: " . ($out['type'] ?? 'unknown'));
6714 //error_log("MXCHAT VECTORSTORE: Output[$idx] keys: " . implode(', ', array_keys($out)));
6715 }
6716 } else {
6717 //error_log("MXCHAT VECTORSTORE: No 'output' key in result!");
6718 }
6719
6720 // Extract file search results from the response
6721 $content = '';
6722 $matches_used = 0;
6723 $all_matches = [];
6724
6725 // The Responses API returns output array with tool results
6726 if (isset($result['output']) && is_array($result['output'])) {
6727 foreach ($result['output'] as $output_item) {
6728 // Look for file_search_call results
6729 if (isset($output_item['type']) && $output_item['type'] === 'file_search_call') {
6730 //error_log("MXCHAT VECTORSTORE: Found file_search_call output item");
6731 //error_log("MXCHAT VECTORSTORE: file_search_call keys: " . implode(', ', array_keys($output_item)));
6732
6733 // Check for search_results in the output item directly
6734 $search_results = $output_item['search_results'] ?? $output_item['results'] ?? [];
6735 //error_log("MXCHAT VECTORSTORE: Search results count: " . count($search_results));
6736
6737 if (empty($search_results)) {
6738 //error_log("MXCHAT VECTORSTORE: No search results found in file_search_call");
6739 //error_log("MXCHAT VECTORSTORE: file_search_call content: " . wp_json_encode($output_item));
6740 }
6741
6742 foreach ($search_results as $index => $search_result) {
6743 $filename = $search_result['filename'] ?? '';
6744 $score = $search_result['score'] ?? 0;
6745 $text_content = '';
6746
6747 // Extract text content from the result
6748 // The text can be directly on the result OR nested under content array
6749 if (isset($search_result['text']) && !empty($search_result['text'])) {
6750 // Direct text field (OpenAI's actual format)
6751 $text_content = $search_result['text'];
6752 //error_log("MXCHAT VECTORSTORE: Found text directly on result[$index], length: " . strlen($text_content));
6753 } elseif (isset($search_result['content']) && is_array($search_result['content'])) {
6754 // Nested content array format
6755 foreach ($search_result['content'] as $content_item) {
6756 if (isset($content_item['text'])) {
6757 $text_content .= $content_item['text'] . "\n";
6758 }
6759 }
6760 //error_log("MXCHAT VECTORSTORE: Found text in content array for result[$index], length: " . strlen($text_content));
6761 } else {
6762 //error_log("MXCHAT VECTORSTORE: No text found for result[$index]. Keys: " . implode(', ', array_keys($search_result)));
6763 }
6764
6765 if (!empty($text_content)) {
6766 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
6767 $content .= trim($text_content) . "\n\n";
6768
6769 if (!empty($filename)) {
6770 $content .= "Source: " . $filename . "\n\n";
6771 }
6772
6773 // Extract URLs from content
6774 preg_match_all(
6775 '#\bhttps?://[^\s<>"\']+#i',
6776 $text_content,
6777 $content_urls
6778 );
6779 if (!empty($content_urls[0])) {
6780 $valid_urls = array_merge($valid_urls, $content_urls[0]);
6781 }
6782
6783 $matches_used++;
6784 }
6785
6786 // Store for similarity analysis
6787 $all_matches[] = [
6788 'document_id' => $filename ?: ('result_' . $index),
6789 'similarity' => $score,
6790 'similarity_percentage' => round($score * 100, 2),
6791 'above_threshold' => true,
6792 'source_display' => $filename,
6793 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
6794 'used_for_context' => true,
6795 'role_restriction' => 'public',
6796 'has_access' => true,
6797 'filtered_out' => false
6798 ];
6799 }
6800 }
6801
6802 // Also check for message content with annotations (citations)
6803 if (isset($output_item['type']) && $output_item['type'] === 'message') {
6804 if (isset($output_item['content']) && is_array($output_item['content'])) {
6805 foreach ($output_item['content'] as $content_block) {
6806 if (isset($content_block['annotations']) && is_array($content_block['annotations'])) {
6807 foreach ($content_block['annotations'] as $annotation) {
6808 if (isset($annotation['filename'])) {
6809 $filename = $annotation['filename'];
6810 $score = $annotation['score'] ?? 0;
6811 $text_content = '';
6812
6813 if (isset($annotation['content']) && is_array($annotation['content'])) {
6814 foreach ($annotation['content'] as $ann_content) {
6815 if (isset($ann_content['text'])) {
6816 $text_content .= $ann_content['text'] . "\n";
6817 }
6818 }
6819 }
6820
6821 if (!empty($text_content) && $matches_used < $max_results) {
6822 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
6823 $content .= trim($text_content) . "\n\n";
6824 $content .= "Source: " . $filename . "\n\n";
6825
6826 preg_match_all(
6827 '#\bhttps?://[^\s<>"\']+#i',
6828 $text_content,
6829 $content_urls
6830 );
6831 if (!empty($content_urls[0])) {
6832 $valid_urls = array_merge($valid_urls, $content_urls[0]);
6833 }
6834
6835 $matches_used++;
6836
6837 $all_matches[] = [
6838 'document_id' => $filename,
6839 'similarity' => $score,
6840 'similarity_percentage' => round($score * 100, 2),
6841 'above_threshold' => true,
6842 'source_display' => $filename,
6843 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
6844 'used_for_context' => true,
6845 'role_restriction' => 'public',
6846 'has_access' => true,
6847 'filtered_out' => false
6848 ];
6849 }
6850 }
6851 }
6852 }
6853 }
6854 }
6855 }
6856 }
6857 }
6858
6859 // Store for testing panel
6860 $this->last_similarity_analysis['top_matches'] = $all_matches;
6861 $this->last_similarity_analysis['total_checked'] = count($all_matches);
6862
6863 // Store unique valid URLs for validation
6864 $this->current_valid_urls = array_unique($valid_urls);
6865
6866 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
6867 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
6868
6869 //error_log("MXCHAT VECTORSTORE: ========== SEARCH COMPLETE ==========");
6870 //error_log("MXCHAT VECTORSTORE: Matches used: " . $matches_used);
6871 //error_log("MXCHAT VECTORSTORE: All matches count: " . count($all_matches));
6872 //error_log("MXCHAT VECTORSTORE: Content length: " . strlen($content));
6873 if ($matches_used > 0) {
6874 //error_log("MXCHAT VECTORSTORE: Content preview: " . substr($content, 0, 500));
6875 }
6876
6877 // Check if citation links are enabled
6878 $citation_links_enabled = ($mxchat_options['citation_links_toggle'] ?? 'on') === 'on';
6879
6880 // Add response guidelines
6881 if ($matches_used === 0) {
6882 //error_log("MXCHAT VECTORSTORE: No matches found - returning empty reference message");
6883 $content = "No reference information was found for this query.\n\n";
6884 } else {
6885 // Build response guidelines based on citation links setting
6886 $content .= "\n## Response Guidelines ##\n" .
6887 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
6888 "Be conversational and friendly, but never mention your knowledge base or training data. " .
6889 "If you don't have specific information or are uncertain about any details, it's always " .
6890 "better to honestly say you don't know rather than making up or guessing at answers. " .
6891 "When information is incomplete, let them know you are unsure.\n\n";
6892
6893 // Only add hyperlink instructions if citation links are enabled
6894 if ($citation_links_enabled) {
6895 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
6896 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
6897 } else {
6898 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
6899 "Simply provide helpful answers based on the reference information without citing sources.";
6900 }
6901 }
6902
6903 //error_log("MXCHAT DEBUG: Vector Store search complete. Matches used: " . $matches_used);
6904
6905 return trim($content);
6906 }
6907
6908 /**
6909 * Check if the given model is an OpenAI chat model
6910 *
6911 * @param string $model The model ID
6912 * @return bool True if it's an OpenAI model
6913 */
6914 private function is_openai_chat_model($model) {
6915 $openai_prefixes = array('gpt-', 'o1-', 'o3-');
6916 foreach ($openai_prefixes as $prefix) {
6917 if (strpos($model, $prefix) === 0) {
6918 return true;
6919 }
6920 }
6921 return false;
6922 }
6923
6924 /**
6925 * Get bot-specific Vector Store configuration
6926 *
6927 * @param string $bot_id The bot ID
6928 * @return array Configuration array
6929 */
6930 private function get_bot_vectorstore_config($bot_id = 'default') {
6931 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
6932
6933 // Default global settings
6934 $default_config = array(
6935 'use_vectorstore' => ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1',
6936 'vectorstore_ids' => $vectorstore_options['mxchat_vectorstore_ids'] ?? '',
6937 'max_results' => $vectorstore_options['mxchat_vectorstore_max_results'] ?? 5
6938 );
6939
6940 // Allow multi-bot plugin to override with bot-specific settings
6941 $bot_config = apply_filters('mxchat_get_bot_vectorstore_config', $default_config, $bot_id);
6942
6943 // Preserve max_results from global settings if not set in bot config
6944 if (!isset($bot_config['max_results'])) {
6945 $bot_config['max_results'] = $default_config['max_results'];
6946 }
6947
6948 return $bot_config;
6949 }
6950
6951 private function mxchat_find_relevant_products($user_embedding) {
6952 //error_log('MXChat Vector Search: Starting product search...');
6953
6954 // Retrieve the add-on settings from the database
6955 $addon_options = get_option('mxchat_pinecone_addon_options', array());
6956
6957 // Determine whether Pinecone is enabled
6958 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1') ? 1 : 0;
6959
6960 //error_log('Pinecone enabled flag: ' . $use_pinecone);
6961
6962 if ($use_pinecone === 1) {
6963 //error_log('MXChat Vector Search: Using Pinecone database for products');
6964 return $this->find_relevant_products_pinecone($user_embedding);
6965 } else {
6966 //error_log('MXChat Vector Search: Using WordPress database for products');
6967 return $this->find_relevant_products_wordpress($user_embedding);
6968 }
6969 }
6970 private function find_relevant_products_wordpress($user_embedding) {
6971 global $wpdb;
6972 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6973
6974 if (!is_array($user_embedding)) {
6975 return '';
6976 }
6977
6978 // Streaming top-K pass: scan rows in small batches, keep only the top 3
6979 // results above the similarity threshold. Peak memory is bounded by
6980 // $batch_size embedding rows plus a 3-element top list.
6981 $batch_size = 250;
6982 $similarity_threshold = 0.85;
6983 $top_k = 3;
6984 $top_results = [];
6985 $offset = 0;
6986
6987 do {
6988 $batch = $wpdb->get_results($wpdb->prepare(
6989 "SELECT id, embedding_vector
6990 FROM {$system_prompt_table}
6991 LIMIT %d OFFSET %d",
6992 $batch_size,
6993 $offset
6994 ));
6995
6996 if (empty($batch)) {
6997 break;
6998 }
6999
7000 foreach ($batch as $row) {
7001 $database_embedding = $row->embedding_vector
7002 ? unserialize($row->embedding_vector, ['allowed_classes' => false])
7003 : null;
7004
7005 if (!is_array($database_embedding)) {
7006 unset($database_embedding);
7007 continue;
7008 }
7009
7010 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
7011 unset($database_embedding);
7012
7013 if ($similarity < $similarity_threshold) {
7014 continue;
7015 }
7016
7017 // Insert into bounded top-K (kept sorted descending)
7018 if (count($top_results) < $top_k) {
7019 $top_results[] = ['id' => $row->id, 'similarity' => $similarity];
7020 usort($top_results, function ($a, $b) {
7021 return $b['similarity'] <=> $a['similarity'];
7022 });
7023 } elseif ($similarity > $top_results[$top_k - 1]['similarity']) {
7024 $top_results[$top_k - 1] = ['id' => $row->id, 'similarity' => $similarity];
7025 usort($top_results, function ($a, $b) {
7026 return $b['similarity'] <=> $a['similarity'];
7027 });
7028 }
7029 }
7030
7031 unset($batch);
7032 $offset += $batch_size;
7033 } while (true);
7034
7035 if (empty($top_results)) {
7036 return '';
7037 }
7038
7039 $content = '';
7040 foreach ($top_results as $result) {
7041 $chunk_content = $this->fetch_content_with_product_links($result['id']);
7042 $content .= $chunk_content . "\n\n";
7043 }
7044
7045 return trim($content);
7046 }
7047
7048
7049 private function find_relevant_products_pinecone($user_embedding) {
7050 //error_log('Starting Pinecone product search...');
7051
7052 $options = get_option('mxchat_pinecone_addon_options', array());
7053 $api_key = $options['mxchat_pinecone_api_key'] ?? '';
7054 $host = $options['mxchat_pinecone_host'] ?? '';
7055
7056 if (empty($host) || empty($api_key)) {
7057 //error_log('Pinecone credentials not properly configured for product search');
7058 return '';
7059 }
7060
7061 $similarity_threshold = 0.85;
7062 $api_endpoint = "https://{$host}/query";
7063
7064 $request_body = array(
7065 'vector' => $user_embedding,
7066 'topK' => 5,
7067 'includeMetadata' => true,
7068 'includeValues' => true,
7069 'filter' => array(
7070 'type' => 'product'
7071 )
7072 );
7073
7074 //error_log('Sending request to Pinecone with body: ' . wp_json_encode($request_body));
7075
7076 $response = wp_remote_post($api_endpoint, array(
7077 'headers' => array(
7078 'Api-Key' => $api_key,
7079 'accept' => 'application/json',
7080 'content-type' => 'application/json'
7081 ),
7082 'body' => wp_json_encode($request_body),
7083 'timeout' => 30
7084 ));
7085
7086 if (is_wp_error($response)) {
7087 //error_log('Pinecone product query error: ' . $response->get_error_message());
7088 return '';
7089 }
7090
7091 $response_code = wp_remote_retrieve_response_code($response);
7092 //error_log('Pinecone response code: ' . $response_code);
7093
7094 if ($response_code !== 200) {
7095 //error_log('Pinecone API error during product search: ' . wp_remote_retrieve_body($response));
7096 return '';
7097 }
7098
7099 $results = json_decode(wp_remote_retrieve_body($response), true);
7100 //error_log('Pinecone raw response: ' . wp_remote_retrieve_body($response));
7101
7102 if (empty($results['matches'])) {
7103 //error_log('No matches found in Pinecone response');
7104 return '';
7105 }
7106
7107 $content = '';
7108 foreach ($results['matches'] as $match) {
7109 if ($match['score'] < $similarity_threshold) {
7110 //error_log("Match below threshold: " . $match['score']);
7111 continue;
7112 }
7113
7114 if (!empty($match['metadata']['text'])) {
7115 $content .= $match['metadata']['text'];
7116 if (!empty($match['metadata']['source_url'])) {
7117 $content .= "\n\nFor more details, check out this product: " . esc_url($match['metadata']['source_url']);
7118 }
7119 $content .= "\n\n";
7120 }
7121 }
7122
7123 return trim($content);
7124 }
7125
7126
7127 private function fetch_content_with_product_links($most_relevant_id) {
7128 global $wpdb;
7129 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
7130
7131 // Fetch the article content and associated product URL
7132 $query = $wpdb->prepare("SELECT article_content, source_url FROM {$system_prompt_table} WHERE id = %d", $most_relevant_id);
7133 $result = $wpdb->get_row($query);
7134
7135 if ($result) {
7136 // Append the product link to the content if available
7137 $content = $result->article_content;
7138 if (!empty($result->source_url)) {
7139 $content .= "\n\nFor more details, check out this product: " . esc_url($result->source_url);
7140 }
7141 return $content;
7142 }
7143
7144 return null;
7145 }
7146
7147 /**
7148 * Get system instructions for a specific bot or default
7149 * Checks for multi-bot add-on and uses bot-specific instructions if available
7150 * Automatically strips URLs if citation links are disabled
7151 * Replaces {visitor_name} placeholder with actual visitor name if available
7152 *
7153 * @param string $bot_id The bot ID to get instructions for
7154 * @param string $session_id Optional session ID to lookup visitor name
7155 */
7156 private function get_system_instructions($bot_id = 'default', $session_id = '') {
7157 $instructions = '';
7158
7159 // Check if multi-bot add-on is active
7160 if (class_exists('MxChat_Multi_Bot_Core_Manager') && $bot_id !== 'default') {
7161 // Get bot-specific options from multi-bot add-on
7162 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
7163
7164 // If bot has custom system instructions, use those
7165 if (!empty($bot_options['system_prompt_instructions'])) {
7166 $instructions = $bot_options['system_prompt_instructions'];
7167 }
7168 }
7169
7170 // Fall back to default system instructions
7171 if (empty($instructions)) {
7172 $instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
7173 }
7174
7175 // Check if citation links are disabled - if so, strip URLs from instructions
7176 $fresh_options = get_option('mxchat_options', []);
7177 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
7178
7179 if (!$citation_links_enabled && !empty($instructions)) {
7180 $instructions = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $instructions);
7181 $instructions = preg_replace('/\s+/', ' ', trim($instructions)); // Clean up extra spaces
7182 }
7183
7184 // Replace {visitor_name} placeholder with actual visitor name if available
7185 if (!empty($instructions) && !empty($session_id) && stripos($instructions, '{visitor_name}') !== false) {
7186 $name_option_key = "mxchat_name_{$session_id}";
7187 $visitor_name = get_option($name_option_key, '');
7188
7189 if (!empty($visitor_name)) {
7190 $instructions = str_ireplace('{visitor_name}', sanitize_text_field($visitor_name), $instructions);
7191 } else {
7192 // Remove placeholder if no name is available
7193 $instructions = str_ireplace('{visitor_name}', '', $instructions);
7194 $instructions = preg_replace('/\s{2,}/', ' ', trim($instructions)); // Clean up extra spaces
7195 }
7196 }
7197
7198 // Allow developers to filter system instructions and process shortcodes
7199 $instructions = apply_filters('mxchat_system_instructions', $instructions, $bot_id, $session_id);
7200 $instructions = do_shortcode($instructions);
7201
7202 return $instructions;
7203 }
7204 /**
7205 * Get the current bot ID from session or request context
7206 */
7207 private function get_current_bot_id($session_id = '') {
7208 // First, check if bot_id is passed in the current request
7209 if (isset($_POST['bot_id']) && !empty($_POST['bot_id'])) {
7210 return sanitize_key($_POST['bot_id']);
7211 }
7212
7213 // If not in POST, try to get it from session data
7214 if (!empty($session_id)) {
7215 $bot_id = get_option("mxchat_session_bot_{$session_id}", '');
7216 if (!empty($bot_id)) {
7217 return $bot_id;
7218 }
7219 }
7220
7221 // Fall back to default
7222 return 'default';
7223 }
7224 /* ====================================================================== *
7225 * Native function-calling loop (plan-mxchat-20260617-a41dee)
7226 *
7227 * Model-driven tool use. The model is offered MxChat's enabled callbacks as
7228 * tools (sourced from MxChat_Tool_Registry, the single source the admin AI
7229 * Tools checklist also reads). When the model calls a tool, the matching
7230 * callback runs through its EXISTING permission checks, its output is fed
7231 * back, and the loop continues up to a depth cap. INDEPENDENT of the
7232 * intent→callback router — it runs only after intents miss, and works with
7233 * ZERO Actions created.
7234 *
7235 * Entered ONLY when: function calling is enabled + the active model is
7236 * tool-capable + at least one tool is enabled. Default-off, so existing
7237 * installs never enter this branch (byte-for-byte unchanged behavior). The
7238 * tool round is buffered (non-streaming) per the plan; the final answer is
7239 * emitted via the same SSE/JSON envelopes the normal path uses.
7240 * ====================================================================== */
7241
7242 /** Gate: should the function-calling loop handle this turn? */
7243 private function mxchat_fc_should_run($selected_model) {
7244 if (!class_exists('MxChat_Tool_Registry') || !MxChat_Tool_Registry::is_enabled()) {
7245 return false;
7246 }
7247 if (class_exists('MxChat_Model_Catalog') && !MxChat_Model_Catalog::supports_tools($selected_model)) {
7248 return false;
7249 }
7250 $tools = MxChat_Tool_Registry::enabled_tools();
7251 return !empty($tools);
7252 }
7253
7254 private function mxchat_fc_log($msg) {
7255 if (defined('MXCHAT_DEV_MODE') && MXCHAT_DEV_MODE) {
7256 error_log('[MxChat FC] ' . $msg);
7257 }
7258 }
7259
7260 /**
7261 * Resolve provider transport details. Returns null when FC can't run for this
7262 * model/config (missing key, unsupported provider) so the caller falls back to
7263 * the normal path. OpenAI/xAI/DeepSeek/OpenRouter/Custom share the
7264 * OpenAI-compatible 'openai' family; Claude and Gemini are distinct.
7265 */
7266 private function mxchat_fc_resolve_provider($selected_model, $opts) {
7267 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
7268 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
7269 if ($selected_model === 'claude-opus-4-20250514') { $selected_model = 'claude-opus-4-8'; }
7270 elseif ($selected_model === 'claude-sonnet-4-20250514') { $selected_model = 'claude-sonnet-4-6'; }
7271 if ($selected_model === 'openrouter') {
7272 $model = isset($opts['openrouter_selected_model']) ? $opts['openrouter_selected_model'] : '';
7273 $key = isset($opts['openrouter_api_key']) ? $opts['openrouter_api_key'] : '';
7274 if ($model === '' || $key === '') return null;
7275 return array('family'=>'openai','model'=>$model,'url'=>'https://openrouter.ai/api/v1/chat/completions',
7276 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'openai');
7277 }
7278 $prefix = strtolower(explode('-', $selected_model)[0]);
7279 switch ($prefix) {
7280 case 'gpt': case 'o1': case 'o3': case 'o4':
7281 $key = isset($opts['api_key']) ? $opts['api_key'] : '';
7282 if ($key === '') return null;
7283 return array('family'=>'openai','model'=>$selected_model,'url'=>'https://api.openai.com/v1/chat/completions',
7284 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'openai');
7285 case 'claude':
7286 $key = isset($opts['claude_api_key']) ? $opts['claude_api_key'] : '';
7287 if ($key === '') return null;
7288 return array('family'=>'anthropic','model'=>$selected_model,'url'=>'https://api.anthropic.com/v1/messages',
7289 'headers'=>array('Content-Type'=>'application/json','x-api-key'=>$key,'anthropic-version'=>'2023-06-01'),'tag'=>'anthropic');
7290 case 'gemini':
7291 $key = isset($opts['gemini_api_key']) ? $opts['gemini_api_key'] : '';
7292 if ($key === '') return null;
7293 return array('family'=>'gemini','model'=>$selected_model,'key'=>$key,'tag'=>'gemini');
7294 case 'grok': case 'xai':
7295 $key = isset($opts['xai_api_key']) ? $opts['xai_api_key'] : '';
7296 if ($key === '') return null;
7297 return array('family'=>'openai','model'=>$selected_model,'url'=>'https://api.x.ai/v1/chat/completions',
7298 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'xai');
7299 case 'deepseek':
7300 $key = isset($opts['deepseek_api_key']) ? $opts['deepseek_api_key'] : '';
7301 if ($key === '') return null;
7302 return array('family'=>'openai','model'=>$selected_model,'url'=>'https://api.deepseek.com/v1/chat/completions',
7303 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'openai');
7304 case 'custom':
7305 $base = isset($opts['custom_provider_base_url']) ? rtrim($opts['custom_provider_base_url'], '/') : '';
7306 $key = isset($opts['custom_provider_api_key']) ? $opts['custom_provider_api_key'] : '';
7307 $model = isset($opts['custom_provider_model']) ? $opts['custom_provider_model'] : '';
7308 if ($base === '' || $model === '') return null;
7309 $url = (strpos($base, 'chat/completions') !== false) ? $base : $base . '/chat/completions';
7310 $headers = array('Content-Type'=>'application/json');
7311 if ($key !== '') $headers['Authorization'] = 'Bearer '.$key;
7312 return array('family'=>'openai','model'=>$model,'url'=>$url,'headers'=>$headers,'tag'=>'openai');
7313 }
7314 return null;
7315 }
7316
7317 /**
7318 * Top-level function-calling attempt. Returns:
7319 * ['handled'=>true, 'text'=>'<final answer>'] when the model used ≥1 tool
7320 * ['handled'=>false] otherwise (caller falls back
7321 * to the normal streamed path)
7322 */
7323 private function mxchat_fc_attempt($message, $relevant_content, $conversation_history, $selected_model, $opts, $session_id, $user_id) {
7324 $prov = $this->mxchat_fc_resolve_provider($selected_model, $opts);
7325 if (!$prov) {
7326 return array('handled' => false);
7327 }
7328 $tools = MxChat_Tool_Registry::enabled_tools();
7329 if (empty($tools)) {
7330 return array('handled' => false);
7331 }
7332
7333 $bot_id = $this->get_current_bot_id($session_id);
7334 $system = $this->get_system_instructions($bot_id, $session_id);
7335
7336 // Force callbacks into return-mode (some echo SSE directly when streaming);
7337 // we buffer the whole tool round, then emit once. Restored in finally.
7338 $prev_streaming = $this->is_streaming;
7339 $this->is_streaming = false;
7340 try {
7341 if ($prov['family'] === 'anthropic') {
7342 return $this->mxchat_fc_loop_anthropic($prov, $system, $relevant_content, $conversation_history, $tools, $message, $user_id, $session_id);
7343 } elseif ($prov['family'] === 'gemini') {
7344 return $this->mxchat_fc_loop_gemini($prov, $system, $relevant_content, $conversation_history, $tools, $message, $user_id, $session_id);
7345 }
7346 return $this->mxchat_fc_loop_openai($prov, $system, $relevant_content, $conversation_history, $tools, $message, $user_id, $session_id);
7347 } catch (\Throwable $e) {
7348 $this->mxchat_fc_log('attempt threw: ' . $e->getMessage());
7349 return array('handled' => false);
7350 } finally {
7351 $this->is_streaming = $prev_streaming;
7352 }
7353 }
7354
7355 /** Normalize MxChat history rows to [{role:user|assistant, content}]. */
7356 private function mxchat_fc_normalize_history($conversation_history) {
7357 $out = array();
7358 if (!is_array($conversation_history)) return $out;
7359 foreach ($conversation_history as $m) {
7360 if (!is_array($m) || !isset($m['role']) || !isset($m['content'])) continue;
7361 $role = $m['role'];
7362 if ($role === 'bot' || $role === 'agent') $role = 'assistant';
7363 if (!in_array($role, array('user', 'assistant'), true)) $role = 'user';
7364 $out[] = array('role' => $role, 'content' => (string) $m['content']);
7365 }
7366 return $out;
7367 }
7368
7369 /** Execute the matched callback for a tool call. Returns ['ok'=>bool,'content'=>string]. */
7370 private function mxchat_fc_execute_tool($tool_name, $args, $orig_message, $user_id, $session_id) {
7371 $tool = MxChat_Tool_Registry::tool_by_name($tool_name, true); // enabled-only
7372 if (!$tool) {
7373 return array('ok' => false, 'content' => 'This tool is not available or not enabled.');
7374 }
7375 $fn = $tool['callback'];
7376
7377 // MxChat callbacks are message-driven: hand them the model's `query`
7378 // (falling back to the original user message).
7379 $query = '';
7380 if (is_array($args) && isset($args['query']) && is_string($args['query'])) {
7381 $query = $args['query'];
7382 }
7383 if ($query === '') $query = $orig_message;
7384
7385 // Synthetic intent row (matches wp_mxchat_intents columns → no undefined-prop warnings).
7386 $synthetic_intent = (object) array(
7387 'id' => 0, 'intent_label' => $tool['label'], 'phrases' => '',
7388 'embedding_vector' => '', 'callback_function' => $fn,
7389 'similarity_threshold' => 0.0, 'enabled' => 1, 'enabled_bots' => null,
7390 );
7391
7392 try {
7393 if (!empty($tool['is_addon'])) {
7394 $result = apply_filters($fn, false, $query, $user_id, $session_id, $synthetic_intent);
7395 } elseif (method_exists($this, $fn)) {
7396 $result = call_user_func(array($this, $fn), $query, $user_id, $session_id, $synthetic_intent, null);
7397 } else {
7398 return array('ok' => false, 'content' => 'Tool implementation not found.');
7399 }
7400 } catch (\Throwable $e) {
7401 $this->mxchat_fc_log("tool {$fn} threw: " . $e->getMessage());
7402 return array('ok' => false, 'content' => 'The tool failed to run.');
7403 }
7404
7405 // plan-mxchat-20260617-48a57a — surface UI-bearing tool output.
7406 // If the callback produced a UI element (generated image, product card, image
7407 // gallery), its html MUST reach the FRONTEND as a real rendered bot message —
7408 // NOT be stripped to text and handed to the model to paraphrase (that was the
7409 // bug: under function calling, UI-bearing actions rendered nothing). Capture
7410 // the html here; the FC outcome handler emits it in the response envelope.
7411 $ui = $this->mxchat_fc_ui_payload_from($result);
7412 if ($ui['html'] !== '' || !empty($ui['images'])) {
7413 if ($ui['html'] !== '') {
7414 $this->fc_ui_html .= ($this->fc_ui_html !== '' ? "\n" : '') . $ui['html'];
7415 }
7416 if (!empty($ui['images']) && is_array($ui['images'])) {
7417 $this->fc_ui_images = array_merge($this->fc_ui_images, $ui['images']);
7418 }
7419 $this->fc_ui_captured = true;
7420
7421 // Persist the html to the transcript ONLY if the callback did not already
7422 // do so itself. Core image/search callbacks self-save (text + html);
7423 // add-on callbacks (e.g. woo product cards) return html for the caller to
7424 // save. ui_self_saves carries this from the registry; default by source
7425 // (core self-saves, add-on does not) when a tool predates the flag.
7426 $self_saves = array_key_exists('ui_self_saves', $tool)
7427 ? !empty($tool['ui_self_saves'])
7428 : empty($tool['is_addon']);
7429 if ($ui['html'] !== '' && !$self_saves) {
7430 $this->mxchat_save_chat_message($session_id, 'bot', $ui['html']);
7431 }
7432
7433 // Hand the MODEL a short acknowledgment (never the raw or stripped html)
7434 // so the loop can add a one-line caption without trying to re-describe a
7435 // visual it cannot see and without duplicating the displayed element.
7436 $summary = isset($ui['text']) ? trim((string) $ui['text']) : '';
7437 $ack = __('[A visual result has already been shown to the user in the chat. Do not repeat or describe it in detail — reply with at most a brief one-line caption.]', 'mxchat');
7438 $content = $summary !== '' ? ($ack . ' ' . $summary) : $ack;
7439 $this->mxchat_fc_log("executed {$fn} → [ui payload surfaced] " . substr($content, 0, 120));
7440 return array('ok' => true, 'content' => $content);
7441 }
7442
7443 $content = $this->mxchat_fc_stringify_result($result);
7444 $this->mxchat_fc_log("executed {$fn} → " . substr($content, 0, 160));
7445 return array('ok' => true, 'content' => $content);
7446 }
7447
7448 /**
7449 * Extract a UI payload (html + images + text) from a tool callback's return,
7450 * falling back to $this->fallbackResponse for callbacks that return true after
7451 * setting it. plan-mxchat-20260617-48a57a.
7452 *
7453 * @return array{html:string,images:array,text:string}
7454 */
7455 private function mxchat_fc_ui_payload_from($result) {
7456 $src = null;
7457 if (is_array($result)) {
7458 $src = $result;
7459 } elseif ($result === true && isset($this->fallbackResponse) && is_array($this->fallbackResponse)) {
7460 $src = $this->fallbackResponse;
7461 }
7462 $html = (is_array($src) && isset($src['html']) && is_string($src['html'])) ? $src['html'] : '';
7463 $images = (is_array($src) && isset($src['images']) && is_array($src['images'])) ? $src['images'] : array();
7464 $text = (is_array($src) && isset($src['text'])) ? (string) $src['text'] : '';
7465 return array('html' => $html, 'images' => $images, 'text' => $text);
7466 }
7467
7468 /** Coerce a callback's return (string|array|true|false) into a tool-result string. */
7469 private function mxchat_fc_stringify_result($result) {
7470 if (is_string($result)) {
7471 return $result === '' ? 'No result.' : $result;
7472 }
7473 if ($result === true) {
7474 // Callbacks that set fallbackResponse and return true.
7475 $fb = isset($this->fallbackResponse) ? $this->fallbackResponse : null;
7476 if (is_array($fb)) {
7477 if (!empty($fb['text'])) return (string) $fb['text'];
7478 if (!empty($fb['html'])) return wp_strip_all_tags((string) $fb['html']);
7479 }
7480 return 'Done.';
7481 }
7482 if ($result === false || $result === null) {
7483 return 'No result.';
7484 }
7485 if (is_array($result)) {
7486 if (isset($result['text']) && $result['text'] !== '') return (string) $result['text'];
7487 if (isset($result['html']) && $result['html'] !== '') return wp_strip_all_tags((string) $result['html']);
7488 $json = wp_json_encode($result);
7489 return $json !== false ? $json : 'No result.';
7490 }
7491 return (string) $result;
7492 }
7493
7494 /** HTTP code + decoded body for a function-calling request. */
7495 private function mxchat_fc_post($url, $body, $headers, $tag) {
7496 $args = array(
7497 'body' => wp_json_encode($body),
7498 'headers' => $headers,
7499 'timeout' => 60,
7500 'redirection' => 5,
7501 'blocking' => true,
7502 'httpversion' => '1.0',
7503 'sslverify' => true,
7504 );
7505 $response = $this->mxchat_provider_call_with_retry($url, $args, $tag);
7506 if (is_wp_error($response)) {
7507 return array('code' => 0, 'data' => null, 'error' => $response->get_error_message());
7508 }
7509 $code = (int) wp_remote_retrieve_response_code($response);
7510 $data = json_decode(wp_remote_retrieve_body($response), true);
7511 return array('code' => $code, 'data' => $data, 'error' => null);
7512 }
7513
7514 /* ---------------- OpenAI-compatible loop (OpenAI/xAI/DeepSeek/OpenRouter/Custom) -------------- */
7515 private function mxchat_fc_loop_openai($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) {
7516 $messages = array();
7517 $messages[] = array('role' => 'system', 'content' => $system . ' ' . $relevant_content);
7518 foreach ($this->mxchat_fc_normalize_history($conversation_history) as $m) {
7519 $messages[] = $m;
7520 }
7521
7522 $depth = MxChat_Tool_Registry::max_depth();
7523 $budget = MxChat_Tool_Registry::max_tool_calls_per_turn();
7524 $tool_schema = MxChat_Tool_Registry::to_openai_tools($tools);
7525 $used_tool = false;
7526 $calls_made = 0;
7527
7528 for ($step = 0; $step <= $depth; $step++) {
7529 $offer_tools = ($step < $depth) && !empty($tool_schema);
7530 $body = array('model' => $prov['model'], 'messages' => $messages, 'temperature' => 1, 'stream' => false);
7531 if ($offer_tools) {
7532 $body['tools'] = $tool_schema;
7533 $body['tool_choice'] = 'auto';
7534 }
7535 $r = $this->mxchat_fc_post($prov['url'], $body, $prov['headers'], $prov['tag']);
7536 if ($r['code'] !== 200 || !is_array($r['data'])) {
7537 $this->mxchat_fc_log('openai call failed: code=' . $r['code'] . ' err=' . ($r['error'] ?? ''));
7538 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
7539 }
7540 $msg = isset($r['data']['choices'][0]['message']) ? $r['data']['choices'][0]['message'] : null;
7541 if (!$msg) {
7542 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
7543 }
7544 $tool_calls = isset($msg['tool_calls']) && is_array($msg['tool_calls']) ? $msg['tool_calls'] : array();
7545 if (empty($tool_calls)) {
7546 $text = isset($msg['content']) ? trim((string) $msg['content']) : '';
7547 if (!$used_tool) return array('handled' => false); // model never used a tool → normal path
7548 return array('handled' => true, 'text' => ($text !== '' ? $text : $this->mxchat_fc_giveup_text()));
7549 }
7550 // Append the assistant tool-call turn verbatim, then a tool result per call.
7551 $used_tool = true;
7552 $messages[] = $msg;
7553 foreach ($tool_calls as $tc) {
7554 if ($calls_made >= $budget) break;
7555 $calls_made++;
7556 $name = isset($tc['function']['name']) ? $tc['function']['name'] : '';
7557 $args = array();
7558 if (isset($tc['function']['arguments'])) {
7559 $decoded = json_decode($tc['function']['arguments'], true);
7560 if (is_array($decoded)) $args = $decoded;
7561 }
7562 $exec = $this->mxchat_fc_execute_tool($name, $args, $orig_message, $user_id, $session_id);
7563 $messages[] = array(
7564 'role' => 'tool',
7565 'tool_call_id' => isset($tc['id']) ? $tc['id'] : '',
7566 'content' => $exec['content'],
7567 );
7568 }
7569 }
7570 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
7571 }
7572
7573 /* ---------------- Anthropic Claude loop ---------------- */
7574 private function mxchat_fc_loop_anthropic($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) {
7575 $messages = $this->mxchat_fc_normalize_history($conversation_history);
7576 $messages[] = array('role' => 'user', 'content' => $relevant_content);
7577
7578 $depth = MxChat_Tool_Registry::max_depth();
7579 $budget = MxChat_Tool_Registry::max_tool_calls_per_turn();
7580 $tool_schema = MxChat_Tool_Registry::to_anthropic_tools($tools);
7581 $omit_temp = $this->mxchat_claude_omits_temperature($prov['model']);
7582 $used_tool = false;
7583 $calls_made = 0;
7584
7585 for ($step = 0; $step <= $depth; $step++) {
7586 $offer_tools = ($step < $depth) && !empty($tool_schema);
7587 $body = array('model' => $prov['model'], 'max_tokens' => 1024, 'temperature' => 0.8,
7588 'messages' => $messages, 'system' => $system);
7589 if ($omit_temp) unset($body['temperature']);
7590 if ($offer_tools) {
7591 $body['tools'] = $tool_schema;
7592 $body['tool_choice'] = array('type' => 'auto');
7593 }
7594 $r = $this->mxchat_fc_post($prov['url'], $body, $prov['headers'], $prov['tag']);
7595 if ($r['code'] !== 200 || !is_array($r['data'])) {
7596 $this->mxchat_fc_log('anthropic call failed: code=' . $r['code'] . ' err=' . ($r['error'] ?? ''));
7597 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
7598 }
7599 $content = isset($r['data']['content']) && is_array($r['data']['content']) ? $r['data']['content'] : array();
7600 $tool_uses = array();
7601 $text_out = '';
7602 foreach ($content as $block) {
7603 if (!isset($block['type'])) continue;
7604 if ($block['type'] === 'tool_use') {
7605 $tool_uses[] = $block;
7606 } elseif ($block['type'] === 'text' && isset($block['text'])) {
7607 $text_out .= $block['text'];
7608 }
7609 }
7610 if (empty($tool_uses)) {
7611 if (!$used_tool) return array('handled' => false);
7612 $text_out = trim($text_out);
7613 return array('handled' => true, 'text' => ($text_out !== '' ? $text_out : $this->mxchat_fc_giveup_text()));
7614 }
7615 // Append the assistant turn (the full content array), then a user turn of tool_result blocks.
7616 $used_tool = true;
7617 $messages[] = array('role' => 'assistant', 'content' => $content);
7618 $results = array();
7619 foreach ($tool_uses as $tu) {
7620 if ($calls_made >= $budget) break;
7621 $calls_made++;
7622 $name = isset($tu['name']) ? $tu['name'] : '';
7623 $args = isset($tu['input']) && is_array($tu['input']) ? $tu['input'] : array();
7624 $exec = $this->mxchat_fc_execute_tool($name, $args, $orig_message, $user_id, $session_id);
7625 $results[] = array(
7626 'type' => 'tool_result',
7627 'tool_use_id' => isset($tu['id']) ? $tu['id'] : '',
7628 'content' => $exec['content'],
7629 );
7630 }
7631 $messages[] = array('role' => 'user', 'content' => $results);
7632 }
7633 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
7634 }
7635
7636 /* ---------------- Google Gemini loop ---------------- */
7637 private function mxchat_fc_loop_gemini($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) {
7638 $contents = array();
7639 $contents[] = array('role' => 'user', 'parts' => array(array('text' => '[System Instructions] ' . $system . ' ' . $relevant_content)));
7640 $contents[] = array('role' => 'model', 'parts' => array(array('text' => 'I understand and will follow these instructions.')));
7641 foreach ($this->mxchat_fc_normalize_history($conversation_history) as $m) {
7642 $contents[] = array('role' => ($m['role'] === 'assistant' ? 'model' : 'user'),
7643 'parts' => array(array('text' => $m['content'])));
7644 }
7645
7646 $depth = MxChat_Tool_Registry::max_depth();
7647 $budget = MxChat_Tool_Registry::max_tool_calls_per_turn();
7648 $tool_schema = MxChat_Tool_Registry::to_gemini_tools($tools);
7649 // Function calling (tools + functionDeclarations + toolConfig) is a v1beta feature on the
7650 // Generative Language REST API. The v1 endpoint silently ignores the tools array, so a
7651 // non-preview model (e.g. gemini-2.5-pro, gemini-3.5-flash, gemini-3.1-flash-lite) would
7652 // just answer in text and never emit a tool call. Always use v1beta for the FC loop —
7653 // confirmed against Google's function-calling docs (their REST example targets
7654 // v1beta/models/gemini-3.5-flash:generateContent). v1beta is a superset, so every model
7655 // reachable on v1 is also reachable here.
7656 $api_version = 'v1beta';
7657 $url = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $prov['model'] . ':generateContent?key=' . $prov['key'];
7658 $headers = array('Content-Type' => 'application/json');
7659 $used_tool = false;
7660 $calls_made = 0;
7661
7662 for ($step = 0; $step <= $depth; $step++) {
7663 $offer_tools = ($step < $depth) && !empty($tool_schema);
7664 $body = array(
7665 'contents' => $contents,
7666 'generationConfig' => array('temperature' => 0.7, 'topP' => 0.95, 'topK' => 40, 'maxOutputTokens' => 8192),
7667 );
7668 if ($offer_tools) {
7669 $body['tools'] = $tool_schema;
7670 $body['toolConfig'] = array('functionCallingConfig' => array('mode' => 'AUTO'));
7671 }
7672 $r = $this->mxchat_fc_post($url, $body, $headers, 'gemini');
7673 if ($r['code'] !== 200 || !is_array($r['data']) || isset($r['data']['error'])) {
7674 $this->mxchat_fc_log('gemini call failed: code=' . $r['code'] . ' err=' . ($r['error'] ?? ''));
7675 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
7676 }
7677 $parts = isset($r['data']['candidates'][0]['content']['parts']) && is_array($r['data']['candidates'][0]['content']['parts'])
7678 ? $r['data']['candidates'][0]['content']['parts'] : array();
7679 $fn_calls = array();
7680 $text_out = '';
7681 foreach ($parts as $p) {
7682 if (isset($p['functionCall'])) {
7683 $fn_calls[] = $p['functionCall'];
7684 } elseif (isset($p['text'])) {
7685 $text_out .= $p['text'];
7686 }
7687 }
7688 if (empty($fn_calls)) {
7689 if (!$used_tool) return array('handled' => false);
7690 $text_out = trim($text_out);
7691 return array('handled' => true, 'text' => ($text_out !== '' ? $text_out : $this->mxchat_fc_giveup_text()));
7692 }
7693 // Append the model turn (its parts) then a user turn of functionResponse parts.
7694 $used_tool = true;
7695 $contents[] = array('role' => 'model', 'parts' => $parts);
7696 $resp_parts = array();
7697 foreach ($fn_calls as $fcall) {
7698 if ($calls_made >= $budget) break;
7699 $calls_made++;
7700 $name = isset($fcall['name']) ? $fcall['name'] : '';
7701 $args = isset($fcall['args']) && is_array($fcall['args']) ? $fcall['args'] : array();
7702 $exec = $this->mxchat_fc_execute_tool($name, $args, $orig_message, $user_id, $session_id);
7703 $fr = array('name' => $name, 'response' => array('result' => $exec['content']));
7704 // Gemini 3 function calls carry a unique id; echo the matching id back in the
7705 // functionResponse so the model maps the result to the right call (Google REST
7706 // guidance). Older models omit the id — then we send none, exactly as before.
7707 if (isset($fcall['id']) && $fcall['id'] !== '') { $fr['id'] = $fcall['id']; }
7708 $resp_parts[] = array('functionResponse' => $fr);
7709 }
7710 $contents[] = array('role' => 'user', 'parts' => $resp_parts);
7711 }
7712 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
7713 }
7714
7715 private function mxchat_fc_giveup_text() {
7716 return esc_html__('I looked into that but could not put together a final answer. Please try rephrasing your request.', 'mxchat');
7717 }
7718
7719 private function mxchat_generate_response($relevant_content, $api_key, $xai_api_key, $claude_api_key, $deepseek_api_key, $gemini_api_key, $openrouter_api_key, $conversation_history, $streaming = false, $session_id = '', $testing_data = null, $selected_model = 'gpt-5.1-chat-latest') {
7720 try {
7721 if (!$relevant_content) {
7722 $error_response = [
7723 'error' => esc_html__("I couldn't find relevant information on that topic.", 'mxchat'),
7724 'error_code' => 'no_relevant_content'
7725 ];
7726
7727 if ($testing_data !== null) {
7728 $error_response['testing_data'] = $testing_data;
7729 }
7730
7731 return $error_response;
7732 }
7733
7734 if (!is_array($conversation_history)) {
7735 $conversation_history = array();
7736 }
7737
7738 // Check if this is an OpenRouter model
7739 if ($selected_model === 'openrouter') {
7740 // Get the actual OpenRouter model from options
7741 $openrouter_selected_model = $this->options['openrouter_selected_model'] ?? '';
7742
7743 if (empty($openrouter_selected_model)) {
7744 $error_response = [
7745 'error' => esc_html__('No OpenRouter model selected. Please select a model in settings.', 'mxchat'),
7746 'error_code' => 'no_openrouter_model_selected'
7747 ];
7748 if ($testing_data !== null) {
7749 $error_response['testing_data'] = $testing_data;
7750 }
7751 return $error_response;
7752 }
7753
7754 if (empty($openrouter_api_key)) {
7755 $error_response = [
7756 'error' => esc_html__('OpenRouter API key is not configured', 'mxchat'),
7757 'error_code' => 'missing_openrouter_api_key'
7758 ];
7759 if ($testing_data !== null) {
7760 $error_response['testing_data'] = $testing_data;
7761 }
7762 return $error_response;
7763 }
7764
7765 if ($streaming) {
7766 return $this->mxchat_generate_response_openrouter_stream(
7767 $openrouter_selected_model,
7768 $openrouter_api_key,
7769 $conversation_history,
7770 $relevant_content,
7771 $session_id,
7772 $testing_data
7773 );
7774 } else {
7775 $response = $this->mxchat_generate_response_openrouter(
7776 $openrouter_selected_model,
7777 $openrouter_api_key,
7778 $conversation_history,
7779 $relevant_content,
7780 $session_id
7781 );
7782 }
7783
7784 if (is_array($response) && isset($response['error'])) {
7785 if ($testing_data !== null) {
7786 $response['testing_data'] = $testing_data;
7787 }
7788 return $response;
7789 }
7790
7791 return $response;
7792 }
7793
7794 // Extract model prefix to determine the provider
7795 $model_parts = explode('-', $selected_model);
7796 $provider = strtolower($model_parts[0]);
7797
7798 // Handle model selection based on provider prefix
7799 switch ($provider) {
7800 case 'gemini':
7801 if (empty($gemini_api_key)) {
7802 $error_response = [
7803 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
7804 'error_code' => 'missing_gemini_api_key'
7805 ];
7806 if ($testing_data !== null) {
7807 $error_response['testing_data'] = $testing_data;
7808 }
7809 return $error_response;
7810 }
7811 $response = $this->mxchat_generate_response_gemini(
7812 $selected_model,
7813 $gemini_api_key,
7814 $conversation_history,
7815 $relevant_content,
7816 $session_id
7817 );
7818 break;
7819
7820 case 'claude':
7821 if (empty($claude_api_key)) {
7822 $error_response = [
7823 'error' => esc_html__('Claude API key is not configured', 'mxchat'),
7824 'error_code' => 'missing_claude_api_key'
7825 ];
7826 if ($testing_data !== null) {
7827 $error_response['testing_data'] = $testing_data;
7828 }
7829 return $error_response;
7830 }
7831 if ($streaming) {
7832 return $this->mxchat_generate_response_claude_stream(
7833 $selected_model,
7834 $claude_api_key,
7835 $conversation_history,
7836 $relevant_content,
7837 $session_id,
7838 $testing_data
7839 );
7840 } else {
7841 $response = $this->mxchat_generate_response_claude(
7842 $selected_model,
7843 $claude_api_key,
7844 $conversation_history,
7845 $relevant_content,
7846 $session_id
7847 );
7848 }
7849 break;
7850
7851 case 'grok':
7852 if (empty($xai_api_key)) {
7853 $error_response = [
7854 'error' => esc_html__('X.AI API key is not configured', 'mxchat'),
7855 'error_code' => 'missing_xai_api_key'
7856 ];
7857 if ($testing_data !== null) {
7858 $error_response['testing_data'] = $testing_data;
7859 }
7860 return $error_response;
7861 }
7862 if ($streaming) {
7863 return $this->mxchat_generate_response_xai_stream(
7864 $selected_model,
7865 $xai_api_key,
7866 $conversation_history,
7867 $relevant_content,
7868 $session_id,
7869 $testing_data
7870 );
7871 } else {
7872 $response = $this->mxchat_generate_response_xai(
7873 $selected_model,
7874 $xai_api_key,
7875 $conversation_history,
7876 $relevant_content,
7877 $session_id
7878 );
7879 }
7880 break;
7881
7882 case 'deepseek':
7883 if (empty($deepseek_api_key)) {
7884 $error_response = [
7885 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
7886 'error_code' => 'missing_deepseek_api_key'
7887 ];
7888 if ($testing_data !== null) {
7889 $error_response['testing_data'] = $testing_data;
7890 }
7891 return $error_response;
7892 }
7893 if ($streaming) {
7894 return $this->mxchat_generate_response_deepseek_stream(
7895 $selected_model,
7896 $deepseek_api_key,
7897 $conversation_history,
7898 $relevant_content,
7899 $session_id,
7900 $testing_data
7901 );
7902 } else {
7903 $response = $this->mxchat_generate_response_deepseek(
7904 $selected_model,
7905 $deepseek_api_key,
7906 $conversation_history,
7907 $relevant_content,
7908 $session_id
7909 );
7910 }
7911 break;
7912
7913 case 'custom':
7914 // Custom (OpenAI-compatible) provider — Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI
7915 $cp_base_url = isset($this->options['custom_provider_base_url']) ? trim((string) $this->options['custom_provider_base_url']) : '';
7916 if (empty($cp_base_url)) {
7917 $error_response = [
7918 'error' => esc_html__('Custom provider is not configured. Set Base URL in MxChat → API Keys → Custom Provider.', 'mxchat'),
7919 'error_code' => 'missing_custom_provider_base_url'
7920 ];
7921 if ($testing_data !== null) {
7922 $error_response['testing_data'] = $testing_data;
7923 }
7924 return $error_response;
7925 }
7926 if ($streaming) {
7927 return $this->mxchat_generate_response_custom_stream(
7928 $selected_model,
7929 $conversation_history,
7930 $relevant_content,
7931 $session_id,
7932 $testing_data
7933 );
7934 } else {
7935 $response = $this->mxchat_generate_response_custom(
7936 $selected_model,
7937 $conversation_history,
7938 $relevant_content
7939 );
7940 }
7941 break;
7942
7943 case 'gpt':
7944 case 'o1':
7945 if (empty($api_key)) {
7946 $error_response = [
7947 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
7948 'error_code' => 'missing_openai_api_key'
7949 ];
7950 if ($testing_data !== null) {
7951 $error_response['testing_data'] = $testing_data;
7952 }
7953 return $error_response;
7954 }
7955
7956 // Check if web search is enabled for this OpenAI model
7957 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
7958 // Models that don't support web search
7959 $unsupported_web_search_models = array('gpt-4.1-nano');
7960 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
7961
7962 if ($web_search_enabled && $model_supports_web_search) {
7963 // Use Responses API (required for some models, or when web search is enabled)
7964 return $this->mxchat_generate_response_openai_web_search(
7965 $selected_model,
7966 $api_key,
7967 $conversation_history,
7968 $relevant_content,
7969 $session_id,
7970 $testing_data,
7971 $streaming
7972 );
7973 } elseif ($streaming) {
7974 return $this->mxchat_generate_response_openai_stream(
7975 $selected_model,
7976 $api_key,
7977 $conversation_history,
7978 $relevant_content,
7979 $session_id,
7980 $testing_data
7981 );
7982 } else {
7983 $response = $this->mxchat_generate_response_openai(
7984 $selected_model,
7985 $api_key,
7986 $conversation_history,
7987 $relevant_content,
7988 $session_id
7989 );
7990 }
7991 break;
7992
7993 default:
7994 if (empty($api_key)) {
7995 $error_response = [
7996 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
7997 'error_code' => 'missing_openai_api_key'
7998 ];
7999 if ($testing_data !== null) {
8000 $error_response['testing_data'] = $testing_data;
8001 }
8002 return $error_response;
8003 }
8004
8005 // Check if web search is enabled (default case also handles OpenAI models)
8006 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
8007 $unsupported_web_search_models = array('gpt-4.1-nano');
8008 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
8009
8010 if ($web_search_enabled && $model_supports_web_search) {
8011 return $this->mxchat_generate_response_openai_web_search(
8012 $selected_model,
8013 $api_key,
8014 $conversation_history,
8015 $relevant_content,
8016 $session_id,
8017 $testing_data,
8018 $streaming
8019 );
8020 } elseif ($streaming) {
8021 return $this->mxchat_generate_response_openai_stream(
8022 $selected_model,
8023 $api_key,
8024 $conversation_history,
8025 $relevant_content,
8026 $session_id,
8027 $testing_data
8028 );
8029 } else {
8030 $response = $this->mxchat_generate_response_openai(
8031 $selected_model,
8032 $api_key,
8033 $conversation_history,
8034 $relevant_content,
8035 $session_id
8036 );
8037 }
8038 break;
8039 }
8040
8041 if (is_array($response) && isset($response['error'])) {
8042 if ($testing_data !== null) {
8043 $response['testing_data'] = $testing_data;
8044 }
8045 return $response;
8046 }
8047
8048 return $response;
8049
8050 } catch (Exception $e) {
8051 $error_response = [
8052 'error' => sprintf(esc_html__('An error occurred: %s', 'mxchat'), esc_html($e->getMessage())),
8053 'error_code' => 'system_exception',
8054 'exception_details' => $e->getMessage()
8055 ];
8056
8057 if ($testing_data !== null) {
8058 $error_response['testing_data'] = $testing_data;
8059 }
8060
8061 return $error_response;
8062 }
8063 }
8064 private function mxchat_generate_response_openrouter_stream($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
8065 try {
8066 $bot_id = $this->get_current_bot_id($session_id);
8067 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8068
8069 if (!is_array($conversation_history)) {
8070 $conversation_history = array();
8071 }
8072
8073 $formatted_conversation = array();
8074
8075 $formatted_conversation[] = array(
8076 'role' => 'system',
8077 'content' => $system_prompt_instructions . " " . $relevant_content
8078 );
8079
8080 foreach ($conversation_history as $message) {
8081 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8082 $role = $message['role'];
8083 if ($role === 'bot' || $role === 'agent') {
8084 $role = 'assistant';
8085 }
8086 if (!in_array($role, ['system', 'assistant', 'user'])) {
8087 $role = 'user';
8088 }
8089 $formatted_conversation[] = array(
8090 'role' => $role,
8091 'content' => $message['content']
8092 );
8093 }
8094 }
8095
8096 if (headers_sent() || !function_exists('curl_init')) {
8097 $regular_response = $this->mxchat_generate_response_openrouter(
8098 $selected_model,
8099 $openrouter_api_key,
8100 $conversation_history,
8101 $relevant_content,
8102 $session_id
8103 );
8104
8105 // Save bot response to transcript
8106 if (!empty($regular_response) && !empty($session_id)) {
8107 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
8108 }
8109
8110 $response_data = [
8111 'text' => $regular_response,
8112 'html' => '',
8113 'session_id' => $session_id
8114 ];
8115
8116 if ($testing_data !== null) {
8117 $response_data['testing_data'] = $testing_data;
8118 }
8119
8120 header('Content-Type: application/json');
8121 echo json_encode($response_data);
8122 return true;
8123 }
8124
8125 $body = json_encode([
8126 'model' => $selected_model,
8127 'messages' => $formatted_conversation,
8128 'temperature' => 1,
8129 'stream' => true
8130 ]);
8131
8132 // V2 retry-on-initial-connect: setup_streaming_headers is now lazy-fired
8133 // inside WRITEFUNCTION on first byte of a successful upstream.
8134
8135 $captured_status_code = 0;
8136 $captured_body_pre_stream = '';
8137 $full_response = '';
8138 $stream_started = false;
8139 $buffer = '';
8140 $errno = 0;
8141 $last_curl_error = '';
8142 $http_code = 0;
8143 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
8144 $backoff_ms = array(0, 750, 2000);
8145
8146 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
8147 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
8148 usleep($backoff_ms[$attempt] * 1000);
8149 }
8150
8151 $captured_status_code = 0;
8152 $captured_body_pre_stream = '';
8153 $full_response = '';
8154 $stream_started = false;
8155 $buffer = '';
8156
8157 $ch = curl_init();
8158 curl_setopt($ch, CURLOPT_URL, 'https://openrouter.ai/api/v1/chat/completions');
8159 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
8160 curl_setopt($ch, CURLOPT_POST, true);
8161 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
8162 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
8163 'Content-Type: application/json',
8164 'Authorization: Bearer ' . $openrouter_api_key,
8165 'HTTP-Referer: ' . home_url(),
8166 'X-Title: ' . get_bloginfo('name')
8167 ));
8168 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8169 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
8170
8171 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
8172 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
8173 $captured_status_code = (int) $m[1];
8174 }
8175 return strlen($header);
8176 });
8177
8178 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
8179 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
8180 $captured_body_pre_stream .= $data;
8181 return strlen($data);
8182 }
8183
8184 if (!$this->streaming_headers_sent) {
8185 $this->setup_streaming_headers();
8186 }
8187
8188 if (!$stream_started && $testing_data !== null) {
8189 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
8190 flush();
8191 $stream_started = true;
8192 }
8193
8194 $buffer .= $data;
8195 $lines = explode("\n", $buffer);
8196 $buffer = array_pop($lines);
8197
8198 foreach ($lines as $line) {
8199 if (trim($line) === '') {
8200 continue;
8201 }
8202 if (strpos($line, 'data: ') !== 0) {
8203 continue;
8204 }
8205
8206 $json_str = substr($line, 6);
8207
8208 if (trim($json_str) === '[DONE]') {
8209 echo "data: [DONE]\n\n";
8210 flush();
8211 continue;
8212 }
8213
8214 $json = json_decode(trim($json_str), true);
8215 if ($json && isset($json['choices'][0]['delta']['content'])) {
8216 $content = $json['choices'][0]['delta']['content'];
8217 $full_response .= $content;
8218
8219 echo "data: " . json_encode(['content' => $content]) . "\n\n";
8220 flush();
8221 }
8222 }
8223
8224 return strlen($data);
8225 });
8226
8227 $response = curl_exec($ch);
8228 $errno = curl_errno($ch);
8229 $last_curl_error = curl_error($ch);
8230 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
8231 curl_close($ch);
8232
8233 if (!$errno && $http_code === 200) {
8234 break;
8235 }
8236
8237 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
8238 $can_retry = !$this->streaming_headers_sent
8239 && ($attempt + 1) < $max_attempts
8240 && $is_transient;
8241
8242 if (defined('WP_DEBUG') && WP_DEBUG) {
8243 error_log(sprintf(
8244 '[MxChat] openrouter_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
8245 $attempt + 1, $max_attempts, $http_code, $errno,
8246 $is_transient ? 'yes' : 'no',
8247 $can_retry ? 'Retrying.' : 'Giving up.'
8248 ));
8249 }
8250
8251 if (!$can_retry) {
8252 break;
8253 }
8254 }
8255
8256 if (!$errno && $http_code === 200) {
8257 if (!empty($full_response) && !empty($session_id)) {
8258 $rag_context_for_storage = null;
8259 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
8260 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
8261
8262 if ($has_rag_data || $has_action_data) {
8263 $rag_context_for_storage = [];
8264
8265 if ($has_rag_data) {
8266 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
8267 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
8268 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
8269 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
8270 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
8271 }
8272
8273 if ($has_action_data) {
8274 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
8275 }
8276 }
8277 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
8278 }
8279 return true;
8280 }
8281
8282 return $this->mxchat_stream_emit_fallback(
8283 'openai',
8284 $this->mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id),
8285 $session_id,
8286 $testing_data
8287 );
8288
8289 } catch (Exception $e) {
8290 return $this->mxchat_stream_emit_fallback(
8291 'openai',
8292 $this->mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id),
8293 $session_id,
8294 $testing_data
8295 );
8296 }
8297 }
8298 private function mxchat_generate_response_openai_stream($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
8299 try {
8300 $bot_id = $this->get_current_bot_id($session_id);
8301
8302 // Get system prompt instructions using centralized function
8303 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8304
8305 // Ensure conversation_history is an array
8306 if (!is_array($conversation_history)) {
8307 $conversation_history = array();
8308 }
8309
8310 // Format conversation history for OpenAI
8311 $formatted_conversation = array();
8312
8313 $formatted_conversation[] = array(
8314 'role' => 'system',
8315 'content' => $system_prompt_instructions . " " . $relevant_content
8316 );
8317
8318 foreach ($conversation_history as $message) {
8319 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8320 $role = $message['role'];
8321 if ($role === 'bot' || $role === 'agent') {
8322 $role = 'assistant';
8323 }
8324 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8325 $role = 'user';
8326 }
8327 $formatted_conversation[] = array(
8328 'role' => $role,
8329 'content' => $message['content']
8330 );
8331 }
8332 }
8333
8334 // Check if we can actually stream
8335 if (headers_sent() || !function_exists('curl_init')) {
8336 // Fallback to regular response with testing data
8337 $regular_response = $this->mxchat_generate_response_openai(
8338 $selected_model,
8339 $api_key,
8340 $conversation_history,
8341 $relevant_content,
8342 $session_id
8343 );
8344
8345 // Save bot response to transcript
8346 if (!empty($regular_response) && !empty($session_id)) {
8347 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
8348 }
8349
8350 $response_data = [
8351 'text' => $regular_response,
8352 'html' => '',
8353 'session_id' => $session_id
8354 ];
8355
8356 if ($testing_data !== null) {
8357 $response_data['testing_data'] = $testing_data;
8358 }
8359
8360 header('Content-Type: application/json');
8361 echo json_encode($response_data);
8362 return true;
8363 }
8364
8365 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
8366 $is_gpt5_model = (
8367 strpos($selected_model, 'gpt-5') === 0 ||
8368 $selected_model === 'gpt-5.2' ||
8369 $selected_model === 'gpt-5.1-2025-11-13' ||
8370 $selected_model === 'gpt-5' ||
8371 $selected_model === 'gpt-5-mini' ||
8372 $selected_model === 'gpt-5-nano'
8373 );
8374
8375 // Build request body with optimal settings for fast streaming
8376 $request_body = [
8377 'model' => $selected_model,
8378 'messages' => $formatted_conversation,
8379 'temperature' => 1,
8380 'stream' => true
8381 ];
8382
8383 // Add reasoning_effort only for GPT-5 models that support it
8384 // These chat models don't support reasoning_effort parameter
8385 $no_reasoning_models = array('gpt-5.2', 'gpt-5.1-chat-latest', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
8386 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_models, true)) {
8387 // GPT-5.1 uses 'low' instead of 'minimal'
8388 if ($selected_model === 'gpt-5.1-2025-11-13') {
8389 $request_body['reasoning_effort'] = 'low';
8390 } elseif ($selected_model === 'gpt-5.5') {
8391 $request_body['reasoning_effort'] = 'none';
8392 } elseif ($selected_model === 'gpt-5.4') {
8393 $request_body['reasoning_effort'] = 'none';
8394 } else {
8395 $request_body['reasoning_effort'] = 'minimal';
8396 }
8397 }
8398
8399 $body = json_encode($request_body);
8400
8401 // V2 retry-on-initial-connect: do NOT call setup_streaming_headers() here.
8402 // It is now lazy-fired inside the WRITEFUNCTION on the first byte of a
8403 // SUCCESSFUL upstream response, gated by the captured HTTP status.
8404
8405 $captured_status_code = 0;
8406 $captured_body_pre_stream = '';
8407 $full_response = '';
8408 $stream_started = false;
8409 $buffer = '';
8410 $errno = 0;
8411 $last_curl_error = '';
8412 $http_code = 0;
8413 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
8414 $backoff_ms = array(0, 750, 2000);
8415
8416 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
8417 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
8418 usleep($backoff_ms[$attempt] * 1000);
8419 }
8420
8421 // Reset per-attempt capture state.
8422 $captured_status_code = 0;
8423 $captured_body_pre_stream = '';
8424 $full_response = '';
8425 $stream_started = false;
8426 $buffer = '';
8427
8428 $ch = curl_init();
8429 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
8430 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
8431 curl_setopt($ch, CURLOPT_POST, true);
8432 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
8433 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
8434 'Content-Type: application/json',
8435 'Authorization: Bearer ' . $api_key
8436 ));
8437 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8438 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
8439
8440 // Capture HTTP status as soon as response headers arrive — fires before WRITEFUNCTION.
8441 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
8442 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
8443 $captured_status_code = (int) $m[1];
8444 }
8445 return strlen($header);
8446 });
8447
8448 // Buffer control for real-time streaming
8449 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
8450 // V2 guard: if upstream returned non-200, buffer body for transient
8451 // classification and DO NOT emit to client. Stream channel must NOT open.
8452 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
8453 $captured_body_pre_stream .= $data;
8454 return strlen($data);
8455 }
8456
8457 // Lazy-fire streaming headers on first byte of a SUCCESSFUL upstream.
8458 // After this point streaming_headers_sent === true → retry is structurally blocked.
8459 if (!$this->streaming_headers_sent) {
8460 $this->setup_streaming_headers();
8461 }
8462
8463 // Send testing data as the first event if available
8464 if (!$stream_started && $testing_data !== null) {
8465 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
8466 flush();
8467 $stream_started = true;
8468 }
8469
8470 // CRITICAL FIX: Append new data to buffer
8471 $buffer .= $data;
8472
8473 // Process complete lines only
8474 $lines = explode("\n", $buffer);
8475
8476 // CRITICAL FIX: Keep the last incomplete line in the buffer
8477 $buffer = array_pop($lines);
8478
8479 foreach ($lines as $line) {
8480 if (trim($line) === '') {
8481 continue;
8482 }
8483 if (strpos($line, 'data: ') !== 0) {
8484 continue;
8485 }
8486
8487 $json_str = substr($line, 6);
8488
8489 if (trim($json_str) === '[DONE]') {
8490 echo "data: [DONE]\n\n";
8491 flush();
8492 continue;
8493 }
8494
8495 $json = json_decode(trim($json_str), true);
8496 if ($json && isset($json['choices'][0]['delta']['content'])) {
8497 $content = $json['choices'][0]['delta']['content'];
8498 $full_response .= $content;
8499
8500 echo "data: " . json_encode(['content' => $content]) . "\n\n";
8501 flush();
8502 }
8503 }
8504
8505 return strlen($data);
8506 });
8507
8508 $response = curl_exec($ch);
8509 $errno = curl_errno($ch);
8510 $last_curl_error = curl_error($ch);
8511 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
8512 curl_close($ch);
8513
8514 if (!$errno && $http_code === 200) {
8515 break; // Happy path — WRITEFUNCTION already streamed everything.
8516 }
8517
8518 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
8519 $can_retry = !$this->streaming_headers_sent
8520 && ($attempt + 1) < $max_attempts
8521 && $is_transient;
8522
8523 if (defined('WP_DEBUG') && WP_DEBUG) {
8524 error_log(sprintf(
8525 '[MxChat] openai_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
8526 $attempt + 1, $max_attempts, $http_code, $errno,
8527 $is_transient ? 'yes' : 'no',
8528 $can_retry ? 'Retrying.' : 'Giving up.'
8529 ));
8530 }
8531
8532 if (!$can_retry) {
8533 break;
8534 }
8535 }
8536
8537 // Post-loop branch.
8538 if (!$errno && $http_code === 200) {
8539 // Happy path — save the complete response to maintain chat persistence.
8540 if (!empty($full_response) && !empty($session_id)) {
8541 $rag_context_for_storage = null;
8542 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
8543 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
8544
8545 if ($has_rag_data || $has_action_data) {
8546 $rag_context_for_storage = [];
8547
8548 if ($has_rag_data) {
8549 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
8550 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
8551 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
8552 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
8553 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
8554 }
8555
8556 if ($has_action_data) {
8557 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
8558 }
8559 }
8560 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
8561 }
8562
8563 return true;
8564 }
8565
8566 // Failure path — branch on whether SSE channel was opened.
8567 return $this->mxchat_stream_emit_fallback(
8568 'openai',
8569 $this->mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content, $session_id),
8570 $session_id,
8571 $testing_data
8572 );
8573
8574 } catch (Exception $e) {
8575 return $this->mxchat_stream_emit_fallback(
8576 'openai',
8577 $this->mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content, $session_id),
8578 $session_id,
8579 $testing_data
8580 );
8581 }
8582 }
8583
8584 /**
8585 * Shared fallback emitter for streaming chat functions. Two outcomes:
8586 * - streaming_headers_sent === true: SSE channel is open. Emit fallback content
8587 * as `data: {...}\n\n` + `data: [DONE]\n\n` so the widget renders it as a
8588 * normal bot bubble. Transcript row is persisted.
8589 * - streaming_headers_sent === false: SSE channel never opened (retries
8590 * exhausted on initial connect). Emit a clean JSON response — the path
8591 * the widget would normally hit if streaming wasn't even attempted.
8592 *
8593 * Used by all six *_stream functions after their per-attempt retry loop.
8594 */
8595 private function mxchat_stream_emit_fallback($provider_hint, $regular_response, $session_id, $testing_data = null) {
8596 $is_error_array = is_array($regular_response) && isset($regular_response['error']);
8597
8598 if ($this->streaming_headers_sent) {
8599 if ($is_error_array) {
8600 echo "data: " . json_encode([
8601 'error' => true,
8602 'error_message' => $regular_response['error'],
8603 'error_code' => $regular_response['error_code'] ?? 'api_error',
8604 'text' => $regular_response['error'],
8605 'message' => $regular_response['error']
8606 ]) . "\n\n";
8607 echo "data: [DONE]\n\n";
8608 flush();
8609 return true;
8610 }
8611 $fallback_message = (string) $regular_response;
8612 if (!empty($fallback_message) && !empty($session_id)) {
8613 $this->mxchat_save_chat_message($session_id, 'bot', $fallback_message);
8614 }
8615 echo "data: " . json_encode(['content' => $fallback_message]) . "\n\n";
8616 echo "data: [DONE]\n\n";
8617 flush();
8618 return true;
8619 }
8620
8621 // SSE channel never opened — clean JSON fallback.
8622 if ($is_error_array) {
8623 header('Content-Type: application/json');
8624 echo json_encode(array(
8625 'error' => true,
8626 'error_message' => $regular_response['error'],
8627 'error_code' => $regular_response['error_code'] ?? 'api_error',
8628 'text' => $regular_response['error'],
8629 'message' => $regular_response['error'],
8630 ));
8631 return true;
8632 }
8633
8634 $fallback_message = (string) $regular_response;
8635 if (!empty($fallback_message) && !empty($session_id)) {
8636 $this->mxchat_save_chat_message($session_id, 'bot', $fallback_message);
8637 }
8638 $response_data = array(
8639 'text' => $fallback_message,
8640 'html' => '',
8641 'session_id' => $session_id,
8642 );
8643 if ($testing_data !== null) {
8644 $response_data['testing_data'] = $testing_data;
8645 }
8646 header('Content-Type: application/json');
8647 echo json_encode($response_data);
8648 return true;
8649 }
8650
8651 /**
8652 * Resolve custom (OpenAI-compatible) provider config from settings.
8653 * Returns ['base_url','api_key','model','auth_scheme','api_version','chat_url','headers'].
8654 */
8655 private function mxchat_resolve_custom_provider() {
8656 $base_url = isset($this->options['custom_provider_base_url']) ? rtrim(trim((string) $this->options['custom_provider_base_url']), '/') : '';
8657 $api_key = isset($this->options['custom_provider_api_key']) ? trim((string) $this->options['custom_provider_api_key']) : '';
8658 $model = isset($this->options['custom_provider_model']) ? trim((string) $this->options['custom_provider_model']) : '';
8659 $auth_scheme = isset($this->options['custom_provider_auth_scheme']) ? $this->options['custom_provider_auth_scheme'] : 'bearer';
8660 $api_version = isset($this->options['custom_provider_api_version']) ? trim((string) $this->options['custom_provider_api_version']) : '';
8661
8662 $chat_url = $base_url . '/chat/completions';
8663 if (!empty($api_version)) {
8664 $chat_url .= (strpos($chat_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($api_version);
8665 }
8666
8667 $headers = array('Content-Type: application/json');
8668 if (!empty($api_key)) {
8669 if ($auth_scheme === 'api-key') {
8670 $headers[] = 'api-key: ' . $api_key;
8671 } else {
8672 $headers[] = 'Authorization: Bearer ' . $api_key;
8673 }
8674 }
8675
8676 return array(
8677 'base_url' => $base_url,
8678 'api_key' => $api_key,
8679 'model' => $model !== '' ? $model : 'default',
8680 'auth_scheme' => $auth_scheme,
8681 'api_version' => $api_version,
8682 'chat_url' => $chat_url,
8683 'headers' => $headers,
8684 );
8685 }
8686
8687 /**
8688 * Streaming chat completion against an OpenAI-compatible custom provider
8689 * (Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.).
8690 * Mirrors mxchat_generate_response_openai_stream but with parameterized URL/auth/model.
8691 */
8692 private function mxchat_generate_response_custom_stream($selected_model, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
8693 try {
8694 $cfg = $this->mxchat_resolve_custom_provider();
8695 if (empty($cfg['base_url'])) {
8696 return array('error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url');
8697 }
8698
8699 $bot_id = $this->get_current_bot_id($session_id);
8700 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8701 if (!is_array($conversation_history)) {
8702 $conversation_history = array();
8703 }
8704
8705 $formatted_conversation = array();
8706 $formatted_conversation[] = array(
8707 'role' => 'system',
8708 'content' => $system_prompt_instructions . ' ' . $relevant_content,
8709 );
8710 foreach ($conversation_history as $message) {
8711 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8712 $role = $message['role'];
8713 if ($role === 'bot' || $role === 'agent') { $role = 'assistant'; }
8714 if (!in_array($role, array('system', 'assistant', 'user', 'function', 'tool'))) { $role = 'user'; }
8715 $formatted_conversation[] = array('role' => $role, 'content' => $message['content']);
8716 }
8717 }
8718
8719 if (headers_sent() || !function_exists('curl_init')) {
8720 // No streaming capability — fall through to non-stream wrapper
8721 $regular = $this->mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content);
8722 if (!empty($regular) && !empty($session_id) && is_string($regular)) {
8723 $this->mxchat_save_chat_message($session_id, 'bot', $regular);
8724 }
8725 $response_data = array('text' => is_string($regular) ? $regular : '', 'html' => '', 'session_id' => $session_id);
8726 if ($testing_data !== null) { $response_data['testing_data'] = $testing_data; }
8727 header('Content-Type: application/json');
8728 echo json_encode($response_data);
8729 return true;
8730 }
8731
8732 $request_body = array(
8733 'model' => $cfg['model'],
8734 'messages' => $formatted_conversation,
8735 'stream' => true,
8736 );
8737 $body = json_encode($request_body);
8738
8739 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
8740
8741 $captured_status_code = 0;
8742 $captured_body_pre_stream = '';
8743 $full_response = '';
8744 $stream_started = false;
8745 $buffer = '';
8746 $errno = 0;
8747 $http_code = 0;
8748 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
8749 $backoff_ms = array(0, 750, 2000);
8750
8751 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
8752 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
8753 usleep($backoff_ms[$attempt] * 1000);
8754 }
8755
8756 $captured_status_code = 0;
8757 $captured_body_pre_stream = '';
8758 $full_response = '';
8759 $stream_started = false;
8760 $buffer = '';
8761
8762 $ch = curl_init();
8763 curl_setopt($ch, CURLOPT_URL, $cfg['chat_url']);
8764 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
8765 curl_setopt($ch, CURLOPT_POST, true);
8766 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
8767 curl_setopt($ch, CURLOPT_HTTPHEADER, $cfg['headers']);
8768 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8769 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
8770
8771 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
8772 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
8773 $captured_status_code = (int) $m[1];
8774 }
8775 return strlen($header);
8776 });
8777
8778 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
8779 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
8780 $captured_body_pre_stream .= $data;
8781 return strlen($data);
8782 }
8783
8784 if (!$this->streaming_headers_sent) {
8785 $this->setup_streaming_headers();
8786 }
8787
8788 if (!$stream_started && $testing_data !== null) {
8789 echo "data: " . json_encode(array('testing_data' => $testing_data)) . "\n\n";
8790 flush();
8791 $stream_started = true;
8792 }
8793 $buffer .= $data;
8794 $lines = explode("\n", $buffer);
8795 $buffer = array_pop($lines);
8796 foreach ($lines as $line) {
8797 if (trim($line) === '') { continue; }
8798 if (strpos($line, 'data: ') !== 0) { continue; }
8799 $json_str = substr($line, 6);
8800 if (trim($json_str) === '[DONE]') {
8801 echo "data: [DONE]\n\n";
8802 flush();
8803 continue;
8804 }
8805 $json = json_decode(trim($json_str), true);
8806 if ($json && isset($json['choices'][0]['delta']['content'])) {
8807 $content = $json['choices'][0]['delta']['content'];
8808 $full_response .= $content;
8809 echo "data: " . json_encode(array('content' => $content)) . "\n\n";
8810 flush();
8811 }
8812 }
8813 return strlen($data);
8814 });
8815
8816 $response = curl_exec($ch);
8817 $errno = curl_errno($ch);
8818 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
8819 curl_close($ch);
8820
8821 if (!$errno && $http_code === 200) {
8822 break;
8823 }
8824
8825 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
8826 $can_retry = !$this->streaming_headers_sent
8827 && ($attempt + 1) < $max_attempts
8828 && $is_transient;
8829
8830 if (defined('WP_DEBUG') && WP_DEBUG) {
8831 error_log(sprintf(
8832 '[MxChat] custom_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
8833 $attempt + 1, $max_attempts, $http_code, $errno,
8834 $is_transient ? 'yes' : 'no',
8835 $can_retry ? 'Retrying.' : 'Giving up.'
8836 ));
8837 }
8838
8839 if (!$can_retry) {
8840 break;
8841 }
8842 }
8843
8844 if (!$errno && $http_code === 200) {
8845 if (!empty($full_response) && !empty($session_id)) {
8846 $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
8847 }
8848 return true;
8849 }
8850
8851 return $this->mxchat_stream_emit_fallback(
8852 'openai',
8853 $this->mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content),
8854 $session_id,
8855 $testing_data
8856 );
8857
8858 } catch (Exception $e) {
8859 return array('error' => sprintf(esc_html__('Custom provider error: %s', 'mxchat'), $e->getMessage()), 'error_code' => 'custom_provider_exception');
8860 }
8861 }
8862
8863 /**
8864 * Non-streaming chat completion against a custom OpenAI-compatible provider.
8865 * Returns string content on success, array['error'=>...] on failure.
8866 */
8867 private function mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content) {
8868 $cfg = $this->mxchat_resolve_custom_provider();
8869 if (empty($cfg['base_url'])) {
8870 return array('error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url');
8871 }
8872
8873 $bot_id = $this->get_current_bot_id(null);
8874 $system_prompt_instructions = $this->get_system_instructions($bot_id, null);
8875 if (!is_array($conversation_history)) {
8876 $conversation_history = array();
8877 }
8878
8879 $messages = array(array(
8880 'role' => 'system',
8881 'content' => $system_prompt_instructions . ' ' . $relevant_content,
8882 ));
8883 foreach ($conversation_history as $message) {
8884 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8885 $role = $message['role'];
8886 if ($role === 'bot' || $role === 'agent') { $role = 'assistant'; }
8887 if (!in_array($role, array('system', 'assistant', 'user', 'function', 'tool'))) { $role = 'user'; }
8888 $messages[] = array('role' => $role, 'content' => $message['content']);
8889 }
8890 }
8891
8892 $headers_assoc = array('Content-Type' => 'application/json');
8893 if (!empty($cfg['api_key'])) {
8894 if ($cfg['auth_scheme'] === 'api-key') {
8895 $headers_assoc['api-key'] = $cfg['api_key'];
8896 } else {
8897 $headers_assoc['Authorization'] = 'Bearer ' . $cfg['api_key'];
8898 }
8899 }
8900
8901 $response = $this->mxchat_provider_call_with_retry($cfg['chat_url'], array(
8902 'headers' => $headers_assoc,
8903 'body' => wp_json_encode(array(
8904 'model' => $cfg['model'],
8905 'messages' => $messages,
8906 )),
8907 'timeout' => 120,
8908 ), 'openai');
8909
8910 if (is_wp_error($response)) {
8911 return array('error' => sprintf(esc_html__('Custom provider request failed: %s', 'mxchat'), $response->get_error_message()), 'error_code' => 'custom_provider_network_error');
8912 }
8913 $code = (int) wp_remote_retrieve_response_code($response);
8914 if ($code < 200 || $code >= 300) {
8915 return array('error' => sprintf(esc_html__('Custom provider returned HTTP %d.', 'mxchat'), $code), 'error_code' => 'custom_provider_http_error');
8916 }
8917 $body = json_decode(wp_remote_retrieve_body($response), true);
8918 if (isset($body['choices'][0]['message']['content'])) {
8919 return (string) $body['choices'][0]['message']['content'];
8920 }
8921 return array('error' => esc_html__('Custom provider returned an unexpected response shape.', 'mxchat'), 'error_code' => 'custom_provider_response_shape');
8922 }
8923
8924 /**
8925 * Generate response using OpenAI Responses API with web search tool
8926 * This uses the newer Responses API which supports web search functionality
8927 */
8928 private function mxchat_generate_response_openai_web_search($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null, $streaming = false) {
8929 try {
8930 $bot_id = $this->get_current_bot_id($session_id);
8931 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8932
8933 if (!is_array($conversation_history)) {
8934 $conversation_history = array();
8935 }
8936
8937 // Build the input for Responses API
8938 // The Responses API uses a different format - we need to construct the input properly
8939 $input_parts = [];
8940
8941 // Add system instructions as context
8942 $system_context = $system_prompt_instructions . "\n\n" . $relevant_content;
8943
8944 // Build conversation as input items for Responses API
8945 foreach ($conversation_history as $message) {
8946 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8947 $role = $message['role'];
8948 if ($role === 'bot' || $role === 'agent') {
8949 $role = 'assistant';
8950 }
8951 if (!in_array($role, ['assistant', 'user'])) {
8952 $role = 'user';
8953 }
8954 $input_parts[] = [
8955 'type' => 'message',
8956 'role' => $role,
8957 'content' => $message['content']
8958 ];
8959 }
8960 }
8961
8962 // Build request body for Responses API
8963 $request_body = [
8964 'model' => $selected_model,
8965 'input' => $input_parts,
8966 'instructions' => $system_context,
8967 'stream' => $streaming
8968 ];
8969
8970 // Only add web search tool if web search is enabled in settings
8971 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
8972 if ($web_search_enabled) {
8973 $request_body['tools'] = [
8974 ['type' => 'web_search']
8975 ];
8976 }
8977
8978 // Add reasoning effort for supported models
8979 $is_gpt5_model = strpos($selected_model, 'gpt-5') === 0;
8980 $no_reasoning_web = array('gpt-5.2', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
8981 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_web, true)) {
8982 if ($selected_model === 'gpt-5.1-2025-11-13') {
8983 $request_body['reasoning'] = ['effort' => 'low'];
8984 } elseif ($selected_model === 'gpt-5.5') {
8985 $request_body['reasoning'] = ['effort' => 'low'];
8986 } elseif ($selected_model === 'gpt-5.4') {
8987 $request_body['reasoning'] = ['effort' => 'low'];
8988 }
8989 }
8990
8991 //error_log("MXCHAT WEB SEARCH: Request body: " . json_encode($request_body));
8992
8993 if ($streaming) {
8994 return $this->mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data);
8995 } else {
8996 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
8997 }
8998
8999 } catch (Exception $e) {
9000 //error_log("MXCHAT WEB SEARCH ERROR: " . $e->getMessage());
9001 return [
9002 'error' => sprintf(esc_html__('Web search error: %s', 'mxchat'), esc_html($e->getMessage())),
9003 'error_code' => 'web_search_exception'
9004 ];
9005 }
9006 }
9007
9008 /**
9009 * Handle non-streaming web search response
9010 */
9011 private function mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data) {
9012 $request_body['stream'] = false;
9013
9014 $response = $this->mxchat_provider_call_with_retry('https://api.openai.com/v1/responses', array(
9015 'headers' => array(
9016 'Authorization' => 'Bearer ' . $api_key,
9017 'Content-Type' => 'application/json'
9018 ),
9019 'body' => json_encode($request_body),
9020 'timeout' => 90
9021 ), 'openai');
9022
9023 if (is_wp_error($response)) {
9024 //error_log("MXCHAT WEB SEARCH ERROR: WP Error: " . $response->get_error_message());
9025 return [
9026 'error' => esc_html__('Failed to connect to OpenAI web search API', 'mxchat'),
9027 'error_code' => 'web_search_connection_error'
9028 ];
9029 }
9030
9031 $response_code = wp_remote_retrieve_response_code($response);
9032 $response_body = wp_remote_retrieve_body($response);
9033
9034 //error_log("MXCHAT WEB SEARCH: Response code: " . $response_code);
9035 //error_log("MXCHAT WEB SEARCH: Response body (first 2000): " . substr($response_body, 0, 2000));
9036
9037 if ($response_code !== 200) {
9038 $error_data = json_decode($response_body, true);
9039 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
9040 return [
9041 'error' => sprintf(esc_html__('OpenAI API error: %s', 'mxchat'), esc_html($error_message)),
9042 'error_code' => 'web_search_api_error'
9043 ];
9044 }
9045
9046 $result = json_decode($response_body, true);
9047
9048 if (json_last_error() !== JSON_ERROR_NONE) {
9049 return [
9050 'error' => esc_html__('Invalid response from OpenAI', 'mxchat'),
9051 'error_code' => 'web_search_json_error'
9052 ];
9053 }
9054
9055 // Extract the response text and citations from Responses API format
9056 $output_text = '';
9057 $citations = [];
9058
9059 if (isset($result['output'])) {
9060 foreach ($result['output'] as $output_item) {
9061 if ($output_item['type'] === 'message' && isset($output_item['content'])) {
9062 foreach ($output_item['content'] as $content_item) {
9063 if ($content_item['type'] === 'output_text') {
9064 $output_text .= $content_item['text'];
9065
9066 // Extract citations/annotations
9067 if (isset($content_item['annotations'])) {
9068 foreach ($content_item['annotations'] as $annotation) {
9069 if ($annotation['type'] === 'url_citation') {
9070 $citations[] = [
9071 'url' => $annotation['url'],
9072 'title' => $annotation['title'] ?? ''
9073 ];
9074 }
9075 }
9076 }
9077 }
9078 }
9079 }
9080 }
9081 }
9082
9083 // If we have citations, append them to the response
9084 if (!empty($citations)) {
9085 $output_text .= "\n\n**Sources:**\n";
9086 $seen_urls = [];
9087 foreach ($citations as $citation) {
9088 if (!in_array($citation['url'], $seen_urls)) {
9089 $seen_urls[] = $citation['url'];
9090 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
9091 $output_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
9092 }
9093 }
9094 }
9095
9096 // Transcript save is handled by the main handler (mxchat_handle_chat_request)
9097 // which includes rag_context for the "sources" link in transcripts.
9098
9099 return $output_text;
9100 }
9101
9102 /**
9103 * Handle streaming web search response using Responses API
9104 */
9105 private function mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data) {
9106 $request_body['stream'] = true;
9107
9108 // Check if we can stream
9109 if (headers_sent() || !function_exists('curl_init')) {
9110 // Fallback to non-streaming
9111 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
9112 }
9113
9114 // Setup streaming headers
9115 $this->setup_streaming_headers();
9116
9117 $ch = curl_init();
9118 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/responses');
9119 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9120 curl_setopt($ch, CURLOPT_POST, true);
9121 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request_body));
9122 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
9123 'Content-Type: application/json',
9124 'Authorization: Bearer ' . $api_key
9125 ));
9126 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9127 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
9128
9129 $full_response = '';
9130 $stream_started = false;
9131 $buffer = '';
9132 $citations = [];
9133
9134 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$citations, $testing_data) {
9135 // Send testing data as first event if available
9136 if (!$stream_started && $testing_data !== null) {
9137 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
9138 flush();
9139 $stream_started = true;
9140 }
9141
9142 $buffer .= $data;
9143 $lines = explode("\n", $buffer);
9144 $buffer = array_pop($lines);
9145
9146 foreach ($lines as $line) {
9147 if (trim($line) === '') continue;
9148 if (strpos($line, 'data: ') !== 0) continue;
9149
9150 $json_str = substr($line, 6);
9151
9152 if (trim($json_str) === '[DONE]') {
9153 // Append citations if we have any
9154 if (!empty($citations)) {
9155 $citation_text = "\n\n**Sources:**\n";
9156 $seen_urls = [];
9157 foreach ($citations as $citation) {
9158 if (!in_array($citation['url'], $seen_urls)) {
9159 $seen_urls[] = $citation['url'];
9160 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
9161 $citation_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
9162 }
9163 }
9164 echo "data: " . json_encode(['content' => $citation_text]) . "\n\n";
9165 $full_response .= $citation_text;
9166 flush();
9167 }
9168 echo "data: [DONE]\n\n";
9169 flush();
9170 continue;
9171 }
9172
9173 $json = json_decode(trim($json_str), true);
9174 if (!$json) continue;
9175
9176 // Handle Responses API streaming events
9177 // The format is different from Chat Completions
9178 if (isset($json['type'])) {
9179 switch ($json['type']) {
9180 case 'response.output_text.delta':
9181 // Text content delta
9182 if (isset($json['delta'])) {
9183 $content = $json['delta'];
9184 $full_response .= $content;
9185 echo "data: " . json_encode(['content' => $content]) . "\n\n";
9186 flush();
9187 }
9188 break;
9189
9190 case 'response.output_item.done':
9191 // Check for citations in completed items
9192 if (isset($json['item']['content'])) {
9193 foreach ($json['item']['content'] as $content_item) {
9194 if (isset($content_item['annotations'])) {
9195 foreach ($content_item['annotations'] as $annotation) {
9196 if ($annotation['type'] === 'url_citation') {
9197 $citations[] = [
9198 'url' => $annotation['url'],
9199 'title' => $annotation['title'] ?? ''
9200 ];
9201 }
9202 }
9203 }
9204 }
9205 }
9206 break;
9207 }
9208 }
9209 }
9210
9211 return strlen($data);
9212 });
9213
9214 $response = curl_exec($ch);
9215 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
9216
9217 if (curl_errno($ch) || $http_code !== 200) {
9218 $curl_error = curl_error($ch);
9219 curl_close($ch);
9220
9221 //error_log("MXCHAT WEB SEARCH STREAM ERROR: HTTP $http_code, cURL error: $curl_error");
9222
9223 return $this->mxchat_stream_emit_fallback(
9224 'web_search',
9225 $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data),
9226 $session_id,
9227 $testing_data
9228 );
9229 }
9230
9231 curl_close($ch);
9232
9233 // Save the complete response with RAG context so the "sources" link
9234 // appears in transcripts — mirrors the pattern used by Claude/OpenAI streaming.
9235 if (!empty($full_response) && !empty($session_id)) {
9236 $rag_context_for_storage = null;
9237 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
9238 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
9239
9240 if ($has_rag_data || $has_action_data) {
9241 $rag_context_for_storage = [];
9242
9243 if ($has_rag_data) {
9244 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
9245 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
9246 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
9247 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
9248 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
9249 }
9250
9251 if ($has_action_data) {
9252 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
9253 }
9254 }
9255 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
9256 }
9257
9258 return true;
9259 }
9260
9261 private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
9262 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
9263 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
9264 if ($selected_model === 'claude-opus-4-20250514') { $selected_model = 'claude-opus-4-8'; }
9265 elseif ($selected_model === 'claude-sonnet-4-20250514') { $selected_model = 'claude-sonnet-4-6'; }
9266 try {
9267 // Get bot ID from session or request
9268 $bot_id = $this->get_current_bot_id($session_id);
9269
9270 // Get system prompt instructions using centralized function
9271 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9272 // Ensure conversation_history is an array
9273 if (!is_array($conversation_history)) {
9274 $conversation_history = array();
9275 }
9276
9277 // Clean and validate conversation history
9278 foreach ($conversation_history as &$message) {
9279 // Convert bot and agent roles to assistant
9280 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
9281 $message['role'] = 'assistant';
9282 }
9283
9284 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
9285 if (!in_array($message['role'], ['assistant', 'user'])) {
9286 $message['role'] = 'user';
9287 }
9288
9289 // Ensure content field exists
9290 if (!isset($message['content']) || empty($message['content'])) {
9291 $message['content'] = '';
9292 }
9293
9294 // Remove any unsupported fields
9295 $message = array_intersect_key($message, array_flip(['role', 'content']));
9296 }
9297
9298 // Add relevant content as the latest user message
9299 $conversation_history[] = [
9300 'role' => 'user',
9301 'content' => $relevant_content
9302 ];
9303
9304 // Prepare the request body with stream: true
9305 $payload = [
9306 'model' => $selected_model,
9307 'messages' => $conversation_history,
9308 'max_tokens' => 1000,
9309 'temperature' => 0.8,
9310 'system' => $system_prompt_instructions,
9311 'stream' => true
9312 ];
9313 if ($this->mxchat_claude_omits_temperature($selected_model)) { unset($payload['temperature']); }
9314 $body = json_encode($payload);
9315
9316 // Check if we can actually stream (headers not sent, etc.)
9317 if (headers_sent() || !function_exists('curl_init')) {
9318 // Fallback to regular response with testing data
9319 //error_log("MxChat: Streaming not possible, falling back to regular response");
9320 $regular_response = $this->mxchat_generate_response_claude(
9321 $selected_model,
9322 $claude_api_key,
9323 array_slice($conversation_history, 0, -1), // Remove the added content
9324 $relevant_content,
9325 $session_id
9326 );
9327
9328 // Save bot response to transcript
9329 if (!empty($regular_response) && !empty($session_id)) {
9330 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
9331 }
9332
9333 // Return as JSON with testing data
9334 $response_data = [
9335 'text' => $regular_response,
9336 'html' => '',
9337 'session_id' => $session_id
9338 ];
9339
9340 if ($testing_data !== null) {
9341 $response_data['testing_data'] = $testing_data;
9342 //error_log("MxChat Testing: Added testing data to Claude fallback response");
9343 }
9344
9345 // Clear any streaming headers and send JSON
9346 if (headers_sent() === false) {
9347 header('Content-Type: application/json');
9348 }
9349 echo json_encode($response_data);
9350 return true; // Indicate we handled the response
9351 }
9352
9353 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
9354
9355 $captured_status_code = 0;
9356 $captured_body_pre_stream = '';
9357 $full_response = '';
9358 $stream_started = false;
9359 $buffer = '';
9360 $errno = 0;
9361 $http_code = 0;
9362 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
9363 $backoff_ms = array(0, 750, 2000);
9364
9365 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
9366 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
9367 usleep($backoff_ms[$attempt] * 1000);
9368 }
9369
9370 $captured_status_code = 0;
9371 $captured_body_pre_stream = '';
9372 $full_response = '';
9373 $stream_started = false;
9374 $buffer = '';
9375
9376 $ch = curl_init();
9377 curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
9378 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9379 curl_setopt($ch, CURLOPT_POST, true);
9380 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
9381 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
9382 'Content-Type: application/json',
9383 'x-api-key: ' . $claude_api_key,
9384 'anthropic-version: 2023-06-01'
9385 ));
9386 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9387 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
9388
9389 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
9390 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
9391 $captured_status_code = (int) $m[1];
9392 }
9393 return strlen($header);
9394 });
9395
9396 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
9397 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
9398 $captured_body_pre_stream .= $data;
9399 return strlen($data);
9400 }
9401
9402 if (!$this->streaming_headers_sent) {
9403 $this->setup_streaming_headers();
9404 }
9405
9406 if (!$stream_started && $testing_data !== null) {
9407 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
9408 flush();
9409 $stream_started = true;
9410 }
9411
9412 $buffer .= $data;
9413 $lines = explode("\n", $buffer);
9414 $buffer = array_pop($lines);
9415
9416 foreach ($lines as $line) {
9417 if (trim($line) === '') {
9418 continue;
9419 }
9420
9421 if (strpos($line, 'event: ') === 0) {
9422 continue;
9423 }
9424
9425 if (strpos($line, 'data: ') === 0) {
9426 $json_str = substr($line, 6);
9427
9428 $json = json_decode(trim($json_str), true);
9429 if (json_last_error() !== JSON_ERROR_NONE) {
9430 continue;
9431 }
9432
9433 if (isset($json['type'])) {
9434 switch ($json['type']) {
9435 case 'content_block_delta':
9436 if (isset($json['delta']['text'])) {
9437 $content = $json['delta']['text'];
9438 $full_response .= $content;
9439 echo "data: " . json_encode(['content' => $content]) . "\n\n";
9440 flush();
9441 }
9442 break;
9443
9444 case 'message_stop':
9445 echo "data: [DONE]\n\n";
9446 flush();
9447 break;
9448
9449 case 'error':
9450 echo "data: " . json_encode(['error' => $json['error']['message'] ?? 'Unknown error']) . "\n\n";
9451 flush();
9452 break;
9453 }
9454 }
9455 }
9456 }
9457
9458 return strlen($data);
9459 });
9460
9461 $response = curl_exec($ch);
9462 $errno = curl_errno($ch);
9463 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
9464 curl_close($ch);
9465
9466 if (!$errno && $http_code === 200) {
9467 break;
9468 }
9469
9470 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'anthropic', $errno);
9471 $can_retry = !$this->streaming_headers_sent
9472 && ($attempt + 1) < $max_attempts
9473 && $is_transient;
9474
9475 if (defined('WP_DEBUG') && WP_DEBUG) {
9476 error_log(sprintf(
9477 '[MxChat] claude_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
9478 $attempt + 1, $max_attempts, $http_code, $errno,
9479 $is_transient ? 'yes' : 'no',
9480 $can_retry ? 'Retrying.' : 'Giving up.'
9481 ));
9482 }
9483
9484 if (!$can_retry) {
9485 break;
9486 }
9487 }
9488
9489 if ($errno || $http_code !== 200) {
9490 return $this->mxchat_stream_emit_fallback(
9491 'anthropic',
9492 $this->mxchat_generate_response_claude($selected_model, $claude_api_key, array_slice($conversation_history, 0, -1), $relevant_content, $session_id),
9493 $session_id,
9494 $testing_data
9495 );
9496 }
9497
9498 // Save the complete response to maintain chat persistence
9499 if (!empty($full_response) && !empty($session_id)) {
9500 // Prepare RAG context for streaming response
9501 $rag_context_for_storage = null;
9502 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
9503 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
9504
9505 if ($has_rag_data || $has_action_data) {
9506 $rag_context_for_storage = [];
9507
9508 if ($has_rag_data) {
9509 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
9510 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
9511 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
9512 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
9513 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
9514 }
9515
9516 if ($has_action_data) {
9517 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
9518 }
9519 }
9520 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
9521 }
9522
9523 return true; // Indicate streaming completed successfully
9524
9525 } catch (Exception $e) {
9526 return $this->mxchat_stream_emit_fallback(
9527 'anthropic',
9528 $this->mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id),
9529 $session_id,
9530 $testing_data
9531 );
9532 }
9533 }
9534 private function mxchat_generate_response_xai_stream($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
9535 try {
9536 // Get bot ID from session or request
9537 $bot_id = $this->get_current_bot_id($session_id);
9538
9539 // Get system prompt instructions using centralized function
9540 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9541
9542 // Ensure conversation_history is an array
9543 if (!is_array($conversation_history)) {
9544 $conversation_history = array();
9545 }
9546
9547 // Format conversation history for X.AI (same as OpenAI format)
9548 $formatted_conversation = array();
9549
9550 $formatted_conversation[] = array(
9551 'role' => 'system',
9552 'content' => $system_prompt_instructions . " " . $relevant_content
9553 );
9554
9555 foreach ($conversation_history as $message) {
9556 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9557 $role = $message['role'];
9558 if ($role === 'bot' || $role === 'agent') {
9559 $role = 'assistant';
9560 }
9561 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
9562 $role = 'user';
9563 }
9564 $formatted_conversation[] = array(
9565 'role' => $role,
9566 'content' => $message['content']
9567 );
9568 }
9569 }
9570
9571 // Check if we can actually stream
9572 if (headers_sent() || !function_exists('curl_init')) {
9573 // Fallback to regular response with testing data
9574 //error_log("MxChat: X.AI streaming not possible, falling back to regular response");
9575 $regular_response = $this->mxchat_generate_response_xai(
9576 $selected_model,
9577 $xai_api_key,
9578 $conversation_history,
9579 $relevant_content,
9580 $session_id
9581 );
9582
9583 // Save bot response to transcript
9584 if (!empty($regular_response) && !empty($session_id)) {
9585 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
9586 }
9587
9588 $response_data = [
9589 'text' => $regular_response,
9590 'html' => '',
9591 'session_id' => $session_id
9592 ];
9593
9594 if ($testing_data !== null) {
9595 $response_data['testing_data'] = $testing_data;
9596 //error_log("MxChat Testing: Added testing data to X.AI fallback response");
9597 }
9598
9599 header('Content-Type: application/json');
9600 echo json_encode($response_data);
9601 return true;
9602 }
9603
9604 // Prepare the request body with stream: true
9605 $body = json_encode([
9606 'model' => $selected_model,
9607 'messages' => $formatted_conversation,
9608 'temperature' => 0.8,
9609 'stream' => true
9610 ]);
9611
9612 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
9613
9614 $captured_status_code = 0;
9615 $captured_body_pre_stream = '';
9616 $full_response = '';
9617 $stream_started = false;
9618 $buffer = '';
9619 $errno = 0;
9620 $http_code = 0;
9621 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
9622 $backoff_ms = array(0, 750, 2000);
9623
9624 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
9625 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
9626 usleep($backoff_ms[$attempt] * 1000);
9627 }
9628
9629 $captured_status_code = 0;
9630 $captured_body_pre_stream = '';
9631 $full_response = '';
9632 $stream_started = false;
9633 $buffer = '';
9634
9635 $ch = curl_init();
9636 curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
9637 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9638 curl_setopt($ch, CURLOPT_POST, true);
9639 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
9640 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
9641 'Content-Type: application/json',
9642 'Authorization: Bearer ' . $xai_api_key
9643 ));
9644 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9645 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
9646
9647 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
9648 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
9649 $captured_status_code = (int) $m[1];
9650 }
9651 return strlen($header);
9652 });
9653
9654 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
9655 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
9656 $captured_body_pre_stream .= $data;
9657 return strlen($data);
9658 }
9659
9660 if (!$this->streaming_headers_sent) {
9661 $this->setup_streaming_headers();
9662 }
9663
9664 if (!$stream_started && $testing_data !== null) {
9665 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
9666 flush();
9667 $stream_started = true;
9668 }
9669
9670 $buffer .= $data;
9671 $lines = explode("\n", $buffer);
9672 $buffer = array_pop($lines);
9673
9674 foreach ($lines as $line) {
9675 if (trim($line) === '') {
9676 continue;
9677 }
9678 if (strpos($line, 'data: ') !== 0) {
9679 continue;
9680 }
9681
9682 $json_str = substr($line, 6);
9683
9684 if (trim($json_str) === '[DONE]') {
9685 echo "data: [DONE]\n\n";
9686 flush();
9687 continue;
9688 }
9689
9690 $json = json_decode(trim($json_str), true);
9691 if ($json && isset($json['choices'][0]['delta']['content'])) {
9692 $content = $json['choices'][0]['delta']['content'];
9693 $full_response .= $content;
9694 echo "data: " . json_encode(['content' => $content]) . "\n\n";
9695 flush();
9696 }
9697 }
9698
9699 return strlen($data);
9700 });
9701
9702 $response = curl_exec($ch);
9703 $errno = curl_errno($ch);
9704 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
9705 curl_close($ch);
9706
9707 if (!$errno && $http_code === 200) {
9708 break;
9709 }
9710
9711 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'xai', $errno);
9712 $can_retry = !$this->streaming_headers_sent
9713 && ($attempt + 1) < $max_attempts
9714 && $is_transient;
9715
9716 if (defined('WP_DEBUG') && WP_DEBUG) {
9717 error_log(sprintf(
9718 '[MxChat] xai_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
9719 $attempt + 1, $max_attempts, $http_code, $errno,
9720 $is_transient ? 'yes' : 'no',
9721 $can_retry ? 'Retrying.' : 'Giving up.'
9722 ));
9723 }
9724
9725 if (!$can_retry) {
9726 break;
9727 }
9728 }
9729
9730 if ($errno || $http_code !== 200) {
9731 return $this->mxchat_stream_emit_fallback(
9732 'xai',
9733 $this->mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id),
9734 $session_id,
9735 $testing_data
9736 );
9737 }
9738
9739 // Save the complete response to maintain chat persistence
9740 if (!empty($full_response) && !empty($session_id)) {
9741 // Prepare RAG context for streaming response
9742 $rag_context_for_storage = null;
9743 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
9744 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
9745
9746 if ($has_rag_data || $has_action_data) {
9747 $rag_context_for_storage = [];
9748
9749 if ($has_rag_data) {
9750 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
9751 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
9752 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
9753 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
9754 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
9755 }
9756
9757 if ($has_action_data) {
9758 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
9759 }
9760 }
9761 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
9762 }
9763
9764 return true; // Indicate streaming completed successfully
9765
9766 } catch (Exception $e) {
9767 return $this->mxchat_stream_emit_fallback(
9768 'xai',
9769 $this->mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content),
9770 $session_id,
9771 $testing_data
9772 );
9773 }
9774 }
9775 private function mxchat_generate_response_deepseek_stream($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
9776 try {
9777 // Get bot ID from session or request
9778 $bot_id = $this->get_current_bot_id($session_id);
9779
9780 // Get system prompt instructions using centralized function
9781 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9782
9783 // Ensure conversation_history is an array
9784 if (!is_array($conversation_history)) {
9785 $conversation_history = array();
9786 }
9787
9788 // Format conversation history for DeepSeek
9789 $formatted_conversation = array();
9790
9791 $formatted_conversation[] = array(
9792 'role' => 'system',
9793 'content' => $system_prompt_instructions . " " . $relevant_content
9794 );
9795
9796 foreach ($conversation_history as $message) {
9797 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9798 $role = $message['role'];
9799 if ($role === 'bot' || $role === 'agent') {
9800 $role = 'assistant';
9801 }
9802 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
9803 $role = 'user';
9804 }
9805 $formatted_conversation[] = array(
9806 'role' => $role,
9807 'content' => $message['content']
9808 );
9809 }
9810 }
9811
9812 // Check if we can actually stream
9813 if (headers_sent() || !function_exists('curl_init')) {
9814 // Fallback to regular response with testing data
9815 //error_log("MxChat: DeepSeek streaming not possible, falling back to regular response");
9816 $regular_response = $this->mxchat_generate_response_deepseek(
9817 $selected_model,
9818 $deepseek_api_key,
9819 $conversation_history,
9820 $relevant_content,
9821 $session_id
9822 );
9823
9824 // Save bot response to transcript
9825 if (!empty($regular_response) && !empty($session_id)) {
9826 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
9827 }
9828
9829 $response_data = [
9830 'text' => $regular_response,
9831 'html' => '',
9832 'session_id' => $session_id
9833 ];
9834
9835 if ($testing_data !== null) {
9836 $response_data['testing_data'] = $testing_data;
9837 //error_log("MxChat Testing: Added testing data to DeepSeek fallback response");
9838 }
9839
9840 header('Content-Type: application/json');
9841 echo json_encode($response_data);
9842 return true;
9843 }
9844
9845 // Prepare the request body with stream: true
9846 $body = json_encode([
9847 'model' => $selected_model,
9848 'messages' => $formatted_conversation,
9849 'temperature' => 0.8,
9850 'stream' => true
9851 ]);
9852
9853 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
9854
9855 $captured_status_code = 0;
9856 $captured_body_pre_stream = '';
9857 $full_response = '';
9858 $stream_started = false;
9859 $buffer = '';
9860 $errno = 0;
9861 $http_code = 0;
9862 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
9863 $backoff_ms = array(0, 750, 2000);
9864
9865 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
9866 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
9867 usleep($backoff_ms[$attempt] * 1000);
9868 }
9869
9870 $captured_status_code = 0;
9871 $captured_body_pre_stream = '';
9872 $full_response = '';
9873 $stream_started = false;
9874 $buffer = '';
9875
9876 $ch = curl_init();
9877 curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
9878 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9879 curl_setopt($ch, CURLOPT_POST, true);
9880 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
9881 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
9882 'Content-Type: application/json',
9883 'Authorization: Bearer ' . $deepseek_api_key
9884 ));
9885 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9886 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
9887
9888 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
9889 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
9890 $captured_status_code = (int) $m[1];
9891 }
9892 return strlen($header);
9893 });
9894
9895 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
9896 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
9897 $captured_body_pre_stream .= $data;
9898 return strlen($data);
9899 }
9900
9901 if (!$this->streaming_headers_sent) {
9902 $this->setup_streaming_headers();
9903 }
9904
9905 if (!$stream_started && $testing_data !== null) {
9906 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
9907 flush();
9908 $stream_started = true;
9909 }
9910
9911 $buffer .= $data;
9912 $lines = explode("\n", $buffer);
9913 $buffer = array_pop($lines);
9914
9915 foreach ($lines as $line) {
9916 if (trim($line) === '') {
9917 continue;
9918 }
9919 if (strpos($line, 'data: ') !== 0) {
9920 continue;
9921 }
9922
9923 $json_str = substr($line, 6);
9924
9925 if (trim($json_str) === '[DONE]') {
9926 echo "data: [DONE]\n\n";
9927 flush();
9928 continue;
9929 }
9930
9931 $json = json_decode(trim($json_str), true);
9932 if ($json && isset($json['choices'][0]['delta']['content'])) {
9933 $content = $json['choices'][0]['delta']['content'];
9934 $full_response .= $content;
9935 echo "data: " . json_encode(['content' => $content]) . "\n\n";
9936 flush();
9937 }
9938 }
9939
9940 return strlen($data);
9941 });
9942
9943 $response = curl_exec($ch);
9944 $errno = curl_errno($ch);
9945 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
9946 curl_close($ch);
9947
9948 if (!$errno && $http_code === 200) {
9949 break;
9950 }
9951
9952 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
9953 $can_retry = !$this->streaming_headers_sent
9954 && ($attempt + 1) < $max_attempts
9955 && $is_transient;
9956
9957 if (defined('WP_DEBUG') && WP_DEBUG) {
9958 error_log(sprintf(
9959 '[MxChat] deepseek_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
9960 $attempt + 1, $max_attempts, $http_code, $errno,
9961 $is_transient ? 'yes' : 'no',
9962 $can_retry ? 'Retrying.' : 'Giving up.'
9963 ));
9964 }
9965
9966 if (!$can_retry) {
9967 break;
9968 }
9969 }
9970
9971 if ($errno || $http_code !== 200) {
9972 return $this->mxchat_stream_emit_fallback(
9973 'openai',
9974 $this->mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id),
9975 $session_id,
9976 $testing_data
9977 );
9978 }
9979
9980 // Save the complete response to maintain chat persistence
9981 if (!empty($full_response) && !empty($session_id)) {
9982 // Prepare RAG context for streaming response
9983 $rag_context_for_storage = null;
9984 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
9985 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
9986
9987 if ($has_rag_data || $has_action_data) {
9988 $rag_context_for_storage = [];
9989
9990 if ($has_rag_data) {
9991 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
9992 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
9993 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
9994 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
9995 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
9996 }
9997
9998 if ($has_action_data) {
9999 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10000 }
10001 }
10002 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
10003 }
10004
10005 return true; // Indicate streaming completed successfully
10006
10007 } catch (Exception $e) {
10008 return $this->mxchat_stream_emit_fallback(
10009 'openai',
10010 $this->mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content),
10011 $session_id,
10012 $testing_data
10013 );
10014 }
10015 }
10016
10017
10018 private function mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id = '') {
10019 try {
10020 if (!is_array($conversation_history)) {
10021 $conversation_history = array();
10022 }
10023
10024 $bot_id = $this->get_current_bot_id($session_id);
10025 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10026
10027 $formatted_conversation = array();
10028
10029 $formatted_conversation[] = array(
10030 'role' => 'system',
10031 'content' => $system_prompt_instructions . " " . $relevant_content
10032 );
10033
10034 foreach ($conversation_history as $message) {
10035 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
10036 $role = $message['role'];
10037
10038 if ($role === 'bot' || $role === 'agent') {
10039 $role = 'assistant';
10040 }
10041 if (!in_array($role, ['system', 'assistant', 'user'])) {
10042 $role = 'user';
10043 }
10044
10045 $formatted_conversation[] = array(
10046 'role' => $role,
10047 'content' => $message['content']
10048 );
10049 }
10050 }
10051
10052 $body = json_encode([
10053 'model' => $selected_model,
10054 'messages' => $formatted_conversation,
10055 'temperature' => 1,
10056 ]);
10057
10058 $args = [
10059 'body' => $body,
10060 'headers' => [
10061 'Content-Type' => 'application/json',
10062 'Authorization' => 'Bearer ' . $openrouter_api_key,
10063 'HTTP-Referer' => home_url(),
10064 'X-Title' => get_bloginfo('name'),
10065 ],
10066 'timeout' => 60,
10067 'redirection' => 5,
10068 'blocking' => true,
10069 'httpversion' => '1.0',
10070 'sslverify' => true,
10071 ];
10072
10073 $response = $this->mxchat_provider_call_with_retry('https://openrouter.ai/api/v1/chat/completions', $args, 'openai');
10074
10075 if (is_wp_error($response)) {
10076 $error_message = $response->get_error_message();
10077 return [
10078 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'OpenRouter'),
10079 'error_code' => 'openrouter_connection_error',
10080 'provider' => 'openrouter'
10081 ];
10082 }
10083
10084 $status_code = wp_remote_retrieve_response_code($response);
10085 if ($status_code !== 200) {
10086 $response_body = wp_remote_retrieve_body($response);
10087 $decoded_response = json_decode($response_body, true);
10088
10089 $error_message = isset($decoded_response['error']['message'])
10090 ? $decoded_response['error']['message']
10091 : 'HTTP Error ' . $status_code;
10092
10093 return [
10094 'error' => esc_html__('OpenRouter API error: ', 'mxchat') . esc_html($error_message),
10095 'error_code' => 'openrouter_api_error',
10096 'provider' => 'openrouter',
10097 'status_code' => $status_code
10098 ];
10099 }
10100
10101 $response_body = wp_remote_retrieve_body($response);
10102 $decoded_response = json_decode($response_body, true);
10103
10104 if (isset($decoded_response['choices'][0]['message']['content'])) {
10105 return trim($decoded_response['choices'][0]['message']['content']);
10106 } else {
10107 return [
10108 'error' => esc_html__('Unexpected response format from OpenRouter.', 'mxchat'),
10109 'error_code' => 'openrouter_response_format_error',
10110 'provider' => 'openrouter'
10111 ];
10112 }
10113 } catch (Exception $e) {
10114 return [
10115 'error' => esc_html__('System error when processing OpenRouter request: ', 'mxchat') . esc_html($e->getMessage()),
10116 'error_code' => 'openrouter_exception',
10117 'provider' => 'openrouter'
10118 ];
10119 }
10120 }
10121
10122 /**
10123 * Build a chat-bubble-safe message for a non-200 provider (chat) error.
10124 *
10125 * Visitors must NEVER see raw API internals (model names, key/billing/quota
10126 * text). Admins (manage_options) get an actionable hint — and, for the common
10127 * "model not available on this key" case, a direct pointer to change the model
10128 * (the site owner can fix it in one click). Anthropic returns model-access as a
10129 * 4xx with a message like "Claude Fable 5 is not available. Please use Opus 4.8."
10130 *
10131 * Provider-agnostic by design (reusable for the xai/gemini/deepseek branches),
10132 * but Anthropic is the confirmed, reproduced case wired up here (plan 1d3b0f).
10133 *
10134 * @param int $http_code HTTP status from the provider.
10135 * @param string $error_message Raw provider error.message (may be empty).
10136 * @param string $provider_label Human provider name, e.g. 'Anthropic'.
10137 * @return string Message safe to render as a chat bubble.
10138 */
10139 private function mxchat_friendly_chat_error($http_code, $error_message, $provider_label = '') {
10140 $raw = trim((string) $error_message);
10141
10142 // Detect a model-access / availability problem the site owner can fix by
10143 // choosing a different model. (Anthropic phrasing + the common API shapes.)
10144 $low = strtolower($raw);
10145 $is_model_access = (strpos($low, 'not available') !== false)
10146 || (strpos($low, 'does not have access') !== false)
10147 || (strpos($low, 'do not have access') !== false)
10148 || (strpos($low, 'does not exist') !== false) // OpenAI: "model `x` does not exist or you do not have access"
10149 || (strpos($low, 'model_not_found') !== false)
10150 || (strpos($low, 'not_found_error') !== false)
10151 || (strpos($low, 'model not found') !== false) // xAI
10152 || (strpos($low, 'not found') !== false) // Gemini: "models/x is not found for API version ..."
10153 || (strpos($low, 'permission_denied') !== false) // Gemini gated model
10154 || (strpos($low, 'permission denied') !== false);
10155
10156 if (current_user_can('manage_options')) {
10157 if ($is_model_access) {
10158 return $raw !== ''
10159 ? sprintf(
10160 /* translators: %s: raw provider error detail */
10161 esc_html__('The selected AI model isn\'t available on your API key. Choose another model in MxChat → Settings. (Details: %s)', 'mxchat'),
10162 $raw
10163 )
10164 : esc_html__('The selected AI model isn\'t available on your API key. Choose another model in MxChat → Settings.', 'mxchat');
10165 }
10166 return $raw !== ''
10167 ? sprintf(
10168 /* translators: 1: provider label, 2: raw provider error detail */
10169 esc_html__('The AI provider (%1$s) returned an error: %2$s. Check your model and API key in MxChat → Settings.', 'mxchat'),
10170 $provider_label !== '' ? $provider_label : esc_html__('AI', 'mxchat'),
10171 $raw
10172 )
10173 : esc_html__('The AI provider returned an error. Check your model and API key in MxChat → Settings.', 'mxchat');
10174 }
10175
10176 // Visitors: friendly, generic, no internals leaked.
10177 return esc_html__('Sorry, I\'m having trouble responding right now. Please try again in a moment.', 'mxchat');
10178 }
10179
10180 private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id = '') {
10181 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
10182 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
10183 if ($selected_model === 'claude-opus-4-20250514') { $selected_model = 'claude-opus-4-8'; }
10184 elseif ($selected_model === 'claude-sonnet-4-20250514') { $selected_model = 'claude-sonnet-4-6'; }
10185
10186 // Get bot ID from session or request
10187 $bot_id = $this->get_current_bot_id($session_id);
10188
10189 // Get system prompt instructions using centralized function
10190 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10191
10192 // Clean and validate conversation history
10193 foreach ($conversation_history as &$message) {
10194 // Convert bot and agent roles to assistant
10195 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
10196 $message['role'] = 'assistant';
10197 }
10198
10199 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
10200 if (!in_array($message['role'], ['assistant', 'user'])) {
10201 $message['role'] = 'user';
10202 }
10203
10204 // Ensure content field exists
10205 if (!isset($message['content']) || empty($message['content'])) {
10206 $message['content'] = '';
10207 }
10208
10209 // Remove any unsupported fields
10210 $message = array_intersect_key($message, array_flip(['role', 'content']));
10211 }
10212
10213 // Add relevant content as the latest user message
10214 $conversation_history[] = [
10215 'role' => 'user',
10216 'content' => $relevant_content
10217 ];
10218
10219 // Build request body
10220 $payload = [
10221 'model' => $selected_model,
10222 'max_tokens' => 1000,
10223 'temperature' => 0.8,
10224 'messages' => $conversation_history,
10225 'system' => $system_prompt_instructions
10226 ];
10227 if ($this->mxchat_claude_omits_temperature($selected_model)) { unset($payload['temperature']); }
10228 $body = json_encode($payload);
10229
10230 // Set up API request
10231 $args = [
10232 'body' => $body,
10233 'headers' => [
10234 'Content-Type' => 'application/json',
10235 'x-api-key' => $claude_api_key,
10236 'anthropic-version' => '2023-06-01'
10237 ],
10238 'timeout' => 60,
10239 'redirection' => 5,
10240 'blocking' => true,
10241 'httpversion' => '1.0',
10242 'sslverify' => true,
10243 ];
10244
10245 // Make API request
10246 $response = $this->mxchat_provider_call_with_retry('https://api.anthropic.com/v1/messages', $args, 'anthropic');
10247
10248 // Check for WordPress errors
10249 if (is_wp_error($response)) {
10250 //error_log("Claude API request error: " . $response->get_error_message());
10251 return "Sorry, there was an error connecting to the API.";
10252 }
10253
10254 // Check HTTP response code
10255 $http_code = wp_remote_retrieve_response_code($response);
10256 if ($http_code !== 200) {
10257 $error_body = wp_remote_retrieve_body($response);
10258 //error_log("Claude API HTTP error: " . $http_code . " - " . $error_body);
10259
10260 // Try to extract error message from response
10261 $error_data = json_decode($error_body, true);
10262 $error_message = isset($error_data['error']['message']) ?
10263 $error_data['error']['message'] :
10264 "HTTP error " . $http_code;
10265
10266 // Surface an admin-actionable message (and a model-change pointer for the
10267 // model-access case) without leaking raw API internals to visitors. This
10268 // is the single chokepoint for BOTH the non-streaming and streaming Claude
10269 // paths (the stream's non-200 fallback re-enters this method). plan 1d3b0f.
10270 return $this->mxchat_friendly_chat_error($http_code, $error_message, 'Anthropic');
10271 }
10272
10273 // Parse response
10274 $response_body = json_decode(wp_remote_retrieve_body($response), true);
10275
10276 // Check for JSON decode errors
10277 if (json_last_error() !== JSON_ERROR_NONE) {
10278 //error_log("Claude API JSON decode error: " . json_last_error_msg());
10279 return "Sorry, there was an error processing the API response.";
10280 }
10281
10282 // Extract and validate response content. claude-fable-5 prepends a
10283 // thinking block to content even with no thinking param — take the first
10284 // TEXT block rather than content[0].
10285 if (isset($response_body['content']) && is_array($response_body['content'])) {
10286 foreach ($response_body['content'] as $block) {
10287 if (isset($block['type'], $block['text']) && $block['type'] === 'text') {
10288 return trim($block['text']);
10289 }
10290 }
10291 }
10292
10293 // Log unexpected response format
10294 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
10295 return "Sorry, I received an unexpected response format from the API.";
10296 }
10297 private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content, $session_id = '') {
10298 try {
10299 // Ensure conversation_history is an array
10300 if (!is_array($conversation_history)) {
10301 $conversation_history = array();
10302 }
10303
10304 // Get bot ID from session or request. plan eb9c38: resolve the real bot
10305 // from the session (was hardcoded '' → always default bot on multi-bot
10306 // installs) and fix the undefined $session_id that fed get_system_instructions.
10307 $bot_id = $this->get_current_bot_id($session_id);
10308
10309 // Get system prompt instructions using centralized function
10310 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10311
10312 // Create a new array for the formatted conversation
10313 $formatted_conversation = array();
10314
10315 // Add system message first
10316 $formatted_conversation[] = array(
10317 'role' => 'system',
10318 'content' => $system_prompt_instructions . " " . $relevant_content
10319 );
10320
10321 // Add the rest of the conversation history
10322 foreach ($conversation_history as $message) {
10323 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
10324 $role = $message['role'];
10325
10326 // Convert roles to supported format
10327 if ($role === 'bot' || $role === 'agent') {
10328 $role = 'assistant';
10329 }
10330 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
10331 $role = 'user';
10332 }
10333
10334 $formatted_conversation[] = array(
10335 'role' => $role,
10336 'content' => $message['content']
10337 );
10338 }
10339 }
10340
10341 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
10342 $is_gpt5_model = (
10343 strpos($selected_model, 'gpt-5') === 0 ||
10344 $selected_model === 'gpt-5.2' ||
10345 $selected_model === 'gpt-5.1-2025-11-13' ||
10346 $selected_model === 'gpt-5' ||
10347 $selected_model === 'gpt-5-mini' ||
10348 $selected_model === 'gpt-5-nano'
10349 );
10350
10351 // Build request body with optimal settings for fast responses
10352 $request_body = [
10353 'model' => $selected_model,
10354 'messages' => $formatted_conversation,
10355 'temperature' => 1,
10356 'stream' => false
10357 ];
10358
10359 // Add reasoning_effort only for GPT-5 models that support it
10360 // These chat models don't support reasoning_effort parameter
10361 $no_reasoning_models = array('gpt-5.2', 'gpt-5.1-chat-latest', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
10362 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_models, true)) {
10363 // GPT-5.1 uses 'low' instead of 'minimal'
10364 if ($selected_model === 'gpt-5.1-2025-11-13') {
10365 $request_body['reasoning_effort'] = 'low';
10366 } elseif ($selected_model === 'gpt-5.5') {
10367 $request_body['reasoning_effort'] = 'none';
10368 } elseif ($selected_model === 'gpt-5.4') {
10369 $request_body['reasoning_effort'] = 'none';
10370 } else {
10371 $request_body['reasoning_effort'] = 'minimal';
10372 }
10373 }
10374
10375 $body = json_encode($request_body);
10376
10377 $args = [
10378 'body' => $body,
10379 'headers' => [
10380 'Content-Type' => 'application/json',
10381 'Authorization' => 'Bearer ' . $api_key,
10382 ],
10383 'timeout' => 60,
10384 'redirection' => 5,
10385 'blocking' => true,
10386 'httpversion' => '1.0',
10387 'sslverify' => true,
10388 ];
10389
10390 $response = $this->mxchat_provider_call_with_retry('https://api.openai.com/v1/chat/completions', $args, 'openai');
10391
10392 if (is_wp_error($response)) {
10393 $error_message = $response->get_error_message();
10394 return [
10395 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'OpenAI'),
10396 'error_code' => 'openai_connection_error',
10397 'provider' => 'openai'
10398 ];
10399 }
10400
10401 $status_code = wp_remote_retrieve_response_code($response);
10402 if ($status_code !== 200) {
10403 $response_body = wp_remote_retrieve_body($response);
10404 $decoded_response = json_decode($response_body, true);
10405
10406 $error_message = isset($decoded_response['error']['message'])
10407 ? $decoded_response['error']['message']
10408 : 'HTTP Error ' . $status_code;
10409
10410 $error_type = isset($decoded_response['error']['type'])
10411 ? $decoded_response['error']['type']
10412 : 'unknown';
10413
10414 // Handle specific error types
10415 switch ($error_type) {
10416 case 'invalid_request_error':
10417 if (strpos($error_message, 'API key') !== false) {
10418 return [
10419 'error' => esc_html__('Invalid OpenAI API key. Please check your API key configuration.', 'mxchat'),
10420 'error_code' => 'openai_invalid_api_key',
10421 'provider' => 'openai'
10422 ];
10423 }
10424 break;
10425
10426 case 'authentication_error':
10427 return [
10428 'error' => esc_html__('Authentication failed with OpenAI. Please check your API key.', 'mxchat'),
10429 'error_code' => 'openai_auth_error',
10430 'provider' => 'openai'
10431 ];
10432
10433 case 'rate_limit_exceeded':
10434 return [
10435 'error' => esc_html__('OpenAI rate limit exceeded. Please try again later.', 'mxchat'),
10436 'error_code' => 'openai_rate_limit',
10437 'provider' => 'openai'
10438 ];
10439
10440 case 'quota_exceeded':
10441 return [
10442 'error' => esc_html__('OpenAI API quota exceeded. Please check your billing details.', 'mxchat'),
10443 'error_code' => 'openai_quota_exceeded',
10444 'provider' => 'openai'
10445 ];
10446 }
10447
10448 // Generic error fallback only — the typed cases above already produce
10449 // clean messages. Route the raw-tail generic case through the leak-safe
10450 // helper so visitors never see provider internals. plan 5da59a.
10451 return [
10452 'error' => $this->mxchat_friendly_chat_error($status_code, $error_message, 'OpenAI'),
10453 'error_code' => 'openai_api_error',
10454 'provider' => 'openai',
10455 'status_code' => $status_code
10456 ];
10457 }
10458
10459 $response_body = wp_remote_retrieve_body($response);
10460 $decoded_response = json_decode($response_body, true);
10461
10462 if (isset($decoded_response['choices'][0]['message']['content'])) {
10463 return trim($decoded_response['choices'][0]['message']['content']);
10464 } else {
10465 return [
10466 'error' => esc_html__('Unexpected response format from OpenAI.', 'mxchat'),
10467 'error_code' => 'openai_response_format_error',
10468 'provider' => 'openai'
10469 ];
10470 }
10471 } catch (Exception $e) {
10472 return [
10473 'error' => esc_html__('System error when processing OpenAI request: ', 'mxchat') . esc_html($e->getMessage()),
10474 'error_code' => 'openai_exception',
10475 'provider' => 'openai'
10476 ];
10477 }
10478 }
10479
10480 private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id = '') {
10481 try {
10482 // Get bot ID from session or request
10483 $bot_id = $this->get_current_bot_id($session_id);
10484
10485 // Get system prompt instructions using centralized function
10486 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10487
10488 // Add system prompt to relevant content
10489 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
10490
10491 // Prepend system instructions to the conversation history
10492 array_unshift($conversation_history, [
10493 'role' => 'system',
10494 'content' => "Here are your instructions: " . $content_with_instructions
10495 ]);
10496
10497 // Ensure consistency: Replace 'bot' and 'agent' roles with supported values
10498 foreach ($conversation_history as &$message) {
10499 if ($message['role'] === 'bot') {
10500 $message['role'] = 'assistant';
10501 } elseif ($message['role'] === 'agent') {
10502 // Tag the message as coming from a live agent
10503 $message['role'] = 'assistant';
10504 if (!isset($message['metadata'])) {
10505 $message['metadata'] = ['source' => 'live_agent'];
10506 }
10507 }
10508
10509 // Ensure all roles are valid
10510 if (!in_array($message['role'], ['system', 'assistant', 'user', 'function', 'tool'])) {
10511 $message['role'] = 'user'; // Default to 'user'
10512 }
10513 }
10514
10515 // Build the request body
10516 $body = json_encode([
10517 'model' => $selected_model,
10518 'messages' => $conversation_history,
10519 'temperature' => 0.8,
10520 'stream' => false
10521 ]);
10522
10523 // Set up the API request
10524 $args = [
10525 'body' => $body,
10526 'headers' => [
10527 'Content-Type' => 'application/json',
10528 'Authorization' => 'Bearer ' . $xai_api_key,
10529 ],
10530 'timeout' => 60,
10531 'redirection' => 5,
10532 'blocking' => true,
10533 'httpversion' => '1.0',
10534 'sslverify' => true,
10535 ];
10536
10537 // Make the API request
10538 $response = $this->mxchat_provider_call_with_retry('https://api.x.ai/v1/chat/completions', $args, 'xai');
10539
10540 // Process the response
10541 if (is_wp_error($response)) {
10542 $error_message = $response->get_error_message();
10543 //error_log('X.AI API Error: ' . $error_message);
10544 return [
10545 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'X.AI'),
10546 'error_code' => 'xai_connection_error',
10547 'provider' => 'xai'
10548 ];
10549 }
10550
10551 $status_code = wp_remote_retrieve_response_code($response);
10552 if ($status_code !== 200) {
10553 $response_body = wp_remote_retrieve_body($response);
10554 $decoded_response = json_decode($response_body, true);
10555
10556 // Log the full response for debugging
10557 //error_log('X.AI Error Response: ' . print_r($decoded_response, true));
10558
10559 // Extract error message from X.AI's specific format
10560 $error_message = '';
10561
10562 // Check for direct error string (as seen in your logs)
10563 if (isset($decoded_response['error']) && is_string($decoded_response['error'])) {
10564 $error_message = $decoded_response['error'];
10565 }
10566 // Check for nested error object (OpenAI style)
10567 elseif (isset($decoded_response['error']['message'])) {
10568 $error_message = $decoded_response['error']['message'];
10569 }
10570 // Check for top-level message
10571 elseif (isset($decoded_response['message'])) {
10572 $error_message = $decoded_response['message'];
10573 }
10574 // Fallback
10575 else {
10576 $error_message = 'HTTP Error ' . $status_code;
10577 }
10578
10579 //error_log('X.AI API HTTP Error: ' . $status_code . ' - ' . $error_message);
10580
10581 // Check for API key errors using string matching
10582 if (stripos($error_message, 'api key') !== false ||
10583 stripos($error_message, 'incorrect api key') !== false ||
10584 stripos($error_message, 'invalid api key') !== false) {
10585 return [
10586 'error' => esc_html__('Invalid X.AI API key. Please check your API key configuration.', 'mxchat'),
10587 'error_code' => 'xai_invalid_api_key',
10588 'provider' => 'xai'
10589 ];
10590 }
10591
10592 // Authentication errors
10593 if ($status_code === 401 || $status_code === 403 ||
10594 stripos($error_message, 'auth') !== false) {
10595 return [
10596 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat'),
10597 'error_code' => 'xai_auth_error',
10598 'provider' => 'xai'
10599 ];
10600 }
10601
10602 // Model errors
10603 if (stripos($error_message, 'model') !== false) {
10604 return [
10605 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat'),
10606 'error_code' => 'xai_invalid_model',
10607 'provider' => 'xai'
10608 ];
10609 }
10610
10611 // Rate limit errors
10612 if ($status_code === 429 ||
10613 stripos($error_message, 'rate') !== false ||
10614 stripos($error_message, 'limit') !== false) {
10615 return [
10616 'error' => esc_html__('X.AI rate limit exceeded. Please try again later.', 'mxchat'),
10617 'error_code' => 'xai_rate_limit',
10618 'provider' => 'xai'
10619 ];
10620 }
10621
10622 // Quota errors
10623 if (stripos($error_message, 'quota') !== false ||
10624 stripos($error_message, 'billing') !== false) {
10625 return [
10626 'error' => esc_html__('X.AI API quota exceeded. Please check your billing details.', 'mxchat'),
10627 'error_code' => 'xai_quota_exceeded',
10628 'provider' => 'xai'
10629 ];
10630 }
10631
10632 // Server errors
10633 if ($status_code >= 500) {
10634 return [
10635 'error' => esc_html__('X.AI service is currently unavailable. Please try again later.', 'mxchat'),
10636 'error_code' => 'xai_service_unavailable',
10637 'provider' => 'xai'
10638 ];
10639 }
10640
10641 // Generic error fallback. Route the user-facing text through the
10642 // leak-safe helper (admins get an actionable hint, visitors a generic
10643 // fallback) instead of echoing raw provider internals. Preserve the
10644 // structured contract (error_code/provider/status_code) for logging. plan 5da59a.
10645 return [
10646 'error' => $this->mxchat_friendly_chat_error($status_code, $error_message, 'xAI'),
10647 'error_code' => 'xai_api_error',
10648 'provider' => 'xai',
10649 'status_code' => $status_code
10650 ];
10651 }
10652
10653 $response_body = wp_remote_retrieve_body($response);
10654 $decoded_response = json_decode($response_body, true);
10655
10656 if (isset($decoded_response['choices'][0]['message']['content'])) {
10657 return trim($decoded_response['choices'][0]['message']['content']);
10658 } else {
10659 //error_log('X.AI API Response Format Error: ' . print_r($decoded_response, true));
10660 return [
10661 'error' => esc_html__('Unexpected response format from X.AI.', 'mxchat'),
10662 'error_code' => 'xai_response_format_error',
10663 'provider' => 'xai'
10664 ];
10665 }
10666 } catch (Exception $e) {
10667 //error_log('X.AI Exception: ' . $e->getMessage());
10668 return [
10669 'error' => esc_html__('System error when processing X.AI request: ', 'mxchat') . esc_html($e->getMessage()),
10670 'error_code' => 'xai_exception',
10671 'provider' => 'xai'
10672 ];
10673 }
10674
10675
10676 }
10677 private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id = '') {
10678 try {
10679 // Ensure conversation_history is an array
10680 if (!is_array($conversation_history)) {
10681 $conversation_history = array();
10682 }
10683
10684 // Get bot ID from session or request
10685 $bot_id = $this->get_current_bot_id($session_id);
10686
10687 // Get system prompt instructions using centralized function
10688 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10689
10690 // Create a new array for the formatted conversation
10691 $formatted_conversation = array();
10692
10693 // Add system message first
10694 $formatted_conversation[] = array(
10695 'role' => 'system',
10696 'content' => $system_prompt_instructions . " " . $relevant_content
10697 );
10698
10699 // Add the rest of the conversation history
10700 foreach ($conversation_history as $message) {
10701 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
10702 $role = $message['role'];
10703
10704 // Convert roles to supported format
10705 if ($role === 'bot' || $role === 'agent') {
10706 $role = 'assistant';
10707 }
10708 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
10709 $role = 'user';
10710 }
10711
10712 $formatted_conversation[] = array(
10713 'role' => $role,
10714 'content' => $message['content']
10715 );
10716 }
10717 }
10718
10719 $body = json_encode([
10720 'model' => $selected_model,
10721 'messages' => $formatted_conversation,
10722 'temperature' => 0.8,
10723 'stream' => false
10724 ]);
10725
10726 $args = [
10727 'body' => $body,
10728 'headers' => [
10729 'Content-Type' => 'application/json',
10730 'Authorization' => 'Bearer ' . $deepseek_api_key,
10731 ],
10732 'timeout' => 60,
10733 'redirection' => 5,
10734 'blocking' => true,
10735 'httpversion' => '1.0',
10736 'sslverify' => true,
10737 ];
10738
10739 $response = $this->mxchat_provider_call_with_retry('https://api.deepseek.com/v1/chat/completions', $args, 'openai');
10740
10741 if (is_wp_error($response)) {
10742 $error_message = $response->get_error_message();
10743 //error_log('DeepSeek API Error: ' . $error_message);
10744 return [
10745 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'DeepSeek'),
10746 'error_code' => 'deepseek_connection_error',
10747 'provider' => 'deepseek'
10748 ];
10749 }
10750
10751 $status_code = wp_remote_retrieve_response_code($response);
10752 if ($status_code !== 200) {
10753 $response_body = wp_remote_retrieve_body($response);
10754 $decoded_response = json_decode($response_body, true);
10755
10756 $error_message = isset($decoded_response['error']['message'])
10757 ? $decoded_response['error']['message']
10758 : 'HTTP Error ' . $status_code;
10759
10760 $error_type = isset($decoded_response['error']['type'])
10761 ? $decoded_response['error']['type']
10762 : 'unknown';
10763
10764 //error_log('DeepSeek API HTTP Error: ' . $status_code . ' - ' . $error_message);
10765
10766 // Handle specific error types
10767 switch ($status_code) {
10768 case 401:
10769 return [
10770 'error' => esc_html__('Authentication failed with DeepSeek. Please check your API key.', 'mxchat'),
10771 'error_code' => 'deepseek_auth_error',
10772 'provider' => 'deepseek'
10773 ];
10774
10775 case 400:
10776 if (strpos($error_message, 'API key') !== false) {
10777 return [
10778 'error' => esc_html__('Invalid DeepSeek API key. Please check your API key configuration.', 'mxchat'),
10779 'error_code' => 'deepseek_invalid_api_key',
10780 'provider' => 'deepseek'
10781 ];
10782 }
10783 break;
10784
10785 case 429:
10786 if (strpos($error_message, 'quota') !== false) {
10787 return [
10788 'error' => esc_html__('DeepSeek API quota exceeded. Please check your billing details.', 'mxchat'),
10789 'error_code' => 'deepseek_quota_exceeded',
10790 'provider' => 'deepseek'
10791 ];
10792 } else {
10793 return [
10794 'error' => esc_html__('DeepSeek rate limit exceeded. Please try again later.', 'mxchat'),
10795 'error_code' => 'deepseek_rate_limit',
10796 'provider' => 'deepseek'
10797 ];
10798 }
10799
10800 case 500:
10801 case 502:
10802 case 503:
10803 case 504:
10804 return [
10805 'error' => esc_html__('DeepSeek service is currently unavailable. Please try again later.', 'mxchat'),
10806 'error_code' => 'deepseek_service_unavailable',
10807 'provider' => 'deepseek'
10808 ];
10809 }
10810
10811 // Generic error fallback — leak-safe helper (see plan 5da59a / 1d3b0f).
10812 return [
10813 'error' => $this->mxchat_friendly_chat_error($status_code, $error_message, 'DeepSeek'),
10814 'error_code' => 'deepseek_api_error',
10815 'provider' => 'deepseek',
10816 'status_code' => $status_code
10817 ];
10818 }
10819
10820 $response_body = wp_remote_retrieve_body($response);
10821 $decoded_response = json_decode($response_body, true);
10822
10823 if (isset($decoded_response['choices'][0]['message']['content'])) {
10824 return trim($decoded_response['choices'][0]['message']['content']);
10825 } else {
10826 //error_log('DeepSeek API Response Format Error: ' . print_r($decoded_response, true));
10827 return [
10828 'error' => esc_html__('Unexpected response format from DeepSeek.', 'mxchat'),
10829 'error_code' => 'deepseek_response_format_error',
10830 'provider' => 'deepseek'
10831 ];
10832 }
10833 } catch (Exception $e) {
10834 //error_log('DeepSeek Exception: ' . $e->getMessage());
10835 return [
10836 'error' => esc_html__('System error when processing DeepSeek request: ', 'mxchat') . esc_html($e->getMessage()),
10837 'error_code' => 'deepseek_exception',
10838 'provider' => 'deepseek'
10839 ];
10840 }
10841 }
10842 private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content, $session_id = '') {
10843 // Read-time remap: gemini-3-pro-preview was shut down March 9, 2026.
10844 // Auto-rescue existing installs whose saved model is the dead ID.
10845 if ($selected_model === 'gemini-3-pro-preview') {
10846 $selected_model = 'gemini-3.1-pro-preview';
10847 }
10848 // Get bot ID from session or request
10849 $bot_id = $this->get_current_bot_id($session_id);
10850
10851 // Get system prompt instructions using centralized function
10852 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10853
10854 // Add system prompt to relevant content
10855 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
10856
10857 // Format messages for Gemini API
10858 $formatted_messages = [];
10859
10860 // Add system message as the first user message with role prefix
10861 // Note: Gemini doesn't have a dedicated system role, so we use a prefixed user message
10862 $formatted_messages[] = [
10863 'role' => 'user',
10864 'parts' => [
10865 ['text' => "[System Instructions] " . $content_with_instructions]
10866 ]
10867 ];
10868
10869 // Add model response to acknowledge system instructions
10870 $formatted_messages[] = [
10871 'role' => 'model',
10872 'parts' => [
10873 ['text' => "I understand and will follow these instructions."]
10874 ]
10875 ];
10876
10877 // Process the rest of the conversation history
10878 $current_role = null;
10879 $current_parts = [];
10880
10881 foreach ($conversation_history as $message) {
10882 // Skip the first system message as we already handled it
10883 if ($message['role'] === 'system') {
10884 continue;
10885 }
10886
10887 // Map roles to Gemini format
10888 $gemini_role = '';
10889 if ($message['role'] === 'user') {
10890 $gemini_role = 'user';
10891 } else if (in_array($message['role'], ['assistant', 'bot', 'agent'])) {
10892 $gemini_role = 'model';
10893 } else {
10894 // Skip unsupported roles
10895 continue;
10896 }
10897
10898 // If we have a new role, add the previous message
10899 if ($current_role !== null && $current_role !== $gemini_role && !empty($current_parts)) {
10900 $formatted_messages[] = [
10901 'role' => $current_role,
10902 'parts' => $current_parts
10903 ];
10904 $current_parts = [];
10905 }
10906
10907 // Set current role and add text to parts
10908 $current_role = $gemini_role;
10909 $current_parts[] = ['text' => $message['content']];
10910 }
10911
10912 // Add the last message if there's content
10913 if ($current_role !== null && !empty($current_parts)) {
10914 $formatted_messages[] = [
10915 'role' => $current_role,
10916 'parts' => $current_parts
10917 ];
10918 }
10919
10920 // Built-in Web Search grounding for Gemini (plan 46b9ea).
10921 // The enable_web_search toggle historically routed ONLY to OpenAI's web_search
10922 // tool; for a Gemini chat model it was a silent no-op. Gemini grounds natively
10923 // (and free) via the Google Search tool, so when the toggle is on we attach it
10924 // here on the PLAIN dispatch path. The function-calling loop (mxchat_fc_loop_gemini)
10925 // is a SEPARATE path reached only when AI Tools are active, so grounding here
10926 // never double-fires with function calling.
10927 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
10928 // Gemini ids that do NOT support Google Search grounding (none today — every
10929 // shipped chat model is 2.x/3.x and grounds natively). Kept as the explicit
10930 // opt-out list mirroring the OpenAI $unsupported_web_search_models pattern.
10931 $gemini_unsupported_grounding = array();
10932 $grounding_active = $web_search_enabled && !in_array($selected_model, $gemini_unsupported_grounding, true);
10933
10934 // Build the request body
10935 $request_payload = [
10936 'contents' => $formatted_messages,
10937 'generationConfig' => [
10938 'temperature' => 0.7,
10939 'topP' => 0.95,
10940 'topK' => 40,
10941 'maxOutputTokens' => 8192,
10942 ],
10943 'safetySettings' => [
10944 [
10945 'category' => 'HARM_CATEGORY_HARASSMENT',
10946 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
10947 ],
10948 [
10949 'category' => 'HARM_CATEGORY_HATE_SPEECH',
10950 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
10951 ],
10952 [
10953 'category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
10954 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
10955 ],
10956 [
10957 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT',
10958 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
10959 ]
10960 ]
10961 ];
10962
10963 if ($grounding_active) {
10964 // Gemini 1.5 used the older google_search_retrieval shape; 2.0+ uses the
10965 // bare google_search tool. Branch by model family so a future 1.5 id still
10966 // grounds (no 1.5 ships today, so this resolves to google_search). The empty
10967 // tool config must serialize as a JSON object {}, not an array [].
10968 if (strpos($selected_model, 'gemini-1.5') !== false) {
10969 $request_payload['tools'] = [ ['google_search_retrieval' => new \stdClass()] ];
10970 } else {
10971 $request_payload['tools'] = [ ['google_search' => new \stdClass()] ];
10972 }
10973 }
10974
10975 $body = json_encode($request_payload);
10976
10977 // Prepare the API endpoint
10978 // Use v1beta for preview models (Gemini 3, experimental), v1 for stable models.
10979 // Grounding (the google_search tool) is a v1beta feature, so force v1beta whenever
10980 // it's active — otherwise a stable model on v1 would silently drop the tool.
10981 $api_version = ($grounding_active || strpos($selected_model, 'preview') !== false || strpos($selected_model, 'exp') !== false) ? 'v1beta' : 'v1';
10982 $api_endpoint = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
10983
10984 // Set up the API request
10985 $args = [
10986 'body' => $body,
10987 'headers' => [
10988 'Content-Type' => 'application/json',
10989 ],
10990 'timeout' => 60,
10991 'redirection' => 5,
10992 'blocking' => true,
10993 'httpversion' => '1.0',
10994 'sslverify' => true,
10995 ];
10996
10997 // Make the API request
10998 $response = $this->mxchat_provider_call_with_retry($api_endpoint, $args, 'gemini');
10999
11000 // Process the response
11001 if (is_wp_error($response)) {
11002 // plan b13282: route the transport-error string through the leak-safe helper
11003 // (admin-actionable, generic for visitors) instead of echoing the raw WP HTTP
11004 // error. http_code 0 = no HTTP response, so the helper uses the generic branch.
11005 return $this->mxchat_friendly_chat_error(0, $response->get_error_message(), 'Gemini');
11006 }
11007
11008 $response_body = json_decode(wp_remote_retrieve_body($response), true);
11009
11010 // Handle potential errors in the response. Gemini surfaces errors as a
11011 // 200/non-200 body with an `error` envelope; route the user-facing text
11012 // through the leak-safe helper (admin-actionable, no visitor leak) rather
11013 // than echoing the raw provider message. plan 5da59a.
11014 if (isset($response_body['error'])) {
11015 //error_log('Gemini API Error: ' . json_encode($response_body['error']));
11016 $gemini_error_message = isset($response_body['error']['message'])
11017 ? $response_body['error']['message']
11018 : 'Unknown error';
11019 $gemini_http_code = wp_remote_retrieve_response_code($response);
11020 return $this->mxchat_friendly_chat_error($gemini_http_code, $gemini_error_message, 'Gemini');
11021 }
11022
11023 // Extract the response text
11024 if (isset($response_body['candidates'][0]['content']['parts'][0]['text'])) {
11025 return trim($response_body['candidates'][0]['content']['parts'][0]['text']);
11026 } else {
11027 //error_log('Unexpected Gemini API response format: ' . json_encode($response_body));
11028 return "Sorry, I couldn't process that request. The response format was unexpected.";
11029 }
11030 }
11031
11032
11033 public function test_streaming_request() {
11034 $options = get_option('mxchat_options', []);
11035 $model = $options['model'] ?? 'gpt-5.1-chat-latest';
11036
11037 // Detect provider from model prefix
11038 $provider = strtolower(explode('-', $model)[0]);
11039
11040 $sample_prompt = 'Hello! Can you stream this response back to me?';
11041 $messages = [['role' => 'user', 'content' => $sample_prompt]];
11042 $headers = [];
11043 $body = [];
11044 $url = '';
11045 $api_key = '';
11046
11047 switch ($provider) {
11048 case 'gpt':
11049 case 'o1':
11050 $api_key = $options['api_key'] ?? '';
11051 if (empty($api_key)) return '❌ Missing API key for OpenAI';
11052 $url = 'https://api.openai.com/v1/chat/completions';
11053 $headers = [
11054 'Content-Type: application/json',
11055 'Authorization: Bearer ' . $api_key
11056 ];
11057 $body = [
11058 'model' => $model,
11059 'messages' => $messages,
11060 'stream' => true
11061 ];
11062 break;
11063
11064 case 'claude':
11065 $api_key = $options['claude_api_key'] ?? '';
11066 if (empty($api_key)) return '❌ Missing API key for Claude';
11067 $url = 'https://api.anthropic.com/v1/messages';
11068 $headers = [
11069 'Content-Type: application/json',
11070 'x-api-key: ' . $api_key,
11071 'anthropic-version: 2023-06-01'
11072 ];
11073 $body = [
11074 'model' => $model,
11075 'messages' => $messages,
11076 'max_tokens' => 100,
11077 'stream' => true
11078 ];
11079 break;
11080
11081 case 'grok':
11082 $api_key = $options['xai_api_key'] ?? '';
11083 if (empty($api_key)) return '❌ Missing API key for X.AI';
11084 $url = 'https://api.x.ai/v1/chat/completions';
11085 $headers = [
11086 'Content-Type: application/json',
11087 'Authorization: Bearer ' . $api_key
11088 ];
11089 $body = [
11090 'model' => $model,
11091 'messages' => $messages,
11092 'stream' => true
11093 ];
11094 break;
11095
11096 case 'deepseek':
11097 if (empty($deepseek_api_key)) {
11098 $error_response = [
11099 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
11100 'error_code' => 'missing_deepseek_api_key'
11101 ];
11102 if ($testing_data !== null) {
11103 $error_response['testing_data'] = $testing_data;
11104 }
11105 return $error_response;
11106 }
11107 if ($streaming) {
11108 return $this->mxchat_generate_response_deepseek_stream(
11109 $selected_model,
11110 $deepseek_api_key,
11111 $conversation_history,
11112 $relevant_content,
11113 $session_id,
11114 $testing_data // Pass testing data
11115 );
11116 } else {
11117 $response = $this->mxchat_generate_response_deepseek(
11118 $selected_model,
11119 $deepseek_api_key,
11120 $conversation_history,
11121 $relevant_content,
11122 $session_id
11123 );
11124 }
11125 break;
11126
11127 case 'gemini':
11128 $api_key = $options['gemini_api_key'] ?? '';
11129 if (empty($api_key)) return '❌ Missing API key for Gemini';
11130 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':streamGenerateContent?key=' . $api_key;
11131 $headers = ['Content-Type: application/json'];
11132 $body = [
11133 'contents' => [['role' => 'user', 'parts' => [['text' => $sample_prompt]]]],
11134 'generationConfig' => ['temperature' => 0.7]
11135 ];
11136 break;
11137
11138 default:
11139 return '❌ Unsupported provider: ' . $provider;
11140 }
11141
11142 // Do the actual streaming test
11143 $ch = curl_init($url);
11144 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
11145 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
11146 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
11147 curl_setopt($ch, CURLOPT_TIMEOUT, 15);
11148 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
11149
11150 $response = curl_exec($ch);
11151 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
11152 $error = curl_error($ch);
11153 curl_close($ch);
11154
11155 if ($error) return "❌ cURL error: $error";
11156 if ($http_code !== 200) {
11157 $error_message = json_decode($response, true)['error']['message'] ?? 'Unknown';
11158 return "❌ HTTP $http_code: $error_message";
11159 }
11160
11161 return true;
11162 }
11163
11164 public function mxchat_dismiss_pre_chat_message() {
11165 // Get and sanitize the user identifier
11166 $user_id = $this->mxchat_get_user_identifier();
11167 $user_id = sanitize_key($user_id);
11168
11169 // Set a transient to track that the user has dismissed the pre-chat message
11170 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
11171 set_transient($transient_key, true, DAY_IN_SECONDS);
11172
11173 wp_send_json_success();
11174 }
11175
11176 public function mxchat_check_pre_chat_message_status() {
11177 // Get and sanitize the user identifier
11178 $user_id = $this->mxchat_get_user_identifier();
11179 $user_id = sanitize_key($user_id);
11180
11181 // Check if the transient exists (i.e., if the message was dismissed)
11182 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
11183 $dismissed = get_transient($transient_key);
11184
11185 // Log the result to see if it's being set correctly
11186 //error_log("Check pre-chat message dismissed for $user_id: " . ($dismissed ? 'Yes' : 'No'));
11187
11188 if ($dismissed) {
11189 wp_send_json_success(['dismissed' => true]);
11190 } else {
11191 wp_send_json_success(['dismissed' => false]);
11192 }
11193
11194 wp_die();
11195 }
11196
11197 private function mxchat_calculate_cosine_similarity($vectorA, $vectorB) {
11198 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
11199 return 0;
11200 }
11201
11202 $dotProduct = array_sum(array_map(function ($a, $b) {
11203 return $a * $b;
11204 }, $vectorA, $vectorB));
11205 $normA = sqrt(array_sum(array_map(function ($a) {
11206 return $a * $a;
11207 }, $vectorA)));
11208 $normB = sqrt(array_sum(array_map(function ($b) {
11209 return $b * $b;
11210 }, $vectorB)));
11211
11212 if ($normA == 0 || $normB == 0) {
11213 return 0;
11214 }
11215
11216 return $dotProduct / ($normA * $normB);
11217 }
11218
11219
11220 public function mxchat_enqueue_scripts_styles() {
11221 // Fetch options from the database first to check loading strategy
11222 $this->options = get_option('mxchat_options');
11223 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
11224
11225 // Always enqueue CSS immediately
11226 wp_enqueue_style(
11227 'mxchat-chat-css',
11228 plugin_dir_url(__FILE__) . '../css/chat-style.css',
11229 array(),
11230 MXCHAT_VERSION
11231 );
11232
11233 // Handle script loading based on strategy
11234 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
11235 // Enqueue the script normally
11236 wp_enqueue_script(
11237 'mxchat-chat-js',
11238 plugin_dir_url(__FILE__) . '../js/chat-script.js',
11239 array('jquery'),
11240 MXCHAT_VERSION,
11241 true
11242 );
11243
11244 // Add defer attribute if strategy is 'defer'
11245 if ($loading_strategy === 'defer') {
11246 wp_script_add_data('mxchat-chat-js', 'strategy', 'defer');
11247 }
11248 } else {
11249 // For delay or interaction-based loading, we'll use a custom loader
11250 // Don't enqueue the main script - we'll load it dynamically
11251 add_action('wp_footer', array($this, 'mxchat_output_delayed_script_loader'), 99);
11252 }
11253
11254 $prompts_options = get_option('mxchat_prompts_options', array());
11255
11256 // Check if AI theme is active - if so, skip inline colors in JavaScript
11257 $theme_options = get_option('mxchat_theme_options', array());
11258 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
11259 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
11260 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
11261
11262 // Prepare settings for JavaScript
11263 $style_settings = array(
11264 'ajax_url' => admin_url('admin-ajax.php'),
11265 // The chat-send nonce is now fetched per-request from /wp-json/mxchat/v1/nonce
11266 // (plan-6a68c9) so it never sits in cached HTML. We still emit a nonce here
11267 // as a one-shot fallback for the first interaction on a fresh page load
11268 // (so the very first chat-send doesn't need to wait for a REST round-trip),
11269 // but the widget refetches before each subsequent send.
11270 'nonce' => wp_create_nonce('mxchat_chat_send'),
11271 'rest_url' => esc_url_raw(trailingslashit(rest_url('mxchat/v1'))),
11272 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
11273 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
11274 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
11275 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
11276 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
11277 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
11278 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
11279 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
11280 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
11281 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
11282 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
11283 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
11284 'icon_color' => $this->options['icon_color'] ?? '#fff',
11285 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
11286 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
11287 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
11288 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
11289 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
11290 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
11291 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
11292 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
11293 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
11294 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off', // FIXED
11295 'initial_email_state' => null, // Also fixed this undefined variable
11296 'skip_email_check' => true,
11297 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
11298 'skip_inline_colors' => $skip_inline_colors,
11299 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array(),
11300 );
11301
11302 // Behavior gates + labels (model, streaming, rate-limit copy, toolbar,
11303 // print/transcript, satisfaction rating) come from the shared
11304 // dynamic-settings method so this inline payload and the first-open
11305 // refresh endpoint can never drift (plan-32db95).
11306 $style_settings = array_merge($style_settings, $this->get_dynamic_widget_settings());
11307
11308 // For normal/defer loading, use wp_localize_script
11309 // For delayed loading, we store settings in a transient to be output inline
11310 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
11311 wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
11312 } else {
11313 // Store settings for the delayed loader to use
11314 set_transient('mxchat_delayed_settings_' . get_current_user_id(), $style_settings, 60);
11315 }
11316 }
11317
11318 /**
11319 * Output the delayed script loader for performance optimization
11320 */
11321 public function mxchat_output_delayed_script_loader() {
11322 $this->options = get_option('mxchat_options');
11323 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
11324 $script_url = plugin_dir_url(__FILE__) . '../js/chat-script.js?ver=' . MXCHAT_VERSION;
11325
11326 // Get the stored settings
11327 $prompts_options = get_option('mxchat_prompts_options', array());
11328 $theme_options = get_option('mxchat_theme_options', array());
11329 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
11330 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
11331 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
11332
11333 $style_settings = array(
11334 'ajax_url' => admin_url('admin-ajax.php'),
11335 // Per-request nonce — see plan-6a68c9; widget fetches via /wp-json/mxchat/v1/nonce
11336 // before each send. This inline value is a one-shot fallback for the first interaction.
11337 'nonce' => wp_create_nonce('mxchat_chat_send'),
11338 'rest_url' => esc_url_raw(trailingslashit(rest_url('mxchat/v1'))),
11339 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
11340 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
11341 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
11342 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
11343 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
11344 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
11345 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
11346 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
11347 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
11348 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
11349 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
11350 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
11351 'icon_color' => $this->options['icon_color'] ?? '#fff',
11352 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
11353 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
11354 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
11355 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
11356 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
11357 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
11358 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
11359 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
11360 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
11361 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off',
11362 'initial_email_state' => null,
11363 'skip_email_check' => true,
11364 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
11365 'skip_inline_colors' => $skip_inline_colors,
11366 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array(),
11367 );
11368
11369 // Behavior gates + labels (model, streaming, rate-limit copy, toolbar,
11370 // print/transcript, satisfaction rating) come from the shared
11371 // dynamic-settings method so this inline payload and the first-open
11372 // refresh endpoint can never drift (plan-32db95).
11373 $style_settings = array_merge($style_settings, $this->get_dynamic_widget_settings());
11374
11375 // Determine delay time based on strategy
11376 $delay_ms = 0;
11377 switch ($loading_strategy) {
11378 case 'delay_1s':
11379 $delay_ms = 1000;
11380 break;
11381 case 'delay_3s':
11382 $delay_ms = 3000;
11383 break;
11384 case 'delay_5s':
11385 $delay_ms = 5000;
11386 break;
11387 }
11388
11389 ?>
11390 <script type="text/javascript">
11391 (function() {
11392 var mxchatLoaded = false;
11393 var mxchatChat = <?php echo wp_json_encode($style_settings); ?>;
11394 window.mxchatChat = mxchatChat;
11395
11396 function loadMxChatScript() {
11397 if (mxchatLoaded) return;
11398 mxchatLoaded = true;
11399
11400 function appendChatScript() {
11401 var script = document.createElement('script');
11402 script.src = <?php echo wp_json_encode($script_url); ?>;
11403 script.type = 'text/javascript';
11404 document.body.appendChild(script);
11405 }
11406
11407 if (typeof jQuery !== 'undefined') {
11408 appendChatScript();
11409 } else {
11410 var jq = document.createElement('script');
11411 jq.src = <?php echo wp_json_encode(includes_url('js/jquery/jquery.min.js')); ?>;
11412 jq.onload = appendChatScript;
11413 document.body.appendChild(jq);
11414 }
11415 }
11416
11417 <?php if ($loading_strategy === 'on_interaction'): ?>
11418 // Load on user interaction
11419 var events = ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'];
11420 events.forEach(function(evt) {
11421 window.addEventListener(evt, loadMxChatScript, {once: true, passive: true});
11422 });
11423 // Fallback: load after 8 seconds if no interaction
11424 setTimeout(loadMxChatScript, 8000);
11425 <?php else: ?>
11426 // Load after specified delay
11427 setTimeout(loadMxChatScript, <?php echo intval($delay_ms); ?>);
11428 <?php endif; ?>
11429 })();
11430 </script>
11431 <?php
11432 }
11433
11434 /**
11435 * Setup the cron jobs for rate limits with guard against multiple calls
11436 */
11437 public function setup_rate_limit_cron_jobs() {
11438 // Add a guard to prevent multiple rapid calls
11439 $last_setup = get_transient('mxchat_cron_setup_guard');
11440 if ($last_setup && (time() - $last_setup) < 60) {
11441 // Don't run again if we ran less than 60 seconds ago
11442 return;
11443 }
11444
11445 // Set the guard
11446 set_transient('mxchat_cron_setup_guard', time(), 300); // 5 minutes
11447
11448 try {
11449 // First, check if WordPress cron is disabled
11450 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
11451 //error_log('MxChat: WordPress cron is disabled (DISABLE_WP_CRON = true), using fallback system');
11452 $this->setup_fallback_rate_limit_system();
11453 return;
11454 }
11455
11456 // Check if cron is already scheduled - if so, don't mess with it
11457 if (wp_next_scheduled('mxchat_reset_rate_limits')) {
11458 //error_log('MxChat: Rate limit cron already scheduled, skipping setup');
11459 return;
11460 }
11461
11462 // Clear any orphaned hooks (but don't loop indefinitely)
11463 $hooks_to_clear = [
11464 'mxchat_reset_rate_limits',
11465 'mxchat_reset_hourly_rate_limits',
11466 'mxchat_reset_daily_rate_limits',
11467 'mxchat_reset_weekly_rate_limits',
11468 'mxchat_reset_monthly_rate_limits'
11469 ];
11470
11471 foreach ($hooks_to_clear as $hook) {
11472 // Only clear a maximum of 3 instances to prevent infinite loops
11473 $cleared = 0;
11474 while (wp_next_scheduled($hook) && $cleared < 3) {
11475 wp_clear_scheduled_hook($hook);
11476 $cleared++;
11477 }
11478 }
11479
11480 // Small delay after clearing
11481 usleep(100000); // 0.1 seconds
11482
11483 // Try to schedule the event
11484 $initial_time = time() + 300; // Start in 5 minutes
11485 $result = wp_schedule_event($initial_time, 'hourly', 'mxchat_reset_rate_limits');
11486
11487 if ($result === false) {
11488 //error_log('MxChat: Failed to schedule cron, using fallback system');
11489 $this->setup_fallback_rate_limit_system();
11490 } else {
11491 //error_log('MxChat: Successfully scheduled rate limit reset cron');
11492 }
11493
11494 } catch (Exception $e) {
11495 //error_log('MxChat: Cron setup exception: ' . $e->getMessage());
11496 $this->setup_fallback_rate_limit_system();
11497 }
11498 }
11499
11500 /**
11501 * Try alternative cron scheduling methods
11502 */
11503 private function try_alternative_cron_scheduling($initial_time) {
11504 try {
11505 // Method 1: Try with current time instead of future time
11506 $result1 = wp_schedule_event(time(), 'hourly', 'mxchat_reset_rate_limits');
11507 if ($result1 !== false) {
11508 //error_log('MxChat: Alternative method 1 (current time) succeeded');
11509 return true;
11510 }
11511
11512 // Method 2: Try with a different interval
11513 $result2 = wp_schedule_event($initial_time, 'daily', 'mxchat_reset_rate_limits');
11514 if ($result2 !== false) {
11515 //error_log('MxChat: Alternative method 2 (daily interval) succeeded');
11516 return true;
11517 }
11518
11519 // Method 3: Try wp_schedule_single_event first, then recurring
11520 $result3 = wp_schedule_single_event($initial_time, 'mxchat_reset_rate_limits');
11521 if ($result3 !== false) {
11522 //error_log('MxChat: Alternative method 3 (single event) succeeded');
11523 // Schedule the next one manually in the handler
11524 return true;
11525 }
11526
11527 return false;
11528
11529 } catch (Exception $e) {
11530 //error_log('MxChat: Alternative cron scheduling exception: ' . $e->getMessage());
11531 return false;
11532 }
11533 }
11534
11535 /**
11536 * Enhanced fallback rate limit system
11537 */
11538 private function setup_fallback_rate_limit_system() {
11539 // Set a flag to use database-based rate limit cleanup
11540 update_option('mxchat_use_fallback_rate_limits', true);
11541
11542 // Schedule a one-time check to happen on the next plugin load
11543 update_option('mxchat_next_rate_limit_check', time() + 3600);
11544
11545 // Also set up a more frequent fallback check (every 4 hours)
11546 update_option('mxchat_fallback_check_interval', 4 * 3600);
11547
11548 //error_log('MxChat: Fallback rate limit system activated');
11549 }
11550
11551 /**
11552 * Enhanced fallback check method
11553 */
11554 public function check_fallback_rate_limits() {
11555 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
11556
11557 if (!$use_fallback) {
11558 return; // Regular cron is working
11559 }
11560
11561 $next_check = get_option('mxchat_next_rate_limit_check', 0);
11562 $check_interval = get_option('mxchat_fallback_check_interval', 3600);
11563
11564 if (time() >= $next_check) {
11565 //error_log('MxChat: Running fallback rate limit cleanup');
11566 $this->mxchat_reset_rate_limits();
11567
11568 // Schedule next check
11569 update_option('mxchat_next_rate_limit_check', time() + $check_interval);
11570 }
11571 }
11572 /**
11573 * Enhanced rate limit check that includes fallback cleanup and bot-specific rate limits
11574 */
11575 public function check_rate_limit() {
11576 // Check if we need to run fallback cleanup
11577 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
11578 $next_check = get_option('mxchat_next_rate_limit_check', 0);
11579
11580 if ($use_fallback && time() >= $next_check) {
11581 $this->mxchat_reset_rate_limits();
11582 update_option('mxchat_next_rate_limit_check', time() + 3600); // Next hour
11583 }
11584
11585 // Get bot ID from current request context
11586 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
11587
11588 // Get bot-specific options (includes rate limits if overridden)
11589 $bot_options = $this->get_bot_options($bot_id);
11590 $current_options = !empty($bot_options) ? $bot_options : $this->options;
11591
11592 // Use bot-specific rate limits if available, otherwise fall back to default
11593 $rate_limits_source = isset($current_options['rate_limits']) ? $current_options['rate_limits'] : get_option('mxchat_options', [])['rate_limits'] ?? [];
11594
11595 // -------------------------------------------------------------------
11596 // Whole-chatbot global cap (independent of role). Evaluated FIRST so
11597 // it acts as a hard ceiling across all users + all roles. Default is
11598 // 'unlimited' so existing installs are unchanged. Counter key drops
11599 // both <role> and <user_id> segments — single pool per bot.
11600 // -------------------------------------------------------------------
11601 $global_cfg = isset($current_options['rate_limits_global']) && is_array($current_options['rate_limits_global'])
11602 ? $current_options['rate_limits_global']
11603 : (isset(get_option('mxchat_options', [])['rate_limits_global']) ? get_option('mxchat_options', [])['rate_limits_global'] : []);
11604 $global_limit_raw = isset($global_cfg['limit']) ? (string) $global_cfg['limit'] : 'unlimited';
11605 $global_timeframe = isset($global_cfg['timeframe']) ? (string) $global_cfg['timeframe'] : 'daily';
11606 if ($global_limit_raw !== '' && $global_limit_raw !== 'unlimited' && (int) $global_limit_raw >= 1) {
11607 $bot_id_for_global = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
11608 $safe_bot_global = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id_for_global);
11609 $global_option = 'mxchat_chat_limit_' . $safe_bot_global . '_global';
11610 $global_data = get_option($global_option, ['count' => 0, 'timestamp' => time()]);
11611 if ((int) $global_data['count'] === 0) {
11612 $global_data['timestamp'] = time();
11613 update_option($global_option, $global_data);
11614 }
11615 $now = time();
11616 $ts = (int) $global_data['timestamp'];
11617 $reset = false;
11618 switch ($global_timeframe) {
11619 case 'hourly': $reset = ($now - $ts) >= 3600; break;
11620 case 'daily': $reset = ($now - $ts) >= 86400; break;
11621 case 'weekly': $reset = ($now - $ts) >= 604800; break;
11622 case 'monthly': $reset = ($now - $ts) >= 2592000; break;
11623 }
11624 if ($reset) {
11625 $global_data = ['count' => 0, 'timestamp' => $now];
11626 update_option($global_option, $global_data);
11627 }
11628 if ((int) $global_data['count'] >= (int) $global_limit_raw) {
11629 $global_msg = !empty($global_cfg['message'])
11630 ? $global_cfg['message']
11631 : __('This chatbot has reached its message limit. Please try again later.', 'mxchat');
11632 return [
11633 'error' => true,
11634 'message' => $this->process_rate_limit_message_html($global_msg),
11635 ];
11636 }
11637 // Reserve the slot for this request. Per-role check below also increments
11638 // its own counter — that is intentional, both ceilings apply independently.
11639 $global_data['count']++;
11640 update_option($global_option, $global_data);
11641 }
11642
11643 // Determine user role or if logged out
11644 if (is_user_logged_in()) {
11645 $user = wp_get_current_user();
11646 $user_id = $user->ID;
11647
11648 // Get the user's primary role using reset() to safely get the first element
11649 $user_roles = $user->roles;
11650
11651 // Safely get the first role regardless of array key structure
11652 if (!empty($user_roles) && is_array($user_roles)) {
11653 $role = reset($user_roles); // This safely gets the first element regardless of key
11654 } else {
11655 $role = 'subscriber'; // Default to subscriber if no role found
11656 }
11657 } else {
11658 $role = 'logged_out';
11659 // Use IP address for non-logged-in users
11660 $user_id = $this->get_client_ip();
11661 }
11662
11663 // Check if rate limits are configured for this role
11664 if (!isset($rate_limits_source[$role])) {
11665 return true; // No limit set for this role
11666 }
11667
11668 $limit = $rate_limits_source[$role]['limit'];
11669
11670 // If unlimited, return true immediately
11671 if ($limit === 'unlimited') {
11672 return true;
11673 }
11674
11675 // Get the option name for this user/role with safer naming (include bot_id for bot-specific limits)
11676 $safe_role = preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
11677 $safe_user_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $user_id);
11678 $safe_bot_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id);
11679
11680 // Include bot_id in option name so each bot has separate rate limits
11681 $option_name = 'mxchat_chat_limit_' . $safe_bot_id . '_' . $safe_role . '_' . $safe_user_id;
11682
11683 // Get the counter data
11684 $limit_data = get_option($option_name, ['count' => 0, 'timestamp' => time()]);
11685
11686 // If first request or counter reset needed, set the initial timestamp
11687 if ($limit_data['count'] === 0) {
11688 $limit_data['timestamp'] = time();
11689 update_option($option_name, $limit_data);
11690 }
11691
11692 // Get the timeframe
11693 $timeframe = isset($rate_limits_source[$role]['timeframe']) ?
11694 $rate_limits_source[$role]['timeframe'] : 'daily';
11695
11696 // Check if the counter needs to be reset based on timeframe
11697 $current_time = time();
11698 $timestamp = $limit_data['timestamp'];
11699 $should_reset = false;
11700
11701 switch ($timeframe) {
11702 case 'hourly':
11703 $should_reset = ($current_time - $timestamp) >= 3600; // 1 hour
11704 break;
11705 case 'daily':
11706 $should_reset = ($current_time - $timestamp) >= 86400; // 24 hours
11707 break;
11708 case 'weekly':
11709 $should_reset = ($current_time - $timestamp) >= 604800; // 7 days
11710 break;
11711 case 'monthly':
11712 $should_reset = ($current_time - $timestamp) >= 2592000; // 30 days
11713 break;
11714 }
11715
11716 // Reset the counter if the timeframe has passed
11717 if ($should_reset) {
11718 $limit_data = ['count' => 0, 'timestamp' => $current_time];
11719 update_option($option_name, $limit_data);
11720 }
11721
11722 // Check if user has exceeded their limit
11723 if ($limit_data['count'] >= intval($limit)) {
11724 // Get the custom message for this role
11725 $message = !empty($rate_limits_source[$role]['message'])
11726 ? $rate_limits_source[$role]['message']
11727 : __('Rate limit exceeded. Please try again later.', 'mxchat');
11728
11729 // Add timeframe information to the message if placeholders exist
11730 $timeframe_label = '';
11731 switch ($timeframe) {
11732 case 'hourly':
11733 $timeframe_label = __('hour', 'mxchat');
11734 break;
11735 case 'daily':
11736 $timeframe_label = __('day', 'mxchat');
11737 break;
11738 case 'weekly':
11739 $timeframe_label = __('week', 'mxchat');
11740 break;
11741 case 'monthly':
11742 $timeframe_label = __('month', 'mxchat');
11743 break;
11744 }
11745
11746 // Replace placeholders in the message
11747 $message = str_replace(
11748 ['{limit}', '{count}', '{remaining}', '{timeframe}'],
11749 [intval($limit), $limit_data['count'], max(0, intval($limit) - $limit_data['count']), $timeframe_label],
11750 $message
11751 );
11752
11753 // Process HTML links in the message
11754 $message = $this->process_rate_limit_message_html($message);
11755
11756 // Return error with the processed message
11757 return [
11758 'error' => true,
11759 'message' => $message
11760 ];
11761 }
11762
11763 // Increment the counter
11764 $limit_data['count']++;
11765 update_option($option_name, $limit_data);
11766
11767 return true;
11768 }
11769
11770 /**
11771 * Enhanced rate limit reset with better error handling
11772 */
11773 public function mxchat_reset_rate_limits() {
11774 try {
11775 global $wpdb;
11776 $all_options = get_option('mxchat_options', []);
11777 $current_time = time();
11778
11779 // Get rate limit options with a safer query and limit
11780 $option_names = $wpdb->get_col(
11781 $wpdb->prepare(
11782 "SELECT option_name FROM {$wpdb->options}
11783 WHERE option_name LIKE %s
11784 LIMIT 1000",
11785 'mxchat_chat_limit_%'
11786 )
11787 );
11788
11789 if (empty($option_names)) {
11790 return;
11791 }
11792
11793 $processed_count = 0;
11794 $max_processing_time = 30; // Maximum 30 seconds
11795 $start_time = time();
11796
11797 foreach ($option_names as $option_name) {
11798 // Check processing time limit
11799 if ((time() - $start_time) > $max_processing_time) {
11800 //error_log('MxChat: Rate limit reset timeout after processing ' . $processed_count . ' entries');
11801 break;
11802 }
11803
11804 // Parse the option name more safely
11805 if (!preg_match('/^mxchat_chat_limit_(.+)_(.+)$/', $option_name, $matches)) {
11806 continue;
11807 }
11808
11809 $role_and_user = $matches[1] . '_' . $matches[2];
11810 $parts = explode('_', $role_and_user);
11811
11812 if (count($parts) < 2) {
11813 continue;
11814 }
11815
11816 // Extract role (everything except the last part which is user ID)
11817 $user_id_part = array_pop($parts);
11818 $role = implode('_', $parts);
11819
11820 // Skip if role doesn't exist in our settings
11821 if (!isset($all_options['rate_limits'][$role])) {
11822 // Clean up orphaned entries
11823 delete_option($option_name);
11824 continue;
11825 }
11826
11827 $timeframe = $all_options['rate_limits'][$role]['timeframe'] ?? 'daily';
11828 $limit_data = get_option($option_name);
11829
11830 if (!$limit_data || !is_array($limit_data) || !isset($limit_data['timestamp'])) {
11831 // Clean up invalid entries
11832 delete_option($option_name);
11833 continue;
11834 }
11835
11836 $timestamp = $limit_data['timestamp'];
11837 $should_reset = false;
11838
11839 // Determine if we should reset based on the timeframe
11840 switch ($timeframe) {
11841 case 'hourly':
11842 $should_reset = ($current_time - $timestamp) >= 3600;
11843 break;
11844 case 'daily':
11845 $should_reset = ($current_time - $timestamp) >= 86400;
11846 break;
11847 case 'weekly':
11848 $should_reset = ($current_time - $timestamp) >= 604800;
11849 break;
11850 case 'monthly':
11851 $should_reset = ($current_time - $timestamp) >= 2592000;
11852 break;
11853 }
11854
11855 // Reset the counter if the timeframe has passed
11856 if ($should_reset) {
11857 delete_option($option_name);
11858 wp_cache_delete($option_name, 'options');
11859 $processed_count++;
11860 }
11861 }
11862
11863 // Clean up any orphaned cache entries
11864 wp_cache_delete('mxchat_all_chat_limits', 'options');
11865
11866 //error_log("MxChat: Rate limit reset completed. Processed {$processed_count} entries.");
11867
11868 } catch (Exception $e) {
11869 //error_log('MxChat: Rate limit reset error: ' . $e->getMessage());
11870 }
11871 }
11872
11873
11874 /**
11875 * Process HTML links in rate limit messages
11876 *
11877 * @param string $message The rate limit message
11878 * @return string The processed message with safe HTML links
11879 */
11880 private function process_rate_limit_message_html($message) {
11881 // Return original message if empty
11882 if (empty($message)) {
11883 return $message;
11884 }
11885
11886 // First, convert markdown links to HTML
11887 $message = $this->convert_markdown_links($message);
11888
11889 // Then, auto-convert any remaining plain URLs to links
11890 $message = $this->auto_link_urls($message);
11891
11892 // Allow basic HTML tags for links and formatting
11893 $allowed_tags = [
11894 'a' => [
11895 'href' => true,
11896 'target' => true,
11897 'rel' => true,
11898 'title' => true,
11899 'class' => true
11900 ],
11901 'strong' => [],
11902 'em' => [],
11903 'br' => [],
11904 'b' => [],
11905 'i' => [],
11906 'span' => ['class' => true]
11907 ];
11908
11909 // Sanitize but allow the specified HTML tags
11910 $processed_message = wp_kses($message, $allowed_tags);
11911
11912 // If wp_kses stripped everything, return the original message as plain text
11913 if (empty($processed_message) && !empty($message)) {
11914 // Strip all HTML and return plain text as fallback
11915 return wp_strip_all_tags($message);
11916 }
11917
11918 return $processed_message;
11919 }
11920
11921 /**
11922 * Convert markdown links to HTML
11923 *
11924 * @param string $text The text to process
11925 * @return string The text with markdown links converted to HTML
11926 */
11927 private function convert_markdown_links($text) {
11928 // Return original text if empty
11929 if (empty($text)) {
11930 return $text;
11931 }
11932
11933 // Pattern to match markdown links: [text](url)
11934 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
11935
11936 $processed_text = preg_replace_callback($pattern, function($matches) {
11937 $link_text = $matches[1];
11938 $url = $matches[2];
11939
11940 // Clean up any trailing punctuation from the URL
11941 $url = rtrim($url, '.,;:!?');
11942
11943 // Sanitize the link text and URL
11944 $safe_text = esc_html($link_text);
11945 $safe_url = esc_url($url);
11946
11947 // Create the HTML link
11948 return '<a href="' . $safe_url . '" target="_blank" rel="noopener noreferrer">' . $safe_text . '</a>';
11949 }, $text);
11950
11951 // If preg_replace_callback failed, return original text
11952 if ($processed_text === null) {
11953 return $text;
11954 }
11955
11956 return $processed_text;
11957 }
11958
11959 /**
11960 * Auto-convert plain URLs to clickable links
11961 *
11962 * @param string $text The text to process
11963 * @return string The text with URLs converted to links
11964 */
11965 private function auto_link_urls($text) {
11966 // Return original text if empty
11967 if (empty($text)) {
11968 return $text;
11969 }
11970
11971 // Simple pattern that avoids complex lookbehinds
11972 // This will match URLs that are not already inside href attributes or markdown links
11973 $pattern = '/(?<!href=["\'])(?<!\]\()https?:\/\/[^\s<>"\')\]]+/i';
11974
11975 $processed_text = preg_replace_callback($pattern, function($matches) {
11976 $url = $matches[0];
11977 // Clean up any trailing punctuation that might have been captured
11978 $url = rtrim($url, '.,;:!?');
11979
11980 // Add target="_blank" and rel="noopener noreferrer" for security
11981 return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
11982 }, $text);
11983
11984 // If preg_replace_callback failed, return original text
11985 if ($processed_text === null) {
11986 return $text;
11987 }
11988
11989 return $processed_text;
11990 }
11991
11992
11993 // Helper function to get client IP address
11994 private function get_client_ip() {
11995 // Check for shared internet/ISP IP
11996 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
11997 return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
11998 }
11999
12000 // Check for IPs passing through proxies
12001 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
12002 // Use the first value in the comma-separated list
12003 $forwarded_for = explode(',', sanitize_text_field($_SERVER['HTTP_X_FORWARDED_FOR']));
12004 return trim($forwarded_for[0]);
12005 }
12006
12007 if (!empty($_SERVER['REMOTE_ADDR'])) {
12008 return sanitize_text_field($_SERVER['REMOTE_ADDR']);
12009 }
12010
12011 // Fallback
12012 return 'unknown';
12013 }
12014
12015 /**
12016 * AJAX handler to get system information for testing panel
12017 */
12018 /**
12019 * AJAX handler to get system information for testing panel
12020 */
12021 public function mxchat_get_system_info() {
12022 // Verify nonce for security
12023 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
12024 wp_send_json_error(['message' => 'Invalid nonce']);
12025 return;
12026 }
12027
12028 // Only allow admin users
12029 if (!current_user_can('administrator')) {
12030 wp_send_json_error(['message' => 'Unauthorized']);
12031 return;
12032 }
12033
12034 // Get system prompt from options
12035 $system_prompt = isset($this->options['system_prompt_instructions'])
12036 ? $this->options['system_prompt_instructions']
12037 : 'No system prompt configured';
12038
12039 // Get selected model
12040 $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest';
12041
12042 // Check if OpenRouter is being used
12043 $is_openrouter = ($selected_model === 'openrouter');
12044 $openrouter_model = '';
12045
12046 if ($is_openrouter) {
12047 // Get the actual OpenRouter model that's selected
12048 $openrouter_model = isset($this->options['openrouter_selected_model'])
12049 ? $this->options['openrouter_selected_model']
12050 : 'No OpenRouter model selected';
12051
12052 // Update selected_model display to show both
12053 $selected_model = 'OpenRouter: ' . $openrouter_model;
12054 }
12055
12056 // Get API key status (just check if they exist, don't expose the keys)
12057 $api_status = [];
12058 $api_status['openai'] = !empty($this->options['api_key']);
12059 $api_status['claude'] = !empty($this->options['claude_api_key']);
12060 $api_status['gemini'] = !empty($this->options['gemini_api_key']);
12061 $api_status['xai'] = !empty($this->options['xai_api_key']);
12062 $api_status['deepseek'] = !empty($this->options['deepseek_api_key']);
12063 $api_status['openrouter'] = !empty($this->options['openrouter_api_key']);
12064
12065 wp_send_json_success([
12066 'system_prompt' => $system_prompt,
12067 'selected_model' => $selected_model,
12068 'is_openrouter' => $is_openrouter,
12069 'openrouter_model' => $openrouter_model,
12070 'api_status' => $api_status
12071 ]);
12072 }
12073
12074 /**
12075 * AJAX handler to get similarity threshold
12076 */
12077 public function mxchat_get_similarity_threshold() {
12078 // Verify nonce for security
12079 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
12080 wp_send_json_error(['message' => 'Invalid nonce']);
12081 return;
12082 }
12083
12084 // Only allow admin users
12085 if (!current_user_can('administrator')) {
12086 wp_send_json_error(['message' => 'Unauthorized']);
12087 return;
12088 }
12089
12090 // Get similarity threshold from main options (default 35%)
12091 $similarity_threshold = isset($this->options['similarity_threshold'])
12092 ? ((int) $this->options['similarity_threshold']) / 100
12093 : 0.35;
12094
12095 wp_send_json_success([
12096 'threshold' => $similarity_threshold,
12097 'threshold_percentage' => ($similarity_threshold * 100) . '%'
12098 ]);
12099 }
12100
12101 /**
12102 * AJAX handler to get knowledge base status
12103 */
12104 public function mxchat_get_kb_status() {
12105 // Verify nonce for security
12106 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
12107 wp_send_json_error(['message' => 'Invalid nonce']);
12108 return;
12109 }
12110
12111 // Only allow admin users
12112 if (!current_user_can('administrator')) {
12113 wp_send_json_error(['message' => 'Unauthorized']);
12114 return;
12115 }
12116
12117 // Check OpenAI Vector Store first (takes priority)
12118 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
12119 $use_vectorstore = (isset($vectorstore_options['mxchat_use_openai_vectorstore']) && $vectorstore_options['mxchat_use_openai_vectorstore'] === '1');
12120
12121 if ($use_vectorstore) {
12122 $vectorstore_ids = $vectorstore_options['mxchat_openai_vectorstore_ids'] ?? '';
12123 $id_count = !empty($vectorstore_ids) ? count(array_filter(array_map('trim', explode(',', $vectorstore_ids)))) : 0;
12124
12125 $kb_info = [
12126 'type' => 'OpenAI Vector Store',
12127 'status' => 'Active',
12128 'documents' => $id_count > 0 ? $id_count . ' vector store' . ($id_count > 1 ? 's' : '') . ' configured' : 'No vector stores configured'
12129 ];
12130
12131 wp_send_json_success($kb_info);
12132 return;
12133 }
12134
12135 // Check Pinecone vs WordPress
12136 $addon_options = get_option('mxchat_pinecone_addon_options', array());
12137 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
12138
12139 $kb_info = [
12140 'type' => $use_pinecone ? 'Pinecone' : 'WordPress Database',
12141 'status' => 'Active'
12142 ];
12143
12144 // Get document count
12145 if ($use_pinecone) {
12146 $kb_info['documents'] = 'Connected to Pinecone';
12147 $kb_info['api_configured'] = !empty($addon_options['mxchat_pinecone_api_key']);
12148 } else {
12149 // Count documents in WordPress database
12150 global $wpdb;
12151 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
12152 $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
12153 $kb_info['documents'] = $count ? $count . ' documents' : 'No documents';
12154 }
12155
12156 wp_send_json_success($kb_info);
12157 }
12158
12159 /**
12160 * AJAX handler to start a completely fresh session (NEW - replaces old clear session)
12161 */
12162 public function mxchat_start_fresh_session() {
12163 // Verify nonce for security
12164 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
12165 wp_send_json_error(['message' => 'Invalid nonce']);
12166 return;
12167 }
12168
12169 // Only allow admin users
12170 if (!current_user_can('administrator')) {
12171 wp_send_json_error(['message' => 'Unauthorized']);
12172 return;
12173 }
12174
12175 $old_session_id = isset($_POST['old_session_id']) ? sanitize_text_field($_POST['old_session_id']) : '';
12176 $new_session_id = isset($_POST['new_session_id']) ? sanitize_text_field($_POST['new_session_id']) : '';
12177
12178 if (empty($old_session_id)) {
12179 wp_send_json_error(['message' => 'Old session ID required']);
12180 return;
12181 }
12182
12183 // If no new session ID provided, generate one
12184 if (empty($new_session_id)) {
12185 $new_session_id = 'mxchat_chat_' . substr(md5(uniqid()), 0, 9);
12186 }
12187
12188 // Clear ALL data associated with the old session
12189 $this->clear_complete_session_data($old_session_id);
12190
12191 // Initialize the new session
12192 $this->initialize_fresh_session($new_session_id);
12193
12194 wp_send_json_success([
12195 'message' => 'Fresh session started successfully',
12196 'new_session_id' => $new_session_id,
12197 'old_session_id' => $old_session_id
12198 ]);
12199 }
12200
12201 /**
12202 * Clear ALL data associated with a session (ENHANCED)
12203 */
12204 private function clear_complete_session_data($session_id) {
12205 // Clear chat history
12206 delete_option("mxchat_history_{$session_id}");
12207
12208 // Clear chat mode
12209 delete_option("mxchat_mode_{$session_id}");
12210
12211 // Clear any PDF/Word transients
12212 $this->clear_pdf_transients($session_id);
12213 if (method_exists($this, 'clear_word_transients')) {
12214 $this->clear_word_transients($session_id);
12215 }
12216
12217 // Clear agent-related data
12218 delete_option("mxchat_channel_{$session_id}");
12219 delete_option("mxchat_agent_name_{$session_id}");
12220 delete_option("mxchat_email_{$session_id}");
12221
12222 // Clear any recommendation flow state
12223 delete_option("mxchat_sr_flow_state_{$session_id}");
12224
12225 // Clear any cached embeddings or context
12226 delete_transient("mxchat_context_{$session_id}");
12227 delete_transient("mxchat_last_query_{$session_id}");
12228
12229 // Clear any testing data
12230 delete_transient("mxchat_testing_data_{$session_id}");
12231
12232 // Clear any rate limiting data for this session
12233 delete_transient("mxchat_rate_limit_{$session_id}");
12234
12235 // Clear any other session-specific transients
12236 delete_transient("mxchat_waiting_for_pdf_url_{$session_id}");
12237 delete_transient("mxchat_include_pdf_in_context_{$session_id}");
12238 delete_transient("mxchat_include_word_in_context_{$session_id}");
12239
12240 // Clear form addon state (pending forms and submitted forms)
12241 delete_option("mxchat_pending_form_{$session_id}");
12242 delete_option("mxchat_submitted_forms_{$session_id}");
12243
12244 //error_log("MxChat: Cleared all data for session: {$session_id}");
12245 }
12246
12247 /**
12248 * Initialize a fresh session with default data
12249 */
12250 private function initialize_fresh_session($session_id) {
12251 // Set default chat mode
12252 update_option("mxchat_mode_{$session_id}", 'ai');
12253
12254 //error_log("MxChat: Initialized fresh session: {$session_id}");
12255 }
12256
12257 /**
12258 * Helper method to clear Word document transients (if you have Word support)
12259 */
12260 private function clear_word_transients($session_id) {
12261 delete_transient('mxchat_word_url_' . $session_id);
12262 delete_transient('mxchat_word_filename_' . $session_id);
12263 delete_transient('mxchat_word_embeddings_' . $session_id);
12264 delete_transient('mxchat_include_word_in_context_' . $session_id);
12265 }
12266
12267 /**
12268 * Simplified testing data capture method (CLEANED UP)
12269 */
12270 private function capture_testing_data($user_embedding, $message, $session_id) {
12271 // Only capture for admin users
12272 if (!current_user_can('administrator')) {
12273 return null;
12274 }
12275
12276 $testing_data = [
12277 'query' => $message,
12278 'timestamp' => time(),
12279 'top_matches' => [],
12280 'action_matches' => [] // Add action matches
12281 ];
12282
12283 // Get similarity threshold
12284 $similarity_threshold = isset($this->options['similarity_threshold'])
12285 ? ((int) $this->options['similarity_threshold']) / 100
12286 : 0.35;
12287
12288 $testing_data['similarity_threshold'] = $similarity_threshold;
12289
12290 // Use the real similarity analysis if available
12291 if ($this->last_similarity_analysis !== null) {
12292 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
12293 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
12294 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
12295 } else {
12296 // Fallback: determine knowledge base type
12297 $addon_options = get_option('mxchat_pinecone_addon_options', array());
12298 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
12299
12300 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
12301 }
12302
12303 // Include action analysis if available
12304 if (isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
12305 $testing_data['action_matches'] = $this->last_action_analysis;
12306
12307 // Clear it after capturing to avoid stale data
12308 $this->last_action_analysis = null;
12309 }
12310
12311 return $testing_data;
12312 }
12313
12314
12315 /**
12316 * Track URL clicks from chatbot responses
12317 */
12318 public function mxchat_track_url_click() {
12319 // Verify nonce for security
12320 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
12321 wp_send_json_error(['message' => 'Invalid nonce']);
12322 wp_die();
12323 }
12324
12325 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
12326 $clicked_url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
12327 $message_context = isset($_POST['message_context']) ? sanitize_textarea_field($_POST['message_context']) : '';
12328
12329 if (empty($session_id) || empty($clicked_url)) {
12330 wp_send_json_error(['message' => 'Missing required data']);
12331 wp_die();
12332 }
12333
12334 global $wpdb;
12335 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
12336
12337 // Insert click tracking record
12338 $wpdb->insert(
12339 $table_name,
12340 [
12341 'session_id' => $session_id,
12342 'clicked_url' => $clicked_url,
12343 'message_context' => $message_context,
12344 'click_timestamp' => current_time('mysql', 1),
12345 'user_ip' => $_SERVER['REMOTE_ADDR'],
12346 'user_agent' => $_SERVER['HTTP_USER_AGENT']
12347 ]
12348 );
12349
12350 wp_send_json_success(['message' => 'Click tracked']);
12351 wp_die();
12352 }
12353
12354 /**
12355 * Get URL click analytics for a session
12356 */
12357 public function mxchat_get_url_clicks($session_id) {
12358 global $wpdb;
12359 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
12360
12361 $clicks = $wpdb->get_results($wpdb->prepare(
12362 "SELECT * FROM $table_name WHERE session_id = %s ORDER BY click_timestamp ASC",
12363 $session_id
12364 ));
12365
12366 return $clicks;
12367 }
12368 /**
12369 * Track the originating page where chat was started
12370 */
12371 public function mxchat_track_originating_page() {
12372 // Verify nonce
12373 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
12374 wp_send_json_error(['message' => 'Invalid nonce']);
12375 wp_die();
12376 }
12377
12378 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
12379 $page_url = isset($_POST['page_url']) ? esc_url_raw($_POST['page_url']) : '';
12380 $page_title = isset($_POST['page_title']) ? sanitize_text_field($_POST['page_title']) : '';
12381
12382 if (empty($session_id)) {
12383 wp_send_json_error(['message' => 'Missing session ID']);
12384 wp_die();
12385 }
12386
12387 global $wpdb;
12388 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
12389
12390 // Check if we've already tracked for this session
12391 $existing = $wpdb->get_var($wpdb->prepare(
12392 "SELECT COUNT(*) FROM $table_name
12393 WHERE session_id = %s
12394 AND originating_page_url IS NOT NULL",
12395 $session_id
12396 ));
12397
12398 if ($existing > 0) {
12399 wp_send_json_success(['message' => 'Already tracked']);
12400 wp_die();
12401 }
12402
12403 // Update the first message in this session with originating page info
12404 $wpdb->query($wpdb->prepare(
12405 "UPDATE $table_name
12406 SET originating_page_url = %s,
12407 originating_page_title = %s
12408 WHERE session_id = %s
12409 ORDER BY timestamp ASC
12410 LIMIT 1",
12411 $page_url,
12412 $page_title,
12413 $session_id
12414 ));
12415
12416 wp_send_json_success(['message' => 'Originating page tracked']);
12417 wp_die();
12418 }
12419
12420 /**
12421 * Validate and clean URLs from AI response
12422 * Removes any URLs that aren't in the knowledge base
12423 *
12424 * @param string $response_text The AI-generated response
12425 * @param array $valid_urls Array of URLs from the knowledge base
12426 * @return string Cleaned response with invalid URLs removed/flagged
12427 */
12428 private function validate_and_clean_urls($response_text, $valid_urls) {
12429 // DEBUG: Log what we're working with
12430 //error_log("=== MxChat URL Validation Debug ===");
12431 //error_log("Valid URLs count: " . count($valid_urls));
12432 //error_log("Valid URLs: " . print_r($valid_urls, true));
12433 //error_log("Response text length: " . strlen($response_text));
12434 //error_log("Response text preview: " . substr($response_text, 0, 500));
12435
12436 // If no valid URLs provided or empty response, return as-is
12437 if (empty($valid_urls) || empty($response_text)) {
12438 //error_log("Validation skipped - empty valid_urls or response");
12439 return $response_text;
12440 }
12441
12442 // Extract all URLs from the AI response
12443 // This regex matches http:// and https:// URLs
12444 preg_match_all(
12445 '#\bhttps?://[^\s<>"\')\]]+#i',
12446 $response_text,
12447 $matches
12448 );
12449
12450 // If no URLs found in response, return as-is
12451 if (empty($matches[0])) {
12452 //error_log("No URLs found in response");
12453 return $response_text;
12454 }
12455
12456 $found_urls = $matches[0];
12457 $cleaned_response = $response_text;
12458 $removed_count = 0;
12459
12460 // Normalize valid URLs for comparison (remove trailing slashes, fragments, etc.)
12461 $normalized_valid_urls = array_map(function($url) {
12462 // Remove trailing slash
12463 $url = rtrim($url, '/');
12464 // Remove URL fragments (#section)
12465 $url = preg_replace('/#.*$/', '', $url);
12466 // Remove trailing punctuation that might have been captured
12467 $url = rtrim($url, '.,;:!?');
12468 return $url;
12469 }, $valid_urls);
12470
12471 //error_log("Normalized valid URLs: " . print_r($normalized_valid_urls, true));
12472
12473 foreach ($found_urls as $found_url) {
12474 // Clean up the found URL (remove trailing punctuation that might have been captured)
12475 $clean_found_url = rtrim($found_url, '.,;:!?)');
12476
12477 // DEBUG: Log each URL being checked
12478 //error_log("Checking found URL: " . $found_url);
12479
12480 // Normalize for comparison
12481 $normalized_found = rtrim($clean_found_url, '/');
12482 $normalized_found = preg_replace('/#.*$/', '', $normalized_found);
12483
12484 //error_log("Normalized found URL: " . $normalized_found);
12485
12486 // Check if this URL exists in our valid URLs list
12487 $is_valid = false;
12488
12489 //error_log("Starting validation checks for: " . $normalized_found);
12490
12491 // First, try exact match
12492 if (in_array($normalized_found, $normalized_valid_urls)) {
12493 $is_valid = true;
12494 //error_log("EXACT MATCH FOUND");
12495 } else {
12496 //error_log("No exact match, checking variations...");
12497 // If no exact match, check if it's a variation (with query params, etc.)
12498 foreach ($normalized_valid_urls as $valid_url) {
12499 //error_log(" Comparing against valid URL: " . $valid_url);
12500
12501 // Check if the found URL starts with a valid URL (handles query params)
12502 if (strpos($normalized_found, $valid_url) === 0) {
12503 // Check what comes after the valid URL
12504 $remainder = substr($normalized_found, strlen($valid_url));
12505
12506 // Only valid if:
12507 // 1. Exact match (remainder is empty)
12508 // 2. Query params (starts with ?)
12509 // 3. Fragment (starts with #)
12510 if (empty($remainder) || $remainder[0] === '?' || $remainder[0] === '#') {
12511 $is_valid = true;
12512 //error_log(" MATCH: Found URL is valid variation of base URL");
12513 break;
12514 } else {
12515 //error_log(" NOT A MATCH: Found URL extends path beyond valid URL (remainder: " . $remainder . ")");
12516 }
12517 }
12518 // Also check the reverse (in case valid URL has query params)
12519 if (strpos($valid_url, $normalized_found) === 0) {
12520 $is_valid = true;
12521 //error_log(" MATCH: Valid URL starts with found URL");
12522 break;
12523 }
12524 }
12525
12526 if (!$is_valid) {
12527 //error_log("NO MATCH FOUND - URL should be removed");
12528 }
12529 }
12530
12531 // If URL is not valid, remove it from the response
12532 if (!$is_valid) {
12533 // Log the removal for debugging
12534 //error_log("MxChat: Removed hallucinated URL: " . $found_url);
12535 //error_log("MxChat: Valid URLs were: " . implode(', ', array_slice($normalized_valid_urls, 0, 5)));
12536
12537 $removed_count++;
12538
12539 // Check if URL is part of a markdown link: [text](url)
12540 $markdown_pattern = '/\[([^\]]+)\]\(' . preg_quote($found_url, '/') . '\)/';
12541 if (preg_match($markdown_pattern, $cleaned_response)) {
12542 //error_log("Found markdown link, removing but keeping text");
12543 // Remove the markdown link but keep the text
12544 $cleaned_response = preg_replace($markdown_pattern, '$1', $cleaned_response);
12545 }
12546 // Check if URL is part of an HTML link: <a href="url">text</a>
12547 else if (preg_match('/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>(.*?)<\/a>/i', $cleaned_response, $link_match)) {
12548 //error_log("Found HTML link, removing but keeping text");
12549 // Remove the HTML link but keep the text
12550 $link_text = $link_match[1];
12551 $cleaned_response = preg_replace(
12552 '/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>.*?<\/a>/i',
12553 $link_text,
12554 $cleaned_response
12555 );
12556 }
12557 // Otherwise just remove the bare URL
12558 else {
12559 //error_log("Removing bare URL");
12560 $cleaned_response = str_replace($found_url, '', $cleaned_response);
12561 }
12562 }
12563 }
12564
12565 // Log summary if any URLs were removed
12566 if ($removed_count > 0) {
12567 //error_log("MxChat: URL Validation Summary - Removed {$removed_count} hallucinated URL(s)");
12568 } else {
12569 //error_log("MxChat: URL Validation Summary - No URLs removed, all were valid");
12570 }
12571
12572 // Clean up any double spaces or awkward punctuation left behind
12573 // IMPORTANT: Only collapse horizontal whitespace (spaces/tabs), preserve newlines for markdown formatting
12574 $cleaned_response = preg_replace('/[^\S\n]+/', ' ', $cleaned_response); // Collapse spaces/tabs but NOT newlines
12575 $cleaned_response = preg_replace('/[^\S\n]+([.,;:!?])/', '$1', $cleaned_response); // Same for punctuation cleanup
12576
12577 //error_log("Final cleaned response: " . $cleaned_response);
12578
12579 return trim($cleaned_response);
12580 }
12581
12582 /**
12583 * AJAX handler to get current chat mode for a session
12584 */
12585 public function mxchat_get_current_chat_mode() {
12586 // Verify nonce for security
12587 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
12588 wp_send_json_error(['message' => 'Invalid nonce']);
12589 wp_die();
12590 }
12591
12592 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
12593
12594 if (empty($session_id)) {
12595 wp_send_json_error(['message' => 'Session ID missing']);
12596 wp_die();
12597 }
12598
12599 // Get the current chat mode for this session
12600 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
12601
12602 wp_send_json_success([
12603 'chat_mode' => $chat_mode
12604 ]);
12605 wp_die();
12606 }
12607
12608
12609
12610 }
12611 ?>
12612