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

10,788 lines 430.0 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 // Transcript save is handled by the main handler (mxchat_handle_chat_request)
7403 // which includes rag_context for the "sources" link in transcripts.
7404
7405 return $output_text;
7406 }
7407
7408 /**
7409 * Handle streaming web search response using Responses API
7410 */
7411 private function mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data) {
7412 $request_body['stream'] = true;
7413
7414 // Check if we can stream
7415 if (headers_sent() || !function_exists('curl_init')) {
7416 // Fallback to non-streaming
7417 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7418 }
7419
7420 // Setup streaming headers
7421 $this->setup_streaming_headers();
7422
7423 $ch = curl_init();
7424 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/responses');
7425 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7426 curl_setopt($ch, CURLOPT_POST, true);
7427 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request_body));
7428 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7429 'Content-Type: application/json',
7430 'Authorization: Bearer ' . $api_key
7431 ));
7432 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7433 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
7434
7435 $full_response = '';
7436 $stream_started = false;
7437 $buffer = '';
7438 $citations = [];
7439
7440 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$citations, $testing_data) {
7441 // Send testing data as first event if available
7442 if (!$stream_started && $testing_data !== null) {
7443 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7444 flush();
7445 $stream_started = true;
7446 }
7447
7448 $buffer .= $data;
7449 $lines = explode("\n", $buffer);
7450 $buffer = array_pop($lines);
7451
7452 foreach ($lines as $line) {
7453 if (trim($line) === '') continue;
7454 if (strpos($line, 'data: ') !== 0) continue;
7455
7456 $json_str = substr($line, 6);
7457
7458 if (trim($json_str) === '[DONE]') {
7459 // Append citations if we have any
7460 if (!empty($citations)) {
7461 $citation_text = "\n\n**Sources:**\n";
7462 $seen_urls = [];
7463 foreach ($citations as $citation) {
7464 if (!in_array($citation['url'], $seen_urls)) {
7465 $seen_urls[] = $citation['url'];
7466 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
7467 $citation_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
7468 }
7469 }
7470 echo "data: " . json_encode(['content' => $citation_text]) . "\n\n";
7471 $full_response .= $citation_text;
7472 flush();
7473 }
7474 echo "data: [DONE]\n\n";
7475 flush();
7476 continue;
7477 }
7478
7479 $json = json_decode(trim($json_str), true);
7480 if (!$json) continue;
7481
7482 // Handle Responses API streaming events
7483 // The format is different from Chat Completions
7484 if (isset($json['type'])) {
7485 switch ($json['type']) {
7486 case 'response.output_text.delta':
7487 // Text content delta
7488 if (isset($json['delta'])) {
7489 $content = $json['delta'];
7490 $full_response .= $content;
7491 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7492 flush();
7493 }
7494 break;
7495
7496 case 'response.output_item.done':
7497 // Check for citations in completed items
7498 if (isset($json['item']['content'])) {
7499 foreach ($json['item']['content'] as $content_item) {
7500 if (isset($content_item['annotations'])) {
7501 foreach ($content_item['annotations'] as $annotation) {
7502 if ($annotation['type'] === 'url_citation') {
7503 $citations[] = [
7504 'url' => $annotation['url'],
7505 'title' => $annotation['title'] ?? ''
7506 ];
7507 }
7508 }
7509 }
7510 }
7511 }
7512 break;
7513 }
7514 }
7515 }
7516
7517 return strlen($data);
7518 });
7519
7520 $response = curl_exec($ch);
7521 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7522
7523 if (curl_errno($ch) || $http_code !== 200) {
7524 $curl_error = curl_error($ch);
7525 curl_close($ch);
7526
7527 //error_log("MXCHAT WEB SEARCH STREAM ERROR: HTTP $http_code, cURL error: $curl_error");
7528
7529 // Fallback to non-streaming
7530 $fallback_response = $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7531
7532 if (is_array($fallback_response) && isset($fallback_response['error'])) {
7533 echo "data: " . json_encode([
7534 'error' => true,
7535 'error_message' => $fallback_response['error'],
7536 'error_code' => $fallback_response['error_code'] ?? 'web_search_error'
7537 ]) . "\n\n";
7538 echo "data: [DONE]\n\n";
7539 flush();
7540 return true;
7541 }
7542
7543 $response_data = [
7544 'text' => $fallback_response,
7545 'html' => '',
7546 'session_id' => $session_id
7547 ];
7548 if ($testing_data !== null) {
7549 $response_data['testing_data'] = $testing_data;
7550 }
7551 header('Content-Type: application/json');
7552 echo json_encode($response_data);
7553 return true;
7554 }
7555
7556 curl_close($ch);
7557
7558 // Save the complete response with RAG context so the "sources" link
7559 // appears in transcripts — mirrors the pattern used by Claude/OpenAI streaming.
7560 if (!empty($full_response) && !empty($session_id)) {
7561 $rag_context_for_storage = null;
7562 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7563 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7564
7565 if ($has_rag_data || $has_action_data) {
7566 $rag_context_for_storage = [];
7567
7568 if ($has_rag_data) {
7569 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7570 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7571 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7572 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7573 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7574 }
7575
7576 if ($has_action_data) {
7577 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7578 }
7579 }
7580 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7581 }
7582
7583 return true;
7584 }
7585
7586 private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7587 try {
7588 // Get bot ID from session or request
7589 $bot_id = $this->get_current_bot_id($session_id);
7590
7591 // Get system prompt instructions using centralized function
7592 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7593 // Ensure conversation_history is an array
7594 if (!is_array($conversation_history)) {
7595 $conversation_history = array();
7596 }
7597
7598 // Clean and validate conversation history
7599 foreach ($conversation_history as &$message) {
7600 // Convert bot and agent roles to assistant
7601 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
7602 $message['role'] = 'assistant';
7603 }
7604
7605 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
7606 if (!in_array($message['role'], ['assistant', 'user'])) {
7607 $message['role'] = 'user';
7608 }
7609
7610 // Ensure content field exists
7611 if (!isset($message['content']) || empty($message['content'])) {
7612 $message['content'] = '';
7613 }
7614
7615 // Remove any unsupported fields
7616 $message = array_intersect_key($message, array_flip(['role', 'content']));
7617 }
7618
7619 // Add relevant content as the latest user message
7620 $conversation_history[] = [
7621 'role' => 'user',
7622 'content' => $relevant_content
7623 ];
7624
7625 // Prepare the request body with stream: true
7626 $body = json_encode([
7627 'model' => $selected_model,
7628 'messages' => $conversation_history,
7629 'max_tokens' => 1000,
7630 'temperature' => 0.8,
7631 'system' => $system_prompt_instructions,
7632 'stream' => true
7633 ]);
7634
7635 // Check if we can actually stream (headers not sent, etc.)
7636 if (headers_sent() || !function_exists('curl_init')) {
7637 // Fallback to regular response with testing data
7638 //error_log("MxChat: Streaming not possible, falling back to regular response");
7639 $regular_response = $this->mxchat_generate_response_claude(
7640 $selected_model,
7641 $claude_api_key,
7642 array_slice($conversation_history, 0, -1), // Remove the added content
7643 $relevant_content
7644 );
7645
7646 // Save bot response to transcript
7647 if (!empty($regular_response) && !empty($session_id)) {
7648 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7649 }
7650
7651 // Return as JSON with testing data
7652 $response_data = [
7653 'text' => $regular_response,
7654 'html' => '',
7655 'session_id' => $session_id
7656 ];
7657
7658 if ($testing_data !== null) {
7659 $response_data['testing_data'] = $testing_data;
7660 //error_log("MxChat Testing: Added testing data to Claude fallback response");
7661 }
7662
7663 // Clear any streaming headers and send JSON
7664 if (headers_sent() === false) {
7665 header('Content-Type: application/json');
7666 }
7667 echo json_encode($response_data);
7668 return true; // Indicate we handled the response
7669 }
7670
7671 // Setup streaming headers now that we know we're actually streaming
7672 $this->setup_streaming_headers();
7673
7674 // Use cURL for streaming support
7675 $ch = curl_init();
7676 curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
7677 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7678 curl_setopt($ch, CURLOPT_POST, true);
7679 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7680 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7681 'Content-Type: application/json',
7682 'x-api-key: ' . $claude_api_key,
7683 'anthropic-version: 2023-06-01'
7684 ));
7685 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7686 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7687
7688 $full_response = ''; // Accumulate full response for saving
7689 $stream_started = false;
7690 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7691
7692 // Buffer control for real-time streaming
7693 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7694 // Send testing data as the first event if available
7695 if (!$stream_started && $testing_data !== null) {
7696 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7697 flush();
7698 $stream_started = true;
7699 //error_log("MxChat Testing: Sent testing data in Claude stream");
7700 }
7701
7702 // CRITICAL FIX: Append new data to buffer
7703 $buffer .= $data;
7704
7705 // Process complete lines only
7706 $lines = explode("\n", $buffer);
7707
7708 // CRITICAL FIX: Keep the last incomplete line in the buffer
7709 // The last element might be incomplete, so keep it in buffer
7710 $buffer = array_pop($lines);
7711
7712 foreach ($lines as $line) {
7713 if (trim($line) === '') {
7714 continue;
7715 }
7716
7717 // Claude uses event: and data: format
7718 if (strpos($line, 'event: ') === 0) {
7719 // Store the event type for the next data line
7720 continue;
7721 }
7722
7723 if (strpos($line, 'data: ') === 0) {
7724 $json_str = substr($line, 6); // Remove 'data: ' prefix
7725
7726 $json = json_decode(trim($json_str), true);
7727 if (json_last_error() !== JSON_ERROR_NONE) {
7728 continue;
7729 }
7730
7731 // Handle different event types
7732 if (isset($json['type'])) {
7733 switch ($json['type']) {
7734 case 'content_block_delta':
7735 if (isset($json['delta']['text'])) {
7736 $content = $json['delta']['text'];
7737 $full_response .= $content; // Accumulate
7738 // Send as SSE format compatible with your frontend
7739 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7740 flush();
7741 }
7742 break;
7743
7744 case 'message_stop':
7745 echo "data: [DONE]\n\n";
7746 flush();
7747 break;
7748
7749 case 'error':
7750 echo "data: " . json_encode(['error' => $json['error']['message'] ?? 'Unknown error']) . "\n\n";
7751 flush();
7752 break;
7753 }
7754 }
7755 }
7756 }
7757
7758 return strlen($data);
7759 });
7760
7761 $response = curl_exec($ch);
7762 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7763
7764 if (curl_errno($ch)) {
7765 curl_close($ch);
7766 throw new Exception('cURL Error: ' . curl_error($ch));
7767 }
7768
7769 curl_close($ch);
7770
7771 if ($http_code !== 200) {
7772 // Fallback to regular response
7773 //error_log("MxChat: Claude streaming failed with HTTP $http_code, falling back");
7774 $regular_response = $this->mxchat_generate_response_claude(
7775 $selected_model,
7776 $claude_api_key,
7777 array_slice($conversation_history, 0, -1), // Remove the added content
7778 $relevant_content
7779 );
7780
7781 // FIXED: Check if regular response returned an error
7782 if (is_array($regular_response) && isset($regular_response['error'])) {
7783 // Send error in SSE format since we're in streaming mode
7784 echo "data: " . json_encode([
7785 'error' => true,
7786 'error_message' => $regular_response['error'],
7787 'error_code' => $regular_response['error_code'] ?? 'api_error',
7788 'text' => $regular_response['error'],
7789 'message' => $regular_response['error']
7790 ]) . "\n\n";
7791 echo "data: [DONE]\n\n";
7792 flush();
7793 return true;
7794 }
7795
7796 $response_data = [
7797 'text' => $regular_response,
7798 'html' => '',
7799 'session_id' => $session_id
7800 ];
7801
7802 if ($testing_data !== null) {
7803 $response_data['testing_data'] = $testing_data;
7804 //error_log("MxChat Testing: Added testing data to Claude error fallback");
7805 }
7806
7807 header('Content-Type: application/json');
7808 echo json_encode($response_data);
7809 return true;
7810 }
7811
7812 // Save the complete response to maintain chat persistence
7813 if (!empty($full_response) && !empty($session_id)) {
7814 // Prepare RAG context for streaming response
7815 $rag_context_for_storage = null;
7816 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7817 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7818
7819 if ($has_rag_data || $has_action_data) {
7820 $rag_context_for_storage = [];
7821
7822 if ($has_rag_data) {
7823 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7824 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7825 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7826 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7827 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7828 }
7829
7830 if ($has_action_data) {
7831 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7832 }
7833 }
7834 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7835 }
7836
7837 return true; // Indicate streaming completed successfully
7838
7839 } catch (Exception $e) {
7840 //error_log("MxChat Claude streaming exception: " . $e->getMessage());
7841
7842 // Fallback to regular response on exception
7843 $regular_response = $this->mxchat_generate_response_claude(
7844 $selected_model,
7845 $claude_api_key,
7846 $conversation_history,
7847 $relevant_content
7848 );
7849
7850 // FIXED: Check if regular response returned an error
7851 if (is_array($regular_response) && isset($regular_response['error'])) {
7852 // Send error in SSE format since we're in streaming mode
7853 echo "data: " . json_encode([
7854 'error' => true,
7855 'error_message' => $regular_response['error'],
7856 'error_code' => $regular_response['error_code'] ?? 'api_error',
7857 'text' => $regular_response['error'],
7858 'message' => $regular_response['error']
7859 ]) . "\n\n";
7860 echo "data: [DONE]\n\n";
7861 flush();
7862 return true;
7863 }
7864
7865 $response_data = [
7866 'text' => $regular_response,
7867 'html' => '',
7868 'session_id' => $session_id
7869 ];
7870
7871 if ($testing_data !== null) {
7872 $response_data['testing_data'] = $testing_data;
7873 //error_log("MxChat Testing: Added testing data to Claude exception fallback");
7874 }
7875
7876 header('Content-Type: application/json');
7877 echo json_encode($response_data);
7878 return true;
7879 }
7880 }
7881 private function mxchat_generate_response_xai_stream($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7882 try {
7883 // Get bot ID from session or request
7884 $bot_id = $this->get_current_bot_id($session_id);
7885
7886 // Get system prompt instructions using centralized function
7887 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7888
7889 // Ensure conversation_history is an array
7890 if (!is_array($conversation_history)) {
7891 $conversation_history = array();
7892 }
7893
7894 // Format conversation history for X.AI (same as OpenAI format)
7895 $formatted_conversation = array();
7896
7897 $formatted_conversation[] = array(
7898 'role' => 'system',
7899 'content' => $system_prompt_instructions . " " . $relevant_content
7900 );
7901
7902 foreach ($conversation_history as $message) {
7903 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7904 $role = $message['role'];
7905 if ($role === 'bot' || $role === 'agent') {
7906 $role = 'assistant';
7907 }
7908 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
7909 $role = 'user';
7910 }
7911 $formatted_conversation[] = array(
7912 'role' => $role,
7913 'content' => $message['content']
7914 );
7915 }
7916 }
7917
7918 // Check if we can actually stream
7919 if (headers_sent() || !function_exists('curl_init')) {
7920 // Fallback to regular response with testing data
7921 //error_log("MxChat: X.AI streaming not possible, falling back to regular response");
7922 $regular_response = $this->mxchat_generate_response_xai(
7923 $selected_model,
7924 $xai_api_key,
7925 $conversation_history,
7926 $relevant_content
7927 );
7928
7929 // Save bot response to transcript
7930 if (!empty($regular_response) && !empty($session_id)) {
7931 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7932 }
7933
7934 $response_data = [
7935 'text' => $regular_response,
7936 'html' => '',
7937 'session_id' => $session_id
7938 ];
7939
7940 if ($testing_data !== null) {
7941 $response_data['testing_data'] = $testing_data;
7942 //error_log("MxChat Testing: Added testing data to X.AI fallback response");
7943 }
7944
7945 header('Content-Type: application/json');
7946 echo json_encode($response_data);
7947 return true;
7948 }
7949
7950 // Prepare the request body with stream: true
7951 $body = json_encode([
7952 'model' => $selected_model,
7953 'messages' => $formatted_conversation,
7954 'temperature' => 0.8,
7955 'stream' => true
7956 ]);
7957
7958 // Setup streaming headers now that we know we're actually streaming
7959 $this->setup_streaming_headers();
7960
7961 // Use cURL for streaming support
7962 $ch = curl_init();
7963 curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
7964 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7965 curl_setopt($ch, CURLOPT_POST, true);
7966 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7967 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7968 'Content-Type: application/json',
7969 'Authorization: Bearer ' . $xai_api_key
7970 ));
7971 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7972 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7973
7974 $full_response = ''; // Accumulate full response for saving
7975 $stream_started = false;
7976 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7977
7978 // Buffer control for real-time streaming
7979 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7980 // Send testing data as the first event if available
7981 if (!$stream_started && $testing_data !== null) {
7982 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7983 flush();
7984 $stream_started = true;
7985 //error_log("MxChat Testing: Sent testing data in X.AI stream");
7986 }
7987
7988 // CRITICAL FIX: Append new data to buffer
7989 $buffer .= $data;
7990
7991 // Process complete lines only
7992 $lines = explode("\n", $buffer);
7993
7994 // CRITICAL FIX: Keep the last incomplete line in the buffer
7995 // The last element might be incomplete, so keep it in buffer
7996 $buffer = array_pop($lines);
7997
7998 foreach ($lines as $line) {
7999 // Skip empty lines
8000 if (trim($line) === '') {
8001 continue;
8002 }
8003
8004 // Only process lines that start with "data: "
8005 if (strpos($line, 'data: ') !== 0) {
8006 continue;
8007 }
8008
8009 $json_str = substr($line, 6); // Remove 'data: ' prefix
8010
8011 if (trim($json_str) === '[DONE]') {
8012 echo "data: [DONE]\n\n";
8013 flush();
8014 continue;
8015 }
8016
8017 // Try to decode JSON
8018 $json = json_decode(trim($json_str), true);
8019 if ($json && isset($json['choices'][0]['delta']['content'])) {
8020 $content = $json['choices'][0]['delta']['content'];
8021 $full_response .= $content; // Accumulate
8022 // Send as SSE format
8023 echo "data: " . json_encode(['content' => $content]) . "\n\n";
8024 flush();
8025 }
8026 }
8027
8028 return strlen($data);
8029 });
8030
8031 $response = curl_exec($ch);
8032 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
8033
8034 if (curl_errno($ch) || $http_code !== 200) {
8035 curl_close($ch);
8036
8037 // Fallback to regular response
8038 //error_log("MxChat: X.AI streaming failed, falling back");
8039 $regular_response = $this->mxchat_generate_response_xai(
8040 $selected_model,
8041 $xai_api_key,
8042 $conversation_history,
8043 $relevant_content
8044 );
8045
8046 $response_data = [
8047 'text' => $regular_response,
8048 'html' => '',
8049 'session_id' => $session_id
8050 ];
8051
8052 if ($testing_data !== null) {
8053 $response_data['testing_data'] = $testing_data;
8054 //error_log("MxChat Testing: Added testing data to X.AI error fallback");
8055 }
8056
8057 header('Content-Type: application/json');
8058 echo json_encode($response_data);
8059 return true;
8060 }
8061
8062 curl_close($ch);
8063
8064 // Save the complete response to maintain chat persistence
8065 if (!empty($full_response) && !empty($session_id)) {
8066 // Prepare RAG context for streaming response
8067 $rag_context_for_storage = null;
8068 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
8069 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
8070
8071 if ($has_rag_data || $has_action_data) {
8072 $rag_context_for_storage = [];
8073
8074 if ($has_rag_data) {
8075 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
8076 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
8077 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
8078 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
8079 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
8080 }
8081
8082 if ($has_action_data) {
8083 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
8084 }
8085 }
8086 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
8087 }
8088
8089 return true; // Indicate streaming completed successfully
8090
8091 } catch (Exception $e) {
8092 //error_log("MxChat X.AI streaming exception: " . $e->getMessage());
8093
8094 // Fallback to regular response
8095 $regular_response = $this->mxchat_generate_response_xai(
8096 $selected_model,
8097 $xai_api_key,
8098 $conversation_history,
8099 $relevant_content
8100 );
8101
8102 $response_data = [
8103 'text' => $regular_response,
8104 'html' => '',
8105 'session_id' => $session_id
8106 ];
8107
8108 if ($testing_data !== null) {
8109 $response_data['testing_data'] = $testing_data;
8110 //error_log("MxChat Testing: Added testing data to X.AI exception fallback");
8111 }
8112
8113 header('Content-Type: application/json');
8114 echo json_encode($response_data);
8115 return true;
8116 }
8117 }
8118 private function mxchat_generate_response_deepseek_stream($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
8119 try {
8120 // Get bot ID from session or request
8121 $bot_id = $this->get_current_bot_id($session_id);
8122
8123 // Get system prompt instructions using centralized function
8124 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8125
8126 // Ensure conversation_history is an array
8127 if (!is_array($conversation_history)) {
8128 $conversation_history = array();
8129 }
8130
8131 // Format conversation history for DeepSeek
8132 $formatted_conversation = array();
8133
8134 $formatted_conversation[] = array(
8135 'role' => 'system',
8136 'content' => $system_prompt_instructions . " " . $relevant_content
8137 );
8138
8139 foreach ($conversation_history as $message) {
8140 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8141 $role = $message['role'];
8142 if ($role === 'bot' || $role === 'agent') {
8143 $role = 'assistant';
8144 }
8145 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8146 $role = 'user';
8147 }
8148 $formatted_conversation[] = array(
8149 'role' => $role,
8150 'content' => $message['content']
8151 );
8152 }
8153 }
8154
8155 // Check if we can actually stream
8156 if (headers_sent() || !function_exists('curl_init')) {
8157 // Fallback to regular response with testing data
8158 //error_log("MxChat: DeepSeek streaming not possible, falling back to regular response");
8159 $regular_response = $this->mxchat_generate_response_deepseek(
8160 $selected_model,
8161 $deepseek_api_key,
8162 $conversation_history,
8163 $relevant_content
8164 );
8165
8166 // Save bot response to transcript
8167 if (!empty($regular_response) && !empty($session_id)) {
8168 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
8169 }
8170
8171 $response_data = [
8172 'text' => $regular_response,
8173 'html' => '',
8174 'session_id' => $session_id
8175 ];
8176
8177 if ($testing_data !== null) {
8178 $response_data['testing_data'] = $testing_data;
8179 //error_log("MxChat Testing: Added testing data to DeepSeek fallback response");
8180 }
8181
8182 header('Content-Type: application/json');
8183 echo json_encode($response_data);
8184 return true;
8185 }
8186
8187 // Prepare the request body with stream: true
8188 $body = json_encode([
8189 'model' => $selected_model,
8190 'messages' => $formatted_conversation,
8191 'temperature' => 0.8,
8192 'stream' => true
8193 ]);
8194
8195 // Setup streaming headers now that we know we're actually streaming
8196 $this->setup_streaming_headers();
8197
8198 // Use cURL for streaming support
8199 $ch = curl_init();
8200 curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
8201 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
8202 curl_setopt($ch, CURLOPT_POST, true);
8203 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
8204 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
8205 'Content-Type: application/json',
8206 'Authorization: Bearer ' . $deepseek_api_key
8207 ));
8208 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8209 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
8210
8211 $full_response = ''; // Accumulate full response for saving
8212 $stream_started = false;
8213 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
8214
8215 // Buffer control for real-time streaming
8216 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
8217 // Send testing data as the first event if available
8218 if (!$stream_started && $testing_data !== null) {
8219 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
8220 flush();
8221 $stream_started = true;
8222 //error_log("MxChat Testing: Sent testing data in DeepSeek stream");
8223 }
8224
8225 // CRITICAL FIX: Append new data to buffer
8226 $buffer .= $data;
8227
8228 // Process complete lines only
8229 $lines = explode("\n", $buffer);
8230
8231 // CRITICAL FIX: Keep the last incomplete line in the buffer
8232 // The last element might be incomplete, so keep it in buffer
8233 $buffer = array_pop($lines);
8234
8235 foreach ($lines as $line) {
8236 // Skip empty lines
8237 if (trim($line) === '') {
8238 continue;
8239 }
8240
8241 // Only process lines that start with "data: "
8242 if (strpos($line, 'data: ') !== 0) {
8243 continue;
8244 }
8245
8246 $json_str = substr($line, 6); // Remove 'data: ' prefix
8247
8248 if (trim($json_str) === '[DONE]') {
8249 echo "data: [DONE]\n\n";
8250 flush();
8251 continue;
8252 }
8253
8254 // Try to decode JSON
8255 $json = json_decode(trim($json_str), true);
8256 if ($json && isset($json['choices'][0]['delta']['content'])) {
8257 $content = $json['choices'][0]['delta']['content'];
8258 $full_response .= $content; // Accumulate the full response
8259
8260 // Send as SSE format
8261 echo "data: " . json_encode(['content' => $content]) . "\n\n";
8262 flush();
8263 }
8264 }
8265
8266 return strlen($data);
8267 });
8268
8269 $response = curl_exec($ch);
8270 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
8271
8272 if (curl_errno($ch) || $http_code !== 200) {
8273 $curl_error = curl_error($ch);
8274 curl_close($ch);
8275
8276 // Log the specific error for debugging
8277 //error_log("MxChat: DeepSeek streaming failed - HTTP: $http_code, cURL: $curl_error");
8278
8279 // Fallback to regular response
8280 $regular_response = $this->mxchat_generate_response_deepseek(
8281 $selected_model,
8282 $deepseek_api_key,
8283 $conversation_history,
8284 $relevant_content
8285 );
8286
8287 // Handle error response from regular function
8288 if (is_array($regular_response) && isset($regular_response['error'])) {
8289 if ($testing_data !== null) {
8290 $regular_response['testing_data'] = $testing_data;
8291 }
8292 header('Content-Type: application/json');
8293 echo json_encode($regular_response);
8294 return true;
8295 }
8296
8297 $response_data = [
8298 'text' => $regular_response,
8299 'html' => '',
8300 'session_id' => $session_id
8301 ];
8302
8303 if ($testing_data !== null) {
8304 $response_data['testing_data'] = $testing_data;
8305 //error_log("MxChat Testing: Added testing data to DeepSeek error fallback");
8306 }
8307
8308 header('Content-Type: application/json');
8309 echo json_encode($response_data);
8310 return true;
8311 }
8312
8313 curl_close($ch);
8314
8315 // Save the complete response to maintain chat persistence
8316 if (!empty($full_response) && !empty($session_id)) {
8317 // Prepare RAG context for streaming response
8318 $rag_context_for_storage = null;
8319 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
8320 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
8321
8322 if ($has_rag_data || $has_action_data) {
8323 $rag_context_for_storage = [];
8324
8325 if ($has_rag_data) {
8326 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
8327 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
8328 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
8329 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
8330 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
8331 }
8332
8333 if ($has_action_data) {
8334 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
8335 }
8336 }
8337 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
8338 }
8339
8340 return true; // Indicate streaming completed successfully
8341
8342 } catch (Exception $e) {
8343 //error_log("MxChat DeepSeek streaming exception: " . $e->getMessage());
8344
8345 // Fallback to regular response
8346 $regular_response = $this->mxchat_generate_response_deepseek(
8347 $selected_model,
8348 $deepseek_api_key,
8349 $conversation_history,
8350 $relevant_content
8351 );
8352
8353 // Handle error response from regular function
8354 if (is_array($regular_response) && isset($regular_response['error'])) {
8355 if ($testing_data !== null) {
8356 $regular_response['testing_data'] = $testing_data;
8357 }
8358 header('Content-Type: application/json');
8359 echo json_encode($regular_response);
8360 return true;
8361 }
8362
8363 $response_data = [
8364 'text' => $regular_response,
8365 'html' => '',
8366 'session_id' => $session_id
8367 ];
8368
8369 if ($testing_data !== null) {
8370 $response_data['testing_data'] = $testing_data;
8371 //error_log("MxChat Testing: Added testing data to DeepSeek exception fallback");
8372 }
8373
8374 header('Content-Type: application/json');
8375 echo json_encode($response_data);
8376 return true;
8377 }
8378 }
8379
8380
8381 private function mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content) {
8382 try {
8383 if (!is_array($conversation_history)) {
8384 $conversation_history = array();
8385 }
8386
8387 $bot_id = $this->get_current_bot_id('');
8388 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8389
8390 $formatted_conversation = array();
8391
8392 $formatted_conversation[] = array(
8393 'role' => 'system',
8394 'content' => $system_prompt_instructions . " " . $relevant_content
8395 );
8396
8397 foreach ($conversation_history as $message) {
8398 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8399 $role = $message['role'];
8400
8401 if ($role === 'bot' || $role === 'agent') {
8402 $role = 'assistant';
8403 }
8404 if (!in_array($role, ['system', 'assistant', 'user'])) {
8405 $role = 'user';
8406 }
8407
8408 $formatted_conversation[] = array(
8409 'role' => $role,
8410 'content' => $message['content']
8411 );
8412 }
8413 }
8414
8415 $body = json_encode([
8416 'model' => $selected_model,
8417 'messages' => $formatted_conversation,
8418 'temperature' => 1,
8419 ]);
8420
8421 $args = [
8422 'body' => $body,
8423 'headers' => [
8424 'Content-Type' => 'application/json',
8425 'Authorization' => 'Bearer ' . $openrouter_api_key,
8426 'HTTP-Referer' => home_url(),
8427 'X-Title' => get_bloginfo('name'),
8428 ],
8429 'timeout' => 60,
8430 'redirection' => 5,
8431 'blocking' => true,
8432 'httpversion' => '1.0',
8433 'sslverify' => true,
8434 ];
8435
8436 $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', $args);
8437
8438 if (is_wp_error($response)) {
8439 $error_message = $response->get_error_message();
8440 return [
8441 'error' => esc_html__('Connection error when contacting OpenRouter: ', 'mxchat') . esc_html($error_message),
8442 'error_code' => 'openrouter_connection_error',
8443 'provider' => 'openrouter'
8444 ];
8445 }
8446
8447 $status_code = wp_remote_retrieve_response_code($response);
8448 if ($status_code !== 200) {
8449 $response_body = wp_remote_retrieve_body($response);
8450 $decoded_response = json_decode($response_body, true);
8451
8452 $error_message = isset($decoded_response['error']['message'])
8453 ? $decoded_response['error']['message']
8454 : 'HTTP Error ' . $status_code;
8455
8456 return [
8457 'error' => esc_html__('OpenRouter API error: ', 'mxchat') . esc_html($error_message),
8458 'error_code' => 'openrouter_api_error',
8459 'provider' => 'openrouter',
8460 'status_code' => $status_code
8461 ];
8462 }
8463
8464 $response_body = wp_remote_retrieve_body($response);
8465 $decoded_response = json_decode($response_body, true);
8466
8467 if (isset($decoded_response['choices'][0]['message']['content'])) {
8468 return trim($decoded_response['choices'][0]['message']['content']);
8469 } else {
8470 return [
8471 'error' => esc_html__('Unexpected response format from OpenRouter.', 'mxchat'),
8472 'error_code' => 'openrouter_response_format_error',
8473 'provider' => 'openrouter'
8474 ];
8475 }
8476 } catch (Exception $e) {
8477 return [
8478 'error' => esc_html__('System error when processing OpenRouter request: ', 'mxchat') . esc_html($e->getMessage()),
8479 'error_code' => 'openrouter_exception',
8480 'provider' => 'openrouter'
8481 ];
8482 }
8483 }
8484 private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content) {
8485
8486 // Get bot ID from session or request
8487 $bot_id = $this->get_current_bot_id($session_id);
8488
8489 // Get system prompt instructions using centralized function
8490 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8491
8492 // Clean and validate conversation history
8493 foreach ($conversation_history as &$message) {
8494 // Convert bot and agent roles to assistant
8495 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
8496 $message['role'] = 'assistant';
8497 }
8498
8499 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
8500 if (!in_array($message['role'], ['assistant', 'user'])) {
8501 $message['role'] = 'user';
8502 }
8503
8504 // Ensure content field exists
8505 if (!isset($message['content']) || empty($message['content'])) {
8506 $message['content'] = '';
8507 }
8508
8509 // Remove any unsupported fields
8510 $message = array_intersect_key($message, array_flip(['role', 'content']));
8511 }
8512
8513 // Add relevant content as the latest user message
8514 $conversation_history[] = [
8515 'role' => 'user',
8516 'content' => $relevant_content
8517 ];
8518
8519 // Build request body
8520 $body = json_encode([
8521 'model' => $selected_model,
8522 'max_tokens' => 1000,
8523 'temperature' => 0.8,
8524 'messages' => $conversation_history,
8525 'system' => $system_prompt_instructions
8526 ]);
8527
8528 // Set up API request
8529 $args = [
8530 'body' => $body,
8531 'headers' => [
8532 'Content-Type' => 'application/json',
8533 'x-api-key' => $claude_api_key,
8534 'anthropic-version' => '2023-06-01'
8535 ],
8536 'timeout' => 60,
8537 'redirection' => 5,
8538 'blocking' => true,
8539 'httpversion' => '1.0',
8540 'sslverify' => true,
8541 ];
8542
8543 // Make API request
8544 $response = wp_remote_post('https://api.anthropic.com/v1/messages', $args);
8545
8546 // Check for WordPress errors
8547 if (is_wp_error($response)) {
8548 //error_log("Claude API request error: " . $response->get_error_message());
8549 return "Sorry, there was an error connecting to the API.";
8550 }
8551
8552 // Check HTTP response code
8553 $http_code = wp_remote_retrieve_response_code($response);
8554 if ($http_code !== 200) {
8555 $error_body = wp_remote_retrieve_body($response);
8556 //error_log("Claude API HTTP error: " . $http_code . " - " . $error_body);
8557
8558 // Try to extract error message from response
8559 $error_data = json_decode($error_body, true);
8560 $error_message = isset($error_data['error']['message']) ?
8561 $error_data['error']['message'] :
8562 "HTTP error " . $http_code;
8563
8564 return "Sorry, the API returned an error: " . $error_message;
8565 }
8566
8567 // Parse response
8568 $response_body = json_decode(wp_remote_retrieve_body($response), true);
8569
8570 // Check for JSON decode errors
8571 if (json_last_error() !== JSON_ERROR_NONE) {
8572 //error_log("Claude API JSON decode error: " . json_last_error_msg());
8573 return "Sorry, there was an error processing the API response.";
8574 }
8575
8576 // Extract and validate response content
8577 if (isset($response_body['content']) &&
8578 is_array($response_body['content']) &&
8579 !empty($response_body['content']) &&
8580 isset($response_body['content'][0]['text'])) {
8581 return trim($response_body['content'][0]['text']);
8582 }
8583
8584 // Log unexpected response format
8585 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
8586 return "Sorry, I received an unexpected response format from the API.";
8587 }
8588 private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content) {
8589 try {
8590 // Ensure conversation_history is an array
8591 if (!is_array($conversation_history)) {
8592 $conversation_history = array();
8593 }
8594
8595 // Get bot ID from session or request
8596 $bot_id = $this->get_current_bot_id('');
8597
8598 // Get system prompt instructions using centralized function
8599 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8600
8601 // Create a new array for the formatted conversation
8602 $formatted_conversation = array();
8603
8604 // Add system message first
8605 $formatted_conversation[] = array(
8606 'role' => 'system',
8607 'content' => $system_prompt_instructions . " " . $relevant_content
8608 );
8609
8610 // Add the rest of the conversation history
8611 foreach ($conversation_history as $message) {
8612 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8613 $role = $message['role'];
8614
8615 // Convert roles to supported format
8616 if ($role === 'bot' || $role === 'agent') {
8617 $role = 'assistant';
8618 }
8619 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8620 $role = 'user';
8621 }
8622
8623 $formatted_conversation[] = array(
8624 'role' => $role,
8625 'content' => $message['content']
8626 );
8627 }
8628 }
8629
8630 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
8631 $is_gpt5_model = (
8632 strpos($selected_model, 'gpt-5') === 0 ||
8633 $selected_model === 'gpt-5.2' ||
8634 $selected_model === 'gpt-5.1-2025-11-13' ||
8635 $selected_model === 'gpt-5' ||
8636 $selected_model === 'gpt-5-mini' ||
8637 $selected_model === 'gpt-5-nano'
8638 );
8639
8640 // Build request body with optimal settings for fast responses
8641 $request_body = [
8642 'model' => $selected_model,
8643 'messages' => $formatted_conversation,
8644 'temperature' => 1,
8645 'stream' => false
8646 ];
8647
8648 // Add reasoning_effort only for GPT-5 models that support it
8649 // These chat models don't support reasoning_effort parameter
8650 $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');
8651 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_models, true)) {
8652 // GPT-5.1 uses 'low' instead of 'minimal'
8653 if ($selected_model === 'gpt-5.1-2025-11-13') {
8654 $request_body['reasoning_effort'] = 'low';
8655 } elseif ($selected_model === 'gpt-5.4') {
8656 $request_body['reasoning_effort'] = 'none';
8657 } else {
8658 $request_body['reasoning_effort'] = 'minimal';
8659 }
8660 }
8661
8662 $body = json_encode($request_body);
8663
8664 $args = [
8665 'body' => $body,
8666 'headers' => [
8667 'Content-Type' => 'application/json',
8668 'Authorization' => 'Bearer ' . $api_key,
8669 ],
8670 'timeout' => 60,
8671 'redirection' => 5,
8672 'blocking' => true,
8673 'httpversion' => '1.0',
8674 'sslverify' => true,
8675 ];
8676
8677 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', $args);
8678
8679 if (is_wp_error($response)) {
8680 $error_message = $response->get_error_message();
8681 return [
8682 'error' => esc_html__('Connection error when contacting OpenAI: ', 'mxchat') . esc_html($error_message),
8683 'error_code' => 'openai_connection_error',
8684 'provider' => 'openai'
8685 ];
8686 }
8687
8688 $status_code = wp_remote_retrieve_response_code($response);
8689 if ($status_code !== 200) {
8690 $response_body = wp_remote_retrieve_body($response);
8691 $decoded_response = json_decode($response_body, true);
8692
8693 $error_message = isset($decoded_response['error']['message'])
8694 ? $decoded_response['error']['message']
8695 : 'HTTP Error ' . $status_code;
8696
8697 $error_type = isset($decoded_response['error']['type'])
8698 ? $decoded_response['error']['type']
8699 : 'unknown';
8700
8701 // Handle specific error types
8702 switch ($error_type) {
8703 case 'invalid_request_error':
8704 if (strpos($error_message, 'API key') !== false) {
8705 return [
8706 'error' => esc_html__('Invalid OpenAI API key. Please check your API key configuration.', 'mxchat'),
8707 'error_code' => 'openai_invalid_api_key',
8708 'provider' => 'openai'
8709 ];
8710 }
8711 break;
8712
8713 case 'authentication_error':
8714 return [
8715 'error' => esc_html__('Authentication failed with OpenAI. Please check your API key.', 'mxchat'),
8716 'error_code' => 'openai_auth_error',
8717 'provider' => 'openai'
8718 ];
8719
8720 case 'rate_limit_exceeded':
8721 return [
8722 'error' => esc_html__('OpenAI rate limit exceeded. Please try again later.', 'mxchat'),
8723 'error_code' => 'openai_rate_limit',
8724 'provider' => 'openai'
8725 ];
8726
8727 case 'quota_exceeded':
8728 return [
8729 'error' => esc_html__('OpenAI API quota exceeded. Please check your billing details.', 'mxchat'),
8730 'error_code' => 'openai_quota_exceeded',
8731 'provider' => 'openai'
8732 ];
8733 }
8734
8735 // Generic error fallback
8736 return [
8737 'error' => esc_html__('OpenAI API error: ', 'mxchat') . esc_html($error_message),
8738 'error_code' => 'openai_api_error',
8739 'provider' => 'openai',
8740 'status_code' => $status_code
8741 ];
8742 }
8743
8744 $response_body = wp_remote_retrieve_body($response);
8745 $decoded_response = json_decode($response_body, true);
8746
8747 if (isset($decoded_response['choices'][0]['message']['content'])) {
8748 return trim($decoded_response['choices'][0]['message']['content']);
8749 } else {
8750 return [
8751 'error' => esc_html__('Unexpected response format from OpenAI.', 'mxchat'),
8752 'error_code' => 'openai_response_format_error',
8753 'provider' => 'openai'
8754 ];
8755 }
8756 } catch (Exception $e) {
8757 return [
8758 'error' => esc_html__('System error when processing OpenAI request: ', 'mxchat') . esc_html($e->getMessage()),
8759 'error_code' => 'openai_exception',
8760 'provider' => 'openai'
8761 ];
8762 }
8763 }
8764
8765 private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content) {
8766 try {
8767 // Get bot ID from session or request
8768 $bot_id = $this->get_current_bot_id($session_id);
8769
8770 // Get system prompt instructions using centralized function
8771 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8772
8773 // Add system prompt to relevant content
8774 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
8775
8776 // Prepend system instructions to the conversation history
8777 array_unshift($conversation_history, [
8778 'role' => 'system',
8779 'content' => "Here are your instructions: " . $content_with_instructions
8780 ]);
8781
8782 // Ensure consistency: Replace 'bot' and 'agent' roles with supported values
8783 foreach ($conversation_history as &$message) {
8784 if ($message['role'] === 'bot') {
8785 $message['role'] = 'assistant';
8786 } elseif ($message['role'] === 'agent') {
8787 // Tag the message as coming from a live agent
8788 $message['role'] = 'assistant';
8789 if (!isset($message['metadata'])) {
8790 $message['metadata'] = ['source' => 'live_agent'];
8791 }
8792 }
8793
8794 // Ensure all roles are valid
8795 if (!in_array($message['role'], ['system', 'assistant', 'user', 'function', 'tool'])) {
8796 $message['role'] = 'user'; // Default to 'user'
8797 }
8798 }
8799
8800 // Build the request body
8801 $body = json_encode([
8802 'model' => $selected_model,
8803 'messages' => $conversation_history,
8804 'temperature' => 0.8,
8805 'stream' => false
8806 ]);
8807
8808 // Set up the API request
8809 $args = [
8810 'body' => $body,
8811 'headers' => [
8812 'Content-Type' => 'application/json',
8813 'Authorization' => 'Bearer ' . $xai_api_key,
8814 ],
8815 'timeout' => 60,
8816 'redirection' => 5,
8817 'blocking' => true,
8818 'httpversion' => '1.0',
8819 'sslverify' => true,
8820 ];
8821
8822 // Make the API request
8823 $response = wp_remote_post('https://api.x.ai/v1/chat/completions', $args);
8824
8825 // Process the response
8826 if (is_wp_error($response)) {
8827 $error_message = $response->get_error_message();
8828 //error_log('X.AI API Error: ' . $error_message);
8829 return [
8830 'error' => esc_html__('Connection error when contacting X.AI: ', 'mxchat') . esc_html($error_message),
8831 'error_code' => 'xai_connection_error',
8832 'provider' => 'xai'
8833 ];
8834 }
8835
8836 $status_code = wp_remote_retrieve_response_code($response);
8837 if ($status_code !== 200) {
8838 $response_body = wp_remote_retrieve_body($response);
8839 $decoded_response = json_decode($response_body, true);
8840
8841 // Log the full response for debugging
8842 //error_log('X.AI Error Response: ' . print_r($decoded_response, true));
8843
8844 // Extract error message from X.AI's specific format
8845 $error_message = '';
8846
8847 // Check for direct error string (as seen in your logs)
8848 if (isset($decoded_response['error']) && is_string($decoded_response['error'])) {
8849 $error_message = $decoded_response['error'];
8850 }
8851 // Check for nested error object (OpenAI style)
8852 elseif (isset($decoded_response['error']['message'])) {
8853 $error_message = $decoded_response['error']['message'];
8854 }
8855 // Check for top-level message
8856 elseif (isset($decoded_response['message'])) {
8857 $error_message = $decoded_response['message'];
8858 }
8859 // Fallback
8860 else {
8861 $error_message = 'HTTP Error ' . $status_code;
8862 }
8863
8864 //error_log('X.AI API HTTP Error: ' . $status_code . ' - ' . $error_message);
8865
8866 // Check for API key errors using string matching
8867 if (stripos($error_message, 'api key') !== false ||
8868 stripos($error_message, 'incorrect api key') !== false ||
8869 stripos($error_message, 'invalid api key') !== false) {
8870 return [
8871 'error' => esc_html__('Invalid X.AI API key. Please check your API key configuration.', 'mxchat'),
8872 'error_code' => 'xai_invalid_api_key',
8873 'provider' => 'xai'
8874 ];
8875 }
8876
8877 // Authentication errors
8878 if ($status_code === 401 || $status_code === 403 ||
8879 stripos($error_message, 'auth') !== false) {
8880 return [
8881 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat'),
8882 'error_code' => 'xai_auth_error',
8883 'provider' => 'xai'
8884 ];
8885 }
8886
8887 // Model errors
8888 if (stripos($error_message, 'model') !== false) {
8889 return [
8890 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat'),
8891 'error_code' => 'xai_invalid_model',
8892 'provider' => 'xai'
8893 ];
8894 }
8895
8896 // Rate limit errors
8897 if ($status_code === 429 ||
8898 stripos($error_message, 'rate') !== false ||
8899 stripos($error_message, 'limit') !== false) {
8900 return [
8901 'error' => esc_html__('X.AI rate limit exceeded. Please try again later.', 'mxchat'),
8902 'error_code' => 'xai_rate_limit',
8903 'provider' => 'xai'
8904 ];
8905 }
8906
8907 // Quota errors
8908 if (stripos($error_message, 'quota') !== false ||
8909 stripos($error_message, 'billing') !== false) {
8910 return [
8911 'error' => esc_html__('X.AI API quota exceeded. Please check your billing details.', 'mxchat'),
8912 'error_code' => 'xai_quota_exceeded',
8913 'provider' => 'xai'
8914 ];
8915 }
8916
8917 // Server errors
8918 if ($status_code >= 500) {
8919 return [
8920 'error' => esc_html__('X.AI service is currently unavailable. Please try again later.', 'mxchat'),
8921 'error_code' => 'xai_service_unavailable',
8922 'provider' => 'xai'
8923 ];
8924 }
8925
8926 // Generic error fallback with the actual error message
8927 return [
8928 'error' => esc_html__('X.AI API error: ', 'mxchat') . esc_html($error_message),
8929 'error_code' => 'xai_api_error',
8930 'provider' => 'xai',
8931 'status_code' => $status_code
8932 ];
8933 }
8934
8935 $response_body = wp_remote_retrieve_body($response);
8936 $decoded_response = json_decode($response_body, true);
8937
8938 if (isset($decoded_response['choices'][0]['message']['content'])) {
8939 return trim($decoded_response['choices'][0]['message']['content']);
8940 } else {
8941 //error_log('X.AI API Response Format Error: ' . print_r($decoded_response, true));
8942 return [
8943 'error' => esc_html__('Unexpected response format from X.AI.', 'mxchat'),
8944 'error_code' => 'xai_response_format_error',
8945 'provider' => 'xai'
8946 ];
8947 }
8948 } catch (Exception $e) {
8949 //error_log('X.AI Exception: ' . $e->getMessage());
8950 return [
8951 'error' => esc_html__('System error when processing X.AI request: ', 'mxchat') . esc_html($e->getMessage()),
8952 'error_code' => 'xai_exception',
8953 'provider' => 'xai'
8954 ];
8955 }
8956
8957
8958 }
8959 private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content) {
8960 try {
8961 // Ensure conversation_history is an array
8962 if (!is_array($conversation_history)) {
8963 $conversation_history = array();
8964 }
8965
8966 // Get bot ID from session or request
8967 $bot_id = $this->get_current_bot_id($session_id);
8968
8969 // Get system prompt instructions using centralized function
8970 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8971
8972 // Create a new array for the formatted conversation
8973 $formatted_conversation = array();
8974
8975 // Add system message first
8976 $formatted_conversation[] = array(
8977 'role' => 'system',
8978 'content' => $system_prompt_instructions . " " . $relevant_content
8979 );
8980
8981 // Add the rest of the conversation history
8982 foreach ($conversation_history as $message) {
8983 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8984 $role = $message['role'];
8985
8986 // Convert roles to supported format
8987 if ($role === 'bot' || $role === 'agent') {
8988 $role = 'assistant';
8989 }
8990 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8991 $role = 'user';
8992 }
8993
8994 $formatted_conversation[] = array(
8995 'role' => $role,
8996 'content' => $message['content']
8997 );
8998 }
8999 }
9000
9001 $body = json_encode([
9002 'model' => $selected_model,
9003 'messages' => $formatted_conversation,
9004 'temperature' => 0.8,
9005 'stream' => false
9006 ]);
9007
9008 $args = [
9009 'body' => $body,
9010 'headers' => [
9011 'Content-Type' => 'application/json',
9012 'Authorization' => 'Bearer ' . $deepseek_api_key,
9013 ],
9014 'timeout' => 60,
9015 'redirection' => 5,
9016 'blocking' => true,
9017 'httpversion' => '1.0',
9018 'sslverify' => true,
9019 ];
9020
9021 $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', $args);
9022
9023 if (is_wp_error($response)) {
9024 $error_message = $response->get_error_message();
9025 //error_log('DeepSeek API Error: ' . $error_message);
9026 return [
9027 'error' => esc_html__('Connection error when contacting DeepSeek: ', 'mxchat') . esc_html($error_message),
9028 'error_code' => 'deepseek_connection_error',
9029 'provider' => 'deepseek'
9030 ];
9031 }
9032
9033 $status_code = wp_remote_retrieve_response_code($response);
9034 if ($status_code !== 200) {
9035 $response_body = wp_remote_retrieve_body($response);
9036 $decoded_response = json_decode($response_body, true);
9037
9038 $error_message = isset($decoded_response['error']['message'])
9039 ? $decoded_response['error']['message']
9040 : 'HTTP Error ' . $status_code;
9041
9042 $error_type = isset($decoded_response['error']['type'])
9043 ? $decoded_response['error']['type']
9044 : 'unknown';
9045
9046 //error_log('DeepSeek API HTTP Error: ' . $status_code . ' - ' . $error_message);
9047
9048 // Handle specific error types
9049 switch ($status_code) {
9050 case 401:
9051 return [
9052 'error' => esc_html__('Authentication failed with DeepSeek. Please check your API key.', 'mxchat'),
9053 'error_code' => 'deepseek_auth_error',
9054 'provider' => 'deepseek'
9055 ];
9056
9057 case 400:
9058 if (strpos($error_message, 'API key') !== false) {
9059 return [
9060 'error' => esc_html__('Invalid DeepSeek API key. Please check your API key configuration.', 'mxchat'),
9061 'error_code' => 'deepseek_invalid_api_key',
9062 'provider' => 'deepseek'
9063 ];
9064 }
9065 break;
9066
9067 case 429:
9068 if (strpos($error_message, 'quota') !== false) {
9069 return [
9070 'error' => esc_html__('DeepSeek API quota exceeded. Please check your billing details.', 'mxchat'),
9071 'error_code' => 'deepseek_quota_exceeded',
9072 'provider' => 'deepseek'
9073 ];
9074 } else {
9075 return [
9076 'error' => esc_html__('DeepSeek rate limit exceeded. Please try again later.', 'mxchat'),
9077 'error_code' => 'deepseek_rate_limit',
9078 'provider' => 'deepseek'
9079 ];
9080 }
9081
9082 case 500:
9083 case 502:
9084 case 503:
9085 case 504:
9086 return [
9087 'error' => esc_html__('DeepSeek service is currently unavailable. Please try again later.', 'mxchat'),
9088 'error_code' => 'deepseek_service_unavailable',
9089 'provider' => 'deepseek'
9090 ];
9091 }
9092
9093 // Generic error fallback
9094 return [
9095 'error' => esc_html__('DeepSeek API error: ', 'mxchat') . esc_html($error_message),
9096 'error_code' => 'deepseek_api_error',
9097 'provider' => 'deepseek',
9098 'status_code' => $status_code
9099 ];
9100 }
9101
9102 $response_body = wp_remote_retrieve_body($response);
9103 $decoded_response = json_decode($response_body, true);
9104
9105 if (isset($decoded_response['choices'][0]['message']['content'])) {
9106 return trim($decoded_response['choices'][0]['message']['content']);
9107 } else {
9108 //error_log('DeepSeek API Response Format Error: ' . print_r($decoded_response, true));
9109 return [
9110 'error' => esc_html__('Unexpected response format from DeepSeek.', 'mxchat'),
9111 'error_code' => 'deepseek_response_format_error',
9112 'provider' => 'deepseek'
9113 ];
9114 }
9115 } catch (Exception $e) {
9116 //error_log('DeepSeek Exception: ' . $e->getMessage());
9117 return [
9118 'error' => esc_html__('System error when processing DeepSeek request: ', 'mxchat') . esc_html($e->getMessage()),
9119 'error_code' => 'deepseek_exception',
9120 'provider' => 'deepseek'
9121 ];
9122 }
9123 }
9124 private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content) {
9125 // Get bot ID from session or request
9126 $bot_id = $this->get_current_bot_id($session_id);
9127
9128 // Get system prompt instructions using centralized function
9129 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9130
9131 // Add system prompt to relevant content
9132 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
9133
9134 // Format messages for Gemini API
9135 $formatted_messages = [];
9136
9137 // Add system message as the first user message with role prefix
9138 // Note: Gemini doesn't have a dedicated system role, so we use a prefixed user message
9139 $formatted_messages[] = [
9140 'role' => 'user',
9141 'parts' => [
9142 ['text' => "[System Instructions] " . $content_with_instructions]
9143 ]
9144 ];
9145
9146 // Add model response to acknowledge system instructions
9147 $formatted_messages[] = [
9148 'role' => 'model',
9149 'parts' => [
9150 ['text' => "I understand and will follow these instructions."]
9151 ]
9152 ];
9153
9154 // Process the rest of the conversation history
9155 $current_role = null;
9156 $current_parts = [];
9157
9158 foreach ($conversation_history as $message) {
9159 // Skip the first system message as we already handled it
9160 if ($message['role'] === 'system') {
9161 continue;
9162 }
9163
9164 // Map roles to Gemini format
9165 $gemini_role = '';
9166 if ($message['role'] === 'user') {
9167 $gemini_role = 'user';
9168 } else if (in_array($message['role'], ['assistant', 'bot', 'agent'])) {
9169 $gemini_role = 'model';
9170 } else {
9171 // Skip unsupported roles
9172 continue;
9173 }
9174
9175 // If we have a new role, add the previous message
9176 if ($current_role !== null && $current_role !== $gemini_role && !empty($current_parts)) {
9177 $formatted_messages[] = [
9178 'role' => $current_role,
9179 'parts' => $current_parts
9180 ];
9181 $current_parts = [];
9182 }
9183
9184 // Set current role and add text to parts
9185 $current_role = $gemini_role;
9186 $current_parts[] = ['text' => $message['content']];
9187 }
9188
9189 // Add the last message if there's content
9190 if ($current_role !== null && !empty($current_parts)) {
9191 $formatted_messages[] = [
9192 'role' => $current_role,
9193 'parts' => $current_parts
9194 ];
9195 }
9196
9197 // Build the request body
9198 $body = json_encode([
9199 'contents' => $formatted_messages,
9200 'generationConfig' => [
9201 'temperature' => 0.7,
9202 'topP' => 0.95,
9203 'topK' => 40,
9204 'maxOutputTokens' => 8192,
9205 ],
9206 'safetySettings' => [
9207 [
9208 'category' => 'HARM_CATEGORY_HARASSMENT',
9209 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9210 ],
9211 [
9212 'category' => 'HARM_CATEGORY_HATE_SPEECH',
9213 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9214 ],
9215 [
9216 'category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
9217 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9218 ],
9219 [
9220 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT',
9221 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9222 ]
9223 ]
9224 ]);
9225
9226 // Prepare the API endpoint
9227 // Use v1beta for preview models (Gemini 3, experimental), v1 for stable models
9228 $api_version = (strpos($selected_model, 'preview') !== false || strpos($selected_model, 'exp') !== false) ? 'v1beta' : 'v1';
9229 $api_endpoint = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
9230
9231 // Set up the API request
9232 $args = [
9233 'body' => $body,
9234 'headers' => [
9235 'Content-Type' => 'application/json',
9236 ],
9237 'timeout' => 60,
9238 'redirection' => 5,
9239 'blocking' => true,
9240 'httpversion' => '1.0',
9241 'sslverify' => true,
9242 ];
9243
9244 // Make the API request
9245 $response = wp_remote_post($api_endpoint, $args);
9246
9247 // Process the response
9248 if (is_wp_error($response)) {
9249 return "Sorry, there was an error processing your request: " . $response->get_error_message();
9250 }
9251
9252 $response_body = json_decode(wp_remote_retrieve_body($response), true);
9253
9254 // Handle potential errors in the response
9255 if (isset($response_body['error'])) {
9256 //error_log('Gemini API Error: ' . json_encode($response_body['error']));
9257 return "Sorry, there was an error with the Gemini API: " .
9258 (isset($response_body['error']['message']) ? $response_body['error']['message'] : 'Unknown error');
9259 }
9260
9261 // Extract the response text
9262 if (isset($response_body['candidates'][0]['content']['parts'][0]['text'])) {
9263 return trim($response_body['candidates'][0]['content']['parts'][0]['text']);
9264 } else {
9265 //error_log('Unexpected Gemini API response format: ' . json_encode($response_body));
9266 return "Sorry, I couldn't process that request. The response format was unexpected.";
9267 }
9268 }
9269
9270
9271 public function test_streaming_request() {
9272 $options = get_option('mxchat_options', []);
9273 $model = $options['model'] ?? 'gpt-5.1-chat-latest';
9274
9275 // Detect provider from model prefix
9276 $provider = strtolower(explode('-', $model)[0]);
9277
9278 $sample_prompt = 'Hello! Can you stream this response back to me?';
9279 $messages = [['role' => 'user', 'content' => $sample_prompt]];
9280 $headers = [];
9281 $body = [];
9282 $url = '';
9283 $api_key = '';
9284
9285 switch ($provider) {
9286 case 'gpt':
9287 case 'o1':
9288 $api_key = $options['api_key'] ?? '';
9289 if (empty($api_key)) return '❌ Missing API key for OpenAI';
9290 $url = 'https://api.openai.com/v1/chat/completions';
9291 $headers = [
9292 'Content-Type: application/json',
9293 'Authorization: Bearer ' . $api_key
9294 ];
9295 $body = [
9296 'model' => $model,
9297 'messages' => $messages,
9298 'stream' => true
9299 ];
9300 break;
9301
9302 case 'claude':
9303 $api_key = $options['claude_api_key'] ?? '';
9304 if (empty($api_key)) return '❌ Missing API key for Claude';
9305 $url = 'https://api.anthropic.com/v1/messages';
9306 $headers = [
9307 'Content-Type: application/json',
9308 'x-api-key: ' . $api_key,
9309 'anthropic-version: 2023-06-01'
9310 ];
9311 $body = [
9312 'model' => $model,
9313 'messages' => $messages,
9314 'max_tokens' => 100,
9315 'stream' => true
9316 ];
9317 break;
9318
9319 case 'grok':
9320 $api_key = $options['xai_api_key'] ?? '';
9321 if (empty($api_key)) return '❌ Missing API key for X.AI';
9322 $url = 'https://api.x.ai/v1/chat/completions';
9323 $headers = [
9324 'Content-Type: application/json',
9325 'Authorization: Bearer ' . $api_key
9326 ];
9327 $body = [
9328 'model' => $model,
9329 'messages' => $messages,
9330 'stream' => true
9331 ];
9332 break;
9333
9334 case 'deepseek':
9335 if (empty($deepseek_api_key)) {
9336 $error_response = [
9337 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
9338 'error_code' => 'missing_deepseek_api_key'
9339 ];
9340 if ($testing_data !== null) {
9341 $error_response['testing_data'] = $testing_data;
9342 }
9343 return $error_response;
9344 }
9345 if ($streaming) {
9346 return $this->mxchat_generate_response_deepseek_stream(
9347 $selected_model,
9348 $deepseek_api_key,
9349 $conversation_history,
9350 $relevant_content,
9351 $session_id,
9352 $testing_data // Pass testing data
9353 );
9354 } else {
9355 $response = $this->mxchat_generate_response_deepseek(
9356 $selected_model,
9357 $deepseek_api_key,
9358 $conversation_history,
9359 $relevant_content
9360 );
9361 }
9362 break;
9363
9364 case 'gemini':
9365 $api_key = $options['gemini_api_key'] ?? '';
9366 if (empty($api_key)) return '❌ Missing API key for Gemini';
9367 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':streamGenerateContent?key=' . $api_key;
9368 $headers = ['Content-Type: application/json'];
9369 $body = [
9370 'contents' => [['role' => 'user', 'parts' => [['text' => $sample_prompt]]]],
9371 'generationConfig' => ['temperature' => 0.7]
9372 ];
9373 break;
9374
9375 default:
9376 return '❌ Unsupported provider: ' . $provider;
9377 }
9378
9379 // Do the actual streaming test
9380 $ch = curl_init($url);
9381 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
9382 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
9383 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
9384 curl_setopt($ch, CURLOPT_TIMEOUT, 15);
9385 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9386
9387 $response = curl_exec($ch);
9388 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
9389 $error = curl_error($ch);
9390 curl_close($ch);
9391
9392 if ($error) return "❌ cURL error: $error";
9393 if ($http_code !== 200) {
9394 $error_message = json_decode($response, true)['error']['message'] ?? 'Unknown';
9395 return "❌ HTTP $http_code: $error_message";
9396 }
9397
9398 return true;
9399 }
9400
9401 public function mxchat_dismiss_pre_chat_message() {
9402 // Get and sanitize the user identifier
9403 $user_id = $this->mxchat_get_user_identifier();
9404 $user_id = sanitize_key($user_id);
9405
9406 // Set a transient to track that the user has dismissed the pre-chat message
9407 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9408 set_transient($transient_key, true, DAY_IN_SECONDS);
9409
9410 wp_send_json_success();
9411 }
9412
9413 public function mxchat_check_pre_chat_message_status() {
9414 // Get and sanitize the user identifier
9415 $user_id = $this->mxchat_get_user_identifier();
9416 $user_id = sanitize_key($user_id);
9417
9418 // Check if the transient exists (i.e., if the message was dismissed)
9419 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9420 $dismissed = get_transient($transient_key);
9421
9422 // Log the result to see if it's being set correctly
9423 //error_log("Check pre-chat message dismissed for $user_id: " . ($dismissed ? 'Yes' : 'No'));
9424
9425 if ($dismissed) {
9426 wp_send_json_success(['dismissed' => true]);
9427 } else {
9428 wp_send_json_success(['dismissed' => false]);
9429 }
9430
9431 wp_die();
9432 }
9433
9434 private function mxchat_calculate_cosine_similarity($vectorA, $vectorB) {
9435 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
9436 return 0;
9437 }
9438
9439 $dotProduct = array_sum(array_map(function ($a, $b) {
9440 return $a * $b;
9441 }, $vectorA, $vectorB));
9442 $normA = sqrt(array_sum(array_map(function ($a) {
9443 return $a * $a;
9444 }, $vectorA)));
9445 $normB = sqrt(array_sum(array_map(function ($b) {
9446 return $b * $b;
9447 }, $vectorB)));
9448
9449 if ($normA == 0 || $normB == 0) {
9450 return 0;
9451 }
9452
9453 return $dotProduct / ($normA * $normB);
9454 }
9455
9456
9457 public function mxchat_enqueue_scripts_styles() {
9458 // Fetch options from the database first to check loading strategy
9459 $this->options = get_option('mxchat_options');
9460 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
9461
9462 // Always enqueue CSS immediately
9463 wp_enqueue_style(
9464 'mxchat-chat-css',
9465 plugin_dir_url(__FILE__) . '../css/chat-style.css',
9466 array(),
9467 MXCHAT_VERSION
9468 );
9469
9470 // Handle script loading based on strategy
9471 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
9472 // Enqueue the script normally
9473 wp_enqueue_script(
9474 'mxchat-chat-js',
9475 plugin_dir_url(__FILE__) . '../js/chat-script.js',
9476 array('jquery'),
9477 MXCHAT_VERSION,
9478 true
9479 );
9480
9481 // Add defer attribute if strategy is 'defer'
9482 if ($loading_strategy === 'defer') {
9483 wp_script_add_data('mxchat-chat-js', 'strategy', 'defer');
9484 }
9485 } else {
9486 // For delay or interaction-based loading, we'll use a custom loader
9487 // Don't enqueue the main script - we'll load it dynamically
9488 add_action('wp_footer', array($this, 'mxchat_output_delayed_script_loader'), 99);
9489 }
9490
9491 $prompts_options = get_option('mxchat_prompts_options', array());
9492
9493 // Check if AI theme is active - if so, skip inline colors in JavaScript
9494 $theme_options = get_option('mxchat_theme_options', array());
9495 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9496 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9497 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9498
9499 // Prepare settings for JavaScript
9500 $style_settings = array(
9501 'ajax_url' => admin_url('admin-ajax.php'),
9502 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9503 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
9504 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
9505 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
9506 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
9507 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9508 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
9509 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
9510 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
9511 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
9512 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
9513 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
9514 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
9515 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
9516 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
9517 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
9518 'icon_color' => $this->options['icon_color'] ?? '#fff',
9519 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
9520 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
9521 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
9522 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
9523 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
9524 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
9525 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
9526 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
9527 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
9528 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9529 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off', // FIXED
9530 'initial_email_state' => null, // Also fixed this undefined variable
9531 'skip_email_check' => true,
9532 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9533 'skip_inline_colors' => $skip_inline_colors,
9534 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9535 );
9536
9537 // For normal/defer loading, use wp_localize_script
9538 // For delayed loading, we store settings in a transient to be output inline
9539 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
9540 wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
9541 } else {
9542 // Store settings for the delayed loader to use
9543 set_transient('mxchat_delayed_settings_' . get_current_user_id(), $style_settings, 60);
9544 }
9545 }
9546
9547 /**
9548 * Output the delayed script loader for performance optimization
9549 */
9550 public function mxchat_output_delayed_script_loader() {
9551 $this->options = get_option('mxchat_options');
9552 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
9553 $script_url = plugin_dir_url(__FILE__) . '../js/chat-script.js?ver=' . MXCHAT_VERSION;
9554
9555 // Get the stored settings
9556 $prompts_options = get_option('mxchat_prompts_options', array());
9557 $theme_options = get_option('mxchat_theme_options', array());
9558 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9559 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9560 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9561
9562 $style_settings = array(
9563 'ajax_url' => admin_url('admin-ajax.php'),
9564 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9565 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
9566 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
9567 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
9568 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
9569 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9570 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
9571 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
9572 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
9573 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
9574 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
9575 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
9576 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
9577 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
9578 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
9579 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
9580 'icon_color' => $this->options['icon_color'] ?? '#fff',
9581 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
9582 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
9583 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
9584 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
9585 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
9586 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
9587 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
9588 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
9589 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
9590 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9591 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off',
9592 'initial_email_state' => null,
9593 'skip_email_check' => true,
9594 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9595 'skip_inline_colors' => $skip_inline_colors,
9596 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9597 );
9598
9599 // Determine delay time based on strategy
9600 $delay_ms = 0;
9601 switch ($loading_strategy) {
9602 case 'delay_1s':
9603 $delay_ms = 1000;
9604 break;
9605 case 'delay_3s':
9606 $delay_ms = 3000;
9607 break;
9608 case 'delay_5s':
9609 $delay_ms = 5000;
9610 break;
9611 }
9612
9613 ?>
9614 <script type="text/javascript">
9615 (function() {
9616 var mxchatLoaded = false;
9617 var mxchatChat = <?php echo wp_json_encode($style_settings); ?>;
9618 window.mxchatChat = mxchatChat;
9619
9620 function loadMxChatScript() {
9621 if (mxchatLoaded) return;
9622 mxchatLoaded = true;
9623
9624 function appendChatScript() {
9625 var script = document.createElement('script');
9626 script.src = <?php echo wp_json_encode($script_url); ?>;
9627 script.type = 'text/javascript';
9628 document.body.appendChild(script);
9629 }
9630
9631 if (typeof jQuery !== 'undefined') {
9632 appendChatScript();
9633 } else {
9634 var jq = document.createElement('script');
9635 jq.src = <?php echo wp_json_encode(includes_url('js/jquery/jquery.min.js')); ?>;
9636 jq.onload = appendChatScript;
9637 document.body.appendChild(jq);
9638 }
9639 }
9640
9641 <?php if ($loading_strategy === 'on_interaction'): ?>
9642 // Load on user interaction
9643 var events = ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'];
9644 events.forEach(function(evt) {
9645 window.addEventListener(evt, loadMxChatScript, {once: true, passive: true});
9646 });
9647 // Fallback: load after 8 seconds if no interaction
9648 setTimeout(loadMxChatScript, 8000);
9649 <?php else: ?>
9650 // Load after specified delay
9651 setTimeout(loadMxChatScript, <?php echo intval($delay_ms); ?>);
9652 <?php endif; ?>
9653 })();
9654 </script>
9655 <?php
9656 }
9657
9658 /**
9659 * Setup the cron jobs for rate limits with guard against multiple calls
9660 */
9661 public function setup_rate_limit_cron_jobs() {
9662 // Add a guard to prevent multiple rapid calls
9663 $last_setup = get_transient('mxchat_cron_setup_guard');
9664 if ($last_setup && (time() - $last_setup) < 60) {
9665 // Don't run again if we ran less than 60 seconds ago
9666 return;
9667 }
9668
9669 // Set the guard
9670 set_transient('mxchat_cron_setup_guard', time(), 300); // 5 minutes
9671
9672 try {
9673 // First, check if WordPress cron is disabled
9674 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
9675 //error_log('MxChat: WordPress cron is disabled (DISABLE_WP_CRON = true), using fallback system');
9676 $this->setup_fallback_rate_limit_system();
9677 return;
9678 }
9679
9680 // Check if cron is already scheduled - if so, don't mess with it
9681 if (wp_next_scheduled('mxchat_reset_rate_limits')) {
9682 //error_log('MxChat: Rate limit cron already scheduled, skipping setup');
9683 return;
9684 }
9685
9686 // Clear any orphaned hooks (but don't loop indefinitely)
9687 $hooks_to_clear = [
9688 'mxchat_reset_rate_limits',
9689 'mxchat_reset_hourly_rate_limits',
9690 'mxchat_reset_daily_rate_limits',
9691 'mxchat_reset_weekly_rate_limits',
9692 'mxchat_reset_monthly_rate_limits'
9693 ];
9694
9695 foreach ($hooks_to_clear as $hook) {
9696 // Only clear a maximum of 3 instances to prevent infinite loops
9697 $cleared = 0;
9698 while (wp_next_scheduled($hook) && $cleared < 3) {
9699 wp_clear_scheduled_hook($hook);
9700 $cleared++;
9701 }
9702 }
9703
9704 // Small delay after clearing
9705 usleep(100000); // 0.1 seconds
9706
9707 // Try to schedule the event
9708 $initial_time = time() + 300; // Start in 5 minutes
9709 $result = wp_schedule_event($initial_time, 'hourly', 'mxchat_reset_rate_limits');
9710
9711 if ($result === false) {
9712 //error_log('MxChat: Failed to schedule cron, using fallback system');
9713 $this->setup_fallback_rate_limit_system();
9714 } else {
9715 //error_log('MxChat: Successfully scheduled rate limit reset cron');
9716 }
9717
9718 } catch (Exception $e) {
9719 //error_log('MxChat: Cron setup exception: ' . $e->getMessage());
9720 $this->setup_fallback_rate_limit_system();
9721 }
9722 }
9723
9724 /**
9725 * Try alternative cron scheduling methods
9726 */
9727 private function try_alternative_cron_scheduling($initial_time) {
9728 try {
9729 // Method 1: Try with current time instead of future time
9730 $result1 = wp_schedule_event(time(), 'hourly', 'mxchat_reset_rate_limits');
9731 if ($result1 !== false) {
9732 //error_log('MxChat: Alternative method 1 (current time) succeeded');
9733 return true;
9734 }
9735
9736 // Method 2: Try with a different interval
9737 $result2 = wp_schedule_event($initial_time, 'daily', 'mxchat_reset_rate_limits');
9738 if ($result2 !== false) {
9739 //error_log('MxChat: Alternative method 2 (daily interval) succeeded');
9740 return true;
9741 }
9742
9743 // Method 3: Try wp_schedule_single_event first, then recurring
9744 $result3 = wp_schedule_single_event($initial_time, 'mxchat_reset_rate_limits');
9745 if ($result3 !== false) {
9746 //error_log('MxChat: Alternative method 3 (single event) succeeded');
9747 // Schedule the next one manually in the handler
9748 return true;
9749 }
9750
9751 return false;
9752
9753 } catch (Exception $e) {
9754 //error_log('MxChat: Alternative cron scheduling exception: ' . $e->getMessage());
9755 return false;
9756 }
9757 }
9758
9759 /**
9760 * Enhanced fallback rate limit system
9761 */
9762 private function setup_fallback_rate_limit_system() {
9763 // Set a flag to use database-based rate limit cleanup
9764 update_option('mxchat_use_fallback_rate_limits', true);
9765
9766 // Schedule a one-time check to happen on the next plugin load
9767 update_option('mxchat_next_rate_limit_check', time() + 3600);
9768
9769 // Also set up a more frequent fallback check (every 4 hours)
9770 update_option('mxchat_fallback_check_interval', 4 * 3600);
9771
9772 //error_log('MxChat: Fallback rate limit system activated');
9773 }
9774
9775 /**
9776 * Enhanced fallback check method
9777 */
9778 public function check_fallback_rate_limits() {
9779 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9780
9781 if (!$use_fallback) {
9782 return; // Regular cron is working
9783 }
9784
9785 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9786 $check_interval = get_option('mxchat_fallback_check_interval', 3600);
9787
9788 if (time() >= $next_check) {
9789 //error_log('MxChat: Running fallback rate limit cleanup');
9790 $this->mxchat_reset_rate_limits();
9791
9792 // Schedule next check
9793 update_option('mxchat_next_rate_limit_check', time() + $check_interval);
9794 }
9795 }
9796 /**
9797 * Enhanced rate limit check that includes fallback cleanup and bot-specific rate limits
9798 */
9799 public function check_rate_limit() {
9800 // Check if we need to run fallback cleanup
9801 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9802 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9803
9804 if ($use_fallback && time() >= $next_check) {
9805 $this->mxchat_reset_rate_limits();
9806 update_option('mxchat_next_rate_limit_check', time() + 3600); // Next hour
9807 }
9808
9809 // Get bot ID from current request context
9810 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
9811
9812 // Get bot-specific options (includes rate limits if overridden)
9813 $bot_options = $this->get_bot_options($bot_id);
9814 $current_options = !empty($bot_options) ? $bot_options : $this->options;
9815
9816 // Use bot-specific rate limits if available, otherwise fall back to default
9817 $rate_limits_source = isset($current_options['rate_limits']) ? $current_options['rate_limits'] : get_option('mxchat_options', [])['rate_limits'] ?? [];
9818
9819 // Determine user role or if logged out
9820 if (is_user_logged_in()) {
9821 $user = wp_get_current_user();
9822 $user_id = $user->ID;
9823
9824 // Get the user's primary role using reset() to safely get the first element
9825 $user_roles = $user->roles;
9826
9827 // Safely get the first role regardless of array key structure
9828 if (!empty($user_roles) && is_array($user_roles)) {
9829 $role = reset($user_roles); // This safely gets the first element regardless of key
9830 } else {
9831 $role = 'subscriber'; // Default to subscriber if no role found
9832 }
9833 } else {
9834 $role = 'logged_out';
9835 // Use IP address for non-logged-in users
9836 $user_id = $this->get_client_ip();
9837 }
9838
9839 // Check if rate limits are configured for this role
9840 if (!isset($rate_limits_source[$role])) {
9841 return true; // No limit set for this role
9842 }
9843
9844 $limit = $rate_limits_source[$role]['limit'];
9845
9846 // If unlimited, return true immediately
9847 if ($limit === 'unlimited') {
9848 return true;
9849 }
9850
9851 // Get the option name for this user/role with safer naming (include bot_id for bot-specific limits)
9852 $safe_role = preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
9853 $safe_user_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $user_id);
9854 $safe_bot_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id);
9855
9856 // Include bot_id in option name so each bot has separate rate limits
9857 $option_name = 'mxchat_chat_limit_' . $safe_bot_id . '_' . $safe_role . '_' . $safe_user_id;
9858
9859 // Get the counter data
9860 $limit_data = get_option($option_name, ['count' => 0, 'timestamp' => time()]);
9861
9862 // If first request or counter reset needed, set the initial timestamp
9863 if ($limit_data['count'] === 0) {
9864 $limit_data['timestamp'] = time();
9865 update_option($option_name, $limit_data);
9866 }
9867
9868 // Get the timeframe
9869 $timeframe = isset($rate_limits_source[$role]['timeframe']) ?
9870 $rate_limits_source[$role]['timeframe'] : 'daily';
9871
9872 // Check if the counter needs to be reset based on timeframe
9873 $current_time = time();
9874 $timestamp = $limit_data['timestamp'];
9875 $should_reset = false;
9876
9877 switch ($timeframe) {
9878 case 'hourly':
9879 $should_reset = ($current_time - $timestamp) >= 3600; // 1 hour
9880 break;
9881 case 'daily':
9882 $should_reset = ($current_time - $timestamp) >= 86400; // 24 hours
9883 break;
9884 case 'weekly':
9885 $should_reset = ($current_time - $timestamp) >= 604800; // 7 days
9886 break;
9887 case 'monthly':
9888 $should_reset = ($current_time - $timestamp) >= 2592000; // 30 days
9889 break;
9890 }
9891
9892 // Reset the counter if the timeframe has passed
9893 if ($should_reset) {
9894 $limit_data = ['count' => 0, 'timestamp' => $current_time];
9895 update_option($option_name, $limit_data);
9896 }
9897
9898 // Check if user has exceeded their limit
9899 if ($limit_data['count'] >= intval($limit)) {
9900 // Get the custom message for this role
9901 $message = !empty($rate_limits_source[$role]['message'])
9902 ? $rate_limits_source[$role]['message']
9903 : __('Rate limit exceeded. Please try again later.', 'mxchat');
9904
9905 // Add timeframe information to the message if placeholders exist
9906 $timeframe_label = '';
9907 switch ($timeframe) {
9908 case 'hourly':
9909 $timeframe_label = __('hour', 'mxchat');
9910 break;
9911 case 'daily':
9912 $timeframe_label = __('day', 'mxchat');
9913 break;
9914 case 'weekly':
9915 $timeframe_label = __('week', 'mxchat');
9916 break;
9917 case 'monthly':
9918 $timeframe_label = __('month', 'mxchat');
9919 break;
9920 }
9921
9922 // Replace placeholders in the message
9923 $message = str_replace(
9924 ['{limit}', '{count}', '{remaining}', '{timeframe}'],
9925 [intval($limit), $limit_data['count'], max(0, intval($limit) - $limit_data['count']), $timeframe_label],
9926 $message
9927 );
9928
9929 // Process HTML links in the message
9930 $message = $this->process_rate_limit_message_html($message);
9931
9932 // Return error with the processed message
9933 return [
9934 'error' => true,
9935 'message' => $message
9936 ];
9937 }
9938
9939 // Increment the counter
9940 $limit_data['count']++;
9941 update_option($option_name, $limit_data);
9942
9943 return true;
9944 }
9945
9946 /**
9947 * Enhanced rate limit reset with better error handling
9948 */
9949 public function mxchat_reset_rate_limits() {
9950 try {
9951 global $wpdb;
9952 $all_options = get_option('mxchat_options', []);
9953 $current_time = time();
9954
9955 // Get rate limit options with a safer query and limit
9956 $option_names = $wpdb->get_col(
9957 $wpdb->prepare(
9958 "SELECT option_name FROM {$wpdb->options}
9959 WHERE option_name LIKE %s
9960 LIMIT 1000",
9961 'mxchat_chat_limit_%'
9962 )
9963 );
9964
9965 if (empty($option_names)) {
9966 return;
9967 }
9968
9969 $processed_count = 0;
9970 $max_processing_time = 30; // Maximum 30 seconds
9971 $start_time = time();
9972
9973 foreach ($option_names as $option_name) {
9974 // Check processing time limit
9975 if ((time() - $start_time) > $max_processing_time) {
9976 //error_log('MxChat: Rate limit reset timeout after processing ' . $processed_count . ' entries');
9977 break;
9978 }
9979
9980 // Parse the option name more safely
9981 if (!preg_match('/^mxchat_chat_limit_(.+)_(.+)$/', $option_name, $matches)) {
9982 continue;
9983 }
9984
9985 $role_and_user = $matches[1] . '_' . $matches[2];
9986 $parts = explode('_', $role_and_user);
9987
9988 if (count($parts) < 2) {
9989 continue;
9990 }
9991
9992 // Extract role (everything except the last part which is user ID)
9993 $user_id_part = array_pop($parts);
9994 $role = implode('_', $parts);
9995
9996 // Skip if role doesn't exist in our settings
9997 if (!isset($all_options['rate_limits'][$role])) {
9998 // Clean up orphaned entries
9999 delete_option($option_name);
10000 continue;
10001 }
10002
10003 $timeframe = $all_options['rate_limits'][$role]['timeframe'] ?? 'daily';
10004 $limit_data = get_option($option_name);
10005
10006 if (!$limit_data || !is_array($limit_data) || !isset($limit_data['timestamp'])) {
10007 // Clean up invalid entries
10008 delete_option($option_name);
10009 continue;
10010 }
10011
10012 $timestamp = $limit_data['timestamp'];
10013 $should_reset = false;
10014
10015 // Determine if we should reset based on the timeframe
10016 switch ($timeframe) {
10017 case 'hourly':
10018 $should_reset = ($current_time - $timestamp) >= 3600;
10019 break;
10020 case 'daily':
10021 $should_reset = ($current_time - $timestamp) >= 86400;
10022 break;
10023 case 'weekly':
10024 $should_reset = ($current_time - $timestamp) >= 604800;
10025 break;
10026 case 'monthly':
10027 $should_reset = ($current_time - $timestamp) >= 2592000;
10028 break;
10029 }
10030
10031 // Reset the counter if the timeframe has passed
10032 if ($should_reset) {
10033 delete_option($option_name);
10034 wp_cache_delete($option_name, 'options');
10035 $processed_count++;
10036 }
10037 }
10038
10039 // Clean up any orphaned cache entries
10040 wp_cache_delete('mxchat_all_chat_limits', 'options');
10041
10042 //error_log("MxChat: Rate limit reset completed. Processed {$processed_count} entries.");
10043
10044 } catch (Exception $e) {
10045 //error_log('MxChat: Rate limit reset error: ' . $e->getMessage());
10046 }
10047 }
10048
10049
10050 /**
10051 * Process HTML links in rate limit messages
10052 *
10053 * @param string $message The rate limit message
10054 * @return string The processed message with safe HTML links
10055 */
10056 private function process_rate_limit_message_html($message) {
10057 // Return original message if empty
10058 if (empty($message)) {
10059 return $message;
10060 }
10061
10062 // First, convert markdown links to HTML
10063 $message = $this->convert_markdown_links($message);
10064
10065 // Then, auto-convert any remaining plain URLs to links
10066 $message = $this->auto_link_urls($message);
10067
10068 // Allow basic HTML tags for links and formatting
10069 $allowed_tags = [
10070 'a' => [
10071 'href' => true,
10072 'target' => true,
10073 'rel' => true,
10074 'title' => true,
10075 'class' => true
10076 ],
10077 'strong' => [],
10078 'em' => [],
10079 'br' => [],
10080 'b' => [],
10081 'i' => [],
10082 'span' => ['class' => true]
10083 ];
10084
10085 // Sanitize but allow the specified HTML tags
10086 $processed_message = wp_kses($message, $allowed_tags);
10087
10088 // If wp_kses stripped everything, return the original message as plain text
10089 if (empty($processed_message) && !empty($message)) {
10090 // Strip all HTML and return plain text as fallback
10091 return wp_strip_all_tags($message);
10092 }
10093
10094 return $processed_message;
10095 }
10096
10097 /**
10098 * Convert markdown links to HTML
10099 *
10100 * @param string $text The text to process
10101 * @return string The text with markdown links converted to HTML
10102 */
10103 private function convert_markdown_links($text) {
10104 // Return original text if empty
10105 if (empty($text)) {
10106 return $text;
10107 }
10108
10109 // Pattern to match markdown links: [text](url)
10110 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
10111
10112 $processed_text = preg_replace_callback($pattern, function($matches) {
10113 $link_text = $matches[1];
10114 $url = $matches[2];
10115
10116 // Clean up any trailing punctuation from the URL
10117 $url = rtrim($url, '.,;:!?');
10118
10119 // Sanitize the link text and URL
10120 $safe_text = esc_html($link_text);
10121 $safe_url = esc_url($url);
10122
10123 // Create the HTML link
10124 return '<a href="' . $safe_url . '" target="_blank" rel="noopener noreferrer">' . $safe_text . '</a>';
10125 }, $text);
10126
10127 // If preg_replace_callback failed, return original text
10128 if ($processed_text === null) {
10129 return $text;
10130 }
10131
10132 return $processed_text;
10133 }
10134
10135 /**
10136 * Auto-convert plain URLs to clickable links
10137 *
10138 * @param string $text The text to process
10139 * @return string The text with URLs converted to links
10140 */
10141 private function auto_link_urls($text) {
10142 // Return original text if empty
10143 if (empty($text)) {
10144 return $text;
10145 }
10146
10147 // Simple pattern that avoids complex lookbehinds
10148 // This will match URLs that are not already inside href attributes or markdown links
10149 $pattern = '/(?<!href=["\'])(?<!\]\()https?:\/\/[^\s<>"\')\]]+/i';
10150
10151 $processed_text = preg_replace_callback($pattern, function($matches) {
10152 $url = $matches[0];
10153 // Clean up any trailing punctuation that might have been captured
10154 $url = rtrim($url, '.,;:!?');
10155
10156 // Add target="_blank" and rel="noopener noreferrer" for security
10157 return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
10158 }, $text);
10159
10160 // If preg_replace_callback failed, return original text
10161 if ($processed_text === null) {
10162 return $text;
10163 }
10164
10165 return $processed_text;
10166 }
10167
10168
10169 // Helper function to get client IP address
10170 private function get_client_ip() {
10171 // Check for shared internet/ISP IP
10172 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
10173 return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
10174 }
10175
10176 // Check for IPs passing through proxies
10177 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
10178 // Use the first value in the comma-separated list
10179 $forwarded_for = explode(',', sanitize_text_field($_SERVER['HTTP_X_FORWARDED_FOR']));
10180 return trim($forwarded_for[0]);
10181 }
10182
10183 if (!empty($_SERVER['REMOTE_ADDR'])) {
10184 return sanitize_text_field($_SERVER['REMOTE_ADDR']);
10185 }
10186
10187 // Fallback
10188 return 'unknown';
10189 }
10190
10191 /**
10192 * AJAX handler to get system information for testing panel
10193 */
10194 /**
10195 * AJAX handler to get system information for testing panel
10196 */
10197 public function mxchat_get_system_info() {
10198 // Verify nonce for security
10199 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10200 wp_send_json_error(['message' => 'Invalid nonce']);
10201 return;
10202 }
10203
10204 // Only allow admin users
10205 if (!current_user_can('administrator')) {
10206 wp_send_json_error(['message' => 'Unauthorized']);
10207 return;
10208 }
10209
10210 // Get system prompt from options
10211 $system_prompt = isset($this->options['system_prompt_instructions'])
10212 ? $this->options['system_prompt_instructions']
10213 : 'No system prompt configured';
10214
10215 // Get selected model
10216 $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest';
10217
10218 // Check if OpenRouter is being used
10219 $is_openrouter = ($selected_model === 'openrouter');
10220 $openrouter_model = '';
10221
10222 if ($is_openrouter) {
10223 // Get the actual OpenRouter model that's selected
10224 $openrouter_model = isset($this->options['openrouter_selected_model'])
10225 ? $this->options['openrouter_selected_model']
10226 : 'No OpenRouter model selected';
10227
10228 // Update selected_model display to show both
10229 $selected_model = 'OpenRouter: ' . $openrouter_model;
10230 }
10231
10232 // Get API key status (just check if they exist, don't expose the keys)
10233 $api_status = [];
10234 $api_status['openai'] = !empty($this->options['api_key']);
10235 $api_status['claude'] = !empty($this->options['claude_api_key']);
10236 $api_status['gemini'] = !empty($this->options['gemini_api_key']);
10237 $api_status['xai'] = !empty($this->options['xai_api_key']);
10238 $api_status['deepseek'] = !empty($this->options['deepseek_api_key']);
10239 $api_status['openrouter'] = !empty($this->options['openrouter_api_key']);
10240
10241 wp_send_json_success([
10242 'system_prompt' => $system_prompt,
10243 'selected_model' => $selected_model,
10244 'is_openrouter' => $is_openrouter,
10245 'openrouter_model' => $openrouter_model,
10246 'api_status' => $api_status
10247 ]);
10248 }
10249
10250 /**
10251 * AJAX handler to get similarity threshold
10252 */
10253 public function mxchat_get_similarity_threshold() {
10254 // Verify nonce for security
10255 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10256 wp_send_json_error(['message' => 'Invalid nonce']);
10257 return;
10258 }
10259
10260 // Only allow admin users
10261 if (!current_user_can('administrator')) {
10262 wp_send_json_error(['message' => 'Unauthorized']);
10263 return;
10264 }
10265
10266 // Get similarity threshold from main options (default 35%)
10267 $similarity_threshold = isset($this->options['similarity_threshold'])
10268 ? ((int) $this->options['similarity_threshold']) / 100
10269 : 0.35;
10270
10271 wp_send_json_success([
10272 'threshold' => $similarity_threshold,
10273 'threshold_percentage' => ($similarity_threshold * 100) . '%'
10274 ]);
10275 }
10276
10277 /**
10278 * AJAX handler to get knowledge base status
10279 */
10280 public function mxchat_get_kb_status() {
10281 // Verify nonce for security
10282 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10283 wp_send_json_error(['message' => 'Invalid nonce']);
10284 return;
10285 }
10286
10287 // Only allow admin users
10288 if (!current_user_can('administrator')) {
10289 wp_send_json_error(['message' => 'Unauthorized']);
10290 return;
10291 }
10292
10293 // Check OpenAI Vector Store first (takes priority)
10294 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
10295 $use_vectorstore = (isset($vectorstore_options['mxchat_use_openai_vectorstore']) && $vectorstore_options['mxchat_use_openai_vectorstore'] === '1');
10296
10297 if ($use_vectorstore) {
10298 $vectorstore_ids = $vectorstore_options['mxchat_openai_vectorstore_ids'] ?? '';
10299 $id_count = !empty($vectorstore_ids) ? count(array_filter(array_map('trim', explode(',', $vectorstore_ids)))) : 0;
10300
10301 $kb_info = [
10302 'type' => 'OpenAI Vector Store',
10303 'status' => 'Active',
10304 'documents' => $id_count > 0 ? $id_count . ' vector store' . ($id_count > 1 ? 's' : '') . ' configured' : 'No vector stores configured'
10305 ];
10306
10307 wp_send_json_success($kb_info);
10308 return;
10309 }
10310
10311 // Check Pinecone vs WordPress
10312 $addon_options = get_option('mxchat_pinecone_addon_options', array());
10313 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
10314
10315 $kb_info = [
10316 'type' => $use_pinecone ? 'Pinecone' : 'WordPress Database',
10317 'status' => 'Active'
10318 ];
10319
10320 // Get document count
10321 if ($use_pinecone) {
10322 $kb_info['documents'] = 'Connected to Pinecone';
10323 $kb_info['api_configured'] = !empty($addon_options['mxchat_pinecone_api_key']);
10324 } else {
10325 // Count documents in WordPress database
10326 global $wpdb;
10327 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10328 $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
10329 $kb_info['documents'] = $count ? $count . ' documents' : 'No documents';
10330 }
10331
10332 wp_send_json_success($kb_info);
10333 }
10334
10335 /**
10336 * AJAX handler to start a completely fresh session (NEW - replaces old clear session)
10337 */
10338 public function mxchat_start_fresh_session() {
10339 // Verify nonce for security
10340 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10341 wp_send_json_error(['message' => 'Invalid nonce']);
10342 return;
10343 }
10344
10345 // Only allow admin users
10346 if (!current_user_can('administrator')) {
10347 wp_send_json_error(['message' => 'Unauthorized']);
10348 return;
10349 }
10350
10351 $old_session_id = isset($_POST['old_session_id']) ? sanitize_text_field($_POST['old_session_id']) : '';
10352 $new_session_id = isset($_POST['new_session_id']) ? sanitize_text_field($_POST['new_session_id']) : '';
10353
10354 if (empty($old_session_id)) {
10355 wp_send_json_error(['message' => 'Old session ID required']);
10356 return;
10357 }
10358
10359 // If no new session ID provided, generate one
10360 if (empty($new_session_id)) {
10361 $new_session_id = 'mxchat_chat_' . substr(md5(uniqid()), 0, 9);
10362 }
10363
10364 // Clear ALL data associated with the old session
10365 $this->clear_complete_session_data($old_session_id);
10366
10367 // Initialize the new session
10368 $this->initialize_fresh_session($new_session_id);
10369
10370 wp_send_json_success([
10371 'message' => 'Fresh session started successfully',
10372 'new_session_id' => $new_session_id,
10373 'old_session_id' => $old_session_id
10374 ]);
10375 }
10376
10377 /**
10378 * Clear ALL data associated with a session (ENHANCED)
10379 */
10380 private function clear_complete_session_data($session_id) {
10381 // Clear chat history
10382 delete_option("mxchat_history_{$session_id}");
10383
10384 // Clear chat mode
10385 delete_option("mxchat_mode_{$session_id}");
10386
10387 // Clear any PDF/Word transients
10388 $this->clear_pdf_transients($session_id);
10389 if (method_exists($this, 'clear_word_transients')) {
10390 $this->clear_word_transients($session_id);
10391 }
10392
10393 // Clear agent-related data
10394 delete_option("mxchat_channel_{$session_id}");
10395 delete_option("mxchat_agent_name_{$session_id}");
10396 delete_option("mxchat_email_{$session_id}");
10397
10398 // Clear any recommendation flow state
10399 delete_option("mxchat_sr_flow_state_{$session_id}");
10400
10401 // Clear any cached embeddings or context
10402 delete_transient("mxchat_context_{$session_id}");
10403 delete_transient("mxchat_last_query_{$session_id}");
10404
10405 // Clear any testing data
10406 delete_transient("mxchat_testing_data_{$session_id}");
10407
10408 // Clear any rate limiting data for this session
10409 delete_transient("mxchat_rate_limit_{$session_id}");
10410
10411 // Clear any other session-specific transients
10412 delete_transient("mxchat_waiting_for_pdf_url_{$session_id}");
10413 delete_transient("mxchat_include_pdf_in_context_{$session_id}");
10414 delete_transient("mxchat_include_word_in_context_{$session_id}");
10415
10416 // Clear form addon state (pending forms and submitted forms)
10417 delete_option("mxchat_pending_form_{$session_id}");
10418 delete_option("mxchat_submitted_forms_{$session_id}");
10419
10420 //error_log("MxChat: Cleared all data for session: {$session_id}");
10421 }
10422
10423 /**
10424 * Initialize a fresh session with default data
10425 */
10426 private function initialize_fresh_session($session_id) {
10427 // Set default chat mode
10428 update_option("mxchat_mode_{$session_id}", 'ai');
10429
10430 //error_log("MxChat: Initialized fresh session: {$session_id}");
10431 }
10432
10433 /**
10434 * Helper method to clear Word document transients (if you have Word support)
10435 */
10436 private function clear_word_transients($session_id) {
10437 delete_transient('mxchat_word_url_' . $session_id);
10438 delete_transient('mxchat_word_filename_' . $session_id);
10439 delete_transient('mxchat_word_embeddings_' . $session_id);
10440 delete_transient('mxchat_include_word_in_context_' . $session_id);
10441 }
10442
10443 /**
10444 * Simplified testing data capture method (CLEANED UP)
10445 */
10446 private function capture_testing_data($user_embedding, $message, $session_id) {
10447 // Only capture for admin users
10448 if (!current_user_can('administrator')) {
10449 return null;
10450 }
10451
10452 $testing_data = [
10453 'query' => $message,
10454 'timestamp' => time(),
10455 'top_matches' => [],
10456 'action_matches' => [] // Add action matches
10457 ];
10458
10459 // Get similarity threshold
10460 $similarity_threshold = isset($this->options['similarity_threshold'])
10461 ? ((int) $this->options['similarity_threshold']) / 100
10462 : 0.35;
10463
10464 $testing_data['similarity_threshold'] = $similarity_threshold;
10465
10466 // Use the real similarity analysis if available
10467 if ($this->last_similarity_analysis !== null) {
10468 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
10469 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
10470 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10471 } else {
10472 // Fallback: determine knowledge base type
10473 $addon_options = get_option('mxchat_pinecone_addon_options', array());
10474 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
10475
10476 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
10477 }
10478
10479 // Include action analysis if available
10480 if (isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
10481 $testing_data['action_matches'] = $this->last_action_analysis;
10482
10483 // Clear it after capturing to avoid stale data
10484 $this->last_action_analysis = null;
10485 }
10486
10487 return $testing_data;
10488 }
10489
10490
10491 /**
10492 * Track URL clicks from chatbot responses
10493 */
10494 public function mxchat_track_url_click() {
10495 // Verify nonce for security
10496 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10497 wp_send_json_error(['message' => 'Invalid nonce']);
10498 wp_die();
10499 }
10500
10501 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10502 $clicked_url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
10503 $message_context = isset($_POST['message_context']) ? sanitize_textarea_field($_POST['message_context']) : '';
10504
10505 if (empty($session_id) || empty($clicked_url)) {
10506 wp_send_json_error(['message' => 'Missing required data']);
10507 wp_die();
10508 }
10509
10510 global $wpdb;
10511 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
10512
10513 // Insert click tracking record
10514 $wpdb->insert(
10515 $table_name,
10516 [
10517 'session_id' => $session_id,
10518 'clicked_url' => $clicked_url,
10519 'message_context' => $message_context,
10520 'click_timestamp' => current_time('mysql', 1),
10521 'user_ip' => $_SERVER['REMOTE_ADDR'],
10522 'user_agent' => $_SERVER['HTTP_USER_AGENT']
10523 ]
10524 );
10525
10526 wp_send_json_success(['message' => 'Click tracked']);
10527 wp_die();
10528 }
10529
10530 /**
10531 * Get URL click analytics for a session
10532 */
10533 public function mxchat_get_url_clicks($session_id) {
10534 global $wpdb;
10535 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
10536
10537 $clicks = $wpdb->get_results($wpdb->prepare(
10538 "SELECT * FROM $table_name WHERE session_id = %s ORDER BY click_timestamp ASC",
10539 $session_id
10540 ));
10541
10542 return $clicks;
10543 }
10544 /**
10545 * Track the originating page where chat was started
10546 */
10547 public function mxchat_track_originating_page() {
10548 // Verify nonce
10549 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10550 wp_send_json_error(['message' => 'Invalid nonce']);
10551 wp_die();
10552 }
10553
10554 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10555 $page_url = isset($_POST['page_url']) ? esc_url_raw($_POST['page_url']) : '';
10556 $page_title = isset($_POST['page_title']) ? sanitize_text_field($_POST['page_title']) : '';
10557
10558 if (empty($session_id)) {
10559 wp_send_json_error(['message' => 'Missing session ID']);
10560 wp_die();
10561 }
10562
10563 global $wpdb;
10564 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
10565
10566 // Check if we've already tracked for this session
10567 $existing = $wpdb->get_var($wpdb->prepare(
10568 "SELECT COUNT(*) FROM $table_name
10569 WHERE session_id = %s
10570 AND originating_page_url IS NOT NULL",
10571 $session_id
10572 ));
10573
10574 if ($existing > 0) {
10575 wp_send_json_success(['message' => 'Already tracked']);
10576 wp_die();
10577 }
10578
10579 // Update the first message in this session with originating page info
10580 $wpdb->query($wpdb->prepare(
10581 "UPDATE $table_name
10582 SET originating_page_url = %s,
10583 originating_page_title = %s
10584 WHERE session_id = %s
10585 ORDER BY timestamp ASC
10586 LIMIT 1",
10587 $page_url,
10588 $page_title,
10589 $session_id
10590 ));
10591
10592 wp_send_json_success(['message' => 'Originating page tracked']);
10593 wp_die();
10594 }
10595
10596 /**
10597 * Validate and clean URLs from AI response
10598 * Removes any URLs that aren't in the knowledge base
10599 *
10600 * @param string $response_text The AI-generated response
10601 * @param array $valid_urls Array of URLs from the knowledge base
10602 * @return string Cleaned response with invalid URLs removed/flagged
10603 */
10604 private function validate_and_clean_urls($response_text, $valid_urls) {
10605 // DEBUG: Log what we're working with
10606 //error_log("=== MxChat URL Validation Debug ===");
10607 //error_log("Valid URLs count: " . count($valid_urls));
10608 //error_log("Valid URLs: " . print_r($valid_urls, true));
10609 //error_log("Response text length: " . strlen($response_text));
10610 //error_log("Response text preview: " . substr($response_text, 0, 500));
10611
10612 // If no valid URLs provided or empty response, return as-is
10613 if (empty($valid_urls) || empty($response_text)) {
10614 //error_log("Validation skipped - empty valid_urls or response");
10615 return $response_text;
10616 }
10617
10618 // Extract all URLs from the AI response
10619 // This regex matches http:// and https:// URLs
10620 preg_match_all(
10621 '#\bhttps?://[^\s<>"\')\]]+#i',
10622 $response_text,
10623 $matches
10624 );
10625
10626 // If no URLs found in response, return as-is
10627 if (empty($matches[0])) {
10628 //error_log("No URLs found in response");
10629 return $response_text;
10630 }
10631
10632 $found_urls = $matches[0];
10633 $cleaned_response = $response_text;
10634 $removed_count = 0;
10635
10636 // Normalize valid URLs for comparison (remove trailing slashes, fragments, etc.)
10637 $normalized_valid_urls = array_map(function($url) {
10638 // Remove trailing slash
10639 $url = rtrim($url, '/');
10640 // Remove URL fragments (#section)
10641 $url = preg_replace('/#.*$/', '', $url);
10642 // Remove trailing punctuation that might have been captured
10643 $url = rtrim($url, '.,;:!?');
10644 return $url;
10645 }, $valid_urls);
10646
10647 //error_log("Normalized valid URLs: " . print_r($normalized_valid_urls, true));
10648
10649 foreach ($found_urls as $found_url) {
10650 // Clean up the found URL (remove trailing punctuation that might have been captured)
10651 $clean_found_url = rtrim($found_url, '.,;:!?)');
10652
10653 // DEBUG: Log each URL being checked
10654 //error_log("Checking found URL: " . $found_url);
10655
10656 // Normalize for comparison
10657 $normalized_found = rtrim($clean_found_url, '/');
10658 $normalized_found = preg_replace('/#.*$/', '', $normalized_found);
10659
10660 //error_log("Normalized found URL: " . $normalized_found);
10661
10662 // Check if this URL exists in our valid URLs list
10663 $is_valid = false;
10664
10665 //error_log("Starting validation checks for: " . $normalized_found);
10666
10667 // First, try exact match
10668 if (in_array($normalized_found, $normalized_valid_urls)) {
10669 $is_valid = true;
10670 //error_log("EXACT MATCH FOUND");
10671 } else {
10672 //error_log("No exact match, checking variations...");
10673 // If no exact match, check if it's a variation (with query params, etc.)
10674 foreach ($normalized_valid_urls as $valid_url) {
10675 //error_log(" Comparing against valid URL: " . $valid_url);
10676
10677 // Check if the found URL starts with a valid URL (handles query params)
10678 if (strpos($normalized_found, $valid_url) === 0) {
10679 // Check what comes after the valid URL
10680 $remainder = substr($normalized_found, strlen($valid_url));
10681
10682 // Only valid if:
10683 // 1. Exact match (remainder is empty)
10684 // 2. Query params (starts with ?)
10685 // 3. Fragment (starts with #)
10686 if (empty($remainder) || $remainder[0] === '?' || $remainder[0] === '#') {
10687 $is_valid = true;
10688 //error_log(" MATCH: Found URL is valid variation of base URL");
10689 break;
10690 } else {
10691 //error_log(" NOT A MATCH: Found URL extends path beyond valid URL (remainder: " . $remainder . ")");
10692 }
10693 }
10694 // Also check the reverse (in case valid URL has query params)
10695 if (strpos($valid_url, $normalized_found) === 0) {
10696 $is_valid = true;
10697 //error_log(" MATCH: Valid URL starts with found URL");
10698 break;
10699 }
10700 }
10701
10702 if (!$is_valid) {
10703 //error_log("NO MATCH FOUND - URL should be removed");
10704 }
10705 }
10706
10707 // If URL is not valid, remove it from the response
10708 if (!$is_valid) {
10709 // Log the removal for debugging
10710 //error_log("MxChat: Removed hallucinated URL: " . $found_url);
10711 //error_log("MxChat: Valid URLs were: " . implode(', ', array_slice($normalized_valid_urls, 0, 5)));
10712
10713 $removed_count++;
10714
10715 // Check if URL is part of a markdown link: [text](url)
10716 $markdown_pattern = '/\[([^\]]+)\]\(' . preg_quote($found_url, '/') . '\)/';
10717 if (preg_match($markdown_pattern, $cleaned_response)) {
10718 //error_log("Found markdown link, removing but keeping text");
10719 // Remove the markdown link but keep the text
10720 $cleaned_response = preg_replace($markdown_pattern, '$1', $cleaned_response);
10721 }
10722 // Check if URL is part of an HTML link: <a href="url">text</a>
10723 else if (preg_match('/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>(.*?)<\/a>/i', $cleaned_response, $link_match)) {
10724 //error_log("Found HTML link, removing but keeping text");
10725 // Remove the HTML link but keep the text
10726 $link_text = $link_match[1];
10727 $cleaned_response = preg_replace(
10728 '/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>.*?<\/a>/i',
10729 $link_text,
10730 $cleaned_response
10731 );
10732 }
10733 // Otherwise just remove the bare URL
10734 else {
10735 //error_log("Removing bare URL");
10736 $cleaned_response = str_replace($found_url, '', $cleaned_response);
10737 }
10738 }
10739 }
10740
10741 // Log summary if any URLs were removed
10742 if ($removed_count > 0) {
10743 //error_log("MxChat: URL Validation Summary - Removed {$removed_count} hallucinated URL(s)");
10744 } else {
10745 //error_log("MxChat: URL Validation Summary - No URLs removed, all were valid");
10746 }
10747
10748 // Clean up any double spaces or awkward punctuation left behind
10749 // IMPORTANT: Only collapse horizontal whitespace (spaces/tabs), preserve newlines for markdown formatting
10750 $cleaned_response = preg_replace('/[^\S\n]+/', ' ', $cleaned_response); // Collapse spaces/tabs but NOT newlines
10751 $cleaned_response = preg_replace('/[^\S\n]+([.,;:!?])/', '$1', $cleaned_response); // Same for punctuation cleanup
10752
10753 //error_log("Final cleaned response: " . $cleaned_response);
10754
10755 return trim($cleaned_response);
10756 }
10757
10758 /**
10759 * AJAX handler to get current chat mode for a session
10760 */
10761 public function mxchat_get_current_chat_mode() {
10762 // Verify nonce for security
10763 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10764 wp_send_json_error(['message' => 'Invalid nonce']);
10765 wp_die();
10766 }
10767
10768 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10769
10770 if (empty($session_id)) {
10771 wp_send_json_error(['message' => 'Session ID missing']);
10772 wp_die();
10773 }
10774
10775 // Get the current chat mode for this session
10776 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
10777
10778 wp_send_json_success([
10779 'chat_mode' => $chat_mode
10780 ]);
10781 wp_die();
10782 }
10783
10784
10785
10786 }
10787 ?>
10788