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

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