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

10,090 lines 406.3 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 $is_streaming = false; // ADDED: Track if current request is streaming
16 private $streaming_headers_sent = false; // Track if streaming headers have been sent
17
18 /**
19 * Setup streaming headers - call this right before actually streaming
20 * This delays header setup to allow actions/forms to return JSON responses
21 */
22 private function setup_streaming_headers() {
23 if ($this->streaming_headers_sent || headers_sent()) {
24 return false;
25 }
26
27 // Disable output buffering
28 while (ob_get_level()) {
29 ob_end_flush();
30 }
31
32 // Set headers for SSE
33 header('Content-Type: text/event-stream');
34 header('Cache-Control: no-cache');
35 header('Connection: keep-alive');
36 header('X-Accel-Buffering: no');
37
38 ob_implicit_flush(true);
39 flush();
40
41 $this->streaming_headers_sent = true;
42 return true;
43 }
44
45 /**
46 * Class constructor
47 */
48 public function __construct() {
49 $this->options = get_option('mxchat_options');
50 $this->prompts_options = get_option('mxchat_prompts_options', array());
51 $this->chat_count = get_option('mxchat_chat_count', 0);
52 $this->word_handler = new MXChat_Word_Handler($this->options);
53
54 // Add all action hooks
55 add_action('wp_enqueue_scripts', array($this, 'mxchat_enqueue_scripts_styles'));
56 add_action('wp_ajax_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
57 add_action('wp_ajax_nopriv_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
58 add_action('wp_ajax_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
59 add_action('wp_ajax_nopriv_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
60
61 // Add the AJAX actions for checking if the pre-chat message was dismissed
62 add_action('wp_ajax_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
63 add_action('wp_ajax_nopriv_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
64 add_action('wp_ajax_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
65 add_action('wp_ajax_nopriv_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
66 add_action('wp_ajax_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
67 add_action('wp_ajax_nopriv_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
68
69 // Add REST API routes registration
70 add_action('rest_api_init', array($this, 'register_routes'));
71 add_action('wp_ajax_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
72 add_action('wp_ajax_nopriv_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
73
74 // Rate limit action - notice we removed the old schedule setup
75 add_action('mxchat_reset_rate_limits', array($this, 'mxchat_reset_rate_limits'));
76
77 // File upload and handling actions
78 add_action('wp_ajax_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
79 add_action('wp_ajax_nopriv_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
80 add_action('wp_ajax_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
81 add_action('wp_ajax_nopriv_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
82
83 // Word document handling actions
84 add_action('wp_ajax_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
85 add_action('wp_ajax_nopriv_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
86 add_action('wp_ajax_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
87 add_action('wp_ajax_nopriv_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
88 add_action('wp_ajax_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
89 add_action('wp_ajax_nopriv_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
90
91 // Email handling actions
92 add_action('wp_ajax_nopriv_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
93 add_action('wp_ajax_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
94 add_action('wp_ajax_nopriv_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
95 add_action('wp_ajax_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
96
97 add_action('wp_ajax_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
98 add_action('wp_ajax_nopriv_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
99
100 // Testing panel AJAX actions
101 add_action('wp_ajax_mxchat_get_system_info', array($this, 'mxchat_get_system_info'));
102 add_action('wp_ajax_mxchat_get_similarity_threshold', array($this, 'mxchat_get_similarity_threshold'));
103 add_action('wp_ajax_mxchat_get_kb_status', array($this, 'mxchat_get_kb_status'));
104 add_action('wp_ajax_mxchat_start_fresh_session', array($this, 'mxchat_start_fresh_session'));
105 // Add to your existing constructor, in the section with other AJAX actions:
106 add_action('wp_ajax_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
107 add_action('wp_ajax_nopriv_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
108 add_action('wp_ajax_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
109 add_action('wp_ajax_nopriv_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
110 // Add chat mode checking actions
111 add_action('wp_ajax_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
112 add_action('wp_ajax_nopriv_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
113
114 // Auto-email transcript action
115 add_action('mxchat_send_delayed_transcript', array($this, 'mxchat_send_delayed_transcript'), 10, 1);
116
117 add_filter('mxchat_check_actions_only', array($this, 'check_actions_for_addons'), 10, 4);
118
119
120 }
121
122 // In your core plugin's check_actions_for_addons method:
123 public function check_actions_for_addons($default, $message, $user_id, $session_id) {
124 //error_log('MxChat Core: check_actions_for_addons called with message: ' . $message);
125
126 $result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
127
128 //error_log('MxChat Core: Intent check result = ' . ($result === false ? 'false' : 'true'));
129
130 return $result;
131 }
132
133 private function mxchat_increment_chat_count() {
134 $chat_count = get_option('mxchat_chat_count', 0);
135 $chat_count++;
136 update_option('mxchat_chat_count', $chat_count);
137 }
138
139 function mxchat_fetch_conversation_history() {
140 if (empty($_POST['session_id'])) {
141 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
142 wp_die();
143 }
144
145 $session_id = sanitize_text_field($_POST['session_id']);
146
147 // SECURITY FIX: Verify session ownership before retrieving data
148 // If IP/user changed, signal frontend to reset session instead of blocking
149 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
150
151 // Check if this session has an owner recorded
152 $session_owner = get_option("mxchat_session_owner_{$session_id}");
153
154 // If session has an owner and it doesn't match current user, trigger session reset
155 if ($session_owner && $session_owner !== $current_user_identifier) {
156 wp_send_json_error([
157 'message' => esc_html__('Your session has expired. Starting a new conversation.', 'mxchat'),
158 'code' => 'session_expired',
159 'action' => 'reset_session'
160 ]);
161 wp_die();
162 }
163
164 // If no owner is set yet, claim ownership (for legacy sessions)
165 if (!$session_owner) {
166 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
167 }
168
169 $history = get_option("mxchat_history_{$session_id}", []); // Retrieve stored history
170 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai'); // Get current chat mode
171
172 if (empty($history)) {
173 // Even if history is empty, return the chat mode
174 wp_send_json_success([
175 'conversation' => [],
176 'chat_mode' => $chat_mode
177 ]);
178 wp_die();
179 }
180
181 wp_send_json_success([
182 'conversation' => $history,
183 'chat_mode' => $chat_mode
184 ]);
185 wp_die();
186 }
187 private function mxchat_fetch_conversation_history_for_ai($session_id) {
188 $history = get_option("mxchat_history_{$session_id}", []);
189 $formatted_history = [];
190
191 // Adjusted for code-heavy conversations
192 $max_tokens = 120000; // Context window size
193 $reserved_tokens = 5000; // Space for system prompts + current query
194 $current_token_count = 0;
195
196 // Allowed HTML tags for content sanitization
197 $allowed_tags = [
198 'pre' => ['class' => true],
199 'code' => ['class' => true],
200 'span' => ['class' => true],
201 'div' => ['class' => true],
202 'strong' => [],
203 'em' => []
204 ];
205
206 foreach (array_reverse($history) as $entry) {
207 // Preserve code blocks while sanitizing other HTML
208 $clean_content = wp_kses($entry['content'], $allowed_tags);
209
210 // Detect code blocks in content
211 $has_code = false;
212 // Replace the HTML check with:
213 // Allow messages that contain code blocks or are plain text
214 if (strpos($clean_content, '<pre') === false &&
215 strpos($clean_content, '<code') === false &&
216 $clean_content !== strip_tags($entry['content'])) {
217 continue;
218 }
219
220 // Skip entries that lost significant content during sanitization
221 if (!$has_code && $clean_content !== strip_tags($entry['content'])) {
222 continue;
223 }
224
225 // More accurate token estimation (1 token ≈ 4 characters)
226 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
227
228 // Check token budget with the new estimate
229 if (($current_token_count + $token_estimate + $reserved_tokens) > $max_tokens) {
230 // Try to fit partial content if it's the first entry
231 if (empty($formatted_history)) {
232 $clean_content = mb_substr($clean_content, 0, ($max_tokens - $reserved_tokens) * 4);
233 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
234 } else {
235 break;
236 }
237 }
238
239 // Add to formatted history
240 $formatted_history[] = [
241 'role' => $entry['role'],
242 'content' => $clean_content
243 ];
244
245 $current_token_count += $token_estimate;
246 }
247
248 // Reverse back to maintain chronological order
249 $formatted_history = array_reverse($formatted_history);
250
251 // Add system message about code context
252 array_unshift($formatted_history, [
253 'role' => 'system',
254 'content' => 'Preserved code blocks are marked with [CODE BLOCK PRESERVED]. '
255 . 'Maintain formatting and syntax highlighting when referencing code.'
256 ]);
257
258 return $formatted_history;
259 }
260
261 public function register_routes() {
262 //error_log(esc_html__('Registering MxChat REST routes', 'mxchat'));
263
264 register_rest_route('mxchat/v1', '/stream', [
265 'methods' => 'GET',
266 'callback' => [$this, 'mxchat_stream_events'],
267 'permission_callback' => [$this, 'verify_chat_session'],
268 ]);
269
270 register_rest_route('mxchat/v1', '/agent-response', [
271 'methods' => 'POST',
272 'callback' => [$this, 'mxchat_handle_agent_response'],
273 'permission_callback' => [$this, 'verify_slack_request'],
274 ]);
275
276 register_rest_route('mxchat/v1', '/slack-interaction', [
277 'methods' => 'POST',
278 'callback' => [$this, 'handle_slack_interaction'],
279 'permission_callback' => [$this, 'verify_slack_request'],
280 ]);
281
282 register_rest_route('mxchat/v1', '/slack-messages', [
283 'methods' => 'POST',
284 'callback' => [$this, 'handle_slack_messages'],
285 'permission_callback' => [$this, 'verify_slack_request'],
286 ]);
287
288 // Telegram webhook endpoint
289 register_rest_route('mxchat/v1', '/telegram-webhook', [
290 'methods' => 'POST',
291 'callback' => [$this, 'handle_telegram_webhook'],
292 'permission_callback' => [$this, 'verify_telegram_request'],
293 ]);
294
295 //error_log(esc_html__('MxChat REST routes registered', 'mxchat'));
296 }
297
298 /**
299 * Verify valid chat session
300 */
301 public function verify_chat_session($request) {
302 $session_id = $request->get_param('session_id');
303 if (empty($session_id)) {
304 //error_log(esc_html__('Empty session ID in chat request', 'mxchat'));
305 return false;
306 }
307
308 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
309 return $chat_mode === 'agent';
310 }
311
312 /**
313 * Verify request is coming from Slack.
314 *
315 * @param WP_REST_Request $request
316 * @return bool True if valid, false otherwise.
317 */
318 public function verify_slack_request($request) {
319 // Get the Slack signing secret from your plugin options
320 $valid_key = $this->options['live_agent_secret_key'] ?? '';
321
322 if (empty($valid_key)) {
323 //error_log(esc_html__('Slack signing secret not configured', 'mxchat'));
324 return false;
325 }
326
327 $timestamp = $request->get_header('X-Slack-Request-Timestamp');
328 $slack_signature = $request->get_header('X-Slack-Signature');
329
330 // Verify timestamp to prevent replay attacks
331 if (abs(time() - intval($timestamp)) > 300) {
332 //error_log(esc_html__('Slack request timestamp too old', 'mxchat'));
333 return false;
334 }
335
336 // Get raw request body
337 $request_body = file_get_contents('php://input');
338
339 // Create the signature base string
340 $sig_basestring = "v0:{$timestamp}:{$request_body}";
341
342 // Calculate expected signature
343 $my_signature = 'v0=' . hash_hmac('sha256', $sig_basestring, $valid_key);
344
345 // Compare signatures
346 return hash_equals($my_signature, $slack_signature);
347 }
348
349 /**
350 * Verify request is coming from Telegram.
351 *
352 * @param WP_REST_Request $request
353 * @return bool True if valid, false otherwise.
354 */
355 public function verify_telegram_request($request) {
356 $secret_token = $this->options['telegram_webhook_secret'] ?? '';
357
358 error_log('[MxChat Telegram DEBUG] verify_telegram_request called');
359 error_log('[MxChat Telegram DEBUG] Stored secret: ' . (empty($secret_token) ? 'EMPTY' : substr($secret_token, 0, 10) . '...'));
360
361 if (empty($secret_token)) {
362 // If no secret is configured, allow the request (for initial setup)
363 error_log('[MxChat Telegram DEBUG] No secret configured, allowing request');
364 return true;
365 }
366
367 // Telegram sends the secret token in the X-Telegram-Bot-Api-Secret-Token header
368 $request_token = $request->get_header('X-Telegram-Bot-Api-Secret-Token');
369
370 error_log('[MxChat Telegram DEBUG] Request token: ' . (empty($request_token) ? 'EMPTY' : substr($request_token, 0, 10) . '...'));
371
372 if (empty($request_token)) {
373 error_log('[MxChat Telegram DEBUG] Request rejected: No token in header');
374 return false;
375 }
376
377 // Timing-safe comparison
378 $result = hash_equals($secret_token, $request_token);
379 error_log('[MxChat Telegram DEBUG] Token comparison result: ' . ($result ? 'MATCH' : 'MISMATCH'));
380 return $result;
381 }
382
383 public function mxchat_stream_events(WP_REST_Request $request) {
384 header('Content-Type: text/event-stream');
385 header('Cache-Control: no-cache');
386 header('Connection: keep-alive');
387
388 $session_id = sanitize_text_field($request->get_param('session_id'));
389 $last_seen_id = sanitize_text_field($request->get_param('last_seen_id')) ?: '';
390
391 if (empty($session_id)) {
392 echo esc_html__("event: error\ndata: ", 'mxchat') . esc_html__('Missing session_id', 'mxchat') . "\n\n";
393 flush();
394 exit;
395 }
396
397 $history = get_option("mxchat_history_{$session_id}", []);
398
399 // Filter only new messages
400 $new_messages = array_filter($history, function ($message) use ($last_seen_id) {
401 return !empty($message['id']) && $message['id'] > $last_seen_id;
402 });
403
404 // Send new messages if available
405 if (!empty($new_messages)) {
406 echo esc_html__("event: newMessages\ndata: ", 'mxchat') . json_encode(array_values($new_messages)) . "\n\n";
407 } else {
408 // Keep the connection alive
409 echo esc_html__("event: keepAlive\ndata: ", 'mxchat') . "{}\n\n";
410 }
411 flush();
412 exit;
413 }
414
415
416
417
418 private function mxchat_save_chat_message($session_id, $role, $message, $originating_page = null, $rag_context = null) {
419 global $wpdb;
420 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
421 //error_log("[DEBUG] mxchat_save_chat_message -> START for session_id: {$session_id}, role: {$role}");
422
423 // Check if this is the first message in a new session (before any other database operations)
424 $is_new_session = false;
425 if ($role === 'user') { // Only check for user messages, not bot responses
426 $existing_messages = $wpdb->get_var($wpdb->prepare(
427 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
428 $session_id
429 ));
430 $is_new_session = ($existing_messages == 0);
431
432 // Log for debugging
433 if ($is_new_session) {
434 //error_log("[DEBUG] This is a NEW session - first message");
435 }
436 }
437
438 // SECURITY FIX: Set session ownership for new sessions
439 if ($is_new_session && $role === 'user') {
440 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
441 $session_owner_key = "mxchat_session_owner_{$session_id}";
442
443 // Only set ownership if not already set
444 if (!get_option($session_owner_key)) {
445 update_option($session_owner_key, $current_user_identifier, 'no');
446 //error_log("[DEBUG] Set session ownership for {$session_id} to {$current_user_identifier}");
447 }
448 }
449
450 // 1) Extract agent name if present
451 $agent_name = '';
452 if (preg_match('/^Agent: (.*?) - /', $message, $matches)) {
453 $agent_name = $matches[1];
454 $message = str_replace("Agent: $agent_name - ", '', $message);
455 $session_meta_key = "mxchat_agent_name_{$session_id}";
456 if (empty(get_option($session_meta_key))) {
457 update_option($session_meta_key, $agent_name);
458 //error_log("[DEBUG] mxchat_save_chat_message -> Stored agent_name in option: {$session_meta_key} => {$agent_name}");
459 }
460 }
461
462 // 2) Generate unique message_id
463 $message_id = uniqid();
464 //error_log("[DEBUG] mxchat_save_chat_message -> Generated message_id: {$message_id}");
465
466 // 3) Determine user_id
467 $user_id = is_user_logged_in() ? get_current_user_id() : 0;
468
469 // 4) Determine user_identifier
470 $user_identifier = $agent_name
471 ? $agent_name
472 : MxChat_User::mxchat_get_user_identifier();
473
474 // 5) Determine displayed_name
475 $user_email = MxChat_User::mxchat_get_user_email();
476 $displayed_name = $agent_name ? $agent_name : ($user_email ?: $user_identifier);
477
478 // 6) Check for a saved email in wp_options
479 $email_option_key = "mxchat_email_{$session_id}";
480 $saved_email = get_option($email_option_key);
481 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for email_option_key: {$email_option_key}, found: {$saved_email}");
482
483 // Check for a saved name in wp_options
484 $name_option_key = "mxchat_name_{$session_id}";
485 $saved_name = get_option($name_option_key);
486 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for name_option_key: {$name_option_key}, found: {$saved_name}");
487
488 // If found, update DB user_email and user_name
489 if ($saved_email || $saved_name) {
490 $update_data = [];
491 if ($saved_email) {
492 $update_data['user_email'] = $saved_email;
493 }
494 if ($saved_name) {
495 $update_data['user_name'] = $saved_name;
496 }
497
498 if (!empty($update_data)) {
499 $update_res = $wpdb->update(
500 $table_name,
501 $update_data,
502 ['session_id' => $session_id],
503 array_fill(0, count($update_data), '%s'),
504 ['%s']
505 );
506 //error_log("[DEBUG] mxchat_save_chat_message -> Attempted DB user_email/user_name update for session_id {$session_id}. update_res: {$update_res}");
507 }
508 }
509
510 // 7) Save to session history in wp_options
511 $history_key = "mxchat_history_{$session_id}";
512 $history = get_option($history_key, []);
513 $history[] = [
514 'id' => $message_id,
515 'role' => $role,
516 'content' => $message,
517 'timestamp' => round(microtime(true) * 1000),
518 'agent_name' => $displayed_name,
519 ];
520 update_option($history_key, $history, 'no');
521 //error_log("[DEBUG] mxchat_save_chat_message -> Updated session history in option: {$history_key}");
522
523 // 8) Save the message to DB (INSERT)
524 $insert_data = [
525 'user_id' => $user_id,
526 'user_identifier'=> $user_identifier,
527 'user_email' => $saved_email ?: $user_email,
528 'user_name' => $saved_name ?: '', // Add name to insert data
529 'session_id' => $session_id,
530 'role' => $role,
531 'message' => $message,
532 'timestamp' => current_time('mysql', 1),
533 ];
534
535 // IMPROVED: Handle originating page data
536 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
537
538 if ($columns_exist) {
539 if ($is_new_session && $role === 'user') {
540 // For the first user message, set originating page data
541
542 // First check if we have it from the parameter
543 if ($originating_page && !empty($originating_page['url'])) {
544 $insert_data['originating_page_url'] = $originating_page['url'];
545 $insert_data['originating_page_title'] = $originating_page['title'] ?? '';
546
547 //error_log("[DEBUG] Setting originating page from parameter: " . $originating_page['url']);
548 }
549 // Otherwise check if it's stored in the instance property
550 else if (isset($this->pending_originating_page) && !empty($this->pending_originating_page['url'])) {
551 $insert_data['originating_page_url'] = $this->pending_originating_page['url'];
552 $insert_data['originating_page_title'] = $this->pending_originating_page['title'] ?? '';
553
554 //error_log("[DEBUG] Setting originating page from pending_originating_page: " . $this->pending_originating_page['url']);
555
556 // Clear after using
557 unset($this->pending_originating_page);
558 }
559 // Fallback to HTTP_REFERER if nothing else is available
560 else if (isset($_SERVER['HTTP_REFERER'])) {
561 $referer_url = esc_url_raw($_SERVER['HTTP_REFERER']);
562 $insert_data['originating_page_url'] = $referer_url;
563
564 // Generate title from URL
565 $parsed_url = parse_url($referer_url);
566 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
567
568 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
569 $insert_data['originating_page_title'] = 'Homepage';
570 } else {
571 $title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
572 $insert_data['originating_page_title'] = ucwords(trim($title));
573 }
574
575 //error_log("[DEBUG] Setting originating page from HTTP_REFERER: " . $referer_url);
576 }
577
578 // Store for this session so all messages have the same originating page
579 if (!empty($insert_data['originating_page_url'])) {
580 update_option("mxchat_originating_page_{$session_id}", [
581 'url' => $insert_data['originating_page_url'],
582 'title' => $insert_data['originating_page_title']
583 ], 'no');
584 }
585 } else {
586 // For subsequent messages in the session, use the stored originating page
587 $stored_originating = get_option("mxchat_originating_page_{$session_id}");
588 if ($stored_originating && !empty($stored_originating['url'])) {
589 $insert_data['originating_page_url'] = $stored_originating['url'];
590 $insert_data['originating_page_title'] = $stored_originating['title'] ?? '';
591 }
592 }
593 }
594
595 // Add RAG context if provided (for bot messages)
596 if ($rag_context !== null && $role === 'bot') {
597 $rag_context_column_exists = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'rag_context'");
598 if ($rag_context_column_exists) {
599 $insert_data['rag_context'] = is_array($rag_context) ? wp_json_encode($rag_context) : $rag_context;
600 }
601 }
602
603 $wpdb->insert($table_name, $insert_data);
604 //error_log("[DEBUG] mxchat_save_chat_message -> Inserted message into DB. row_id: {$wpdb->insert_id}, data: " . print_r($insert_data, true));
605
606 // 9) Send notification email if this is the first user message in a new session
607 if ($wpdb->insert_id && $is_new_session && $role === 'user') {
608 $this->send_new_chat_notification($session_id, array(
609 'identifier' => $user_identifier,
610 'email' => $saved_email ?: $user_email,
611 'ip' => $_SERVER['REMOTE_ADDR']
612 ));
613 }
614
615 // 10) Schedule delayed transcript email if enabled and message is from user
616 if ($wpdb->insert_id && $role === 'user') {
617 $this->schedule_delayed_transcript_email($session_id);
618 }
619
620 //error_log("[DEBUG] mxchat_save_chat_message -> END for session_id: {$session_id}");
621 return $message_id;
622 }
623
624 private function send_new_chat_notification($session_id, $user_info = array()) {
625 $options = get_option('mxchat_transcripts_options');
626
627 // Check if notifications are enabled
628 if (empty($options['mxchat_enable_notifications'])) {
629 return false;
630 }
631
632 // Get notification email
633 $to = !empty($options['mxchat_notification_email']) ?
634 $options['mxchat_notification_email'] :
635 get_option('admin_email');
636
637 if (!is_email($to)) {
638 return false;
639 }
640
641 // Prepare email content
642 $subject = sprintf('[%s] New Chat Session Started', get_bloginfo('name'));
643
644 $user_identifier = isset($user_info['identifier']) ? $user_info['identifier'] : 'Guest';
645 $user_email = isset($user_info['email']) ? $user_info['email'] : 'Not provided';
646 $user_ip = isset($user_info['ip']) ? $user_info['ip'] : $_SERVER['REMOTE_ADDR'];
647
648 $message = sprintf(
649 "A new chat session has started on your website.\n\n" .
650 "Session ID: %s\n" .
651 "User: %s\n" .
652 "Email: %s\n" .
653 "IP Address: %s\n" .
654 "Time: %s\n\n" .
655 "View transcripts: %s",
656 $session_id,
657 $user_identifier,
658 $user_email,
659 $user_ip,
660 current_time('mysql'),
661 admin_url('admin.php?page=mxchat-transcripts')
662 );
663
664 // Send email
665 return wp_mail($to, $subject, $message);
666 }
667
668 /**
669 * Schedule delayed transcript email for a session
670 * Reschedules if a new user message is received
671 */
672 private function schedule_delayed_transcript_email($session_id) {
673 $options = get_option('mxchat_transcripts_options');
674
675 // Check if auto-email is enabled
676 if (empty($options['mxchat_auto_email_transcript_enabled'])) {
677 return;
678 }
679
680 // Get notification email
681 $email = !empty($options['mxchat_notification_email']) ?
682 $options['mxchat_notification_email'] :
683 get_option('admin_email');
684
685 if (!is_email($email)) {
686 return;
687 }
688
689 // Get delay in minutes (default 30)
690 $delay_minutes = isset($options['mxchat_auto_email_transcript_delay']) ?
691 intval($options['mxchat_auto_email_transcript_delay']) : 30;
692
693 // Clear any existing scheduled event for this session
694 $hook = 'mxchat_send_delayed_transcript';
695 $args = array($session_id);
696 $timestamp = wp_next_scheduled($hook, $args);
697
698 if ($timestamp) {
699 wp_unschedule_event($timestamp, $hook, $args);
700 }
701
702 // Schedule new event
703 $schedule_time = time() + ($delay_minutes * 60);
704 wp_schedule_single_event($schedule_time, $hook, $args);
705 }
706
707 /**
708 * Check if chat messages contain contact information (email or phone number)
709 *
710 * @param array $messages Array of message objects with 'message' property
711 * @param object|null $session_data Session data object with user_email property
712 * @return bool True if contact info found, false otherwise
713 */
714 private function chat_contains_contact_info($messages, $session_data = null) {
715 // Check if session already has a stored email
716 if ($session_data && !empty($session_data->user_email)) {
717 return true;
718 }
719
720 // Email regex pattern
721 $email_pattern = '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/';
722
723 // Phone number patterns (covers various formats including international, WhatsApp style)
724 // Matches: +1234567890, (123) 456-7890, 123-456-7890, 123.456.7890, 1234567890, +1 234 567 8900, etc.
725 $phone_pattern = '/(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{2,4}[-.\s]?\d{2,4}(?:[-.\s]?\d{1,4})?/';
726
727 // Only check user messages (not assistant responses)
728 foreach ($messages as $msg) {
729 if ($msg->role !== 'user') {
730 continue;
731 }
732
733 $message_text = $msg->message;
734
735 // Check for email
736 if (preg_match($email_pattern, $message_text)) {
737 return true;
738 }
739
740 // Check for phone number (must be at least 7 digits total to avoid false positives)
741 if (preg_match($phone_pattern, $message_text, $matches)) {
742 // Count actual digits to avoid matching short numbers
743 $digits_only = preg_replace('/\D/', '', $matches[0]);
744 if (strlen($digits_only) >= 7) {
745 return true;
746 }
747 }
748 }
749
750 return false;
751 }
752
753 /**
754 * Send the delayed transcript email with .txt attachment
755 */
756 public function mxchat_send_delayed_transcript($session_id) {
757 global $wpdb;
758
759 $options = get_option('mxchat_transcripts_options');
760
761 // Get notification email
762 $to = !empty($options['mxchat_notification_email']) ?
763 $options['mxchat_notification_email'] :
764 get_option('admin_email');
765
766 if (!is_email($to)) {
767 return false;
768 }
769
770 // Get all messages for this session
771 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
772 $messages = $wpdb->get_results($wpdb->prepare(
773 "SELECT role, message, timestamp FROM {$table_name}
774 WHERE session_id = %s
775 ORDER BY timestamp ASC",
776 $session_id
777 ));
778
779 if (empty($messages)) {
780 return false;
781 }
782
783 // Get session metadata
784 $sessions_table = $wpdb->prefix . 'mxchat_sessions';
785 $session_data = $wpdb->get_row($wpdb->prepare(
786 "SELECT * FROM {$sessions_table} WHERE session_id = %s",
787 $session_id
788 ));
789
790 // Check if contact info is required and if it's present
791 $require_contact = !empty($options['mxchat_auto_email_transcript_require_contact']);
792 if ($require_contact && !$this->chat_contains_contact_info($messages, $session_data)) {
793 // Contact info required but not found - skip sending
794 return false;
795 }
796
797 // Build transcript content
798 $transcript_content = "Chat Transcript\n";
799 $transcript_content .= "================\n\n";
800 $transcript_content .= "Session ID: " . $session_id . "\n";
801
802 if ($session_data) {
803 $transcript_content .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
804 $transcript_content .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
805 $transcript_content .= "Started: " . $session_data->created_at . "\n";
806 }
807
808 $transcript_content .= "\n" . str_repeat("=", 50) . "\n\n";
809
810 // Add messages
811 foreach ($messages as $msg) {
812 $role_label = ($msg->role === 'user') ? 'User' : 'Assistant';
813 $transcript_content .= "[{$msg->timestamp}] {$role_label}:\n";
814 $transcript_content .= $msg->message . "\n\n";
815 }
816
817 // Create temporary file for attachment
818 $upload_dir = wp_upload_dir();
819 $temp_file = $upload_dir['basedir'] . '/mxchat-transcript-' . $session_id . '.txt';
820 file_put_contents($temp_file, $transcript_content);
821
822 // Prepare email
823 $subject = sprintf('[%s] Chat Transcript - Session %s', get_bloginfo('name'), substr($session_id, 0, 8));
824
825 $message = "Please find attached the full chat transcript.\n\n";
826 $message .= "Session ID: {$session_id}\n";
827
828 if ($session_data) {
829 $message .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
830 $message .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
831 }
832
833 $message .= "\nView online: " . admin_url('admin.php?page=mxchat-transcripts');
834
835 // Send email with attachment
836 $attachments = array($temp_file);
837 $result = wp_mail($to, $subject, $message, '', $attachments);
838
839 // Clean up temporary file
840 if (file_exists($temp_file)) {
841 unlink($temp_file);
842 }
843
844 return $result;
845 }
846
847
848
849 public function mxchat_handle_save_email_and_response() {
850 //error_log('[DEBUG] ---------- mxchat_handle_save_email_and_response START ----------');
851 //error_log('DEBUG: POST data: ' . print_r($_POST, true));
852
853 // Validate nonce
854 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
855 //error_log(esc_html__('[ERROR] Invalid nonce in mxchat_handle_save_email_and_response', 'mxchat'));
856 wp_send_json_error(['message' => esc_html__('Invalid nonce.', 'mxchat')]);
857 wp_die();
858 }
859
860 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
861 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
862 $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
863
864 //error_log("[DEBUG] handle_save_email_and_response -> session_id: {$session_id}, email: {$email}, name: {$name}");
865
866 if (empty($session_id) || empty($email)) {
867 //error_log("[ERROR] Missing session_id or email: session_id={$session_id}, email={$email}");
868 wp_send_json_error(['message' => esc_html__('Session ID or email is missing.', 'mxchat')]);
869 wp_die();
870 }
871
872 // Validate name if provided (check if name field is enabled and name is required)
873 $options = get_option('mxchat_options', []);
874 $name_field_enabled = isset($options['enable_name_field']) &&
875 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
876
877 if ($name_field_enabled && (empty($name) || strlen(trim($name)) < 2 || strlen(trim($name)) > 100)) {
878 //error_log("[ERROR] Invalid name: {$name} (enabled: {$name_field_enabled})");
879 wp_send_json_error(['message' => esc_html__('Name must be between 2 and 100 characters.', 'mxchat')]);
880 wp_die();
881 }
882
883 // 1) Always store email in wp_options
884 $email_option_key = "mxchat_email_{$session_id}";
885 update_option($email_option_key, $email);
886 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$email_option_key} => {$email}");
887
888 // Store name in wp_options if provided
889 if (!empty($name)) {
890 $name_option_key = "mxchat_name_{$session_id}";
891 update_option($name_option_key, $name);
892 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$name_option_key} => {$name}");
893 }
894
895 // 2) (Optional) Also store in DB if a row already exists
896 global $wpdb;
897 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
898
899 // Make sure we have a valid placeholder in prepare
900 $sql = $wpdb->prepare("SELECT COUNT(*) FROM {$table_name} WHERE session_id = %s", $session_id);
901 $session_count = $wpdb->get_var($sql);
902
903 //error_log("[DEBUG] handle_save_email_and_response -> session_count for {$session_id}: {$session_count} (SQL: {$sql})");
904
905 if ($session_count) {
906 // Update both user_email and user_name if row(s) exist
907 if (!empty($name)) {
908 $update_sql = $wpdb->prepare(
909 "UPDATE {$table_name} SET user_email = %s, user_name = %s WHERE session_id = %s",
910 $email,
911 $name,
912 $session_id
913 );
914 } else {
915 $update_sql = $wpdb->prepare(
916 "UPDATE {$table_name} SET user_email = %s WHERE session_id = %s",
917 $email,
918 $session_id
919 );
920 }
921 $wpdb->query($update_sql);
922 //error_log("[DEBUG] handle_save_email_and_response -> DB updated: {$update_sql}");
923 } else {
924 //error_log("[INFO] handle_save_email_and_response -> No DB entry for {$session_id}, so email/name is only in wp_options.");
925 }
926
927 // Provide success response (same as original)
928 $bot_message = __('Thanks for providing your email! You can continue chatting now.', 'mxchat');
929 //error_log("[DEBUG] handle_save_email_and_response -> success, returning bot_message: {$bot_message}");
930 wp_send_json_success(['message' => $bot_message]);
931 wp_die();
932 }
933
934 public function mxchat_check_email_provided() {
935 //error_log('[DEBUG] ---------- mxchat_check_email_provided START ----------');
936
937 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
938 //error_log('[ERROR] Invalid nonce in mxchat_check_email_provided');
939 wp_send_json_error(['message' => esc_html__('Invalid nonce', 'mxchat')]);
940 }
941
942 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
943 if (empty($session_id)) {
944 //error_log('[ERROR] No session ID provided in mxchat_check_email_provided');
945 wp_send_json_error(['message' => esc_html__('No session ID provided', 'mxchat')]);
946 }
947
948 // Check if the user is logged in
949 if (is_user_logged_in()) {
950 $current_user = wp_get_current_user();
951 //error_log("[DEBUG] User is logged in as {$current_user->user_email}");
952
953 // Get user's display name for logged in users
954 $user_name = !empty($current_user->display_name) ? $current_user->display_name :
955 (!empty($current_user->first_name) ? $current_user->first_name : '');
956
957 $response_data = ['logged_in' => true, 'email' => $current_user->user_email];
958 if (!empty($user_name)) {
959 $response_data['name'] = $user_name;
960 }
961
962 wp_send_json_success($response_data);
963 }
964
965 // Check if name field is required
966 $options = get_option('mxchat_options', []);
967 $name_field_enabled = isset($options['enable_name_field']) &&
968 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
969
970 $email_option_key = "mxchat_email_{$session_id}";
971 $stored_email = get_option($email_option_key, '');
972
973 // Check for stored name
974 $name_option_key = "mxchat_name_{$session_id}";
975 $stored_name = get_option($name_option_key, '');
976
977 //error_log("[DEBUG] mxchat_check_email_provided -> Checking email option: {$email_option_key}, found: {$stored_email}");
978 //error_log("[DEBUG] mxchat_check_email_provided -> Checking name option: {$name_option_key}, found: {$stored_name}, required: " . ($name_field_enabled ? 'yes' : 'no'));
979
980 // Check if we have email and name (if name is required)
981 $has_required_info = !empty($stored_email);
982
983 if ($name_field_enabled) {
984 $has_required_info = $has_required_info && !empty($stored_name);
985 }
986
987 if ($has_required_info) {
988 //error_log("[DEBUG] mxchat_check_email_provided -> Required info found, returning success");
989
990 $response_data = ['email' => $stored_email];
991 if (!empty($stored_name)) {
992 $response_data['name'] = $stored_name;
993 }
994
995 wp_send_json_success($response_data);
996 } else {
997 //error_log("[DEBUG] mxchat_check_email_provided -> Required info missing, returning error");
998 wp_send_json_error(['message' => esc_html__('No email found', 'mxchat')]);
999 }
1000 }
1001
1002 /**
1003 * Send error response in appropriate format based on streaming mode
1004 * ADDED: Helper method to consistently handle errors in both streaming and non-streaming modes
1005 *
1006 * @param string $error_message The error message to display
1007 * @param string $error_code Optional error code for debugging
1008 */
1009 private function send_error_response($error_message, $error_code = 'api_error') {
1010 if ($this->is_streaming) {
1011 echo "data: " . json_encode([
1012 'error' => true,
1013 'error_message' => $error_message,
1014 'error_code' => $error_code,
1015 'text' => $error_message,
1016 'message' => $error_message
1017 ]) . "\n\n";
1018 echo "data: [DONE]\n\n";
1019 flush();
1020 } else {
1021 wp_send_json_error([
1022 'error_message' => $error_message,
1023 'error_code' => $error_code
1024 ]);
1025 }
1026 wp_die();
1027 }
1028
1029 public function mxchat_handle_chat_request() {
1030 global $wpdb;
1031
1032 // Debug: Log incoming bot_id
1033 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1034 error_log("=== MXCHAT DEBUG: Starting chat request ===");
1035 error_log("MXCHAT DEBUG: Bot ID received: " . $bot_id);
1036
1037 // Get bot-specific options
1038 $bot_options = $this->get_bot_options($bot_id);
1039 $current_options = !empty($bot_options) ? $bot_options : $this->options;
1040
1041 // Check if this is a streaming request
1042 // Allow force_streaming_test parameter to bypass the setting check (for admin compatibility testing)
1043 $force_streaming_test = isset($_POST['force_streaming_test']) && $_POST['force_streaming_test'] === '1' && current_user_can('administrator');
1044 $is_streaming = isset($_POST['action']) && $_POST['action'] === 'mxchat_stream_chat' &&
1045 ($force_streaming_test || (isset($current_options['enable_streaming_toggle']) && $current_options['enable_streaming_toggle'] === 'on'));
1046
1047 // ADDED: Store streaming state in class property for use in private methods
1048 $this->is_streaming = $is_streaming;
1049
1050 // NOTE: Streaming headers are now set later via setup_streaming_headers()
1051 // This allows actions/forms to return JSON responses without header conflicts
1052
1053 // Check if MX Chat Moderation is active
1054 if (class_exists('MX_Chat_Moderation')) {
1055 // Get user email and IP
1056 $user_email = '';
1057 $user_ip = $_SERVER['REMOTE_ADDR'];
1058
1059 // If user is logged in, get their email
1060 if (is_user_logged_in()) {
1061 $current_user = wp_get_current_user();
1062 $user_email = $current_user->user_email;
1063 }
1064
1065 // Create ban handler instance
1066 $ban_handler = new MX_Chat_Ban_Handler();
1067
1068 // Check if user is banned by IP
1069 if ($ban_handler->check_ban($user_ip, 'ip')) {
1070 wp_send_json([
1071 'success' => false,
1072 'message' => esc_html__('Access denied. Your IP address has been banned.', 'mxchat'),
1073 'status' => 'banned'
1074 ]);
1075 wp_die();
1076 }
1077
1078 // If user is logged in, also check email
1079 if (!empty($user_email) && $ban_handler->check_ban($user_email, 'email')) {
1080 wp_send_json([
1081 'success' => false,
1082 'message' => esc_html__('Access denied. Your email address has been banned.', 'mxchat'),
1083 'status' => 'banned'
1084 ]);
1085 wp_die();
1086 }
1087 }
1088
1089 $this->fallbackResponse = ['text' => '', 'html' => '', 'images' => []];
1090 $this->productCardHtml = '';
1091
1092 // Get the actual WordPress user ID if logged in
1093 $is_logged_in = is_user_logged_in();
1094 if ($is_logged_in) {
1095 $user_id = get_current_user_id(); // This will get the actual WordPress user ID
1096 } else {
1097 // For logged-out users, use your existing identifier method
1098 $user_id = $this->mxchat_get_user_identifier();
1099 }
1100
1101 // Get and sanitize the user identifier
1102 $user_id = sanitize_key($user_id);
1103
1104 // Check rate limit using new settings structure
1105 $rate_limit_result = $this->check_rate_limit();
1106
1107 if ($rate_limit_result !== true) {
1108 wp_send_json([
1109 'success' => false,
1110 'message' => $rate_limit_result['message'],
1111 'status' => 'rate_limit_exceeded'
1112 ]);
1113 wp_die();
1114 }
1115
1116 // Rest of your existing code...
1117 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1118
1119 if (empty($session_id)) {
1120 wp_send_json_error(esc_html__('Session ID is missing.', 'mxchat'));
1121 wp_die();
1122 }
1123
1124 // SECURITY FIX: Verify session ownership before processing chat request
1125 // If IP/user changed, signal frontend to reset session instead of blocking
1126 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
1127 $session_owner = get_option("mxchat_session_owner_{$session_id}");
1128
1129 if ($session_owner && $session_owner !== $current_user_identifier) {
1130 // Instead of blocking, tell frontend to start a fresh session
1131 wp_send_json_error([
1132 'message' => esc_html__('Your session has expired. Starting a new conversation.', 'mxchat'),
1133 'code' => 'session_expired',
1134 'action' => 'reset_session'
1135 ]);
1136 wp_die();
1137 }
1138
1139 // Validate and sanitize the incoming message
1140 if (empty($_POST['message'])) {
1141 wp_send_json_error(esc_html__('No message received.', 'mxchat'));
1142 wp_die();
1143 }
1144
1145
1146 // Track originating page for first message in session
1147 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1148
1149 // Check if originating page columns exist
1150 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
1151
1152 if ($columns_exist) {
1153 // Check if this session already has messages
1154 $message_count = $wpdb->get_var($wpdb->prepare(
1155 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
1156 $session_id
1157 ));
1158
1159 // If this is the first message in the session
1160 if ($message_count == 0) {
1161 // Get originating page from JavaScript (preferred) or HTTP_REFERER (fallback)
1162 $originating_url = '';
1163 $originating_title = '';
1164
1165 // Try to get from POST data first (sent by JavaScript)
1166 if (isset($_POST['current_page_url'])) {
1167 $originating_url = esc_url_raw($_POST['current_page_url']);
1168 $originating_title = isset($_POST['current_page_title'])
1169 ? sanitize_text_field($_POST['current_page_title'])
1170 : '';
1171 }
1172 // Fallback to HTTP_REFERER if not provided by JavaScript
1173 else if (isset($_SERVER['HTTP_REFERER'])) {
1174 $originating_url = esc_url_raw($_SERVER['HTTP_REFERER']);
1175 }
1176
1177 // Generate title if we have URL but no title
1178 if ($originating_url && empty($originating_title)) {
1179 $parsed_url = parse_url($originating_url);
1180 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
1181
1182 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
1183 $originating_title = 'Homepage';
1184 } else {
1185 // Clean up the path to make a readable title
1186 $originating_title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
1187 $originating_title = ucwords(trim($originating_title));
1188 }
1189 }
1190
1191 // Store for later use when saving the message
1192 $this->pending_originating_page = [
1193 'url' => $originating_url,
1194 'title' => $originating_title
1195 ];
1196 }
1197 }
1198
1199
1200
1201 // Get page context if provided
1202 $page_context = null;
1203 if (isset($_POST['page_context']) && !empty($_POST['page_context'])) {
1204 $page_context_raw = stripslashes($_POST['page_context']);
1205 $page_context = json_decode($page_context_raw, true);
1206
1207 // Validate page context structure
1208 if (is_array($page_context) &&
1209 isset($page_context['url']) &&
1210 isset($page_context['title']) &&
1211 isset($page_context['content'])) {
1212
1213 // Sanitize page context
1214 $page_context['url'] = esc_url_raw($page_context['url']);
1215 $page_context['title'] = sanitize_text_field($page_context['title']);
1216 $page_context['content'] = wp_kses_post($page_context['content']);
1217 } else {
1218 $page_context = null;
1219 }
1220 }
1221
1222 // Modify the message sanitization to preserve PHP tags in code blocks
1223 $allowed_tags = [
1224 'pre' => [],
1225 'code' => ['class' => true],
1226 'span' => ['class' => true],
1227 'div' => ['class' => true],
1228 ];
1229
1230 // First preserve code blocks
1231 $message = preg_replace_callback('/<pre><code.*?>.*?<\/code><\/pre>/s', function($matches) {
1232 return htmlspecialchars_decode($matches[0]);
1233 }, $_POST['message']);
1234
1235 // Then apply sanitization
1236 $message = wp_kses($message, $allowed_tags);
1237
1238 // Preserve code blocks from markdown conversion
1239 $message = preg_replace('/```(\w+)?\s*([\s\S]+?)```/s', '<pre><code class="$1">$2</code></pre>', $message);
1240 $message = apply_filters('mxchat_filter_message', $message, 'prompt', $session_id);
1241
1242 // ===== SIMPLIFIED TESTING PANEL INITIALIZATION =====
1243 // Always initialize testing data for admins (no toggle needed)
1244 $testing_data = null;
1245 if (current_user_can('administrator')) {
1246 // For vision messages, use the original user message for the query display
1247 $query_for_testing = $message;
1248 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1249 $query_for_testing = sanitize_textarea_field($_POST['original_user_message']);
1250 }
1251
1252 $testing_data = [
1253 'query' => $query_for_testing,
1254 'timestamp' => time(),
1255 'top_matches' => [],
1256 'action_matches' => [], // Initialize action matches array
1257 'page_context' => $page_context, // Include page context in testing data
1258 'is_vision' => isset($_POST['vision_processed']) && $_POST['vision_processed'],
1259 'bot_id' => $bot_id // Include bot ID in testing data
1260 ];
1261
1262 // Get similarity threshold from bot options or default options
1263 $similarity_threshold = isset($current_options['similarity_threshold'])
1264 ? ((int) $current_options['similarity_threshold']) / 100
1265 : 0.35;
1266
1267 $testing_data['similarity_threshold'] = $similarity_threshold;
1268
1269 // Determine knowledge base type using bot-specific config
1270 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
1271 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
1272 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
1273 }
1274 // ===== END SIMPLIFIED TESTING INITIALIZATION =====
1275
1276 // Add debug before and after:
1277 //error_log('MxChat Core: About to call mxchat_pre_process_message filter with message: ' . $message);
1278 $pre_processed_result = apply_filters('mxchat_pre_process_message', $message, $user_id, $session_id);
1279 //error_log('MxChat Core: Filter returned: ' . (is_array($pre_processed_result) ? 'array' : $pre_processed_result));
1280
1281
1282 // If the pre-processing returned a result (not the original message), use it directly
1283 if (is_array($pre_processed_result) && isset($pre_processed_result['text'])) {
1284 // Save the AI response
1285 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['text']);
1286
1287 // Save HTML content if provided
1288 if (!empty($pre_processed_result['html'])) {
1289 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['html']);
1290 }
1291
1292 // Add testing data if admin
1293 $response_data = [
1294 'text' => $pre_processed_result['text'],
1295 'html' => $pre_processed_result['html'] ?? '',
1296 'session_id' => $session_id
1297 ];
1298
1299 if ($testing_data !== null) {
1300 $response_data['testing_data'] = $testing_data;
1301 }
1302
1303 wp_send_json($response_data);
1304 wp_die();
1305 }
1306
1307 // Save the user's message - handle vision processed messages differently
1308 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1309 // For vision messages, save the original user message with image indicator
1310 $original_message = sanitize_textarea_field($_POST['original_user_message']);
1311 if (isset($_POST['vision_images_count']) && $_POST['vision_images_count'] > 0) {
1312 $image_count = intval($_POST['vision_images_count']);
1313 $original_message .= " [{$image_count} image(s)]";
1314 }
1315 $this->mxchat_save_chat_message($session_id, 'user', $original_message);
1316 } else {
1317 // Regular message - save as normal
1318 $this->mxchat_save_chat_message($session_id, 'user', $message);
1319 }
1320
1321
1322 if (is_email($message)) {
1323 // Add the email to Loops
1324 $this->add_email_to_loops($message);
1325
1326 // Get the user's success message instruction using current_options
1327 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1328
1329 // Set instruction for AI using the user's success message
1330 $this->current_action_instruction = $user_success_message;
1331
1332 // Clear the email capture transient since we got the email
1333 delete_transient('mxchat_email_capture_' . $user_id);
1334 }
1335
1336 // Check if we're in an email capture flow but user hasn't provided email yet
1337 elseif (get_transient('mxchat_email_capture_' . $user_id)) {
1338 // Check if the message contains an email (not the whole message being an email)
1339 if (preg_match('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', $message, $matches)) {
1340 $extracted_email = $matches[0];
1341
1342 // Add the extracted email to Loops
1343 $this->add_email_to_loops($extracted_email);
1344
1345 // Get the user's success message instruction using current_options
1346 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1347
1348 // Set instruction for AI using the user's success message
1349 $this->current_action_instruction = $user_success_message;
1350
1351 // Clear the email capture transient since we got the email
1352 delete_transient('mxchat_email_capture_' . $user_id);
1353 }
1354 // If no email found but we're in capture mode, remind them
1355 else {
1356 // Get the original instruction to remind them using current_options
1357 $original_instruction = $current_options['triggered_phrase_response'] ?? __("Please provide your email address.", 'mxchat');
1358 $this->current_action_instruction = $original_instruction;
1359 }
1360 }
1361
1362 $intent_info = '';
1363
1364 // Check chat mode
1365 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1366
1367 // Handle agent mode
1368 // Handle agent mode
1369 if ($chat_mode === 'agent') {
1370 // First, check for switch intent before doing anything else
1371 $intent_matched = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1372
1373 // Capture action analysis for testing panel after intent check
1374 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1375 $testing_data['action_matches'] = $this->last_action_analysis;
1376 }
1377
1378 // Around line 506, in the agent mode handling section:
1379 if ($intent_matched && !empty($this->fallbackResponse['text'])) {
1380 // Update chat mode first
1381 update_option("mxchat_mode_{$session_id}", 'ai');
1382
1383 // Clear any existing PDF context to start fresh
1384 $this->clear_pdf_transients($session_id);
1385
1386 // Prepare clean switch response with explicit chat_mode
1387 $response_data = [
1388 'text' => $this->fallbackResponse['text'],
1389 'html' => $this->fallbackResponse['html'] ?? '',
1390 'session_id' => $session_id,
1391 'chat_mode' => 'ai' // EXPLICITLY SET THIS
1392 ];
1393
1394 if ($testing_data !== null) {
1395 $response_data['testing_data'] = $testing_data;
1396 }
1397
1398 // Save the mode switch message
1399 $this->mxchat_save_chat_message($session_id, 'system', esc_html__('Switched to AI chat mode', 'mxchat'));
1400 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1401
1402 // Send response and exit
1403 wp_send_json($response_data);
1404 wp_die();
1405 } elseif (!$intent_matched) {
1406 // No intent matched, handle live agent message
1407 try {
1408 $this->mxchat_send_user_message_to_agent($message, $user_id, $session_id);
1409
1410 $agent_response = [
1411 'status' => 'waiting_for_agent',
1412 'message' => esc_html__('Message sent to live agent.', 'mxchat')
1413 ];
1414
1415 if ($testing_data !== null) {
1416 $agent_response['testing_data'] = $testing_data;
1417 }
1418
1419 wp_send_json_success($agent_response);
1420 } catch (\Exception $e) {
1421 wp_send_json_error(esc_html__('Failed to send message to agent', 'mxchat'));
1422 }
1423 wp_die();
1424 }
1425 }
1426
1427 // Step 1: Check for new PDF URL in the message
1428 if (!isset($_POST['vision_processed']) && preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
1429 $new_pdf_url = $matches[0];
1430
1431 // Check if this is likely a PDF-related request
1432 $pdf_keywords = ['pdf', 'document', 'read', 'analyze'];
1433 $is_pdf_request = false;
1434
1435 foreach ($pdf_keywords as $keyword) {
1436 if (stripos($message, $keyword) !== false) {
1437 $is_pdf_request = true;
1438 break;
1439 }
1440 }
1441
1442 // If it looks like a PDF request or we're waiting for a PDF URL
1443 if ($is_pdf_request || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
1444 // Validate HTTPS
1445 if (wp_http_validate_url($new_pdf_url) && parse_url($new_pdf_url, PHP_URL_SCHEME) === 'https') {
1446 // Extract filename from URL
1447 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1448
1449 // Clear previous PDF transients
1450 $this->clear_pdf_transients($session_id);
1451
1452 // Process new PDF using current_options
1453 $max_pages = $current_options['pdf_max_pages'] ?? 69;
1454 $embeddings = $this->fetch_and_split_pdf_pages($new_pdf_url, $max_pages);
1455
1456 if ($embeddings === 'too_many_pages') {
1457 $error_text = sprintf(
1458 $current_options['pdf_intent_error_text'] ??
1459 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
1460 $max_pages
1461 );
1462 $this->fallbackResponse['text'] = $error_text;
1463 } elseif ($embeddings) {
1464 // Store new PDF information
1465 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1466
1467 // If the filename is generic, create a more descriptive one
1468 if (in_array($pdf_filename, ['results_download.php', 'download.php', 'view.php', 'pdf.php']) ||
1469 strpos($pdf_filename, '.php') !== false) {
1470 $pdf_filename = 'Document_' . date('Y-m-d_H-i') . '.pdf';
1471 }
1472
1473 set_transient('mxchat_pdf_url_' . $session_id, $new_pdf_url, HOUR_IN_SECONDS);
1474 set_transient('mxchat_pdf_filename_' . $session_id, $pdf_filename, HOUR_IN_SECONDS);
1475 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
1476 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
1477
1478 $success_text = $current_options['pdf_intent_success_text'] ??
1479 esc_html__("I've processed the new PDF '{$pdf_filename}'. What questions do you have about it?", 'mxchat');
1480
1481 $pdf_response = [
1482 'success' => true,
1483 'message' => $success_text,
1484 'data' => [
1485 'filename' => $pdf_filename
1486 ]
1487 ];
1488
1489 if ($testing_data !== null) {
1490 $pdf_response['testing_data'] = $testing_data;
1491 }
1492
1493 wp_send_json($pdf_response);
1494 wp_die();
1495 } else {
1496 $error_text = $current_options['pdf_intent_error_text'] ??
1497 esc_html__("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
1498 $this->fallbackResponse['text'] = $error_text;
1499 }
1500
1501 $pdf_error_response = [
1502 'success' => false,
1503 'message' => $this->fallbackResponse['text']
1504 ];
1505
1506 if ($testing_data !== null) {
1507 $pdf_error_response['testing_data'] = $testing_data;
1508 }
1509
1510 wp_send_json($pdf_error_response);
1511 wp_die();
1512 }
1513 }
1514 }
1515
1516
1517 // Step 2: Detect intent and handle intent-based responses
1518 $intent_result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1519
1520 // Capture action analysis for testing panel after intent check
1521 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1522 $testing_data['action_matches'] = $this->last_action_analysis;
1523 }
1524
1525 // Step 3: Handle the intent result appropriately
1526 if ($intent_result !== false) {
1527 // Intent was matched - ALWAYS send as JSON response, never streaming
1528
1529 if (is_array($intent_result) && (isset($intent_result['text']) || isset($intent_result['html']))) {
1530 // Intent returned a direct response array
1531 $response_data = [
1532 'text' => $intent_result['text'] ?? '',
1533 'html' => $intent_result['html'] ?? '',
1534 'session_id' => $session_id
1535 ];
1536
1537 // IMPORTANT: Include chat_mode if present (for WhatsApp, Slack, etc.)
1538 if (isset($intent_result['chat_mode'])) {
1539 $response_data['chat_mode'] = $intent_result['chat_mode'];
1540 }
1541
1542 if ($testing_data !== null) {
1543 $response_data['testing_data'] = $testing_data;
1544 }
1545
1546 wp_send_json($response_data);
1547 wp_die();
1548 } else if ($intent_result === true && (!empty($this->fallbackResponse['text']) || !empty($this->fallbackResponse['html']))) {
1549 // Intent returned true and set fallbackResponse
1550
1551 // SAVE TO TRANSCRIPT FIRST
1552 if (!empty($this->fallbackResponse['text'])) {
1553 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1554 }
1555 if (!empty($this->fallbackResponse['html'])) {
1556 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1557 }
1558
1559 $response_data = [
1560 'text' => $this->fallbackResponse['text'] ?? '',
1561 'html' => $this->fallbackResponse['html'] ?? '',
1562 'session_id' => $session_id
1563 ];
1564
1565 if (isset($this->fallbackResponse['chat_mode'])) {
1566 $response_data['chat_mode'] = $this->fallbackResponse['chat_mode'];
1567 }
1568
1569 if ($testing_data !== null) {
1570 $response_data['testing_data'] = $testing_data;
1571 }
1572
1573 wp_send_json($response_data);
1574 wp_die();
1575 }
1576 }
1577
1578 // If we get here, no intent matched OR the intent didn't provide a usable response
1579
1580 // Step 4: Generate AI response
1581 $conversation_history = $this->mxchat_fetch_conversation_history_for_ai($session_id);
1582 $this->mxchat_increment_chat_count();
1583
1584 // Generate embedding for the user's query - USE BOT-SPECIFIC API KEY
1585 $api_key = $current_options['api_key'] ?? $this->options['api_key'];
1586 $user_message_embedding = $this->mxchat_generate_embedding($message, $api_key);
1587
1588 // Check if the embedding generation returned an error
1589 if (is_array($user_message_embedding) && isset($user_message_embedding['error'])) {
1590 $error_message = $user_message_embedding['error'];
1591 $error_code = $user_message_embedding['error_code'] ?? 'embedding_error';
1592
1593 // FIXED: Send error in appropriate format based on streaming mode
1594 if ($is_streaming) {
1595 echo "data: " . json_encode([
1596 'error' => true,
1597 'error_message' => $error_message,
1598 'error_code' => $error_code,
1599 'text' => $error_message,
1600 'message' => $error_message
1601 ]) . "\n\n";
1602 echo "data: [DONE]\n\n";
1603 flush();
1604 } else {
1605 wp_send_json_error([
1606 'error_message' => $error_message,
1607 'error_code' => $error_code
1608 ]);
1609 }
1610 wp_die();
1611 }
1612
1613 // Check if the embedding is valid
1614 if (!is_array($user_message_embedding) || empty($user_message_embedding)) {
1615 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
1616
1617 // FIXED: Send error in appropriate format based on streaming mode
1618 if ($is_streaming) {
1619 echo "data: " . json_encode([
1620 'error' => true,
1621 'error_message' => $error_message,
1622 'error_code' => 'invalid_embedding',
1623 'text' => $error_message,
1624 'message' => $error_message
1625 ]) . "\n\n";
1626 echo "data: [DONE]\n\n";
1627 flush();
1628 } else {
1629 wp_send_json_error([
1630 'error_message' => $error_message,
1631 'error_code' => 'invalid_embedding'
1632 ]);
1633 }
1634 wp_die();
1635 }
1636
1637 // Build context with both knowledge base and PDF content if available
1638 $context_content = "User asked: '{$message}'\n\n";
1639
1640 // Add action instruction if present (add this right after the above line)
1641 if (!empty($this->current_action_instruction)) {
1642 $context_content .= "===== SPECIAL INSTRUCTION =====\n";
1643 $context_content .= "IMPORTANT: " . $this->current_action_instruction . "\n";
1644 $context_content .= "Respond naturally and conversationally while following this instruction.\n";
1645 $context_content .= "===== END SPECIAL INSTRUCTION =====\n\n";
1646
1647 // Clear the instruction after using it
1648 $this->current_action_instruction = null;
1649 }
1650
1651
1652 // Add page context if available and contextual awareness is enabled using current_options
1653 if ($page_context && isset($current_options['contextual_awareness_toggle']) && $current_options['contextual_awareness_toggle'] === 'on') {
1654 $context_content .= "===== CURRENT PAGE CONTEXT =====\n";
1655 $context_content .= "Page URL: " . $page_context['url'] . "\n";
1656 $context_content .= "Page Title: " . $page_context['title'] . "\n";
1657 $context_content .= "Page Content: " . $page_context['content'] . "\n";
1658 $context_content .= "===== END CURRENT PAGE CONTEXT =====\n\n";
1659 }
1660
1661 // Get relevant content from knowledge base - PASS BOT_ID and MESSAGE for Vector Store
1662 $relevant_content = $this->mxchat_find_relevant_content($user_message_embedding, $bot_id, $message);
1663
1664 // NEW: Also extract URLs from system instructions (only if citation links enabled)
1665 // Use fresh options to ensure we get the latest setting value
1666 $fresh_options = get_option('mxchat_options', []);
1667 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
1668
1669 $system_instructions = $this->get_system_instructions($bot_id);
1670 if ($citation_links_enabled && !empty($system_instructions)) {
1671 preg_match_all(
1672 '#\bhttps?://[^\s<>"\']+#i',
1673 $system_instructions,
1674 $system_instruction_urls
1675 );
1676
1677 if (!empty($system_instruction_urls[0])) {
1678 // Merge with existing valid URLs
1679 $this->current_valid_urls = array_merge(
1680 $this->current_valid_urls,
1681 $system_instruction_urls[0]
1682 );
1683 // Remove duplicates
1684 $this->current_valid_urls = array_unique($this->current_valid_urls);
1685
1686 error_log("Added " . count($system_instruction_urls[0]) . " URLs from system instructions");
1687 }
1688 }
1689
1690 // ===== CAPTURE REAL SIMILARITY DATA FOR ADMINS =====
1691 if ($testing_data !== null && $this->last_similarity_analysis !== null) {
1692 // Update testing data with the REAL similarity analysis
1693 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
1694 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
1695 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
1696 }
1697 // ===== END SIMILARITY DATA CAPTURE =====
1698
1699 // NEW: Add valid URLs to testing data for admin panel display (AFTER similarity data)
1700 if ($testing_data !== null && !empty($this->current_valid_urls)) {
1701 $testing_data['approved_urls'] = array_values($this->current_valid_urls);
1702 error_log("Added " . count($this->current_valid_urls) . " approved URLs to testing data");
1703 }
1704
1705 if (!empty($relevant_content)) {
1706 $context_content .= "===== OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n" . $relevant_content . "\n===== END OF OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n\n";
1707 } else {
1708 $context_content .= "===== NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE =====\n";
1709 }
1710
1711 // NEW: Add approved URLs list to context for AI (only if citation links enabled)
1712 if ($citation_links_enabled && !empty($this->current_valid_urls)) {
1713 $context_content .= "===== APPROVED URLS FOR CITATIONS =====\n";
1714 $context_content .= "You may ONLY use these exact URLs in your response:\n";
1715 foreach ($this->current_valid_urls as $url) {
1716 $context_content .= "- " . $url . "\n";
1717 }
1718 $context_content .= "\nCRITICAL: Do NOT create, modify, extend, or invent any other URLs. ";
1719 $context_content .= "===== END APPROVED URLS =====\n\n";
1720 }
1721
1722 // Check for and include PDF content
1723 $pdf_url = get_transient('mxchat_pdf_url_' . $session_id);
1724 $pdf_embeddings = get_transient('mxchat_pdf_embeddings_' . $session_id);
1725 $pdf_filename = get_transient('mxchat_pdf_filename_' . $session_id);
1726 if ($pdf_url && $pdf_embeddings && get_transient('mxchat_include_pdf_in_context_' . $session_id)) {
1727 $relevant_pdf_pages = $this->find_relevant_pdf_pages($user_message_embedding, $pdf_embeddings);
1728 if (!empty($relevant_pdf_pages)) {
1729 $context_content .= "Relevant content from PDF document '{$pdf_filename}':\n";
1730 foreach ($relevant_pdf_pages as $page_data) {
1731 $context_content .= "Page {$page_data['page_number']} of '{$pdf_filename}': {$page_data['text']}\n";
1732 }
1733 $context_content .= "\n";
1734 }
1735 }
1736
1737 // Check for and include Word content
1738 $word_url = get_transient('mxchat_word_url_' . $session_id);
1739 $word_embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
1740 $word_filename = get_transient('mxchat_word_filename_' . $session_id);
1741 if ($word_url && $word_embeddings && get_transient('mxchat_include_word_in_context_' . $session_id)) {
1742 $relevant_word_chunks = $this->word_handler->mxchat_find_relevant_word_chunks($user_message_embedding, $word_embeddings);
1743 if (!empty($relevant_word_chunks)) {
1744 $context_content .= "Relevant content from Word document '{$word_filename}':\n";
1745 foreach ($relevant_word_chunks as $chunk_data) {
1746 $context_content .= "Section {$chunk_data['chunk_number']} of '{$word_filename}': {$chunk_data['text']}\n";
1747 }
1748 $context_content .= "\n";
1749 }
1750 }
1751
1752 $context_content = apply_filters('mxchat_prepare_context', $context_content, $session_id);
1753
1754 // Extract model from current options for bot-specific model support
1755 $selected_model = isset($current_options['model']) ? $current_options['model'] : 'gpt-4o';
1756
1757 $response = $this->mxchat_generate_response(
1758 $context_content,
1759 $current_options['api_key'] ?? $this->options['api_key'],
1760 $current_options['xai_api_key'] ?? $this->options['xai_api_key'],
1761 $current_options['claude_api_key'] ?? $this->options['claude_api_key'],
1762 $current_options['deepseek_api_key'] ?? $this->options['deepseek_api_key'],
1763 $current_options['gemini_api_key'] ?? $this->options['gemini_api_key'],
1764 $current_options['openrouter_api_key'] ?? $this->options['openrouter_api_key'],
1765 $conversation_history,
1766 $is_streaming,
1767 $session_id,
1768 $testing_data,
1769 $selected_model
1770 );
1771
1772 // Handle streaming vs non-streaming responses
1773 if ($is_streaming) {
1774 // Check if streaming actually happened or if it fell back to regular response
1775 if ($response === true) {
1776 wp_die();
1777 }
1778 // If we get here, streaming fell back to regular response, continue
1779 // But if there's an error, we need to send it as SSE format since headers are already set
1780 if (is_array($response) && isset($response['error'])) {
1781 $error_message = $response['error'];
1782 $error_code = $response['error_code'] ?? 'api_error';
1783 // Send error in SSE format that the client JS can handle
1784 echo "data: " . json_encode([
1785 'error' => true,
1786 'error_message' => $error_message,
1787 'error_code' => $error_code,
1788 'text' => $error_message, // Also include as text for fallback handling
1789 'message' => $error_message
1790 ]) . "\n\n";
1791 echo "data: [DONE]\n\n";
1792 flush();
1793 wp_die();
1794 }
1795 }
1796
1797 // Check if the response is an error array (non-streaming mode)
1798 if (is_array($response) && isset($response['error'])) {
1799 wp_send_json_error([
1800 'error_message' => $response['error'],
1801 'error_code' => $response['error_code'] ?? 'api_error'
1802 ]);
1803 wp_die();
1804 }
1805
1806 // DEBUG: Check what we have
1807 error_log("=== BEFORE URL VALIDATION ===");
1808 error_log("current_valid_urls is empty? " . (empty($this->current_valid_urls) ? 'YES' : 'NO'));
1809 error_log("current_valid_urls count: " . count($this->current_valid_urls));
1810 error_log("current_valid_urls content: " . print_r($this->current_valid_urls, true));
1811
1812 // If we get here, the response is valid text - now validate URLs
1813 if (!empty($this->current_valid_urls)) {
1814 error_log("CALLING validate_and_clean_urls");
1815 $response = $this->validate_and_clean_urls($response, $this->current_valid_urls);
1816 } else {
1817 error_log("SKIPPING validation - current_valid_urls is empty");
1818 }
1819 // ===== END URL VALIDATION =====
1820
1821 // Prepare RAG context data for storage (only include documents used for context)
1822 $rag_context_for_storage = null;
1823 if ($this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches'])) {
1824 $rag_context_for_storage = [
1825 'top_matches' => $this->last_similarity_analysis['top_matches'],
1826 'approved_urls' => $this->current_valid_urls ?? [],
1827 'similarity_threshold' => $this->last_similarity_analysis['threshold_used'] ?? 0.35,
1828 'knowledge_base_type' => $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database',
1829 'total_documents_checked' => $this->last_similarity_analysis['total_checked'] ?? 0
1830 ];
1831 }
1832
1833 // Save the cleaned response with RAG context
1834 $this->mxchat_save_chat_message($session_id, 'bot', $response, null, $rag_context_for_storage);
1835
1836 // Step 5: Save additional content if available
1837 if (!empty($this->productCardHtml)) {
1838 $this->mxchat_save_chat_message($session_id, 'bot', $this->productCardHtml);
1839 }
1840
1841 if (!empty($this->fallbackResponse['html'])) {
1842 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1843 }
1844
1845 // Step 6: Return the response
1846 // DEBUG: Check if newlines exist in the response
1847 error_log("=== MXCHAT NON-STREAMING RESPONSE DEBUG ===");
1848 error_log("Response has newlines: " . (strpos($response, "\n") !== false ? 'YES' : 'NO'));
1849 error_log("Response first 500 chars: " . substr($response, 0, 500));
1850
1851 $response_data = [
1852 'text' => $response,
1853 'html' => !empty($this->productCardHtml) ? $this->productCardHtml : ($this->fallbackResponse['html'] ?? ''),
1854 'session_id' => $session_id
1855 ];
1856
1857 // Always add testing data for admins (no toggle needed)
1858 if ($testing_data !== null) {
1859 $response_data['testing_data'] = $testing_data;
1860 }
1861
1862 wp_send_json($response_data);
1863 wp_die();
1864 }
1865
1866 /**
1867 * Get bot-specific options for multi-bot functionality
1868 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
1869 */
1870 // Also debug the bot options retrieval
1871 private function get_bot_options($bot_id = 'default') {
1872 error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
1873
1874 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1875 error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
1876 return array();
1877 }
1878
1879 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
1880
1881 if (!empty($bot_options)) {
1882 error_log("MXCHAT DEBUG: Got bot-specific options from filter");
1883 if (isset($bot_options['similarity_threshold'])) {
1884 error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
1885 }
1886 }
1887
1888 return is_array($bot_options) ? $bot_options : array();
1889 }
1890
1891 /**
1892 * Get bot-specific Pinecone configuration
1893 * Used in the knowledge retrieval functions
1894 */
1895 // Also add debugging to your get_bot_pinecone_config function
1896 private function get_bot_pinecone_config($bot_id = 'default') {
1897 error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
1898
1899 // If default bot or multi-bot add-on not active, use default Pinecone config
1900 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1901 error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
1902 $addon_options = get_option('mxchat_pinecone_addon_options', array());
1903 $config = array(
1904 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
1905 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
1906 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
1907 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
1908 );
1909 error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
1910 return $config;
1911 }
1912
1913 error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
1914
1915 // Hook for multi-bot add-on to provide bot-specific Pinecone config
1916 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1917
1918 if (!empty($bot_pinecone_config)) {
1919 error_log("MXCHAT DEBUG: Got bot-specific config from filter");
1920 error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
1921 error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
1922 error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
1923 } else {
1924 error_log("MXCHAT DEBUG: Filter returned empty config!");
1925 }
1926
1927 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
1928 }
1929
1930
1931 // Updated function to check intents and invoke the callback function
1932 private function mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id) {
1933 global $wpdb;
1934 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1935
1936 // Get the current bot_id
1937 $current_bot_id = $this->get_current_bot_id($session_id);
1938
1939 // Generate the user embedding
1940 $user_embedding = $this->mxchat_generate_embedding($message, $this->options['api_key']);
1941
1942 // Check if embedding generation returned an error
1943 if (is_array($user_embedding) && isset($user_embedding['error'])) {
1944 $error_message = $user_embedding['error'];
1945 $error_code = $user_embedding['error_code'] ?? 'embedding_error';
1946
1947 // FIXED: Send error in appropriate format based on streaming mode
1948 if ($this->is_streaming) {
1949 echo "data: " . json_encode([
1950 'error' => true,
1951 'error_message' => $error_message,
1952 'error_code' => $error_code,
1953 'text' => $error_message,
1954 'message' => $error_message
1955 ]) . "\n\n";
1956 echo "data: [DONE]\n\n";
1957 flush();
1958 } else {
1959 wp_send_json_error([
1960 'error_message' => $error_message,
1961 'error_code' => $error_code
1962 ]);
1963 }
1964 wp_die();
1965 }
1966
1967 // Check if embedding is valid
1968 if (!is_array($user_embedding) || empty($user_embedding)) {
1969 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
1970
1971 // FIXED: Send error in appropriate format based on streaming mode
1972 if ($this->is_streaming) {
1973 echo "data: " . json_encode([
1974 'error' => true,
1975 'error_message' => $error_message,
1976 'error_code' => 'invalid_embedding',
1977 'text' => $error_message,
1978 'message' => $error_message
1979 ]) . "\n\n";
1980 echo "data: [DONE]\n\n";
1981 flush();
1982 } else {
1983 wp_send_json_error([
1984 'error_message' => $error_message,
1985 'error_code' => 'invalid_embedding'
1986 ]);
1987 }
1988 wp_die();
1989 }
1990
1991 // Fetch intents from the database
1992 $table_name = $wpdb->prefix . 'mxchat_intents';
1993 if ($chat_mode === 'agent') {
1994 $query = $wpdb->prepare(
1995 "SELECT * FROM $table_name WHERE callback_function = %s AND (enabled = 1 OR enabled IS NULL)",
1996 'mxchat_handle_switch_to_chatbot_intent'
1997 );
1998 $intents = $wpdb->get_results($query);
1999 } else {
2000 $intents = $wpdb->get_results("SELECT * FROM $table_name WHERE enabled = 1 OR enabled IS NULL");
2001 }
2002
2003 if (empty($intents)) {
2004 return false;
2005 }
2006
2007 $highest_similarity = -INF;
2008 $matched_intent = null;
2009
2010 // Array to store action analysis for testing panel
2011 $action_analysis = [];
2012
2013 foreach ($intents as $intent) {
2014 // Additional check for enabled state
2015 $is_enabled = isset($intent->enabled) ? (bool)$intent->enabled : true;
2016 if (!$is_enabled) {
2017 continue;
2018 }
2019
2020 // Check if this action is enabled for the current bot
2021 if (!$this->is_action_enabled_for_bot($intent, $current_bot_id)) {
2022 continue;
2023 }
2024
2025 $intent_embedding_serialized = $intent->embedding_vector;
2026 $intent_embedding = $intent_embedding_serialized
2027 ? unserialize($intent_embedding_serialized, ['allowed_classes' => false])
2028 : null;
2029
2030 if (!is_array($intent_embedding)) {
2031 continue;
2032 }
2033
2034 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $intent_embedding);
2035 $intent_threshold = isset($intent->similarity_threshold) ? $intent->similarity_threshold : 0.85;
2036
2037 // Store action analysis data for testing panel
2038 $action_analysis[] = [
2039 'intent_label' => $intent->intent_label,
2040 'callback_function' => $intent->callback_function,
2041 'similarity' => round($similarity, 4),
2042 'similarity_percentage' => round($similarity * 100, 2),
2043 'threshold' => $intent_threshold,
2044 'threshold_percentage' => round($intent_threshold * 100, 2),
2045 'above_threshold' => $similarity >= $intent_threshold,
2046 'triggered' => false // Will be updated below if this intent is triggered
2047 ];
2048
2049 if ($similarity >= $intent_threshold && $similarity > $highest_similarity) {
2050 $highest_similarity = $similarity;
2051 $matched_intent = $intent;
2052 }
2053 }
2054
2055 // Mark the triggered action if any
2056 if ($matched_intent) {
2057 foreach ($action_analysis as &$action) {
2058 if ($action['intent_label'] === $matched_intent->intent_label) {
2059 $action['triggered'] = true;
2060 break;
2061 }
2062 }
2063 }
2064
2065 // Sort actions by similarity (highest first) and store for testing panel
2066 usort($action_analysis, function($a, $b) {
2067 return $b['similarity'] <=> $a['similarity'];
2068 });
2069
2070 // Store action analysis for testing panel capture
2071 $this->last_action_analysis = $action_analysis;
2072
2073 // Around line 715 in your mxchat_check_intent_and_invoke_callback function
2074 if ($matched_intent) {
2075 // If the callback is a method on this instance (core callback), call it directly
2076 if (method_exists($this, $matched_intent->callback_function)) {
2077 $callback_result = call_user_func(
2078 [$this, $matched_intent->callback_function],
2079 $message,
2080 $user_id,
2081 $session_id,
2082 $matched_intent,
2083 $user_context ?? null
2084 );
2085 } else {
2086 // Otherwise, use apply_filters for add-on callbacks
2087 $callback_result = apply_filters(
2088 $matched_intent->callback_function,
2089 false,
2090 $message,
2091 $user_id,
2092 $session_id,
2093 $matched_intent
2094 );
2095 }
2096
2097 // Handle the callback result properly
2098 if ($callback_result !== false) {
2099 // If callback returned an array with chat_mode, use it directly
2100 if (is_array($callback_result) && isset($callback_result['chat_mode'])) {
2101 $this->fallbackResponse = $callback_result;
2102 return $callback_result; // Return the full array
2103 } else {
2104 $this->fallbackResponse = $callback_result;
2105 return true;
2106 }
2107 }
2108 }
2109
2110 return false;
2111 }
2112
2113 /**
2114 * Check if an action is enabled for a specific bot
2115 */
2116 private function is_action_enabled_for_bot($intent, $bot_id) {
2117 // If enabled_bots column doesn't exist or is null, assume it's enabled for all bots (backward compatibility)
2118 if (!isset($intent->enabled_bots) || empty($intent->enabled_bots)) {
2119 return true;
2120 }
2121
2122 $enabled_bots = json_decode($intent->enabled_bots, true);
2123
2124 // If JSON decode fails or returns empty array, assume enabled for all (backward compatibility)
2125 if (!is_array($enabled_bots) || empty($enabled_bots)) {
2126 return true;
2127 }
2128
2129 // Check if the current bot is in the enabled bots list
2130 return in_array($bot_id, $enabled_bots);
2131 }
2132
2133 // Helper function to clear PDF and Word document related transients
2134 private function clear_pdf_transients($session_id) {
2135 // PDF transients
2136 delete_transient('mxchat_pdf_url_' . $session_id);
2137 delete_transient('mxchat_pdf_embeddings_' . $session_id);
2138 delete_transient('mxchat_include_pdf_in_context_' . $session_id);
2139 delete_transient('mxchat_waiting_for_pdf_url_' . $session_id);
2140
2141 // Word document transients
2142 delete_transient('mxchat_word_url_' . $session_id);
2143 delete_transient('mxchat_word_filename_' . $session_id);
2144 delete_transient('mxchat_word_embeddings_' . $session_id);
2145 delete_transient('mxchat_include_word_in_context_' . $session_id);
2146 delete_transient('mxchat_waiting_for_word_' . $session_id);
2147 }
2148
2149
2150
2151 //verified good
2152 public function mxchat_handle_email_capture($message, $user_id, $session_id) {
2153 // Get the user's original instruction/message
2154 $user_instruction = esc_html($this->options['triggered_phrase_response'] ?? esc_html__("Please provide your email address.", 'mxchat'));
2155
2156 // Set instruction for AI - just pass along what the user wanted to say
2157 $this->current_action_instruction = $user_instruction;
2158
2159 // Set the transient to track email capture flow
2160 set_transient('mxchat_email_capture_' . $user_id, true, 5 * MINUTE_IN_SECONDS);
2161
2162 // Return false to let the AI generate the response
2163 return false;
2164 }
2165
2166 public function mxchat_generate_image($message, $user_id, $session_id) {
2167 //error_log("Starting image generation for message: " . $message);
2168
2169 // Prepare a prompt for DALL-E
2170 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2171
2172 // Use the existing OpenAI API key
2173 $openai_api_key = sanitize_text_field($this->options['api_key']);
2174
2175 // Call DALL-E to generate an image
2176 $image_response = $this->mxchat_generate_dalle_image($prompt, $openai_api_key);
2177
2178 // Check if the response contains an image URL
2179 if (isset($image_response['imageUrl'])) {
2180 $image_url = esc_url_raw($image_response['imageUrl']);
2181
2182 // Construct the HTML with a CSS class instead of inline styles
2183 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2184 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2185
2186 // Save the bot message with both text and HTML
2187 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2188 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2189
2190 // Set the fallback response for the chat handler
2191 $this->fallbackResponse = [
2192 'text' => $response_text,
2193 'html' => $response_html,
2194 'images' => [$image_url]
2195 ];
2196
2197 // For debugging/verification - Use json_encode to verify what's being set
2198 //error_log("Image generation successful - fallbackResponse set: " . json_encode($this->fallbackResponse));
2199
2200 // Return the response directly instead of relying on the property
2201 return $this->fallbackResponse;
2202 } else {
2203 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2204
2205 // Save the error message
2206 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2207
2208 // Set the fallback response for the chat handler
2209 $this->fallbackResponse = [
2210 'text' => $response_text,
2211 'html' => '',
2212 'images' => []
2213 ];
2214
2215 //error_log("DALL-E image generation error: " . esc_html($image_response['error'] ?? 'Unknown error.'));
2216 //error_log("Error fallbackResponse set: " . json_encode($this->fallbackResponse));
2217
2218 // Return the response directly instead of relying on the property
2219 return $this->fallbackResponse;
2220 }
2221 }
2222 private function mxchat_generate_dalle_image($prompt, $api_key, $model = 'dall-e-3', $timeout = 60) {
2223 $api_url = 'https://api.openai.com/v1/images/generations';
2224 $body = json_encode([
2225 'prompt' => sanitize_text_field($prompt),
2226 'n' => 1,
2227 'size' => '1024x1024',
2228 'model' => sanitize_text_field($model),
2229 ]);
2230
2231 $args = [
2232 'body' => $body,
2233 'headers' => [
2234 'Content-Type' => 'application/json',
2235 'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
2236 ],
2237 'method' => 'POST',
2238 'timeout' => absint($timeout),
2239 ];
2240
2241 $response = wp_remote_post($api_url, $args);
2242
2243 if (is_wp_error($response)) {
2244 //error_log("DALL-E request failed: " . $response->get_error_message());
2245 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
2246 }
2247
2248 $response_body = json_decode(wp_remote_retrieve_body($response), true);
2249
2250 if (isset($response_body['data'][0]['url'])) {
2251 return ['imageUrl' => esc_url_raw($response_body['data'][0]['url'])];
2252 } else {
2253 //error_log("DALL-E response error: " . wp_remote_retrieve_body($response));
2254 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
2255 }
2256 }
2257
2258 /**
2259 * Handle web search requests.
2260 *
2261 * Sends the refined search query to the Brave Search API and uses the
2262 * results to generate a conversational response with the AI model.
2263 *
2264 * @since 1.0.0
2265 * @param string $message The user's search query.
2266 * @param string $user_id The user identifier.
2267 * @param string $session_id The current session ID.
2268 * @return array Response array containing text with embedded HTML links
2269 */
2270 public function mxchat_handle_search_request($message, $user_id, $session_id) {
2271 // Step 1: Interpret and refine the search query
2272 $refined_search_query = $this->mxchat_interpret_search_query($message);
2273 if (empty($refined_search_query)) {
2274 return array(
2275 'text' => esc_html__('I apologize, but could you please rephrase your search request?', 'mxchat'),
2276 'html' => ''
2277 );
2278 }
2279
2280 // Retrieve and validate API settings
2281 $options = get_option('mxchat_options');
2282 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2283 $results_count = isset($options['brave_results_count']) ? absint($options['brave_results_count']) : 5;
2284
2285 if (empty($api_key)) {
2286 return array(
2287 'text' => esc_html__('Search functionality is temporarily unavailable. Please try again later.', 'mxchat'),
2288 'html' => ''
2289 );
2290 }
2291
2292 // Build the API request URL
2293 $api_url = add_query_arg(
2294 array(
2295 'q' => rawurlencode($refined_search_query),
2296 'count' => $results_count,
2297 'text_decorations' => 'true',
2298 'rich_data' => 'true',
2299 ),
2300 'https://api.search.brave.com/res/v1/web/search'
2301 );
2302
2303 // Attempt to retrieve cached results first
2304 $transient_key = 'mxchat_search_' . md5($refined_search_query);
2305 $results = get_transient($transient_key);
2306
2307 if (false === $results) {
2308 // SECURITY FIX: Changed to wp_safe_remote_get
2309 $response = wp_safe_remote_get(
2310 $api_url,
2311 array(
2312 'headers' => array(
2313 'Accept' => 'application/json',
2314 'Accept-Encoding' => 'gzip',
2315 'X-Subscription-Token'=> $api_key,
2316 ),
2317 'timeout' => 10,
2318 )
2319 );
2320
2321 if (is_wp_error($response)) {
2322 return array(
2323 'text' => esc_html__('I encountered an error while searching. Please try again.', 'mxchat'),
2324 'html' => ''
2325 );
2326 }
2327
2328 $results = json_decode(wp_remote_retrieve_body($response), true);
2329
2330 if (json_last_error() !== JSON_ERROR_NONE) {
2331 return array(
2332 'text' => esc_html__('I received an invalid response from the search service.', 'mxchat'),
2333 'html' => ''
2334 );
2335 }
2336
2337 // Cache results for one hour
2338 set_transient($transient_key, $results, HOUR_IN_SECONDS);
2339 }
2340
2341 // Process results
2342 if (!empty($results['web']['results']) && is_array($results['web']['results'])) {
2343 // Create a more straightforward summary with HTML links
2344 $search_results_text = '';
2345
2346 // Add a simple intro
2347 $search_results_text .= sprintf(
2348 esc_html__("Here's what I found about '%s':", 'mxchat'),
2349 esc_html($refined_search_query)
2350 );
2351
2352 // Add the top results with HTML links
2353 foreach (array_slice($results['web']['results'], 0, 5) as $result) {
2354 $title = isset($result['title']) ? wp_strip_all_tags($result['title']) : '';
2355 $url = isset($result['url']) ? esc_url($result['url']) : '';
2356 $description = isset($result['description']) ? wp_strip_all_tags($result['description']) : '';
2357
2358 // Add a line break after the intro
2359 $search_results_text .= '<br><br>';
2360
2361 // Add title as a link
2362 $search_results_text .= sprintf(
2363 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a><br>',
2364 $url,
2365 $title
2366 );
2367
2368 // Add a condensed description
2369 $search_results_text .= sprintf("%s", $description);
2370 }
2371
2372 // Save to chat history
2373 $this->mxchat_save_chat_message($session_id, 'bot', $search_results_text);
2374
2375 // Return the formatted text with embedded HTML links
2376 return array(
2377 'text' => $search_results_text,
2378 'html' => ''
2379 );
2380 } else {
2381 return array(
2382 'text' => sprintf(
2383 esc_html__('I searched for "%s" but couldn\'t find any relevant results. Would you like to try different search terms?', 'mxchat'),
2384 esc_html($refined_search_query)
2385 ),
2386 'html' => ''
2387 );
2388 }
2389 }
2390
2391 //very good
2392 /**
2393 * Handle image search requests from the chatbot
2394 *
2395 * @param string $message The user's search query
2396 * @param int $user_id The user's ID
2397 * @param string $session_id The chat session ID
2398 * @return array Response array with text and HTML content
2399 */
2400 public function mxchat_handle_image_search_request($message, $user_id, $session_id) {
2401 // Step 1: Interpret the search query using the user's selected AI model
2402 $refined_search_query = $this->mxchat_interpret_search_query($message);
2403
2404 // If no query was interpreted, return a fallback message
2405 if (empty($refined_search_query)) {
2406 return array(
2407 'text' => __("I'm sorry, I couldn't interpret your search query. Please specify what you'd like to see images of.", 'mxchat'),
2408 'html' => "",
2409 );
2410 }
2411
2412 // Brave API URL
2413 $api_url = 'https://api.search.brave.com/res/v1/images/search';
2414
2415 // Retrieve Brave API settings
2416 $options = get_option('mxchat_options');
2417 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2418
2419 if (empty($api_key)) {
2420 return array(
2421 'text' => __("API key is not configured. Please set it in the Brave Search Settings.", 'mxchat'),
2422 'html' => "",
2423 );
2424 }
2425
2426 $image_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
2427 $safe_search = isset($options['brave_safe_search']) ? sanitize_text_field($options['brave_safe_search']) : 'strict';
2428
2429 // Append query parameters based on settings
2430 $api_url = add_query_arg([
2431 'q' => rawurlencode($refined_search_query),
2432 'count' => $image_count,
2433 'safesearch' => $safe_search,
2434 ], $api_url);
2435
2436 // Implement caching
2437 $transient_key = 'mxchat_image_search_' . md5($refined_search_query);
2438 $body = get_transient($transient_key);
2439
2440 if (false === $body) {
2441 $args = [
2442 'headers' => [
2443 'Accept' => 'application/json',
2444 'Accept-Encoding' => 'gzip',
2445 'X-Subscription-Token' => $api_key,
2446 ],
2447 'timeout' => 10,
2448 ];
2449
2450 // SECURITY FIX: Changed to wp_safe_remote_get
2451 $response = wp_safe_remote_get($api_url, $args);
2452
2453 if (is_wp_error($response)) {
2454 return array(
2455 'text' => __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat'),
2456 'html' => "",
2457 );
2458 }
2459
2460 $body = json_decode(wp_remote_retrieve_body($response), true);
2461 set_transient($transient_key, $body, HOUR_IN_SECONDS);
2462 }
2463
2464 // Process the API response
2465 if (isset($body['results']) && is_array($body['results']) && count($body['results']) > 0) {
2466 $html_output = '<div class="mxchat-image-gallery">';
2467
2468 // Get the configured image count (1-6)
2469 $display_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
2470 $display_count = min($display_count, count($body['results'])); // Make sure we don't exceed available images
2471
2472 // Use only the requested number of images
2473 for ($i = 0; $i < $display_count; $i++) {
2474 $image = $body['results'][$i];
2475 $image_url = isset($image['url']) ? esc_url($image['url']) : '';
2476 $thumbnail_url = isset($image['thumbnail']['src']) ? esc_url($image['thumbnail']['src']) : '';
2477 $title = isset($image['title']) ? esc_html($image['title']) : esc_html__('Image', 'mxchat');
2478
2479 if ($image_url && $thumbnail_url) {
2480 $html_output .= '<div class="mxchat-image-item">';
2481 $html_output .= '<strong class="mxchat-image-title">' . $title . '</strong>';
2482 $html_output .= '<a href="' . $image_url . '" target="_blank" rel="noopener noreferrer" class="mxchat-image-link">';
2483 $html_output .= '<img src="' . $thumbnail_url . '" alt="' . $title . '" class="mxchat-image-thumbnail">';
2484 $html_output .= '</a></div>';
2485 }
2486 }
2487
2488 $html_output .= '</div>';
2489
2490 // Create response text
2491 $response_text = sprintf(__("Here are some images of %s:", 'mxchat'), $refined_search_query);
2492
2493 // Save both response text and HTML to chat history
2494 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2495 $this->mxchat_save_chat_message($session_id, 'bot', $html_output);
2496
2497 // Return the combined response
2498 return array(
2499 'text' => $response_text,
2500 'html' => $html_output,
2501 );
2502 } else {
2503 $response_text = __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat');
2504
2505 // Save the error message to chat history
2506 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2507
2508 return array(
2509 'text' => $response_text,
2510 'html' => "",
2511 );
2512 }
2513 }
2514
2515 /**
2516 * Interpret the search query using the user's selected AI model
2517 *
2518 * @param string $user_query The original query from the user
2519 * @return string The refined search query
2520 */
2521 public function mxchat_interpret_search_query($user_query) {
2522 $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');
2523
2524 // Get options and determine the selected model
2525 $options = $this->options ?? get_option('mxchat_options');
2526 $selected_model = isset($options['model']) ? $options['model'] : 'gpt-4o';
2527
2528 // Extract model prefix to determine the provider
2529 $model_parts = explode('-', $selected_model);
2530 $provider = strtolower($model_parts[0]);
2531
2532 // Determine which API key to use based on the provider
2533 switch ($provider) {
2534 case 'gemini':
2535 $api_key = isset($options['gemini_api_key']) ? sanitize_text_field($options['gemini_api_key']) : '';
2536 if (empty($api_key)) {
2537 return sanitize_text_field($user_query); // Default to original query if API key missing
2538 }
2539 return $this->interpret_query_with_gemini($user_query, $system_prompt, $api_key, $selected_model);
2540
2541 case 'claude':
2542 $api_key = isset($options['claude_api_key']) ? sanitize_text_field($options['claude_api_key']) : '';
2543 if (empty($api_key)) {
2544 return sanitize_text_field($user_query);
2545 }
2546 return $this->interpret_query_with_claude($user_query, $system_prompt, $api_key, $selected_model);
2547
2548 case 'grok':
2549 $api_key = isset($options['xai_api_key']) ? sanitize_text_field($options['xai_api_key']) : '';
2550 if (empty($api_key)) {
2551 return sanitize_text_field($user_query);
2552 }
2553 return $this->interpret_query_with_xai($user_query, $system_prompt, $api_key, $selected_model);
2554
2555 case 'deepseek':
2556 $api_key = isset($options['deepseek_api_key']) ? sanitize_text_field($options['deepseek_api_key']) : '';
2557 if (empty($api_key)) {
2558 return sanitize_text_field($user_query);
2559 }
2560 return $this->interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $selected_model);
2561
2562 case 'gpt':
2563 default:
2564 // Default to OpenAI for custom models or unrecognized prefixes
2565 $api_key = isset($options['api_key']) ? sanitize_text_field($options['api_key']) : '';
2566 if (empty($api_key)) {
2567 return sanitize_text_field($user_query);
2568 }
2569 return $this->interpret_query_with_openai($user_query, $system_prompt, $api_key, $selected_model);
2570 }
2571 }
2572
2573 /**
2574 * Interpret query using OpenAI models
2575 */
2576 private function interpret_query_with_openai($user_query, $system_prompt, $api_key, $model = 'gpt-4o') {
2577 $url = 'https://api.openai.com/v1/chat/completions';
2578 $args = [
2579 'headers' => [
2580 'Authorization' => 'Bearer ' . $api_key,
2581 'Content-Type' => 'application/json',
2582 ],
2583 'body' => wp_json_encode([
2584 'model' => $model,
2585 'messages' => [
2586 ['role' => 'system', 'content' => $system_prompt],
2587 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2588 ],
2589 'temperature' => 0.2,
2590 'max_tokens' => 20,
2591 ]),
2592 'method' => 'POST',
2593 'timeout' => 15,
2594 ];
2595
2596 $response = wp_remote_post($url, $args);
2597 if (is_wp_error($response)) {
2598 return sanitize_text_field($user_query);
2599 }
2600
2601 $body = json_decode(wp_remote_retrieve_body($response), true);
2602 return isset($body['choices'][0]['message']['content'])
2603 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
2604 : sanitize_text_field($user_query);
2605 }
2606
2607 /**
2608 * Interpret query using Claude models
2609 */
2610 private function interpret_query_with_claude($user_query, $system_prompt, $api_key, $model) {
2611 $url = 'https://api.anthropic.com/v1/messages';
2612
2613 $args = [
2614 'headers' => [
2615 'Content-Type' => 'application/json',
2616 'x-api-key' => $api_key,
2617 'anthropic-version' => '2023-06-01',
2618 ],
2619 'body' => wp_json_encode([
2620 'model' => $model,
2621 'system' => $system_prompt,
2622 'messages' => [
2623 ['role' => 'user', 'content' => sanitize_text_field($user_query)]
2624 ],
2625 'max_tokens' => 20,
2626 'temperature' => 0.2,
2627 ]),
2628 'method' => 'POST',
2629 'timeout' => 15,
2630 ];
2631
2632 $response = wp_remote_post($url, $args);
2633 if (is_wp_error($response)) {
2634 return sanitize_text_field($user_query);
2635 }
2636
2637 $body = json_decode(wp_remote_retrieve_body($response), true);
2638 if (!empty($body['content'][0]['text'])) {
2639 return sanitize_text_field(trim($body['content'][0]['text']));
2640 }
2641
2642 return sanitize_text_field($user_query);
2643 }
2644
2645 /**
2646 * Interpret query using Gemini models
2647 */
2648 private function interpret_query_with_gemini($user_query, $system_prompt, $api_key, $model) {
2649 // Strip "gemini-" prefix for the API
2650 $model_version = str_replace('gemini-', '', $model);
2651
2652 $url = "https://generativelanguage.googleapis.com/v1/models/$model_version:generateContent?key=" . urlencode($api_key);
2653
2654 $args = [
2655 'headers' => [
2656 'Content-Type' => 'application/json',
2657 ],
2658 'body' => wp_json_encode([
2659 'contents' => [
2660 [
2661 'role' => 'user',
2662 'parts' => [
2663 ['text' => $system_prompt . "\n\nQuery: " . sanitize_text_field($user_query)]
2664 ]
2665 ]
2666 ],
2667 'generationConfig' => [
2668 'temperature' => 0.2,
2669 'maxOutputTokens' => 20,
2670 ],
2671 ]),
2672 'method' => 'POST',
2673 'timeout' => 15,
2674 ];
2675
2676 $response = wp_remote_post($url, $args);
2677 if (is_wp_error($response)) {
2678 return sanitize_text_field($user_query);
2679 }
2680
2681 $body = json_decode(wp_remote_retrieve_body($response), true);
2682 if (!empty($body['candidates'][0]['content']['parts'][0]['text'])) {
2683 return sanitize_text_field(trim($body['candidates'][0]['content']['parts'][0]['text']));
2684 }
2685
2686 return sanitize_text_field($user_query);
2687 }
2688
2689 /**
2690 * Interpret query using X.AI (Grok) models
2691 */
2692 private function interpret_query_with_xai($user_query, $system_prompt, $api_key, $model) {
2693 $url = 'https://api.xai.com/v1/chat/completions';
2694
2695 $args = [
2696 'headers' => [
2697 'Content-Type' => 'application/json',
2698 'Authorization' => 'Bearer ' . $api_key,
2699 ],
2700 'body' => wp_json_encode([
2701 'model' => $model,
2702 'messages' => [
2703 ['role' => 'system', 'content' => $system_prompt],
2704 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2705 ],
2706 'temperature' => 0.2,
2707 'max_tokens' => 20,
2708 ]),
2709 'method' => 'POST',
2710 'timeout' => 15,
2711 ];
2712
2713 $response = wp_remote_post($url, $args);
2714 if (is_wp_error($response)) {
2715 return sanitize_text_field($user_query);
2716 }
2717
2718 $body = json_decode(wp_remote_retrieve_body($response), true);
2719 if (isset($body['choices'][0]['message']['content'])) {
2720 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
2721 }
2722
2723 return sanitize_text_field($user_query);
2724 }
2725
2726 /**
2727 * Interpret query using DeepSeek models
2728 */
2729 private function interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $model) {
2730 $url = 'https://api.deepseek.com/v1/chat/completions';
2731
2732 $args = [
2733 'headers' => [
2734 'Content-Type' => 'application/json',
2735 'Authorization' => 'Bearer ' . $api_key,
2736 ],
2737 'body' => wp_json_encode([
2738 'model' => $model,
2739 'messages' => [
2740 ['role' => 'system', 'content' => $system_prompt],
2741 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2742 ],
2743 'temperature' => 0.2,
2744 'max_tokens' => 20,
2745 ]),
2746 'method' => 'POST',
2747 'timeout' => 15,
2748 ];
2749
2750 $response = wp_remote_post($url, $args);
2751 if (is_wp_error($response)) {
2752 return sanitize_text_field($user_query);
2753 }
2754
2755 $body = json_decode(wp_remote_retrieve_body($response), true);
2756 if (isset($body['choices'][0]['message']['content'])) {
2757 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
2758 }
2759
2760 return sanitize_text_field($user_query);
2761 }
2762
2763 //very good
2764 private function add_email_to_loops($email) {
2765 // Sanitize the email
2766 $email = sanitize_email($email);
2767
2768 // Retrieve and sanitize options
2769 $api_key = isset($this->options['loops_api_key']) ? sanitize_text_field($this->options['loops_api_key']) : '';
2770 $mailing_list_id = isset($this->options['loops_mailing_list']) ? sanitize_text_field($this->options['loops_mailing_list']) : '';
2771
2772 // Check for missing API key or mailing list ID
2773 if (empty($api_key) || empty($mailing_list_id)) {
2774 //error_log(esc_html__('Loops API key or mailing list ID is missing.', 'mxchat'));
2775 return;
2776 }
2777
2778 $data = array(
2779 'email' => $email,
2780 'subscribed' => true,
2781 'source' => __('MxChat AI Chatbot', 'mxchat'),
2782 'mailingLists' => array($mailing_list_id => true),
2783 );
2784
2785 $url = 'https://app.loops.so/api/v1/contacts/create';
2786 $args = array(
2787 'body' => wp_json_encode($data),
2788 'headers' => array(
2789 'Authorization' => 'Bearer ' . $api_key,
2790 'Content-Type' => 'application/json',
2791 ),
2792 'method' => 'POST',
2793 'timeout' => 45,
2794 );
2795
2796 $response = wp_remote_post($url, $args);
2797
2798 // Handle errors in the API request
2799 if (is_wp_error($response)) {
2800 //error_log(esc_html__('Error adding email to Loops: ', 'mxchat') . $response->get_error_message());
2801 return;
2802 }
2803
2804 // Check for non-200 HTTP responses
2805 $response_code = wp_remote_retrieve_response_code($response);
2806 if ($response_code != 200) {
2807 $response_body = wp_remote_retrieve_body($response);
2808 //error_log(esc_html__('Loops API responded with code ', 'mxchat') . $response_code . ': ' . $response_body);
2809 }
2810 }
2811
2812 public function mxchat_handle_pdf_discussion($message, $user_id, $session_id) {
2813 // Get the maximum number of pages allowed from admin settings
2814 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
2815
2816 // Retrieve options for dynamic texts
2817 $trigger_text = $this->options['pdf_intent_trigger_text'] ?? __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
2818 $success_text = $this->options['pdf_intent_success_text'] ?? __("I've processed the PDF. What questions do you have about it?", 'mxchat');
2819 $error_text = $this->options['pdf_intent_error_text'] ?? __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
2820
2821 // Check for explicit request for new PDF
2822 $new_pdf_requested = stripos($message, 'new') !== false ||
2823 stripos($message, 'another') !== false ||
2824 stripos($message, 'different') !== false;
2825
2826 // If user mentions adding/reading a PDF, set waiting flag
2827 if (stripos($message, 'pdf') !== false ||
2828 stripos($message, 'document') !== false ||
2829 stripos($message, 'read') !== false) {
2830 set_transient('mxchat_waiting_for_pdf_url_' . $session_id, true, HOUR_IN_SECONDS);
2831 $this->fallbackResponse['text'] = $trigger_text;
2832 return;
2833 }
2834
2835 // If we're waiting for a URL or user requested new PDF
2836 if ($new_pdf_requested || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
2837 if (preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
2838 // Process URL... (rest of your existing URL processing code)
2839 } else {
2840 $this->fallbackResponse['text'] = $trigger_text;
2841 }
2842 return;
2843 }
2844
2845 // Default to proceeding with conversation if no specific PDF action is needed
2846 $this->fallbackResponse['text'] = '';
2847 }
2848
2849
2850 /**
2851 * Enhanced fetch_and_split_pdf_pages with SSRF protection
2852 */
2853 private function fetch_and_split_pdf_pages($pdf_source, $max_pages) {
2854 // CLEAR DEBUG LOGGING
2855 //error_log("=== MXCHAT PDF PROCESSING START ===");
2856 //error_log("PDF Source: " . $pdf_source);
2857 //error_log("Max Pages: " . $max_pages);
2858 //error_log("Session ID: " . ($this->session_id ?? 'not set'));
2859
2860 // Check if Advanced Claude Toolbar is available and enabled
2861 $claude_available = function_exists('mxchatACT_is_advanced_claude_enabled');
2862 $claude_enabled = $claude_available ? mxchatACT_is_advanced_claude_enabled() : false;
2863
2864 //error_log("Claude Function Available: " . ($claude_available ? 'YES' : 'NO'));
2865 //error_log("Claude Enabled: " . ($claude_enabled ? 'YES' : 'NO'));
2866
2867 if ($claude_available && $claude_enabled) {
2868 //error_log("🚀 ATTEMPTING CLAUDE PROCESSING...");
2869
2870 // Attempt Claude processing first
2871 $claude_result = apply_filters('mxchat_process_pdf_advanced', false, $pdf_source, $max_pages, $this->session_id);
2872
2873 if ($claude_result !== false && is_array($claude_result) && !empty($claude_result)) {
2874 //error_log("✅ CLAUDE PROCESSING SUCCESSFUL!");
2875 //error_log("Claude returned " . count($claude_result) . " processed pages");
2876
2877 // Log first page details for verification
2878 if (isset($claude_result[0])) {
2879 $first_page = $claude_result[0];
2880 //error_log("First page enhanced: " . (isset($first_page['enhanced']) && $first_page['enhanced'] ? 'YES' : 'NO'));
2881 //error_log("Processing method: " . ($first_page['processing_method'] ?? 'not set'));
2882 //error_log("First page text preview: " . substr($first_page['text'] ?? '', 0, 100) . "...");
2883 }
2884
2885 //error_log("=== MXCHAT PDF PROCESSING END (CLAUDE) ===");
2886 return $claude_result;
2887 } else {
2888 //error_log("❌ CLAUDE PROCESSING FAILED or returned invalid result");
2889 //error_log("Claude result type: " . gettype($claude_result));
2890 if (is_array($claude_result)) {
2891 //error_log("Claude result count: " . count($claude_result));
2892 }
2893 }
2894 }
2895
2896 // Fallback to basic processing
2897 //error_log("🔄 FALLING BACK TO BASIC PDF PROCESSING...");
2898
2899 $upload_dir = wp_upload_dir();
2900 $temp_file = null;
2901
2902 try {
2903 // Your existing basic processing code here...
2904 // (I'll include the key parts with debug logging)
2905
2906 if (filter_var($pdf_source, FILTER_VALIDATE_URL)) {
2907 //error_log("Downloading PDF from URL...");
2908
2909 // SECURITY FIX: Validate URL before processing
2910 if (!$this->mxchat_is_safe_pdf_url($pdf_source)) {
2911 //error_log("❌ SECURITY: Blocked unsafe PDF URL");
2912 return false;
2913 }
2914
2915 $temp_file = wp_tempnam($pdf_source);
2916
2917 // SECURITY FIX: Changed from wp_remote_get to wp_safe_remote_get
2918 $response = wp_safe_remote_get($pdf_source, [
2919 'timeout' => 60,
2920 'headers' => ['User-Agent' => 'MxChat PDF Processor']
2921 ]);
2922
2923 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
2924 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code($response);
2925 //error_log("❌ BASIC PROCESSING: Failed to download PDF: " . $error_message);
2926 return false;
2927 }
2928
2929 file_put_contents($temp_file, wp_remote_retrieve_body($response));
2930 //error_log("✅ PDF downloaded successfully");
2931 } else {
2932 $temp_file = $pdf_source;
2933 //error_log("Using local PDF file: " . $temp_file);
2934 }
2935
2936 // Parse PDF
2937 //error_log("Parsing PDF with basic parser...");
2938 $parser = new \Smalot\PdfParser\Parser();
2939 $pdf = $parser->parseFile($temp_file);
2940 $pages = $pdf->getPages();
2941
2942 //error_log("PDF contains " . count($pages) . " pages");
2943
2944 if (count($pages) > $max_pages) {
2945 //error_log("❌ BASIC PROCESSING: Too many pages (" . count($pages) . " > " . $max_pages . ")");
2946 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file) {
2947 unlink($temp_file);
2948 }
2949 return 'too_many_pages';
2950 }
2951
2952 $embeddings = [];
2953 $processed_pages = 0;
2954
2955 foreach ($pages as $page_number => $page) {
2956 $text = $page->getText();
2957
2958 if (empty(trim($text))) {
2959 //error_log("Skipping empty page: " . ($page_number + 1));
2960 continue;
2961 }
2962
2963 $text = $this->mxchat_clean_text($text);
2964
2965 $embedding = $this->mxchat_generate_embedding(
2966 __("Page ", 'mxchat') . ($page_number + 1) . ": " . $text,
2967 $this->options['api_key']
2968 );
2969
2970 if ($embedding) {
2971 $embeddings[] = [
2972 'page_number' => $page_number + 1,
2973 'embedding' => $embedding,
2974 'text' => $text,
2975 'enhanced' => false, // CLEARLY MARK AS BASIC
2976 'processing_method' => 'basic_pdf_parser'
2977 ];
2978 $processed_pages++;
2979 }
2980 }
2981
2982 //error_log("✅ BASIC PROCESSING COMPLETE: " . $processed_pages . " pages processed");
2983
2984 // Cleanup
2985 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
2986 unlink($temp_file);
2987 }
2988
2989 //error_log("=== MXCHAT PDF PROCESSING END (BASIC) ===");
2990 return $embeddings;
2991
2992 } catch (\Exception $e) {
2993 //error_log("❌ BASIC PROCESSING ERROR: " . $e->getMessage());
2994 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
2995 unlink($temp_file);
2996 }
2997 //error_log("=== MXCHAT PDF PROCESSING END (ERROR) ===");
2998 return false;
2999 }
3000 }
3001
3002
3003 /**
3004 * Validate PDF URL for security
3005 * Prevents SSRF attacks by blocking dangerous URLs
3006 */
3007
3008 private function mxchat_is_safe_pdf_url($url) {
3009 // Use WordPress core function for comprehensive validation
3010 // This blocks localhost, private IPs, and reserved IP ranges
3011 $validated_url = wp_http_validate_url($url);
3012
3013 if ($validated_url === false) {
3014 return false;
3015 }
3016
3017 // Additional check: only allow HTTP/HTTPS schemes
3018 $parsed = parse_url($url);
3019 if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], ['http', 'https'], true)) {
3020 return false;
3021 }
3022
3023 return true;
3024 }
3025
3026
3027 private function mxchat_clean_text($text) {
3028 // Remove excessive whitespace
3029 $text = preg_replace('/\s+/', ' ', $text);
3030
3031 // Remove control characters except newlines and tabs
3032 $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
3033
3034 // Normalize line endings
3035 $text = str_replace(["\r\n", "\r"], "\n", $text);
3036
3037 // Trim whitespace
3038 $text = trim($text);
3039
3040 return $text;
3041 }
3042
3043 private function find_relevant_pdf_pages($query_embedding, $embeddings) {
3044 //error_log(esc_html__("find_relevant_pdf_pages called.", 'mxchat'));
3045
3046 $most_relevant = null;
3047 $highest_similarity = -INF;
3048
3049 foreach ($embeddings as $page_data) {
3050 $similarity = $this->mxchat_calculate_cosine_similarity($query_embedding, $page_data['embedding']);
3051
3052 if ($similarity > $highest_similarity) {
3053 $highest_similarity = $similarity;
3054 $most_relevant = $page_data['page_number'];
3055 }
3056 }
3057
3058 if (!is_null($most_relevant)) {
3059 $page_numbers = range(max(1, $most_relevant - 1), min(count($embeddings), $most_relevant + 1));
3060 return array_filter($embeddings, function ($page) use ($page_numbers) {
3061 return in_array($page['page_number'], $page_numbers);
3062 });
3063 }
3064
3065 return [];
3066 }
3067
3068
3069 public function handle_pdf_upload() {
3070 check_ajax_referer('mxchat_chat_nonce', 'nonce');
3071
3072 if (!isset($_FILES['pdf_file']) || !isset($_POST['session_id'])) {
3073 wp_send_json_error(esc_html__('Missing required parameters.', 'mxchat'));
3074 return;
3075 }
3076
3077 // SECURITY FIX: Check if PDF uploads are enabled in settings
3078 $options = get_option('mxchat_options', array());
3079 $show_pdf_button = isset($options['show_pdf_upload_button']) ? $options['show_pdf_upload_button'] : 'on';
3080
3081 if ($show_pdf_button !== 'on') {
3082 wp_send_json_error(esc_html__('PDF uploads are currently disabled.', 'mxchat'));
3083 return;
3084 }
3085
3086 $file = $_FILES['pdf_file'];
3087 $session_id = sanitize_text_field($_POST['session_id']);
3088 $original_filename = sanitize_text_field($file['name']);
3089
3090 // SECURITY FIX: Verify session ownership before allowing upload
3091 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
3092 $session_owner = get_option("mxchat_session_owner_{$session_id}");
3093
3094 if ($session_owner && $session_owner !== $current_user_identifier) {
3095 wp_send_json_error(esc_html__('Unauthorized access.', 'mxchat'));
3096 return;
3097 }
3098
3099 $file_type = wp_check_filetype($file['name'], ['pdf' => 'application/pdf']);
3100 if ($file_type['type'] !== 'application/pdf') {
3101 wp_send_json_error(esc_html__('Invalid file type. Only PDF files are allowed.', 'mxchat'));
3102 return;
3103 }
3104
3105 $upload_dir = wp_upload_dir();
3106
3107 // SECURITY FIX: Generate random filename without exposing session_id
3108 $random_string = wp_generate_password(20, false, false); // 20 char alphanumeric string
3109 $pdf_filename = 'mxchat_' . $random_string . '_' . time() . '.pdf';
3110 $pdf_path = $upload_dir['path'] . '/' . $pdf_filename;
3111
3112 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
3113 wp_send_json_error(esc_html__('Failed to upload file.', 'mxchat'));
3114 return;
3115 }
3116
3117 $this->clear_pdf_transients($session_id);
3118
3119 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3120 $embeddings = $this->fetch_and_split_pdf_pages($pdf_path, $max_pages);
3121
3122 if ($embeddings === 'too_many_pages') {
3123 unlink($pdf_path);
3124 $error_message = sprintf(
3125 $this->options['pdf_intent_error_text'] ??
3126 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
3127 $max_pages
3128 );
3129 wp_send_json_error($error_message);
3130 return;
3131 }
3132
3133 if ($embeddings === false || empty($embeddings)) {
3134 unlink($pdf_path);
3135 $error_message = $this->options['pdf_intent_error_text'] ??
3136 esc_html__('The uploaded PDF appears to be empty or contains unsupported content.', 'mxchat');
3137 wp_send_json_error($error_message);
3138 return;
3139 }
3140
3141 if (!empty($embeddings)) {
3142 // Store the mapping between session and the random filename
3143 set_transient('mxchat_pdf_url_' . $session_id, $pdf_path, HOUR_IN_SECONDS);
3144 set_transient('mxchat_pdf_filename_' . $session_id, $original_filename, HOUR_IN_SECONDS);
3145 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
3146 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
3147
3148 $success_message = $this->options['pdf_intent_success_text'] ??
3149 esc_html__("I've processed the PDF. What questions do you have about it?", 'mxchat');
3150
3151 wp_send_json_success([
3152 'message' => $success_message,
3153 'filename' => $original_filename
3154 ]);
3155 return;
3156 }
3157
3158 unlink($pdf_path);
3159 $error_message = $this->options['pdf_intent_error_text'] ??
3160 esc_html__('Sorry, I couldn\'t process the PDF. Please ensure it\'s a valid file.', 'mxchat');
3161 wp_send_json_error($error_message);
3162 return;
3163 }
3164 public function handle_pdf_remove() {
3165 check_ajax_referer('mxchat_chat_nonce', 'nonce');
3166
3167 if (empty($_POST['session_id'])) {
3168 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
3169 wp_die();
3170 }
3171
3172 $session_id = sanitize_text_field($_POST['session_id']);
3173 $pdf_path = get_transient('mxchat_pdf_url_' . $session_id);
3174
3175 if ($pdf_path && file_exists($pdf_path)) {
3176 unlink($pdf_path);
3177 }
3178
3179 $this->clear_pdf_transients($session_id);
3180
3181 wp_send_json_success([
3182 'message' => esc_html__('PDF removed successfully.', 'mxchat')
3183 ]);
3184 wp_die();
3185 }
3186
3187
3188 function mxchat_fetch_new_messages() {
3189 $session_id = sanitize_text_field($_POST['session_id']);
3190 $last_seen_id = sanitize_text_field($_POST['last_seen_id']);
3191 $persistence_enabled = $_POST['persistence_enabled'] === 'true';
3192 $initial_timestamp = isset($_POST['initial_timestamp']) ? intval($_POST['initial_timestamp']) : 0;
3193
3194 if (empty($session_id)) {
3195 //error_log(esc_html__('Fetch new messages error: Session ID missing.', 'mxchat'));
3196 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
3197 wp_die();
3198 }
3199
3200 $history = get_option("mxchat_history_{$session_id}", []);
3201
3202 error_log("MxChat WhatsApp DEBUG: Fetch new messages for session {$session_id}");
3203 error_log("MxChat WhatsApp DEBUG: last_seen_id = " . var_export($last_seen_id, true));
3204 error_log("MxChat WhatsApp DEBUG: History count = " . count($history));
3205 error_log("MxChat WhatsApp DEBUG: Full history = " . print_r($history, true));
3206
3207 $new_messages = array_filter($history, function ($message) use ($last_seen_id, $persistence_enabled, $initial_timestamp) {
3208 error_log("MxChat WhatsApp DEBUG: Checking message - ID: " . ($message['id'] ?? 'NO_ID') . ", Role: " . ($message['role'] ?? 'NO_ROLE'));
3209
3210 // If persistence is enabled, show all new messages
3211 if ($persistence_enabled) {
3212 $has_id = !empty($message['id']);
3213 $is_agent = $message['role'] === 'agent';
3214
3215 // If last_seen_id is empty, 'NaN', or invalid, show all agent messages
3216 if (empty($last_seen_id) || $last_seen_id === 'NaN' || $last_seen_id === 'undefined') {
3217 $is_newer = true;
3218 } else {
3219 $is_newer = strcmp($message['id'] ?? '', $last_seen_id) > 0;
3220 }
3221
3222 error_log("MxChat WhatsApp DEBUG: has_id={$has_id}, is_newer={$is_newer}, is_agent={$is_agent}");
3223
3224 return $has_id && $is_newer && $is_agent;
3225 }
3226
3227 // If persistence is disabled, only show messages after initial timestamp
3228 return !empty($message['id']) &&
3229 $message['role'] === 'agent' &&
3230 $message['timestamp'] > $initial_timestamp;
3231 });
3232
3233 error_log("MxChat WhatsApp DEBUG: Filtered messages count = " . count($new_messages));
3234
3235 wp_send_json_success([
3236 'new_messages' => array_values($new_messages)
3237 ]);
3238 wp_die();
3239 }
3240 public function mxchat_live_agent_handover($message, $user_id, $session_id) {
3241 // First check if live agents are available
3242 $live_agent_available = $this->options['live_agent_status'] ?? 'off';
3243 if ($live_agent_available !== 'on') {
3244 $away_message = $this->options['live_agent_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
3245 $this->fallbackResponse = [
3246 'text' => $away_message,
3247 'html' => '',
3248 'images' => [],
3249 'chat_mode' => 'ai'
3250 ];
3251 wp_send_json([
3252 'text' => $away_message,
3253 'html' => '',
3254 'chat_mode' => 'ai',
3255 'session_id' => $session_id
3256 ]);
3257 wp_die();
3258 }
3259
3260 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
3261
3262 if (empty($slack_bot_token)) {
3263 return false;
3264 }
3265
3266 // Check if channel already exists for this session
3267 $channel_id = get_option("mxchat_channel_{$session_id}", '');
3268
3269 if (empty($channel_id)) {
3270 // Create new channel with session ID as name
3271 $channel_name = $this->generate_channel_name($session_id);
3272
3273 //error_log("Attempting to create channel: $channel_name");
3274
3275 $response = wp_remote_post('https://slack.com/api/conversations.create', [
3276 'headers' => [
3277 'Content-Type' => 'application/json',
3278 'Authorization' => 'Bearer ' . $slack_bot_token
3279 ],
3280 'body' => json_encode([
3281 'name' => $channel_name,
3282 'is_private' => false // Public channel - anyone in workspace can join
3283 ])
3284 ]);
3285
3286 if (!is_wp_error($response)) {
3287 $response_body = wp_remote_retrieve_body($response);
3288 $response_data = json_decode($response_body, true);
3289
3290 //error_log("Channel creation response: " . $response_body);
3291
3292 if (isset($response_data['ok']) && $response_data['ok']) {
3293 $channel_id = $response_data['channel']['id'];
3294 $actual_channel_name = $response_data['channel']['name'] ?? 'unknown';
3295 //error_log("Channel created successfully: ID=$channel_id, Name=$actual_channel_name");
3296 update_option("mxchat_channel_{$session_id}", $channel_id);
3297
3298 // Auto-invite agents to the channel
3299 $agent_user_ids = $this->options['live_agent_user_ids'] ?? '';
3300
3301 if (!empty($agent_user_ids)) {
3302 // Parse user IDs (one per line)
3303 $user_ids = array_filter(array_map('trim', explode("\n", $agent_user_ids)));
3304
3305 foreach ($user_ids as $user_id_to_invite) {
3306 //error_log("Inviting user to channel: $user_id_to_invite");
3307
3308 $invite_response = wp_remote_post('https://slack.com/api/conversations.invite', [
3309 'headers' => [
3310 'Content-Type' => 'application/json',
3311 'Authorization' => 'Bearer ' . $slack_bot_token
3312 ],
3313 'body' => json_encode([
3314 'channel' => $channel_id,
3315 'users' => $user_id_to_invite
3316 ])
3317 ]);
3318
3319 if (!is_wp_error($invite_response)) {
3320 $invite_body = wp_remote_retrieve_body($invite_response);
3321 $invite_data = json_decode($invite_body, true);
3322 //error_log("Invite response for $user_id_to_invite: " . $invite_body);
3323
3324 if (isset($invite_data['ok']) && $invite_data['ok']) {
3325 //error_log("Successfully invited user $user_id_to_invite to channel");
3326 } else {
3327 //error_log("Failed to invite user $user_id_to_invite: " . ($invite_data['error'] ?? 'Unknown error'));
3328 }
3329 } else {
3330 //error_log("WP Error inviting user $user_id_to_invite: " . $invite_response->get_error_message());
3331 }
3332 }
3333 } else {
3334 //error_log("No agent user IDs configured for auto-invite");
3335 }
3336 } else {
3337 //error_log("Channel creation failed: " . ($response_data['error'] ?? 'Unknown error'));
3338 }
3339 } else {
3340 //error_log("WP Error creating channel: " . $response->get_error_message());
3341 }
3342
3343 if (empty($channel_id)) {
3344 return false; // Failed to create channel
3345 }
3346 }
3347
3348 // Get recent chat history
3349 $history = get_option("mxchat_history_{$session_id}", []);
3350 $recent_history = array_slice($history, -5);
3351
3352 // Format conversation context
3353 $conversation_context = "";
3354 if (!empty($recent_history)) {
3355 $conversation_context = "*Recent Conversation:*\n";
3356 foreach ($recent_history as $hist_message) {
3357 $role_display = $hist_message['role'] === 'user' ? 'User' : 'AI';
3358 $conversation_context .= ">{$role_display}: {$hist_message['content']}\n";
3359 }
3360 $conversation_context .= "\n";
3361 }
3362
3363 update_option("mxchat_mode_{$session_id}", 'agent');
3364
3365 // Send message to channel
3366 $channel_message = "🔔 *New Live Agent Request*\n\n";
3367 $channel_message .= "*Session ID:* `{$session_id}`\n";
3368 $channel_message .= "*User ID:* `{$user_id}`\n\n";
3369
3370 if (!empty($conversation_context)) {
3371 $channel_message .= $conversation_context;
3372 }
3373
3374 $channel_message .= "*Current Message:*\n{$message}\n\n";
3375 $channel_message .= "_Reply directly in this channel - all messages will go to the user_";
3376
3377 wp_remote_post('https://slack.com/api/chat.postMessage', [
3378 'headers' => [
3379 'Content-Type' => 'application/json',
3380 'Authorization' => 'Bearer ' . $slack_bot_token
3381 ],
3382 'body' => json_encode([
3383 'channel' => $channel_id,
3384 'text' => $channel_message,
3385 'mrkdwn' => true
3386 ])
3387 ]);
3388
3389 $success_message = $this->options['live_agent_notification_message'] ?? 'Live agent has been notified.';
3390 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
3391
3392 $this->fallbackResponse = [
3393 'text' => $success_message,
3394 'html' => '',
3395 'images' => [],
3396 'chat_mode' => 'agent'
3397 ];
3398
3399 wp_send_json([
3400 'success' => true,
3401 'text' => $success_message,
3402 'html' => '',
3403 'chat_mode' => 'agent',
3404 'session_id' => $session_id,
3405 'fallbackResponse' => $this->fallbackResponse
3406 ]);
3407 wp_die();
3408 }
3409
3410 private function generate_channel_name($session_id) {
3411 $email = null;
3412 $name = null;
3413
3414 // 1. First priority: Check if user is logged in and get their info
3415 if (is_user_logged_in()) {
3416 $current_user = wp_get_current_user();
3417 if (!empty($current_user->user_email)) {
3418 $email = $current_user->user_email;
3419 //error_log("[DEBUG] Using logged-in user email for channel: {$email}");
3420 }
3421 if (!empty($current_user->display_name)) {
3422 $name = $current_user->display_name;
3423 //error_log("[DEBUG] Using logged-in user name for channel: {$name}");
3424 }
3425 }
3426
3427 // 2. Second priority: Check for saved email/name from "require email to chat" option
3428 if (empty($email)) {
3429 $email_option_key = "mxchat_email_{$session_id}";
3430 $saved_email = get_option($email_option_key);
3431 if (!empty($saved_email)) {
3432 $email = $saved_email;
3433 //error_log("[DEBUG] Using saved email from session for channel: {$email}");
3434 }
3435 }
3436
3437 if (empty($name)) {
3438 $name_option_key = "mxchat_name_{$session_id}";
3439 $saved_name = get_option($name_option_key);
3440 if (!empty($saved_name)) {
3441 $name = $saved_name;
3442 //error_log("[DEBUG] Using saved name from session for channel: {$name}");
3443 }
3444 }
3445
3446 // 3. Third priority: Check existing chat transcript for email/name
3447 if (empty($email) || empty($name)) {
3448 global $wpdb;
3449 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3450 $existing_data = $wpdb->get_row($wpdb->prepare(
3451 "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",
3452 $session_id
3453 ));
3454
3455 if ($existing_data) {
3456 if (empty($email) && !empty($existing_data->user_email)) {
3457 $email = $existing_data->user_email;
3458 //error_log("[DEBUG] Using email from chat transcript for channel: {$email}");
3459 }
3460 if (empty($name) && !empty($existing_data->user_name)) {
3461 $name = $existing_data->user_name;
3462 //error_log("[DEBUG] Using name from chat transcript for channel: {$name}");
3463 }
3464 }
3465 }
3466
3467 // 4. Generate channel name based on priority: Name > Email > Session ID
3468 $channel_name = '';
3469
3470 if (!empty($name)) {
3471 // Convert name to valid Slack channel name
3472 $base_name = strtolower(trim($name));
3473 // Replace spaces and invalid characters
3474 $base_name = preg_replace('/[^a-z0-9\s]/', '', $base_name);
3475 $base_name = preg_replace('/\s+/', '-', $base_name);
3476 $base_name = trim($base_name, '-');
3477
3478 // Get last 4 characters of session ID for uniqueness
3479 $session_suffix = substr($session_id, -4);
3480 $channel_name = 'chat-' . $base_name . '-' . strtolower($session_suffix);
3481
3482 // Slack channel names have a 21 character limit
3483 if (strlen($channel_name) > 21) {
3484 // Calculate available space for name (21 - 'chat-' - '-' - session_suffix)
3485 $available_space = 21 - 5 - 1 - strlen($session_suffix); // 'chat-' = 5, '-' = 1
3486 $truncated_name = substr($base_name, 0, $available_space);
3487 $truncated_name = rtrim($truncated_name, '-'); // Remove trailing hyphen
3488 $channel_name = 'chat-' . $truncated_name . '-' . strtolower($session_suffix);
3489 }
3490
3491 //error_log("[DEBUG] Using name for channel: {$channel_name} (from name: {$name})");
3492
3493 } elseif (!empty($email)) {
3494 // Convert email to valid Slack channel name (your existing logic)
3495 $channel_name = 'chat-' . strtolower(str_replace(['@', '.', '+', '_'], ['-at-', '-', '-plus-', '-'], $email));
3496 // Remove any remaining invalid characters
3497 $channel_name = preg_replace('/[^a-z0-9\-]/', '', $channel_name);
3498 // Ensure it doesn't end with a hyphen
3499 $channel_name = rtrim($channel_name, '-');
3500 // Slack channel names have a 21 character limit, so truncate if needed
3501 if (strlen($channel_name) > 21) {
3502 $channel_name = substr($channel_name, 0, 21);
3503 $channel_name = rtrim($channel_name, '-'); // Remove trailing hyphen if truncation created one
3504 }
3505
3506 //error_log("[DEBUG] Using email for channel: {$channel_name} (from email: {$email})");
3507
3508 } else {
3509 // Fallback to session ID if no name or email found
3510 $channel_name = 'chat-' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $session_id));
3511 //error_log("[DEBUG] No name or email found, using session ID for channel: {$channel_name}");
3512 }
3513
3514 // Final validation - ensure channel name meets Slack requirements
3515 if (strlen($channel_name) > 21) {
3516 $channel_name = substr($channel_name, 0, 21);
3517 $channel_name = rtrim($channel_name, '-');
3518 }
3519
3520 //error_log("[DEBUG] Generated channel name: {$channel_name}");
3521 return $channel_name;
3522 }
3523
3524 /**
3525 * Telegram Live Agent Handover
3526 * Creates a forum topic in the Telegram group and notifies agents
3527 */
3528 public function mxchat_telegram_live_agent_handover($message, $user_id, $session_id) {
3529 // Check if Telegram agents are available
3530 $telegram_available = $this->options['telegram_status'] ?? 'off';
3531 if ($telegram_available !== 'on') {
3532 $away_message = $this->options['telegram_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
3533 $this->fallbackResponse = [
3534 'text' => $away_message,
3535 'html' => '',
3536 'images' => [],
3537 'chat_mode' => 'ai'
3538 ];
3539 wp_send_json([
3540 'text' => $away_message,
3541 'html' => '',
3542 'chat_mode' => 'ai',
3543 'session_id' => $session_id
3544 ]);
3545 wp_die();
3546 }
3547
3548 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3549 $telegram_group_id = $this->options['telegram_group_id'] ?? '';
3550
3551 if (empty($telegram_bot_token) || empty($telegram_group_id)) {
3552 return false;
3553 }
3554
3555 // Check if topic already exists for this session
3556 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
3557
3558 if (empty($topic_id)) {
3559 // Generate topic name
3560 $topic_name = $this->generate_telegram_topic_name($session_id);
3561
3562 // Random icon color (Telegram forum topic colors)
3563 $icon_colors = [0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F];
3564 $icon_color = $icon_colors[array_rand($icon_colors)];
3565
3566 // Create forum topic
3567 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/createForumTopic", [
3568 'headers' => ['Content-Type' => 'application/json'],
3569 'body' => json_encode([
3570 'chat_id' => $telegram_group_id,
3571 'name' => $topic_name,
3572 'icon_color' => $icon_color
3573 ])
3574 ]);
3575
3576 if (!is_wp_error($response)) {
3577 $response_body = wp_remote_retrieve_body($response);
3578 $response_data = json_decode($response_body, true);
3579
3580 if (isset($response_data['ok']) && $response_data['ok']) {
3581 $topic_id = $response_data['result']['message_thread_id'];
3582 update_option("mxchat_telegram_topic_{$session_id}", $topic_id);
3583 update_option("mxchat_telegram_group_{$session_id}", $telegram_group_id);
3584 }
3585 }
3586
3587 if (empty($topic_id)) {
3588 return false; // Failed to create topic
3589 }
3590 }
3591
3592 // Get recent chat history
3593 $history = get_option("mxchat_history_{$session_id}", []);
3594 $recent_history = array_slice($history, -5);
3595
3596 // Format conversation context for Telegram (HTML format)
3597 $conversation_context = "";
3598 if (!empty($recent_history)) {
3599 $conversation_context = "<b>Recent Conversation:</b>\n";
3600 foreach ($recent_history as $hist_message) {
3601 $role_display = $hist_message['role'] === 'user' ? '👤 User' : '🤖 AI';
3602 $escaped_content = htmlspecialchars($hist_message['content'], ENT_QUOTES, 'UTF-8');
3603 $conversation_context .= "{$role_display}: {$escaped_content}\n";
3604 }
3605 $conversation_context .= "\n";
3606 }
3607
3608 // Get user info
3609 $user_email = get_option("mxchat_email_{$session_id}", 'Not provided');
3610 $user_name = get_option("mxchat_name_{$session_id}", 'Anonymous');
3611
3612 // Update session mode
3613 update_option("mxchat_mode_{$session_id}", 'agent');
3614
3615 // Send initial message to topic
3616 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
3617 $topic_message = "🔔 <b>New Live Agent Request</b>\n\n";
3618 $topic_message .= "<b>Session ID:</b> <code>{$session_id}</code>\n";
3619 $topic_message .= "<b>User:</b> {$user_name}\n";
3620 $topic_message .= "<b>Email:</b> {$user_email}\n\n";
3621
3622 if (!empty($conversation_context)) {
3623 $topic_message .= $conversation_context;
3624 }
3625
3626 $topic_message .= "<b>Current Message:</b>\n{$escaped_message}\n\n";
3627 $topic_message .= "<i>Reply in this topic - messages will be sent to the user</i>\n";
3628 $topic_message .= "<i>Type #close, #end, #disconnect, or #done to end the session</i>";
3629
3630 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3631 'headers' => ['Content-Type' => 'application/json'],
3632 'body' => json_encode([
3633 'chat_id' => $telegram_group_id,
3634 'message_thread_id' => $topic_id,
3635 'text' => $topic_message,
3636 'parse_mode' => 'HTML'
3637 ])
3638 ]);
3639
3640 $success_message = $this->options['telegram_notification_message'] ?? "I've notified a support agent. Please allow a moment for them to respond.";
3641 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
3642
3643 $this->fallbackResponse = [
3644 'text' => $success_message,
3645 'html' => '',
3646 'images' => [],
3647 'chat_mode' => 'agent'
3648 ];
3649
3650 wp_send_json([
3651 'success' => true,
3652 'text' => $success_message,
3653 'html' => '',
3654 'chat_mode' => 'agent',
3655 'session_id' => $session_id,
3656 'fallbackResponse' => $this->fallbackResponse
3657 ]);
3658 wp_die();
3659 }
3660
3661 /**
3662 * Generate topic name for Telegram forum
3663 */
3664 private function generate_telegram_topic_name($session_id) {
3665 $name = null;
3666 $email = null;
3667
3668 // Check logged in user
3669 if (is_user_logged_in()) {
3670 $current_user = wp_get_current_user();
3671 if (!empty($current_user->display_name)) {
3672 $name = $current_user->display_name;
3673 }
3674 if (!empty($current_user->user_email)) {
3675 $email = $current_user->user_email;
3676 }
3677 }
3678
3679 // Check session data
3680 if (empty($name)) {
3681 $name = get_option("mxchat_name_{$session_id}");
3682 }
3683 if (empty($email)) {
3684 $email = get_option("mxchat_email_{$session_id}");
3685 }
3686
3687 // Generate topic name
3688 $session_suffix = substr($session_id, -6);
3689
3690 if (!empty($name)) {
3691 // Clean name for topic (max 128 chars in Telegram)
3692 $clean_name = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $name);
3693 $clean_name = trim($clean_name);
3694 if (strlen($clean_name) > 50) {
3695 $clean_name = substr($clean_name, 0, 50);
3696 }
3697 return "Chat - {$clean_name} ({$session_suffix})";
3698 } elseif (!empty($email)) {
3699 // Use email prefix
3700 $email_prefix = explode('@', $email)[0];
3701 if (strlen($email_prefix) > 30) {
3702 $email_prefix = substr($email_prefix, 0, 30);
3703 }
3704 return "Chat - {$email_prefix} ({$session_suffix})";
3705 }
3706
3707 return "Chat - {$session_suffix}";
3708 }
3709
3710 /**
3711 * Send user message to Telegram agent
3712 */
3713 public function mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id) {
3714 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3715 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
3716 $group_id = get_option("mxchat_telegram_group_{$session_id}", '');
3717
3718 if (empty($telegram_bot_token) || empty($topic_id) || empty($group_id)) {
3719 return false;
3720 }
3721
3722 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
3723 $user_message = "👤 <b>User:</b> {$escaped_message}";
3724
3725 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3726 'headers' => ['Content-Type' => 'application/json'],
3727 'body' => json_encode([
3728 'chat_id' => $group_id,
3729 'message_thread_id' => $topic_id,
3730 'text' => $user_message,
3731 'parse_mode' => 'HTML'
3732 ])
3733 ]);
3734
3735 return !is_wp_error($response);
3736 }
3737
3738 /**
3739 * Handle incoming Telegram webhook
3740 */
3741 public function handle_telegram_webhook(WP_REST_Request $request) {
3742 $body = $request->get_body();
3743 $data = json_decode($body, true);
3744
3745 error_log('[MxChat Telegram DEBUG] Webhook received: ' . $body);
3746
3747 // Handle message events from forum topics
3748 if (isset($data['message'])) {
3749 $message_data = $data['message'];
3750
3751 // Skip if not from a forum topic
3752 if (!isset($message_data['message_thread_id'])) {
3753 error_log('[MxChat Telegram DEBUG] Skipped: No message_thread_id (not a forum topic message)');
3754 return new WP_REST_Response(['ok' => true]);
3755 }
3756
3757 // Skip bot messages
3758 if (isset($message_data['from']['is_bot']) && $message_data['from']['is_bot']) {
3759 error_log('[MxChat Telegram DEBUG] Skipped: Message from bot');
3760 return new WP_REST_Response(['ok' => true]);
3761 }
3762
3763 $chat_id = $message_data['chat']['id'] ?? '';
3764 $topic_id = $message_data['message_thread_id'];
3765 $message_text = $message_data['text'] ?? '';
3766 $message_id = $message_data['message_id'] ?? '';
3767 $from = $message_data['from'] ?? [];
3768 $agent_name = trim(($from['first_name'] ?? '') . ' ' . ($from['last_name'] ?? ''));
3769 if (empty($agent_name)) {
3770 $agent_name = $from['username'] ?? 'Agent';
3771 }
3772
3773 error_log("[MxChat Telegram DEBUG] Parsed: chat_id={$chat_id}, topic_id={$topic_id}, agent={$agent_name}, text={$message_text}");
3774
3775 // Skip empty messages
3776 if (empty($message_text)) {
3777 error_log('[MxChat Telegram DEBUG] Skipped: Empty message text');
3778 return new WP_REST_Response(['ok' => true]);
3779 }
3780
3781 // Find session ID by topic ID - cast to string for comparison
3782 global $wpdb;
3783 $topic_id_str = strval($topic_id);
3784 $session_option = $wpdb->get_var(
3785 $wpdb->prepare(
3786 "SELECT option_name FROM {$wpdb->options}
3787 WHERE option_name LIKE %s
3788 AND option_value = %s",
3789 'mxchat_telegram_topic_%',
3790 $topic_id_str
3791 )
3792 );
3793
3794 error_log("[MxChat Telegram DEBUG] Looking for topic_id={$topic_id_str} in options, found: " . ($session_option ?: 'NULL'));
3795
3796 if ($session_option) {
3797 $session_id = str_replace('mxchat_telegram_topic_', '', $session_option);
3798 error_log("[MxChat Telegram DEBUG] Session ID: {$session_id}");
3799
3800 // Verify the group ID matches
3801 $stored_group_id = get_option("mxchat_telegram_group_{$session_id}", '');
3802 error_log("[MxChat Telegram DEBUG] Stored group_id={$stored_group_id}, received chat_id={$chat_id}");
3803
3804 if (strval($stored_group_id) != strval($chat_id)) {
3805 error_log('[MxChat Telegram DEBUG] Skipped: Group ID mismatch');
3806 return new WP_REST_Response(['ok' => true]);
3807 }
3808
3809 // Check for closure commands
3810 $lower_text = strtolower(trim($message_text));
3811 if (in_array($lower_text, ['#close', '#end', '#disconnect', '#done'])) {
3812 error_log("[MxChat Telegram DEBUG] Closure command received: {$lower_text}");
3813 // End the live agent session
3814 update_option("mxchat_mode_{$session_id}", 'ai');
3815
3816 // Save disconnect message
3817 $disconnect_message = "Live agent session ended. You're now chatting with the AI assistant.";
3818 $this->mxchat_save_chat_message($session_id, 'bot', $disconnect_message);
3819
3820 // Notify in Telegram
3821 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3822 if (!empty($telegram_bot_token)) {
3823 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3824 'headers' => ['Content-Type' => 'application/json'],
3825 'body' => json_encode([
3826 'chat_id' => $chat_id,
3827 'message_thread_id' => $topic_id,
3828 'text' => "✅ Session closed. User returned to AI chatbot.",
3829 'parse_mode' => 'HTML'
3830 ])
3831 ]);
3832
3833 // Optionally close the topic
3834 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/closeForumTopic", [
3835 'headers' => ['Content-Type' => 'application/json'],
3836 'body' => json_encode([
3837 'chat_id' => $chat_id,
3838 'message_thread_id' => $topic_id
3839 ])
3840 ]);
3841 }
3842
3843 return new WP_REST_Response(['ok' => true]);
3844 }
3845
3846 // Deduplicate messages
3847 $message_key = md5($session_id . $message_id . $message_text);
3848 $processed_messages = get_transient('mxchat_telegram_messages_' . $session_id) ?: [];
3849
3850 if (in_array($message_key, $processed_messages)) {
3851 error_log('[MxChat Telegram DEBUG] Skipped: Duplicate message');
3852 return new WP_REST_Response(['ok' => true]);
3853 }
3854
3855 $processed_messages[] = $message_key;
3856 if (count($processed_messages) > 50) {
3857 $processed_messages = array_slice($processed_messages, -50);
3858 }
3859 set_transient('mxchat_telegram_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
3860
3861 // Save the agent message - format with agent name prefix for proper parsing
3862 $formatted_message = "Agent: {$agent_name} - {$message_text}";
3863 error_log("[MxChat Telegram DEBUG] Saving agent message: {$formatted_message}");
3864
3865 $this->mxchat_save_chat_message($session_id, 'agent', $formatted_message);
3866
3867 // Verify the message was saved to history
3868 $history = get_option("mxchat_history_{$session_id}", []);
3869 $last_message = end($history);
3870 error_log("[MxChat Telegram DEBUG] History after save - count: " . count($history) . ", last message role: " . ($last_message['role'] ?? 'none'));
3871
3872 // Send confirmation back to Telegram
3873 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3874 if (!empty($telegram_bot_token)) {
3875 $confirm_key = 'mxchat_telegram_confirm_' . $message_key;
3876 if (!get_transient($confirm_key)) {
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' => $chat_id,
3881 'message_thread_id' => $topic_id,
3882 'text' => "✅ <i>Message sent to user</i>",
3883 'parse_mode' => 'HTML',
3884 'reply_to_message_id' => $message_id
3885 ])
3886 ]);
3887 set_transient($confirm_key, true, 300);
3888 }
3889 }
3890 } else {
3891 error_log("[MxChat Telegram DEBUG] No session found for topic_id={$topic_id}");
3892 }
3893 } else {
3894 error_log('[MxChat Telegram DEBUG] No message in webhook data');
3895 }
3896
3897 return new WP_REST_Response(['ok' => true]);
3898 }
3899
3900 public function mxchat_send_user_message_to_agent($message, $user_id, $session_id) {
3901 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
3902 $channel_id = get_option("mxchat_channel_{$session_id}", '');
3903
3904 if (empty($slack_bot_token) || empty($channel_id)) {
3905 return false;
3906 }
3907
3908 $user_message = "💬 *User:* {$message}";
3909
3910 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
3911 'headers' => [
3912 'Content-Type' => 'application/json',
3913 'Authorization' => 'Bearer ' . $slack_bot_token
3914 ],
3915 'body' => json_encode([
3916 'channel' => $channel_id,
3917 'text' => $user_message,
3918 'mrkdwn' => true
3919 ])
3920 ]);
3921
3922 return !is_wp_error($response);
3923 }
3924 public function handle_slack_interaction(WP_REST_Request $request) {
3925 //error_log('Received Slack interaction');
3926
3927 $payload = json_decode($request->get_param('payload'), true);
3928 //error_log('Payload: ' . print_r($payload, true));
3929
3930 // Handle button click
3931 if ($payload['type'] === 'block_actions' && $payload['actions'][0]['action_id'] === 'reply_to_user') {
3932 $session_id = $payload['actions'][0]['value'];
3933 $trigger_id = $payload['trigger_id'];
3934
3935 // Get Bot Token from settings
3936 $slack_token = $this->options['live_agent_bot_token'] ?? '';
3937
3938 if (empty($slack_token)) {
3939 //error_log('Slack Bot Token not configured');
3940 return new WP_REST_Response(['error' => esc_html__('Bot token not configured', 'mxchat')], 400);
3941 }
3942 $response = wp_remote_post('https://slack.com/api/views.open', [
3943 'headers' => [
3944 'Content-Type' => 'application/json',
3945 'Authorization' => 'Bearer ' . $slack_token
3946 ],
3947 'body' => json_encode([
3948 'trigger_id' => $trigger_id,
3949 'view' => [
3950 'type' => 'modal',
3951 'callback_id' => 'reply_modal',
3952 'title' => [
3953 'type' => 'plain_text',
3954 'text' => __('Reply to User', 'mxchat')
3955 ],
3956 'submit' => [
3957 'type' => 'plain_text',
3958 'text' => __('Send', 'mxchat')
3959 ],
3960 'close' => [
3961 'type' => 'plain_text',
3962 'text' => __('Cancel', 'mxchat')
3963 ],
3964 'blocks' => [
3965 [
3966 'type' => 'input',
3967 'block_id' => 'reply_block',
3968 'label' => [
3969 'type' => 'plain_text',
3970 'text' => sprintf(__('Reply to session: %s', 'mxchat'), $session_id)
3971 ],
3972 'element' => [
3973 'type' => 'plain_text_input',
3974 'action_id' => 'message',
3975 'multiline' => true,
3976 'placeholder' => [
3977 'type' => 'plain_text',
3978 'text' => __('Type your message here...', 'mxchat')
3979 ]
3980 ]
3981 ]
3982 ],
3983 'private_metadata' => $session_id
3984 ]
3985 ])
3986 ]);
3987
3988 //error_log('Views.open response: ' . print_r($response, true));
3989
3990 // Return immediate acknowledgment
3991 return new WP_REST_Response(['ok' => true]);
3992 }
3993
3994 // Handle modal submission
3995 // Handle modal submission
3996 if ($payload['type'] === 'view_submission') {
3997 $session_id = $payload['view']['private_metadata'];
3998 $message = $payload['view']['state']['values']['reply_block']['message']['value'];
3999
4000 // Save the message (keep the message_id but don't include in response)
4001 $this->mxchat_save_chat_message($session_id, 'agent', $message);
4002
4003 // Keep the original response format for Slack
4004 return new WP_REST_Response([
4005 'response_action' => 'clear'
4006 ]);
4007 }
4008
4009 // Default acknowledgment
4010 return new WP_REST_Response(['ok' => true]);
4011 }
4012 public function mxchat_handle_agent_response(WP_REST_Request $request) {
4013 //error_log('Received agent response request');
4014 //error_log('Request data: ' . print_r($request->get_params(), true));
4015 // //error_log('Raw body: ' . file_get_contents('php://input'));
4016
4017 // Get the data from Slack's slash command format
4018 $command_text = $request->get_param('text');
4019 // //error_log('Command text: ' . $command_text);
4020
4021 if (empty($command_text)) {
4022 //error_log(esc_html__('Agent response error: No command text received', 'mxchat'));
4023 return new WP_REST_Response([
4024 'error' => esc_html__('Command text is required. Format: /reply session_id message', 'mxchat')
4025 ], 400);
4026 }
4027
4028 // Split the command text into session_id and message
4029 $parts = explode(' ', $command_text, 2);
4030 if (count($parts) !== 2) {
4031 //error_log('Agent response error: Invalid command format');
4032 return new WP_REST_Response([
4033 'error' => esc_html__('Invalid format. Use: /reply session_id message', 'mxchat')
4034 ], 400);
4035 }
4036
4037 $session_id = sanitize_text_field($parts[0]);
4038 $message = sanitize_text_field($parts[1]);
4039
4040 //error_log("Processing agent response - Session ID: $session_id, Message: $message");
4041
4042 // Save the message
4043 $message_id = $this->mxchat_save_chat_message($session_id, 'agent', $message);
4044
4045 if (!$message_id) {
4046 // //error_log('Failed to save agent message');
4047 return new WP_REST_Response([
4048 'error' => esc_html__('Failed to save message', 'mxchat')
4049 ], 500);
4050 }
4051
4052 // Return success response in Slack's expected format
4053 return new WP_REST_Response([
4054 'response_type' => 'in_channel',
4055 'text' => esc_html__("Message sent successfully to session $session_id", 'mxchat')
4056 ], 200);
4057 }
4058 public function mxchat_handle_switch_to_chatbot_intent($message, $user_id, $session_id) {
4059 // Update mode to AI
4060 update_option("mxchat_mode_{$session_id}", 'ai');
4061
4062 // Clear any existing PDF context to start fresh
4063 $this->clear_pdf_transients($session_id);
4064
4065 // Set the response with explicit chat_mode
4066 $this->fallbackResponse = [
4067 'text' => esc_html__('You are now chatting with the AI chatbot.', 'mxchat'),
4068 'html' => '',
4069 'images' => [],
4070 'chat_mode' => 'ai' // Ensure this is set
4071 ];
4072
4073 // Return the complete response array instead of just true
4074 return $this->fallbackResponse;
4075 }
4076
4077 public function handle_slack_messages(WP_REST_Request $request) {
4078 // Log the incoming request for debugging
4079 //error_log('Slack events request received: ' . $request->get_body());
4080
4081 $body = $request->get_body();
4082 $data = json_decode($body, true);
4083
4084 // Handle Slack URL verification
4085 if (isset($data['type']) && $data['type'] === 'url_verification') {
4086 //error_log('Slack URL verification challenge: ' . $data['challenge']);
4087 return new WP_REST_Response($data['challenge'], 200, ['Content-Type' => 'text/plain']);
4088 }
4089
4090 // IMPORTANT: Handle Slack's event deduplication
4091 if (isset($data['event_id'])) {
4092 $event_id = $data['event_id'];
4093 $processed_events = get_transient('mxchat_slack_events') ?: [];
4094
4095 // Check if we've already processed this event
4096 if (in_array($event_id, $processed_events)) {
4097 //error_log("Duplicate event detected: $event_id");
4098 return new WP_REST_Response(['ok' => true]);
4099 }
4100
4101 // Add this event to processed list
4102 $processed_events[] = $event_id;
4103 // Keep only last 100 events to prevent memory issues
4104 if (count($processed_events) > 100) {
4105 $processed_events = array_slice($processed_events, -100);
4106 }
4107 // Store for 1 hour
4108 set_transient('mxchat_slack_events', $processed_events, HOUR_IN_SECONDS);
4109 }
4110
4111 // Handle message events
4112 if (isset($data['event']) && $data['event']['type'] === 'message') {
4113 $event = $data['event'];
4114
4115 // Skip bot messages and messages with subtypes (like bot_message)
4116 if (isset($event['bot_id']) || isset($event['subtype'])) {
4117 return new WP_REST_Response(['ok' => true]);
4118 }
4119
4120 // Additional check: Skip if this is a threaded reply to our confirmation
4121 if (isset($event['thread_ts']) && $event['thread_ts'] !== $event['ts']) {
4122 return new WP_REST_Response(['ok' => true]);
4123 }
4124
4125 $channel_id = $event['channel'];
4126 $message_text = $event['text'] ?? '';
4127 $message_ts = $event['ts'] ?? '';
4128
4129 // Find session ID by looking for matching channel
4130 global $wpdb;
4131 $session_option = $wpdb->get_var(
4132 $wpdb->prepare(
4133 "SELECT option_name FROM {$wpdb->options}
4134 WHERE option_name LIKE 'mxchat_channel_%'
4135 AND option_value = %s",
4136 $channel_id
4137 )
4138 );
4139
4140 if ($session_option) {
4141 $session_id = str_replace('mxchat_channel_', '', $session_option);
4142
4143 // Create a unique key for this specific message
4144 $message_key = md5($session_id . $message_ts . $message_text);
4145 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
4146
4147 // Check if we've already processed this exact message
4148 if (in_array($message_key, $processed_messages)) {
4149 //error_log("Duplicate message detected for session $session_id");
4150 return new WP_REST_Response(['ok' => true]);
4151 }
4152
4153 // Add to processed messages
4154 $processed_messages[] = $message_key;
4155 // Keep only last 50 messages per session
4156 if (count($processed_messages) > 50) {
4157 $processed_messages = array_slice($processed_messages, -50);
4158 }
4159 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
4160
4161 // Save the agent message
4162 $this->mxchat_save_chat_message($session_id, 'agent', $message_text);
4163
4164 // Send confirmation back to Slack (only once)
4165 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4166 if (!empty($slack_bot_token)) {
4167 // Use a transient to prevent duplicate confirmations
4168 $confirm_key = 'mxchat_confirm_' . $message_key;
4169 if (!get_transient($confirm_key)) {
4170 wp_remote_post('https://slack.com/api/chat.postMessage', [
4171 'headers' => [
4172 'Content-Type' => 'application/json',
4173 'Authorization' => 'Bearer ' . $slack_bot_token
4174 ],
4175 'body' => json_encode([
4176 'channel' => $channel_id,
4177 'text' => "✅ _Message sent to user_",
4178 'thread_ts' => $event['ts'] // Reply in thread
4179 ])
4180 ]);
4181 // Set transient to prevent duplicate confirmations
4182 set_transient($confirm_key, true, 300); // 5 minutes
4183 }
4184 }
4185 }
4186 }
4187
4188 return new WP_REST_Response(['ok' => true]);
4189 }
4190
4191 // For the word upload handler
4192 public function mxchat_handle_word_upload() {
4193 // Delegate to word handler
4194 $this->word_handler->mxchat_handle_word_upload();
4195 }
4196
4197 // For the word removal handler
4198 public function mxchat_handle_word_remove() {
4199 // Delegate to word handler
4200 $this->word_handler->mxchat_handle_word_remove();
4201 }
4202
4203 // For the word status check
4204 public function mxchat_check_word_status() {
4205 // Delegate to word handler
4206 $this->word_handler->mxchat_check_word_status();
4207 }
4208
4209
4210 private function mxchat_get_user_identifier() {
4211 return MxChat_User::mxchat_get_user_identifier();
4212 }
4213
4214 private function mxchat_generate_embedding($text, $api_key) {
4215 try {
4216 // Get options and selected model
4217 $options = get_option('mxchat_options');
4218 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4219
4220 // Determine endpoint and API key based on model
4221 if (strpos($selected_model, 'voyage') === 0) {
4222 $endpoint = 'https://api.voyageai.com/v1/embeddings';
4223 $api_key = $options['voyage_api_key'] ?? '';
4224
4225 // Check if Voyage API key is missing
4226 if (empty($api_key)) {
4227 //error_log('Voyage API key is missing');
4228 return [
4229 'error' => esc_html__('Voyage AI API key is not configured', 'mxchat'),
4230 'error_code' => 'missing_voyage_api_key'
4231 ];
4232 }
4233 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4234 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
4235 $api_key = $options['gemini_api_key'] ?? '';
4236
4237 // Check if Gemini API key is missing
4238 if (empty($api_key)) {
4239 //error_log('Gemini API key is missing');
4240 return [
4241 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
4242 'error_code' => 'missing_gemini_api_key'
4243 ];
4244 }
4245 } else {
4246 $endpoint = 'https://api.openai.com/v1/embeddings';
4247 // Use the passed API key for OpenAI
4248
4249 // Check if OpenAI API key is missing
4250 if (empty($api_key)) {
4251 //error_log('OpenAI API key is missing');
4252 return [
4253 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
4254 'error_code' => 'missing_openai_api_key'
4255 ];
4256 }
4257 }
4258
4259 // Check if text is empty
4260 if (empty($text)) {
4261 //error_log('Empty text provided for embedding generation');
4262 return [
4263 'error' => esc_html__('No text provided for embedding generation', 'mxchat'),
4264 'error_code' => 'empty_embedding_text'
4265 ];
4266 }
4267
4268 // Prepare request body based on provider
4269 if (strpos($selected_model, 'gemini-embedding') === 0) {
4270 // Gemini API format
4271 $request_body = [
4272 'model' => 'models/' . $selected_model,
4273 'content' => [
4274 'parts' => [
4275 ['text' => $text]
4276 ]
4277 ],
4278 'outputDimensionality' => 1536
4279 ];
4280
4281 // Prepare headers for Gemini (API key as query parameter)
4282 $endpoint .= '?key=' . $api_key;
4283 $headers = [
4284 'Content-Type' => 'application/json'
4285 ];
4286 } else {
4287 // OpenAI/Voyage API format
4288 $request_body = [
4289 'input' => $text,
4290 'model' => $selected_model
4291 ];
4292
4293 // Add output_dimension for voyage-3-large
4294 if ($selected_model === 'voyage-3-large') {
4295 $request_body['output_dimension'] = 2048;
4296 }
4297
4298 // Prepare headers for OpenAI/Voyage
4299 $headers = [
4300 'Content-Type' => 'application/json',
4301 'Authorization' => 'Bearer ' . $api_key
4302 ];
4303 }
4304
4305 // Prepare request arguments
4306 $args = [
4307 'body' => wp_json_encode($request_body),
4308 'headers' => $headers,
4309 'timeout' => 60,
4310 'redirection' => 5,
4311 'blocking' => true,
4312 'httpversion' => '1.0',
4313 'sslverify' => true,
4314 ];
4315
4316 // Make the request
4317 $response = wp_remote_post($endpoint, $args);
4318
4319 // Handle WordPress errors
4320 if (is_wp_error($response)) {
4321 $error_message = $response->get_error_message();
4322 //error_log('Embedding Generation Error: ' . $error_message);
4323 return [
4324 'error' => esc_html__('Connection error when generating embeddings: ', 'mxchat') . esc_html($error_message),
4325 'error_code' => 'embedding_connection_error'
4326 ];
4327 }
4328
4329 // Check HTTP status code
4330 $status_code = wp_remote_retrieve_response_code($response);
4331 if ($status_code !== 200) {
4332 $response_body = json_decode(wp_remote_retrieve_body($response), true);
4333
4334 $error_message = isset($response_body['error']['message'])
4335 ? $response_body['error']['message']
4336 : 'HTTP Error ' . $status_code;
4337
4338 $error_type = isset($response_body['error']['type'])
4339 ? $response_body['error']['type']
4340 : 'unknown';
4341
4342 //error_log('Embedding API HTTP Error: ' . $status_code . ' - ' . $error_message);
4343
4344 // Handle specific error types
4345 switch ($error_type) {
4346 case 'invalid_request_error':
4347 if (strpos($error_message, 'API key') !== false) {
4348 return [
4349 'error' => esc_html__('Invalid API key for embedding generation. Please check your API key configuration.', 'mxchat'),
4350 'error_code' => 'embedding_invalid_api_key'
4351 ];
4352 }
4353 break;
4354
4355 case 'authentication_error':
4356 return [
4357 'error' => esc_html__('Authentication failed for embedding generation. Please check your API key.', 'mxchat'),
4358 'error_code' => 'embedding_auth_error'
4359 ];
4360
4361 case 'rate_limit_exceeded':
4362 return [
4363 'error' => esc_html__('Rate limit exceeded for embedding generation. Please try again later.', 'mxchat'),
4364 'error_code' => 'embedding_rate_limit'
4365 ];
4366
4367 case 'quota_exceeded':
4368 return [
4369 'error' => esc_html__('API quota exceeded for embedding generation. Please check your billing details.', 'mxchat'),
4370 'error_code' => 'embedding_quota_exceeded'
4371 ];
4372 }
4373
4374 // Generic error fallback
4375 return [
4376 'error' => esc_html__('Embedding API error - check embedding API key.: ', 'mxchat') . esc_html($error_message),
4377 'error_code' => 'embedding_api_error',
4378 'status_code' => $status_code
4379 ];
4380 }
4381
4382 $response_body = json_decode(wp_remote_retrieve_body($response), true);
4383
4384 // Handle different response formats based on provider
4385 if (strpos($selected_model, 'gemini-embedding') === 0) {
4386 // Gemini API response format
4387 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
4388 return $response_body['embedding']['values'];
4389 } else {
4390 //error_log('Invalid Gemini embedding response: ' . wp_json_encode($response_body));
4391 return [
4392 'error' => esc_html__('Received invalid embedding data from the Gemini API.', 'mxchat'),
4393 'error_code' => 'invalid_gemini_embedding_response'
4394 ];
4395 }
4396 } else {
4397 // OpenAI/Voyage API response format
4398 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
4399 return $response_body['data'][0]['embedding'];
4400 } else {
4401 //error_log('Invalid embedding response: ' . wp_json_encode($response_body));
4402 return [
4403 'error' => esc_html__('Received invalid embedding data from the API.', 'mxchat'),
4404 'error_code' => 'invalid_embedding_response'
4405 ];
4406 }
4407 }
4408 } catch (Exception $e) {
4409 //error_log('Embedding Exception: ' . $e->getMessage());
4410 return [
4411 'error' => esc_html__('System error when generating embeddings: ', 'mxchat') . esc_html($e->getMessage()),
4412 'error_code' => 'embedding_exception'
4413 ];
4414 }
4415 }
4416
4417
4418 private function mxchat_find_relevant_content($user_embedding, $bot_id = 'default', $user_query = '') {
4419 error_log("MXCHAT DEBUG: find_relevant_content called with bot_id: " . $bot_id);
4420
4421 // Check for OpenAI Vector Store first (takes priority when enabled)
4422 $bot_vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
4423
4424 if ($bot_vectorstore_config['use_vectorstore']) {
4425 // Get current model to verify it's an OpenAI model
4426 $bot_options = $this->get_bot_options($bot_id);
4427 $mxchat_options = get_option('mxchat_options', array());
4428 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
4429 $selected_model = $current_options['model'] ?? 'gpt-4o';
4430
4431 if ($this->is_openai_chat_model($selected_model)) {
4432 error_log("MXCHAT DEBUG: Using OpenAI Vector Store for knowledge retrieval");
4433 return $this->find_relevant_content_openai_vectorstore($user_query, $bot_id, $bot_vectorstore_config);
4434 } else {
4435 error_log("MXCHAT DEBUG: Vector Store enabled but model is not OpenAI (" . $selected_model . "), skipping Vector Store");
4436 }
4437 }
4438
4439 // Get bot-specific Pinecone configuration
4440 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
4441
4442 // Debug: Log the Pinecone configuration
4443 error_log("MXCHAT DEBUG: Pinecone config for bot '$bot_id':");
4444 error_log(" - use_pinecone: " . ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false'));
4445 error_log(" - api_key: " . (empty($bot_pinecone_config['api_key']) ? 'EMPTY' : 'SET (hidden)'));
4446 error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'NOT SET'));
4447 error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'NOT SET'));
4448
4449 // Determine whether to use Pinecone based on bot configuration
4450 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
4451
4452 error_log("MXCHAT DEBUG: Using " . ($use_pinecone ? "Pinecone" : "WordPress Database") . " for knowledge retrieval");
4453
4454 if ($use_pinecone) {
4455 return $this->find_relevant_content_pinecone($user_embedding, $bot_id, $bot_pinecone_config);
4456 } else {
4457 return $this->find_relevant_content_wordpress($user_embedding, $bot_id);
4458 }
4459 }
4460
4461 private function find_relevant_content_wordpress($user_embedding, $bot_id = 'default') {
4462 global $wpdb;
4463 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
4464 $cache_key = 'mxchat_system_prompt_embeddings_' . $bot_id;
4465 $batch_size = 500;
4466
4467 // Initialize similarity analysis storage
4468 $this->last_similarity_analysis = [
4469 'knowledge_base_type' => 'WordPress Database',
4470 'bot_id' => $bot_id,
4471 'top_matches' => [],
4472 'threshold_used' => 0,
4473 'total_checked' => 0
4474 ];
4475
4476 // NEW: Initialize valid URLs array
4477 $valid_urls = [];
4478
4479 // Get bot-specific options for similarity threshold
4480 $bot_options = $this->get_bot_options($bot_id);
4481 $current_options = !empty($bot_options) ? $bot_options : $this->options;
4482
4483 // Retrieve embeddings from cache or database
4484 $embeddings = wp_cache_get($cache_key, 'mxchat_system_prompts');
4485 if ($embeddings === false) {
4486 // Cache miss - load embeddings from database WITH CONTENT and ROLE RESTRICTION for testing
4487 $embeddings = [];
4488 $offset = 0;
4489
4490 do {
4491 // Add bot_id filter if not default and if bot_metadata column exists
4492 $bot_filter = '';
4493 if ($bot_id !== 'default') {
4494 // Check if bot_metadata column exists
4495 $column_exists = $wpdb->get_var("SHOW COLUMNS FROM {$system_prompt_table} LIKE 'bot_metadata'");
4496 if ($column_exists) {
4497 $bot_filter = $wpdb->prepare(" AND (bot_metadata = %s OR bot_metadata IS NULL OR bot_metadata = '')", $bot_id);
4498 }
4499 }
4500
4501 $query = $wpdb->prepare(
4502 "SELECT id, embedding_vector, article_content, source_url, role_restriction
4503 FROM {$system_prompt_table}
4504 WHERE 1=1 {$bot_filter}
4505 LIMIT %d OFFSET %d",
4506 $batch_size,
4507 $offset
4508 );
4509
4510 $batch = $wpdb->get_results($query);
4511 if (empty($batch)) {
4512 break;
4513 }
4514
4515 $embeddings = array_merge($embeddings, $batch);
4516 $offset += $batch_size;
4517 unset($batch);
4518 } while (true);
4519
4520 if (empty($embeddings)) {
4521 // Store empty array for valid URLs since no content found
4522 $this->current_valid_urls = [];
4523 return '';
4524 }
4525
4526 // Cache embeddings for future use (but note: this now includes content and role restrictions)
4527 wp_cache_set($cache_key, $embeddings, 'mxchat_system_prompts', 3600);
4528 }
4529
4530 // Get knowledge manager instance for role checking
4531 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
4532
4533 // Get base similarity threshold from bot options or default options
4534 $similarity_threshold = isset($current_options['similarity_threshold'])
4535 ? ((int) $current_options['similarity_threshold']) / 100
4536 : 0.35;
4537
4538 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
4539
4540 // Calculate similarities and build results array
4541 $all_similarities = [];
4542 $url_groups = array(); // NEW: Group by source_url for chunk reassembly
4543
4544 foreach ($embeddings as $embedding) {
4545 $database_embedding = $embedding->embedding_vector
4546 ? unserialize($embedding->embedding_vector, ['allowed_classes' => false])
4547 : null;
4548
4549 if (is_array($database_embedding) && is_array($user_embedding)) {
4550 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
4551
4552 // Check role access
4553 $role_restriction = $embedding->role_restriction ?? 'public';
4554 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
4555
4556 // Store ALL similarities for testing (top 10)
4557 $source_display = '';
4558 $source_url = $embedding->source_url ?? '';
4559 if (!empty($source_url) && $source_url !== '#') {
4560 $source_display = $source_url;
4561 } else {
4562 $content_preview = strip_tags($embedding->article_content ?? '');
4563 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
4564 $source_display = substr(trim($content_preview), 0, 50) . '...';
4565 }
4566
4567 // Parse chunk metadata for display
4568 $article_content_for_parse = $embedding->article_content ?? '';
4569 $parsed_for_display = MxChat_Chunker::parse_stored_chunk($article_content_for_parse);
4570 $is_chunk = $parsed_for_display['is_chunked'];
4571 $chunk_meta = $parsed_for_display['metadata'];
4572
4573 $all_similarities[] = [
4574 'document_id' => $embedding->id,
4575 'similarity' => $similarity,
4576 'similarity_percentage' => round($similarity * 100, 2),
4577 'above_threshold' => $similarity >= $similarity_threshold,
4578 'source_display' => $source_display,
4579 'content_preview' => substr(strip_tags($parsed_for_display['text'] ?? ''), 0, 100) . '...',
4580 'used_for_context' => false,
4581 'role_restriction' => $role_restriction,
4582 'has_access' => $has_access,
4583 'filtered_out' => !$has_access,
4584 'is_chunk' => $is_chunk,
4585 'chunk_index' => $is_chunk ? ($chunk_meta['chunk_index'] ?? 0) : null,
4586 'total_chunks' => $is_chunk ? ($chunk_meta['total_chunks'] ?? 1) : null
4587 ];
4588
4589 // Only consider results above threshold AND with access for content retrieval
4590 if ($similarity >= $similarity_threshold && $has_access && !empty($source_url)) {
4591 // Parse chunk metadata if present
4592 $article_content = $embedding->article_content ?? '';
4593 $parsed = MxChat_Chunker::parse_stored_chunk($article_content);
4594 $is_chunked = $parsed['is_chunked'];
4595 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
4596 $text_content = $parsed['text'];
4597
4598 // Group by source URL
4599 if (!isset($url_groups[$source_url])) {
4600 $url_groups[$source_url] = array(
4601 'source_url' => $source_url,
4602 'best_score' => 0,
4603 'is_chunked' => $is_chunked,
4604 'chunks' => array(),
4605 'single_text' => '',
4606 'single_id' => null
4607 );
4608 }
4609
4610 // Track best score for this URL
4611 if ($similarity > $url_groups[$source_url]['best_score']) {
4612 $url_groups[$source_url]['best_score'] = $similarity;
4613 }
4614
4615 // Store chunk info or single text
4616 if ($is_chunked) {
4617 $url_groups[$source_url]['is_chunked'] = true;
4618 $url_groups[$source_url]['chunks'][] = array(
4619 'id' => $embedding->id,
4620 'score' => $similarity,
4621 'chunk_index' => $chunk_index,
4622 'text' => $text_content
4623 );
4624 } else {
4625 $url_groups[$source_url]['single_text'] = $text_content;
4626 $url_groups[$source_url]['single_id'] = $embedding->id;
4627 }
4628 }
4629 }
4630
4631 unset($database_embedding);
4632 }
4633
4634 // Sort ALL similarities for testing display (highest first)
4635 usort($all_similarities, function ($a, $b) {
4636 return $b['similarity'] <=> $a['similarity'];
4637 });
4638
4639 // Sort URL groups by best score (highest first)
4640 uasort($url_groups, function($a, $b) {
4641 return $b['best_score'] <=> $a['best_score'];
4642 });
4643
4644 // Take top 3 unique URLs
4645 $top_urls = array_slice($url_groups, 0, 3, true);
4646
4647 // Track which document IDs are used for context
4648 $used_document_ids = [];
4649 foreach ($top_urls as $group) {
4650 if ($group['is_chunked']) {
4651 foreach ($group['chunks'] as $chunk) {
4652 $used_document_ids[] = $chunk['id'];
4653 }
4654 } elseif ($group['single_id']) {
4655 $used_document_ids[] = $group['single_id'];
4656 }
4657 }
4658
4659 // Update the all_similarities array to mark which were actually used
4660 foreach ($all_similarities as &$similarity_item) {
4661 $similarity_item['used_for_context'] = in_array($similarity_item['document_id'], $used_document_ids);
4662 }
4663
4664 // Store top 10 for testing panel
4665 $this->last_similarity_analysis['top_matches'] = array_slice($all_similarities, 0, 10);
4666 $this->last_similarity_analysis['total_checked'] = count($embeddings);
4667
4668 // Initialize final content
4669 $content = '';
4670 $matches_used = 0;
4671
4672 // Check if citation links are enabled (default to 'on' for backwards compatibility)
4673 // Use fresh options to ensure we get the latest setting value
4674 $fresh_options = get_option('mxchat_options', []);
4675 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
4676
4677 // Build content from top URLs
4678 foreach ($top_urls as $source_url => $group) {
4679 $full_text = '';
4680
4681 if ($group['is_chunked']) {
4682 // Fetch all chunks for this URL and reassemble
4683 $full_text = $this->reassemble_chunks_from_wordpress($source_url);
4684
4685 // If fetching all chunks fails, fall back to matched chunks
4686 if (empty($full_text)) {
4687 // Sort matched chunks by index and concatenate
4688 usort($group['chunks'], function($a, $b) {
4689 return $a['chunk_index'] <=> $b['chunk_index'];
4690 });
4691
4692 $chunk_texts = array();
4693 foreach ($group['chunks'] as $chunk) {
4694 $chunk_texts[] = $chunk['text'];
4695 }
4696 $full_text = implode("\n\n", $chunk_texts);
4697 }
4698 } else {
4699 $full_text = $group['single_text'];
4700 }
4701
4702 if (!empty($full_text)) {
4703 // Strip URLs from content if citation links are disabled
4704 if (!$citation_links_enabled) {
4705 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
4706 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
4707 }
4708
4709 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
4710 $content .= $full_text . "\n\n";
4711
4712 // Only include citation URLs if citation links are enabled
4713 if ($citation_links_enabled && !empty($source_url) && $source_url !== '#') {
4714 $valid_urls[] = $source_url;
4715 $content .= "URL: " . $source_url . "\n\n";
4716 }
4717
4718 // Extract any URLs from the text content itself (only if citation links enabled)
4719 if ($citation_links_enabled) {
4720 preg_match_all(
4721 '#\bhttps?://[^\s<>"\']+#i',
4722 $full_text,
4723 $content_urls
4724 );
4725 if (!empty($content_urls[0])) {
4726 $valid_urls = array_merge($valid_urls, $content_urls[0]);
4727 }
4728 }
4729
4730 $matches_used++;
4731 }
4732 }
4733
4734 // NEW: Store unique valid URLs for validation
4735 $this->current_valid_urls = array_unique($valid_urls);
4736
4737 // Add response guidelines
4738 if (empty($top_urls)) {
4739 $content = "No reference information was found for this query.\n\n";
4740 } else {
4741 // Build response guidelines based on citation links setting
4742 $content .= "\n## Response Guidelines ##\n" .
4743 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
4744 "Be conversational and friendly, but never mention your knowledge base or training data. " .
4745 "If you don't have specific information or are uncertain about any details, it's always " .
4746 "better to honestly say you don't know rather than making up or guessing at answers. " .
4747 "When information is incomplete, let them know you are unsure.\n\n";
4748
4749 // Only add hyperlink instructions if citation links are enabled
4750 if ($citation_links_enabled) {
4751 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
4752 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
4753 } else {
4754 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
4755 "Simply provide helpful answers based on the reference information without citing sources.";
4756 }
4757 }
4758
4759 return trim($content);
4760 }
4761
4762 /**
4763 * Fetch and reassemble all chunks for a URL from WordPress database
4764 *
4765 * @param string $source_url The source URL to fetch chunks for
4766 * @return string Reassembled content from all chunks
4767 */
4768 private function reassemble_chunks_from_wordpress($source_url) {
4769 global $wpdb;
4770 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
4771
4772 // Fetch all rows with this source_url
4773 $rows = $wpdb->get_results($wpdb->prepare(
4774 "SELECT article_content FROM {$table}
4775 WHERE source_url = %s
4776 ORDER BY id ASC",
4777 $source_url
4778 ));
4779
4780 if (empty($rows)) {
4781 return '';
4782 }
4783
4784 // Parse and sort chunks by index
4785 $chunks = array();
4786 foreach ($rows as $row) {
4787 $parsed = MxChat_Chunker::parse_stored_chunk($row->article_content);
4788
4789 if ($parsed['is_chunked']) {
4790 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
4791 $chunks[$chunk_index] = $parsed['text'];
4792 } else {
4793 // Non-chunked content - just return it
4794 $chunks[] = $parsed['text'];
4795 }
4796 }
4797
4798 // Sort by chunk index
4799 ksort($chunks);
4800
4801 // Reassemble content
4802 return implode("\n\n", $chunks);
4803 }
4804
4805 private function find_relevant_content_pinecone($user_embedding, $bot_id = 'default', $bot_config = null) {
4806 global $wpdb;
4807
4808 error_log("MXCHAT DEBUG: find_relevant_content_pinecone called");
4809 error_log(" - bot_id: " . $bot_id);
4810 error_log(" - user_embedding is array: " . (is_array($user_embedding) ? 'yes' : 'no'));
4811 error_log(" - user_embedding count: " . (is_array($user_embedding) ? count($user_embedding) : 'N/A'));
4812
4813 // Use bot-specific config or fall back to default
4814 if ($bot_config === null) {
4815 $bot_config = $this->get_bot_pinecone_config($bot_id);
4816 }
4817
4818 $api_key = $bot_config['api_key'] ?? '';
4819 $host = $bot_config['host'] ?? '';
4820 $namespace = $bot_config['namespace'] ?? '';
4821
4822 error_log("MXCHAT DEBUG: Pinecone query parameters:");
4823 error_log(" - API Key: " . (empty($api_key) ? 'EMPTY - ERROR!' : 'Present (length: ' . strlen($api_key) . ')'));
4824 error_log(" - Host: " . (empty($host) ? 'EMPTY - ERROR!' : $host));
4825 error_log(" - Namespace: " . (empty($namespace) ? 'EMPTY (will use default)' : $namespace));
4826
4827 // Initialize similarity analysis storage
4828 $this->last_similarity_analysis = [
4829 'knowledge_base_type' => 'Pinecone',
4830 'bot_id' => $bot_id,
4831 'namespace' => $namespace,
4832 'top_matches' => [],
4833 'threshold_used' => 0,
4834 'total_checked' => 0
4835 ];
4836
4837 // NEW: Initialize valid URLs array
4838 $valid_urls = [];
4839
4840 if (empty($host) || empty($api_key)) {
4841 error_log("MXCHAT DEBUG ERROR: Missing Pinecone host or API key!");
4842 error_log(" - Host empty: " . (empty($host) ? 'YES' : 'NO'));
4843 error_log(" - API key empty: " . (empty($api_key) ? 'YES' : 'NO'));
4844 // Store empty array for valid URLs since we can't proceed
4845 $this->current_valid_urls = [];
4846 return '';
4847 }
4848
4849 // Get knowledge manager instance for role checking
4850 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
4851
4852 // Get the similarity threshold from the bot options or main options
4853 $bot_options = $this->get_bot_options($bot_id);
4854 $current_options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', []);
4855
4856 $similarity_threshold = isset($current_options['similarity_threshold'])
4857 ? ((int) $current_options['similarity_threshold']) / 100
4858 : 0.35;
4859
4860 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
4861
4862 // Prepare the query request for Pinecone
4863 $api_endpoint = "https://{$host}/query";
4864
4865 $request_body = array(
4866 'vector' => $user_embedding,
4867 'topK' => 50, // Increased for chunked content grouping - need more candidates to find top 3 unique URLs
4868 'includeMetadata' => true,
4869 'includeValues' => true
4870 );
4871
4872 // Add namespace if specified for this bot
4873 if (!empty($namespace)) {
4874 $request_body['namespace'] = $namespace;
4875 }
4876
4877 error_log("MXCHAT DEBUG: About to call Pinecone API");
4878 error_log(" - Endpoint: " . $api_endpoint);
4879 error_log(" - Namespace in request: " . (!empty($namespace) ? $namespace : 'NOT SET'));
4880
4881 $response = wp_remote_post($api_endpoint, array(
4882 'headers' => array(
4883 'Api-Key' => $api_key,
4884 'accept' => 'application/json',
4885 'content-type' => 'application/json'
4886 ),
4887 'body' => wp_json_encode($request_body),
4888 'timeout' => 30
4889 ));
4890
4891 if (is_wp_error($response)) {
4892 error_log("MXCHAT DEBUG ERROR: WP Error in Pinecone request: " . $response->get_error_message());
4893 // Store empty array for valid URLs
4894 $this->current_valid_urls = [];
4895 return '';
4896 }
4897
4898 $response_code = wp_remote_retrieve_response_code($response);
4899 error_log("MXCHAT DEBUG: Pinecone response code: " . $response_code);
4900
4901 if ($response_code !== 200) {
4902 $response_body = wp_remote_retrieve_body($response);
4903 error_log("MXCHAT DEBUG ERROR: Pinecone API error response: " . substr($response_body, 0, 500));
4904 // Store empty array for valid URLs
4905 $this->current_valid_urls = [];
4906 return '';
4907 }
4908
4909 // ADD DETAILED DEBUG SECTION HERE
4910 $response_body = wp_remote_retrieve_body($response);
4911 error_log("MXCHAT DEBUG: Raw Pinecone response length: " . strlen($response_body));
4912
4913 $results = json_decode($response_body, true);
4914
4915 if (json_last_error() !== JSON_ERROR_NONE) {
4916 error_log("MXCHAT DEBUG ERROR: JSON decode error: " . json_last_error_msg());
4917 error_log("MXCHAT DEBUG: First 500 chars of response: " . substr($response_body, 0, 500));
4918 // Store empty array for valid URLs
4919 $this->current_valid_urls = [];
4920 return '';
4921 }
4922
4923 error_log("MXCHAT DEBUG: Pinecone response structure:");
4924 error_log(" - Has 'matches' key: " . (isset($results['matches']) ? 'yes' : 'no'));
4925 error_log(" - Has 'namespace' key: " . (isset($results['namespace']) ? 'yes (' . $results['namespace'] . ')' : 'no'));
4926
4927 if (empty($results['matches'])) {
4928 error_log("MXCHAT DEBUG: No matches found in Pinecone response");
4929 error_log("MXCHAT DEBUG: Response keys: " . implode(', ', array_keys($results)));
4930 // Store empty array for valid URLs
4931 $this->current_valid_urls = [];
4932 return '';
4933 }
4934
4935 error_log("MXCHAT DEBUG: Found " . count($results['matches']) . " matches in Pinecone");
4936
4937 // Log first match details for debugging
4938 if (!empty($results['matches'][0])) {
4939 $first_match = $results['matches'][0];
4940 error_log("MXCHAT DEBUG: First match details:");
4941 error_log(" - Score: " . ($first_match['score'] ?? 'no score'));
4942 error_log(" - Has metadata: " . (isset($first_match['metadata']) ? 'yes' : 'no'));
4943 if (isset($first_match['metadata'])) {
4944 error_log(" - Metadata keys: " . implode(', ', array_keys($first_match['metadata'])));
4945 }
4946 }
4947
4948 // Initialize the final content
4949 $content = '';
4950 $matches_used = 0;
4951 $matches_used_for_context = [];
4952
4953 // Check if citation links are enabled (default to 'on' for backwards compatibility)
4954 // Use fresh options to ensure we get the latest setting value
4955 $fresh_options = get_option('mxchat_options', []);
4956 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
4957
4958 // NEW CHUNKING LOGIC: Group results by source_url for chunk reassembly
4959 $url_groups = array();
4960
4961 foreach ($results['matches'] as $index => $match) {
4962 // Skip if similarity is below threshold
4963 if ($match['score'] < $similarity_threshold) {
4964 continue;
4965 }
4966
4967 $metadata = $match['metadata'] ?? array();
4968 $source_url = $metadata['source_url'] ?? '';
4969
4970 if (empty($source_url)) {
4971 continue;
4972 }
4973
4974 // LAZY ROLE CHECK: Only check role for content we're actually considering
4975 $match_id = $match['id'] ?? '';
4976 $role_restriction = $this->get_single_vector_role($match_id, $metadata);
4977 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
4978
4979 // Skip if user doesn't have access
4980 if (!$has_access) {
4981 continue;
4982 }
4983
4984 // Group by source URL
4985 if (!isset($url_groups[$source_url])) {
4986 $url_groups[$source_url] = array(
4987 'source_url' => $source_url,
4988 'best_score' => 0,
4989 'is_chunked' => isset($metadata['is_chunked']) && $metadata['is_chunked'],
4990 'chunks' => array(),
4991 'single_text' => ''
4992 );
4993 }
4994
4995 // Track best score for this URL
4996 if ($match['score'] > $url_groups[$source_url]['best_score']) {
4997 $url_groups[$source_url]['best_score'] = $match['score'];
4998 }
4999
5000 // Store chunk info or single text
5001 if ($url_groups[$source_url]['is_chunked']) {
5002 $url_groups[$source_url]['chunks'][] = array(
5003 'id' => $match_id,
5004 'score' => $match['score'],
5005 'chunk_index' => $metadata['chunk_index'] ?? 0,
5006 'text' => $metadata['text'] ?? ''
5007 );
5008 } else {
5009 // Non-chunked content - just store the text
5010 $url_groups[$source_url]['single_text'] = $metadata['text'] ?? '';
5011 $url_groups[$source_url]['single_id'] = $match_id;
5012 }
5013 }
5014
5015 // Sort URL groups by best score (highest first)
5016 uasort($url_groups, function($a, $b) {
5017 return $b['best_score'] <=> $a['best_score'];
5018 });
5019
5020 // Take top 3 unique URLs
5021 $top_urls = array_slice($url_groups, 0, 3, true);
5022
5023 // Track which match IDs are actually used for context (only from top 3 URLs)
5024 foreach ($top_urls as $group) {
5025 if ($group['is_chunked']) {
5026 foreach ($group['chunks'] as $chunk) {
5027 $matches_used_for_context[] = $chunk['id'];
5028 }
5029 } elseif (!empty($group['single_id'])) {
5030 $matches_used_for_context[] = $group['single_id'];
5031 }
5032 }
5033
5034 // Build content from top URLs
5035 foreach ($top_urls as $source_url => $group) {
5036 $full_text = '';
5037
5038 if ($group['is_chunked']) {
5039 // Fetch all chunks for this URL and reassemble
5040 $full_text = $this->reassemble_chunks_from_pinecone($source_url, $bot_config);
5041
5042 // If fetching all chunks fails, fall back to matched chunks
5043 if (empty($full_text)) {
5044 // Sort matched chunks by index and concatenate
5045 usort($group['chunks'], function($a, $b) {
5046 return $a['chunk_index'] <=> $b['chunk_index'];
5047 });
5048
5049 $chunk_texts = array();
5050 foreach ($group['chunks'] as $chunk) {
5051 $chunk_texts[] = $chunk['text'];
5052 }
5053 $full_text = implode("\n\n", $chunk_texts);
5054 }
5055 } else {
5056 $full_text = $group['single_text'];
5057 }
5058
5059 if (!empty($full_text)) {
5060 // Strip URLs from content if citation links are disabled
5061 if (!$citation_links_enabled) {
5062 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
5063 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
5064 }
5065
5066 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
5067 $content .= $full_text . "\n\n";
5068
5069 // Only include citation URLs if citation links are enabled
5070 if ($citation_links_enabled && !empty($source_url) && $source_url !== '#') {
5071 $valid_urls[] = $source_url;
5072 $content .= "URL: " . $source_url . "\n\n";
5073 }
5074
5075 // Extract any URLs from the text content itself (only if citation links enabled)
5076 if ($citation_links_enabled) {
5077 preg_match_all(
5078 '#\bhttps?://[^\s<>"\']+#i',
5079 $full_text,
5080 $content_urls
5081 );
5082 if (!empty($content_urls[0])) {
5083 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5084 }
5085 }
5086
5087 $matches_used++;
5088 }
5089 }
5090
5091 // Process ALL matches for testing data (top 10) - with role checking for testing display
5092 $all_matches = [];
5093 foreach ($results['matches'] as $index => $match) {
5094 if ($index >= 10) break; // Limit to top 10 for testing
5095
5096 $match_id = $match['id'] ?? '';
5097
5098 // Check role access for testing display (use cache if available)
5099 $role_restriction = $this->get_single_vector_role($match_id, $match['metadata']);
5100 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5101
5102 $source_display = '';
5103 if (!empty($match['metadata']['source_url'])) {
5104 $source_display = $match['metadata']['source_url'];
5105 } else {
5106 $content_preview = strip_tags($match['metadata']['text'] ?? '');
5107 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
5108 $source_display = substr(trim($content_preview), 0, 50) . '...';
5109 }
5110
5111 $match_id_for_display = $match['id'] ?? $index;
5112
5113 // Check for chunk metadata in Pinecone
5114 $is_chunk = isset($match['metadata']['is_chunked']) && $match['metadata']['is_chunked'];
5115 $chunk_index = isset($match['metadata']['chunk_index']) ? intval($match['metadata']['chunk_index']) : null;
5116 $total_chunks = isset($match['metadata']['total_chunks']) ? intval($match['metadata']['total_chunks']) : null;
5117
5118 // Also detect chunk from vector ID pattern: {hash}_chunk_{index}
5119 if (!$is_chunk && MxChat_Chunker::is_chunk_vector_id($match_id_for_display)) {
5120 $is_chunk = true;
5121 }
5122
5123 $all_matches[] = [
5124 'document_id' => $match_id_for_display,
5125 'similarity' => $match['score'],
5126 'similarity_percentage' => round($match['score'] * 100, 2),
5127 'above_threshold' => $match['score'] >= $similarity_threshold,
5128 'source_display' => $source_display,
5129 'content_preview' => substr(strip_tags($match['metadata']['text'] ?? ''), 0, 100) . '...',
5130 'used_for_context' => in_array($match_id_for_display, $matches_used_for_context),
5131 'role_restriction' => $role_restriction,
5132 'has_access' => $has_access,
5133 'filtered_out' => !$has_access,
5134 'is_chunk' => $is_chunk,
5135 'chunk_index' => $chunk_index,
5136 'total_chunks' => $total_chunks
5137 ];
5138 }
5139
5140 // Store for testing panel
5141 $this->last_similarity_analysis['top_matches'] = $all_matches;
5142 $this->last_similarity_analysis['total_checked'] = count($results['matches']);
5143
5144 // NEW: Store unique valid URLs for validation
5145 $this->current_valid_urls = array_unique($valid_urls);
5146
5147 // Add response guidelines
5148 if ($matches_used === 0) {
5149 $content = "No reference information was found for this query.\n\n";
5150 } else {
5151 // Build response guidelines based on citation links setting
5152 $content .= "\n## Response Guidelines ##\n" .
5153 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5154 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5155 "If you don't have specific information or are uncertain about any details, it's always " .
5156 "better to honestly say you don't know rather than making up or guessing at answers. " .
5157 "When information is incomplete, let them know you are unsure.\n\n";
5158
5159 // Only add hyperlink instructions if citation links are enabled
5160 if ($citation_links_enabled) {
5161 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5162 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
5163 } else {
5164 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5165 "Simply provide helpful answers based on the reference information without citing sources.";
5166 }
5167 }
5168
5169 return trim($content);
5170 }
5171
5172 /**
5173 * Get role restriction for a single vector (with caching)
5174 */
5175 private function get_single_vector_role($vector_id, $metadata = array()) {
5176 global $wpdb;
5177
5178 if (empty($vector_id)) {
5179 return 'public';
5180 }
5181
5182 // Check cache first
5183 $cache_key = 'mxchat_vector_role_' . $vector_id;
5184 $cached_role = wp_cache_get($cache_key, 'mxchat_vector_roles');
5185
5186 if ($cached_role !== false) {
5187 return $cached_role;
5188 }
5189
5190 $role_restriction = 'public';
5191
5192 // First try Pinecone metadata
5193 if (!empty($metadata['role_restriction'])) {
5194 $role_restriction = $metadata['role_restriction'];
5195 } else {
5196 // Check WordPress table for user-modified roles
5197 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
5198 $stored_role = $wpdb->get_var($wpdb->prepare(
5199 "SELECT role_restriction FROM {$roles_table} WHERE vector_id = %s",
5200 $vector_id
5201 ));
5202
5203 if ($stored_role) {
5204 $role_restriction = $stored_role;
5205 }
5206 }
5207
5208 // Cache individual role for 1 hour
5209 wp_cache_set($cache_key, $role_restriction, 'mxchat_vector_roles', 3600);
5210
5211 return $role_restriction;
5212 }
5213
5214 /**
5215 * Fetch and reassemble all chunks for a URL from Pinecone
5216 *
5217 * @param string $source_url The source URL to fetch chunks for
5218 * @param array $bot_config Bot-specific Pinecone configuration
5219 * @return string Reassembled content from all chunks
5220 */
5221 private function reassemble_chunks_from_pinecone($source_url, $bot_config) {
5222 $api_key = $bot_config['api_key'] ?? '';
5223 $host = $bot_config['host'] ?? '';
5224 $namespace = $bot_config['namespace'] ?? '';
5225
5226 if (empty($host) || empty($api_key)) {
5227 return '';
5228 }
5229
5230 $base_hash = md5($source_url);
5231
5232 // Use Pinecone list API to find all chunk vectors with this prefix
5233 $list_url = "https://{$host}/vectors/list";
5234
5235 $list_body = array(
5236 'prefix' => $base_hash . '_chunk_',
5237 'limit' => 100
5238 );
5239
5240 if (!empty($namespace)) {
5241 $list_body['namespace'] = $namespace;
5242 }
5243
5244 $list_response = wp_remote_post($list_url, array(
5245 'headers' => array(
5246 'Api-Key' => $api_key,
5247 'accept' => 'application/json',
5248 'content-type' => 'application/json'
5249 ),
5250 'body' => wp_json_encode($list_body),
5251 'timeout' => 30
5252 ));
5253
5254 if (is_wp_error($list_response)) {
5255 //error_log('[MXCHAT-CHUNK] List API error: ' . $list_response->get_error_message());
5256 return '';
5257 }
5258
5259 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
5260
5261 if (empty($list_data['vectors'])) {
5262 //error_log('[MXCHAT-CHUNK] No chunk vectors found for URL: ' . $source_url);
5263 return '';
5264 }
5265
5266 // Extract vector IDs
5267 $vector_ids = array();
5268 foreach ($list_data['vectors'] as $vector) {
5269 if (isset($vector['id'])) {
5270 $vector_ids[] = $vector['id'];
5271 }
5272 }
5273
5274 if (empty($vector_ids)) {
5275 return '';
5276 }
5277
5278 // Fetch all chunk content
5279 $fetch_url = "https://{$host}/vectors/fetch";
5280
5281 $fetch_body = array(
5282 'ids' => $vector_ids
5283 );
5284
5285 if (!empty($namespace)) {
5286 $fetch_body['namespace'] = $namespace;
5287 }
5288
5289 $fetch_response = wp_remote_post($fetch_url, array(
5290 'headers' => array(
5291 'Api-Key' => $api_key,
5292 'accept' => 'application/json',
5293 'content-type' => 'application/json'
5294 ),
5295 'body' => wp_json_encode($fetch_body),
5296 'timeout' => 30
5297 ));
5298
5299 if (is_wp_error($fetch_response)) {
5300 //error_log('[MXCHAT-CHUNK] Fetch API error: ' . $fetch_response->get_error_message());
5301 return '';
5302 }
5303
5304 $fetch_data = json_decode(wp_remote_retrieve_body($fetch_response), true);
5305
5306 if (empty($fetch_data['vectors'])) {
5307 return '';
5308 }
5309
5310 // Sort chunks by index and reassemble
5311 $chunks = array();
5312 foreach ($fetch_data['vectors'] as $id => $vector) {
5313 $metadata = $vector['metadata'] ?? array();
5314 $chunk_index = $metadata['chunk_index'] ?? 0;
5315 $text = $metadata['text'] ?? '';
5316
5317 // Store chunk with its index
5318 $chunks[$chunk_index] = $text;
5319 }
5320
5321 // Sort by chunk index
5322 ksort($chunks);
5323
5324 // Reassemble content
5325 return implode("\n\n", $chunks);
5326 }
5327
5328 /**
5329 * Search for relevant content using OpenAI Vector Store (File Search)
5330 *
5331 * @param string $user_query The user's query text
5332 * @param string $bot_id The bot ID
5333 * @param array $vectorstore_config Vector Store configuration
5334 * @return string Formatted context string with references
5335 */
5336 private function find_relevant_content_openai_vectorstore($user_query, $bot_id = 'default', $vectorstore_config = array()) {
5337 error_log("MXCHAT DEBUG: find_relevant_content_openai_vectorstore called");
5338 error_log(" - bot_id: " . $bot_id);
5339 error_log(" - user_query length: " . strlen($user_query));
5340
5341 // Get OpenAI API key
5342 $mxchat_options = get_option('mxchat_options', array());
5343 $api_key = $mxchat_options['api_key'] ?? '';
5344
5345 if (empty($api_key)) {
5346 error_log("MXCHAT DEBUG ERROR: OpenAI API key not configured");
5347 $this->current_valid_urls = [];
5348 return '';
5349 }
5350
5351 // Get Vector Store configuration
5352 if (empty($vectorstore_config)) {
5353 $vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
5354 }
5355
5356 $vectorstore_ids_string = $vectorstore_config['vectorstore_ids'] ?? '';
5357 $max_results = $vectorstore_config['max_results'] ?? 5;
5358
5359 if (empty($vectorstore_ids_string)) {
5360 error_log("MXCHAT DEBUG ERROR: No Vector Store IDs configured");
5361 $this->current_valid_urls = [];
5362 return '';
5363 }
5364
5365 // Parse Vector Store IDs
5366 $vectorstore_ids = array_map('trim', explode(',', $vectorstore_ids_string));
5367 $vectorstore_ids = array_filter($vectorstore_ids); // Remove empty values
5368
5369 error_log("MXCHAT DEBUG: Vector Store IDs: " . implode(', ', $vectorstore_ids));
5370 error_log("MXCHAT DEBUG: Max results: " . $max_results);
5371
5372 // Initialize similarity analysis storage
5373 $this->last_similarity_analysis = [
5374 'knowledge_base_type' => 'OpenAI Vector Store',
5375 'bot_id' => $bot_id,
5376 'vectorstore_ids' => $vectorstore_ids,
5377 'top_matches' => [],
5378 'threshold_used' => 0,
5379 'total_checked' => 0
5380 ];
5381
5382 $valid_urls = [];
5383
5384 // Get the selected model
5385 $bot_options = $this->get_bot_options($bot_id);
5386 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
5387 $selected_model = $current_options['model'] ?? 'gpt-4o';
5388
5389 // Verify it's an OpenAI model
5390 if (!$this->is_openai_chat_model($selected_model)) {
5391 error_log("MXCHAT DEBUG ERROR: Vector Store search requires OpenAI model. Current: " . $selected_model);
5392 $this->current_valid_urls = [];
5393 return '';
5394 }
5395
5396 // Use OpenAI Responses API with file_search tool
5397 $request_body = array(
5398 'model' => $selected_model,
5399 'input' => $user_query,
5400 'tools' => array(
5401 array(
5402 'type' => 'file_search',
5403 'vector_store_ids' => $vectorstore_ids,
5404 'max_num_results' => intval($max_results)
5405 )
5406 ),
5407 'include' => array('output[*].file_search_call.search_results')
5408 );
5409
5410 error_log("MXCHAT VECTORSTORE: ========== REQUEST START ==========");
5411 error_log("MXCHAT VECTORSTORE: Model: " . $selected_model);
5412 error_log("MXCHAT VECTORSTORE: Query: " . substr($user_query, 0, 200));
5413 error_log("MXCHAT VECTORSTORE: Vector Store IDs: " . implode(', ', $vectorstore_ids));
5414 error_log("MXCHAT VECTORSTORE: Max Results: " . $max_results);
5415 error_log("MXCHAT VECTORSTORE: Request body: " . wp_json_encode($request_body));
5416
5417 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
5418 'headers' => array(
5419 'Authorization' => 'Bearer ' . $api_key,
5420 'Content-Type' => 'application/json'
5421 ),
5422 'body' => wp_json_encode($request_body),
5423 'timeout' => 60
5424 ));
5425
5426 if (is_wp_error($response)) {
5427 error_log("MXCHAT VECTORSTORE ERROR: WP Error: " . $response->get_error_message());
5428 $this->current_valid_urls = [];
5429 return '';
5430 }
5431
5432 $response_code = wp_remote_retrieve_response_code($response);
5433 error_log("MXCHAT VECTORSTORE: Response code: " . $response_code);
5434
5435 $response_body = wp_remote_retrieve_body($response);
5436 error_log("MXCHAT VECTORSTORE: Raw response (first 2000 chars): " . substr($response_body, 0, 2000));
5437
5438 if ($response_code !== 200) {
5439 error_log("MXCHAT VECTORSTORE ERROR: API error response: " . $response_body);
5440 $this->current_valid_urls = [];
5441 return '';
5442 }
5443 $result = json_decode($response_body, true);
5444
5445 if (json_last_error() !== JSON_ERROR_NONE) {
5446 error_log("MXCHAT VECTORSTORE ERROR: JSON decode error: " . json_last_error_msg());
5447 $this->current_valid_urls = [];
5448 return '';
5449 }
5450
5451 // Debug: Log the structure of the result
5452 error_log("MXCHAT VECTORSTORE: Result keys: " . implode(', ', array_keys($result)));
5453 if (isset($result['output'])) {
5454 error_log("MXCHAT VECTORSTORE: Output count: " . count($result['output']));
5455 foreach ($result['output'] as $idx => $out) {
5456 error_log("MXCHAT VECTORSTORE: Output[$idx] type: " . ($out['type'] ?? 'unknown'));
5457 error_log("MXCHAT VECTORSTORE: Output[$idx] keys: " . implode(', ', array_keys($out)));
5458 }
5459 } else {
5460 error_log("MXCHAT VECTORSTORE: No 'output' key in result!");
5461 }
5462
5463 // Extract file search results from the response
5464 $content = '';
5465 $matches_used = 0;
5466 $all_matches = [];
5467
5468 // The Responses API returns output array with tool results
5469 if (isset($result['output']) && is_array($result['output'])) {
5470 foreach ($result['output'] as $output_item) {
5471 // Look for file_search_call results
5472 if (isset($output_item['type']) && $output_item['type'] === 'file_search_call') {
5473 error_log("MXCHAT VECTORSTORE: Found file_search_call output item");
5474 error_log("MXCHAT VECTORSTORE: file_search_call keys: " . implode(', ', array_keys($output_item)));
5475
5476 // Check for search_results in the output item directly
5477 $search_results = $output_item['search_results'] ?? $output_item['results'] ?? [];
5478 error_log("MXCHAT VECTORSTORE: Search results count: " . count($search_results));
5479
5480 if (empty($search_results)) {
5481 error_log("MXCHAT VECTORSTORE: No search results found in file_search_call");
5482 error_log("MXCHAT VECTORSTORE: file_search_call content: " . wp_json_encode($output_item));
5483 }
5484
5485 foreach ($search_results as $index => $search_result) {
5486 $filename = $search_result['filename'] ?? '';
5487 $score = $search_result['score'] ?? 0;
5488 $text_content = '';
5489
5490 // Extract text content from the result
5491 // The text can be directly on the result OR nested under content array
5492 if (isset($search_result['text']) && !empty($search_result['text'])) {
5493 // Direct text field (OpenAI's actual format)
5494 $text_content = $search_result['text'];
5495 error_log("MXCHAT VECTORSTORE: Found text directly on result[$index], length: " . strlen($text_content));
5496 } elseif (isset($search_result['content']) && is_array($search_result['content'])) {
5497 // Nested content array format
5498 foreach ($search_result['content'] as $content_item) {
5499 if (isset($content_item['text'])) {
5500 $text_content .= $content_item['text'] . "\n";
5501 }
5502 }
5503 error_log("MXCHAT VECTORSTORE: Found text in content array for result[$index], length: " . strlen($text_content));
5504 } else {
5505 error_log("MXCHAT VECTORSTORE: No text found for result[$index]. Keys: " . implode(', ', array_keys($search_result)));
5506 }
5507
5508 if (!empty($text_content)) {
5509 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
5510 $content .= trim($text_content) . "\n\n";
5511
5512 if (!empty($filename)) {
5513 $content .= "Source: " . $filename . "\n\n";
5514 }
5515
5516 // Extract URLs from content
5517 preg_match_all(
5518 '#\bhttps?://[^\s<>"\']+#i',
5519 $text_content,
5520 $content_urls
5521 );
5522 if (!empty($content_urls[0])) {
5523 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5524 }
5525
5526 $matches_used++;
5527 }
5528
5529 // Store for similarity analysis
5530 $all_matches[] = [
5531 'document_id' => $filename ?: ('result_' . $index),
5532 'similarity' => $score,
5533 'similarity_percentage' => round($score * 100, 2),
5534 'above_threshold' => true,
5535 'source_display' => $filename,
5536 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
5537 'used_for_context' => true,
5538 'role_restriction' => 'public',
5539 'has_access' => true,
5540 'filtered_out' => false
5541 ];
5542 }
5543 }
5544
5545 // Also check for message content with annotations (citations)
5546 if (isset($output_item['type']) && $output_item['type'] === 'message') {
5547 if (isset($output_item['content']) && is_array($output_item['content'])) {
5548 foreach ($output_item['content'] as $content_block) {
5549 if (isset($content_block['annotations']) && is_array($content_block['annotations'])) {
5550 foreach ($content_block['annotations'] as $annotation) {
5551 if (isset($annotation['filename'])) {
5552 $filename = $annotation['filename'];
5553 $score = $annotation['score'] ?? 0;
5554 $text_content = '';
5555
5556 if (isset($annotation['content']) && is_array($annotation['content'])) {
5557 foreach ($annotation['content'] as $ann_content) {
5558 if (isset($ann_content['text'])) {
5559 $text_content .= $ann_content['text'] . "\n";
5560 }
5561 }
5562 }
5563
5564 if (!empty($text_content) && $matches_used < $max_results) {
5565 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
5566 $content .= trim($text_content) . "\n\n";
5567 $content .= "Source: " . $filename . "\n\n";
5568
5569 preg_match_all(
5570 '#\bhttps?://[^\s<>"\']+#i',
5571 $text_content,
5572 $content_urls
5573 );
5574 if (!empty($content_urls[0])) {
5575 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5576 }
5577
5578 $matches_used++;
5579
5580 $all_matches[] = [
5581 'document_id' => $filename,
5582 'similarity' => $score,
5583 'similarity_percentage' => round($score * 100, 2),
5584 'above_threshold' => true,
5585 'source_display' => $filename,
5586 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
5587 'used_for_context' => true,
5588 'role_restriction' => 'public',
5589 'has_access' => true,
5590 'filtered_out' => false
5591 ];
5592 }
5593 }
5594 }
5595 }
5596 }
5597 }
5598 }
5599 }
5600 }
5601
5602 // Store for testing panel
5603 $this->last_similarity_analysis['top_matches'] = $all_matches;
5604 $this->last_similarity_analysis['total_checked'] = count($all_matches);
5605
5606 // Store unique valid URLs for validation
5607 $this->current_valid_urls = array_unique($valid_urls);
5608
5609 error_log("MXCHAT VECTORSTORE: ========== SEARCH COMPLETE ==========");
5610 error_log("MXCHAT VECTORSTORE: Matches used: " . $matches_used);
5611 error_log("MXCHAT VECTORSTORE: All matches count: " . count($all_matches));
5612 error_log("MXCHAT VECTORSTORE: Content length: " . strlen($content));
5613 if ($matches_used > 0) {
5614 error_log("MXCHAT VECTORSTORE: Content preview: " . substr($content, 0, 500));
5615 }
5616
5617 // Check if citation links are enabled
5618 $citation_links_enabled = ($mxchat_options['citation_links_toggle'] ?? 'on') === 'on';
5619
5620 // Add response guidelines
5621 if ($matches_used === 0) {
5622 error_log("MXCHAT VECTORSTORE: No matches found - returning empty reference message");
5623 $content = "No reference information was found for this query.\n\n";
5624 } else {
5625 // Build response guidelines based on citation links setting
5626 $content .= "\n## Response Guidelines ##\n" .
5627 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5628 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5629 "If you don't have specific information or are uncertain about any details, it's always " .
5630 "better to honestly say you don't know rather than making up or guessing at answers. " .
5631 "When information is incomplete, let them know you are unsure.\n\n";
5632
5633 // Only add hyperlink instructions if citation links are enabled
5634 if ($citation_links_enabled) {
5635 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5636 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
5637 } else {
5638 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5639 "Simply provide helpful answers based on the reference information without citing sources.";
5640 }
5641 }
5642
5643 error_log("MXCHAT DEBUG: Vector Store search complete. Matches used: " . $matches_used);
5644
5645 return trim($content);
5646 }
5647
5648 /**
5649 * Check if the given model is an OpenAI chat model
5650 *
5651 * @param string $model The model ID
5652 * @return bool True if it's an OpenAI model
5653 */
5654 private function is_openai_chat_model($model) {
5655 $openai_prefixes = array('gpt-', 'o1-', 'o3-');
5656 foreach ($openai_prefixes as $prefix) {
5657 if (strpos($model, $prefix) === 0) {
5658 return true;
5659 }
5660 }
5661 return false;
5662 }
5663
5664 /**
5665 * Get bot-specific Vector Store configuration
5666 *
5667 * @param string $bot_id The bot ID
5668 * @return array Configuration array
5669 */
5670 private function get_bot_vectorstore_config($bot_id = 'default') {
5671 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
5672
5673 // Default global settings
5674 $default_config = array(
5675 'use_vectorstore' => ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1',
5676 'vectorstore_ids' => $vectorstore_options['mxchat_vectorstore_ids'] ?? '',
5677 'max_results' => $vectorstore_options['mxchat_vectorstore_max_results'] ?? 5
5678 );
5679
5680 // Allow multi-bot plugin to override with bot-specific settings
5681 $bot_config = apply_filters('mxchat_get_bot_vectorstore_config', $default_config, $bot_id);
5682
5683 // Preserve max_results from global settings if not set in bot config
5684 if (!isset($bot_config['max_results'])) {
5685 $bot_config['max_results'] = $default_config['max_results'];
5686 }
5687
5688 return $bot_config;
5689 }
5690
5691 private function mxchat_find_relevant_products($user_embedding) {
5692 //error_log('MXChat Vector Search: Starting product search...');
5693
5694 // Retrieve the add-on settings from the database
5695 $addon_options = get_option('mxchat_pinecone_addon_options', array());
5696
5697 // Determine whether Pinecone is enabled
5698 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1') ? 1 : 0;
5699
5700 //error_log('Pinecone enabled flag: ' . $use_pinecone);
5701
5702 if ($use_pinecone === 1) {
5703 //error_log('MXChat Vector Search: Using Pinecone database for products');
5704 return $this->find_relevant_products_pinecone($user_embedding);
5705 } else {
5706 //error_log('MXChat Vector Search: Using WordPress database for products');
5707 return $this->find_relevant_products_wordpress($user_embedding);
5708 }
5709 }
5710 private function find_relevant_products_wordpress($user_embedding) {
5711 global $wpdb;
5712 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
5713 $cache_key = 'mxchat_system_prompt_embeddings';
5714 $batch_size = 500;
5715
5716 // Original WordPress database search logic
5717 // [Previous implementation remains the same]
5718 $embeddings = wp_cache_get($cache_key, 'mxchat_system_prompts');
5719 if ($embeddings === false) {
5720 $embeddings = [];
5721 $offset = 0;
5722
5723 do {
5724 $query = $wpdb->prepare(
5725 "SELECT id, embedding_vector
5726 FROM {$system_prompt_table}
5727 LIMIT %d OFFSET %d",
5728 $batch_size,
5729 $offset
5730 );
5731
5732 $batch = $wpdb->get_results($query);
5733 if (empty($batch)) {
5734 break;
5735 }
5736
5737 $embeddings = array_merge($embeddings, $batch);
5738 $offset += $batch_size;
5739
5740 unset($batch);
5741
5742 } while (true);
5743
5744 if (empty($embeddings)) {
5745 return '';
5746 }
5747 wp_cache_set($cache_key, $embeddings, 'mxchat_system_prompts', 3600);
5748 }
5749
5750 $relevant_results = [];
5751 foreach ($embeddings as $embedding) {
5752 $database_embedding = $embedding->embedding_vector
5753 ? unserialize($embedding->embedding_vector, ['allowed_classes' => false])
5754 : null;
5755 if (is_array($database_embedding) && is_array($user_embedding)) {
5756 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
5757 $relevant_results[] = [
5758 'id' => $embedding->id,
5759 'similarity' => $similarity
5760 ];
5761 }
5762 unset($database_embedding);
5763 }
5764
5765 // Use fixed threshold for products
5766 $similarity_threshold = 0.85;
5767
5768 $relevant_results = array_filter($relevant_results, function ($result) use ($similarity_threshold) {
5769 return $result['similarity'] >= $similarity_threshold;
5770 });
5771 usort($relevant_results, function ($a, $b) {
5772 return $b['similarity'] <=> $a['similarity'];
5773 });
5774
5775 $top_results = array_slice($relevant_results, 0, 3);
5776 $content = '';
5777
5778 foreach ($top_results as $result) {
5779 $chunk_content = $this->fetch_content_with_product_links($result['id']);
5780 $content .= $chunk_content . "\n\n";
5781 }
5782
5783 return trim($content);
5784 }
5785
5786
5787 private function find_relevant_products_pinecone($user_embedding) {
5788 //error_log('Starting Pinecone product search...');
5789
5790 $options = get_option('mxchat_pinecone_addon_options', array());
5791 $api_key = $options['mxchat_pinecone_api_key'] ?? '';
5792 $host = $options['mxchat_pinecone_host'] ?? '';
5793
5794 if (empty($host) || empty($api_key)) {
5795 //error_log('Pinecone credentials not properly configured for product search');
5796 return '';
5797 }
5798
5799 $similarity_threshold = 0.85;
5800 $api_endpoint = "https://{$host}/query";
5801
5802 $request_body = array(
5803 'vector' => $user_embedding,
5804 'topK' => 5,
5805 'includeMetadata' => true,
5806 'includeValues' => true,
5807 'filter' => array(
5808 'type' => 'product'
5809 )
5810 );
5811
5812 //error_log('Sending request to Pinecone with body: ' . wp_json_encode($request_body));
5813
5814 $response = wp_remote_post($api_endpoint, array(
5815 'headers' => array(
5816 'Api-Key' => $api_key,
5817 'accept' => 'application/json',
5818 'content-type' => 'application/json'
5819 ),
5820 'body' => wp_json_encode($request_body),
5821 'timeout' => 30
5822 ));
5823
5824 if (is_wp_error($response)) {
5825 //error_log('Pinecone product query error: ' . $response->get_error_message());
5826 return '';
5827 }
5828
5829 $response_code = wp_remote_retrieve_response_code($response);
5830 //error_log('Pinecone response code: ' . $response_code);
5831
5832 if ($response_code !== 200) {
5833 //error_log('Pinecone API error during product search: ' . wp_remote_retrieve_body($response));
5834 return '';
5835 }
5836
5837 $results = json_decode(wp_remote_retrieve_body($response), true);
5838 //error_log('Pinecone raw response: ' . wp_remote_retrieve_body($response));
5839
5840 if (empty($results['matches'])) {
5841 //error_log('No matches found in Pinecone response');
5842 return '';
5843 }
5844
5845 $content = '';
5846 foreach ($results['matches'] as $match) {
5847 if ($match['score'] < $similarity_threshold) {
5848 //error_log("Match below threshold: " . $match['score']);
5849 continue;
5850 }
5851
5852 if (!empty($match['metadata']['text'])) {
5853 $content .= $match['metadata']['text'];
5854 if (!empty($match['metadata']['source_url'])) {
5855 $content .= "\n\nFor more details, check out this product: " . esc_url($match['metadata']['source_url']);
5856 }
5857 $content .= "\n\n";
5858 }
5859 }
5860
5861 return trim($content);
5862 }
5863
5864
5865 private function fetch_content_with_product_links($most_relevant_id) {
5866 global $wpdb;
5867 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
5868
5869 // Fetch the article content and associated product URL
5870 $query = $wpdb->prepare("SELECT article_content, source_url FROM {$system_prompt_table} WHERE id = %d", $most_relevant_id);
5871 $result = $wpdb->get_row($query);
5872
5873 if ($result) {
5874 // Append the product link to the content if available
5875 $content = $result->article_content;
5876 if (!empty($result->source_url)) {
5877 $content .= "\n\nFor more details, check out this product: " . esc_url($result->source_url);
5878 }
5879 return $content;
5880 }
5881
5882 return null;
5883 }
5884
5885 /**
5886 * Get system instructions for a specific bot or default
5887 * Checks for multi-bot add-on and uses bot-specific instructions if available
5888 * Automatically strips URLs if citation links are disabled
5889 *
5890 * @param string $bot_id The bot ID to get instructions for
5891 */
5892 private function get_system_instructions($bot_id = 'default') {
5893 $instructions = '';
5894
5895 // Check if multi-bot add-on is active
5896 if (class_exists('MxChat_Multi_Bot_Core_Manager') && $bot_id !== 'default') {
5897 // Get bot-specific options from multi-bot add-on
5898 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
5899
5900 // If bot has custom system instructions, use those
5901 if (!empty($bot_options['system_prompt_instructions'])) {
5902 $instructions = $bot_options['system_prompt_instructions'];
5903 }
5904 }
5905
5906 // Fall back to default system instructions
5907 if (empty($instructions)) {
5908 $instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
5909 }
5910
5911 // Check if citation links are disabled - if so, strip URLs from instructions
5912 $fresh_options = get_option('mxchat_options', []);
5913 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
5914
5915 if (!$citation_links_enabled && !empty($instructions)) {
5916 $instructions = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $instructions);
5917 $instructions = preg_replace('/\s+/', ' ', trim($instructions)); // Clean up extra spaces
5918 }
5919
5920 return $instructions;
5921 }
5922 /**
5923 * Get the current bot ID from session or request context
5924 */
5925 private function get_current_bot_id($session_id = '') {
5926 // First, check if bot_id is passed in the current request
5927 if (isset($_POST['bot_id']) && !empty($_POST['bot_id'])) {
5928 return sanitize_key($_POST['bot_id']);
5929 }
5930
5931 // If not in POST, try to get it from session data
5932 if (!empty($session_id)) {
5933 $bot_id = get_option("mxchat_session_bot_{$session_id}", '');
5934 if (!empty($bot_id)) {
5935 return $bot_id;
5936 }
5937 }
5938
5939 // Fall back to default
5940 return 'default';
5941 }
5942 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-4o') {
5943 try {
5944 if (!$relevant_content) {
5945 $error_response = [
5946 'error' => esc_html__("I couldn't find relevant information on that topic.", 'mxchat'),
5947 'error_code' => 'no_relevant_content'
5948 ];
5949
5950 if ($testing_data !== null) {
5951 $error_response['testing_data'] = $testing_data;
5952 }
5953
5954 return $error_response;
5955 }
5956
5957 if (!is_array($conversation_history)) {
5958 $conversation_history = array();
5959 }
5960
5961 // Check if this is an OpenRouter model
5962 if ($selected_model === 'openrouter') {
5963 // Get the actual OpenRouter model from options
5964 $openrouter_selected_model = $this->options['openrouter_selected_model'] ?? '';
5965
5966 if (empty($openrouter_selected_model)) {
5967 $error_response = [
5968 'error' => esc_html__('No OpenRouter model selected. Please select a model in settings.', 'mxchat'),
5969 'error_code' => 'no_openrouter_model_selected'
5970 ];
5971 if ($testing_data !== null) {
5972 $error_response['testing_data'] = $testing_data;
5973 }
5974 return $error_response;
5975 }
5976
5977 if (empty($openrouter_api_key)) {
5978 $error_response = [
5979 'error' => esc_html__('OpenRouter API key is not configured', 'mxchat'),
5980 'error_code' => 'missing_openrouter_api_key'
5981 ];
5982 if ($testing_data !== null) {
5983 $error_response['testing_data'] = $testing_data;
5984 }
5985 return $error_response;
5986 }
5987
5988 if ($streaming) {
5989 return $this->mxchat_generate_response_openrouter_stream(
5990 $openrouter_selected_model,
5991 $openrouter_api_key,
5992 $conversation_history,
5993 $relevant_content,
5994 $session_id,
5995 $testing_data
5996 );
5997 } else {
5998 $response = $this->mxchat_generate_response_openrouter(
5999 $openrouter_selected_model,
6000 $openrouter_api_key,
6001 $conversation_history,
6002 $relevant_content
6003 );
6004 }
6005
6006 if (is_array($response) && isset($response['error'])) {
6007 if ($testing_data !== null) {
6008 $response['testing_data'] = $testing_data;
6009 }
6010 return $response;
6011 }
6012
6013 return $response;
6014 }
6015
6016 // Extract model prefix to determine the provider
6017 $model_parts = explode('-', $selected_model);
6018 $provider = strtolower($model_parts[0]);
6019
6020 // Handle model selection based on provider prefix
6021 switch ($provider) {
6022 case 'gemini':
6023 if (empty($gemini_api_key)) {
6024 $error_response = [
6025 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
6026 'error_code' => 'missing_gemini_api_key'
6027 ];
6028 if ($testing_data !== null) {
6029 $error_response['testing_data'] = $testing_data;
6030 }
6031 return $error_response;
6032 }
6033 $response = $this->mxchat_generate_response_gemini(
6034 $selected_model,
6035 $gemini_api_key,
6036 $conversation_history,
6037 $relevant_content
6038 );
6039 break;
6040
6041 case 'claude':
6042 if (empty($claude_api_key)) {
6043 $error_response = [
6044 'error' => esc_html__('Claude API key is not configured', 'mxchat'),
6045 'error_code' => 'missing_claude_api_key'
6046 ];
6047 if ($testing_data !== null) {
6048 $error_response['testing_data'] = $testing_data;
6049 }
6050 return $error_response;
6051 }
6052 if ($streaming) {
6053 return $this->mxchat_generate_response_claude_stream(
6054 $selected_model,
6055 $claude_api_key,
6056 $conversation_history,
6057 $relevant_content,
6058 $session_id,
6059 $testing_data
6060 );
6061 } else {
6062 $response = $this->mxchat_generate_response_claude(
6063 $selected_model,
6064 $claude_api_key,
6065 $conversation_history,
6066 $relevant_content
6067 );
6068 }
6069 break;
6070
6071 case 'grok':
6072 if (empty($xai_api_key)) {
6073 $error_response = [
6074 'error' => esc_html__('X.AI API key is not configured', 'mxchat'),
6075 'error_code' => 'missing_xai_api_key'
6076 ];
6077 if ($testing_data !== null) {
6078 $error_response['testing_data'] = $testing_data;
6079 }
6080 return $error_response;
6081 }
6082 if ($streaming) {
6083 return $this->mxchat_generate_response_xai_stream(
6084 $selected_model,
6085 $xai_api_key,
6086 $conversation_history,
6087 $relevant_content,
6088 $session_id,
6089 $testing_data
6090 );
6091 } else {
6092 $response = $this->mxchat_generate_response_xai(
6093 $selected_model,
6094 $xai_api_key,
6095 $conversation_history,
6096 $relevant_content
6097 );
6098 }
6099 break;
6100
6101 case 'deepseek':
6102 if (empty($deepseek_api_key)) {
6103 $error_response = [
6104 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
6105 'error_code' => 'missing_deepseek_api_key'
6106 ];
6107 if ($testing_data !== null) {
6108 $error_response['testing_data'] = $testing_data;
6109 }
6110 return $error_response;
6111 }
6112 if ($streaming) {
6113 return $this->mxchat_generate_response_deepseek_stream(
6114 $selected_model,
6115 $deepseek_api_key,
6116 $conversation_history,
6117 $relevant_content,
6118 $session_id,
6119 $testing_data
6120 );
6121 } else {
6122 $response = $this->mxchat_generate_response_deepseek(
6123 $selected_model,
6124 $deepseek_api_key,
6125 $conversation_history,
6126 $relevant_content
6127 );
6128 }
6129 break;
6130
6131 case 'gpt':
6132 case 'o1':
6133 if (empty($api_key)) {
6134 $error_response = [
6135 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
6136 'error_code' => 'missing_openai_api_key'
6137 ];
6138 if ($testing_data !== null) {
6139 $error_response['testing_data'] = $testing_data;
6140 }
6141 return $error_response;
6142 }
6143
6144 // Check if web search is enabled for this OpenAI model
6145 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
6146 // Models that don't support web search
6147 $unsupported_web_search_models = array('gpt-4.1-nano');
6148 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
6149
6150 if ($web_search_enabled && $model_supports_web_search) {
6151 // Use Responses API with web search
6152 return $this->mxchat_generate_response_openai_web_search(
6153 $selected_model,
6154 $api_key,
6155 $conversation_history,
6156 $relevant_content,
6157 $session_id,
6158 $testing_data,
6159 $streaming
6160 );
6161 } elseif ($streaming) {
6162 return $this->mxchat_generate_response_openai_stream(
6163 $selected_model,
6164 $api_key,
6165 $conversation_history,
6166 $relevant_content,
6167 $session_id,
6168 $testing_data
6169 );
6170 } else {
6171 $response = $this->mxchat_generate_response_openai(
6172 $selected_model,
6173 $api_key,
6174 $conversation_history,
6175 $relevant_content
6176 );
6177 }
6178 break;
6179
6180 default:
6181 if (empty($api_key)) {
6182 $error_response = [
6183 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
6184 'error_code' => 'missing_openai_api_key'
6185 ];
6186 if ($testing_data !== null) {
6187 $error_response['testing_data'] = $testing_data;
6188 }
6189 return $error_response;
6190 }
6191
6192 // Check if web search is enabled (default case also handles OpenAI models)
6193 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
6194 $unsupported_web_search_models = array('gpt-4.1-nano');
6195 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
6196
6197 if ($web_search_enabled && $model_supports_web_search) {
6198 return $this->mxchat_generate_response_openai_web_search(
6199 $selected_model,
6200 $api_key,
6201 $conversation_history,
6202 $relevant_content,
6203 $session_id,
6204 $testing_data,
6205 $streaming
6206 );
6207 } elseif ($streaming) {
6208 return $this->mxchat_generate_response_openai_stream(
6209 $selected_model,
6210 $api_key,
6211 $conversation_history,
6212 $relevant_content,
6213 $session_id,
6214 $testing_data
6215 );
6216 } else {
6217 $response = $this->mxchat_generate_response_openai(
6218 $selected_model,
6219 $api_key,
6220 $conversation_history,
6221 $relevant_content
6222 );
6223 }
6224 break;
6225 }
6226
6227 if (is_array($response) && isset($response['error'])) {
6228 if ($testing_data !== null) {
6229 $response['testing_data'] = $testing_data;
6230 }
6231 return $response;
6232 }
6233
6234 return $response;
6235
6236 } catch (Exception $e) {
6237 $error_response = [
6238 'error' => sprintf(esc_html__('An error occurred: %s', 'mxchat'), esc_html($e->getMessage())),
6239 'error_code' => 'system_exception',
6240 'exception_details' => $e->getMessage()
6241 ];
6242
6243 if ($testing_data !== null) {
6244 $error_response['testing_data'] = $testing_data;
6245 }
6246
6247 return $error_response;
6248 }
6249 }
6250 private function mxchat_generate_response_openrouter_stream($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
6251 try {
6252 $bot_id = $this->get_current_bot_id($session_id);
6253 $system_prompt_instructions = $this->get_system_instructions($bot_id);
6254
6255 if (!is_array($conversation_history)) {
6256 $conversation_history = array();
6257 }
6258
6259 $formatted_conversation = array();
6260
6261 $formatted_conversation[] = array(
6262 'role' => 'system',
6263 'content' => $system_prompt_instructions . " " . $relevant_content
6264 );
6265
6266 foreach ($conversation_history as $message) {
6267 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6268 $role = $message['role'];
6269 if ($role === 'bot' || $role === 'agent') {
6270 $role = 'assistant';
6271 }
6272 if (!in_array($role, ['system', 'assistant', 'user'])) {
6273 $role = 'user';
6274 }
6275 $formatted_conversation[] = array(
6276 'role' => $role,
6277 'content' => $message['content']
6278 );
6279 }
6280 }
6281
6282 if (headers_sent() || !function_exists('curl_init')) {
6283 $regular_response = $this->mxchat_generate_response_openrouter(
6284 $selected_model,
6285 $openrouter_api_key,
6286 $conversation_history,
6287 $relevant_content
6288 );
6289
6290 // Save bot response to transcript
6291 if (!empty($regular_response) && !empty($session_id)) {
6292 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
6293 }
6294
6295 $response_data = [
6296 'text' => $regular_response,
6297 'html' => '',
6298 'session_id' => $session_id
6299 ];
6300
6301 if ($testing_data !== null) {
6302 $response_data['testing_data'] = $testing_data;
6303 }
6304
6305 header('Content-Type: application/json');
6306 echo json_encode($response_data);
6307 return true;
6308 }
6309
6310 $body = json_encode([
6311 'model' => $selected_model,
6312 'messages' => $formatted_conversation,
6313 'temperature' => 1,
6314 'stream' => true
6315 ]);
6316
6317 // Setup streaming headers now that we know we're actually streaming
6318 $this->setup_streaming_headers();
6319
6320 $ch = curl_init();
6321 curl_setopt($ch, CURLOPT_URL, 'https://openrouter.ai/api/v1/chat/completions');
6322 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
6323 curl_setopt($ch, CURLOPT_POST, true);
6324 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
6325 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
6326 'Content-Type: application/json',
6327 'Authorization: Bearer ' . $openrouter_api_key,
6328 'HTTP-Referer: ' . home_url(),
6329 'X-Title: ' . get_bloginfo('name')
6330 ));
6331 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
6332 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
6333
6334 $full_response = '';
6335 $stream_started = false;
6336 $buffer = '';
6337
6338 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
6339 if (!$stream_started && $testing_data !== null) {
6340 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
6341 flush();
6342 $stream_started = true;
6343 }
6344
6345 $buffer .= $data;
6346 $lines = explode("\n", $buffer);
6347 $buffer = array_pop($lines);
6348
6349 foreach ($lines as $line) {
6350 if (trim($line) === '') {
6351 continue;
6352 }
6353
6354 if (strpos($line, 'data: ') !== 0) {
6355 continue;
6356 }
6357
6358 $json_str = substr($line, 6);
6359
6360 if (trim($json_str) === '[DONE]') {
6361 echo "data: [DONE]\n\n";
6362 flush();
6363 continue;
6364 }
6365
6366 $json = json_decode(trim($json_str), true);
6367 if ($json && isset($json['choices'][0]['delta']['content'])) {
6368 $content = $json['choices'][0]['delta']['content'];
6369 $full_response .= $content;
6370
6371 echo "data: " . json_encode(['content' => $content]) . "\n\n";
6372 flush();
6373 }
6374 }
6375
6376 return strlen($data);
6377 });
6378
6379 $response = curl_exec($ch);
6380 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
6381
6382 if (curl_errno($ch) || $http_code !== 200) {
6383 curl_close($ch);
6384
6385 $regular_response = $this->mxchat_generate_response_openrouter(
6386 $selected_model,
6387 $openrouter_api_key,
6388 $conversation_history,
6389 $relevant_content
6390 );
6391
6392 $response_data = [
6393 'text' => $regular_response,
6394 'html' => '',
6395 'session_id' => $session_id
6396 ];
6397
6398 if ($testing_data !== null) {
6399 $response_data['testing_data'] = $testing_data;
6400 }
6401
6402 header('Content-Type: application/json');
6403 echo json_encode($response_data);
6404 return true;
6405 }
6406
6407 curl_close($ch);
6408
6409 if (!empty($full_response) && !empty($session_id)) {
6410 // Prepare RAG context for streaming response
6411 $rag_context_for_storage = null;
6412 if ($this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches'])) {
6413 $rag_context_for_storage = [
6414 'top_matches' => $this->last_similarity_analysis['top_matches'],
6415 'approved_urls' => $this->current_valid_urls ?? [],
6416 'similarity_threshold' => $this->last_similarity_analysis['threshold_used'] ?? 0.35,
6417 'knowledge_base_type' => $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database',
6418 'total_documents_checked' => $this->last_similarity_analysis['total_checked'] ?? 0
6419 ];
6420 }
6421 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
6422 }
6423
6424 return true;
6425
6426 } catch (Exception $e) {
6427 $regular_response = $this->mxchat_generate_response_openrouter(
6428 $selected_model,
6429 $openrouter_api_key,
6430 $conversation_history,
6431 $relevant_content
6432 );
6433
6434 $response_data = [
6435 'text' => $regular_response,
6436 'html' => '',
6437 'session_id' => $session_id
6438 ];
6439
6440 if ($testing_data !== null) {
6441 $response_data['testing_data'] = $testing_data;
6442 }
6443
6444 header('Content-Type: application/json');
6445 echo json_encode($response_data);
6446 return true;
6447 }
6448 }
6449 private function mxchat_generate_response_openai_stream($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
6450 try {
6451 $bot_id = $this->get_current_bot_id($session_id);
6452
6453 // Get system prompt instructions using centralized function
6454 $system_prompt_instructions = $this->get_system_instructions($bot_id);
6455
6456 // Ensure conversation_history is an array
6457 if (!is_array($conversation_history)) {
6458 $conversation_history = array();
6459 }
6460
6461 // Format conversation history for OpenAI
6462 $formatted_conversation = array();
6463
6464 $formatted_conversation[] = array(
6465 'role' => 'system',
6466 'content' => $system_prompt_instructions . " " . $relevant_content
6467 );
6468
6469 foreach ($conversation_history as $message) {
6470 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6471 $role = $message['role'];
6472 if ($role === 'bot' || $role === 'agent') {
6473 $role = 'assistant';
6474 }
6475 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
6476 $role = 'user';
6477 }
6478 $formatted_conversation[] = array(
6479 'role' => $role,
6480 'content' => $message['content']
6481 );
6482 }
6483 }
6484
6485 // Check if we can actually stream
6486 if (headers_sent() || !function_exists('curl_init')) {
6487 // Fallback to regular response with testing data
6488 $regular_response = $this->mxchat_generate_response_openai(
6489 $selected_model,
6490 $api_key,
6491 $conversation_history,
6492 $relevant_content
6493 );
6494
6495 // Save bot response to transcript
6496 if (!empty($regular_response) && !empty($session_id)) {
6497 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
6498 }
6499
6500 $response_data = [
6501 'text' => $regular_response,
6502 'html' => '',
6503 'session_id' => $session_id
6504 ];
6505
6506 if ($testing_data !== null) {
6507 $response_data['testing_data'] = $testing_data;
6508 }
6509
6510 header('Content-Type: application/json');
6511 echo json_encode($response_data);
6512 return true;
6513 }
6514
6515 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
6516 $is_gpt5_model = (
6517 strpos($selected_model, 'gpt-5') === 0 ||
6518 $selected_model === 'gpt-5.2' ||
6519 $selected_model === 'gpt-5.1-2025-11-13' ||
6520 $selected_model === 'gpt-5' ||
6521 $selected_model === 'gpt-5-mini' ||
6522 $selected_model === 'gpt-5-nano'
6523 );
6524
6525 // Build request body with optimal settings for fast streaming
6526 $request_body = [
6527 'model' => $selected_model,
6528 'messages' => $formatted_conversation,
6529 'temperature' => 1,
6530 'stream' => true
6531 ];
6532
6533 // Add reasoning_effort only for GPT-5 models (not GPT-5.2 which doesn't support it)
6534 if ($is_gpt5_model && $selected_model !== 'gpt-5.2') {
6535 // GPT-5.1 uses 'low' instead of 'minimal'
6536 if ($selected_model === 'gpt-5.1-2025-11-13') {
6537 $request_body['reasoning_effort'] = 'low';
6538 } else {
6539 $request_body['reasoning_effort'] = 'minimal'; // For other GPT-5 models
6540 }
6541 }
6542
6543 $body = json_encode($request_body);
6544
6545 // Setup streaming headers now that we know we're actually streaming
6546 $this->setup_streaming_headers();
6547
6548 // Use cURL for streaming support
6549 $ch = curl_init();
6550 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
6551 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
6552 curl_setopt($ch, CURLOPT_POST, true);
6553 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
6554 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
6555 'Content-Type: application/json',
6556 'Authorization: Bearer ' . $api_key
6557 ));
6558 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
6559 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
6560
6561 $full_response = ''; // Accumulate full response for saving
6562 $stream_started = false;
6563 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
6564
6565 // Buffer control for real-time streaming
6566 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
6567 // Send testing data as the first event if available
6568 if (!$stream_started && $testing_data !== null) {
6569 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
6570 flush();
6571 $stream_started = true;
6572 }
6573
6574 // CRITICAL FIX: Append new data to buffer
6575 $buffer .= $data;
6576
6577 // Process complete lines only
6578 $lines = explode("\n", $buffer);
6579
6580 // CRITICAL FIX: Keep the last incomplete line in the buffer
6581 // The last element might be incomplete, so keep it in buffer
6582 $buffer = array_pop($lines);
6583
6584 foreach ($lines as $line) {
6585 // Skip empty lines
6586 if (trim($line) === '') {
6587 continue;
6588 }
6589
6590 // Only process lines that start with "data: "
6591 if (strpos($line, 'data: ') !== 0) {
6592 continue;
6593 }
6594
6595 $json_str = substr($line, 6); // Remove 'data: ' prefix
6596
6597 if (trim($json_str) === '[DONE]') {
6598 echo "data: [DONE]\n\n";
6599 flush();
6600 continue;
6601 }
6602
6603 // Try to decode JSON
6604 $json = json_decode(trim($json_str), true);
6605 if ($json && isset($json['choices'][0]['delta']['content'])) {
6606 $content = $json['choices'][0]['delta']['content'];
6607 $full_response .= $content; // Accumulate the full response
6608
6609 // Send as SSE format
6610 echo "data: " . json_encode(['content' => $content]) . "\n\n";
6611 flush();
6612 }
6613 }
6614
6615 return strlen($data);
6616 });
6617
6618 $response = curl_exec($ch);
6619 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
6620
6621 if (curl_errno($ch) || $http_code !== 200) {
6622 $curl_error = curl_error($ch);
6623 curl_close($ch);
6624
6625 // Fallback to regular response
6626 $regular_response = $this->mxchat_generate_response_openai(
6627 $selected_model,
6628 $api_key,
6629 $conversation_history,
6630 $relevant_content
6631 );
6632
6633 // FIXED: Check if regular response returned an error
6634 if (is_array($regular_response) && isset($regular_response['error'])) {
6635 // Send error in SSE format since we're in streaming mode
6636 echo "data: " . json_encode([
6637 'error' => true,
6638 'error_message' => $regular_response['error'],
6639 'error_code' => $regular_response['error_code'] ?? 'api_error',
6640 'text' => $regular_response['error'],
6641 'message' => $regular_response['error']
6642 ]) . "\n\n";
6643 echo "data: [DONE]\n\n";
6644 flush();
6645 return true;
6646 }
6647
6648 $response_data = [
6649 'text' => $regular_response,
6650 'html' => '',
6651 'session_id' => $session_id
6652 ];
6653
6654 if ($testing_data !== null) {
6655 $response_data['testing_data'] = $testing_data;
6656 }
6657
6658 header('Content-Type: application/json');
6659 echo json_encode($response_data);
6660 return true;
6661 }
6662
6663 curl_close($ch);
6664
6665 // Save the complete response to maintain chat persistence
6666 if (!empty($full_response) && !empty($session_id)) {
6667 // Prepare RAG context for streaming response
6668 $rag_context_for_storage = null;
6669 if ($this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches'])) {
6670 $rag_context_for_storage = [
6671 'top_matches' => $this->last_similarity_analysis['top_matches'],
6672 'approved_urls' => $this->current_valid_urls ?? [],
6673 'similarity_threshold' => $this->last_similarity_analysis['threshold_used'] ?? 0.35,
6674 'knowledge_base_type' => $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database',
6675 'total_documents_checked' => $this->last_similarity_analysis['total_checked'] ?? 0
6676 ];
6677 }
6678 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
6679 }
6680
6681 return true; // Indicate streaming completed successfully
6682
6683 } catch (Exception $e) {
6684 // Fallback to regular response
6685 $regular_response = $this->mxchat_generate_response_openai(
6686 $selected_model,
6687 $api_key,
6688 $conversation_history,
6689 $relevant_content
6690 );
6691
6692 // FIXED: Check if regular response returned an error
6693 if (is_array($regular_response) && isset($regular_response['error'])) {
6694 // Send error in SSE format since we're in streaming mode
6695 echo "data: " . json_encode([
6696 'error' => true,
6697 'error_message' => $regular_response['error'],
6698 'error_code' => $regular_response['error_code'] ?? 'api_error',
6699 'text' => $regular_response['error'],
6700 'message' => $regular_response['error']
6701 ]) . "\n\n";
6702 echo "data: [DONE]\n\n";
6703 flush();
6704 return true;
6705 }
6706
6707 $response_data = [
6708 'text' => $regular_response,
6709 'html' => '',
6710 'session_id' => $session_id
6711 ];
6712
6713 if ($testing_data !== null) {
6714 $response_data['testing_data'] = $testing_data;
6715 }
6716
6717 header('Content-Type: application/json');
6718 echo json_encode($response_data);
6719 return true;
6720 }
6721 }
6722
6723 /**
6724 * Generate response using OpenAI Responses API with web search tool
6725 * This uses the newer Responses API which supports web search functionality
6726 */
6727 private function mxchat_generate_response_openai_web_search($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null, $streaming = false) {
6728 try {
6729 $bot_id = $this->get_current_bot_id($session_id);
6730 $system_prompt_instructions = $this->get_system_instructions($bot_id);
6731
6732 if (!is_array($conversation_history)) {
6733 $conversation_history = array();
6734 }
6735
6736 // Build the input for Responses API
6737 // The Responses API uses a different format - we need to construct the input properly
6738 $input_parts = [];
6739
6740 // Add system instructions as context
6741 $system_context = $system_prompt_instructions . "\n\n" . $relevant_content;
6742
6743 // Build conversation as input items for Responses API
6744 foreach ($conversation_history as $message) {
6745 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6746 $role = $message['role'];
6747 if ($role === 'bot' || $role === 'agent') {
6748 $role = 'assistant';
6749 }
6750 if (!in_array($role, ['assistant', 'user'])) {
6751 $role = 'user';
6752 }
6753 $input_parts[] = [
6754 'type' => 'message',
6755 'role' => $role,
6756 'content' => $message['content']
6757 ];
6758 }
6759 }
6760
6761 // Build request body for Responses API with web search
6762 $request_body = [
6763 'model' => $selected_model,
6764 'input' => $input_parts,
6765 'instructions' => $system_context,
6766 'tools' => [
6767 ['type' => 'web_search']
6768 ],
6769 'stream' => $streaming
6770 ];
6771
6772 // Add reasoning effort for supported models (not for gpt-5 with minimal which doesn't support web search)
6773 // Per OpenAI docs: web search is not supported with gpt-5 minimal reasoning
6774 $is_gpt5_model = strpos($selected_model, 'gpt-5') === 0;
6775 if ($is_gpt5_model && $selected_model !== 'gpt-5.2') {
6776 // Use 'low' for GPT-5.1, skip for others to avoid 'minimal' which doesn't support web search
6777 if ($selected_model === 'gpt-5.1-2025-11-13') {
6778 $request_body['reasoning'] = ['effort' => 'low'];
6779 }
6780 // For other GPT-5 models, don't set reasoning to allow web search
6781 }
6782
6783 error_log("MXCHAT WEB SEARCH: Request body: " . json_encode($request_body));
6784
6785 if ($streaming) {
6786 return $this->mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data);
6787 } else {
6788 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
6789 }
6790
6791 } catch (Exception $e) {
6792 error_log("MXCHAT WEB SEARCH ERROR: " . $e->getMessage());
6793 return [
6794 'error' => sprintf(esc_html__('Web search error: %s', 'mxchat'), esc_html($e->getMessage())),
6795 'error_code' => 'web_search_exception'
6796 ];
6797 }
6798 }
6799
6800 /**
6801 * Handle non-streaming web search response
6802 */
6803 private function mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data) {
6804 $request_body['stream'] = false;
6805
6806 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
6807 'headers' => array(
6808 'Authorization' => 'Bearer ' . $api_key,
6809 'Content-Type' => 'application/json'
6810 ),
6811 'body' => json_encode($request_body),
6812 'timeout' => 90 // Web search can take longer
6813 ));
6814
6815 if (is_wp_error($response)) {
6816 error_log("MXCHAT WEB SEARCH ERROR: WP Error: " . $response->get_error_message());
6817 return [
6818 'error' => esc_html__('Failed to connect to OpenAI web search API', 'mxchat'),
6819 'error_code' => 'web_search_connection_error'
6820 ];
6821 }
6822
6823 $response_code = wp_remote_retrieve_response_code($response);
6824 $response_body = wp_remote_retrieve_body($response);
6825
6826 error_log("MXCHAT WEB SEARCH: Response code: " . $response_code);
6827 error_log("MXCHAT WEB SEARCH: Response body (first 2000): " . substr($response_body, 0, 2000));
6828
6829 if ($response_code !== 200) {
6830 $error_data = json_decode($response_body, true);
6831 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
6832 return [
6833 'error' => sprintf(esc_html__('OpenAI API error: %s', 'mxchat'), esc_html($error_message)),
6834 'error_code' => 'web_search_api_error'
6835 ];
6836 }
6837
6838 $result = json_decode($response_body, true);
6839
6840 if (json_last_error() !== JSON_ERROR_NONE) {
6841 return [
6842 'error' => esc_html__('Invalid response from OpenAI', 'mxchat'),
6843 'error_code' => 'web_search_json_error'
6844 ];
6845 }
6846
6847 // Extract the response text and citations from Responses API format
6848 $output_text = '';
6849 $citations = [];
6850
6851 if (isset($result['output'])) {
6852 foreach ($result['output'] as $output_item) {
6853 if ($output_item['type'] === 'message' && isset($output_item['content'])) {
6854 foreach ($output_item['content'] as $content_item) {
6855 if ($content_item['type'] === 'output_text') {
6856 $output_text .= $content_item['text'];
6857
6858 // Extract citations/annotations
6859 if (isset($content_item['annotations'])) {
6860 foreach ($content_item['annotations'] as $annotation) {
6861 if ($annotation['type'] === 'url_citation') {
6862 $citations[] = [
6863 'url' => $annotation['url'],
6864 'title' => $annotation['title'] ?? ''
6865 ];
6866 }
6867 }
6868 }
6869 }
6870 }
6871 }
6872 }
6873 }
6874
6875 // If we have citations, append them to the response
6876 if (!empty($citations)) {
6877 $output_text .= "\n\n**Sources:**\n";
6878 $seen_urls = [];
6879 foreach ($citations as $citation) {
6880 if (!in_array($citation['url'], $seen_urls)) {
6881 $seen_urls[] = $citation['url'];
6882 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
6883 $output_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
6884 }
6885 }
6886 }
6887
6888 // Save to transcript
6889 if (!empty($output_text) && !empty($session_id)) {
6890 $this->mxchat_save_chat_message($session_id, 'bot', $output_text);
6891 }
6892
6893 return $output_text;
6894 }
6895
6896 /**
6897 * Handle streaming web search response using Responses API
6898 */
6899 private function mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data) {
6900 $request_body['stream'] = true;
6901
6902 // Check if we can stream
6903 if (headers_sent() || !function_exists('curl_init')) {
6904 // Fallback to non-streaming
6905 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
6906 }
6907
6908 // Setup streaming headers
6909 $this->setup_streaming_headers();
6910
6911 $ch = curl_init();
6912 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/responses');
6913 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
6914 curl_setopt($ch, CURLOPT_POST, true);
6915 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request_body));
6916 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
6917 'Content-Type: application/json',
6918 'Authorization: Bearer ' . $api_key
6919 ));
6920 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
6921 curl_setopt($ch, CURLOPT_TIMEOUT, 120); // Web search can take longer
6922
6923 $full_response = '';
6924 $stream_started = false;
6925 $buffer = '';
6926 $citations = [];
6927
6928 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$citations, $testing_data) {
6929 // Send testing data as first event if available
6930 if (!$stream_started && $testing_data !== null) {
6931 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
6932 flush();
6933 $stream_started = true;
6934 }
6935
6936 $buffer .= $data;
6937 $lines = explode("\n", $buffer);
6938 $buffer = array_pop($lines);
6939
6940 foreach ($lines as $line) {
6941 if (trim($line) === '') continue;
6942 if (strpos($line, 'data: ') !== 0) continue;
6943
6944 $json_str = substr($line, 6);
6945
6946 if (trim($json_str) === '[DONE]') {
6947 // Append citations if we have any
6948 if (!empty($citations)) {
6949 $citation_text = "\n\n**Sources:**\n";
6950 $seen_urls = [];
6951 foreach ($citations as $citation) {
6952 if (!in_array($citation['url'], $seen_urls)) {
6953 $seen_urls[] = $citation['url'];
6954 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
6955 $citation_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
6956 }
6957 }
6958 echo "data: " . json_encode(['content' => $citation_text]) . "\n\n";
6959 $full_response .= $citation_text;
6960 flush();
6961 }
6962 echo "data: [DONE]\n\n";
6963 flush();
6964 continue;
6965 }
6966
6967 $json = json_decode(trim($json_str), true);
6968 if (!$json) continue;
6969
6970 // Handle Responses API streaming events
6971 // The format is different from Chat Completions
6972 if (isset($json['type'])) {
6973 switch ($json['type']) {
6974 case 'response.output_text.delta':
6975 // Text content delta
6976 if (isset($json['delta'])) {
6977 $content = $json['delta'];
6978 $full_response .= $content;
6979 echo "data: " . json_encode(['content' => $content]) . "\n\n";
6980 flush();
6981 }
6982 break;
6983
6984 case 'response.output_item.done':
6985 // Check for citations in completed items
6986 if (isset($json['item']['content'])) {
6987 foreach ($json['item']['content'] as $content_item) {
6988 if (isset($content_item['annotations'])) {
6989 foreach ($content_item['annotations'] as $annotation) {
6990 if ($annotation['type'] === 'url_citation') {
6991 $citations[] = [
6992 'url' => $annotation['url'],
6993 'title' => $annotation['title'] ?? ''
6994 ];
6995 }
6996 }
6997 }
6998 }
6999 }
7000 break;
7001 }
7002 }
7003 }
7004
7005 return strlen($data);
7006 });
7007
7008 $response = curl_exec($ch);
7009 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7010
7011 if (curl_errno($ch) || $http_code !== 200) {
7012 $curl_error = curl_error($ch);
7013 curl_close($ch);
7014
7015 error_log("MXCHAT WEB SEARCH STREAM ERROR: HTTP $http_code, cURL error: $curl_error");
7016
7017 // Fallback to non-streaming
7018 $fallback_response = $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7019
7020 if (is_array($fallback_response) && isset($fallback_response['error'])) {
7021 echo "data: " . json_encode([
7022 'error' => true,
7023 'error_message' => $fallback_response['error'],
7024 'error_code' => $fallback_response['error_code'] ?? 'web_search_error'
7025 ]) . "\n\n";
7026 echo "data: [DONE]\n\n";
7027 flush();
7028 return true;
7029 }
7030
7031 $response_data = [
7032 'text' => $fallback_response,
7033 'html' => '',
7034 'session_id' => $session_id
7035 ];
7036 if ($testing_data !== null) {
7037 $response_data['testing_data'] = $testing_data;
7038 }
7039 header('Content-Type: application/json');
7040 echo json_encode($response_data);
7041 return true;
7042 }
7043
7044 curl_close($ch);
7045
7046 // Save the complete response
7047 if (!empty($full_response) && !empty($session_id)) {
7048 $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
7049 }
7050
7051 return true;
7052 }
7053
7054 private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7055 try {
7056 // Get bot ID from session or request
7057 $bot_id = $this->get_current_bot_id($session_id);
7058
7059 // Get system prompt instructions using centralized function
7060 $system_prompt_instructions = $this->get_system_instructions($bot_id);
7061 // Ensure conversation_history is an array
7062 if (!is_array($conversation_history)) {
7063 $conversation_history = array();
7064 }
7065
7066 // Clean and validate conversation history
7067 foreach ($conversation_history as &$message) {
7068 // Convert bot and agent roles to assistant
7069 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
7070 $message['role'] = 'assistant';
7071 }
7072
7073 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
7074 if (!in_array($message['role'], ['assistant', 'user'])) {
7075 $message['role'] = 'user';
7076 }
7077
7078 // Ensure content field exists
7079 if (!isset($message['content']) || empty($message['content'])) {
7080 $message['content'] = '';
7081 }
7082
7083 // Remove any unsupported fields
7084 $message = array_intersect_key($message, array_flip(['role', 'content']));
7085 }
7086
7087 // Add relevant content as the latest user message
7088 $conversation_history[] = [
7089 'role' => 'user',
7090 'content' => $relevant_content
7091 ];
7092
7093 // Prepare the request body with stream: true
7094 $body = json_encode([
7095 'model' => $selected_model,
7096 'messages' => $conversation_history,
7097 'max_tokens' => 1000,
7098 'temperature' => 0.8,
7099 'system' => $system_prompt_instructions,
7100 'stream' => true
7101 ]);
7102
7103 // Check if we can actually stream (headers not sent, etc.)
7104 if (headers_sent() || !function_exists('curl_init')) {
7105 // Fallback to regular response with testing data
7106 //error_log("MxChat: Streaming not possible, falling back to regular response");
7107 $regular_response = $this->mxchat_generate_response_claude(
7108 $selected_model,
7109 $claude_api_key,
7110 array_slice($conversation_history, 0, -1), // Remove the added content
7111 $relevant_content
7112 );
7113
7114 // Save bot response to transcript
7115 if (!empty($regular_response) && !empty($session_id)) {
7116 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7117 }
7118
7119 // Return as JSON with testing data
7120 $response_data = [
7121 'text' => $regular_response,
7122 'html' => '',
7123 'session_id' => $session_id
7124 ];
7125
7126 if ($testing_data !== null) {
7127 $response_data['testing_data'] = $testing_data;
7128 //error_log("MxChat Testing: Added testing data to Claude fallback response");
7129 }
7130
7131 // Clear any streaming headers and send JSON
7132 if (headers_sent() === false) {
7133 header('Content-Type: application/json');
7134 }
7135 echo json_encode($response_data);
7136 return true; // Indicate we handled the response
7137 }
7138
7139 // Setup streaming headers now that we know we're actually streaming
7140 $this->setup_streaming_headers();
7141
7142 // Use cURL for streaming support
7143 $ch = curl_init();
7144 curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
7145 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7146 curl_setopt($ch, CURLOPT_POST, true);
7147 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7148 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7149 'Content-Type: application/json',
7150 'x-api-key: ' . $claude_api_key,
7151 'anthropic-version: 2023-06-01'
7152 ));
7153 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7154 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7155
7156 $full_response = ''; // Accumulate full response for saving
7157 $stream_started = false;
7158 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7159
7160 // Buffer control for real-time streaming
7161 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7162 // Send testing data as the first event if available
7163 if (!$stream_started && $testing_data !== null) {
7164 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7165 flush();
7166 $stream_started = true;
7167 //error_log("MxChat Testing: Sent testing data in Claude stream");
7168 }
7169
7170 // CRITICAL FIX: Append new data to buffer
7171 $buffer .= $data;
7172
7173 // Process complete lines only
7174 $lines = explode("\n", $buffer);
7175
7176 // CRITICAL FIX: Keep the last incomplete line in the buffer
7177 // The last element might be incomplete, so keep it in buffer
7178 $buffer = array_pop($lines);
7179
7180 foreach ($lines as $line) {
7181 if (trim($line) === '') {
7182 continue;
7183 }
7184
7185 // Claude uses event: and data: format
7186 if (strpos($line, 'event: ') === 0) {
7187 // Store the event type for the next data line
7188 continue;
7189 }
7190
7191 if (strpos($line, 'data: ') === 0) {
7192 $json_str = substr($line, 6); // Remove 'data: ' prefix
7193
7194 $json = json_decode(trim($json_str), true);
7195 if (json_last_error() !== JSON_ERROR_NONE) {
7196 continue;
7197 }
7198
7199 // Handle different event types
7200 if (isset($json['type'])) {
7201 switch ($json['type']) {
7202 case 'content_block_delta':
7203 if (isset($json['delta']['text'])) {
7204 $content = $json['delta']['text'];
7205 $full_response .= $content; // Accumulate
7206 // Send as SSE format compatible with your frontend
7207 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7208 flush();
7209 }
7210 break;
7211
7212 case 'message_stop':
7213 echo "data: [DONE]\n\n";
7214 flush();
7215 break;
7216
7217 case 'error':
7218 echo "data: " . json_encode(['error' => $json['error']['message'] ?? 'Unknown error']) . "\n\n";
7219 flush();
7220 break;
7221 }
7222 }
7223 }
7224 }
7225
7226 return strlen($data);
7227 });
7228
7229 $response = curl_exec($ch);
7230 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7231
7232 if (curl_errno($ch)) {
7233 curl_close($ch);
7234 throw new Exception('cURL Error: ' . curl_error($ch));
7235 }
7236
7237 curl_close($ch);
7238
7239 if ($http_code !== 200) {
7240 // Fallback to regular response
7241 //error_log("MxChat: Claude streaming failed with HTTP $http_code, falling back");
7242 $regular_response = $this->mxchat_generate_response_claude(
7243 $selected_model,
7244 $claude_api_key,
7245 array_slice($conversation_history, 0, -1), // Remove the added content
7246 $relevant_content
7247 );
7248
7249 // FIXED: Check if regular response returned an error
7250 if (is_array($regular_response) && isset($regular_response['error'])) {
7251 // Send error in SSE format since we're in streaming mode
7252 echo "data: " . json_encode([
7253 'error' => true,
7254 'error_message' => $regular_response['error'],
7255 'error_code' => $regular_response['error_code'] ?? 'api_error',
7256 'text' => $regular_response['error'],
7257 'message' => $regular_response['error']
7258 ]) . "\n\n";
7259 echo "data: [DONE]\n\n";
7260 flush();
7261 return true;
7262 }
7263
7264 $response_data = [
7265 'text' => $regular_response,
7266 'html' => '',
7267 'session_id' => $session_id
7268 ];
7269
7270 if ($testing_data !== null) {
7271 $response_data['testing_data'] = $testing_data;
7272 //error_log("MxChat Testing: Added testing data to Claude error fallback");
7273 }
7274
7275 header('Content-Type: application/json');
7276 echo json_encode($response_data);
7277 return true;
7278 }
7279
7280 // Save the complete response to maintain chat persistence
7281 if (!empty($full_response) && !empty($session_id)) {
7282 // Prepare RAG context for streaming response
7283 $rag_context_for_storage = null;
7284 if ($this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches'])) {
7285 $rag_context_for_storage = [
7286 'top_matches' => $this->last_similarity_analysis['top_matches'],
7287 'approved_urls' => $this->current_valid_urls ?? [],
7288 'similarity_threshold' => $this->last_similarity_analysis['threshold_used'] ?? 0.35,
7289 'knowledge_base_type' => $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database',
7290 'total_documents_checked' => $this->last_similarity_analysis['total_checked'] ?? 0
7291 ];
7292 }
7293 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7294 }
7295
7296 return true; // Indicate streaming completed successfully
7297
7298 } catch (Exception $e) {
7299 //error_log("MxChat Claude streaming exception: " . $e->getMessage());
7300
7301 // Fallback to regular response on exception
7302 $regular_response = $this->mxchat_generate_response_claude(
7303 $selected_model,
7304 $claude_api_key,
7305 $conversation_history,
7306 $relevant_content
7307 );
7308
7309 // FIXED: Check if regular response returned an error
7310 if (is_array($regular_response) && isset($regular_response['error'])) {
7311 // Send error in SSE format since we're in streaming mode
7312 echo "data: " . json_encode([
7313 'error' => true,
7314 'error_message' => $regular_response['error'],
7315 'error_code' => $regular_response['error_code'] ?? 'api_error',
7316 'text' => $regular_response['error'],
7317 'message' => $regular_response['error']
7318 ]) . "\n\n";
7319 echo "data: [DONE]\n\n";
7320 flush();
7321 return true;
7322 }
7323
7324 $response_data = [
7325 'text' => $regular_response,
7326 'html' => '',
7327 'session_id' => $session_id
7328 ];
7329
7330 if ($testing_data !== null) {
7331 $response_data['testing_data'] = $testing_data;
7332 //error_log("MxChat Testing: Added testing data to Claude exception fallback");
7333 }
7334
7335 header('Content-Type: application/json');
7336 echo json_encode($response_data);
7337 return true;
7338 }
7339 }
7340 private function mxchat_generate_response_xai_stream($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7341 try {
7342 // Get bot ID from session or request
7343 $bot_id = $this->get_current_bot_id($session_id);
7344
7345 // Get system prompt instructions using centralized function
7346 $system_prompt_instructions = $this->get_system_instructions($bot_id);
7347
7348 // Ensure conversation_history is an array
7349 if (!is_array($conversation_history)) {
7350 $conversation_history = array();
7351 }
7352
7353 // Format conversation history for X.AI (same as OpenAI format)
7354 $formatted_conversation = array();
7355
7356 $formatted_conversation[] = array(
7357 'role' => 'system',
7358 'content' => $system_prompt_instructions . " " . $relevant_content
7359 );
7360
7361 foreach ($conversation_history as $message) {
7362 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7363 $role = $message['role'];
7364 if ($role === 'bot' || $role === 'agent') {
7365 $role = 'assistant';
7366 }
7367 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
7368 $role = 'user';
7369 }
7370 $formatted_conversation[] = array(
7371 'role' => $role,
7372 'content' => $message['content']
7373 );
7374 }
7375 }
7376
7377 // Check if we can actually stream
7378 if (headers_sent() || !function_exists('curl_init')) {
7379 // Fallback to regular response with testing data
7380 //error_log("MxChat: X.AI streaming not possible, falling back to regular response");
7381 $regular_response = $this->mxchat_generate_response_xai(
7382 $selected_model,
7383 $xai_api_key,
7384 $conversation_history,
7385 $relevant_content
7386 );
7387
7388 // Save bot response to transcript
7389 if (!empty($regular_response) && !empty($session_id)) {
7390 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7391 }
7392
7393 $response_data = [
7394 'text' => $regular_response,
7395 'html' => '',
7396 'session_id' => $session_id
7397 ];
7398
7399 if ($testing_data !== null) {
7400 $response_data['testing_data'] = $testing_data;
7401 //error_log("MxChat Testing: Added testing data to X.AI fallback response");
7402 }
7403
7404 header('Content-Type: application/json');
7405 echo json_encode($response_data);
7406 return true;
7407 }
7408
7409 // Prepare the request body with stream: true
7410 $body = json_encode([
7411 'model' => $selected_model,
7412 'messages' => $formatted_conversation,
7413 'temperature' => 0.8,
7414 'stream' => true
7415 ]);
7416
7417 // Setup streaming headers now that we know we're actually streaming
7418 $this->setup_streaming_headers();
7419
7420 // Use cURL for streaming support
7421 $ch = curl_init();
7422 curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
7423 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7424 curl_setopt($ch, CURLOPT_POST, true);
7425 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7426 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7427 'Content-Type: application/json',
7428 'Authorization: Bearer ' . $xai_api_key
7429 ));
7430 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7431 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7432
7433 $full_response = ''; // Accumulate full response for saving
7434 $stream_started = false;
7435 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7436
7437 // Buffer control for real-time streaming
7438 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7439 // Send testing data as the first event if available
7440 if (!$stream_started && $testing_data !== null) {
7441 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7442 flush();
7443 $stream_started = true;
7444 //error_log("MxChat Testing: Sent testing data in X.AI stream");
7445 }
7446
7447 // CRITICAL FIX: Append new data to buffer
7448 $buffer .= $data;
7449
7450 // Process complete lines only
7451 $lines = explode("\n", $buffer);
7452
7453 // CRITICAL FIX: Keep the last incomplete line in the buffer
7454 // The last element might be incomplete, so keep it in buffer
7455 $buffer = array_pop($lines);
7456
7457 foreach ($lines as $line) {
7458 // Skip empty lines
7459 if (trim($line) === '') {
7460 continue;
7461 }
7462
7463 // Only process lines that start with "data: "
7464 if (strpos($line, 'data: ') !== 0) {
7465 continue;
7466 }
7467
7468 $json_str = substr($line, 6); // Remove 'data: ' prefix
7469
7470 if (trim($json_str) === '[DONE]') {
7471 echo "data: [DONE]\n\n";
7472 flush();
7473 continue;
7474 }
7475
7476 // Try to decode JSON
7477 $json = json_decode(trim($json_str), true);
7478 if ($json && isset($json['choices'][0]['delta']['content'])) {
7479 $content = $json['choices'][0]['delta']['content'];
7480 $full_response .= $content; // Accumulate
7481 // Send as SSE format
7482 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7483 flush();
7484 }
7485 }
7486
7487 return strlen($data);
7488 });
7489
7490 $response = curl_exec($ch);
7491 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7492
7493 if (curl_errno($ch) || $http_code !== 200) {
7494 curl_close($ch);
7495
7496 // Fallback to regular response
7497 //error_log("MxChat: X.AI streaming failed, falling back");
7498 $regular_response = $this->mxchat_generate_response_xai(
7499 $selected_model,
7500 $xai_api_key,
7501 $conversation_history,
7502 $relevant_content
7503 );
7504
7505 $response_data = [
7506 'text' => $regular_response,
7507 'html' => '',
7508 'session_id' => $session_id
7509 ];
7510
7511 if ($testing_data !== null) {
7512 $response_data['testing_data'] = $testing_data;
7513 //error_log("MxChat Testing: Added testing data to X.AI error fallback");
7514 }
7515
7516 header('Content-Type: application/json');
7517 echo json_encode($response_data);
7518 return true;
7519 }
7520
7521 curl_close($ch);
7522
7523 // Save the complete response to maintain chat persistence
7524 if (!empty($full_response) && !empty($session_id)) {
7525 // Prepare RAG context for streaming response
7526 $rag_context_for_storage = null;
7527 if ($this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches'])) {
7528 $rag_context_for_storage = [
7529 'top_matches' => $this->last_similarity_analysis['top_matches'],
7530 'approved_urls' => $this->current_valid_urls ?? [],
7531 'similarity_threshold' => $this->last_similarity_analysis['threshold_used'] ?? 0.35,
7532 'knowledge_base_type' => $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database',
7533 'total_documents_checked' => $this->last_similarity_analysis['total_checked'] ?? 0
7534 ];
7535 }
7536 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7537 }
7538
7539 return true; // Indicate streaming completed successfully
7540
7541 } catch (Exception $e) {
7542 //error_log("MxChat X.AI streaming exception: " . $e->getMessage());
7543
7544 // Fallback to regular response
7545 $regular_response = $this->mxchat_generate_response_xai(
7546 $selected_model,
7547 $xai_api_key,
7548 $conversation_history,
7549 $relevant_content
7550 );
7551
7552 $response_data = [
7553 'text' => $regular_response,
7554 'html' => '',
7555 'session_id' => $session_id
7556 ];
7557
7558 if ($testing_data !== null) {
7559 $response_data['testing_data'] = $testing_data;
7560 //error_log("MxChat Testing: Added testing data to X.AI exception fallback");
7561 }
7562
7563 header('Content-Type: application/json');
7564 echo json_encode($response_data);
7565 return true;
7566 }
7567 }
7568 private function mxchat_generate_response_deepseek_stream($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7569 try {
7570 // Get bot ID from session or request
7571 $bot_id = $this->get_current_bot_id($session_id);
7572
7573 // Get system prompt instructions using centralized function
7574 $system_prompt_instructions = $this->get_system_instructions($bot_id);
7575
7576 // Ensure conversation_history is an array
7577 if (!is_array($conversation_history)) {
7578 $conversation_history = array();
7579 }
7580
7581 // Format conversation history for DeepSeek
7582 $formatted_conversation = array();
7583
7584 $formatted_conversation[] = array(
7585 'role' => 'system',
7586 'content' => $system_prompt_instructions . " " . $relevant_content
7587 );
7588
7589 foreach ($conversation_history as $message) {
7590 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7591 $role = $message['role'];
7592 if ($role === 'bot' || $role === 'agent') {
7593 $role = 'assistant';
7594 }
7595 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
7596 $role = 'user';
7597 }
7598 $formatted_conversation[] = array(
7599 'role' => $role,
7600 'content' => $message['content']
7601 );
7602 }
7603 }
7604
7605 // Check if we can actually stream
7606 if (headers_sent() || !function_exists('curl_init')) {
7607 // Fallback to regular response with testing data
7608 //error_log("MxChat: DeepSeek streaming not possible, falling back to regular response");
7609 $regular_response = $this->mxchat_generate_response_deepseek(
7610 $selected_model,
7611 $deepseek_api_key,
7612 $conversation_history,
7613 $relevant_content
7614 );
7615
7616 // Save bot response to transcript
7617 if (!empty($regular_response) && !empty($session_id)) {
7618 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7619 }
7620
7621 $response_data = [
7622 'text' => $regular_response,
7623 'html' => '',
7624 'session_id' => $session_id
7625 ];
7626
7627 if ($testing_data !== null) {
7628 $response_data['testing_data'] = $testing_data;
7629 //error_log("MxChat Testing: Added testing data to DeepSeek fallback response");
7630 }
7631
7632 header('Content-Type: application/json');
7633 echo json_encode($response_data);
7634 return true;
7635 }
7636
7637 // Prepare the request body with stream: true
7638 $body = json_encode([
7639 'model' => $selected_model,
7640 'messages' => $formatted_conversation,
7641 'temperature' => 0.8,
7642 'stream' => true
7643 ]);
7644
7645 // Setup streaming headers now that we know we're actually streaming
7646 $this->setup_streaming_headers();
7647
7648 // Use cURL for streaming support
7649 $ch = curl_init();
7650 curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
7651 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7652 curl_setopt($ch, CURLOPT_POST, true);
7653 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7654 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7655 'Content-Type: application/json',
7656 'Authorization: Bearer ' . $deepseek_api_key
7657 ));
7658 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7659 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7660
7661 $full_response = ''; // Accumulate full response for saving
7662 $stream_started = false;
7663 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7664
7665 // Buffer control for real-time streaming
7666 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7667 // Send testing data as the first event if available
7668 if (!$stream_started && $testing_data !== null) {
7669 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7670 flush();
7671 $stream_started = true;
7672 //error_log("MxChat Testing: Sent testing data in DeepSeek stream");
7673 }
7674
7675 // CRITICAL FIX: Append new data to buffer
7676 $buffer .= $data;
7677
7678 // Process complete lines only
7679 $lines = explode("\n", $buffer);
7680
7681 // CRITICAL FIX: Keep the last incomplete line in the buffer
7682 // The last element might be incomplete, so keep it in buffer
7683 $buffer = array_pop($lines);
7684
7685 foreach ($lines as $line) {
7686 // Skip empty lines
7687 if (trim($line) === '') {
7688 continue;
7689 }
7690
7691 // Only process lines that start with "data: "
7692 if (strpos($line, 'data: ') !== 0) {
7693 continue;
7694 }
7695
7696 $json_str = substr($line, 6); // Remove 'data: ' prefix
7697
7698 if (trim($json_str) === '[DONE]') {
7699 echo "data: [DONE]\n\n";
7700 flush();
7701 continue;
7702 }
7703
7704 // Try to decode JSON
7705 $json = json_decode(trim($json_str), true);
7706 if ($json && isset($json['choices'][0]['delta']['content'])) {
7707 $content = $json['choices'][0]['delta']['content'];
7708 $full_response .= $content; // Accumulate the full response
7709
7710 // Send as SSE format
7711 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7712 flush();
7713 }
7714 }
7715
7716 return strlen($data);
7717 });
7718
7719 $response = curl_exec($ch);
7720 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7721
7722 if (curl_errno($ch) || $http_code !== 200) {
7723 $curl_error = curl_error($ch);
7724 curl_close($ch);
7725
7726 // Log the specific error for debugging
7727 //error_log("MxChat: DeepSeek streaming failed - HTTP: $http_code, cURL: $curl_error");
7728
7729 // Fallback to regular response
7730 $regular_response = $this->mxchat_generate_response_deepseek(
7731 $selected_model,
7732 $deepseek_api_key,
7733 $conversation_history,
7734 $relevant_content
7735 );
7736
7737 // Handle error response from regular function
7738 if (is_array($regular_response) && isset($regular_response['error'])) {
7739 if ($testing_data !== null) {
7740 $regular_response['testing_data'] = $testing_data;
7741 }
7742 header('Content-Type: application/json');
7743 echo json_encode($regular_response);
7744 return true;
7745 }
7746
7747 $response_data = [
7748 'text' => $regular_response,
7749 'html' => '',
7750 'session_id' => $session_id
7751 ];
7752
7753 if ($testing_data !== null) {
7754 $response_data['testing_data'] = $testing_data;
7755 //error_log("MxChat Testing: Added testing data to DeepSeek error fallback");
7756 }
7757
7758 header('Content-Type: application/json');
7759 echo json_encode($response_data);
7760 return true;
7761 }
7762
7763 curl_close($ch);
7764
7765 // Save the complete response to maintain chat persistence
7766 if (!empty($full_response) && !empty($session_id)) {
7767 // Prepare RAG context for streaming response
7768 $rag_context_for_storage = null;
7769 if ($this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches'])) {
7770 $rag_context_for_storage = [
7771 'top_matches' => $this->last_similarity_analysis['top_matches'],
7772 'approved_urls' => $this->current_valid_urls ?? [],
7773 'similarity_threshold' => $this->last_similarity_analysis['threshold_used'] ?? 0.35,
7774 'knowledge_base_type' => $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database',
7775 'total_documents_checked' => $this->last_similarity_analysis['total_checked'] ?? 0
7776 ];
7777 }
7778 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7779 }
7780
7781 return true; // Indicate streaming completed successfully
7782
7783 } catch (Exception $e) {
7784 //error_log("MxChat DeepSeek streaming exception: " . $e->getMessage());
7785
7786 // Fallback to regular response
7787 $regular_response = $this->mxchat_generate_response_deepseek(
7788 $selected_model,
7789 $deepseek_api_key,
7790 $conversation_history,
7791 $relevant_content
7792 );
7793
7794 // Handle error response from regular function
7795 if (is_array($regular_response) && isset($regular_response['error'])) {
7796 if ($testing_data !== null) {
7797 $regular_response['testing_data'] = $testing_data;
7798 }
7799 header('Content-Type: application/json');
7800 echo json_encode($regular_response);
7801 return true;
7802 }
7803
7804 $response_data = [
7805 'text' => $regular_response,
7806 'html' => '',
7807 'session_id' => $session_id
7808 ];
7809
7810 if ($testing_data !== null) {
7811 $response_data['testing_data'] = $testing_data;
7812 //error_log("MxChat Testing: Added testing data to DeepSeek exception fallback");
7813 }
7814
7815 header('Content-Type: application/json');
7816 echo json_encode($response_data);
7817 return true;
7818 }
7819 }
7820
7821
7822 private function mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content) {
7823 try {
7824 if (!is_array($conversation_history)) {
7825 $conversation_history = array();
7826 }
7827
7828 $bot_id = $this->get_current_bot_id('');
7829 $system_prompt_instructions = $this->get_system_instructions($bot_id);
7830
7831 $formatted_conversation = array();
7832
7833 $formatted_conversation[] = array(
7834 'role' => 'system',
7835 'content' => $system_prompt_instructions . " " . $relevant_content
7836 );
7837
7838 foreach ($conversation_history as $message) {
7839 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7840 $role = $message['role'];
7841
7842 if ($role === 'bot' || $role === 'agent') {
7843 $role = 'assistant';
7844 }
7845 if (!in_array($role, ['system', 'assistant', 'user'])) {
7846 $role = 'user';
7847 }
7848
7849 $formatted_conversation[] = array(
7850 'role' => $role,
7851 'content' => $message['content']
7852 );
7853 }
7854 }
7855
7856 $body = json_encode([
7857 'model' => $selected_model,
7858 'messages' => $formatted_conversation,
7859 'temperature' => 1,
7860 ]);
7861
7862 $args = [
7863 'body' => $body,
7864 'headers' => [
7865 'Content-Type' => 'application/json',
7866 'Authorization' => 'Bearer ' . $openrouter_api_key,
7867 'HTTP-Referer' => home_url(),
7868 'X-Title' => get_bloginfo('name'),
7869 ],
7870 'timeout' => 60,
7871 'redirection' => 5,
7872 'blocking' => true,
7873 'httpversion' => '1.0',
7874 'sslverify' => true,
7875 ];
7876
7877 $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', $args);
7878
7879 if (is_wp_error($response)) {
7880 $error_message = $response->get_error_message();
7881 return [
7882 'error' => esc_html__('Connection error when contacting OpenRouter: ', 'mxchat') . esc_html($error_message),
7883 'error_code' => 'openrouter_connection_error',
7884 'provider' => 'openrouter'
7885 ];
7886 }
7887
7888 $status_code = wp_remote_retrieve_response_code($response);
7889 if ($status_code !== 200) {
7890 $response_body = wp_remote_retrieve_body($response);
7891 $decoded_response = json_decode($response_body, true);
7892
7893 $error_message = isset($decoded_response['error']['message'])
7894 ? $decoded_response['error']['message']
7895 : 'HTTP Error ' . $status_code;
7896
7897 return [
7898 'error' => esc_html__('OpenRouter API error: ', 'mxchat') . esc_html($error_message),
7899 'error_code' => 'openrouter_api_error',
7900 'provider' => 'openrouter',
7901 'status_code' => $status_code
7902 ];
7903 }
7904
7905 $response_body = wp_remote_retrieve_body($response);
7906 $decoded_response = json_decode($response_body, true);
7907
7908 if (isset($decoded_response['choices'][0]['message']['content'])) {
7909 return trim($decoded_response['choices'][0]['message']['content']);
7910 } else {
7911 return [
7912 'error' => esc_html__('Unexpected response format from OpenRouter.', 'mxchat'),
7913 'error_code' => 'openrouter_response_format_error',
7914 'provider' => 'openrouter'
7915 ];
7916 }
7917 } catch (Exception $e) {
7918 return [
7919 'error' => esc_html__('System error when processing OpenRouter request: ', 'mxchat') . esc_html($e->getMessage()),
7920 'error_code' => 'openrouter_exception',
7921 'provider' => 'openrouter'
7922 ];
7923 }
7924 }
7925 private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content) {
7926
7927 // Get bot ID from session or request
7928 $bot_id = $this->get_current_bot_id($session_id);
7929
7930 // Get system prompt instructions using centralized function
7931 $system_prompt_instructions = $this->get_system_instructions($bot_id);
7932
7933 // Clean and validate conversation history
7934 foreach ($conversation_history as &$message) {
7935 // Convert bot and agent roles to assistant
7936 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
7937 $message['role'] = 'assistant';
7938 }
7939
7940 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
7941 if (!in_array($message['role'], ['assistant', 'user'])) {
7942 $message['role'] = 'user';
7943 }
7944
7945 // Ensure content field exists
7946 if (!isset($message['content']) || empty($message['content'])) {
7947 $message['content'] = '';
7948 }
7949
7950 // Remove any unsupported fields
7951 $message = array_intersect_key($message, array_flip(['role', 'content']));
7952 }
7953
7954 // Add relevant content as the latest user message
7955 $conversation_history[] = [
7956 'role' => 'user',
7957 'content' => $relevant_content
7958 ];
7959
7960 // Build request body
7961 $body = json_encode([
7962 'model' => $selected_model,
7963 'max_tokens' => 1000,
7964 'temperature' => 0.8,
7965 'messages' => $conversation_history,
7966 'system' => $system_prompt_instructions
7967 ]);
7968
7969 // Set up API request
7970 $args = [
7971 'body' => $body,
7972 'headers' => [
7973 'Content-Type' => 'application/json',
7974 'x-api-key' => $claude_api_key,
7975 'anthropic-version' => '2023-06-01'
7976 ],
7977 'timeout' => 60,
7978 'redirection' => 5,
7979 'blocking' => true,
7980 'httpversion' => '1.0',
7981 'sslverify' => true,
7982 ];
7983
7984 // Make API request
7985 $response = wp_remote_post('https://api.anthropic.com/v1/messages', $args);
7986
7987 // Check for WordPress errors
7988 if (is_wp_error($response)) {
7989 //error_log("Claude API request error: " . $response->get_error_message());
7990 return "Sorry, there was an error connecting to the API.";
7991 }
7992
7993 // Check HTTP response code
7994 $http_code = wp_remote_retrieve_response_code($response);
7995 if ($http_code !== 200) {
7996 $error_body = wp_remote_retrieve_body($response);
7997 //error_log("Claude API HTTP error: " . $http_code . " - " . $error_body);
7998
7999 // Try to extract error message from response
8000 $error_data = json_decode($error_body, true);
8001 $error_message = isset($error_data['error']['message']) ?
8002 $error_data['error']['message'] :
8003 "HTTP error " . $http_code;
8004
8005 return "Sorry, the API returned an error: " . $error_message;
8006 }
8007
8008 // Parse response
8009 $response_body = json_decode(wp_remote_retrieve_body($response), true);
8010
8011 // Check for JSON decode errors
8012 if (json_last_error() !== JSON_ERROR_NONE) {
8013 //error_log("Claude API JSON decode error: " . json_last_error_msg());
8014 return "Sorry, there was an error processing the API response.";
8015 }
8016
8017 // Extract and validate response content
8018 if (isset($response_body['content']) &&
8019 is_array($response_body['content']) &&
8020 !empty($response_body['content']) &&
8021 isset($response_body['content'][0]['text'])) {
8022 return trim($response_body['content'][0]['text']);
8023 }
8024
8025 // Log unexpected response format
8026 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
8027 return "Sorry, I received an unexpected response format from the API.";
8028 }
8029 private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content) {
8030 try {
8031 // Ensure conversation_history is an array
8032 if (!is_array($conversation_history)) {
8033 $conversation_history = array();
8034 }
8035
8036 // Get bot ID from session or request
8037 $bot_id = $this->get_current_bot_id('');
8038
8039 // Get system prompt instructions using centralized function
8040 $system_prompt_instructions = $this->get_system_instructions($bot_id);
8041
8042 // Create a new array for the formatted conversation
8043 $formatted_conversation = array();
8044
8045 // Add system message first
8046 $formatted_conversation[] = array(
8047 'role' => 'system',
8048 'content' => $system_prompt_instructions . " " . $relevant_content
8049 );
8050
8051 // Add the rest of the conversation history
8052 foreach ($conversation_history as $message) {
8053 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8054 $role = $message['role'];
8055
8056 // Convert roles to supported format
8057 if ($role === 'bot' || $role === 'agent') {
8058 $role = 'assistant';
8059 }
8060 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8061 $role = 'user';
8062 }
8063
8064 $formatted_conversation[] = array(
8065 'role' => $role,
8066 'content' => $message['content']
8067 );
8068 }
8069 }
8070
8071 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
8072 $is_gpt5_model = (
8073 strpos($selected_model, 'gpt-5') === 0 ||
8074 $selected_model === 'gpt-5.2' ||
8075 $selected_model === 'gpt-5.1-2025-11-13' ||
8076 $selected_model === 'gpt-5' ||
8077 $selected_model === 'gpt-5-mini' ||
8078 $selected_model === 'gpt-5-nano'
8079 );
8080
8081 // Build request body with optimal settings for fast responses
8082 $request_body = [
8083 'model' => $selected_model,
8084 'messages' => $formatted_conversation,
8085 'temperature' => 1,
8086 'stream' => false
8087 ];
8088
8089 // Add reasoning_effort only for GPT-5 models (not GPT-5.2 which doesn't support it)
8090 if ($is_gpt5_model && $selected_model !== 'gpt-5.2') {
8091 // GPT-5.1 uses 'low' instead of 'minimal'
8092 if ($selected_model === 'gpt-5.1-2025-11-13') {
8093 $request_body['reasoning_effort'] = 'low';
8094 } else {
8095 $request_body['reasoning_effort'] = 'minimal'; // For other GPT-5 models
8096 }
8097 }
8098
8099 $body = json_encode($request_body);
8100
8101 $args = [
8102 'body' => $body,
8103 'headers' => [
8104 'Content-Type' => 'application/json',
8105 'Authorization' => 'Bearer ' . $api_key,
8106 ],
8107 'timeout' => 60,
8108 'redirection' => 5,
8109 'blocking' => true,
8110 'httpversion' => '1.0',
8111 'sslverify' => true,
8112 ];
8113
8114 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', $args);
8115
8116 if (is_wp_error($response)) {
8117 $error_message = $response->get_error_message();
8118 return [
8119 'error' => esc_html__('Connection error when contacting OpenAI: ', 'mxchat') . esc_html($error_message),
8120 'error_code' => 'openai_connection_error',
8121 'provider' => 'openai'
8122 ];
8123 }
8124
8125 $status_code = wp_remote_retrieve_response_code($response);
8126 if ($status_code !== 200) {
8127 $response_body = wp_remote_retrieve_body($response);
8128 $decoded_response = json_decode($response_body, true);
8129
8130 $error_message = isset($decoded_response['error']['message'])
8131 ? $decoded_response['error']['message']
8132 : 'HTTP Error ' . $status_code;
8133
8134 $error_type = isset($decoded_response['error']['type'])
8135 ? $decoded_response['error']['type']
8136 : 'unknown';
8137
8138 // Handle specific error types
8139 switch ($error_type) {
8140 case 'invalid_request_error':
8141 if (strpos($error_message, 'API key') !== false) {
8142 return [
8143 'error' => esc_html__('Invalid OpenAI API key. Please check your API key configuration.', 'mxchat'),
8144 'error_code' => 'openai_invalid_api_key',
8145 'provider' => 'openai'
8146 ];
8147 }
8148 break;
8149
8150 case 'authentication_error':
8151 return [
8152 'error' => esc_html__('Authentication failed with OpenAI. Please check your API key.', 'mxchat'),
8153 'error_code' => 'openai_auth_error',
8154 'provider' => 'openai'
8155 ];
8156
8157 case 'rate_limit_exceeded':
8158 return [
8159 'error' => esc_html__('OpenAI rate limit exceeded. Please try again later.', 'mxchat'),
8160 'error_code' => 'openai_rate_limit',
8161 'provider' => 'openai'
8162 ];
8163
8164 case 'quota_exceeded':
8165 return [
8166 'error' => esc_html__('OpenAI API quota exceeded. Please check your billing details.', 'mxchat'),
8167 'error_code' => 'openai_quota_exceeded',
8168 'provider' => 'openai'
8169 ];
8170 }
8171
8172 // Generic error fallback
8173 return [
8174 'error' => esc_html__('OpenAI API error: ', 'mxchat') . esc_html($error_message),
8175 'error_code' => 'openai_api_error',
8176 'provider' => 'openai',
8177 'status_code' => $status_code
8178 ];
8179 }
8180
8181 $response_body = wp_remote_retrieve_body($response);
8182 $decoded_response = json_decode($response_body, true);
8183
8184 if (isset($decoded_response['choices'][0]['message']['content'])) {
8185 return trim($decoded_response['choices'][0]['message']['content']);
8186 } else {
8187 return [
8188 'error' => esc_html__('Unexpected response format from OpenAI.', 'mxchat'),
8189 'error_code' => 'openai_response_format_error',
8190 'provider' => 'openai'
8191 ];
8192 }
8193 } catch (Exception $e) {
8194 return [
8195 'error' => esc_html__('System error when processing OpenAI request: ', 'mxchat') . esc_html($e->getMessage()),
8196 'error_code' => 'openai_exception',
8197 'provider' => 'openai'
8198 ];
8199 }
8200 }
8201
8202 private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content) {
8203 try {
8204 // Get bot ID from session or request
8205 $bot_id = $this->get_current_bot_id($session_id);
8206
8207 // Get system prompt instructions using centralized function
8208 $system_prompt_instructions = $this->get_system_instructions($bot_id);
8209
8210 // Add system prompt to relevant content
8211 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
8212
8213 // Prepend system instructions to the conversation history
8214 array_unshift($conversation_history, [
8215 'role' => 'system',
8216 'content' => "Here are your instructions: " . $content_with_instructions
8217 ]);
8218
8219 // Ensure consistency: Replace 'bot' and 'agent' roles with supported values
8220 foreach ($conversation_history as &$message) {
8221 if ($message['role'] === 'bot') {
8222 $message['role'] = 'assistant';
8223 } elseif ($message['role'] === 'agent') {
8224 // Tag the message as coming from a live agent
8225 $message['role'] = 'assistant';
8226 if (!isset($message['metadata'])) {
8227 $message['metadata'] = ['source' => 'live_agent'];
8228 }
8229 }
8230
8231 // Ensure all roles are valid
8232 if (!in_array($message['role'], ['system', 'assistant', 'user', 'function', 'tool'])) {
8233 $message['role'] = 'user'; // Default to 'user'
8234 }
8235 }
8236
8237 // Build the request body
8238 $body = json_encode([
8239 'model' => $selected_model,
8240 'messages' => $conversation_history,
8241 'temperature' => 0.8,
8242 'stream' => false
8243 ]);
8244
8245 // Set up the API request
8246 $args = [
8247 'body' => $body,
8248 'headers' => [
8249 'Content-Type' => 'application/json',
8250 'Authorization' => 'Bearer ' . $xai_api_key,
8251 ],
8252 'timeout' => 60,
8253 'redirection' => 5,
8254 'blocking' => true,
8255 'httpversion' => '1.0',
8256 'sslverify' => true,
8257 ];
8258
8259 // Make the API request
8260 $response = wp_remote_post('https://api.x.ai/v1/chat/completions', $args);
8261
8262 // Process the response
8263 if (is_wp_error($response)) {
8264 $error_message = $response->get_error_message();
8265 //error_log('X.AI API Error: ' . $error_message);
8266 return [
8267 'error' => esc_html__('Connection error when contacting X.AI: ', 'mxchat') . esc_html($error_message),
8268 'error_code' => 'xai_connection_error',
8269 'provider' => 'xai'
8270 ];
8271 }
8272
8273 $status_code = wp_remote_retrieve_response_code($response);
8274 if ($status_code !== 200) {
8275 $response_body = wp_remote_retrieve_body($response);
8276 $decoded_response = json_decode($response_body, true);
8277
8278 // Log the full response for debugging
8279 //error_log('X.AI Error Response: ' . print_r($decoded_response, true));
8280
8281 // Extract error message from X.AI's specific format
8282 $error_message = '';
8283
8284 // Check for direct error string (as seen in your logs)
8285 if (isset($decoded_response['error']) && is_string($decoded_response['error'])) {
8286 $error_message = $decoded_response['error'];
8287 }
8288 // Check for nested error object (OpenAI style)
8289 elseif (isset($decoded_response['error']['message'])) {
8290 $error_message = $decoded_response['error']['message'];
8291 }
8292 // Check for top-level message
8293 elseif (isset($decoded_response['message'])) {
8294 $error_message = $decoded_response['message'];
8295 }
8296 // Fallback
8297 else {
8298 $error_message = 'HTTP Error ' . $status_code;
8299 }
8300
8301 //error_log('X.AI API HTTP Error: ' . $status_code . ' - ' . $error_message);
8302
8303 // Check for API key errors using string matching
8304 if (stripos($error_message, 'api key') !== false ||
8305 stripos($error_message, 'incorrect api key') !== false ||
8306 stripos($error_message, 'invalid api key') !== false) {
8307 return [
8308 'error' => esc_html__('Invalid X.AI API key. Please check your API key configuration.', 'mxchat'),
8309 'error_code' => 'xai_invalid_api_key',
8310 'provider' => 'xai'
8311 ];
8312 }
8313
8314 // Authentication errors
8315 if ($status_code === 401 || $status_code === 403 ||
8316 stripos($error_message, 'auth') !== false) {
8317 return [
8318 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat'),
8319 'error_code' => 'xai_auth_error',
8320 'provider' => 'xai'
8321 ];
8322 }
8323
8324 // Model errors
8325 if (stripos($error_message, 'model') !== false) {
8326 return [
8327 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat'),
8328 'error_code' => 'xai_invalid_model',
8329 'provider' => 'xai'
8330 ];
8331 }
8332
8333 // Rate limit errors
8334 if ($status_code === 429 ||
8335 stripos($error_message, 'rate') !== false ||
8336 stripos($error_message, 'limit') !== false) {
8337 return [
8338 'error' => esc_html__('X.AI rate limit exceeded. Please try again later.', 'mxchat'),
8339 'error_code' => 'xai_rate_limit',
8340 'provider' => 'xai'
8341 ];
8342 }
8343
8344 // Quota errors
8345 if (stripos($error_message, 'quota') !== false ||
8346 stripos($error_message, 'billing') !== false) {
8347 return [
8348 'error' => esc_html__('X.AI API quota exceeded. Please check your billing details.', 'mxchat'),
8349 'error_code' => 'xai_quota_exceeded',
8350 'provider' => 'xai'
8351 ];
8352 }
8353
8354 // Server errors
8355 if ($status_code >= 500) {
8356 return [
8357 'error' => esc_html__('X.AI service is currently unavailable. Please try again later.', 'mxchat'),
8358 'error_code' => 'xai_service_unavailable',
8359 'provider' => 'xai'
8360 ];
8361 }
8362
8363 // Generic error fallback with the actual error message
8364 return [
8365 'error' => esc_html__('X.AI API error: ', 'mxchat') . esc_html($error_message),
8366 'error_code' => 'xai_api_error',
8367 'provider' => 'xai',
8368 'status_code' => $status_code
8369 ];
8370 }
8371
8372 $response_body = wp_remote_retrieve_body($response);
8373 $decoded_response = json_decode($response_body, true);
8374
8375 if (isset($decoded_response['choices'][0]['message']['content'])) {
8376 return trim($decoded_response['choices'][0]['message']['content']);
8377 } else {
8378 //error_log('X.AI API Response Format Error: ' . print_r($decoded_response, true));
8379 return [
8380 'error' => esc_html__('Unexpected response format from X.AI.', 'mxchat'),
8381 'error_code' => 'xai_response_format_error',
8382 'provider' => 'xai'
8383 ];
8384 }
8385 } catch (Exception $e) {
8386 //error_log('X.AI Exception: ' . $e->getMessage());
8387 return [
8388 'error' => esc_html__('System error when processing X.AI request: ', 'mxchat') . esc_html($e->getMessage()),
8389 'error_code' => 'xai_exception',
8390 'provider' => 'xai'
8391 ];
8392 }
8393
8394
8395 }
8396 private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content) {
8397 try {
8398 // Ensure conversation_history is an array
8399 if (!is_array($conversation_history)) {
8400 $conversation_history = array();
8401 }
8402
8403 // Get bot ID from session or request
8404 $bot_id = $this->get_current_bot_id($session_id);
8405
8406 // Get system prompt instructions using centralized function
8407 $system_prompt_instructions = $this->get_system_instructions($bot_id);
8408
8409 // Create a new array for the formatted conversation
8410 $formatted_conversation = array();
8411
8412 // Add system message first
8413 $formatted_conversation[] = array(
8414 'role' => 'system',
8415 'content' => $system_prompt_instructions . " " . $relevant_content
8416 );
8417
8418 // Add the rest of the conversation history
8419 foreach ($conversation_history as $message) {
8420 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8421 $role = $message['role'];
8422
8423 // Convert roles to supported format
8424 if ($role === 'bot' || $role === 'agent') {
8425 $role = 'assistant';
8426 }
8427 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8428 $role = 'user';
8429 }
8430
8431 $formatted_conversation[] = array(
8432 'role' => $role,
8433 'content' => $message['content']
8434 );
8435 }
8436 }
8437
8438 $body = json_encode([
8439 'model' => $selected_model,
8440 'messages' => $formatted_conversation,
8441 'temperature' => 0.8,
8442 'stream' => false
8443 ]);
8444
8445 $args = [
8446 'body' => $body,
8447 'headers' => [
8448 'Content-Type' => 'application/json',
8449 'Authorization' => 'Bearer ' . $deepseek_api_key,
8450 ],
8451 'timeout' => 60,
8452 'redirection' => 5,
8453 'blocking' => true,
8454 'httpversion' => '1.0',
8455 'sslverify' => true,
8456 ];
8457
8458 $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', $args);
8459
8460 if (is_wp_error($response)) {
8461 $error_message = $response->get_error_message();
8462 //error_log('DeepSeek API Error: ' . $error_message);
8463 return [
8464 'error' => esc_html__('Connection error when contacting DeepSeek: ', 'mxchat') . esc_html($error_message),
8465 'error_code' => 'deepseek_connection_error',
8466 'provider' => 'deepseek'
8467 ];
8468 }
8469
8470 $status_code = wp_remote_retrieve_response_code($response);
8471 if ($status_code !== 200) {
8472 $response_body = wp_remote_retrieve_body($response);
8473 $decoded_response = json_decode($response_body, true);
8474
8475 $error_message = isset($decoded_response['error']['message'])
8476 ? $decoded_response['error']['message']
8477 : 'HTTP Error ' . $status_code;
8478
8479 $error_type = isset($decoded_response['error']['type'])
8480 ? $decoded_response['error']['type']
8481 : 'unknown';
8482
8483 //error_log('DeepSeek API HTTP Error: ' . $status_code . ' - ' . $error_message);
8484
8485 // Handle specific error types
8486 switch ($status_code) {
8487 case 401:
8488 return [
8489 'error' => esc_html__('Authentication failed with DeepSeek. Please check your API key.', 'mxchat'),
8490 'error_code' => 'deepseek_auth_error',
8491 'provider' => 'deepseek'
8492 ];
8493
8494 case 400:
8495 if (strpos($error_message, 'API key') !== false) {
8496 return [
8497 'error' => esc_html__('Invalid DeepSeek API key. Please check your API key configuration.', 'mxchat'),
8498 'error_code' => 'deepseek_invalid_api_key',
8499 'provider' => 'deepseek'
8500 ];
8501 }
8502 break;
8503
8504 case 429:
8505 if (strpos($error_message, 'quota') !== false) {
8506 return [
8507 'error' => esc_html__('DeepSeek API quota exceeded. Please check your billing details.', 'mxchat'),
8508 'error_code' => 'deepseek_quota_exceeded',
8509 'provider' => 'deepseek'
8510 ];
8511 } else {
8512 return [
8513 'error' => esc_html__('DeepSeek rate limit exceeded. Please try again later.', 'mxchat'),
8514 'error_code' => 'deepseek_rate_limit',
8515 'provider' => 'deepseek'
8516 ];
8517 }
8518
8519 case 500:
8520 case 502:
8521 case 503:
8522 case 504:
8523 return [
8524 'error' => esc_html__('DeepSeek service is currently unavailable. Please try again later.', 'mxchat'),
8525 'error_code' => 'deepseek_service_unavailable',
8526 'provider' => 'deepseek'
8527 ];
8528 }
8529
8530 // Generic error fallback
8531 return [
8532 'error' => esc_html__('DeepSeek API error: ', 'mxchat') . esc_html($error_message),
8533 'error_code' => 'deepseek_api_error',
8534 'provider' => 'deepseek',
8535 'status_code' => $status_code
8536 ];
8537 }
8538
8539 $response_body = wp_remote_retrieve_body($response);
8540 $decoded_response = json_decode($response_body, true);
8541
8542 if (isset($decoded_response['choices'][0]['message']['content'])) {
8543 return trim($decoded_response['choices'][0]['message']['content']);
8544 } else {
8545 //error_log('DeepSeek API Response Format Error: ' . print_r($decoded_response, true));
8546 return [
8547 'error' => esc_html__('Unexpected response format from DeepSeek.', 'mxchat'),
8548 'error_code' => 'deepseek_response_format_error',
8549 'provider' => 'deepseek'
8550 ];
8551 }
8552 } catch (Exception $e) {
8553 //error_log('DeepSeek Exception: ' . $e->getMessage());
8554 return [
8555 'error' => esc_html__('System error when processing DeepSeek request: ', 'mxchat') . esc_html($e->getMessage()),
8556 'error_code' => 'deepseek_exception',
8557 'provider' => 'deepseek'
8558 ];
8559 }
8560 }
8561 private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content) {
8562 // Get bot ID from session or request
8563 $bot_id = $this->get_current_bot_id($session_id);
8564
8565 // Get system prompt instructions using centralized function
8566 $system_prompt_instructions = $this->get_system_instructions($bot_id);
8567
8568 // Add system prompt to relevant content
8569 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
8570
8571 // Format messages for Gemini API
8572 $formatted_messages = [];
8573
8574 // Add system message as the first user message with role prefix
8575 // Note: Gemini doesn't have a dedicated system role, so we use a prefixed user message
8576 $formatted_messages[] = [
8577 'role' => 'user',
8578 'parts' => [
8579 ['text' => "[System Instructions] " . $content_with_instructions]
8580 ]
8581 ];
8582
8583 // Add model response to acknowledge system instructions
8584 $formatted_messages[] = [
8585 'role' => 'model',
8586 'parts' => [
8587 ['text' => "I understand and will follow these instructions."]
8588 ]
8589 ];
8590
8591 // Process the rest of the conversation history
8592 $current_role = null;
8593 $current_parts = [];
8594
8595 foreach ($conversation_history as $message) {
8596 // Skip the first system message as we already handled it
8597 if ($message['role'] === 'system') {
8598 continue;
8599 }
8600
8601 // Map roles to Gemini format
8602 $gemini_role = '';
8603 if ($message['role'] === 'user') {
8604 $gemini_role = 'user';
8605 } else if (in_array($message['role'], ['assistant', 'bot', 'agent'])) {
8606 $gemini_role = 'model';
8607 } else {
8608 // Skip unsupported roles
8609 continue;
8610 }
8611
8612 // If we have a new role, add the previous message
8613 if ($current_role !== null && $current_role !== $gemini_role && !empty($current_parts)) {
8614 $formatted_messages[] = [
8615 'role' => $current_role,
8616 'parts' => $current_parts
8617 ];
8618 $current_parts = [];
8619 }
8620
8621 // Set current role and add text to parts
8622 $current_role = $gemini_role;
8623 $current_parts[] = ['text' => $message['content']];
8624 }
8625
8626 // Add the last message if there's content
8627 if ($current_role !== null && !empty($current_parts)) {
8628 $formatted_messages[] = [
8629 'role' => $current_role,
8630 'parts' => $current_parts
8631 ];
8632 }
8633
8634 // Build the request body
8635 $body = json_encode([
8636 'contents' => $formatted_messages,
8637 'generationConfig' => [
8638 'temperature' => 0.7,
8639 'topP' => 0.95,
8640 'topK' => 40,
8641 'maxOutputTokens' => 8192,
8642 ],
8643 'safetySettings' => [
8644 [
8645 'category' => 'HARM_CATEGORY_HARASSMENT',
8646 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
8647 ],
8648 [
8649 'category' => 'HARM_CATEGORY_HATE_SPEECH',
8650 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
8651 ],
8652 [
8653 'category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
8654 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
8655 ],
8656 [
8657 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT',
8658 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
8659 ]
8660 ]
8661 ]);
8662
8663 // Prepare the API endpoint
8664 $api_endpoint = 'https://generativelanguage.googleapis.com/v1/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
8665
8666 // Set up the API request
8667 $args = [
8668 'body' => $body,
8669 'headers' => [
8670 'Content-Type' => 'application/json',
8671 ],
8672 'timeout' => 60,
8673 'redirection' => 5,
8674 'blocking' => true,
8675 'httpversion' => '1.0',
8676 'sslverify' => true,
8677 ];
8678
8679 // Make the API request
8680 $response = wp_remote_post($api_endpoint, $args);
8681
8682 // Process the response
8683 if (is_wp_error($response)) {
8684 return "Sorry, there was an error processing your request: " . $response->get_error_message();
8685 }
8686
8687 $response_body = json_decode(wp_remote_retrieve_body($response), true);
8688
8689 // Handle potential errors in the response
8690 if (isset($response_body['error'])) {
8691 //error_log('Gemini API Error: ' . json_encode($response_body['error']));
8692 return "Sorry, there was an error with the Gemini API: " .
8693 (isset($response_body['error']['message']) ? $response_body['error']['message'] : 'Unknown error');
8694 }
8695
8696 // Extract the response text
8697 if (isset($response_body['candidates'][0]['content']['parts'][0]['text'])) {
8698 return trim($response_body['candidates'][0]['content']['parts'][0]['text']);
8699 } else {
8700 //error_log('Unexpected Gemini API response format: ' . json_encode($response_body));
8701 return "Sorry, I couldn't process that request. The response format was unexpected.";
8702 }
8703 }
8704
8705
8706 public function test_streaming_request() {
8707 $options = get_option('mxchat_options', []);
8708 $model = $options['model'] ?? 'gpt-4o';
8709
8710 // Detect provider from model prefix
8711 $provider = strtolower(explode('-', $model)[0]);
8712
8713 $sample_prompt = 'Hello! Can you stream this response back to me?';
8714 $messages = [['role' => 'user', 'content' => $sample_prompt]];
8715 $headers = [];
8716 $body = [];
8717 $url = '';
8718 $api_key = '';
8719
8720 switch ($provider) {
8721 case 'gpt':
8722 case 'o1':
8723 $api_key = $options['api_key'] ?? '';
8724 if (empty($api_key)) return '❌ Missing API key for OpenAI';
8725 $url = 'https://api.openai.com/v1/chat/completions';
8726 $headers = [
8727 'Content-Type: application/json',
8728 'Authorization: Bearer ' . $api_key
8729 ];
8730 $body = [
8731 'model' => $model,
8732 'messages' => $messages,
8733 'stream' => true
8734 ];
8735 break;
8736
8737 case 'claude':
8738 $api_key = $options['claude_api_key'] ?? '';
8739 if (empty($api_key)) return '❌ Missing API key for Claude';
8740 $url = 'https://api.anthropic.com/v1/messages';
8741 $headers = [
8742 'Content-Type: application/json',
8743 'x-api-key: ' . $api_key,
8744 'anthropic-version: 2023-06-01'
8745 ];
8746 $body = [
8747 'model' => $model,
8748 'messages' => $messages,
8749 'max_tokens' => 100,
8750 'stream' => true
8751 ];
8752 break;
8753
8754 case 'grok':
8755 $api_key = $options['xai_api_key'] ?? '';
8756 if (empty($api_key)) return '❌ Missing API key for X.AI';
8757 $url = 'https://api.x.ai/v1/chat/completions';
8758 $headers = [
8759 'Content-Type: application/json',
8760 'Authorization: Bearer ' . $api_key
8761 ];
8762 $body = [
8763 'model' => $model,
8764 'messages' => $messages,
8765 'stream' => true
8766 ];
8767 break;
8768
8769 case 'deepseek':
8770 if (empty($deepseek_api_key)) {
8771 $error_response = [
8772 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
8773 'error_code' => 'missing_deepseek_api_key'
8774 ];
8775 if ($testing_data !== null) {
8776 $error_response['testing_data'] = $testing_data;
8777 }
8778 return $error_response;
8779 }
8780 if ($streaming) {
8781 return $this->mxchat_generate_response_deepseek_stream(
8782 $selected_model,
8783 $deepseek_api_key,
8784 $conversation_history,
8785 $relevant_content,
8786 $session_id,
8787 $testing_data // Pass testing data
8788 );
8789 } else {
8790 $response = $this->mxchat_generate_response_deepseek(
8791 $selected_model,
8792 $deepseek_api_key,
8793 $conversation_history,
8794 $relevant_content
8795 );
8796 }
8797 break;
8798
8799 case 'gemini':
8800 $api_key = $options['gemini_api_key'] ?? '';
8801 if (empty($api_key)) return '❌ Missing API key for Gemini';
8802 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':streamGenerateContent?key=' . $api_key;
8803 $headers = ['Content-Type: application/json'];
8804 $body = [
8805 'contents' => [['role' => 'user', 'parts' => [['text' => $sample_prompt]]]],
8806 'generationConfig' => ['temperature' => 0.7]
8807 ];
8808 break;
8809
8810 default:
8811 return '❌ Unsupported provider: ' . $provider;
8812 }
8813
8814 // Do the actual streaming test
8815 $ch = curl_init($url);
8816 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
8817 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
8818 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
8819 curl_setopt($ch, CURLOPT_TIMEOUT, 15);
8820 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8821
8822 $response = curl_exec($ch);
8823 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
8824 $error = curl_error($ch);
8825 curl_close($ch);
8826
8827 if ($error) return "❌ cURL error: $error";
8828 if ($http_code !== 200) {
8829 $error_message = json_decode($response, true)['error']['message'] ?? 'Unknown';
8830 return "❌ HTTP $http_code: $error_message";
8831 }
8832
8833 return true;
8834 }
8835
8836 public function mxchat_dismiss_pre_chat_message() {
8837 // Get and sanitize the user identifier
8838 $user_id = $this->mxchat_get_user_identifier();
8839 $user_id = sanitize_key($user_id);
8840
8841 // Set a transient to track that the user has dismissed the pre-chat message
8842 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
8843 set_transient($transient_key, true, DAY_IN_SECONDS);
8844
8845 wp_send_json_success();
8846 }
8847
8848 public function mxchat_check_pre_chat_message_status() {
8849 // Get and sanitize the user identifier
8850 $user_id = $this->mxchat_get_user_identifier();
8851 $user_id = sanitize_key($user_id);
8852
8853 // Check if the transient exists (i.e., if the message was dismissed)
8854 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
8855 $dismissed = get_transient($transient_key);
8856
8857 // Log the result to see if it's being set correctly
8858 //error_log("Check pre-chat message dismissed for $user_id: " . ($dismissed ? 'Yes' : 'No'));
8859
8860 if ($dismissed) {
8861 wp_send_json_success(['dismissed' => true]);
8862 } else {
8863 wp_send_json_success(['dismissed' => false]);
8864 }
8865
8866 wp_die();
8867 }
8868
8869 private function mxchat_calculate_cosine_similarity($vectorA, $vectorB) {
8870 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
8871 return 0;
8872 }
8873
8874 $dotProduct = array_sum(array_map(function ($a, $b) {
8875 return $a * $b;
8876 }, $vectorA, $vectorB));
8877 $normA = sqrt(array_sum(array_map(function ($a) {
8878 return $a * $a;
8879 }, $vectorA)));
8880 $normB = sqrt(array_sum(array_map(function ($b) {
8881 return $b * $b;
8882 }, $vectorB)));
8883
8884 if ($normA == 0 || $normB == 0) {
8885 return 0;
8886 }
8887
8888 return $dotProduct / ($normA * $normB);
8889 }
8890
8891
8892 public function mxchat_enqueue_scripts_styles() {
8893 // Enqueue the script
8894 wp_enqueue_script(
8895 'mxchat-chat-js',
8896 plugin_dir_url(__FILE__) . '../js/chat-script.js',
8897 array('jquery'),
8898 MXCHAT_VERSION,
8899 true
8900 );
8901 // Enqueue the CSS
8902 wp_enqueue_style(
8903 'mxchat-chat-css',
8904 plugin_dir_url(__FILE__) . '../css/chat-style.css',
8905 array(),
8906 MXCHAT_VERSION
8907 );
8908 // Fetch options from the database
8909 $this->options = get_option('mxchat_options');
8910 $prompts_options = get_option('mxchat_prompts_options', array());
8911
8912 // Check if AI theme is active - if so, skip inline colors in JavaScript
8913 $theme_options = get_option('mxchat_theme_options', array());
8914 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
8915 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
8916 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
8917
8918 // Prepare settings for JavaScript
8919 $style_settings = array(
8920 'ajax_url' => admin_url('admin-ajax.php'),
8921 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
8922 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-4o',
8923 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
8924 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
8925 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
8926 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
8927 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
8928 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
8929 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
8930 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
8931 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
8932 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
8933 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
8934 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
8935 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
8936 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
8937 'icon_color' => $this->options['icon_color'] ?? '#fff',
8938 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
8939 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
8940 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
8941 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
8942 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
8943 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
8944 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
8945 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
8946 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
8947 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
8948 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off', // FIXED
8949 'initial_email_state' => null, // Also fixed this undefined variable
8950 'skip_email_check' => true,
8951 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
8952 'skip_inline_colors' => $skip_inline_colors,
8953 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
8954 );
8955 // Pass the settings to the script
8956 wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
8957 }
8958
8959
8960 /**
8961 * Setup the cron jobs for rate limits with guard against multiple calls
8962 */
8963 public function setup_rate_limit_cron_jobs() {
8964 // Add a guard to prevent multiple rapid calls
8965 $last_setup = get_transient('mxchat_cron_setup_guard');
8966 if ($last_setup && (time() - $last_setup) < 60) {
8967 // Don't run again if we ran less than 60 seconds ago
8968 return;
8969 }
8970
8971 // Set the guard
8972 set_transient('mxchat_cron_setup_guard', time(), 300); // 5 minutes
8973
8974 try {
8975 // First, check if WordPress cron is disabled
8976 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
8977 //error_log('MxChat: WordPress cron is disabled (DISABLE_WP_CRON = true), using fallback system');
8978 $this->setup_fallback_rate_limit_system();
8979 return;
8980 }
8981
8982 // Check if cron is already scheduled - if so, don't mess with it
8983 if (wp_next_scheduled('mxchat_reset_rate_limits')) {
8984 //error_log('MxChat: Rate limit cron already scheduled, skipping setup');
8985 return;
8986 }
8987
8988 // Clear any orphaned hooks (but don't loop indefinitely)
8989 $hooks_to_clear = [
8990 'mxchat_reset_rate_limits',
8991 'mxchat_reset_hourly_rate_limits',
8992 'mxchat_reset_daily_rate_limits',
8993 'mxchat_reset_weekly_rate_limits',
8994 'mxchat_reset_monthly_rate_limits'
8995 ];
8996
8997 foreach ($hooks_to_clear as $hook) {
8998 // Only clear a maximum of 3 instances to prevent infinite loops
8999 $cleared = 0;
9000 while (wp_next_scheduled($hook) && $cleared < 3) {
9001 wp_clear_scheduled_hook($hook);
9002 $cleared++;
9003 }
9004 }
9005
9006 // Small delay after clearing
9007 usleep(100000); // 0.1 seconds
9008
9009 // Try to schedule the event
9010 $initial_time = time() + 300; // Start in 5 minutes
9011 $result = wp_schedule_event($initial_time, 'hourly', 'mxchat_reset_rate_limits');
9012
9013 if ($result === false) {
9014 //error_log('MxChat: Failed to schedule cron, using fallback system');
9015 $this->setup_fallback_rate_limit_system();
9016 } else {
9017 //error_log('MxChat: Successfully scheduled rate limit reset cron');
9018 }
9019
9020 } catch (Exception $e) {
9021 //error_log('MxChat: Cron setup exception: ' . $e->getMessage());
9022 $this->setup_fallback_rate_limit_system();
9023 }
9024 }
9025
9026 /**
9027 * Try alternative cron scheduling methods
9028 */
9029 private function try_alternative_cron_scheduling($initial_time) {
9030 try {
9031 // Method 1: Try with current time instead of future time
9032 $result1 = wp_schedule_event(time(), 'hourly', 'mxchat_reset_rate_limits');
9033 if ($result1 !== false) {
9034 //error_log('MxChat: Alternative method 1 (current time) succeeded');
9035 return true;
9036 }
9037
9038 // Method 2: Try with a different interval
9039 $result2 = wp_schedule_event($initial_time, 'daily', 'mxchat_reset_rate_limits');
9040 if ($result2 !== false) {
9041 //error_log('MxChat: Alternative method 2 (daily interval) succeeded');
9042 return true;
9043 }
9044
9045 // Method 3: Try wp_schedule_single_event first, then recurring
9046 $result3 = wp_schedule_single_event($initial_time, 'mxchat_reset_rate_limits');
9047 if ($result3 !== false) {
9048 //error_log('MxChat: Alternative method 3 (single event) succeeded');
9049 // Schedule the next one manually in the handler
9050 return true;
9051 }
9052
9053 return false;
9054
9055 } catch (Exception $e) {
9056 //error_log('MxChat: Alternative cron scheduling exception: ' . $e->getMessage());
9057 return false;
9058 }
9059 }
9060
9061 /**
9062 * Enhanced fallback rate limit system
9063 */
9064 private function setup_fallback_rate_limit_system() {
9065 // Set a flag to use database-based rate limit cleanup
9066 update_option('mxchat_use_fallback_rate_limits', true);
9067
9068 // Schedule a one-time check to happen on the next plugin load
9069 update_option('mxchat_next_rate_limit_check', time() + 3600);
9070
9071 // Also set up a more frequent fallback check (every 4 hours)
9072 update_option('mxchat_fallback_check_interval', 4 * 3600);
9073
9074 //error_log('MxChat: Fallback rate limit system activated');
9075 }
9076
9077 /**
9078 * Enhanced fallback check method
9079 */
9080 public function check_fallback_rate_limits() {
9081 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9082
9083 if (!$use_fallback) {
9084 return; // Regular cron is working
9085 }
9086
9087 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9088 $check_interval = get_option('mxchat_fallback_check_interval', 3600);
9089
9090 if (time() >= $next_check) {
9091 //error_log('MxChat: Running fallback rate limit cleanup');
9092 $this->mxchat_reset_rate_limits();
9093
9094 // Schedule next check
9095 update_option('mxchat_next_rate_limit_check', time() + $check_interval);
9096 }
9097 }
9098 /**
9099 * Enhanced rate limit check that includes fallback cleanup and bot-specific rate limits
9100 */
9101 public function check_rate_limit() {
9102 // Check if we need to run fallback cleanup
9103 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9104 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9105
9106 if ($use_fallback && time() >= $next_check) {
9107 $this->mxchat_reset_rate_limits();
9108 update_option('mxchat_next_rate_limit_check', time() + 3600); // Next hour
9109 }
9110
9111 // Get bot ID from current request context
9112 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
9113
9114 // Get bot-specific options (includes rate limits if overridden)
9115 $bot_options = $this->get_bot_options($bot_id);
9116 $current_options = !empty($bot_options) ? $bot_options : $this->options;
9117
9118 // Use bot-specific rate limits if available, otherwise fall back to default
9119 $rate_limits_source = isset($current_options['rate_limits']) ? $current_options['rate_limits'] : get_option('mxchat_options', [])['rate_limits'] ?? [];
9120
9121 // Determine user role or if logged out
9122 if (is_user_logged_in()) {
9123 $user = wp_get_current_user();
9124 $user_id = $user->ID;
9125
9126 // Get the user's primary role using reset() to safely get the first element
9127 $user_roles = $user->roles;
9128
9129 // Safely get the first role regardless of array key structure
9130 if (!empty($user_roles) && is_array($user_roles)) {
9131 $role = reset($user_roles); // This safely gets the first element regardless of key
9132 } else {
9133 $role = 'subscriber'; // Default to subscriber if no role found
9134 }
9135 } else {
9136 $role = 'logged_out';
9137 // Use IP address for non-logged-in users
9138 $user_id = $this->get_client_ip();
9139 }
9140
9141 // Check if rate limits are configured for this role
9142 if (!isset($rate_limits_source[$role])) {
9143 return true; // No limit set for this role
9144 }
9145
9146 $limit = $rate_limits_source[$role]['limit'];
9147
9148 // If unlimited, return true immediately
9149 if ($limit === 'unlimited') {
9150 return true;
9151 }
9152
9153 // Get the option name for this user/role with safer naming (include bot_id for bot-specific limits)
9154 $safe_role = preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
9155 $safe_user_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $user_id);
9156 $safe_bot_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id);
9157
9158 // Include bot_id in option name so each bot has separate rate limits
9159 $option_name = 'mxchat_chat_limit_' . $safe_bot_id . '_' . $safe_role . '_' . $safe_user_id;
9160
9161 // Get the counter data
9162 $limit_data = get_option($option_name, ['count' => 0, 'timestamp' => time()]);
9163
9164 // If first request or counter reset needed, set the initial timestamp
9165 if ($limit_data['count'] === 0) {
9166 $limit_data['timestamp'] = time();
9167 update_option($option_name, $limit_data);
9168 }
9169
9170 // Get the timeframe
9171 $timeframe = isset($rate_limits_source[$role]['timeframe']) ?
9172 $rate_limits_source[$role]['timeframe'] : 'daily';
9173
9174 // Check if the counter needs to be reset based on timeframe
9175 $current_time = time();
9176 $timestamp = $limit_data['timestamp'];
9177 $should_reset = false;
9178
9179 switch ($timeframe) {
9180 case 'hourly':
9181 $should_reset = ($current_time - $timestamp) >= 3600; // 1 hour
9182 break;
9183 case 'daily':
9184 $should_reset = ($current_time - $timestamp) >= 86400; // 24 hours
9185 break;
9186 case 'weekly':
9187 $should_reset = ($current_time - $timestamp) >= 604800; // 7 days
9188 break;
9189 case 'monthly':
9190 $should_reset = ($current_time - $timestamp) >= 2592000; // 30 days
9191 break;
9192 }
9193
9194 // Reset the counter if the timeframe has passed
9195 if ($should_reset) {
9196 $limit_data = ['count' => 0, 'timestamp' => $current_time];
9197 update_option($option_name, $limit_data);
9198 }
9199
9200 // Check if user has exceeded their limit
9201 if ($limit_data['count'] >= intval($limit)) {
9202 // Get the custom message for this role
9203 $message = !empty($rate_limits_source[$role]['message'])
9204 ? $rate_limits_source[$role]['message']
9205 : __('Rate limit exceeded. Please try again later.', 'mxchat');
9206
9207 // Add timeframe information to the message if placeholders exist
9208 $timeframe_label = '';
9209 switch ($timeframe) {
9210 case 'hourly':
9211 $timeframe_label = __('hour', 'mxchat');
9212 break;
9213 case 'daily':
9214 $timeframe_label = __('day', 'mxchat');
9215 break;
9216 case 'weekly':
9217 $timeframe_label = __('week', 'mxchat');
9218 break;
9219 case 'monthly':
9220 $timeframe_label = __('month', 'mxchat');
9221 break;
9222 }
9223
9224 // Replace placeholders in the message
9225 $message = str_replace(
9226 ['{limit}', '{count}', '{remaining}', '{timeframe}'],
9227 [intval($limit), $limit_data['count'], max(0, intval($limit) - $limit_data['count']), $timeframe_label],
9228 $message
9229 );
9230
9231 // Process HTML links in the message
9232 $message = $this->process_rate_limit_message_html($message);
9233
9234 // Return error with the processed message
9235 return [
9236 'error' => true,
9237 'message' => $message
9238 ];
9239 }
9240
9241 // Increment the counter
9242 $limit_data['count']++;
9243 update_option($option_name, $limit_data);
9244
9245 return true;
9246 }
9247
9248 /**
9249 * Enhanced rate limit reset with better error handling
9250 */
9251 public function mxchat_reset_rate_limits() {
9252 try {
9253 global $wpdb;
9254 $all_options = get_option('mxchat_options', []);
9255 $current_time = time();
9256
9257 // Get rate limit options with a safer query and limit
9258 $option_names = $wpdb->get_col(
9259 $wpdb->prepare(
9260 "SELECT option_name FROM {$wpdb->options}
9261 WHERE option_name LIKE %s
9262 LIMIT 1000",
9263 'mxchat_chat_limit_%'
9264 )
9265 );
9266
9267 if (empty($option_names)) {
9268 return;
9269 }
9270
9271 $processed_count = 0;
9272 $max_processing_time = 30; // Maximum 30 seconds
9273 $start_time = time();
9274
9275 foreach ($option_names as $option_name) {
9276 // Check processing time limit
9277 if ((time() - $start_time) > $max_processing_time) {
9278 //error_log('MxChat: Rate limit reset timeout after processing ' . $processed_count . ' entries');
9279 break;
9280 }
9281
9282 // Parse the option name more safely
9283 if (!preg_match('/^mxchat_chat_limit_(.+)_(.+)$/', $option_name, $matches)) {
9284 continue;
9285 }
9286
9287 $role_and_user = $matches[1] . '_' . $matches[2];
9288 $parts = explode('_', $role_and_user);
9289
9290 if (count($parts) < 2) {
9291 continue;
9292 }
9293
9294 // Extract role (everything except the last part which is user ID)
9295 $user_id_part = array_pop($parts);
9296 $role = implode('_', $parts);
9297
9298 // Skip if role doesn't exist in our settings
9299 if (!isset($all_options['rate_limits'][$role])) {
9300 // Clean up orphaned entries
9301 delete_option($option_name);
9302 continue;
9303 }
9304
9305 $timeframe = $all_options['rate_limits'][$role]['timeframe'] ?? 'daily';
9306 $limit_data = get_option($option_name);
9307
9308 if (!$limit_data || !is_array($limit_data) || !isset($limit_data['timestamp'])) {
9309 // Clean up invalid entries
9310 delete_option($option_name);
9311 continue;
9312 }
9313
9314 $timestamp = $limit_data['timestamp'];
9315 $should_reset = false;
9316
9317 // Determine if we should reset based on the timeframe
9318 switch ($timeframe) {
9319 case 'hourly':
9320 $should_reset = ($current_time - $timestamp) >= 3600;
9321 break;
9322 case 'daily':
9323 $should_reset = ($current_time - $timestamp) >= 86400;
9324 break;
9325 case 'weekly':
9326 $should_reset = ($current_time - $timestamp) >= 604800;
9327 break;
9328 case 'monthly':
9329 $should_reset = ($current_time - $timestamp) >= 2592000;
9330 break;
9331 }
9332
9333 // Reset the counter if the timeframe has passed
9334 if ($should_reset) {
9335 delete_option($option_name);
9336 wp_cache_delete($option_name, 'options');
9337 $processed_count++;
9338 }
9339 }
9340
9341 // Clean up any orphaned cache entries
9342 wp_cache_delete('mxchat_all_chat_limits', 'options');
9343
9344 //error_log("MxChat: Rate limit reset completed. Processed {$processed_count} entries.");
9345
9346 } catch (Exception $e) {
9347 //error_log('MxChat: Rate limit reset error: ' . $e->getMessage());
9348 }
9349 }
9350
9351
9352 /**
9353 * Process HTML links in rate limit messages
9354 *
9355 * @param string $message The rate limit message
9356 * @return string The processed message with safe HTML links
9357 */
9358 private function process_rate_limit_message_html($message) {
9359 // Return original message if empty
9360 if (empty($message)) {
9361 return $message;
9362 }
9363
9364 // First, convert markdown links to HTML
9365 $message = $this->convert_markdown_links($message);
9366
9367 // Then, auto-convert any remaining plain URLs to links
9368 $message = $this->auto_link_urls($message);
9369
9370 // Allow basic HTML tags for links and formatting
9371 $allowed_tags = [
9372 'a' => [
9373 'href' => true,
9374 'target' => true,
9375 'rel' => true,
9376 'title' => true,
9377 'class' => true
9378 ],
9379 'strong' => [],
9380 'em' => [],
9381 'br' => [],
9382 'b' => [],
9383 'i' => [],
9384 'span' => ['class' => true]
9385 ];
9386
9387 // Sanitize but allow the specified HTML tags
9388 $processed_message = wp_kses($message, $allowed_tags);
9389
9390 // If wp_kses stripped everything, return the original message as plain text
9391 if (empty($processed_message) && !empty($message)) {
9392 // Strip all HTML and return plain text as fallback
9393 return wp_strip_all_tags($message);
9394 }
9395
9396 return $processed_message;
9397 }
9398
9399 /**
9400 * Convert markdown links to HTML
9401 *
9402 * @param string $text The text to process
9403 * @return string The text with markdown links converted to HTML
9404 */
9405 private function convert_markdown_links($text) {
9406 // Return original text if empty
9407 if (empty($text)) {
9408 return $text;
9409 }
9410
9411 // Pattern to match markdown links: [text](url)
9412 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
9413
9414 $processed_text = preg_replace_callback($pattern, function($matches) {
9415 $link_text = $matches[1];
9416 $url = $matches[2];
9417
9418 // Clean up any trailing punctuation from the URL
9419 $url = rtrim($url, '.,;:!?');
9420
9421 // Sanitize the link text and URL
9422 $safe_text = esc_html($link_text);
9423 $safe_url = esc_url($url);
9424
9425 // Create the HTML link
9426 return '<a href="' . $safe_url . '" target="_blank" rel="noopener noreferrer">' . $safe_text . '</a>';
9427 }, $text);
9428
9429 // If preg_replace_callback failed, return original text
9430 if ($processed_text === null) {
9431 return $text;
9432 }
9433
9434 return $processed_text;
9435 }
9436
9437 /**
9438 * Auto-convert plain URLs to clickable links
9439 *
9440 * @param string $text The text to process
9441 * @return string The text with URLs converted to links
9442 */
9443 private function auto_link_urls($text) {
9444 // Return original text if empty
9445 if (empty($text)) {
9446 return $text;
9447 }
9448
9449 // Simple pattern that avoids complex lookbehinds
9450 // This will match URLs that are not already inside href attributes or markdown links
9451 $pattern = '/(?<!href=["\'])(?<!\]\()https?:\/\/[^\s<>"\')\]]+/i';
9452
9453 $processed_text = preg_replace_callback($pattern, function($matches) {
9454 $url = $matches[0];
9455 // Clean up any trailing punctuation that might have been captured
9456 $url = rtrim($url, '.,;:!?');
9457
9458 // Add target="_blank" and rel="noopener noreferrer" for security
9459 return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
9460 }, $text);
9461
9462 // If preg_replace_callback failed, return original text
9463 if ($processed_text === null) {
9464 return $text;
9465 }
9466
9467 return $processed_text;
9468 }
9469
9470
9471 // Helper function to get client IP address
9472 private function get_client_ip() {
9473 // Check for shared internet/ISP IP
9474 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
9475 return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
9476 }
9477
9478 // Check for IPs passing through proxies
9479 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
9480 // Use the first value in the comma-separated list
9481 $forwarded_for = explode(',', sanitize_text_field($_SERVER['HTTP_X_FORWARDED_FOR']));
9482 return trim($forwarded_for[0]);
9483 }
9484
9485 if (!empty($_SERVER['REMOTE_ADDR'])) {
9486 return sanitize_text_field($_SERVER['REMOTE_ADDR']);
9487 }
9488
9489 // Fallback
9490 return 'unknown';
9491 }
9492
9493 /**
9494 * AJAX handler to get system information for testing panel
9495 */
9496 /**
9497 * AJAX handler to get system information for testing panel
9498 */
9499 public function mxchat_get_system_info() {
9500 // Verify nonce for security
9501 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
9502 wp_send_json_error(['message' => 'Invalid nonce']);
9503 return;
9504 }
9505
9506 // Only allow admin users
9507 if (!current_user_can('administrator')) {
9508 wp_send_json_error(['message' => 'Unauthorized']);
9509 return;
9510 }
9511
9512 // Get system prompt from options
9513 $system_prompt = isset($this->options['system_prompt_instructions'])
9514 ? $this->options['system_prompt_instructions']
9515 : 'No system prompt configured';
9516
9517 // Get selected model
9518 $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-4o';
9519
9520 // Check if OpenRouter is being used
9521 $is_openrouter = ($selected_model === 'openrouter');
9522 $openrouter_model = '';
9523
9524 if ($is_openrouter) {
9525 // Get the actual OpenRouter model that's selected
9526 $openrouter_model = isset($this->options['openrouter_selected_model'])
9527 ? $this->options['openrouter_selected_model']
9528 : 'No OpenRouter model selected';
9529
9530 // Update selected_model display to show both
9531 $selected_model = 'OpenRouter: ' . $openrouter_model;
9532 }
9533
9534 // Get API key status (just check if they exist, don't expose the keys)
9535 $api_status = [];
9536 $api_status['openai'] = !empty($this->options['api_key']);
9537 $api_status['claude'] = !empty($this->options['claude_api_key']);
9538 $api_status['gemini'] = !empty($this->options['gemini_api_key']);
9539 $api_status['xai'] = !empty($this->options['xai_api_key']);
9540 $api_status['deepseek'] = !empty($this->options['deepseek_api_key']);
9541 $api_status['openrouter'] = !empty($this->options['openrouter_api_key']);
9542
9543 wp_send_json_success([
9544 'system_prompt' => $system_prompt,
9545 'selected_model' => $selected_model,
9546 'is_openrouter' => $is_openrouter,
9547 'openrouter_model' => $openrouter_model,
9548 'api_status' => $api_status
9549 ]);
9550 }
9551
9552 /**
9553 * AJAX handler to get similarity threshold
9554 */
9555 public function mxchat_get_similarity_threshold() {
9556 // Verify nonce for security
9557 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
9558 wp_send_json_error(['message' => 'Invalid nonce']);
9559 return;
9560 }
9561
9562 // Only allow admin users
9563 if (!current_user_can('administrator')) {
9564 wp_send_json_error(['message' => 'Unauthorized']);
9565 return;
9566 }
9567
9568 // Get similarity threshold from main options (default 35%)
9569 $similarity_threshold = isset($this->options['similarity_threshold'])
9570 ? ((int) $this->options['similarity_threshold']) / 100
9571 : 0.35;
9572
9573 wp_send_json_success([
9574 'threshold' => $similarity_threshold,
9575 'threshold_percentage' => ($similarity_threshold * 100) . '%'
9576 ]);
9577 }
9578
9579 /**
9580 * AJAX handler to get knowledge base status
9581 */
9582 public function mxchat_get_kb_status() {
9583 // Verify nonce for security
9584 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
9585 wp_send_json_error(['message' => 'Invalid nonce']);
9586 return;
9587 }
9588
9589 // Only allow admin users
9590 if (!current_user_can('administrator')) {
9591 wp_send_json_error(['message' => 'Unauthorized']);
9592 return;
9593 }
9594
9595 // Check OpenAI Vector Store first (takes priority)
9596 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
9597 $use_vectorstore = (isset($vectorstore_options['mxchat_use_openai_vectorstore']) && $vectorstore_options['mxchat_use_openai_vectorstore'] === '1');
9598
9599 if ($use_vectorstore) {
9600 $vectorstore_ids = $vectorstore_options['mxchat_openai_vectorstore_ids'] ?? '';
9601 $id_count = !empty($vectorstore_ids) ? count(array_filter(array_map('trim', explode(',', $vectorstore_ids)))) : 0;
9602
9603 $kb_info = [
9604 'type' => 'OpenAI Vector Store',
9605 'status' => 'Active',
9606 'documents' => $id_count > 0 ? $id_count . ' vector store' . ($id_count > 1 ? 's' : '') . ' configured' : 'No vector stores configured'
9607 ];
9608
9609 wp_send_json_success($kb_info);
9610 return;
9611 }
9612
9613 // Check Pinecone vs WordPress
9614 $addon_options = get_option('mxchat_pinecone_addon_options', array());
9615 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
9616
9617 $kb_info = [
9618 'type' => $use_pinecone ? 'Pinecone' : 'WordPress Database',
9619 'status' => 'Active'
9620 ];
9621
9622 // Get document count
9623 if ($use_pinecone) {
9624 $kb_info['documents'] = 'Connected to Pinecone';
9625 $kb_info['api_configured'] = !empty($addon_options['mxchat_pinecone_api_key']);
9626 } else {
9627 // Count documents in WordPress database
9628 global $wpdb;
9629 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9630 $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
9631 $kb_info['documents'] = $count ? $count . ' documents' : 'No documents';
9632 }
9633
9634 wp_send_json_success($kb_info);
9635 }
9636
9637 /**
9638 * AJAX handler to start a completely fresh session (NEW - replaces old clear session)
9639 */
9640 public function mxchat_start_fresh_session() {
9641 // Verify nonce for security
9642 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
9643 wp_send_json_error(['message' => 'Invalid nonce']);
9644 return;
9645 }
9646
9647 // Only allow admin users
9648 if (!current_user_can('administrator')) {
9649 wp_send_json_error(['message' => 'Unauthorized']);
9650 return;
9651 }
9652
9653 $old_session_id = isset($_POST['old_session_id']) ? sanitize_text_field($_POST['old_session_id']) : '';
9654 $new_session_id = isset($_POST['new_session_id']) ? sanitize_text_field($_POST['new_session_id']) : '';
9655
9656 if (empty($old_session_id)) {
9657 wp_send_json_error(['message' => 'Old session ID required']);
9658 return;
9659 }
9660
9661 // If no new session ID provided, generate one
9662 if (empty($new_session_id)) {
9663 $new_session_id = 'mxchat_chat_' . substr(md5(uniqid()), 0, 9);
9664 }
9665
9666 // Clear ALL data associated with the old session
9667 $this->clear_complete_session_data($old_session_id);
9668
9669 // Initialize the new session
9670 $this->initialize_fresh_session($new_session_id);
9671
9672 wp_send_json_success([
9673 'message' => 'Fresh session started successfully',
9674 'new_session_id' => $new_session_id,
9675 'old_session_id' => $old_session_id
9676 ]);
9677 }
9678
9679 /**
9680 * Clear ALL data associated with a session (ENHANCED)
9681 */
9682 private function clear_complete_session_data($session_id) {
9683 // Clear chat history
9684 delete_option("mxchat_history_{$session_id}");
9685
9686 // Clear chat mode
9687 delete_option("mxchat_mode_{$session_id}");
9688
9689 // Clear any PDF/Word transients
9690 $this->clear_pdf_transients($session_id);
9691 if (method_exists($this, 'clear_word_transients')) {
9692 $this->clear_word_transients($session_id);
9693 }
9694
9695 // Clear agent-related data
9696 delete_option("mxchat_channel_{$session_id}");
9697 delete_option("mxchat_agent_name_{$session_id}");
9698 delete_option("mxchat_email_{$session_id}");
9699
9700 // Clear any recommendation flow state
9701 delete_option("mxchat_sr_flow_state_{$session_id}");
9702
9703 // Clear any cached embeddings or context
9704 delete_transient("mxchat_context_{$session_id}");
9705 delete_transient("mxchat_last_query_{$session_id}");
9706
9707 // Clear any testing data
9708 delete_transient("mxchat_testing_data_{$session_id}");
9709
9710 // Clear any rate limiting data for this session
9711 delete_transient("mxchat_rate_limit_{$session_id}");
9712
9713 // Clear any other session-specific transients
9714 delete_transient("mxchat_waiting_for_pdf_url_{$session_id}");
9715 delete_transient("mxchat_include_pdf_in_context_{$session_id}");
9716 delete_transient("mxchat_include_word_in_context_{$session_id}");
9717
9718 // Clear form addon state (pending forms and submitted forms)
9719 delete_option("mxchat_pending_form_{$session_id}");
9720 delete_option("mxchat_submitted_forms_{$session_id}");
9721
9722 //error_log("MxChat: Cleared all data for session: {$session_id}");
9723 }
9724
9725 /**
9726 * Initialize a fresh session with default data
9727 */
9728 private function initialize_fresh_session($session_id) {
9729 // Set default chat mode
9730 update_option("mxchat_mode_{$session_id}", 'ai');
9731
9732 //error_log("MxChat: Initialized fresh session: {$session_id}");
9733 }
9734
9735 /**
9736 * Helper method to clear Word document transients (if you have Word support)
9737 */
9738 private function clear_word_transients($session_id) {
9739 delete_transient('mxchat_word_url_' . $session_id);
9740 delete_transient('mxchat_word_filename_' . $session_id);
9741 delete_transient('mxchat_word_embeddings_' . $session_id);
9742 delete_transient('mxchat_include_word_in_context_' . $session_id);
9743 }
9744
9745 /**
9746 * Simplified testing data capture method (CLEANED UP)
9747 */
9748 private function capture_testing_data($user_embedding, $message, $session_id) {
9749 // Only capture for admin users
9750 if (!current_user_can('administrator')) {
9751 return null;
9752 }
9753
9754 $testing_data = [
9755 'query' => $message,
9756 'timestamp' => time(),
9757 'top_matches' => [],
9758 'action_matches' => [] // Add action matches
9759 ];
9760
9761 // Get similarity threshold
9762 $similarity_threshold = isset($this->options['similarity_threshold'])
9763 ? ((int) $this->options['similarity_threshold']) / 100
9764 : 0.35;
9765
9766 $testing_data['similarity_threshold'] = $similarity_threshold;
9767
9768 // Use the real similarity analysis if available
9769 if ($this->last_similarity_analysis !== null) {
9770 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
9771 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
9772 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
9773 } else {
9774 // Fallback: determine knowledge base type
9775 $addon_options = get_option('mxchat_pinecone_addon_options', array());
9776 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
9777
9778 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
9779 }
9780
9781 // Include action analysis if available
9782 if (isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
9783 $testing_data['action_matches'] = $this->last_action_analysis;
9784
9785 // Clear it after capturing to avoid stale data
9786 $this->last_action_analysis = null;
9787 }
9788
9789 return $testing_data;
9790 }
9791
9792
9793 /**
9794 * Track URL clicks from chatbot responses
9795 */
9796 public function mxchat_track_url_click() {
9797 // Verify nonce for security
9798 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
9799 wp_send_json_error(['message' => 'Invalid nonce']);
9800 wp_die();
9801 }
9802
9803 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
9804 $clicked_url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
9805 $message_context = isset($_POST['message_context']) ? sanitize_textarea_field($_POST['message_context']) : '';
9806
9807 if (empty($session_id) || empty($clicked_url)) {
9808 wp_send_json_error(['message' => 'Missing required data']);
9809 wp_die();
9810 }
9811
9812 global $wpdb;
9813 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
9814
9815 // Insert click tracking record
9816 $wpdb->insert(
9817 $table_name,
9818 [
9819 'session_id' => $session_id,
9820 'clicked_url' => $clicked_url,
9821 'message_context' => $message_context,
9822 'click_timestamp' => current_time('mysql', 1),
9823 'user_ip' => $_SERVER['REMOTE_ADDR'],
9824 'user_agent' => $_SERVER['HTTP_USER_AGENT']
9825 ]
9826 );
9827
9828 wp_send_json_success(['message' => 'Click tracked']);
9829 wp_die();
9830 }
9831
9832 /**
9833 * Get URL click analytics for a session
9834 */
9835 public function mxchat_get_url_clicks($session_id) {
9836 global $wpdb;
9837 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
9838
9839 $clicks = $wpdb->get_results($wpdb->prepare(
9840 "SELECT * FROM $table_name WHERE session_id = %s ORDER BY click_timestamp ASC",
9841 $session_id
9842 ));
9843
9844 return $clicks;
9845 }
9846 /**
9847 * Track the originating page where chat was started
9848 */
9849 public function mxchat_track_originating_page() {
9850 // Verify nonce
9851 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
9852 wp_send_json_error(['message' => 'Invalid nonce']);
9853 wp_die();
9854 }
9855
9856 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
9857 $page_url = isset($_POST['page_url']) ? esc_url_raw($_POST['page_url']) : '';
9858 $page_title = isset($_POST['page_title']) ? sanitize_text_field($_POST['page_title']) : '';
9859
9860 if (empty($session_id)) {
9861 wp_send_json_error(['message' => 'Missing session ID']);
9862 wp_die();
9863 }
9864
9865 global $wpdb;
9866 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
9867
9868 // Check if we've already tracked for this session
9869 $existing = $wpdb->get_var($wpdb->prepare(
9870 "SELECT COUNT(*) FROM $table_name
9871 WHERE session_id = %s
9872 AND originating_page_url IS NOT NULL",
9873 $session_id
9874 ));
9875
9876 if ($existing > 0) {
9877 wp_send_json_success(['message' => 'Already tracked']);
9878 wp_die();
9879 }
9880
9881 // Update the first message in this session with originating page info
9882 $wpdb->query($wpdb->prepare(
9883 "UPDATE $table_name
9884 SET originating_page_url = %s,
9885 originating_page_title = %s
9886 WHERE session_id = %s
9887 ORDER BY timestamp ASC
9888 LIMIT 1",
9889 $page_url,
9890 $page_title,
9891 $session_id
9892 ));
9893
9894 wp_send_json_success(['message' => 'Originating page tracked']);
9895 wp_die();
9896 }
9897
9898 /**
9899 * Validate and clean URLs from AI response
9900 * Removes any URLs that aren't in the knowledge base
9901 *
9902 * @param string $response_text The AI-generated response
9903 * @param array $valid_urls Array of URLs from the knowledge base
9904 * @return string Cleaned response with invalid URLs removed/flagged
9905 */
9906 private function validate_and_clean_urls($response_text, $valid_urls) {
9907 // DEBUG: Log what we're working with
9908 error_log("=== MxChat URL Validation Debug ===");
9909 error_log("Valid URLs count: " . count($valid_urls));
9910 error_log("Valid URLs: " . print_r($valid_urls, true));
9911 error_log("Response text length: " . strlen($response_text));
9912 error_log("Response text preview: " . substr($response_text, 0, 500));
9913
9914 // If no valid URLs provided or empty response, return as-is
9915 if (empty($valid_urls) || empty($response_text)) {
9916 error_log("Validation skipped - empty valid_urls or response");
9917 return $response_text;
9918 }
9919
9920 // Extract all URLs from the AI response
9921 // This regex matches http:// and https:// URLs
9922 preg_match_all(
9923 '#\bhttps?://[^\s<>"\')\]]+#i',
9924 $response_text,
9925 $matches
9926 );
9927
9928 // If no URLs found in response, return as-is
9929 if (empty($matches[0])) {
9930 error_log("No URLs found in response");
9931 return $response_text;
9932 }
9933
9934 $found_urls = $matches[0];
9935 $cleaned_response = $response_text;
9936 $removed_count = 0;
9937
9938 // Normalize valid URLs for comparison (remove trailing slashes, fragments, etc.)
9939 $normalized_valid_urls = array_map(function($url) {
9940 // Remove trailing slash
9941 $url = rtrim($url, '/');
9942 // Remove URL fragments (#section)
9943 $url = preg_replace('/#.*$/', '', $url);
9944 // Remove trailing punctuation that might have been captured
9945 $url = rtrim($url, '.,;:!?');
9946 return $url;
9947 }, $valid_urls);
9948
9949 error_log("Normalized valid URLs: " . print_r($normalized_valid_urls, true));
9950
9951 foreach ($found_urls as $found_url) {
9952 // Clean up the found URL (remove trailing punctuation that might have been captured)
9953 $clean_found_url = rtrim($found_url, '.,;:!?)');
9954
9955 // DEBUG: Log each URL being checked
9956 error_log("Checking found URL: " . $found_url);
9957
9958 // Normalize for comparison
9959 $normalized_found = rtrim($clean_found_url, '/');
9960 $normalized_found = preg_replace('/#.*$/', '', $normalized_found);
9961
9962 error_log("Normalized found URL: " . $normalized_found);
9963
9964 // Check if this URL exists in our valid URLs list
9965 $is_valid = false;
9966
9967 error_log("Starting validation checks for: " . $normalized_found);
9968
9969 // First, try exact match
9970 if (in_array($normalized_found, $normalized_valid_urls)) {
9971 $is_valid = true;
9972 error_log("EXACT MATCH FOUND");
9973 } else {
9974 error_log("No exact match, checking variations...");
9975 // If no exact match, check if it's a variation (with query params, etc.)
9976 foreach ($normalized_valid_urls as $valid_url) {
9977 error_log(" Comparing against valid URL: " . $valid_url);
9978
9979 // Check if the found URL starts with a valid URL (handles query params)
9980 if (strpos($normalized_found, $valid_url) === 0) {
9981 // Check what comes after the valid URL
9982 $remainder = substr($normalized_found, strlen($valid_url));
9983
9984 // Only valid if:
9985 // 1. Exact match (remainder is empty)
9986 // 2. Query params (starts with ?)
9987 // 3. Fragment (starts with #)
9988 if (empty($remainder) || $remainder[0] === '?' || $remainder[0] === '#') {
9989 $is_valid = true;
9990 error_log(" MATCH: Found URL is valid variation of base URL");
9991 break;
9992 } else {
9993 error_log(" NOT A MATCH: Found URL extends path beyond valid URL (remainder: " . $remainder . ")");
9994 }
9995 }
9996 // Also check the reverse (in case valid URL has query params)
9997 if (strpos($valid_url, $normalized_found) === 0) {
9998 $is_valid = true;
9999 error_log(" MATCH: Valid URL starts with found URL");
10000 break;
10001 }
10002 }
10003
10004 if (!$is_valid) {
10005 error_log("NO MATCH FOUND - URL should be removed");
10006 }
10007 }
10008
10009 // If URL is not valid, remove it from the response
10010 if (!$is_valid) {
10011 // Log the removal for debugging
10012 error_log("MxChat: Removed hallucinated URL: " . $found_url);
10013 error_log("MxChat: Valid URLs were: " . implode(', ', array_slice($normalized_valid_urls, 0, 5)));
10014
10015 $removed_count++;
10016
10017 // Check if URL is part of a markdown link: [text](url)
10018 $markdown_pattern = '/\[([^\]]+)\]\(' . preg_quote($found_url, '/') . '\)/';
10019 if (preg_match($markdown_pattern, $cleaned_response)) {
10020 error_log("Found markdown link, removing but keeping text");
10021 // Remove the markdown link but keep the text
10022 $cleaned_response = preg_replace($markdown_pattern, '$1', $cleaned_response);
10023 }
10024 // Check if URL is part of an HTML link: <a href="url">text</a>
10025 else if (preg_match('/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>(.*?)<\/a>/i', $cleaned_response, $link_match)) {
10026 error_log("Found HTML link, removing but keeping text");
10027 // Remove the HTML link but keep the text
10028 $link_text = $link_match[1];
10029 $cleaned_response = preg_replace(
10030 '/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>.*?<\/a>/i',
10031 $link_text,
10032 $cleaned_response
10033 );
10034 }
10035 // Otherwise just remove the bare URL
10036 else {
10037 error_log("Removing bare URL");
10038 $cleaned_response = str_replace($found_url, '', $cleaned_response);
10039 }
10040 }
10041 }
10042
10043 // Log summary if any URLs were removed
10044 if ($removed_count > 0) {
10045 error_log("MxChat: URL Validation Summary - Removed {$removed_count} hallucinated URL(s)");
10046 } else {
10047 error_log("MxChat: URL Validation Summary - No URLs removed, all were valid");
10048 }
10049
10050 // Clean up any double spaces or awkward punctuation left behind
10051 // IMPORTANT: Only collapse horizontal whitespace (spaces/tabs), preserve newlines for markdown formatting
10052 $cleaned_response = preg_replace('/[^\S\n]+/', ' ', $cleaned_response); // Collapse spaces/tabs but NOT newlines
10053 $cleaned_response = preg_replace('/[^\S\n]+([.,;:!?])/', '$1', $cleaned_response); // Same for punctuation cleanup
10054
10055 error_log("Final cleaned response: " . $cleaned_response);
10056
10057 return trim($cleaned_response);
10058 }
10059
10060 /**
10061 * AJAX handler to get current chat mode for a session
10062 */
10063 public function mxchat_get_current_chat_mode() {
10064 // Verify nonce for security
10065 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10066 wp_send_json_error(['message' => 'Invalid nonce']);
10067 wp_die();
10068 }
10069
10070 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10071
10072 if (empty($session_id)) {
10073 wp_send_json_error(['message' => 'Session ID missing']);
10074 wp_die();
10075 }
10076
10077 // Get the current chat mode for this session
10078 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
10079
10080 wp_send_json_success([
10081 'chat_mode' => $chat_mode
10082 ]);
10083 wp_die();
10084 }
10085
10086
10087
10088 }
10089 ?>
10090