PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.0
MxChat – AI Chatbot & Content Generation for WordPress v3.2.0
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.0, at includes/class-mxchat-integrator.php

10,770 lines 428.8 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 private $word_handler;
13 private $last_similarity_analysis = null;
14 private $current_valid_urls = [];
15 private $last_vectorstore_error = null;
16 private $is_streaming = false; // ADDED: Track if current request is streaming
17 private $streaming_headers_sent = false; // Track if streaming headers have been sent
18
19 /**
20 * Setup streaming headers - call this right before actually streaming
21 * This delays header setup to allow actions/forms to return JSON responses
22 */
23 private function setup_streaming_headers() {
24 if ($this->streaming_headers_sent || headers_sent()) {
25 return false;
26 }
27
28 // Disable output buffering
29 while (ob_get_level()) {
30 ob_end_flush();
31 }
32
33 // Set headers for SSE
34 header('Content-Type: text/event-stream');
35 header('Cache-Control: no-cache');
36 header('Connection: keep-alive');
37 header('X-Accel-Buffering: no');
38
39 ob_implicit_flush(true);
40 flush();
41
42 $this->streaming_headers_sent = true;
43 return true;
44 }
45
46 /**
47 * Class constructor
48 */
49 public function __construct() {
50 $this->options = get_option('mxchat_options');
51 $this->prompts_options = get_option('mxchat_prompts_options', array());
52 $this->chat_count = get_option('mxchat_chat_count', 0);
53 $this->word_handler = new MXChat_Word_Handler($this->options);
54
55 // Add all action hooks
56 add_action('wp_enqueue_scripts', array($this, 'mxchat_enqueue_scripts_styles'));
57 add_action('wp_ajax_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
58 add_action('wp_ajax_nopriv_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
59 add_action('wp_ajax_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
60 add_action('wp_ajax_nopriv_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
61
62 // Add the AJAX actions for checking if the pre-chat message was dismissed
63 add_action('wp_ajax_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
64 add_action('wp_ajax_nopriv_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
65 add_action('wp_ajax_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
66 add_action('wp_ajax_nopriv_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
67 add_action('wp_ajax_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
68 add_action('wp_ajax_nopriv_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
69
70 // Add REST API routes registration
71 add_action('rest_api_init', array($this, 'register_routes'));
72 add_action('wp_ajax_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
73 add_action('wp_ajax_nopriv_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
74
75 // Rate limit action - notice we removed the old schedule setup
76 add_action('mxchat_reset_rate_limits', array($this, 'mxchat_reset_rate_limits'));
77
78 // File upload and handling actions
79 add_action('wp_ajax_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
80 add_action('wp_ajax_nopriv_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
81 add_action('wp_ajax_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
82 add_action('wp_ajax_nopriv_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
83
84 // Word document handling actions
85 add_action('wp_ajax_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
86 add_action('wp_ajax_nopriv_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
87 add_action('wp_ajax_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
88 add_action('wp_ajax_nopriv_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
89 add_action('wp_ajax_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
90 add_action('wp_ajax_nopriv_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
91
92 // Email handling actions
93 add_action('wp_ajax_nopriv_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
94 add_action('wp_ajax_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
95 add_action('wp_ajax_nopriv_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
96 add_action('wp_ajax_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
97
98 add_action('wp_ajax_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
99 add_action('wp_ajax_nopriv_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
100
101 // Testing panel AJAX actions
102 add_action('wp_ajax_mxchat_get_system_info', array($this, 'mxchat_get_system_info'));
103 add_action('wp_ajax_mxchat_get_similarity_threshold', array($this, 'mxchat_get_similarity_threshold'));
104 add_action('wp_ajax_mxchat_get_kb_status', array($this, 'mxchat_get_kb_status'));
105 add_action('wp_ajax_mxchat_start_fresh_session', array($this, 'mxchat_start_fresh_session'));
106 // Add to your existing constructor, in the section with other AJAX actions:
107 add_action('wp_ajax_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
108 add_action('wp_ajax_nopriv_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
109 add_action('wp_ajax_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
110 add_action('wp_ajax_nopriv_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
111 // Add chat mode checking actions
112 add_action('wp_ajax_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
113 add_action('wp_ajax_nopriv_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
114
115 // Nonce refresh for page-cache compatibility (WP Rocket, LiteSpeed, etc.)
116 add_action('wp_ajax_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
117 add_action('wp_ajax_nopriv_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
118
119 // Auto-email transcript action
120 add_action('mxchat_send_delayed_transcript', array($this, 'mxchat_send_delayed_transcript'), 10, 1);
121
122 add_filter('mxchat_check_actions_only', array($this, 'check_actions_for_addons'), 10, 4);
123
124
125 }
126
127 /**
128 * Return a fresh nonce so cached pages can replace the stale one.
129 */
130 public function mxchat_refresh_nonce() {
131 nocache_headers();
132 wp_send_json_success(array('nonce' => wp_create_nonce('mxchat_chat_nonce')));
133 }
134
135 // In your core plugin's check_actions_for_addons method:
136 public function check_actions_for_addons($default, $message, $user_id, $session_id) {
137 //error_log('MxChat Core: check_actions_for_addons called with message: ' . $message);
138
139 $result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
140
141 //error_log('MxChat Core: Intent check result = ' . ($result === false ? 'false' : 'true'));
142
143 return $result;
144 }
145
146 private function mxchat_increment_chat_count() {
147 $chat_count = get_option('mxchat_chat_count', 0);
148 $chat_count++;
149 update_option('mxchat_chat_count', $chat_count);
150 }
151
152 function mxchat_fetch_conversation_history() {
153 if (empty($_POST['session_id'])) {
154 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
155 wp_die();
156 }
157
158 $session_id = sanitize_text_field($_POST['session_id']);
159
160 // SECURITY FIX: Verify session ownership before retrieving data
161 // If IP/user changed, signal frontend to reset session instead of blocking
162 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
163
164 // Check if this session has an owner recorded
165 $session_owner = get_option("mxchat_session_owner_{$session_id}");
166
167 // Update session owner if it changed (e.g. IP changed due to network switch)
168 // The session ID itself is the authentication — if the client has it, they own it
169 if (!$session_owner || $session_owner !== $current_user_identifier) {
170 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
171 }
172
173 $history = get_option("mxchat_history_{$session_id}", []); // Retrieve stored history
174 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai'); // Get current chat mode
175
176 if (empty($history)) {
177 // Even if history is empty, return the chat mode
178 wp_send_json_success([
179 'conversation' => [],
180 'chat_mode' => $chat_mode
181 ]);
182 wp_die();
183 }
184
185 wp_send_json_success([
186 'conversation' => $history,
187 'chat_mode' => $chat_mode
188 ]);
189 wp_die();
190 }
191 private function mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp = 0) {
192 $history = get_option("mxchat_history_{$session_id}", []);
193
194 // Check persistence setting - when OFF, only include messages from current page load
195 $options = get_option('mxchat_options', []);
196 $persistence_enabled = isset($options['chat_persistence_toggle']) && $options['chat_persistence_toggle'] === 'on';
197
198 // Filter history when persistence is OFF to match what the user sees
199 if (!$persistence_enabled && $session_start_timestamp > 0) {
200 $history = array_filter($history, function($entry) use ($session_start_timestamp) {
201 // Include messages from this page load onwards
202 return isset($entry['timestamp']) && $entry['timestamp'] >= $session_start_timestamp;
203 });
204 // Re-index array after filtering
205 $history = array_values($history);
206 }
207
208 $formatted_history = [];
209
210 // Adjusted for code-heavy conversations
211 $max_tokens = 120000; // Context window size
212 $reserved_tokens = 5000; // Space for system prompts + current query
213 $current_token_count = 0;
214
215 // Allowed HTML tags for content sanitization
216 $allowed_tags = [
217 'pre' => ['class' => true],
218 'code' => ['class' => true],
219 'span' => ['class' => true],
220 'div' => ['class' => true],
221 'strong' => [],
222 'em' => []
223 ];
224
225 foreach (array_reverse($history) as $entry) {
226 // Preserve code blocks while sanitizing other HTML
227 $clean_content = wp_kses($entry['content'], $allowed_tags);
228
229 // Detect code blocks in content
230 $has_code = false;
231 // Replace the HTML check with:
232 // Allow messages that contain code blocks or are plain text
233 if (strpos($clean_content, '<pre') === false &&
234 strpos($clean_content, '<code') === false &&
235 $clean_content !== strip_tags($entry['content'])) {
236 continue;
237 }
238
239 // Skip entries that lost significant content during sanitization
240 if (!$has_code && $clean_content !== strip_tags($entry['content'])) {
241 continue;
242 }
243
244 // More accurate token estimation (1 token ≈ 4 characters)
245 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
246
247 // Check token budget with the new estimate
248 if (($current_token_count + $token_estimate + $reserved_tokens) > $max_tokens) {
249 // Try to fit partial content if it's the first entry
250 if (empty($formatted_history)) {
251 $clean_content = mb_substr($clean_content, 0, ($max_tokens - $reserved_tokens) * 4);
252 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
253 } else {
254 break;
255 }
256 }
257
258 // Add to formatted history
259 $formatted_history[] = [
260 'role' => $entry['role'],
261 'content' => $clean_content
262 ];
263
264 $current_token_count += $token_estimate;
265 }
266
267 // Reverse back to maintain chronological order
268 $formatted_history = array_reverse($formatted_history);
269
270 // Add system message about code context
271 array_unshift($formatted_history, [
272 'role' => 'system',
273 'content' => 'Preserved code blocks are marked with [CODE BLOCK PRESERVED]. '
274 . 'Maintain formatting and syntax highlighting when referencing code.'
275 ]);
276
277 return $formatted_history;
278 }
279
280 public function register_routes() {
281 //error_log(esc_html__('Registering MxChat REST routes', 'mxchat'));
282
283 register_rest_route('mxchat/v1', '/stream', [
284 'methods' => 'GET',
285 'callback' => [$this, 'mxchat_stream_events'],
286 'permission_callback' => [$this, 'verify_chat_session'],
287 ]);
288
289 register_rest_route('mxchat/v1', '/agent-response', [
290 'methods' => 'POST',
291 'callback' => [$this, 'mxchat_handle_agent_response'],
292 'permission_callback' => [$this, 'verify_slack_request'],
293 ]);
294
295 register_rest_route('mxchat/v1', '/slack-interaction', [
296 'methods' => 'POST',
297 'callback' => [$this, 'handle_slack_interaction'],
298 'permission_callback' => [$this, 'verify_slack_request'],
299 ]);
300
301 register_rest_route('mxchat/v1', '/slack-messages', [
302 'methods' => 'POST',
303 'callback' => [$this, 'handle_slack_messages'],
304 'permission_callback' => [$this, 'verify_slack_request'],
305 ]);
306
307 // Telegram webhook endpoint
308 register_rest_route('mxchat/v1', '/telegram-webhook', [
309 'methods' => 'POST',
310 'callback' => [$this, 'handle_telegram_webhook'],
311 'permission_callback' => [$this, 'verify_telegram_request'],
312 ]);
313
314 //error_log(esc_html__('MxChat REST routes registered', 'mxchat'));
315 }
316
317 /**
318 * Verify valid chat session
319 */
320 public function verify_chat_session($request) {
321 $session_id = $request->get_param('session_id');
322 if (empty($session_id)) {
323 //error_log(esc_html__('Empty session ID in chat request', 'mxchat'));
324 return false;
325 }
326
327 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
328 return $chat_mode === 'agent';
329 }
330
331 /**
332 * Verify request is coming from Slack.
333 *
334 * @param WP_REST_Request $request
335 * @return bool True if valid, false otherwise.
336 */
337 public function verify_slack_request($request) {
338 // Get the Slack signing secret from your plugin options
339 $valid_key = $this->options['live_agent_secret_key'] ?? '';
340
341 if (empty($valid_key)) {
342 //error_log(esc_html__('Slack signing secret not configured', 'mxchat'));
343 return false;
344 }
345
346 $timestamp = $request->get_header('X-Slack-Request-Timestamp');
347 $slack_signature = $request->get_header('X-Slack-Signature');
348
349 // Verify timestamp to prevent replay attacks
350 if (abs(time() - intval($timestamp)) > 300) {
351 //error_log(esc_html__('Slack request timestamp too old', 'mxchat'));
352 return false;
353 }
354
355 // Get raw request body from the WP_REST_Request object
356 // (php://input may already be consumed by WordPress at this point)
357 $request_body = $request->get_body();
358
359 // Create the signature base string
360 $sig_basestring = "v0:{$timestamp}:{$request_body}";
361
362 // Calculate expected signature
363 $my_signature = 'v0=' . hash_hmac('sha256', $sig_basestring, $valid_key);
364
365 // Compare signatures
366 return hash_equals($my_signature, $slack_signature);
367 }
368
369 /**
370 * Verify request is coming from Telegram.
371 *
372 * @param WP_REST_Request $request
373 * @return bool True if valid, false otherwise.
374 */
375 public function verify_telegram_request($request) {
376 $secret_token = $this->options['telegram_webhook_secret'] ?? '';
377
378 //error_log('[MxChat Telegram DEBUG] verify_telegram_request called');
379 //error_log('[MxChat Telegram DEBUG] Stored secret: ' . (empty($secret_token) ? 'EMPTY' : substr($secret_token, 0, 10) . '...'));
380
381 if (empty($secret_token)) {
382 // If no secret is configured, allow the request (for initial setup)
383 //error_log('[MxChat Telegram DEBUG] No secret configured, allowing request');
384 return true;
385 }
386
387 // Telegram sends the secret token in the X-Telegram-Bot-Api-Secret-Token header
388 $request_token = $request->get_header('X-Telegram-Bot-Api-Secret-Token');
389
390 //error_log('[MxChat Telegram DEBUG] Request token: ' . (empty($request_token) ? 'EMPTY' : substr($request_token, 0, 10) . '...'));
391
392 if (empty($request_token)) {
393 //error_log('[MxChat Telegram DEBUG] Request rejected: No token in header');
394 return false;
395 }
396
397 // Timing-safe comparison
398 $result = hash_equals($secret_token, $request_token);
399 //error_log('[MxChat Telegram DEBUG] Token comparison result: ' . ($result ? 'MATCH' : 'MISMATCH'));
400 return $result;
401 }
402
403 public function mxchat_stream_events(WP_REST_Request $request) {
404 header('Content-Type: text/event-stream');
405 header('Cache-Control: no-cache');
406 header('Connection: keep-alive');
407
408 $session_id = sanitize_text_field($request->get_param('session_id'));
409 $last_seen_id = sanitize_text_field($request->get_param('last_seen_id')) ?: '';
410
411 if (empty($session_id)) {
412 echo esc_html__("event: error\ndata: ", 'mxchat') . esc_html__('Missing session_id', 'mxchat') . "\n\n";
413 flush();
414 exit;
415 }
416
417 $history = get_option("mxchat_history_{$session_id}", []);
418
419 // Filter only new messages
420 $new_messages = array_filter($history, function ($message) use ($last_seen_id) {
421 return !empty($message['id']) && $message['id'] > $last_seen_id;
422 });
423
424 // Send new messages if available
425 if (!empty($new_messages)) {
426 echo esc_html__("event: newMessages\ndata: ", 'mxchat') . json_encode(array_values($new_messages)) . "\n\n";
427 } else {
428 // Keep the connection alive
429 echo esc_html__("event: keepAlive\ndata: ", 'mxchat') . "{}\n\n";
430 }
431 flush();
432 exit;
433 }
434
435
436
437
438 private function mxchat_save_chat_message($session_id, $role, $message, $originating_page = null, $rag_context = null) {
439 global $wpdb;
440 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
441 //error_log("[DEBUG] mxchat_save_chat_message -> START for session_id: {$session_id}, role: {$role}");
442
443 // Check if this is the first message in a new session (before any other database operations)
444 $is_new_session = false;
445 if ($role === 'user') { // Only check for user messages, not bot responses
446 $existing_messages = $wpdb->get_var($wpdb->prepare(
447 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
448 $session_id
449 ));
450 $is_new_session = ($existing_messages == 0);
451
452 // Log for debugging
453 if ($is_new_session) {
454 //error_log("[DEBUG] This is a NEW session - first message");
455 }
456 }
457
458 // SECURITY FIX: Set session ownership for new sessions
459 if ($is_new_session && $role === 'user') {
460 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
461 $session_owner_key = "mxchat_session_owner_{$session_id}";
462
463 // Only set ownership if not already set
464 if (!get_option($session_owner_key)) {
465 update_option($session_owner_key, $current_user_identifier, 'no');
466 //error_log("[DEBUG] Set session ownership for {$session_id} to {$current_user_identifier}");
467 }
468 }
469
470 // 1) Extract agent name if present
471 $agent_name = '';
472 if (preg_match('/^Agent: (.*?) - /', $message, $matches)) {
473 $agent_name = $matches[1];
474 $message = str_replace("Agent: $agent_name - ", '', $message);
475 $session_meta_key = "mxchat_agent_name_{$session_id}";
476 if (empty(get_option($session_meta_key))) {
477 update_option($session_meta_key, $agent_name);
478 //error_log("[DEBUG] mxchat_save_chat_message -> Stored agent_name in option: {$session_meta_key} => {$agent_name}");
479 }
480 }
481
482 // 2) Generate unique message_id
483 $message_id = uniqid();
484 //error_log("[DEBUG] mxchat_save_chat_message -> Generated message_id: {$message_id}");
485
486 // 3) Determine user_id
487 $user_id = is_user_logged_in() ? get_current_user_id() : 0;
488
489 // 4) Determine user_identifier
490 $user_identifier = $agent_name
491 ? $agent_name
492 : MxChat_User::mxchat_get_user_identifier();
493
494 // 5) Determine displayed_name
495 $user_email = MxChat_User::mxchat_get_user_email();
496 $displayed_name = $agent_name ? $agent_name : ($user_email ?: $user_identifier);
497
498 // 6) Check for a saved email in wp_options
499 $email_option_key = "mxchat_email_{$session_id}";
500 $saved_email = get_option($email_option_key);
501 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for email_option_key: {$email_option_key}, found: {$saved_email}");
502
503 // Check for a saved name in wp_options
504 $name_option_key = "mxchat_name_{$session_id}";
505 $saved_name = get_option($name_option_key);
506 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for name_option_key: {$name_option_key}, found: {$saved_name}");
507
508 // If found, update DB user_email and user_name
509 if ($saved_email || $saved_name) {
510 $update_data = [];
511 if ($saved_email) {
512 $update_data['user_email'] = $saved_email;
513 }
514 if ($saved_name) {
515 $update_data['user_name'] = $saved_name;
516 }
517
518 if (!empty($update_data)) {
519 $update_res = $wpdb->update(
520 $table_name,
521 $update_data,
522 ['session_id' => $session_id],
523 array_fill(0, count($update_data), '%s'),
524 ['%s']
525 );
526 //error_log("[DEBUG] mxchat_save_chat_message -> Attempted DB user_email/user_name update for session_id {$session_id}. update_res: {$update_res}");
527 }
528 }
529
530 // 7) Save to session history in wp_options
531 $history_key = "mxchat_history_{$session_id}";
532 $history = get_option($history_key, []);
533 $history[] = [
534 'id' => $message_id,
535 'role' => $role,
536 'content' => $message,
537 'timestamp' => round(microtime(true) * 1000),
538 'agent_name' => $displayed_name,
539 ];
540 update_option($history_key, $history, 'no');
541 //error_log("[DEBUG] mxchat_save_chat_message -> Updated session history in option: {$history_key}");
542
543 // 8) Save the message to DB (INSERT)
544 $insert_data = [
545 'user_id' => $user_id,
546 'user_identifier'=> $user_identifier,
547 'user_email' => $saved_email ?: $user_email,
548 'user_name' => $saved_name ?: '', // Add name to insert data
549 'session_id' => $session_id,
550 'role' => $role,
551 'message' => $message,
552 'timestamp' => current_time('mysql', 1),
553 ];
554
555 // IMPROVED: Handle originating page data
556 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
557
558 if ($columns_exist) {
559 if ($is_new_session && $role === 'user') {
560 // For the first user message, set originating page data
561
562 // First check if we have it from the parameter
563 if ($originating_page && !empty($originating_page['url'])) {
564 $insert_data['originating_page_url'] = $originating_page['url'];
565 $insert_data['originating_page_title'] = $originating_page['title'] ?? '';
566
567 //error_log("[DEBUG] Setting originating page from parameter: " . $originating_page['url']);
568 }
569 // Otherwise check if it's stored in the instance property
570 else if (isset($this->pending_originating_page) && !empty($this->pending_originating_page['url'])) {
571 $insert_data['originating_page_url'] = $this->pending_originating_page['url'];
572 $insert_data['originating_page_title'] = $this->pending_originating_page['title'] ?? '';
573
574 //error_log("[DEBUG] Setting originating page from pending_originating_page: " . $this->pending_originating_page['url']);
575
576 // Clear after using
577 unset($this->pending_originating_page);
578 }
579 // Fallback to HTTP_REFERER if nothing else is available
580 else if (isset($_SERVER['HTTP_REFERER'])) {
581 $referer_url = esc_url_raw($_SERVER['HTTP_REFERER']);
582 $insert_data['originating_page_url'] = $referer_url;
583
584 // Generate title from URL
585 $parsed_url = parse_url($referer_url);
586 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
587
588 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
589 $insert_data['originating_page_title'] = 'Homepage';
590 } else {
591 $title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
592 $insert_data['originating_page_title'] = ucwords(trim($title));
593 }
594
595 //error_log("[DEBUG] Setting originating page from HTTP_REFERER: " . $referer_url);
596 }
597
598 // Store for this session so all messages have the same originating page
599 if (!empty($insert_data['originating_page_url'])) {
600 update_option("mxchat_originating_page_{$session_id}", [
601 'url' => $insert_data['originating_page_url'],
602 'title' => $insert_data['originating_page_title']
603 ], 'no');
604 }
605 } else {
606 // For subsequent messages in the session, use the stored originating page
607 $stored_originating = get_option("mxchat_originating_page_{$session_id}");
608 if ($stored_originating && !empty($stored_originating['url'])) {
609 $insert_data['originating_page_url'] = $stored_originating['url'];
610 $insert_data['originating_page_title'] = $stored_originating['title'] ?? '';
611 }
612 }
613 }
614
615 // Add RAG context if provided (for bot messages)
616 if ($rag_context !== null && $role === 'bot') {
617 $rag_context_column_exists = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'rag_context'");
618 if ($rag_context_column_exists) {
619 $insert_data['rag_context'] = is_array($rag_context) ? wp_json_encode($rag_context) : $rag_context;
620 }
621 }
622
623 $wpdb->insert($table_name, $insert_data);
624 //error_log("[DEBUG] mxchat_save_chat_message -> Inserted message into DB. row_id: {$wpdb->insert_id}, data: " . print_r($insert_data, true));
625
626 // 9) Send notification email if this is the first user message in a new session
627 if ($wpdb->insert_id && $is_new_session && $role === 'user') {
628 $this->send_new_chat_notification($session_id, array(
629 'identifier' => $user_identifier,
630 'email' => $saved_email ?: $user_email,
631 'ip' => $_SERVER['REMOTE_ADDR']
632 ));
633 }
634
635 // 10) Schedule delayed transcript email if enabled and message is from user
636 if ($wpdb->insert_id && $role === 'user') {
637 $this->schedule_delayed_transcript_email($session_id);
638 }
639
640 //error_log("[DEBUG] mxchat_save_chat_message -> END for session_id: {$session_id}");
641 return $message_id;
642 }
643
644 private function send_new_chat_notification($session_id, $user_info = array()) {
645 $options = get_option('mxchat_transcripts_options');
646
647 // Check if notifications are enabled
648 if (empty($options['mxchat_enable_notifications'])) {
649 return false;
650 }
651
652 // Get notification email
653 $to = !empty($options['mxchat_notification_email']) ?
654 $options['mxchat_notification_email'] :
655 get_option('admin_email');
656
657 if (!is_email($to)) {
658 return false;
659 }
660
661 // Prepare email content
662 $subject = sprintf('[%s] New Chat Session Started', get_bloginfo('name'));
663
664 $user_identifier = isset($user_info['identifier']) ? $user_info['identifier'] : 'Guest';
665 $user_email = isset($user_info['email']) ? $user_info['email'] : 'Not provided';
666 $user_ip = isset($user_info['ip']) ? $user_info['ip'] : $_SERVER['REMOTE_ADDR'];
667
668 $message = sprintf(
669 "A new chat session has started on your website.\n\n" .
670 "Session ID: %s\n" .
671 "User: %s\n" .
672 "Email: %s\n" .
673 "IP Address: %s\n" .
674 "Time: %s\n\n" .
675 "View transcripts: %s",
676 $session_id,
677 $user_identifier,
678 $user_email,
679 $user_ip,
680 current_time('mysql'),
681 admin_url('admin.php?page=mxchat-transcripts')
682 );
683
684 // Send email
685 return wp_mail($to, $subject, $message);
686 }
687
688 /**
689 * Schedule delayed transcript email for a session
690 * Reschedules if a new user message is received
691 */
692 private function schedule_delayed_transcript_email($session_id) {
693 $options = get_option('mxchat_transcripts_options');
694
695 // Check if auto-email is enabled
696 if (empty($options['mxchat_auto_email_transcript_enabled'])) {
697 return;
698 }
699
700 // Get notification email
701 $email = !empty($options['mxchat_notification_email']) ?
702 $options['mxchat_notification_email'] :
703 get_option('admin_email');
704
705 if (!is_email($email)) {
706 return;
707 }
708
709 // Get delay in minutes (default 30)
710 $delay_minutes = isset($options['mxchat_auto_email_transcript_delay']) ?
711 intval($options['mxchat_auto_email_transcript_delay']) : 30;
712
713 // Clear any existing scheduled event for this session
714 $hook = 'mxchat_send_delayed_transcript';
715 $args = array($session_id);
716 $timestamp = wp_next_scheduled($hook, $args);
717
718 if ($timestamp) {
719 wp_unschedule_event($timestamp, $hook, $args);
720 }
721
722 // Schedule new event
723 $schedule_time = time() + ($delay_minutes * 60);
724 wp_schedule_single_event($schedule_time, $hook, $args);
725 }
726
727 /**
728 * Check if chat messages contain contact information (email or phone number)
729 *
730 * @param array $messages Array of message objects with 'message' property
731 * @param object|null $session_data Session data object with user_email property
732 * @return bool True if contact info found, false otherwise
733 */
734 private function chat_contains_contact_info($messages, $session_data = null) {
735 // Check if session already has a stored email
736 if ($session_data && !empty($session_data->user_email)) {
737 return true;
738 }
739
740 // Email regex pattern
741 $email_pattern = '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/';
742
743 // Phone number patterns (covers various formats including international, WhatsApp style)
744 // Matches: +1234567890, (123) 456-7890, 123-456-7890, 123.456.7890, 1234567890, +1 234 567 8900, etc.
745 $phone_pattern = '/(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{2,4}[-.\s]?\d{2,4}(?:[-.\s]?\d{1,4})?/';
746
747 // Only check user messages (not assistant responses)
748 foreach ($messages as $msg) {
749 if ($msg->role !== 'user') {
750 continue;
751 }
752
753 $message_text = $msg->message;
754
755 // Check for email
756 if (preg_match($email_pattern, $message_text)) {
757 return true;
758 }
759
760 // Check for phone number (must be at least 7 digits total to avoid false positives)
761 if (preg_match($phone_pattern, $message_text, $matches)) {
762 // Count actual digits to avoid matching short numbers
763 $digits_only = preg_replace('/\D/', '', $matches[0]);
764 if (strlen($digits_only) >= 7) {
765 return true;
766 }
767 }
768 }
769
770 return false;
771 }
772
773 /**
774 * Send the delayed transcript email with .txt attachment
775 */
776 public function mxchat_send_delayed_transcript($session_id) {
777 global $wpdb;
778
779 $options = get_option('mxchat_transcripts_options');
780
781 // Get notification email
782 $to = !empty($options['mxchat_notification_email']) ?
783 $options['mxchat_notification_email'] :
784 get_option('admin_email');
785
786 if (!is_email($to)) {
787 return false;
788 }
789
790 // Get all messages for this session
791 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
792 $messages = $wpdb->get_results($wpdb->prepare(
793 "SELECT role, message, timestamp FROM {$table_name}
794 WHERE session_id = %s
795 ORDER BY timestamp ASC",
796 $session_id
797 ));
798
799 if (empty($messages)) {
800 return false;
801 }
802
803 // Get session metadata
804 $sessions_table = $wpdb->prefix . 'mxchat_sessions';
805 $session_data = $wpdb->get_row($wpdb->prepare(
806 "SELECT * FROM {$sessions_table} WHERE session_id = %s",
807 $session_id
808 ));
809
810 // Check if contact info is required and if it's present
811 $require_contact = !empty($options['mxchat_auto_email_transcript_require_contact']);
812 if ($require_contact && !$this->chat_contains_contact_info($messages, $session_data)) {
813 // Contact info required but not found - skip sending
814 return false;
815 }
816
817 // Build transcript content
818 $transcript_content = "Chat Transcript\n";
819 $transcript_content .= "================\n\n";
820 $transcript_content .= "Session ID: " . $session_id . "\n";
821
822 if ($session_data) {
823 $transcript_content .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
824 $transcript_content .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
825 $transcript_content .= "Started: " . $session_data->created_at . "\n";
826 }
827
828 $transcript_content .= "\n" . str_repeat("=", 50) . "\n\n";
829
830 // Add messages
831 foreach ($messages as $msg) {
832 $role_label = ($msg->role === 'user') ? 'User' : 'Assistant';
833 $transcript_content .= "[{$msg->timestamp}] {$role_label}:\n";
834 $transcript_content .= $msg->message . "\n\n";
835 }
836
837 // Create temporary file for attachment using WP_Filesystem
838 $upload_dir = wp_upload_dir();
839 $temp_file = $upload_dir['basedir'] . '/mxchat-transcript-' . $session_id . '.txt';
840 global $wp_filesystem;
841 if (empty($wp_filesystem)) {
842 require_once ABSPATH . 'wp-admin/includes/file.php';
843 WP_Filesystem();
844 }
845 $wp_filesystem->put_contents($temp_file, $transcript_content, FS_CHMOD_FILE);
846
847 // Prepare email
848 $subject = sprintf('[%s] Chat Transcript - Session %s', get_bloginfo('name'), substr($session_id, 0, 8));
849
850 $message = "Please find attached the full chat transcript.\n\n";
851 $message .= "Session ID: {$session_id}\n";
852
853 if ($session_data) {
854 $message .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
855 $message .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
856 }
857
858 $message .= "\nView online: " . admin_url('admin.php?page=mxchat-transcripts');
859
860 // Send email with attachment
861 $attachments = array($temp_file);
862 $result = wp_mail($to, $subject, $message, '', $attachments);
863
864 // Clean up temporary file
865 if (file_exists($temp_file)) {
866 unlink($temp_file);
867 }
868
869 return $result;
870 }
871
872
873
874 public function mxchat_handle_save_email_and_response() {
875 //error_log('[DEBUG] ---------- mxchat_handle_save_email_and_response START ----------');
876 //error_log('DEBUG: POST data: ' . print_r($_POST, true));
877
878 nocache_headers();
879
880 // Validate nonce
881 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
882 //error_log(esc_html__('[ERROR] Invalid nonce in mxchat_handle_save_email_and_response', 'mxchat'));
883 wp_send_json_error(['message' => esc_html__('Invalid nonce.', 'mxchat')]);
884 wp_die();
885 }
886
887 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
888 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
889 $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
890
891 //error_log("[DEBUG] handle_save_email_and_response -> session_id: {$session_id}, email: {$email}, name: {$name}");
892
893 if (empty($session_id) || $session_id === 'null' || empty($email)) {
894 //error_log("[ERROR] Missing session_id or email: session_id={$session_id}, email={$email}");
895 wp_send_json_error(['message' => esc_html__('Session ID or email is missing.', 'mxchat')]);
896 wp_die();
897 }
898
899 // Validate name if provided (check if name field is enabled and name is required)
900 $options = get_option('mxchat_options', []);
901 $name_field_enabled = isset($options['enable_name_field']) &&
902 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
903
904 if ($name_field_enabled && (empty($name) || strlen(trim($name)) < 2 || strlen(trim($name)) > 100)) {
905 //error_log("[ERROR] Invalid name: {$name} (enabled: {$name_field_enabled})");
906 wp_send_json_error(['message' => esc_html__('Name must be between 2 and 100 characters.', 'mxchat')]);
907 wp_die();
908 }
909
910 // 1) Always store email in wp_options
911 $email_option_key = "mxchat_email_{$session_id}";
912 update_option($email_option_key, $email, 'no');
913 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$email_option_key} => {$email}");
914
915 // Store name in wp_options if provided
916 if (!empty($name)) {
917 $name_option_key = "mxchat_name_{$session_id}";
918 update_option($name_option_key, $name, 'no');
919 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$name_option_key} => {$name}");
920 }
921
922 // 2) (Optional) Also store in DB if a row already exists
923 global $wpdb;
924 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
925
926 // Make sure we have a valid placeholder in prepare
927 $sql = $wpdb->prepare("SELECT COUNT(*) FROM {$table_name} WHERE session_id = %s", $session_id);
928 $session_count = $wpdb->get_var($sql);
929
930 //error_log("[DEBUG] handle_save_email_and_response -> session_count for {$session_id}: {$session_count} (SQL: {$sql})");
931
932 if ($session_count) {
933 // Update both user_email and user_name if row(s) exist
934 if (!empty($name)) {
935 $update_sql = $wpdb->prepare(
936 "UPDATE {$table_name} SET user_email = %s, user_name = %s WHERE session_id = %s",
937 $email,
938 $name,
939 $session_id
940 );
941 } else {
942 $update_sql = $wpdb->prepare(
943 "UPDATE {$table_name} SET user_email = %s WHERE session_id = %s",
944 $email,
945 $session_id
946 );
947 }
948 $wpdb->query($update_sql);
949 //error_log("[DEBUG] handle_save_email_and_response -> DB updated: {$update_sql}");
950 } else {
951 //error_log("[INFO] handle_save_email_and_response -> No DB entry for {$session_id}, so email/name is only in wp_options.");
952 }
953
954 // Provide success response (same as original)
955 $bot_message = __('Thanks for providing your email! You can continue chatting now.', 'mxchat');
956 //error_log("[DEBUG] handle_save_email_and_response -> success, returning bot_message: {$bot_message}");
957 wp_send_json_success(['message' => $bot_message]);
958 wp_die();
959 }
960
961 public function mxchat_check_email_provided() {
962 //error_log('[DEBUG] ---------- mxchat_check_email_provided START ----------');
963
964 nocache_headers();
965
966 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
967 //error_log('[ERROR] Invalid nonce in mxchat_check_email_provided');
968 wp_send_json_error(['message' => esc_html__('Invalid nonce', 'mxchat')]);
969 }
970
971 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
972 if (empty($session_id) || $session_id === 'null') {
973 //error_log('[ERROR] No session ID provided in mxchat_check_email_provided');
974 wp_send_json_error(['message' => esc_html__('No session ID provided', 'mxchat')]);
975 }
976
977 // Check if the user is logged in
978 if (is_user_logged_in()) {
979 $current_user = wp_get_current_user();
980 //error_log("[DEBUG] User is logged in as {$current_user->user_email}");
981
982 // Get user's display name for logged in users
983 $user_name = !empty($current_user->display_name) ? $current_user->display_name :
984 (!empty($current_user->first_name) ? $current_user->first_name : '');
985
986 $response_data = ['logged_in' => true, 'email' => $current_user->user_email];
987 if (!empty($user_name)) {
988 $response_data['name'] = $user_name;
989 }
990
991 wp_send_json_success($response_data);
992 }
993
994 // Check if name field is required
995 $options = get_option('mxchat_options', []);
996 $name_field_enabled = isset($options['enable_name_field']) &&
997 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
998
999 $email_option_key = "mxchat_email_{$session_id}";
1000 $stored_email = get_option($email_option_key, '');
1001
1002 // Check for stored name
1003 $name_option_key = "mxchat_name_{$session_id}";
1004 $stored_name = get_option($name_option_key, '');
1005
1006 //error_log("[DEBUG] mxchat_check_email_provided -> Checking email option: {$email_option_key}, found: {$stored_email}");
1007 //error_log("[DEBUG] mxchat_check_email_provided -> Checking name option: {$name_option_key}, found: {$stored_name}, required: " . ($name_field_enabled ? 'yes' : 'no'));
1008
1009 // Check if we have email and name (if name is required)
1010 $has_required_info = !empty($stored_email);
1011
1012 if ($name_field_enabled) {
1013 $has_required_info = $has_required_info && !empty($stored_name);
1014 }
1015
1016 if ($has_required_info) {
1017 //error_log("[DEBUG] mxchat_check_email_provided -> Required info found, returning success");
1018
1019 $response_data = ['email' => $stored_email];
1020 if (!empty($stored_name)) {
1021 $response_data['name'] = $stored_name;
1022 }
1023
1024 wp_send_json_success($response_data);
1025 } else {
1026 //error_log("[DEBUG] mxchat_check_email_provided -> Required info missing, returning error");
1027 wp_send_json_error(['message' => esc_html__('No email found', 'mxchat')]);
1028 }
1029 }
1030
1031 /**
1032 * Send error response in appropriate format based on streaming mode
1033 * ADDED: Helper method to consistently handle errors in both streaming and non-streaming modes
1034 *
1035 * @param string $error_message The error message to display
1036 * @param string $error_code Optional error code for debugging
1037 */
1038 private function send_error_response($error_message, $error_code = 'api_error') {
1039 if ($this->is_streaming) {
1040 echo "data: " . json_encode([
1041 'error' => true,
1042 'error_message' => $error_message,
1043 'error_code' => $error_code,
1044 'text' => $error_message,
1045 'message' => $error_message
1046 ]) . "\n\n";
1047 echo "data: [DONE]\n\n";
1048 flush();
1049 } else {
1050 wp_send_json_error([
1051 'error_message' => $error_message,
1052 'error_code' => $error_code
1053 ]);
1054 }
1055 wp_die();
1056 }
1057
1058 public function mxchat_handle_chat_request() {
1059 global $wpdb;
1060
1061 // Debug: Log incoming bot_id
1062 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1063 //error_log("=== MXCHAT DEBUG: Starting chat request ===");
1064 //error_log("MXCHAT DEBUG: Bot ID received: " . $bot_id);
1065
1066 // Get bot-specific options
1067 $bot_options = $this->get_bot_options($bot_id);
1068 $current_options = !empty($bot_options) ? $bot_options : $this->options;
1069
1070 // Check if this is a streaming request
1071 // Allow force_streaming_test parameter to bypass the setting check (for admin compatibility testing)
1072 $force_streaming_test = isset($_POST['force_streaming_test']) && $_POST['force_streaming_test'] === '1' && current_user_can('administrator');
1073 $is_streaming = isset($_POST['action']) && $_POST['action'] === 'mxchat_stream_chat' &&
1074 ($force_streaming_test || (isset($current_options['enable_streaming_toggle']) && $current_options['enable_streaming_toggle'] === 'on'));
1075
1076 // ADDED: Store streaming state in class property for use in private methods
1077 $this->is_streaming = $is_streaming;
1078
1079 // NOTE: Streaming headers are now set later via setup_streaming_headers()
1080 // This allows actions/forms to return JSON responses without header conflicts
1081
1082 // Check if MX Chat Moderation is active
1083 if (class_exists('MX_Chat_Moderation')) {
1084 // Get user email and IP
1085 $user_email = '';
1086 $user_ip = $_SERVER['REMOTE_ADDR'];
1087
1088 // If user is logged in, get their email
1089 if (is_user_logged_in()) {
1090 $current_user = wp_get_current_user();
1091 $user_email = $current_user->user_email;
1092 }
1093
1094 // Create ban handler instance
1095 $ban_handler = new MX_Chat_Ban_Handler();
1096
1097 // Check if user is banned by IP
1098 if ($ban_handler->check_ban($user_ip, 'ip')) {
1099 wp_send_json([
1100 'success' => false,
1101 'message' => esc_html__('Access denied. Your IP address has been banned.', 'mxchat'),
1102 'status' => 'banned'
1103 ]);
1104 wp_die();
1105 }
1106
1107 // If user is logged in, also check email
1108 if (!empty($user_email) && $ban_handler->check_ban($user_email, 'email')) {
1109 wp_send_json([
1110 'success' => false,
1111 'message' => esc_html__('Access denied. Your email address has been banned.', 'mxchat'),
1112 'status' => 'banned'
1113 ]);
1114 wp_die();
1115 }
1116 }
1117
1118 $this->fallbackResponse = ['text' => '', 'html' => '', 'images' => []];
1119 $this->productCardHtml = '';
1120
1121 // Get the actual WordPress user ID if logged in
1122 $is_logged_in = is_user_logged_in();
1123 if ($is_logged_in) {
1124 $user_id = get_current_user_id(); // This will get the actual WordPress user ID
1125 } else {
1126 // For logged-out users, use your existing identifier method
1127 $user_id = $this->mxchat_get_user_identifier();
1128 }
1129
1130 // Get and sanitize the user identifier
1131 $user_id = sanitize_key($user_id);
1132
1133 // Check rate limit using new settings structure
1134 $rate_limit_result = $this->check_rate_limit();
1135
1136 if ($rate_limit_result !== true) {
1137 wp_send_json([
1138 'success' => false,
1139 'message' => $rate_limit_result['message'],
1140 'status' => 'rate_limit_exceeded'
1141 ]);
1142 wp_die();
1143 }
1144
1145 // Rest of your existing code...
1146 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1147
1148 if (empty($session_id)) {
1149 wp_send_json_error(esc_html__('Session ID is missing.', 'mxchat'));
1150 wp_die();
1151 }
1152
1153 // Update session owner if it changed (e.g. IP changed due to network switch)
1154 // The session ID itself is the authentication — if the client has it, they own it
1155 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
1156 $session_owner = get_option("mxchat_session_owner_{$session_id}");
1157
1158 if (!$session_owner || $session_owner !== $current_user_identifier) {
1159 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
1160 }
1161
1162 // Validate and sanitize the incoming message
1163 if (empty($_POST['message'])) {
1164 wp_send_json_error(esc_html__('No message received.', 'mxchat'));
1165 wp_die();
1166 }
1167
1168
1169 // Track originating page for first message in session
1170 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1171
1172 // Check if originating page columns exist
1173 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
1174
1175 if ($columns_exist) {
1176 // Check if this session already has messages
1177 $message_count = $wpdb->get_var($wpdb->prepare(
1178 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
1179 $session_id
1180 ));
1181
1182 // If this is the first message in the session
1183 if ($message_count == 0) {
1184 // Get originating page from JavaScript (preferred) or HTTP_REFERER (fallback)
1185 $originating_url = '';
1186 $originating_title = '';
1187
1188 // Try to get from POST data first (sent by JavaScript)
1189 if (isset($_POST['current_page_url'])) {
1190 $originating_url = esc_url_raw($_POST['current_page_url']);
1191 $originating_title = isset($_POST['current_page_title'])
1192 ? sanitize_text_field($_POST['current_page_title'])
1193 : '';
1194 }
1195 // Fallback to HTTP_REFERER if not provided by JavaScript
1196 else if (isset($_SERVER['HTTP_REFERER'])) {
1197 $originating_url = esc_url_raw($_SERVER['HTTP_REFERER']);
1198 }
1199
1200 // Generate title if we have URL but no title
1201 if ($originating_url && empty($originating_title)) {
1202 $parsed_url = parse_url($originating_url);
1203 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
1204
1205 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
1206 $originating_title = 'Homepage';
1207 } else {
1208 // Clean up the path to make a readable title
1209 $originating_title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
1210 $originating_title = ucwords(trim($originating_title));
1211 }
1212 }
1213
1214 // Store for later use when saving the message
1215 $this->pending_originating_page = [
1216 'url' => $originating_url,
1217 'title' => $originating_title
1218 ];
1219 }
1220 }
1221
1222
1223
1224 // Get page context if provided
1225 $page_context = null;
1226 if (isset($_POST['page_context']) && !empty($_POST['page_context'])) {
1227 $page_context_raw = stripslashes($_POST['page_context']);
1228 $page_context = json_decode($page_context_raw, true);
1229
1230 // Validate page context structure
1231 if (is_array($page_context) &&
1232 isset($page_context['url']) &&
1233 isset($page_context['title']) &&
1234 isset($page_context['content'])) {
1235
1236 // Sanitize page context
1237 $page_context['url'] = esc_url_raw($page_context['url']);
1238 $page_context['title'] = sanitize_text_field($page_context['title']);
1239 $page_context['content'] = wp_kses_post($page_context['content']);
1240 } else {
1241 $page_context = null;
1242 }
1243 }
1244
1245 // Modify the message sanitization to preserve PHP tags in code blocks
1246 $allowed_tags = [
1247 'pre' => [],
1248 'code' => ['class' => true],
1249 'span' => ['class' => true],
1250 'div' => ['class' => true],
1251 ];
1252
1253 // First preserve code blocks
1254 $message = preg_replace_callback('/<pre><code.*?>.*?<\/code><\/pre>/s', function($matches) {
1255 return htmlspecialchars_decode($matches[0]);
1256 }, $_POST['message']);
1257
1258 // Then apply sanitization
1259 $message = wp_kses($message, $allowed_tags);
1260
1261 // Preserve code blocks from markdown conversion
1262 $message = preg_replace('/```(\w+)?\s*([\s\S]+?)```/s', '<pre><code class="$1">$2</code></pre>', $message);
1263 $message = apply_filters('mxchat_filter_message', $message, 'prompt', $session_id);
1264
1265 // ===== SIMPLIFIED TESTING PANEL INITIALIZATION =====
1266 // Always initialize testing data for admins (no toggle needed)
1267 $testing_data = null;
1268 if (current_user_can('administrator')) {
1269 // For vision messages, use the original user message for the query display
1270 $query_for_testing = $message;
1271 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1272 $query_for_testing = sanitize_textarea_field($_POST['original_user_message']);
1273 }
1274
1275 $testing_data = [
1276 'query' => $query_for_testing,
1277 'timestamp' => time(),
1278 'top_matches' => [],
1279 'action_matches' => [], // Initialize action matches array
1280 'page_context' => $page_context, // Include page context in testing data
1281 'is_vision' => isset($_POST['vision_processed']) && $_POST['vision_processed'],
1282 'bot_id' => $bot_id // Include bot ID in testing data
1283 ];
1284
1285 // Get similarity threshold from bot options or default options
1286 $similarity_threshold = isset($current_options['similarity_threshold'])
1287 ? ((int) $current_options['similarity_threshold']) / 100
1288 : 0.35;
1289
1290 $testing_data['similarity_threshold'] = $similarity_threshold;
1291
1292 // Determine knowledge base type using bot-specific config
1293 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
1294 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
1295 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
1296 }
1297 // ===== END SIMPLIFIED TESTING INITIALIZATION =====
1298
1299 // Add debug before and after:
1300 //error_log('MxChat Core: About to call mxchat_pre_process_message filter with message: ' . $message);
1301 $pre_processed_result = apply_filters('mxchat_pre_process_message', $message, $user_id, $session_id);
1302 //error_log('MxChat Core: Filter returned: ' . (is_array($pre_processed_result) ? 'array' : $pre_processed_result));
1303
1304
1305 // If the pre-processing returned a result (not the original message), use it directly
1306 if (is_array($pre_processed_result) && isset($pre_processed_result['text'])) {
1307 // Save the AI response
1308 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['text']);
1309
1310 // Save HTML content if provided
1311 if (!empty($pre_processed_result['html'])) {
1312 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['html']);
1313 }
1314
1315 // Add testing data if admin
1316 $response_data = [
1317 'text' => $pre_processed_result['text'],
1318 'html' => $pre_processed_result['html'] ?? '',
1319 'session_id' => $session_id
1320 ];
1321
1322 if ($testing_data !== null) {
1323 $response_data['testing_data'] = $testing_data;
1324 }
1325
1326 wp_send_json($response_data);
1327 wp_die();
1328 }
1329
1330 // Save the user's message - handle vision processed messages differently
1331 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1332 // For vision messages, save the original user message with image indicator
1333 $original_message = sanitize_textarea_field($_POST['original_user_message']);
1334 if (isset($_POST['vision_images_count']) && $_POST['vision_images_count'] > 0) {
1335 $image_count = intval($_POST['vision_images_count']);
1336 $original_message .= " [{$image_count} image(s)]";
1337 }
1338 $this->mxchat_save_chat_message($session_id, 'user', $original_message);
1339 } else {
1340 // Regular message - save as normal
1341 $this->mxchat_save_chat_message($session_id, 'user', $message);
1342 }
1343
1344
1345 if (is_email($message)) {
1346 // Add the email to Loops
1347 $this->add_email_to_loops($message);
1348
1349 // Get the user's success message instruction using current_options
1350 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1351
1352 // Set instruction for AI using the user's success message
1353 $this->current_action_instruction = $user_success_message;
1354
1355 // Clear the email capture transient since we got the email
1356 delete_transient('mxchat_email_capture_' . $user_id);
1357 }
1358
1359 // Check if we're in an email capture flow but user hasn't provided email yet
1360 elseif (get_transient('mxchat_email_capture_' . $user_id)) {
1361 // Check if the message contains an email (not the whole message being an email)
1362 if (preg_match('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', $message, $matches)) {
1363 $extracted_email = $matches[0];
1364
1365 // Add the extracted email to Loops
1366 $this->add_email_to_loops($extracted_email);
1367
1368 // Get the user's success message instruction using current_options
1369 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1370
1371 // Set instruction for AI using the user's success message
1372 $this->current_action_instruction = $user_success_message;
1373
1374 // Clear the email capture transient since we got the email
1375 delete_transient('mxchat_email_capture_' . $user_id);
1376 }
1377 // If no email found but we're in capture mode, remind them
1378 else {
1379 // Get the original instruction to remind them using current_options
1380 $original_instruction = $current_options['triggered_phrase_response'] ?? __("Please provide your email address.", 'mxchat');
1381 $this->current_action_instruction = $original_instruction;
1382 }
1383 }
1384
1385 $intent_info = '';
1386
1387 // Check chat mode
1388 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1389
1390 // Handle agent mode
1391 // Handle agent mode
1392 if ($chat_mode === 'agent') {
1393 // First, check for switch intent before doing anything else
1394 $intent_matched = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1395
1396 // Capture action analysis for testing panel after intent check
1397 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1398 $testing_data['action_matches'] = $this->last_action_analysis;
1399 }
1400
1401 // Around line 506, in the agent mode handling section:
1402 if ($intent_matched && !empty($this->fallbackResponse['text'])) {
1403 // Update chat mode first
1404 update_option("mxchat_mode_{$session_id}", 'ai');
1405
1406 // Clear any existing PDF context to start fresh
1407 $this->clear_pdf_transients($session_id);
1408
1409 // Prepare clean switch response with explicit chat_mode
1410 $response_data = [
1411 'text' => $this->fallbackResponse['text'],
1412 'html' => $this->fallbackResponse['html'] ?? '',
1413 'session_id' => $session_id,
1414 'chat_mode' => 'ai' // EXPLICITLY SET THIS
1415 ];
1416
1417 if ($testing_data !== null) {
1418 $response_data['testing_data'] = $testing_data;
1419 }
1420
1421 // Save the mode switch message
1422 $this->mxchat_save_chat_message($session_id, 'system', esc_html__('Switched to AI chat mode', 'mxchat'));
1423 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1424
1425 // Send response and exit
1426 wp_send_json($response_data);
1427 wp_die();
1428 } elseif (!$intent_matched) {
1429 // No intent matched, handle live agent message
1430 try {
1431 $this->mxchat_send_user_message_to_agent($message, $user_id, $session_id);
1432
1433 $agent_response = [
1434 'status' => 'waiting_for_agent',
1435 'message' => esc_html__('Message sent to live agent.', 'mxchat')
1436 ];
1437
1438 if ($testing_data !== null) {
1439 $agent_response['testing_data'] = $testing_data;
1440 }
1441
1442 wp_send_json_success($agent_response);
1443 } catch (\Exception $e) {
1444 wp_send_json_error(esc_html__('Failed to send message to agent', 'mxchat'));
1445 }
1446 wp_die();
1447 }
1448 }
1449
1450 // Step 1: Check for new PDF URL in the message
1451 if (!isset($_POST['vision_processed']) && preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
1452 $new_pdf_url = $matches[0];
1453
1454 // Check if this is likely a PDF-related request
1455 $pdf_keywords = ['pdf', 'document', 'read', 'analyze'];
1456 $is_pdf_request = false;
1457
1458 foreach ($pdf_keywords as $keyword) {
1459 if (stripos($message, $keyword) !== false) {
1460 $is_pdf_request = true;
1461 break;
1462 }
1463 }
1464
1465 // If it looks like a PDF request or we're waiting for a PDF URL
1466 if ($is_pdf_request || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
1467 // Validate HTTPS
1468 if (wp_http_validate_url($new_pdf_url) && parse_url($new_pdf_url, PHP_URL_SCHEME) === 'https') {
1469 // Extract filename from URL
1470 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1471
1472 // Clear previous PDF transients
1473 $this->clear_pdf_transients($session_id);
1474
1475 // Process new PDF using current_options
1476 $max_pages = $current_options['pdf_max_pages'] ?? 69;
1477 $embeddings = $this->fetch_and_split_pdf_pages($new_pdf_url, $max_pages);
1478
1479 if ($embeddings === 'too_many_pages') {
1480 $error_text = sprintf(
1481 $current_options['pdf_intent_error_text'] ??
1482 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
1483 $max_pages
1484 );
1485 $this->fallbackResponse['text'] = $error_text;
1486 } elseif ($embeddings) {
1487 // Store new PDF information
1488 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1489
1490 // If the filename is generic, create a more descriptive one
1491 if (in_array($pdf_filename, ['results_download.php', 'download.php', 'view.php', 'pdf.php']) ||
1492 strpos($pdf_filename, '.php') !== false) {
1493 $pdf_filename = 'Document_' . date('Y-m-d_H-i') . '.pdf';
1494 }
1495
1496 set_transient('mxchat_pdf_url_' . $session_id, $new_pdf_url, HOUR_IN_SECONDS);
1497 set_transient('mxchat_pdf_filename_' . $session_id, $pdf_filename, HOUR_IN_SECONDS);
1498 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
1499 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
1500
1501 $success_text = $current_options['pdf_intent_success_text'] ??
1502 esc_html__("I've processed the new PDF '{$pdf_filename}'. What questions do you have about it?", 'mxchat');
1503
1504 $pdf_response = [
1505 'success' => true,
1506 'message' => $success_text,
1507 'data' => [
1508 'filename' => $pdf_filename
1509 ]
1510 ];
1511
1512 if ($testing_data !== null) {
1513 $pdf_response['testing_data'] = $testing_data;
1514 }
1515
1516 wp_send_json($pdf_response);
1517 wp_die();
1518 } else {
1519 $error_text = $current_options['pdf_intent_error_text'] ??
1520 esc_html__("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
1521 $this->fallbackResponse['text'] = $error_text;
1522 }
1523
1524 $pdf_error_response = [
1525 'success' => false,
1526 'message' => $this->fallbackResponse['text']
1527 ];
1528
1529 if ($testing_data !== null) {
1530 $pdf_error_response['testing_data'] = $testing_data;
1531 }
1532
1533 wp_send_json($pdf_error_response);
1534 wp_die();
1535 }
1536 }
1537 }
1538
1539
1540 // Step 2: Detect intent and handle intent-based responses
1541 $intent_result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1542
1543 // Capture action analysis for testing panel after intent check
1544 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1545 $testing_data['action_matches'] = $this->last_action_analysis;
1546 }
1547
1548 // Step 3: Handle the intent result appropriately
1549 if ($intent_result !== false) {
1550 // Intent was matched - ALWAYS send as JSON response, never streaming
1551
1552 if (is_array($intent_result) && (isset($intent_result['text']) || isset($intent_result['html']))) {
1553 // Intent returned a direct response array
1554 $response_data = [
1555 'text' => $intent_result['text'] ?? '',
1556 'html' => $intent_result['html'] ?? '',
1557 'session_id' => $session_id
1558 ];
1559
1560 // IMPORTANT: Include chat_mode if present (for WhatsApp, Slack, etc.)
1561 if (isset($intent_result['chat_mode'])) {
1562 $response_data['chat_mode'] = $intent_result['chat_mode'];
1563 }
1564
1565 if ($testing_data !== null) {
1566 $response_data['testing_data'] = $testing_data;
1567 }
1568
1569 wp_send_json($response_data);
1570 wp_die();
1571 } else if ($intent_result === true && (!empty($this->fallbackResponse['text']) || !empty($this->fallbackResponse['html']))) {
1572 // Intent returned true and set fallbackResponse
1573
1574 // SAVE TO TRANSCRIPT
1575 if (!empty($this->fallbackResponse['text'])) {
1576 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1577 }
1578 // Save action HTML (product cards, featured products, etc.) so it renders in transcripts
1579 if (!empty($this->fallbackResponse['html'])) {
1580 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1581 }
1582
1583 $response_data = [
1584 'text' => $this->fallbackResponse['text'] ?? '',
1585 'html' => $this->fallbackResponse['html'] ?? '',
1586 'session_id' => $session_id
1587 ];
1588
1589 if (isset($this->fallbackResponse['chat_mode'])) {
1590 $response_data['chat_mode'] = $this->fallbackResponse['chat_mode'];
1591 }
1592
1593 if ($testing_data !== null) {
1594 $response_data['testing_data'] = $testing_data;
1595 }
1596
1597 wp_send_json($response_data);
1598 wp_die();
1599 }
1600 }
1601
1602 // If we get here, no intent matched OR the intent didn't provide a usable response
1603
1604 // Step 4: Generate AI response
1605 // Get session start timestamp - when persistence is OFF, only include messages from this page load
1606 $session_start_timestamp = isset($_POST['session_start_timestamp']) ? intval($_POST['session_start_timestamp']) : 0;
1607 $conversation_history = $this->mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp);
1608 $this->mxchat_increment_chat_count();
1609
1610 // Generate embedding for the user's query - USE BOT-SPECIFIC API KEY
1611 $api_key = $current_options['api_key'] ?? $this->options['api_key'];
1612 $user_message_embedding = $this->mxchat_generate_embedding($message, $api_key);
1613
1614 // Check if the embedding generation returned an error
1615 if (is_array($user_message_embedding) && isset($user_message_embedding['error'])) {
1616 $error_message = $user_message_embedding['error'];
1617 $error_code = $user_message_embedding['error_code'] ?? 'embedding_error';
1618
1619 // FIXED: Send error in appropriate format based on streaming mode
1620 if ($is_streaming) {
1621 echo "data: " . json_encode([
1622 'error' => true,
1623 'error_message' => $error_message,
1624 'error_code' => $error_code,
1625 'text' => $error_message,
1626 'message' => $error_message
1627 ]) . "\n\n";
1628 echo "data: [DONE]\n\n";
1629 flush();
1630 } else {
1631 wp_send_json_error([
1632 'error_message' => $error_message,
1633 'error_code' => $error_code
1634 ]);
1635 }
1636 wp_die();
1637 }
1638
1639 // Check if the embedding is valid
1640 if (!is_array($user_message_embedding) || empty($user_message_embedding)) {
1641 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
1642
1643 // FIXED: Send error in appropriate format based on streaming mode
1644 if ($is_streaming) {
1645 echo "data: " . json_encode([
1646 'error' => true,
1647 'error_message' => $error_message,
1648 'error_code' => 'invalid_embedding',
1649 'text' => $error_message,
1650 'message' => $error_message
1651 ]) . "\n\n";
1652 echo "data: [DONE]\n\n";
1653 flush();
1654 } else {
1655 wp_send_json_error([
1656 'error_message' => $error_message,
1657 'error_code' => 'invalid_embedding'
1658 ]);
1659 }
1660 wp_die();
1661 }
1662
1663 // Build context with both knowledge base and PDF content if available
1664 $context_content = "User asked: '{$message}'\n\n";
1665
1666 // Add action instruction if present (add this right after the above line)
1667 if (!empty($this->current_action_instruction)) {
1668 $context_content .= "===== SPECIAL INSTRUCTION =====\n";
1669 $context_content .= "IMPORTANT: " . $this->current_action_instruction . "\n";
1670 $context_content .= "Respond naturally and conversationally while following this instruction.\n";
1671 $context_content .= "===== END SPECIAL INSTRUCTION =====\n\n";
1672
1673 // Clear the instruction after using it
1674 $this->current_action_instruction = null;
1675 }
1676
1677
1678 // Add page context if available and contextual awareness is enabled using current_options
1679 if ($page_context && isset($current_options['contextual_awareness_toggle']) && $current_options['contextual_awareness_toggle'] === 'on') {
1680 $context_content .= "===== CURRENT PAGE CONTEXT =====\n";
1681 $context_content .= "Page URL: " . $page_context['url'] . "\n";
1682 $context_content .= "Page Title: " . $page_context['title'] . "\n";
1683 $context_content .= "Page Content: " . $page_context['content'] . "\n";
1684 $context_content .= "===== END CURRENT PAGE CONTEXT =====\n\n";
1685 }
1686
1687 // Get relevant content from knowledge base - PASS BOT_ID and MESSAGE for Vector Store
1688 $relevant_content = $this->mxchat_find_relevant_content($user_message_embedding, $bot_id, $message);
1689
1690 // NEW: Also extract URLs from system instructions (only if citation links enabled)
1691 // Use fresh options to ensure we get the latest setting value
1692 $fresh_options = get_option('mxchat_options', []);
1693 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
1694
1695 $system_instructions = $this->get_system_instructions($bot_id, $session_id);
1696 if ($citation_links_enabled && !empty($system_instructions)) {
1697 preg_match_all(
1698 '#\bhttps?://[^\s<>"\']+#i',
1699 $system_instructions,
1700 $system_instruction_urls
1701 );
1702
1703 if (!empty($system_instruction_urls[0])) {
1704 // Merge with existing valid URLs
1705 $this->current_valid_urls = array_merge(
1706 $this->current_valid_urls,
1707 $system_instruction_urls[0]
1708 );
1709 // Remove duplicates
1710 $this->current_valid_urls = array_unique($this->current_valid_urls);
1711
1712 //error_log("Added " . count($system_instruction_urls[0]) . " URLs from system instructions");
1713 }
1714 }
1715
1716 // ===== CAPTURE REAL SIMILARITY DATA FOR ADMINS =====
1717 if ($testing_data !== null && $this->last_similarity_analysis !== null) {
1718 // Update testing data with the REAL similarity analysis
1719 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
1720 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
1721 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
1722 $testing_data['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
1723 $testing_data['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
1724 }
1725 // ===== END SIMILARITY DATA CAPTURE =====
1726
1727 // NEW: Add valid URLs to testing data for admin panel display (AFTER similarity data)
1728 if ($testing_data !== null && !empty($this->current_valid_urls)) {
1729 $testing_data['approved_urls'] = array_values($this->current_valid_urls);
1730 //error_log("Added " . count($this->current_valid_urls) . " approved URLs to testing data");
1731 }
1732
1733 if (!empty($relevant_content)) {
1734 $context_content .= "===== OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n" . $relevant_content . "\n===== END OF OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n\n";
1735 } else {
1736 $context_content .= "===== NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE =====\n";
1737 }
1738
1739 // NEW: Add approved URLs list to context for AI (only if citation links enabled)
1740 if ($citation_links_enabled && !empty($this->current_valid_urls)) {
1741 $context_content .= "===== APPROVED URLS FOR CITATIONS =====\n";
1742 $context_content .= "You may ONLY use these exact URLs in your response:\n";
1743 foreach ($this->current_valid_urls as $url) {
1744 $context_content .= "- " . $url . "\n";
1745 }
1746 $context_content .= "\nCRITICAL: Do NOT create, modify, extend, or invent any other URLs. ";
1747 $context_content .= "===== END APPROVED URLS =====\n\n";
1748 }
1749
1750 // Check for and include PDF content
1751 $pdf_url = get_transient('mxchat_pdf_url_' . $session_id);
1752 $pdf_embeddings = get_transient('mxchat_pdf_embeddings_' . $session_id);
1753 $pdf_filename = get_transient('mxchat_pdf_filename_' . $session_id);
1754 if ($pdf_url && $pdf_embeddings && get_transient('mxchat_include_pdf_in_context_' . $session_id)) {
1755 $relevant_pdf_pages = $this->find_relevant_pdf_pages($user_message_embedding, $pdf_embeddings);
1756 if (!empty($relevant_pdf_pages)) {
1757 $context_content .= "Relevant content from PDF document '{$pdf_filename}':\n";
1758 foreach ($relevant_pdf_pages as $page_data) {
1759 $context_content .= "Page {$page_data['page_number']} of '{$pdf_filename}': {$page_data['text']}\n";
1760 }
1761 $context_content .= "\n";
1762 }
1763 }
1764
1765 // Check for and include Word content
1766 $word_url = get_transient('mxchat_word_url_' . $session_id);
1767 $word_embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
1768 $word_filename = get_transient('mxchat_word_filename_' . $session_id);
1769 if ($word_url && $word_embeddings && get_transient('mxchat_include_word_in_context_' . $session_id)) {
1770 $relevant_word_chunks = $this->word_handler->mxchat_find_relevant_word_chunks($user_message_embedding, $word_embeddings);
1771 if (!empty($relevant_word_chunks)) {
1772 $context_content .= "Relevant content from Word document '{$word_filename}':\n";
1773 foreach ($relevant_word_chunks as $chunk_data) {
1774 $context_content .= "Section {$chunk_data['chunk_number']} of '{$word_filename}': {$chunk_data['text']}\n";
1775 }
1776 $context_content .= "\n";
1777 }
1778 }
1779
1780 $context_content = apply_filters('mxchat_prepare_context', $context_content, $session_id);
1781
1782 // Extract model from current options for bot-specific model support
1783 $selected_model = isset($current_options['model']) ? $current_options['model'] : 'gpt-5.1-chat-latest';
1784
1785 $response = $this->mxchat_generate_response(
1786 $context_content,
1787 $current_options['api_key'] ?? $this->options['api_key'],
1788 $current_options['xai_api_key'] ?? $this->options['xai_api_key'],
1789 $current_options['claude_api_key'] ?? $this->options['claude_api_key'],
1790 $current_options['deepseek_api_key'] ?? $this->options['deepseek_api_key'],
1791 $current_options['gemini_api_key'] ?? $this->options['gemini_api_key'],
1792 $current_options['openrouter_api_key'] ?? $this->options['openrouter_api_key'],
1793 $conversation_history,
1794 $is_streaming,
1795 $session_id,
1796 $testing_data,
1797 $selected_model
1798 );
1799
1800 // Handle streaming vs non-streaming responses
1801 if ($is_streaming) {
1802 // Check if streaming actually happened or if it fell back to regular response
1803 if ($response === true) {
1804 wp_die();
1805 }
1806 // If we get here, streaming fell back to regular response, continue
1807 // But if there's an error, we need to send it as SSE format since headers are already set
1808 if (is_array($response) && isset($response['error'])) {
1809 $error_message = $response['error'];
1810 $error_code = $response['error_code'] ?? 'api_error';
1811 // Send error in SSE format that the client JS can handle
1812 echo "data: " . json_encode([
1813 'error' => true,
1814 'error_message' => $error_message,
1815 'error_code' => $error_code,
1816 'text' => $error_message, // Also include as text for fallback handling
1817 'message' => $error_message
1818 ]) . "\n\n";
1819 echo "data: [DONE]\n\n";
1820 flush();
1821 wp_die();
1822 }
1823 }
1824
1825 // Check if the response is an error array (non-streaming mode)
1826 if (is_array($response) && isset($response['error'])) {
1827 wp_send_json_error([
1828 'error_message' => $response['error'],
1829 'error_code' => $response['error_code'] ?? 'api_error'
1830 ]);
1831 wp_die();
1832 }
1833
1834 // DEBUG: Check what we have
1835 //error_log("=== BEFORE URL VALIDATION ===");
1836 //error_log("current_valid_urls is empty? " . (empty($this->current_valid_urls) ? 'YES' : 'NO'));
1837 //error_log("current_valid_urls count: " . count($this->current_valid_urls));
1838 //error_log("current_valid_urls content: " . print_r($this->current_valid_urls, true));
1839
1840 // If we get here, the response is valid text - now validate URLs
1841 if (!empty($this->current_valid_urls)) {
1842 //error_log("CALLING validate_and_clean_urls");
1843 $response = $this->validate_and_clean_urls($response, $this->current_valid_urls);
1844 } else {
1845 //error_log("SKIPPING validation - current_valid_urls is empty");
1846 }
1847 // ===== END URL VALIDATION =====
1848
1849 // Prepare RAG context data for storage (only include documents used for context)
1850 $rag_context_for_storage = null;
1851 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
1852 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
1853
1854 if ($has_rag_data || $has_action_data) {
1855 $rag_context_for_storage = [];
1856
1857 // Add RAG/source data if available
1858 if ($has_rag_data) {
1859 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
1860 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
1861 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
1862 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
1863 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
1864 $rag_context_for_storage['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
1865 $rag_context_for_storage['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
1866 }
1867
1868 // Add action analysis data if available
1869 if ($has_action_data) {
1870 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
1871 }
1872 }
1873
1874 // Save the cleaned response with RAG context
1875 $this->mxchat_save_chat_message($session_id, 'bot', $response, null, $rag_context_for_storage);
1876
1877 // Step 5: Save additional content if available
1878 if (!empty($this->productCardHtml)) {
1879 $this->mxchat_save_chat_message($session_id, 'bot', $this->productCardHtml);
1880 }
1881
1882 if (!empty($this->fallbackResponse['html'])) {
1883 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1884 }
1885
1886 // Step 6: Return the response
1887 // DEBUG: Check if newlines exist in the response
1888 //error_log("=== MXCHAT NON-STREAMING RESPONSE DEBUG ===");
1889 //error_log("Response has newlines: " . (strpos($response, "\n") !== false ? 'YES' : 'NO'));
1890 //error_log("Response first 500 chars: " . substr($response, 0, 500));
1891
1892 $response_data = [
1893 'text' => $response,
1894 'html' => !empty($this->productCardHtml) ? $this->productCardHtml : ($this->fallbackResponse['html'] ?? ''),
1895 'session_id' => $session_id
1896 ];
1897
1898 // Include vectorstore error info for admin debugging (only visible to admins via testing_data)
1899 if (!empty($this->last_vectorstore_error) && $testing_data !== null) {
1900 $testing_data['vectorstore_error'] = $this->last_vectorstore_error;
1901 }
1902
1903 // Also pass it as a top-level field so JS can show a better error message to admins
1904 if (!empty($this->last_vectorstore_error) && current_user_can('manage_options')) {
1905 $response_data['vectorstore_error'] = $this->last_vectorstore_error;
1906 }
1907
1908 // Always add testing data for admins (no toggle needed)
1909 if ($testing_data !== null) {
1910 $response_data['testing_data'] = $testing_data;
1911 }
1912
1913 wp_send_json($response_data);
1914 wp_die();
1915 }
1916
1917 /**
1918 * Get bot-specific options for multi-bot functionality
1919 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
1920 */
1921 // Also debug the bot options retrieval
1922 private function get_bot_options($bot_id = 'default') {
1923 //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
1924
1925 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1926 //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
1927 return array();
1928 }
1929
1930 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
1931
1932 if (!empty($bot_options)) {
1933 //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
1934 if (isset($bot_options['similarity_threshold'])) {
1935 //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
1936 }
1937 }
1938
1939 return is_array($bot_options) ? $bot_options : array();
1940 }
1941
1942 /**
1943 * Get bot-specific Pinecone configuration
1944 * Used in the knowledge retrieval functions
1945 */
1946 // Also add debugging to your get_bot_pinecone_config function
1947 private function get_bot_pinecone_config($bot_id = 'default') {
1948 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
1949
1950 // If default bot or multi-bot add-on not active, use default Pinecone config
1951 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1952 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
1953 $addon_options = get_option('mxchat_pinecone_addon_options', array());
1954 $config = array(
1955 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
1956 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
1957 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
1958 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
1959 );
1960 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
1961 return $config;
1962 }
1963
1964 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
1965
1966 // Hook for multi-bot add-on to provide bot-specific Pinecone config
1967 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1968
1969 if (!empty($bot_pinecone_config)) {
1970 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
1971 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
1972 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
1973 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
1974 } else {
1975 //error_log("MXCHAT DEBUG: Filter returned empty config!");
1976 }
1977
1978 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
1979 }
1980
1981
1982 // Updated function to check intents and invoke the callback function
1983 private function mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id) {
1984 global $wpdb;
1985 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1986
1987 // Get the current bot_id
1988 $current_bot_id = $this->get_current_bot_id($session_id);
1989
1990 // Generate the user embedding
1991 $user_embedding = $this->mxchat_generate_embedding($message, $this->options['api_key']);
1992
1993 // Check if embedding generation returned an error
1994 if (is_array($user_embedding) && isset($user_embedding['error'])) {
1995 $error_message = $user_embedding['error'];
1996 $error_code = $user_embedding['error_code'] ?? 'embedding_error';
1997
1998 // FIXED: Send error in appropriate format based on streaming mode
1999 if ($this->is_streaming) {
2000 echo "data: " . json_encode([
2001 'error' => true,
2002 'error_message' => $error_message,
2003 'error_code' => $error_code,
2004 'text' => $error_message,
2005 'message' => $error_message
2006 ]) . "\n\n";
2007 echo "data: [DONE]\n\n";
2008 flush();
2009 } else {
2010 wp_send_json_error([
2011 'error_message' => $error_message,
2012 'error_code' => $error_code
2013 ]);
2014 }
2015 wp_die();
2016 }
2017
2018 // Check if embedding is valid
2019 if (!is_array($user_embedding) || empty($user_embedding)) {
2020 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
2021
2022 // FIXED: Send error in appropriate format based on streaming mode
2023 if ($this->is_streaming) {
2024 echo "data: " . json_encode([
2025 'error' => true,
2026 'error_message' => $error_message,
2027 'error_code' => 'invalid_embedding',
2028 'text' => $error_message,
2029 'message' => $error_message
2030 ]) . "\n\n";
2031 echo "data: [DONE]\n\n";
2032 flush();
2033 } else {
2034 wp_send_json_error([
2035 'error_message' => $error_message,
2036 'error_code' => 'invalid_embedding'
2037 ]);
2038 }
2039 wp_die();
2040 }
2041
2042 // Fetch intents from the database
2043 $table_name = $wpdb->prefix . 'mxchat_intents';
2044 if ($chat_mode === 'agent') {
2045 $query = $wpdb->prepare(
2046 "SELECT * FROM $table_name WHERE callback_function = %s AND (enabled = 1 OR enabled IS NULL)",
2047 'mxchat_handle_switch_to_chatbot_intent'
2048 );
2049 $intents = $wpdb->get_results($query);
2050 } else {
2051 $intents = $wpdb->get_results("SELECT * FROM $table_name WHERE enabled = 1 OR enabled IS NULL");
2052 }
2053
2054 if (empty($intents)) {
2055 return false;
2056 }
2057
2058 // Prefetch individual phrase vectors from wp_mxchat_intent_phrases (grouped by intent_id)
2059 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
2060 $phrases_by_intent = [];
2061 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
2062 $all_phrases = $wpdb->get_results("SELECT intent_id, phrase, embedding_vector FROM $phrases_table");
2063 foreach ($all_phrases as $p) {
2064 $phrases_by_intent[$p->intent_id][] = $p;
2065 }
2066 }
2067
2068 $highest_similarity = -INF;
2069 $matched_intent = null;
2070
2071 // Array to store action analysis for testing panel
2072 $action_analysis = [];
2073
2074 foreach ($intents as $intent) {
2075 // Additional check for enabled state
2076 $is_enabled = isset($intent->enabled) ? (bool)$intent->enabled : true;
2077 if (!$is_enabled) {
2078 continue;
2079 }
2080
2081 // Check if this action is enabled for the current bot
2082 if (!$this->is_action_enabled_for_bot($intent, $current_bot_id)) {
2083 continue;
2084 }
2085
2086 $best_similarity = -INF;
2087 $matched_phrase_text = '';
2088
2089 // Check legacy embedding vector (existing behavior)
2090 $intent_embedding_serialized = $intent->embedding_vector;
2091 $intent_embedding = $intent_embedding_serialized
2092 ? unserialize($intent_embedding_serialized, ['allowed_classes' => false])
2093 : null;
2094
2095 if (is_array($intent_embedding) && !empty($intent_embedding)) {
2096 $legacy_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $intent_embedding);
2097 if ($legacy_similarity > $best_similarity) {
2098 $best_similarity = $legacy_similarity;
2099 $matched_phrase_text = 'legacy';
2100 }
2101 }
2102
2103 // Check individual phrase vectors
2104 if (isset($phrases_by_intent[$intent->id])) {
2105 foreach ($phrases_by_intent[$intent->id] as $phrase_row) {
2106 $phrase_embedding = $phrase_row->embedding_vector
2107 ? unserialize($phrase_row->embedding_vector, ['allowed_classes' => false])
2108 : null;
2109 if (!is_array($phrase_embedding)) {
2110 continue;
2111 }
2112 $phrase_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $phrase_embedding);
2113 if ($phrase_similarity > $best_similarity) {
2114 $best_similarity = $phrase_similarity;
2115 $matched_phrase_text = $phrase_row->phrase;
2116 }
2117 }
2118 }
2119
2120 // Skip if no valid embedding was found at all
2121 if ($best_similarity === -INF) {
2122 continue;
2123 }
2124
2125 $similarity = $best_similarity;
2126 $intent_threshold = isset($intent->similarity_threshold) ? $intent->similarity_threshold : 0.85;
2127
2128 // Store action analysis data for testing panel
2129 $action_analysis[] = [
2130 'intent_label' => $intent->intent_label,
2131 'callback_function' => $intent->callback_function,
2132 'similarity' => round($similarity, 4),
2133 'similarity_percentage' => round($similarity * 100, 2),
2134 'threshold' => $intent_threshold,
2135 'threshold_percentage' => round($intent_threshold * 100, 2),
2136 'above_threshold' => $similarity >= $intent_threshold,
2137 'matched_phrase' => $matched_phrase_text,
2138 'triggered' => false // Will be updated below if this intent is triggered
2139 ];
2140
2141 if ($similarity >= $intent_threshold && $similarity > $highest_similarity) {
2142 $highest_similarity = $similarity;
2143 $matched_intent = $intent;
2144 }
2145 }
2146
2147 // Mark the triggered action if any
2148 if ($matched_intent) {
2149 foreach ($action_analysis as &$action) {
2150 if ($action['intent_label'] === $matched_intent->intent_label) {
2151 $action['triggered'] = true;
2152 break;
2153 }
2154 }
2155 }
2156
2157 // Sort actions by similarity (highest first) and store for testing panel
2158 usort($action_analysis, function($a, $b) {
2159 return $b['similarity'] <=> $a['similarity'];
2160 });
2161
2162 // Store action analysis for testing panel capture
2163 $this->last_action_analysis = $action_analysis;
2164
2165 // Around line 715 in your mxchat_check_intent_and_invoke_callback function
2166 if ($matched_intent) {
2167 // If the callback is a method on this instance (core callback), call it directly
2168 if (method_exists($this, $matched_intent->callback_function)) {
2169 $callback_result = call_user_func(
2170 [$this, $matched_intent->callback_function],
2171 $message,
2172 $user_id,
2173 $session_id,
2174 $matched_intent,
2175 $user_context ?? null
2176 );
2177 } else {
2178 // Otherwise, use apply_filters for add-on callbacks
2179 $callback_result = apply_filters(
2180 $matched_intent->callback_function,
2181 false,
2182 $message,
2183 $user_id,
2184 $session_id,
2185 $matched_intent
2186 );
2187 }
2188
2189 // Handle the callback result properly
2190 if ($callback_result !== false) {
2191 // If callback returned an array with chat_mode, use it directly
2192 if (is_array($callback_result) && isset($callback_result['chat_mode'])) {
2193 $this->fallbackResponse = $callback_result;
2194 return $callback_result; // Return the full array
2195 } else {
2196 $this->fallbackResponse = $callback_result;
2197 return true;
2198 }
2199 }
2200 }
2201
2202 return false;
2203 }
2204
2205 /**
2206 * Check if an action is enabled for a specific bot
2207 */
2208 private function is_action_enabled_for_bot($intent, $bot_id) {
2209 // If enabled_bots column doesn't exist or is null, assume it's enabled for all bots (backward compatibility)
2210 if (!isset($intent->enabled_bots) || empty($intent->enabled_bots)) {
2211 return true;
2212 }
2213
2214 $enabled_bots = json_decode($intent->enabled_bots, true);
2215
2216 // If JSON decode fails or returns empty array, assume enabled for all (backward compatibility)
2217 if (!is_array($enabled_bots) || empty($enabled_bots)) {
2218 return true;
2219 }
2220
2221 // Admin testing tab uses bot_id "testing" — treat it as "default" so all
2222 // default-bot actions are testable from the admin panel
2223 if ($bot_id === 'testing') {
2224 $bot_id = 'default';
2225 }
2226
2227 // Check if the current bot is in the enabled bots list
2228 return in_array($bot_id, $enabled_bots);
2229 }
2230
2231 // Helper function to clear PDF and Word document related transients
2232 private function clear_pdf_transients($session_id) {
2233 // PDF transients
2234 delete_transient('mxchat_pdf_url_' . $session_id);
2235 delete_transient('mxchat_pdf_embeddings_' . $session_id);
2236 delete_transient('mxchat_include_pdf_in_context_' . $session_id);
2237 delete_transient('mxchat_waiting_for_pdf_url_' . $session_id);
2238
2239 // Word document transients
2240 delete_transient('mxchat_word_url_' . $session_id);
2241 delete_transient('mxchat_word_filename_' . $session_id);
2242 delete_transient('mxchat_word_embeddings_' . $session_id);
2243 delete_transient('mxchat_include_word_in_context_' . $session_id);
2244 delete_transient('mxchat_waiting_for_word_' . $session_id);
2245 }
2246
2247
2248
2249 //verified good
2250 public function mxchat_handle_email_capture($message, $user_id, $session_id) {
2251 // Get the user's original instruction/message
2252 $user_instruction = esc_html($this->options['triggered_phrase_response'] ?? esc_html__("Please provide your email address.", 'mxchat'));
2253
2254 // Set instruction for AI - just pass along what the user wanted to say
2255 $this->current_action_instruction = $user_instruction;
2256
2257 // Set the transient to track email capture flow
2258 set_transient('mxchat_email_capture_' . $user_id, true, 5 * MINUTE_IN_SECONDS);
2259
2260 // Return false to let the AI generate the response
2261 return false;
2262 }
2263
2264 public function mxchat_generate_image($message, $user_id, $session_id) {
2265 //error_log("Starting image generation for message: " . $message);
2266
2267 // Prepare a prompt for OpenAI image generation
2268 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2269
2270 // Use the existing OpenAI API key
2271 $openai_api_key = sanitize_text_field($this->options['api_key']);
2272
2273 // Call OpenAI GPT Image to generate an image
2274 $image_response = $this->mxchat_generate_openai_image($prompt, $openai_api_key);
2275
2276 // Check if the response contains an image URL
2277 if (isset($image_response['imageUrl'])) {
2278 $image_url = esc_url_raw($image_response['imageUrl']);
2279
2280 // Construct the HTML with a CSS class instead of inline styles
2281 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2282 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2283
2284 // Save the bot message with both text and HTML
2285 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2286 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2287
2288 // Set the fallback response for the chat handler
2289 $this->fallbackResponse = [
2290 'text' => $response_text,
2291 'html' => $response_html,
2292 'images' => [$image_url]
2293 ];
2294
2295 // For debugging/verification - Use json_encode to verify what's being set
2296 //error_log("Image generation successful - fallbackResponse set: " . json_encode($this->fallbackResponse));
2297
2298 // Return the response directly instead of relying on the property
2299 return $this->fallbackResponse;
2300 } else {
2301 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2302
2303 // Save the error message
2304 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2305
2306 // Set the fallback response for the chat handler
2307 $this->fallbackResponse = [
2308 'text' => $response_text,
2309 'html' => '',
2310 'images' => []
2311 ];
2312
2313 //error_log("DALL-E image generation error: " . esc_html($image_response['error'] ?? 'Unknown error.'));
2314 //error_log("Error fallbackResponse set: " . json_encode($this->fallbackResponse));
2315
2316 // Return the response directly instead of relying on the property
2317 return $this->fallbackResponse;
2318 }
2319 }
2320
2321 public function mxchat_generate_gemini_image($message, $user_id, $session_id) {
2322 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2323
2324 $gemini_api_key = sanitize_text_field($this->options['gemini_api_key'] ?? '');
2325 if (empty($gemini_api_key)) {
2326 $response_text = esc_html__("Gemini API key is not configured.", 'mxchat');
2327 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2328 return ['text' => $response_text, 'html' => '', 'images' => []];
2329 }
2330
2331 $image_response = $this->mxchat_generate_imagen_image($prompt, $gemini_api_key);
2332
2333 if (isset($image_response['imageUrl'])) {
2334 $image_url = esc_url_raw($image_response['imageUrl']);
2335
2336 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2337 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2338
2339 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2340 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2341
2342 $this->fallbackResponse = [
2343 'text' => $response_text,
2344 'html' => $response_html,
2345 'images' => [$image_url]
2346 ];
2347
2348 return $this->fallbackResponse;
2349 } else {
2350 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2351
2352 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2353
2354 $this->fallbackResponse = [
2355 'text' => $response_text,
2356 'html' => '',
2357 'images' => []
2358 ];
2359
2360 return $this->fallbackResponse;
2361 }
2362 }
2363
2364 private function mxchat_save_generated_image($base64_data, $mime_type = 'image/png', $prefix = 'mxchat-generated') {
2365 $extension = ($mime_type === 'image/jpeg') ? 'jpg' : 'png';
2366 $filename = sanitize_file_name($prefix . '-' . wp_generate_uuid4() . '.' . $extension);
2367 $decoded = base64_decode($base64_data);
2368
2369 if ($decoded === false) {
2370 return new \WP_Error('decode_failed', esc_html__('Failed to decode image data.', 'mxchat'));
2371 }
2372
2373 $upload = wp_upload_bits($filename, null, $decoded);
2374
2375 if (!empty($upload['error'])) {
2376 return new \WP_Error('upload_failed', $upload['error']);
2377 }
2378
2379 $attach_id = wp_insert_attachment([
2380 'post_mime_type' => $mime_type,
2381 'post_title' => $prefix,
2382 'post_content' => '',
2383 'post_status' => 'inherit',
2384 ], $upload['file']);
2385
2386 if (is_wp_error($attach_id)) {
2387 return $attach_id;
2388 }
2389
2390 require_once ABSPATH . 'wp-admin/includes/image.php';
2391 $metadata = wp_generate_attachment_metadata($attach_id, $upload['file']);
2392 wp_update_attachment_metadata($attach_id, $metadata);
2393
2394 return esc_url_raw(wp_get_attachment_url($attach_id));
2395 }
2396
2397 private function mxchat_generate_openai_image($prompt, $api_key, $model = 'gpt-image-1', $timeout = 60) {
2398 $api_url = 'https://api.openai.com/v1/images/generations';
2399 $body = json_encode([
2400 'prompt' => sanitize_text_field($prompt),
2401 'n' => 1,
2402 'size' => '1024x1024',
2403 'quality' => 'medium',
2404 'output_format' => 'png',
2405 'model' => sanitize_text_field($model),
2406 ]);
2407
2408 $args = [
2409 'body' => $body,
2410 'headers' => [
2411 'Content-Type' => 'application/json',
2412 'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
2413 ],
2414 'method' => 'POST',
2415 'timeout' => absint($timeout),
2416 ];
2417
2418 $response = wp_remote_post($api_url, $args);
2419
2420 if (is_wp_error($response)) {
2421 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
2422 }
2423
2424 $response_body = json_decode(wp_remote_retrieve_body($response), true);
2425
2426 $b64 = $response_body['data'][0]['b64_json'] ?? $response_body['data'][0]['b64'] ?? null;
2427 if ($b64) {
2428 $saved_url = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-openai');
2429 if (is_wp_error($saved_url)) {
2430 return ['error' => $saved_url->get_error_message()];
2431 }
2432 return ['imageUrl' => $saved_url];
2433 } else {
2434 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
2435 }
2436 }
2437
2438 private function mxchat_generate_imagen_image($prompt, $api_key, $timeout = 60) {
2439 $api_url = 'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict';
2440
2441 $body = json_encode([
2442 'instances' => [['prompt' => sanitize_text_field($prompt)]],
2443 'parameters' => [
2444 'sampleCount' => 1,
2445 'aspectRatio' => '1:1',
2446 ],
2447 ]);
2448
2449 $args = [
2450 'body' => $body,
2451 'headers' => [
2452 'Content-Type' => 'application/json',
2453 'x-goog-api-key' => sanitize_text_field($api_key),
2454 ],
2455 'method' => 'POST',
2456 'timeout' => absint($timeout),
2457 ];
2458
2459 $response = wp_remote_post($api_url, $args);
2460
2461 if (is_wp_error($response)) {
2462 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
2463 }
2464
2465 $response_body = json_decode(wp_remote_retrieve_body($response), true);
2466
2467 $b64 = $response_body['predictions'][0]['bytesBase64Encoded'] ?? $response_body['predictions'][0]['imageBytes'] ?? null;
2468 if ($b64) {
2469 $mime = $response_body['predictions'][0]['mimeType'] ?? 'image/png';
2470 $saved_url = $this->mxchat_save_generated_image($b64, $mime, 'mxchat-gemini');
2471 if (is_wp_error($saved_url)) {
2472 return ['error' => $saved_url->get_error_message()];
2473 }
2474 return ['imageUrl' => $saved_url];
2475 } else {
2476 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
2477 }
2478 }
2479
2480 /**
2481 * Handle web search requests.
2482 *
2483 * Sends the refined search query to the Brave Search API and uses the
2484 * results to generate a conversational response with the AI model.
2485 *
2486 * @since 1.0.0
2487 * @param string $message The user's search query.
2488 * @param string $user_id The user identifier.
2489 * @param string $session_id The current session ID.
2490 * @return array Response array containing text with embedded HTML links
2491 */
2492 public function mxchat_handle_search_request($message, $user_id, $session_id) {
2493 // Step 1: Interpret and refine the search query
2494 $refined_search_query = $this->mxchat_interpret_search_query($message);
2495 if (empty($refined_search_query)) {
2496 return array(
2497 'text' => esc_html__('I apologize, but could you please rephrase your search request?', 'mxchat'),
2498 'html' => ''
2499 );
2500 }
2501
2502 // Retrieve and validate API settings
2503 $options = get_option('mxchat_options');
2504 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2505 $results_count = isset($options['brave_results_count']) ? absint($options['brave_results_count']) : 5;
2506
2507 if (empty($api_key)) {
2508 return array(
2509 'text' => esc_html__('Search functionality is temporarily unavailable. Please try again later.', 'mxchat'),
2510 'html' => ''
2511 );
2512 }
2513
2514 // Build the API request URL
2515 $api_url = add_query_arg(
2516 array(
2517 'q' => rawurlencode($refined_search_query),
2518 'count' => $results_count,
2519 'text_decorations' => 'true',
2520 'rich_data' => 'true',
2521 ),
2522 'https://api.search.brave.com/res/v1/web/search'
2523 );
2524
2525 // Attempt to retrieve cached results first
2526 $transient_key = 'mxchat_search_' . md5($refined_search_query);
2527 $results = get_transient($transient_key);
2528
2529 if (false === $results) {
2530 // SECURITY FIX: Changed to wp_safe_remote_get
2531 $response = wp_safe_remote_get(
2532 $api_url,
2533 array(
2534 'headers' => array(
2535 'Accept' => 'application/json',
2536 'Accept-Encoding' => 'gzip',
2537 'X-Subscription-Token'=> $api_key,
2538 ),
2539 'timeout' => 10,
2540 )
2541 );
2542
2543 if (is_wp_error($response)) {
2544 return array(
2545 'text' => esc_html__('I encountered an error while searching. Please try again.', 'mxchat'),
2546 'html' => ''
2547 );
2548 }
2549
2550 $results = json_decode(wp_remote_retrieve_body($response), true);
2551
2552 if (json_last_error() !== JSON_ERROR_NONE) {
2553 return array(
2554 'text' => esc_html__('I received an invalid response from the search service.', 'mxchat'),
2555 'html' => ''
2556 );
2557 }
2558
2559 // Cache results for one hour
2560 set_transient($transient_key, $results, HOUR_IN_SECONDS);
2561 }
2562
2563 // Process results
2564 if (!empty($results['web']['results']) && is_array($results['web']['results'])) {
2565 // Create a more straightforward summary with HTML links
2566 $search_results_text = '';
2567
2568 // Add a simple intro
2569 $search_results_text .= sprintf(
2570 esc_html__("Here's what I found about '%s':", 'mxchat'),
2571 esc_html($refined_search_query)
2572 );
2573
2574 // Add the top results with HTML links
2575 foreach (array_slice($results['web']['results'], 0, 5) as $result) {
2576 $title = isset($result['title']) ? wp_strip_all_tags($result['title']) : '';
2577 $url = isset($result['url']) ? esc_url($result['url']) : '';
2578 $description = isset($result['description']) ? wp_strip_all_tags($result['description']) : '';
2579
2580 // Add a line break after the intro
2581 $search_results_text .= '<br><br>';
2582
2583 // Add title as a link
2584 $search_results_text .= sprintf(
2585 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a><br>',
2586 $url,
2587 $title
2588 );
2589
2590 // Add a condensed description
2591 $search_results_text .= sprintf("%s", $description);
2592 }
2593
2594 // Save to chat history
2595 $this->mxchat_save_chat_message($session_id, 'bot', $search_results_text);
2596
2597 // Return the formatted text with embedded HTML links
2598 return array(
2599 'text' => $search_results_text,
2600 'html' => ''
2601 );
2602 } else {
2603 return array(
2604 'text' => sprintf(
2605 esc_html__('I searched for "%s" but couldn\'t find any relevant results. Would you like to try different search terms?', 'mxchat'),
2606 esc_html($refined_search_query)
2607 ),
2608 'html' => ''
2609 );
2610 }
2611 }
2612
2613 //very good
2614 /**
2615 * Handle image search requests from the chatbot
2616 *
2617 * @param string $message The user's search query
2618 * @param int $user_id The user's ID
2619 * @param string $session_id The chat session ID
2620 * @return array Response array with text and HTML content
2621 */
2622 public function mxchat_handle_image_search_request($message, $user_id, $session_id) {
2623 // Step 1: Interpret the search query using the user's selected AI model
2624 $refined_search_query = $this->mxchat_interpret_search_query($message);
2625
2626 // If no query was interpreted, return a fallback message
2627 if (empty($refined_search_query)) {
2628 return array(
2629 'text' => __("I'm sorry, I couldn't interpret your search query. Please specify what you'd like to see images of.", 'mxchat'),
2630 'html' => "",
2631 );
2632 }
2633
2634 // Brave API URL
2635 $api_url = 'https://api.search.brave.com/res/v1/images/search';
2636
2637 // Retrieve Brave API settings
2638 $options = get_option('mxchat_options');
2639 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2640
2641 if (empty($api_key)) {
2642 return array(
2643 'text' => __("API key is not configured. Please set it in the Brave Search Settings.", 'mxchat'),
2644 'html' => "",
2645 );
2646 }
2647
2648 $image_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
2649 $safe_search = isset($options['brave_safe_search']) ? sanitize_text_field($options['brave_safe_search']) : 'strict';
2650
2651 // Append query parameters based on settings
2652 $api_url = add_query_arg([
2653 'q' => rawurlencode($refined_search_query),
2654 'count' => $image_count,
2655 'safesearch' => $safe_search,
2656 ], $api_url);
2657
2658 // Implement caching
2659 $transient_key = 'mxchat_image_search_' . md5($refined_search_query);
2660 $body = get_transient($transient_key);
2661
2662 if (false === $body) {
2663 $args = [
2664 'headers' => [
2665 'Accept' => 'application/json',
2666 'Accept-Encoding' => 'gzip',
2667 'X-Subscription-Token' => $api_key,
2668 ],
2669 'timeout' => 10,
2670 ];
2671
2672 // SECURITY FIX: Changed to wp_safe_remote_get
2673 $response = wp_safe_remote_get($api_url, $args);
2674
2675 if (is_wp_error($response)) {
2676 return array(
2677 'text' => __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat'),
2678 'html' => "",
2679 );
2680 }
2681
2682 $body = json_decode(wp_remote_retrieve_body($response), true);
2683 set_transient($transient_key, $body, HOUR_IN_SECONDS);
2684 }
2685
2686 // Process the API response
2687 if (isset($body['results']) && is_array($body['results']) && count($body['results']) > 0) {
2688 $html_output = '<div class="mxchat-image-gallery">';
2689
2690 // Get the configured image count (1-6)
2691 $display_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
2692 $display_count = min($display_count, count($body['results'])); // Make sure we don't exceed available images
2693
2694 // Use only the requested number of images
2695 for ($i = 0; $i < $display_count; $i++) {
2696 $image = $body['results'][$i];
2697 $image_url = isset($image['url']) ? esc_url($image['url']) : '';
2698 $thumbnail_url = isset($image['thumbnail']['src']) ? esc_url($image['thumbnail']['src']) : '';
2699 $title = isset($image['title']) ? esc_html($image['title']) : esc_html__('Image', 'mxchat');
2700
2701 if ($image_url && $thumbnail_url) {
2702 $html_output .= '<div class="mxchat-image-item">';
2703 $html_output .= '<strong class="mxchat-image-title">' . $title . '</strong>';
2704 $html_output .= '<a href="' . $image_url . '" target="_blank" rel="noopener noreferrer" class="mxchat-image-link">';
2705 $html_output .= '<img src="' . $thumbnail_url . '" alt="' . $title . '" class="mxchat-image-thumbnail">';
2706 $html_output .= '</a></div>';
2707 }
2708 }
2709
2710 $html_output .= '</div>';
2711
2712 // Create response text
2713 $response_text = sprintf(__("Here are some images of %s:", 'mxchat'), $refined_search_query);
2714
2715 // Save both response text and HTML to chat history
2716 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2717 $this->mxchat_save_chat_message($session_id, 'bot', $html_output);
2718
2719 // Return the combined response
2720 return array(
2721 'text' => $response_text,
2722 'html' => $html_output,
2723 );
2724 } else {
2725 $response_text = __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat');
2726
2727 // Save the error message to chat history
2728 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2729
2730 return array(
2731 'text' => $response_text,
2732 'html' => "",
2733 );
2734 }
2735 }
2736
2737 /**
2738 * Interpret the search query using the user's selected AI model
2739 *
2740 * @param string $user_query The original query from the user
2741 * @return string The refined search query
2742 */
2743 public function mxchat_interpret_search_query($user_query) {
2744 $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');
2745
2746 // Get options and determine the selected model
2747 $options = $this->options ?? get_option('mxchat_options');
2748 $selected_model = isset($options['model']) ? $options['model'] : 'gpt-5.1-chat-latest';
2749
2750 // Extract model prefix to determine the provider
2751 $model_parts = explode('-', $selected_model);
2752 $provider = strtolower($model_parts[0]);
2753
2754 // Determine which API key to use based on the provider
2755 switch ($provider) {
2756 case 'gemini':
2757 $api_key = isset($options['gemini_api_key']) ? sanitize_text_field($options['gemini_api_key']) : '';
2758 if (empty($api_key)) {
2759 return sanitize_text_field($user_query); // Default to original query if API key missing
2760 }
2761 return $this->interpret_query_with_gemini($user_query, $system_prompt, $api_key, $selected_model);
2762
2763 case 'claude':
2764 $api_key = isset($options['claude_api_key']) ? sanitize_text_field($options['claude_api_key']) : '';
2765 if (empty($api_key)) {
2766 return sanitize_text_field($user_query);
2767 }
2768 return $this->interpret_query_with_claude($user_query, $system_prompt, $api_key, $selected_model);
2769
2770 case 'grok':
2771 $api_key = isset($options['xai_api_key']) ? sanitize_text_field($options['xai_api_key']) : '';
2772 if (empty($api_key)) {
2773 return sanitize_text_field($user_query);
2774 }
2775 return $this->interpret_query_with_xai($user_query, $system_prompt, $api_key, $selected_model);
2776
2777 case 'deepseek':
2778 $api_key = isset($options['deepseek_api_key']) ? sanitize_text_field($options['deepseek_api_key']) : '';
2779 if (empty($api_key)) {
2780 return sanitize_text_field($user_query);
2781 }
2782 return $this->interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $selected_model);
2783
2784 case 'gpt':
2785 default:
2786 // Default to OpenAI for custom models or unrecognized prefixes
2787 $api_key = isset($options['api_key']) ? sanitize_text_field($options['api_key']) : '';
2788 if (empty($api_key)) {
2789 return sanitize_text_field($user_query);
2790 }
2791 return $this->interpret_query_with_openai($user_query, $system_prompt, $api_key, $selected_model);
2792 }
2793 }
2794
2795 /**
2796 * Interpret query using OpenAI models
2797 */
2798 private function interpret_query_with_openai($user_query, $system_prompt, $api_key, $model = 'gpt-5.1-chat-latest') {
2799 $url = 'https://api.openai.com/v1/chat/completions';
2800 $args = [
2801 'headers' => [
2802 'Authorization' => 'Bearer ' . $api_key,
2803 'Content-Type' => 'application/json',
2804 ],
2805 'body' => wp_json_encode([
2806 'model' => $model,
2807 'messages' => [
2808 ['role' => 'system', 'content' => $system_prompt],
2809 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2810 ],
2811 'temperature' => 0.2,
2812 'max_tokens' => 20,
2813 ]),
2814 'method' => 'POST',
2815 'timeout' => 15,
2816 ];
2817
2818 $response = wp_remote_post($url, $args);
2819 if (is_wp_error($response)) {
2820 return sanitize_text_field($user_query);
2821 }
2822
2823 $body = json_decode(wp_remote_retrieve_body($response), true);
2824 return isset($body['choices'][0]['message']['content'])
2825 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
2826 : sanitize_text_field($user_query);
2827 }
2828
2829 /**
2830 * Interpret query using Claude models
2831 */
2832 private function interpret_query_with_claude($user_query, $system_prompt, $api_key, $model) {
2833 $url = 'https://api.anthropic.com/v1/messages';
2834
2835 $args = [
2836 'headers' => [
2837 'Content-Type' => 'application/json',
2838 'x-api-key' => $api_key,
2839 'anthropic-version' => '2023-06-01',
2840 ],
2841 'body' => wp_json_encode([
2842 'model' => $model,
2843 'system' => $system_prompt,
2844 'messages' => [
2845 ['role' => 'user', 'content' => sanitize_text_field($user_query)]
2846 ],
2847 'max_tokens' => 20,
2848 'temperature' => 0.2,
2849 ]),
2850 'method' => 'POST',
2851 'timeout' => 15,
2852 ];
2853
2854 $response = wp_remote_post($url, $args);
2855 if (is_wp_error($response)) {
2856 return sanitize_text_field($user_query);
2857 }
2858
2859 $body = json_decode(wp_remote_retrieve_body($response), true);
2860 if (!empty($body['content'][0]['text'])) {
2861 return sanitize_text_field(trim($body['content'][0]['text']));
2862 }
2863
2864 return sanitize_text_field($user_query);
2865 }
2866
2867 /**
2868 * Interpret query using Gemini models
2869 */
2870 private function interpret_query_with_gemini($user_query, $system_prompt, $api_key, $model) {
2871 // Use v1beta for preview models, v1 for stable models
2872 $api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
2873
2874 $url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key=" . urlencode($api_key);
2875
2876 $args = [
2877 'headers' => [
2878 'Content-Type' => 'application/json',
2879 ],
2880 'body' => wp_json_encode([
2881 'contents' => [
2882 [
2883 'role' => 'user',
2884 'parts' => [
2885 ['text' => $system_prompt . "\n\nQuery: " . sanitize_text_field($user_query)]
2886 ]
2887 ]
2888 ],
2889 'generationConfig' => [
2890 'temperature' => 0.2,
2891 'maxOutputTokens' => 20,
2892 ],
2893 ]),
2894 'method' => 'POST',
2895 'timeout' => 15,
2896 ];
2897
2898 $response = wp_remote_post($url, $args);
2899 if (is_wp_error($response)) {
2900 return sanitize_text_field($user_query);
2901 }
2902
2903 $body = json_decode(wp_remote_retrieve_body($response), true);
2904 if (!empty($body['candidates'][0]['content']['parts'][0]['text'])) {
2905 return sanitize_text_field(trim($body['candidates'][0]['content']['parts'][0]['text']));
2906 }
2907
2908 return sanitize_text_field($user_query);
2909 }
2910
2911 /**
2912 * Interpret query using X.AI (Grok) models
2913 */
2914 private function interpret_query_with_xai($user_query, $system_prompt, $api_key, $model) {
2915 $url = 'https://api.xai.com/v1/chat/completions';
2916
2917 $args = [
2918 'headers' => [
2919 'Content-Type' => 'application/json',
2920 'Authorization' => 'Bearer ' . $api_key,
2921 ],
2922 'body' => wp_json_encode([
2923 'model' => $model,
2924 'messages' => [
2925 ['role' => 'system', 'content' => $system_prompt],
2926 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2927 ],
2928 'temperature' => 0.2,
2929 'max_tokens' => 20,
2930 ]),
2931 'method' => 'POST',
2932 'timeout' => 15,
2933 ];
2934
2935 $response = wp_remote_post($url, $args);
2936 if (is_wp_error($response)) {
2937 return sanitize_text_field($user_query);
2938 }
2939
2940 $body = json_decode(wp_remote_retrieve_body($response), true);
2941 if (isset($body['choices'][0]['message']['content'])) {
2942 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
2943 }
2944
2945 return sanitize_text_field($user_query);
2946 }
2947
2948 /**
2949 * Interpret query using DeepSeek models
2950 */
2951 private function interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $model) {
2952 $url = 'https://api.deepseek.com/v1/chat/completions';
2953
2954 $args = [
2955 'headers' => [
2956 'Content-Type' => 'application/json',
2957 'Authorization' => 'Bearer ' . $api_key,
2958 ],
2959 'body' => wp_json_encode([
2960 'model' => $model,
2961 'messages' => [
2962 ['role' => 'system', 'content' => $system_prompt],
2963 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2964 ],
2965 'temperature' => 0.2,
2966 'max_tokens' => 20,
2967 ]),
2968 'method' => 'POST',
2969 'timeout' => 15,
2970 ];
2971
2972 $response = wp_remote_post($url, $args);
2973 if (is_wp_error($response)) {
2974 return sanitize_text_field($user_query);
2975 }
2976
2977 $body = json_decode(wp_remote_retrieve_body($response), true);
2978 if (isset($body['choices'][0]['message']['content'])) {
2979 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
2980 }
2981
2982 return sanitize_text_field($user_query);
2983 }
2984
2985 //very good
2986 private function add_email_to_loops($email) {
2987 // Sanitize the email
2988 $email = sanitize_email($email);
2989
2990 // Retrieve and sanitize options
2991 $api_key = isset($this->options['loops_api_key']) ? sanitize_text_field($this->options['loops_api_key']) : '';
2992 $mailing_list_id = isset($this->options['loops_mailing_list']) ? sanitize_text_field($this->options['loops_mailing_list']) : '';
2993
2994 // Check for missing API key or mailing list ID
2995 if (empty($api_key) || empty($mailing_list_id)) {
2996 //error_log(esc_html__('Loops API key or mailing list ID is missing.', 'mxchat'));
2997 return;
2998 }
2999
3000 $data = array(
3001 'email' => $email,
3002 'subscribed' => true,
3003 'source' => __('MxChat AI Chatbot', 'mxchat'),
3004 'mailingLists' => array($mailing_list_id => true),
3005 );
3006
3007 $url = 'https://app.loops.so/api/v1/contacts/create';
3008 $args = array(
3009 'body' => wp_json_encode($data),
3010 'headers' => array(
3011 'Authorization' => 'Bearer ' . $api_key,
3012 'Content-Type' => 'application/json',
3013 ),
3014 'method' => 'POST',
3015 'timeout' => 45,
3016 );
3017
3018 $response = wp_remote_post($url, $args);
3019
3020 // Handle errors in the API request
3021 if (is_wp_error($response)) {
3022 //error_log(esc_html__('Error adding email to Loops: ', 'mxchat') . $response->get_error_message());
3023 return;
3024 }
3025
3026 // Check for non-200 HTTP responses
3027 $response_code = wp_remote_retrieve_response_code($response);
3028 if ($response_code != 200) {
3029 $response_body = wp_remote_retrieve_body($response);
3030 //error_log(esc_html__('Loops API responded with code ', 'mxchat') . $response_code . ': ' . $response_body);
3031 }
3032 }
3033
3034 public function mxchat_handle_pdf_discussion($message, $user_id, $session_id) {
3035 // Get the maximum number of pages allowed from admin settings
3036 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3037
3038 // Retrieve options for dynamic texts
3039 $trigger_text = $this->options['pdf_intent_trigger_text'] ?? __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
3040 $success_text = $this->options['pdf_intent_success_text'] ?? __("I've processed the PDF. What questions do you have about it?", 'mxchat');
3041 $error_text = $this->options['pdf_intent_error_text'] ?? __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
3042
3043 // Check for explicit request for new PDF
3044 $new_pdf_requested = stripos($message, 'new') !== false ||
3045 stripos($message, 'another') !== false ||
3046 stripos($message, 'different') !== false;
3047
3048 // If user mentions adding/reading a PDF, set waiting flag
3049 if (stripos($message, 'pdf') !== false ||
3050 stripos($message, 'document') !== false ||
3051 stripos($message, 'read') !== false) {
3052 set_transient('mxchat_waiting_for_pdf_url_' . $session_id, true, HOUR_IN_SECONDS);
3053 $this->fallbackResponse['text'] = $trigger_text;
3054 return;
3055 }
3056
3057 // If we're waiting for a URL or user requested new PDF
3058 if ($new_pdf_requested || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
3059 if (preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
3060 // Process URL... (rest of your existing URL processing code)
3061 } else {
3062 $this->fallbackResponse['text'] = $trigger_text;
3063 }
3064 return;
3065 }
3066
3067 // Default to proceeding with conversation if no specific PDF action is needed
3068 $this->fallbackResponse['text'] = '';
3069 }
3070
3071
3072 /**
3073 * Enhanced fetch_and_split_pdf_pages with SSRF protection
3074 */
3075 private function fetch_and_split_pdf_pages($pdf_source, $max_pages) {
3076 // CLEAR DEBUG LOGGING
3077 //error_log("=== MXCHAT PDF PROCESSING START ===");
3078 //error_log("PDF Source: " . $pdf_source);
3079 //error_log("Max Pages: " . $max_pages);
3080 //error_log("Session ID: " . ($this->session_id ?? 'not set'));
3081
3082 // Check if Advanced Claude Toolbar is available and enabled
3083 $claude_available = function_exists('mxchatACT_is_advanced_claude_enabled');
3084 $claude_enabled = $claude_available ? mxchatACT_is_advanced_claude_enabled() : false;
3085
3086 //error_log("Claude Function Available: " . ($claude_available ? 'YES' : 'NO'));
3087 //error_log("Claude Enabled: " . ($claude_enabled ? 'YES' : 'NO'));
3088
3089 if ($claude_available && $claude_enabled) {
3090 //error_log("🚀 ATTEMPTING CLAUDE PROCESSING...");
3091
3092 // Attempt Claude processing first
3093 $claude_result = apply_filters('mxchat_process_pdf_advanced', false, $pdf_source, $max_pages, $this->session_id);
3094
3095 if ($claude_result !== false && is_array($claude_result) && !empty($claude_result)) {
3096 //error_log("✅ CLAUDE PROCESSING SUCCESSFUL!");
3097 //error_log("Claude returned " . count($claude_result) . " processed pages");
3098
3099 // Log first page details for verification
3100 if (isset($claude_result[0])) {
3101 $first_page = $claude_result[0];
3102 //error_log("First page enhanced: " . (isset($first_page['enhanced']) && $first_page['enhanced'] ? 'YES' : 'NO'));
3103 //error_log("Processing method: " . ($first_page['processing_method'] ?? 'not set'));
3104 //error_log("First page text preview: " . substr($first_page['text'] ?? '', 0, 100) . "...");
3105 }
3106
3107 //error_log("=== MXCHAT PDF PROCESSING END (CLAUDE) ===");
3108 return $claude_result;
3109 } else {
3110 //error_log("❌ CLAUDE PROCESSING FAILED or returned invalid result");
3111 //error_log("Claude result type: " . gettype($claude_result));
3112 if (is_array($claude_result)) {
3113 //error_log("Claude result count: " . count($claude_result));
3114 }
3115 }
3116 }
3117
3118 // Fallback to basic processing
3119 //error_log("🔄 FALLING BACK TO BASIC PDF PROCESSING...");
3120
3121 $upload_dir = wp_upload_dir();
3122 $temp_file = null;
3123
3124 try {
3125 // Your existing basic processing code here...
3126 // (I'll include the key parts with debug logging)
3127
3128 if (filter_var($pdf_source, FILTER_VALIDATE_URL)) {
3129 //error_log("Downloading PDF from URL...");
3130
3131 // SECURITY FIX: Validate URL before processing
3132 if (!$this->mxchat_is_safe_pdf_url($pdf_source)) {
3133 //error_log("❌ SECURITY: Blocked unsafe PDF URL");
3134 return false;
3135 }
3136
3137 $temp_file = wp_tempnam($pdf_source);
3138
3139 // SECURITY FIX: Changed from wp_remote_get to wp_safe_remote_get
3140 $response = wp_safe_remote_get($pdf_source, [
3141 'timeout' => 60,
3142 'headers' => ['User-Agent' => 'MxChat PDF Processor']
3143 ]);
3144
3145 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
3146 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code($response);
3147 //error_log("❌ BASIC PROCESSING: Failed to download PDF: " . $error_message);
3148 return false;
3149 }
3150
3151 global $wp_filesystem;
3152 if (empty($wp_filesystem)) {
3153 require_once ABSPATH . 'wp-admin/includes/file.php';
3154 WP_Filesystem();
3155 }
3156 $wp_filesystem->put_contents($temp_file, wp_remote_retrieve_body($response), FS_CHMOD_FILE);
3157 //error_log("✅ PDF downloaded successfully");
3158 } else {
3159 $temp_file = $pdf_source;
3160 //error_log("Using local PDF file: " . $temp_file);
3161 }
3162
3163 // Parse PDF
3164 //error_log("Parsing PDF with basic parser...");
3165 mxchat_load_pdf_parser();
3166 $parser = new \Smalot\PdfParser\Parser();
3167 $pdf = $parser->parseFile($temp_file);
3168 $pages = $pdf->getPages();
3169
3170 //error_log("PDF contains " . count($pages) . " pages");
3171
3172 if (count($pages) > $max_pages) {
3173 //error_log("❌ BASIC PROCESSING: Too many pages (" . count($pages) . " > " . $max_pages . ")");
3174 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file) {
3175 unlink($temp_file);
3176 }
3177 return 'too_many_pages';
3178 }
3179
3180 $embeddings = [];
3181 $processed_pages = 0;
3182
3183 foreach ($pages as $page_number => $page) {
3184 $text = $page->getText();
3185
3186 if (empty(trim($text))) {
3187 //error_log("Skipping empty page: " . ($page_number + 1));
3188 continue;
3189 }
3190
3191 $text = $this->mxchat_clean_text($text);
3192
3193 $embedding = $this->mxchat_generate_embedding(
3194 __("Page ", 'mxchat') . ($page_number + 1) . ": " . $text,
3195 $this->options['api_key']
3196 );
3197
3198 if ($embedding) {
3199 $embeddings[] = [
3200 'page_number' => $page_number + 1,
3201 'embedding' => $embedding,
3202 'text' => $text,
3203 'enhanced' => false, // CLEARLY MARK AS BASIC
3204 'processing_method' => 'basic_pdf_parser'
3205 ];
3206 $processed_pages++;
3207 }
3208 }
3209
3210 //error_log("✅ BASIC PROCESSING COMPLETE: " . $processed_pages . " pages processed");
3211
3212 // Cleanup
3213 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3214 unlink($temp_file);
3215 }
3216
3217 //error_log("=== MXCHAT PDF PROCESSING END (BASIC) ===");
3218 return $embeddings;
3219
3220 } catch (\Exception $e) {
3221 //error_log("❌ BASIC PROCESSING ERROR: " . $e->getMessage());
3222 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3223 unlink($temp_file);
3224 }
3225 //error_log("=== MXCHAT PDF PROCESSING END (ERROR) ===");
3226 return false;
3227 }
3228 }
3229
3230
3231 /**
3232 * Validate PDF URL for security
3233 * Prevents SSRF attacks by blocking dangerous URLs
3234 */
3235
3236 private function mxchat_is_safe_pdf_url($url) {
3237 // Use WordPress core function for comprehensive validation
3238 // This blocks localhost, private IPs, and reserved IP ranges
3239 $validated_url = wp_http_validate_url($url);
3240
3241 if ($validated_url === false) {
3242 return false;
3243 }
3244
3245 // Additional check: only allow HTTP/HTTPS schemes
3246 $parsed = parse_url($url);
3247 if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], ['http', 'https'], true)) {
3248 return false;
3249 }
3250
3251 return true;
3252 }
3253
3254
3255 private function mxchat_clean_text($text) {
3256 // Remove excessive whitespace
3257 $text = preg_replace('/\s+/', ' ', $text);
3258
3259 // Remove control characters except newlines and tabs
3260 $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
3261
3262 // Normalize line endings
3263 $text = str_replace(["\r\n", "\r"], "\n", $text);
3264
3265 // Trim whitespace
3266 $text = trim($text);
3267
3268 return $text;
3269 }
3270
3271 private function find_relevant_pdf_pages($query_embedding, $embeddings) {
3272 //error_log(esc_html__("find_relevant_pdf_pages called.", 'mxchat'));
3273
3274 $most_relevant = null;
3275 $highest_similarity = -INF;
3276
3277 foreach ($embeddings as $page_data) {
3278 $similarity = $this->mxchat_calculate_cosine_similarity($query_embedding, $page_data['embedding']);
3279
3280 if ($similarity > $highest_similarity) {
3281 $highest_similarity = $similarity;
3282 $most_relevant = $page_data['page_number'];
3283 }
3284 }
3285
3286 if (!is_null($most_relevant)) {
3287 $page_numbers = range(max(1, $most_relevant - 1), min(count($embeddings), $most_relevant + 1));
3288 return array_filter($embeddings, function ($page) use ($page_numbers) {
3289 return in_array($page['page_number'], $page_numbers);
3290 });
3291 }
3292
3293 return [];
3294 }
3295
3296
3297 public function handle_pdf_upload() {
3298 check_ajax_referer('mxchat_chat_nonce', 'nonce');
3299
3300 if (!isset($_FILES['pdf_file']) || !isset($_POST['session_id'])) {
3301 wp_send_json_error(esc_html__('Missing required parameters.', 'mxchat'));
3302 return;
3303 }
3304
3305 // SECURITY FIX: Check if PDF uploads are enabled in settings
3306 $options = get_option('mxchat_options', array());
3307 $show_pdf_button = isset($options['show_pdf_upload_button']) ? $options['show_pdf_upload_button'] : 'on';
3308
3309 if ($show_pdf_button !== 'on') {
3310 wp_send_json_error(esc_html__('PDF uploads are currently disabled.', 'mxchat'));
3311 return;
3312 }
3313
3314 $file = $_FILES['pdf_file'];
3315 $session_id = sanitize_text_field($_POST['session_id']);
3316 $original_filename = sanitize_text_field($file['name']);
3317
3318 // Update session owner if it changed (e.g. IP changed due to network switch)
3319 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
3320 $session_owner = get_option("mxchat_session_owner_{$session_id}");
3321
3322 if (!$session_owner || $session_owner !== $current_user_identifier) {
3323 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
3324 }
3325
3326 $file_type = wp_check_filetype($file['name'], ['pdf' => 'application/pdf']);
3327 if ($file_type['type'] !== 'application/pdf') {
3328 wp_send_json_error(esc_html__('Invalid file type. Only PDF files are allowed.', 'mxchat'));
3329 return;
3330 }
3331
3332 $upload_dir = wp_upload_dir();
3333
3334 // SECURITY FIX: Generate random filename without exposing session_id
3335 $random_string = wp_generate_password(20, false, false); // 20 char alphanumeric string
3336 $pdf_filename = 'mxchat_' . $random_string . '_' . time() . '.pdf';
3337 $pdf_path = $upload_dir['path'] . '/' . $pdf_filename;
3338
3339 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
3340 wp_send_json_error(esc_html__('Failed to upload file.', 'mxchat'));
3341 return;
3342 }
3343
3344 $this->clear_pdf_transients($session_id);
3345
3346 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3347 $embeddings = $this->fetch_and_split_pdf_pages($pdf_path, $max_pages);
3348
3349 if ($embeddings === 'too_many_pages') {
3350 unlink($pdf_path);
3351 $error_message = sprintf(
3352 $this->options['pdf_intent_error_text'] ??
3353 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
3354 $max_pages
3355 );
3356 wp_send_json_error($error_message);
3357 return;
3358 }
3359
3360 if ($embeddings === false || empty($embeddings)) {
3361 unlink($pdf_path);
3362 $error_message = $this->options['pdf_intent_error_text'] ??
3363 esc_html__('The uploaded PDF appears to be empty or contains unsupported content.', 'mxchat');
3364 wp_send_json_error($error_message);
3365 return;
3366 }
3367
3368 if (!empty($embeddings)) {
3369 // Store the mapping between session and the random filename
3370 set_transient('mxchat_pdf_url_' . $session_id, $pdf_path, HOUR_IN_SECONDS);
3371 set_transient('mxchat_pdf_filename_' . $session_id, $original_filename, HOUR_IN_SECONDS);
3372 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
3373 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
3374
3375 $success_message = $this->options['pdf_intent_success_text'] ??
3376 esc_html__("I've processed the PDF. What questions do you have about it?", 'mxchat');
3377
3378 wp_send_json_success([
3379 'message' => $success_message,
3380 'filename' => $original_filename
3381 ]);
3382 return;
3383 }
3384
3385 unlink($pdf_path);
3386 $error_message = $this->options['pdf_intent_error_text'] ??
3387 esc_html__('Sorry, I couldn\'t process the PDF. Please ensure it\'s a valid file.', 'mxchat');
3388 wp_send_json_error($error_message);
3389 return;
3390 }
3391 public function handle_pdf_remove() {
3392 check_ajax_referer('mxchat_chat_nonce', 'nonce');
3393
3394 if (empty($_POST['session_id'])) {
3395 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
3396 wp_die();
3397 }
3398
3399 $session_id = sanitize_text_field($_POST['session_id']);
3400 $pdf_path = get_transient('mxchat_pdf_url_' . $session_id);
3401
3402 if ($pdf_path && file_exists($pdf_path)) {
3403 unlink($pdf_path);
3404 }
3405
3406 $this->clear_pdf_transients($session_id);
3407
3408 wp_send_json_success([
3409 'message' => esc_html__('PDF removed successfully.', 'mxchat')
3410 ]);
3411 wp_die();
3412 }
3413
3414
3415 function mxchat_fetch_new_messages() {
3416 $session_id = sanitize_text_field($_POST['session_id']);
3417 $last_seen_id = sanitize_text_field($_POST['last_seen_id']);
3418 $persistence_enabled = $_POST['persistence_enabled'] === 'true';
3419 $initial_timestamp = isset($_POST['initial_timestamp']) ? intval($_POST['initial_timestamp']) : 0;
3420
3421 if (empty($session_id)) {
3422 //error_log(esc_html__('Fetch new messages error: Session ID missing.', 'mxchat'));
3423 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
3424 wp_die();
3425 }
3426
3427 $history = get_option("mxchat_history_{$session_id}", []);
3428
3429 //error_log("MxChat WhatsApp DEBUG: Fetch new messages for session {$session_id}");
3430 //error_log("MxChat WhatsApp DEBUG: last_seen_id = " . var_export($last_seen_id, true));
3431 //error_log("MxChat WhatsApp DEBUG: History count = " . count($history));
3432 //error_log("MxChat WhatsApp DEBUG: Full history = " . print_r($history, true));
3433
3434 $new_messages = array_filter($history, function ($message) use ($last_seen_id, $persistence_enabled, $initial_timestamp) {
3435 //error_log("MxChat WhatsApp DEBUG: Checking message - ID: " . ($message['id'] ?? 'NO_ID') . ", Role: " . ($message['role'] ?? 'NO_ROLE'));
3436
3437 // If persistence is enabled, show all new messages
3438 if ($persistence_enabled) {
3439 $has_id = !empty($message['id']);
3440 $is_agent = $message['role'] === 'agent';
3441
3442 // If last_seen_id is empty, 'NaN', or invalid, show all agent messages
3443 if (empty($last_seen_id) || $last_seen_id === 'NaN' || $last_seen_id === 'undefined') {
3444 $is_newer = true;
3445 } else {
3446 $is_newer = strcmp($message['id'] ?? '', $last_seen_id) > 0;
3447 }
3448
3449 //error_log("MxChat WhatsApp DEBUG: has_id={$has_id}, is_newer={$is_newer}, is_agent={$is_agent}");
3450
3451 return $has_id && $is_newer && $is_agent;
3452 }
3453
3454 // If persistence is disabled, only show messages after initial timestamp
3455 return !empty($message['id']) &&
3456 $message['role'] === 'agent' &&
3457 $message['timestamp'] > $initial_timestamp;
3458 });
3459
3460 //error_log("MxChat WhatsApp DEBUG: Filtered messages count = " . count($new_messages));
3461
3462 // Include current chat mode so frontend can detect agent→AI transitions
3463 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
3464
3465 wp_send_json_success([
3466 'new_messages' => array_values($new_messages),
3467 'chat_mode' => $chat_mode
3468 ]);
3469 wp_die();
3470 }
3471 public function mxchat_live_agent_handover($message, $user_id, $session_id) {
3472 // First check if live agents are available
3473 $live_agent_available = $this->options['live_agent_status'] ?? 'off';
3474 if ($live_agent_available !== 'on') {
3475 $away_message = $this->options['live_agent_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
3476 $this->fallbackResponse = [
3477 'text' => $away_message,
3478 'html' => '',
3479 'images' => [],
3480 'chat_mode' => 'ai'
3481 ];
3482 wp_send_json([
3483 'text' => $away_message,
3484 'html' => '',
3485 'chat_mode' => 'ai',
3486 'session_id' => $session_id
3487 ]);
3488 wp_die();
3489 }
3490
3491 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
3492
3493 if (empty($slack_bot_token)) {
3494 return false;
3495 }
3496
3497 // Check if channel already exists for this session
3498 $channel_id = get_option("mxchat_channel_{$session_id}", '');
3499
3500 if (empty($channel_id)) {
3501 // Create new channel with session ID as name
3502 $channel_name = $this->generate_channel_name($session_id);
3503
3504 //error_log("Attempting to create channel: $channel_name");
3505
3506 $response = wp_remote_post('https://slack.com/api/conversations.create', [
3507 'headers' => [
3508 'Content-Type' => 'application/json',
3509 'Authorization' => 'Bearer ' . $slack_bot_token
3510 ],
3511 'body' => json_encode([
3512 'name' => $channel_name,
3513 'is_private' => false // Public channel - anyone in workspace can join
3514 ])
3515 ]);
3516
3517 if (!is_wp_error($response)) {
3518 $response_body = wp_remote_retrieve_body($response);
3519 $response_data = json_decode($response_body, true);
3520
3521 //error_log("Channel creation response: " . $response_body);
3522
3523 if (isset($response_data['ok']) && $response_data['ok']) {
3524 $channel_id = $response_data['channel']['id'];
3525 $actual_channel_name = $response_data['channel']['name'] ?? 'unknown';
3526 //error_log("Channel created successfully: ID=$channel_id, Name=$actual_channel_name");
3527 update_option("mxchat_channel_{$session_id}", $channel_id);
3528
3529 // Auto-invite agents to the channel
3530 $agent_user_ids = $this->options['live_agent_user_ids'] ?? '';
3531
3532 if (!empty($agent_user_ids)) {
3533 // Parse user IDs (one per line)
3534 $user_ids = array_filter(array_map('trim', explode("\n", $agent_user_ids)));
3535
3536 foreach ($user_ids as $user_id_to_invite) {
3537 //error_log("Inviting user to channel: $user_id_to_invite");
3538
3539 $invite_response = wp_remote_post('https://slack.com/api/conversations.invite', [
3540 'headers' => [
3541 'Content-Type' => 'application/json',
3542 'Authorization' => 'Bearer ' . $slack_bot_token
3543 ],
3544 'body' => json_encode([
3545 'channel' => $channel_id,
3546 'users' => $user_id_to_invite
3547 ])
3548 ]);
3549
3550 if (!is_wp_error($invite_response)) {
3551 $invite_body = wp_remote_retrieve_body($invite_response);
3552 $invite_data = json_decode($invite_body, true);
3553 //error_log("Invite response for $user_id_to_invite: " . $invite_body);
3554
3555 if (isset($invite_data['ok']) && $invite_data['ok']) {
3556 //error_log("Successfully invited user $user_id_to_invite to channel");
3557 } else {
3558 //error_log("Failed to invite user $user_id_to_invite: " . ($invite_data['error'] ?? 'Unknown error'));
3559 }
3560 } else {
3561 //error_log("WP Error inviting user $user_id_to_invite: " . $invite_response->get_error_message());
3562 }
3563 }
3564 } else {
3565 //error_log("No agent user IDs configured for auto-invite");
3566 }
3567 } else {
3568 //error_log("Channel creation failed: " . ($response_data['error'] ?? 'Unknown error'));
3569 }
3570 } else {
3571 //error_log("WP Error creating channel: " . $response->get_error_message());
3572 }
3573
3574 if (empty($channel_id)) {
3575 return false; // Failed to create channel
3576 }
3577 }
3578
3579 // Get recent chat history
3580 $history = get_option("mxchat_history_{$session_id}", []);
3581 $recent_history = array_slice($history, -5);
3582
3583 // Format conversation context
3584 $conversation_context = "";
3585 if (!empty($recent_history)) {
3586 $conversation_context = "*Recent Conversation:*\n";
3587 foreach ($recent_history as $hist_message) {
3588 $role_display = $hist_message['role'] === 'user' ? 'User' : 'AI';
3589 $conversation_context .= ">{$role_display}: {$hist_message['content']}\n";
3590 }
3591 $conversation_context .= "\n";
3592 }
3593
3594 update_option("mxchat_mode_{$session_id}", 'agent');
3595
3596 // Send message to channel
3597 $channel_message = "🔔 *New Live Agent Request*\n\n";
3598 $channel_message .= "*Session ID:* `{$session_id}`\n";
3599 $channel_message .= "*User ID:* `{$user_id}`\n\n";
3600
3601 if (!empty($conversation_context)) {
3602 $channel_message .= $conversation_context;
3603 }
3604
3605 $channel_message .= "*Current Message:*\n{$message}\n\n";
3606 $channel_message .= "_Reply directly in this channel - all messages will go to the user_";
3607
3608 wp_remote_post('https://slack.com/api/chat.postMessage', [
3609 'headers' => [
3610 'Content-Type' => 'application/json',
3611 'Authorization' => 'Bearer ' . $slack_bot_token
3612 ],
3613 'body' => json_encode([
3614 'channel' => $channel_id,
3615 'text' => $channel_message,
3616 'mrkdwn' => true
3617 ])
3618 ]);
3619
3620 $success_message = $this->options['live_agent_notification_message'] ?? 'Live agent has been notified.';
3621 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
3622
3623 $this->fallbackResponse = [
3624 'text' => $success_message,
3625 'html' => '',
3626 'images' => [],
3627 'chat_mode' => 'agent'
3628 ];
3629
3630 wp_send_json([
3631 'success' => true,
3632 'text' => $success_message,
3633 'html' => '',
3634 'chat_mode' => 'agent',
3635 'session_id' => $session_id,
3636 'fallbackResponse' => $this->fallbackResponse
3637 ]);
3638 wp_die();
3639 }
3640
3641 private function generate_channel_name($session_id) {
3642 $email = null;
3643 $name = null;
3644
3645 // 1. First priority: Check if user is logged in and get their info
3646 if (is_user_logged_in()) {
3647 $current_user = wp_get_current_user();
3648 if (!empty($current_user->user_email)) {
3649 $email = $current_user->user_email;
3650 //error_log("[DEBUG] Using logged-in user email for channel: {$email}");
3651 }
3652 if (!empty($current_user->display_name)) {
3653 $name = $current_user->display_name;
3654 //error_log("[DEBUG] Using logged-in user name for channel: {$name}");
3655 }
3656 }
3657
3658 // 2. Second priority: Check for saved email/name from "require email to chat" option
3659 if (empty($email)) {
3660 $email_option_key = "mxchat_email_{$session_id}";
3661 $saved_email = get_option($email_option_key);
3662 if (!empty($saved_email)) {
3663 $email = $saved_email;
3664 //error_log("[DEBUG] Using saved email from session for channel: {$email}");
3665 }
3666 }
3667
3668 if (empty($name)) {
3669 $name_option_key = "mxchat_name_{$session_id}";
3670 $saved_name = get_option($name_option_key);
3671 if (!empty($saved_name)) {
3672 $name = $saved_name;
3673 //error_log("[DEBUG] Using saved name from session for channel: {$name}");
3674 }
3675 }
3676
3677 // 3. Third priority: Check existing chat transcript for email/name
3678 if (empty($email) || empty($name)) {
3679 global $wpdb;
3680 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3681 $existing_data = $wpdb->get_row($wpdb->prepare(
3682 "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",
3683 $session_id
3684 ));
3685
3686 if ($existing_data) {
3687 if (empty($email) && !empty($existing_data->user_email)) {
3688 $email = $existing_data->user_email;
3689 //error_log("[DEBUG] Using email from chat transcript for channel: {$email}");
3690 }
3691 if (empty($name) && !empty($existing_data->user_name)) {
3692 $name = $existing_data->user_name;
3693 //error_log("[DEBUG] Using name from chat transcript for channel: {$name}");
3694 }
3695 }
3696 }
3697
3698 // 4. Generate channel name based on priority: Name > Email > Session ID
3699 $channel_name = '';
3700
3701 if (!empty($name)) {
3702 // Convert name to valid Slack channel name
3703 $base_name = strtolower(trim($name));
3704 // Replace spaces and invalid characters
3705 $base_name = preg_replace('/[^a-z0-9\s]/', '', $base_name);
3706 $base_name = preg_replace('/\s+/', '-', $base_name);
3707 $base_name = trim($base_name, '-');
3708
3709 // Get last 4 characters of session ID for uniqueness
3710 $session_suffix = substr($session_id, -4);
3711 $channel_name = 'chat-' . $base_name . '-' . strtolower($session_suffix);
3712
3713 // Slack channel names have a 21 character limit
3714 if (strlen($channel_name) > 21) {
3715 // Calculate available space for name (21 - 'chat-' - '-' - session_suffix)
3716 $available_space = 21 - 5 - 1 - strlen($session_suffix); // 'chat-' = 5, '-' = 1
3717 $truncated_name = substr($base_name, 0, $available_space);
3718 $truncated_name = rtrim($truncated_name, '-'); // Remove trailing hyphen
3719 $channel_name = 'chat-' . $truncated_name . '-' . strtolower($session_suffix);
3720 }
3721
3722 //error_log("[DEBUG] Using name for channel: {$channel_name} (from name: {$name})");
3723
3724 } elseif (!empty($email)) {
3725 // Convert email to valid Slack channel name (your existing logic)
3726 $channel_name = 'chat-' . strtolower(str_replace(['@', '.', '+', '_'], ['-at-', '-', '-plus-', '-'], $email));
3727 // Remove any remaining invalid characters
3728 $channel_name = preg_replace('/[^a-z0-9\-]/', '', $channel_name);
3729 // Ensure it doesn't end with a hyphen
3730 $channel_name = rtrim($channel_name, '-');
3731 // Slack channel names have a 21 character limit, so truncate if needed
3732 if (strlen($channel_name) > 21) {
3733 $channel_name = substr($channel_name, 0, 21);
3734 $channel_name = rtrim($channel_name, '-'); // Remove trailing hyphen if truncation created one
3735 }
3736
3737 //error_log("[DEBUG] Using email for channel: {$channel_name} (from email: {$email})");
3738
3739 } else {
3740 // Fallback to session ID if no name or email found
3741 $channel_name = 'chat-' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $session_id));
3742 //error_log("[DEBUG] No name or email found, using session ID for channel: {$channel_name}");
3743 }
3744
3745 // Final validation - ensure channel name meets Slack requirements
3746 if (strlen($channel_name) > 21) {
3747 $channel_name = substr($channel_name, 0, 21);
3748 $channel_name = rtrim($channel_name, '-');
3749 }
3750
3751 //error_log("[DEBUG] Generated channel name: {$channel_name}");
3752 return $channel_name;
3753 }
3754
3755 /**
3756 * Telegram Live Agent Handover
3757 * Creates a forum topic in the Telegram group and notifies agents
3758 */
3759 public function mxchat_telegram_live_agent_handover($message, $user_id, $session_id) {
3760 // Check if Telegram agents are available
3761 $telegram_available = $this->options['telegram_status'] ?? 'off';
3762 if ($telegram_available !== 'on') {
3763 $away_message = $this->options['telegram_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
3764 $this->fallbackResponse = [
3765 'text' => $away_message,
3766 'html' => '',
3767 'images' => [],
3768 'chat_mode' => 'ai'
3769 ];
3770 wp_send_json([
3771 'text' => $away_message,
3772 'html' => '',
3773 'chat_mode' => 'ai',
3774 'session_id' => $session_id
3775 ]);
3776 wp_die();
3777 }
3778
3779 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3780 $telegram_group_id = $this->options['telegram_group_id'] ?? '';
3781
3782 if (empty($telegram_bot_token) || empty($telegram_group_id)) {
3783 return false;
3784 }
3785
3786 // Check if topic already exists for this session
3787 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
3788
3789 if (empty($topic_id)) {
3790 // Generate topic name
3791 $topic_name = $this->generate_telegram_topic_name($session_id);
3792
3793 // Random icon color (Telegram forum topic colors)
3794 $icon_colors = [0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F];
3795 $icon_color = $icon_colors[array_rand($icon_colors)];
3796
3797 // Create forum topic
3798 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/createForumTopic", [
3799 'headers' => ['Content-Type' => 'application/json'],
3800 'body' => json_encode([
3801 'chat_id' => $telegram_group_id,
3802 'name' => $topic_name,
3803 'icon_color' => $icon_color
3804 ])
3805 ]);
3806
3807 if (!is_wp_error($response)) {
3808 $response_body = wp_remote_retrieve_body($response);
3809 $response_data = json_decode($response_body, true);
3810
3811 if (isset($response_data['ok']) && $response_data['ok']) {
3812 $topic_id = $response_data['result']['message_thread_id'];
3813 update_option("mxchat_telegram_topic_{$session_id}", $topic_id);
3814 update_option("mxchat_telegram_group_{$session_id}", $telegram_group_id);
3815 }
3816 }
3817
3818 if (empty($topic_id)) {
3819 return false; // Failed to create topic
3820 }
3821 }
3822
3823 // Get recent chat history
3824 $history = get_option("mxchat_history_{$session_id}", []);
3825 $recent_history = array_slice($history, -5);
3826
3827 // Format conversation context for Telegram (HTML format)
3828 $conversation_context = "";
3829 if (!empty($recent_history)) {
3830 $conversation_context = "<b>Recent Conversation:</b>\n";
3831 foreach ($recent_history as $hist_message) {
3832 $role_display = $hist_message['role'] === 'user' ? '👤 User' : '🤖 AI';
3833 $escaped_content = htmlspecialchars($hist_message['content'], ENT_QUOTES, 'UTF-8');
3834 $conversation_context .= "{$role_display}: {$escaped_content}\n";
3835 }
3836 $conversation_context .= "\n";
3837 }
3838
3839 // Get user info
3840 $user_email = get_option("mxchat_email_{$session_id}", 'Not provided');
3841 $user_name = get_option("mxchat_name_{$session_id}", 'Anonymous');
3842
3843 // Update session mode
3844 update_option("mxchat_mode_{$session_id}", 'agent');
3845
3846 // Send initial message to topic
3847 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
3848 $topic_message = "🔔 <b>New Live Agent Request</b>\n\n";
3849 $topic_message .= "<b>Session ID:</b> <code>{$session_id}</code>\n";
3850 $topic_message .= "<b>User:</b> {$user_name}\n";
3851 $topic_message .= "<b>Email:</b> {$user_email}\n\n";
3852
3853 if (!empty($conversation_context)) {
3854 $topic_message .= $conversation_context;
3855 }
3856
3857 $topic_message .= "<b>Current Message:</b>\n{$escaped_message}\n\n";
3858 $topic_message .= "<i>Reply in this topic - messages will be sent to the user</i>\n";
3859 $topic_message .= "<i>Type #close, #end, #disconnect, or #done to end the session</i>";
3860
3861 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3862 'headers' => ['Content-Type' => 'application/json'],
3863 'body' => json_encode([
3864 'chat_id' => $telegram_group_id,
3865 'message_thread_id' => $topic_id,
3866 'text' => $topic_message,
3867 'parse_mode' => 'HTML'
3868 ])
3869 ]);
3870
3871 $success_message = $this->options['telegram_notification_message'] ?? "I've notified a support agent. Please allow a moment for them to respond.";
3872 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
3873
3874 $this->fallbackResponse = [
3875 'text' => $success_message,
3876 'html' => '',
3877 'images' => [],
3878 'chat_mode' => 'agent'
3879 ];
3880
3881 wp_send_json([
3882 'success' => true,
3883 'text' => $success_message,
3884 'html' => '',
3885 'chat_mode' => 'agent',
3886 'session_id' => $session_id,
3887 'fallbackResponse' => $this->fallbackResponse
3888 ]);
3889 wp_die();
3890 }
3891
3892 /**
3893 * Generate topic name for Telegram forum
3894 */
3895 private function generate_telegram_topic_name($session_id) {
3896 $name = null;
3897 $email = null;
3898
3899 // Check logged in user
3900 if (is_user_logged_in()) {
3901 $current_user = wp_get_current_user();
3902 if (!empty($current_user->display_name)) {
3903 $name = $current_user->display_name;
3904 }
3905 if (!empty($current_user->user_email)) {
3906 $email = $current_user->user_email;
3907 }
3908 }
3909
3910 // Check session data
3911 if (empty($name)) {
3912 $name = get_option("mxchat_name_{$session_id}");
3913 }
3914 if (empty($email)) {
3915 $email = get_option("mxchat_email_{$session_id}");
3916 }
3917
3918 // Generate topic name
3919 $session_suffix = substr($session_id, -6);
3920
3921 if (!empty($name)) {
3922 // Clean name for topic (max 128 chars in Telegram)
3923 $clean_name = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $name);
3924 $clean_name = trim($clean_name);
3925 if (strlen($clean_name) > 50) {
3926 $clean_name = substr($clean_name, 0, 50);
3927 }
3928 return "Chat - {$clean_name} ({$session_suffix})";
3929 } elseif (!empty($email)) {
3930 // Use email prefix
3931 $email_prefix = explode('@', $email)[0];
3932 if (strlen($email_prefix) > 30) {
3933 $email_prefix = substr($email_prefix, 0, 30);
3934 }
3935 return "Chat - {$email_prefix} ({$session_suffix})";
3936 }
3937
3938 return "Chat - {$session_suffix}";
3939 }
3940
3941 /**
3942 * Send user message to Telegram agent
3943 */
3944 public function mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id) {
3945 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3946 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
3947 $group_id = get_option("mxchat_telegram_group_{$session_id}", '');
3948
3949 if (empty($telegram_bot_token) || empty($topic_id) || empty($group_id)) {
3950 return false;
3951 }
3952
3953 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
3954 $user_message = "👤 <b>User:</b> {$escaped_message}";
3955
3956 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3957 'headers' => ['Content-Type' => 'application/json'],
3958 'body' => json_encode([
3959 'chat_id' => $group_id,
3960 'message_thread_id' => $topic_id,
3961 'text' => $user_message,
3962 'parse_mode' => 'HTML'
3963 ])
3964 ]);
3965
3966 return !is_wp_error($response);
3967 }
3968
3969 /**
3970 * Handle incoming Telegram webhook
3971 */
3972 public function handle_telegram_webhook(WP_REST_Request $request) {
3973 $body = $request->get_body();
3974 $data = json_decode($body, true);
3975
3976 //error_log('[MxChat Telegram DEBUG] Webhook received: ' . $body);
3977
3978 // Handle message events from forum topics
3979 if (isset($data['message'])) {
3980 $message_data = $data['message'];
3981
3982 // Skip if not from a forum topic
3983 if (!isset($message_data['message_thread_id'])) {
3984 //error_log('[MxChat Telegram DEBUG] Skipped: No message_thread_id (not a forum topic message)');
3985 return new WP_REST_Response(['ok' => true]);
3986 }
3987
3988 // Skip bot messages
3989 if (isset($message_data['from']['is_bot']) && $message_data['from']['is_bot']) {
3990 //error_log('[MxChat Telegram DEBUG] Skipped: Message from bot');
3991 return new WP_REST_Response(['ok' => true]);
3992 }
3993
3994 $chat_id = $message_data['chat']['id'] ?? '';
3995 $topic_id = $message_data['message_thread_id'];
3996 $message_text = $message_data['text'] ?? '';
3997 $message_id = $message_data['message_id'] ?? '';
3998 $from = $message_data['from'] ?? [];
3999 $agent_name = trim(($from['first_name'] ?? '') . ' ' . ($from['last_name'] ?? ''));
4000 if (empty($agent_name)) {
4001 $agent_name = $from['username'] ?? 'Agent';
4002 }
4003
4004 //error_log("[MxChat Telegram DEBUG] Parsed: chat_id={$chat_id}, topic_id={$topic_id}, agent={$agent_name}, text={$message_text}");
4005
4006 // Skip empty messages
4007 if (empty($message_text)) {
4008 //error_log('[MxChat Telegram DEBUG] Skipped: Empty message text');
4009 return new WP_REST_Response(['ok' => true]);
4010 }
4011
4012 // Find session ID by topic ID - cast to string for comparison
4013 global $wpdb;
4014 $topic_id_str = strval($topic_id);
4015 $session_option = $wpdb->get_var(
4016 $wpdb->prepare(
4017 "SELECT option_name FROM {$wpdb->options}
4018 WHERE option_name LIKE %s
4019 AND option_value = %s",
4020 'mxchat_telegram_topic_%',
4021 $topic_id_str
4022 )
4023 );
4024
4025 //error_log("[MxChat Telegram DEBUG] Looking for topic_id={$topic_id_str} in options, found: " . ($session_option ?: 'NULL'));
4026
4027 if ($session_option) {
4028 $session_id = str_replace('mxchat_telegram_topic_', '', $session_option);
4029 //error_log("[MxChat Telegram DEBUG] Session ID: {$session_id}");
4030
4031 // Verify the group ID matches
4032 $stored_group_id = get_option("mxchat_telegram_group_{$session_id}", '');
4033 //error_log("[MxChat Telegram DEBUG] Stored group_id={$stored_group_id}, received chat_id={$chat_id}");
4034
4035 if (strval($stored_group_id) != strval($chat_id)) {
4036 //error_log('[MxChat Telegram DEBUG] Skipped: Group ID mismatch');
4037 return new WP_REST_Response(['ok' => true]);
4038 }
4039
4040 // Check for closure commands
4041 $lower_text = strtolower(trim($message_text));
4042 if (in_array($lower_text, ['#close', '#end', '#disconnect', '#done'])) {
4043 //error_log("[MxChat Telegram DEBUG] Closure command received: {$lower_text}");
4044 // End the live agent session
4045 update_option("mxchat_mode_{$session_id}", 'ai');
4046
4047 // Save disconnect message
4048 $disconnect_message = "Live agent session ended. You're now chatting with the AI assistant.";
4049 $this->mxchat_save_chat_message($session_id, 'bot', $disconnect_message);
4050
4051 // Notify in Telegram
4052 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4053 if (!empty($telegram_bot_token)) {
4054 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4055 'headers' => ['Content-Type' => 'application/json'],
4056 'body' => json_encode([
4057 'chat_id' => $chat_id,
4058 'message_thread_id' => $topic_id,
4059 'text' => "✅ Session closed. User returned to AI chatbot.",
4060 'parse_mode' => 'HTML'
4061 ])
4062 ]);
4063
4064 // Optionally close the topic
4065 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/closeForumTopic", [
4066 'headers' => ['Content-Type' => 'application/json'],
4067 'body' => json_encode([
4068 'chat_id' => $chat_id,
4069 'message_thread_id' => $topic_id
4070 ])
4071 ]);
4072 }
4073
4074 return new WP_REST_Response(['ok' => true]);
4075 }
4076
4077 // Deduplicate messages
4078 $message_key = md5($session_id . $message_id . $message_text);
4079 $processed_messages = get_transient('mxchat_telegram_messages_' . $session_id) ?: [];
4080
4081 if (in_array($message_key, $processed_messages)) {
4082 //error_log('[MxChat Telegram DEBUG] Skipped: Duplicate message');
4083 return new WP_REST_Response(['ok' => true]);
4084 }
4085
4086 $processed_messages[] = $message_key;
4087 if (count($processed_messages) > 50) {
4088 $processed_messages = array_slice($processed_messages, -50);
4089 }
4090 set_transient('mxchat_telegram_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
4091
4092 // Save the agent message - format with agent name prefix for proper parsing
4093 $formatted_message = "Agent: {$agent_name} - {$message_text}";
4094 //error_log("[MxChat Telegram DEBUG] Saving agent message: {$formatted_message}");
4095
4096 $this->mxchat_save_chat_message($session_id, 'agent', $formatted_message);
4097
4098 // Verify the message was saved to history
4099 $history = get_option("mxchat_history_{$session_id}", []);
4100 $last_message = end($history);
4101 //error_log("[MxChat Telegram DEBUG] History after save - count: " . count($history) . ", last message role: " . ($last_message['role'] ?? 'none'));
4102
4103 // Send confirmation back to Telegram
4104 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4105 if (!empty($telegram_bot_token)) {
4106 $confirm_key = 'mxchat_telegram_confirm_' . $message_key;
4107 if (!get_transient($confirm_key)) {
4108 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4109 'headers' => ['Content-Type' => 'application/json'],
4110 'body' => json_encode([
4111 'chat_id' => $chat_id,
4112 'message_thread_id' => $topic_id,
4113 'text' => "✅ <i>Message sent to user</i>",
4114 'parse_mode' => 'HTML',
4115 'reply_to_message_id' => $message_id
4116 ])
4117 ]);
4118 set_transient($confirm_key, true, 300);
4119 }
4120 }
4121 } else {
4122 //error_log("[MxChat Telegram DEBUG] No session found for topic_id={$topic_id}");
4123 }
4124 } else {
4125 //error_log('[MxChat Telegram DEBUG] No message in webhook data');
4126 }
4127
4128 return new WP_REST_Response(['ok' => true]);
4129 }
4130
4131 public function mxchat_send_user_message_to_agent($message, $user_id, $session_id) {
4132 // Check if this is a Telegram agent session
4133 $telegram_topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
4134 if (!empty($telegram_topic_id)) {
4135 return $this->mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id);
4136 }
4137
4138 // Otherwise, try Slack
4139 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4140 $channel_id = get_option("mxchat_channel_{$session_id}", '');
4141
4142 if (empty($slack_bot_token) || empty($channel_id)) {
4143 return false;
4144 }
4145
4146 $user_message = "💬 *User:* {$message}";
4147
4148 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
4149 'headers' => [
4150 'Content-Type' => 'application/json',
4151 'Authorization' => 'Bearer ' . $slack_bot_token
4152 ],
4153 'body' => json_encode([
4154 'channel' => $channel_id,
4155 'text' => $user_message,
4156 'mrkdwn' => true
4157 ])
4158 ]);
4159
4160 return !is_wp_error($response);
4161 }
4162 public function handle_slack_interaction(WP_REST_Request $request) {
4163 //error_log('Received Slack interaction');
4164
4165 $payload = json_decode($request->get_param('payload'), true);
4166 //error_log('Payload: ' . print_r($payload, true));
4167
4168 // Handle button click
4169 if ($payload['type'] === 'block_actions' && $payload['actions'][0]['action_id'] === 'reply_to_user') {
4170 $session_id = $payload['actions'][0]['value'];
4171 $trigger_id = $payload['trigger_id'];
4172
4173 // Get Bot Token from settings
4174 $slack_token = $this->options['live_agent_bot_token'] ?? '';
4175
4176 if (empty($slack_token)) {
4177 //error_log('Slack Bot Token not configured');
4178 return new WP_REST_Response(['error' => esc_html__('Bot token not configured', 'mxchat')], 400);
4179 }
4180 $response = wp_remote_post('https://slack.com/api/views.open', [
4181 'headers' => [
4182 'Content-Type' => 'application/json',
4183 'Authorization' => 'Bearer ' . $slack_token
4184 ],
4185 'body' => json_encode([
4186 'trigger_id' => $trigger_id,
4187 'view' => [
4188 'type' => 'modal',
4189 'callback_id' => 'reply_modal',
4190 'title' => [
4191 'type' => 'plain_text',
4192 'text' => __('Reply to User', 'mxchat')
4193 ],
4194 'submit' => [
4195 'type' => 'plain_text',
4196 'text' => __('Send', 'mxchat')
4197 ],
4198 'close' => [
4199 'type' => 'plain_text',
4200 'text' => __('Cancel', 'mxchat')
4201 ],
4202 'blocks' => [
4203 [
4204 'type' => 'input',
4205 'block_id' => 'reply_block',
4206 'label' => [
4207 'type' => 'plain_text',
4208 'text' => sprintf(__('Reply to session: %s', 'mxchat'), $session_id)
4209 ],
4210 'element' => [
4211 'type' => 'plain_text_input',
4212 'action_id' => 'message',
4213 'multiline' => true,
4214 'placeholder' => [
4215 'type' => 'plain_text',
4216 'text' => __('Type your message here...', 'mxchat')
4217 ]
4218 ]
4219 ]
4220 ],
4221 'private_metadata' => $session_id
4222 ]
4223 ])
4224 ]);
4225
4226 //error_log('Views.open response: ' . print_r($response, true));
4227
4228 // Return immediate acknowledgment
4229 return new WP_REST_Response(['ok' => true]);
4230 }
4231
4232 // Handle modal submission
4233 // Handle modal submission
4234 if ($payload['type'] === 'view_submission') {
4235 $session_id = $payload['view']['private_metadata'];
4236 $message = $payload['view']['state']['values']['reply_block']['message']['value'];
4237
4238 // Save the message (keep the message_id but don't include in response)
4239 $this->mxchat_save_chat_message($session_id, 'agent', $message);
4240
4241 // Keep the original response format for Slack
4242 return new WP_REST_Response([
4243 'response_action' => 'clear'
4244 ]);
4245 }
4246
4247 // Default acknowledgment
4248 return new WP_REST_Response(['ok' => true]);
4249 }
4250 public function mxchat_handle_agent_response(WP_REST_Request $request) {
4251 //error_log('Received agent response request');
4252 //error_log('Request data: ' . print_r($request->get_params(), true));
4253 // //error_log('Raw body: ' . file_get_contents('php://input'));
4254
4255 // Get the data from Slack's slash command format
4256 $command_text = $request->get_param('text');
4257 // //error_log('Command text: ' . $command_text);
4258
4259 if (empty($command_text)) {
4260 //error_log(esc_html__('Agent response error: No command text received', 'mxchat'));
4261 return new WP_REST_Response([
4262 'error' => esc_html__('Command text is required. Format: /reply session_id message', 'mxchat')
4263 ], 400);
4264 }
4265
4266 // Split the command text into session_id and message
4267 $parts = explode(' ', $command_text, 2);
4268 if (count($parts) !== 2) {
4269 //error_log('Agent response error: Invalid command format');
4270 return new WP_REST_Response([
4271 'error' => esc_html__('Invalid format. Use: /reply session_id message', 'mxchat')
4272 ], 400);
4273 }
4274
4275 $session_id = sanitize_text_field($parts[0]);
4276 $message = sanitize_text_field($parts[1]);
4277
4278 //error_log("Processing agent response - Session ID: $session_id, Message: $message");
4279
4280 // Save the message
4281 $message_id = $this->mxchat_save_chat_message($session_id, 'agent', $message);
4282
4283 if (!$message_id) {
4284 // //error_log('Failed to save agent message');
4285 return new WP_REST_Response([
4286 'error' => esc_html__('Failed to save message', 'mxchat')
4287 ], 500);
4288 }
4289
4290 // Return success response in Slack's expected format
4291 return new WP_REST_Response([
4292 'response_type' => 'in_channel',
4293 'text' => esc_html__("Message sent successfully to session $session_id", 'mxchat')
4294 ], 200);
4295 }
4296 public function mxchat_handle_switch_to_chatbot_intent($message, $user_id, $session_id) {
4297 // Update mode to AI
4298 update_option("mxchat_mode_{$session_id}", 'ai');
4299
4300 // Clear any existing PDF context to start fresh
4301 $this->clear_pdf_transients($session_id);
4302
4303 // Set the response with explicit chat_mode
4304 $this->fallbackResponse = [
4305 'text' => esc_html__('You are now chatting with the AI chatbot.', 'mxchat'),
4306 'html' => '',
4307 'images' => [],
4308 'chat_mode' => 'ai' // Ensure this is set
4309 ];
4310
4311 // Return the complete response array instead of just true
4312 return $this->fallbackResponse;
4313 }
4314
4315 public function handle_slack_messages(WP_REST_Request $request) {
4316 // Log the incoming request for debugging
4317 //error_log('Slack events request received: ' . $request->get_body());
4318
4319 $body = $request->get_body();
4320 $data = json_decode($body, true);
4321
4322 // Handle Slack URL verification
4323 if (isset($data['type']) && $data['type'] === 'url_verification') {
4324 //error_log('Slack URL verification challenge: ' . $data['challenge']);
4325 return new WP_REST_Response($data['challenge'], 200, ['Content-Type' => 'text/plain']);
4326 }
4327
4328 // IMPORTANT: Handle Slack's event deduplication
4329 if (isset($data['event_id'])) {
4330 $event_id = $data['event_id'];
4331 $processed_events = get_transient('mxchat_slack_events') ?: [];
4332
4333 // Check if we've already processed this event
4334 if (in_array($event_id, $processed_events)) {
4335 //error_log("Duplicate event detected: $event_id");
4336 return new WP_REST_Response(['ok' => true]);
4337 }
4338
4339 // Add this event to processed list
4340 $processed_events[] = $event_id;
4341 // Keep only last 100 events to prevent memory issues
4342 if (count($processed_events) > 100) {
4343 $processed_events = array_slice($processed_events, -100);
4344 }
4345 // Store for 1 hour
4346 set_transient('mxchat_slack_events', $processed_events, HOUR_IN_SECONDS);
4347 }
4348
4349 // Handle message events
4350 if (isset($data['event']) && $data['event']['type'] === 'message') {
4351 $event = $data['event'];
4352
4353 // Skip bot messages and messages with subtypes (like bot_message)
4354 if (isset($event['bot_id']) || isset($event['subtype'])) {
4355 return new WP_REST_Response(['ok' => true]);
4356 }
4357
4358 // Additional check: Skip if this is a threaded reply to our confirmation
4359 if (isset($event['thread_ts']) && $event['thread_ts'] !== $event['ts']) {
4360 return new WP_REST_Response(['ok' => true]);
4361 }
4362
4363 $channel_id = $event['channel'];
4364 $message_text = $event['text'] ?? '';
4365 $message_ts = $event['ts'] ?? '';
4366
4367 // Find session ID by looking for matching channel
4368 global $wpdb;
4369 $session_option = $wpdb->get_var(
4370 $wpdb->prepare(
4371 "SELECT option_name FROM {$wpdb->options}
4372 WHERE option_name LIKE 'mxchat_channel_%'
4373 AND option_value = %s",
4374 $channel_id
4375 )
4376 );
4377
4378 if ($session_option) {
4379 $session_id = str_replace('mxchat_channel_', '', $session_option);
4380
4381 // Create a unique key for this specific message
4382 $message_key = md5($session_id . $message_ts . $message_text);
4383 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
4384
4385 // Check if we've already processed this exact message
4386 if (in_array($message_key, $processed_messages)) {
4387 //error_log("Duplicate message detected for session $session_id");
4388 return new WP_REST_Response(['ok' => true]);
4389 }
4390
4391 // Add to processed messages
4392 $processed_messages[] = $message_key;
4393 // Keep only last 50 messages per session
4394 if (count($processed_messages) > 50) {
4395 $processed_messages = array_slice($processed_messages, -50);
4396 }
4397 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
4398
4399 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4400
4401 // Handle agent ending the chat — transfer back to AI
4402 // Format: "!endchat" or "!endchat <custom message to user>"
4403 if (preg_match('/^!endchat\b/i', trim($message_text))) {
4404 update_option("mxchat_mode_{$session_id}", 'ai');
4405
4406 // Extract custom message after !endchat, or use empty string
4407 $custom_message = trim(preg_replace('/^!endchat\s*/i', '', trim($message_text)));
4408
4409 // Send the agent's custom farewell message if provided
4410 if (!empty($custom_message)) {
4411 $this->mxchat_save_chat_message($session_id, 'agent', $custom_message);
4412 }
4413
4414 // Confirm in Slack channel
4415 if (!empty($slack_bot_token)) {
4416 wp_remote_post('https://slack.com/api/chat.postMessage', [
4417 'headers' => [
4418 'Content-Type' => 'application/json',
4419 'Authorization' => 'Bearer ' . $slack_bot_token
4420 ],
4421 'body' => json_encode([
4422 'channel' => $channel_id,
4423 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.",
4424 'mrkdwn' => true
4425 ])
4426 ]);
4427 }
4428
4429 return new WP_REST_Response(['ok' => true]);
4430 }
4431
4432 // Save the agent message
4433 $this->mxchat_save_chat_message($session_id, 'agent', $message_text);
4434
4435 // Send confirmation back to Slack (only once)
4436 if (!empty($slack_bot_token)) {
4437 // Use a transient to prevent duplicate confirmations
4438 $confirm_key = 'mxchat_confirm_' . $message_key;
4439 if (!get_transient($confirm_key)) {
4440 wp_remote_post('https://slack.com/api/chat.postMessage', [
4441 'headers' => [
4442 'Content-Type' => 'application/json',
4443 'Authorization' => 'Bearer ' . $slack_bot_token
4444 ],
4445 'body' => json_encode([
4446 'channel' => $channel_id,
4447 'text' => "✅ _Message sent to user_",
4448 'thread_ts' => $event['ts'] // Reply in thread
4449 ])
4450 ]);
4451 // Set transient to prevent duplicate confirmations
4452 set_transient($confirm_key, true, 300); // 5 minutes
4453 }
4454 }
4455 }
4456 }
4457
4458 return new WP_REST_Response(['ok' => true]);
4459 }
4460
4461 // For the word upload handler
4462 public function mxchat_handle_word_upload() {
4463 // Delegate to word handler
4464 $this->word_handler->mxchat_handle_word_upload();
4465 }
4466
4467 // For the word removal handler
4468 public function mxchat_handle_word_remove() {
4469 // Delegate to word handler
4470 $this->word_handler->mxchat_handle_word_remove();
4471 }
4472
4473 // For the word status check
4474 public function mxchat_check_word_status() {
4475 // Delegate to word handler
4476 $this->word_handler->mxchat_check_word_status();
4477 }
4478
4479
4480 private function mxchat_get_user_identifier() {
4481 return MxChat_User::mxchat_get_user_identifier();
4482 }
4483
4484 private function mxchat_generate_embedding($text, $api_key) {
4485 try {
4486 // Get options and selected model
4487 $options = get_option('mxchat_options');
4488 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4489
4490 // Determine endpoint and API key based on model
4491 if (strpos($selected_model, 'voyage') === 0) {
4492 $endpoint = 'https://api.voyageai.com/v1/embeddings';
4493 $api_key = $options['voyage_api_key'] ?? '';
4494
4495 // Check if Voyage API key is missing
4496 if (empty($api_key)) {
4497 //error_log('Voyage API key is missing');
4498 return [
4499 'error' => esc_html__('Voyage AI API key is not configured', 'mxchat'),
4500 'error_code' => 'missing_voyage_api_key'
4501 ];
4502 }
4503 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4504 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
4505 $api_key = $options['gemini_api_key'] ?? '';
4506
4507 // Check if Gemini API key is missing
4508 if (empty($api_key)) {
4509 //error_log('Gemini API key is missing');
4510 return [
4511 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
4512 'error_code' => 'missing_gemini_api_key'
4513 ];
4514 }
4515 } else {
4516 $endpoint = 'https://api.openai.com/v1/embeddings';
4517 // Use the passed API key for OpenAI
4518
4519 // Check if OpenAI API key is missing
4520 if (empty($api_key)) {
4521 //error_log('OpenAI API key is missing');
4522 return [
4523 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
4524 'error_code' => 'missing_openai_api_key'
4525 ];
4526 }
4527 }
4528
4529 // Check if text is empty
4530 if (empty($text)) {
4531 //error_log('Empty text provided for embedding generation');
4532 return [
4533 'error' => esc_html__('No text provided for embedding generation', 'mxchat'),
4534 'error_code' => 'empty_embedding_text'
4535 ];
4536 }
4537
4538 // Prepare request body based on provider
4539 if (strpos($selected_model, 'gemini-embedding') === 0) {
4540 // Gemini API format
4541 $request_body = [
4542 'model' => 'models/' . $selected_model,
4543 'content' => [
4544 'parts' => [
4545 ['text' => $text]
4546 ]
4547 ],
4548 'outputDimensionality' => 1536
4549 ];
4550
4551 // Prepare headers for Gemini (API key as query parameter)
4552 $endpoint .= '?key=' . $api_key;
4553 $headers = [
4554 'Content-Type' => 'application/json'
4555 ];
4556 } else {
4557 // OpenAI/Voyage API format
4558 $request_body = [
4559 'input' => $text,
4560 'model' => $selected_model
4561 ];
4562
4563 // Add output_dimension for voyage-3-large
4564 if ($selected_model === 'voyage-3-large') {
4565 $request_body['output_dimension'] = 2048;
4566 }
4567
4568 // Prepare headers for OpenAI/Voyage
4569 $headers = [
4570 'Content-Type' => 'application/json',
4571 'Authorization' => 'Bearer ' . $api_key
4572 ];
4573 }
4574
4575 // Prepare request arguments
4576 $args = [
4577 'body' => wp_json_encode($request_body),
4578 'headers' => $headers,
4579 'timeout' => 60,
4580 'redirection' => 5,
4581 'blocking' => true,
4582 'httpversion' => '1.0',
4583 'sslverify' => true,
4584 ];
4585
4586 // Make the request
4587 $response = wp_remote_post($endpoint, $args);
4588
4589 // Handle WordPress errors
4590 if (is_wp_error($response)) {
4591 $error_message = $response->get_error_message();
4592 //error_log('Embedding Generation Error: ' . $error_message);
4593 return [
4594 'error' => esc_html__('Connection error when generating embeddings: ', 'mxchat') . esc_html($error_message),
4595 'error_code' => 'embedding_connection_error'
4596 ];
4597 }
4598
4599 // Check HTTP status code
4600 $status_code = wp_remote_retrieve_response_code($response);
4601 if ($status_code !== 200) {
4602 $response_body = json_decode(wp_remote_retrieve_body($response), true);
4603
4604 $error_message = isset($response_body['error']['message'])
4605 ? $response_body['error']['message']
4606 : 'HTTP Error ' . $status_code;
4607
4608 $error_type = isset($response_body['error']['type'])
4609 ? $response_body['error']['type']
4610 : 'unknown';
4611
4612 //error_log('Embedding API HTTP Error: ' . $status_code . ' - ' . $error_message);
4613
4614 // Handle specific error types
4615 switch ($error_type) {
4616 case 'invalid_request_error':
4617 if (strpos($error_message, 'API key') !== false) {
4618 return [
4619 'error' => esc_html__('Invalid API key for embedding generation. Please check your API key configuration.', 'mxchat'),
4620 'error_code' => 'embedding_invalid_api_key'
4621 ];
4622 }
4623 break;
4624
4625 case 'authentication_error':
4626 return [
4627 'error' => esc_html__('Authentication failed for embedding generation. Please check your API key.', 'mxchat'),
4628 'error_code' => 'embedding_auth_error'
4629 ];
4630
4631 case 'rate_limit_exceeded':
4632 return [
4633 'error' => esc_html__('Rate limit exceeded for embedding generation. Please try again later.', 'mxchat'),
4634 'error_code' => 'embedding_rate_limit'
4635 ];
4636
4637 case 'quota_exceeded':
4638 return [
4639 'error' => esc_html__('API quota exceeded for embedding generation. Please check your billing details.', 'mxchat'),
4640 'error_code' => 'embedding_quota_exceeded'
4641 ];
4642 }
4643
4644 // Generic error fallback
4645 return [
4646 'error' => esc_html__('Embedding API error - check embedding API key.: ', 'mxchat') . esc_html($error_message),
4647 'error_code' => 'embedding_api_error',
4648 'status_code' => $status_code
4649 ];
4650 }
4651
4652 $response_body = json_decode(wp_remote_retrieve_body($response), true);
4653
4654 // Handle different response formats based on provider
4655 if (strpos($selected_model, 'gemini-embedding') === 0) {
4656 // Gemini API response format
4657 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
4658 return $response_body['embedding']['values'];
4659 } else {
4660 //error_log('Invalid Gemini embedding response: ' . wp_json_encode($response_body));
4661 return [
4662 'error' => esc_html__('Received invalid embedding data from the Gemini API.', 'mxchat'),
4663 'error_code' => 'invalid_gemini_embedding_response'
4664 ];
4665 }
4666 } else {
4667 // OpenAI/Voyage API response format
4668 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
4669 return $response_body['data'][0]['embedding'];
4670 } else {
4671 //error_log('Invalid embedding response: ' . wp_json_encode($response_body));
4672 return [
4673 'error' => esc_html__('Received invalid embedding data from the API.', 'mxchat'),
4674 'error_code' => 'invalid_embedding_response'
4675 ];
4676 }
4677 }
4678 } catch (Exception $e) {
4679 //error_log('Embedding Exception: ' . $e->getMessage());
4680 return [
4681 'error' => esc_html__('System error when generating embeddings: ', 'mxchat') . esc_html($e->getMessage()),
4682 'error_code' => 'embedding_exception'
4683 ];
4684 }
4685 }
4686
4687
4688 private function mxchat_find_relevant_content($user_embedding, $bot_id = 'default', $user_query = '') {
4689 //error_log("MXCHAT DEBUG: find_relevant_content called with bot_id: " . $bot_id);
4690
4691 // Check for OpenAI Vector Store first (takes priority when enabled)
4692 $bot_vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
4693
4694 if ($bot_vectorstore_config['use_vectorstore']) {
4695 // Get current model to verify it's an OpenAI model
4696 $bot_options = $this->get_bot_options($bot_id);
4697 $mxchat_options = get_option('mxchat_options', array());
4698 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
4699 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
4700
4701 if ($this->is_openai_chat_model($selected_model)) {
4702 //error_log("MXCHAT DEBUG: Using OpenAI Vector Store for knowledge retrieval");
4703 return $this->find_relevant_content_openai_vectorstore($user_query, $bot_id, $bot_vectorstore_config);
4704 } else {
4705 //error_log("MXCHAT DEBUG: Vector Store enabled but model is not OpenAI (" . $selected_model . "), skipping Vector Store");
4706 }
4707 }
4708
4709 // Get bot-specific Pinecone configuration
4710 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
4711
4712 // Debug: Log the Pinecone configuration
4713 //error_log("MXCHAT DEBUG: Pinecone config for bot '$bot_id':");
4714 //error_log(" - use_pinecone: " . ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false'));
4715 //error_log(" - api_key: " . (empty($bot_pinecone_config['api_key']) ? 'EMPTY' : 'SET (hidden)'));
4716 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'NOT SET'));
4717 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'NOT SET'));
4718
4719 // Determine whether to use Pinecone based on bot configuration
4720 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
4721
4722 //error_log("MXCHAT DEBUG: Using " . ($use_pinecone ? "Pinecone" : "WordPress Database") . " for knowledge retrieval");
4723
4724 if ($use_pinecone) {
4725 return $this->find_relevant_content_pinecone($user_embedding, $bot_id, $bot_pinecone_config);
4726 } else {
4727 return $this->find_relevant_content_wordpress($user_embedding, $bot_id);
4728 }
4729 }
4730
4731 private function find_relevant_content_wordpress($user_embedding, $bot_id = 'default') {
4732 global $wpdb;
4733 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
4734 // Initialize similarity analysis storage
4735 $this->last_similarity_analysis = [
4736 'knowledge_base_type' => 'WordPress Database',
4737 'bot_id' => $bot_id,
4738 'top_matches' => [],
4739 'threshold_used' => 0,
4740 'total_checked' => 0
4741 ];
4742
4743 // NEW: Initialize valid URLs array
4744 $valid_urls = [];
4745
4746 // Get bot-specific options for similarity threshold
4747 $bot_options = $this->get_bot_options($bot_id);
4748 $current_options = !empty($bot_options) ? $bot_options : $this->options;
4749
4750 // Get knowledge manager instance for role checking
4751 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
4752
4753 // Get base similarity threshold from bot options or default options
4754 $similarity_threshold = isset($current_options['similarity_threshold'])
4755 ? ((int) $current_options['similarity_threshold']) / 100
4756 : 0.35;
4757 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
4758
4759 // Precompute bot_filter once, outside the streaming loop
4760 $bot_filter = '';
4761 if ($bot_id !== 'default') {
4762 $column_exists = $wpdb->get_var("SHOW COLUMNS FROM {$system_prompt_table} LIKE 'bot_metadata'");
4763 if ($column_exists) {
4764 $bot_filter = $wpdb->prepare(" AND (bot_metadata = %s OR bot_metadata IS NULL OR bot_metadata = '')", $bot_id);
4765 }
4766 }
4767
4768 // ===== STREAMING TOP-K PASS =====
4769 // Stream rows in small batches, compute cosine similarity per row, and keep only:
4770 // - top 10 by raw similarity (for the testing/debug display panel)
4771 // - candidates above threshold with access (capped) for context assembly
4772 // This bounds peak memory regardless of knowledge base size and avoids loading
4773 // article_content for every row. article_content is fetched in Phase 2 for winners only.
4774 $batch_size = 250;
4775 $max_candidates = 200; // safety cap, well above rag_sources_limit * max_chunks_per_source
4776 $top_display = [];
4777 $candidates = [];
4778 $total_checked = 0;
4779 $offset = 0;
4780
4781 do {
4782 $batch = $wpdb->get_results($wpdb->prepare(
4783 "SELECT id, embedding_vector, source_url, role_restriction
4784 FROM {$system_prompt_table}
4785 WHERE 1=1 {$bot_filter}
4786 LIMIT %d OFFSET %d",
4787 $batch_size,
4788 $offset
4789 ));
4790
4791 if (empty($batch)) {
4792 break;
4793 }
4794
4795 foreach ($batch as $row) {
4796 $database_embedding = $row->embedding_vector
4797 ? unserialize($row->embedding_vector, ['allowed_classes' => false])
4798 : null;
4799
4800 if (!is_array($database_embedding) || !is_array($user_embedding)) {
4801 unset($database_embedding);
4802 continue;
4803 }
4804
4805 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
4806 unset($database_embedding);
4807
4808 $role_restriction = $row->role_restriction ?? 'public';
4809 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
4810 $source_url = $row->source_url ?? '';
4811
4812 // Maintain top 10 display buffer (insert-if-beats-worst)
4813 if (count($top_display) < 10) {
4814 $top_display[] = [
4815 'id' => $row->id,
4816 'similarity' => $similarity,
4817 'source_url' => $source_url,
4818 'role_restriction' => $role_restriction,
4819 'has_access' => $has_access,
4820 ];
4821 usort($top_display, function ($a, $b) {
4822 return $b['similarity'] <=> $a['similarity'];
4823 });
4824 } elseif ($similarity > $top_display[9]['similarity']) {
4825 $top_display[9] = [
4826 'id' => $row->id,
4827 'similarity' => $similarity,
4828 'source_url' => $source_url,
4829 'role_restriction' => $role_restriction,
4830 'has_access' => $has_access,
4831 ];
4832 usort($top_display, function ($a, $b) {
4833 return $b['similarity'] <=> $a['similarity'];
4834 });
4835 }
4836
4837 // Track candidates for context assembly (above threshold + has access)
4838 if ($similarity >= $similarity_threshold && $has_access) {
4839 $candidates[] = [
4840 'id' => $row->id,
4841 'similarity' => $similarity,
4842 'source_url' => $source_url,
4843 ];
4844 }
4845
4846 $total_checked++;
4847 }
4848
4849 unset($batch);
4850
4851 // Trim candidates periodically to cap memory during long scans
4852 if (count($candidates) > $max_candidates) {
4853 usort($candidates, function ($a, $b) {
4854 return $b['similarity'] <=> $a['similarity'];
4855 });
4856 $candidates = array_slice($candidates, 0, $max_candidates);
4857 }
4858
4859 $offset += $batch_size;
4860 } while (true);
4861
4862 if ($total_checked === 0) {
4863 $this->current_valid_urls = [];
4864 return '';
4865 }
4866
4867 // Final candidates sort (best first)
4868 if (count($candidates) > 1) {
4869 usort($candidates, function ($a, $b) {
4870 return $b['similarity'] <=> $a['similarity'];
4871 });
4872 }
4873
4874 // ===== PHASE 2: FETCH ARTICLE CONTENT ONLY FOR WINNERS =====
4875 // Gather unique IDs we actually need (top_display + candidates) and pull
4876 // article_content in bounded IN() batches. This avoids loading content for
4877 // every row during the similarity scan.
4878 $needed_ids = [];
4879 foreach ($top_display as $item) {
4880 $needed_ids[$item['id']] = true;
4881 }
4882 foreach ($candidates as $item) {
4883 $needed_ids[$item['id']] = true;
4884 }
4885 $needed_ids = array_keys($needed_ids);
4886
4887 $content_map = [];
4888 if (!empty($needed_ids)) {
4889 foreach (array_chunk($needed_ids, 250) as $chunk_ids) {
4890 $placeholders = implode(',', array_fill(0, count($chunk_ids), '%d'));
4891 $rows = $wpdb->get_results($wpdb->prepare(
4892 "SELECT id, article_content FROM {$system_prompt_table} WHERE id IN ($placeholders)",
4893 ...$chunk_ids
4894 ));
4895 foreach ($rows as $r) {
4896 $content_map[$r->id] = $r->article_content;
4897 }
4898 unset($rows);
4899 }
4900 }
4901
4902 // Build the all_similarities display array from the top 10
4903 $all_similarities = [];
4904 foreach ($top_display as $item) {
4905 $article_content_for_parse = $content_map[$item['id']] ?? '';
4906 $parsed_for_display = MxChat_Chunker::parse_stored_chunk($article_content_for_parse);
4907 $is_chunk = $parsed_for_display['is_chunked'];
4908 $chunk_meta = $parsed_for_display['metadata'];
4909
4910 if (!empty($item['source_url']) && $item['source_url'] !== '#') {
4911 $source_display = $item['source_url'];
4912 } else {
4913 $content_preview = strip_tags($article_content_for_parse);
4914 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
4915 $source_display = substr(trim($content_preview), 0, 50) . '...';
4916 }
4917
4918 $all_similarities[] = [
4919 'document_id' => $item['id'],
4920 'similarity' => $item['similarity'],
4921 'similarity_percentage' => round($item['similarity'] * 100, 2),
4922 'above_threshold' => $item['similarity'] >= $similarity_threshold,
4923 'source_display' => $source_display,
4924 'content_preview' => substr(strip_tags($parsed_for_display['text'] ?? ''), 0, 100) . '...',
4925 'used_for_context' => false,
4926 'role_restriction' => $item['role_restriction'],
4927 'has_access' => $item['has_access'],
4928 'filtered_out' => !$item['has_access'],
4929 'is_chunk' => $is_chunk,
4930 'chunk_index' => $is_chunk ? ($chunk_meta['chunk_index'] ?? 0) : null,
4931 'total_chunks' => $is_chunk ? ($chunk_meta['total_chunks'] ?? 1) : null
4932 ];
4933 }
4934
4935 // Build url_groups from candidates for chunk reassembly
4936 $url_groups = array();
4937 foreach ($candidates as $cand) {
4938 $article_content = $content_map[$cand['id']] ?? '';
4939 $parsed = MxChat_Chunker::parse_stored_chunk($article_content);
4940 $is_chunked = $parsed['is_chunked'];
4941 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
4942 $text_content = $parsed['text'];
4943
4944 $source_url = $cand['source_url'];
4945 $group_key = !empty($source_url) ? $source_url : '_manual_' . $cand['id'];
4946
4947 if (!isset($url_groups[$group_key])) {
4948 $url_groups[$group_key] = array(
4949 'source_url' => $source_url,
4950 'best_score' => 0,
4951 'is_chunked' => $is_chunked,
4952 'chunks' => array(),
4953 'single_text' => '',
4954 'single_id' => null
4955 );
4956 }
4957
4958 if ($cand['similarity'] > $url_groups[$group_key]['best_score']) {
4959 $url_groups[$group_key]['best_score'] = $cand['similarity'];
4960 }
4961
4962 if ($is_chunked) {
4963 $url_groups[$group_key]['is_chunked'] = true;
4964 $url_groups[$group_key]['chunks'][] = array(
4965 'id' => $cand['id'],
4966 'score' => $cand['similarity'],
4967 'chunk_index' => $chunk_index,
4968 'text' => $text_content
4969 );
4970 } else {
4971 $url_groups[$group_key]['single_text'] = $text_content;
4972 $url_groups[$group_key]['single_id'] = $cand['id'];
4973 }
4974 }
4975
4976 // Sort ALL similarities for testing display (highest first)
4977 usort($all_similarities, function ($a, $b) {
4978 return $b['similarity'] <=> $a['similarity'];
4979 });
4980
4981 // Sort URL groups by best score (highest first)
4982 uasort($url_groups, function($a, $b) {
4983 return $b['best_score'] <=> $a['best_score'];
4984 });
4985
4986 // Get RAG sources limit from options (default 6, min 3, max 10)
4987 $rag_sources_limit = isset($options['rag_sources_limit']) ? intval($options['rag_sources_limit']) : 3;
4988 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
4989 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
4990
4991 // Take top N unique URLs based on user setting
4992 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
4993
4994 // Track which document IDs are used for context
4995 $used_document_ids = [];
4996 foreach ($top_urls as $group) {
4997 if ($group['is_chunked']) {
4998 foreach ($group['chunks'] as $chunk) {
4999 $used_document_ids[] = $chunk['id'];
5000 }
5001 } elseif ($group['single_id']) {
5002 $used_document_ids[] = $group['single_id'];
5003 }
5004 }
5005
5006 // Update the all_similarities array to mark which were actually used
5007 foreach ($all_similarities as &$similarity_item) {
5008 $similarity_item['used_for_context'] = in_array($similarity_item['document_id'], $used_document_ids);
5009 }
5010
5011 // Store top 10 for testing panel
5012 $this->last_similarity_analysis['top_matches'] = array_slice($all_similarities, 0, 10);
5013 $this->last_similarity_analysis['total_checked'] = $total_checked;
5014
5015 // Initialize final content
5016 $content = '';
5017 $matches_used = 0;
5018 $total_chunks_used = 0;
5019 $max_total_chunks = isset($options['rag_chunks_limit']) ? intval($options['rag_chunks_limit']) : 15;
5020 if ($max_total_chunks < 8) $max_total_chunks = 8;
5021 if ($max_total_chunks > 20) $max_total_chunks = 20;
5022 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
5023
5024 // Check if citation links are enabled (default to 'on' for backwards compatibility)
5025 // Use fresh options to ensure we get the latest setting value
5026 $fresh_options = get_option('mxchat_options', []);
5027 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
5028
5029 // Build content from top sources
5030 foreach ($top_urls as $group_key => $group) {
5031 $source_url = $group['source_url']; // Use actual source_url, not the group key
5032
5033 // Stop if we've hit the total chunk limit
5034 if ($total_chunks_used >= $max_total_chunks) {
5035 break;
5036 }
5037
5038 $full_text = '';
5039 $chunks_in_this_source = 1; // Default for non-chunked content
5040
5041 if ($group['is_chunked']) {
5042 // Calculate how many chunks we can still use (respect both total and per-source caps)
5043 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
5044
5045 // Fetch chunks for this URL with limit
5046 $full_text = $this->reassemble_chunks_from_wordpress($source_url, $chunks_remaining, $chunks_in_this_source);
5047
5048 // If fetching all chunks fails, fall back to matched chunks
5049 if (empty($full_text)) {
5050 // Sort matched chunks by index and concatenate
5051 usort($group['chunks'], function($a, $b) {
5052 return $a['chunk_index'] <=> $b['chunk_index'];
5053 });
5054
5055 $chunk_texts = array();
5056 $chunks_in_this_source = 0;
5057 foreach ($group['chunks'] as $chunk) {
5058 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
5059 break;
5060 }
5061 $chunk_texts[] = $chunk['text'];
5062 $chunks_in_this_source++;
5063 }
5064 $full_text = implode("\n\n", $chunk_texts);
5065 }
5066 } else {
5067 $full_text = $group['single_text'];
5068 $chunks_in_this_source = 1;
5069 }
5070
5071 if (!empty($full_text)) {
5072 // Strip URLs from content if citation links are disabled
5073 if (!$citation_links_enabled) {
5074 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
5075 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
5076 }
5077
5078 // Use numbered reference for URL-based entries, plain info label for manual entries
5079 if (!empty($source_url) && $source_url !== '#') {
5080 $matches_used++;
5081 $content .= "## Reference " . $matches_used . " ##\n";
5082 $content .= $full_text . "\n\n";
5083
5084 // Only include citation URLs if citation links are enabled
5085 if ($citation_links_enabled) {
5086 $valid_urls[] = $source_url;
5087 $content .= "URL: " . $source_url . "\n\n";
5088 }
5089 } else {
5090 // Manual entry — no reference number, no citation
5091 $content .= "## Information ##\n";
5092 $content .= $full_text . "\n\n";
5093 }
5094
5095 // Extract any URLs from the text content itself (only if citation links enabled)
5096 if ($citation_links_enabled) {
5097 preg_match_all(
5098 '#\bhttps?://[^\s<>"\']+#i',
5099 $full_text,
5100 $content_urls
5101 );
5102 if (!empty($content_urls[0])) {
5103 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5104 }
5105 }
5106
5107 $total_chunks_used += $chunks_in_this_source;
5108 }
5109 }
5110
5111 // NEW: Store unique valid URLs for validation
5112 $this->current_valid_urls = array_unique($valid_urls);
5113
5114 // Store sources and chunks counts for testing/transcript display
5115 $this->last_similarity_analysis['sources_used'] = $matches_used;
5116 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
5117
5118 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
5119 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
5120
5121 // Add response guidelines
5122 if (empty($top_urls)) {
5123 $content = "No reference information was found for this query.\n\n";
5124 } else {
5125 // Build response guidelines based on citation links setting
5126 $content .= "\n## Response Guidelines ##\n" .
5127 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5128 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5129 "If you don't have specific information or are uncertain about any details, it's always " .
5130 "better to honestly say you don't know rather than making up or guessing at answers. " .
5131 "When information is incomplete, let them know you are unsure.\n\n";
5132
5133 // Only add hyperlink instructions if citation links are enabled
5134 if ($citation_links_enabled) {
5135 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5136 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
5137 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
5138 } else {
5139 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5140 "Simply provide helpful answers based on the reference information without citing sources.";
5141 }
5142 }
5143
5144 return trim($content);
5145 }
5146
5147 /**
5148 * Fetch and reassemble chunks for a URL from WordPress database
5149 *
5150 * @param string $source_url The source URL to fetch chunks for
5151 * @param int $max_chunks Maximum number of chunks to return (0 = unlimited)
5152 * @param int &$chunk_count Reference to store the actual number of chunks returned
5153 * @return string Reassembled content from chunks
5154 */
5155 private function reassemble_chunks_from_wordpress($source_url, $max_chunks = 0, &$chunk_count = 0) {
5156 global $wpdb;
5157 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
5158
5159 // Fetch all rows with this source_url
5160 $rows = $wpdb->get_results($wpdb->prepare(
5161 "SELECT article_content FROM {$table}
5162 WHERE source_url = %s
5163 ORDER BY id ASC",
5164 $source_url
5165 ));
5166
5167 if (empty($rows)) {
5168 $chunk_count = 0;
5169 return '';
5170 }
5171
5172 // Parse and sort chunks by index
5173 $chunks = array();
5174 foreach ($rows as $row) {
5175 $parsed = MxChat_Chunker::parse_stored_chunk($row->article_content);
5176
5177 if ($parsed['is_chunked']) {
5178 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
5179 $chunks[$chunk_index] = $parsed['text'];
5180 } else {
5181 // Non-chunked content - just return it
5182 $chunks[] = $parsed['text'];
5183 }
5184 }
5185
5186 // Sort by chunk index
5187 ksort($chunks);
5188
5189 // Apply chunk limit if specified
5190 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
5191 $chunks = array_slice($chunks, 0, $max_chunks, true);
5192 }
5193
5194 // Store actual chunk count
5195 $chunk_count = count($chunks);
5196
5197 // Reassemble content
5198 return implode("\n\n", $chunks);
5199 }
5200
5201 private function find_relevant_content_pinecone($user_embedding, $bot_id = 'default', $bot_config = null) {
5202 global $wpdb;
5203
5204 //error_log("MXCHAT DEBUG: find_relevant_content_pinecone called");
5205 //error_log(" - bot_id: " . $bot_id);
5206 //error_log(" - user_embedding is array: " . (is_array($user_embedding) ? 'yes' : 'no'));
5207 //error_log(" - user_embedding count: " . (is_array($user_embedding) ? count($user_embedding) : 'N/A'));
5208
5209 // Use bot-specific config or fall back to default
5210 if ($bot_config === null) {
5211 $bot_config = $this->get_bot_pinecone_config($bot_id);
5212 }
5213
5214 $api_key = $bot_config['api_key'] ?? '';
5215 $host = $bot_config['host'] ?? '';
5216 $namespace = $bot_config['namespace'] ?? '';
5217
5218 //error_log("MXCHAT DEBUG: Pinecone query parameters:");
5219 //error_log(" - API Key: " . (empty($api_key) ? 'EMPTY - ERROR!' : 'Present (length: ' . strlen($api_key) . ')'));
5220 //error_log(" - Host: " . (empty($host) ? 'EMPTY - ERROR!' : $host));
5221 //error_log(" - Namespace: " . (empty($namespace) ? 'EMPTY (will use default)' : $namespace));
5222
5223 // Initialize similarity analysis storage
5224 $this->last_similarity_analysis = [
5225 'knowledge_base_type' => 'Pinecone',
5226 'bot_id' => $bot_id,
5227 'namespace' => $namespace,
5228 'top_matches' => [],
5229 'threshold_used' => 0,
5230 'total_checked' => 0
5231 ];
5232
5233 // NEW: Initialize valid URLs array
5234 $valid_urls = [];
5235
5236 if (empty($host) || empty($api_key)) {
5237 //error_log("MXCHAT DEBUG ERROR: Missing Pinecone host or API key!");
5238 //error_log(" - Host empty: " . (empty($host) ? 'YES' : 'NO'));
5239 //error_log(" - API key empty: " . (empty($api_key) ? 'YES' : 'NO'));
5240 // Store empty array for valid URLs since we can't proceed
5241 $this->current_valid_urls = [];
5242 return '';
5243 }
5244
5245 // Get knowledge manager instance for role checking
5246 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
5247
5248 // Get the similarity threshold from the bot options or main options
5249 $bot_options = $this->get_bot_options($bot_id);
5250 $current_options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', []);
5251
5252 $similarity_threshold = isset($current_options['similarity_threshold'])
5253 ? ((int) $current_options['similarity_threshold']) / 100
5254 : 0.35;
5255
5256 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
5257
5258 // Prepare the query request for Pinecone
5259 $api_endpoint = "https://{$host}/query";
5260
5261 $request_body = array(
5262 'vector' => $user_embedding,
5263 'topK' => 50, // Increased for chunked content grouping - need more candidates to find top N unique URLs
5264 'includeMetadata' => true,
5265 'includeValues' => true
5266 );
5267
5268 // Add namespace if specified for this bot
5269 if (!empty($namespace)) {
5270 $request_body['namespace'] = $namespace;
5271 }
5272
5273 //error_log("MXCHAT DEBUG: About to call Pinecone API");
5274 //error_log(" - Endpoint: " . $api_endpoint);
5275 //error_log(" - Namespace in request: " . (!empty($namespace) ? $namespace : 'NOT SET'));
5276
5277 $response = wp_remote_post($api_endpoint, array(
5278 'headers' => array(
5279 'Api-Key' => $api_key,
5280 'accept' => 'application/json',
5281 'content-type' => 'application/json'
5282 ),
5283 'body' => wp_json_encode($request_body),
5284 'timeout' => 30
5285 ));
5286
5287 if (is_wp_error($response)) {
5288 //error_log("MXCHAT DEBUG ERROR: WP Error in Pinecone request: " . $response->get_error_message());
5289 // Store empty array for valid URLs
5290 $this->current_valid_urls = [];
5291 return '';
5292 }
5293
5294 $response_code = wp_remote_retrieve_response_code($response);
5295 //error_log("MXCHAT DEBUG: Pinecone response code: " . $response_code);
5296
5297 if ($response_code !== 200) {
5298 $response_body = wp_remote_retrieve_body($response);
5299 //error_log("MXCHAT DEBUG ERROR: Pinecone API error response: " . substr($response_body, 0, 500));
5300 // Store empty array for valid URLs
5301 $this->current_valid_urls = [];
5302 return '';
5303 }
5304
5305 // ADD DETAILED DEBUG SECTION HERE
5306 $response_body = wp_remote_retrieve_body($response);
5307 //error_log("MXCHAT DEBUG: Raw Pinecone response length: " . strlen($response_body));
5308
5309 $results = json_decode($response_body, true);
5310
5311 if (json_last_error() !== JSON_ERROR_NONE) {
5312 //error_log("MXCHAT DEBUG ERROR: JSON decode error: " . json_last_error_msg());
5313 //error_log("MXCHAT DEBUG: First 500 chars of response: " . substr($response_body, 0, 500));
5314 // Store empty array for valid URLs
5315 $this->current_valid_urls = [];
5316 return '';
5317 }
5318
5319 //error_log("MXCHAT DEBUG: Pinecone response structure:");
5320 //error_log(" - Has 'matches' key: " . (isset($results['matches']) ? 'yes' : 'no'));
5321 //error_log(" - Has 'namespace' key: " . (isset($results['namespace']) ? 'yes (' . $results['namespace'] . ')' : 'no'));
5322
5323 if (empty($results['matches'])) {
5324 //error_log("MXCHAT DEBUG: No matches found in Pinecone response");
5325 //error_log("MXCHAT DEBUG: Response keys: " . implode(', ', array_keys($results)));
5326 // Store empty array for valid URLs
5327 $this->current_valid_urls = [];
5328 return '';
5329 }
5330
5331 //error_log("MXCHAT DEBUG: Found " . count($results['matches']) . " matches in Pinecone");
5332
5333 // Log first match details for debugging
5334 if (!empty($results['matches'][0])) {
5335 $first_match = $results['matches'][0];
5336 //error_log("MXCHAT DEBUG: First match details:");
5337 //error_log(" - Score: " . ($first_match['score'] ?? 'no score'));
5338 //error_log(" - Has metadata: " . (isset($first_match['metadata']) ? 'yes' : 'no'));
5339 if (isset($first_match['metadata'])) {
5340 //error_log(" - Metadata keys: " . implode(', ', array_keys($first_match['metadata'])));
5341 }
5342 }
5343
5344 // Initialize the final content
5345 $content = '';
5346 $matches_used = 0;
5347 $matches_used_for_context = [];
5348 $total_chunks_used = 0;
5349 $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
5350 if ($max_total_chunks < 8) $max_total_chunks = 8;
5351 if ($max_total_chunks > 20) $max_total_chunks = 20;
5352 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
5353
5354 // Check if citation links are enabled (default to 'on' for backwards compatibility)
5355 // Use fresh options to ensure we get the latest setting value
5356 $fresh_options = get_option('mxchat_options', []);
5357 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
5358
5359 // NEW CHUNKING LOGIC: Group results by source_url for chunk reassembly
5360 $url_groups = array();
5361
5362 foreach ($results['matches'] as $index => $match) {
5363 // Skip if similarity is below threshold
5364 if ($match['score'] < $similarity_threshold) {
5365 continue;
5366 }
5367
5368 $metadata = $match['metadata'] ?? array();
5369 $source_url = $metadata['source_url'] ?? '';
5370 $match_id = $match['id'] ?? '';
5371
5372 // LAZY ROLE CHECK: Only check role for content we're actually considering
5373 $role_restriction = $this->get_single_vector_role($match_id, $metadata);
5374 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5375
5376 // Skip if user doesn't have access
5377 if (!$has_access) {
5378 continue;
5379 }
5380
5381 // Use a unique key for manual entries without a source URL
5382 $group_key = !empty($source_url) ? $source_url : '_manual_' . $match_id;
5383
5384 // Group by source URL (or unique key for manual entries)
5385 if (!isset($url_groups[$group_key])) {
5386 $url_groups[$group_key] = array(
5387 'source_url' => $source_url,
5388 'best_score' => 0,
5389 'is_chunked' => isset($metadata['is_chunked']) && $metadata['is_chunked'],
5390 'chunks' => array(),
5391 'single_text' => ''
5392 );
5393 }
5394
5395 // Track best score for this group
5396 if ($match['score'] > $url_groups[$group_key]['best_score']) {
5397 $url_groups[$group_key]['best_score'] = $match['score'];
5398 }
5399
5400 // Store chunk info or single text
5401 if ($url_groups[$group_key]['is_chunked']) {
5402 $url_groups[$group_key]['chunks'][] = array(
5403 'id' => $match_id,
5404 'score' => $match['score'],
5405 'chunk_index' => $metadata['chunk_index'] ?? 0,
5406 'text' => $metadata['text'] ?? ''
5407 );
5408 } else {
5409 // Non-chunked content - just store the text
5410 $url_groups[$group_key]['single_text'] = $metadata['text'] ?? '';
5411 $url_groups[$group_key]['single_id'] = $match_id;
5412 }
5413 }
5414
5415 // Sort URL groups by best score (highest first)
5416 uasort($url_groups, function($a, $b) {
5417 return $b['best_score'] <=> $a['best_score'];
5418 });
5419
5420 // Get RAG sources limit from options (default 6, min 3, max 10)
5421 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
5422 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
5423 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
5424
5425 // Take top N unique URLs based on user setting
5426 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
5427
5428 // Track which match IDs are actually used for context
5429 foreach ($top_urls as $group) {
5430 if ($group['is_chunked']) {
5431 foreach ($group['chunks'] as $chunk) {
5432 $matches_used_for_context[] = $chunk['id'];
5433 }
5434 } elseif (!empty($group['single_id'])) {
5435 $matches_used_for_context[] = $group['single_id'];
5436 }
5437 }
5438
5439 // Build content from top sources
5440 foreach ($top_urls as $group_key => $group) {
5441 $source_url = $group['source_url']; // Use actual source_url, not the group key
5442
5443 // Stop if we've hit the total chunk limit
5444 if ($total_chunks_used >= $max_total_chunks) {
5445 break;
5446 }
5447
5448 $full_text = '';
5449 $chunks_in_this_source = 1; // Default for non-chunked content
5450
5451 if ($group['is_chunked']) {
5452 // Calculate how many chunks we can still use (respect both total and per-source caps)
5453 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
5454
5455 // Fetch chunks for this URL with limit
5456 $full_text = $this->reassemble_chunks_from_pinecone($source_url, $bot_config, $chunks_remaining, $chunks_in_this_source);
5457
5458 // If fetching all chunks fails, fall back to matched chunks
5459 if (empty($full_text)) {
5460 // Sort matched chunks by index and concatenate
5461 usort($group['chunks'], function($a, $b) {
5462 return $a['chunk_index'] <=> $b['chunk_index'];
5463 });
5464
5465 $chunk_texts = array();
5466 $chunks_in_this_source = 0;
5467 foreach ($group['chunks'] as $chunk) {
5468 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
5469 break;
5470 }
5471 $chunk_texts[] = $chunk['text'];
5472 $chunks_in_this_source++;
5473 }
5474 $full_text = implode("\n\n", $chunk_texts);
5475 }
5476 } else {
5477 $full_text = $group['single_text'];
5478 $chunks_in_this_source = 1;
5479 }
5480
5481 if (!empty($full_text)) {
5482 // Strip URLs from content if citation links are disabled
5483 if (!$citation_links_enabled) {
5484 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
5485 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
5486 }
5487
5488 // Use numbered reference for URL-based entries, plain info label for manual entries
5489 if (!empty($source_url) && $source_url !== '#') {
5490 $matches_used++;
5491 $content .= "## Reference " . $matches_used . " ##\n";
5492 $content .= $full_text . "\n\n";
5493
5494 // Only include citation URLs if citation links are enabled
5495 if ($citation_links_enabled) {
5496 $valid_urls[] = $source_url;
5497 $content .= "URL: " . $source_url . "\n\n";
5498 }
5499 } else {
5500 // Manual entry — no reference number, no citation
5501 $content .= "## Information ##\n";
5502 $content .= $full_text . "\n\n";
5503 }
5504
5505 // Extract any URLs from the text content itself (only if citation links enabled)
5506 if ($citation_links_enabled) {
5507 preg_match_all(
5508 '#\bhttps?://[^\s<>"\']+#i',
5509 $full_text,
5510 $content_urls
5511 );
5512 if (!empty($content_urls[0])) {
5513 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5514 }
5515 }
5516
5517 $total_chunks_used += $chunks_in_this_source;
5518 }
5519 }
5520
5521 // Process ALL matches for testing data (top 10) - with role checking for testing display
5522 $all_matches = [];
5523 foreach ($results['matches'] as $index => $match) {
5524 if ($index >= 10) break; // Limit to top 10 for testing
5525
5526 $match_id = $match['id'] ?? '';
5527
5528 // Check role access for testing display (use cache if available)
5529 $role_restriction = $this->get_single_vector_role($match_id, $match['metadata']);
5530 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5531
5532 $source_display = '';
5533 if (!empty($match['metadata']['source_url'])) {
5534 $source_display = $match['metadata']['source_url'];
5535 } else {
5536 $content_preview = strip_tags($match['metadata']['text'] ?? '');
5537 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
5538 $source_display = substr(trim($content_preview), 0, 50) . '...';
5539 }
5540
5541 $match_id_for_display = $match['id'] ?? $index;
5542
5543 // Check for chunk metadata in Pinecone
5544 $is_chunk = isset($match['metadata']['is_chunked']) && $match['metadata']['is_chunked'];
5545 $chunk_index = isset($match['metadata']['chunk_index']) ? intval($match['metadata']['chunk_index']) : null;
5546 $total_chunks = isset($match['metadata']['total_chunks']) ? intval($match['metadata']['total_chunks']) : null;
5547
5548 // Also detect chunk from vector ID pattern: {hash}_chunk_{index}
5549 if (!$is_chunk && MxChat_Chunker::is_chunk_vector_id($match_id_for_display)) {
5550 $is_chunk = true;
5551 }
5552
5553 $all_matches[] = [
5554 'document_id' => $match_id_for_display,
5555 'similarity' => $match['score'],
5556 'similarity_percentage' => round($match['score'] * 100, 2),
5557 'above_threshold' => $match['score'] >= $similarity_threshold,
5558 'source_display' => $source_display,
5559 'content_preview' => substr(strip_tags($match['metadata']['text'] ?? ''), 0, 100) . '...',
5560 'used_for_context' => in_array($match_id_for_display, $matches_used_for_context),
5561 'role_restriction' => $role_restriction,
5562 'has_access' => $has_access,
5563 'filtered_out' => !$has_access,
5564 'is_chunk' => $is_chunk,
5565 'chunk_index' => $chunk_index,
5566 'total_chunks' => $total_chunks
5567 ];
5568 }
5569
5570 // Store for testing panel
5571 $this->last_similarity_analysis['top_matches'] = $all_matches;
5572 $this->last_similarity_analysis['total_checked'] = count($results['matches']);
5573 $this->last_similarity_analysis['sources_used'] = $matches_used;
5574 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
5575
5576 // NEW: Store unique valid URLs for validation
5577 $this->current_valid_urls = array_unique($valid_urls);
5578
5579 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
5580 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
5581
5582 // Add response guidelines
5583 if ($matches_used === 0) {
5584 $content = "No reference information was found for this query.\n\n";
5585 } else {
5586 // Build response guidelines based on citation links setting
5587 $content .= "\n## Response Guidelines ##\n" .
5588 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5589 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5590 "If you don't have specific information or are uncertain about any details, it's always " .
5591 "better to honestly say you don't know rather than making up or guessing at answers. " .
5592 "When information is incomplete, let them know you are unsure.\n\n";
5593
5594 // Only add hyperlink instructions if citation links are enabled
5595 if ($citation_links_enabled) {
5596 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5597 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
5598 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
5599 } else {
5600 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5601 "Simply provide helpful answers based on the reference information without citing sources.";
5602 }
5603 }
5604
5605 return trim($content);
5606 }
5607
5608 /**
5609 * Get role restriction for a single vector (with caching)
5610 */
5611 private function get_single_vector_role($vector_id, $metadata = array()) {
5612 global $wpdb;
5613
5614 if (empty($vector_id)) {
5615 return 'public';
5616 }
5617
5618 // Check cache first
5619 $cache_key = 'mxchat_vector_role_' . $vector_id;
5620 $cached_role = wp_cache_get($cache_key, 'mxchat_vector_roles');
5621
5622 if ($cached_role !== false) {
5623 return $cached_role;
5624 }
5625
5626 $role_restriction = 'public';
5627
5628 // First try Pinecone metadata
5629 if (!empty($metadata['role_restriction'])) {
5630 $role_restriction = $metadata['role_restriction'];
5631 } else {
5632 // Check WordPress table for user-modified roles
5633 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
5634 $stored_role = $wpdb->get_var($wpdb->prepare(
5635 "SELECT role_restriction FROM {$roles_table} WHERE vector_id = %s",
5636 $vector_id
5637 ));
5638
5639 if ($stored_role) {
5640 $role_restriction = $stored_role;
5641 }
5642 }
5643
5644 // Cache individual role for 1 hour
5645 wp_cache_set($cache_key, $role_restriction, 'mxchat_vector_roles', 3600);
5646
5647 return $role_restriction;
5648 }
5649
5650 /**
5651 * Fetch and reassemble all chunks for a URL from Pinecone
5652 *
5653 * @param string $source_url The source URL to fetch chunks for
5654 * @param array $bot_config Bot-specific Pinecone configuration
5655 * @return string Reassembled content from all chunks
5656 */
5657 private function reassemble_chunks_from_pinecone($source_url, $bot_config, $max_chunks = 0, &$chunk_count = 0) {
5658 $api_key = $bot_config['api_key'] ?? '';
5659 $host = $bot_config['host'] ?? '';
5660 $namespace = $bot_config['namespace'] ?? '';
5661
5662 if (empty($host) || empty($api_key)) {
5663 $chunk_count = 0;
5664 return '';
5665 }
5666
5667 $base_hash = md5($source_url);
5668
5669 // Use Pinecone list API to find all chunk vectors with this prefix
5670 $list_url = "https://{$host}/vectors/list";
5671
5672 // Limit to max_chunks if specified, otherwise fetch up to 100
5673 $fetch_limit = ($max_chunks > 0 && $max_chunks < 100) ? $max_chunks : 100;
5674
5675 $list_body = array(
5676 'prefix' => $base_hash . '_chunk_',
5677 'limit' => $fetch_limit
5678 );
5679
5680 if (!empty($namespace)) {
5681 $list_body['namespace'] = $namespace;
5682 }
5683
5684 $list_response = wp_remote_post($list_url, array(
5685 'headers' => array(
5686 'Api-Key' => $api_key,
5687 'accept' => 'application/json',
5688 'content-type' => 'application/json'
5689 ),
5690 'body' => wp_json_encode($list_body),
5691 'timeout' => 30
5692 ));
5693
5694 if (is_wp_error($list_response)) {
5695 //error_log('[MXCHAT-CHUNK] List API error: ' . $list_response->get_error_message());
5696 return '';
5697 }
5698
5699 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
5700
5701 if (empty($list_data['vectors'])) {
5702 //error_log('[MXCHAT-CHUNK] No chunk vectors found for URL: ' . $source_url);
5703 return '';
5704 }
5705
5706 // Extract vector IDs
5707 $vector_ids = array();
5708 foreach ($list_data['vectors'] as $vector) {
5709 if (isset($vector['id'])) {
5710 $vector_ids[] = $vector['id'];
5711 }
5712 }
5713
5714 if (empty($vector_ids)) {
5715 return '';
5716 }
5717
5718 // Fetch all chunk content
5719 $fetch_url = "https://{$host}/vectors/fetch";
5720
5721 $fetch_body = array(
5722 'ids' => $vector_ids
5723 );
5724
5725 if (!empty($namespace)) {
5726 $fetch_body['namespace'] = $namespace;
5727 }
5728
5729 $fetch_response = wp_remote_post($fetch_url, array(
5730 'headers' => array(
5731 'Api-Key' => $api_key,
5732 'accept' => 'application/json',
5733 'content-type' => 'application/json'
5734 ),
5735 'body' => wp_json_encode($fetch_body),
5736 'timeout' => 30
5737 ));
5738
5739 if (is_wp_error($fetch_response)) {
5740 //error_log('[MXCHAT-CHUNK] Fetch API error: ' . $fetch_response->get_error_message());
5741 return '';
5742 }
5743
5744 $fetch_data = json_decode(wp_remote_retrieve_body($fetch_response), true);
5745
5746 if (empty($fetch_data['vectors'])) {
5747 return '';
5748 }
5749
5750 // Sort chunks by index and reassemble
5751 $chunks = array();
5752 foreach ($fetch_data['vectors'] as $id => $vector) {
5753 $metadata = $vector['metadata'] ?? array();
5754 $chunk_index = $metadata['chunk_index'] ?? 0;
5755 $text = $metadata['text'] ?? '';
5756
5757 // Store chunk with its index
5758 $chunks[$chunk_index] = $text;
5759 }
5760
5761 // Sort by chunk index
5762 ksort($chunks);
5763
5764 // Apply chunk limit if specified
5765 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
5766 $chunks = array_slice($chunks, 0, $max_chunks, true);
5767 }
5768
5769 // Store actual chunk count
5770 $chunk_count = count($chunks);
5771
5772 // Reassemble content
5773 return implode("\n\n", $chunks);
5774 }
5775
5776 /**
5777 * Search for relevant content using OpenAI Vector Store (File Search)
5778 *
5779 * @param string $user_query The user's query text
5780 * @param string $bot_id The bot ID
5781 * @param array $vectorstore_config Vector Store configuration
5782 * @return string Formatted context string with references
5783 */
5784 private function find_relevant_content_openai_vectorstore($user_query, $bot_id = 'default', $vectorstore_config = array()) {
5785 //error_log("MXCHAT DEBUG: find_relevant_content_openai_vectorstore called");
5786 //error_log(" - bot_id: " . $bot_id);
5787 //error_log(" - user_query length: " . strlen($user_query));
5788
5789 // Get OpenAI API key
5790 $mxchat_options = get_option('mxchat_options', array());
5791 $api_key = $mxchat_options['api_key'] ?? '';
5792
5793 // Reset vectorstore error tracking
5794 $this->last_vectorstore_error = null;
5795
5796 if (empty($api_key)) {
5797 //error_log("MXCHAT DEBUG ERROR: OpenAI API key not configured");
5798 $this->last_vectorstore_error = 'Vector Store search failed: OpenAI API key is not configured.';
5799 $this->current_valid_urls = [];
5800 return '';
5801 }
5802
5803 // Get Vector Store configuration
5804 if (empty($vectorstore_config)) {
5805 $vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
5806 }
5807
5808 $vectorstore_ids_string = $vectorstore_config['vectorstore_ids'] ?? '';
5809 $max_results = $vectorstore_config['max_results'] ?? 5;
5810
5811 if (empty($vectorstore_ids_string)) {
5812 //error_log("MXCHAT DEBUG ERROR: No Vector Store IDs configured");
5813 $this->last_vectorstore_error = 'Vector Store search failed: No Vector Store IDs are configured for this bot.';
5814 $this->current_valid_urls = [];
5815 return '';
5816 }
5817
5818 // Parse Vector Store IDs
5819 $vectorstore_ids = array_map('trim', explode(',', $vectorstore_ids_string));
5820 $vectorstore_ids = array_filter($vectorstore_ids); // Remove empty values
5821
5822 //error_log("MXCHAT DEBUG: Vector Store IDs: " . implode(', ', $vectorstore_ids));
5823 //error_log("MXCHAT DEBUG: Max results: " . $max_results);
5824
5825 // Initialize similarity analysis storage
5826 $this->last_similarity_analysis = [
5827 'knowledge_base_type' => 'OpenAI Vector Store',
5828 'bot_id' => $bot_id,
5829 'vectorstore_ids' => $vectorstore_ids,
5830 'top_matches' => [],
5831 'threshold_used' => 0,
5832 'total_checked' => 0
5833 ];
5834
5835 $valid_urls = [];
5836
5837 // Get the selected model
5838 $bot_options = $this->get_bot_options($bot_id);
5839 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
5840 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
5841
5842 // Verify it's an OpenAI model
5843 if (!$this->is_openai_chat_model($selected_model)) {
5844 //error_log("MXCHAT DEBUG ERROR: Vector Store search requires OpenAI model. Current: " . $selected_model);
5845 $this->last_vectorstore_error = 'Vector Store search requires an OpenAI model. Current model: ' . $selected_model;
5846 $this->current_valid_urls = [];
5847 return '';
5848 }
5849
5850 // Use OpenAI Responses API with file_search tool
5851 $request_body = array(
5852 'model' => $selected_model,
5853 'input' => $user_query,
5854 'tools' => array(
5855 array(
5856 'type' => 'file_search',
5857 'vector_store_ids' => $vectorstore_ids,
5858 'max_num_results' => intval($max_results)
5859 )
5860 ),
5861 'include' => array('output[*].file_search_call.search_results')
5862 );
5863
5864 //error_log("MXCHAT VECTORSTORE: ========== REQUEST START ==========");
5865 //error_log("MXCHAT VECTORSTORE: Model: " . $selected_model);
5866 //error_log("MXCHAT VECTORSTORE: Query: " . substr($user_query, 0, 200));
5867 //error_log("MXCHAT VECTORSTORE: Vector Store IDs: " . implode(', ', $vectorstore_ids));
5868 //error_log("MXCHAT VECTORSTORE: Max Results: " . $max_results);
5869 //error_log("MXCHAT VECTORSTORE: Request body: " . wp_json_encode($request_body));
5870
5871 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
5872 'headers' => array(
5873 'Authorization' => 'Bearer ' . $api_key,
5874 'Content-Type' => 'application/json'
5875 ),
5876 'body' => wp_json_encode($request_body),
5877 'timeout' => 60
5878 ));
5879
5880 if (is_wp_error($response)) {
5881 //error_log("MXCHAT VECTORSTORE ERROR: WP Error: " . $response->get_error_message());
5882 $this->last_vectorstore_error = 'Vector Store API request failed: ' . $response->get_error_message();
5883 $this->current_valid_urls = [];
5884 return '';
5885 }
5886
5887 $response_code = wp_remote_retrieve_response_code($response);
5888 //error_log("MXCHAT VECTORSTORE: Response code: " . $response_code);
5889
5890 $response_body = wp_remote_retrieve_body($response);
5891 //error_log("MXCHAT VECTORSTORE: Raw response (first 2000 chars): " . substr($response_body, 0, 2000));
5892
5893 if ($response_code !== 200) {
5894 //error_log("MXCHAT VECTORSTORE ERROR: API error response: " . $response_body);
5895 $api_error_detail = '';
5896 $decoded_error = json_decode($response_body, true);
5897 if (isset($decoded_error['error']['message'])) {
5898 $api_error_detail = $decoded_error['error']['message'];
5899 }
5900 $this->last_vectorstore_error = 'Vector Store API returned HTTP ' . $response_code . ($api_error_detail ? ': ' . $api_error_detail : '');
5901 $this->current_valid_urls = [];
5902 return '';
5903 }
5904 $result = json_decode($response_body, true);
5905
5906 if (json_last_error() !== JSON_ERROR_NONE) {
5907 //error_log("MXCHAT VECTORSTORE ERROR: JSON decode error: " . json_last_error_msg());
5908 $this->last_vectorstore_error = 'Vector Store response could not be parsed: ' . json_last_error_msg();
5909 $this->current_valid_urls = [];
5910 return '';
5911 }
5912
5913 // Debug: Log the structure of the result
5914 //error_log("MXCHAT VECTORSTORE: Result keys: " . implode(', ', array_keys($result)));
5915 if (isset($result['output'])) {
5916 //error_log("MXCHAT VECTORSTORE: Output count: " . count($result['output']));
5917 foreach ($result['output'] as $idx => $out) {
5918 //error_log("MXCHAT VECTORSTORE: Output[$idx] type: " . ($out['type'] ?? 'unknown'));
5919 //error_log("MXCHAT VECTORSTORE: Output[$idx] keys: " . implode(', ', array_keys($out)));
5920 }
5921 } else {
5922 //error_log("MXCHAT VECTORSTORE: No 'output' key in result!");
5923 }
5924
5925 // Extract file search results from the response
5926 $content = '';
5927 $matches_used = 0;
5928 $all_matches = [];
5929
5930 // The Responses API returns output array with tool results
5931 if (isset($result['output']) && is_array($result['output'])) {
5932 foreach ($result['output'] as $output_item) {
5933 // Look for file_search_call results
5934 if (isset($output_item['type']) && $output_item['type'] === 'file_search_call') {
5935 //error_log("MXCHAT VECTORSTORE: Found file_search_call output item");
5936 //error_log("MXCHAT VECTORSTORE: file_search_call keys: " . implode(', ', array_keys($output_item)));
5937
5938 // Check for search_results in the output item directly
5939 $search_results = $output_item['search_results'] ?? $output_item['results'] ?? [];
5940 //error_log("MXCHAT VECTORSTORE: Search results count: " . count($search_results));
5941
5942 if (empty($search_results)) {
5943 //error_log("MXCHAT VECTORSTORE: No search results found in file_search_call");
5944 //error_log("MXCHAT VECTORSTORE: file_search_call content: " . wp_json_encode($output_item));
5945 }
5946
5947 foreach ($search_results as $index => $search_result) {
5948 $filename = $search_result['filename'] ?? '';
5949 $score = $search_result['score'] ?? 0;
5950 $text_content = '';
5951
5952 // Extract text content from the result
5953 // The text can be directly on the result OR nested under content array
5954 if (isset($search_result['text']) && !empty($search_result['text'])) {
5955 // Direct text field (OpenAI's actual format)
5956 $text_content = $search_result['text'];
5957 //error_log("MXCHAT VECTORSTORE: Found text directly on result[$index], length: " . strlen($text_content));
5958 } elseif (isset($search_result['content']) && is_array($search_result['content'])) {
5959 // Nested content array format
5960 foreach ($search_result['content'] as $content_item) {
5961 if (isset($content_item['text'])) {
5962 $text_content .= $content_item['text'] . "\n";
5963 }
5964 }
5965 //error_log("MXCHAT VECTORSTORE: Found text in content array for result[$index], length: " . strlen($text_content));
5966 } else {
5967 //error_log("MXCHAT VECTORSTORE: No text found for result[$index]. Keys: " . implode(', ', array_keys($search_result)));
5968 }
5969
5970 if (!empty($text_content)) {
5971 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
5972 $content .= trim($text_content) . "\n\n";
5973
5974 if (!empty($filename)) {
5975 $content .= "Source: " . $filename . "\n\n";
5976 }
5977
5978 // Extract URLs from content
5979 preg_match_all(
5980 '#\bhttps?://[^\s<>"\']+#i',
5981 $text_content,
5982 $content_urls
5983 );
5984 if (!empty($content_urls[0])) {
5985 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5986 }
5987
5988 $matches_used++;
5989 }
5990
5991 // Store for similarity analysis
5992 $all_matches[] = [
5993 'document_id' => $filename ?: ('result_' . $index),
5994 'similarity' => $score,
5995 'similarity_percentage' => round($score * 100, 2),
5996 'above_threshold' => true,
5997 'source_display' => $filename,
5998 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
5999 'used_for_context' => true,
6000 'role_restriction' => 'public',
6001 'has_access' => true,
6002 'filtered_out' => false
6003 ];
6004 }
6005 }
6006
6007 // Also check for message content with annotations (citations)
6008 if (isset($output_item['type']) && $output_item['type'] === 'message') {
6009 if (isset($output_item['content']) && is_array($output_item['content'])) {
6010 foreach ($output_item['content'] as $content_block) {
6011 if (isset($content_block['annotations']) && is_array($content_block['annotations'])) {
6012 foreach ($content_block['annotations'] as $annotation) {
6013 if (isset($annotation['filename'])) {
6014 $filename = $annotation['filename'];
6015 $score = $annotation['score'] ?? 0;
6016 $text_content = '';
6017
6018 if (isset($annotation['content']) && is_array($annotation['content'])) {
6019 foreach ($annotation['content'] as $ann_content) {
6020 if (isset($ann_content['text'])) {
6021 $text_content .= $ann_content['text'] . "\n";
6022 }
6023 }
6024 }
6025
6026 if (!empty($text_content) && $matches_used < $max_results) {
6027 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
6028 $content .= trim($text_content) . "\n\n";
6029 $content .= "Source: " . $filename . "\n\n";
6030
6031 preg_match_all(
6032 '#\bhttps?://[^\s<>"\']+#i',
6033 $text_content,
6034 $content_urls
6035 );
6036 if (!empty($content_urls[0])) {
6037 $valid_urls = array_merge($valid_urls, $content_urls[0]);
6038 }
6039
6040 $matches_used++;
6041
6042 $all_matches[] = [
6043 'document_id' => $filename,
6044 'similarity' => $score,
6045 'similarity_percentage' => round($score * 100, 2),
6046 'above_threshold' => true,
6047 'source_display' => $filename,
6048 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
6049 'used_for_context' => true,
6050 'role_restriction' => 'public',
6051 'has_access' => true,
6052 'filtered_out' => false
6053 ];
6054 }
6055 }
6056 }
6057 }
6058 }
6059 }
6060 }
6061 }
6062 }
6063
6064 // Store for testing panel
6065 $this->last_similarity_analysis['top_matches'] = $all_matches;
6066 $this->last_similarity_analysis['total_checked'] = count($all_matches);
6067
6068 // Store unique valid URLs for validation
6069 $this->current_valid_urls = array_unique($valid_urls);
6070
6071 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
6072 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
6073
6074 //error_log("MXCHAT VECTORSTORE: ========== SEARCH COMPLETE ==========");
6075 //error_log("MXCHAT VECTORSTORE: Matches used: " . $matches_used);
6076 //error_log("MXCHAT VECTORSTORE: All matches count: " . count($all_matches));
6077 //error_log("MXCHAT VECTORSTORE: Content length: " . strlen($content));
6078 if ($matches_used > 0) {
6079 //error_log("MXCHAT VECTORSTORE: Content preview: " . substr($content, 0, 500));
6080 }
6081
6082 // Check if citation links are enabled
6083 $citation_links_enabled = ($mxchat_options['citation_links_toggle'] ?? 'on') === 'on';
6084
6085 // Add response guidelines
6086 if ($matches_used === 0) {
6087 //error_log("MXCHAT VECTORSTORE: No matches found - returning empty reference message");
6088 $content = "No reference information was found for this query.\n\n";
6089 } else {
6090 // Build response guidelines based on citation links setting
6091 $content .= "\n## Response Guidelines ##\n" .
6092 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
6093 "Be conversational and friendly, but never mention your knowledge base or training data. " .
6094 "If you don't have specific information or are uncertain about any details, it's always " .
6095 "better to honestly say you don't know rather than making up or guessing at answers. " .
6096 "When information is incomplete, let them know you are unsure.\n\n";
6097
6098 // Only add hyperlink instructions if citation links are enabled
6099 if ($citation_links_enabled) {
6100 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
6101 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
6102 } else {
6103 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
6104 "Simply provide helpful answers based on the reference information without citing sources.";
6105 }
6106 }
6107
6108 //error_log("MXCHAT DEBUG: Vector Store search complete. Matches used: " . $matches_used);
6109
6110 return trim($content);
6111 }
6112
6113 /**
6114 * Check if the given model is an OpenAI chat model
6115 *
6116 * @param string $model The model ID
6117 * @return bool True if it's an OpenAI model
6118 */
6119 private function is_openai_chat_model($model) {
6120 $openai_prefixes = array('gpt-', 'o1-', 'o3-');
6121 foreach ($openai_prefixes as $prefix) {
6122 if (strpos($model, $prefix) === 0) {
6123 return true;
6124 }
6125 }
6126 return false;
6127 }
6128
6129 /**
6130 * Get bot-specific Vector Store configuration
6131 *
6132 * @param string $bot_id The bot ID
6133 * @return array Configuration array
6134 */
6135 private function get_bot_vectorstore_config($bot_id = 'default') {
6136 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
6137
6138 // Default global settings
6139 $default_config = array(
6140 'use_vectorstore' => ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1',
6141 'vectorstore_ids' => $vectorstore_options['mxchat_vectorstore_ids'] ?? '',
6142 'max_results' => $vectorstore_options['mxchat_vectorstore_max_results'] ?? 5
6143 );
6144
6145 // Allow multi-bot plugin to override with bot-specific settings
6146 $bot_config = apply_filters('mxchat_get_bot_vectorstore_config', $default_config, $bot_id);
6147
6148 // Preserve max_results from global settings if not set in bot config
6149 if (!isset($bot_config['max_results'])) {
6150 $bot_config['max_results'] = $default_config['max_results'];
6151 }
6152
6153 return $bot_config;
6154 }
6155
6156 private function mxchat_find_relevant_products($user_embedding) {
6157 //error_log('MXChat Vector Search: Starting product search...');
6158
6159 // Retrieve the add-on settings from the database
6160 $addon_options = get_option('mxchat_pinecone_addon_options', array());
6161
6162 // Determine whether Pinecone is enabled
6163 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1') ? 1 : 0;
6164
6165 //error_log('Pinecone enabled flag: ' . $use_pinecone);
6166
6167 if ($use_pinecone === 1) {
6168 //error_log('MXChat Vector Search: Using Pinecone database for products');
6169 return $this->find_relevant_products_pinecone($user_embedding);
6170 } else {
6171 //error_log('MXChat Vector Search: Using WordPress database for products');
6172 return $this->find_relevant_products_wordpress($user_embedding);
6173 }
6174 }
6175 private function find_relevant_products_wordpress($user_embedding) {
6176 global $wpdb;
6177 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6178
6179 if (!is_array($user_embedding)) {
6180 return '';
6181 }
6182
6183 // Streaming top-K pass: scan rows in small batches, keep only the top 3
6184 // results above the similarity threshold. Peak memory is bounded by
6185 // $batch_size embedding rows plus a 3-element top list.
6186 $batch_size = 250;
6187 $similarity_threshold = 0.85;
6188 $top_k = 3;
6189 $top_results = [];
6190 $offset = 0;
6191
6192 do {
6193 $batch = $wpdb->get_results($wpdb->prepare(
6194 "SELECT id, embedding_vector
6195 FROM {$system_prompt_table}
6196 LIMIT %d OFFSET %d",
6197 $batch_size,
6198 $offset
6199 ));
6200
6201 if (empty($batch)) {
6202 break;
6203 }
6204
6205 foreach ($batch as $row) {
6206 $database_embedding = $row->embedding_vector
6207 ? unserialize($row->embedding_vector, ['allowed_classes' => false])
6208 : null;
6209
6210 if (!is_array($database_embedding)) {
6211 unset($database_embedding);
6212 continue;
6213 }
6214
6215 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
6216 unset($database_embedding);
6217
6218 if ($similarity < $similarity_threshold) {
6219 continue;
6220 }
6221
6222 // Insert into bounded top-K (kept sorted descending)
6223 if (count($top_results) < $top_k) {
6224 $top_results[] = ['id' => $row->id, 'similarity' => $similarity];
6225 usort($top_results, function ($a, $b) {
6226 return $b['similarity'] <=> $a['similarity'];
6227 });
6228 } elseif ($similarity > $top_results[$top_k - 1]['similarity']) {
6229 $top_results[$top_k - 1] = ['id' => $row->id, 'similarity' => $similarity];
6230 usort($top_results, function ($a, $b) {
6231 return $b['similarity'] <=> $a['similarity'];
6232 });
6233 }
6234 }
6235
6236 unset($batch);
6237 $offset += $batch_size;
6238 } while (true);
6239
6240 if (empty($top_results)) {
6241 return '';
6242 }
6243
6244 $content = '';
6245 foreach ($top_results as $result) {
6246 $chunk_content = $this->fetch_content_with_product_links($result['id']);
6247 $content .= $chunk_content . "\n\n";
6248 }
6249
6250 return trim($content);
6251 }
6252
6253
6254 private function find_relevant_products_pinecone($user_embedding) {
6255 //error_log('Starting Pinecone product search...');
6256
6257 $options = get_option('mxchat_pinecone_addon_options', array());
6258 $api_key = $options['mxchat_pinecone_api_key'] ?? '';
6259 $host = $options['mxchat_pinecone_host'] ?? '';
6260
6261 if (empty($host) || empty($api_key)) {
6262 //error_log('Pinecone credentials not properly configured for product search');
6263 return '';
6264 }
6265
6266 $similarity_threshold = 0.85;
6267 $api_endpoint = "https://{$host}/query";
6268
6269 $request_body = array(
6270 'vector' => $user_embedding,
6271 'topK' => 5,
6272 'includeMetadata' => true,
6273 'includeValues' => true,
6274 'filter' => array(
6275 'type' => 'product'
6276 )
6277 );
6278
6279 //error_log('Sending request to Pinecone with body: ' . wp_json_encode($request_body));
6280
6281 $response = wp_remote_post($api_endpoint, array(
6282 'headers' => array(
6283 'Api-Key' => $api_key,
6284 'accept' => 'application/json',
6285 'content-type' => 'application/json'
6286 ),
6287 'body' => wp_json_encode($request_body),
6288 'timeout' => 30
6289 ));
6290
6291 if (is_wp_error($response)) {
6292 //error_log('Pinecone product query error: ' . $response->get_error_message());
6293 return '';
6294 }
6295
6296 $response_code = wp_remote_retrieve_response_code($response);
6297 //error_log('Pinecone response code: ' . $response_code);
6298
6299 if ($response_code !== 200) {
6300 //error_log('Pinecone API error during product search: ' . wp_remote_retrieve_body($response));
6301 return '';
6302 }
6303
6304 $results = json_decode(wp_remote_retrieve_body($response), true);
6305 //error_log('Pinecone raw response: ' . wp_remote_retrieve_body($response));
6306
6307 if (empty($results['matches'])) {
6308 //error_log('No matches found in Pinecone response');
6309 return '';
6310 }
6311
6312 $content = '';
6313 foreach ($results['matches'] as $match) {
6314 if ($match['score'] < $similarity_threshold) {
6315 //error_log("Match below threshold: " . $match['score']);
6316 continue;
6317 }
6318
6319 if (!empty($match['metadata']['text'])) {
6320 $content .= $match['metadata']['text'];
6321 if (!empty($match['metadata']['source_url'])) {
6322 $content .= "\n\nFor more details, check out this product: " . esc_url($match['metadata']['source_url']);
6323 }
6324 $content .= "\n\n";
6325 }
6326 }
6327
6328 return trim($content);
6329 }
6330
6331
6332 private function fetch_content_with_product_links($most_relevant_id) {
6333 global $wpdb;
6334 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6335
6336 // Fetch the article content and associated product URL
6337 $query = $wpdb->prepare("SELECT article_content, source_url FROM {$system_prompt_table} WHERE id = %d", $most_relevant_id);
6338 $result = $wpdb->get_row($query);
6339
6340 if ($result) {
6341 // Append the product link to the content if available
6342 $content = $result->article_content;
6343 if (!empty($result->source_url)) {
6344 $content .= "\n\nFor more details, check out this product: " . esc_url($result->source_url);
6345 }
6346 return $content;
6347 }
6348
6349 return null;
6350 }
6351
6352 /**
6353 * Get system instructions for a specific bot or default
6354 * Checks for multi-bot add-on and uses bot-specific instructions if available
6355 * Automatically strips URLs if citation links are disabled
6356 * Replaces {visitor_name} placeholder with actual visitor name if available
6357 *
6358 * @param string $bot_id The bot ID to get instructions for
6359 * @param string $session_id Optional session ID to lookup visitor name
6360 */
6361 private function get_system_instructions($bot_id = 'default', $session_id = '') {
6362 $instructions = '';
6363
6364 // Check if multi-bot add-on is active
6365 if (class_exists('MxChat_Multi_Bot_Core_Manager') && $bot_id !== 'default') {
6366 // Get bot-specific options from multi-bot add-on
6367 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
6368
6369 // If bot has custom system instructions, use those
6370 if (!empty($bot_options['system_prompt_instructions'])) {
6371 $instructions = $bot_options['system_prompt_instructions'];
6372 }
6373 }
6374
6375 // Fall back to default system instructions
6376 if (empty($instructions)) {
6377 $instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
6378 }
6379
6380 // Check if citation links are disabled - if so, strip URLs from instructions
6381 $fresh_options = get_option('mxchat_options', []);
6382 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6383
6384 if (!$citation_links_enabled && !empty($instructions)) {
6385 $instructions = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $instructions);
6386 $instructions = preg_replace('/\s+/', ' ', trim($instructions)); // Clean up extra spaces
6387 }
6388
6389 // Replace {visitor_name} placeholder with actual visitor name if available
6390 if (!empty($instructions) && !empty($session_id) && stripos($instructions, '{visitor_name}') !== false) {
6391 $name_option_key = "mxchat_name_{$session_id}";
6392 $visitor_name = get_option($name_option_key, '');
6393
6394 if (!empty($visitor_name)) {
6395 $instructions = str_ireplace('{visitor_name}', sanitize_text_field($visitor_name), $instructions);
6396 } else {
6397 // Remove placeholder if no name is available
6398 $instructions = str_ireplace('{visitor_name}', '', $instructions);
6399 $instructions = preg_replace('/\s{2,}/', ' ', trim($instructions)); // Clean up extra spaces
6400 }
6401 }
6402
6403 // Allow developers to filter system instructions and process shortcodes
6404 $instructions = apply_filters('mxchat_system_instructions', $instructions, $bot_id, $session_id);
6405 $instructions = do_shortcode($instructions);
6406
6407 return $instructions;
6408 }
6409 /**
6410 * Get the current bot ID from session or request context
6411 */
6412 private function get_current_bot_id($session_id = '') {
6413 // First, check if bot_id is passed in the current request
6414 if (isset($_POST['bot_id']) && !empty($_POST['bot_id'])) {
6415 return sanitize_key($_POST['bot_id']);
6416 }
6417
6418 // If not in POST, try to get it from session data
6419 if (!empty($session_id)) {
6420 $bot_id = get_option("mxchat_session_bot_{$session_id}", '');
6421 if (!empty($bot_id)) {
6422 return $bot_id;
6423 }
6424 }
6425
6426 // Fall back to default
6427 return 'default';
6428 }
6429 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') {
6430 try {
6431 if (!$relevant_content) {
6432 $error_response = [
6433 'error' => esc_html__("I couldn't find relevant information on that topic.", 'mxchat'),
6434 'error_code' => 'no_relevant_content'
6435 ];
6436
6437 if ($testing_data !== null) {
6438 $error_response['testing_data'] = $testing_data;
6439 }
6440
6441 return $error_response;
6442 }
6443
6444 if (!is_array($conversation_history)) {
6445 $conversation_history = array();
6446 }
6447
6448 // Check if this is an OpenRouter model
6449 if ($selected_model === 'openrouter') {
6450 // Get the actual OpenRouter model from options
6451 $openrouter_selected_model = $this->options['openrouter_selected_model'] ?? '';
6452
6453 if (empty($openrouter_selected_model)) {
6454 $error_response = [
6455 'error' => esc_html__('No OpenRouter model selected. Please select a model in settings.', 'mxchat'),
6456 'error_code' => 'no_openrouter_model_selected'
6457 ];
6458 if ($testing_data !== null) {
6459 $error_response['testing_data'] = $testing_data;
6460 }
6461 return $error_response;
6462 }
6463
6464 if (empty($openrouter_api_key)) {
6465 $error_response = [
6466 'error' => esc_html__('OpenRouter API key is not configured', 'mxchat'),
6467 'error_code' => 'missing_openrouter_api_key'
6468 ];
6469 if ($testing_data !== null) {
6470 $error_response['testing_data'] = $testing_data;
6471 }
6472 return $error_response;
6473 }
6474
6475 if ($streaming) {
6476 return $this->mxchat_generate_response_openrouter_stream(
6477 $openrouter_selected_model,
6478 $openrouter_api_key,
6479 $conversation_history,
6480 $relevant_content,
6481 $session_id,
6482 $testing_data
6483 );
6484 } else {
6485 $response = $this->mxchat_generate_response_openrouter(
6486 $openrouter_selected_model,
6487 $openrouter_api_key,
6488 $conversation_history,
6489 $relevant_content
6490 );
6491 }
6492
6493 if (is_array($response) && isset($response['error'])) {
6494 if ($testing_data !== null) {
6495 $response['testing_data'] = $testing_data;
6496 }
6497 return $response;
6498 }
6499
6500 return $response;
6501 }
6502
6503 // Extract model prefix to determine the provider
6504 $model_parts = explode('-', $selected_model);
6505 $provider = strtolower($model_parts[0]);
6506
6507 // Handle model selection based on provider prefix
6508 switch ($provider) {
6509 case 'gemini':
6510 if (empty($gemini_api_key)) {
6511 $error_response = [
6512 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
6513 'error_code' => 'missing_gemini_api_key'
6514 ];
6515 if ($testing_data !== null) {
6516 $error_response['testing_data'] = $testing_data;
6517 }
6518 return $error_response;
6519 }
6520 $response = $this->mxchat_generate_response_gemini(
6521 $selected_model,
6522 $gemini_api_key,
6523 $conversation_history,
6524 $relevant_content
6525 );
6526 break;
6527
6528 case 'claude':
6529 if (empty($claude_api_key)) {
6530 $error_response = [
6531 'error' => esc_html__('Claude API key is not configured', 'mxchat'),
6532 'error_code' => 'missing_claude_api_key'
6533 ];
6534 if ($testing_data !== null) {
6535 $error_response['testing_data'] = $testing_data;
6536 }
6537 return $error_response;
6538 }
6539 if ($streaming) {
6540 return $this->mxchat_generate_response_claude_stream(
6541 $selected_model,
6542 $claude_api_key,
6543 $conversation_history,
6544 $relevant_content,
6545 $session_id,
6546 $testing_data
6547 );
6548 } else {
6549 $response = $this->mxchat_generate_response_claude(
6550 $selected_model,
6551 $claude_api_key,
6552 $conversation_history,
6553 $relevant_content
6554 );
6555 }
6556 break;
6557
6558 case 'grok':
6559 if (empty($xai_api_key)) {
6560 $error_response = [
6561 'error' => esc_html__('X.AI API key is not configured', 'mxchat'),
6562 'error_code' => 'missing_xai_api_key'
6563 ];
6564 if ($testing_data !== null) {
6565 $error_response['testing_data'] = $testing_data;
6566 }
6567 return $error_response;
6568 }
6569 if ($streaming) {
6570 return $this->mxchat_generate_response_xai_stream(
6571 $selected_model,
6572 $xai_api_key,
6573 $conversation_history,
6574 $relevant_content,
6575 $session_id,
6576 $testing_data
6577 );
6578 } else {
6579 $response = $this->mxchat_generate_response_xai(
6580 $selected_model,
6581 $xai_api_key,
6582 $conversation_history,
6583 $relevant_content
6584 );
6585 }
6586 break;
6587
6588 case 'deepseek':
6589 if (empty($deepseek_api_key)) {
6590 $error_response = [
6591 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
6592 'error_code' => 'missing_deepseek_api_key'
6593 ];
6594 if ($testing_data !== null) {
6595 $error_response['testing_data'] = $testing_data;
6596 }
6597 return $error_response;
6598 }
6599 if ($streaming) {
6600 return $this->mxchat_generate_response_deepseek_stream(
6601 $selected_model,
6602 $deepseek_api_key,
6603 $conversation_history,
6604 $relevant_content,
6605 $session_id,
6606 $testing_data
6607 );
6608 } else {
6609 $response = $this->mxchat_generate_response_deepseek(
6610 $selected_model,
6611 $deepseek_api_key,
6612 $conversation_history,
6613 $relevant_content
6614 );
6615 }
6616 break;
6617
6618 case 'gpt':
6619 case 'o1':
6620 if (empty($api_key)) {
6621 $error_response = [
6622 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
6623 'error_code' => 'missing_openai_api_key'
6624 ];
6625 if ($testing_data !== null) {
6626 $error_response['testing_data'] = $testing_data;
6627 }
6628 return $error_response;
6629 }
6630
6631 // Check if web search is enabled for this OpenAI model
6632 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
6633 // Models that don't support web search
6634 $unsupported_web_search_models = array('gpt-4.1-nano');
6635 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
6636
6637 if ($web_search_enabled && $model_supports_web_search) {
6638 // Use Responses API (required for some models, or when web search is enabled)
6639 return $this->mxchat_generate_response_openai_web_search(
6640 $selected_model,
6641 $api_key,
6642 $conversation_history,
6643 $relevant_content,
6644 $session_id,
6645 $testing_data,
6646 $streaming
6647 );
6648 } elseif ($streaming) {
6649 return $this->mxchat_generate_response_openai_stream(
6650 $selected_model,
6651 $api_key,
6652 $conversation_history,
6653 $relevant_content,
6654 $session_id,
6655 $testing_data
6656 );
6657 } else {
6658 $response = $this->mxchat_generate_response_openai(
6659 $selected_model,
6660 $api_key,
6661 $conversation_history,
6662 $relevant_content
6663 );
6664 }
6665 break;
6666
6667 default:
6668 if (empty($api_key)) {
6669 $error_response = [
6670 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
6671 'error_code' => 'missing_openai_api_key'
6672 ];
6673 if ($testing_data !== null) {
6674 $error_response['testing_data'] = $testing_data;
6675 }
6676 return $error_response;
6677 }
6678
6679 // Check if web search is enabled (default case also handles OpenAI models)
6680 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
6681 $unsupported_web_search_models = array('gpt-4.1-nano');
6682 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
6683
6684 if ($web_search_enabled && $model_supports_web_search) {
6685 return $this->mxchat_generate_response_openai_web_search(
6686 $selected_model,
6687 $api_key,
6688 $conversation_history,
6689 $relevant_content,
6690 $session_id,
6691 $testing_data,
6692 $streaming
6693 );
6694 } elseif ($streaming) {
6695 return $this->mxchat_generate_response_openai_stream(
6696 $selected_model,
6697 $api_key,
6698 $conversation_history,
6699 $relevant_content,
6700 $session_id,
6701 $testing_data
6702 );
6703 } else {
6704 $response = $this->mxchat_generate_response_openai(
6705 $selected_model,
6706 $api_key,
6707 $conversation_history,
6708 $relevant_content
6709 );
6710 }
6711 break;
6712 }
6713
6714 if (is_array($response) && isset($response['error'])) {
6715 if ($testing_data !== null) {
6716 $response['testing_data'] = $testing_data;
6717 }
6718 return $response;
6719 }
6720
6721 return $response;
6722
6723 } catch (Exception $e) {
6724 $error_response = [
6725 'error' => sprintf(esc_html__('An error occurred: %s', 'mxchat'), esc_html($e->getMessage())),
6726 'error_code' => 'system_exception',
6727 'exception_details' => $e->getMessage()
6728 ];
6729
6730 if ($testing_data !== null) {
6731 $error_response['testing_data'] = $testing_data;
6732 }
6733
6734 return $error_response;
6735 }
6736 }
6737 private function mxchat_generate_response_openrouter_stream($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
6738 try {
6739 $bot_id = $this->get_current_bot_id($session_id);
6740 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
6741
6742 if (!is_array($conversation_history)) {
6743 $conversation_history = array();
6744 }
6745
6746 $formatted_conversation = array();
6747
6748 $formatted_conversation[] = array(
6749 'role' => 'system',
6750 'content' => $system_prompt_instructions . " " . $relevant_content
6751 );
6752
6753 foreach ($conversation_history as $message) {
6754 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6755 $role = $message['role'];
6756 if ($role === 'bot' || $role === 'agent') {
6757 $role = 'assistant';
6758 }
6759 if (!in_array($role, ['system', 'assistant', 'user'])) {
6760 $role = 'user';
6761 }
6762 $formatted_conversation[] = array(
6763 'role' => $role,
6764 'content' => $message['content']
6765 );
6766 }
6767 }
6768
6769 if (headers_sent() || !function_exists('curl_init')) {
6770 $regular_response = $this->mxchat_generate_response_openrouter(
6771 $selected_model,
6772 $openrouter_api_key,
6773 $conversation_history,
6774 $relevant_content
6775 );
6776
6777 // Save bot response to transcript
6778 if (!empty($regular_response) && !empty($session_id)) {
6779 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
6780 }
6781
6782 $response_data = [
6783 'text' => $regular_response,
6784 'html' => '',
6785 'session_id' => $session_id
6786 ];
6787
6788 if ($testing_data !== null) {
6789 $response_data['testing_data'] = $testing_data;
6790 }
6791
6792 header('Content-Type: application/json');
6793 echo json_encode($response_data);
6794 return true;
6795 }
6796
6797 $body = json_encode([
6798 'model' => $selected_model,
6799 'messages' => $formatted_conversation,
6800 'temperature' => 1,
6801 'stream' => true
6802 ]);
6803
6804 // Setup streaming headers now that we know we're actually streaming
6805 $this->setup_streaming_headers();
6806
6807 $ch = curl_init();
6808 curl_setopt($ch, CURLOPT_URL, 'https://openrouter.ai/api/v1/chat/completions');
6809 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
6810 curl_setopt($ch, CURLOPT_POST, true);
6811 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
6812 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
6813 'Content-Type: application/json',
6814 'Authorization: Bearer ' . $openrouter_api_key,
6815 'HTTP-Referer: ' . home_url(),
6816 'X-Title: ' . get_bloginfo('name')
6817 ));
6818 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
6819 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
6820
6821 $full_response = '';
6822 $stream_started = false;
6823 $buffer = '';
6824
6825 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
6826 if (!$stream_started && $testing_data !== null) {
6827 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
6828 flush();
6829 $stream_started = true;
6830 }
6831
6832 $buffer .= $data;
6833 $lines = explode("\n", $buffer);
6834 $buffer = array_pop($lines);
6835
6836 foreach ($lines as $line) {
6837 if (trim($line) === '') {
6838 continue;
6839 }
6840
6841 if (strpos($line, 'data: ') !== 0) {
6842 continue;
6843 }
6844
6845 $json_str = substr($line, 6);
6846
6847 if (trim($json_str) === '[DONE]') {
6848 echo "data: [DONE]\n\n";
6849 flush();
6850 continue;
6851 }
6852
6853 $json = json_decode(trim($json_str), true);
6854 if ($json && isset($json['choices'][0]['delta']['content'])) {
6855 $content = $json['choices'][0]['delta']['content'];
6856 $full_response .= $content;
6857
6858 echo "data: " . json_encode(['content' => $content]) . "\n\n";
6859 flush();
6860 }
6861 }
6862
6863 return strlen($data);
6864 });
6865
6866 $response = curl_exec($ch);
6867 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
6868
6869 if (curl_errno($ch) || $http_code !== 200) {
6870 curl_close($ch);
6871
6872 $regular_response = $this->mxchat_generate_response_openrouter(
6873 $selected_model,
6874 $openrouter_api_key,
6875 $conversation_history,
6876 $relevant_content
6877 );
6878
6879 $response_data = [
6880 'text' => $regular_response,
6881 'html' => '',
6882 'session_id' => $session_id
6883 ];
6884
6885 if ($testing_data !== null) {
6886 $response_data['testing_data'] = $testing_data;
6887 }
6888
6889 header('Content-Type: application/json');
6890 echo json_encode($response_data);
6891 return true;
6892 }
6893
6894 curl_close($ch);
6895
6896 if (!empty($full_response) && !empty($session_id)) {
6897 // Prepare RAG context for streaming response
6898 $rag_context_for_storage = null;
6899 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
6900 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
6901
6902 if ($has_rag_data || $has_action_data) {
6903 $rag_context_for_storage = [];
6904
6905 if ($has_rag_data) {
6906 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
6907 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
6908 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
6909 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
6910 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
6911 }
6912
6913 if ($has_action_data) {
6914 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
6915 }
6916 }
6917 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
6918 }
6919
6920 return true;
6921
6922 } catch (Exception $e) {
6923 $regular_response = $this->mxchat_generate_response_openrouter(
6924 $selected_model,
6925 $openrouter_api_key,
6926 $conversation_history,
6927 $relevant_content
6928 );
6929
6930 $response_data = [
6931 'text' => $regular_response,
6932 'html' => '',
6933 'session_id' => $session_id
6934 ];
6935
6936 if ($testing_data !== null) {
6937 $response_data['testing_data'] = $testing_data;
6938 }
6939
6940 header('Content-Type: application/json');
6941 echo json_encode($response_data);
6942 return true;
6943 }
6944 }
6945 private function mxchat_generate_response_openai_stream($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
6946 try {
6947 $bot_id = $this->get_current_bot_id($session_id);
6948
6949 // Get system prompt instructions using centralized function
6950 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
6951
6952 // Ensure conversation_history is an array
6953 if (!is_array($conversation_history)) {
6954 $conversation_history = array();
6955 }
6956
6957 // Format conversation history for OpenAI
6958 $formatted_conversation = array();
6959
6960 $formatted_conversation[] = array(
6961 'role' => 'system',
6962 'content' => $system_prompt_instructions . " " . $relevant_content
6963 );
6964
6965 foreach ($conversation_history as $message) {
6966 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6967 $role = $message['role'];
6968 if ($role === 'bot' || $role === 'agent') {
6969 $role = 'assistant';
6970 }
6971 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
6972 $role = 'user';
6973 }
6974 $formatted_conversation[] = array(
6975 'role' => $role,
6976 'content' => $message['content']
6977 );
6978 }
6979 }
6980
6981 // Check if we can actually stream
6982 if (headers_sent() || !function_exists('curl_init')) {
6983 // Fallback to regular response with testing data
6984 $regular_response = $this->mxchat_generate_response_openai(
6985 $selected_model,
6986 $api_key,
6987 $conversation_history,
6988 $relevant_content
6989 );
6990
6991 // Save bot response to transcript
6992 if (!empty($regular_response) && !empty($session_id)) {
6993 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
6994 }
6995
6996 $response_data = [
6997 'text' => $regular_response,
6998 'html' => '',
6999 'session_id' => $session_id
7000 ];
7001
7002 if ($testing_data !== null) {
7003 $response_data['testing_data'] = $testing_data;
7004 }
7005
7006 header('Content-Type: application/json');
7007 echo json_encode($response_data);
7008 return true;
7009 }
7010
7011 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
7012 $is_gpt5_model = (
7013 strpos($selected_model, 'gpt-5') === 0 ||
7014 $selected_model === 'gpt-5.2' ||
7015 $selected_model === 'gpt-5.1-2025-11-13' ||
7016 $selected_model === 'gpt-5' ||
7017 $selected_model === 'gpt-5-mini' ||
7018 $selected_model === 'gpt-5-nano'
7019 );
7020
7021 // Build request body with optimal settings for fast streaming
7022 $request_body = [
7023 'model' => $selected_model,
7024 'messages' => $formatted_conversation,
7025 'temperature' => 1,
7026 'stream' => true
7027 ];
7028
7029 // Add reasoning_effort only for GPT-5 models that support it
7030 // These chat models don't support reasoning_effort parameter
7031 $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');
7032 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_models, true)) {
7033 // GPT-5.1 uses 'low' instead of 'minimal'
7034 if ($selected_model === 'gpt-5.1-2025-11-13') {
7035 $request_body['reasoning_effort'] = 'low';
7036 } elseif ($selected_model === 'gpt-5.4') {
7037 $request_body['reasoning_effort'] = 'none';
7038 } else {
7039 $request_body['reasoning_effort'] = 'minimal';
7040 }
7041 }
7042
7043 $body = json_encode($request_body);
7044
7045 // Setup streaming headers now that we know we're actually streaming
7046 $this->setup_streaming_headers();
7047
7048 // Use cURL for streaming support
7049 $ch = curl_init();
7050 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
7051 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7052 curl_setopt($ch, CURLOPT_POST, true);
7053 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7054 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7055 'Content-Type: application/json',
7056 'Authorization: Bearer ' . $api_key
7057 ));
7058 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7059 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7060
7061 $full_response = ''; // Accumulate full response for saving
7062 $stream_started = false;
7063 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7064
7065 // Buffer control for real-time streaming
7066 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7067 // Send testing data as the first event if available
7068 if (!$stream_started && $testing_data !== null) {
7069 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7070 flush();
7071 $stream_started = true;
7072 }
7073
7074 // CRITICAL FIX: Append new data to buffer
7075 $buffer .= $data;
7076
7077 // Process complete lines only
7078 $lines = explode("\n", $buffer);
7079
7080 // CRITICAL FIX: Keep the last incomplete line in the buffer
7081 // The last element might be incomplete, so keep it in buffer
7082 $buffer = array_pop($lines);
7083
7084 foreach ($lines as $line) {
7085 // Skip empty lines
7086 if (trim($line) === '') {
7087 continue;
7088 }
7089
7090 // Only process lines that start with "data: "
7091 if (strpos($line, 'data: ') !== 0) {
7092 continue;
7093 }
7094
7095 $json_str = substr($line, 6); // Remove 'data: ' prefix
7096
7097 if (trim($json_str) === '[DONE]') {
7098 echo "data: [DONE]\n\n";
7099 flush();
7100 continue;
7101 }
7102
7103 // Try to decode JSON
7104 $json = json_decode(trim($json_str), true);
7105 if ($json && isset($json['choices'][0]['delta']['content'])) {
7106 $content = $json['choices'][0]['delta']['content'];
7107 $full_response .= $content; // Accumulate the full response
7108
7109 // Send as SSE format
7110 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7111 flush();
7112 }
7113 }
7114
7115 return strlen($data);
7116 });
7117
7118 $response = curl_exec($ch);
7119 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7120
7121 if (curl_errno($ch) || $http_code !== 200) {
7122 $curl_error = curl_error($ch);
7123 curl_close($ch);
7124
7125 // Fallback to regular response
7126 $regular_response = $this->mxchat_generate_response_openai(
7127 $selected_model,
7128 $api_key,
7129 $conversation_history,
7130 $relevant_content
7131 );
7132
7133 // FIXED: Check if regular response returned an error
7134 if (is_array($regular_response) && isset($regular_response['error'])) {
7135 // Send error in SSE format since we're in streaming mode
7136 echo "data: " . json_encode([
7137 'error' => true,
7138 'error_message' => $regular_response['error'],
7139 'error_code' => $regular_response['error_code'] ?? 'api_error',
7140 'text' => $regular_response['error'],
7141 'message' => $regular_response['error']
7142 ]) . "\n\n";
7143 echo "data: [DONE]\n\n";
7144 flush();
7145 return true;
7146 }
7147
7148 $response_data = [
7149 'text' => $regular_response,
7150 'html' => '',
7151 'session_id' => $session_id
7152 ];
7153
7154 if ($testing_data !== null) {
7155 $response_data['testing_data'] = $testing_data;
7156 }
7157
7158 header('Content-Type: application/json');
7159 echo json_encode($response_data);
7160 return true;
7161 }
7162
7163 curl_close($ch);
7164
7165 // Save the complete response to maintain chat persistence
7166 if (!empty($full_response) && !empty($session_id)) {
7167 // Prepare RAG context for streaming response
7168 $rag_context_for_storage = null;
7169 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7170 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7171
7172 if ($has_rag_data || $has_action_data) {
7173 $rag_context_for_storage = [];
7174
7175 if ($has_rag_data) {
7176 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7177 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7178 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7179 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7180 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7181 }
7182
7183 if ($has_action_data) {
7184 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7185 }
7186 }
7187 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7188 }
7189
7190 return true; // Indicate streaming completed successfully
7191
7192 } catch (Exception $e) {
7193 // Fallback to regular response
7194 $regular_response = $this->mxchat_generate_response_openai(
7195 $selected_model,
7196 $api_key,
7197 $conversation_history,
7198 $relevant_content
7199 );
7200
7201 // FIXED: Check if regular response returned an error
7202 if (is_array($regular_response) && isset($regular_response['error'])) {
7203 // Send error in SSE format since we're in streaming mode
7204 echo "data: " . json_encode([
7205 'error' => true,
7206 'error_message' => $regular_response['error'],
7207 'error_code' => $regular_response['error_code'] ?? 'api_error',
7208 'text' => $regular_response['error'],
7209 'message' => $regular_response['error']
7210 ]) . "\n\n";
7211 echo "data: [DONE]\n\n";
7212 flush();
7213 return true;
7214 }
7215
7216 $response_data = [
7217 'text' => $regular_response,
7218 'html' => '',
7219 'session_id' => $session_id
7220 ];
7221
7222 if ($testing_data !== null) {
7223 $response_data['testing_data'] = $testing_data;
7224 }
7225
7226 header('Content-Type: application/json');
7227 echo json_encode($response_data);
7228 return true;
7229 }
7230 }
7231
7232 /**
7233 * Generate response using OpenAI Responses API with web search tool
7234 * This uses the newer Responses API which supports web search functionality
7235 */
7236 private function mxchat_generate_response_openai_web_search($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null, $streaming = false) {
7237 try {
7238 $bot_id = $this->get_current_bot_id($session_id);
7239 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7240
7241 if (!is_array($conversation_history)) {
7242 $conversation_history = array();
7243 }
7244
7245 // Build the input for Responses API
7246 // The Responses API uses a different format - we need to construct the input properly
7247 $input_parts = [];
7248
7249 // Add system instructions as context
7250 $system_context = $system_prompt_instructions . "\n\n" . $relevant_content;
7251
7252 // Build conversation as input items for Responses API
7253 foreach ($conversation_history as $message) {
7254 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7255 $role = $message['role'];
7256 if ($role === 'bot' || $role === 'agent') {
7257 $role = 'assistant';
7258 }
7259 if (!in_array($role, ['assistant', 'user'])) {
7260 $role = 'user';
7261 }
7262 $input_parts[] = [
7263 'type' => 'message',
7264 'role' => $role,
7265 'content' => $message['content']
7266 ];
7267 }
7268 }
7269
7270 // Build request body for Responses API
7271 $request_body = [
7272 'model' => $selected_model,
7273 'input' => $input_parts,
7274 'instructions' => $system_context,
7275 'stream' => $streaming
7276 ];
7277
7278 // Only add web search tool if web search is enabled in settings
7279 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
7280 if ($web_search_enabled) {
7281 $request_body['tools'] = [
7282 ['type' => 'web_search']
7283 ];
7284 }
7285
7286 // Add reasoning effort for supported models
7287 $is_gpt5_model = strpos($selected_model, 'gpt-5') === 0;
7288 $no_reasoning_web = array('gpt-5.2', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
7289 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_web, true)) {
7290 if ($selected_model === 'gpt-5.1-2025-11-13') {
7291 $request_body['reasoning'] = ['effort' => 'low'];
7292 } elseif ($selected_model === 'gpt-5.4') {
7293 $request_body['reasoning'] = ['effort' => 'low'];
7294 }
7295 }
7296
7297 //error_log("MXCHAT WEB SEARCH: Request body: " . json_encode($request_body));
7298
7299 if ($streaming) {
7300 return $this->mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data);
7301 } else {
7302 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7303 }
7304
7305 } catch (Exception $e) {
7306 //error_log("MXCHAT WEB SEARCH ERROR: " . $e->getMessage());
7307 return [
7308 'error' => sprintf(esc_html__('Web search error: %s', 'mxchat'), esc_html($e->getMessage())),
7309 'error_code' => 'web_search_exception'
7310 ];
7311 }
7312 }
7313
7314 /**
7315 * Handle non-streaming web search response
7316 */
7317 private function mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data) {
7318 $request_body['stream'] = false;
7319
7320 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
7321 'headers' => array(
7322 'Authorization' => 'Bearer ' . $api_key,
7323 'Content-Type' => 'application/json'
7324 ),
7325 'body' => json_encode($request_body),
7326 'timeout' => 90
7327 ));
7328
7329 if (is_wp_error($response)) {
7330 //error_log("MXCHAT WEB SEARCH ERROR: WP Error: " . $response->get_error_message());
7331 return [
7332 'error' => esc_html__('Failed to connect to OpenAI web search API', 'mxchat'),
7333 'error_code' => 'web_search_connection_error'
7334 ];
7335 }
7336
7337 $response_code = wp_remote_retrieve_response_code($response);
7338 $response_body = wp_remote_retrieve_body($response);
7339
7340 //error_log("MXCHAT WEB SEARCH: Response code: " . $response_code);
7341 //error_log("MXCHAT WEB SEARCH: Response body (first 2000): " . substr($response_body, 0, 2000));
7342
7343 if ($response_code !== 200) {
7344 $error_data = json_decode($response_body, true);
7345 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
7346 return [
7347 'error' => sprintf(esc_html__('OpenAI API error: %s', 'mxchat'), esc_html($error_message)),
7348 'error_code' => 'web_search_api_error'
7349 ];
7350 }
7351
7352 $result = json_decode($response_body, true);
7353
7354 if (json_last_error() !== JSON_ERROR_NONE) {
7355 return [
7356 'error' => esc_html__('Invalid response from OpenAI', 'mxchat'),
7357 'error_code' => 'web_search_json_error'
7358 ];
7359 }
7360
7361 // Extract the response text and citations from Responses API format
7362 $output_text = '';
7363 $citations = [];
7364
7365 if (isset($result['output'])) {
7366 foreach ($result['output'] as $output_item) {
7367 if ($output_item['type'] === 'message' && isset($output_item['content'])) {
7368 foreach ($output_item['content'] as $content_item) {
7369 if ($content_item['type'] === 'output_text') {
7370 $output_text .= $content_item['text'];
7371
7372 // Extract citations/annotations
7373 if (isset($content_item['annotations'])) {
7374 foreach ($content_item['annotations'] as $annotation) {
7375 if ($annotation['type'] === 'url_citation') {
7376 $citations[] = [
7377 'url' => $annotation['url'],
7378 'title' => $annotation['title'] ?? ''
7379 ];
7380 }
7381 }
7382 }
7383 }
7384 }
7385 }
7386 }
7387 }
7388
7389 // If we have citations, append them to the response
7390 if (!empty($citations)) {
7391 $output_text .= "\n\n**Sources:**\n";
7392 $seen_urls = [];
7393 foreach ($citations as $citation) {
7394 if (!in_array($citation['url'], $seen_urls)) {
7395 $seen_urls[] = $citation['url'];
7396 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
7397 $output_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
7398 }
7399 }
7400 }
7401
7402 // Save to transcript
7403 if (!empty($output_text) && !empty($session_id)) {
7404 $this->mxchat_save_chat_message($session_id, 'bot', $output_text);
7405 }
7406
7407 return $output_text;
7408 }
7409
7410 /**
7411 * Handle streaming web search response using Responses API
7412 */
7413 private function mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data) {
7414 $request_body['stream'] = true;
7415
7416 // Check if we can stream
7417 if (headers_sent() || !function_exists('curl_init')) {
7418 // Fallback to non-streaming
7419 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7420 }
7421
7422 // Setup streaming headers
7423 $this->setup_streaming_headers();
7424
7425 $ch = curl_init();
7426 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/responses');
7427 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7428 curl_setopt($ch, CURLOPT_POST, true);
7429 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request_body));
7430 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7431 'Content-Type: application/json',
7432 'Authorization: Bearer ' . $api_key
7433 ));
7434 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7435 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
7436
7437 $full_response = '';
7438 $stream_started = false;
7439 $buffer = '';
7440 $citations = [];
7441
7442 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$citations, $testing_data) {
7443 // Send testing data as first event if available
7444 if (!$stream_started && $testing_data !== null) {
7445 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7446 flush();
7447 $stream_started = true;
7448 }
7449
7450 $buffer .= $data;
7451 $lines = explode("\n", $buffer);
7452 $buffer = array_pop($lines);
7453
7454 foreach ($lines as $line) {
7455 if (trim($line) === '') continue;
7456 if (strpos($line, 'data: ') !== 0) continue;
7457
7458 $json_str = substr($line, 6);
7459
7460 if (trim($json_str) === '[DONE]') {
7461 // Append citations if we have any
7462 if (!empty($citations)) {
7463 $citation_text = "\n\n**Sources:**\n";
7464 $seen_urls = [];
7465 foreach ($citations as $citation) {
7466 if (!in_array($citation['url'], $seen_urls)) {
7467 $seen_urls[] = $citation['url'];
7468 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
7469 $citation_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
7470 }
7471 }
7472 echo "data: " . json_encode(['content' => $citation_text]) . "\n\n";
7473 $full_response .= $citation_text;
7474 flush();
7475 }
7476 echo "data: [DONE]\n\n";
7477 flush();
7478 continue;
7479 }
7480
7481 $json = json_decode(trim($json_str), true);
7482 if (!$json) continue;
7483
7484 // Handle Responses API streaming events
7485 // The format is different from Chat Completions
7486 if (isset($json['type'])) {
7487 switch ($json['type']) {
7488 case 'response.output_text.delta':
7489 // Text content delta
7490 if (isset($json['delta'])) {
7491 $content = $json['delta'];
7492 $full_response .= $content;
7493 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7494 flush();
7495 }
7496 break;
7497
7498 case 'response.output_item.done':
7499 // Check for citations in completed items
7500 if (isset($json['item']['content'])) {
7501 foreach ($json['item']['content'] as $content_item) {
7502 if (isset($content_item['annotations'])) {
7503 foreach ($content_item['annotations'] as $annotation) {
7504 if ($annotation['type'] === 'url_citation') {
7505 $citations[] = [
7506 'url' => $annotation['url'],
7507 'title' => $annotation['title'] ?? ''
7508 ];
7509 }
7510 }
7511 }
7512 }
7513 }
7514 break;
7515 }
7516 }
7517 }
7518
7519 return strlen($data);
7520 });
7521
7522 $response = curl_exec($ch);
7523 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7524
7525 if (curl_errno($ch) || $http_code !== 200) {
7526 $curl_error = curl_error($ch);
7527 curl_close($ch);
7528
7529 //error_log("MXCHAT WEB SEARCH STREAM ERROR: HTTP $http_code, cURL error: $curl_error");
7530
7531 // Fallback to non-streaming
7532 $fallback_response = $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7533
7534 if (is_array($fallback_response) && isset($fallback_response['error'])) {
7535 echo "data: " . json_encode([
7536 'error' => true,
7537 'error_message' => $fallback_response['error'],
7538 'error_code' => $fallback_response['error_code'] ?? 'web_search_error'
7539 ]) . "\n\n";
7540 echo "data: [DONE]\n\n";
7541 flush();
7542 return true;
7543 }
7544
7545 $response_data = [
7546 'text' => $fallback_response,
7547 'html' => '',
7548 'session_id' => $session_id
7549 ];
7550 if ($testing_data !== null) {
7551 $response_data['testing_data'] = $testing_data;
7552 }
7553 header('Content-Type: application/json');
7554 echo json_encode($response_data);
7555 return true;
7556 }
7557
7558 curl_close($ch);
7559
7560 // Save the complete response
7561 if (!empty($full_response) && !empty($session_id)) {
7562 $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
7563 }
7564
7565 return true;
7566 }
7567
7568 private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7569 try {
7570 // Get bot ID from session or request
7571 $bot_id = $this->get_current_bot_id($session_id);
7572
7573 // Get system prompt instructions using centralized function
7574 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7575 // Ensure conversation_history is an array
7576 if (!is_array($conversation_history)) {
7577 $conversation_history = array();
7578 }
7579
7580 // Clean and validate conversation history
7581 foreach ($conversation_history as &$message) {
7582 // Convert bot and agent roles to assistant
7583 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
7584 $message['role'] = 'assistant';
7585 }
7586
7587 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
7588 if (!in_array($message['role'], ['assistant', 'user'])) {
7589 $message['role'] = 'user';
7590 }
7591
7592 // Ensure content field exists
7593 if (!isset($message['content']) || empty($message['content'])) {
7594 $message['content'] = '';
7595 }
7596
7597 // Remove any unsupported fields
7598 $message = array_intersect_key($message, array_flip(['role', 'content']));
7599 }
7600
7601 // Add relevant content as the latest user message
7602 $conversation_history[] = [
7603 'role' => 'user',
7604 'content' => $relevant_content
7605 ];
7606
7607 // Prepare the request body with stream: true
7608 $body = json_encode([
7609 'model' => $selected_model,
7610 'messages' => $conversation_history,
7611 'max_tokens' => 1000,
7612 'temperature' => 0.8,
7613 'system' => $system_prompt_instructions,
7614 'stream' => true
7615 ]);
7616
7617 // Check if we can actually stream (headers not sent, etc.)
7618 if (headers_sent() || !function_exists('curl_init')) {
7619 // Fallback to regular response with testing data
7620 //error_log("MxChat: Streaming not possible, falling back to regular response");
7621 $regular_response = $this->mxchat_generate_response_claude(
7622 $selected_model,
7623 $claude_api_key,
7624 array_slice($conversation_history, 0, -1), // Remove the added content
7625 $relevant_content
7626 );
7627
7628 // Save bot response to transcript
7629 if (!empty($regular_response) && !empty($session_id)) {
7630 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7631 }
7632
7633 // Return as JSON with testing data
7634 $response_data = [
7635 'text' => $regular_response,
7636 'html' => '',
7637 'session_id' => $session_id
7638 ];
7639
7640 if ($testing_data !== null) {
7641 $response_data['testing_data'] = $testing_data;
7642 //error_log("MxChat Testing: Added testing data to Claude fallback response");
7643 }
7644
7645 // Clear any streaming headers and send JSON
7646 if (headers_sent() === false) {
7647 header('Content-Type: application/json');
7648 }
7649 echo json_encode($response_data);
7650 return true; // Indicate we handled the response
7651 }
7652
7653 // Setup streaming headers now that we know we're actually streaming
7654 $this->setup_streaming_headers();
7655
7656 // Use cURL for streaming support
7657 $ch = curl_init();
7658 curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
7659 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7660 curl_setopt($ch, CURLOPT_POST, true);
7661 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7662 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7663 'Content-Type: application/json',
7664 'x-api-key: ' . $claude_api_key,
7665 'anthropic-version: 2023-06-01'
7666 ));
7667 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7668 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7669
7670 $full_response = ''; // Accumulate full response for saving
7671 $stream_started = false;
7672 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7673
7674 // Buffer control for real-time streaming
7675 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7676 // Send testing data as the first event if available
7677 if (!$stream_started && $testing_data !== null) {
7678 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7679 flush();
7680 $stream_started = true;
7681 //error_log("MxChat Testing: Sent testing data in Claude stream");
7682 }
7683
7684 // CRITICAL FIX: Append new data to buffer
7685 $buffer .= $data;
7686
7687 // Process complete lines only
7688 $lines = explode("\n", $buffer);
7689
7690 // CRITICAL FIX: Keep the last incomplete line in the buffer
7691 // The last element might be incomplete, so keep it in buffer
7692 $buffer = array_pop($lines);
7693
7694 foreach ($lines as $line) {
7695 if (trim($line) === '') {
7696 continue;
7697 }
7698
7699 // Claude uses event: and data: format
7700 if (strpos($line, 'event: ') === 0) {
7701 // Store the event type for the next data line
7702 continue;
7703 }
7704
7705 if (strpos($line, 'data: ') === 0) {
7706 $json_str = substr($line, 6); // Remove 'data: ' prefix
7707
7708 $json = json_decode(trim($json_str), true);
7709 if (json_last_error() !== JSON_ERROR_NONE) {
7710 continue;
7711 }
7712
7713 // Handle different event types
7714 if (isset($json['type'])) {
7715 switch ($json['type']) {
7716 case 'content_block_delta':
7717 if (isset($json['delta']['text'])) {
7718 $content = $json['delta']['text'];
7719 $full_response .= $content; // Accumulate
7720 // Send as SSE format compatible with your frontend
7721 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7722 flush();
7723 }
7724 break;
7725
7726 case 'message_stop':
7727 echo "data: [DONE]\n\n";
7728 flush();
7729 break;
7730
7731 case 'error':
7732 echo "data: " . json_encode(['error' => $json['error']['message'] ?? 'Unknown error']) . "\n\n";
7733 flush();
7734 break;
7735 }
7736 }
7737 }
7738 }
7739
7740 return strlen($data);
7741 });
7742
7743 $response = curl_exec($ch);
7744 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7745
7746 if (curl_errno($ch)) {
7747 curl_close($ch);
7748 throw new Exception('cURL Error: ' . curl_error($ch));
7749 }
7750
7751 curl_close($ch);
7752
7753 if ($http_code !== 200) {
7754 // Fallback to regular response
7755 //error_log("MxChat: Claude streaming failed with HTTP $http_code, falling back");
7756 $regular_response = $this->mxchat_generate_response_claude(
7757 $selected_model,
7758 $claude_api_key,
7759 array_slice($conversation_history, 0, -1), // Remove the added content
7760 $relevant_content
7761 );
7762
7763 // FIXED: Check if regular response returned an error
7764 if (is_array($regular_response) && isset($regular_response['error'])) {
7765 // Send error in SSE format since we're in streaming mode
7766 echo "data: " . json_encode([
7767 'error' => true,
7768 'error_message' => $regular_response['error'],
7769 'error_code' => $regular_response['error_code'] ?? 'api_error',
7770 'text' => $regular_response['error'],
7771 'message' => $regular_response['error']
7772 ]) . "\n\n";
7773 echo "data: [DONE]\n\n";
7774 flush();
7775 return true;
7776 }
7777
7778 $response_data = [
7779 'text' => $regular_response,
7780 'html' => '',
7781 'session_id' => $session_id
7782 ];
7783
7784 if ($testing_data !== null) {
7785 $response_data['testing_data'] = $testing_data;
7786 //error_log("MxChat Testing: Added testing data to Claude error fallback");
7787 }
7788
7789 header('Content-Type: application/json');
7790 echo json_encode($response_data);
7791 return true;
7792 }
7793
7794 // Save the complete response to maintain chat persistence
7795 if (!empty($full_response) && !empty($session_id)) {
7796 // Prepare RAG context for streaming response
7797 $rag_context_for_storage = null;
7798 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7799 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7800
7801 if ($has_rag_data || $has_action_data) {
7802 $rag_context_for_storage = [];
7803
7804 if ($has_rag_data) {
7805 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7806 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7807 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7808 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7809 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7810 }
7811
7812 if ($has_action_data) {
7813 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7814 }
7815 }
7816 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7817 }
7818
7819 return true; // Indicate streaming completed successfully
7820
7821 } catch (Exception $e) {
7822 //error_log("MxChat Claude streaming exception: " . $e->getMessage());
7823
7824 // Fallback to regular response on exception
7825 $regular_response = $this->mxchat_generate_response_claude(
7826 $selected_model,
7827 $claude_api_key,
7828 $conversation_history,
7829 $relevant_content
7830 );
7831
7832 // FIXED: Check if regular response returned an error
7833 if (is_array($regular_response) && isset($regular_response['error'])) {
7834 // Send error in SSE format since we're in streaming mode
7835 echo "data: " . json_encode([
7836 'error' => true,
7837 'error_message' => $regular_response['error'],
7838 'error_code' => $regular_response['error_code'] ?? 'api_error',
7839 'text' => $regular_response['error'],
7840 'message' => $regular_response['error']
7841 ]) . "\n\n";
7842 echo "data: [DONE]\n\n";
7843 flush();
7844 return true;
7845 }
7846
7847 $response_data = [
7848 'text' => $regular_response,
7849 'html' => '',
7850 'session_id' => $session_id
7851 ];
7852
7853 if ($testing_data !== null) {
7854 $response_data['testing_data'] = $testing_data;
7855 //error_log("MxChat Testing: Added testing data to Claude exception fallback");
7856 }
7857
7858 header('Content-Type: application/json');
7859 echo json_encode($response_data);
7860 return true;
7861 }
7862 }
7863 private function mxchat_generate_response_xai_stream($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7864 try {
7865 // Get bot ID from session or request
7866 $bot_id = $this->get_current_bot_id($session_id);
7867
7868 // Get system prompt instructions using centralized function
7869 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7870
7871 // Ensure conversation_history is an array
7872 if (!is_array($conversation_history)) {
7873 $conversation_history = array();
7874 }
7875
7876 // Format conversation history for X.AI (same as OpenAI format)
7877 $formatted_conversation = array();
7878
7879 $formatted_conversation[] = array(
7880 'role' => 'system',
7881 'content' => $system_prompt_instructions . " " . $relevant_content
7882 );
7883
7884 foreach ($conversation_history as $message) {
7885 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7886 $role = $message['role'];
7887 if ($role === 'bot' || $role === 'agent') {
7888 $role = 'assistant';
7889 }
7890 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
7891 $role = 'user';
7892 }
7893 $formatted_conversation[] = array(
7894 'role' => $role,
7895 'content' => $message['content']
7896 );
7897 }
7898 }
7899
7900 // Check if we can actually stream
7901 if (headers_sent() || !function_exists('curl_init')) {
7902 // Fallback to regular response with testing data
7903 //error_log("MxChat: X.AI streaming not possible, falling back to regular response");
7904 $regular_response = $this->mxchat_generate_response_xai(
7905 $selected_model,
7906 $xai_api_key,
7907 $conversation_history,
7908 $relevant_content
7909 );
7910
7911 // Save bot response to transcript
7912 if (!empty($regular_response) && !empty($session_id)) {
7913 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7914 }
7915
7916 $response_data = [
7917 'text' => $regular_response,
7918 'html' => '',
7919 'session_id' => $session_id
7920 ];
7921
7922 if ($testing_data !== null) {
7923 $response_data['testing_data'] = $testing_data;
7924 //error_log("MxChat Testing: Added testing data to X.AI fallback response");
7925 }
7926
7927 header('Content-Type: application/json');
7928 echo json_encode($response_data);
7929 return true;
7930 }
7931
7932 // Prepare the request body with stream: true
7933 $body = json_encode([
7934 'model' => $selected_model,
7935 'messages' => $formatted_conversation,
7936 'temperature' => 0.8,
7937 'stream' => true
7938 ]);
7939
7940 // Setup streaming headers now that we know we're actually streaming
7941 $this->setup_streaming_headers();
7942
7943 // Use cURL for streaming support
7944 $ch = curl_init();
7945 curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
7946 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7947 curl_setopt($ch, CURLOPT_POST, true);
7948 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7949 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7950 'Content-Type: application/json',
7951 'Authorization: Bearer ' . $xai_api_key
7952 ));
7953 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7954 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7955
7956 $full_response = ''; // Accumulate full response for saving
7957 $stream_started = false;
7958 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7959
7960 // Buffer control for real-time streaming
7961 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7962 // Send testing data as the first event if available
7963 if (!$stream_started && $testing_data !== null) {
7964 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7965 flush();
7966 $stream_started = true;
7967 //error_log("MxChat Testing: Sent testing data in X.AI stream");
7968 }
7969
7970 // CRITICAL FIX: Append new data to buffer
7971 $buffer .= $data;
7972
7973 // Process complete lines only
7974 $lines = explode("\n", $buffer);
7975
7976 // CRITICAL FIX: Keep the last incomplete line in the buffer
7977 // The last element might be incomplete, so keep it in buffer
7978 $buffer = array_pop($lines);
7979
7980 foreach ($lines as $line) {
7981 // Skip empty lines
7982 if (trim($line) === '') {
7983 continue;
7984 }
7985
7986 // Only process lines that start with "data: "
7987 if (strpos($line, 'data: ') !== 0) {
7988 continue;
7989 }
7990
7991 $json_str = substr($line, 6); // Remove 'data: ' prefix
7992
7993 if (trim($json_str) === '[DONE]') {
7994 echo "data: [DONE]\n\n";
7995 flush();
7996 continue;
7997 }
7998
7999 // Try to decode JSON
8000 $json = json_decode(trim($json_str), true);
8001 if ($json && isset($json['choices'][0]['delta']['content'])) {
8002 $content = $json['choices'][0]['delta']['content'];
8003 $full_response .= $content; // Accumulate
8004 // Send as SSE format
8005 echo "data: " . json_encode(['content' => $content]) . "\n\n";
8006 flush();
8007 }
8008 }
8009
8010 return strlen($data);
8011 });
8012
8013 $response = curl_exec($ch);
8014 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
8015
8016 if (curl_errno($ch) || $http_code !== 200) {
8017 curl_close($ch);
8018
8019 // Fallback to regular response
8020 //error_log("MxChat: X.AI streaming failed, falling back");
8021 $regular_response = $this->mxchat_generate_response_xai(
8022 $selected_model,
8023 $xai_api_key,
8024 $conversation_history,
8025 $relevant_content
8026 );
8027
8028 $response_data = [
8029 'text' => $regular_response,
8030 'html' => '',
8031 'session_id' => $session_id
8032 ];
8033
8034 if ($testing_data !== null) {
8035 $response_data['testing_data'] = $testing_data;
8036 //error_log("MxChat Testing: Added testing data to X.AI error fallback");
8037 }
8038
8039 header('Content-Type: application/json');
8040 echo json_encode($response_data);
8041 return true;
8042 }
8043
8044 curl_close($ch);
8045
8046 // Save the complete response to maintain chat persistence
8047 if (!empty($full_response) && !empty($session_id)) {
8048 // Prepare RAG context for streaming response
8049 $rag_context_for_storage = null;
8050 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
8051 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
8052
8053 if ($has_rag_data || $has_action_data) {
8054 $rag_context_for_storage = [];
8055
8056 if ($has_rag_data) {
8057 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
8058 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
8059 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
8060 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
8061 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
8062 }
8063
8064 if ($has_action_data) {
8065 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
8066 }
8067 }
8068 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
8069 }
8070
8071 return true; // Indicate streaming completed successfully
8072
8073 } catch (Exception $e) {
8074 //error_log("MxChat X.AI streaming exception: " . $e->getMessage());
8075
8076 // Fallback to regular response
8077 $regular_response = $this->mxchat_generate_response_xai(
8078 $selected_model,
8079 $xai_api_key,
8080 $conversation_history,
8081 $relevant_content
8082 );
8083
8084 $response_data = [
8085 'text' => $regular_response,
8086 'html' => '',
8087 'session_id' => $session_id
8088 ];
8089
8090 if ($testing_data !== null) {
8091 $response_data['testing_data'] = $testing_data;
8092 //error_log("MxChat Testing: Added testing data to X.AI exception fallback");
8093 }
8094
8095 header('Content-Type: application/json');
8096 echo json_encode($response_data);
8097 return true;
8098 }
8099 }
8100 private function mxchat_generate_response_deepseek_stream($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
8101 try {
8102 // Get bot ID from session or request
8103 $bot_id = $this->get_current_bot_id($session_id);
8104
8105 // Get system prompt instructions using centralized function
8106 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8107
8108 // Ensure conversation_history is an array
8109 if (!is_array($conversation_history)) {
8110 $conversation_history = array();
8111 }
8112
8113 // Format conversation history for DeepSeek
8114 $formatted_conversation = array();
8115
8116 $formatted_conversation[] = array(
8117 'role' => 'system',
8118 'content' => $system_prompt_instructions . " " . $relevant_content
8119 );
8120
8121 foreach ($conversation_history as $message) {
8122 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8123 $role = $message['role'];
8124 if ($role === 'bot' || $role === 'agent') {
8125 $role = 'assistant';
8126 }
8127 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8128 $role = 'user';
8129 }
8130 $formatted_conversation[] = array(
8131 'role' => $role,
8132 'content' => $message['content']
8133 );
8134 }
8135 }
8136
8137 // Check if we can actually stream
8138 if (headers_sent() || !function_exists('curl_init')) {
8139 // Fallback to regular response with testing data
8140 //error_log("MxChat: DeepSeek streaming not possible, falling back to regular response");
8141 $regular_response = $this->mxchat_generate_response_deepseek(
8142 $selected_model,
8143 $deepseek_api_key,
8144 $conversation_history,
8145 $relevant_content
8146 );
8147
8148 // Save bot response to transcript
8149 if (!empty($regular_response) && !empty($session_id)) {
8150 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
8151 }
8152
8153 $response_data = [
8154 'text' => $regular_response,
8155 'html' => '',
8156 'session_id' => $session_id
8157 ];
8158
8159 if ($testing_data !== null) {
8160 $response_data['testing_data'] = $testing_data;
8161 //error_log("MxChat Testing: Added testing data to DeepSeek fallback response");
8162 }
8163
8164 header('Content-Type: application/json');
8165 echo json_encode($response_data);
8166 return true;
8167 }
8168
8169 // Prepare the request body with stream: true
8170 $body = json_encode([
8171 'model' => $selected_model,
8172 'messages' => $formatted_conversation,
8173 'temperature' => 0.8,
8174 'stream' => true
8175 ]);
8176
8177 // Setup streaming headers now that we know we're actually streaming
8178 $this->setup_streaming_headers();
8179
8180 // Use cURL for streaming support
8181 $ch = curl_init();
8182 curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
8183 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
8184 curl_setopt($ch, CURLOPT_POST, true);
8185 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
8186 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
8187 'Content-Type: application/json',
8188 'Authorization: Bearer ' . $deepseek_api_key
8189 ));
8190 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8191 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
8192
8193 $full_response = ''; // Accumulate full response for saving
8194 $stream_started = false;
8195 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
8196
8197 // Buffer control for real-time streaming
8198 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
8199 // Send testing data as the first event if available
8200 if (!$stream_started && $testing_data !== null) {
8201 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
8202 flush();
8203 $stream_started = true;
8204 //error_log("MxChat Testing: Sent testing data in DeepSeek stream");
8205 }
8206
8207 // CRITICAL FIX: Append new data to buffer
8208 $buffer .= $data;
8209
8210 // Process complete lines only
8211 $lines = explode("\n", $buffer);
8212
8213 // CRITICAL FIX: Keep the last incomplete line in the buffer
8214 // The last element might be incomplete, so keep it in buffer
8215 $buffer = array_pop($lines);
8216
8217 foreach ($lines as $line) {
8218 // Skip empty lines
8219 if (trim($line) === '') {
8220 continue;
8221 }
8222
8223 // Only process lines that start with "data: "
8224 if (strpos($line, 'data: ') !== 0) {
8225 continue;
8226 }
8227
8228 $json_str = substr($line, 6); // Remove 'data: ' prefix
8229
8230 if (trim($json_str) === '[DONE]') {
8231 echo "data: [DONE]\n\n";
8232 flush();
8233 continue;
8234 }
8235
8236 // Try to decode JSON
8237 $json = json_decode(trim($json_str), true);
8238 if ($json && isset($json['choices'][0]['delta']['content'])) {
8239 $content = $json['choices'][0]['delta']['content'];
8240 $full_response .= $content; // Accumulate the full response
8241
8242 // Send as SSE format
8243 echo "data: " . json_encode(['content' => $content]) . "\n\n";
8244 flush();
8245 }
8246 }
8247
8248 return strlen($data);
8249 });
8250
8251 $response = curl_exec($ch);
8252 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
8253
8254 if (curl_errno($ch) || $http_code !== 200) {
8255 $curl_error = curl_error($ch);
8256 curl_close($ch);
8257
8258 // Log the specific error for debugging
8259 //error_log("MxChat: DeepSeek streaming failed - HTTP: $http_code, cURL: $curl_error");
8260
8261 // Fallback to regular response
8262 $regular_response = $this->mxchat_generate_response_deepseek(
8263 $selected_model,
8264 $deepseek_api_key,
8265 $conversation_history,
8266 $relevant_content
8267 );
8268
8269 // Handle error response from regular function
8270 if (is_array($regular_response) && isset($regular_response['error'])) {
8271 if ($testing_data !== null) {
8272 $regular_response['testing_data'] = $testing_data;
8273 }
8274 header('Content-Type: application/json');
8275 echo json_encode($regular_response);
8276 return true;
8277 }
8278
8279 $response_data = [
8280 'text' => $regular_response,
8281 'html' => '',
8282 'session_id' => $session_id
8283 ];
8284
8285 if ($testing_data !== null) {
8286 $response_data['testing_data'] = $testing_data;
8287 //error_log("MxChat Testing: Added testing data to DeepSeek error fallback");
8288 }
8289
8290 header('Content-Type: application/json');
8291 echo json_encode($response_data);
8292 return true;
8293 }
8294
8295 curl_close($ch);
8296
8297 // Save the complete response to maintain chat persistence
8298 if (!empty($full_response) && !empty($session_id)) {
8299 // Prepare RAG context for streaming response
8300 $rag_context_for_storage = null;
8301 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
8302 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
8303
8304 if ($has_rag_data || $has_action_data) {
8305 $rag_context_for_storage = [];
8306
8307 if ($has_rag_data) {
8308 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
8309 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
8310 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
8311 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
8312 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
8313 }
8314
8315 if ($has_action_data) {
8316 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
8317 }
8318 }
8319 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
8320 }
8321
8322 return true; // Indicate streaming completed successfully
8323
8324 } catch (Exception $e) {
8325 //error_log("MxChat DeepSeek streaming exception: " . $e->getMessage());
8326
8327 // Fallback to regular response
8328 $regular_response = $this->mxchat_generate_response_deepseek(
8329 $selected_model,
8330 $deepseek_api_key,
8331 $conversation_history,
8332 $relevant_content
8333 );
8334
8335 // Handle error response from regular function
8336 if (is_array($regular_response) && isset($regular_response['error'])) {
8337 if ($testing_data !== null) {
8338 $regular_response['testing_data'] = $testing_data;
8339 }
8340 header('Content-Type: application/json');
8341 echo json_encode($regular_response);
8342 return true;
8343 }
8344
8345 $response_data = [
8346 'text' => $regular_response,
8347 'html' => '',
8348 'session_id' => $session_id
8349 ];
8350
8351 if ($testing_data !== null) {
8352 $response_data['testing_data'] = $testing_data;
8353 //error_log("MxChat Testing: Added testing data to DeepSeek exception fallback");
8354 }
8355
8356 header('Content-Type: application/json');
8357 echo json_encode($response_data);
8358 return true;
8359 }
8360 }
8361
8362
8363 private function mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content) {
8364 try {
8365 if (!is_array($conversation_history)) {
8366 $conversation_history = array();
8367 }
8368
8369 $bot_id = $this->get_current_bot_id('');
8370 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8371
8372 $formatted_conversation = array();
8373
8374 $formatted_conversation[] = array(
8375 'role' => 'system',
8376 'content' => $system_prompt_instructions . " " . $relevant_content
8377 );
8378
8379 foreach ($conversation_history as $message) {
8380 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8381 $role = $message['role'];
8382
8383 if ($role === 'bot' || $role === 'agent') {
8384 $role = 'assistant';
8385 }
8386 if (!in_array($role, ['system', 'assistant', 'user'])) {
8387 $role = 'user';
8388 }
8389
8390 $formatted_conversation[] = array(
8391 'role' => $role,
8392 'content' => $message['content']
8393 );
8394 }
8395 }
8396
8397 $body = json_encode([
8398 'model' => $selected_model,
8399 'messages' => $formatted_conversation,
8400 'temperature' => 1,
8401 ]);
8402
8403 $args = [
8404 'body' => $body,
8405 'headers' => [
8406 'Content-Type' => 'application/json',
8407 'Authorization' => 'Bearer ' . $openrouter_api_key,
8408 'HTTP-Referer' => home_url(),
8409 'X-Title' => get_bloginfo('name'),
8410 ],
8411 'timeout' => 60,
8412 'redirection' => 5,
8413 'blocking' => true,
8414 'httpversion' => '1.0',
8415 'sslverify' => true,
8416 ];
8417
8418 $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', $args);
8419
8420 if (is_wp_error($response)) {
8421 $error_message = $response->get_error_message();
8422 return [
8423 'error' => esc_html__('Connection error when contacting OpenRouter: ', 'mxchat') . esc_html($error_message),
8424 'error_code' => 'openrouter_connection_error',
8425 'provider' => 'openrouter'
8426 ];
8427 }
8428
8429 $status_code = wp_remote_retrieve_response_code($response);
8430 if ($status_code !== 200) {
8431 $response_body = wp_remote_retrieve_body($response);
8432 $decoded_response = json_decode($response_body, true);
8433
8434 $error_message = isset($decoded_response['error']['message'])
8435 ? $decoded_response['error']['message']
8436 : 'HTTP Error ' . $status_code;
8437
8438 return [
8439 'error' => esc_html__('OpenRouter API error: ', 'mxchat') . esc_html($error_message),
8440 'error_code' => 'openrouter_api_error',
8441 'provider' => 'openrouter',
8442 'status_code' => $status_code
8443 ];
8444 }
8445
8446 $response_body = wp_remote_retrieve_body($response);
8447 $decoded_response = json_decode($response_body, true);
8448
8449 if (isset($decoded_response['choices'][0]['message']['content'])) {
8450 return trim($decoded_response['choices'][0]['message']['content']);
8451 } else {
8452 return [
8453 'error' => esc_html__('Unexpected response format from OpenRouter.', 'mxchat'),
8454 'error_code' => 'openrouter_response_format_error',
8455 'provider' => 'openrouter'
8456 ];
8457 }
8458 } catch (Exception $e) {
8459 return [
8460 'error' => esc_html__('System error when processing OpenRouter request: ', 'mxchat') . esc_html($e->getMessage()),
8461 'error_code' => 'openrouter_exception',
8462 'provider' => 'openrouter'
8463 ];
8464 }
8465 }
8466 private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content) {
8467
8468 // Get bot ID from session or request
8469 $bot_id = $this->get_current_bot_id($session_id);
8470
8471 // Get system prompt instructions using centralized function
8472 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8473
8474 // Clean and validate conversation history
8475 foreach ($conversation_history as &$message) {
8476 // Convert bot and agent roles to assistant
8477 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
8478 $message['role'] = 'assistant';
8479 }
8480
8481 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
8482 if (!in_array($message['role'], ['assistant', 'user'])) {
8483 $message['role'] = 'user';
8484 }
8485
8486 // Ensure content field exists
8487 if (!isset($message['content']) || empty($message['content'])) {
8488 $message['content'] = '';
8489 }
8490
8491 // Remove any unsupported fields
8492 $message = array_intersect_key($message, array_flip(['role', 'content']));
8493 }
8494
8495 // Add relevant content as the latest user message
8496 $conversation_history[] = [
8497 'role' => 'user',
8498 'content' => $relevant_content
8499 ];
8500
8501 // Build request body
8502 $body = json_encode([
8503 'model' => $selected_model,
8504 'max_tokens' => 1000,
8505 'temperature' => 0.8,
8506 'messages' => $conversation_history,
8507 'system' => $system_prompt_instructions
8508 ]);
8509
8510 // Set up API request
8511 $args = [
8512 'body' => $body,
8513 'headers' => [
8514 'Content-Type' => 'application/json',
8515 'x-api-key' => $claude_api_key,
8516 'anthropic-version' => '2023-06-01'
8517 ],
8518 'timeout' => 60,
8519 'redirection' => 5,
8520 'blocking' => true,
8521 'httpversion' => '1.0',
8522 'sslverify' => true,
8523 ];
8524
8525 // Make API request
8526 $response = wp_remote_post('https://api.anthropic.com/v1/messages', $args);
8527
8528 // Check for WordPress errors
8529 if (is_wp_error($response)) {
8530 //error_log("Claude API request error: " . $response->get_error_message());
8531 return "Sorry, there was an error connecting to the API.";
8532 }
8533
8534 // Check HTTP response code
8535 $http_code = wp_remote_retrieve_response_code($response);
8536 if ($http_code !== 200) {
8537 $error_body = wp_remote_retrieve_body($response);
8538 //error_log("Claude API HTTP error: " . $http_code . " - " . $error_body);
8539
8540 // Try to extract error message from response
8541 $error_data = json_decode($error_body, true);
8542 $error_message = isset($error_data['error']['message']) ?
8543 $error_data['error']['message'] :
8544 "HTTP error " . $http_code;
8545
8546 return "Sorry, the API returned an error: " . $error_message;
8547 }
8548
8549 // Parse response
8550 $response_body = json_decode(wp_remote_retrieve_body($response), true);
8551
8552 // Check for JSON decode errors
8553 if (json_last_error() !== JSON_ERROR_NONE) {
8554 //error_log("Claude API JSON decode error: " . json_last_error_msg());
8555 return "Sorry, there was an error processing the API response.";
8556 }
8557
8558 // Extract and validate response content
8559 if (isset($response_body['content']) &&
8560 is_array($response_body['content']) &&
8561 !empty($response_body['content']) &&
8562 isset($response_body['content'][0]['text'])) {
8563 return trim($response_body['content'][0]['text']);
8564 }
8565
8566 // Log unexpected response format
8567 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
8568 return "Sorry, I received an unexpected response format from the API.";
8569 }
8570 private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content) {
8571 try {
8572 // Ensure conversation_history is an array
8573 if (!is_array($conversation_history)) {
8574 $conversation_history = array();
8575 }
8576
8577 // Get bot ID from session or request
8578 $bot_id = $this->get_current_bot_id('');
8579
8580 // Get system prompt instructions using centralized function
8581 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8582
8583 // Create a new array for the formatted conversation
8584 $formatted_conversation = array();
8585
8586 // Add system message first
8587 $formatted_conversation[] = array(
8588 'role' => 'system',
8589 'content' => $system_prompt_instructions . " " . $relevant_content
8590 );
8591
8592 // Add the rest of the conversation history
8593 foreach ($conversation_history as $message) {
8594 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8595 $role = $message['role'];
8596
8597 // Convert roles to supported format
8598 if ($role === 'bot' || $role === 'agent') {
8599 $role = 'assistant';
8600 }
8601 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8602 $role = 'user';
8603 }
8604
8605 $formatted_conversation[] = array(
8606 'role' => $role,
8607 'content' => $message['content']
8608 );
8609 }
8610 }
8611
8612 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
8613 $is_gpt5_model = (
8614 strpos($selected_model, 'gpt-5') === 0 ||
8615 $selected_model === 'gpt-5.2' ||
8616 $selected_model === 'gpt-5.1-2025-11-13' ||
8617 $selected_model === 'gpt-5' ||
8618 $selected_model === 'gpt-5-mini' ||
8619 $selected_model === 'gpt-5-nano'
8620 );
8621
8622 // Build request body with optimal settings for fast responses
8623 $request_body = [
8624 'model' => $selected_model,
8625 'messages' => $formatted_conversation,
8626 'temperature' => 1,
8627 'stream' => false
8628 ];
8629
8630 // Add reasoning_effort only for GPT-5 models that support it
8631 // These chat models don't support reasoning_effort parameter
8632 $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');
8633 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_models, true)) {
8634 // GPT-5.1 uses 'low' instead of 'minimal'
8635 if ($selected_model === 'gpt-5.1-2025-11-13') {
8636 $request_body['reasoning_effort'] = 'low';
8637 } elseif ($selected_model === 'gpt-5.4') {
8638 $request_body['reasoning_effort'] = 'none';
8639 } else {
8640 $request_body['reasoning_effort'] = 'minimal';
8641 }
8642 }
8643
8644 $body = json_encode($request_body);
8645
8646 $args = [
8647 'body' => $body,
8648 'headers' => [
8649 'Content-Type' => 'application/json',
8650 'Authorization' => 'Bearer ' . $api_key,
8651 ],
8652 'timeout' => 60,
8653 'redirection' => 5,
8654 'blocking' => true,
8655 'httpversion' => '1.0',
8656 'sslverify' => true,
8657 ];
8658
8659 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', $args);
8660
8661 if (is_wp_error($response)) {
8662 $error_message = $response->get_error_message();
8663 return [
8664 'error' => esc_html__('Connection error when contacting OpenAI: ', 'mxchat') . esc_html($error_message),
8665 'error_code' => 'openai_connection_error',
8666 'provider' => 'openai'
8667 ];
8668 }
8669
8670 $status_code = wp_remote_retrieve_response_code($response);
8671 if ($status_code !== 200) {
8672 $response_body = wp_remote_retrieve_body($response);
8673 $decoded_response = json_decode($response_body, true);
8674
8675 $error_message = isset($decoded_response['error']['message'])
8676 ? $decoded_response['error']['message']
8677 : 'HTTP Error ' . $status_code;
8678
8679 $error_type = isset($decoded_response['error']['type'])
8680 ? $decoded_response['error']['type']
8681 : 'unknown';
8682
8683 // Handle specific error types
8684 switch ($error_type) {
8685 case 'invalid_request_error':
8686 if (strpos($error_message, 'API key') !== false) {
8687 return [
8688 'error' => esc_html__('Invalid OpenAI API key. Please check your API key configuration.', 'mxchat'),
8689 'error_code' => 'openai_invalid_api_key',
8690 'provider' => 'openai'
8691 ];
8692 }
8693 break;
8694
8695 case 'authentication_error':
8696 return [
8697 'error' => esc_html__('Authentication failed with OpenAI. Please check your API key.', 'mxchat'),
8698 'error_code' => 'openai_auth_error',
8699 'provider' => 'openai'
8700 ];
8701
8702 case 'rate_limit_exceeded':
8703 return [
8704 'error' => esc_html__('OpenAI rate limit exceeded. Please try again later.', 'mxchat'),
8705 'error_code' => 'openai_rate_limit',
8706 'provider' => 'openai'
8707 ];
8708
8709 case 'quota_exceeded':
8710 return [
8711 'error' => esc_html__('OpenAI API quota exceeded. Please check your billing details.', 'mxchat'),
8712 'error_code' => 'openai_quota_exceeded',
8713 'provider' => 'openai'
8714 ];
8715 }
8716
8717 // Generic error fallback
8718 return [
8719 'error' => esc_html__('OpenAI API error: ', 'mxchat') . esc_html($error_message),
8720 'error_code' => 'openai_api_error',
8721 'provider' => 'openai',
8722 'status_code' => $status_code
8723 ];
8724 }
8725
8726 $response_body = wp_remote_retrieve_body($response);
8727 $decoded_response = json_decode($response_body, true);
8728
8729 if (isset($decoded_response['choices'][0]['message']['content'])) {
8730 return trim($decoded_response['choices'][0]['message']['content']);
8731 } else {
8732 return [
8733 'error' => esc_html__('Unexpected response format from OpenAI.', 'mxchat'),
8734 'error_code' => 'openai_response_format_error',
8735 'provider' => 'openai'
8736 ];
8737 }
8738 } catch (Exception $e) {
8739 return [
8740 'error' => esc_html__('System error when processing OpenAI request: ', 'mxchat') . esc_html($e->getMessage()),
8741 'error_code' => 'openai_exception',
8742 'provider' => 'openai'
8743 ];
8744 }
8745 }
8746
8747 private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content) {
8748 try {
8749 // Get bot ID from session or request
8750 $bot_id = $this->get_current_bot_id($session_id);
8751
8752 // Get system prompt instructions using centralized function
8753 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8754
8755 // Add system prompt to relevant content
8756 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
8757
8758 // Prepend system instructions to the conversation history
8759 array_unshift($conversation_history, [
8760 'role' => 'system',
8761 'content' => "Here are your instructions: " . $content_with_instructions
8762 ]);
8763
8764 // Ensure consistency: Replace 'bot' and 'agent' roles with supported values
8765 foreach ($conversation_history as &$message) {
8766 if ($message['role'] === 'bot') {
8767 $message['role'] = 'assistant';
8768 } elseif ($message['role'] === 'agent') {
8769 // Tag the message as coming from a live agent
8770 $message['role'] = 'assistant';
8771 if (!isset($message['metadata'])) {
8772 $message['metadata'] = ['source' => 'live_agent'];
8773 }
8774 }
8775
8776 // Ensure all roles are valid
8777 if (!in_array($message['role'], ['system', 'assistant', 'user', 'function', 'tool'])) {
8778 $message['role'] = 'user'; // Default to 'user'
8779 }
8780 }
8781
8782 // Build the request body
8783 $body = json_encode([
8784 'model' => $selected_model,
8785 'messages' => $conversation_history,
8786 'temperature' => 0.8,
8787 'stream' => false
8788 ]);
8789
8790 // Set up the API request
8791 $args = [
8792 'body' => $body,
8793 'headers' => [
8794 'Content-Type' => 'application/json',
8795 'Authorization' => 'Bearer ' . $xai_api_key,
8796 ],
8797 'timeout' => 60,
8798 'redirection' => 5,
8799 'blocking' => true,
8800 'httpversion' => '1.0',
8801 'sslverify' => true,
8802 ];
8803
8804 // Make the API request
8805 $response = wp_remote_post('https://api.x.ai/v1/chat/completions', $args);
8806
8807 // Process the response
8808 if (is_wp_error($response)) {
8809 $error_message = $response->get_error_message();
8810 //error_log('X.AI API Error: ' . $error_message);
8811 return [
8812 'error' => esc_html__('Connection error when contacting X.AI: ', 'mxchat') . esc_html($error_message),
8813 'error_code' => 'xai_connection_error',
8814 'provider' => 'xai'
8815 ];
8816 }
8817
8818 $status_code = wp_remote_retrieve_response_code($response);
8819 if ($status_code !== 200) {
8820 $response_body = wp_remote_retrieve_body($response);
8821 $decoded_response = json_decode($response_body, true);
8822
8823 // Log the full response for debugging
8824 //error_log('X.AI Error Response: ' . print_r($decoded_response, true));
8825
8826 // Extract error message from X.AI's specific format
8827 $error_message = '';
8828
8829 // Check for direct error string (as seen in your logs)
8830 if (isset($decoded_response['error']) && is_string($decoded_response['error'])) {
8831 $error_message = $decoded_response['error'];
8832 }
8833 // Check for nested error object (OpenAI style)
8834 elseif (isset($decoded_response['error']['message'])) {
8835 $error_message = $decoded_response['error']['message'];
8836 }
8837 // Check for top-level message
8838 elseif (isset($decoded_response['message'])) {
8839 $error_message = $decoded_response['message'];
8840 }
8841 // Fallback
8842 else {
8843 $error_message = 'HTTP Error ' . $status_code;
8844 }
8845
8846 //error_log('X.AI API HTTP Error: ' . $status_code . ' - ' . $error_message);
8847
8848 // Check for API key errors using string matching
8849 if (stripos($error_message, 'api key') !== false ||
8850 stripos($error_message, 'incorrect api key') !== false ||
8851 stripos($error_message, 'invalid api key') !== false) {
8852 return [
8853 'error' => esc_html__('Invalid X.AI API key. Please check your API key configuration.', 'mxchat'),
8854 'error_code' => 'xai_invalid_api_key',
8855 'provider' => 'xai'
8856 ];
8857 }
8858
8859 // Authentication errors
8860 if ($status_code === 401 || $status_code === 403 ||
8861 stripos($error_message, 'auth') !== false) {
8862 return [
8863 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat'),
8864 'error_code' => 'xai_auth_error',
8865 'provider' => 'xai'
8866 ];
8867 }
8868
8869 // Model errors
8870 if (stripos($error_message, 'model') !== false) {
8871 return [
8872 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat'),
8873 'error_code' => 'xai_invalid_model',
8874 'provider' => 'xai'
8875 ];
8876 }
8877
8878 // Rate limit errors
8879 if ($status_code === 429 ||
8880 stripos($error_message, 'rate') !== false ||
8881 stripos($error_message, 'limit') !== false) {
8882 return [
8883 'error' => esc_html__('X.AI rate limit exceeded. Please try again later.', 'mxchat'),
8884 'error_code' => 'xai_rate_limit',
8885 'provider' => 'xai'
8886 ];
8887 }
8888
8889 // Quota errors
8890 if (stripos($error_message, 'quota') !== false ||
8891 stripos($error_message, 'billing') !== false) {
8892 return [
8893 'error' => esc_html__('X.AI API quota exceeded. Please check your billing details.', 'mxchat'),
8894 'error_code' => 'xai_quota_exceeded',
8895 'provider' => 'xai'
8896 ];
8897 }
8898
8899 // Server errors
8900 if ($status_code >= 500) {
8901 return [
8902 'error' => esc_html__('X.AI service is currently unavailable. Please try again later.', 'mxchat'),
8903 'error_code' => 'xai_service_unavailable',
8904 'provider' => 'xai'
8905 ];
8906 }
8907
8908 // Generic error fallback with the actual error message
8909 return [
8910 'error' => esc_html__('X.AI API error: ', 'mxchat') . esc_html($error_message),
8911 'error_code' => 'xai_api_error',
8912 'provider' => 'xai',
8913 'status_code' => $status_code
8914 ];
8915 }
8916
8917 $response_body = wp_remote_retrieve_body($response);
8918 $decoded_response = json_decode($response_body, true);
8919
8920 if (isset($decoded_response['choices'][0]['message']['content'])) {
8921 return trim($decoded_response['choices'][0]['message']['content']);
8922 } else {
8923 //error_log('X.AI API Response Format Error: ' . print_r($decoded_response, true));
8924 return [
8925 'error' => esc_html__('Unexpected response format from X.AI.', 'mxchat'),
8926 'error_code' => 'xai_response_format_error',
8927 'provider' => 'xai'
8928 ];
8929 }
8930 } catch (Exception $e) {
8931 //error_log('X.AI Exception: ' . $e->getMessage());
8932 return [
8933 'error' => esc_html__('System error when processing X.AI request: ', 'mxchat') . esc_html($e->getMessage()),
8934 'error_code' => 'xai_exception',
8935 'provider' => 'xai'
8936 ];
8937 }
8938
8939
8940 }
8941 private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content) {
8942 try {
8943 // Ensure conversation_history is an array
8944 if (!is_array($conversation_history)) {
8945 $conversation_history = array();
8946 }
8947
8948 // Get bot ID from session or request
8949 $bot_id = $this->get_current_bot_id($session_id);
8950
8951 // Get system prompt instructions using centralized function
8952 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8953
8954 // Create a new array for the formatted conversation
8955 $formatted_conversation = array();
8956
8957 // Add system message first
8958 $formatted_conversation[] = array(
8959 'role' => 'system',
8960 'content' => $system_prompt_instructions . " " . $relevant_content
8961 );
8962
8963 // Add the rest of the conversation history
8964 foreach ($conversation_history as $message) {
8965 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8966 $role = $message['role'];
8967
8968 // Convert roles to supported format
8969 if ($role === 'bot' || $role === 'agent') {
8970 $role = 'assistant';
8971 }
8972 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8973 $role = 'user';
8974 }
8975
8976 $formatted_conversation[] = array(
8977 'role' => $role,
8978 'content' => $message['content']
8979 );
8980 }
8981 }
8982
8983 $body = json_encode([
8984 'model' => $selected_model,
8985 'messages' => $formatted_conversation,
8986 'temperature' => 0.8,
8987 'stream' => false
8988 ]);
8989
8990 $args = [
8991 'body' => $body,
8992 'headers' => [
8993 'Content-Type' => 'application/json',
8994 'Authorization' => 'Bearer ' . $deepseek_api_key,
8995 ],
8996 'timeout' => 60,
8997 'redirection' => 5,
8998 'blocking' => true,
8999 'httpversion' => '1.0',
9000 'sslverify' => true,
9001 ];
9002
9003 $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', $args);
9004
9005 if (is_wp_error($response)) {
9006 $error_message = $response->get_error_message();
9007 //error_log('DeepSeek API Error: ' . $error_message);
9008 return [
9009 'error' => esc_html__('Connection error when contacting DeepSeek: ', 'mxchat') . esc_html($error_message),
9010 'error_code' => 'deepseek_connection_error',
9011 'provider' => 'deepseek'
9012 ];
9013 }
9014
9015 $status_code = wp_remote_retrieve_response_code($response);
9016 if ($status_code !== 200) {
9017 $response_body = wp_remote_retrieve_body($response);
9018 $decoded_response = json_decode($response_body, true);
9019
9020 $error_message = isset($decoded_response['error']['message'])
9021 ? $decoded_response['error']['message']
9022 : 'HTTP Error ' . $status_code;
9023
9024 $error_type = isset($decoded_response['error']['type'])
9025 ? $decoded_response['error']['type']
9026 : 'unknown';
9027
9028 //error_log('DeepSeek API HTTP Error: ' . $status_code . ' - ' . $error_message);
9029
9030 // Handle specific error types
9031 switch ($status_code) {
9032 case 401:
9033 return [
9034 'error' => esc_html__('Authentication failed with DeepSeek. Please check your API key.', 'mxchat'),
9035 'error_code' => 'deepseek_auth_error',
9036 'provider' => 'deepseek'
9037 ];
9038
9039 case 400:
9040 if (strpos($error_message, 'API key') !== false) {
9041 return [
9042 'error' => esc_html__('Invalid DeepSeek API key. Please check your API key configuration.', 'mxchat'),
9043 'error_code' => 'deepseek_invalid_api_key',
9044 'provider' => 'deepseek'
9045 ];
9046 }
9047 break;
9048
9049 case 429:
9050 if (strpos($error_message, 'quota') !== false) {
9051 return [
9052 'error' => esc_html__('DeepSeek API quota exceeded. Please check your billing details.', 'mxchat'),
9053 'error_code' => 'deepseek_quota_exceeded',
9054 'provider' => 'deepseek'
9055 ];
9056 } else {
9057 return [
9058 'error' => esc_html__('DeepSeek rate limit exceeded. Please try again later.', 'mxchat'),
9059 'error_code' => 'deepseek_rate_limit',
9060 'provider' => 'deepseek'
9061 ];
9062 }
9063
9064 case 500:
9065 case 502:
9066 case 503:
9067 case 504:
9068 return [
9069 'error' => esc_html__('DeepSeek service is currently unavailable. Please try again later.', 'mxchat'),
9070 'error_code' => 'deepseek_service_unavailable',
9071 'provider' => 'deepseek'
9072 ];
9073 }
9074
9075 // Generic error fallback
9076 return [
9077 'error' => esc_html__('DeepSeek API error: ', 'mxchat') . esc_html($error_message),
9078 'error_code' => 'deepseek_api_error',
9079 'provider' => 'deepseek',
9080 'status_code' => $status_code
9081 ];
9082 }
9083
9084 $response_body = wp_remote_retrieve_body($response);
9085 $decoded_response = json_decode($response_body, true);
9086
9087 if (isset($decoded_response['choices'][0]['message']['content'])) {
9088 return trim($decoded_response['choices'][0]['message']['content']);
9089 } else {
9090 //error_log('DeepSeek API Response Format Error: ' . print_r($decoded_response, true));
9091 return [
9092 'error' => esc_html__('Unexpected response format from DeepSeek.', 'mxchat'),
9093 'error_code' => 'deepseek_response_format_error',
9094 'provider' => 'deepseek'
9095 ];
9096 }
9097 } catch (Exception $e) {
9098 //error_log('DeepSeek Exception: ' . $e->getMessage());
9099 return [
9100 'error' => esc_html__('System error when processing DeepSeek request: ', 'mxchat') . esc_html($e->getMessage()),
9101 'error_code' => 'deepseek_exception',
9102 'provider' => 'deepseek'
9103 ];
9104 }
9105 }
9106 private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content) {
9107 // Get bot ID from session or request
9108 $bot_id = $this->get_current_bot_id($session_id);
9109
9110 // Get system prompt instructions using centralized function
9111 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9112
9113 // Add system prompt to relevant content
9114 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
9115
9116 // Format messages for Gemini API
9117 $formatted_messages = [];
9118
9119 // Add system message as the first user message with role prefix
9120 // Note: Gemini doesn't have a dedicated system role, so we use a prefixed user message
9121 $formatted_messages[] = [
9122 'role' => 'user',
9123 'parts' => [
9124 ['text' => "[System Instructions] " . $content_with_instructions]
9125 ]
9126 ];
9127
9128 // Add model response to acknowledge system instructions
9129 $formatted_messages[] = [
9130 'role' => 'model',
9131 'parts' => [
9132 ['text' => "I understand and will follow these instructions."]
9133 ]
9134 ];
9135
9136 // Process the rest of the conversation history
9137 $current_role = null;
9138 $current_parts = [];
9139
9140 foreach ($conversation_history as $message) {
9141 // Skip the first system message as we already handled it
9142 if ($message['role'] === 'system') {
9143 continue;
9144 }
9145
9146 // Map roles to Gemini format
9147 $gemini_role = '';
9148 if ($message['role'] === 'user') {
9149 $gemini_role = 'user';
9150 } else if (in_array($message['role'], ['assistant', 'bot', 'agent'])) {
9151 $gemini_role = 'model';
9152 } else {
9153 // Skip unsupported roles
9154 continue;
9155 }
9156
9157 // If we have a new role, add the previous message
9158 if ($current_role !== null && $current_role !== $gemini_role && !empty($current_parts)) {
9159 $formatted_messages[] = [
9160 'role' => $current_role,
9161 'parts' => $current_parts
9162 ];
9163 $current_parts = [];
9164 }
9165
9166 // Set current role and add text to parts
9167 $current_role = $gemini_role;
9168 $current_parts[] = ['text' => $message['content']];
9169 }
9170
9171 // Add the last message if there's content
9172 if ($current_role !== null && !empty($current_parts)) {
9173 $formatted_messages[] = [
9174 'role' => $current_role,
9175 'parts' => $current_parts
9176 ];
9177 }
9178
9179 // Build the request body
9180 $body = json_encode([
9181 'contents' => $formatted_messages,
9182 'generationConfig' => [
9183 'temperature' => 0.7,
9184 'topP' => 0.95,
9185 'topK' => 40,
9186 'maxOutputTokens' => 8192,
9187 ],
9188 'safetySettings' => [
9189 [
9190 'category' => 'HARM_CATEGORY_HARASSMENT',
9191 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9192 ],
9193 [
9194 'category' => 'HARM_CATEGORY_HATE_SPEECH',
9195 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9196 ],
9197 [
9198 'category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
9199 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9200 ],
9201 [
9202 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT',
9203 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9204 ]
9205 ]
9206 ]);
9207
9208 // Prepare the API endpoint
9209 // Use v1beta for preview models (Gemini 3, experimental), v1 for stable models
9210 $api_version = (strpos($selected_model, 'preview') !== false || strpos($selected_model, 'exp') !== false) ? 'v1beta' : 'v1';
9211 $api_endpoint = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
9212
9213 // Set up the API request
9214 $args = [
9215 'body' => $body,
9216 'headers' => [
9217 'Content-Type' => 'application/json',
9218 ],
9219 'timeout' => 60,
9220 'redirection' => 5,
9221 'blocking' => true,
9222 'httpversion' => '1.0',
9223 'sslverify' => true,
9224 ];
9225
9226 // Make the API request
9227 $response = wp_remote_post($api_endpoint, $args);
9228
9229 // Process the response
9230 if (is_wp_error($response)) {
9231 return "Sorry, there was an error processing your request: " . $response->get_error_message();
9232 }
9233
9234 $response_body = json_decode(wp_remote_retrieve_body($response), true);
9235
9236 // Handle potential errors in the response
9237 if (isset($response_body['error'])) {
9238 //error_log('Gemini API Error: ' . json_encode($response_body['error']));
9239 return "Sorry, there was an error with the Gemini API: " .
9240 (isset($response_body['error']['message']) ? $response_body['error']['message'] : 'Unknown error');
9241 }
9242
9243 // Extract the response text
9244 if (isset($response_body['candidates'][0]['content']['parts'][0]['text'])) {
9245 return trim($response_body['candidates'][0]['content']['parts'][0]['text']);
9246 } else {
9247 //error_log('Unexpected Gemini API response format: ' . json_encode($response_body));
9248 return "Sorry, I couldn't process that request. The response format was unexpected.";
9249 }
9250 }
9251
9252
9253 public function test_streaming_request() {
9254 $options = get_option('mxchat_options', []);
9255 $model = $options['model'] ?? 'gpt-5.1-chat-latest';
9256
9257 // Detect provider from model prefix
9258 $provider = strtolower(explode('-', $model)[0]);
9259
9260 $sample_prompt = 'Hello! Can you stream this response back to me?';
9261 $messages = [['role' => 'user', 'content' => $sample_prompt]];
9262 $headers = [];
9263 $body = [];
9264 $url = '';
9265 $api_key = '';
9266
9267 switch ($provider) {
9268 case 'gpt':
9269 case 'o1':
9270 $api_key = $options['api_key'] ?? '';
9271 if (empty($api_key)) return '❌ Missing API key for OpenAI';
9272 $url = 'https://api.openai.com/v1/chat/completions';
9273 $headers = [
9274 'Content-Type: application/json',
9275 'Authorization: Bearer ' . $api_key
9276 ];
9277 $body = [
9278 'model' => $model,
9279 'messages' => $messages,
9280 'stream' => true
9281 ];
9282 break;
9283
9284 case 'claude':
9285 $api_key = $options['claude_api_key'] ?? '';
9286 if (empty($api_key)) return '❌ Missing API key for Claude';
9287 $url = 'https://api.anthropic.com/v1/messages';
9288 $headers = [
9289 'Content-Type: application/json',
9290 'x-api-key: ' . $api_key,
9291 'anthropic-version: 2023-06-01'
9292 ];
9293 $body = [
9294 'model' => $model,
9295 'messages' => $messages,
9296 'max_tokens' => 100,
9297 'stream' => true
9298 ];
9299 break;
9300
9301 case 'grok':
9302 $api_key = $options['xai_api_key'] ?? '';
9303 if (empty($api_key)) return '❌ Missing API key for X.AI';
9304 $url = 'https://api.x.ai/v1/chat/completions';
9305 $headers = [
9306 'Content-Type: application/json',
9307 'Authorization: Bearer ' . $api_key
9308 ];
9309 $body = [
9310 'model' => $model,
9311 'messages' => $messages,
9312 'stream' => true
9313 ];
9314 break;
9315
9316 case 'deepseek':
9317 if (empty($deepseek_api_key)) {
9318 $error_response = [
9319 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
9320 'error_code' => 'missing_deepseek_api_key'
9321 ];
9322 if ($testing_data !== null) {
9323 $error_response['testing_data'] = $testing_data;
9324 }
9325 return $error_response;
9326 }
9327 if ($streaming) {
9328 return $this->mxchat_generate_response_deepseek_stream(
9329 $selected_model,
9330 $deepseek_api_key,
9331 $conversation_history,
9332 $relevant_content,
9333 $session_id,
9334 $testing_data // Pass testing data
9335 );
9336 } else {
9337 $response = $this->mxchat_generate_response_deepseek(
9338 $selected_model,
9339 $deepseek_api_key,
9340 $conversation_history,
9341 $relevant_content
9342 );
9343 }
9344 break;
9345
9346 case 'gemini':
9347 $api_key = $options['gemini_api_key'] ?? '';
9348 if (empty($api_key)) return '❌ Missing API key for Gemini';
9349 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':streamGenerateContent?key=' . $api_key;
9350 $headers = ['Content-Type: application/json'];
9351 $body = [
9352 'contents' => [['role' => 'user', 'parts' => [['text' => $sample_prompt]]]],
9353 'generationConfig' => ['temperature' => 0.7]
9354 ];
9355 break;
9356
9357 default:
9358 return '❌ Unsupported provider: ' . $provider;
9359 }
9360
9361 // Do the actual streaming test
9362 $ch = curl_init($url);
9363 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
9364 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
9365 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
9366 curl_setopt($ch, CURLOPT_TIMEOUT, 15);
9367 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9368
9369 $response = curl_exec($ch);
9370 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
9371 $error = curl_error($ch);
9372 curl_close($ch);
9373
9374 if ($error) return "❌ cURL error: $error";
9375 if ($http_code !== 200) {
9376 $error_message = json_decode($response, true)['error']['message'] ?? 'Unknown';
9377 return "❌ HTTP $http_code: $error_message";
9378 }
9379
9380 return true;
9381 }
9382
9383 public function mxchat_dismiss_pre_chat_message() {
9384 // Get and sanitize the user identifier
9385 $user_id = $this->mxchat_get_user_identifier();
9386 $user_id = sanitize_key($user_id);
9387
9388 // Set a transient to track that the user has dismissed the pre-chat message
9389 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9390 set_transient($transient_key, true, DAY_IN_SECONDS);
9391
9392 wp_send_json_success();
9393 }
9394
9395 public function mxchat_check_pre_chat_message_status() {
9396 // Get and sanitize the user identifier
9397 $user_id = $this->mxchat_get_user_identifier();
9398 $user_id = sanitize_key($user_id);
9399
9400 // Check if the transient exists (i.e., if the message was dismissed)
9401 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9402 $dismissed = get_transient($transient_key);
9403
9404 // Log the result to see if it's being set correctly
9405 //error_log("Check pre-chat message dismissed for $user_id: " . ($dismissed ? 'Yes' : 'No'));
9406
9407 if ($dismissed) {
9408 wp_send_json_success(['dismissed' => true]);
9409 } else {
9410 wp_send_json_success(['dismissed' => false]);
9411 }
9412
9413 wp_die();
9414 }
9415
9416 private function mxchat_calculate_cosine_similarity($vectorA, $vectorB) {
9417 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
9418 return 0;
9419 }
9420
9421 $dotProduct = array_sum(array_map(function ($a, $b) {
9422 return $a * $b;
9423 }, $vectorA, $vectorB));
9424 $normA = sqrt(array_sum(array_map(function ($a) {
9425 return $a * $a;
9426 }, $vectorA)));
9427 $normB = sqrt(array_sum(array_map(function ($b) {
9428 return $b * $b;
9429 }, $vectorB)));
9430
9431 if ($normA == 0 || $normB == 0) {
9432 return 0;
9433 }
9434
9435 return $dotProduct / ($normA * $normB);
9436 }
9437
9438
9439 public function mxchat_enqueue_scripts_styles() {
9440 // Fetch options from the database first to check loading strategy
9441 $this->options = get_option('mxchat_options');
9442 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
9443
9444 // Always enqueue CSS immediately
9445 wp_enqueue_style(
9446 'mxchat-chat-css',
9447 plugin_dir_url(__FILE__) . '../css/chat-style.css',
9448 array(),
9449 MXCHAT_VERSION
9450 );
9451
9452 // Handle script loading based on strategy
9453 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
9454 // Enqueue the script normally
9455 wp_enqueue_script(
9456 'mxchat-chat-js',
9457 plugin_dir_url(__FILE__) . '../js/chat-script.js',
9458 array('jquery'),
9459 MXCHAT_VERSION,
9460 true
9461 );
9462
9463 // Add defer attribute if strategy is 'defer'
9464 if ($loading_strategy === 'defer') {
9465 wp_script_add_data('mxchat-chat-js', 'strategy', 'defer');
9466 }
9467 } else {
9468 // For delay or interaction-based loading, we'll use a custom loader
9469 // Don't enqueue the main script - we'll load it dynamically
9470 add_action('wp_footer', array($this, 'mxchat_output_delayed_script_loader'), 99);
9471 }
9472
9473 $prompts_options = get_option('mxchat_prompts_options', array());
9474
9475 // Check if AI theme is active - if so, skip inline colors in JavaScript
9476 $theme_options = get_option('mxchat_theme_options', array());
9477 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9478 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9479 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9480
9481 // Prepare settings for JavaScript
9482 $style_settings = array(
9483 'ajax_url' => admin_url('admin-ajax.php'),
9484 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9485 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
9486 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
9487 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
9488 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
9489 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9490 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
9491 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
9492 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
9493 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
9494 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
9495 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
9496 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
9497 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
9498 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
9499 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
9500 'icon_color' => $this->options['icon_color'] ?? '#fff',
9501 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
9502 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
9503 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
9504 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
9505 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
9506 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
9507 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
9508 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
9509 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
9510 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9511 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off', // FIXED
9512 'initial_email_state' => null, // Also fixed this undefined variable
9513 'skip_email_check' => true,
9514 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9515 'skip_inline_colors' => $skip_inline_colors,
9516 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9517 );
9518
9519 // For normal/defer loading, use wp_localize_script
9520 // For delayed loading, we store settings in a transient to be output inline
9521 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
9522 wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
9523 } else {
9524 // Store settings for the delayed loader to use
9525 set_transient('mxchat_delayed_settings_' . get_current_user_id(), $style_settings, 60);
9526 }
9527 }
9528
9529 /**
9530 * Output the delayed script loader for performance optimization
9531 */
9532 public function mxchat_output_delayed_script_loader() {
9533 $this->options = get_option('mxchat_options');
9534 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
9535 $script_url = plugin_dir_url(__FILE__) . '../js/chat-script.js?ver=' . MXCHAT_VERSION;
9536
9537 // Get the stored settings
9538 $prompts_options = get_option('mxchat_prompts_options', array());
9539 $theme_options = get_option('mxchat_theme_options', array());
9540 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9541 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9542 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9543
9544 $style_settings = array(
9545 'ajax_url' => admin_url('admin-ajax.php'),
9546 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9547 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
9548 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
9549 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
9550 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
9551 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9552 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
9553 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
9554 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
9555 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
9556 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
9557 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
9558 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
9559 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
9560 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
9561 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
9562 'icon_color' => $this->options['icon_color'] ?? '#fff',
9563 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
9564 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
9565 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
9566 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
9567 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
9568 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
9569 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
9570 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
9571 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
9572 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9573 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off',
9574 'initial_email_state' => null,
9575 'skip_email_check' => true,
9576 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9577 'skip_inline_colors' => $skip_inline_colors,
9578 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9579 );
9580
9581 // Determine delay time based on strategy
9582 $delay_ms = 0;
9583 switch ($loading_strategy) {
9584 case 'delay_1s':
9585 $delay_ms = 1000;
9586 break;
9587 case 'delay_3s':
9588 $delay_ms = 3000;
9589 break;
9590 case 'delay_5s':
9591 $delay_ms = 5000;
9592 break;
9593 }
9594
9595 ?>
9596 <script type="text/javascript">
9597 (function() {
9598 var mxchatLoaded = false;
9599 var mxchatChat = <?php echo wp_json_encode($style_settings); ?>;
9600 window.mxchatChat = mxchatChat;
9601
9602 function loadMxChatScript() {
9603 if (mxchatLoaded) return;
9604 mxchatLoaded = true;
9605
9606 function appendChatScript() {
9607 var script = document.createElement('script');
9608 script.src = <?php echo wp_json_encode($script_url); ?>;
9609 script.type = 'text/javascript';
9610 document.body.appendChild(script);
9611 }
9612
9613 if (typeof jQuery !== 'undefined') {
9614 appendChatScript();
9615 } else {
9616 var jq = document.createElement('script');
9617 jq.src = <?php echo wp_json_encode(includes_url('js/jquery/jquery.min.js')); ?>;
9618 jq.onload = appendChatScript;
9619 document.body.appendChild(jq);
9620 }
9621 }
9622
9623 <?php if ($loading_strategy === 'on_interaction'): ?>
9624 // Load on user interaction
9625 var events = ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'];
9626 events.forEach(function(evt) {
9627 window.addEventListener(evt, loadMxChatScript, {once: true, passive: true});
9628 });
9629 // Fallback: load after 8 seconds if no interaction
9630 setTimeout(loadMxChatScript, 8000);
9631 <?php else: ?>
9632 // Load after specified delay
9633 setTimeout(loadMxChatScript, <?php echo intval($delay_ms); ?>);
9634 <?php endif; ?>
9635 })();
9636 </script>
9637 <?php
9638 }
9639
9640 /**
9641 * Setup the cron jobs for rate limits with guard against multiple calls
9642 */
9643 public function setup_rate_limit_cron_jobs() {
9644 // Add a guard to prevent multiple rapid calls
9645 $last_setup = get_transient('mxchat_cron_setup_guard');
9646 if ($last_setup && (time() - $last_setup) < 60) {
9647 // Don't run again if we ran less than 60 seconds ago
9648 return;
9649 }
9650
9651 // Set the guard
9652 set_transient('mxchat_cron_setup_guard', time(), 300); // 5 minutes
9653
9654 try {
9655 // First, check if WordPress cron is disabled
9656 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
9657 //error_log('MxChat: WordPress cron is disabled (DISABLE_WP_CRON = true), using fallback system');
9658 $this->setup_fallback_rate_limit_system();
9659 return;
9660 }
9661
9662 // Check if cron is already scheduled - if so, don't mess with it
9663 if (wp_next_scheduled('mxchat_reset_rate_limits')) {
9664 //error_log('MxChat: Rate limit cron already scheduled, skipping setup');
9665 return;
9666 }
9667
9668 // Clear any orphaned hooks (but don't loop indefinitely)
9669 $hooks_to_clear = [
9670 'mxchat_reset_rate_limits',
9671 'mxchat_reset_hourly_rate_limits',
9672 'mxchat_reset_daily_rate_limits',
9673 'mxchat_reset_weekly_rate_limits',
9674 'mxchat_reset_monthly_rate_limits'
9675 ];
9676
9677 foreach ($hooks_to_clear as $hook) {
9678 // Only clear a maximum of 3 instances to prevent infinite loops
9679 $cleared = 0;
9680 while (wp_next_scheduled($hook) && $cleared < 3) {
9681 wp_clear_scheduled_hook($hook);
9682 $cleared++;
9683 }
9684 }
9685
9686 // Small delay after clearing
9687 usleep(100000); // 0.1 seconds
9688
9689 // Try to schedule the event
9690 $initial_time = time() + 300; // Start in 5 minutes
9691 $result = wp_schedule_event($initial_time, 'hourly', 'mxchat_reset_rate_limits');
9692
9693 if ($result === false) {
9694 //error_log('MxChat: Failed to schedule cron, using fallback system');
9695 $this->setup_fallback_rate_limit_system();
9696 } else {
9697 //error_log('MxChat: Successfully scheduled rate limit reset cron');
9698 }
9699
9700 } catch (Exception $e) {
9701 //error_log('MxChat: Cron setup exception: ' . $e->getMessage());
9702 $this->setup_fallback_rate_limit_system();
9703 }
9704 }
9705
9706 /**
9707 * Try alternative cron scheduling methods
9708 */
9709 private function try_alternative_cron_scheduling($initial_time) {
9710 try {
9711 // Method 1: Try with current time instead of future time
9712 $result1 = wp_schedule_event(time(), 'hourly', 'mxchat_reset_rate_limits');
9713 if ($result1 !== false) {
9714 //error_log('MxChat: Alternative method 1 (current time) succeeded');
9715 return true;
9716 }
9717
9718 // Method 2: Try with a different interval
9719 $result2 = wp_schedule_event($initial_time, 'daily', 'mxchat_reset_rate_limits');
9720 if ($result2 !== false) {
9721 //error_log('MxChat: Alternative method 2 (daily interval) succeeded');
9722 return true;
9723 }
9724
9725 // Method 3: Try wp_schedule_single_event first, then recurring
9726 $result3 = wp_schedule_single_event($initial_time, 'mxchat_reset_rate_limits');
9727 if ($result3 !== false) {
9728 //error_log('MxChat: Alternative method 3 (single event) succeeded');
9729 // Schedule the next one manually in the handler
9730 return true;
9731 }
9732
9733 return false;
9734
9735 } catch (Exception $e) {
9736 //error_log('MxChat: Alternative cron scheduling exception: ' . $e->getMessage());
9737 return false;
9738 }
9739 }
9740
9741 /**
9742 * Enhanced fallback rate limit system
9743 */
9744 private function setup_fallback_rate_limit_system() {
9745 // Set a flag to use database-based rate limit cleanup
9746 update_option('mxchat_use_fallback_rate_limits', true);
9747
9748 // Schedule a one-time check to happen on the next plugin load
9749 update_option('mxchat_next_rate_limit_check', time() + 3600);
9750
9751 // Also set up a more frequent fallback check (every 4 hours)
9752 update_option('mxchat_fallback_check_interval', 4 * 3600);
9753
9754 //error_log('MxChat: Fallback rate limit system activated');
9755 }
9756
9757 /**
9758 * Enhanced fallback check method
9759 */
9760 public function check_fallback_rate_limits() {
9761 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9762
9763 if (!$use_fallback) {
9764 return; // Regular cron is working
9765 }
9766
9767 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9768 $check_interval = get_option('mxchat_fallback_check_interval', 3600);
9769
9770 if (time() >= $next_check) {
9771 //error_log('MxChat: Running fallback rate limit cleanup');
9772 $this->mxchat_reset_rate_limits();
9773
9774 // Schedule next check
9775 update_option('mxchat_next_rate_limit_check', time() + $check_interval);
9776 }
9777 }
9778 /**
9779 * Enhanced rate limit check that includes fallback cleanup and bot-specific rate limits
9780 */
9781 public function check_rate_limit() {
9782 // Check if we need to run fallback cleanup
9783 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9784 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9785
9786 if ($use_fallback && time() >= $next_check) {
9787 $this->mxchat_reset_rate_limits();
9788 update_option('mxchat_next_rate_limit_check', time() + 3600); // Next hour
9789 }
9790
9791 // Get bot ID from current request context
9792 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
9793
9794 // Get bot-specific options (includes rate limits if overridden)
9795 $bot_options = $this->get_bot_options($bot_id);
9796 $current_options = !empty($bot_options) ? $bot_options : $this->options;
9797
9798 // Use bot-specific rate limits if available, otherwise fall back to default
9799 $rate_limits_source = isset($current_options['rate_limits']) ? $current_options['rate_limits'] : get_option('mxchat_options', [])['rate_limits'] ?? [];
9800
9801 // Determine user role or if logged out
9802 if (is_user_logged_in()) {
9803 $user = wp_get_current_user();
9804 $user_id = $user->ID;
9805
9806 // Get the user's primary role using reset() to safely get the first element
9807 $user_roles = $user->roles;
9808
9809 // Safely get the first role regardless of array key structure
9810 if (!empty($user_roles) && is_array($user_roles)) {
9811 $role = reset($user_roles); // This safely gets the first element regardless of key
9812 } else {
9813 $role = 'subscriber'; // Default to subscriber if no role found
9814 }
9815 } else {
9816 $role = 'logged_out';
9817 // Use IP address for non-logged-in users
9818 $user_id = $this->get_client_ip();
9819 }
9820
9821 // Check if rate limits are configured for this role
9822 if (!isset($rate_limits_source[$role])) {
9823 return true; // No limit set for this role
9824 }
9825
9826 $limit = $rate_limits_source[$role]['limit'];
9827
9828 // If unlimited, return true immediately
9829 if ($limit === 'unlimited') {
9830 return true;
9831 }
9832
9833 // Get the option name for this user/role with safer naming (include bot_id for bot-specific limits)
9834 $safe_role = preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
9835 $safe_user_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $user_id);
9836 $safe_bot_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id);
9837
9838 // Include bot_id in option name so each bot has separate rate limits
9839 $option_name = 'mxchat_chat_limit_' . $safe_bot_id . '_' . $safe_role . '_' . $safe_user_id;
9840
9841 // Get the counter data
9842 $limit_data = get_option($option_name, ['count' => 0, 'timestamp' => time()]);
9843
9844 // If first request or counter reset needed, set the initial timestamp
9845 if ($limit_data['count'] === 0) {
9846 $limit_data['timestamp'] = time();
9847 update_option($option_name, $limit_data);
9848 }
9849
9850 // Get the timeframe
9851 $timeframe = isset($rate_limits_source[$role]['timeframe']) ?
9852 $rate_limits_source[$role]['timeframe'] : 'daily';
9853
9854 // Check if the counter needs to be reset based on timeframe
9855 $current_time = time();
9856 $timestamp = $limit_data['timestamp'];
9857 $should_reset = false;
9858
9859 switch ($timeframe) {
9860 case 'hourly':
9861 $should_reset = ($current_time - $timestamp) >= 3600; // 1 hour
9862 break;
9863 case 'daily':
9864 $should_reset = ($current_time - $timestamp) >= 86400; // 24 hours
9865 break;
9866 case 'weekly':
9867 $should_reset = ($current_time - $timestamp) >= 604800; // 7 days
9868 break;
9869 case 'monthly':
9870 $should_reset = ($current_time - $timestamp) >= 2592000; // 30 days
9871 break;
9872 }
9873
9874 // Reset the counter if the timeframe has passed
9875 if ($should_reset) {
9876 $limit_data = ['count' => 0, 'timestamp' => $current_time];
9877 update_option($option_name, $limit_data);
9878 }
9879
9880 // Check if user has exceeded their limit
9881 if ($limit_data['count'] >= intval($limit)) {
9882 // Get the custom message for this role
9883 $message = !empty($rate_limits_source[$role]['message'])
9884 ? $rate_limits_source[$role]['message']
9885 : __('Rate limit exceeded. Please try again later.', 'mxchat');
9886
9887 // Add timeframe information to the message if placeholders exist
9888 $timeframe_label = '';
9889 switch ($timeframe) {
9890 case 'hourly':
9891 $timeframe_label = __('hour', 'mxchat');
9892 break;
9893 case 'daily':
9894 $timeframe_label = __('day', 'mxchat');
9895 break;
9896 case 'weekly':
9897 $timeframe_label = __('week', 'mxchat');
9898 break;
9899 case 'monthly':
9900 $timeframe_label = __('month', 'mxchat');
9901 break;
9902 }
9903
9904 // Replace placeholders in the message
9905 $message = str_replace(
9906 ['{limit}', '{count}', '{remaining}', '{timeframe}'],
9907 [intval($limit), $limit_data['count'], max(0, intval($limit) - $limit_data['count']), $timeframe_label],
9908 $message
9909 );
9910
9911 // Process HTML links in the message
9912 $message = $this->process_rate_limit_message_html($message);
9913
9914 // Return error with the processed message
9915 return [
9916 'error' => true,
9917 'message' => $message
9918 ];
9919 }
9920
9921 // Increment the counter
9922 $limit_data['count']++;
9923 update_option($option_name, $limit_data);
9924
9925 return true;
9926 }
9927
9928 /**
9929 * Enhanced rate limit reset with better error handling
9930 */
9931 public function mxchat_reset_rate_limits() {
9932 try {
9933 global $wpdb;
9934 $all_options = get_option('mxchat_options', []);
9935 $current_time = time();
9936
9937 // Get rate limit options with a safer query and limit
9938 $option_names = $wpdb->get_col(
9939 $wpdb->prepare(
9940 "SELECT option_name FROM {$wpdb->options}
9941 WHERE option_name LIKE %s
9942 LIMIT 1000",
9943 'mxchat_chat_limit_%'
9944 )
9945 );
9946
9947 if (empty($option_names)) {
9948 return;
9949 }
9950
9951 $processed_count = 0;
9952 $max_processing_time = 30; // Maximum 30 seconds
9953 $start_time = time();
9954
9955 foreach ($option_names as $option_name) {
9956 // Check processing time limit
9957 if ((time() - $start_time) > $max_processing_time) {
9958 //error_log('MxChat: Rate limit reset timeout after processing ' . $processed_count . ' entries');
9959 break;
9960 }
9961
9962 // Parse the option name more safely
9963 if (!preg_match('/^mxchat_chat_limit_(.+)_(.+)$/', $option_name, $matches)) {
9964 continue;
9965 }
9966
9967 $role_and_user = $matches[1] . '_' . $matches[2];
9968 $parts = explode('_', $role_and_user);
9969
9970 if (count($parts) < 2) {
9971 continue;
9972 }
9973
9974 // Extract role (everything except the last part which is user ID)
9975 $user_id_part = array_pop($parts);
9976 $role = implode('_', $parts);
9977
9978 // Skip if role doesn't exist in our settings
9979 if (!isset($all_options['rate_limits'][$role])) {
9980 // Clean up orphaned entries
9981 delete_option($option_name);
9982 continue;
9983 }
9984
9985 $timeframe = $all_options['rate_limits'][$role]['timeframe'] ?? 'daily';
9986 $limit_data = get_option($option_name);
9987
9988 if (!$limit_data || !is_array($limit_data) || !isset($limit_data['timestamp'])) {
9989 // Clean up invalid entries
9990 delete_option($option_name);
9991 continue;
9992 }
9993
9994 $timestamp = $limit_data['timestamp'];
9995 $should_reset = false;
9996
9997 // Determine if we should reset based on the timeframe
9998 switch ($timeframe) {
9999 case 'hourly':
10000 $should_reset = ($current_time - $timestamp) >= 3600;
10001 break;
10002 case 'daily':
10003 $should_reset = ($current_time - $timestamp) >= 86400;
10004 break;
10005 case 'weekly':
10006 $should_reset = ($current_time - $timestamp) >= 604800;
10007 break;
10008 case 'monthly':
10009 $should_reset = ($current_time - $timestamp) >= 2592000;
10010 break;
10011 }
10012
10013 // Reset the counter if the timeframe has passed
10014 if ($should_reset) {
10015 delete_option($option_name);
10016 wp_cache_delete($option_name, 'options');
10017 $processed_count++;
10018 }
10019 }
10020
10021 // Clean up any orphaned cache entries
10022 wp_cache_delete('mxchat_all_chat_limits', 'options');
10023
10024 //error_log("MxChat: Rate limit reset completed. Processed {$processed_count} entries.");
10025
10026 } catch (Exception $e) {
10027 //error_log('MxChat: Rate limit reset error: ' . $e->getMessage());
10028 }
10029 }
10030
10031
10032 /**
10033 * Process HTML links in rate limit messages
10034 *
10035 * @param string $message The rate limit message
10036 * @return string The processed message with safe HTML links
10037 */
10038 private function process_rate_limit_message_html($message) {
10039 // Return original message if empty
10040 if (empty($message)) {
10041 return $message;
10042 }
10043
10044 // First, convert markdown links to HTML
10045 $message = $this->convert_markdown_links($message);
10046
10047 // Then, auto-convert any remaining plain URLs to links
10048 $message = $this->auto_link_urls($message);
10049
10050 // Allow basic HTML tags for links and formatting
10051 $allowed_tags = [
10052 'a' => [
10053 'href' => true,
10054 'target' => true,
10055 'rel' => true,
10056 'title' => true,
10057 'class' => true
10058 ],
10059 'strong' => [],
10060 'em' => [],
10061 'br' => [],
10062 'b' => [],
10063 'i' => [],
10064 'span' => ['class' => true]
10065 ];
10066
10067 // Sanitize but allow the specified HTML tags
10068 $processed_message = wp_kses($message, $allowed_tags);
10069
10070 // If wp_kses stripped everything, return the original message as plain text
10071 if (empty($processed_message) && !empty($message)) {
10072 // Strip all HTML and return plain text as fallback
10073 return wp_strip_all_tags($message);
10074 }
10075
10076 return $processed_message;
10077 }
10078
10079 /**
10080 * Convert markdown links to HTML
10081 *
10082 * @param string $text The text to process
10083 * @return string The text with markdown links converted to HTML
10084 */
10085 private function convert_markdown_links($text) {
10086 // Return original text if empty
10087 if (empty($text)) {
10088 return $text;
10089 }
10090
10091 // Pattern to match markdown links: [text](url)
10092 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
10093
10094 $processed_text = preg_replace_callback($pattern, function($matches) {
10095 $link_text = $matches[1];
10096 $url = $matches[2];
10097
10098 // Clean up any trailing punctuation from the URL
10099 $url = rtrim($url, '.,;:!?');
10100
10101 // Sanitize the link text and URL
10102 $safe_text = esc_html($link_text);
10103 $safe_url = esc_url($url);
10104
10105 // Create the HTML link
10106 return '<a href="' . $safe_url . '" target="_blank" rel="noopener noreferrer">' . $safe_text . '</a>';
10107 }, $text);
10108
10109 // If preg_replace_callback failed, return original text
10110 if ($processed_text === null) {
10111 return $text;
10112 }
10113
10114 return $processed_text;
10115 }
10116
10117 /**
10118 * Auto-convert plain URLs to clickable links
10119 *
10120 * @param string $text The text to process
10121 * @return string The text with URLs converted to links
10122 */
10123 private function auto_link_urls($text) {
10124 // Return original text if empty
10125 if (empty($text)) {
10126 return $text;
10127 }
10128
10129 // Simple pattern that avoids complex lookbehinds
10130 // This will match URLs that are not already inside href attributes or markdown links
10131 $pattern = '/(?<!href=["\'])(?<!\]\()https?:\/\/[^\s<>"\')\]]+/i';
10132
10133 $processed_text = preg_replace_callback($pattern, function($matches) {
10134 $url = $matches[0];
10135 // Clean up any trailing punctuation that might have been captured
10136 $url = rtrim($url, '.,;:!?');
10137
10138 // Add target="_blank" and rel="noopener noreferrer" for security
10139 return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
10140 }, $text);
10141
10142 // If preg_replace_callback failed, return original text
10143 if ($processed_text === null) {
10144 return $text;
10145 }
10146
10147 return $processed_text;
10148 }
10149
10150
10151 // Helper function to get client IP address
10152 private function get_client_ip() {
10153 // Check for shared internet/ISP IP
10154 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
10155 return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
10156 }
10157
10158 // Check for IPs passing through proxies
10159 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
10160 // Use the first value in the comma-separated list
10161 $forwarded_for = explode(',', sanitize_text_field($_SERVER['HTTP_X_FORWARDED_FOR']));
10162 return trim($forwarded_for[0]);
10163 }
10164
10165 if (!empty($_SERVER['REMOTE_ADDR'])) {
10166 return sanitize_text_field($_SERVER['REMOTE_ADDR']);
10167 }
10168
10169 // Fallback
10170 return 'unknown';
10171 }
10172
10173 /**
10174 * AJAX handler to get system information for testing panel
10175 */
10176 /**
10177 * AJAX handler to get system information for testing panel
10178 */
10179 public function mxchat_get_system_info() {
10180 // Verify nonce for security
10181 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10182 wp_send_json_error(['message' => 'Invalid nonce']);
10183 return;
10184 }
10185
10186 // Only allow admin users
10187 if (!current_user_can('administrator')) {
10188 wp_send_json_error(['message' => 'Unauthorized']);
10189 return;
10190 }
10191
10192 // Get system prompt from options
10193 $system_prompt = isset($this->options['system_prompt_instructions'])
10194 ? $this->options['system_prompt_instructions']
10195 : 'No system prompt configured';
10196
10197 // Get selected model
10198 $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest';
10199
10200 // Check if OpenRouter is being used
10201 $is_openrouter = ($selected_model === 'openrouter');
10202 $openrouter_model = '';
10203
10204 if ($is_openrouter) {
10205 // Get the actual OpenRouter model that's selected
10206 $openrouter_model = isset($this->options['openrouter_selected_model'])
10207 ? $this->options['openrouter_selected_model']
10208 : 'No OpenRouter model selected';
10209
10210 // Update selected_model display to show both
10211 $selected_model = 'OpenRouter: ' . $openrouter_model;
10212 }
10213
10214 // Get API key status (just check if they exist, don't expose the keys)
10215 $api_status = [];
10216 $api_status['openai'] = !empty($this->options['api_key']);
10217 $api_status['claude'] = !empty($this->options['claude_api_key']);
10218 $api_status['gemini'] = !empty($this->options['gemini_api_key']);
10219 $api_status['xai'] = !empty($this->options['xai_api_key']);
10220 $api_status['deepseek'] = !empty($this->options['deepseek_api_key']);
10221 $api_status['openrouter'] = !empty($this->options['openrouter_api_key']);
10222
10223 wp_send_json_success([
10224 'system_prompt' => $system_prompt,
10225 'selected_model' => $selected_model,
10226 'is_openrouter' => $is_openrouter,
10227 'openrouter_model' => $openrouter_model,
10228 'api_status' => $api_status
10229 ]);
10230 }
10231
10232 /**
10233 * AJAX handler to get similarity threshold
10234 */
10235 public function mxchat_get_similarity_threshold() {
10236 // Verify nonce for security
10237 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10238 wp_send_json_error(['message' => 'Invalid nonce']);
10239 return;
10240 }
10241
10242 // Only allow admin users
10243 if (!current_user_can('administrator')) {
10244 wp_send_json_error(['message' => 'Unauthorized']);
10245 return;
10246 }
10247
10248 // Get similarity threshold from main options (default 35%)
10249 $similarity_threshold = isset($this->options['similarity_threshold'])
10250 ? ((int) $this->options['similarity_threshold']) / 100
10251 : 0.35;
10252
10253 wp_send_json_success([
10254 'threshold' => $similarity_threshold,
10255 'threshold_percentage' => ($similarity_threshold * 100) . '%'
10256 ]);
10257 }
10258
10259 /**
10260 * AJAX handler to get knowledge base status
10261 */
10262 public function mxchat_get_kb_status() {
10263 // Verify nonce for security
10264 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10265 wp_send_json_error(['message' => 'Invalid nonce']);
10266 return;
10267 }
10268
10269 // Only allow admin users
10270 if (!current_user_can('administrator')) {
10271 wp_send_json_error(['message' => 'Unauthorized']);
10272 return;
10273 }
10274
10275 // Check OpenAI Vector Store first (takes priority)
10276 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
10277 $use_vectorstore = (isset($vectorstore_options['mxchat_use_openai_vectorstore']) && $vectorstore_options['mxchat_use_openai_vectorstore'] === '1');
10278
10279 if ($use_vectorstore) {
10280 $vectorstore_ids = $vectorstore_options['mxchat_openai_vectorstore_ids'] ?? '';
10281 $id_count = !empty($vectorstore_ids) ? count(array_filter(array_map('trim', explode(',', $vectorstore_ids)))) : 0;
10282
10283 $kb_info = [
10284 'type' => 'OpenAI Vector Store',
10285 'status' => 'Active',
10286 'documents' => $id_count > 0 ? $id_count . ' vector store' . ($id_count > 1 ? 's' : '') . ' configured' : 'No vector stores configured'
10287 ];
10288
10289 wp_send_json_success($kb_info);
10290 return;
10291 }
10292
10293 // Check Pinecone vs WordPress
10294 $addon_options = get_option('mxchat_pinecone_addon_options', array());
10295 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
10296
10297 $kb_info = [
10298 'type' => $use_pinecone ? 'Pinecone' : 'WordPress Database',
10299 'status' => 'Active'
10300 ];
10301
10302 // Get document count
10303 if ($use_pinecone) {
10304 $kb_info['documents'] = 'Connected to Pinecone';
10305 $kb_info['api_configured'] = !empty($addon_options['mxchat_pinecone_api_key']);
10306 } else {
10307 // Count documents in WordPress database
10308 global $wpdb;
10309 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10310 $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
10311 $kb_info['documents'] = $count ? $count . ' documents' : 'No documents';
10312 }
10313
10314 wp_send_json_success($kb_info);
10315 }
10316
10317 /**
10318 * AJAX handler to start a completely fresh session (NEW - replaces old clear session)
10319 */
10320 public function mxchat_start_fresh_session() {
10321 // Verify nonce for security
10322 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10323 wp_send_json_error(['message' => 'Invalid nonce']);
10324 return;
10325 }
10326
10327 // Only allow admin users
10328 if (!current_user_can('administrator')) {
10329 wp_send_json_error(['message' => 'Unauthorized']);
10330 return;
10331 }
10332
10333 $old_session_id = isset($_POST['old_session_id']) ? sanitize_text_field($_POST['old_session_id']) : '';
10334 $new_session_id = isset($_POST['new_session_id']) ? sanitize_text_field($_POST['new_session_id']) : '';
10335
10336 if (empty($old_session_id)) {
10337 wp_send_json_error(['message' => 'Old session ID required']);
10338 return;
10339 }
10340
10341 // If no new session ID provided, generate one
10342 if (empty($new_session_id)) {
10343 $new_session_id = 'mxchat_chat_' . substr(md5(uniqid()), 0, 9);
10344 }
10345
10346 // Clear ALL data associated with the old session
10347 $this->clear_complete_session_data($old_session_id);
10348
10349 // Initialize the new session
10350 $this->initialize_fresh_session($new_session_id);
10351
10352 wp_send_json_success([
10353 'message' => 'Fresh session started successfully',
10354 'new_session_id' => $new_session_id,
10355 'old_session_id' => $old_session_id
10356 ]);
10357 }
10358
10359 /**
10360 * Clear ALL data associated with a session (ENHANCED)
10361 */
10362 private function clear_complete_session_data($session_id) {
10363 // Clear chat history
10364 delete_option("mxchat_history_{$session_id}");
10365
10366 // Clear chat mode
10367 delete_option("mxchat_mode_{$session_id}");
10368
10369 // Clear any PDF/Word transients
10370 $this->clear_pdf_transients($session_id);
10371 if (method_exists($this, 'clear_word_transients')) {
10372 $this->clear_word_transients($session_id);
10373 }
10374
10375 // Clear agent-related data
10376 delete_option("mxchat_channel_{$session_id}");
10377 delete_option("mxchat_agent_name_{$session_id}");
10378 delete_option("mxchat_email_{$session_id}");
10379
10380 // Clear any recommendation flow state
10381 delete_option("mxchat_sr_flow_state_{$session_id}");
10382
10383 // Clear any cached embeddings or context
10384 delete_transient("mxchat_context_{$session_id}");
10385 delete_transient("mxchat_last_query_{$session_id}");
10386
10387 // Clear any testing data
10388 delete_transient("mxchat_testing_data_{$session_id}");
10389
10390 // Clear any rate limiting data for this session
10391 delete_transient("mxchat_rate_limit_{$session_id}");
10392
10393 // Clear any other session-specific transients
10394 delete_transient("mxchat_waiting_for_pdf_url_{$session_id}");
10395 delete_transient("mxchat_include_pdf_in_context_{$session_id}");
10396 delete_transient("mxchat_include_word_in_context_{$session_id}");
10397
10398 // Clear form addon state (pending forms and submitted forms)
10399 delete_option("mxchat_pending_form_{$session_id}");
10400 delete_option("mxchat_submitted_forms_{$session_id}");
10401
10402 //error_log("MxChat: Cleared all data for session: {$session_id}");
10403 }
10404
10405 /**
10406 * Initialize a fresh session with default data
10407 */
10408 private function initialize_fresh_session($session_id) {
10409 // Set default chat mode
10410 update_option("mxchat_mode_{$session_id}", 'ai');
10411
10412 //error_log("MxChat: Initialized fresh session: {$session_id}");
10413 }
10414
10415 /**
10416 * Helper method to clear Word document transients (if you have Word support)
10417 */
10418 private function clear_word_transients($session_id) {
10419 delete_transient('mxchat_word_url_' . $session_id);
10420 delete_transient('mxchat_word_filename_' . $session_id);
10421 delete_transient('mxchat_word_embeddings_' . $session_id);
10422 delete_transient('mxchat_include_word_in_context_' . $session_id);
10423 }
10424
10425 /**
10426 * Simplified testing data capture method (CLEANED UP)
10427 */
10428 private function capture_testing_data($user_embedding, $message, $session_id) {
10429 // Only capture for admin users
10430 if (!current_user_can('administrator')) {
10431 return null;
10432 }
10433
10434 $testing_data = [
10435 'query' => $message,
10436 'timestamp' => time(),
10437 'top_matches' => [],
10438 'action_matches' => [] // Add action matches
10439 ];
10440
10441 // Get similarity threshold
10442 $similarity_threshold = isset($this->options['similarity_threshold'])
10443 ? ((int) $this->options['similarity_threshold']) / 100
10444 : 0.35;
10445
10446 $testing_data['similarity_threshold'] = $similarity_threshold;
10447
10448 // Use the real similarity analysis if available
10449 if ($this->last_similarity_analysis !== null) {
10450 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
10451 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
10452 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10453 } else {
10454 // Fallback: determine knowledge base type
10455 $addon_options = get_option('mxchat_pinecone_addon_options', array());
10456 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
10457
10458 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
10459 }
10460
10461 // Include action analysis if available
10462 if (isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
10463 $testing_data['action_matches'] = $this->last_action_analysis;
10464
10465 // Clear it after capturing to avoid stale data
10466 $this->last_action_analysis = null;
10467 }
10468
10469 return $testing_data;
10470 }
10471
10472
10473 /**
10474 * Track URL clicks from chatbot responses
10475 */
10476 public function mxchat_track_url_click() {
10477 // Verify nonce for security
10478 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10479 wp_send_json_error(['message' => 'Invalid nonce']);
10480 wp_die();
10481 }
10482
10483 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10484 $clicked_url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
10485 $message_context = isset($_POST['message_context']) ? sanitize_textarea_field($_POST['message_context']) : '';
10486
10487 if (empty($session_id) || empty($clicked_url)) {
10488 wp_send_json_error(['message' => 'Missing required data']);
10489 wp_die();
10490 }
10491
10492 global $wpdb;
10493 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
10494
10495 // Insert click tracking record
10496 $wpdb->insert(
10497 $table_name,
10498 [
10499 'session_id' => $session_id,
10500 'clicked_url' => $clicked_url,
10501 'message_context' => $message_context,
10502 'click_timestamp' => current_time('mysql', 1),
10503 'user_ip' => $_SERVER['REMOTE_ADDR'],
10504 'user_agent' => $_SERVER['HTTP_USER_AGENT']
10505 ]
10506 );
10507
10508 wp_send_json_success(['message' => 'Click tracked']);
10509 wp_die();
10510 }
10511
10512 /**
10513 * Get URL click analytics for a session
10514 */
10515 public function mxchat_get_url_clicks($session_id) {
10516 global $wpdb;
10517 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
10518
10519 $clicks = $wpdb->get_results($wpdb->prepare(
10520 "SELECT * FROM $table_name WHERE session_id = %s ORDER BY click_timestamp ASC",
10521 $session_id
10522 ));
10523
10524 return $clicks;
10525 }
10526 /**
10527 * Track the originating page where chat was started
10528 */
10529 public function mxchat_track_originating_page() {
10530 // Verify nonce
10531 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10532 wp_send_json_error(['message' => 'Invalid nonce']);
10533 wp_die();
10534 }
10535
10536 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10537 $page_url = isset($_POST['page_url']) ? esc_url_raw($_POST['page_url']) : '';
10538 $page_title = isset($_POST['page_title']) ? sanitize_text_field($_POST['page_title']) : '';
10539
10540 if (empty($session_id)) {
10541 wp_send_json_error(['message' => 'Missing session ID']);
10542 wp_die();
10543 }
10544
10545 global $wpdb;
10546 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
10547
10548 // Check if we've already tracked for this session
10549 $existing = $wpdb->get_var($wpdb->prepare(
10550 "SELECT COUNT(*) FROM $table_name
10551 WHERE session_id = %s
10552 AND originating_page_url IS NOT NULL",
10553 $session_id
10554 ));
10555
10556 if ($existing > 0) {
10557 wp_send_json_success(['message' => 'Already tracked']);
10558 wp_die();
10559 }
10560
10561 // Update the first message in this session with originating page info
10562 $wpdb->query($wpdb->prepare(
10563 "UPDATE $table_name
10564 SET originating_page_url = %s,
10565 originating_page_title = %s
10566 WHERE session_id = %s
10567 ORDER BY timestamp ASC
10568 LIMIT 1",
10569 $page_url,
10570 $page_title,
10571 $session_id
10572 ));
10573
10574 wp_send_json_success(['message' => 'Originating page tracked']);
10575 wp_die();
10576 }
10577
10578 /**
10579 * Validate and clean URLs from AI response
10580 * Removes any URLs that aren't in the knowledge base
10581 *
10582 * @param string $response_text The AI-generated response
10583 * @param array $valid_urls Array of URLs from the knowledge base
10584 * @return string Cleaned response with invalid URLs removed/flagged
10585 */
10586 private function validate_and_clean_urls($response_text, $valid_urls) {
10587 // DEBUG: Log what we're working with
10588 //error_log("=== MxChat URL Validation Debug ===");
10589 //error_log("Valid URLs count: " . count($valid_urls));
10590 //error_log("Valid URLs: " . print_r($valid_urls, true));
10591 //error_log("Response text length: " . strlen($response_text));
10592 //error_log("Response text preview: " . substr($response_text, 0, 500));
10593
10594 // If no valid URLs provided or empty response, return as-is
10595 if (empty($valid_urls) || empty($response_text)) {
10596 //error_log("Validation skipped - empty valid_urls or response");
10597 return $response_text;
10598 }
10599
10600 // Extract all URLs from the AI response
10601 // This regex matches http:// and https:// URLs
10602 preg_match_all(
10603 '#\bhttps?://[^\s<>"\')\]]+#i',
10604 $response_text,
10605 $matches
10606 );
10607
10608 // If no URLs found in response, return as-is
10609 if (empty($matches[0])) {
10610 //error_log("No URLs found in response");
10611 return $response_text;
10612 }
10613
10614 $found_urls = $matches[0];
10615 $cleaned_response = $response_text;
10616 $removed_count = 0;
10617
10618 // Normalize valid URLs for comparison (remove trailing slashes, fragments, etc.)
10619 $normalized_valid_urls = array_map(function($url) {
10620 // Remove trailing slash
10621 $url = rtrim($url, '/');
10622 // Remove URL fragments (#section)
10623 $url = preg_replace('/#.*$/', '', $url);
10624 // Remove trailing punctuation that might have been captured
10625 $url = rtrim($url, '.,;:!?');
10626 return $url;
10627 }, $valid_urls);
10628
10629 //error_log("Normalized valid URLs: " . print_r($normalized_valid_urls, true));
10630
10631 foreach ($found_urls as $found_url) {
10632 // Clean up the found URL (remove trailing punctuation that might have been captured)
10633 $clean_found_url = rtrim($found_url, '.,;:!?)');
10634
10635 // DEBUG: Log each URL being checked
10636 //error_log("Checking found URL: " . $found_url);
10637
10638 // Normalize for comparison
10639 $normalized_found = rtrim($clean_found_url, '/');
10640 $normalized_found = preg_replace('/#.*$/', '', $normalized_found);
10641
10642 //error_log("Normalized found URL: " . $normalized_found);
10643
10644 // Check if this URL exists in our valid URLs list
10645 $is_valid = false;
10646
10647 //error_log("Starting validation checks for: " . $normalized_found);
10648
10649 // First, try exact match
10650 if (in_array($normalized_found, $normalized_valid_urls)) {
10651 $is_valid = true;
10652 //error_log("EXACT MATCH FOUND");
10653 } else {
10654 //error_log("No exact match, checking variations...");
10655 // If no exact match, check if it's a variation (with query params, etc.)
10656 foreach ($normalized_valid_urls as $valid_url) {
10657 //error_log(" Comparing against valid URL: " . $valid_url);
10658
10659 // Check if the found URL starts with a valid URL (handles query params)
10660 if (strpos($normalized_found, $valid_url) === 0) {
10661 // Check what comes after the valid URL
10662 $remainder = substr($normalized_found, strlen($valid_url));
10663
10664 // Only valid if:
10665 // 1. Exact match (remainder is empty)
10666 // 2. Query params (starts with ?)
10667 // 3. Fragment (starts with #)
10668 if (empty($remainder) || $remainder[0] === '?' || $remainder[0] === '#') {
10669 $is_valid = true;
10670 //error_log(" MATCH: Found URL is valid variation of base URL");
10671 break;
10672 } else {
10673 //error_log(" NOT A MATCH: Found URL extends path beyond valid URL (remainder: " . $remainder . ")");
10674 }
10675 }
10676 // Also check the reverse (in case valid URL has query params)
10677 if (strpos($valid_url, $normalized_found) === 0) {
10678 $is_valid = true;
10679 //error_log(" MATCH: Valid URL starts with found URL");
10680 break;
10681 }
10682 }
10683
10684 if (!$is_valid) {
10685 //error_log("NO MATCH FOUND - URL should be removed");
10686 }
10687 }
10688
10689 // If URL is not valid, remove it from the response
10690 if (!$is_valid) {
10691 // Log the removal for debugging
10692 //error_log("MxChat: Removed hallucinated URL: " . $found_url);
10693 //error_log("MxChat: Valid URLs were: " . implode(', ', array_slice($normalized_valid_urls, 0, 5)));
10694
10695 $removed_count++;
10696
10697 // Check if URL is part of a markdown link: [text](url)
10698 $markdown_pattern = '/\[([^\]]+)\]\(' . preg_quote($found_url, '/') . '\)/';
10699 if (preg_match($markdown_pattern, $cleaned_response)) {
10700 //error_log("Found markdown link, removing but keeping text");
10701 // Remove the markdown link but keep the text
10702 $cleaned_response = preg_replace($markdown_pattern, '$1', $cleaned_response);
10703 }
10704 // Check if URL is part of an HTML link: <a href="url">text</a>
10705 else if (preg_match('/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>(.*?)<\/a>/i', $cleaned_response, $link_match)) {
10706 //error_log("Found HTML link, removing but keeping text");
10707 // Remove the HTML link but keep the text
10708 $link_text = $link_match[1];
10709 $cleaned_response = preg_replace(
10710 '/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>.*?<\/a>/i',
10711 $link_text,
10712 $cleaned_response
10713 );
10714 }
10715 // Otherwise just remove the bare URL
10716 else {
10717 //error_log("Removing bare URL");
10718 $cleaned_response = str_replace($found_url, '', $cleaned_response);
10719 }
10720 }
10721 }
10722
10723 // Log summary if any URLs were removed
10724 if ($removed_count > 0) {
10725 //error_log("MxChat: URL Validation Summary - Removed {$removed_count} hallucinated URL(s)");
10726 } else {
10727 //error_log("MxChat: URL Validation Summary - No URLs removed, all were valid");
10728 }
10729
10730 // Clean up any double spaces or awkward punctuation left behind
10731 // IMPORTANT: Only collapse horizontal whitespace (spaces/tabs), preserve newlines for markdown formatting
10732 $cleaned_response = preg_replace('/[^\S\n]+/', ' ', $cleaned_response); // Collapse spaces/tabs but NOT newlines
10733 $cleaned_response = preg_replace('/[^\S\n]+([.,;:!?])/', '$1', $cleaned_response); // Same for punctuation cleanup
10734
10735 //error_log("Final cleaned response: " . $cleaned_response);
10736
10737 return trim($cleaned_response);
10738 }
10739
10740 /**
10741 * AJAX handler to get current chat mode for a session
10742 */
10743 public function mxchat_get_current_chat_mode() {
10744 // Verify nonce for security
10745 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10746 wp_send_json_error(['message' => 'Invalid nonce']);
10747 wp_die();
10748 }
10749
10750 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10751
10752 if (empty($session_id)) {
10753 wp_send_json_error(['message' => 'Session ID missing']);
10754 wp_die();
10755 }
10756
10757 // Get the current chat mode for this session
10758 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
10759
10760 wp_send_json_success([
10761 'chat_mode' => $chat_mode
10762 ]);
10763 wp_die();
10764 }
10765
10766
10767
10768 }
10769 ?>
10770