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

10,722 lines 427.4 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 // If session has an owner and it doesn't match current user, trigger session reset
168 if ($session_owner && $session_owner !== $current_user_identifier) {
169 wp_send_json_error([
170 'message' => esc_html__('Your session has expired. Starting a new conversation.', 'mxchat'),
171 'code' => 'session_expired',
172 'action' => 'reset_session'
173 ]);
174 wp_die();
175 }
176
177 // If no owner is set yet, claim ownership (for legacy sessions)
178 if (!$session_owner) {
179 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
180 }
181
182 $history = get_option("mxchat_history_{$session_id}", []); // Retrieve stored history
183 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai'); // Get current chat mode
184
185 if (empty($history)) {
186 // Even if history is empty, return the chat mode
187 wp_send_json_success([
188 'conversation' => [],
189 'chat_mode' => $chat_mode
190 ]);
191 wp_die();
192 }
193
194 wp_send_json_success([
195 'conversation' => $history,
196 'chat_mode' => $chat_mode
197 ]);
198 wp_die();
199 }
200 private function mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp = 0) {
201 $history = get_option("mxchat_history_{$session_id}", []);
202
203 // Check persistence setting - when OFF, only include messages from current page load
204 $options = get_option('mxchat_options', []);
205 $persistence_enabled = isset($options['chat_persistence_toggle']) && $options['chat_persistence_toggle'] === 'on';
206
207 // Filter history when persistence is OFF to match what the user sees
208 if (!$persistence_enabled && $session_start_timestamp > 0) {
209 $history = array_filter($history, function($entry) use ($session_start_timestamp) {
210 // Include messages from this page load onwards
211 return isset($entry['timestamp']) && $entry['timestamp'] >= $session_start_timestamp;
212 });
213 // Re-index array after filtering
214 $history = array_values($history);
215 }
216
217 $formatted_history = [];
218
219 // Adjusted for code-heavy conversations
220 $max_tokens = 120000; // Context window size
221 $reserved_tokens = 5000; // Space for system prompts + current query
222 $current_token_count = 0;
223
224 // Allowed HTML tags for content sanitization
225 $allowed_tags = [
226 'pre' => ['class' => true],
227 'code' => ['class' => true],
228 'span' => ['class' => true],
229 'div' => ['class' => true],
230 'strong' => [],
231 'em' => []
232 ];
233
234 foreach (array_reverse($history) as $entry) {
235 // Preserve code blocks while sanitizing other HTML
236 $clean_content = wp_kses($entry['content'], $allowed_tags);
237
238 // Detect code blocks in content
239 $has_code = false;
240 // Replace the HTML check with:
241 // Allow messages that contain code blocks or are plain text
242 if (strpos($clean_content, '<pre') === false &&
243 strpos($clean_content, '<code') === false &&
244 $clean_content !== strip_tags($entry['content'])) {
245 continue;
246 }
247
248 // Skip entries that lost significant content during sanitization
249 if (!$has_code && $clean_content !== strip_tags($entry['content'])) {
250 continue;
251 }
252
253 // More accurate token estimation (1 token ≈ 4 characters)
254 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
255
256 // Check token budget with the new estimate
257 if (($current_token_count + $token_estimate + $reserved_tokens) > $max_tokens) {
258 // Try to fit partial content if it's the first entry
259 if (empty($formatted_history)) {
260 $clean_content = mb_substr($clean_content, 0, ($max_tokens - $reserved_tokens) * 4);
261 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
262 } else {
263 break;
264 }
265 }
266
267 // Add to formatted history
268 $formatted_history[] = [
269 'role' => $entry['role'],
270 'content' => $clean_content
271 ];
272
273 $current_token_count += $token_estimate;
274 }
275
276 // Reverse back to maintain chronological order
277 $formatted_history = array_reverse($formatted_history);
278
279 // Add system message about code context
280 array_unshift($formatted_history, [
281 'role' => 'system',
282 'content' => 'Preserved code blocks are marked with [CODE BLOCK PRESERVED]. '
283 . 'Maintain formatting and syntax highlighting when referencing code.'
284 ]);
285
286 return $formatted_history;
287 }
288
289 public function register_routes() {
290 //error_log(esc_html__('Registering MxChat REST routes', 'mxchat'));
291
292 register_rest_route('mxchat/v1', '/stream', [
293 'methods' => 'GET',
294 'callback' => [$this, 'mxchat_stream_events'],
295 'permission_callback' => [$this, 'verify_chat_session'],
296 ]);
297
298 register_rest_route('mxchat/v1', '/agent-response', [
299 'methods' => 'POST',
300 'callback' => [$this, 'mxchat_handle_agent_response'],
301 'permission_callback' => [$this, 'verify_slack_request'],
302 ]);
303
304 register_rest_route('mxchat/v1', '/slack-interaction', [
305 'methods' => 'POST',
306 'callback' => [$this, 'handle_slack_interaction'],
307 'permission_callback' => [$this, 'verify_slack_request'],
308 ]);
309
310 register_rest_route('mxchat/v1', '/slack-messages', [
311 'methods' => 'POST',
312 'callback' => [$this, 'handle_slack_messages'],
313 'permission_callback' => [$this, 'verify_slack_request'],
314 ]);
315
316 // Telegram webhook endpoint
317 register_rest_route('mxchat/v1', '/telegram-webhook', [
318 'methods' => 'POST',
319 'callback' => [$this, 'handle_telegram_webhook'],
320 'permission_callback' => [$this, 'verify_telegram_request'],
321 ]);
322
323 //error_log(esc_html__('MxChat REST routes registered', 'mxchat'));
324 }
325
326 /**
327 * Verify valid chat session
328 */
329 public function verify_chat_session($request) {
330 $session_id = $request->get_param('session_id');
331 if (empty($session_id)) {
332 //error_log(esc_html__('Empty session ID in chat request', 'mxchat'));
333 return false;
334 }
335
336 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
337 return $chat_mode === 'agent';
338 }
339
340 /**
341 * Verify request is coming from Slack.
342 *
343 * @param WP_REST_Request $request
344 * @return bool True if valid, false otherwise.
345 */
346 public function verify_slack_request($request) {
347 // Get the Slack signing secret from your plugin options
348 $valid_key = $this->options['live_agent_secret_key'] ?? '';
349
350 if (empty($valid_key)) {
351 //error_log(esc_html__('Slack signing secret not configured', 'mxchat'));
352 return false;
353 }
354
355 $timestamp = $request->get_header('X-Slack-Request-Timestamp');
356 $slack_signature = $request->get_header('X-Slack-Signature');
357
358 // Verify timestamp to prevent replay attacks
359 if (abs(time() - intval($timestamp)) > 300) {
360 //error_log(esc_html__('Slack request timestamp too old', 'mxchat'));
361 return false;
362 }
363
364 // Get raw request body from the WP_REST_Request object
365 // (php://input may already be consumed by WordPress at this point)
366 $request_body = $request->get_body();
367
368 // Create the signature base string
369 $sig_basestring = "v0:{$timestamp}:{$request_body}";
370
371 // Calculate expected signature
372 $my_signature = 'v0=' . hash_hmac('sha256', $sig_basestring, $valid_key);
373
374 // Compare signatures
375 return hash_equals($my_signature, $slack_signature);
376 }
377
378 /**
379 * Verify request is coming from Telegram.
380 *
381 * @param WP_REST_Request $request
382 * @return bool True if valid, false otherwise.
383 */
384 public function verify_telegram_request($request) {
385 $secret_token = $this->options['telegram_webhook_secret'] ?? '';
386
387 //error_log('[MxChat Telegram DEBUG] verify_telegram_request called');
388 //error_log('[MxChat Telegram DEBUG] Stored secret: ' . (empty($secret_token) ? 'EMPTY' : substr($secret_token, 0, 10) . '...'));
389
390 if (empty($secret_token)) {
391 // If no secret is configured, allow the request (for initial setup)
392 //error_log('[MxChat Telegram DEBUG] No secret configured, allowing request');
393 return true;
394 }
395
396 // Telegram sends the secret token in the X-Telegram-Bot-Api-Secret-Token header
397 $request_token = $request->get_header('X-Telegram-Bot-Api-Secret-Token');
398
399 //error_log('[MxChat Telegram DEBUG] Request token: ' . (empty($request_token) ? 'EMPTY' : substr($request_token, 0, 10) . '...'));
400
401 if (empty($request_token)) {
402 //error_log('[MxChat Telegram DEBUG] Request rejected: No token in header');
403 return false;
404 }
405
406 // Timing-safe comparison
407 $result = hash_equals($secret_token, $request_token);
408 //error_log('[MxChat Telegram DEBUG] Token comparison result: ' . ($result ? 'MATCH' : 'MISMATCH'));
409 return $result;
410 }
411
412 public function mxchat_stream_events(WP_REST_Request $request) {
413 header('Content-Type: text/event-stream');
414 header('Cache-Control: no-cache');
415 header('Connection: keep-alive');
416
417 $session_id = sanitize_text_field($request->get_param('session_id'));
418 $last_seen_id = sanitize_text_field($request->get_param('last_seen_id')) ?: '';
419
420 if (empty($session_id)) {
421 echo esc_html__("event: error\ndata: ", 'mxchat') . esc_html__('Missing session_id', 'mxchat') . "\n\n";
422 flush();
423 exit;
424 }
425
426 $history = get_option("mxchat_history_{$session_id}", []);
427
428 // Filter only new messages
429 $new_messages = array_filter($history, function ($message) use ($last_seen_id) {
430 return !empty($message['id']) && $message['id'] > $last_seen_id;
431 });
432
433 // Send new messages if available
434 if (!empty($new_messages)) {
435 echo esc_html__("event: newMessages\ndata: ", 'mxchat') . json_encode(array_values($new_messages)) . "\n\n";
436 } else {
437 // Keep the connection alive
438 echo esc_html__("event: keepAlive\ndata: ", 'mxchat') . "{}\n\n";
439 }
440 flush();
441 exit;
442 }
443
444
445
446
447 private function mxchat_save_chat_message($session_id, $role, $message, $originating_page = null, $rag_context = null) {
448 global $wpdb;
449 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
450 //error_log("[DEBUG] mxchat_save_chat_message -> START for session_id: {$session_id}, role: {$role}");
451
452 // Check if this is the first message in a new session (before any other database operations)
453 $is_new_session = false;
454 if ($role === 'user') { // Only check for user messages, not bot responses
455 $existing_messages = $wpdb->get_var($wpdb->prepare(
456 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
457 $session_id
458 ));
459 $is_new_session = ($existing_messages == 0);
460
461 // Log for debugging
462 if ($is_new_session) {
463 //error_log("[DEBUG] This is a NEW session - first message");
464 }
465 }
466
467 // SECURITY FIX: Set session ownership for new sessions
468 if ($is_new_session && $role === 'user') {
469 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
470 $session_owner_key = "mxchat_session_owner_{$session_id}";
471
472 // Only set ownership if not already set
473 if (!get_option($session_owner_key)) {
474 update_option($session_owner_key, $current_user_identifier, 'no');
475 //error_log("[DEBUG] Set session ownership for {$session_id} to {$current_user_identifier}");
476 }
477 }
478
479 // 1) Extract agent name if present
480 $agent_name = '';
481 if (preg_match('/^Agent: (.*?) - /', $message, $matches)) {
482 $agent_name = $matches[1];
483 $message = str_replace("Agent: $agent_name - ", '', $message);
484 $session_meta_key = "mxchat_agent_name_{$session_id}";
485 if (empty(get_option($session_meta_key))) {
486 update_option($session_meta_key, $agent_name);
487 //error_log("[DEBUG] mxchat_save_chat_message -> Stored agent_name in option: {$session_meta_key} => {$agent_name}");
488 }
489 }
490
491 // 2) Generate unique message_id
492 $message_id = uniqid();
493 //error_log("[DEBUG] mxchat_save_chat_message -> Generated message_id: {$message_id}");
494
495 // 3) Determine user_id
496 $user_id = is_user_logged_in() ? get_current_user_id() : 0;
497
498 // 4) Determine user_identifier
499 $user_identifier = $agent_name
500 ? $agent_name
501 : MxChat_User::mxchat_get_user_identifier();
502
503 // 5) Determine displayed_name
504 $user_email = MxChat_User::mxchat_get_user_email();
505 $displayed_name = $agent_name ? $agent_name : ($user_email ?: $user_identifier);
506
507 // 6) Check for a saved email in wp_options
508 $email_option_key = "mxchat_email_{$session_id}";
509 $saved_email = get_option($email_option_key);
510 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for email_option_key: {$email_option_key}, found: {$saved_email}");
511
512 // Check for a saved name in wp_options
513 $name_option_key = "mxchat_name_{$session_id}";
514 $saved_name = get_option($name_option_key);
515 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for name_option_key: {$name_option_key}, found: {$saved_name}");
516
517 // If found, update DB user_email and user_name
518 if ($saved_email || $saved_name) {
519 $update_data = [];
520 if ($saved_email) {
521 $update_data['user_email'] = $saved_email;
522 }
523 if ($saved_name) {
524 $update_data['user_name'] = $saved_name;
525 }
526
527 if (!empty($update_data)) {
528 $update_res = $wpdb->update(
529 $table_name,
530 $update_data,
531 ['session_id' => $session_id],
532 array_fill(0, count($update_data), '%s'),
533 ['%s']
534 );
535 //error_log("[DEBUG] mxchat_save_chat_message -> Attempted DB user_email/user_name update for session_id {$session_id}. update_res: {$update_res}");
536 }
537 }
538
539 // 7) Save to session history in wp_options
540 $history_key = "mxchat_history_{$session_id}";
541 $history = get_option($history_key, []);
542 $history[] = [
543 'id' => $message_id,
544 'role' => $role,
545 'content' => $message,
546 'timestamp' => round(microtime(true) * 1000),
547 'agent_name' => $displayed_name,
548 ];
549 update_option($history_key, $history, 'no');
550 //error_log("[DEBUG] mxchat_save_chat_message -> Updated session history in option: {$history_key}");
551
552 // 8) Save the message to DB (INSERT)
553 $insert_data = [
554 'user_id' => $user_id,
555 'user_identifier'=> $user_identifier,
556 'user_email' => $saved_email ?: $user_email,
557 'user_name' => $saved_name ?: '', // Add name to insert data
558 'session_id' => $session_id,
559 'role' => $role,
560 'message' => $message,
561 'timestamp' => current_time('mysql', 1),
562 ];
563
564 // IMPROVED: Handle originating page data
565 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
566
567 if ($columns_exist) {
568 if ($is_new_session && $role === 'user') {
569 // For the first user message, set originating page data
570
571 // First check if we have it from the parameter
572 if ($originating_page && !empty($originating_page['url'])) {
573 $insert_data['originating_page_url'] = $originating_page['url'];
574 $insert_data['originating_page_title'] = $originating_page['title'] ?? '';
575
576 //error_log("[DEBUG] Setting originating page from parameter: " . $originating_page['url']);
577 }
578 // Otherwise check if it's stored in the instance property
579 else if (isset($this->pending_originating_page) && !empty($this->pending_originating_page['url'])) {
580 $insert_data['originating_page_url'] = $this->pending_originating_page['url'];
581 $insert_data['originating_page_title'] = $this->pending_originating_page['title'] ?? '';
582
583 //error_log("[DEBUG] Setting originating page from pending_originating_page: " . $this->pending_originating_page['url']);
584
585 // Clear after using
586 unset($this->pending_originating_page);
587 }
588 // Fallback to HTTP_REFERER if nothing else is available
589 else if (isset($_SERVER['HTTP_REFERER'])) {
590 $referer_url = esc_url_raw($_SERVER['HTTP_REFERER']);
591 $insert_data['originating_page_url'] = $referer_url;
592
593 // Generate title from URL
594 $parsed_url = parse_url($referer_url);
595 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
596
597 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
598 $insert_data['originating_page_title'] = 'Homepage';
599 } else {
600 $title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
601 $insert_data['originating_page_title'] = ucwords(trim($title));
602 }
603
604 //error_log("[DEBUG] Setting originating page from HTTP_REFERER: " . $referer_url);
605 }
606
607 // Store for this session so all messages have the same originating page
608 if (!empty($insert_data['originating_page_url'])) {
609 update_option("mxchat_originating_page_{$session_id}", [
610 'url' => $insert_data['originating_page_url'],
611 'title' => $insert_data['originating_page_title']
612 ], 'no');
613 }
614 } else {
615 // For subsequent messages in the session, use the stored originating page
616 $stored_originating = get_option("mxchat_originating_page_{$session_id}");
617 if ($stored_originating && !empty($stored_originating['url'])) {
618 $insert_data['originating_page_url'] = $stored_originating['url'];
619 $insert_data['originating_page_title'] = $stored_originating['title'] ?? '';
620 }
621 }
622 }
623
624 // Add RAG context if provided (for bot messages)
625 if ($rag_context !== null && $role === 'bot') {
626 $rag_context_column_exists = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'rag_context'");
627 if ($rag_context_column_exists) {
628 $insert_data['rag_context'] = is_array($rag_context) ? wp_json_encode($rag_context) : $rag_context;
629 }
630 }
631
632 $wpdb->insert($table_name, $insert_data);
633 //error_log("[DEBUG] mxchat_save_chat_message -> Inserted message into DB. row_id: {$wpdb->insert_id}, data: " . print_r($insert_data, true));
634
635 // 9) Send notification email if this is the first user message in a new session
636 if ($wpdb->insert_id && $is_new_session && $role === 'user') {
637 $this->send_new_chat_notification($session_id, array(
638 'identifier' => $user_identifier,
639 'email' => $saved_email ?: $user_email,
640 'ip' => $_SERVER['REMOTE_ADDR']
641 ));
642 }
643
644 // 10) Schedule delayed transcript email if enabled and message is from user
645 if ($wpdb->insert_id && $role === 'user') {
646 $this->schedule_delayed_transcript_email($session_id);
647 }
648
649 //error_log("[DEBUG] mxchat_save_chat_message -> END for session_id: {$session_id}");
650 return $message_id;
651 }
652
653 private function send_new_chat_notification($session_id, $user_info = array()) {
654 $options = get_option('mxchat_transcripts_options');
655
656 // Check if notifications are enabled
657 if (empty($options['mxchat_enable_notifications'])) {
658 return false;
659 }
660
661 // Get notification email
662 $to = !empty($options['mxchat_notification_email']) ?
663 $options['mxchat_notification_email'] :
664 get_option('admin_email');
665
666 if (!is_email($to)) {
667 return false;
668 }
669
670 // Prepare email content
671 $subject = sprintf('[%s] New Chat Session Started', get_bloginfo('name'));
672
673 $user_identifier = isset($user_info['identifier']) ? $user_info['identifier'] : 'Guest';
674 $user_email = isset($user_info['email']) ? $user_info['email'] : 'Not provided';
675 $user_ip = isset($user_info['ip']) ? $user_info['ip'] : $_SERVER['REMOTE_ADDR'];
676
677 $message = sprintf(
678 "A new chat session has started on your website.\n\n" .
679 "Session ID: %s\n" .
680 "User: %s\n" .
681 "Email: %s\n" .
682 "IP Address: %s\n" .
683 "Time: %s\n\n" .
684 "View transcripts: %s",
685 $session_id,
686 $user_identifier,
687 $user_email,
688 $user_ip,
689 current_time('mysql'),
690 admin_url('admin.php?page=mxchat-transcripts')
691 );
692
693 // Send email
694 return wp_mail($to, $subject, $message);
695 }
696
697 /**
698 * Schedule delayed transcript email for a session
699 * Reschedules if a new user message is received
700 */
701 private function schedule_delayed_transcript_email($session_id) {
702 $options = get_option('mxchat_transcripts_options');
703
704 // Check if auto-email is enabled
705 if (empty($options['mxchat_auto_email_transcript_enabled'])) {
706 return;
707 }
708
709 // Get notification email
710 $email = !empty($options['mxchat_notification_email']) ?
711 $options['mxchat_notification_email'] :
712 get_option('admin_email');
713
714 if (!is_email($email)) {
715 return;
716 }
717
718 // Get delay in minutes (default 30)
719 $delay_minutes = isset($options['mxchat_auto_email_transcript_delay']) ?
720 intval($options['mxchat_auto_email_transcript_delay']) : 30;
721
722 // Clear any existing scheduled event for this session
723 $hook = 'mxchat_send_delayed_transcript';
724 $args = array($session_id);
725 $timestamp = wp_next_scheduled($hook, $args);
726
727 if ($timestamp) {
728 wp_unschedule_event($timestamp, $hook, $args);
729 }
730
731 // Schedule new event
732 $schedule_time = time() + ($delay_minutes * 60);
733 wp_schedule_single_event($schedule_time, $hook, $args);
734 }
735
736 /**
737 * Check if chat messages contain contact information (email or phone number)
738 *
739 * @param array $messages Array of message objects with 'message' property
740 * @param object|null $session_data Session data object with user_email property
741 * @return bool True if contact info found, false otherwise
742 */
743 private function chat_contains_contact_info($messages, $session_data = null) {
744 // Check if session already has a stored email
745 if ($session_data && !empty($session_data->user_email)) {
746 return true;
747 }
748
749 // Email regex pattern
750 $email_pattern = '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/';
751
752 // Phone number patterns (covers various formats including international, WhatsApp style)
753 // Matches: +1234567890, (123) 456-7890, 123-456-7890, 123.456.7890, 1234567890, +1 234 567 8900, etc.
754 $phone_pattern = '/(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{2,4}[-.\s]?\d{2,4}(?:[-.\s]?\d{1,4})?/';
755
756 // Only check user messages (not assistant responses)
757 foreach ($messages as $msg) {
758 if ($msg->role !== 'user') {
759 continue;
760 }
761
762 $message_text = $msg->message;
763
764 // Check for email
765 if (preg_match($email_pattern, $message_text)) {
766 return true;
767 }
768
769 // Check for phone number (must be at least 7 digits total to avoid false positives)
770 if (preg_match($phone_pattern, $message_text, $matches)) {
771 // Count actual digits to avoid matching short numbers
772 $digits_only = preg_replace('/\D/', '', $matches[0]);
773 if (strlen($digits_only) >= 7) {
774 return true;
775 }
776 }
777 }
778
779 return false;
780 }
781
782 /**
783 * Send the delayed transcript email with .txt attachment
784 */
785 public function mxchat_send_delayed_transcript($session_id) {
786 global $wpdb;
787
788 $options = get_option('mxchat_transcripts_options');
789
790 // Get notification email
791 $to = !empty($options['mxchat_notification_email']) ?
792 $options['mxchat_notification_email'] :
793 get_option('admin_email');
794
795 if (!is_email($to)) {
796 return false;
797 }
798
799 // Get all messages for this session
800 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
801 $messages = $wpdb->get_results($wpdb->prepare(
802 "SELECT role, message, timestamp FROM {$table_name}
803 WHERE session_id = %s
804 ORDER BY timestamp ASC",
805 $session_id
806 ));
807
808 if (empty($messages)) {
809 return false;
810 }
811
812 // Get session metadata
813 $sessions_table = $wpdb->prefix . 'mxchat_sessions';
814 $session_data = $wpdb->get_row($wpdb->prepare(
815 "SELECT * FROM {$sessions_table} WHERE session_id = %s",
816 $session_id
817 ));
818
819 // Check if contact info is required and if it's present
820 $require_contact = !empty($options['mxchat_auto_email_transcript_require_contact']);
821 if ($require_contact && !$this->chat_contains_contact_info($messages, $session_data)) {
822 // Contact info required but not found - skip sending
823 return false;
824 }
825
826 // Build transcript content
827 $transcript_content = "Chat Transcript\n";
828 $transcript_content .= "================\n\n";
829 $transcript_content .= "Session ID: " . $session_id . "\n";
830
831 if ($session_data) {
832 $transcript_content .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
833 $transcript_content .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
834 $transcript_content .= "Started: " . $session_data->created_at . "\n";
835 }
836
837 $transcript_content .= "\n" . str_repeat("=", 50) . "\n\n";
838
839 // Add messages
840 foreach ($messages as $msg) {
841 $role_label = ($msg->role === 'user') ? 'User' : 'Assistant';
842 $transcript_content .= "[{$msg->timestamp}] {$role_label}:\n";
843 $transcript_content .= $msg->message . "\n\n";
844 }
845
846 // Create temporary file for attachment using WP_Filesystem
847 $upload_dir = wp_upload_dir();
848 $temp_file = $upload_dir['basedir'] . '/mxchat-transcript-' . $session_id . '.txt';
849 global $wp_filesystem;
850 if (empty($wp_filesystem)) {
851 require_once ABSPATH . 'wp-admin/includes/file.php';
852 WP_Filesystem();
853 }
854 $wp_filesystem->put_contents($temp_file, $transcript_content, FS_CHMOD_FILE);
855
856 // Prepare email
857 $subject = sprintf('[%s] Chat Transcript - Session %s', get_bloginfo('name'), substr($session_id, 0, 8));
858
859 $message = "Please find attached the full chat transcript.\n\n";
860 $message .= "Session ID: {$session_id}\n";
861
862 if ($session_data) {
863 $message .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
864 $message .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
865 }
866
867 $message .= "\nView online: " . admin_url('admin.php?page=mxchat-transcripts');
868
869 // Send email with attachment
870 $attachments = array($temp_file);
871 $result = wp_mail($to, $subject, $message, '', $attachments);
872
873 // Clean up temporary file
874 if (file_exists($temp_file)) {
875 unlink($temp_file);
876 }
877
878 return $result;
879 }
880
881
882
883 public function mxchat_handle_save_email_and_response() {
884 //error_log('[DEBUG] ---------- mxchat_handle_save_email_and_response START ----------');
885 //error_log('DEBUG: POST data: ' . print_r($_POST, true));
886
887 nocache_headers();
888
889 // Validate nonce
890 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
891 //error_log(esc_html__('[ERROR] Invalid nonce in mxchat_handle_save_email_and_response', 'mxchat'));
892 wp_send_json_error(['message' => esc_html__('Invalid nonce.', 'mxchat')]);
893 wp_die();
894 }
895
896 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
897 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
898 $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
899
900 //error_log("[DEBUG] handle_save_email_and_response -> session_id: {$session_id}, email: {$email}, name: {$name}");
901
902 if (empty($session_id) || $session_id === 'null' || empty($email)) {
903 //error_log("[ERROR] Missing session_id or email: session_id={$session_id}, email={$email}");
904 wp_send_json_error(['message' => esc_html__('Session ID or email is missing.', 'mxchat')]);
905 wp_die();
906 }
907
908 // Validate name if provided (check if name field is enabled and name is required)
909 $options = get_option('mxchat_options', []);
910 $name_field_enabled = isset($options['enable_name_field']) &&
911 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
912
913 if ($name_field_enabled && (empty($name) || strlen(trim($name)) < 2 || strlen(trim($name)) > 100)) {
914 //error_log("[ERROR] Invalid name: {$name} (enabled: {$name_field_enabled})");
915 wp_send_json_error(['message' => esc_html__('Name must be between 2 and 100 characters.', 'mxchat')]);
916 wp_die();
917 }
918
919 // 1) Always store email in wp_options
920 $email_option_key = "mxchat_email_{$session_id}";
921 update_option($email_option_key, $email, 'no');
922 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$email_option_key} => {$email}");
923
924 // Store name in wp_options if provided
925 if (!empty($name)) {
926 $name_option_key = "mxchat_name_{$session_id}";
927 update_option($name_option_key, $name, 'no');
928 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$name_option_key} => {$name}");
929 }
930
931 // 2) (Optional) Also store in DB if a row already exists
932 global $wpdb;
933 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
934
935 // Make sure we have a valid placeholder in prepare
936 $sql = $wpdb->prepare("SELECT COUNT(*) FROM {$table_name} WHERE session_id = %s", $session_id);
937 $session_count = $wpdb->get_var($sql);
938
939 //error_log("[DEBUG] handle_save_email_and_response -> session_count for {$session_id}: {$session_count} (SQL: {$sql})");
940
941 if ($session_count) {
942 // Update both user_email and user_name if row(s) exist
943 if (!empty($name)) {
944 $update_sql = $wpdb->prepare(
945 "UPDATE {$table_name} SET user_email = %s, user_name = %s WHERE session_id = %s",
946 $email,
947 $name,
948 $session_id
949 );
950 } else {
951 $update_sql = $wpdb->prepare(
952 "UPDATE {$table_name} SET user_email = %s WHERE session_id = %s",
953 $email,
954 $session_id
955 );
956 }
957 $wpdb->query($update_sql);
958 //error_log("[DEBUG] handle_save_email_and_response -> DB updated: {$update_sql}");
959 } else {
960 //error_log("[INFO] handle_save_email_and_response -> No DB entry for {$session_id}, so email/name is only in wp_options.");
961 }
962
963 // Provide success response (same as original)
964 $bot_message = __('Thanks for providing your email! You can continue chatting now.', 'mxchat');
965 //error_log("[DEBUG] handle_save_email_and_response -> success, returning bot_message: {$bot_message}");
966 wp_send_json_success(['message' => $bot_message]);
967 wp_die();
968 }
969
970 public function mxchat_check_email_provided() {
971 //error_log('[DEBUG] ---------- mxchat_check_email_provided START ----------');
972
973 nocache_headers();
974
975 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
976 //error_log('[ERROR] Invalid nonce in mxchat_check_email_provided');
977 wp_send_json_error(['message' => esc_html__('Invalid nonce', 'mxchat')]);
978 }
979
980 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
981 if (empty($session_id) || $session_id === 'null') {
982 //error_log('[ERROR] No session ID provided in mxchat_check_email_provided');
983 wp_send_json_error(['message' => esc_html__('No session ID provided', 'mxchat')]);
984 }
985
986 // Check if the user is logged in
987 if (is_user_logged_in()) {
988 $current_user = wp_get_current_user();
989 //error_log("[DEBUG] User is logged in as {$current_user->user_email}");
990
991 // Get user's display name for logged in users
992 $user_name = !empty($current_user->display_name) ? $current_user->display_name :
993 (!empty($current_user->first_name) ? $current_user->first_name : '');
994
995 $response_data = ['logged_in' => true, 'email' => $current_user->user_email];
996 if (!empty($user_name)) {
997 $response_data['name'] = $user_name;
998 }
999
1000 wp_send_json_success($response_data);
1001 }
1002
1003 // Check if name field is required
1004 $options = get_option('mxchat_options', []);
1005 $name_field_enabled = isset($options['enable_name_field']) &&
1006 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
1007
1008 $email_option_key = "mxchat_email_{$session_id}";
1009 $stored_email = get_option($email_option_key, '');
1010
1011 // Check for stored name
1012 $name_option_key = "mxchat_name_{$session_id}";
1013 $stored_name = get_option($name_option_key, '');
1014
1015 //error_log("[DEBUG] mxchat_check_email_provided -> Checking email option: {$email_option_key}, found: {$stored_email}");
1016 //error_log("[DEBUG] mxchat_check_email_provided -> Checking name option: {$name_option_key}, found: {$stored_name}, required: " . ($name_field_enabled ? 'yes' : 'no'));
1017
1018 // Check if we have email and name (if name is required)
1019 $has_required_info = !empty($stored_email);
1020
1021 if ($name_field_enabled) {
1022 $has_required_info = $has_required_info && !empty($stored_name);
1023 }
1024
1025 if ($has_required_info) {
1026 //error_log("[DEBUG] mxchat_check_email_provided -> Required info found, returning success");
1027
1028 $response_data = ['email' => $stored_email];
1029 if (!empty($stored_name)) {
1030 $response_data['name'] = $stored_name;
1031 }
1032
1033 wp_send_json_success($response_data);
1034 } else {
1035 //error_log("[DEBUG] mxchat_check_email_provided -> Required info missing, returning error");
1036 wp_send_json_error(['message' => esc_html__('No email found', 'mxchat')]);
1037 }
1038 }
1039
1040 /**
1041 * Send error response in appropriate format based on streaming mode
1042 * ADDED: Helper method to consistently handle errors in both streaming and non-streaming modes
1043 *
1044 * @param string $error_message The error message to display
1045 * @param string $error_code Optional error code for debugging
1046 */
1047 private function send_error_response($error_message, $error_code = 'api_error') {
1048 if ($this->is_streaming) {
1049 echo "data: " . json_encode([
1050 'error' => true,
1051 'error_message' => $error_message,
1052 'error_code' => $error_code,
1053 'text' => $error_message,
1054 'message' => $error_message
1055 ]) . "\n\n";
1056 echo "data: [DONE]\n\n";
1057 flush();
1058 } else {
1059 wp_send_json_error([
1060 'error_message' => $error_message,
1061 'error_code' => $error_code
1062 ]);
1063 }
1064 wp_die();
1065 }
1066
1067 public function mxchat_handle_chat_request() {
1068 global $wpdb;
1069
1070 // Debug: Log incoming bot_id
1071 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1072 //error_log("=== MXCHAT DEBUG: Starting chat request ===");
1073 //error_log("MXCHAT DEBUG: Bot ID received: " . $bot_id);
1074
1075 // Get bot-specific options
1076 $bot_options = $this->get_bot_options($bot_id);
1077 $current_options = !empty($bot_options) ? $bot_options : $this->options;
1078
1079 // Check if this is a streaming request
1080 // Allow force_streaming_test parameter to bypass the setting check (for admin compatibility testing)
1081 $force_streaming_test = isset($_POST['force_streaming_test']) && $_POST['force_streaming_test'] === '1' && current_user_can('administrator');
1082 $is_streaming = isset($_POST['action']) && $_POST['action'] === 'mxchat_stream_chat' &&
1083 ($force_streaming_test || (isset($current_options['enable_streaming_toggle']) && $current_options['enable_streaming_toggle'] === 'on'));
1084
1085 // ADDED: Store streaming state in class property for use in private methods
1086 $this->is_streaming = $is_streaming;
1087
1088 // NOTE: Streaming headers are now set later via setup_streaming_headers()
1089 // This allows actions/forms to return JSON responses without header conflicts
1090
1091 // Check if MX Chat Moderation is active
1092 if (class_exists('MX_Chat_Moderation')) {
1093 // Get user email and IP
1094 $user_email = '';
1095 $user_ip = $_SERVER['REMOTE_ADDR'];
1096
1097 // If user is logged in, get their email
1098 if (is_user_logged_in()) {
1099 $current_user = wp_get_current_user();
1100 $user_email = $current_user->user_email;
1101 }
1102
1103 // Create ban handler instance
1104 $ban_handler = new MX_Chat_Ban_Handler();
1105
1106 // Check if user is banned by IP
1107 if ($ban_handler->check_ban($user_ip, 'ip')) {
1108 wp_send_json([
1109 'success' => false,
1110 'message' => esc_html__('Access denied. Your IP address has been banned.', 'mxchat'),
1111 'status' => 'banned'
1112 ]);
1113 wp_die();
1114 }
1115
1116 // If user is logged in, also check email
1117 if (!empty($user_email) && $ban_handler->check_ban($user_email, 'email')) {
1118 wp_send_json([
1119 'success' => false,
1120 'message' => esc_html__('Access denied. Your email address has been banned.', 'mxchat'),
1121 'status' => 'banned'
1122 ]);
1123 wp_die();
1124 }
1125 }
1126
1127 $this->fallbackResponse = ['text' => '', 'html' => '', 'images' => []];
1128 $this->productCardHtml = '';
1129
1130 // Get the actual WordPress user ID if logged in
1131 $is_logged_in = is_user_logged_in();
1132 if ($is_logged_in) {
1133 $user_id = get_current_user_id(); // This will get the actual WordPress user ID
1134 } else {
1135 // For logged-out users, use your existing identifier method
1136 $user_id = $this->mxchat_get_user_identifier();
1137 }
1138
1139 // Get and sanitize the user identifier
1140 $user_id = sanitize_key($user_id);
1141
1142 // Check rate limit using new settings structure
1143 $rate_limit_result = $this->check_rate_limit();
1144
1145 if ($rate_limit_result !== true) {
1146 wp_send_json([
1147 'success' => false,
1148 'message' => $rate_limit_result['message'],
1149 'status' => 'rate_limit_exceeded'
1150 ]);
1151 wp_die();
1152 }
1153
1154 // Rest of your existing code...
1155 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1156
1157 if (empty($session_id)) {
1158 wp_send_json_error(esc_html__('Session ID is missing.', 'mxchat'));
1159 wp_die();
1160 }
1161
1162 // SECURITY FIX: Verify session ownership before processing chat request
1163 // If IP/user changed, signal frontend to reset session instead of blocking
1164 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
1165 $session_owner = get_option("mxchat_session_owner_{$session_id}");
1166
1167 if ($session_owner && $session_owner !== $current_user_identifier) {
1168 // Instead of blocking, tell frontend to start a fresh session
1169 wp_send_json_error([
1170 'message' => esc_html__('Your session has expired. Starting a new conversation.', 'mxchat'),
1171 'code' => 'session_expired',
1172 'action' => 'reset_session'
1173 ]);
1174 wp_die();
1175 }
1176
1177 // Validate and sanitize the incoming message
1178 if (empty($_POST['message'])) {
1179 wp_send_json_error(esc_html__('No message received.', 'mxchat'));
1180 wp_die();
1181 }
1182
1183
1184 // Track originating page for first message in session
1185 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1186
1187 // Check if originating page columns exist
1188 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
1189
1190 if ($columns_exist) {
1191 // Check if this session already has messages
1192 $message_count = $wpdb->get_var($wpdb->prepare(
1193 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
1194 $session_id
1195 ));
1196
1197 // If this is the first message in the session
1198 if ($message_count == 0) {
1199 // Get originating page from JavaScript (preferred) or HTTP_REFERER (fallback)
1200 $originating_url = '';
1201 $originating_title = '';
1202
1203 // Try to get from POST data first (sent by JavaScript)
1204 if (isset($_POST['current_page_url'])) {
1205 $originating_url = esc_url_raw($_POST['current_page_url']);
1206 $originating_title = isset($_POST['current_page_title'])
1207 ? sanitize_text_field($_POST['current_page_title'])
1208 : '';
1209 }
1210 // Fallback to HTTP_REFERER if not provided by JavaScript
1211 else if (isset($_SERVER['HTTP_REFERER'])) {
1212 $originating_url = esc_url_raw($_SERVER['HTTP_REFERER']);
1213 }
1214
1215 // Generate title if we have URL but no title
1216 if ($originating_url && empty($originating_title)) {
1217 $parsed_url = parse_url($originating_url);
1218 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
1219
1220 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
1221 $originating_title = 'Homepage';
1222 } else {
1223 // Clean up the path to make a readable title
1224 $originating_title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
1225 $originating_title = ucwords(trim($originating_title));
1226 }
1227 }
1228
1229 // Store for later use when saving the message
1230 $this->pending_originating_page = [
1231 'url' => $originating_url,
1232 'title' => $originating_title
1233 ];
1234 }
1235 }
1236
1237
1238
1239 // Get page context if provided
1240 $page_context = null;
1241 if (isset($_POST['page_context']) && !empty($_POST['page_context'])) {
1242 $page_context_raw = stripslashes($_POST['page_context']);
1243 $page_context = json_decode($page_context_raw, true);
1244
1245 // Validate page context structure
1246 if (is_array($page_context) &&
1247 isset($page_context['url']) &&
1248 isset($page_context['title']) &&
1249 isset($page_context['content'])) {
1250
1251 // Sanitize page context
1252 $page_context['url'] = esc_url_raw($page_context['url']);
1253 $page_context['title'] = sanitize_text_field($page_context['title']);
1254 $page_context['content'] = wp_kses_post($page_context['content']);
1255 } else {
1256 $page_context = null;
1257 }
1258 }
1259
1260 // Modify the message sanitization to preserve PHP tags in code blocks
1261 $allowed_tags = [
1262 'pre' => [],
1263 'code' => ['class' => true],
1264 'span' => ['class' => true],
1265 'div' => ['class' => true],
1266 ];
1267
1268 // First preserve code blocks
1269 $message = preg_replace_callback('/<pre><code.*?>.*?<\/code><\/pre>/s', function($matches) {
1270 return htmlspecialchars_decode($matches[0]);
1271 }, $_POST['message']);
1272
1273 // Then apply sanitization
1274 $message = wp_kses($message, $allowed_tags);
1275
1276 // Preserve code blocks from markdown conversion
1277 $message = preg_replace('/```(\w+)?\s*([\s\S]+?)```/s', '<pre><code class="$1">$2</code></pre>', $message);
1278 $message = apply_filters('mxchat_filter_message', $message, 'prompt', $session_id);
1279
1280 // ===== SIMPLIFIED TESTING PANEL INITIALIZATION =====
1281 // Always initialize testing data for admins (no toggle needed)
1282 $testing_data = null;
1283 if (current_user_can('administrator')) {
1284 // For vision messages, use the original user message for the query display
1285 $query_for_testing = $message;
1286 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1287 $query_for_testing = sanitize_textarea_field($_POST['original_user_message']);
1288 }
1289
1290 $testing_data = [
1291 'query' => $query_for_testing,
1292 'timestamp' => time(),
1293 'top_matches' => [],
1294 'action_matches' => [], // Initialize action matches array
1295 'page_context' => $page_context, // Include page context in testing data
1296 'is_vision' => isset($_POST['vision_processed']) && $_POST['vision_processed'],
1297 'bot_id' => $bot_id // Include bot ID in testing data
1298 ];
1299
1300 // Get similarity threshold from bot options or default options
1301 $similarity_threshold = isset($current_options['similarity_threshold'])
1302 ? ((int) $current_options['similarity_threshold']) / 100
1303 : 0.35;
1304
1305 $testing_data['similarity_threshold'] = $similarity_threshold;
1306
1307 // Determine knowledge base type using bot-specific config
1308 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
1309 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
1310 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
1311 }
1312 // ===== END SIMPLIFIED TESTING INITIALIZATION =====
1313
1314 // Add debug before and after:
1315 //error_log('MxChat Core: About to call mxchat_pre_process_message filter with message: ' . $message);
1316 $pre_processed_result = apply_filters('mxchat_pre_process_message', $message, $user_id, $session_id);
1317 //error_log('MxChat Core: Filter returned: ' . (is_array($pre_processed_result) ? 'array' : $pre_processed_result));
1318
1319
1320 // If the pre-processing returned a result (not the original message), use it directly
1321 if (is_array($pre_processed_result) && isset($pre_processed_result['text'])) {
1322 // Save the AI response
1323 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['text']);
1324
1325 // Save HTML content if provided
1326 if (!empty($pre_processed_result['html'])) {
1327 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['html']);
1328 }
1329
1330 // Add testing data if admin
1331 $response_data = [
1332 'text' => $pre_processed_result['text'],
1333 'html' => $pre_processed_result['html'] ?? '',
1334 'session_id' => $session_id
1335 ];
1336
1337 if ($testing_data !== null) {
1338 $response_data['testing_data'] = $testing_data;
1339 }
1340
1341 wp_send_json($response_data);
1342 wp_die();
1343 }
1344
1345 // Save the user's message - handle vision processed messages differently
1346 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1347 // For vision messages, save the original user message with image indicator
1348 $original_message = sanitize_textarea_field($_POST['original_user_message']);
1349 if (isset($_POST['vision_images_count']) && $_POST['vision_images_count'] > 0) {
1350 $image_count = intval($_POST['vision_images_count']);
1351 $original_message .= " [{$image_count} image(s)]";
1352 }
1353 $this->mxchat_save_chat_message($session_id, 'user', $original_message);
1354 } else {
1355 // Regular message - save as normal
1356 $this->mxchat_save_chat_message($session_id, 'user', $message);
1357 }
1358
1359
1360 if (is_email($message)) {
1361 // Add the email to Loops
1362 $this->add_email_to_loops($message);
1363
1364 // Get the user's success message instruction using current_options
1365 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1366
1367 // Set instruction for AI using the user's success message
1368 $this->current_action_instruction = $user_success_message;
1369
1370 // Clear the email capture transient since we got the email
1371 delete_transient('mxchat_email_capture_' . $user_id);
1372 }
1373
1374 // Check if we're in an email capture flow but user hasn't provided email yet
1375 elseif (get_transient('mxchat_email_capture_' . $user_id)) {
1376 // Check if the message contains an email (not the whole message being an email)
1377 if (preg_match('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', $message, $matches)) {
1378 $extracted_email = $matches[0];
1379
1380 // Add the extracted email to Loops
1381 $this->add_email_to_loops($extracted_email);
1382
1383 // Get the user's success message instruction using current_options
1384 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1385
1386 // Set instruction for AI using the user's success message
1387 $this->current_action_instruction = $user_success_message;
1388
1389 // Clear the email capture transient since we got the email
1390 delete_transient('mxchat_email_capture_' . $user_id);
1391 }
1392 // If no email found but we're in capture mode, remind them
1393 else {
1394 // Get the original instruction to remind them using current_options
1395 $original_instruction = $current_options['triggered_phrase_response'] ?? __("Please provide your email address.", 'mxchat');
1396 $this->current_action_instruction = $original_instruction;
1397 }
1398 }
1399
1400 $intent_info = '';
1401
1402 // Check chat mode
1403 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1404
1405 // Handle agent mode
1406 // Handle agent mode
1407 if ($chat_mode === 'agent') {
1408 // First, check for switch intent before doing anything else
1409 $intent_matched = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1410
1411 // Capture action analysis for testing panel after intent check
1412 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1413 $testing_data['action_matches'] = $this->last_action_analysis;
1414 }
1415
1416 // Around line 506, in the agent mode handling section:
1417 if ($intent_matched && !empty($this->fallbackResponse['text'])) {
1418 // Update chat mode first
1419 update_option("mxchat_mode_{$session_id}", 'ai');
1420
1421 // Clear any existing PDF context to start fresh
1422 $this->clear_pdf_transients($session_id);
1423
1424 // Prepare clean switch response with explicit chat_mode
1425 $response_data = [
1426 'text' => $this->fallbackResponse['text'],
1427 'html' => $this->fallbackResponse['html'] ?? '',
1428 'session_id' => $session_id,
1429 'chat_mode' => 'ai' // EXPLICITLY SET THIS
1430 ];
1431
1432 if ($testing_data !== null) {
1433 $response_data['testing_data'] = $testing_data;
1434 }
1435
1436 // Save the mode switch message
1437 $this->mxchat_save_chat_message($session_id, 'system', esc_html__('Switched to AI chat mode', 'mxchat'));
1438 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1439
1440 // Send response and exit
1441 wp_send_json($response_data);
1442 wp_die();
1443 } elseif (!$intent_matched) {
1444 // No intent matched, handle live agent message
1445 try {
1446 $this->mxchat_send_user_message_to_agent($message, $user_id, $session_id);
1447
1448 $agent_response = [
1449 'status' => 'waiting_for_agent',
1450 'message' => esc_html__('Message sent to live agent.', 'mxchat')
1451 ];
1452
1453 if ($testing_data !== null) {
1454 $agent_response['testing_data'] = $testing_data;
1455 }
1456
1457 wp_send_json_success($agent_response);
1458 } catch (\Exception $e) {
1459 wp_send_json_error(esc_html__('Failed to send message to agent', 'mxchat'));
1460 }
1461 wp_die();
1462 }
1463 }
1464
1465 // Step 1: Check for new PDF URL in the message
1466 if (!isset($_POST['vision_processed']) && preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
1467 $new_pdf_url = $matches[0];
1468
1469 // Check if this is likely a PDF-related request
1470 $pdf_keywords = ['pdf', 'document', 'read', 'analyze'];
1471 $is_pdf_request = false;
1472
1473 foreach ($pdf_keywords as $keyword) {
1474 if (stripos($message, $keyword) !== false) {
1475 $is_pdf_request = true;
1476 break;
1477 }
1478 }
1479
1480 // If it looks like a PDF request or we're waiting for a PDF URL
1481 if ($is_pdf_request || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
1482 // Validate HTTPS
1483 if (wp_http_validate_url($new_pdf_url) && parse_url($new_pdf_url, PHP_URL_SCHEME) === 'https') {
1484 // Extract filename from URL
1485 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1486
1487 // Clear previous PDF transients
1488 $this->clear_pdf_transients($session_id);
1489
1490 // Process new PDF using current_options
1491 $max_pages = $current_options['pdf_max_pages'] ?? 69;
1492 $embeddings = $this->fetch_and_split_pdf_pages($new_pdf_url, $max_pages);
1493
1494 if ($embeddings === 'too_many_pages') {
1495 $error_text = sprintf(
1496 $current_options['pdf_intent_error_text'] ??
1497 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
1498 $max_pages
1499 );
1500 $this->fallbackResponse['text'] = $error_text;
1501 } elseif ($embeddings) {
1502 // Store new PDF information
1503 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1504
1505 // If the filename is generic, create a more descriptive one
1506 if (in_array($pdf_filename, ['results_download.php', 'download.php', 'view.php', 'pdf.php']) ||
1507 strpos($pdf_filename, '.php') !== false) {
1508 $pdf_filename = 'Document_' . date('Y-m-d_H-i') . '.pdf';
1509 }
1510
1511 set_transient('mxchat_pdf_url_' . $session_id, $new_pdf_url, HOUR_IN_SECONDS);
1512 set_transient('mxchat_pdf_filename_' . $session_id, $pdf_filename, HOUR_IN_SECONDS);
1513 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
1514 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
1515
1516 $success_text = $current_options['pdf_intent_success_text'] ??
1517 esc_html__("I've processed the new PDF '{$pdf_filename}'. What questions do you have about it?", 'mxchat');
1518
1519 $pdf_response = [
1520 'success' => true,
1521 'message' => $success_text,
1522 'data' => [
1523 'filename' => $pdf_filename
1524 ]
1525 ];
1526
1527 if ($testing_data !== null) {
1528 $pdf_response['testing_data'] = $testing_data;
1529 }
1530
1531 wp_send_json($pdf_response);
1532 wp_die();
1533 } else {
1534 $error_text = $current_options['pdf_intent_error_text'] ??
1535 esc_html__("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
1536 $this->fallbackResponse['text'] = $error_text;
1537 }
1538
1539 $pdf_error_response = [
1540 'success' => false,
1541 'message' => $this->fallbackResponse['text']
1542 ];
1543
1544 if ($testing_data !== null) {
1545 $pdf_error_response['testing_data'] = $testing_data;
1546 }
1547
1548 wp_send_json($pdf_error_response);
1549 wp_die();
1550 }
1551 }
1552 }
1553
1554
1555 // Step 2: Detect intent and handle intent-based responses
1556 $intent_result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1557
1558 // Capture action analysis for testing panel after intent check
1559 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1560 $testing_data['action_matches'] = $this->last_action_analysis;
1561 }
1562
1563 // Step 3: Handle the intent result appropriately
1564 if ($intent_result !== false) {
1565 // Intent was matched - ALWAYS send as JSON response, never streaming
1566
1567 if (is_array($intent_result) && (isset($intent_result['text']) || isset($intent_result['html']))) {
1568 // Intent returned a direct response array
1569 $response_data = [
1570 'text' => $intent_result['text'] ?? '',
1571 'html' => $intent_result['html'] ?? '',
1572 'session_id' => $session_id
1573 ];
1574
1575 // IMPORTANT: Include chat_mode if present (for WhatsApp, Slack, etc.)
1576 if (isset($intent_result['chat_mode'])) {
1577 $response_data['chat_mode'] = $intent_result['chat_mode'];
1578 }
1579
1580 if ($testing_data !== null) {
1581 $response_data['testing_data'] = $testing_data;
1582 }
1583
1584 wp_send_json($response_data);
1585 wp_die();
1586 } else if ($intent_result === true && (!empty($this->fallbackResponse['text']) || !empty($this->fallbackResponse['html']))) {
1587 // Intent returned true and set fallbackResponse
1588
1589 // SAVE TO TRANSCRIPT
1590 if (!empty($this->fallbackResponse['text'])) {
1591 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1592 }
1593 // Save action HTML (product cards, featured products, etc.) so it renders in transcripts
1594 if (!empty($this->fallbackResponse['html'])) {
1595 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1596 }
1597
1598 $response_data = [
1599 'text' => $this->fallbackResponse['text'] ?? '',
1600 'html' => $this->fallbackResponse['html'] ?? '',
1601 'session_id' => $session_id
1602 ];
1603
1604 if (isset($this->fallbackResponse['chat_mode'])) {
1605 $response_data['chat_mode'] = $this->fallbackResponse['chat_mode'];
1606 }
1607
1608 if ($testing_data !== null) {
1609 $response_data['testing_data'] = $testing_data;
1610 }
1611
1612 wp_send_json($response_data);
1613 wp_die();
1614 }
1615 }
1616
1617 // If we get here, no intent matched OR the intent didn't provide a usable response
1618
1619 // Step 4: Generate AI response
1620 // Get session start timestamp - when persistence is OFF, only include messages from this page load
1621 $session_start_timestamp = isset($_POST['session_start_timestamp']) ? intval($_POST['session_start_timestamp']) : 0;
1622 $conversation_history = $this->mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp);
1623 $this->mxchat_increment_chat_count();
1624
1625 // Generate embedding for the user's query - USE BOT-SPECIFIC API KEY
1626 $api_key = $current_options['api_key'] ?? $this->options['api_key'];
1627 $user_message_embedding = $this->mxchat_generate_embedding($message, $api_key);
1628
1629 // Check if the embedding generation returned an error
1630 if (is_array($user_message_embedding) && isset($user_message_embedding['error'])) {
1631 $error_message = $user_message_embedding['error'];
1632 $error_code = $user_message_embedding['error_code'] ?? 'embedding_error';
1633
1634 // FIXED: Send error in appropriate format based on streaming mode
1635 if ($is_streaming) {
1636 echo "data: " . json_encode([
1637 'error' => true,
1638 'error_message' => $error_message,
1639 'error_code' => $error_code,
1640 'text' => $error_message,
1641 'message' => $error_message
1642 ]) . "\n\n";
1643 echo "data: [DONE]\n\n";
1644 flush();
1645 } else {
1646 wp_send_json_error([
1647 'error_message' => $error_message,
1648 'error_code' => $error_code
1649 ]);
1650 }
1651 wp_die();
1652 }
1653
1654 // Check if the embedding is valid
1655 if (!is_array($user_message_embedding) || empty($user_message_embedding)) {
1656 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
1657
1658 // FIXED: Send error in appropriate format based on streaming mode
1659 if ($is_streaming) {
1660 echo "data: " . json_encode([
1661 'error' => true,
1662 'error_message' => $error_message,
1663 'error_code' => 'invalid_embedding',
1664 'text' => $error_message,
1665 'message' => $error_message
1666 ]) . "\n\n";
1667 echo "data: [DONE]\n\n";
1668 flush();
1669 } else {
1670 wp_send_json_error([
1671 'error_message' => $error_message,
1672 'error_code' => 'invalid_embedding'
1673 ]);
1674 }
1675 wp_die();
1676 }
1677
1678 // Build context with both knowledge base and PDF content if available
1679 $context_content = "User asked: '{$message}'\n\n";
1680
1681 // Add action instruction if present (add this right after the above line)
1682 if (!empty($this->current_action_instruction)) {
1683 $context_content .= "===== SPECIAL INSTRUCTION =====\n";
1684 $context_content .= "IMPORTANT: " . $this->current_action_instruction . "\n";
1685 $context_content .= "Respond naturally and conversationally while following this instruction.\n";
1686 $context_content .= "===== END SPECIAL INSTRUCTION =====\n\n";
1687
1688 // Clear the instruction after using it
1689 $this->current_action_instruction = null;
1690 }
1691
1692
1693 // Add page context if available and contextual awareness is enabled using current_options
1694 if ($page_context && isset($current_options['contextual_awareness_toggle']) && $current_options['contextual_awareness_toggle'] === 'on') {
1695 $context_content .= "===== CURRENT PAGE CONTEXT =====\n";
1696 $context_content .= "Page URL: " . $page_context['url'] . "\n";
1697 $context_content .= "Page Title: " . $page_context['title'] . "\n";
1698 $context_content .= "Page Content: " . $page_context['content'] . "\n";
1699 $context_content .= "===== END CURRENT PAGE CONTEXT =====\n\n";
1700 }
1701
1702 // Get relevant content from knowledge base - PASS BOT_ID and MESSAGE for Vector Store
1703 $relevant_content = $this->mxchat_find_relevant_content($user_message_embedding, $bot_id, $message);
1704
1705 // NEW: Also extract URLs from system instructions (only if citation links enabled)
1706 // Use fresh options to ensure we get the latest setting value
1707 $fresh_options = get_option('mxchat_options', []);
1708 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
1709
1710 $system_instructions = $this->get_system_instructions($bot_id, $session_id);
1711 if ($citation_links_enabled && !empty($system_instructions)) {
1712 preg_match_all(
1713 '#\bhttps?://[^\s<>"\']+#i',
1714 $system_instructions,
1715 $system_instruction_urls
1716 );
1717
1718 if (!empty($system_instruction_urls[0])) {
1719 // Merge with existing valid URLs
1720 $this->current_valid_urls = array_merge(
1721 $this->current_valid_urls,
1722 $system_instruction_urls[0]
1723 );
1724 // Remove duplicates
1725 $this->current_valid_urls = array_unique($this->current_valid_urls);
1726
1727 //error_log("Added " . count($system_instruction_urls[0]) . " URLs from system instructions");
1728 }
1729 }
1730
1731 // ===== CAPTURE REAL SIMILARITY DATA FOR ADMINS =====
1732 if ($testing_data !== null && $this->last_similarity_analysis !== null) {
1733 // Update testing data with the REAL similarity analysis
1734 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
1735 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
1736 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
1737 $testing_data['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
1738 $testing_data['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
1739 }
1740 // ===== END SIMILARITY DATA CAPTURE =====
1741
1742 // NEW: Add valid URLs to testing data for admin panel display (AFTER similarity data)
1743 if ($testing_data !== null && !empty($this->current_valid_urls)) {
1744 $testing_data['approved_urls'] = array_values($this->current_valid_urls);
1745 //error_log("Added " . count($this->current_valid_urls) . " approved URLs to testing data");
1746 }
1747
1748 if (!empty($relevant_content)) {
1749 $context_content .= "===== OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n" . $relevant_content . "\n===== END OF OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n\n";
1750 } else {
1751 $context_content .= "===== NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE =====\n";
1752 }
1753
1754 // NEW: Add approved URLs list to context for AI (only if citation links enabled)
1755 if ($citation_links_enabled && !empty($this->current_valid_urls)) {
1756 $context_content .= "===== APPROVED URLS FOR CITATIONS =====\n";
1757 $context_content .= "You may ONLY use these exact URLs in your response:\n";
1758 foreach ($this->current_valid_urls as $url) {
1759 $context_content .= "- " . $url . "\n";
1760 }
1761 $context_content .= "\nCRITICAL: Do NOT create, modify, extend, or invent any other URLs. ";
1762 $context_content .= "===== END APPROVED URLS =====\n\n";
1763 }
1764
1765 // Check for and include PDF content
1766 $pdf_url = get_transient('mxchat_pdf_url_' . $session_id);
1767 $pdf_embeddings = get_transient('mxchat_pdf_embeddings_' . $session_id);
1768 $pdf_filename = get_transient('mxchat_pdf_filename_' . $session_id);
1769 if ($pdf_url && $pdf_embeddings && get_transient('mxchat_include_pdf_in_context_' . $session_id)) {
1770 $relevant_pdf_pages = $this->find_relevant_pdf_pages($user_message_embedding, $pdf_embeddings);
1771 if (!empty($relevant_pdf_pages)) {
1772 $context_content .= "Relevant content from PDF document '{$pdf_filename}':\n";
1773 foreach ($relevant_pdf_pages as $page_data) {
1774 $context_content .= "Page {$page_data['page_number']} of '{$pdf_filename}': {$page_data['text']}\n";
1775 }
1776 $context_content .= "\n";
1777 }
1778 }
1779
1780 // Check for and include Word content
1781 $word_url = get_transient('mxchat_word_url_' . $session_id);
1782 $word_embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
1783 $word_filename = get_transient('mxchat_word_filename_' . $session_id);
1784 if ($word_url && $word_embeddings && get_transient('mxchat_include_word_in_context_' . $session_id)) {
1785 $relevant_word_chunks = $this->word_handler->mxchat_find_relevant_word_chunks($user_message_embedding, $word_embeddings);
1786 if (!empty($relevant_word_chunks)) {
1787 $context_content .= "Relevant content from Word document '{$word_filename}':\n";
1788 foreach ($relevant_word_chunks as $chunk_data) {
1789 $context_content .= "Section {$chunk_data['chunk_number']} of '{$word_filename}': {$chunk_data['text']}\n";
1790 }
1791 $context_content .= "\n";
1792 }
1793 }
1794
1795 $context_content = apply_filters('mxchat_prepare_context', $context_content, $session_id);
1796
1797 // Extract model from current options for bot-specific model support
1798 $selected_model = isset($current_options['model']) ? $current_options['model'] : 'gpt-5.1-chat-latest';
1799
1800 $response = $this->mxchat_generate_response(
1801 $context_content,
1802 $current_options['api_key'] ?? $this->options['api_key'],
1803 $current_options['xai_api_key'] ?? $this->options['xai_api_key'],
1804 $current_options['claude_api_key'] ?? $this->options['claude_api_key'],
1805 $current_options['deepseek_api_key'] ?? $this->options['deepseek_api_key'],
1806 $current_options['gemini_api_key'] ?? $this->options['gemini_api_key'],
1807 $current_options['openrouter_api_key'] ?? $this->options['openrouter_api_key'],
1808 $conversation_history,
1809 $is_streaming,
1810 $session_id,
1811 $testing_data,
1812 $selected_model
1813 );
1814
1815 // Handle streaming vs non-streaming responses
1816 if ($is_streaming) {
1817 // Check if streaming actually happened or if it fell back to regular response
1818 if ($response === true) {
1819 wp_die();
1820 }
1821 // If we get here, streaming fell back to regular response, continue
1822 // But if there's an error, we need to send it as SSE format since headers are already set
1823 if (is_array($response) && isset($response['error'])) {
1824 $error_message = $response['error'];
1825 $error_code = $response['error_code'] ?? 'api_error';
1826 // Send error in SSE format that the client JS can handle
1827 echo "data: " . json_encode([
1828 'error' => true,
1829 'error_message' => $error_message,
1830 'error_code' => $error_code,
1831 'text' => $error_message, // Also include as text for fallback handling
1832 'message' => $error_message
1833 ]) . "\n\n";
1834 echo "data: [DONE]\n\n";
1835 flush();
1836 wp_die();
1837 }
1838 }
1839
1840 // Check if the response is an error array (non-streaming mode)
1841 if (is_array($response) && isset($response['error'])) {
1842 wp_send_json_error([
1843 'error_message' => $response['error'],
1844 'error_code' => $response['error_code'] ?? 'api_error'
1845 ]);
1846 wp_die();
1847 }
1848
1849 // DEBUG: Check what we have
1850 //error_log("=== BEFORE URL VALIDATION ===");
1851 //error_log("current_valid_urls is empty? " . (empty($this->current_valid_urls) ? 'YES' : 'NO'));
1852 //error_log("current_valid_urls count: " . count($this->current_valid_urls));
1853 //error_log("current_valid_urls content: " . print_r($this->current_valid_urls, true));
1854
1855 // If we get here, the response is valid text - now validate URLs
1856 if (!empty($this->current_valid_urls)) {
1857 //error_log("CALLING validate_and_clean_urls");
1858 $response = $this->validate_and_clean_urls($response, $this->current_valid_urls);
1859 } else {
1860 //error_log("SKIPPING validation - current_valid_urls is empty");
1861 }
1862 // ===== END URL VALIDATION =====
1863
1864 // Prepare RAG context data for storage (only include documents used for context)
1865 $rag_context_for_storage = null;
1866 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
1867 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
1868
1869 if ($has_rag_data || $has_action_data) {
1870 $rag_context_for_storage = [];
1871
1872 // Add RAG/source data if available
1873 if ($has_rag_data) {
1874 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
1875 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
1876 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
1877 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
1878 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
1879 $rag_context_for_storage['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
1880 $rag_context_for_storage['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
1881 }
1882
1883 // Add action analysis data if available
1884 if ($has_action_data) {
1885 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
1886 }
1887 }
1888
1889 // Save the cleaned response with RAG context
1890 $this->mxchat_save_chat_message($session_id, 'bot', $response, null, $rag_context_for_storage);
1891
1892 // Step 5: Save additional content if available
1893 if (!empty($this->productCardHtml)) {
1894 $this->mxchat_save_chat_message($session_id, 'bot', $this->productCardHtml);
1895 }
1896
1897 if (!empty($this->fallbackResponse['html'])) {
1898 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1899 }
1900
1901 // Step 6: Return the response
1902 // DEBUG: Check if newlines exist in the response
1903 //error_log("=== MXCHAT NON-STREAMING RESPONSE DEBUG ===");
1904 //error_log("Response has newlines: " . (strpos($response, "\n") !== false ? 'YES' : 'NO'));
1905 //error_log("Response first 500 chars: " . substr($response, 0, 500));
1906
1907 $response_data = [
1908 'text' => $response,
1909 'html' => !empty($this->productCardHtml) ? $this->productCardHtml : ($this->fallbackResponse['html'] ?? ''),
1910 'session_id' => $session_id
1911 ];
1912
1913 // Include vectorstore error info for admin debugging (only visible to admins via testing_data)
1914 if (!empty($this->last_vectorstore_error) && $testing_data !== null) {
1915 $testing_data['vectorstore_error'] = $this->last_vectorstore_error;
1916 }
1917
1918 // Also pass it as a top-level field so JS can show a better error message to admins
1919 if (!empty($this->last_vectorstore_error) && current_user_can('manage_options')) {
1920 $response_data['vectorstore_error'] = $this->last_vectorstore_error;
1921 }
1922
1923 // Always add testing data for admins (no toggle needed)
1924 if ($testing_data !== null) {
1925 $response_data['testing_data'] = $testing_data;
1926 }
1927
1928 wp_send_json($response_data);
1929 wp_die();
1930 }
1931
1932 /**
1933 * Get bot-specific options for multi-bot functionality
1934 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
1935 */
1936 // Also debug the bot options retrieval
1937 private function get_bot_options($bot_id = 'default') {
1938 //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
1939
1940 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1941 //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
1942 return array();
1943 }
1944
1945 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
1946
1947 if (!empty($bot_options)) {
1948 //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
1949 if (isset($bot_options['similarity_threshold'])) {
1950 //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
1951 }
1952 }
1953
1954 return is_array($bot_options) ? $bot_options : array();
1955 }
1956
1957 /**
1958 * Get bot-specific Pinecone configuration
1959 * Used in the knowledge retrieval functions
1960 */
1961 // Also add debugging to your get_bot_pinecone_config function
1962 private function get_bot_pinecone_config($bot_id = 'default') {
1963 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
1964
1965 // If default bot or multi-bot add-on not active, use default Pinecone config
1966 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1967 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
1968 $addon_options = get_option('mxchat_pinecone_addon_options', array());
1969 $config = array(
1970 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
1971 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
1972 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
1973 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
1974 );
1975 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
1976 return $config;
1977 }
1978
1979 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
1980
1981 // Hook for multi-bot add-on to provide bot-specific Pinecone config
1982 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1983
1984 if (!empty($bot_pinecone_config)) {
1985 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
1986 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
1987 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
1988 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
1989 } else {
1990 //error_log("MXCHAT DEBUG: Filter returned empty config!");
1991 }
1992
1993 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
1994 }
1995
1996
1997 // Updated function to check intents and invoke the callback function
1998 private function mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id) {
1999 global $wpdb;
2000 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
2001
2002 // Get the current bot_id
2003 $current_bot_id = $this->get_current_bot_id($session_id);
2004
2005 // Generate the user embedding
2006 $user_embedding = $this->mxchat_generate_embedding($message, $this->options['api_key']);
2007
2008 // Check if embedding generation returned an error
2009 if (is_array($user_embedding) && isset($user_embedding['error'])) {
2010 $error_message = $user_embedding['error'];
2011 $error_code = $user_embedding['error_code'] ?? 'embedding_error';
2012
2013 // FIXED: Send error in appropriate format based on streaming mode
2014 if ($this->is_streaming) {
2015 echo "data: " . json_encode([
2016 'error' => true,
2017 'error_message' => $error_message,
2018 'error_code' => $error_code,
2019 'text' => $error_message,
2020 'message' => $error_message
2021 ]) . "\n\n";
2022 echo "data: [DONE]\n\n";
2023 flush();
2024 } else {
2025 wp_send_json_error([
2026 'error_message' => $error_message,
2027 'error_code' => $error_code
2028 ]);
2029 }
2030 wp_die();
2031 }
2032
2033 // Check if embedding is valid
2034 if (!is_array($user_embedding) || empty($user_embedding)) {
2035 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
2036
2037 // FIXED: Send error in appropriate format based on streaming mode
2038 if ($this->is_streaming) {
2039 echo "data: " . json_encode([
2040 'error' => true,
2041 'error_message' => $error_message,
2042 'error_code' => 'invalid_embedding',
2043 'text' => $error_message,
2044 'message' => $error_message
2045 ]) . "\n\n";
2046 echo "data: [DONE]\n\n";
2047 flush();
2048 } else {
2049 wp_send_json_error([
2050 'error_message' => $error_message,
2051 'error_code' => 'invalid_embedding'
2052 ]);
2053 }
2054 wp_die();
2055 }
2056
2057 // Fetch intents from the database
2058 $table_name = $wpdb->prefix . 'mxchat_intents';
2059 if ($chat_mode === 'agent') {
2060 $query = $wpdb->prepare(
2061 "SELECT * FROM $table_name WHERE callback_function = %s AND (enabled = 1 OR enabled IS NULL)",
2062 'mxchat_handle_switch_to_chatbot_intent'
2063 );
2064 $intents = $wpdb->get_results($query);
2065 } else {
2066 $intents = $wpdb->get_results("SELECT * FROM $table_name WHERE enabled = 1 OR enabled IS NULL");
2067 }
2068
2069 if (empty($intents)) {
2070 return false;
2071 }
2072
2073 // Prefetch individual phrase vectors from wp_mxchat_intent_phrases (grouped by intent_id)
2074 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
2075 $phrases_by_intent = [];
2076 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
2077 $all_phrases = $wpdb->get_results("SELECT intent_id, phrase, embedding_vector FROM $phrases_table");
2078 foreach ($all_phrases as $p) {
2079 $phrases_by_intent[$p->intent_id][] = $p;
2080 }
2081 }
2082
2083 $highest_similarity = -INF;
2084 $matched_intent = null;
2085
2086 // Array to store action analysis for testing panel
2087 $action_analysis = [];
2088
2089 foreach ($intents as $intent) {
2090 // Additional check for enabled state
2091 $is_enabled = isset($intent->enabled) ? (bool)$intent->enabled : true;
2092 if (!$is_enabled) {
2093 continue;
2094 }
2095
2096 // Check if this action is enabled for the current bot
2097 if (!$this->is_action_enabled_for_bot($intent, $current_bot_id)) {
2098 continue;
2099 }
2100
2101 $best_similarity = -INF;
2102 $matched_phrase_text = '';
2103
2104 // Check legacy embedding vector (existing behavior)
2105 $intent_embedding_serialized = $intent->embedding_vector;
2106 $intent_embedding = $intent_embedding_serialized
2107 ? unserialize($intent_embedding_serialized, ['allowed_classes' => false])
2108 : null;
2109
2110 if (is_array($intent_embedding) && !empty($intent_embedding)) {
2111 $legacy_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $intent_embedding);
2112 if ($legacy_similarity > $best_similarity) {
2113 $best_similarity = $legacy_similarity;
2114 $matched_phrase_text = 'legacy';
2115 }
2116 }
2117
2118 // Check individual phrase vectors
2119 if (isset($phrases_by_intent[$intent->id])) {
2120 foreach ($phrases_by_intent[$intent->id] as $phrase_row) {
2121 $phrase_embedding = $phrase_row->embedding_vector
2122 ? unserialize($phrase_row->embedding_vector, ['allowed_classes' => false])
2123 : null;
2124 if (!is_array($phrase_embedding)) {
2125 continue;
2126 }
2127 $phrase_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $phrase_embedding);
2128 if ($phrase_similarity > $best_similarity) {
2129 $best_similarity = $phrase_similarity;
2130 $matched_phrase_text = $phrase_row->phrase;
2131 }
2132 }
2133 }
2134
2135 // Skip if no valid embedding was found at all
2136 if ($best_similarity === -INF) {
2137 continue;
2138 }
2139
2140 $similarity = $best_similarity;
2141 $intent_threshold = isset($intent->similarity_threshold) ? $intent->similarity_threshold : 0.85;
2142
2143 // Store action analysis data for testing panel
2144 $action_analysis[] = [
2145 'intent_label' => $intent->intent_label,
2146 'callback_function' => $intent->callback_function,
2147 'similarity' => round($similarity, 4),
2148 'similarity_percentage' => round($similarity * 100, 2),
2149 'threshold' => $intent_threshold,
2150 'threshold_percentage' => round($intent_threshold * 100, 2),
2151 'above_threshold' => $similarity >= $intent_threshold,
2152 'matched_phrase' => $matched_phrase_text,
2153 'triggered' => false // Will be updated below if this intent is triggered
2154 ];
2155
2156 if ($similarity >= $intent_threshold && $similarity > $highest_similarity) {
2157 $highest_similarity = $similarity;
2158 $matched_intent = $intent;
2159 }
2160 }
2161
2162 // Mark the triggered action if any
2163 if ($matched_intent) {
2164 foreach ($action_analysis as &$action) {
2165 if ($action['intent_label'] === $matched_intent->intent_label) {
2166 $action['triggered'] = true;
2167 break;
2168 }
2169 }
2170 }
2171
2172 // Sort actions by similarity (highest first) and store for testing panel
2173 usort($action_analysis, function($a, $b) {
2174 return $b['similarity'] <=> $a['similarity'];
2175 });
2176
2177 // Store action analysis for testing panel capture
2178 $this->last_action_analysis = $action_analysis;
2179
2180 // Around line 715 in your mxchat_check_intent_and_invoke_callback function
2181 if ($matched_intent) {
2182 // If the callback is a method on this instance (core callback), call it directly
2183 if (method_exists($this, $matched_intent->callback_function)) {
2184 $callback_result = call_user_func(
2185 [$this, $matched_intent->callback_function],
2186 $message,
2187 $user_id,
2188 $session_id,
2189 $matched_intent,
2190 $user_context ?? null
2191 );
2192 } else {
2193 // Otherwise, use apply_filters for add-on callbacks
2194 $callback_result = apply_filters(
2195 $matched_intent->callback_function,
2196 false,
2197 $message,
2198 $user_id,
2199 $session_id,
2200 $matched_intent
2201 );
2202 }
2203
2204 // Handle the callback result properly
2205 if ($callback_result !== false) {
2206 // If callback returned an array with chat_mode, use it directly
2207 if (is_array($callback_result) && isset($callback_result['chat_mode'])) {
2208 $this->fallbackResponse = $callback_result;
2209 return $callback_result; // Return the full array
2210 } else {
2211 $this->fallbackResponse = $callback_result;
2212 return true;
2213 }
2214 }
2215 }
2216
2217 return false;
2218 }
2219
2220 /**
2221 * Check if an action is enabled for a specific bot
2222 */
2223 private function is_action_enabled_for_bot($intent, $bot_id) {
2224 // If enabled_bots column doesn't exist or is null, assume it's enabled for all bots (backward compatibility)
2225 if (!isset($intent->enabled_bots) || empty($intent->enabled_bots)) {
2226 return true;
2227 }
2228
2229 $enabled_bots = json_decode($intent->enabled_bots, true);
2230
2231 // If JSON decode fails or returns empty array, assume enabled for all (backward compatibility)
2232 if (!is_array($enabled_bots) || empty($enabled_bots)) {
2233 return true;
2234 }
2235
2236 // Admin testing tab uses bot_id "testing" — treat it as "default" so all
2237 // default-bot actions are testable from the admin panel
2238 if ($bot_id === 'testing') {
2239 $bot_id = 'default';
2240 }
2241
2242 // Check if the current bot is in the enabled bots list
2243 return in_array($bot_id, $enabled_bots);
2244 }
2245
2246 // Helper function to clear PDF and Word document related transients
2247 private function clear_pdf_transients($session_id) {
2248 // PDF transients
2249 delete_transient('mxchat_pdf_url_' . $session_id);
2250 delete_transient('mxchat_pdf_embeddings_' . $session_id);
2251 delete_transient('mxchat_include_pdf_in_context_' . $session_id);
2252 delete_transient('mxchat_waiting_for_pdf_url_' . $session_id);
2253
2254 // Word document transients
2255 delete_transient('mxchat_word_url_' . $session_id);
2256 delete_transient('mxchat_word_filename_' . $session_id);
2257 delete_transient('mxchat_word_embeddings_' . $session_id);
2258 delete_transient('mxchat_include_word_in_context_' . $session_id);
2259 delete_transient('mxchat_waiting_for_word_' . $session_id);
2260 }
2261
2262
2263
2264 //verified good
2265 public function mxchat_handle_email_capture($message, $user_id, $session_id) {
2266 // Get the user's original instruction/message
2267 $user_instruction = esc_html($this->options['triggered_phrase_response'] ?? esc_html__("Please provide your email address.", 'mxchat'));
2268
2269 // Set instruction for AI - just pass along what the user wanted to say
2270 $this->current_action_instruction = $user_instruction;
2271
2272 // Set the transient to track email capture flow
2273 set_transient('mxchat_email_capture_' . $user_id, true, 5 * MINUTE_IN_SECONDS);
2274
2275 // Return false to let the AI generate the response
2276 return false;
2277 }
2278
2279 public function mxchat_generate_image($message, $user_id, $session_id) {
2280 //error_log("Starting image generation for message: " . $message);
2281
2282 // Prepare a prompt for OpenAI image generation
2283 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2284
2285 // Use the existing OpenAI API key
2286 $openai_api_key = sanitize_text_field($this->options['api_key']);
2287
2288 // Call OpenAI GPT Image to generate an image
2289 $image_response = $this->mxchat_generate_openai_image($prompt, $openai_api_key);
2290
2291 // Check if the response contains an image URL
2292 if (isset($image_response['imageUrl'])) {
2293 $image_url = esc_url_raw($image_response['imageUrl']);
2294
2295 // Construct the HTML with a CSS class instead of inline styles
2296 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2297 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2298
2299 // Save the bot message with both text and HTML
2300 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2301 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2302
2303 // Set the fallback response for the chat handler
2304 $this->fallbackResponse = [
2305 'text' => $response_text,
2306 'html' => $response_html,
2307 'images' => [$image_url]
2308 ];
2309
2310 // For debugging/verification - Use json_encode to verify what's being set
2311 //error_log("Image generation successful - fallbackResponse set: " . json_encode($this->fallbackResponse));
2312
2313 // Return the response directly instead of relying on the property
2314 return $this->fallbackResponse;
2315 } else {
2316 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2317
2318 // Save the error message
2319 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2320
2321 // Set the fallback response for the chat handler
2322 $this->fallbackResponse = [
2323 'text' => $response_text,
2324 'html' => '',
2325 'images' => []
2326 ];
2327
2328 //error_log("DALL-E image generation error: " . esc_html($image_response['error'] ?? 'Unknown error.'));
2329 //error_log("Error fallbackResponse set: " . json_encode($this->fallbackResponse));
2330
2331 // Return the response directly instead of relying on the property
2332 return $this->fallbackResponse;
2333 }
2334 }
2335
2336 public function mxchat_generate_gemini_image($message, $user_id, $session_id) {
2337 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2338
2339 $gemini_api_key = sanitize_text_field($this->options['gemini_api_key'] ?? '');
2340 if (empty($gemini_api_key)) {
2341 $response_text = esc_html__("Gemini API key is not configured.", 'mxchat');
2342 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2343 return ['text' => $response_text, 'html' => '', 'images' => []];
2344 }
2345
2346 $image_response = $this->mxchat_generate_imagen_image($prompt, $gemini_api_key);
2347
2348 if (isset($image_response['imageUrl'])) {
2349 $image_url = esc_url_raw($image_response['imageUrl']);
2350
2351 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2352 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2353
2354 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2355 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2356
2357 $this->fallbackResponse = [
2358 'text' => $response_text,
2359 'html' => $response_html,
2360 'images' => [$image_url]
2361 ];
2362
2363 return $this->fallbackResponse;
2364 } else {
2365 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2366
2367 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2368
2369 $this->fallbackResponse = [
2370 'text' => $response_text,
2371 'html' => '',
2372 'images' => []
2373 ];
2374
2375 return $this->fallbackResponse;
2376 }
2377 }
2378
2379 private function mxchat_save_generated_image($base64_data, $mime_type = 'image/png', $prefix = 'mxchat-generated') {
2380 $extension = ($mime_type === 'image/jpeg') ? 'jpg' : 'png';
2381 $filename = sanitize_file_name($prefix . '-' . wp_generate_uuid4() . '.' . $extension);
2382 $decoded = base64_decode($base64_data);
2383
2384 if ($decoded === false) {
2385 return new \WP_Error('decode_failed', esc_html__('Failed to decode image data.', 'mxchat'));
2386 }
2387
2388 $upload = wp_upload_bits($filename, null, $decoded);
2389
2390 if (!empty($upload['error'])) {
2391 return new \WP_Error('upload_failed', $upload['error']);
2392 }
2393
2394 $attach_id = wp_insert_attachment([
2395 'post_mime_type' => $mime_type,
2396 'post_title' => $prefix,
2397 'post_content' => '',
2398 'post_status' => 'inherit',
2399 ], $upload['file']);
2400
2401 if (is_wp_error($attach_id)) {
2402 return $attach_id;
2403 }
2404
2405 require_once ABSPATH . 'wp-admin/includes/image.php';
2406 $metadata = wp_generate_attachment_metadata($attach_id, $upload['file']);
2407 wp_update_attachment_metadata($attach_id, $metadata);
2408
2409 return esc_url_raw(wp_get_attachment_url($attach_id));
2410 }
2411
2412 private function mxchat_generate_openai_image($prompt, $api_key, $model = 'gpt-image-1', $timeout = 60) {
2413 $api_url = 'https://api.openai.com/v1/images/generations';
2414 $body = json_encode([
2415 'prompt' => sanitize_text_field($prompt),
2416 'n' => 1,
2417 'size' => '1024x1024',
2418 'quality' => 'medium',
2419 'output_format' => 'png',
2420 'model' => sanitize_text_field($model),
2421 ]);
2422
2423 $args = [
2424 'body' => $body,
2425 'headers' => [
2426 'Content-Type' => 'application/json',
2427 'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
2428 ],
2429 'method' => 'POST',
2430 'timeout' => absint($timeout),
2431 ];
2432
2433 $response = wp_remote_post($api_url, $args);
2434
2435 if (is_wp_error($response)) {
2436 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
2437 }
2438
2439 $response_body = json_decode(wp_remote_retrieve_body($response), true);
2440
2441 $b64 = $response_body['data'][0]['b64_json'] ?? $response_body['data'][0]['b64'] ?? null;
2442 if ($b64) {
2443 $saved_url = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-openai');
2444 if (is_wp_error($saved_url)) {
2445 return ['error' => $saved_url->get_error_message()];
2446 }
2447 return ['imageUrl' => $saved_url];
2448 } else {
2449 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
2450 }
2451 }
2452
2453 private function mxchat_generate_imagen_image($prompt, $api_key, $timeout = 60) {
2454 $api_url = 'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict';
2455
2456 $body = json_encode([
2457 'instances' => [['prompt' => sanitize_text_field($prompt)]],
2458 'parameters' => [
2459 'sampleCount' => 1,
2460 'aspectRatio' => '1:1',
2461 ],
2462 ]);
2463
2464 $args = [
2465 'body' => $body,
2466 'headers' => [
2467 'Content-Type' => 'application/json',
2468 'x-goog-api-key' => sanitize_text_field($api_key),
2469 ],
2470 'method' => 'POST',
2471 'timeout' => absint($timeout),
2472 ];
2473
2474 $response = wp_remote_post($api_url, $args);
2475
2476 if (is_wp_error($response)) {
2477 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
2478 }
2479
2480 $response_body = json_decode(wp_remote_retrieve_body($response), true);
2481
2482 $b64 = $response_body['predictions'][0]['bytesBase64Encoded'] ?? $response_body['predictions'][0]['imageBytes'] ?? null;
2483 if ($b64) {
2484 $mime = $response_body['predictions'][0]['mimeType'] ?? 'image/png';
2485 $saved_url = $this->mxchat_save_generated_image($b64, $mime, 'mxchat-gemini');
2486 if (is_wp_error($saved_url)) {
2487 return ['error' => $saved_url->get_error_message()];
2488 }
2489 return ['imageUrl' => $saved_url];
2490 } else {
2491 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
2492 }
2493 }
2494
2495 /**
2496 * Handle web search requests.
2497 *
2498 * Sends the refined search query to the Brave Search API and uses the
2499 * results to generate a conversational response with the AI model.
2500 *
2501 * @since 1.0.0
2502 * @param string $message The user's search query.
2503 * @param string $user_id The user identifier.
2504 * @param string $session_id The current session ID.
2505 * @return array Response array containing text with embedded HTML links
2506 */
2507 public function mxchat_handle_search_request($message, $user_id, $session_id) {
2508 // Step 1: Interpret and refine the search query
2509 $refined_search_query = $this->mxchat_interpret_search_query($message);
2510 if (empty($refined_search_query)) {
2511 return array(
2512 'text' => esc_html__('I apologize, but could you please rephrase your search request?', 'mxchat'),
2513 'html' => ''
2514 );
2515 }
2516
2517 // Retrieve and validate API settings
2518 $options = get_option('mxchat_options');
2519 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2520 $results_count = isset($options['brave_results_count']) ? absint($options['brave_results_count']) : 5;
2521
2522 if (empty($api_key)) {
2523 return array(
2524 'text' => esc_html__('Search functionality is temporarily unavailable. Please try again later.', 'mxchat'),
2525 'html' => ''
2526 );
2527 }
2528
2529 // Build the API request URL
2530 $api_url = add_query_arg(
2531 array(
2532 'q' => rawurlencode($refined_search_query),
2533 'count' => $results_count,
2534 'text_decorations' => 'true',
2535 'rich_data' => 'true',
2536 ),
2537 'https://api.search.brave.com/res/v1/web/search'
2538 );
2539
2540 // Attempt to retrieve cached results first
2541 $transient_key = 'mxchat_search_' . md5($refined_search_query);
2542 $results = get_transient($transient_key);
2543
2544 if (false === $results) {
2545 // SECURITY FIX: Changed to wp_safe_remote_get
2546 $response = wp_safe_remote_get(
2547 $api_url,
2548 array(
2549 'headers' => array(
2550 'Accept' => 'application/json',
2551 'Accept-Encoding' => 'gzip',
2552 'X-Subscription-Token'=> $api_key,
2553 ),
2554 'timeout' => 10,
2555 )
2556 );
2557
2558 if (is_wp_error($response)) {
2559 return array(
2560 'text' => esc_html__('I encountered an error while searching. Please try again.', 'mxchat'),
2561 'html' => ''
2562 );
2563 }
2564
2565 $results = json_decode(wp_remote_retrieve_body($response), true);
2566
2567 if (json_last_error() !== JSON_ERROR_NONE) {
2568 return array(
2569 'text' => esc_html__('I received an invalid response from the search service.', 'mxchat'),
2570 'html' => ''
2571 );
2572 }
2573
2574 // Cache results for one hour
2575 set_transient($transient_key, $results, HOUR_IN_SECONDS);
2576 }
2577
2578 // Process results
2579 if (!empty($results['web']['results']) && is_array($results['web']['results'])) {
2580 // Create a more straightforward summary with HTML links
2581 $search_results_text = '';
2582
2583 // Add a simple intro
2584 $search_results_text .= sprintf(
2585 esc_html__("Here's what I found about '%s':", 'mxchat'),
2586 esc_html($refined_search_query)
2587 );
2588
2589 // Add the top results with HTML links
2590 foreach (array_slice($results['web']['results'], 0, 5) as $result) {
2591 $title = isset($result['title']) ? wp_strip_all_tags($result['title']) : '';
2592 $url = isset($result['url']) ? esc_url($result['url']) : '';
2593 $description = isset($result['description']) ? wp_strip_all_tags($result['description']) : '';
2594
2595 // Add a line break after the intro
2596 $search_results_text .= '<br><br>';
2597
2598 // Add title as a link
2599 $search_results_text .= sprintf(
2600 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a><br>',
2601 $url,
2602 $title
2603 );
2604
2605 // Add a condensed description
2606 $search_results_text .= sprintf("%s", $description);
2607 }
2608
2609 // Save to chat history
2610 $this->mxchat_save_chat_message($session_id, 'bot', $search_results_text);
2611
2612 // Return the formatted text with embedded HTML links
2613 return array(
2614 'text' => $search_results_text,
2615 'html' => ''
2616 );
2617 } else {
2618 return array(
2619 'text' => sprintf(
2620 esc_html__('I searched for "%s" but couldn\'t find any relevant results. Would you like to try different search terms?', 'mxchat'),
2621 esc_html($refined_search_query)
2622 ),
2623 'html' => ''
2624 );
2625 }
2626 }
2627
2628 //very good
2629 /**
2630 * Handle image search requests from the chatbot
2631 *
2632 * @param string $message The user's search query
2633 * @param int $user_id The user's ID
2634 * @param string $session_id The chat session ID
2635 * @return array Response array with text and HTML content
2636 */
2637 public function mxchat_handle_image_search_request($message, $user_id, $session_id) {
2638 // Step 1: Interpret the search query using the user's selected AI model
2639 $refined_search_query = $this->mxchat_interpret_search_query($message);
2640
2641 // If no query was interpreted, return a fallback message
2642 if (empty($refined_search_query)) {
2643 return array(
2644 'text' => __("I'm sorry, I couldn't interpret your search query. Please specify what you'd like to see images of.", 'mxchat'),
2645 'html' => "",
2646 );
2647 }
2648
2649 // Brave API URL
2650 $api_url = 'https://api.search.brave.com/res/v1/images/search';
2651
2652 // Retrieve Brave API settings
2653 $options = get_option('mxchat_options');
2654 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2655
2656 if (empty($api_key)) {
2657 return array(
2658 'text' => __("API key is not configured. Please set it in the Brave Search Settings.", 'mxchat'),
2659 'html' => "",
2660 );
2661 }
2662
2663 $image_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
2664 $safe_search = isset($options['brave_safe_search']) ? sanitize_text_field($options['brave_safe_search']) : 'strict';
2665
2666 // Append query parameters based on settings
2667 $api_url = add_query_arg([
2668 'q' => rawurlencode($refined_search_query),
2669 'count' => $image_count,
2670 'safesearch' => $safe_search,
2671 ], $api_url);
2672
2673 // Implement caching
2674 $transient_key = 'mxchat_image_search_' . md5($refined_search_query);
2675 $body = get_transient($transient_key);
2676
2677 if (false === $body) {
2678 $args = [
2679 'headers' => [
2680 'Accept' => 'application/json',
2681 'Accept-Encoding' => 'gzip',
2682 'X-Subscription-Token' => $api_key,
2683 ],
2684 'timeout' => 10,
2685 ];
2686
2687 // SECURITY FIX: Changed to wp_safe_remote_get
2688 $response = wp_safe_remote_get($api_url, $args);
2689
2690 if (is_wp_error($response)) {
2691 return array(
2692 'text' => __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat'),
2693 'html' => "",
2694 );
2695 }
2696
2697 $body = json_decode(wp_remote_retrieve_body($response), true);
2698 set_transient($transient_key, $body, HOUR_IN_SECONDS);
2699 }
2700
2701 // Process the API response
2702 if (isset($body['results']) && is_array($body['results']) && count($body['results']) > 0) {
2703 $html_output = '<div class="mxchat-image-gallery">';
2704
2705 // Get the configured image count (1-6)
2706 $display_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
2707 $display_count = min($display_count, count($body['results'])); // Make sure we don't exceed available images
2708
2709 // Use only the requested number of images
2710 for ($i = 0; $i < $display_count; $i++) {
2711 $image = $body['results'][$i];
2712 $image_url = isset($image['url']) ? esc_url($image['url']) : '';
2713 $thumbnail_url = isset($image['thumbnail']['src']) ? esc_url($image['thumbnail']['src']) : '';
2714 $title = isset($image['title']) ? esc_html($image['title']) : esc_html__('Image', 'mxchat');
2715
2716 if ($image_url && $thumbnail_url) {
2717 $html_output .= '<div class="mxchat-image-item">';
2718 $html_output .= '<strong class="mxchat-image-title">' . $title . '</strong>';
2719 $html_output .= '<a href="' . $image_url . '" target="_blank" rel="noopener noreferrer" class="mxchat-image-link">';
2720 $html_output .= '<img src="' . $thumbnail_url . '" alt="' . $title . '" class="mxchat-image-thumbnail">';
2721 $html_output .= '</a></div>';
2722 }
2723 }
2724
2725 $html_output .= '</div>';
2726
2727 // Create response text
2728 $response_text = sprintf(__("Here are some images of %s:", 'mxchat'), $refined_search_query);
2729
2730 // Save both response text and HTML to chat history
2731 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2732 $this->mxchat_save_chat_message($session_id, 'bot', $html_output);
2733
2734 // Return the combined response
2735 return array(
2736 'text' => $response_text,
2737 'html' => $html_output,
2738 );
2739 } else {
2740 $response_text = __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat');
2741
2742 // Save the error message to chat history
2743 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2744
2745 return array(
2746 'text' => $response_text,
2747 'html' => "",
2748 );
2749 }
2750 }
2751
2752 /**
2753 * Interpret the search query using the user's selected AI model
2754 *
2755 * @param string $user_query The original query from the user
2756 * @return string The refined search query
2757 */
2758 public function mxchat_interpret_search_query($user_query) {
2759 $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');
2760
2761 // Get options and determine the selected model
2762 $options = $this->options ?? get_option('mxchat_options');
2763 $selected_model = isset($options['model']) ? $options['model'] : 'gpt-5.1-chat-latest';
2764
2765 // Extract model prefix to determine the provider
2766 $model_parts = explode('-', $selected_model);
2767 $provider = strtolower($model_parts[0]);
2768
2769 // Determine which API key to use based on the provider
2770 switch ($provider) {
2771 case 'gemini':
2772 $api_key = isset($options['gemini_api_key']) ? sanitize_text_field($options['gemini_api_key']) : '';
2773 if (empty($api_key)) {
2774 return sanitize_text_field($user_query); // Default to original query if API key missing
2775 }
2776 return $this->interpret_query_with_gemini($user_query, $system_prompt, $api_key, $selected_model);
2777
2778 case 'claude':
2779 $api_key = isset($options['claude_api_key']) ? sanitize_text_field($options['claude_api_key']) : '';
2780 if (empty($api_key)) {
2781 return sanitize_text_field($user_query);
2782 }
2783 return $this->interpret_query_with_claude($user_query, $system_prompt, $api_key, $selected_model);
2784
2785 case 'grok':
2786 $api_key = isset($options['xai_api_key']) ? sanitize_text_field($options['xai_api_key']) : '';
2787 if (empty($api_key)) {
2788 return sanitize_text_field($user_query);
2789 }
2790 return $this->interpret_query_with_xai($user_query, $system_prompt, $api_key, $selected_model);
2791
2792 case 'deepseek':
2793 $api_key = isset($options['deepseek_api_key']) ? sanitize_text_field($options['deepseek_api_key']) : '';
2794 if (empty($api_key)) {
2795 return sanitize_text_field($user_query);
2796 }
2797 return $this->interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $selected_model);
2798
2799 case 'gpt':
2800 default:
2801 // Default to OpenAI for custom models or unrecognized prefixes
2802 $api_key = isset($options['api_key']) ? sanitize_text_field($options['api_key']) : '';
2803 if (empty($api_key)) {
2804 return sanitize_text_field($user_query);
2805 }
2806 return $this->interpret_query_with_openai($user_query, $system_prompt, $api_key, $selected_model);
2807 }
2808 }
2809
2810 /**
2811 * Interpret query using OpenAI models
2812 */
2813 private function interpret_query_with_openai($user_query, $system_prompt, $api_key, $model = 'gpt-5.1-chat-latest') {
2814 $url = 'https://api.openai.com/v1/chat/completions';
2815 $args = [
2816 'headers' => [
2817 'Authorization' => 'Bearer ' . $api_key,
2818 'Content-Type' => 'application/json',
2819 ],
2820 'body' => wp_json_encode([
2821 'model' => $model,
2822 'messages' => [
2823 ['role' => 'system', 'content' => $system_prompt],
2824 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2825 ],
2826 'temperature' => 0.2,
2827 'max_tokens' => 20,
2828 ]),
2829 'method' => 'POST',
2830 'timeout' => 15,
2831 ];
2832
2833 $response = wp_remote_post($url, $args);
2834 if (is_wp_error($response)) {
2835 return sanitize_text_field($user_query);
2836 }
2837
2838 $body = json_decode(wp_remote_retrieve_body($response), true);
2839 return isset($body['choices'][0]['message']['content'])
2840 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
2841 : sanitize_text_field($user_query);
2842 }
2843
2844 /**
2845 * Interpret query using Claude models
2846 */
2847 private function interpret_query_with_claude($user_query, $system_prompt, $api_key, $model) {
2848 $url = 'https://api.anthropic.com/v1/messages';
2849
2850 $args = [
2851 'headers' => [
2852 'Content-Type' => 'application/json',
2853 'x-api-key' => $api_key,
2854 'anthropic-version' => '2023-06-01',
2855 ],
2856 'body' => wp_json_encode([
2857 'model' => $model,
2858 'system' => $system_prompt,
2859 'messages' => [
2860 ['role' => 'user', 'content' => sanitize_text_field($user_query)]
2861 ],
2862 'max_tokens' => 20,
2863 'temperature' => 0.2,
2864 ]),
2865 'method' => 'POST',
2866 'timeout' => 15,
2867 ];
2868
2869 $response = wp_remote_post($url, $args);
2870 if (is_wp_error($response)) {
2871 return sanitize_text_field($user_query);
2872 }
2873
2874 $body = json_decode(wp_remote_retrieve_body($response), true);
2875 if (!empty($body['content'][0]['text'])) {
2876 return sanitize_text_field(trim($body['content'][0]['text']));
2877 }
2878
2879 return sanitize_text_field($user_query);
2880 }
2881
2882 /**
2883 * Interpret query using Gemini models
2884 */
2885 private function interpret_query_with_gemini($user_query, $system_prompt, $api_key, $model) {
2886 // Use v1beta for preview models, v1 for stable models
2887 $api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
2888
2889 $url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key=" . urlencode($api_key);
2890
2891 $args = [
2892 'headers' => [
2893 'Content-Type' => 'application/json',
2894 ],
2895 'body' => wp_json_encode([
2896 'contents' => [
2897 [
2898 'role' => 'user',
2899 'parts' => [
2900 ['text' => $system_prompt . "\n\nQuery: " . sanitize_text_field($user_query)]
2901 ]
2902 ]
2903 ],
2904 'generationConfig' => [
2905 'temperature' => 0.2,
2906 'maxOutputTokens' => 20,
2907 ],
2908 ]),
2909 'method' => 'POST',
2910 'timeout' => 15,
2911 ];
2912
2913 $response = wp_remote_post($url, $args);
2914 if (is_wp_error($response)) {
2915 return sanitize_text_field($user_query);
2916 }
2917
2918 $body = json_decode(wp_remote_retrieve_body($response), true);
2919 if (!empty($body['candidates'][0]['content']['parts'][0]['text'])) {
2920 return sanitize_text_field(trim($body['candidates'][0]['content']['parts'][0]['text']));
2921 }
2922
2923 return sanitize_text_field($user_query);
2924 }
2925
2926 /**
2927 * Interpret query using X.AI (Grok) models
2928 */
2929 private function interpret_query_with_xai($user_query, $system_prompt, $api_key, $model) {
2930 $url = 'https://api.xai.com/v1/chat/completions';
2931
2932 $args = [
2933 'headers' => [
2934 'Content-Type' => 'application/json',
2935 'Authorization' => 'Bearer ' . $api_key,
2936 ],
2937 'body' => wp_json_encode([
2938 'model' => $model,
2939 'messages' => [
2940 ['role' => 'system', 'content' => $system_prompt],
2941 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2942 ],
2943 'temperature' => 0.2,
2944 'max_tokens' => 20,
2945 ]),
2946 'method' => 'POST',
2947 'timeout' => 15,
2948 ];
2949
2950 $response = wp_remote_post($url, $args);
2951 if (is_wp_error($response)) {
2952 return sanitize_text_field($user_query);
2953 }
2954
2955 $body = json_decode(wp_remote_retrieve_body($response), true);
2956 if (isset($body['choices'][0]['message']['content'])) {
2957 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
2958 }
2959
2960 return sanitize_text_field($user_query);
2961 }
2962
2963 /**
2964 * Interpret query using DeepSeek models
2965 */
2966 private function interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $model) {
2967 $url = 'https://api.deepseek.com/v1/chat/completions';
2968
2969 $args = [
2970 'headers' => [
2971 'Content-Type' => 'application/json',
2972 'Authorization' => 'Bearer ' . $api_key,
2973 ],
2974 'body' => wp_json_encode([
2975 'model' => $model,
2976 'messages' => [
2977 ['role' => 'system', 'content' => $system_prompt],
2978 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2979 ],
2980 'temperature' => 0.2,
2981 'max_tokens' => 20,
2982 ]),
2983 'method' => 'POST',
2984 'timeout' => 15,
2985 ];
2986
2987 $response = wp_remote_post($url, $args);
2988 if (is_wp_error($response)) {
2989 return sanitize_text_field($user_query);
2990 }
2991
2992 $body = json_decode(wp_remote_retrieve_body($response), true);
2993 if (isset($body['choices'][0]['message']['content'])) {
2994 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
2995 }
2996
2997 return sanitize_text_field($user_query);
2998 }
2999
3000 //very good
3001 private function add_email_to_loops($email) {
3002 // Sanitize the email
3003 $email = sanitize_email($email);
3004
3005 // Retrieve and sanitize options
3006 $api_key = isset($this->options['loops_api_key']) ? sanitize_text_field($this->options['loops_api_key']) : '';
3007 $mailing_list_id = isset($this->options['loops_mailing_list']) ? sanitize_text_field($this->options['loops_mailing_list']) : '';
3008
3009 // Check for missing API key or mailing list ID
3010 if (empty($api_key) || empty($mailing_list_id)) {
3011 //error_log(esc_html__('Loops API key or mailing list ID is missing.', 'mxchat'));
3012 return;
3013 }
3014
3015 $data = array(
3016 'email' => $email,
3017 'subscribed' => true,
3018 'source' => __('MxChat AI Chatbot', 'mxchat'),
3019 'mailingLists' => array($mailing_list_id => true),
3020 );
3021
3022 $url = 'https://app.loops.so/api/v1/contacts/create';
3023 $args = array(
3024 'body' => wp_json_encode($data),
3025 'headers' => array(
3026 'Authorization' => 'Bearer ' . $api_key,
3027 'Content-Type' => 'application/json',
3028 ),
3029 'method' => 'POST',
3030 'timeout' => 45,
3031 );
3032
3033 $response = wp_remote_post($url, $args);
3034
3035 // Handle errors in the API request
3036 if (is_wp_error($response)) {
3037 //error_log(esc_html__('Error adding email to Loops: ', 'mxchat') . $response->get_error_message());
3038 return;
3039 }
3040
3041 // Check for non-200 HTTP responses
3042 $response_code = wp_remote_retrieve_response_code($response);
3043 if ($response_code != 200) {
3044 $response_body = wp_remote_retrieve_body($response);
3045 //error_log(esc_html__('Loops API responded with code ', 'mxchat') . $response_code . ': ' . $response_body);
3046 }
3047 }
3048
3049 public function mxchat_handle_pdf_discussion($message, $user_id, $session_id) {
3050 // Get the maximum number of pages allowed from admin settings
3051 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3052
3053 // Retrieve options for dynamic texts
3054 $trigger_text = $this->options['pdf_intent_trigger_text'] ?? __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
3055 $success_text = $this->options['pdf_intent_success_text'] ?? __("I've processed the PDF. What questions do you have about it?", 'mxchat');
3056 $error_text = $this->options['pdf_intent_error_text'] ?? __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
3057
3058 // Check for explicit request for new PDF
3059 $new_pdf_requested = stripos($message, 'new') !== false ||
3060 stripos($message, 'another') !== false ||
3061 stripos($message, 'different') !== false;
3062
3063 // If user mentions adding/reading a PDF, set waiting flag
3064 if (stripos($message, 'pdf') !== false ||
3065 stripos($message, 'document') !== false ||
3066 stripos($message, 'read') !== false) {
3067 set_transient('mxchat_waiting_for_pdf_url_' . $session_id, true, HOUR_IN_SECONDS);
3068 $this->fallbackResponse['text'] = $trigger_text;
3069 return;
3070 }
3071
3072 // If we're waiting for a URL or user requested new PDF
3073 if ($new_pdf_requested || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
3074 if (preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
3075 // Process URL... (rest of your existing URL processing code)
3076 } else {
3077 $this->fallbackResponse['text'] = $trigger_text;
3078 }
3079 return;
3080 }
3081
3082 // Default to proceeding with conversation if no specific PDF action is needed
3083 $this->fallbackResponse['text'] = '';
3084 }
3085
3086
3087 /**
3088 * Enhanced fetch_and_split_pdf_pages with SSRF protection
3089 */
3090 private function fetch_and_split_pdf_pages($pdf_source, $max_pages) {
3091 // CLEAR DEBUG LOGGING
3092 //error_log("=== MXCHAT PDF PROCESSING START ===");
3093 //error_log("PDF Source: " . $pdf_source);
3094 //error_log("Max Pages: " . $max_pages);
3095 //error_log("Session ID: " . ($this->session_id ?? 'not set'));
3096
3097 // Check if Advanced Claude Toolbar is available and enabled
3098 $claude_available = function_exists('mxchatACT_is_advanced_claude_enabled');
3099 $claude_enabled = $claude_available ? mxchatACT_is_advanced_claude_enabled() : false;
3100
3101 //error_log("Claude Function Available: " . ($claude_available ? 'YES' : 'NO'));
3102 //error_log("Claude Enabled: " . ($claude_enabled ? 'YES' : 'NO'));
3103
3104 if ($claude_available && $claude_enabled) {
3105 //error_log("🚀 ATTEMPTING CLAUDE PROCESSING...");
3106
3107 // Attempt Claude processing first
3108 $claude_result = apply_filters('mxchat_process_pdf_advanced', false, $pdf_source, $max_pages, $this->session_id);
3109
3110 if ($claude_result !== false && is_array($claude_result) && !empty($claude_result)) {
3111 //error_log("✅ CLAUDE PROCESSING SUCCESSFUL!");
3112 //error_log("Claude returned " . count($claude_result) . " processed pages");
3113
3114 // Log first page details for verification
3115 if (isset($claude_result[0])) {
3116 $first_page = $claude_result[0];
3117 //error_log("First page enhanced: " . (isset($first_page['enhanced']) && $first_page['enhanced'] ? 'YES' : 'NO'));
3118 //error_log("Processing method: " . ($first_page['processing_method'] ?? 'not set'));
3119 //error_log("First page text preview: " . substr($first_page['text'] ?? '', 0, 100) . "...");
3120 }
3121
3122 //error_log("=== MXCHAT PDF PROCESSING END (CLAUDE) ===");
3123 return $claude_result;
3124 } else {
3125 //error_log("❌ CLAUDE PROCESSING FAILED or returned invalid result");
3126 //error_log("Claude result type: " . gettype($claude_result));
3127 if (is_array($claude_result)) {
3128 //error_log("Claude result count: " . count($claude_result));
3129 }
3130 }
3131 }
3132
3133 // Fallback to basic processing
3134 //error_log("🔄 FALLING BACK TO BASIC PDF PROCESSING...");
3135
3136 $upload_dir = wp_upload_dir();
3137 $temp_file = null;
3138
3139 try {
3140 // Your existing basic processing code here...
3141 // (I'll include the key parts with debug logging)
3142
3143 if (filter_var($pdf_source, FILTER_VALIDATE_URL)) {
3144 //error_log("Downloading PDF from URL...");
3145
3146 // SECURITY FIX: Validate URL before processing
3147 if (!$this->mxchat_is_safe_pdf_url($pdf_source)) {
3148 //error_log("❌ SECURITY: Blocked unsafe PDF URL");
3149 return false;
3150 }
3151
3152 $temp_file = wp_tempnam($pdf_source);
3153
3154 // SECURITY FIX: Changed from wp_remote_get to wp_safe_remote_get
3155 $response = wp_safe_remote_get($pdf_source, [
3156 'timeout' => 60,
3157 'headers' => ['User-Agent' => 'MxChat PDF Processor']
3158 ]);
3159
3160 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
3161 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code($response);
3162 //error_log("❌ BASIC PROCESSING: Failed to download PDF: " . $error_message);
3163 return false;
3164 }
3165
3166 global $wp_filesystem;
3167 if (empty($wp_filesystem)) {
3168 require_once ABSPATH . 'wp-admin/includes/file.php';
3169 WP_Filesystem();
3170 }
3171 $wp_filesystem->put_contents($temp_file, wp_remote_retrieve_body($response), FS_CHMOD_FILE);
3172 //error_log("✅ PDF downloaded successfully");
3173 } else {
3174 $temp_file = $pdf_source;
3175 //error_log("Using local PDF file: " . $temp_file);
3176 }
3177
3178 // Parse PDF
3179 //error_log("Parsing PDF with basic parser...");
3180 mxchat_load_pdf_parser();
3181 $parser = new \Smalot\PdfParser\Parser();
3182 $pdf = $parser->parseFile($temp_file);
3183 $pages = $pdf->getPages();
3184
3185 //error_log("PDF contains " . count($pages) . " pages");
3186
3187 if (count($pages) > $max_pages) {
3188 //error_log("❌ BASIC PROCESSING: Too many pages (" . count($pages) . " > " . $max_pages . ")");
3189 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file) {
3190 unlink($temp_file);
3191 }
3192 return 'too_many_pages';
3193 }
3194
3195 $embeddings = [];
3196 $processed_pages = 0;
3197
3198 foreach ($pages as $page_number => $page) {
3199 $text = $page->getText();
3200
3201 if (empty(trim($text))) {
3202 //error_log("Skipping empty page: " . ($page_number + 1));
3203 continue;
3204 }
3205
3206 $text = $this->mxchat_clean_text($text);
3207
3208 $embedding = $this->mxchat_generate_embedding(
3209 __("Page ", 'mxchat') . ($page_number + 1) . ": " . $text,
3210 $this->options['api_key']
3211 );
3212
3213 if ($embedding) {
3214 $embeddings[] = [
3215 'page_number' => $page_number + 1,
3216 'embedding' => $embedding,
3217 'text' => $text,
3218 'enhanced' => false, // CLEARLY MARK AS BASIC
3219 'processing_method' => 'basic_pdf_parser'
3220 ];
3221 $processed_pages++;
3222 }
3223 }
3224
3225 //error_log("✅ BASIC PROCESSING COMPLETE: " . $processed_pages . " pages processed");
3226
3227 // Cleanup
3228 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3229 unlink($temp_file);
3230 }
3231
3232 //error_log("=== MXCHAT PDF PROCESSING END (BASIC) ===");
3233 return $embeddings;
3234
3235 } catch (\Exception $e) {
3236 //error_log("❌ BASIC PROCESSING ERROR: " . $e->getMessage());
3237 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3238 unlink($temp_file);
3239 }
3240 //error_log("=== MXCHAT PDF PROCESSING END (ERROR) ===");
3241 return false;
3242 }
3243 }
3244
3245
3246 /**
3247 * Validate PDF URL for security
3248 * Prevents SSRF attacks by blocking dangerous URLs
3249 */
3250
3251 private function mxchat_is_safe_pdf_url($url) {
3252 // Use WordPress core function for comprehensive validation
3253 // This blocks localhost, private IPs, and reserved IP ranges
3254 $validated_url = wp_http_validate_url($url);
3255
3256 if ($validated_url === false) {
3257 return false;
3258 }
3259
3260 // Additional check: only allow HTTP/HTTPS schemes
3261 $parsed = parse_url($url);
3262 if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], ['http', 'https'], true)) {
3263 return false;
3264 }
3265
3266 return true;
3267 }
3268
3269
3270 private function mxchat_clean_text($text) {
3271 // Remove excessive whitespace
3272 $text = preg_replace('/\s+/', ' ', $text);
3273
3274 // Remove control characters except newlines and tabs
3275 $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
3276
3277 // Normalize line endings
3278 $text = str_replace(["\r\n", "\r"], "\n", $text);
3279
3280 // Trim whitespace
3281 $text = trim($text);
3282
3283 return $text;
3284 }
3285
3286 private function find_relevant_pdf_pages($query_embedding, $embeddings) {
3287 //error_log(esc_html__("find_relevant_pdf_pages called.", 'mxchat'));
3288
3289 $most_relevant = null;
3290 $highest_similarity = -INF;
3291
3292 foreach ($embeddings as $page_data) {
3293 $similarity = $this->mxchat_calculate_cosine_similarity($query_embedding, $page_data['embedding']);
3294
3295 if ($similarity > $highest_similarity) {
3296 $highest_similarity = $similarity;
3297 $most_relevant = $page_data['page_number'];
3298 }
3299 }
3300
3301 if (!is_null($most_relevant)) {
3302 $page_numbers = range(max(1, $most_relevant - 1), min(count($embeddings), $most_relevant + 1));
3303 return array_filter($embeddings, function ($page) use ($page_numbers) {
3304 return in_array($page['page_number'], $page_numbers);
3305 });
3306 }
3307
3308 return [];
3309 }
3310
3311
3312 public function handle_pdf_upload() {
3313 check_ajax_referer('mxchat_chat_nonce', 'nonce');
3314
3315 if (!isset($_FILES['pdf_file']) || !isset($_POST['session_id'])) {
3316 wp_send_json_error(esc_html__('Missing required parameters.', 'mxchat'));
3317 return;
3318 }
3319
3320 // SECURITY FIX: Check if PDF uploads are enabled in settings
3321 $options = get_option('mxchat_options', array());
3322 $show_pdf_button = isset($options['show_pdf_upload_button']) ? $options['show_pdf_upload_button'] : 'on';
3323
3324 if ($show_pdf_button !== 'on') {
3325 wp_send_json_error(esc_html__('PDF uploads are currently disabled.', 'mxchat'));
3326 return;
3327 }
3328
3329 $file = $_FILES['pdf_file'];
3330 $session_id = sanitize_text_field($_POST['session_id']);
3331 $original_filename = sanitize_text_field($file['name']);
3332
3333 // SECURITY FIX: Verify session ownership before allowing upload
3334 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
3335 $session_owner = get_option("mxchat_session_owner_{$session_id}");
3336
3337 if ($session_owner && $session_owner !== $current_user_identifier) {
3338 wp_send_json_error(esc_html__('Unauthorized access.', 'mxchat'));
3339 return;
3340 }
3341
3342 $file_type = wp_check_filetype($file['name'], ['pdf' => 'application/pdf']);
3343 if ($file_type['type'] !== 'application/pdf') {
3344 wp_send_json_error(esc_html__('Invalid file type. Only PDF files are allowed.', 'mxchat'));
3345 return;
3346 }
3347
3348 $upload_dir = wp_upload_dir();
3349
3350 // SECURITY FIX: Generate random filename without exposing session_id
3351 $random_string = wp_generate_password(20, false, false); // 20 char alphanumeric string
3352 $pdf_filename = 'mxchat_' . $random_string . '_' . time() . '.pdf';
3353 $pdf_path = $upload_dir['path'] . '/' . $pdf_filename;
3354
3355 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
3356 wp_send_json_error(esc_html__('Failed to upload file.', 'mxchat'));
3357 return;
3358 }
3359
3360 $this->clear_pdf_transients($session_id);
3361
3362 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3363 $embeddings = $this->fetch_and_split_pdf_pages($pdf_path, $max_pages);
3364
3365 if ($embeddings === 'too_many_pages') {
3366 unlink($pdf_path);
3367 $error_message = sprintf(
3368 $this->options['pdf_intent_error_text'] ??
3369 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
3370 $max_pages
3371 );
3372 wp_send_json_error($error_message);
3373 return;
3374 }
3375
3376 if ($embeddings === false || empty($embeddings)) {
3377 unlink($pdf_path);
3378 $error_message = $this->options['pdf_intent_error_text'] ??
3379 esc_html__('The uploaded PDF appears to be empty or contains unsupported content.', 'mxchat');
3380 wp_send_json_error($error_message);
3381 return;
3382 }
3383
3384 if (!empty($embeddings)) {
3385 // Store the mapping between session and the random filename
3386 set_transient('mxchat_pdf_url_' . $session_id, $pdf_path, HOUR_IN_SECONDS);
3387 set_transient('mxchat_pdf_filename_' . $session_id, $original_filename, HOUR_IN_SECONDS);
3388 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
3389 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
3390
3391 $success_message = $this->options['pdf_intent_success_text'] ??
3392 esc_html__("I've processed the PDF. What questions do you have about it?", 'mxchat');
3393
3394 wp_send_json_success([
3395 'message' => $success_message,
3396 'filename' => $original_filename
3397 ]);
3398 return;
3399 }
3400
3401 unlink($pdf_path);
3402 $error_message = $this->options['pdf_intent_error_text'] ??
3403 esc_html__('Sorry, I couldn\'t process the PDF. Please ensure it\'s a valid file.', 'mxchat');
3404 wp_send_json_error($error_message);
3405 return;
3406 }
3407 public function handle_pdf_remove() {
3408 check_ajax_referer('mxchat_chat_nonce', 'nonce');
3409
3410 if (empty($_POST['session_id'])) {
3411 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
3412 wp_die();
3413 }
3414
3415 $session_id = sanitize_text_field($_POST['session_id']);
3416 $pdf_path = get_transient('mxchat_pdf_url_' . $session_id);
3417
3418 if ($pdf_path && file_exists($pdf_path)) {
3419 unlink($pdf_path);
3420 }
3421
3422 $this->clear_pdf_transients($session_id);
3423
3424 wp_send_json_success([
3425 'message' => esc_html__('PDF removed successfully.', 'mxchat')
3426 ]);
3427 wp_die();
3428 }
3429
3430
3431 function mxchat_fetch_new_messages() {
3432 $session_id = sanitize_text_field($_POST['session_id']);
3433 $last_seen_id = sanitize_text_field($_POST['last_seen_id']);
3434 $persistence_enabled = $_POST['persistence_enabled'] === 'true';
3435 $initial_timestamp = isset($_POST['initial_timestamp']) ? intval($_POST['initial_timestamp']) : 0;
3436
3437 if (empty($session_id)) {
3438 //error_log(esc_html__('Fetch new messages error: Session ID missing.', 'mxchat'));
3439 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
3440 wp_die();
3441 }
3442
3443 $history = get_option("mxchat_history_{$session_id}", []);
3444
3445 //error_log("MxChat WhatsApp DEBUG: Fetch new messages for session {$session_id}");
3446 //error_log("MxChat WhatsApp DEBUG: last_seen_id = " . var_export($last_seen_id, true));
3447 //error_log("MxChat WhatsApp DEBUG: History count = " . count($history));
3448 //error_log("MxChat WhatsApp DEBUG: Full history = " . print_r($history, true));
3449
3450 $new_messages = array_filter($history, function ($message) use ($last_seen_id, $persistence_enabled, $initial_timestamp) {
3451 //error_log("MxChat WhatsApp DEBUG: Checking message - ID: " . ($message['id'] ?? 'NO_ID') . ", Role: " . ($message['role'] ?? 'NO_ROLE'));
3452
3453 // If persistence is enabled, show all new messages
3454 if ($persistence_enabled) {
3455 $has_id = !empty($message['id']);
3456 $is_agent = $message['role'] === 'agent';
3457
3458 // If last_seen_id is empty, 'NaN', or invalid, show all agent messages
3459 if (empty($last_seen_id) || $last_seen_id === 'NaN' || $last_seen_id === 'undefined') {
3460 $is_newer = true;
3461 } else {
3462 $is_newer = strcmp($message['id'] ?? '', $last_seen_id) > 0;
3463 }
3464
3465 //error_log("MxChat WhatsApp DEBUG: has_id={$has_id}, is_newer={$is_newer}, is_agent={$is_agent}");
3466
3467 return $has_id && $is_newer && $is_agent;
3468 }
3469
3470 // If persistence is disabled, only show messages after initial timestamp
3471 return !empty($message['id']) &&
3472 $message['role'] === 'agent' &&
3473 $message['timestamp'] > $initial_timestamp;
3474 });
3475
3476 //error_log("MxChat WhatsApp DEBUG: Filtered messages count = " . count($new_messages));
3477
3478 // Include current chat mode so frontend can detect agent→AI transitions
3479 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
3480
3481 wp_send_json_success([
3482 'new_messages' => array_values($new_messages),
3483 'chat_mode' => $chat_mode
3484 ]);
3485 wp_die();
3486 }
3487 public function mxchat_live_agent_handover($message, $user_id, $session_id) {
3488 // First check if live agents are available
3489 $live_agent_available = $this->options['live_agent_status'] ?? 'off';
3490 if ($live_agent_available !== 'on') {
3491 $away_message = $this->options['live_agent_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
3492 $this->fallbackResponse = [
3493 'text' => $away_message,
3494 'html' => '',
3495 'images' => [],
3496 'chat_mode' => 'ai'
3497 ];
3498 wp_send_json([
3499 'text' => $away_message,
3500 'html' => '',
3501 'chat_mode' => 'ai',
3502 'session_id' => $session_id
3503 ]);
3504 wp_die();
3505 }
3506
3507 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
3508
3509 if (empty($slack_bot_token)) {
3510 return false;
3511 }
3512
3513 // Check if channel already exists for this session
3514 $channel_id = get_option("mxchat_channel_{$session_id}", '');
3515
3516 if (empty($channel_id)) {
3517 // Create new channel with session ID as name
3518 $channel_name = $this->generate_channel_name($session_id);
3519
3520 //error_log("Attempting to create channel: $channel_name");
3521
3522 $response = wp_remote_post('https://slack.com/api/conversations.create', [
3523 'headers' => [
3524 'Content-Type' => 'application/json',
3525 'Authorization' => 'Bearer ' . $slack_bot_token
3526 ],
3527 'body' => json_encode([
3528 'name' => $channel_name,
3529 'is_private' => false // Public channel - anyone in workspace can join
3530 ])
3531 ]);
3532
3533 if (!is_wp_error($response)) {
3534 $response_body = wp_remote_retrieve_body($response);
3535 $response_data = json_decode($response_body, true);
3536
3537 //error_log("Channel creation response: " . $response_body);
3538
3539 if (isset($response_data['ok']) && $response_data['ok']) {
3540 $channel_id = $response_data['channel']['id'];
3541 $actual_channel_name = $response_data['channel']['name'] ?? 'unknown';
3542 //error_log("Channel created successfully: ID=$channel_id, Name=$actual_channel_name");
3543 update_option("mxchat_channel_{$session_id}", $channel_id);
3544
3545 // Auto-invite agents to the channel
3546 $agent_user_ids = $this->options['live_agent_user_ids'] ?? '';
3547
3548 if (!empty($agent_user_ids)) {
3549 // Parse user IDs (one per line)
3550 $user_ids = array_filter(array_map('trim', explode("\n", $agent_user_ids)));
3551
3552 foreach ($user_ids as $user_id_to_invite) {
3553 //error_log("Inviting user to channel: $user_id_to_invite");
3554
3555 $invite_response = wp_remote_post('https://slack.com/api/conversations.invite', [
3556 'headers' => [
3557 'Content-Type' => 'application/json',
3558 'Authorization' => 'Bearer ' . $slack_bot_token
3559 ],
3560 'body' => json_encode([
3561 'channel' => $channel_id,
3562 'users' => $user_id_to_invite
3563 ])
3564 ]);
3565
3566 if (!is_wp_error($invite_response)) {
3567 $invite_body = wp_remote_retrieve_body($invite_response);
3568 $invite_data = json_decode($invite_body, true);
3569 //error_log("Invite response for $user_id_to_invite: " . $invite_body);
3570
3571 if (isset($invite_data['ok']) && $invite_data['ok']) {
3572 //error_log("Successfully invited user $user_id_to_invite to channel");
3573 } else {
3574 //error_log("Failed to invite user $user_id_to_invite: " . ($invite_data['error'] ?? 'Unknown error'));
3575 }
3576 } else {
3577 //error_log("WP Error inviting user $user_id_to_invite: " . $invite_response->get_error_message());
3578 }
3579 }
3580 } else {
3581 //error_log("No agent user IDs configured for auto-invite");
3582 }
3583 } else {
3584 //error_log("Channel creation failed: " . ($response_data['error'] ?? 'Unknown error'));
3585 }
3586 } else {
3587 //error_log("WP Error creating channel: " . $response->get_error_message());
3588 }
3589
3590 if (empty($channel_id)) {
3591 return false; // Failed to create channel
3592 }
3593 }
3594
3595 // Get recent chat history
3596 $history = get_option("mxchat_history_{$session_id}", []);
3597 $recent_history = array_slice($history, -5);
3598
3599 // Format conversation context
3600 $conversation_context = "";
3601 if (!empty($recent_history)) {
3602 $conversation_context = "*Recent Conversation:*\n";
3603 foreach ($recent_history as $hist_message) {
3604 $role_display = $hist_message['role'] === 'user' ? 'User' : 'AI';
3605 $conversation_context .= ">{$role_display}: {$hist_message['content']}\n";
3606 }
3607 $conversation_context .= "\n";
3608 }
3609
3610 update_option("mxchat_mode_{$session_id}", 'agent');
3611
3612 // Send message to channel
3613 $channel_message = "🔔 *New Live Agent Request*\n\n";
3614 $channel_message .= "*Session ID:* `{$session_id}`\n";
3615 $channel_message .= "*User ID:* `{$user_id}`\n\n";
3616
3617 if (!empty($conversation_context)) {
3618 $channel_message .= $conversation_context;
3619 }
3620
3621 $channel_message .= "*Current Message:*\n{$message}\n\n";
3622 $channel_message .= "_Reply directly in this channel - all messages will go to the user_";
3623
3624 wp_remote_post('https://slack.com/api/chat.postMessage', [
3625 'headers' => [
3626 'Content-Type' => 'application/json',
3627 'Authorization' => 'Bearer ' . $slack_bot_token
3628 ],
3629 'body' => json_encode([
3630 'channel' => $channel_id,
3631 'text' => $channel_message,
3632 'mrkdwn' => true
3633 ])
3634 ]);
3635
3636 $success_message = $this->options['live_agent_notification_message'] ?? 'Live agent has been notified.';
3637 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
3638
3639 $this->fallbackResponse = [
3640 'text' => $success_message,
3641 'html' => '',
3642 'images' => [],
3643 'chat_mode' => 'agent'
3644 ];
3645
3646 wp_send_json([
3647 'success' => true,
3648 'text' => $success_message,
3649 'html' => '',
3650 'chat_mode' => 'agent',
3651 'session_id' => $session_id,
3652 'fallbackResponse' => $this->fallbackResponse
3653 ]);
3654 wp_die();
3655 }
3656
3657 private function generate_channel_name($session_id) {
3658 $email = null;
3659 $name = null;
3660
3661 // 1. First priority: Check if user is logged in and get their info
3662 if (is_user_logged_in()) {
3663 $current_user = wp_get_current_user();
3664 if (!empty($current_user->user_email)) {
3665 $email = $current_user->user_email;
3666 //error_log("[DEBUG] Using logged-in user email for channel: {$email}");
3667 }
3668 if (!empty($current_user->display_name)) {
3669 $name = $current_user->display_name;
3670 //error_log("[DEBUG] Using logged-in user name for channel: {$name}");
3671 }
3672 }
3673
3674 // 2. Second priority: Check for saved email/name from "require email to chat" option
3675 if (empty($email)) {
3676 $email_option_key = "mxchat_email_{$session_id}";
3677 $saved_email = get_option($email_option_key);
3678 if (!empty($saved_email)) {
3679 $email = $saved_email;
3680 //error_log("[DEBUG] Using saved email from session for channel: {$email}");
3681 }
3682 }
3683
3684 if (empty($name)) {
3685 $name_option_key = "mxchat_name_{$session_id}";
3686 $saved_name = get_option($name_option_key);
3687 if (!empty($saved_name)) {
3688 $name = $saved_name;
3689 //error_log("[DEBUG] Using saved name from session for channel: {$name}");
3690 }
3691 }
3692
3693 // 3. Third priority: Check existing chat transcript for email/name
3694 if (empty($email) || empty($name)) {
3695 global $wpdb;
3696 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3697 $existing_data = $wpdb->get_row($wpdb->prepare(
3698 "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",
3699 $session_id
3700 ));
3701
3702 if ($existing_data) {
3703 if (empty($email) && !empty($existing_data->user_email)) {
3704 $email = $existing_data->user_email;
3705 //error_log("[DEBUG] Using email from chat transcript for channel: {$email}");
3706 }
3707 if (empty($name) && !empty($existing_data->user_name)) {
3708 $name = $existing_data->user_name;
3709 //error_log("[DEBUG] Using name from chat transcript for channel: {$name}");
3710 }
3711 }
3712 }
3713
3714 // 4. Generate channel name based on priority: Name > Email > Session ID
3715 $channel_name = '';
3716
3717 if (!empty($name)) {
3718 // Convert name to valid Slack channel name
3719 $base_name = strtolower(trim($name));
3720 // Replace spaces and invalid characters
3721 $base_name = preg_replace('/[^a-z0-9\s]/', '', $base_name);
3722 $base_name = preg_replace('/\s+/', '-', $base_name);
3723 $base_name = trim($base_name, '-');
3724
3725 // Get last 4 characters of session ID for uniqueness
3726 $session_suffix = substr($session_id, -4);
3727 $channel_name = 'chat-' . $base_name . '-' . strtolower($session_suffix);
3728
3729 // Slack channel names have a 21 character limit
3730 if (strlen($channel_name) > 21) {
3731 // Calculate available space for name (21 - 'chat-' - '-' - session_suffix)
3732 $available_space = 21 - 5 - 1 - strlen($session_suffix); // 'chat-' = 5, '-' = 1
3733 $truncated_name = substr($base_name, 0, $available_space);
3734 $truncated_name = rtrim($truncated_name, '-'); // Remove trailing hyphen
3735 $channel_name = 'chat-' . $truncated_name . '-' . strtolower($session_suffix);
3736 }
3737
3738 //error_log("[DEBUG] Using name for channel: {$channel_name} (from name: {$name})");
3739
3740 } elseif (!empty($email)) {
3741 // Convert email to valid Slack channel name (your existing logic)
3742 $channel_name = 'chat-' . strtolower(str_replace(['@', '.', '+', '_'], ['-at-', '-', '-plus-', '-'], $email));
3743 // Remove any remaining invalid characters
3744 $channel_name = preg_replace('/[^a-z0-9\-]/', '', $channel_name);
3745 // Ensure it doesn't end with a hyphen
3746 $channel_name = rtrim($channel_name, '-');
3747 // Slack channel names have a 21 character limit, so truncate if needed
3748 if (strlen($channel_name) > 21) {
3749 $channel_name = substr($channel_name, 0, 21);
3750 $channel_name = rtrim($channel_name, '-'); // Remove trailing hyphen if truncation created one
3751 }
3752
3753 //error_log("[DEBUG] Using email for channel: {$channel_name} (from email: {$email})");
3754
3755 } else {
3756 // Fallback to session ID if no name or email found
3757 $channel_name = 'chat-' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $session_id));
3758 //error_log("[DEBUG] No name or email found, using session ID for channel: {$channel_name}");
3759 }
3760
3761 // Final validation - ensure channel name meets Slack requirements
3762 if (strlen($channel_name) > 21) {
3763 $channel_name = substr($channel_name, 0, 21);
3764 $channel_name = rtrim($channel_name, '-');
3765 }
3766
3767 //error_log("[DEBUG] Generated channel name: {$channel_name}");
3768 return $channel_name;
3769 }
3770
3771 /**
3772 * Telegram Live Agent Handover
3773 * Creates a forum topic in the Telegram group and notifies agents
3774 */
3775 public function mxchat_telegram_live_agent_handover($message, $user_id, $session_id) {
3776 // Check if Telegram agents are available
3777 $telegram_available = $this->options['telegram_status'] ?? 'off';
3778 if ($telegram_available !== 'on') {
3779 $away_message = $this->options['telegram_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
3780 $this->fallbackResponse = [
3781 'text' => $away_message,
3782 'html' => '',
3783 'images' => [],
3784 'chat_mode' => 'ai'
3785 ];
3786 wp_send_json([
3787 'text' => $away_message,
3788 'html' => '',
3789 'chat_mode' => 'ai',
3790 'session_id' => $session_id
3791 ]);
3792 wp_die();
3793 }
3794
3795 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3796 $telegram_group_id = $this->options['telegram_group_id'] ?? '';
3797
3798 if (empty($telegram_bot_token) || empty($telegram_group_id)) {
3799 return false;
3800 }
3801
3802 // Check if topic already exists for this session
3803 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
3804
3805 if (empty($topic_id)) {
3806 // Generate topic name
3807 $topic_name = $this->generate_telegram_topic_name($session_id);
3808
3809 // Random icon color (Telegram forum topic colors)
3810 $icon_colors = [0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F];
3811 $icon_color = $icon_colors[array_rand($icon_colors)];
3812
3813 // Create forum topic
3814 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/createForumTopic", [
3815 'headers' => ['Content-Type' => 'application/json'],
3816 'body' => json_encode([
3817 'chat_id' => $telegram_group_id,
3818 'name' => $topic_name,
3819 'icon_color' => $icon_color
3820 ])
3821 ]);
3822
3823 if (!is_wp_error($response)) {
3824 $response_body = wp_remote_retrieve_body($response);
3825 $response_data = json_decode($response_body, true);
3826
3827 if (isset($response_data['ok']) && $response_data['ok']) {
3828 $topic_id = $response_data['result']['message_thread_id'];
3829 update_option("mxchat_telegram_topic_{$session_id}", $topic_id);
3830 update_option("mxchat_telegram_group_{$session_id}", $telegram_group_id);
3831 }
3832 }
3833
3834 if (empty($topic_id)) {
3835 return false; // Failed to create topic
3836 }
3837 }
3838
3839 // Get recent chat history
3840 $history = get_option("mxchat_history_{$session_id}", []);
3841 $recent_history = array_slice($history, -5);
3842
3843 // Format conversation context for Telegram (HTML format)
3844 $conversation_context = "";
3845 if (!empty($recent_history)) {
3846 $conversation_context = "<b>Recent Conversation:</b>\n";
3847 foreach ($recent_history as $hist_message) {
3848 $role_display = $hist_message['role'] === 'user' ? '👤 User' : '🤖 AI';
3849 $escaped_content = htmlspecialchars($hist_message['content'], ENT_QUOTES, 'UTF-8');
3850 $conversation_context .= "{$role_display}: {$escaped_content}\n";
3851 }
3852 $conversation_context .= "\n";
3853 }
3854
3855 // Get user info
3856 $user_email = get_option("mxchat_email_{$session_id}", 'Not provided');
3857 $user_name = get_option("mxchat_name_{$session_id}", 'Anonymous');
3858
3859 // Update session mode
3860 update_option("mxchat_mode_{$session_id}", 'agent');
3861
3862 // Send initial message to topic
3863 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
3864 $topic_message = "🔔 <b>New Live Agent Request</b>\n\n";
3865 $topic_message .= "<b>Session ID:</b> <code>{$session_id}</code>\n";
3866 $topic_message .= "<b>User:</b> {$user_name}\n";
3867 $topic_message .= "<b>Email:</b> {$user_email}\n\n";
3868
3869 if (!empty($conversation_context)) {
3870 $topic_message .= $conversation_context;
3871 }
3872
3873 $topic_message .= "<b>Current Message:</b>\n{$escaped_message}\n\n";
3874 $topic_message .= "<i>Reply in this topic - messages will be sent to the user</i>\n";
3875 $topic_message .= "<i>Type #close, #end, #disconnect, or #done to end the session</i>";
3876
3877 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3878 'headers' => ['Content-Type' => 'application/json'],
3879 'body' => json_encode([
3880 'chat_id' => $telegram_group_id,
3881 'message_thread_id' => $topic_id,
3882 'text' => $topic_message,
3883 'parse_mode' => 'HTML'
3884 ])
3885 ]);
3886
3887 $success_message = $this->options['telegram_notification_message'] ?? "I've notified a support agent. Please allow a moment for them to respond.";
3888 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
3889
3890 $this->fallbackResponse = [
3891 'text' => $success_message,
3892 'html' => '',
3893 'images' => [],
3894 'chat_mode' => 'agent'
3895 ];
3896
3897 wp_send_json([
3898 'success' => true,
3899 'text' => $success_message,
3900 'html' => '',
3901 'chat_mode' => 'agent',
3902 'session_id' => $session_id,
3903 'fallbackResponse' => $this->fallbackResponse
3904 ]);
3905 wp_die();
3906 }
3907
3908 /**
3909 * Generate topic name for Telegram forum
3910 */
3911 private function generate_telegram_topic_name($session_id) {
3912 $name = null;
3913 $email = null;
3914
3915 // Check logged in user
3916 if (is_user_logged_in()) {
3917 $current_user = wp_get_current_user();
3918 if (!empty($current_user->display_name)) {
3919 $name = $current_user->display_name;
3920 }
3921 if (!empty($current_user->user_email)) {
3922 $email = $current_user->user_email;
3923 }
3924 }
3925
3926 // Check session data
3927 if (empty($name)) {
3928 $name = get_option("mxchat_name_{$session_id}");
3929 }
3930 if (empty($email)) {
3931 $email = get_option("mxchat_email_{$session_id}");
3932 }
3933
3934 // Generate topic name
3935 $session_suffix = substr($session_id, -6);
3936
3937 if (!empty($name)) {
3938 // Clean name for topic (max 128 chars in Telegram)
3939 $clean_name = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $name);
3940 $clean_name = trim($clean_name);
3941 if (strlen($clean_name) > 50) {
3942 $clean_name = substr($clean_name, 0, 50);
3943 }
3944 return "Chat - {$clean_name} ({$session_suffix})";
3945 } elseif (!empty($email)) {
3946 // Use email prefix
3947 $email_prefix = explode('@', $email)[0];
3948 if (strlen($email_prefix) > 30) {
3949 $email_prefix = substr($email_prefix, 0, 30);
3950 }
3951 return "Chat - {$email_prefix} ({$session_suffix})";
3952 }
3953
3954 return "Chat - {$session_suffix}";
3955 }
3956
3957 /**
3958 * Send user message to Telegram agent
3959 */
3960 public function mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id) {
3961 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3962 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
3963 $group_id = get_option("mxchat_telegram_group_{$session_id}", '');
3964
3965 if (empty($telegram_bot_token) || empty($topic_id) || empty($group_id)) {
3966 return false;
3967 }
3968
3969 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
3970 $user_message = "👤 <b>User:</b> {$escaped_message}";
3971
3972 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3973 'headers' => ['Content-Type' => 'application/json'],
3974 'body' => json_encode([
3975 'chat_id' => $group_id,
3976 'message_thread_id' => $topic_id,
3977 'text' => $user_message,
3978 'parse_mode' => 'HTML'
3979 ])
3980 ]);
3981
3982 return !is_wp_error($response);
3983 }
3984
3985 /**
3986 * Handle incoming Telegram webhook
3987 */
3988 public function handle_telegram_webhook(WP_REST_Request $request) {
3989 $body = $request->get_body();
3990 $data = json_decode($body, true);
3991
3992 //error_log('[MxChat Telegram DEBUG] Webhook received: ' . $body);
3993
3994 // Handle message events from forum topics
3995 if (isset($data['message'])) {
3996 $message_data = $data['message'];
3997
3998 // Skip if not from a forum topic
3999 if (!isset($message_data['message_thread_id'])) {
4000 //error_log('[MxChat Telegram DEBUG] Skipped: No message_thread_id (not a forum topic message)');
4001 return new WP_REST_Response(['ok' => true]);
4002 }
4003
4004 // Skip bot messages
4005 if (isset($message_data['from']['is_bot']) && $message_data['from']['is_bot']) {
4006 //error_log('[MxChat Telegram DEBUG] Skipped: Message from bot');
4007 return new WP_REST_Response(['ok' => true]);
4008 }
4009
4010 $chat_id = $message_data['chat']['id'] ?? '';
4011 $topic_id = $message_data['message_thread_id'];
4012 $message_text = $message_data['text'] ?? '';
4013 $message_id = $message_data['message_id'] ?? '';
4014 $from = $message_data['from'] ?? [];
4015 $agent_name = trim(($from['first_name'] ?? '') . ' ' . ($from['last_name'] ?? ''));
4016 if (empty($agent_name)) {
4017 $agent_name = $from['username'] ?? 'Agent';
4018 }
4019
4020 //error_log("[MxChat Telegram DEBUG] Parsed: chat_id={$chat_id}, topic_id={$topic_id}, agent={$agent_name}, text={$message_text}");
4021
4022 // Skip empty messages
4023 if (empty($message_text)) {
4024 //error_log('[MxChat Telegram DEBUG] Skipped: Empty message text');
4025 return new WP_REST_Response(['ok' => true]);
4026 }
4027
4028 // Find session ID by topic ID - cast to string for comparison
4029 global $wpdb;
4030 $topic_id_str = strval($topic_id);
4031 $session_option = $wpdb->get_var(
4032 $wpdb->prepare(
4033 "SELECT option_name FROM {$wpdb->options}
4034 WHERE option_name LIKE %s
4035 AND option_value = %s",
4036 'mxchat_telegram_topic_%',
4037 $topic_id_str
4038 )
4039 );
4040
4041 //error_log("[MxChat Telegram DEBUG] Looking for topic_id={$topic_id_str} in options, found: " . ($session_option ?: 'NULL'));
4042
4043 if ($session_option) {
4044 $session_id = str_replace('mxchat_telegram_topic_', '', $session_option);
4045 //error_log("[MxChat Telegram DEBUG] Session ID: {$session_id}");
4046
4047 // Verify the group ID matches
4048 $stored_group_id = get_option("mxchat_telegram_group_{$session_id}", '');
4049 //error_log("[MxChat Telegram DEBUG] Stored group_id={$stored_group_id}, received chat_id={$chat_id}");
4050
4051 if (strval($stored_group_id) != strval($chat_id)) {
4052 //error_log('[MxChat Telegram DEBUG] Skipped: Group ID mismatch');
4053 return new WP_REST_Response(['ok' => true]);
4054 }
4055
4056 // Check for closure commands
4057 $lower_text = strtolower(trim($message_text));
4058 if (in_array($lower_text, ['#close', '#end', '#disconnect', '#done'])) {
4059 //error_log("[MxChat Telegram DEBUG] Closure command received: {$lower_text}");
4060 // End the live agent session
4061 update_option("mxchat_mode_{$session_id}", 'ai');
4062
4063 // Save disconnect message
4064 $disconnect_message = "Live agent session ended. You're now chatting with the AI assistant.";
4065 $this->mxchat_save_chat_message($session_id, 'bot', $disconnect_message);
4066
4067 // Notify in Telegram
4068 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4069 if (!empty($telegram_bot_token)) {
4070 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4071 'headers' => ['Content-Type' => 'application/json'],
4072 'body' => json_encode([
4073 'chat_id' => $chat_id,
4074 'message_thread_id' => $topic_id,
4075 'text' => "✅ Session closed. User returned to AI chatbot.",
4076 'parse_mode' => 'HTML'
4077 ])
4078 ]);
4079
4080 // Optionally close the topic
4081 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/closeForumTopic", [
4082 'headers' => ['Content-Type' => 'application/json'],
4083 'body' => json_encode([
4084 'chat_id' => $chat_id,
4085 'message_thread_id' => $topic_id
4086 ])
4087 ]);
4088 }
4089
4090 return new WP_REST_Response(['ok' => true]);
4091 }
4092
4093 // Deduplicate messages
4094 $message_key = md5($session_id . $message_id . $message_text);
4095 $processed_messages = get_transient('mxchat_telegram_messages_' . $session_id) ?: [];
4096
4097 if (in_array($message_key, $processed_messages)) {
4098 //error_log('[MxChat Telegram DEBUG] Skipped: Duplicate message');
4099 return new WP_REST_Response(['ok' => true]);
4100 }
4101
4102 $processed_messages[] = $message_key;
4103 if (count($processed_messages) > 50) {
4104 $processed_messages = array_slice($processed_messages, -50);
4105 }
4106 set_transient('mxchat_telegram_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
4107
4108 // Save the agent message - format with agent name prefix for proper parsing
4109 $formatted_message = "Agent: {$agent_name} - {$message_text}";
4110 //error_log("[MxChat Telegram DEBUG] Saving agent message: {$formatted_message}");
4111
4112 $this->mxchat_save_chat_message($session_id, 'agent', $formatted_message);
4113
4114 // Verify the message was saved to history
4115 $history = get_option("mxchat_history_{$session_id}", []);
4116 $last_message = end($history);
4117 //error_log("[MxChat Telegram DEBUG] History after save - count: " . count($history) . ", last message role: " . ($last_message['role'] ?? 'none'));
4118
4119 // Send confirmation back to Telegram
4120 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4121 if (!empty($telegram_bot_token)) {
4122 $confirm_key = 'mxchat_telegram_confirm_' . $message_key;
4123 if (!get_transient($confirm_key)) {
4124 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4125 'headers' => ['Content-Type' => 'application/json'],
4126 'body' => json_encode([
4127 'chat_id' => $chat_id,
4128 'message_thread_id' => $topic_id,
4129 'text' => "✅ <i>Message sent to user</i>",
4130 'parse_mode' => 'HTML',
4131 'reply_to_message_id' => $message_id
4132 ])
4133 ]);
4134 set_transient($confirm_key, true, 300);
4135 }
4136 }
4137 } else {
4138 //error_log("[MxChat Telegram DEBUG] No session found for topic_id={$topic_id}");
4139 }
4140 } else {
4141 //error_log('[MxChat Telegram DEBUG] No message in webhook data');
4142 }
4143
4144 return new WP_REST_Response(['ok' => true]);
4145 }
4146
4147 public function mxchat_send_user_message_to_agent($message, $user_id, $session_id) {
4148 // Check if this is a Telegram agent session
4149 $telegram_topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
4150 if (!empty($telegram_topic_id)) {
4151 return $this->mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id);
4152 }
4153
4154 // Otherwise, try Slack
4155 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4156 $channel_id = get_option("mxchat_channel_{$session_id}", '');
4157
4158 if (empty($slack_bot_token) || empty($channel_id)) {
4159 return false;
4160 }
4161
4162 $user_message = "💬 *User:* {$message}";
4163
4164 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
4165 'headers' => [
4166 'Content-Type' => 'application/json',
4167 'Authorization' => 'Bearer ' . $slack_bot_token
4168 ],
4169 'body' => json_encode([
4170 'channel' => $channel_id,
4171 'text' => $user_message,
4172 'mrkdwn' => true
4173 ])
4174 ]);
4175
4176 return !is_wp_error($response);
4177 }
4178 public function handle_slack_interaction(WP_REST_Request $request) {
4179 //error_log('Received Slack interaction');
4180
4181 $payload = json_decode($request->get_param('payload'), true);
4182 //error_log('Payload: ' . print_r($payload, true));
4183
4184 // Handle button click
4185 if ($payload['type'] === 'block_actions' && $payload['actions'][0]['action_id'] === 'reply_to_user') {
4186 $session_id = $payload['actions'][0]['value'];
4187 $trigger_id = $payload['trigger_id'];
4188
4189 // Get Bot Token from settings
4190 $slack_token = $this->options['live_agent_bot_token'] ?? '';
4191
4192 if (empty($slack_token)) {
4193 //error_log('Slack Bot Token not configured');
4194 return new WP_REST_Response(['error' => esc_html__('Bot token not configured', 'mxchat')], 400);
4195 }
4196 $response = wp_remote_post('https://slack.com/api/views.open', [
4197 'headers' => [
4198 'Content-Type' => 'application/json',
4199 'Authorization' => 'Bearer ' . $slack_token
4200 ],
4201 'body' => json_encode([
4202 'trigger_id' => $trigger_id,
4203 'view' => [
4204 'type' => 'modal',
4205 'callback_id' => 'reply_modal',
4206 'title' => [
4207 'type' => 'plain_text',
4208 'text' => __('Reply to User', 'mxchat')
4209 ],
4210 'submit' => [
4211 'type' => 'plain_text',
4212 'text' => __('Send', 'mxchat')
4213 ],
4214 'close' => [
4215 'type' => 'plain_text',
4216 'text' => __('Cancel', 'mxchat')
4217 ],
4218 'blocks' => [
4219 [
4220 'type' => 'input',
4221 'block_id' => 'reply_block',
4222 'label' => [
4223 'type' => 'plain_text',
4224 'text' => sprintf(__('Reply to session: %s', 'mxchat'), $session_id)
4225 ],
4226 'element' => [
4227 'type' => 'plain_text_input',
4228 'action_id' => 'message',
4229 'multiline' => true,
4230 'placeholder' => [
4231 'type' => 'plain_text',
4232 'text' => __('Type your message here...', 'mxchat')
4233 ]
4234 ]
4235 ]
4236 ],
4237 'private_metadata' => $session_id
4238 ]
4239 ])
4240 ]);
4241
4242 //error_log('Views.open response: ' . print_r($response, true));
4243
4244 // Return immediate acknowledgment
4245 return new WP_REST_Response(['ok' => true]);
4246 }
4247
4248 // Handle modal submission
4249 // Handle modal submission
4250 if ($payload['type'] === 'view_submission') {
4251 $session_id = $payload['view']['private_metadata'];
4252 $message = $payload['view']['state']['values']['reply_block']['message']['value'];
4253
4254 // Save the message (keep the message_id but don't include in response)
4255 $this->mxchat_save_chat_message($session_id, 'agent', $message);
4256
4257 // Keep the original response format for Slack
4258 return new WP_REST_Response([
4259 'response_action' => 'clear'
4260 ]);
4261 }
4262
4263 // Default acknowledgment
4264 return new WP_REST_Response(['ok' => true]);
4265 }
4266 public function mxchat_handle_agent_response(WP_REST_Request $request) {
4267 //error_log('Received agent response request');
4268 //error_log('Request data: ' . print_r($request->get_params(), true));
4269 // //error_log('Raw body: ' . file_get_contents('php://input'));
4270
4271 // Get the data from Slack's slash command format
4272 $command_text = $request->get_param('text');
4273 // //error_log('Command text: ' . $command_text);
4274
4275 if (empty($command_text)) {
4276 //error_log(esc_html__('Agent response error: No command text received', 'mxchat'));
4277 return new WP_REST_Response([
4278 'error' => esc_html__('Command text is required. Format: /reply session_id message', 'mxchat')
4279 ], 400);
4280 }
4281
4282 // Split the command text into session_id and message
4283 $parts = explode(' ', $command_text, 2);
4284 if (count($parts) !== 2) {
4285 //error_log('Agent response error: Invalid command format');
4286 return new WP_REST_Response([
4287 'error' => esc_html__('Invalid format. Use: /reply session_id message', 'mxchat')
4288 ], 400);
4289 }
4290
4291 $session_id = sanitize_text_field($parts[0]);
4292 $message = sanitize_text_field($parts[1]);
4293
4294 //error_log("Processing agent response - Session ID: $session_id, Message: $message");
4295
4296 // Save the message
4297 $message_id = $this->mxchat_save_chat_message($session_id, 'agent', $message);
4298
4299 if (!$message_id) {
4300 // //error_log('Failed to save agent message');
4301 return new WP_REST_Response([
4302 'error' => esc_html__('Failed to save message', 'mxchat')
4303 ], 500);
4304 }
4305
4306 // Return success response in Slack's expected format
4307 return new WP_REST_Response([
4308 'response_type' => 'in_channel',
4309 'text' => esc_html__("Message sent successfully to session $session_id", 'mxchat')
4310 ], 200);
4311 }
4312 public function mxchat_handle_switch_to_chatbot_intent($message, $user_id, $session_id) {
4313 // Update mode to AI
4314 update_option("mxchat_mode_{$session_id}", 'ai');
4315
4316 // Clear any existing PDF context to start fresh
4317 $this->clear_pdf_transients($session_id);
4318
4319 // Set the response with explicit chat_mode
4320 $this->fallbackResponse = [
4321 'text' => esc_html__('You are now chatting with the AI chatbot.', 'mxchat'),
4322 'html' => '',
4323 'images' => [],
4324 'chat_mode' => 'ai' // Ensure this is set
4325 ];
4326
4327 // Return the complete response array instead of just true
4328 return $this->fallbackResponse;
4329 }
4330
4331 public function handle_slack_messages(WP_REST_Request $request) {
4332 // Log the incoming request for debugging
4333 //error_log('Slack events request received: ' . $request->get_body());
4334
4335 $body = $request->get_body();
4336 $data = json_decode($body, true);
4337
4338 // Handle Slack URL verification
4339 if (isset($data['type']) && $data['type'] === 'url_verification') {
4340 //error_log('Slack URL verification challenge: ' . $data['challenge']);
4341 return new WP_REST_Response($data['challenge'], 200, ['Content-Type' => 'text/plain']);
4342 }
4343
4344 // IMPORTANT: Handle Slack's event deduplication
4345 if (isset($data['event_id'])) {
4346 $event_id = $data['event_id'];
4347 $processed_events = get_transient('mxchat_slack_events') ?: [];
4348
4349 // Check if we've already processed this event
4350 if (in_array($event_id, $processed_events)) {
4351 //error_log("Duplicate event detected: $event_id");
4352 return new WP_REST_Response(['ok' => true]);
4353 }
4354
4355 // Add this event to processed list
4356 $processed_events[] = $event_id;
4357 // Keep only last 100 events to prevent memory issues
4358 if (count($processed_events) > 100) {
4359 $processed_events = array_slice($processed_events, -100);
4360 }
4361 // Store for 1 hour
4362 set_transient('mxchat_slack_events', $processed_events, HOUR_IN_SECONDS);
4363 }
4364
4365 // Handle message events
4366 if (isset($data['event']) && $data['event']['type'] === 'message') {
4367 $event = $data['event'];
4368
4369 // Skip bot messages and messages with subtypes (like bot_message)
4370 if (isset($event['bot_id']) || isset($event['subtype'])) {
4371 return new WP_REST_Response(['ok' => true]);
4372 }
4373
4374 // Additional check: Skip if this is a threaded reply to our confirmation
4375 if (isset($event['thread_ts']) && $event['thread_ts'] !== $event['ts']) {
4376 return new WP_REST_Response(['ok' => true]);
4377 }
4378
4379 $channel_id = $event['channel'];
4380 $message_text = $event['text'] ?? '';
4381 $message_ts = $event['ts'] ?? '';
4382
4383 // Find session ID by looking for matching channel
4384 global $wpdb;
4385 $session_option = $wpdb->get_var(
4386 $wpdb->prepare(
4387 "SELECT option_name FROM {$wpdb->options}
4388 WHERE option_name LIKE 'mxchat_channel_%'
4389 AND option_value = %s",
4390 $channel_id
4391 )
4392 );
4393
4394 if ($session_option) {
4395 $session_id = str_replace('mxchat_channel_', '', $session_option);
4396
4397 // Create a unique key for this specific message
4398 $message_key = md5($session_id . $message_ts . $message_text);
4399 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
4400
4401 // Check if we've already processed this exact message
4402 if (in_array($message_key, $processed_messages)) {
4403 //error_log("Duplicate message detected for session $session_id");
4404 return new WP_REST_Response(['ok' => true]);
4405 }
4406
4407 // Add to processed messages
4408 $processed_messages[] = $message_key;
4409 // Keep only last 50 messages per session
4410 if (count($processed_messages) > 50) {
4411 $processed_messages = array_slice($processed_messages, -50);
4412 }
4413 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
4414
4415 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4416
4417 // Handle agent ending the chat — transfer back to AI
4418 // Format: "!endchat" or "!endchat <custom message to user>"
4419 if (preg_match('/^!endchat\b/i', trim($message_text))) {
4420 update_option("mxchat_mode_{$session_id}", 'ai');
4421
4422 // Extract custom message after !endchat, or use empty string
4423 $custom_message = trim(preg_replace('/^!endchat\s*/i', '', trim($message_text)));
4424
4425 // Send the agent's custom farewell message if provided
4426 if (!empty($custom_message)) {
4427 $this->mxchat_save_chat_message($session_id, 'agent', $custom_message);
4428 }
4429
4430 // Confirm in Slack channel
4431 if (!empty($slack_bot_token)) {
4432 wp_remote_post('https://slack.com/api/chat.postMessage', [
4433 'headers' => [
4434 'Content-Type' => 'application/json',
4435 'Authorization' => 'Bearer ' . $slack_bot_token
4436 ],
4437 'body' => json_encode([
4438 'channel' => $channel_id,
4439 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.",
4440 'mrkdwn' => true
4441 ])
4442 ]);
4443 }
4444
4445 return new WP_REST_Response(['ok' => true]);
4446 }
4447
4448 // Save the agent message
4449 $this->mxchat_save_chat_message($session_id, 'agent', $message_text);
4450
4451 // Send confirmation back to Slack (only once)
4452 if (!empty($slack_bot_token)) {
4453 // Use a transient to prevent duplicate confirmations
4454 $confirm_key = 'mxchat_confirm_' . $message_key;
4455 if (!get_transient($confirm_key)) {
4456 wp_remote_post('https://slack.com/api/chat.postMessage', [
4457 'headers' => [
4458 'Content-Type' => 'application/json',
4459 'Authorization' => 'Bearer ' . $slack_bot_token
4460 ],
4461 'body' => json_encode([
4462 'channel' => $channel_id,
4463 'text' => "✅ _Message sent to user_",
4464 'thread_ts' => $event['ts'] // Reply in thread
4465 ])
4466 ]);
4467 // Set transient to prevent duplicate confirmations
4468 set_transient($confirm_key, true, 300); // 5 minutes
4469 }
4470 }
4471 }
4472 }
4473
4474 return new WP_REST_Response(['ok' => true]);
4475 }
4476
4477 // For the word upload handler
4478 public function mxchat_handle_word_upload() {
4479 // Delegate to word handler
4480 $this->word_handler->mxchat_handle_word_upload();
4481 }
4482
4483 // For the word removal handler
4484 public function mxchat_handle_word_remove() {
4485 // Delegate to word handler
4486 $this->word_handler->mxchat_handle_word_remove();
4487 }
4488
4489 // For the word status check
4490 public function mxchat_check_word_status() {
4491 // Delegate to word handler
4492 $this->word_handler->mxchat_check_word_status();
4493 }
4494
4495
4496 private function mxchat_get_user_identifier() {
4497 return MxChat_User::mxchat_get_user_identifier();
4498 }
4499
4500 private function mxchat_generate_embedding($text, $api_key) {
4501 try {
4502 // Get options and selected model
4503 $options = get_option('mxchat_options');
4504 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4505
4506 // Determine endpoint and API key based on model
4507 if (strpos($selected_model, 'voyage') === 0) {
4508 $endpoint = 'https://api.voyageai.com/v1/embeddings';
4509 $api_key = $options['voyage_api_key'] ?? '';
4510
4511 // Check if Voyage API key is missing
4512 if (empty($api_key)) {
4513 //error_log('Voyage API key is missing');
4514 return [
4515 'error' => esc_html__('Voyage AI API key is not configured', 'mxchat'),
4516 'error_code' => 'missing_voyage_api_key'
4517 ];
4518 }
4519 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4520 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
4521 $api_key = $options['gemini_api_key'] ?? '';
4522
4523 // Check if Gemini API key is missing
4524 if (empty($api_key)) {
4525 //error_log('Gemini API key is missing');
4526 return [
4527 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
4528 'error_code' => 'missing_gemini_api_key'
4529 ];
4530 }
4531 } else {
4532 $endpoint = 'https://api.openai.com/v1/embeddings';
4533 // Use the passed API key for OpenAI
4534
4535 // Check if OpenAI API key is missing
4536 if (empty($api_key)) {
4537 //error_log('OpenAI API key is missing');
4538 return [
4539 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
4540 'error_code' => 'missing_openai_api_key'
4541 ];
4542 }
4543 }
4544
4545 // Check if text is empty
4546 if (empty($text)) {
4547 //error_log('Empty text provided for embedding generation');
4548 return [
4549 'error' => esc_html__('No text provided for embedding generation', 'mxchat'),
4550 'error_code' => 'empty_embedding_text'
4551 ];
4552 }
4553
4554 // Prepare request body based on provider
4555 if (strpos($selected_model, 'gemini-embedding') === 0) {
4556 // Gemini API format
4557 $request_body = [
4558 'model' => 'models/' . $selected_model,
4559 'content' => [
4560 'parts' => [
4561 ['text' => $text]
4562 ]
4563 ],
4564 'outputDimensionality' => 1536
4565 ];
4566
4567 // Prepare headers for Gemini (API key as query parameter)
4568 $endpoint .= '?key=' . $api_key;
4569 $headers = [
4570 'Content-Type' => 'application/json'
4571 ];
4572 } else {
4573 // OpenAI/Voyage API format
4574 $request_body = [
4575 'input' => $text,
4576 'model' => $selected_model
4577 ];
4578
4579 // Add output_dimension for voyage-3-large
4580 if ($selected_model === 'voyage-3-large') {
4581 $request_body['output_dimension'] = 2048;
4582 }
4583
4584 // Prepare headers for OpenAI/Voyage
4585 $headers = [
4586 'Content-Type' => 'application/json',
4587 'Authorization' => 'Bearer ' . $api_key
4588 ];
4589 }
4590
4591 // Prepare request arguments
4592 $args = [
4593 'body' => wp_json_encode($request_body),
4594 'headers' => $headers,
4595 'timeout' => 60,
4596 'redirection' => 5,
4597 'blocking' => true,
4598 'httpversion' => '1.0',
4599 'sslverify' => true,
4600 ];
4601
4602 // Make the request
4603 $response = wp_remote_post($endpoint, $args);
4604
4605 // Handle WordPress errors
4606 if (is_wp_error($response)) {
4607 $error_message = $response->get_error_message();
4608 //error_log('Embedding Generation Error: ' . $error_message);
4609 return [
4610 'error' => esc_html__('Connection error when generating embeddings: ', 'mxchat') . esc_html($error_message),
4611 'error_code' => 'embedding_connection_error'
4612 ];
4613 }
4614
4615 // Check HTTP status code
4616 $status_code = wp_remote_retrieve_response_code($response);
4617 if ($status_code !== 200) {
4618 $response_body = json_decode(wp_remote_retrieve_body($response), true);
4619
4620 $error_message = isset($response_body['error']['message'])
4621 ? $response_body['error']['message']
4622 : 'HTTP Error ' . $status_code;
4623
4624 $error_type = isset($response_body['error']['type'])
4625 ? $response_body['error']['type']
4626 : 'unknown';
4627
4628 //error_log('Embedding API HTTP Error: ' . $status_code . ' - ' . $error_message);
4629
4630 // Handle specific error types
4631 switch ($error_type) {
4632 case 'invalid_request_error':
4633 if (strpos($error_message, 'API key') !== false) {
4634 return [
4635 'error' => esc_html__('Invalid API key for embedding generation. Please check your API key configuration.', 'mxchat'),
4636 'error_code' => 'embedding_invalid_api_key'
4637 ];
4638 }
4639 break;
4640
4641 case 'authentication_error':
4642 return [
4643 'error' => esc_html__('Authentication failed for embedding generation. Please check your API key.', 'mxchat'),
4644 'error_code' => 'embedding_auth_error'
4645 ];
4646
4647 case 'rate_limit_exceeded':
4648 return [
4649 'error' => esc_html__('Rate limit exceeded for embedding generation. Please try again later.', 'mxchat'),
4650 'error_code' => 'embedding_rate_limit'
4651 ];
4652
4653 case 'quota_exceeded':
4654 return [
4655 'error' => esc_html__('API quota exceeded for embedding generation. Please check your billing details.', 'mxchat'),
4656 'error_code' => 'embedding_quota_exceeded'
4657 ];
4658 }
4659
4660 // Generic error fallback
4661 return [
4662 'error' => esc_html__('Embedding API error - check embedding API key.: ', 'mxchat') . esc_html($error_message),
4663 'error_code' => 'embedding_api_error',
4664 'status_code' => $status_code
4665 ];
4666 }
4667
4668 $response_body = json_decode(wp_remote_retrieve_body($response), true);
4669
4670 // Handle different response formats based on provider
4671 if (strpos($selected_model, 'gemini-embedding') === 0) {
4672 // Gemini API response format
4673 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
4674 return $response_body['embedding']['values'];
4675 } else {
4676 //error_log('Invalid Gemini embedding response: ' . wp_json_encode($response_body));
4677 return [
4678 'error' => esc_html__('Received invalid embedding data from the Gemini API.', 'mxchat'),
4679 'error_code' => 'invalid_gemini_embedding_response'
4680 ];
4681 }
4682 } else {
4683 // OpenAI/Voyage API response format
4684 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
4685 return $response_body['data'][0]['embedding'];
4686 } else {
4687 //error_log('Invalid embedding response: ' . wp_json_encode($response_body));
4688 return [
4689 'error' => esc_html__('Received invalid embedding data from the API.', 'mxchat'),
4690 'error_code' => 'invalid_embedding_response'
4691 ];
4692 }
4693 }
4694 } catch (Exception $e) {
4695 //error_log('Embedding Exception: ' . $e->getMessage());
4696 return [
4697 'error' => esc_html__('System error when generating embeddings: ', 'mxchat') . esc_html($e->getMessage()),
4698 'error_code' => 'embedding_exception'
4699 ];
4700 }
4701 }
4702
4703
4704 private function mxchat_find_relevant_content($user_embedding, $bot_id = 'default', $user_query = '') {
4705 //error_log("MXCHAT DEBUG: find_relevant_content called with bot_id: " . $bot_id);
4706
4707 // Check for OpenAI Vector Store first (takes priority when enabled)
4708 $bot_vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
4709
4710 if ($bot_vectorstore_config['use_vectorstore']) {
4711 // Get current model to verify it's an OpenAI model
4712 $bot_options = $this->get_bot_options($bot_id);
4713 $mxchat_options = get_option('mxchat_options', array());
4714 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
4715 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
4716
4717 if ($this->is_openai_chat_model($selected_model)) {
4718 //error_log("MXCHAT DEBUG: Using OpenAI Vector Store for knowledge retrieval");
4719 return $this->find_relevant_content_openai_vectorstore($user_query, $bot_id, $bot_vectorstore_config);
4720 } else {
4721 //error_log("MXCHAT DEBUG: Vector Store enabled but model is not OpenAI (" . $selected_model . "), skipping Vector Store");
4722 }
4723 }
4724
4725 // Get bot-specific Pinecone configuration
4726 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
4727
4728 // Debug: Log the Pinecone configuration
4729 //error_log("MXCHAT DEBUG: Pinecone config for bot '$bot_id':");
4730 //error_log(" - use_pinecone: " . ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false'));
4731 //error_log(" - api_key: " . (empty($bot_pinecone_config['api_key']) ? 'EMPTY' : 'SET (hidden)'));
4732 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'NOT SET'));
4733 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'NOT SET'));
4734
4735 // Determine whether to use Pinecone based on bot configuration
4736 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
4737
4738 //error_log("MXCHAT DEBUG: Using " . ($use_pinecone ? "Pinecone" : "WordPress Database") . " for knowledge retrieval");
4739
4740 if ($use_pinecone) {
4741 return $this->find_relevant_content_pinecone($user_embedding, $bot_id, $bot_pinecone_config);
4742 } else {
4743 return $this->find_relevant_content_wordpress($user_embedding, $bot_id);
4744 }
4745 }
4746
4747 private function find_relevant_content_wordpress($user_embedding, $bot_id = 'default') {
4748 global $wpdb;
4749 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
4750 $cache_key = 'mxchat_system_prompt_embeddings_' . $bot_id;
4751 $batch_size = 500;
4752
4753 // Initialize similarity analysis storage
4754 $this->last_similarity_analysis = [
4755 'knowledge_base_type' => 'WordPress Database',
4756 'bot_id' => $bot_id,
4757 'top_matches' => [],
4758 'threshold_used' => 0,
4759 'total_checked' => 0
4760 ];
4761
4762 // NEW: Initialize valid URLs array
4763 $valid_urls = [];
4764
4765 // Get bot-specific options for similarity threshold
4766 $bot_options = $this->get_bot_options($bot_id);
4767 $current_options = !empty($bot_options) ? $bot_options : $this->options;
4768
4769 // Retrieve embeddings from cache or database
4770 $embeddings = wp_cache_get($cache_key, 'mxchat_system_prompts');
4771 if ($embeddings === false) {
4772 // Cache miss - load embeddings from database WITH CONTENT and ROLE RESTRICTION for testing
4773 $embeddings = [];
4774 $offset = 0;
4775
4776 do {
4777 // Add bot_id filter if not default and if bot_metadata column exists
4778 $bot_filter = '';
4779 if ($bot_id !== 'default') {
4780 // Check if bot_metadata column exists
4781 $column_exists = $wpdb->get_var("SHOW COLUMNS FROM {$system_prompt_table} LIKE 'bot_metadata'");
4782 if ($column_exists) {
4783 $bot_filter = $wpdb->prepare(" AND (bot_metadata = %s OR bot_metadata IS NULL OR bot_metadata = '')", $bot_id);
4784 }
4785 }
4786
4787 $query = $wpdb->prepare(
4788 "SELECT id, embedding_vector, article_content, source_url, role_restriction
4789 FROM {$system_prompt_table}
4790 WHERE 1=1 {$bot_filter}
4791 LIMIT %d OFFSET %d",
4792 $batch_size,
4793 $offset
4794 );
4795
4796 $batch = $wpdb->get_results($query);
4797 if (empty($batch)) {
4798 break;
4799 }
4800
4801 $embeddings = array_merge($embeddings, $batch);
4802 $offset += $batch_size;
4803 unset($batch);
4804 } while (true);
4805
4806 if (empty($embeddings)) {
4807 // Store empty array for valid URLs since no content found
4808 $this->current_valid_urls = [];
4809 return '';
4810 }
4811
4812 // Cache embeddings for future use (but note: this now includes content and role restrictions)
4813 wp_cache_set($cache_key, $embeddings, 'mxchat_system_prompts', 3600);
4814 }
4815
4816 // Get knowledge manager instance for role checking
4817 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
4818
4819 // Get base similarity threshold from bot options or default options
4820 $similarity_threshold = isset($current_options['similarity_threshold'])
4821 ? ((int) $current_options['similarity_threshold']) / 100
4822 : 0.35;
4823
4824 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
4825
4826 // Calculate similarities and build results array
4827 $all_similarities = [];
4828 $url_groups = array(); // NEW: Group by source_url for chunk reassembly
4829
4830 foreach ($embeddings as $embedding) {
4831 $database_embedding = $embedding->embedding_vector
4832 ? unserialize($embedding->embedding_vector, ['allowed_classes' => false])
4833 : null;
4834
4835 if (is_array($database_embedding) && is_array($user_embedding)) {
4836 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
4837
4838 // Check role access
4839 $role_restriction = $embedding->role_restriction ?? 'public';
4840 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
4841
4842 // Store ALL similarities for testing (top 10)
4843 $source_display = '';
4844 $source_url = $embedding->source_url ?? '';
4845 if (!empty($source_url) && $source_url !== '#') {
4846 $source_display = $source_url;
4847 } else {
4848 $content_preview = strip_tags($embedding->article_content ?? '');
4849 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
4850 $source_display = substr(trim($content_preview), 0, 50) . '...';
4851 }
4852
4853 // Parse chunk metadata for display
4854 $article_content_for_parse = $embedding->article_content ?? '';
4855 $parsed_for_display = MxChat_Chunker::parse_stored_chunk($article_content_for_parse);
4856 $is_chunk = $parsed_for_display['is_chunked'];
4857 $chunk_meta = $parsed_for_display['metadata'];
4858
4859 $all_similarities[] = [
4860 'document_id' => $embedding->id,
4861 'similarity' => $similarity,
4862 'similarity_percentage' => round($similarity * 100, 2),
4863 'above_threshold' => $similarity >= $similarity_threshold,
4864 'source_display' => $source_display,
4865 'content_preview' => substr(strip_tags($parsed_for_display['text'] ?? ''), 0, 100) . '...',
4866 'used_for_context' => false,
4867 'role_restriction' => $role_restriction,
4868 'has_access' => $has_access,
4869 'filtered_out' => !$has_access,
4870 'is_chunk' => $is_chunk,
4871 'chunk_index' => $is_chunk ? ($chunk_meta['chunk_index'] ?? 0) : null,
4872 'total_chunks' => $is_chunk ? ($chunk_meta['total_chunks'] ?? 1) : null
4873 ];
4874
4875 // Only consider results above threshold AND with access for content retrieval
4876 if ($similarity >= $similarity_threshold && $has_access) {
4877 // Parse chunk metadata if present
4878 $article_content = $embedding->article_content ?? '';
4879 $parsed = MxChat_Chunker::parse_stored_chunk($article_content);
4880 $is_chunked = $parsed['is_chunked'];
4881 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
4882 $text_content = $parsed['text'];
4883
4884 // Use a unique key for manual entries without a source URL
4885 $group_key = !empty($source_url) ? $source_url : '_manual_' . $embedding->id;
4886
4887 // Group by source URL (or unique key for manual entries)
4888 if (!isset($url_groups[$group_key])) {
4889 $url_groups[$group_key] = array(
4890 'source_url' => $source_url,
4891 'best_score' => 0,
4892 'is_chunked' => $is_chunked,
4893 'chunks' => array(),
4894 'single_text' => '',
4895 'single_id' => null
4896 );
4897 }
4898
4899 // Track best score for this group
4900 if ($similarity > $url_groups[$group_key]['best_score']) {
4901 $url_groups[$group_key]['best_score'] = $similarity;
4902 }
4903
4904 // Store chunk info or single text
4905 if ($is_chunked) {
4906 $url_groups[$group_key]['is_chunked'] = true;
4907 $url_groups[$group_key]['chunks'][] = array(
4908 'id' => $embedding->id,
4909 'score' => $similarity,
4910 'chunk_index' => $chunk_index,
4911 'text' => $text_content
4912 );
4913 } else {
4914 $url_groups[$group_key]['single_text'] = $text_content;
4915 $url_groups[$group_key]['single_id'] = $embedding->id;
4916 }
4917 }
4918 }
4919
4920 unset($database_embedding);
4921 }
4922
4923 // Sort ALL similarities for testing display (highest first)
4924 usort($all_similarities, function ($a, $b) {
4925 return $b['similarity'] <=> $a['similarity'];
4926 });
4927
4928 // Sort URL groups by best score (highest first)
4929 uasort($url_groups, function($a, $b) {
4930 return $b['best_score'] <=> $a['best_score'];
4931 });
4932
4933 // Get RAG sources limit from options (default 6, min 3, max 10)
4934 $rag_sources_limit = isset($options['rag_sources_limit']) ? intval($options['rag_sources_limit']) : 3;
4935 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
4936 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
4937
4938 // Take top N unique URLs based on user setting
4939 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
4940
4941 // Track which document IDs are used for context
4942 $used_document_ids = [];
4943 foreach ($top_urls as $group) {
4944 if ($group['is_chunked']) {
4945 foreach ($group['chunks'] as $chunk) {
4946 $used_document_ids[] = $chunk['id'];
4947 }
4948 } elseif ($group['single_id']) {
4949 $used_document_ids[] = $group['single_id'];
4950 }
4951 }
4952
4953 // Update the all_similarities array to mark which were actually used
4954 foreach ($all_similarities as &$similarity_item) {
4955 $similarity_item['used_for_context'] = in_array($similarity_item['document_id'], $used_document_ids);
4956 }
4957
4958 // Store top 10 for testing panel
4959 $this->last_similarity_analysis['top_matches'] = array_slice($all_similarities, 0, 10);
4960 $this->last_similarity_analysis['total_checked'] = count($embeddings);
4961
4962 // Initialize final content
4963 $content = '';
4964 $matches_used = 0;
4965 $total_chunks_used = 0;
4966 $max_total_chunks = isset($options['rag_chunks_limit']) ? intval($options['rag_chunks_limit']) : 15;
4967 if ($max_total_chunks < 8) $max_total_chunks = 8;
4968 if ($max_total_chunks > 20) $max_total_chunks = 20;
4969 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
4970
4971 // Check if citation links are enabled (default to 'on' for backwards compatibility)
4972 // Use fresh options to ensure we get the latest setting value
4973 $fresh_options = get_option('mxchat_options', []);
4974 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
4975
4976 // Build content from top sources
4977 foreach ($top_urls as $group_key => $group) {
4978 $source_url = $group['source_url']; // Use actual source_url, not the group key
4979
4980 // Stop if we've hit the total chunk limit
4981 if ($total_chunks_used >= $max_total_chunks) {
4982 break;
4983 }
4984
4985 $full_text = '';
4986 $chunks_in_this_source = 1; // Default for non-chunked content
4987
4988 if ($group['is_chunked']) {
4989 // Calculate how many chunks we can still use (respect both total and per-source caps)
4990 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
4991
4992 // Fetch chunks for this URL with limit
4993 $full_text = $this->reassemble_chunks_from_wordpress($source_url, $chunks_remaining, $chunks_in_this_source);
4994
4995 // If fetching all chunks fails, fall back to matched chunks
4996 if (empty($full_text)) {
4997 // Sort matched chunks by index and concatenate
4998 usort($group['chunks'], function($a, $b) {
4999 return $a['chunk_index'] <=> $b['chunk_index'];
5000 });
5001
5002 $chunk_texts = array();
5003 $chunks_in_this_source = 0;
5004 foreach ($group['chunks'] as $chunk) {
5005 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
5006 break;
5007 }
5008 $chunk_texts[] = $chunk['text'];
5009 $chunks_in_this_source++;
5010 }
5011 $full_text = implode("\n\n", $chunk_texts);
5012 }
5013 } else {
5014 $full_text = $group['single_text'];
5015 $chunks_in_this_source = 1;
5016 }
5017
5018 if (!empty($full_text)) {
5019 // Strip URLs from content if citation links are disabled
5020 if (!$citation_links_enabled) {
5021 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
5022 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
5023 }
5024
5025 // Use numbered reference for URL-based entries, plain info label for manual entries
5026 if (!empty($source_url) && $source_url !== '#') {
5027 $matches_used++;
5028 $content .= "## Reference " . $matches_used . " ##\n";
5029 $content .= $full_text . "\n\n";
5030
5031 // Only include citation URLs if citation links are enabled
5032 if ($citation_links_enabled) {
5033 $valid_urls[] = $source_url;
5034 $content .= "URL: " . $source_url . "\n\n";
5035 }
5036 } else {
5037 // Manual entry — no reference number, no citation
5038 $content .= "## Information ##\n";
5039 $content .= $full_text . "\n\n";
5040 }
5041
5042 // Extract any URLs from the text content itself (only if citation links enabled)
5043 if ($citation_links_enabled) {
5044 preg_match_all(
5045 '#\bhttps?://[^\s<>"\']+#i',
5046 $full_text,
5047 $content_urls
5048 );
5049 if (!empty($content_urls[0])) {
5050 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5051 }
5052 }
5053
5054 $total_chunks_used += $chunks_in_this_source;
5055 }
5056 }
5057
5058 // NEW: Store unique valid URLs for validation
5059 $this->current_valid_urls = array_unique($valid_urls);
5060
5061 // Store sources and chunks counts for testing/transcript display
5062 $this->last_similarity_analysis['sources_used'] = $matches_used;
5063 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
5064
5065 // Add response guidelines
5066 if (empty($top_urls)) {
5067 $content = "No reference information was found for this query.\n\n";
5068 } else {
5069 // Build response guidelines based on citation links setting
5070 $content .= "\n## Response Guidelines ##\n" .
5071 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5072 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5073 "If you don't have specific information or are uncertain about any details, it's always " .
5074 "better to honestly say you don't know rather than making up or guessing at answers. " .
5075 "When information is incomplete, let them know you are unsure.\n\n";
5076
5077 // Only add hyperlink instructions if citation links are enabled
5078 if ($citation_links_enabled) {
5079 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5080 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
5081 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
5082 } else {
5083 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5084 "Simply provide helpful answers based on the reference information without citing sources.";
5085 }
5086 }
5087
5088 return trim($content);
5089 }
5090
5091 /**
5092 * Fetch and reassemble chunks for a URL from WordPress database
5093 *
5094 * @param string $source_url The source URL to fetch chunks for
5095 * @param int $max_chunks Maximum number of chunks to return (0 = unlimited)
5096 * @param int &$chunk_count Reference to store the actual number of chunks returned
5097 * @return string Reassembled content from chunks
5098 */
5099 private function reassemble_chunks_from_wordpress($source_url, $max_chunks = 0, &$chunk_count = 0) {
5100 global $wpdb;
5101 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
5102
5103 // Fetch all rows with this source_url
5104 $rows = $wpdb->get_results($wpdb->prepare(
5105 "SELECT article_content FROM {$table}
5106 WHERE source_url = %s
5107 ORDER BY id ASC",
5108 $source_url
5109 ));
5110
5111 if (empty($rows)) {
5112 $chunk_count = 0;
5113 return '';
5114 }
5115
5116 // Parse and sort chunks by index
5117 $chunks = array();
5118 foreach ($rows as $row) {
5119 $parsed = MxChat_Chunker::parse_stored_chunk($row->article_content);
5120
5121 if ($parsed['is_chunked']) {
5122 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
5123 $chunks[$chunk_index] = $parsed['text'];
5124 } else {
5125 // Non-chunked content - just return it
5126 $chunks[] = $parsed['text'];
5127 }
5128 }
5129
5130 // Sort by chunk index
5131 ksort($chunks);
5132
5133 // Apply chunk limit if specified
5134 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
5135 $chunks = array_slice($chunks, 0, $max_chunks, true);
5136 }
5137
5138 // Store actual chunk count
5139 $chunk_count = count($chunks);
5140
5141 // Reassemble content
5142 return implode("\n\n", $chunks);
5143 }
5144
5145 private function find_relevant_content_pinecone($user_embedding, $bot_id = 'default', $bot_config = null) {
5146 global $wpdb;
5147
5148 //error_log("MXCHAT DEBUG: find_relevant_content_pinecone called");
5149 //error_log(" - bot_id: " . $bot_id);
5150 //error_log(" - user_embedding is array: " . (is_array($user_embedding) ? 'yes' : 'no'));
5151 //error_log(" - user_embedding count: " . (is_array($user_embedding) ? count($user_embedding) : 'N/A'));
5152
5153 // Use bot-specific config or fall back to default
5154 if ($bot_config === null) {
5155 $bot_config = $this->get_bot_pinecone_config($bot_id);
5156 }
5157
5158 $api_key = $bot_config['api_key'] ?? '';
5159 $host = $bot_config['host'] ?? '';
5160 $namespace = $bot_config['namespace'] ?? '';
5161
5162 //error_log("MXCHAT DEBUG: Pinecone query parameters:");
5163 //error_log(" - API Key: " . (empty($api_key) ? 'EMPTY - ERROR!' : 'Present (length: ' . strlen($api_key) . ')'));
5164 //error_log(" - Host: " . (empty($host) ? 'EMPTY - ERROR!' : $host));
5165 //error_log(" - Namespace: " . (empty($namespace) ? 'EMPTY (will use default)' : $namespace));
5166
5167 // Initialize similarity analysis storage
5168 $this->last_similarity_analysis = [
5169 'knowledge_base_type' => 'Pinecone',
5170 'bot_id' => $bot_id,
5171 'namespace' => $namespace,
5172 'top_matches' => [],
5173 'threshold_used' => 0,
5174 'total_checked' => 0
5175 ];
5176
5177 // NEW: Initialize valid URLs array
5178 $valid_urls = [];
5179
5180 if (empty($host) || empty($api_key)) {
5181 //error_log("MXCHAT DEBUG ERROR: Missing Pinecone host or API key!");
5182 //error_log(" - Host empty: " . (empty($host) ? 'YES' : 'NO'));
5183 //error_log(" - API key empty: " . (empty($api_key) ? 'YES' : 'NO'));
5184 // Store empty array for valid URLs since we can't proceed
5185 $this->current_valid_urls = [];
5186 return '';
5187 }
5188
5189 // Get knowledge manager instance for role checking
5190 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
5191
5192 // Get the similarity threshold from the bot options or main options
5193 $bot_options = $this->get_bot_options($bot_id);
5194 $current_options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', []);
5195
5196 $similarity_threshold = isset($current_options['similarity_threshold'])
5197 ? ((int) $current_options['similarity_threshold']) / 100
5198 : 0.35;
5199
5200 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
5201
5202 // Prepare the query request for Pinecone
5203 $api_endpoint = "https://{$host}/query";
5204
5205 $request_body = array(
5206 'vector' => $user_embedding,
5207 'topK' => 50, // Increased for chunked content grouping - need more candidates to find top N unique URLs
5208 'includeMetadata' => true,
5209 'includeValues' => true
5210 );
5211
5212 // Add namespace if specified for this bot
5213 if (!empty($namespace)) {
5214 $request_body['namespace'] = $namespace;
5215 }
5216
5217 //error_log("MXCHAT DEBUG: About to call Pinecone API");
5218 //error_log(" - Endpoint: " . $api_endpoint);
5219 //error_log(" - Namespace in request: " . (!empty($namespace) ? $namespace : 'NOT SET'));
5220
5221 $response = wp_remote_post($api_endpoint, array(
5222 'headers' => array(
5223 'Api-Key' => $api_key,
5224 'accept' => 'application/json',
5225 'content-type' => 'application/json'
5226 ),
5227 'body' => wp_json_encode($request_body),
5228 'timeout' => 30
5229 ));
5230
5231 if (is_wp_error($response)) {
5232 //error_log("MXCHAT DEBUG ERROR: WP Error in Pinecone request: " . $response->get_error_message());
5233 // Store empty array for valid URLs
5234 $this->current_valid_urls = [];
5235 return '';
5236 }
5237
5238 $response_code = wp_remote_retrieve_response_code($response);
5239 //error_log("MXCHAT DEBUG: Pinecone response code: " . $response_code);
5240
5241 if ($response_code !== 200) {
5242 $response_body = wp_remote_retrieve_body($response);
5243 //error_log("MXCHAT DEBUG ERROR: Pinecone API error response: " . substr($response_body, 0, 500));
5244 // Store empty array for valid URLs
5245 $this->current_valid_urls = [];
5246 return '';
5247 }
5248
5249 // ADD DETAILED DEBUG SECTION HERE
5250 $response_body = wp_remote_retrieve_body($response);
5251 //error_log("MXCHAT DEBUG: Raw Pinecone response length: " . strlen($response_body));
5252
5253 $results = json_decode($response_body, true);
5254
5255 if (json_last_error() !== JSON_ERROR_NONE) {
5256 //error_log("MXCHAT DEBUG ERROR: JSON decode error: " . json_last_error_msg());
5257 //error_log("MXCHAT DEBUG: First 500 chars of response: " . substr($response_body, 0, 500));
5258 // Store empty array for valid URLs
5259 $this->current_valid_urls = [];
5260 return '';
5261 }
5262
5263 //error_log("MXCHAT DEBUG: Pinecone response structure:");
5264 //error_log(" - Has 'matches' key: " . (isset($results['matches']) ? 'yes' : 'no'));
5265 //error_log(" - Has 'namespace' key: " . (isset($results['namespace']) ? 'yes (' . $results['namespace'] . ')' : 'no'));
5266
5267 if (empty($results['matches'])) {
5268 //error_log("MXCHAT DEBUG: No matches found in Pinecone response");
5269 //error_log("MXCHAT DEBUG: Response keys: " . implode(', ', array_keys($results)));
5270 // Store empty array for valid URLs
5271 $this->current_valid_urls = [];
5272 return '';
5273 }
5274
5275 //error_log("MXCHAT DEBUG: Found " . count($results['matches']) . " matches in Pinecone");
5276
5277 // Log first match details for debugging
5278 if (!empty($results['matches'][0])) {
5279 $first_match = $results['matches'][0];
5280 //error_log("MXCHAT DEBUG: First match details:");
5281 //error_log(" - Score: " . ($first_match['score'] ?? 'no score'));
5282 //error_log(" - Has metadata: " . (isset($first_match['metadata']) ? 'yes' : 'no'));
5283 if (isset($first_match['metadata'])) {
5284 //error_log(" - Metadata keys: " . implode(', ', array_keys($first_match['metadata'])));
5285 }
5286 }
5287
5288 // Initialize the final content
5289 $content = '';
5290 $matches_used = 0;
5291 $matches_used_for_context = [];
5292 $total_chunks_used = 0;
5293 $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
5294 if ($max_total_chunks < 8) $max_total_chunks = 8;
5295 if ($max_total_chunks > 20) $max_total_chunks = 20;
5296 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
5297
5298 // Check if citation links are enabled (default to 'on' for backwards compatibility)
5299 // Use fresh options to ensure we get the latest setting value
5300 $fresh_options = get_option('mxchat_options', []);
5301 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
5302
5303 // NEW CHUNKING LOGIC: Group results by source_url for chunk reassembly
5304 $url_groups = array();
5305
5306 foreach ($results['matches'] as $index => $match) {
5307 // Skip if similarity is below threshold
5308 if ($match['score'] < $similarity_threshold) {
5309 continue;
5310 }
5311
5312 $metadata = $match['metadata'] ?? array();
5313 $source_url = $metadata['source_url'] ?? '';
5314 $match_id = $match['id'] ?? '';
5315
5316 // LAZY ROLE CHECK: Only check role for content we're actually considering
5317 $role_restriction = $this->get_single_vector_role($match_id, $metadata);
5318 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5319
5320 // Skip if user doesn't have access
5321 if (!$has_access) {
5322 continue;
5323 }
5324
5325 // Use a unique key for manual entries without a source URL
5326 $group_key = !empty($source_url) ? $source_url : '_manual_' . $match_id;
5327
5328 // Group by source URL (or unique key for manual entries)
5329 if (!isset($url_groups[$group_key])) {
5330 $url_groups[$group_key] = array(
5331 'source_url' => $source_url,
5332 'best_score' => 0,
5333 'is_chunked' => isset($metadata['is_chunked']) && $metadata['is_chunked'],
5334 'chunks' => array(),
5335 'single_text' => ''
5336 );
5337 }
5338
5339 // Track best score for this group
5340 if ($match['score'] > $url_groups[$group_key]['best_score']) {
5341 $url_groups[$group_key]['best_score'] = $match['score'];
5342 }
5343
5344 // Store chunk info or single text
5345 if ($url_groups[$group_key]['is_chunked']) {
5346 $url_groups[$group_key]['chunks'][] = array(
5347 'id' => $match_id,
5348 'score' => $match['score'],
5349 'chunk_index' => $metadata['chunk_index'] ?? 0,
5350 'text' => $metadata['text'] ?? ''
5351 );
5352 } else {
5353 // Non-chunked content - just store the text
5354 $url_groups[$group_key]['single_text'] = $metadata['text'] ?? '';
5355 $url_groups[$group_key]['single_id'] = $match_id;
5356 }
5357 }
5358
5359 // Sort URL groups by best score (highest first)
5360 uasort($url_groups, function($a, $b) {
5361 return $b['best_score'] <=> $a['best_score'];
5362 });
5363
5364 // Get RAG sources limit from options (default 6, min 3, max 10)
5365 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
5366 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
5367 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
5368
5369 // Take top N unique URLs based on user setting
5370 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
5371
5372 // Track which match IDs are actually used for context
5373 foreach ($top_urls as $group) {
5374 if ($group['is_chunked']) {
5375 foreach ($group['chunks'] as $chunk) {
5376 $matches_used_for_context[] = $chunk['id'];
5377 }
5378 } elseif (!empty($group['single_id'])) {
5379 $matches_used_for_context[] = $group['single_id'];
5380 }
5381 }
5382
5383 // Build content from top sources
5384 foreach ($top_urls as $group_key => $group) {
5385 $source_url = $group['source_url']; // Use actual source_url, not the group key
5386
5387 // Stop if we've hit the total chunk limit
5388 if ($total_chunks_used >= $max_total_chunks) {
5389 break;
5390 }
5391
5392 $full_text = '';
5393 $chunks_in_this_source = 1; // Default for non-chunked content
5394
5395 if ($group['is_chunked']) {
5396 // Calculate how many chunks we can still use (respect both total and per-source caps)
5397 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
5398
5399 // Fetch chunks for this URL with limit
5400 $full_text = $this->reassemble_chunks_from_pinecone($source_url, $bot_config, $chunks_remaining, $chunks_in_this_source);
5401
5402 // If fetching all chunks fails, fall back to matched chunks
5403 if (empty($full_text)) {
5404 // Sort matched chunks by index and concatenate
5405 usort($group['chunks'], function($a, $b) {
5406 return $a['chunk_index'] <=> $b['chunk_index'];
5407 });
5408
5409 $chunk_texts = array();
5410 $chunks_in_this_source = 0;
5411 foreach ($group['chunks'] as $chunk) {
5412 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
5413 break;
5414 }
5415 $chunk_texts[] = $chunk['text'];
5416 $chunks_in_this_source++;
5417 }
5418 $full_text = implode("\n\n", $chunk_texts);
5419 }
5420 } else {
5421 $full_text = $group['single_text'];
5422 $chunks_in_this_source = 1;
5423 }
5424
5425 if (!empty($full_text)) {
5426 // Strip URLs from content if citation links are disabled
5427 if (!$citation_links_enabled) {
5428 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
5429 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
5430 }
5431
5432 // Use numbered reference for URL-based entries, plain info label for manual entries
5433 if (!empty($source_url) && $source_url !== '#') {
5434 $matches_used++;
5435 $content .= "## Reference " . $matches_used . " ##\n";
5436 $content .= $full_text . "\n\n";
5437
5438 // Only include citation URLs if citation links are enabled
5439 if ($citation_links_enabled) {
5440 $valid_urls[] = $source_url;
5441 $content .= "URL: " . $source_url . "\n\n";
5442 }
5443 } else {
5444 // Manual entry — no reference number, no citation
5445 $content .= "## Information ##\n";
5446 $content .= $full_text . "\n\n";
5447 }
5448
5449 // Extract any URLs from the text content itself (only if citation links enabled)
5450 if ($citation_links_enabled) {
5451 preg_match_all(
5452 '#\bhttps?://[^\s<>"\']+#i',
5453 $full_text,
5454 $content_urls
5455 );
5456 if (!empty($content_urls[0])) {
5457 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5458 }
5459 }
5460
5461 $total_chunks_used += $chunks_in_this_source;
5462 }
5463 }
5464
5465 // Process ALL matches for testing data (top 10) - with role checking for testing display
5466 $all_matches = [];
5467 foreach ($results['matches'] as $index => $match) {
5468 if ($index >= 10) break; // Limit to top 10 for testing
5469
5470 $match_id = $match['id'] ?? '';
5471
5472 // Check role access for testing display (use cache if available)
5473 $role_restriction = $this->get_single_vector_role($match_id, $match['metadata']);
5474 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5475
5476 $source_display = '';
5477 if (!empty($match['metadata']['source_url'])) {
5478 $source_display = $match['metadata']['source_url'];
5479 } else {
5480 $content_preview = strip_tags($match['metadata']['text'] ?? '');
5481 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
5482 $source_display = substr(trim($content_preview), 0, 50) . '...';
5483 }
5484
5485 $match_id_for_display = $match['id'] ?? $index;
5486
5487 // Check for chunk metadata in Pinecone
5488 $is_chunk = isset($match['metadata']['is_chunked']) && $match['metadata']['is_chunked'];
5489 $chunk_index = isset($match['metadata']['chunk_index']) ? intval($match['metadata']['chunk_index']) : null;
5490 $total_chunks = isset($match['metadata']['total_chunks']) ? intval($match['metadata']['total_chunks']) : null;
5491
5492 // Also detect chunk from vector ID pattern: {hash}_chunk_{index}
5493 if (!$is_chunk && MxChat_Chunker::is_chunk_vector_id($match_id_for_display)) {
5494 $is_chunk = true;
5495 }
5496
5497 $all_matches[] = [
5498 'document_id' => $match_id_for_display,
5499 'similarity' => $match['score'],
5500 'similarity_percentage' => round($match['score'] * 100, 2),
5501 'above_threshold' => $match['score'] >= $similarity_threshold,
5502 'source_display' => $source_display,
5503 'content_preview' => substr(strip_tags($match['metadata']['text'] ?? ''), 0, 100) . '...',
5504 'used_for_context' => in_array($match_id_for_display, $matches_used_for_context),
5505 'role_restriction' => $role_restriction,
5506 'has_access' => $has_access,
5507 'filtered_out' => !$has_access,
5508 'is_chunk' => $is_chunk,
5509 'chunk_index' => $chunk_index,
5510 'total_chunks' => $total_chunks
5511 ];
5512 }
5513
5514 // Store for testing panel
5515 $this->last_similarity_analysis['top_matches'] = $all_matches;
5516 $this->last_similarity_analysis['total_checked'] = count($results['matches']);
5517 $this->last_similarity_analysis['sources_used'] = $matches_used;
5518 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
5519
5520 // NEW: Store unique valid URLs for validation
5521 $this->current_valid_urls = array_unique($valid_urls);
5522
5523 // Add response guidelines
5524 if ($matches_used === 0) {
5525 $content = "No reference information was found for this query.\n\n";
5526 } else {
5527 // Build response guidelines based on citation links setting
5528 $content .= "\n## Response Guidelines ##\n" .
5529 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5530 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5531 "If you don't have specific information or are uncertain about any details, it's always " .
5532 "better to honestly say you don't know rather than making up or guessing at answers. " .
5533 "When information is incomplete, let them know you are unsure.\n\n";
5534
5535 // Only add hyperlink instructions if citation links are enabled
5536 if ($citation_links_enabled) {
5537 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5538 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
5539 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
5540 } else {
5541 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5542 "Simply provide helpful answers based on the reference information without citing sources.";
5543 }
5544 }
5545
5546 return trim($content);
5547 }
5548
5549 /**
5550 * Get role restriction for a single vector (with caching)
5551 */
5552 private function get_single_vector_role($vector_id, $metadata = array()) {
5553 global $wpdb;
5554
5555 if (empty($vector_id)) {
5556 return 'public';
5557 }
5558
5559 // Check cache first
5560 $cache_key = 'mxchat_vector_role_' . $vector_id;
5561 $cached_role = wp_cache_get($cache_key, 'mxchat_vector_roles');
5562
5563 if ($cached_role !== false) {
5564 return $cached_role;
5565 }
5566
5567 $role_restriction = 'public';
5568
5569 // First try Pinecone metadata
5570 if (!empty($metadata['role_restriction'])) {
5571 $role_restriction = $metadata['role_restriction'];
5572 } else {
5573 // Check WordPress table for user-modified roles
5574 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
5575 $stored_role = $wpdb->get_var($wpdb->prepare(
5576 "SELECT role_restriction FROM {$roles_table} WHERE vector_id = %s",
5577 $vector_id
5578 ));
5579
5580 if ($stored_role) {
5581 $role_restriction = $stored_role;
5582 }
5583 }
5584
5585 // Cache individual role for 1 hour
5586 wp_cache_set($cache_key, $role_restriction, 'mxchat_vector_roles', 3600);
5587
5588 return $role_restriction;
5589 }
5590
5591 /**
5592 * Fetch and reassemble all chunks for a URL from Pinecone
5593 *
5594 * @param string $source_url The source URL to fetch chunks for
5595 * @param array $bot_config Bot-specific Pinecone configuration
5596 * @return string Reassembled content from all chunks
5597 */
5598 private function reassemble_chunks_from_pinecone($source_url, $bot_config, $max_chunks = 0, &$chunk_count = 0) {
5599 $api_key = $bot_config['api_key'] ?? '';
5600 $host = $bot_config['host'] ?? '';
5601 $namespace = $bot_config['namespace'] ?? '';
5602
5603 if (empty($host) || empty($api_key)) {
5604 $chunk_count = 0;
5605 return '';
5606 }
5607
5608 $base_hash = md5($source_url);
5609
5610 // Use Pinecone list API to find all chunk vectors with this prefix
5611 $list_url = "https://{$host}/vectors/list";
5612
5613 // Limit to max_chunks if specified, otherwise fetch up to 100
5614 $fetch_limit = ($max_chunks > 0 && $max_chunks < 100) ? $max_chunks : 100;
5615
5616 $list_body = array(
5617 'prefix' => $base_hash . '_chunk_',
5618 'limit' => $fetch_limit
5619 );
5620
5621 if (!empty($namespace)) {
5622 $list_body['namespace'] = $namespace;
5623 }
5624
5625 $list_response = wp_remote_post($list_url, array(
5626 'headers' => array(
5627 'Api-Key' => $api_key,
5628 'accept' => 'application/json',
5629 'content-type' => 'application/json'
5630 ),
5631 'body' => wp_json_encode($list_body),
5632 'timeout' => 30
5633 ));
5634
5635 if (is_wp_error($list_response)) {
5636 //error_log('[MXCHAT-CHUNK] List API error: ' . $list_response->get_error_message());
5637 return '';
5638 }
5639
5640 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
5641
5642 if (empty($list_data['vectors'])) {
5643 //error_log('[MXCHAT-CHUNK] No chunk vectors found for URL: ' . $source_url);
5644 return '';
5645 }
5646
5647 // Extract vector IDs
5648 $vector_ids = array();
5649 foreach ($list_data['vectors'] as $vector) {
5650 if (isset($vector['id'])) {
5651 $vector_ids[] = $vector['id'];
5652 }
5653 }
5654
5655 if (empty($vector_ids)) {
5656 return '';
5657 }
5658
5659 // Fetch all chunk content
5660 $fetch_url = "https://{$host}/vectors/fetch";
5661
5662 $fetch_body = array(
5663 'ids' => $vector_ids
5664 );
5665
5666 if (!empty($namespace)) {
5667 $fetch_body['namespace'] = $namespace;
5668 }
5669
5670 $fetch_response = wp_remote_post($fetch_url, array(
5671 'headers' => array(
5672 'Api-Key' => $api_key,
5673 'accept' => 'application/json',
5674 'content-type' => 'application/json'
5675 ),
5676 'body' => wp_json_encode($fetch_body),
5677 'timeout' => 30
5678 ));
5679
5680 if (is_wp_error($fetch_response)) {
5681 //error_log('[MXCHAT-CHUNK] Fetch API error: ' . $fetch_response->get_error_message());
5682 return '';
5683 }
5684
5685 $fetch_data = json_decode(wp_remote_retrieve_body($fetch_response), true);
5686
5687 if (empty($fetch_data['vectors'])) {
5688 return '';
5689 }
5690
5691 // Sort chunks by index and reassemble
5692 $chunks = array();
5693 foreach ($fetch_data['vectors'] as $id => $vector) {
5694 $metadata = $vector['metadata'] ?? array();
5695 $chunk_index = $metadata['chunk_index'] ?? 0;
5696 $text = $metadata['text'] ?? '';
5697
5698 // Store chunk with its index
5699 $chunks[$chunk_index] = $text;
5700 }
5701
5702 // Sort by chunk index
5703 ksort($chunks);
5704
5705 // Apply chunk limit if specified
5706 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
5707 $chunks = array_slice($chunks, 0, $max_chunks, true);
5708 }
5709
5710 // Store actual chunk count
5711 $chunk_count = count($chunks);
5712
5713 // Reassemble content
5714 return implode("\n\n", $chunks);
5715 }
5716
5717 /**
5718 * Search for relevant content using OpenAI Vector Store (File Search)
5719 *
5720 * @param string $user_query The user's query text
5721 * @param string $bot_id The bot ID
5722 * @param array $vectorstore_config Vector Store configuration
5723 * @return string Formatted context string with references
5724 */
5725 private function find_relevant_content_openai_vectorstore($user_query, $bot_id = 'default', $vectorstore_config = array()) {
5726 //error_log("MXCHAT DEBUG: find_relevant_content_openai_vectorstore called");
5727 //error_log(" - bot_id: " . $bot_id);
5728 //error_log(" - user_query length: " . strlen($user_query));
5729
5730 // Get OpenAI API key
5731 $mxchat_options = get_option('mxchat_options', array());
5732 $api_key = $mxchat_options['api_key'] ?? '';
5733
5734 // Reset vectorstore error tracking
5735 $this->last_vectorstore_error = null;
5736
5737 if (empty($api_key)) {
5738 //error_log("MXCHAT DEBUG ERROR: OpenAI API key not configured");
5739 $this->last_vectorstore_error = 'Vector Store search failed: OpenAI API key is not configured.';
5740 $this->current_valid_urls = [];
5741 return '';
5742 }
5743
5744 // Get Vector Store configuration
5745 if (empty($vectorstore_config)) {
5746 $vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
5747 }
5748
5749 $vectorstore_ids_string = $vectorstore_config['vectorstore_ids'] ?? '';
5750 $max_results = $vectorstore_config['max_results'] ?? 5;
5751
5752 if (empty($vectorstore_ids_string)) {
5753 //error_log("MXCHAT DEBUG ERROR: No Vector Store IDs configured");
5754 $this->last_vectorstore_error = 'Vector Store search failed: No Vector Store IDs are configured for this bot.';
5755 $this->current_valid_urls = [];
5756 return '';
5757 }
5758
5759 // Parse Vector Store IDs
5760 $vectorstore_ids = array_map('trim', explode(',', $vectorstore_ids_string));
5761 $vectorstore_ids = array_filter($vectorstore_ids); // Remove empty values
5762
5763 //error_log("MXCHAT DEBUG: Vector Store IDs: " . implode(', ', $vectorstore_ids));
5764 //error_log("MXCHAT DEBUG: Max results: " . $max_results);
5765
5766 // Initialize similarity analysis storage
5767 $this->last_similarity_analysis = [
5768 'knowledge_base_type' => 'OpenAI Vector Store',
5769 'bot_id' => $bot_id,
5770 'vectorstore_ids' => $vectorstore_ids,
5771 'top_matches' => [],
5772 'threshold_used' => 0,
5773 'total_checked' => 0
5774 ];
5775
5776 $valid_urls = [];
5777
5778 // Get the selected model
5779 $bot_options = $this->get_bot_options($bot_id);
5780 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
5781 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
5782
5783 // Verify it's an OpenAI model
5784 if (!$this->is_openai_chat_model($selected_model)) {
5785 //error_log("MXCHAT DEBUG ERROR: Vector Store search requires OpenAI model. Current: " . $selected_model);
5786 $this->last_vectorstore_error = 'Vector Store search requires an OpenAI model. Current model: ' . $selected_model;
5787 $this->current_valid_urls = [];
5788 return '';
5789 }
5790
5791 // Use OpenAI Responses API with file_search tool
5792 $request_body = array(
5793 'model' => $selected_model,
5794 'input' => $user_query,
5795 'tools' => array(
5796 array(
5797 'type' => 'file_search',
5798 'vector_store_ids' => $vectorstore_ids,
5799 'max_num_results' => intval($max_results)
5800 )
5801 ),
5802 'include' => array('output[*].file_search_call.search_results')
5803 );
5804
5805 //error_log("MXCHAT VECTORSTORE: ========== REQUEST START ==========");
5806 //error_log("MXCHAT VECTORSTORE: Model: " . $selected_model);
5807 //error_log("MXCHAT VECTORSTORE: Query: " . substr($user_query, 0, 200));
5808 //error_log("MXCHAT VECTORSTORE: Vector Store IDs: " . implode(', ', $vectorstore_ids));
5809 //error_log("MXCHAT VECTORSTORE: Max Results: " . $max_results);
5810 //error_log("MXCHAT VECTORSTORE: Request body: " . wp_json_encode($request_body));
5811
5812 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
5813 'headers' => array(
5814 'Authorization' => 'Bearer ' . $api_key,
5815 'Content-Type' => 'application/json'
5816 ),
5817 'body' => wp_json_encode($request_body),
5818 'timeout' => 60
5819 ));
5820
5821 if (is_wp_error($response)) {
5822 //error_log("MXCHAT VECTORSTORE ERROR: WP Error: " . $response->get_error_message());
5823 $this->last_vectorstore_error = 'Vector Store API request failed: ' . $response->get_error_message();
5824 $this->current_valid_urls = [];
5825 return '';
5826 }
5827
5828 $response_code = wp_remote_retrieve_response_code($response);
5829 //error_log("MXCHAT VECTORSTORE: Response code: " . $response_code);
5830
5831 $response_body = wp_remote_retrieve_body($response);
5832 //error_log("MXCHAT VECTORSTORE: Raw response (first 2000 chars): " . substr($response_body, 0, 2000));
5833
5834 if ($response_code !== 200) {
5835 //error_log("MXCHAT VECTORSTORE ERROR: API error response: " . $response_body);
5836 $api_error_detail = '';
5837 $decoded_error = json_decode($response_body, true);
5838 if (isset($decoded_error['error']['message'])) {
5839 $api_error_detail = $decoded_error['error']['message'];
5840 }
5841 $this->last_vectorstore_error = 'Vector Store API returned HTTP ' . $response_code . ($api_error_detail ? ': ' . $api_error_detail : '');
5842 $this->current_valid_urls = [];
5843 return '';
5844 }
5845 $result = json_decode($response_body, true);
5846
5847 if (json_last_error() !== JSON_ERROR_NONE) {
5848 //error_log("MXCHAT VECTORSTORE ERROR: JSON decode error: " . json_last_error_msg());
5849 $this->last_vectorstore_error = 'Vector Store response could not be parsed: ' . json_last_error_msg();
5850 $this->current_valid_urls = [];
5851 return '';
5852 }
5853
5854 // Debug: Log the structure of the result
5855 //error_log("MXCHAT VECTORSTORE: Result keys: " . implode(', ', array_keys($result)));
5856 if (isset($result['output'])) {
5857 //error_log("MXCHAT VECTORSTORE: Output count: " . count($result['output']));
5858 foreach ($result['output'] as $idx => $out) {
5859 //error_log("MXCHAT VECTORSTORE: Output[$idx] type: " . ($out['type'] ?? 'unknown'));
5860 //error_log("MXCHAT VECTORSTORE: Output[$idx] keys: " . implode(', ', array_keys($out)));
5861 }
5862 } else {
5863 //error_log("MXCHAT VECTORSTORE: No 'output' key in result!");
5864 }
5865
5866 // Extract file search results from the response
5867 $content = '';
5868 $matches_used = 0;
5869 $all_matches = [];
5870
5871 // The Responses API returns output array with tool results
5872 if (isset($result['output']) && is_array($result['output'])) {
5873 foreach ($result['output'] as $output_item) {
5874 // Look for file_search_call results
5875 if (isset($output_item['type']) && $output_item['type'] === 'file_search_call') {
5876 //error_log("MXCHAT VECTORSTORE: Found file_search_call output item");
5877 //error_log("MXCHAT VECTORSTORE: file_search_call keys: " . implode(', ', array_keys($output_item)));
5878
5879 // Check for search_results in the output item directly
5880 $search_results = $output_item['search_results'] ?? $output_item['results'] ?? [];
5881 //error_log("MXCHAT VECTORSTORE: Search results count: " . count($search_results));
5882
5883 if (empty($search_results)) {
5884 //error_log("MXCHAT VECTORSTORE: No search results found in file_search_call");
5885 //error_log("MXCHAT VECTORSTORE: file_search_call content: " . wp_json_encode($output_item));
5886 }
5887
5888 foreach ($search_results as $index => $search_result) {
5889 $filename = $search_result['filename'] ?? '';
5890 $score = $search_result['score'] ?? 0;
5891 $text_content = '';
5892
5893 // Extract text content from the result
5894 // The text can be directly on the result OR nested under content array
5895 if (isset($search_result['text']) && !empty($search_result['text'])) {
5896 // Direct text field (OpenAI's actual format)
5897 $text_content = $search_result['text'];
5898 //error_log("MXCHAT VECTORSTORE: Found text directly on result[$index], length: " . strlen($text_content));
5899 } elseif (isset($search_result['content']) && is_array($search_result['content'])) {
5900 // Nested content array format
5901 foreach ($search_result['content'] as $content_item) {
5902 if (isset($content_item['text'])) {
5903 $text_content .= $content_item['text'] . "\n";
5904 }
5905 }
5906 //error_log("MXCHAT VECTORSTORE: Found text in content array for result[$index], length: " . strlen($text_content));
5907 } else {
5908 //error_log("MXCHAT VECTORSTORE: No text found for result[$index]. Keys: " . implode(', ', array_keys($search_result)));
5909 }
5910
5911 if (!empty($text_content)) {
5912 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
5913 $content .= trim($text_content) . "\n\n";
5914
5915 if (!empty($filename)) {
5916 $content .= "Source: " . $filename . "\n\n";
5917 }
5918
5919 // Extract URLs from content
5920 preg_match_all(
5921 '#\bhttps?://[^\s<>"\']+#i',
5922 $text_content,
5923 $content_urls
5924 );
5925 if (!empty($content_urls[0])) {
5926 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5927 }
5928
5929 $matches_used++;
5930 }
5931
5932 // Store for similarity analysis
5933 $all_matches[] = [
5934 'document_id' => $filename ?: ('result_' . $index),
5935 'similarity' => $score,
5936 'similarity_percentage' => round($score * 100, 2),
5937 'above_threshold' => true,
5938 'source_display' => $filename,
5939 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
5940 'used_for_context' => true,
5941 'role_restriction' => 'public',
5942 'has_access' => true,
5943 'filtered_out' => false
5944 ];
5945 }
5946 }
5947
5948 // Also check for message content with annotations (citations)
5949 if (isset($output_item['type']) && $output_item['type'] === 'message') {
5950 if (isset($output_item['content']) && is_array($output_item['content'])) {
5951 foreach ($output_item['content'] as $content_block) {
5952 if (isset($content_block['annotations']) && is_array($content_block['annotations'])) {
5953 foreach ($content_block['annotations'] as $annotation) {
5954 if (isset($annotation['filename'])) {
5955 $filename = $annotation['filename'];
5956 $score = $annotation['score'] ?? 0;
5957 $text_content = '';
5958
5959 if (isset($annotation['content']) && is_array($annotation['content'])) {
5960 foreach ($annotation['content'] as $ann_content) {
5961 if (isset($ann_content['text'])) {
5962 $text_content .= $ann_content['text'] . "\n";
5963 }
5964 }
5965 }
5966
5967 if (!empty($text_content) && $matches_used < $max_results) {
5968 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
5969 $content .= trim($text_content) . "\n\n";
5970 $content .= "Source: " . $filename . "\n\n";
5971
5972 preg_match_all(
5973 '#\bhttps?://[^\s<>"\']+#i',
5974 $text_content,
5975 $content_urls
5976 );
5977 if (!empty($content_urls[0])) {
5978 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5979 }
5980
5981 $matches_used++;
5982
5983 $all_matches[] = [
5984 'document_id' => $filename,
5985 'similarity' => $score,
5986 'similarity_percentage' => round($score * 100, 2),
5987 'above_threshold' => true,
5988 'source_display' => $filename,
5989 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
5990 'used_for_context' => true,
5991 'role_restriction' => 'public',
5992 'has_access' => true,
5993 'filtered_out' => false
5994 ];
5995 }
5996 }
5997 }
5998 }
5999 }
6000 }
6001 }
6002 }
6003 }
6004
6005 // Store for testing panel
6006 $this->last_similarity_analysis['top_matches'] = $all_matches;
6007 $this->last_similarity_analysis['total_checked'] = count($all_matches);
6008
6009 // Store unique valid URLs for validation
6010 $this->current_valid_urls = array_unique($valid_urls);
6011
6012 //error_log("MXCHAT VECTORSTORE: ========== SEARCH COMPLETE ==========");
6013 //error_log("MXCHAT VECTORSTORE: Matches used: " . $matches_used);
6014 //error_log("MXCHAT VECTORSTORE: All matches count: " . count($all_matches));
6015 //error_log("MXCHAT VECTORSTORE: Content length: " . strlen($content));
6016 if ($matches_used > 0) {
6017 //error_log("MXCHAT VECTORSTORE: Content preview: " . substr($content, 0, 500));
6018 }
6019
6020 // Check if citation links are enabled
6021 $citation_links_enabled = ($mxchat_options['citation_links_toggle'] ?? 'on') === 'on';
6022
6023 // Add response guidelines
6024 if ($matches_used === 0) {
6025 //error_log("MXCHAT VECTORSTORE: No matches found - returning empty reference message");
6026 $content = "No reference information was found for this query.\n\n";
6027 } else {
6028 // Build response guidelines based on citation links setting
6029 $content .= "\n## Response Guidelines ##\n" .
6030 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
6031 "Be conversational and friendly, but never mention your knowledge base or training data. " .
6032 "If you don't have specific information or are uncertain about any details, it's always " .
6033 "better to honestly say you don't know rather than making up or guessing at answers. " .
6034 "When information is incomplete, let them know you are unsure.\n\n";
6035
6036 // Only add hyperlink instructions if citation links are enabled
6037 if ($citation_links_enabled) {
6038 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
6039 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
6040 } else {
6041 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
6042 "Simply provide helpful answers based on the reference information without citing sources.";
6043 }
6044 }
6045
6046 //error_log("MXCHAT DEBUG: Vector Store search complete. Matches used: " . $matches_used);
6047
6048 return trim($content);
6049 }
6050
6051 /**
6052 * Check if the given model is an OpenAI chat model
6053 *
6054 * @param string $model The model ID
6055 * @return bool True if it's an OpenAI model
6056 */
6057 private function is_openai_chat_model($model) {
6058 $openai_prefixes = array('gpt-', 'o1-', 'o3-');
6059 foreach ($openai_prefixes as $prefix) {
6060 if (strpos($model, $prefix) === 0) {
6061 return true;
6062 }
6063 }
6064 return false;
6065 }
6066
6067 /**
6068 * Get bot-specific Vector Store configuration
6069 *
6070 * @param string $bot_id The bot ID
6071 * @return array Configuration array
6072 */
6073 private function get_bot_vectorstore_config($bot_id = 'default') {
6074 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
6075
6076 // Default global settings
6077 $default_config = array(
6078 'use_vectorstore' => ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1',
6079 'vectorstore_ids' => $vectorstore_options['mxchat_vectorstore_ids'] ?? '',
6080 'max_results' => $vectorstore_options['mxchat_vectorstore_max_results'] ?? 5
6081 );
6082
6083 // Allow multi-bot plugin to override with bot-specific settings
6084 $bot_config = apply_filters('mxchat_get_bot_vectorstore_config', $default_config, $bot_id);
6085
6086 // Preserve max_results from global settings if not set in bot config
6087 if (!isset($bot_config['max_results'])) {
6088 $bot_config['max_results'] = $default_config['max_results'];
6089 }
6090
6091 return $bot_config;
6092 }
6093
6094 private function mxchat_find_relevant_products($user_embedding) {
6095 //error_log('MXChat Vector Search: Starting product search...');
6096
6097 // Retrieve the add-on settings from the database
6098 $addon_options = get_option('mxchat_pinecone_addon_options', array());
6099
6100 // Determine whether Pinecone is enabled
6101 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1') ? 1 : 0;
6102
6103 //error_log('Pinecone enabled flag: ' . $use_pinecone);
6104
6105 if ($use_pinecone === 1) {
6106 //error_log('MXChat Vector Search: Using Pinecone database for products');
6107 return $this->find_relevant_products_pinecone($user_embedding);
6108 } else {
6109 //error_log('MXChat Vector Search: Using WordPress database for products');
6110 return $this->find_relevant_products_wordpress($user_embedding);
6111 }
6112 }
6113 private function find_relevant_products_wordpress($user_embedding) {
6114 global $wpdb;
6115 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6116 $cache_key = 'mxchat_system_prompt_embeddings';
6117 $batch_size = 500;
6118
6119 // Original WordPress database search logic
6120 // [Previous implementation remains the same]
6121 $embeddings = wp_cache_get($cache_key, 'mxchat_system_prompts');
6122 if ($embeddings === false) {
6123 $embeddings = [];
6124 $offset = 0;
6125
6126 do {
6127 $query = $wpdb->prepare(
6128 "SELECT id, embedding_vector
6129 FROM {$system_prompt_table}
6130 LIMIT %d OFFSET %d",
6131 $batch_size,
6132 $offset
6133 );
6134
6135 $batch = $wpdb->get_results($query);
6136 if (empty($batch)) {
6137 break;
6138 }
6139
6140 $embeddings = array_merge($embeddings, $batch);
6141 $offset += $batch_size;
6142
6143 unset($batch);
6144
6145 } while (true);
6146
6147 if (empty($embeddings)) {
6148 return '';
6149 }
6150 wp_cache_set($cache_key, $embeddings, 'mxchat_system_prompts', 3600);
6151 }
6152
6153 $relevant_results = [];
6154 foreach ($embeddings as $embedding) {
6155 $database_embedding = $embedding->embedding_vector
6156 ? unserialize($embedding->embedding_vector, ['allowed_classes' => false])
6157 : null;
6158 if (is_array($database_embedding) && is_array($user_embedding)) {
6159 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
6160 $relevant_results[] = [
6161 'id' => $embedding->id,
6162 'similarity' => $similarity
6163 ];
6164 }
6165 unset($database_embedding);
6166 }
6167
6168 // Use fixed threshold for products
6169 $similarity_threshold = 0.85;
6170
6171 $relevant_results = array_filter($relevant_results, function ($result) use ($similarity_threshold) {
6172 return $result['similarity'] >= $similarity_threshold;
6173 });
6174 usort($relevant_results, function ($a, $b) {
6175 return $b['similarity'] <=> $a['similarity'];
6176 });
6177
6178 $top_results = array_slice($relevant_results, 0, 3);
6179 $content = '';
6180
6181 foreach ($top_results as $result) {
6182 $chunk_content = $this->fetch_content_with_product_links($result['id']);
6183 $content .= $chunk_content . "\n\n";
6184 }
6185
6186 return trim($content);
6187 }
6188
6189
6190 private function find_relevant_products_pinecone($user_embedding) {
6191 //error_log('Starting Pinecone product search...');
6192
6193 $options = get_option('mxchat_pinecone_addon_options', array());
6194 $api_key = $options['mxchat_pinecone_api_key'] ?? '';
6195 $host = $options['mxchat_pinecone_host'] ?? '';
6196
6197 if (empty($host) || empty($api_key)) {
6198 //error_log('Pinecone credentials not properly configured for product search');
6199 return '';
6200 }
6201
6202 $similarity_threshold = 0.85;
6203 $api_endpoint = "https://{$host}/query";
6204
6205 $request_body = array(
6206 'vector' => $user_embedding,
6207 'topK' => 5,
6208 'includeMetadata' => true,
6209 'includeValues' => true,
6210 'filter' => array(
6211 'type' => 'product'
6212 )
6213 );
6214
6215 //error_log('Sending request to Pinecone with body: ' . wp_json_encode($request_body));
6216
6217 $response = wp_remote_post($api_endpoint, array(
6218 'headers' => array(
6219 'Api-Key' => $api_key,
6220 'accept' => 'application/json',
6221 'content-type' => 'application/json'
6222 ),
6223 'body' => wp_json_encode($request_body),
6224 'timeout' => 30
6225 ));
6226
6227 if (is_wp_error($response)) {
6228 //error_log('Pinecone product query error: ' . $response->get_error_message());
6229 return '';
6230 }
6231
6232 $response_code = wp_remote_retrieve_response_code($response);
6233 //error_log('Pinecone response code: ' . $response_code);
6234
6235 if ($response_code !== 200) {
6236 //error_log('Pinecone API error during product search: ' . wp_remote_retrieve_body($response));
6237 return '';
6238 }
6239
6240 $results = json_decode(wp_remote_retrieve_body($response), true);
6241 //error_log('Pinecone raw response: ' . wp_remote_retrieve_body($response));
6242
6243 if (empty($results['matches'])) {
6244 //error_log('No matches found in Pinecone response');
6245 return '';
6246 }
6247
6248 $content = '';
6249 foreach ($results['matches'] as $match) {
6250 if ($match['score'] < $similarity_threshold) {
6251 //error_log("Match below threshold: " . $match['score']);
6252 continue;
6253 }
6254
6255 if (!empty($match['metadata']['text'])) {
6256 $content .= $match['metadata']['text'];
6257 if (!empty($match['metadata']['source_url'])) {
6258 $content .= "\n\nFor more details, check out this product: " . esc_url($match['metadata']['source_url']);
6259 }
6260 $content .= "\n\n";
6261 }
6262 }
6263
6264 return trim($content);
6265 }
6266
6267
6268 private function fetch_content_with_product_links($most_relevant_id) {
6269 global $wpdb;
6270 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6271
6272 // Fetch the article content and associated product URL
6273 $query = $wpdb->prepare("SELECT article_content, source_url FROM {$system_prompt_table} WHERE id = %d", $most_relevant_id);
6274 $result = $wpdb->get_row($query);
6275
6276 if ($result) {
6277 // Append the product link to the content if available
6278 $content = $result->article_content;
6279 if (!empty($result->source_url)) {
6280 $content .= "\n\nFor more details, check out this product: " . esc_url($result->source_url);
6281 }
6282 return $content;
6283 }
6284
6285 return null;
6286 }
6287
6288 /**
6289 * Get system instructions for a specific bot or default
6290 * Checks for multi-bot add-on and uses bot-specific instructions if available
6291 * Automatically strips URLs if citation links are disabled
6292 * Replaces {visitor_name} placeholder with actual visitor name if available
6293 *
6294 * @param string $bot_id The bot ID to get instructions for
6295 * @param string $session_id Optional session ID to lookup visitor name
6296 */
6297 private function get_system_instructions($bot_id = 'default', $session_id = '') {
6298 $instructions = '';
6299
6300 // Check if multi-bot add-on is active
6301 if (class_exists('MxChat_Multi_Bot_Core_Manager') && $bot_id !== 'default') {
6302 // Get bot-specific options from multi-bot add-on
6303 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
6304
6305 // If bot has custom system instructions, use those
6306 if (!empty($bot_options['system_prompt_instructions'])) {
6307 $instructions = $bot_options['system_prompt_instructions'];
6308 }
6309 }
6310
6311 // Fall back to default system instructions
6312 if (empty($instructions)) {
6313 $instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
6314 }
6315
6316 // Check if citation links are disabled - if so, strip URLs from instructions
6317 $fresh_options = get_option('mxchat_options', []);
6318 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6319
6320 if (!$citation_links_enabled && !empty($instructions)) {
6321 $instructions = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $instructions);
6322 $instructions = preg_replace('/\s+/', ' ', trim($instructions)); // Clean up extra spaces
6323 }
6324
6325 // Replace {visitor_name} placeholder with actual visitor name if available
6326 if (!empty($instructions) && !empty($session_id) && stripos($instructions, '{visitor_name}') !== false) {
6327 $name_option_key = "mxchat_name_{$session_id}";
6328 $visitor_name = get_option($name_option_key, '');
6329
6330 if (!empty($visitor_name)) {
6331 $instructions = str_ireplace('{visitor_name}', sanitize_text_field($visitor_name), $instructions);
6332 } else {
6333 // Remove placeholder if no name is available
6334 $instructions = str_ireplace('{visitor_name}', '', $instructions);
6335 $instructions = preg_replace('/\s{2,}/', ' ', trim($instructions)); // Clean up extra spaces
6336 }
6337 }
6338
6339 // Allow developers to filter system instructions and process shortcodes
6340 $instructions = apply_filters('mxchat_system_instructions', $instructions, $bot_id, $session_id);
6341 $instructions = do_shortcode($instructions);
6342
6343 return $instructions;
6344 }
6345 /**
6346 * Get the current bot ID from session or request context
6347 */
6348 private function get_current_bot_id($session_id = '') {
6349 // First, check if bot_id is passed in the current request
6350 if (isset($_POST['bot_id']) && !empty($_POST['bot_id'])) {
6351 return sanitize_key($_POST['bot_id']);
6352 }
6353
6354 // If not in POST, try to get it from session data
6355 if (!empty($session_id)) {
6356 $bot_id = get_option("mxchat_session_bot_{$session_id}", '');
6357 if (!empty($bot_id)) {
6358 return $bot_id;
6359 }
6360 }
6361
6362 // Fall back to default
6363 return 'default';
6364 }
6365 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') {
6366 try {
6367 if (!$relevant_content) {
6368 $error_response = [
6369 'error' => esc_html__("I couldn't find relevant information on that topic.", 'mxchat'),
6370 'error_code' => 'no_relevant_content'
6371 ];
6372
6373 if ($testing_data !== null) {
6374 $error_response['testing_data'] = $testing_data;
6375 }
6376
6377 return $error_response;
6378 }
6379
6380 if (!is_array($conversation_history)) {
6381 $conversation_history = array();
6382 }
6383
6384 // Check if this is an OpenRouter model
6385 if ($selected_model === 'openrouter') {
6386 // Get the actual OpenRouter model from options
6387 $openrouter_selected_model = $this->options['openrouter_selected_model'] ?? '';
6388
6389 if (empty($openrouter_selected_model)) {
6390 $error_response = [
6391 'error' => esc_html__('No OpenRouter model selected. Please select a model in settings.', 'mxchat'),
6392 'error_code' => 'no_openrouter_model_selected'
6393 ];
6394 if ($testing_data !== null) {
6395 $error_response['testing_data'] = $testing_data;
6396 }
6397 return $error_response;
6398 }
6399
6400 if (empty($openrouter_api_key)) {
6401 $error_response = [
6402 'error' => esc_html__('OpenRouter API key is not configured', 'mxchat'),
6403 'error_code' => 'missing_openrouter_api_key'
6404 ];
6405 if ($testing_data !== null) {
6406 $error_response['testing_data'] = $testing_data;
6407 }
6408 return $error_response;
6409 }
6410
6411 if ($streaming) {
6412 return $this->mxchat_generate_response_openrouter_stream(
6413 $openrouter_selected_model,
6414 $openrouter_api_key,
6415 $conversation_history,
6416 $relevant_content,
6417 $session_id,
6418 $testing_data
6419 );
6420 } else {
6421 $response = $this->mxchat_generate_response_openrouter(
6422 $openrouter_selected_model,
6423 $openrouter_api_key,
6424 $conversation_history,
6425 $relevant_content
6426 );
6427 }
6428
6429 if (is_array($response) && isset($response['error'])) {
6430 if ($testing_data !== null) {
6431 $response['testing_data'] = $testing_data;
6432 }
6433 return $response;
6434 }
6435
6436 return $response;
6437 }
6438
6439 // Extract model prefix to determine the provider
6440 $model_parts = explode('-', $selected_model);
6441 $provider = strtolower($model_parts[0]);
6442
6443 // Handle model selection based on provider prefix
6444 switch ($provider) {
6445 case 'gemini':
6446 if (empty($gemini_api_key)) {
6447 $error_response = [
6448 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
6449 'error_code' => 'missing_gemini_api_key'
6450 ];
6451 if ($testing_data !== null) {
6452 $error_response['testing_data'] = $testing_data;
6453 }
6454 return $error_response;
6455 }
6456 $response = $this->mxchat_generate_response_gemini(
6457 $selected_model,
6458 $gemini_api_key,
6459 $conversation_history,
6460 $relevant_content
6461 );
6462 break;
6463
6464 case 'claude':
6465 if (empty($claude_api_key)) {
6466 $error_response = [
6467 'error' => esc_html__('Claude API key is not configured', 'mxchat'),
6468 'error_code' => 'missing_claude_api_key'
6469 ];
6470 if ($testing_data !== null) {
6471 $error_response['testing_data'] = $testing_data;
6472 }
6473 return $error_response;
6474 }
6475 if ($streaming) {
6476 return $this->mxchat_generate_response_claude_stream(
6477 $selected_model,
6478 $claude_api_key,
6479 $conversation_history,
6480 $relevant_content,
6481 $session_id,
6482 $testing_data
6483 );
6484 } else {
6485 $response = $this->mxchat_generate_response_claude(
6486 $selected_model,
6487 $claude_api_key,
6488 $conversation_history,
6489 $relevant_content
6490 );
6491 }
6492 break;
6493
6494 case 'grok':
6495 if (empty($xai_api_key)) {
6496 $error_response = [
6497 'error' => esc_html__('X.AI API key is not configured', 'mxchat'),
6498 'error_code' => 'missing_xai_api_key'
6499 ];
6500 if ($testing_data !== null) {
6501 $error_response['testing_data'] = $testing_data;
6502 }
6503 return $error_response;
6504 }
6505 if ($streaming) {
6506 return $this->mxchat_generate_response_xai_stream(
6507 $selected_model,
6508 $xai_api_key,
6509 $conversation_history,
6510 $relevant_content,
6511 $session_id,
6512 $testing_data
6513 );
6514 } else {
6515 $response = $this->mxchat_generate_response_xai(
6516 $selected_model,
6517 $xai_api_key,
6518 $conversation_history,
6519 $relevant_content
6520 );
6521 }
6522 break;
6523
6524 case 'deepseek':
6525 if (empty($deepseek_api_key)) {
6526 $error_response = [
6527 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
6528 'error_code' => 'missing_deepseek_api_key'
6529 ];
6530 if ($testing_data !== null) {
6531 $error_response['testing_data'] = $testing_data;
6532 }
6533 return $error_response;
6534 }
6535 if ($streaming) {
6536 return $this->mxchat_generate_response_deepseek_stream(
6537 $selected_model,
6538 $deepseek_api_key,
6539 $conversation_history,
6540 $relevant_content,
6541 $session_id,
6542 $testing_data
6543 );
6544 } else {
6545 $response = $this->mxchat_generate_response_deepseek(
6546 $selected_model,
6547 $deepseek_api_key,
6548 $conversation_history,
6549 $relevant_content
6550 );
6551 }
6552 break;
6553
6554 case 'gpt':
6555 case 'o1':
6556 if (empty($api_key)) {
6557 $error_response = [
6558 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
6559 'error_code' => 'missing_openai_api_key'
6560 ];
6561 if ($testing_data !== null) {
6562 $error_response['testing_data'] = $testing_data;
6563 }
6564 return $error_response;
6565 }
6566
6567 // Check if web search is enabled for this OpenAI model
6568 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
6569 // Models that don't support web search
6570 $unsupported_web_search_models = array('gpt-4.1-nano');
6571 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
6572
6573 if ($web_search_enabled && $model_supports_web_search) {
6574 // Use Responses API (required for some models, or when web search is enabled)
6575 return $this->mxchat_generate_response_openai_web_search(
6576 $selected_model,
6577 $api_key,
6578 $conversation_history,
6579 $relevant_content,
6580 $session_id,
6581 $testing_data,
6582 $streaming
6583 );
6584 } elseif ($streaming) {
6585 return $this->mxchat_generate_response_openai_stream(
6586 $selected_model,
6587 $api_key,
6588 $conversation_history,
6589 $relevant_content,
6590 $session_id,
6591 $testing_data
6592 );
6593 } else {
6594 $response = $this->mxchat_generate_response_openai(
6595 $selected_model,
6596 $api_key,
6597 $conversation_history,
6598 $relevant_content
6599 );
6600 }
6601 break;
6602
6603 default:
6604 if (empty($api_key)) {
6605 $error_response = [
6606 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
6607 'error_code' => 'missing_openai_api_key'
6608 ];
6609 if ($testing_data !== null) {
6610 $error_response['testing_data'] = $testing_data;
6611 }
6612 return $error_response;
6613 }
6614
6615 // Check if web search is enabled (default case also handles OpenAI models)
6616 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
6617 $unsupported_web_search_models = array('gpt-4.1-nano');
6618 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
6619
6620 if ($web_search_enabled && $model_supports_web_search) {
6621 return $this->mxchat_generate_response_openai_web_search(
6622 $selected_model,
6623 $api_key,
6624 $conversation_history,
6625 $relevant_content,
6626 $session_id,
6627 $testing_data,
6628 $streaming
6629 );
6630 } elseif ($streaming) {
6631 return $this->mxchat_generate_response_openai_stream(
6632 $selected_model,
6633 $api_key,
6634 $conversation_history,
6635 $relevant_content,
6636 $session_id,
6637 $testing_data
6638 );
6639 } else {
6640 $response = $this->mxchat_generate_response_openai(
6641 $selected_model,
6642 $api_key,
6643 $conversation_history,
6644 $relevant_content
6645 );
6646 }
6647 break;
6648 }
6649
6650 if (is_array($response) && isset($response['error'])) {
6651 if ($testing_data !== null) {
6652 $response['testing_data'] = $testing_data;
6653 }
6654 return $response;
6655 }
6656
6657 return $response;
6658
6659 } catch (Exception $e) {
6660 $error_response = [
6661 'error' => sprintf(esc_html__('An error occurred: %s', 'mxchat'), esc_html($e->getMessage())),
6662 'error_code' => 'system_exception',
6663 'exception_details' => $e->getMessage()
6664 ];
6665
6666 if ($testing_data !== null) {
6667 $error_response['testing_data'] = $testing_data;
6668 }
6669
6670 return $error_response;
6671 }
6672 }
6673 private function mxchat_generate_response_openrouter_stream($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
6674 try {
6675 $bot_id = $this->get_current_bot_id($session_id);
6676 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
6677
6678 if (!is_array($conversation_history)) {
6679 $conversation_history = array();
6680 }
6681
6682 $formatted_conversation = array();
6683
6684 $formatted_conversation[] = array(
6685 'role' => 'system',
6686 'content' => $system_prompt_instructions . " " . $relevant_content
6687 );
6688
6689 foreach ($conversation_history as $message) {
6690 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6691 $role = $message['role'];
6692 if ($role === 'bot' || $role === 'agent') {
6693 $role = 'assistant';
6694 }
6695 if (!in_array($role, ['system', 'assistant', 'user'])) {
6696 $role = 'user';
6697 }
6698 $formatted_conversation[] = array(
6699 'role' => $role,
6700 'content' => $message['content']
6701 );
6702 }
6703 }
6704
6705 if (headers_sent() || !function_exists('curl_init')) {
6706 $regular_response = $this->mxchat_generate_response_openrouter(
6707 $selected_model,
6708 $openrouter_api_key,
6709 $conversation_history,
6710 $relevant_content
6711 );
6712
6713 // Save bot response to transcript
6714 if (!empty($regular_response) && !empty($session_id)) {
6715 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
6716 }
6717
6718 $response_data = [
6719 'text' => $regular_response,
6720 'html' => '',
6721 'session_id' => $session_id
6722 ];
6723
6724 if ($testing_data !== null) {
6725 $response_data['testing_data'] = $testing_data;
6726 }
6727
6728 header('Content-Type: application/json');
6729 echo json_encode($response_data);
6730 return true;
6731 }
6732
6733 $body = json_encode([
6734 'model' => $selected_model,
6735 'messages' => $formatted_conversation,
6736 'temperature' => 1,
6737 'stream' => true
6738 ]);
6739
6740 // Setup streaming headers now that we know we're actually streaming
6741 $this->setup_streaming_headers();
6742
6743 $ch = curl_init();
6744 curl_setopt($ch, CURLOPT_URL, 'https://openrouter.ai/api/v1/chat/completions');
6745 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
6746 curl_setopt($ch, CURLOPT_POST, true);
6747 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
6748 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
6749 'Content-Type: application/json',
6750 'Authorization: Bearer ' . $openrouter_api_key,
6751 'HTTP-Referer: ' . home_url(),
6752 'X-Title: ' . get_bloginfo('name')
6753 ));
6754 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
6755 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
6756
6757 $full_response = '';
6758 $stream_started = false;
6759 $buffer = '';
6760
6761 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
6762 if (!$stream_started && $testing_data !== null) {
6763 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
6764 flush();
6765 $stream_started = true;
6766 }
6767
6768 $buffer .= $data;
6769 $lines = explode("\n", $buffer);
6770 $buffer = array_pop($lines);
6771
6772 foreach ($lines as $line) {
6773 if (trim($line) === '') {
6774 continue;
6775 }
6776
6777 if (strpos($line, 'data: ') !== 0) {
6778 continue;
6779 }
6780
6781 $json_str = substr($line, 6);
6782
6783 if (trim($json_str) === '[DONE]') {
6784 echo "data: [DONE]\n\n";
6785 flush();
6786 continue;
6787 }
6788
6789 $json = json_decode(trim($json_str), true);
6790 if ($json && isset($json['choices'][0]['delta']['content'])) {
6791 $content = $json['choices'][0]['delta']['content'];
6792 $full_response .= $content;
6793
6794 echo "data: " . json_encode(['content' => $content]) . "\n\n";
6795 flush();
6796 }
6797 }
6798
6799 return strlen($data);
6800 });
6801
6802 $response = curl_exec($ch);
6803 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
6804
6805 if (curl_errno($ch) || $http_code !== 200) {
6806 curl_close($ch);
6807
6808 $regular_response = $this->mxchat_generate_response_openrouter(
6809 $selected_model,
6810 $openrouter_api_key,
6811 $conversation_history,
6812 $relevant_content
6813 );
6814
6815 $response_data = [
6816 'text' => $regular_response,
6817 'html' => '',
6818 'session_id' => $session_id
6819 ];
6820
6821 if ($testing_data !== null) {
6822 $response_data['testing_data'] = $testing_data;
6823 }
6824
6825 header('Content-Type: application/json');
6826 echo json_encode($response_data);
6827 return true;
6828 }
6829
6830 curl_close($ch);
6831
6832 if (!empty($full_response) && !empty($session_id)) {
6833 // Prepare RAG context for streaming response
6834 $rag_context_for_storage = null;
6835 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
6836 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
6837
6838 if ($has_rag_data || $has_action_data) {
6839 $rag_context_for_storage = [];
6840
6841 if ($has_rag_data) {
6842 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
6843 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
6844 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
6845 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
6846 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
6847 }
6848
6849 if ($has_action_data) {
6850 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
6851 }
6852 }
6853 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
6854 }
6855
6856 return true;
6857
6858 } catch (Exception $e) {
6859 $regular_response = $this->mxchat_generate_response_openrouter(
6860 $selected_model,
6861 $openrouter_api_key,
6862 $conversation_history,
6863 $relevant_content
6864 );
6865
6866 $response_data = [
6867 'text' => $regular_response,
6868 'html' => '',
6869 'session_id' => $session_id
6870 ];
6871
6872 if ($testing_data !== null) {
6873 $response_data['testing_data'] = $testing_data;
6874 }
6875
6876 header('Content-Type: application/json');
6877 echo json_encode($response_data);
6878 return true;
6879 }
6880 }
6881 private function mxchat_generate_response_openai_stream($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
6882 try {
6883 $bot_id = $this->get_current_bot_id($session_id);
6884
6885 // Get system prompt instructions using centralized function
6886 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
6887
6888 // Ensure conversation_history is an array
6889 if (!is_array($conversation_history)) {
6890 $conversation_history = array();
6891 }
6892
6893 // Format conversation history for OpenAI
6894 $formatted_conversation = array();
6895
6896 $formatted_conversation[] = array(
6897 'role' => 'system',
6898 'content' => $system_prompt_instructions . " " . $relevant_content
6899 );
6900
6901 foreach ($conversation_history as $message) {
6902 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6903 $role = $message['role'];
6904 if ($role === 'bot' || $role === 'agent') {
6905 $role = 'assistant';
6906 }
6907 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
6908 $role = 'user';
6909 }
6910 $formatted_conversation[] = array(
6911 'role' => $role,
6912 'content' => $message['content']
6913 );
6914 }
6915 }
6916
6917 // Check if we can actually stream
6918 if (headers_sent() || !function_exists('curl_init')) {
6919 // Fallback to regular response with testing data
6920 $regular_response = $this->mxchat_generate_response_openai(
6921 $selected_model,
6922 $api_key,
6923 $conversation_history,
6924 $relevant_content
6925 );
6926
6927 // Save bot response to transcript
6928 if (!empty($regular_response) && !empty($session_id)) {
6929 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
6930 }
6931
6932 $response_data = [
6933 'text' => $regular_response,
6934 'html' => '',
6935 'session_id' => $session_id
6936 ];
6937
6938 if ($testing_data !== null) {
6939 $response_data['testing_data'] = $testing_data;
6940 }
6941
6942 header('Content-Type: application/json');
6943 echo json_encode($response_data);
6944 return true;
6945 }
6946
6947 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
6948 $is_gpt5_model = (
6949 strpos($selected_model, 'gpt-5') === 0 ||
6950 $selected_model === 'gpt-5.2' ||
6951 $selected_model === 'gpt-5.1-2025-11-13' ||
6952 $selected_model === 'gpt-5' ||
6953 $selected_model === 'gpt-5-mini' ||
6954 $selected_model === 'gpt-5-nano'
6955 );
6956
6957 // Build request body with optimal settings for fast streaming
6958 $request_body = [
6959 'model' => $selected_model,
6960 'messages' => $formatted_conversation,
6961 'temperature' => 1,
6962 'stream' => true
6963 ];
6964
6965 // Add reasoning_effort only for GPT-5 models that support it
6966 // These chat models don't support reasoning_effort parameter
6967 $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');
6968 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_models, true)) {
6969 // GPT-5.1 uses 'low' instead of 'minimal'
6970 if ($selected_model === 'gpt-5.1-2025-11-13') {
6971 $request_body['reasoning_effort'] = 'low';
6972 } elseif ($selected_model === 'gpt-5.4') {
6973 $request_body['reasoning_effort'] = 'none';
6974 } else {
6975 $request_body['reasoning_effort'] = 'minimal';
6976 }
6977 }
6978
6979 $body = json_encode($request_body);
6980
6981 // Setup streaming headers now that we know we're actually streaming
6982 $this->setup_streaming_headers();
6983
6984 // Use cURL for streaming support
6985 $ch = curl_init();
6986 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
6987 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
6988 curl_setopt($ch, CURLOPT_POST, true);
6989 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
6990 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
6991 'Content-Type: application/json',
6992 'Authorization: Bearer ' . $api_key
6993 ));
6994 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
6995 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
6996
6997 $full_response = ''; // Accumulate full response for saving
6998 $stream_started = false;
6999 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7000
7001 // Buffer control for real-time streaming
7002 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7003 // Send testing data as the first event if available
7004 if (!$stream_started && $testing_data !== null) {
7005 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7006 flush();
7007 $stream_started = true;
7008 }
7009
7010 // CRITICAL FIX: Append new data to buffer
7011 $buffer .= $data;
7012
7013 // Process complete lines only
7014 $lines = explode("\n", $buffer);
7015
7016 // CRITICAL FIX: Keep the last incomplete line in the buffer
7017 // The last element might be incomplete, so keep it in buffer
7018 $buffer = array_pop($lines);
7019
7020 foreach ($lines as $line) {
7021 // Skip empty lines
7022 if (trim($line) === '') {
7023 continue;
7024 }
7025
7026 // Only process lines that start with "data: "
7027 if (strpos($line, 'data: ') !== 0) {
7028 continue;
7029 }
7030
7031 $json_str = substr($line, 6); // Remove 'data: ' prefix
7032
7033 if (trim($json_str) === '[DONE]') {
7034 echo "data: [DONE]\n\n";
7035 flush();
7036 continue;
7037 }
7038
7039 // Try to decode JSON
7040 $json = json_decode(trim($json_str), true);
7041 if ($json && isset($json['choices'][0]['delta']['content'])) {
7042 $content = $json['choices'][0]['delta']['content'];
7043 $full_response .= $content; // Accumulate the full response
7044
7045 // Send as SSE format
7046 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7047 flush();
7048 }
7049 }
7050
7051 return strlen($data);
7052 });
7053
7054 $response = curl_exec($ch);
7055 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7056
7057 if (curl_errno($ch) || $http_code !== 200) {
7058 $curl_error = curl_error($ch);
7059 curl_close($ch);
7060
7061 // Fallback to regular response
7062 $regular_response = $this->mxchat_generate_response_openai(
7063 $selected_model,
7064 $api_key,
7065 $conversation_history,
7066 $relevant_content
7067 );
7068
7069 // FIXED: Check if regular response returned an error
7070 if (is_array($regular_response) && isset($regular_response['error'])) {
7071 // Send error in SSE format since we're in streaming mode
7072 echo "data: " . json_encode([
7073 'error' => true,
7074 'error_message' => $regular_response['error'],
7075 'error_code' => $regular_response['error_code'] ?? 'api_error',
7076 'text' => $regular_response['error'],
7077 'message' => $regular_response['error']
7078 ]) . "\n\n";
7079 echo "data: [DONE]\n\n";
7080 flush();
7081 return true;
7082 }
7083
7084 $response_data = [
7085 'text' => $regular_response,
7086 'html' => '',
7087 'session_id' => $session_id
7088 ];
7089
7090 if ($testing_data !== null) {
7091 $response_data['testing_data'] = $testing_data;
7092 }
7093
7094 header('Content-Type: application/json');
7095 echo json_encode($response_data);
7096 return true;
7097 }
7098
7099 curl_close($ch);
7100
7101 // Save the complete response to maintain chat persistence
7102 if (!empty($full_response) && !empty($session_id)) {
7103 // Prepare RAG context for streaming response
7104 $rag_context_for_storage = null;
7105 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7106 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7107
7108 if ($has_rag_data || $has_action_data) {
7109 $rag_context_for_storage = [];
7110
7111 if ($has_rag_data) {
7112 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7113 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7114 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7115 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7116 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7117 }
7118
7119 if ($has_action_data) {
7120 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7121 }
7122 }
7123 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7124 }
7125
7126 return true; // Indicate streaming completed successfully
7127
7128 } catch (Exception $e) {
7129 // Fallback to regular response
7130 $regular_response = $this->mxchat_generate_response_openai(
7131 $selected_model,
7132 $api_key,
7133 $conversation_history,
7134 $relevant_content
7135 );
7136
7137 // FIXED: Check if regular response returned an error
7138 if (is_array($regular_response) && isset($regular_response['error'])) {
7139 // Send error in SSE format since we're in streaming mode
7140 echo "data: " . json_encode([
7141 'error' => true,
7142 'error_message' => $regular_response['error'],
7143 'error_code' => $regular_response['error_code'] ?? 'api_error',
7144 'text' => $regular_response['error'],
7145 'message' => $regular_response['error']
7146 ]) . "\n\n";
7147 echo "data: [DONE]\n\n";
7148 flush();
7149 return true;
7150 }
7151
7152 $response_data = [
7153 'text' => $regular_response,
7154 'html' => '',
7155 'session_id' => $session_id
7156 ];
7157
7158 if ($testing_data !== null) {
7159 $response_data['testing_data'] = $testing_data;
7160 }
7161
7162 header('Content-Type: application/json');
7163 echo json_encode($response_data);
7164 return true;
7165 }
7166 }
7167
7168 /**
7169 * Generate response using OpenAI Responses API with web search tool
7170 * This uses the newer Responses API which supports web search functionality
7171 */
7172 private function mxchat_generate_response_openai_web_search($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null, $streaming = false) {
7173 try {
7174 $bot_id = $this->get_current_bot_id($session_id);
7175 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7176
7177 if (!is_array($conversation_history)) {
7178 $conversation_history = array();
7179 }
7180
7181 // Build the input for Responses API
7182 // The Responses API uses a different format - we need to construct the input properly
7183 $input_parts = [];
7184
7185 // Add system instructions as context
7186 $system_context = $system_prompt_instructions . "\n\n" . $relevant_content;
7187
7188 // Build conversation as input items for Responses API
7189 foreach ($conversation_history as $message) {
7190 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7191 $role = $message['role'];
7192 if ($role === 'bot' || $role === 'agent') {
7193 $role = 'assistant';
7194 }
7195 if (!in_array($role, ['assistant', 'user'])) {
7196 $role = 'user';
7197 }
7198 $input_parts[] = [
7199 'type' => 'message',
7200 'role' => $role,
7201 'content' => $message['content']
7202 ];
7203 }
7204 }
7205
7206 // Build request body for Responses API
7207 $request_body = [
7208 'model' => $selected_model,
7209 'input' => $input_parts,
7210 'instructions' => $system_context,
7211 'stream' => $streaming
7212 ];
7213
7214 // Only add web search tool if web search is enabled in settings
7215 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
7216 if ($web_search_enabled) {
7217 $request_body['tools'] = [
7218 ['type' => 'web_search']
7219 ];
7220 }
7221
7222 // Add reasoning effort for supported models
7223 $is_gpt5_model = strpos($selected_model, 'gpt-5') === 0;
7224 $no_reasoning_web = array('gpt-5.2', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
7225 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_web, true)) {
7226 if ($selected_model === 'gpt-5.1-2025-11-13') {
7227 $request_body['reasoning'] = ['effort' => 'low'];
7228 } elseif ($selected_model === 'gpt-5.4') {
7229 $request_body['reasoning'] = ['effort' => 'low'];
7230 }
7231 }
7232
7233 //error_log("MXCHAT WEB SEARCH: Request body: " . json_encode($request_body));
7234
7235 if ($streaming) {
7236 return $this->mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data);
7237 } else {
7238 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7239 }
7240
7241 } catch (Exception $e) {
7242 //error_log("MXCHAT WEB SEARCH ERROR: " . $e->getMessage());
7243 return [
7244 'error' => sprintf(esc_html__('Web search error: %s', 'mxchat'), esc_html($e->getMessage())),
7245 'error_code' => 'web_search_exception'
7246 ];
7247 }
7248 }
7249
7250 /**
7251 * Handle non-streaming web search response
7252 */
7253 private function mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data) {
7254 $request_body['stream'] = false;
7255
7256 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
7257 'headers' => array(
7258 'Authorization' => 'Bearer ' . $api_key,
7259 'Content-Type' => 'application/json'
7260 ),
7261 'body' => json_encode($request_body),
7262 'timeout' => 90
7263 ));
7264
7265 if (is_wp_error($response)) {
7266 //error_log("MXCHAT WEB SEARCH ERROR: WP Error: " . $response->get_error_message());
7267 return [
7268 'error' => esc_html__('Failed to connect to OpenAI web search API', 'mxchat'),
7269 'error_code' => 'web_search_connection_error'
7270 ];
7271 }
7272
7273 $response_code = wp_remote_retrieve_response_code($response);
7274 $response_body = wp_remote_retrieve_body($response);
7275
7276 //error_log("MXCHAT WEB SEARCH: Response code: " . $response_code);
7277 //error_log("MXCHAT WEB SEARCH: Response body (first 2000): " . substr($response_body, 0, 2000));
7278
7279 if ($response_code !== 200) {
7280 $error_data = json_decode($response_body, true);
7281 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
7282 return [
7283 'error' => sprintf(esc_html__('OpenAI API error: %s', 'mxchat'), esc_html($error_message)),
7284 'error_code' => 'web_search_api_error'
7285 ];
7286 }
7287
7288 $result = json_decode($response_body, true);
7289
7290 if (json_last_error() !== JSON_ERROR_NONE) {
7291 return [
7292 'error' => esc_html__('Invalid response from OpenAI', 'mxchat'),
7293 'error_code' => 'web_search_json_error'
7294 ];
7295 }
7296
7297 // Extract the response text and citations from Responses API format
7298 $output_text = '';
7299 $citations = [];
7300
7301 if (isset($result['output'])) {
7302 foreach ($result['output'] as $output_item) {
7303 if ($output_item['type'] === 'message' && isset($output_item['content'])) {
7304 foreach ($output_item['content'] as $content_item) {
7305 if ($content_item['type'] === 'output_text') {
7306 $output_text .= $content_item['text'];
7307
7308 // Extract citations/annotations
7309 if (isset($content_item['annotations'])) {
7310 foreach ($content_item['annotations'] as $annotation) {
7311 if ($annotation['type'] === 'url_citation') {
7312 $citations[] = [
7313 'url' => $annotation['url'],
7314 'title' => $annotation['title'] ?? ''
7315 ];
7316 }
7317 }
7318 }
7319 }
7320 }
7321 }
7322 }
7323 }
7324
7325 // If we have citations, append them to the response
7326 if (!empty($citations)) {
7327 $output_text .= "\n\n**Sources:**\n";
7328 $seen_urls = [];
7329 foreach ($citations as $citation) {
7330 if (!in_array($citation['url'], $seen_urls)) {
7331 $seen_urls[] = $citation['url'];
7332 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
7333 $output_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
7334 }
7335 }
7336 }
7337
7338 // Save to transcript
7339 if (!empty($output_text) && !empty($session_id)) {
7340 $this->mxchat_save_chat_message($session_id, 'bot', $output_text);
7341 }
7342
7343 return $output_text;
7344 }
7345
7346 /**
7347 * Handle streaming web search response using Responses API
7348 */
7349 private function mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data) {
7350 $request_body['stream'] = true;
7351
7352 // Check if we can stream
7353 if (headers_sent() || !function_exists('curl_init')) {
7354 // Fallback to non-streaming
7355 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7356 }
7357
7358 // Setup streaming headers
7359 $this->setup_streaming_headers();
7360
7361 $ch = curl_init();
7362 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/responses');
7363 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7364 curl_setopt($ch, CURLOPT_POST, true);
7365 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request_body));
7366 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7367 'Content-Type: application/json',
7368 'Authorization: Bearer ' . $api_key
7369 ));
7370 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7371 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
7372
7373 $full_response = '';
7374 $stream_started = false;
7375 $buffer = '';
7376 $citations = [];
7377
7378 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$citations, $testing_data) {
7379 // Send testing data as first event if available
7380 if (!$stream_started && $testing_data !== null) {
7381 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7382 flush();
7383 $stream_started = true;
7384 }
7385
7386 $buffer .= $data;
7387 $lines = explode("\n", $buffer);
7388 $buffer = array_pop($lines);
7389
7390 foreach ($lines as $line) {
7391 if (trim($line) === '') continue;
7392 if (strpos($line, 'data: ') !== 0) continue;
7393
7394 $json_str = substr($line, 6);
7395
7396 if (trim($json_str) === '[DONE]') {
7397 // Append citations if we have any
7398 if (!empty($citations)) {
7399 $citation_text = "\n\n**Sources:**\n";
7400 $seen_urls = [];
7401 foreach ($citations as $citation) {
7402 if (!in_array($citation['url'], $seen_urls)) {
7403 $seen_urls[] = $citation['url'];
7404 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
7405 $citation_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
7406 }
7407 }
7408 echo "data: " . json_encode(['content' => $citation_text]) . "\n\n";
7409 $full_response .= $citation_text;
7410 flush();
7411 }
7412 echo "data: [DONE]\n\n";
7413 flush();
7414 continue;
7415 }
7416
7417 $json = json_decode(trim($json_str), true);
7418 if (!$json) continue;
7419
7420 // Handle Responses API streaming events
7421 // The format is different from Chat Completions
7422 if (isset($json['type'])) {
7423 switch ($json['type']) {
7424 case 'response.output_text.delta':
7425 // Text content delta
7426 if (isset($json['delta'])) {
7427 $content = $json['delta'];
7428 $full_response .= $content;
7429 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7430 flush();
7431 }
7432 break;
7433
7434 case 'response.output_item.done':
7435 // Check for citations in completed items
7436 if (isset($json['item']['content'])) {
7437 foreach ($json['item']['content'] as $content_item) {
7438 if (isset($content_item['annotations'])) {
7439 foreach ($content_item['annotations'] as $annotation) {
7440 if ($annotation['type'] === 'url_citation') {
7441 $citations[] = [
7442 'url' => $annotation['url'],
7443 'title' => $annotation['title'] ?? ''
7444 ];
7445 }
7446 }
7447 }
7448 }
7449 }
7450 break;
7451 }
7452 }
7453 }
7454
7455 return strlen($data);
7456 });
7457
7458 $response = curl_exec($ch);
7459 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7460
7461 if (curl_errno($ch) || $http_code !== 200) {
7462 $curl_error = curl_error($ch);
7463 curl_close($ch);
7464
7465 //error_log("MXCHAT WEB SEARCH STREAM ERROR: HTTP $http_code, cURL error: $curl_error");
7466
7467 // Fallback to non-streaming
7468 $fallback_response = $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7469
7470 if (is_array($fallback_response) && isset($fallback_response['error'])) {
7471 echo "data: " . json_encode([
7472 'error' => true,
7473 'error_message' => $fallback_response['error'],
7474 'error_code' => $fallback_response['error_code'] ?? 'web_search_error'
7475 ]) . "\n\n";
7476 echo "data: [DONE]\n\n";
7477 flush();
7478 return true;
7479 }
7480
7481 $response_data = [
7482 'text' => $fallback_response,
7483 'html' => '',
7484 'session_id' => $session_id
7485 ];
7486 if ($testing_data !== null) {
7487 $response_data['testing_data'] = $testing_data;
7488 }
7489 header('Content-Type: application/json');
7490 echo json_encode($response_data);
7491 return true;
7492 }
7493
7494 curl_close($ch);
7495
7496 // Save the complete response
7497 if (!empty($full_response) && !empty($session_id)) {
7498 $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
7499 }
7500
7501 return true;
7502 }
7503
7504 private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7505 try {
7506 // Get bot ID from session or request
7507 $bot_id = $this->get_current_bot_id($session_id);
7508
7509 // Get system prompt instructions using centralized function
7510 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7511 // Ensure conversation_history is an array
7512 if (!is_array($conversation_history)) {
7513 $conversation_history = array();
7514 }
7515
7516 // Clean and validate conversation history
7517 foreach ($conversation_history as &$message) {
7518 // Convert bot and agent roles to assistant
7519 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
7520 $message['role'] = 'assistant';
7521 }
7522
7523 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
7524 if (!in_array($message['role'], ['assistant', 'user'])) {
7525 $message['role'] = 'user';
7526 }
7527
7528 // Ensure content field exists
7529 if (!isset($message['content']) || empty($message['content'])) {
7530 $message['content'] = '';
7531 }
7532
7533 // Remove any unsupported fields
7534 $message = array_intersect_key($message, array_flip(['role', 'content']));
7535 }
7536
7537 // Add relevant content as the latest user message
7538 $conversation_history[] = [
7539 'role' => 'user',
7540 'content' => $relevant_content
7541 ];
7542
7543 // Prepare the request body with stream: true
7544 $body = json_encode([
7545 'model' => $selected_model,
7546 'messages' => $conversation_history,
7547 'max_tokens' => 1000,
7548 'temperature' => 0.8,
7549 'system' => $system_prompt_instructions,
7550 'stream' => true
7551 ]);
7552
7553 // Check if we can actually stream (headers not sent, etc.)
7554 if (headers_sent() || !function_exists('curl_init')) {
7555 // Fallback to regular response with testing data
7556 //error_log("MxChat: Streaming not possible, falling back to regular response");
7557 $regular_response = $this->mxchat_generate_response_claude(
7558 $selected_model,
7559 $claude_api_key,
7560 array_slice($conversation_history, 0, -1), // Remove the added content
7561 $relevant_content
7562 );
7563
7564 // Save bot response to transcript
7565 if (!empty($regular_response) && !empty($session_id)) {
7566 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7567 }
7568
7569 // Return as JSON with testing data
7570 $response_data = [
7571 'text' => $regular_response,
7572 'html' => '',
7573 'session_id' => $session_id
7574 ];
7575
7576 if ($testing_data !== null) {
7577 $response_data['testing_data'] = $testing_data;
7578 //error_log("MxChat Testing: Added testing data to Claude fallback response");
7579 }
7580
7581 // Clear any streaming headers and send JSON
7582 if (headers_sent() === false) {
7583 header('Content-Type: application/json');
7584 }
7585 echo json_encode($response_data);
7586 return true; // Indicate we handled the response
7587 }
7588
7589 // Setup streaming headers now that we know we're actually streaming
7590 $this->setup_streaming_headers();
7591
7592 // Use cURL for streaming support
7593 $ch = curl_init();
7594 curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
7595 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7596 curl_setopt($ch, CURLOPT_POST, true);
7597 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7598 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7599 'Content-Type: application/json',
7600 'x-api-key: ' . $claude_api_key,
7601 'anthropic-version: 2023-06-01'
7602 ));
7603 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7604 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7605
7606 $full_response = ''; // Accumulate full response for saving
7607 $stream_started = false;
7608 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7609
7610 // Buffer control for real-time streaming
7611 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7612 // Send testing data as the first event if available
7613 if (!$stream_started && $testing_data !== null) {
7614 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7615 flush();
7616 $stream_started = true;
7617 //error_log("MxChat Testing: Sent testing data in Claude stream");
7618 }
7619
7620 // CRITICAL FIX: Append new data to buffer
7621 $buffer .= $data;
7622
7623 // Process complete lines only
7624 $lines = explode("\n", $buffer);
7625
7626 // CRITICAL FIX: Keep the last incomplete line in the buffer
7627 // The last element might be incomplete, so keep it in buffer
7628 $buffer = array_pop($lines);
7629
7630 foreach ($lines as $line) {
7631 if (trim($line) === '') {
7632 continue;
7633 }
7634
7635 // Claude uses event: and data: format
7636 if (strpos($line, 'event: ') === 0) {
7637 // Store the event type for the next data line
7638 continue;
7639 }
7640
7641 if (strpos($line, 'data: ') === 0) {
7642 $json_str = substr($line, 6); // Remove 'data: ' prefix
7643
7644 $json = json_decode(trim($json_str), true);
7645 if (json_last_error() !== JSON_ERROR_NONE) {
7646 continue;
7647 }
7648
7649 // Handle different event types
7650 if (isset($json['type'])) {
7651 switch ($json['type']) {
7652 case 'content_block_delta':
7653 if (isset($json['delta']['text'])) {
7654 $content = $json['delta']['text'];
7655 $full_response .= $content; // Accumulate
7656 // Send as SSE format compatible with your frontend
7657 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7658 flush();
7659 }
7660 break;
7661
7662 case 'message_stop':
7663 echo "data: [DONE]\n\n";
7664 flush();
7665 break;
7666
7667 case 'error':
7668 echo "data: " . json_encode(['error' => $json['error']['message'] ?? 'Unknown error']) . "\n\n";
7669 flush();
7670 break;
7671 }
7672 }
7673 }
7674 }
7675
7676 return strlen($data);
7677 });
7678
7679 $response = curl_exec($ch);
7680 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7681
7682 if (curl_errno($ch)) {
7683 curl_close($ch);
7684 throw new Exception('cURL Error: ' . curl_error($ch));
7685 }
7686
7687 curl_close($ch);
7688
7689 if ($http_code !== 200) {
7690 // Fallback to regular response
7691 //error_log("MxChat: Claude streaming failed with HTTP $http_code, falling back");
7692 $regular_response = $this->mxchat_generate_response_claude(
7693 $selected_model,
7694 $claude_api_key,
7695 array_slice($conversation_history, 0, -1), // Remove the added content
7696 $relevant_content
7697 );
7698
7699 // FIXED: Check if regular response returned an error
7700 if (is_array($regular_response) && isset($regular_response['error'])) {
7701 // Send error in SSE format since we're in streaming mode
7702 echo "data: " . json_encode([
7703 'error' => true,
7704 'error_message' => $regular_response['error'],
7705 'error_code' => $regular_response['error_code'] ?? 'api_error',
7706 'text' => $regular_response['error'],
7707 'message' => $regular_response['error']
7708 ]) . "\n\n";
7709 echo "data: [DONE]\n\n";
7710 flush();
7711 return true;
7712 }
7713
7714 $response_data = [
7715 'text' => $regular_response,
7716 'html' => '',
7717 'session_id' => $session_id
7718 ];
7719
7720 if ($testing_data !== null) {
7721 $response_data['testing_data'] = $testing_data;
7722 //error_log("MxChat Testing: Added testing data to Claude error fallback");
7723 }
7724
7725 header('Content-Type: application/json');
7726 echo json_encode($response_data);
7727 return true;
7728 }
7729
7730 // Save the complete response to maintain chat persistence
7731 if (!empty($full_response) && !empty($session_id)) {
7732 // Prepare RAG context for streaming response
7733 $rag_context_for_storage = null;
7734 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7735 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7736
7737 if ($has_rag_data || $has_action_data) {
7738 $rag_context_for_storage = [];
7739
7740 if ($has_rag_data) {
7741 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7742 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7743 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7744 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7745 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7746 }
7747
7748 if ($has_action_data) {
7749 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7750 }
7751 }
7752 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7753 }
7754
7755 return true; // Indicate streaming completed successfully
7756
7757 } catch (Exception $e) {
7758 //error_log("MxChat Claude streaming exception: " . $e->getMessage());
7759
7760 // Fallback to regular response on exception
7761 $regular_response = $this->mxchat_generate_response_claude(
7762 $selected_model,
7763 $claude_api_key,
7764 $conversation_history,
7765 $relevant_content
7766 );
7767
7768 // FIXED: Check if regular response returned an error
7769 if (is_array($regular_response) && isset($regular_response['error'])) {
7770 // Send error in SSE format since we're in streaming mode
7771 echo "data: " . json_encode([
7772 'error' => true,
7773 'error_message' => $regular_response['error'],
7774 'error_code' => $regular_response['error_code'] ?? 'api_error',
7775 'text' => $regular_response['error'],
7776 'message' => $regular_response['error']
7777 ]) . "\n\n";
7778 echo "data: [DONE]\n\n";
7779 flush();
7780 return true;
7781 }
7782
7783 $response_data = [
7784 'text' => $regular_response,
7785 'html' => '',
7786 'session_id' => $session_id
7787 ];
7788
7789 if ($testing_data !== null) {
7790 $response_data['testing_data'] = $testing_data;
7791 //error_log("MxChat Testing: Added testing data to Claude exception fallback");
7792 }
7793
7794 header('Content-Type: application/json');
7795 echo json_encode($response_data);
7796 return true;
7797 }
7798 }
7799 private function mxchat_generate_response_xai_stream($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7800 try {
7801 // Get bot ID from session or request
7802 $bot_id = $this->get_current_bot_id($session_id);
7803
7804 // Get system prompt instructions using centralized function
7805 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7806
7807 // Ensure conversation_history is an array
7808 if (!is_array($conversation_history)) {
7809 $conversation_history = array();
7810 }
7811
7812 // Format conversation history for X.AI (same as OpenAI format)
7813 $formatted_conversation = array();
7814
7815 $formatted_conversation[] = array(
7816 'role' => 'system',
7817 'content' => $system_prompt_instructions . " " . $relevant_content
7818 );
7819
7820 foreach ($conversation_history as $message) {
7821 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7822 $role = $message['role'];
7823 if ($role === 'bot' || $role === 'agent') {
7824 $role = 'assistant';
7825 }
7826 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
7827 $role = 'user';
7828 }
7829 $formatted_conversation[] = array(
7830 'role' => $role,
7831 'content' => $message['content']
7832 );
7833 }
7834 }
7835
7836 // Check if we can actually stream
7837 if (headers_sent() || !function_exists('curl_init')) {
7838 // Fallback to regular response with testing data
7839 //error_log("MxChat: X.AI streaming not possible, falling back to regular response");
7840 $regular_response = $this->mxchat_generate_response_xai(
7841 $selected_model,
7842 $xai_api_key,
7843 $conversation_history,
7844 $relevant_content
7845 );
7846
7847 // Save bot response to transcript
7848 if (!empty($regular_response) && !empty($session_id)) {
7849 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7850 }
7851
7852 $response_data = [
7853 'text' => $regular_response,
7854 'html' => '',
7855 'session_id' => $session_id
7856 ];
7857
7858 if ($testing_data !== null) {
7859 $response_data['testing_data'] = $testing_data;
7860 //error_log("MxChat Testing: Added testing data to X.AI fallback response");
7861 }
7862
7863 header('Content-Type: application/json');
7864 echo json_encode($response_data);
7865 return true;
7866 }
7867
7868 // Prepare the request body with stream: true
7869 $body = json_encode([
7870 'model' => $selected_model,
7871 'messages' => $formatted_conversation,
7872 'temperature' => 0.8,
7873 'stream' => true
7874 ]);
7875
7876 // Setup streaming headers now that we know we're actually streaming
7877 $this->setup_streaming_headers();
7878
7879 // Use cURL for streaming support
7880 $ch = curl_init();
7881 curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
7882 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7883 curl_setopt($ch, CURLOPT_POST, true);
7884 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7885 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7886 'Content-Type: application/json',
7887 'Authorization: Bearer ' . $xai_api_key
7888 ));
7889 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7890 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7891
7892 $full_response = ''; // Accumulate full response for saving
7893 $stream_started = false;
7894 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7895
7896 // Buffer control for real-time streaming
7897 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7898 // Send testing data as the first event if available
7899 if (!$stream_started && $testing_data !== null) {
7900 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7901 flush();
7902 $stream_started = true;
7903 //error_log("MxChat Testing: Sent testing data in X.AI stream");
7904 }
7905
7906 // CRITICAL FIX: Append new data to buffer
7907 $buffer .= $data;
7908
7909 // Process complete lines only
7910 $lines = explode("\n", $buffer);
7911
7912 // CRITICAL FIX: Keep the last incomplete line in the buffer
7913 // The last element might be incomplete, so keep it in buffer
7914 $buffer = array_pop($lines);
7915
7916 foreach ($lines as $line) {
7917 // Skip empty lines
7918 if (trim($line) === '') {
7919 continue;
7920 }
7921
7922 // Only process lines that start with "data: "
7923 if (strpos($line, 'data: ') !== 0) {
7924 continue;
7925 }
7926
7927 $json_str = substr($line, 6); // Remove 'data: ' prefix
7928
7929 if (trim($json_str) === '[DONE]') {
7930 echo "data: [DONE]\n\n";
7931 flush();
7932 continue;
7933 }
7934
7935 // Try to decode JSON
7936 $json = json_decode(trim($json_str), true);
7937 if ($json && isset($json['choices'][0]['delta']['content'])) {
7938 $content = $json['choices'][0]['delta']['content'];
7939 $full_response .= $content; // Accumulate
7940 // Send as SSE format
7941 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7942 flush();
7943 }
7944 }
7945
7946 return strlen($data);
7947 });
7948
7949 $response = curl_exec($ch);
7950 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7951
7952 if (curl_errno($ch) || $http_code !== 200) {
7953 curl_close($ch);
7954
7955 // Fallback to regular response
7956 //error_log("MxChat: X.AI streaming failed, falling back");
7957 $regular_response = $this->mxchat_generate_response_xai(
7958 $selected_model,
7959 $xai_api_key,
7960 $conversation_history,
7961 $relevant_content
7962 );
7963
7964 $response_data = [
7965 'text' => $regular_response,
7966 'html' => '',
7967 'session_id' => $session_id
7968 ];
7969
7970 if ($testing_data !== null) {
7971 $response_data['testing_data'] = $testing_data;
7972 //error_log("MxChat Testing: Added testing data to X.AI error fallback");
7973 }
7974
7975 header('Content-Type: application/json');
7976 echo json_encode($response_data);
7977 return true;
7978 }
7979
7980 curl_close($ch);
7981
7982 // Save the complete response to maintain chat persistence
7983 if (!empty($full_response) && !empty($session_id)) {
7984 // Prepare RAG context for streaming response
7985 $rag_context_for_storage = null;
7986 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7987 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7988
7989 if ($has_rag_data || $has_action_data) {
7990 $rag_context_for_storage = [];
7991
7992 if ($has_rag_data) {
7993 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7994 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7995 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7996 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7997 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7998 }
7999
8000 if ($has_action_data) {
8001 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
8002 }
8003 }
8004 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
8005 }
8006
8007 return true; // Indicate streaming completed successfully
8008
8009 } catch (Exception $e) {
8010 //error_log("MxChat X.AI streaming exception: " . $e->getMessage());
8011
8012 // Fallback to regular response
8013 $regular_response = $this->mxchat_generate_response_xai(
8014 $selected_model,
8015 $xai_api_key,
8016 $conversation_history,
8017 $relevant_content
8018 );
8019
8020 $response_data = [
8021 'text' => $regular_response,
8022 'html' => '',
8023 'session_id' => $session_id
8024 ];
8025
8026 if ($testing_data !== null) {
8027 $response_data['testing_data'] = $testing_data;
8028 //error_log("MxChat Testing: Added testing data to X.AI exception fallback");
8029 }
8030
8031 header('Content-Type: application/json');
8032 echo json_encode($response_data);
8033 return true;
8034 }
8035 }
8036 private function mxchat_generate_response_deepseek_stream($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
8037 try {
8038 // Get bot ID from session or request
8039 $bot_id = $this->get_current_bot_id($session_id);
8040
8041 // Get system prompt instructions using centralized function
8042 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8043
8044 // Ensure conversation_history is an array
8045 if (!is_array($conversation_history)) {
8046 $conversation_history = array();
8047 }
8048
8049 // Format conversation history for DeepSeek
8050 $formatted_conversation = array();
8051
8052 $formatted_conversation[] = array(
8053 'role' => 'system',
8054 'content' => $system_prompt_instructions . " " . $relevant_content
8055 );
8056
8057 foreach ($conversation_history as $message) {
8058 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8059 $role = $message['role'];
8060 if ($role === 'bot' || $role === 'agent') {
8061 $role = 'assistant';
8062 }
8063 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8064 $role = 'user';
8065 }
8066 $formatted_conversation[] = array(
8067 'role' => $role,
8068 'content' => $message['content']
8069 );
8070 }
8071 }
8072
8073 // Check if we can actually stream
8074 if (headers_sent() || !function_exists('curl_init')) {
8075 // Fallback to regular response with testing data
8076 //error_log("MxChat: DeepSeek streaming not possible, falling back to regular response");
8077 $regular_response = $this->mxchat_generate_response_deepseek(
8078 $selected_model,
8079 $deepseek_api_key,
8080 $conversation_history,
8081 $relevant_content
8082 );
8083
8084 // Save bot response to transcript
8085 if (!empty($regular_response) && !empty($session_id)) {
8086 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
8087 }
8088
8089 $response_data = [
8090 'text' => $regular_response,
8091 'html' => '',
8092 'session_id' => $session_id
8093 ];
8094
8095 if ($testing_data !== null) {
8096 $response_data['testing_data'] = $testing_data;
8097 //error_log("MxChat Testing: Added testing data to DeepSeek fallback response");
8098 }
8099
8100 header('Content-Type: application/json');
8101 echo json_encode($response_data);
8102 return true;
8103 }
8104
8105 // Prepare the request body with stream: true
8106 $body = json_encode([
8107 'model' => $selected_model,
8108 'messages' => $formatted_conversation,
8109 'temperature' => 0.8,
8110 'stream' => true
8111 ]);
8112
8113 // Setup streaming headers now that we know we're actually streaming
8114 $this->setup_streaming_headers();
8115
8116 // Use cURL for streaming support
8117 $ch = curl_init();
8118 curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
8119 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
8120 curl_setopt($ch, CURLOPT_POST, true);
8121 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
8122 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
8123 'Content-Type: application/json',
8124 'Authorization: Bearer ' . $deepseek_api_key
8125 ));
8126 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8127 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
8128
8129 $full_response = ''; // Accumulate full response for saving
8130 $stream_started = false;
8131 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
8132
8133 // Buffer control for real-time streaming
8134 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
8135 // Send testing data as the first event if available
8136 if (!$stream_started && $testing_data !== null) {
8137 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
8138 flush();
8139 $stream_started = true;
8140 //error_log("MxChat Testing: Sent testing data in DeepSeek stream");
8141 }
8142
8143 // CRITICAL FIX: Append new data to buffer
8144 $buffer .= $data;
8145
8146 // Process complete lines only
8147 $lines = explode("\n", $buffer);
8148
8149 // CRITICAL FIX: Keep the last incomplete line in the buffer
8150 // The last element might be incomplete, so keep it in buffer
8151 $buffer = array_pop($lines);
8152
8153 foreach ($lines as $line) {
8154 // Skip empty lines
8155 if (trim($line) === '') {
8156 continue;
8157 }
8158
8159 // Only process lines that start with "data: "
8160 if (strpos($line, 'data: ') !== 0) {
8161 continue;
8162 }
8163
8164 $json_str = substr($line, 6); // Remove 'data: ' prefix
8165
8166 if (trim($json_str) === '[DONE]') {
8167 echo "data: [DONE]\n\n";
8168 flush();
8169 continue;
8170 }
8171
8172 // Try to decode JSON
8173 $json = json_decode(trim($json_str), true);
8174 if ($json && isset($json['choices'][0]['delta']['content'])) {
8175 $content = $json['choices'][0]['delta']['content'];
8176 $full_response .= $content; // Accumulate the full response
8177
8178 // Send as SSE format
8179 echo "data: " . json_encode(['content' => $content]) . "\n\n";
8180 flush();
8181 }
8182 }
8183
8184 return strlen($data);
8185 });
8186
8187 $response = curl_exec($ch);
8188 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
8189
8190 if (curl_errno($ch) || $http_code !== 200) {
8191 $curl_error = curl_error($ch);
8192 curl_close($ch);
8193
8194 // Log the specific error for debugging
8195 //error_log("MxChat: DeepSeek streaming failed - HTTP: $http_code, cURL: $curl_error");
8196
8197 // Fallback to regular response
8198 $regular_response = $this->mxchat_generate_response_deepseek(
8199 $selected_model,
8200 $deepseek_api_key,
8201 $conversation_history,
8202 $relevant_content
8203 );
8204
8205 // Handle error response from regular function
8206 if (is_array($regular_response) && isset($regular_response['error'])) {
8207 if ($testing_data !== null) {
8208 $regular_response['testing_data'] = $testing_data;
8209 }
8210 header('Content-Type: application/json');
8211 echo json_encode($regular_response);
8212 return true;
8213 }
8214
8215 $response_data = [
8216 'text' => $regular_response,
8217 'html' => '',
8218 'session_id' => $session_id
8219 ];
8220
8221 if ($testing_data !== null) {
8222 $response_data['testing_data'] = $testing_data;
8223 //error_log("MxChat Testing: Added testing data to DeepSeek error fallback");
8224 }
8225
8226 header('Content-Type: application/json');
8227 echo json_encode($response_data);
8228 return true;
8229 }
8230
8231 curl_close($ch);
8232
8233 // Save the complete response to maintain chat persistence
8234 if (!empty($full_response) && !empty($session_id)) {
8235 // Prepare RAG context for streaming response
8236 $rag_context_for_storage = null;
8237 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
8238 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
8239
8240 if ($has_rag_data || $has_action_data) {
8241 $rag_context_for_storage = [];
8242
8243 if ($has_rag_data) {
8244 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
8245 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
8246 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
8247 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
8248 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
8249 }
8250
8251 if ($has_action_data) {
8252 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
8253 }
8254 }
8255 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
8256 }
8257
8258 return true; // Indicate streaming completed successfully
8259
8260 } catch (Exception $e) {
8261 //error_log("MxChat DeepSeek streaming exception: " . $e->getMessage());
8262
8263 // Fallback to regular response
8264 $regular_response = $this->mxchat_generate_response_deepseek(
8265 $selected_model,
8266 $deepseek_api_key,
8267 $conversation_history,
8268 $relevant_content
8269 );
8270
8271 // Handle error response from regular function
8272 if (is_array($regular_response) && isset($regular_response['error'])) {
8273 if ($testing_data !== null) {
8274 $regular_response['testing_data'] = $testing_data;
8275 }
8276 header('Content-Type: application/json');
8277 echo json_encode($regular_response);
8278 return true;
8279 }
8280
8281 $response_data = [
8282 'text' => $regular_response,
8283 'html' => '',
8284 'session_id' => $session_id
8285 ];
8286
8287 if ($testing_data !== null) {
8288 $response_data['testing_data'] = $testing_data;
8289 //error_log("MxChat Testing: Added testing data to DeepSeek exception fallback");
8290 }
8291
8292 header('Content-Type: application/json');
8293 echo json_encode($response_data);
8294 return true;
8295 }
8296 }
8297
8298
8299 private function mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content) {
8300 try {
8301 if (!is_array($conversation_history)) {
8302 $conversation_history = array();
8303 }
8304
8305 $bot_id = $this->get_current_bot_id('');
8306 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8307
8308 $formatted_conversation = array();
8309
8310 $formatted_conversation[] = array(
8311 'role' => 'system',
8312 'content' => $system_prompt_instructions . " " . $relevant_content
8313 );
8314
8315 foreach ($conversation_history as $message) {
8316 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8317 $role = $message['role'];
8318
8319 if ($role === 'bot' || $role === 'agent') {
8320 $role = 'assistant';
8321 }
8322 if (!in_array($role, ['system', 'assistant', 'user'])) {
8323 $role = 'user';
8324 }
8325
8326 $formatted_conversation[] = array(
8327 'role' => $role,
8328 'content' => $message['content']
8329 );
8330 }
8331 }
8332
8333 $body = json_encode([
8334 'model' => $selected_model,
8335 'messages' => $formatted_conversation,
8336 'temperature' => 1,
8337 ]);
8338
8339 $args = [
8340 'body' => $body,
8341 'headers' => [
8342 'Content-Type' => 'application/json',
8343 'Authorization' => 'Bearer ' . $openrouter_api_key,
8344 'HTTP-Referer' => home_url(),
8345 'X-Title' => get_bloginfo('name'),
8346 ],
8347 'timeout' => 60,
8348 'redirection' => 5,
8349 'blocking' => true,
8350 'httpversion' => '1.0',
8351 'sslverify' => true,
8352 ];
8353
8354 $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', $args);
8355
8356 if (is_wp_error($response)) {
8357 $error_message = $response->get_error_message();
8358 return [
8359 'error' => esc_html__('Connection error when contacting OpenRouter: ', 'mxchat') . esc_html($error_message),
8360 'error_code' => 'openrouter_connection_error',
8361 'provider' => 'openrouter'
8362 ];
8363 }
8364
8365 $status_code = wp_remote_retrieve_response_code($response);
8366 if ($status_code !== 200) {
8367 $response_body = wp_remote_retrieve_body($response);
8368 $decoded_response = json_decode($response_body, true);
8369
8370 $error_message = isset($decoded_response['error']['message'])
8371 ? $decoded_response['error']['message']
8372 : 'HTTP Error ' . $status_code;
8373
8374 return [
8375 'error' => esc_html__('OpenRouter API error: ', 'mxchat') . esc_html($error_message),
8376 'error_code' => 'openrouter_api_error',
8377 'provider' => 'openrouter',
8378 'status_code' => $status_code
8379 ];
8380 }
8381
8382 $response_body = wp_remote_retrieve_body($response);
8383 $decoded_response = json_decode($response_body, true);
8384
8385 if (isset($decoded_response['choices'][0]['message']['content'])) {
8386 return trim($decoded_response['choices'][0]['message']['content']);
8387 } else {
8388 return [
8389 'error' => esc_html__('Unexpected response format from OpenRouter.', 'mxchat'),
8390 'error_code' => 'openrouter_response_format_error',
8391 'provider' => 'openrouter'
8392 ];
8393 }
8394 } catch (Exception $e) {
8395 return [
8396 'error' => esc_html__('System error when processing OpenRouter request: ', 'mxchat') . esc_html($e->getMessage()),
8397 'error_code' => 'openrouter_exception',
8398 'provider' => 'openrouter'
8399 ];
8400 }
8401 }
8402 private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content) {
8403
8404 // Get bot ID from session or request
8405 $bot_id = $this->get_current_bot_id($session_id);
8406
8407 // Get system prompt instructions using centralized function
8408 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8409
8410 // Clean and validate conversation history
8411 foreach ($conversation_history as &$message) {
8412 // Convert bot and agent roles to assistant
8413 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
8414 $message['role'] = 'assistant';
8415 }
8416
8417 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
8418 if (!in_array($message['role'], ['assistant', 'user'])) {
8419 $message['role'] = 'user';
8420 }
8421
8422 // Ensure content field exists
8423 if (!isset($message['content']) || empty($message['content'])) {
8424 $message['content'] = '';
8425 }
8426
8427 // Remove any unsupported fields
8428 $message = array_intersect_key($message, array_flip(['role', 'content']));
8429 }
8430
8431 // Add relevant content as the latest user message
8432 $conversation_history[] = [
8433 'role' => 'user',
8434 'content' => $relevant_content
8435 ];
8436
8437 // Build request body
8438 $body = json_encode([
8439 'model' => $selected_model,
8440 'max_tokens' => 1000,
8441 'temperature' => 0.8,
8442 'messages' => $conversation_history,
8443 'system' => $system_prompt_instructions
8444 ]);
8445
8446 // Set up API request
8447 $args = [
8448 'body' => $body,
8449 'headers' => [
8450 'Content-Type' => 'application/json',
8451 'x-api-key' => $claude_api_key,
8452 'anthropic-version' => '2023-06-01'
8453 ],
8454 'timeout' => 60,
8455 'redirection' => 5,
8456 'blocking' => true,
8457 'httpversion' => '1.0',
8458 'sslverify' => true,
8459 ];
8460
8461 // Make API request
8462 $response = wp_remote_post('https://api.anthropic.com/v1/messages', $args);
8463
8464 // Check for WordPress errors
8465 if (is_wp_error($response)) {
8466 //error_log("Claude API request error: " . $response->get_error_message());
8467 return "Sorry, there was an error connecting to the API.";
8468 }
8469
8470 // Check HTTP response code
8471 $http_code = wp_remote_retrieve_response_code($response);
8472 if ($http_code !== 200) {
8473 $error_body = wp_remote_retrieve_body($response);
8474 //error_log("Claude API HTTP error: " . $http_code . " - " . $error_body);
8475
8476 // Try to extract error message from response
8477 $error_data = json_decode($error_body, true);
8478 $error_message = isset($error_data['error']['message']) ?
8479 $error_data['error']['message'] :
8480 "HTTP error " . $http_code;
8481
8482 return "Sorry, the API returned an error: " . $error_message;
8483 }
8484
8485 // Parse response
8486 $response_body = json_decode(wp_remote_retrieve_body($response), true);
8487
8488 // Check for JSON decode errors
8489 if (json_last_error() !== JSON_ERROR_NONE) {
8490 //error_log("Claude API JSON decode error: " . json_last_error_msg());
8491 return "Sorry, there was an error processing the API response.";
8492 }
8493
8494 // Extract and validate response content
8495 if (isset($response_body['content']) &&
8496 is_array($response_body['content']) &&
8497 !empty($response_body['content']) &&
8498 isset($response_body['content'][0]['text'])) {
8499 return trim($response_body['content'][0]['text']);
8500 }
8501
8502 // Log unexpected response format
8503 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
8504 return "Sorry, I received an unexpected response format from the API.";
8505 }
8506 private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content) {
8507 try {
8508 // Ensure conversation_history is an array
8509 if (!is_array($conversation_history)) {
8510 $conversation_history = array();
8511 }
8512
8513 // Get bot ID from session or request
8514 $bot_id = $this->get_current_bot_id('');
8515
8516 // Get system prompt instructions using centralized function
8517 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8518
8519 // Create a new array for the formatted conversation
8520 $formatted_conversation = array();
8521
8522 // Add system message first
8523 $formatted_conversation[] = array(
8524 'role' => 'system',
8525 'content' => $system_prompt_instructions . " " . $relevant_content
8526 );
8527
8528 // Add the rest of the conversation history
8529 foreach ($conversation_history as $message) {
8530 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8531 $role = $message['role'];
8532
8533 // Convert roles to supported format
8534 if ($role === 'bot' || $role === 'agent') {
8535 $role = 'assistant';
8536 }
8537 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8538 $role = 'user';
8539 }
8540
8541 $formatted_conversation[] = array(
8542 'role' => $role,
8543 'content' => $message['content']
8544 );
8545 }
8546 }
8547
8548 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
8549 $is_gpt5_model = (
8550 strpos($selected_model, 'gpt-5') === 0 ||
8551 $selected_model === 'gpt-5.2' ||
8552 $selected_model === 'gpt-5.1-2025-11-13' ||
8553 $selected_model === 'gpt-5' ||
8554 $selected_model === 'gpt-5-mini' ||
8555 $selected_model === 'gpt-5-nano'
8556 );
8557
8558 // Build request body with optimal settings for fast responses
8559 $request_body = [
8560 'model' => $selected_model,
8561 'messages' => $formatted_conversation,
8562 'temperature' => 1,
8563 'stream' => false
8564 ];
8565
8566 // Add reasoning_effort only for GPT-5 models that support it
8567 // These chat models don't support reasoning_effort parameter
8568 $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');
8569 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_models, true)) {
8570 // GPT-5.1 uses 'low' instead of 'minimal'
8571 if ($selected_model === 'gpt-5.1-2025-11-13') {
8572 $request_body['reasoning_effort'] = 'low';
8573 } elseif ($selected_model === 'gpt-5.4') {
8574 $request_body['reasoning_effort'] = 'none';
8575 } else {
8576 $request_body['reasoning_effort'] = 'minimal';
8577 }
8578 }
8579
8580 $body = json_encode($request_body);
8581
8582 $args = [
8583 'body' => $body,
8584 'headers' => [
8585 'Content-Type' => 'application/json',
8586 'Authorization' => 'Bearer ' . $api_key,
8587 ],
8588 'timeout' => 60,
8589 'redirection' => 5,
8590 'blocking' => true,
8591 'httpversion' => '1.0',
8592 'sslverify' => true,
8593 ];
8594
8595 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', $args);
8596
8597 if (is_wp_error($response)) {
8598 $error_message = $response->get_error_message();
8599 return [
8600 'error' => esc_html__('Connection error when contacting OpenAI: ', 'mxchat') . esc_html($error_message),
8601 'error_code' => 'openai_connection_error',
8602 'provider' => 'openai'
8603 ];
8604 }
8605
8606 $status_code = wp_remote_retrieve_response_code($response);
8607 if ($status_code !== 200) {
8608 $response_body = wp_remote_retrieve_body($response);
8609 $decoded_response = json_decode($response_body, true);
8610
8611 $error_message = isset($decoded_response['error']['message'])
8612 ? $decoded_response['error']['message']
8613 : 'HTTP Error ' . $status_code;
8614
8615 $error_type = isset($decoded_response['error']['type'])
8616 ? $decoded_response['error']['type']
8617 : 'unknown';
8618
8619 // Handle specific error types
8620 switch ($error_type) {
8621 case 'invalid_request_error':
8622 if (strpos($error_message, 'API key') !== false) {
8623 return [
8624 'error' => esc_html__('Invalid OpenAI API key. Please check your API key configuration.', 'mxchat'),
8625 'error_code' => 'openai_invalid_api_key',
8626 'provider' => 'openai'
8627 ];
8628 }
8629 break;
8630
8631 case 'authentication_error':
8632 return [
8633 'error' => esc_html__('Authentication failed with OpenAI. Please check your API key.', 'mxchat'),
8634 'error_code' => 'openai_auth_error',
8635 'provider' => 'openai'
8636 ];
8637
8638 case 'rate_limit_exceeded':
8639 return [
8640 'error' => esc_html__('OpenAI rate limit exceeded. Please try again later.', 'mxchat'),
8641 'error_code' => 'openai_rate_limit',
8642 'provider' => 'openai'
8643 ];
8644
8645 case 'quota_exceeded':
8646 return [
8647 'error' => esc_html__('OpenAI API quota exceeded. Please check your billing details.', 'mxchat'),
8648 'error_code' => 'openai_quota_exceeded',
8649 'provider' => 'openai'
8650 ];
8651 }
8652
8653 // Generic error fallback
8654 return [
8655 'error' => esc_html__('OpenAI API error: ', 'mxchat') . esc_html($error_message),
8656 'error_code' => 'openai_api_error',
8657 'provider' => 'openai',
8658 'status_code' => $status_code
8659 ];
8660 }
8661
8662 $response_body = wp_remote_retrieve_body($response);
8663 $decoded_response = json_decode($response_body, true);
8664
8665 if (isset($decoded_response['choices'][0]['message']['content'])) {
8666 return trim($decoded_response['choices'][0]['message']['content']);
8667 } else {
8668 return [
8669 'error' => esc_html__('Unexpected response format from OpenAI.', 'mxchat'),
8670 'error_code' => 'openai_response_format_error',
8671 'provider' => 'openai'
8672 ];
8673 }
8674 } catch (Exception $e) {
8675 return [
8676 'error' => esc_html__('System error when processing OpenAI request: ', 'mxchat') . esc_html($e->getMessage()),
8677 'error_code' => 'openai_exception',
8678 'provider' => 'openai'
8679 ];
8680 }
8681 }
8682
8683 private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content) {
8684 try {
8685 // Get bot ID from session or request
8686 $bot_id = $this->get_current_bot_id($session_id);
8687
8688 // Get system prompt instructions using centralized function
8689 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8690
8691 // Add system prompt to relevant content
8692 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
8693
8694 // Prepend system instructions to the conversation history
8695 array_unshift($conversation_history, [
8696 'role' => 'system',
8697 'content' => "Here are your instructions: " . $content_with_instructions
8698 ]);
8699
8700 // Ensure consistency: Replace 'bot' and 'agent' roles with supported values
8701 foreach ($conversation_history as &$message) {
8702 if ($message['role'] === 'bot') {
8703 $message['role'] = 'assistant';
8704 } elseif ($message['role'] === 'agent') {
8705 // Tag the message as coming from a live agent
8706 $message['role'] = 'assistant';
8707 if (!isset($message['metadata'])) {
8708 $message['metadata'] = ['source' => 'live_agent'];
8709 }
8710 }
8711
8712 // Ensure all roles are valid
8713 if (!in_array($message['role'], ['system', 'assistant', 'user', 'function', 'tool'])) {
8714 $message['role'] = 'user'; // Default to 'user'
8715 }
8716 }
8717
8718 // Build the request body
8719 $body = json_encode([
8720 'model' => $selected_model,
8721 'messages' => $conversation_history,
8722 'temperature' => 0.8,
8723 'stream' => false
8724 ]);
8725
8726 // Set up the API request
8727 $args = [
8728 'body' => $body,
8729 'headers' => [
8730 'Content-Type' => 'application/json',
8731 'Authorization' => 'Bearer ' . $xai_api_key,
8732 ],
8733 'timeout' => 60,
8734 'redirection' => 5,
8735 'blocking' => true,
8736 'httpversion' => '1.0',
8737 'sslverify' => true,
8738 ];
8739
8740 // Make the API request
8741 $response = wp_remote_post('https://api.x.ai/v1/chat/completions', $args);
8742
8743 // Process the response
8744 if (is_wp_error($response)) {
8745 $error_message = $response->get_error_message();
8746 //error_log('X.AI API Error: ' . $error_message);
8747 return [
8748 'error' => esc_html__('Connection error when contacting X.AI: ', 'mxchat') . esc_html($error_message),
8749 'error_code' => 'xai_connection_error',
8750 'provider' => 'xai'
8751 ];
8752 }
8753
8754 $status_code = wp_remote_retrieve_response_code($response);
8755 if ($status_code !== 200) {
8756 $response_body = wp_remote_retrieve_body($response);
8757 $decoded_response = json_decode($response_body, true);
8758
8759 // Log the full response for debugging
8760 //error_log('X.AI Error Response: ' . print_r($decoded_response, true));
8761
8762 // Extract error message from X.AI's specific format
8763 $error_message = '';
8764
8765 // Check for direct error string (as seen in your logs)
8766 if (isset($decoded_response['error']) && is_string($decoded_response['error'])) {
8767 $error_message = $decoded_response['error'];
8768 }
8769 // Check for nested error object (OpenAI style)
8770 elseif (isset($decoded_response['error']['message'])) {
8771 $error_message = $decoded_response['error']['message'];
8772 }
8773 // Check for top-level message
8774 elseif (isset($decoded_response['message'])) {
8775 $error_message = $decoded_response['message'];
8776 }
8777 // Fallback
8778 else {
8779 $error_message = 'HTTP Error ' . $status_code;
8780 }
8781
8782 //error_log('X.AI API HTTP Error: ' . $status_code . ' - ' . $error_message);
8783
8784 // Check for API key errors using string matching
8785 if (stripos($error_message, 'api key') !== false ||
8786 stripos($error_message, 'incorrect api key') !== false ||
8787 stripos($error_message, 'invalid api key') !== false) {
8788 return [
8789 'error' => esc_html__('Invalid X.AI API key. Please check your API key configuration.', 'mxchat'),
8790 'error_code' => 'xai_invalid_api_key',
8791 'provider' => 'xai'
8792 ];
8793 }
8794
8795 // Authentication errors
8796 if ($status_code === 401 || $status_code === 403 ||
8797 stripos($error_message, 'auth') !== false) {
8798 return [
8799 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat'),
8800 'error_code' => 'xai_auth_error',
8801 'provider' => 'xai'
8802 ];
8803 }
8804
8805 // Model errors
8806 if (stripos($error_message, 'model') !== false) {
8807 return [
8808 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat'),
8809 'error_code' => 'xai_invalid_model',
8810 'provider' => 'xai'
8811 ];
8812 }
8813
8814 // Rate limit errors
8815 if ($status_code === 429 ||
8816 stripos($error_message, 'rate') !== false ||
8817 stripos($error_message, 'limit') !== false) {
8818 return [
8819 'error' => esc_html__('X.AI rate limit exceeded. Please try again later.', 'mxchat'),
8820 'error_code' => 'xai_rate_limit',
8821 'provider' => 'xai'
8822 ];
8823 }
8824
8825 // Quota errors
8826 if (stripos($error_message, 'quota') !== false ||
8827 stripos($error_message, 'billing') !== false) {
8828 return [
8829 'error' => esc_html__('X.AI API quota exceeded. Please check your billing details.', 'mxchat'),
8830 'error_code' => 'xai_quota_exceeded',
8831 'provider' => 'xai'
8832 ];
8833 }
8834
8835 // Server errors
8836 if ($status_code >= 500) {
8837 return [
8838 'error' => esc_html__('X.AI service is currently unavailable. Please try again later.', 'mxchat'),
8839 'error_code' => 'xai_service_unavailable',
8840 'provider' => 'xai'
8841 ];
8842 }
8843
8844 // Generic error fallback with the actual error message
8845 return [
8846 'error' => esc_html__('X.AI API error: ', 'mxchat') . esc_html($error_message),
8847 'error_code' => 'xai_api_error',
8848 'provider' => 'xai',
8849 'status_code' => $status_code
8850 ];
8851 }
8852
8853 $response_body = wp_remote_retrieve_body($response);
8854 $decoded_response = json_decode($response_body, true);
8855
8856 if (isset($decoded_response['choices'][0]['message']['content'])) {
8857 return trim($decoded_response['choices'][0]['message']['content']);
8858 } else {
8859 //error_log('X.AI API Response Format Error: ' . print_r($decoded_response, true));
8860 return [
8861 'error' => esc_html__('Unexpected response format from X.AI.', 'mxchat'),
8862 'error_code' => 'xai_response_format_error',
8863 'provider' => 'xai'
8864 ];
8865 }
8866 } catch (Exception $e) {
8867 //error_log('X.AI Exception: ' . $e->getMessage());
8868 return [
8869 'error' => esc_html__('System error when processing X.AI request: ', 'mxchat') . esc_html($e->getMessage()),
8870 'error_code' => 'xai_exception',
8871 'provider' => 'xai'
8872 ];
8873 }
8874
8875
8876 }
8877 private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content) {
8878 try {
8879 // Ensure conversation_history is an array
8880 if (!is_array($conversation_history)) {
8881 $conversation_history = array();
8882 }
8883
8884 // Get bot ID from session or request
8885 $bot_id = $this->get_current_bot_id($session_id);
8886
8887 // Get system prompt instructions using centralized function
8888 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8889
8890 // Create a new array for the formatted conversation
8891 $formatted_conversation = array();
8892
8893 // Add system message first
8894 $formatted_conversation[] = array(
8895 'role' => 'system',
8896 'content' => $system_prompt_instructions . " " . $relevant_content
8897 );
8898
8899 // Add the rest of the conversation history
8900 foreach ($conversation_history as $message) {
8901 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8902 $role = $message['role'];
8903
8904 // Convert roles to supported format
8905 if ($role === 'bot' || $role === 'agent') {
8906 $role = 'assistant';
8907 }
8908 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8909 $role = 'user';
8910 }
8911
8912 $formatted_conversation[] = array(
8913 'role' => $role,
8914 'content' => $message['content']
8915 );
8916 }
8917 }
8918
8919 $body = json_encode([
8920 'model' => $selected_model,
8921 'messages' => $formatted_conversation,
8922 'temperature' => 0.8,
8923 'stream' => false
8924 ]);
8925
8926 $args = [
8927 'body' => $body,
8928 'headers' => [
8929 'Content-Type' => 'application/json',
8930 'Authorization' => 'Bearer ' . $deepseek_api_key,
8931 ],
8932 'timeout' => 60,
8933 'redirection' => 5,
8934 'blocking' => true,
8935 'httpversion' => '1.0',
8936 'sslverify' => true,
8937 ];
8938
8939 $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', $args);
8940
8941 if (is_wp_error($response)) {
8942 $error_message = $response->get_error_message();
8943 //error_log('DeepSeek API Error: ' . $error_message);
8944 return [
8945 'error' => esc_html__('Connection error when contacting DeepSeek: ', 'mxchat') . esc_html($error_message),
8946 'error_code' => 'deepseek_connection_error',
8947 'provider' => 'deepseek'
8948 ];
8949 }
8950
8951 $status_code = wp_remote_retrieve_response_code($response);
8952 if ($status_code !== 200) {
8953 $response_body = wp_remote_retrieve_body($response);
8954 $decoded_response = json_decode($response_body, true);
8955
8956 $error_message = isset($decoded_response['error']['message'])
8957 ? $decoded_response['error']['message']
8958 : 'HTTP Error ' . $status_code;
8959
8960 $error_type = isset($decoded_response['error']['type'])
8961 ? $decoded_response['error']['type']
8962 : 'unknown';
8963
8964 //error_log('DeepSeek API HTTP Error: ' . $status_code . ' - ' . $error_message);
8965
8966 // Handle specific error types
8967 switch ($status_code) {
8968 case 401:
8969 return [
8970 'error' => esc_html__('Authentication failed with DeepSeek. Please check your API key.', 'mxchat'),
8971 'error_code' => 'deepseek_auth_error',
8972 'provider' => 'deepseek'
8973 ];
8974
8975 case 400:
8976 if (strpos($error_message, 'API key') !== false) {
8977 return [
8978 'error' => esc_html__('Invalid DeepSeek API key. Please check your API key configuration.', 'mxchat'),
8979 'error_code' => 'deepseek_invalid_api_key',
8980 'provider' => 'deepseek'
8981 ];
8982 }
8983 break;
8984
8985 case 429:
8986 if (strpos($error_message, 'quota') !== false) {
8987 return [
8988 'error' => esc_html__('DeepSeek API quota exceeded. Please check your billing details.', 'mxchat'),
8989 'error_code' => 'deepseek_quota_exceeded',
8990 'provider' => 'deepseek'
8991 ];
8992 } else {
8993 return [
8994 'error' => esc_html__('DeepSeek rate limit exceeded. Please try again later.', 'mxchat'),
8995 'error_code' => 'deepseek_rate_limit',
8996 'provider' => 'deepseek'
8997 ];
8998 }
8999
9000 case 500:
9001 case 502:
9002 case 503:
9003 case 504:
9004 return [
9005 'error' => esc_html__('DeepSeek service is currently unavailable. Please try again later.', 'mxchat'),
9006 'error_code' => 'deepseek_service_unavailable',
9007 'provider' => 'deepseek'
9008 ];
9009 }
9010
9011 // Generic error fallback
9012 return [
9013 'error' => esc_html__('DeepSeek API error: ', 'mxchat') . esc_html($error_message),
9014 'error_code' => 'deepseek_api_error',
9015 'provider' => 'deepseek',
9016 'status_code' => $status_code
9017 ];
9018 }
9019
9020 $response_body = wp_remote_retrieve_body($response);
9021 $decoded_response = json_decode($response_body, true);
9022
9023 if (isset($decoded_response['choices'][0]['message']['content'])) {
9024 return trim($decoded_response['choices'][0]['message']['content']);
9025 } else {
9026 //error_log('DeepSeek API Response Format Error: ' . print_r($decoded_response, true));
9027 return [
9028 'error' => esc_html__('Unexpected response format from DeepSeek.', 'mxchat'),
9029 'error_code' => 'deepseek_response_format_error',
9030 'provider' => 'deepseek'
9031 ];
9032 }
9033 } catch (Exception $e) {
9034 //error_log('DeepSeek Exception: ' . $e->getMessage());
9035 return [
9036 'error' => esc_html__('System error when processing DeepSeek request: ', 'mxchat') . esc_html($e->getMessage()),
9037 'error_code' => 'deepseek_exception',
9038 'provider' => 'deepseek'
9039 ];
9040 }
9041 }
9042 private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content) {
9043 // Get bot ID from session or request
9044 $bot_id = $this->get_current_bot_id($session_id);
9045
9046 // Get system prompt instructions using centralized function
9047 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9048
9049 // Add system prompt to relevant content
9050 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
9051
9052 // Format messages for Gemini API
9053 $formatted_messages = [];
9054
9055 // Add system message as the first user message with role prefix
9056 // Note: Gemini doesn't have a dedicated system role, so we use a prefixed user message
9057 $formatted_messages[] = [
9058 'role' => 'user',
9059 'parts' => [
9060 ['text' => "[System Instructions] " . $content_with_instructions]
9061 ]
9062 ];
9063
9064 // Add model response to acknowledge system instructions
9065 $formatted_messages[] = [
9066 'role' => 'model',
9067 'parts' => [
9068 ['text' => "I understand and will follow these instructions."]
9069 ]
9070 ];
9071
9072 // Process the rest of the conversation history
9073 $current_role = null;
9074 $current_parts = [];
9075
9076 foreach ($conversation_history as $message) {
9077 // Skip the first system message as we already handled it
9078 if ($message['role'] === 'system') {
9079 continue;
9080 }
9081
9082 // Map roles to Gemini format
9083 $gemini_role = '';
9084 if ($message['role'] === 'user') {
9085 $gemini_role = 'user';
9086 } else if (in_array($message['role'], ['assistant', 'bot', 'agent'])) {
9087 $gemini_role = 'model';
9088 } else {
9089 // Skip unsupported roles
9090 continue;
9091 }
9092
9093 // If we have a new role, add the previous message
9094 if ($current_role !== null && $current_role !== $gemini_role && !empty($current_parts)) {
9095 $formatted_messages[] = [
9096 'role' => $current_role,
9097 'parts' => $current_parts
9098 ];
9099 $current_parts = [];
9100 }
9101
9102 // Set current role and add text to parts
9103 $current_role = $gemini_role;
9104 $current_parts[] = ['text' => $message['content']];
9105 }
9106
9107 // Add the last message if there's content
9108 if ($current_role !== null && !empty($current_parts)) {
9109 $formatted_messages[] = [
9110 'role' => $current_role,
9111 'parts' => $current_parts
9112 ];
9113 }
9114
9115 // Build the request body
9116 $body = json_encode([
9117 'contents' => $formatted_messages,
9118 'generationConfig' => [
9119 'temperature' => 0.7,
9120 'topP' => 0.95,
9121 'topK' => 40,
9122 'maxOutputTokens' => 8192,
9123 ],
9124 'safetySettings' => [
9125 [
9126 'category' => 'HARM_CATEGORY_HARASSMENT',
9127 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9128 ],
9129 [
9130 'category' => 'HARM_CATEGORY_HATE_SPEECH',
9131 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9132 ],
9133 [
9134 'category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
9135 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9136 ],
9137 [
9138 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT',
9139 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
9140 ]
9141 ]
9142 ]);
9143
9144 // Prepare the API endpoint
9145 // Use v1beta for preview models (Gemini 3, experimental), v1 for stable models
9146 $api_version = (strpos($selected_model, 'preview') !== false || strpos($selected_model, 'exp') !== false) ? 'v1beta' : 'v1';
9147 $api_endpoint = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
9148
9149 // Set up the API request
9150 $args = [
9151 'body' => $body,
9152 'headers' => [
9153 'Content-Type' => 'application/json',
9154 ],
9155 'timeout' => 60,
9156 'redirection' => 5,
9157 'blocking' => true,
9158 'httpversion' => '1.0',
9159 'sslverify' => true,
9160 ];
9161
9162 // Make the API request
9163 $response = wp_remote_post($api_endpoint, $args);
9164
9165 // Process the response
9166 if (is_wp_error($response)) {
9167 return "Sorry, there was an error processing your request: " . $response->get_error_message();
9168 }
9169
9170 $response_body = json_decode(wp_remote_retrieve_body($response), true);
9171
9172 // Handle potential errors in the response
9173 if (isset($response_body['error'])) {
9174 //error_log('Gemini API Error: ' . json_encode($response_body['error']));
9175 return "Sorry, there was an error with the Gemini API: " .
9176 (isset($response_body['error']['message']) ? $response_body['error']['message'] : 'Unknown error');
9177 }
9178
9179 // Extract the response text
9180 if (isset($response_body['candidates'][0]['content']['parts'][0]['text'])) {
9181 return trim($response_body['candidates'][0]['content']['parts'][0]['text']);
9182 } else {
9183 //error_log('Unexpected Gemini API response format: ' . json_encode($response_body));
9184 return "Sorry, I couldn't process that request. The response format was unexpected.";
9185 }
9186 }
9187
9188
9189 public function test_streaming_request() {
9190 $options = get_option('mxchat_options', []);
9191 $model = $options['model'] ?? 'gpt-5.1-chat-latest';
9192
9193 // Detect provider from model prefix
9194 $provider = strtolower(explode('-', $model)[0]);
9195
9196 $sample_prompt = 'Hello! Can you stream this response back to me?';
9197 $messages = [['role' => 'user', 'content' => $sample_prompt]];
9198 $headers = [];
9199 $body = [];
9200 $url = '';
9201 $api_key = '';
9202
9203 switch ($provider) {
9204 case 'gpt':
9205 case 'o1':
9206 $api_key = $options['api_key'] ?? '';
9207 if (empty($api_key)) return '❌ Missing API key for OpenAI';
9208 $url = 'https://api.openai.com/v1/chat/completions';
9209 $headers = [
9210 'Content-Type: application/json',
9211 'Authorization: Bearer ' . $api_key
9212 ];
9213 $body = [
9214 'model' => $model,
9215 'messages' => $messages,
9216 'stream' => true
9217 ];
9218 break;
9219
9220 case 'claude':
9221 $api_key = $options['claude_api_key'] ?? '';
9222 if (empty($api_key)) return '❌ Missing API key for Claude';
9223 $url = 'https://api.anthropic.com/v1/messages';
9224 $headers = [
9225 'Content-Type: application/json',
9226 'x-api-key: ' . $api_key,
9227 'anthropic-version: 2023-06-01'
9228 ];
9229 $body = [
9230 'model' => $model,
9231 'messages' => $messages,
9232 'max_tokens' => 100,
9233 'stream' => true
9234 ];
9235 break;
9236
9237 case 'grok':
9238 $api_key = $options['xai_api_key'] ?? '';
9239 if (empty($api_key)) return '❌ Missing API key for X.AI';
9240 $url = 'https://api.x.ai/v1/chat/completions';
9241 $headers = [
9242 'Content-Type: application/json',
9243 'Authorization: Bearer ' . $api_key
9244 ];
9245 $body = [
9246 'model' => $model,
9247 'messages' => $messages,
9248 'stream' => true
9249 ];
9250 break;
9251
9252 case 'deepseek':
9253 if (empty($deepseek_api_key)) {
9254 $error_response = [
9255 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
9256 'error_code' => 'missing_deepseek_api_key'
9257 ];
9258 if ($testing_data !== null) {
9259 $error_response['testing_data'] = $testing_data;
9260 }
9261 return $error_response;
9262 }
9263 if ($streaming) {
9264 return $this->mxchat_generate_response_deepseek_stream(
9265 $selected_model,
9266 $deepseek_api_key,
9267 $conversation_history,
9268 $relevant_content,
9269 $session_id,
9270 $testing_data // Pass testing data
9271 );
9272 } else {
9273 $response = $this->mxchat_generate_response_deepseek(
9274 $selected_model,
9275 $deepseek_api_key,
9276 $conversation_history,
9277 $relevant_content
9278 );
9279 }
9280 break;
9281
9282 case 'gemini':
9283 $api_key = $options['gemini_api_key'] ?? '';
9284 if (empty($api_key)) return '❌ Missing API key for Gemini';
9285 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':streamGenerateContent?key=' . $api_key;
9286 $headers = ['Content-Type: application/json'];
9287 $body = [
9288 'contents' => [['role' => 'user', 'parts' => [['text' => $sample_prompt]]]],
9289 'generationConfig' => ['temperature' => 0.7]
9290 ];
9291 break;
9292
9293 default:
9294 return '❌ Unsupported provider: ' . $provider;
9295 }
9296
9297 // Do the actual streaming test
9298 $ch = curl_init($url);
9299 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
9300 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
9301 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
9302 curl_setopt($ch, CURLOPT_TIMEOUT, 15);
9303 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9304
9305 $response = curl_exec($ch);
9306 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
9307 $error = curl_error($ch);
9308 curl_close($ch);
9309
9310 if ($error) return "❌ cURL error: $error";
9311 if ($http_code !== 200) {
9312 $error_message = json_decode($response, true)['error']['message'] ?? 'Unknown';
9313 return "❌ HTTP $http_code: $error_message";
9314 }
9315
9316 return true;
9317 }
9318
9319 public function mxchat_dismiss_pre_chat_message() {
9320 // Get and sanitize the user identifier
9321 $user_id = $this->mxchat_get_user_identifier();
9322 $user_id = sanitize_key($user_id);
9323
9324 // Set a transient to track that the user has dismissed the pre-chat message
9325 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9326 set_transient($transient_key, true, DAY_IN_SECONDS);
9327
9328 wp_send_json_success();
9329 }
9330
9331 public function mxchat_check_pre_chat_message_status() {
9332 // Get and sanitize the user identifier
9333 $user_id = $this->mxchat_get_user_identifier();
9334 $user_id = sanitize_key($user_id);
9335
9336 // Check if the transient exists (i.e., if the message was dismissed)
9337 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9338 $dismissed = get_transient($transient_key);
9339
9340 // Log the result to see if it's being set correctly
9341 //error_log("Check pre-chat message dismissed for $user_id: " . ($dismissed ? 'Yes' : 'No'));
9342
9343 if ($dismissed) {
9344 wp_send_json_success(['dismissed' => true]);
9345 } else {
9346 wp_send_json_success(['dismissed' => false]);
9347 }
9348
9349 wp_die();
9350 }
9351
9352 private function mxchat_calculate_cosine_similarity($vectorA, $vectorB) {
9353 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
9354 return 0;
9355 }
9356
9357 $dotProduct = array_sum(array_map(function ($a, $b) {
9358 return $a * $b;
9359 }, $vectorA, $vectorB));
9360 $normA = sqrt(array_sum(array_map(function ($a) {
9361 return $a * $a;
9362 }, $vectorA)));
9363 $normB = sqrt(array_sum(array_map(function ($b) {
9364 return $b * $b;
9365 }, $vectorB)));
9366
9367 if ($normA == 0 || $normB == 0) {
9368 return 0;
9369 }
9370
9371 return $dotProduct / ($normA * $normB);
9372 }
9373
9374
9375 public function mxchat_enqueue_scripts_styles() {
9376 // Fetch options from the database first to check loading strategy
9377 $this->options = get_option('mxchat_options');
9378 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
9379
9380 // Always enqueue CSS immediately
9381 wp_enqueue_style(
9382 'mxchat-chat-css',
9383 plugin_dir_url(__FILE__) . '../css/chat-style.css',
9384 array(),
9385 MXCHAT_VERSION
9386 );
9387
9388 // Protect MxChat CSS from LiteSpeed UCSS/CCSS stripping via data-no-optimize attribute
9389 add_filter('style_loader_tag', function($tag, $handle) {
9390 if ($handle === 'mxchat-chat-css' || strpos($handle, 'mxchat') !== false) {
9391 $tag = str_replace("rel='stylesheet'", "rel='stylesheet' data-no-optimize='1'", $tag);
9392 $tag = str_replace('rel="stylesheet"', 'rel="stylesheet" data-no-optimize="1"', $tag);
9393 }
9394 return $tag;
9395 }, 10, 2);
9396
9397 // Handle script loading based on strategy
9398 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
9399 // Enqueue the script normally
9400 wp_enqueue_script(
9401 'mxchat-chat-js',
9402 plugin_dir_url(__FILE__) . '../js/chat-script.js',
9403 array('jquery'),
9404 MXCHAT_VERSION,
9405 true
9406 );
9407
9408 // Add defer attribute if strategy is 'defer'
9409 if ($loading_strategy === 'defer') {
9410 wp_script_add_data('mxchat-chat-js', 'strategy', 'defer');
9411 }
9412 } else {
9413 // For delay or interaction-based loading, we'll use a custom loader
9414 // Don't enqueue the main script - we'll load it dynamically
9415 add_action('wp_footer', array($this, 'mxchat_output_delayed_script_loader'), 99);
9416 }
9417
9418 // Protect MxChat JS from LiteSpeed optimization stripping via data-no-optimize attribute
9419 add_filter('script_loader_tag', function($tag, $handle) {
9420 if ($handle === 'mxchat-chat-js' || strpos($handle, 'mxchat') !== false) {
9421 $tag = str_replace('<script ', '<script data-no-optimize="1" ', $tag);
9422 }
9423 return $tag;
9424 }, 10, 2);
9425 $prompts_options = get_option('mxchat_prompts_options', array());
9426
9427 // Check if AI theme is active - if so, skip inline colors in JavaScript
9428 $theme_options = get_option('mxchat_theme_options', array());
9429 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9430 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9431 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9432
9433 // Prepare settings for JavaScript
9434 $style_settings = array(
9435 'ajax_url' => admin_url('admin-ajax.php'),
9436 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9437 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
9438 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
9439 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
9440 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
9441 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9442 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
9443 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
9444 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
9445 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
9446 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
9447 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
9448 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
9449 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
9450 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
9451 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
9452 'icon_color' => $this->options['icon_color'] ?? '#fff',
9453 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
9454 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
9455 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
9456 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
9457 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
9458 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
9459 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
9460 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
9461 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
9462 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9463 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off', // FIXED
9464 'initial_email_state' => null, // Also fixed this undefined variable
9465 'skip_email_check' => true,
9466 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9467 'skip_inline_colors' => $skip_inline_colors,
9468 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9469 );
9470
9471 // For normal/defer loading, use wp_localize_script
9472 // For delayed loading, we store settings in a transient to be output inline
9473 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
9474 wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
9475 } else {
9476 // Store settings for the delayed loader to use
9477 set_transient('mxchat_delayed_settings_' . get_current_user_id(), $style_settings, 60);
9478 }
9479 }
9480
9481 /**
9482 * Output the delayed script loader for performance optimization
9483 */
9484 public function mxchat_output_delayed_script_loader() {
9485 $this->options = get_option('mxchat_options');
9486 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
9487 $script_url = plugin_dir_url(__FILE__) . '../js/chat-script.js?ver=' . MXCHAT_VERSION;
9488
9489 // Get the stored settings
9490 $prompts_options = get_option('mxchat_prompts_options', array());
9491 $theme_options = get_option('mxchat_theme_options', array());
9492 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9493 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9494 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9495
9496 $style_settings = array(
9497 'ajax_url' => admin_url('admin-ajax.php'),
9498 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9499 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
9500 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
9501 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
9502 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
9503 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9504 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
9505 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
9506 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
9507 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
9508 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
9509 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
9510 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
9511 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
9512 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
9513 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
9514 'icon_color' => $this->options['icon_color'] ?? '#fff',
9515 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
9516 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
9517 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
9518 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
9519 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
9520 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
9521 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
9522 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
9523 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
9524 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9525 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off',
9526 'initial_email_state' => null,
9527 'skip_email_check' => true,
9528 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9529 'skip_inline_colors' => $skip_inline_colors,
9530 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9531 );
9532
9533 // Determine delay time based on strategy
9534 $delay_ms = 0;
9535 switch ($loading_strategy) {
9536 case 'delay_1s':
9537 $delay_ms = 1000;
9538 break;
9539 case 'delay_3s':
9540 $delay_ms = 3000;
9541 break;
9542 case 'delay_5s':
9543 $delay_ms = 5000;
9544 break;
9545 }
9546
9547 ?>
9548 <script type="text/javascript">
9549 (function() {
9550 var mxchatLoaded = false;
9551 var mxchatChat = <?php echo wp_json_encode($style_settings); ?>;
9552 window.mxchatChat = mxchatChat;
9553
9554 function loadMxChatScript() {
9555 if (mxchatLoaded) return;
9556 mxchatLoaded = true;
9557
9558 function appendChatScript() {
9559 var script = document.createElement('script');
9560 script.src = <?php echo wp_json_encode($script_url); ?>;
9561 script.type = 'text/javascript';
9562 document.body.appendChild(script);
9563 }
9564
9565 if (typeof jQuery !== 'undefined') {
9566 appendChatScript();
9567 } else {
9568 var jq = document.createElement('script');
9569 jq.src = <?php echo wp_json_encode(includes_url('js/jquery/jquery.min.js')); ?>;
9570 jq.onload = appendChatScript;
9571 document.body.appendChild(jq);
9572 }
9573 }
9574
9575 <?php if ($loading_strategy === 'on_interaction'): ?>
9576 // Load on user interaction
9577 var events = ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'];
9578 events.forEach(function(evt) {
9579 window.addEventListener(evt, loadMxChatScript, {once: true, passive: true});
9580 });
9581 // Fallback: load after 8 seconds if no interaction
9582 setTimeout(loadMxChatScript, 8000);
9583 <?php else: ?>
9584 // Load after specified delay
9585 setTimeout(loadMxChatScript, <?php echo intval($delay_ms); ?>);
9586 <?php endif; ?>
9587 })();
9588 </script>
9589 <?php
9590 }
9591
9592 /**
9593 * Setup the cron jobs for rate limits with guard against multiple calls
9594 */
9595 public function setup_rate_limit_cron_jobs() {
9596 // Add a guard to prevent multiple rapid calls
9597 $last_setup = get_transient('mxchat_cron_setup_guard');
9598 if ($last_setup && (time() - $last_setup) < 60) {
9599 // Don't run again if we ran less than 60 seconds ago
9600 return;
9601 }
9602
9603 // Set the guard
9604 set_transient('mxchat_cron_setup_guard', time(), 300); // 5 minutes
9605
9606 try {
9607 // First, check if WordPress cron is disabled
9608 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
9609 //error_log('MxChat: WordPress cron is disabled (DISABLE_WP_CRON = true), using fallback system');
9610 $this->setup_fallback_rate_limit_system();
9611 return;
9612 }
9613
9614 // Check if cron is already scheduled - if so, don't mess with it
9615 if (wp_next_scheduled('mxchat_reset_rate_limits')) {
9616 //error_log('MxChat: Rate limit cron already scheduled, skipping setup');
9617 return;
9618 }
9619
9620 // Clear any orphaned hooks (but don't loop indefinitely)
9621 $hooks_to_clear = [
9622 'mxchat_reset_rate_limits',
9623 'mxchat_reset_hourly_rate_limits',
9624 'mxchat_reset_daily_rate_limits',
9625 'mxchat_reset_weekly_rate_limits',
9626 'mxchat_reset_monthly_rate_limits'
9627 ];
9628
9629 foreach ($hooks_to_clear as $hook) {
9630 // Only clear a maximum of 3 instances to prevent infinite loops
9631 $cleared = 0;
9632 while (wp_next_scheduled($hook) && $cleared < 3) {
9633 wp_clear_scheduled_hook($hook);
9634 $cleared++;
9635 }
9636 }
9637
9638 // Small delay after clearing
9639 usleep(100000); // 0.1 seconds
9640
9641 // Try to schedule the event
9642 $initial_time = time() + 300; // Start in 5 minutes
9643 $result = wp_schedule_event($initial_time, 'hourly', 'mxchat_reset_rate_limits');
9644
9645 if ($result === false) {
9646 //error_log('MxChat: Failed to schedule cron, using fallback system');
9647 $this->setup_fallback_rate_limit_system();
9648 } else {
9649 //error_log('MxChat: Successfully scheduled rate limit reset cron');
9650 }
9651
9652 } catch (Exception $e) {
9653 //error_log('MxChat: Cron setup exception: ' . $e->getMessage());
9654 $this->setup_fallback_rate_limit_system();
9655 }
9656 }
9657
9658 /**
9659 * Try alternative cron scheduling methods
9660 */
9661 private function try_alternative_cron_scheduling($initial_time) {
9662 try {
9663 // Method 1: Try with current time instead of future time
9664 $result1 = wp_schedule_event(time(), 'hourly', 'mxchat_reset_rate_limits');
9665 if ($result1 !== false) {
9666 //error_log('MxChat: Alternative method 1 (current time) succeeded');
9667 return true;
9668 }
9669
9670 // Method 2: Try with a different interval
9671 $result2 = wp_schedule_event($initial_time, 'daily', 'mxchat_reset_rate_limits');
9672 if ($result2 !== false) {
9673 //error_log('MxChat: Alternative method 2 (daily interval) succeeded');
9674 return true;
9675 }
9676
9677 // Method 3: Try wp_schedule_single_event first, then recurring
9678 $result3 = wp_schedule_single_event($initial_time, 'mxchat_reset_rate_limits');
9679 if ($result3 !== false) {
9680 //error_log('MxChat: Alternative method 3 (single event) succeeded');
9681 // Schedule the next one manually in the handler
9682 return true;
9683 }
9684
9685 return false;
9686
9687 } catch (Exception $e) {
9688 //error_log('MxChat: Alternative cron scheduling exception: ' . $e->getMessage());
9689 return false;
9690 }
9691 }
9692
9693 /**
9694 * Enhanced fallback rate limit system
9695 */
9696 private function setup_fallback_rate_limit_system() {
9697 // Set a flag to use database-based rate limit cleanup
9698 update_option('mxchat_use_fallback_rate_limits', true);
9699
9700 // Schedule a one-time check to happen on the next plugin load
9701 update_option('mxchat_next_rate_limit_check', time() + 3600);
9702
9703 // Also set up a more frequent fallback check (every 4 hours)
9704 update_option('mxchat_fallback_check_interval', 4 * 3600);
9705
9706 //error_log('MxChat: Fallback rate limit system activated');
9707 }
9708
9709 /**
9710 * Enhanced fallback check method
9711 */
9712 public function check_fallback_rate_limits() {
9713 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9714
9715 if (!$use_fallback) {
9716 return; // Regular cron is working
9717 }
9718
9719 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9720 $check_interval = get_option('mxchat_fallback_check_interval', 3600);
9721
9722 if (time() >= $next_check) {
9723 //error_log('MxChat: Running fallback rate limit cleanup');
9724 $this->mxchat_reset_rate_limits();
9725
9726 // Schedule next check
9727 update_option('mxchat_next_rate_limit_check', time() + $check_interval);
9728 }
9729 }
9730 /**
9731 * Enhanced rate limit check that includes fallback cleanup and bot-specific rate limits
9732 */
9733 public function check_rate_limit() {
9734 // Check if we need to run fallback cleanup
9735 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9736 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9737
9738 if ($use_fallback && time() >= $next_check) {
9739 $this->mxchat_reset_rate_limits();
9740 update_option('mxchat_next_rate_limit_check', time() + 3600); // Next hour
9741 }
9742
9743 // Get bot ID from current request context
9744 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
9745
9746 // Get bot-specific options (includes rate limits if overridden)
9747 $bot_options = $this->get_bot_options($bot_id);
9748 $current_options = !empty($bot_options) ? $bot_options : $this->options;
9749
9750 // Use bot-specific rate limits if available, otherwise fall back to default
9751 $rate_limits_source = isset($current_options['rate_limits']) ? $current_options['rate_limits'] : get_option('mxchat_options', [])['rate_limits'] ?? [];
9752
9753 // Determine user role or if logged out
9754 if (is_user_logged_in()) {
9755 $user = wp_get_current_user();
9756 $user_id = $user->ID;
9757
9758 // Get the user's primary role using reset() to safely get the first element
9759 $user_roles = $user->roles;
9760
9761 // Safely get the first role regardless of array key structure
9762 if (!empty($user_roles) && is_array($user_roles)) {
9763 $role = reset($user_roles); // This safely gets the first element regardless of key
9764 } else {
9765 $role = 'subscriber'; // Default to subscriber if no role found
9766 }
9767 } else {
9768 $role = 'logged_out';
9769 // Use IP address for non-logged-in users
9770 $user_id = $this->get_client_ip();
9771 }
9772
9773 // Check if rate limits are configured for this role
9774 if (!isset($rate_limits_source[$role])) {
9775 return true; // No limit set for this role
9776 }
9777
9778 $limit = $rate_limits_source[$role]['limit'];
9779
9780 // If unlimited, return true immediately
9781 if ($limit === 'unlimited') {
9782 return true;
9783 }
9784
9785 // Get the option name for this user/role with safer naming (include bot_id for bot-specific limits)
9786 $safe_role = preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
9787 $safe_user_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $user_id);
9788 $safe_bot_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id);
9789
9790 // Include bot_id in option name so each bot has separate rate limits
9791 $option_name = 'mxchat_chat_limit_' . $safe_bot_id . '_' . $safe_role . '_' . $safe_user_id;
9792
9793 // Get the counter data
9794 $limit_data = get_option($option_name, ['count' => 0, 'timestamp' => time()]);
9795
9796 // If first request or counter reset needed, set the initial timestamp
9797 if ($limit_data['count'] === 0) {
9798 $limit_data['timestamp'] = time();
9799 update_option($option_name, $limit_data);
9800 }
9801
9802 // Get the timeframe
9803 $timeframe = isset($rate_limits_source[$role]['timeframe']) ?
9804 $rate_limits_source[$role]['timeframe'] : 'daily';
9805
9806 // Check if the counter needs to be reset based on timeframe
9807 $current_time = time();
9808 $timestamp = $limit_data['timestamp'];
9809 $should_reset = false;
9810
9811 switch ($timeframe) {
9812 case 'hourly':
9813 $should_reset = ($current_time - $timestamp) >= 3600; // 1 hour
9814 break;
9815 case 'daily':
9816 $should_reset = ($current_time - $timestamp) >= 86400; // 24 hours
9817 break;
9818 case 'weekly':
9819 $should_reset = ($current_time - $timestamp) >= 604800; // 7 days
9820 break;
9821 case 'monthly':
9822 $should_reset = ($current_time - $timestamp) >= 2592000; // 30 days
9823 break;
9824 }
9825
9826 // Reset the counter if the timeframe has passed
9827 if ($should_reset) {
9828 $limit_data = ['count' => 0, 'timestamp' => $current_time];
9829 update_option($option_name, $limit_data);
9830 }
9831
9832 // Check if user has exceeded their limit
9833 if ($limit_data['count'] >= intval($limit)) {
9834 // Get the custom message for this role
9835 $message = !empty($rate_limits_source[$role]['message'])
9836 ? $rate_limits_source[$role]['message']
9837 : __('Rate limit exceeded. Please try again later.', 'mxchat');
9838
9839 // Add timeframe information to the message if placeholders exist
9840 $timeframe_label = '';
9841 switch ($timeframe) {
9842 case 'hourly':
9843 $timeframe_label = __('hour', 'mxchat');
9844 break;
9845 case 'daily':
9846 $timeframe_label = __('day', 'mxchat');
9847 break;
9848 case 'weekly':
9849 $timeframe_label = __('week', 'mxchat');
9850 break;
9851 case 'monthly':
9852 $timeframe_label = __('month', 'mxchat');
9853 break;
9854 }
9855
9856 // Replace placeholders in the message
9857 $message = str_replace(
9858 ['{limit}', '{count}', '{remaining}', '{timeframe}'],
9859 [intval($limit), $limit_data['count'], max(0, intval($limit) - $limit_data['count']), $timeframe_label],
9860 $message
9861 );
9862
9863 // Process HTML links in the message
9864 $message = $this->process_rate_limit_message_html($message);
9865
9866 // Return error with the processed message
9867 return [
9868 'error' => true,
9869 'message' => $message
9870 ];
9871 }
9872
9873 // Increment the counter
9874 $limit_data['count']++;
9875 update_option($option_name, $limit_data);
9876
9877 return true;
9878 }
9879
9880 /**
9881 * Enhanced rate limit reset with better error handling
9882 */
9883 public function mxchat_reset_rate_limits() {
9884 try {
9885 global $wpdb;
9886 $all_options = get_option('mxchat_options', []);
9887 $current_time = time();
9888
9889 // Get rate limit options with a safer query and limit
9890 $option_names = $wpdb->get_col(
9891 $wpdb->prepare(
9892 "SELECT option_name FROM {$wpdb->options}
9893 WHERE option_name LIKE %s
9894 LIMIT 1000",
9895 'mxchat_chat_limit_%'
9896 )
9897 );
9898
9899 if (empty($option_names)) {
9900 return;
9901 }
9902
9903 $processed_count = 0;
9904 $max_processing_time = 30; // Maximum 30 seconds
9905 $start_time = time();
9906
9907 foreach ($option_names as $option_name) {
9908 // Check processing time limit
9909 if ((time() - $start_time) > $max_processing_time) {
9910 //error_log('MxChat: Rate limit reset timeout after processing ' . $processed_count . ' entries');
9911 break;
9912 }
9913
9914 // Parse the option name more safely
9915 if (!preg_match('/^mxchat_chat_limit_(.+)_(.+)$/', $option_name, $matches)) {
9916 continue;
9917 }
9918
9919 $role_and_user = $matches[1] . '_' . $matches[2];
9920 $parts = explode('_', $role_and_user);
9921
9922 if (count($parts) < 2) {
9923 continue;
9924 }
9925
9926 // Extract role (everything except the last part which is user ID)
9927 $user_id_part = array_pop($parts);
9928 $role = implode('_', $parts);
9929
9930 // Skip if role doesn't exist in our settings
9931 if (!isset($all_options['rate_limits'][$role])) {
9932 // Clean up orphaned entries
9933 delete_option($option_name);
9934 continue;
9935 }
9936
9937 $timeframe = $all_options['rate_limits'][$role]['timeframe'] ?? 'daily';
9938 $limit_data = get_option($option_name);
9939
9940 if (!$limit_data || !is_array($limit_data) || !isset($limit_data['timestamp'])) {
9941 // Clean up invalid entries
9942 delete_option($option_name);
9943 continue;
9944 }
9945
9946 $timestamp = $limit_data['timestamp'];
9947 $should_reset = false;
9948
9949 // Determine if we should reset based on the timeframe
9950 switch ($timeframe) {
9951 case 'hourly':
9952 $should_reset = ($current_time - $timestamp) >= 3600;
9953 break;
9954 case 'daily':
9955 $should_reset = ($current_time - $timestamp) >= 86400;
9956 break;
9957 case 'weekly':
9958 $should_reset = ($current_time - $timestamp) >= 604800;
9959 break;
9960 case 'monthly':
9961 $should_reset = ($current_time - $timestamp) >= 2592000;
9962 break;
9963 }
9964
9965 // Reset the counter if the timeframe has passed
9966 if ($should_reset) {
9967 delete_option($option_name);
9968 wp_cache_delete($option_name, 'options');
9969 $processed_count++;
9970 }
9971 }
9972
9973 // Clean up any orphaned cache entries
9974 wp_cache_delete('mxchat_all_chat_limits', 'options');
9975
9976 //error_log("MxChat: Rate limit reset completed. Processed {$processed_count} entries.");
9977
9978 } catch (Exception $e) {
9979 //error_log('MxChat: Rate limit reset error: ' . $e->getMessage());
9980 }
9981 }
9982
9983
9984 /**
9985 * Process HTML links in rate limit messages
9986 *
9987 * @param string $message The rate limit message
9988 * @return string The processed message with safe HTML links
9989 */
9990 private function process_rate_limit_message_html($message) {
9991 // Return original message if empty
9992 if (empty($message)) {
9993 return $message;
9994 }
9995
9996 // First, convert markdown links to HTML
9997 $message = $this->convert_markdown_links($message);
9998
9999 // Then, auto-convert any remaining plain URLs to links
10000 $message = $this->auto_link_urls($message);
10001
10002 // Allow basic HTML tags for links and formatting
10003 $allowed_tags = [
10004 'a' => [
10005 'href' => true,
10006 'target' => true,
10007 'rel' => true,
10008 'title' => true,
10009 'class' => true
10010 ],
10011 'strong' => [],
10012 'em' => [],
10013 'br' => [],
10014 'b' => [],
10015 'i' => [],
10016 'span' => ['class' => true]
10017 ];
10018
10019 // Sanitize but allow the specified HTML tags
10020 $processed_message = wp_kses($message, $allowed_tags);
10021
10022 // If wp_kses stripped everything, return the original message as plain text
10023 if (empty($processed_message) && !empty($message)) {
10024 // Strip all HTML and return plain text as fallback
10025 return wp_strip_all_tags($message);
10026 }
10027
10028 return $processed_message;
10029 }
10030
10031 /**
10032 * Convert markdown links to HTML
10033 *
10034 * @param string $text The text to process
10035 * @return string The text with markdown links converted to HTML
10036 */
10037 private function convert_markdown_links($text) {
10038 // Return original text if empty
10039 if (empty($text)) {
10040 return $text;
10041 }
10042
10043 // Pattern to match markdown links: [text](url)
10044 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
10045
10046 $processed_text = preg_replace_callback($pattern, function($matches) {
10047 $link_text = $matches[1];
10048 $url = $matches[2];
10049
10050 // Clean up any trailing punctuation from the URL
10051 $url = rtrim($url, '.,;:!?');
10052
10053 // Sanitize the link text and URL
10054 $safe_text = esc_html($link_text);
10055 $safe_url = esc_url($url);
10056
10057 // Create the HTML link
10058 return '<a href="' . $safe_url . '" target="_blank" rel="noopener noreferrer">' . $safe_text . '</a>';
10059 }, $text);
10060
10061 // If preg_replace_callback failed, return original text
10062 if ($processed_text === null) {
10063 return $text;
10064 }
10065
10066 return $processed_text;
10067 }
10068
10069 /**
10070 * Auto-convert plain URLs to clickable links
10071 *
10072 * @param string $text The text to process
10073 * @return string The text with URLs converted to links
10074 */
10075 private function auto_link_urls($text) {
10076 // Return original text if empty
10077 if (empty($text)) {
10078 return $text;
10079 }
10080
10081 // Simple pattern that avoids complex lookbehinds
10082 // This will match URLs that are not already inside href attributes or markdown links
10083 $pattern = '/(?<!href=["\'])(?<!\]\()https?:\/\/[^\s<>"\')\]]+/i';
10084
10085 $processed_text = preg_replace_callback($pattern, function($matches) {
10086 $url = $matches[0];
10087 // Clean up any trailing punctuation that might have been captured
10088 $url = rtrim($url, '.,;:!?');
10089
10090 // Add target="_blank" and rel="noopener noreferrer" for security
10091 return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
10092 }, $text);
10093
10094 // If preg_replace_callback failed, return original text
10095 if ($processed_text === null) {
10096 return $text;
10097 }
10098
10099 return $processed_text;
10100 }
10101
10102
10103 // Helper function to get client IP address
10104 private function get_client_ip() {
10105 // Check for shared internet/ISP IP
10106 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
10107 return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
10108 }
10109
10110 // Check for IPs passing through proxies
10111 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
10112 // Use the first value in the comma-separated list
10113 $forwarded_for = explode(',', sanitize_text_field($_SERVER['HTTP_X_FORWARDED_FOR']));
10114 return trim($forwarded_for[0]);
10115 }
10116
10117 if (!empty($_SERVER['REMOTE_ADDR'])) {
10118 return sanitize_text_field($_SERVER['REMOTE_ADDR']);
10119 }
10120
10121 // Fallback
10122 return 'unknown';
10123 }
10124
10125 /**
10126 * AJAX handler to get system information for testing panel
10127 */
10128 /**
10129 * AJAX handler to get system information for testing panel
10130 */
10131 public function mxchat_get_system_info() {
10132 // Verify nonce for security
10133 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10134 wp_send_json_error(['message' => 'Invalid nonce']);
10135 return;
10136 }
10137
10138 // Only allow admin users
10139 if (!current_user_can('administrator')) {
10140 wp_send_json_error(['message' => 'Unauthorized']);
10141 return;
10142 }
10143
10144 // Get system prompt from options
10145 $system_prompt = isset($this->options['system_prompt_instructions'])
10146 ? $this->options['system_prompt_instructions']
10147 : 'No system prompt configured';
10148
10149 // Get selected model
10150 $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest';
10151
10152 // Check if OpenRouter is being used
10153 $is_openrouter = ($selected_model === 'openrouter');
10154 $openrouter_model = '';
10155
10156 if ($is_openrouter) {
10157 // Get the actual OpenRouter model that's selected
10158 $openrouter_model = isset($this->options['openrouter_selected_model'])
10159 ? $this->options['openrouter_selected_model']
10160 : 'No OpenRouter model selected';
10161
10162 // Update selected_model display to show both
10163 $selected_model = 'OpenRouter: ' . $openrouter_model;
10164 }
10165
10166 // Get API key status (just check if they exist, don't expose the keys)
10167 $api_status = [];
10168 $api_status['openai'] = !empty($this->options['api_key']);
10169 $api_status['claude'] = !empty($this->options['claude_api_key']);
10170 $api_status['gemini'] = !empty($this->options['gemini_api_key']);
10171 $api_status['xai'] = !empty($this->options['xai_api_key']);
10172 $api_status['deepseek'] = !empty($this->options['deepseek_api_key']);
10173 $api_status['openrouter'] = !empty($this->options['openrouter_api_key']);
10174
10175 wp_send_json_success([
10176 'system_prompt' => $system_prompt,
10177 'selected_model' => $selected_model,
10178 'is_openrouter' => $is_openrouter,
10179 'openrouter_model' => $openrouter_model,
10180 'api_status' => $api_status
10181 ]);
10182 }
10183
10184 /**
10185 * AJAX handler to get similarity threshold
10186 */
10187 public function mxchat_get_similarity_threshold() {
10188 // Verify nonce for security
10189 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10190 wp_send_json_error(['message' => 'Invalid nonce']);
10191 return;
10192 }
10193
10194 // Only allow admin users
10195 if (!current_user_can('administrator')) {
10196 wp_send_json_error(['message' => 'Unauthorized']);
10197 return;
10198 }
10199
10200 // Get similarity threshold from main options (default 35%)
10201 $similarity_threshold = isset($this->options['similarity_threshold'])
10202 ? ((int) $this->options['similarity_threshold']) / 100
10203 : 0.35;
10204
10205 wp_send_json_success([
10206 'threshold' => $similarity_threshold,
10207 'threshold_percentage' => ($similarity_threshold * 100) . '%'
10208 ]);
10209 }
10210
10211 /**
10212 * AJAX handler to get knowledge base status
10213 */
10214 public function mxchat_get_kb_status() {
10215 // Verify nonce for security
10216 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10217 wp_send_json_error(['message' => 'Invalid nonce']);
10218 return;
10219 }
10220
10221 // Only allow admin users
10222 if (!current_user_can('administrator')) {
10223 wp_send_json_error(['message' => 'Unauthorized']);
10224 return;
10225 }
10226
10227 // Check OpenAI Vector Store first (takes priority)
10228 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
10229 $use_vectorstore = (isset($vectorstore_options['mxchat_use_openai_vectorstore']) && $vectorstore_options['mxchat_use_openai_vectorstore'] === '1');
10230
10231 if ($use_vectorstore) {
10232 $vectorstore_ids = $vectorstore_options['mxchat_openai_vectorstore_ids'] ?? '';
10233 $id_count = !empty($vectorstore_ids) ? count(array_filter(array_map('trim', explode(',', $vectorstore_ids)))) : 0;
10234
10235 $kb_info = [
10236 'type' => 'OpenAI Vector Store',
10237 'status' => 'Active',
10238 'documents' => $id_count > 0 ? $id_count . ' vector store' . ($id_count > 1 ? 's' : '') . ' configured' : 'No vector stores configured'
10239 ];
10240
10241 wp_send_json_success($kb_info);
10242 return;
10243 }
10244
10245 // Check Pinecone vs WordPress
10246 $addon_options = get_option('mxchat_pinecone_addon_options', array());
10247 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
10248
10249 $kb_info = [
10250 'type' => $use_pinecone ? 'Pinecone' : 'WordPress Database',
10251 'status' => 'Active'
10252 ];
10253
10254 // Get document count
10255 if ($use_pinecone) {
10256 $kb_info['documents'] = 'Connected to Pinecone';
10257 $kb_info['api_configured'] = !empty($addon_options['mxchat_pinecone_api_key']);
10258 } else {
10259 // Count documents in WordPress database
10260 global $wpdb;
10261 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
10262 $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
10263 $kb_info['documents'] = $count ? $count . ' documents' : 'No documents';
10264 }
10265
10266 wp_send_json_success($kb_info);
10267 }
10268
10269 /**
10270 * AJAX handler to start a completely fresh session (NEW - replaces old clear session)
10271 */
10272 public function mxchat_start_fresh_session() {
10273 // Verify nonce for security
10274 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
10275 wp_send_json_error(['message' => 'Invalid nonce']);
10276 return;
10277 }
10278
10279 // Only allow admin users
10280 if (!current_user_can('administrator')) {
10281 wp_send_json_error(['message' => 'Unauthorized']);
10282 return;
10283 }
10284
10285 $old_session_id = isset($_POST['old_session_id']) ? sanitize_text_field($_POST['old_session_id']) : '';
10286 $new_session_id = isset($_POST['new_session_id']) ? sanitize_text_field($_POST['new_session_id']) : '';
10287
10288 if (empty($old_session_id)) {
10289 wp_send_json_error(['message' => 'Old session ID required']);
10290 return;
10291 }
10292
10293 // If no new session ID provided, generate one
10294 if (empty($new_session_id)) {
10295 $new_session_id = 'mxchat_chat_' . substr(md5(uniqid()), 0, 9);
10296 }
10297
10298 // Clear ALL data associated with the old session
10299 $this->clear_complete_session_data($old_session_id);
10300
10301 // Initialize the new session
10302 $this->initialize_fresh_session($new_session_id);
10303
10304 wp_send_json_success([
10305 'message' => 'Fresh session started successfully',
10306 'new_session_id' => $new_session_id,
10307 'old_session_id' => $old_session_id
10308 ]);
10309 }
10310
10311 /**
10312 * Clear ALL data associated with a session (ENHANCED)
10313 */
10314 private function clear_complete_session_data($session_id) {
10315 // Clear chat history
10316 delete_option("mxchat_history_{$session_id}");
10317
10318 // Clear chat mode
10319 delete_option("mxchat_mode_{$session_id}");
10320
10321 // Clear any PDF/Word transients
10322 $this->clear_pdf_transients($session_id);
10323 if (method_exists($this, 'clear_word_transients')) {
10324 $this->clear_word_transients($session_id);
10325 }
10326
10327 // Clear agent-related data
10328 delete_option("mxchat_channel_{$session_id}");
10329 delete_option("mxchat_agent_name_{$session_id}");
10330 delete_option("mxchat_email_{$session_id}");
10331
10332 // Clear any recommendation flow state
10333 delete_option("mxchat_sr_flow_state_{$session_id}");
10334
10335 // Clear any cached embeddings or context
10336 delete_transient("mxchat_context_{$session_id}");
10337 delete_transient("mxchat_last_query_{$session_id}");
10338
10339 // Clear any testing data
10340 delete_transient("mxchat_testing_data_{$session_id}");
10341
10342 // Clear any rate limiting data for this session
10343 delete_transient("mxchat_rate_limit_{$session_id}");
10344
10345 // Clear any other session-specific transients
10346 delete_transient("mxchat_waiting_for_pdf_url_{$session_id}");
10347 delete_transient("mxchat_include_pdf_in_context_{$session_id}");
10348 delete_transient("mxchat_include_word_in_context_{$session_id}");
10349
10350 // Clear form addon state (pending forms and submitted forms)
10351 delete_option("mxchat_pending_form_{$session_id}");
10352 delete_option("mxchat_submitted_forms_{$session_id}");
10353
10354 //error_log("MxChat: Cleared all data for session: {$session_id}");
10355 }
10356
10357 /**
10358 * Initialize a fresh session with default data
10359 */
10360 private function initialize_fresh_session($session_id) {
10361 // Set default chat mode
10362 update_option("mxchat_mode_{$session_id}", 'ai');
10363
10364 //error_log("MxChat: Initialized fresh session: {$session_id}");
10365 }
10366
10367 /**
10368 * Helper method to clear Word document transients (if you have Word support)
10369 */
10370 private function clear_word_transients($session_id) {
10371 delete_transient('mxchat_word_url_' . $session_id);
10372 delete_transient('mxchat_word_filename_' . $session_id);
10373 delete_transient('mxchat_word_embeddings_' . $session_id);
10374 delete_transient('mxchat_include_word_in_context_' . $session_id);
10375 }
10376
10377 /**
10378 * Simplified testing data capture method (CLEANED UP)
10379 */
10380 private function capture_testing_data($user_embedding, $message, $session_id) {
10381 // Only capture for admin users
10382 if (!current_user_can('administrator')) {
10383 return null;
10384 }
10385
10386 $testing_data = [
10387 'query' => $message,
10388 'timestamp' => time(),
10389 'top_matches' => [],
10390 'action_matches' => [] // Add action matches
10391 ];
10392
10393 // Get similarity threshold
10394 $similarity_threshold = isset($this->options['similarity_threshold'])
10395 ? ((int) $this->options['similarity_threshold']) / 100
10396 : 0.35;
10397
10398 $testing_data['similarity_threshold'] = $similarity_threshold;
10399
10400 // Use the real similarity analysis if available
10401 if ($this->last_similarity_analysis !== null) {
10402 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
10403 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
10404 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10405 } else {
10406 // Fallback: determine knowledge base type
10407 $addon_options = get_option('mxchat_pinecone_addon_options', array());
10408 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
10409
10410 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
10411 }
10412
10413 // Include action analysis if available
10414 if (isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
10415 $testing_data['action_matches'] = $this->last_action_analysis;
10416
10417 // Clear it after capturing to avoid stale data
10418 $this->last_action_analysis = null;
10419 }
10420
10421 return $testing_data;
10422 }
10423
10424
10425 /**
10426 * Track URL clicks from chatbot responses
10427 */
10428 public function mxchat_track_url_click() {
10429 // Verify nonce for security
10430 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10431 wp_send_json_error(['message' => 'Invalid nonce']);
10432 wp_die();
10433 }
10434
10435 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10436 $clicked_url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
10437 $message_context = isset($_POST['message_context']) ? sanitize_textarea_field($_POST['message_context']) : '';
10438
10439 if (empty($session_id) || empty($clicked_url)) {
10440 wp_send_json_error(['message' => 'Missing required data']);
10441 wp_die();
10442 }
10443
10444 global $wpdb;
10445 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
10446
10447 // Insert click tracking record
10448 $wpdb->insert(
10449 $table_name,
10450 [
10451 'session_id' => $session_id,
10452 'clicked_url' => $clicked_url,
10453 'message_context' => $message_context,
10454 'click_timestamp' => current_time('mysql', 1),
10455 'user_ip' => $_SERVER['REMOTE_ADDR'],
10456 'user_agent' => $_SERVER['HTTP_USER_AGENT']
10457 ]
10458 );
10459
10460 wp_send_json_success(['message' => 'Click tracked']);
10461 wp_die();
10462 }
10463
10464 /**
10465 * Get URL click analytics for a session
10466 */
10467 public function mxchat_get_url_clicks($session_id) {
10468 global $wpdb;
10469 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
10470
10471 $clicks = $wpdb->get_results($wpdb->prepare(
10472 "SELECT * FROM $table_name WHERE session_id = %s ORDER BY click_timestamp ASC",
10473 $session_id
10474 ));
10475
10476 return $clicks;
10477 }
10478 /**
10479 * Track the originating page where chat was started
10480 */
10481 public function mxchat_track_originating_page() {
10482 // Verify nonce
10483 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10484 wp_send_json_error(['message' => 'Invalid nonce']);
10485 wp_die();
10486 }
10487
10488 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10489 $page_url = isset($_POST['page_url']) ? esc_url_raw($_POST['page_url']) : '';
10490 $page_title = isset($_POST['page_title']) ? sanitize_text_field($_POST['page_title']) : '';
10491
10492 if (empty($session_id)) {
10493 wp_send_json_error(['message' => 'Missing session ID']);
10494 wp_die();
10495 }
10496
10497 global $wpdb;
10498 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
10499
10500 // Check if we've already tracked for this session
10501 $existing = $wpdb->get_var($wpdb->prepare(
10502 "SELECT COUNT(*) FROM $table_name
10503 WHERE session_id = %s
10504 AND originating_page_url IS NOT NULL",
10505 $session_id
10506 ));
10507
10508 if ($existing > 0) {
10509 wp_send_json_success(['message' => 'Already tracked']);
10510 wp_die();
10511 }
10512
10513 // Update the first message in this session with originating page info
10514 $wpdb->query($wpdb->prepare(
10515 "UPDATE $table_name
10516 SET originating_page_url = %s,
10517 originating_page_title = %s
10518 WHERE session_id = %s
10519 ORDER BY timestamp ASC
10520 LIMIT 1",
10521 $page_url,
10522 $page_title,
10523 $session_id
10524 ));
10525
10526 wp_send_json_success(['message' => 'Originating page tracked']);
10527 wp_die();
10528 }
10529
10530 /**
10531 * Validate and clean URLs from AI response
10532 * Removes any URLs that aren't in the knowledge base
10533 *
10534 * @param string $response_text The AI-generated response
10535 * @param array $valid_urls Array of URLs from the knowledge base
10536 * @return string Cleaned response with invalid URLs removed/flagged
10537 */
10538 private function validate_and_clean_urls($response_text, $valid_urls) {
10539 // DEBUG: Log what we're working with
10540 //error_log("=== MxChat URL Validation Debug ===");
10541 //error_log("Valid URLs count: " . count($valid_urls));
10542 //error_log("Valid URLs: " . print_r($valid_urls, true));
10543 //error_log("Response text length: " . strlen($response_text));
10544 //error_log("Response text preview: " . substr($response_text, 0, 500));
10545
10546 // If no valid URLs provided or empty response, return as-is
10547 if (empty($valid_urls) || empty($response_text)) {
10548 //error_log("Validation skipped - empty valid_urls or response");
10549 return $response_text;
10550 }
10551
10552 // Extract all URLs from the AI response
10553 // This regex matches http:// and https:// URLs
10554 preg_match_all(
10555 '#\bhttps?://[^\s<>"\')\]]+#i',
10556 $response_text,
10557 $matches
10558 );
10559
10560 // If no URLs found in response, return as-is
10561 if (empty($matches[0])) {
10562 //error_log("No URLs found in response");
10563 return $response_text;
10564 }
10565
10566 $found_urls = $matches[0];
10567 $cleaned_response = $response_text;
10568 $removed_count = 0;
10569
10570 // Normalize valid URLs for comparison (remove trailing slashes, fragments, etc.)
10571 $normalized_valid_urls = array_map(function($url) {
10572 // Remove trailing slash
10573 $url = rtrim($url, '/');
10574 // Remove URL fragments (#section)
10575 $url = preg_replace('/#.*$/', '', $url);
10576 // Remove trailing punctuation that might have been captured
10577 $url = rtrim($url, '.,;:!?');
10578 return $url;
10579 }, $valid_urls);
10580
10581 //error_log("Normalized valid URLs: " . print_r($normalized_valid_urls, true));
10582
10583 foreach ($found_urls as $found_url) {
10584 // Clean up the found URL (remove trailing punctuation that might have been captured)
10585 $clean_found_url = rtrim($found_url, '.,;:!?)');
10586
10587 // DEBUG: Log each URL being checked
10588 //error_log("Checking found URL: " . $found_url);
10589
10590 // Normalize for comparison
10591 $normalized_found = rtrim($clean_found_url, '/');
10592 $normalized_found = preg_replace('/#.*$/', '', $normalized_found);
10593
10594 //error_log("Normalized found URL: " . $normalized_found);
10595
10596 // Check if this URL exists in our valid URLs list
10597 $is_valid = false;
10598
10599 //error_log("Starting validation checks for: " . $normalized_found);
10600
10601 // First, try exact match
10602 if (in_array($normalized_found, $normalized_valid_urls)) {
10603 $is_valid = true;
10604 //error_log("EXACT MATCH FOUND");
10605 } else {
10606 //error_log("No exact match, checking variations...");
10607 // If no exact match, check if it's a variation (with query params, etc.)
10608 foreach ($normalized_valid_urls as $valid_url) {
10609 //error_log(" Comparing against valid URL: " . $valid_url);
10610
10611 // Check if the found URL starts with a valid URL (handles query params)
10612 if (strpos($normalized_found, $valid_url) === 0) {
10613 // Check what comes after the valid URL
10614 $remainder = substr($normalized_found, strlen($valid_url));
10615
10616 // Only valid if:
10617 // 1. Exact match (remainder is empty)
10618 // 2. Query params (starts with ?)
10619 // 3. Fragment (starts with #)
10620 if (empty($remainder) || $remainder[0] === '?' || $remainder[0] === '#') {
10621 $is_valid = true;
10622 //error_log(" MATCH: Found URL is valid variation of base URL");
10623 break;
10624 } else {
10625 //error_log(" NOT A MATCH: Found URL extends path beyond valid URL (remainder: " . $remainder . ")");
10626 }
10627 }
10628 // Also check the reverse (in case valid URL has query params)
10629 if (strpos($valid_url, $normalized_found) === 0) {
10630 $is_valid = true;
10631 //error_log(" MATCH: Valid URL starts with found URL");
10632 break;
10633 }
10634 }
10635
10636 if (!$is_valid) {
10637 //error_log("NO MATCH FOUND - URL should be removed");
10638 }
10639 }
10640
10641 // If URL is not valid, remove it from the response
10642 if (!$is_valid) {
10643 // Log the removal for debugging
10644 //error_log("MxChat: Removed hallucinated URL: " . $found_url);
10645 //error_log("MxChat: Valid URLs were: " . implode(', ', array_slice($normalized_valid_urls, 0, 5)));
10646
10647 $removed_count++;
10648
10649 // Check if URL is part of a markdown link: [text](url)
10650 $markdown_pattern = '/\[([^\]]+)\]\(' . preg_quote($found_url, '/') . '\)/';
10651 if (preg_match($markdown_pattern, $cleaned_response)) {
10652 //error_log("Found markdown link, removing but keeping text");
10653 // Remove the markdown link but keep the text
10654 $cleaned_response = preg_replace($markdown_pattern, '$1', $cleaned_response);
10655 }
10656 // Check if URL is part of an HTML link: <a href="url">text</a>
10657 else if (preg_match('/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>(.*?)<\/a>/i', $cleaned_response, $link_match)) {
10658 //error_log("Found HTML link, removing but keeping text");
10659 // Remove the HTML link but keep the text
10660 $link_text = $link_match[1];
10661 $cleaned_response = preg_replace(
10662 '/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>.*?<\/a>/i',
10663 $link_text,
10664 $cleaned_response
10665 );
10666 }
10667 // Otherwise just remove the bare URL
10668 else {
10669 //error_log("Removing bare URL");
10670 $cleaned_response = str_replace($found_url, '', $cleaned_response);
10671 }
10672 }
10673 }
10674
10675 // Log summary if any URLs were removed
10676 if ($removed_count > 0) {
10677 //error_log("MxChat: URL Validation Summary - Removed {$removed_count} hallucinated URL(s)");
10678 } else {
10679 //error_log("MxChat: URL Validation Summary - No URLs removed, all were valid");
10680 }
10681
10682 // Clean up any double spaces or awkward punctuation left behind
10683 // IMPORTANT: Only collapse horizontal whitespace (spaces/tabs), preserve newlines for markdown formatting
10684 $cleaned_response = preg_replace('/[^\S\n]+/', ' ', $cleaned_response); // Collapse spaces/tabs but NOT newlines
10685 $cleaned_response = preg_replace('/[^\S\n]+([.,;:!?])/', '$1', $cleaned_response); // Same for punctuation cleanup
10686
10687 //error_log("Final cleaned response: " . $cleaned_response);
10688
10689 return trim($cleaned_response);
10690 }
10691
10692 /**
10693 * AJAX handler to get current chat mode for a session
10694 */
10695 public function mxchat_get_current_chat_mode() {
10696 // Verify nonce for security
10697 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10698 wp_send_json_error(['message' => 'Invalid nonce']);
10699 wp_die();
10700 }
10701
10702 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10703
10704 if (empty($session_id)) {
10705 wp_send_json_error(['message' => 'Session ID missing']);
10706 wp_die();
10707 }
10708
10709 // Get the current chat mode for this session
10710 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
10711
10712 wp_send_json_success([
10713 'chat_mode' => $chat_mode
10714 ]);
10715 wp_die();
10716 }
10717
10718
10719
10720 }
10721 ?>
10722