PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.0
MxChat – AI Chatbot & Content Generation for WordPress v3.1.0
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / includes / class-mxchat-integrator.php

class-mxchat-integrator.php in MxChat – AI Chatbot & Content Generation for WordPress 3.1.0, at includes/class-mxchat-integrator.php

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