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

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

13,416 lines 573.8 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 // plan-mxchat-20260717-03ba33 — consent-safe YouTube embed queued during RAG
13 // retrieval when a video-backed KB entry is used as context. Emitted on the
14 // response 'html' channel alongside productCardHtml (non-streaming path,
15 // same constraint as product cards).
16 private $videoEmbedHtml = '';
17 // plan-mxchat-20260617-48a57a — function-calling UI payload capture. When a
18 // model-invoked tool yields a UI element (generated image, woo product card,
19 // image-search gallery), the FC loop stashes its html here so the FC outcome
20 // handler can SURFACE it to the frontend the same way the intent path does,
21 // instead of stripping it to text for the model (the bug: UI-bearing actions
22 // rendered nothing under function calling).
23 private $fc_ui_html = '';
24 private $fc_ui_images = array();
25 private $fc_ui_captured = false;
26 // plan-mxchat-20260722-59bc1b — {context} placeholder support. When the
27 // owner's system instructions carry {context}, the assembled KB block is
28 // stashed here (instead of being appended to $context_content) and
29 // get_system_instructions() injects it at the token's position. Null until
30 // the per-turn KB assembly has run — the early URL-extraction call to
31 // get_system_instructions() must NOT consume the token.
32 private $context_kb_block = null;
33 private $word_handler;
34 private $last_similarity_analysis = null;
35 private $current_valid_urls = [];
36 private $last_vectorstore_error = null;
37 private $is_streaming = false; // ADDED: Track if current request is streaming
38 private $streaming_headers_sent = false; // Track if streaming headers have been sent
39 private $pending_originating_page = null; // Originating page captured at session start, consumed on row insert
40 private $current_action_instruction = null; // Success-message instruction injected into the next system context
41 private $last_action_analysis = null; // Last action-match analysis for testing_data payloads
42
43 /**
44 * Setup streaming headers - call this right before actually streaming
45 * This delays header setup to allow actions/forms to return JSON responses
46 */
47 /**
48 * Auto-retry wrapper around wp_remote_post for chat-send provider calls.
49 *
50 * Retries up to twice (750ms then 2000ms backoff) when the upstream provider
51 * returns a TRANSIENT error: WP timeout, 429, 502, 503, 504, or a provider-
52 * specific "overloaded" / "rate limit" body string. Returns immediately on
53 * permanent errors (401/403/404/422) so misconfiguration surfaces fast.
54 *
55 * Drop-in replacement for wp_remote_post — returns the same shape
56 * (WP_Error or response array) so the caller's existing error-handling
57 * code path is unchanged.
58 *
59 * STREAMING PATH NOTE: this helper is ONLY for non-streaming chat-send
60 * paths (the *_response_openai / *_response_claude / etc functions).
61 * For the *_stream variants, the cURL initial-connect happens inside a
62 * read-chunks loop — retrying there safely (without re-emitting partial
63 * stream chunks to the client) is a separate problem. Streaming paths
64 * are NOT wrapped in this build; tracked as a follow-on.
65 *
66 * Honors the `mxchat_options['auto_retry_on_transient_error']` toggle
67 * (default true). When false, behavior is identical to plain wp_remote_post.
68 */
69 private function mxchat_provider_call_with_retry($url, $args, $provider_hint = '') {
70 $opts = is_array($this->options ?? null) ? $this->options : array();
71 $enabled = !isset($opts['auto_retry_on_transient_error']) ||
72 (string) $opts['auto_retry_on_transient_error'] !== '0';
73
74 if (!$enabled) {
75 return wp_remote_post($url, $args);
76 }
77
78 $backoffs = array(0, 750, 2000); // ms — first attempt 0, then retry waits
79 $last_response = null;
80
81 foreach ($backoffs as $i => $delay_ms) {
82 if ($delay_ms > 0) {
83 usleep($delay_ms * 1000);
84 }
85 $response = wp_remote_post($url, $args);
86 $last_response = $response;
87
88 if (!$this->mxchat_is_transient_provider_error($response, $provider_hint)) {
89 return $response;
90 }
91
92 if (defined('WP_DEBUG') && WP_DEBUG) {
93 $code_for_log = is_wp_error($response) ? 'wp_error:' . $response->get_error_code()
94 : (int) wp_remote_retrieve_response_code($response);
95 error_log(sprintf(
96 '[MxChat] Transient provider error (provider=%s, attempt=%d/3, status=%s). %s',
97 $provider_hint ?: 'unknown',
98 $i + 1,
99 $code_for_log,
100 ($i + 1) < count($backoffs) ? 'Retrying.' : 'Giving up.'
101 ));
102 }
103 }
104
105 return $last_response;
106 }
107
108 /**
109 * Returns true if a wp_remote_post response represents a TRANSIENT
110 * provider error worth retrying. Conservative — only retries on signals
111 * that are very likely to clear within a few seconds.
112 *
113 * Transient signals:
114 * - WP_Error with timeout / connection / dns / ssl
115 * - HTTP 429, 502, 503, 504
116 * - Provider-specific overload bodies (gemini "overloaded", openai
117 * "server_error", anthropic "overloaded_error", xai/grok "Rate limit")
118 *
119 * NOT transient (return false — fail-fast):
120 * - 200/2xx (success)
121 * - 401, 403, 404, 422 (auth / config errors — retrying wastes the
122 * budget; the user needs to fix something)
123 * - Any other 4xx (assume permanent unless explicitly listed above)
124 * - 5xx other than the four listed above (e.g. 500 generic server error
125 * is often a malformed request on our side, not a transient outage)
126 */
127 private function mxchat_is_transient_provider_error($response, $provider_hint = '') {
128 if (is_wp_error($response)) {
129 $code = $response->get_error_code();
130 return in_array($code, array('http_request_failed', 'connection_failed', 'connection_timeout'), true)
131 || stripos((string) $response->get_error_message(), 'timed out') !== false
132 || stripos((string) $response->get_error_message(), 'timeout') !== false;
133 }
134
135 $status = (int) wp_remote_retrieve_response_code($response);
136 if (in_array($status, array(429, 502, 503, 504), true)) {
137 return true;
138 }
139 if ($status >= 200 && $status < 300) {
140 return false;
141 }
142 // Permanent 4xx that should fail fast — even with no body.
143 if (in_array($status, array(401, 403, 404, 405, 422), true)) {
144 return false;
145 }
146
147 // Provider-specific body inspection for the cases where the upstream
148 // returns 200 with an error envelope (gemini does this for overload).
149 $body = (string) wp_remote_retrieve_body($response);
150 if ($body === '') {
151 return false;
152 }
153 $lower = strtolower($body);
154 $hint = strtolower((string) $provider_hint);
155
156 if ($hint === 'gemini' && (strpos($lower, 'overloaded') !== false
157 || strpos($lower, 'high demand') !== false
158 || strpos($lower, 'model is overloaded') !== false)) {
159 return true;
160 }
161 if ($hint === 'openai' && (strpos($lower, 'rate limit reached') !== false
162 || strpos($lower, '"type":"server_error"') !== false
163 || strpos($lower, '"code":"server_error"') !== false)) {
164 return true;
165 }
166 if ($hint === 'anthropic' && (strpos($lower, '"type":"overloaded_error"') !== false
167 || strpos($lower, 'overloaded_error') !== false)) {
168 return true;
169 }
170 if (($hint === 'xai' || $hint === 'grok') && strpos($lower, 'rate limit') !== false) {
171 return true;
172 }
173
174 return false;
175 }
176
177 /**
178 * Streaming-path classifier: same rules as mxchat_is_transient_provider_error
179 * but takes a raw (http_code, body, provider_hint, curl_errno) tuple as
180 * captured during a cURL streaming exec. cURL's WRITEFUNCTION/HEADERFUNCTION
181 * collect status separately from a plain wp_remote_post array shape, so the
182 * non-streaming helper above can't be called directly. This delegate keeps
183 * the classification rules identical across both paths.
184 */
185 private function mxchat_is_transient_provider_error_raw($http_code, $body, $provider_hint = '', $curl_errno = 0) {
186 if ($curl_errno) {
187 // cURL transport-level error (timeout, connection failure, DNS, etc.)
188 // Match the same WP_Error timeout/connection signals the array variant treats as transient.
189 return in_array($curl_errno, array(
190 CURLE_OPERATION_TIMEDOUT,
191 CURLE_COULDNT_CONNECT,
192 CURLE_COULDNT_RESOLVE_HOST,
193 CURLE_SSL_CONNECT_ERROR,
194 CURLE_GOT_NOTHING,
195 CURLE_SEND_ERROR,
196 CURLE_RECV_ERROR,
197 ), true);
198 }
199
200 $status = (int) $http_code;
201 if (in_array($status, array(429, 502, 503, 504), true)) {
202 return true;
203 }
204 if ($status >= 200 && $status < 300) {
205 return false;
206 }
207 if (in_array($status, array(401, 403, 404, 405, 422), true)) {
208 return false;
209 }
210
211 $body = (string) $body;
212 if ($body === '') {
213 return false;
214 }
215 $lower = strtolower($body);
216 $hint = strtolower((string) $provider_hint);
217
218 if ($hint === 'gemini' && (strpos($lower, 'overloaded') !== false
219 || strpos($lower, 'high demand') !== false
220 || strpos($lower, 'model is overloaded') !== false)) {
221 return true;
222 }
223 if ($hint === 'openai' && (strpos($lower, 'rate limit reached') !== false
224 || strpos($lower, '"type":"server_error"') !== false
225 || strpos($lower, '"code":"server_error"') !== false)) {
226 return true;
227 }
228 if ($hint === 'anthropic' && (strpos($lower, '"type":"overloaded_error"') !== false
229 || strpos($lower, 'overloaded_error') !== false)) {
230 return true;
231 }
232 if (($hint === 'xai' || $hint === 'grok') && strpos($lower, 'rate limit') !== false) {
233 return true;
234 }
235
236 return false;
237 }
238
239 /**
240 * Whether transient-error auto-retry is enabled in admin settings.
241 * Default true unless explicitly set to '0'. Used by both wp_remote_post
242 * (mxchat_provider_call_with_retry) and cURL streaming paths.
243 */
244 private function mxchat_retry_enabled() {
245 $opts = is_array($this->options ?? null) ? $this->options : array();
246 return !isset($opts['auto_retry_on_transient_error']) ||
247 (string) $opts['auto_retry_on_transient_error'] !== '0';
248 }
249
250 private function setup_streaming_headers() {
251 if ($this->streaming_headers_sent || headers_sent()) {
252 return false;
253 }
254
255 // Disable output buffering
256 while (ob_get_level()) {
257 ob_end_flush();
258 }
259
260 // Set headers for SSE
261 header('Content-Type: text/event-stream');
262 header('Cache-Control: no-cache');
263 header('Connection: keep-alive');
264 header('X-Accel-Buffering: no');
265
266 ob_implicit_flush(true);
267 flush();
268
269 $this->streaming_headers_sent = true;
270 return true;
271 }
272
273 /**
274 * Class constructor
275 */
276 public function __construct() {
277 $this->options = get_option('mxchat_options');
278 $this->prompts_options = get_option('mxchat_prompts_options', array());
279 $this->chat_count = get_option('mxchat_chat_count', 0);
280 $this->word_handler = new MXChat_Word_Handler($this->options);
281
282 // Add all action hooks
283 add_action('wp_enqueue_scripts', array($this, 'mxchat_enqueue_scripts_styles'));
284 add_action('wp_ajax_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
285 add_action('wp_ajax_nopriv_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
286 add_action('wp_ajax_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
287 add_action('wp_ajax_nopriv_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
288
289 // Add the AJAX actions for checking if the pre-chat message was dismissed
290 add_action('wp_ajax_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
291 add_action('wp_ajax_nopriv_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
292 add_action('wp_ajax_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
293 add_action('wp_ajax_nopriv_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
294 add_action('wp_ajax_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
295 add_action('wp_ajax_nopriv_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
296
297 // Add REST API routes registration
298 add_action('rest_api_init', array($this, 'register_routes'));
299 add_action('wp_ajax_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
300 add_action('wp_ajax_nopriv_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
301
302 // Rate limit action - notice we removed the old schedule setup
303 add_action('mxchat_reset_rate_limits', array($this, 'mxchat_reset_rate_limits'));
304
305 // Self-heal: if the reset event is ever lost (cron row cleared, botched
306 // migration, deactivate/reactivate race), an admin-context request brings it
307 // back. Cheap by construction: 60s transient guard + early return when the
308 // event is already scheduled. Without this, a lost event with the fallback
309 // flag unset leaves visitors rate-limited forever.
310 add_action('admin_init', array($this, 'setup_rate_limit_cron_jobs'));
311
312 // File upload and handling actions
313 add_action('wp_ajax_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
314 add_action('wp_ajax_nopriv_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
315 add_action('wp_ajax_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
316 add_action('wp_ajax_nopriv_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
317
318 // Word document handling actions
319 add_action('wp_ajax_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
320 add_action('wp_ajax_nopriv_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
321 add_action('wp_ajax_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
322 add_action('wp_ajax_nopriv_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
323 add_action('wp_ajax_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
324 add_action('wp_ajax_nopriv_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
325
326 // Email handling actions
327 add_action('wp_ajax_nopriv_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
328 add_action('wp_ajax_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
329 add_action('wp_ajax_nopriv_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
330 add_action('wp_ajax_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
331
332 add_action('wp_ajax_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
333 add_action('wp_ajax_nopriv_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
334
335 // Testing panel AJAX actions
336 add_action('wp_ajax_mxchat_get_system_info', array($this, 'mxchat_get_system_info'));
337 add_action('wp_ajax_mxchat_get_similarity_threshold', array($this, 'mxchat_get_similarity_threshold'));
338 add_action('wp_ajax_mxchat_get_kb_status', array($this, 'mxchat_get_kb_status'));
339 add_action('wp_ajax_mxchat_start_fresh_session', array($this, 'mxchat_start_fresh_session'));
340 // Add to your existing constructor, in the section with other AJAX actions:
341 add_action('wp_ajax_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
342 add_action('wp_ajax_nopriv_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
343 add_action('wp_ajax_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
344 add_action('wp_ajax_nopriv_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
345 // Add chat mode checking actions
346 add_action('wp_ajax_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
347 add_action('wp_ajax_nopriv_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
348
349 // Nonce refresh for page-cache compatibility (WP Rocket, LiteSpeed, etc.)
350 add_action('wp_ajax_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
351 add_action('wp_ajax_nopriv_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
352
353 // Auto-email transcript action
354 add_action('mxchat_send_delayed_transcript', array($this, 'mxchat_send_delayed_transcript'), 10, 1);
355
356 add_filter('mxchat_check_actions_only', array($this, 'check_actions_for_addons'), 10, 4);
357
358
359 }
360
361 /**
362 * Return a fresh nonce so cached pages can replace the stale one.
363 * With `with_settings`, also returns the current behavior-gate settings so
364 * the widget can correct stale inline-localized values (plan-32db95).
365 */
366 public function mxchat_refresh_nonce() {
367 nocache_headers();
368 $payload = array('nonce' => wp_create_nonce('mxchat_chat_nonce'));
369 if (!empty($_REQUEST['with_settings'])) {
370 $payload['settings'] = $this->get_dynamic_widget_settings(true);
371 }
372 wp_send_json_success($payload);
373 }
374
375 /**
376 * Behavior-gate settings the widget may re-fetch at runtime (plan-32db95).
377 *
378 * Every widget setting ships inline in page HTML via wp_localize_script, so
379 * full-page caches (host caches, WP Rocket, LiteSpeed, W3TC, FlyingPress,
380 * WP Super Cache, Cloudflare APO, the browser itself) keep serving a stale
381 * snapshot after an admin changes a setting. MxChat_Cache_Purge clears the
382 * caches PHP can reach; this payload covers the rest — the widget requests
383 * it on first open (via the nonce-refresh endpoints) and merges it over
384 * `mxchatChat`, the same distrust-cached-HTML pattern the 3.2.7 per-request
385 * nonce uses.
386 *
387 * Behavior gates + labels ONLY — colors stay inline because they're also
388 * server-inline-styled, and a runtime swap would visibly flash.
389 *
390 * Both wp_localize_script blocks merge this exact array, so the inline and
391 * refreshed payloads cannot drift.
392 *
393 * @param bool $fresh Re-read mxchat_options from the DB (endpoint paths)
394 * instead of trusting the instance copy.
395 * @return array
396 */
397 public function get_dynamic_widget_settings($fresh = false) {
398 $options = $fresh ? get_option('mxchat_options', array()) : $this->options;
399 if (!is_array($options)) {
400 $options = array();
401 }
402 return array(
403 'model' => isset($options['model']) ? $options['model'] : 'gpt-5.1-chat-latest',
404 'enable_streaming_toggle' => isset($options['enable_streaming_toggle']) ? $options['enable_streaming_toggle'] : 'on',
405 'rate_limit_message' => $options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
406 'chat_toolbar_toggle' => $options['chat_toolbar_toggle'] ?? 'off',
407 'print_button_enabled' => $options['print_button_enabled'] ?? 'on',
408 'print_button_label' => esc_html__('Download Transcript', 'mxchat'),
409 // "Start new chat" header-menu item (plan ac2e81). Default OFF.
410 'reset_chat_enabled' => $options['reset_chat_enabled'] ?? 'off',
411 'reset_chat_label' => !empty($options['reset_chat_label']) ? esc_html($options['reset_chat_label']) : esc_html__('Start new chat', 'mxchat'),
412 'reset_chat_confirm' => esc_html__('Start a new chat? This clears the current conversation.', 'mxchat'),
413 'stop_button_label' => esc_html__('Stop response', 'mxchat'),
414 'print_header_title' => esc_html(get_bloginfo('name')) . ' — ' . esc_html__('Chat transcript', 'mxchat'),
415 // Emit 'on'/'off' STRINGS, never booleans: wp_localize_script casts
416 // scalars to string, and (string) false === '' — which the widget's
417 // old gate read as enabled (plan-4bba64). The filter keeps its
418 // boolean contract; only the emitted value is stringified.
419 'satisfaction_rating_enabled' => apply_filters(
420 'mxchat_satisfaction_rating_enabled',
421 ($options['satisfaction_rating_enabled'] ?? 'off') === 'on'
422 ) ? 'on' : 'off',
423 'satisfaction_rating_idle_seconds' => max(5, min(600, intval($options['satisfaction_rating_idle_seconds'] ?? 60))),
424 'satisfaction_rating_copy' => array(
425 'question' => !empty($options['satisfaction_rating_question']) ? esc_html($options['satisfaction_rating_question']) : esc_html__('Was this helpful?', 'mxchat'),
426 'helpful' => esc_html__('Helpful', 'mxchat'),
427 'not_helpful' => esc_html__('Not helpful', 'mxchat'),
428 'dismiss' => esc_html__('Dismiss', 'mxchat'),
429 'thanks' => !empty($options['satisfaction_rating_thanks']) ? esc_html($options['satisfaction_rating_thanks']) : esc_html__('Thanks! Anything we should improve? (optional)', 'mxchat'),
430 'placeholder' => !empty($options['satisfaction_rating_placeholder']) ? esc_html($options['satisfaction_rating_placeholder']) : esc_html__('Tell us what could be better…', 'mxchat'),
431 'send' => esc_html__('Send', 'mxchat'),
432 'skip' => esc_html__('Skip', 'mxchat'),
433 'saved' => !empty($options['satisfaction_rating_saved']) ? esc_html($options['satisfaction_rating_saved']) : esc_html__('Thanks for the feedback.', 'mxchat'),
434 ),
435 );
436 }
437
438 // In your core plugin's check_actions_for_addons method:
439 public function check_actions_for_addons($default, $message, $user_id, $session_id) {
440 //error_log('MxChat Core: check_actions_for_addons called with message: ' . $message);
441
442 $result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
443
444 //error_log('MxChat Core: Intent check result = ' . ($result === false ? 'false' : 'true'));
445
446 return $result;
447 }
448
449 private function mxchat_increment_chat_count() {
450 $chat_count = get_option('mxchat_chat_count', 0);
451 $chat_count++;
452 update_option('mxchat_chat_count', $chat_count);
453 }
454
455 function mxchat_fetch_conversation_history() {
456 if (empty($_POST['session_id'])) {
457 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
458 wp_die();
459 }
460
461 $session_id = sanitize_text_field($_POST['session_id']);
462
463 // SECURITY FIX: Verify session ownership before retrieving data
464 // If IP/user changed, signal frontend to reset session instead of blocking
465 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
466
467 // Check if this session has an owner recorded
468 $session_owner = get_option("mxchat_session_owner_{$session_id}");
469
470 // Update session owner if it changed (e.g. IP changed due to network switch)
471 // The session ID itself is the authentication — if the client has it, they own it
472 if (!$session_owner || $session_owner !== $current_user_identifier) {
473 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
474 }
475
476 $history = get_option("mxchat_history_{$session_id}", []); // Retrieve stored history
477 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai'); // Get current chat mode
478
479 if (empty($history)) {
480 // Even if history is empty, return the chat mode
481 wp_send_json_success([
482 'conversation' => [],
483 'chat_mode' => $chat_mode
484 ]);
485 wp_die();
486 }
487
488 wp_send_json_success([
489 'conversation' => $history,
490 'chat_mode' => $chat_mode
491 ]);
492 wp_die();
493 }
494 private function mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp = 0) {
495 $history = get_option("mxchat_history_{$session_id}", []);
496
497 // Check persistence setting - when OFF, only include messages from current page load
498 $options = get_option('mxchat_options', []);
499 $persistence_enabled = isset($options['chat_persistence_toggle']) && $options['chat_persistence_toggle'] === 'on';
500
501 // Filter history when persistence is OFF to match what the user sees
502 if (!$persistence_enabled && $session_start_timestamp > 0) {
503 $history = array_filter($history, function($entry) use ($session_start_timestamp) {
504 // Include messages from this page load onwards
505 return isset($entry['timestamp']) && $entry['timestamp'] >= $session_start_timestamp;
506 });
507 // Re-index array after filtering
508 $history = array_values($history);
509 }
510
511 $formatted_history = [];
512
513 // Adjusted for code-heavy conversations
514 $max_tokens = 120000; // Context window size
515 $reserved_tokens = 5000; // Space for system prompts + current query
516 $current_token_count = 0;
517
518 // Allowed HTML tags for content sanitization
519 $allowed_tags = [
520 'pre' => ['class' => true],
521 'code' => ['class' => true],
522 'span' => ['class' => true],
523 'div' => ['class' => true],
524 'strong' => [],
525 'em' => []
526 ];
527
528 foreach (array_reverse($history) as $entry) {
529 // Preserve code blocks while sanitizing other HTML
530 $clean_content = wp_kses($entry['content'], $allowed_tags);
531
532 // Detect code blocks in content
533 $has_code = false;
534 // Replace the HTML check with:
535 // Allow messages that contain code blocks or are plain text
536 if (strpos($clean_content, '<pre') === false &&
537 strpos($clean_content, '<code') === false &&
538 $clean_content !== strip_tags($entry['content'])) {
539 continue;
540 }
541
542 // Skip entries that lost significant content during sanitization
543 if (!$has_code && $clean_content !== strip_tags($entry['content'])) {
544 continue;
545 }
546
547 // More accurate token estimation (1 token ≈ 4 characters)
548 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
549
550 // Check token budget with the new estimate
551 if (($current_token_count + $token_estimate + $reserved_tokens) > $max_tokens) {
552 // Try to fit partial content if it's the first entry
553 if (empty($formatted_history)) {
554 $clean_content = mb_substr($clean_content, 0, ($max_tokens - $reserved_tokens) * 4);
555 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
556 } else {
557 break;
558 }
559 }
560
561 // Add to formatted history
562 $formatted_history[] = [
563 'role' => $entry['role'],
564 'content' => $clean_content
565 ];
566
567 $current_token_count += $token_estimate;
568 }
569
570 // Reverse back to maintain chronological order
571 $formatted_history = array_reverse($formatted_history);
572
573 // Add system message about code context
574 array_unshift($formatted_history, [
575 'role' => 'system',
576 'content' => 'Preserved code blocks are marked with [CODE BLOCK PRESERVED]. '
577 . 'Maintain formatting and syntax highlighting when referencing code.'
578 ]);
579
580 return $formatted_history;
581 }
582
583 public function register_routes() {
584 //error_log(esc_html__('Registering MxChat REST routes', 'mxchat'));
585
586 // Per-request chat-send nonce endpoint — issues a fresh nonce on demand
587 // so the chat widget never depends on a stale nonce embedded in cached HTML.
588 // Public (no auth), rate-limited (1 call / IP / second via a transient).
589 register_rest_route('mxchat/v1', '/nonce', [
590 'methods' => 'GET',
591 'callback' => [$this, 'mxchat_issue_chat_send_nonce'],
592 'permission_callback' => '__return_true',
593 ]);
594
595 register_rest_route('mxchat/v1', '/stream', [
596 'methods' => 'GET',
597 'callback' => [$this, 'mxchat_stream_events'],
598 'permission_callback' => [$this, 'verify_chat_session'],
599 ]);
600
601 register_rest_route('mxchat/v1', '/agent-response', [
602 'methods' => 'POST',
603 'callback' => [$this, 'mxchat_handle_agent_response'],
604 'permission_callback' => [$this, 'verify_slack_request'],
605 ]);
606
607 register_rest_route('mxchat/v1', '/slack-interaction', [
608 'methods' => 'POST',
609 'callback' => [$this, 'handle_slack_interaction'],
610 'permission_callback' => [$this, 'verify_slack_request'],
611 ]);
612
613 register_rest_route('mxchat/v1', '/slack-messages', [
614 'methods' => 'POST',
615 'callback' => [$this, 'handle_slack_messages'],
616 'permission_callback' => [$this, 'verify_slack_request'],
617 ]);
618
619 // Telegram webhook endpoint
620 register_rest_route('mxchat/v1', '/telegram-webhook', [
621 'methods' => 'POST',
622 'callback' => [$this, 'handle_telegram_webhook'],
623 'permission_callback' => [$this, 'verify_telegram_request'],
624 ]);
625
626 //error_log(esc_html__('MxChat REST routes registered', 'mxchat'));
627 }
628
629 /**
630 * Issue a fresh per-request nonce for chat-send. Returned to the widget which
631 * caches it for the session and includes it on every chat-send / stream-send /
632 * upload call. By moving the nonce out of inline `window.mxchatChat = {...}` HTML
633 * we eliminate the entire class of "first-message Access denied" failures that
634 * plague WP installs behind a full-page cache (WP Rocket, LiteSpeed, FlyingPress,
635 * W3 Total Cache, Cloudflare APO) — the nonce is never cached because it never
636 * lives in the HTML body.
637 *
638 * Public endpoint. Rate-limited to 1 call / IP / 1s via a transient so a single
639 * client browser can't be used to flood the nonce-issuance path.
640 *
641 * Nonce action: `mxchat_chat_send` (new). The chat-send AJAX handlers accept
642 * BOTH this action AND the legacy `mxchat_chat_nonce` action for a 30-day
643 * backwards-compat window so cached pages still in users' browsers don't break
644 * mid-session.
645 *
646 * @since 3.2.7
647 */
648 public function mxchat_issue_chat_send_nonce(WP_REST_Request $request) {
649 $ip = '';
650 if (!empty($_SERVER['REMOTE_ADDR'])) {
651 $ip = preg_replace('#[^0-9a-fA-F:\.]#', '', wp_unslash((string) $_SERVER['REMOTE_ADDR']));
652 }
653 if ($ip !== '') {
654 // Best-effort rate limit. WP transients with sub-second TTL are racy
655 // (parallel bursts can squeak through before set_transient completes);
656 // we use 2s to make the gate slightly more reliable. Real production
657 // rate-limiting at sub-second granularity needs Redis or DB row locks
658 // — out of scope for this endpoint, which is already cheap.
659 $key = 'mxchat_nonce_rl_' . md5($ip);
660 if (get_transient($key)) {
661 return new WP_REST_Response(array(
662 'error' => 'rate_limited',
663 'message' => __('Too many nonce requests. Try again shortly.', 'mxchat'),
664 ), 429);
665 }
666 set_transient($key, 1, 2);
667 }
668
669 // The widget calls this endpoint without an X-WP-Nonce header, so WordPress does not
670 // honor the auth cookie and the request runs as uid=0 even for logged-in users. That
671 // makes wp_create_nonce() bind the nonce to uid=0, which then fails wp_verify_nonce()
672 // at admin-ajax (which runs as the real uid) -> logged-in users get a 403 on upload.
673 // Resolve the real user from the logged_in cookie so the nonce binds to the correct uid.
674 if ( ! is_user_logged_in() ) {
675 $maybe_uid = wp_validate_auth_cookie( '', 'logged_in' );
676 if ( $maybe_uid ) {
677 wp_set_current_user( $maybe_uid );
678 }
679 }
680
681 $payload = array(
682 'nonce' => wp_create_nonce('mxchat_chat_send'),
683 'expires_in' => 86400, // WP nonces live 24h; widget caches for 12h conservatively.
684 );
685
686 // plan-32db95: the widget's first-open refresh asks for current behavior
687 // settings in the same round-trip, so stale inline-localized values on
688 // cached pages get corrected without a second request. All values in
689 // this payload already ship in public page HTML — nothing sensitive.
690 if ($request->get_param('with_settings')) {
691 $payload['settings'] = $this->get_dynamic_widget_settings(true);
692 }
693
694 return new WP_REST_Response($payload, 200);
695 }
696
697 /**
698 * Verify a chat-send nonce. Accepts BOTH the new `mxchat_chat_send` action
699 * (issued by /wp-json/mxchat/v1/nonce) AND the legacy `mxchat_chat_nonce`
700 * action (inline-localized in older cached HTML). The legacy acceptance is
701 * a 30-day backwards-compat window — to be removed in a follow-up release
702 * after 2026-06-27.
703 *
704 * @param string $posted_nonce
705 * @return bool
706 */
707 public static function mxchat_verify_chat_send_nonce($posted_nonce) {
708 if (!is_string($posted_nonce) || $posted_nonce === '') {
709 return false;
710 }
711 return (bool) wp_verify_nonce($posted_nonce, 'mxchat_chat_send')
712 || (bool) wp_verify_nonce($posted_nonce, 'mxchat_chat_nonce');
713 }
714
715 /**
716 * Verify valid chat session
717 */
718 public function verify_chat_session($request) {
719 $session_id = $request->get_param('session_id');
720 if (empty($session_id)) {
721 //error_log(esc_html__('Empty session ID in chat request', 'mxchat'));
722 return false;
723 }
724
725 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
726 return $chat_mode === 'agent';
727 }
728
729 /**
730 * Verify request is coming from Slack.
731 *
732 * @param WP_REST_Request $request
733 * @return bool True if valid, false otherwise.
734 */
735 public function verify_slack_request($request) {
736 // Get the Slack signing secret from your plugin options
737 $valid_key = $this->options['live_agent_secret_key'] ?? '';
738
739 if (empty($valid_key)) {
740 //error_log(esc_html__('Slack signing secret not configured', 'mxchat'));
741 return false;
742 }
743
744 $timestamp = $request->get_header('X-Slack-Request-Timestamp');
745 $slack_signature = $request->get_header('X-Slack-Signature');
746
747 // Verify timestamp to prevent replay attacks
748 if (abs(time() - intval($timestamp)) > 300) {
749 //error_log(esc_html__('Slack request timestamp too old', 'mxchat'));
750 return false;
751 }
752
753 // Get raw request body from the WP_REST_Request object
754 // (php://input may already be consumed by WordPress at this point)
755 $request_body = $request->get_body();
756
757 // Create the signature base string
758 $sig_basestring = "v0:{$timestamp}:{$request_body}";
759
760 // Calculate expected signature
761 $my_signature = 'v0=' . hash_hmac('sha256', $sig_basestring, $valid_key);
762
763 // Compare signatures
764 return hash_equals($my_signature, $slack_signature);
765 }
766
767 /**
768 * Verify request is coming from Telegram.
769 *
770 * @param WP_REST_Request $request
771 * @return bool True if valid, false otherwise.
772 */
773 public function verify_telegram_request($request) {
774 $secret_token = $this->options['telegram_webhook_secret'] ?? '';
775
776 //error_log('[MxChat Telegram DEBUG] verify_telegram_request called');
777 //error_log('[MxChat Telegram DEBUG] Stored secret: ' . (empty($secret_token) ? 'EMPTY' : substr($secret_token, 0, 10) . '...'));
778
779 if (empty($secret_token)) {
780 // No secret configured (legacy setup). Do NOT fail open to the whole
781 // internet — that lets an unauthenticated caller write agent-branded
782 // messages. Fall back to verifying the request originates from
783 // Telegram's published webhook IP ranges so existing no-secret installs
784 // keep working while an arbitrary-internet caller is blocked. Setting a
785 // real secret (see the admin notice) is the recommended path.
786 // (plan-0c17b5)
787 $peer = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '';
788 if ($this->mxchat_ip_in_telegram_ranges($peer)) {
789 return true;
790 }
791 error_log('MxChat: Telegram webhook has no secret configured and the request '
792 . 'is not from a Telegram IP range; rejected. Set a webhook secret to secure it.');
793 return false;
794 }
795
796 // Telegram sends the secret token in the X-Telegram-Bot-Api-Secret-Token header
797 $request_token = $request->get_header('X-Telegram-Bot-Api-Secret-Token');
798
799 //error_log('[MxChat Telegram DEBUG] Request token: ' . (empty($request_token) ? 'EMPTY' : substr($request_token, 0, 10) . '...'));
800
801 if (empty($request_token)) {
802 //error_log('[MxChat Telegram DEBUG] Request rejected: No token in header');
803 return false;
804 }
805
806 // Timing-safe comparison
807 $result = hash_equals($secret_token, $request_token);
808 //error_log('[MxChat Telegram DEBUG] Token comparison result: ' . ($result ? 'MATCH' : 'MISMATCH'));
809 return $result;
810 }
811
812 /**
813 * Whether $ip falls within Telegram's published webhook IPv4 ranges
814 * (149.154.160.0/20 and 91.108.4.0/22). Used as an authenticity fallback for
815 * the Telegram webhook when no secret token is configured, so a legacy
816 * no-secret install keeps working without failing open to the entire internet.
817 *
818 * Uses the real TCP peer (REMOTE_ADDR); a spoofable X-Forwarded-For is NOT
819 * consulted. Behind a reverse proxy / CDN that rewrites REMOTE_ADDR this may
820 * not match — which is exactly why configuring a real webhook secret is the
821 * recommended path. (plan-0c17b5)
822 *
823 * @param string $ip Candidate IPv4 address.
824 * @return bool
825 */
826 private function mxchat_ip_in_telegram_ranges($ip) {
827 if (!is_string($ip) || $ip === '' || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
828 return false;
829 }
830 $ip_long = ip2long($ip);
831 if ($ip_long === false) {
832 return false;
833 }
834 $ranges = array(
835 array('149.154.160.0', 20),
836 array('91.108.4.0', 22),
837 );
838 foreach ($ranges as $range) {
839 $subnet_long = ip2long($range[0]);
840 if ($subnet_long === false) {
841 continue;
842 }
843 $mask = (0xFFFFFFFF << (32 - $range[1])) & 0xFFFFFFFF;
844 if (($ip_long & $mask) === ($subnet_long & $mask)) {
845 return true;
846 }
847 }
848 return false;
849 }
850
851 public function mxchat_stream_events(WP_REST_Request $request) {
852 header('Content-Type: text/event-stream');
853 header('Cache-Control: no-cache');
854 header('Connection: keep-alive');
855
856 $session_id = sanitize_text_field($request->get_param('session_id'));
857 $last_seen_id = sanitize_text_field($request->get_param('last_seen_id')) ?: '';
858
859 if (empty($session_id)) {
860 echo esc_html__("event: error\ndata: ", 'mxchat') . esc_html__('Missing session_id', 'mxchat') . "\n\n";
861 flush();
862 exit;
863 }
864
865 $history = get_option("mxchat_history_{$session_id}", []);
866
867 // Filter only new messages
868 $new_messages = array_filter($history, function ($message) use ($last_seen_id) {
869 return !empty($message['id']) && $message['id'] > $last_seen_id;
870 });
871
872 // Send new messages if available
873 if (!empty($new_messages)) {
874 echo esc_html__("event: newMessages\ndata: ", 'mxchat') . json_encode(array_values($new_messages)) . "\n\n";
875 } else {
876 // Keep the connection alive
877 echo esc_html__("event: keepAlive\ndata: ", 'mxchat') . "{}\n\n";
878 }
879 flush();
880 exit;
881 }
882
883
884
885
886 private function mxchat_save_chat_message($session_id, $role, $message, $originating_page = null, $rag_context = null) {
887 global $wpdb;
888 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
889 //error_log("[DEBUG] mxchat_save_chat_message -> START for session_id: {$session_id}, role: {$role}");
890
891 // Check if this is the first message in a new session (before any other database operations)
892 $is_new_session = false;
893 if ($role === 'user') { // Only check for user messages, not bot responses
894 $existing_messages = $wpdb->get_var($wpdb->prepare(
895 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
896 $session_id
897 ));
898 $is_new_session = ($existing_messages == 0);
899
900 // Log for debugging
901 if ($is_new_session) {
902 //error_log("[DEBUG] This is a NEW session - first message");
903 }
904 }
905
906 // SECURITY FIX: Set session ownership for new sessions
907 if ($is_new_session && $role === 'user') {
908 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
909 $session_owner_key = "mxchat_session_owner_{$session_id}";
910
911 // Only set ownership if not already set
912 if (!get_option($session_owner_key)) {
913 update_option($session_owner_key, $current_user_identifier, 'no');
914 //error_log("[DEBUG] Set session ownership for {$session_id} to {$current_user_identifier}");
915 }
916 }
917
918 // 1) Extract agent name if present
919 $agent_name = '';
920 if (preg_match('/^Agent: (.*?) - /', $message, $matches)) {
921 $agent_name = $matches[1];
922 $message = str_replace("Agent: $agent_name - ", '', $message);
923 $session_meta_key = "mxchat_agent_name_{$session_id}";
924 if (empty(get_option($session_meta_key))) {
925 update_option($session_meta_key, $agent_name);
926 //error_log("[DEBUG] mxchat_save_chat_message -> Stored agent_name in option: {$session_meta_key} => {$agent_name}");
927 }
928 }
929
930 // 2) Generate unique message_id
931 $message_id = uniqid();
932 //error_log("[DEBUG] mxchat_save_chat_message -> Generated message_id: {$message_id}");
933
934 // 3) Determine user_id
935 $user_id = is_user_logged_in() ? get_current_user_id() : 0;
936
937 // 4) Determine user_identifier
938 $user_identifier = $agent_name
939 ? $agent_name
940 : MxChat_User::mxchat_get_user_identifier();
941
942 // 5) Determine displayed_name
943 $user_email = MxChat_User::mxchat_get_user_email();
944 $displayed_name = $agent_name ? $agent_name : ($user_email ?: $user_identifier);
945
946 // 6) Check for a saved email in wp_options
947 $email_option_key = "mxchat_email_{$session_id}";
948 $saved_email = get_option($email_option_key);
949 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for email_option_key: {$email_option_key}, found: {$saved_email}");
950
951 // Check for a saved name in wp_options
952 $name_option_key = "mxchat_name_{$session_id}";
953 $saved_name = get_option($name_option_key);
954 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for name_option_key: {$name_option_key}, found: {$saved_name}");
955
956 // If found, update DB user_email and user_name
957 if ($saved_email || $saved_name) {
958 $update_data = [];
959 if ($saved_email) {
960 $update_data['user_email'] = $saved_email;
961 }
962 if ($saved_name) {
963 $update_data['user_name'] = $saved_name;
964 }
965
966 if (!empty($update_data)) {
967 $update_res = $wpdb->update(
968 $table_name,
969 $update_data,
970 ['session_id' => $session_id],
971 array_fill(0, count($update_data), '%s'),
972 ['%s']
973 );
974 //error_log("[DEBUG] mxchat_save_chat_message -> Attempted DB user_email/user_name update for session_id {$session_id}. update_res: {$update_res}");
975 }
976 }
977
978 // 7) Save to session history in wp_options
979 $history_key = "mxchat_history_{$session_id}";
980 $history = get_option($history_key, []);
981 $history[] = [
982 'id' => $message_id,
983 'role' => $role,
984 'content' => $message,
985 'timestamp' => round(microtime(true) * 1000),
986 'agent_name' => $displayed_name,
987 ];
988 update_option($history_key, $history, 'no');
989 //error_log("[DEBUG] mxchat_save_chat_message -> Updated session history in option: {$history_key}");
990
991 // 8) Save the message to DB (INSERT)
992 $insert_data = [
993 'user_id' => $user_id,
994 'user_identifier'=> $user_identifier,
995 'user_email' => $saved_email ?: $user_email,
996 'user_name' => $saved_name ?: '', // Add name to insert data
997 'session_id' => $session_id,
998 'role' => $role,
999 'message' => $message,
1000 'timestamp' => current_time('mysql', 1),
1001 ];
1002
1003 // IMPROVED: Handle originating page data
1004 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
1005
1006 if ($columns_exist) {
1007 if ($is_new_session && $role === 'user') {
1008 // For the first user message, set originating page data
1009
1010 // First check if we have it from the parameter
1011 if ($originating_page && !empty($originating_page['url'])) {
1012 $insert_data['originating_page_url'] = $originating_page['url'];
1013 $insert_data['originating_page_title'] = $originating_page['title'] ?? '';
1014
1015 //error_log("[DEBUG] Setting originating page from parameter: " . $originating_page['url']);
1016 }
1017 // Otherwise check if it's stored in the instance property
1018 else if (isset($this->pending_originating_page) && !empty($this->pending_originating_page['url'])) {
1019 $insert_data['originating_page_url'] = $this->pending_originating_page['url'];
1020 $insert_data['originating_page_title'] = $this->pending_originating_page['title'] ?? '';
1021
1022 //error_log("[DEBUG] Setting originating page from pending_originating_page: " . $this->pending_originating_page['url']);
1023
1024 // Clear after using (= null, not unset(): unset() undeclares the property
1025 // and the next assignment recreates it dynamic, re-triggering the PHP 8.2 deprecation)
1026 $this->pending_originating_page = null;
1027 }
1028 // Fallback to HTTP_REFERER if nothing else is available
1029 else if (isset($_SERVER['HTTP_REFERER'])) {
1030 $referer_url = esc_url_raw($_SERVER['HTTP_REFERER']);
1031 $insert_data['originating_page_url'] = $referer_url;
1032
1033 // Generate title from URL
1034 $parsed_url = parse_url($referer_url);
1035 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
1036
1037 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
1038 $insert_data['originating_page_title'] = 'Homepage';
1039 } else {
1040 $title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
1041 $insert_data['originating_page_title'] = ucwords(trim($title));
1042 }
1043
1044 //error_log("[DEBUG] Setting originating page from HTTP_REFERER: " . $referer_url);
1045 }
1046
1047 // Store for this session so all messages have the same originating page
1048 if (!empty($insert_data['originating_page_url'])) {
1049 update_option("mxchat_originating_page_{$session_id}", [
1050 'url' => $insert_data['originating_page_url'],
1051 'title' => $insert_data['originating_page_title']
1052 ], 'no');
1053 }
1054 } else {
1055 // For subsequent messages in the session, use the stored originating page
1056 $stored_originating = get_option("mxchat_originating_page_{$session_id}");
1057 if ($stored_originating && !empty($stored_originating['url'])) {
1058 $insert_data['originating_page_url'] = $stored_originating['url'];
1059 $insert_data['originating_page_title'] = $stored_originating['title'] ?? '';
1060 }
1061 }
1062 }
1063
1064 // Add RAG context if provided (for bot messages)
1065 if ($rag_context !== null && $role === 'bot') {
1066 $rag_context_column_exists = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'rag_context'");
1067 if ($rag_context_column_exists) {
1068 $insert_data['rag_context'] = is_array($rag_context) ? wp_json_encode($rag_context) : $rag_context;
1069 }
1070 }
1071
1072 $wpdb->insert($table_name, $insert_data);
1073 //error_log("[DEBUG] mxchat_save_chat_message -> Inserted message into DB. row_id: {$wpdb->insert_id}, data: " . print_r($insert_data, true));
1074
1075 // 9) Send notification email if this is the first user message in a new session
1076 if ($wpdb->insert_id && $is_new_session && $role === 'user') {
1077 $this->send_new_chat_notification($session_id, array(
1078 'identifier' => $user_identifier,
1079 'email' => $saved_email ?: $user_email,
1080 'ip' => $_SERVER['REMOTE_ADDR']
1081 ));
1082 }
1083
1084 // 10) Schedule delayed transcript email if enabled and message is from user
1085 if ($wpdb->insert_id && $role === 'user') {
1086 $this->schedule_delayed_transcript_email($session_id);
1087 }
1088
1089 //error_log("[DEBUG] mxchat_save_chat_message -> END for session_id: {$session_id}");
1090 return $message_id;
1091 }
1092
1093 private function send_new_chat_notification($session_id, $user_info = array()) {
1094 $options = get_option('mxchat_transcripts_options');
1095
1096 // Check if notifications are enabled
1097 if (empty($options['mxchat_enable_notifications'])) {
1098 return false;
1099 }
1100
1101 // Get notification email
1102 $to = !empty($options['mxchat_notification_email']) ?
1103 $options['mxchat_notification_email'] :
1104 get_option('admin_email');
1105
1106 if (!is_email($to)) {
1107 return false;
1108 }
1109
1110 // Prepare email content
1111 $subject = sprintf('[%s] New Chat Session Started', get_bloginfo('name'));
1112
1113 $user_identifier = isset($user_info['identifier']) ? $user_info['identifier'] : 'Guest';
1114 $user_email = isset($user_info['email']) ? $user_info['email'] : 'Not provided';
1115 $user_ip = isset($user_info['ip']) ? $user_info['ip'] : $_SERVER['REMOTE_ADDR'];
1116
1117 $message = sprintf(
1118 "A new chat session has started on your website.\n\n" .
1119 "Session ID: %s\n" .
1120 "User: %s\n" .
1121 "Email: %s\n" .
1122 "IP Address: %s\n" .
1123 "Time: %s\n\n" .
1124 "View transcripts: %s",
1125 $session_id,
1126 $user_identifier,
1127 $user_email,
1128 $user_ip,
1129 current_time('mysql'),
1130 admin_url('admin.php?page=mxchat-transcripts')
1131 );
1132
1133 // Send email
1134 return wp_mail($to, $subject, $message);
1135 }
1136
1137 /**
1138 * Schedule delayed transcript email for a session
1139 * Reschedules if a new user message is received
1140 */
1141 private function schedule_delayed_transcript_email($session_id) {
1142 $options = get_option('mxchat_transcripts_options');
1143
1144 // Check if auto-email is enabled
1145 if (empty($options['mxchat_auto_email_transcript_enabled'])) {
1146 return;
1147 }
1148
1149 // Get notification email
1150 $email = !empty($options['mxchat_notification_email']) ?
1151 $options['mxchat_notification_email'] :
1152 get_option('admin_email');
1153
1154 if (!is_email($email)) {
1155 return;
1156 }
1157
1158 // Get delay in minutes (default 30)
1159 $delay_minutes = isset($options['mxchat_auto_email_transcript_delay']) ?
1160 intval($options['mxchat_auto_email_transcript_delay']) : 30;
1161
1162 // Clear any existing scheduled event for this session
1163 $hook = 'mxchat_send_delayed_transcript';
1164 $args = array($session_id);
1165 $timestamp = wp_next_scheduled($hook, $args);
1166
1167 if ($timestamp) {
1168 wp_unschedule_event($timestamp, $hook, $args);
1169 }
1170
1171 // Schedule new event
1172 $schedule_time = time() + ($delay_minutes * 60);
1173 wp_schedule_single_event($schedule_time, $hook, $args);
1174 }
1175
1176 /**
1177 * Check if chat messages contain contact information (email or phone number)
1178 *
1179 * @param array $messages Array of message objects with 'message' property
1180 * @param object|null $session_data Session data object with user_email property
1181 * @return bool True if contact info found, false otherwise
1182 */
1183 private function chat_contains_contact_info($messages, $session_data = null) {
1184 // Check if session already has a stored email
1185 if ($session_data && !empty($session_data->user_email)) {
1186 return true;
1187 }
1188
1189 // Email regex pattern
1190 $email_pattern = '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/';
1191
1192 // Phone number patterns (covers various formats including international, WhatsApp style)
1193 // Matches: +1234567890, (123) 456-7890, 123-456-7890, 123.456.7890, 1234567890, +1 234 567 8900, etc.
1194 $phone_pattern = '/(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{2,4}[-.\s]?\d{2,4}(?:[-.\s]?\d{1,4})?/';
1195
1196 // Only check user messages (not assistant responses)
1197 foreach ($messages as $msg) {
1198 if ($msg->role !== 'user') {
1199 continue;
1200 }
1201
1202 $message_text = $msg->message;
1203
1204 // Check for email
1205 if (preg_match($email_pattern, $message_text)) {
1206 return true;
1207 }
1208
1209 // Check for phone number (must be at least 7 digits total to avoid false positives)
1210 if (preg_match($phone_pattern, $message_text, $matches)) {
1211 // Count actual digits to avoid matching short numbers
1212 $digits_only = preg_replace('/\D/', '', $matches[0]);
1213 if (strlen($digits_only) >= 7) {
1214 return true;
1215 }
1216 }
1217 }
1218
1219 return false;
1220 }
1221
1222 /**
1223 * Send the delayed transcript email with .txt attachment
1224 */
1225 public function mxchat_send_delayed_transcript($session_id) {
1226 global $wpdb;
1227
1228 $options = get_option('mxchat_transcripts_options');
1229
1230 // Get notification email
1231 $to = !empty($options['mxchat_notification_email']) ?
1232 $options['mxchat_notification_email'] :
1233 get_option('admin_email');
1234
1235 if (!is_email($to)) {
1236 return false;
1237 }
1238
1239 // Get all messages for this session
1240 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1241 $messages = $wpdb->get_results($wpdb->prepare(
1242 "SELECT role, message, timestamp FROM {$table_name}
1243 WHERE session_id = %s
1244 ORDER BY timestamp ASC",
1245 $session_id
1246 ));
1247
1248 if (empty($messages)) {
1249 return false;
1250 }
1251
1252 // Get session metadata
1253 $sessions_table = $wpdb->prefix . 'mxchat_sessions';
1254 $session_data = $wpdb->get_row($wpdb->prepare(
1255 "SELECT * FROM {$sessions_table} WHERE session_id = %s",
1256 $session_id
1257 ));
1258
1259 // Check if contact info is required and if it's present
1260 $require_contact = !empty($options['mxchat_auto_email_transcript_require_contact']);
1261 if ($require_contact && !$this->chat_contains_contact_info($messages, $session_data)) {
1262 // Contact info required but not found - skip sending
1263 return false;
1264 }
1265
1266 // Build transcript content
1267 $transcript_content = "Chat Transcript\n";
1268 $transcript_content .= "================\n\n";
1269 $transcript_content .= "Session ID: " . $session_id . "\n";
1270
1271 if ($session_data) {
1272 $transcript_content .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
1273 $transcript_content .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
1274 $transcript_content .= "Started: " . $session_data->created_at . "\n";
1275 }
1276
1277 $transcript_content .= "\n" . str_repeat("=", 50) . "\n\n";
1278
1279 // Add messages
1280 foreach ($messages as $msg) {
1281 // 'agent' rows are live-agent (human) replies — label them as such in
1282 // the emailed transcript, same distinction the Transcripts viewer draws.
1283 $role_label = ($msg->role === 'user') ? 'User' : (($msg->role === 'agent') ? 'Live Agent' : 'Assistant');
1284 $transcript_content .= "[{$msg->timestamp}] {$role_label}:\n";
1285 $transcript_content .= $msg->message . "\n\n";
1286 }
1287
1288 // Create temporary file for attachment using WP_Filesystem
1289 $upload_dir = wp_upload_dir();
1290 $temp_file = $upload_dir['basedir'] . '/mxchat-transcript-' . $session_id . '.txt';
1291 global $wp_filesystem;
1292 if (empty($wp_filesystem)) {
1293 require_once ABSPATH . 'wp-admin/includes/file.php';
1294 WP_Filesystem();
1295 }
1296 $wp_filesystem->put_contents($temp_file, $transcript_content, FS_CHMOD_FILE);
1297
1298 // Prepare email
1299 $subject = sprintf('[%s] Chat Transcript - Session %s', get_bloginfo('name'), substr($session_id, 0, 8));
1300
1301 $message = "Please find attached the full chat transcript.\n\n";
1302 $message .= "Session ID: {$session_id}\n";
1303
1304 if ($session_data) {
1305 $message .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
1306 $message .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
1307 }
1308
1309 $message .= "\nView online: " . admin_url('admin.php?page=mxchat-transcripts');
1310
1311 // Send email with attachment
1312 $attachments = array($temp_file);
1313 $result = wp_mail($to, $subject, $message, '', $attachments);
1314
1315 // Clean up temporary file
1316 if (file_exists($temp_file)) {
1317 unlink($temp_file);
1318 }
1319
1320 return $result;
1321 }
1322
1323
1324
1325 public function mxchat_handle_save_email_and_response() {
1326 //error_log('[DEBUG] ---------- mxchat_handle_save_email_and_response START ----------');
1327 //error_log('DEBUG: POST data: ' . print_r($_POST, true));
1328
1329 nocache_headers();
1330
1331 // Validate nonce
1332 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
1333 //error_log(esc_html__('[ERROR] Invalid nonce in mxchat_handle_save_email_and_response', 'mxchat'));
1334 wp_send_json_error(['message' => esc_html__('Invalid nonce.', 'mxchat')]);
1335 wp_die();
1336 }
1337
1338 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1339 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
1340 $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
1341
1342 //error_log("[DEBUG] handle_save_email_and_response -> session_id: {$session_id}, email: {$email}, name: {$name}");
1343
1344 if (empty($session_id) || $session_id === 'null' || empty($email)) {
1345 //error_log("[ERROR] Missing session_id or email: session_id={$session_id}, email={$email}");
1346 wp_send_json_error(['message' => esc_html__('Session ID or email is missing.', 'mxchat')]);
1347 wp_die();
1348 }
1349
1350 // Validate name if provided (check if name field is enabled and name is required)
1351 $options = get_option('mxchat_options', []);
1352 $name_field_enabled = isset($options['enable_name_field']) &&
1353 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
1354
1355 if ($name_field_enabled && (empty($name) || strlen(trim($name)) < 2 || strlen(trim($name)) > 100)) {
1356 //error_log("[ERROR] Invalid name: {$name} (enabled: {$name_field_enabled})");
1357 wp_send_json_error(['message' => esc_html__('Name must be between 2 and 100 characters.', 'mxchat')]);
1358 wp_die();
1359 }
1360
1361 // 1) Always store email in wp_options
1362 $email_option_key = "mxchat_email_{$session_id}";
1363 update_option($email_option_key, $email, 'no');
1364 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$email_option_key} => {$email}");
1365
1366 // Store name in wp_options if provided
1367 if (!empty($name)) {
1368 $name_option_key = "mxchat_name_{$session_id}";
1369 update_option($name_option_key, $name, 'no');
1370 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$name_option_key} => {$name}");
1371 }
1372
1373 // 2) (Optional) Also store in DB if a row already exists
1374 global $wpdb;
1375 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1376
1377 // Make sure we have a valid placeholder in prepare
1378 $sql = $wpdb->prepare("SELECT COUNT(*) FROM {$table_name} WHERE session_id = %s", $session_id);
1379 $session_count = $wpdb->get_var($sql);
1380
1381 //error_log("[DEBUG] handle_save_email_and_response -> session_count for {$session_id}: {$session_count} (SQL: {$sql})");
1382
1383 if ($session_count) {
1384 // Update both user_email and user_name if row(s) exist
1385 if (!empty($name)) {
1386 $update_sql = $wpdb->prepare(
1387 "UPDATE {$table_name} SET user_email = %s, user_name = %s WHERE session_id = %s",
1388 $email,
1389 $name,
1390 $session_id
1391 );
1392 } else {
1393 $update_sql = $wpdb->prepare(
1394 "UPDATE {$table_name} SET user_email = %s WHERE session_id = %s",
1395 $email,
1396 $session_id
1397 );
1398 }
1399 $wpdb->query($update_sql);
1400 //error_log("[DEBUG] handle_save_email_and_response -> DB updated: {$update_sql}");
1401 } else {
1402 //error_log("[INFO] handle_save_email_and_response -> No DB entry for {$session_id}, so email/name is only in wp_options.");
1403 }
1404
1405 // Provide success response (same as original)
1406 $bot_message = __('Thanks for providing your email! You can continue chatting now.', 'mxchat');
1407 //error_log("[DEBUG] handle_save_email_and_response -> success, returning bot_message: {$bot_message}");
1408 wp_send_json_success(['message' => $bot_message]);
1409 wp_die();
1410 }
1411
1412 public function mxchat_check_email_provided() {
1413 //error_log('[DEBUG] ---------- mxchat_check_email_provided START ----------');
1414
1415 nocache_headers();
1416
1417 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
1418 //error_log('[ERROR] Invalid nonce in mxchat_check_email_provided');
1419 wp_send_json_error(['message' => esc_html__('Invalid nonce', 'mxchat')]);
1420 }
1421
1422 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1423 if (empty($session_id) || $session_id === 'null') {
1424 //error_log('[ERROR] No session ID provided in mxchat_check_email_provided');
1425 wp_send_json_error(['message' => esc_html__('No session ID provided', 'mxchat')]);
1426 }
1427
1428 // Check if the user is logged in
1429 if (is_user_logged_in()) {
1430 $current_user = wp_get_current_user();
1431 //error_log("[DEBUG] User is logged in as {$current_user->user_email}");
1432
1433 // Get user's display name for logged in users
1434 $user_name = !empty($current_user->display_name) ? $current_user->display_name :
1435 (!empty($current_user->first_name) ? $current_user->first_name : '');
1436
1437 $response_data = ['logged_in' => true, 'email' => $current_user->user_email];
1438 if (!empty($user_name)) {
1439 $response_data['name'] = $user_name;
1440 }
1441
1442 wp_send_json_success($response_data);
1443 }
1444
1445 // Check if name field is required
1446 $options = get_option('mxchat_options', []);
1447 $name_field_enabled = isset($options['enable_name_field']) &&
1448 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
1449
1450 $email_option_key = "mxchat_email_{$session_id}";
1451 $stored_email = get_option($email_option_key, '');
1452
1453 // Check for stored name
1454 $name_option_key = "mxchat_name_{$session_id}";
1455 $stored_name = get_option($name_option_key, '');
1456
1457 //error_log("[DEBUG] mxchat_check_email_provided -> Checking email option: {$email_option_key}, found: {$stored_email}");
1458 //error_log("[DEBUG] mxchat_check_email_provided -> Checking name option: {$name_option_key}, found: {$stored_name}, required: " . ($name_field_enabled ? 'yes' : 'no'));
1459
1460 // Check if we have email and name (if name is required)
1461 $has_required_info = !empty($stored_email);
1462
1463 if ($name_field_enabled) {
1464 $has_required_info = $has_required_info && !empty($stored_name);
1465 }
1466
1467 if ($has_required_info) {
1468 //error_log("[DEBUG] mxchat_check_email_provided -> Required info found, returning success");
1469
1470 $response_data = ['email' => $stored_email];
1471 if (!empty($stored_name)) {
1472 $response_data['name'] = $stored_name;
1473 }
1474
1475 wp_send_json_success($response_data);
1476 } else {
1477 //error_log("[DEBUG] mxchat_check_email_provided -> Required info missing, returning error");
1478 wp_send_json_error(['message' => esc_html__('No email found', 'mxchat')]);
1479 }
1480 }
1481
1482 /**
1483 * Send error response in appropriate format based on streaming mode
1484 * ADDED: Helper method to consistently handle errors in both streaming and non-streaming modes
1485 *
1486 * @param string $error_message The error message to display
1487 * @param string $error_code Optional error code for debugging
1488 */
1489 private function send_error_response($error_message, $error_code = 'api_error') {
1490 if ($this->is_streaming) {
1491 echo "data: " . json_encode([
1492 'error' => true,
1493 'error_message' => $error_message,
1494 'error_code' => $error_code,
1495 'text' => $error_message,
1496 'message' => $error_message
1497 ]) . "\n\n";
1498 echo "data: [DONE]\n\n";
1499 flush();
1500 } else {
1501 wp_send_json_error([
1502 'error_message' => $error_message,
1503 'error_code' => $error_code
1504 ]);
1505 }
1506 wp_die();
1507 }
1508
1509 public function mxchat_handle_chat_request() {
1510 global $wpdb;
1511
1512 // Debug: Log incoming bot_id
1513 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1514 //error_log("=== MXCHAT DEBUG: Starting chat request ===");
1515 //error_log("MXCHAT DEBUG: Bot ID received: " . $bot_id);
1516
1517 // Get bot-specific options
1518 $bot_options = $this->get_bot_options($bot_id);
1519 $current_options = !empty($bot_options) ? $bot_options : $this->options;
1520
1521 // Check if this is a streaming request
1522 // Allow force_streaming_test parameter to bypass the setting check (for admin compatibility testing)
1523 $force_streaming_test = isset($_POST['force_streaming_test']) && $_POST['force_streaming_test'] === '1' && current_user_can('administrator');
1524 $is_streaming = isset($_POST['action']) && $_POST['action'] === 'mxchat_stream_chat' &&
1525 ($force_streaming_test || (isset($current_options['enable_streaming_toggle']) && $current_options['enable_streaming_toggle'] === 'on'));
1526
1527 // ADDED: Store streaming state in class property for use in private methods
1528 $this->is_streaming = $is_streaming;
1529
1530 // NOTE: Streaming headers are now set later via setup_streaming_headers()
1531 // This allows actions/forms to return JSON responses without header conflicts
1532
1533 // Check if MX Chat Moderation is active
1534 if (class_exists('MX_Chat_Moderation')) {
1535 // Get user email and IP
1536 $user_email = '';
1537 $user_ip = $_SERVER['REMOTE_ADDR'];
1538
1539 // If user is logged in, get their email
1540 if (is_user_logged_in()) {
1541 $current_user = wp_get_current_user();
1542 $user_email = $current_user->user_email;
1543 }
1544
1545 // Create ban handler instance
1546 $ban_handler = new MX_Chat_Ban_Handler();
1547
1548 // Check if user is banned by IP
1549 if ($ban_handler->check_ban($user_ip, 'ip')) {
1550 wp_send_json([
1551 'success' => false,
1552 'message' => esc_html__('Access denied. Your IP address has been banned.', 'mxchat'),
1553 'status' => 'banned'
1554 ]);
1555 wp_die();
1556 }
1557
1558 // If user is logged in, also check email
1559 if (!empty($user_email) && $ban_handler->check_ban($user_email, 'email')) {
1560 wp_send_json([
1561 'success' => false,
1562 'message' => esc_html__('Access denied. Your email address has been banned.', 'mxchat'),
1563 'status' => 'banned'
1564 ]);
1565 wp_die();
1566 }
1567 }
1568
1569 $this->fallbackResponse = ['text' => '', 'html' => '', 'images' => []];
1570 $this->productCardHtml = '';
1571 $this->videoEmbedHtml = '';
1572 // Reset the per-turn function-calling UI capture (plan 48a57a).
1573 $this->fc_ui_html = '';
1574 $this->fc_ui_images = array();
1575 $this->fc_ui_captured = false;
1576
1577 // Get the actual WordPress user ID if logged in
1578 $is_logged_in = is_user_logged_in();
1579 if ($is_logged_in) {
1580 $user_id = get_current_user_id(); // This will get the actual WordPress user ID
1581 } else {
1582 // For logged-out users, use your existing identifier method
1583 $user_id = $this->mxchat_get_user_identifier();
1584 }
1585
1586 // Get and sanitize the user identifier
1587 $user_id = sanitize_key($user_id);
1588
1589 // Check rate limit using new settings structure
1590 $rate_limit_result = $this->check_rate_limit();
1591
1592 if ($rate_limit_result !== true) {
1593 wp_send_json([
1594 'success' => false,
1595 'message' => $rate_limit_result['message'],
1596 'status' => 'rate_limit_exceeded'
1597 ]);
1598 wp_die();
1599 }
1600
1601 // Rest of your existing code...
1602 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1603
1604 // Treat the literal strings 'null' / 'undefined' as missing too. Browser edge cases
1605 // (Safari ITP, private mode, cross-origin iframes with partitioned storage) can cause
1606 // the frontend FormData.append() to stringify a null session_id into the literal
1607 // "null", which would otherwise pass empty() and pollute the transcripts table with
1608 // ghost sessions that group every visitor's first message under one row.
1609 if ($session_id === 'null' || $session_id === 'undefined') {
1610 $session_id = '';
1611 }
1612
1613 if (empty($session_id)) {
1614 wp_send_json_error(esc_html__('Session ID is missing.', 'mxchat'));
1615 wp_die();
1616 }
1617
1618 // Update session owner if it changed (e.g. IP changed due to network switch)
1619 // The session ID itself is the authentication — if the client has it, they own it
1620 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
1621 $session_owner = get_option("mxchat_session_owner_{$session_id}");
1622
1623 if (!$session_owner || $session_owner !== $current_user_identifier) {
1624 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
1625 }
1626
1627 // Validate and sanitize the incoming message
1628 if (empty($_POST['message'])) {
1629 wp_send_json_error(esc_html__('No message received.', 'mxchat'));
1630 wp_die();
1631 }
1632
1633 // Enforce the configurable max input length (plan a3fae2 part C). 0 = unlimited.
1634 // Server-side guard backing the textarea's client-side maxlength (which is bypassable).
1635 // Reads the global core setting and measures characters (mb_strlen on the unslashed
1636 // raw POST), matching the maxlength semantics.
1637 $mxchat_max_input_length = isset($this->options['max_input_length']) ? intval($this->options['max_input_length']) : 0;
1638 if ($mxchat_max_input_length > 0) {
1639 $mxchat_incoming_raw = is_string($_POST['message']) ? wp_unslash($_POST['message']) : '';
1640 if (mb_strlen($mxchat_incoming_raw) > $mxchat_max_input_length) {
1641 wp_send_json([
1642 'success' => false,
1643 /* translators: %d: maximum allowed characters */
1644 'message' => sprintf(esc_html__('Your message is too long. Please keep it under %d characters.', 'mxchat'), $mxchat_max_input_length),
1645 'status' => 'message_too_long'
1646 ]);
1647 wp_die();
1648 }
1649 }
1650
1651
1652 // Track originating page for first message in session
1653 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1654
1655 // Check if originating page columns exist
1656 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
1657
1658 if ($columns_exist) {
1659 // Check if this session already has messages
1660 $message_count = $wpdb->get_var($wpdb->prepare(
1661 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
1662 $session_id
1663 ));
1664
1665 // If this is the first message in the session
1666 if ($message_count == 0) {
1667 // Get originating page from JavaScript (preferred) or HTTP_REFERER (fallback)
1668 $originating_url = '';
1669 $originating_title = '';
1670
1671 // Try to get from POST data first (sent by JavaScript)
1672 if (isset($_POST['current_page_url'])) {
1673 $originating_url = esc_url_raw($_POST['current_page_url']);
1674 $originating_title = isset($_POST['current_page_title'])
1675 ? sanitize_text_field($_POST['current_page_title'])
1676 : '';
1677 }
1678 // Fallback to HTTP_REFERER if not provided by JavaScript
1679 else if (isset($_SERVER['HTTP_REFERER'])) {
1680 $originating_url = esc_url_raw($_SERVER['HTTP_REFERER']);
1681 }
1682
1683 // Generate title if we have URL but no title
1684 if ($originating_url && empty($originating_title)) {
1685 $parsed_url = parse_url($originating_url);
1686 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
1687
1688 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
1689 $originating_title = 'Homepage';
1690 } else {
1691 // Clean up the path to make a readable title
1692 $originating_title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
1693 $originating_title = ucwords(trim($originating_title));
1694 }
1695 }
1696
1697 // Store for later use when saving the message
1698 $this->pending_originating_page = [
1699 'url' => $originating_url,
1700 'title' => $originating_title
1701 ];
1702 }
1703 }
1704
1705
1706
1707 // Get page context if provided
1708 $page_context = null;
1709 if (isset($_POST['page_context']) && !empty($_POST['page_context'])) {
1710 $page_context_raw = stripslashes($_POST['page_context']);
1711 $page_context = json_decode($page_context_raw, true);
1712
1713 // Validate page context structure
1714 if (is_array($page_context) &&
1715 isset($page_context['url']) &&
1716 isset($page_context['title']) &&
1717 isset($page_context['content'])) {
1718
1719 // Sanitize page context
1720 $page_context['url'] = esc_url_raw($page_context['url']);
1721 $page_context['title'] = sanitize_text_field($page_context['title']);
1722 $page_context['content'] = wp_kses_post($page_context['content']);
1723 } else {
1724 $page_context = null;
1725 }
1726 }
1727
1728 // Modify the message sanitization to preserve PHP tags in code blocks
1729 $allowed_tags = [
1730 'pre' => [],
1731 'code' => ['class' => true],
1732 'span' => ['class' => true],
1733 'div' => ['class' => true],
1734 ];
1735
1736 // First preserve code blocks
1737 $message = preg_replace_callback('/<pre><code.*?>.*?<\/code><\/pre>/s', function($matches) {
1738 return htmlspecialchars_decode($matches[0]);
1739 }, $_POST['message']);
1740
1741 // Then apply sanitization
1742 $message = wp_kses($message, $allowed_tags);
1743
1744 // Preserve code blocks from markdown conversion
1745 $message = preg_replace('/```(\w+)?\s*([\s\S]+?)```/s', '<pre><code class="$1">$2</code></pre>', $message);
1746 $message = apply_filters('mxchat_filter_message', $message, 'prompt', $session_id);
1747
1748 // ===== SIMPLIFIED TESTING PANEL INITIALIZATION =====
1749 // Always initialize testing data for admins (no toggle needed)
1750 $testing_data = null;
1751 if (current_user_can('administrator')) {
1752 // For vision messages, use the original user message for the query display
1753 $query_for_testing = $message;
1754 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1755 $query_for_testing = sanitize_textarea_field($_POST['original_user_message']);
1756 }
1757
1758 $testing_data = [
1759 'query' => $query_for_testing,
1760 'timestamp' => time(),
1761 'top_matches' => [],
1762 'action_matches' => [], // Initialize action matches array
1763 'page_context' => $page_context, // Include page context in testing data
1764 'is_vision' => isset($_POST['vision_processed']) && $_POST['vision_processed'],
1765 'bot_id' => $bot_id // Include bot ID in testing data
1766 ];
1767
1768 // Get similarity threshold from bot options or default options
1769 $similarity_threshold = isset($current_options['similarity_threshold'])
1770 ? ((int) $current_options['similarity_threshold']) / 100
1771 : 0.35;
1772
1773 $testing_data['similarity_threshold'] = $similarity_threshold;
1774
1775 // Determine knowledge base type using bot-specific config
1776 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
1777 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
1778 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
1779 }
1780 // ===== END SIMPLIFIED TESTING INITIALIZATION =====
1781
1782 // Add debug before and after:
1783 //error_log('MxChat Core: About to call mxchat_pre_process_message filter with message: ' . $message);
1784 $pre_processed_result = apply_filters('mxchat_pre_process_message', $message, $user_id, $session_id);
1785 //error_log('MxChat Core: Filter returned: ' . (is_array($pre_processed_result) ? 'array' : $pre_processed_result));
1786
1787
1788 // If the pre-processing returned a result (not the original message), use it directly
1789 if (is_array($pre_processed_result) && isset($pre_processed_result['text'])) {
1790 // Save the AI response
1791 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['text']);
1792
1793 // Save HTML content if provided
1794 if (!empty($pre_processed_result['html'])) {
1795 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['html']);
1796 }
1797
1798 // Add testing data if admin
1799 $response_data = [
1800 'text' => $pre_processed_result['text'],
1801 'html' => $pre_processed_result['html'] ?? '',
1802 'session_id' => $session_id
1803 ];
1804
1805 if ($testing_data !== null) {
1806 $response_data['testing_data'] = $testing_data;
1807 }
1808
1809 wp_send_json($response_data);
1810 wp_die();
1811 }
1812
1813 // Save the user's message - handle vision processed messages differently
1814 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1815 // For vision messages, save the original user message with image indicator
1816 $original_message = sanitize_textarea_field($_POST['original_user_message']);
1817 if (isset($_POST['vision_images_count']) && $_POST['vision_images_count'] > 0) {
1818 $image_count = intval($_POST['vision_images_count']);
1819 $original_message .= " [{$image_count} image(s)]";
1820 }
1821 $this->mxchat_save_chat_message($session_id, 'user', $original_message);
1822 } else {
1823 // Regular message - save as normal
1824 $this->mxchat_save_chat_message($session_id, 'user', $message);
1825 }
1826
1827
1828 if (is_email($message)) {
1829 // Add the email to Loops
1830 $this->add_email_to_loops($message);
1831
1832 // Get the user's success message instruction using current_options
1833 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1834
1835 // Set instruction for AI using the user's success message
1836 $this->current_action_instruction = $user_success_message;
1837
1838 // Clear the email capture transient since we got the email
1839 delete_transient('mxchat_email_capture_' . $user_id);
1840 }
1841
1842 // Check if we're in an email capture flow but user hasn't provided email yet
1843 elseif (get_transient('mxchat_email_capture_' . $user_id)) {
1844 // Check if the message contains an email (not the whole message being an email)
1845 if (preg_match('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', $message, $matches)) {
1846 $extracted_email = $matches[0];
1847
1848 // Add the extracted email to Loops
1849 $this->add_email_to_loops($extracted_email);
1850
1851 // Get the user's success message instruction using current_options
1852 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1853
1854 // Set instruction for AI using the user's success message
1855 $this->current_action_instruction = $user_success_message;
1856
1857 // Clear the email capture transient since we got the email
1858 delete_transient('mxchat_email_capture_' . $user_id);
1859 }
1860 // If no email found but we're in capture mode, remind them
1861 else {
1862 // Get the original instruction to remind them using current_options
1863 $original_instruction = $current_options['triggered_phrase_response'] ?? __("Please provide your email address.", 'mxchat');
1864 $this->current_action_instruction = $original_instruction;
1865 }
1866 }
1867
1868 $intent_info = '';
1869
1870 // Check chat mode
1871 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1872
1873 // Handle agent mode
1874 // Handle agent mode
1875 if ($chat_mode === 'agent') {
1876 // First, check for switch intent before doing anything else
1877 $intent_matched = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1878
1879 // Capture action analysis for testing panel after intent check
1880 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1881 $testing_data['action_matches'] = $this->last_action_analysis;
1882 }
1883
1884 // Around line 506, in the agent mode handling section:
1885 if ($intent_matched && !empty($this->fallbackResponse['text'])) {
1886 // Update chat mode first
1887 update_option("mxchat_mode_{$session_id}", 'ai');
1888
1889 // Clear any existing PDF context to start fresh
1890 $this->clear_pdf_transients($session_id);
1891
1892 // Prepare clean switch response with explicit chat_mode
1893 $response_data = [
1894 'text' => $this->fallbackResponse['text'],
1895 'html' => $this->fallbackResponse['html'] ?? '',
1896 'session_id' => $session_id,
1897 'chat_mode' => 'ai' // EXPLICITLY SET THIS
1898 ];
1899
1900 if ($testing_data !== null) {
1901 $response_data['testing_data'] = $testing_data;
1902 }
1903
1904 // Save the mode switch message
1905 $this->mxchat_save_chat_message($session_id, 'system', esc_html__('Switched to AI chat mode', 'mxchat'));
1906 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1907
1908 // Send response and exit
1909 wp_send_json($response_data);
1910 wp_die();
1911 } elseif (!$intent_matched) {
1912 // No intent matched, handle live agent message
1913 try {
1914 $this->mxchat_send_user_message_to_agent($message, $user_id, $session_id);
1915
1916 $agent_response = [
1917 'status' => 'waiting_for_agent',
1918 'message' => esc_html__('Message sent to live agent.', 'mxchat')
1919 ];
1920
1921 if ($testing_data !== null) {
1922 $agent_response['testing_data'] = $testing_data;
1923 }
1924
1925 wp_send_json_success($agent_response);
1926 } catch (\Exception $e) {
1927 wp_send_json_error(esc_html__('Failed to send message to agent', 'mxchat'));
1928 }
1929 wp_die();
1930 }
1931 }
1932
1933 // Step 1: Check for new PDF URL in the message
1934 if (!isset($_POST['vision_processed']) && preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
1935 $new_pdf_url = $matches[0];
1936
1937 // Check if this is likely a PDF-related request
1938 $pdf_keywords = ['pdf', 'document', 'read', 'analyze'];
1939 $is_pdf_request = false;
1940
1941 foreach ($pdf_keywords as $keyword) {
1942 if (stripos($message, $keyword) !== false) {
1943 $is_pdf_request = true;
1944 break;
1945 }
1946 }
1947
1948 // If it looks like a PDF request or we're waiting for a PDF URL
1949 if ($is_pdf_request || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
1950 // Validate HTTPS
1951 if (wp_http_validate_url($new_pdf_url) && parse_url($new_pdf_url, PHP_URL_SCHEME) === 'https') {
1952 // Extract filename from URL
1953 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1954
1955 // Clear previous PDF transients
1956 $this->clear_pdf_transients($session_id);
1957
1958 // Process new PDF using current_options
1959 $max_pages = $current_options['pdf_max_pages'] ?? 69;
1960 $embeddings = $this->fetch_and_split_pdf_pages($new_pdf_url, $max_pages);
1961
1962 if ($embeddings === 'too_many_pages') {
1963 $error_text = sprintf(
1964 $current_options['pdf_intent_error_text'] ??
1965 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
1966 $max_pages
1967 );
1968 $this->fallbackResponse['text'] = $error_text;
1969 } elseif ($embeddings) {
1970 // Store new PDF information
1971 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1972
1973 // If the filename is generic, create a more descriptive one
1974 if (in_array($pdf_filename, ['results_download.php', 'download.php', 'view.php', 'pdf.php']) ||
1975 strpos($pdf_filename, '.php') !== false) {
1976 $pdf_filename = 'Document_' . date('Y-m-d_H-i') . '.pdf';
1977 }
1978
1979 set_transient('mxchat_pdf_url_' . $session_id, $new_pdf_url, HOUR_IN_SECONDS);
1980 set_transient('mxchat_pdf_filename_' . $session_id, $pdf_filename, HOUR_IN_SECONDS);
1981 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
1982 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
1983
1984 $success_text = $current_options['pdf_intent_success_text'] ??
1985 esc_html__("I've processed the new PDF '{$pdf_filename}'. What questions do you have about it?", 'mxchat');
1986
1987 $pdf_response = [
1988 'success' => true,
1989 'message' => $success_text,
1990 'data' => [
1991 'filename' => $pdf_filename
1992 ]
1993 ];
1994
1995 if ($testing_data !== null) {
1996 $pdf_response['testing_data'] = $testing_data;
1997 }
1998
1999 wp_send_json($pdf_response);
2000 wp_die();
2001 } else {
2002 $error_text = $current_options['pdf_intent_error_text'] ??
2003 esc_html__("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
2004 $this->fallbackResponse['text'] = $error_text;
2005 }
2006
2007 $pdf_error_response = [
2008 'success' => false,
2009 'message' => $this->fallbackResponse['text']
2010 ];
2011
2012 if ($testing_data !== null) {
2013 $pdf_error_response['testing_data'] = $testing_data;
2014 }
2015
2016 wp_send_json($pdf_error_response);
2017 wp_die();
2018 }
2019 }
2020 }
2021
2022
2023 // Step 2: Detect intent and handle intent-based responses
2024 $intent_result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
2025
2026 // Capture action analysis for testing panel after intent check
2027 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
2028 $testing_data['action_matches'] = $this->last_action_analysis;
2029 }
2030
2031 // Step 3: Handle the intent result appropriately
2032 if ($intent_result !== false) {
2033 // Intent was matched - ALWAYS send as JSON response, never streaming
2034
2035 if (is_array($intent_result) && (isset($intent_result['text']) || isset($intent_result['html']))) {
2036 // Intent returned a direct response array
2037 $response_data = [
2038 'text' => $intent_result['text'] ?? '',
2039 'html' => $intent_result['html'] ?? '',
2040 'session_id' => $session_id
2041 ];
2042
2043 // IMPORTANT: Include chat_mode if present (for WhatsApp, Slack, etc.)
2044 if (isset($intent_result['chat_mode'])) {
2045 $response_data['chat_mode'] = $intent_result['chat_mode'];
2046 }
2047
2048 if ($testing_data !== null) {
2049 $response_data['testing_data'] = $testing_data;
2050 }
2051
2052 wp_send_json($response_data);
2053 wp_die();
2054 } else if ($intent_result === true && (!empty($this->fallbackResponse['text']) || !empty($this->fallbackResponse['html']))) {
2055 // Intent returned true and set fallbackResponse
2056
2057 // SAVE TO TRANSCRIPT
2058 if (!empty($this->fallbackResponse['text'])) {
2059 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
2060 }
2061 // Save action HTML (product cards, featured products, etc.) so it renders in transcripts
2062 if (!empty($this->fallbackResponse['html'])) {
2063 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
2064 }
2065
2066 $response_data = [
2067 'text' => $this->fallbackResponse['text'] ?? '',
2068 'html' => $this->fallbackResponse['html'] ?? '',
2069 'session_id' => $session_id
2070 ];
2071
2072 if (isset($this->fallbackResponse['chat_mode'])) {
2073 $response_data['chat_mode'] = $this->fallbackResponse['chat_mode'];
2074 }
2075
2076 if ($testing_data !== null) {
2077 $response_data['testing_data'] = $testing_data;
2078 }
2079
2080 wp_send_json($response_data);
2081 wp_die();
2082 }
2083 }
2084
2085 // If we get here, no intent matched OR the intent didn't provide a usable response
2086
2087 // Step 4: Generate AI response
2088 // Get session start timestamp - when persistence is OFF, only include messages from this page load
2089 $session_start_timestamp = isset($_POST['session_start_timestamp']) ? intval($_POST['session_start_timestamp']) : 0;
2090 $conversation_history = $this->mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp);
2091 $this->mxchat_increment_chat_count();
2092
2093 // Generate embedding for the user's query - USE BOT-SPECIFIC API KEY
2094 $api_key = $current_options['api_key'] ?? $this->options['api_key'];
2095 $user_message_embedding = $this->mxchat_generate_embedding($message, $api_key);
2096
2097 // Check if the embedding generation returned an error
2098 if (is_array($user_message_embedding) && isset($user_message_embedding['error'])) {
2099 $error_message = $user_message_embedding['error'];
2100 $error_code = $user_message_embedding['error_code'] ?? 'embedding_error';
2101
2102 // FIXED: Send error in appropriate format based on streaming mode
2103 if ($is_streaming) {
2104 echo "data: " . json_encode([
2105 'error' => true,
2106 'error_message' => $error_message,
2107 'error_code' => $error_code,
2108 'text' => $error_message,
2109 'message' => $error_message
2110 ]) . "\n\n";
2111 echo "data: [DONE]\n\n";
2112 flush();
2113 } else {
2114 wp_send_json_error([
2115 'error_message' => $error_message,
2116 'error_code' => $error_code
2117 ]);
2118 }
2119 wp_die();
2120 }
2121
2122 // Check if the embedding is valid
2123 if (!is_array($user_message_embedding) || empty($user_message_embedding)) {
2124 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
2125
2126 // FIXED: Send error in appropriate format based on streaming mode
2127 if ($is_streaming) {
2128 echo "data: " . json_encode([
2129 'error' => true,
2130 'error_message' => $error_message,
2131 'error_code' => 'invalid_embedding',
2132 'text' => $error_message,
2133 'message' => $error_message
2134 ]) . "\n\n";
2135 echo "data: [DONE]\n\n";
2136 flush();
2137 } else {
2138 wp_send_json_error([
2139 'error_message' => $error_message,
2140 'error_code' => 'invalid_embedding'
2141 ]);
2142 }
2143 wp_die();
2144 }
2145
2146 // Build context with both knowledge base and PDF content if available
2147 $context_content = "User asked: '{$message}'\n\n";
2148
2149 // Add action instruction if present (add this right after the above line)
2150 if (!empty($this->current_action_instruction)) {
2151 $context_content .= "===== SPECIAL INSTRUCTION =====\n";
2152 $context_content .= "IMPORTANT: " . $this->current_action_instruction . "\n";
2153 $context_content .= "Respond naturally and conversationally while following this instruction.\n";
2154 $context_content .= "===== END SPECIAL INSTRUCTION =====\n\n";
2155
2156 // Clear the instruction after using it
2157 $this->current_action_instruction = null;
2158 }
2159
2160
2161 // Add page context if available and contextual awareness is enabled using current_options
2162 if ($page_context && isset($current_options['contextual_awareness_toggle']) && $current_options['contextual_awareness_toggle'] === 'on') {
2163 $context_content .= "===== CURRENT PAGE CONTEXT =====\n";
2164 $context_content .= "Page URL: " . $page_context['url'] . "\n";
2165 $context_content .= "Page Title: " . $page_context['title'] . "\n";
2166 $context_content .= "Page Content: " . $page_context['content'] . "\n";
2167 $context_content .= "===== END CURRENT PAGE CONTEXT =====\n\n";
2168 }
2169
2170 // Get relevant content from knowledge base - PASS BOT_ID and MESSAGE for Vector Store
2171 $relevant_content = $this->mxchat_find_relevant_content($user_message_embedding, $bot_id, $message);
2172
2173 // NEW: Also extract URLs from system instructions (only if citation links enabled)
2174 // Use fresh options to ensure we get the latest setting value
2175 $fresh_options = get_option('mxchat_options', []);
2176 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
2177
2178 $system_instructions = $this->get_system_instructions($bot_id, $session_id);
2179 if ($citation_links_enabled && !empty($system_instructions)) {
2180 preg_match_all(
2181 '#\bhttps?://[^\s<>"\']+#i',
2182 $system_instructions,
2183 $system_instruction_urls
2184 );
2185
2186 if (!empty($system_instruction_urls[0])) {
2187 // Merge with existing valid URLs
2188 $this->current_valid_urls = array_merge(
2189 $this->current_valid_urls,
2190 $system_instruction_urls[0]
2191 );
2192 // Remove duplicates
2193 $this->current_valid_urls = array_unique($this->current_valid_urls);
2194
2195 //error_log("Added " . count($system_instruction_urls[0]) . " URLs from system instructions");
2196 }
2197 }
2198
2199 // ===== CAPTURE REAL SIMILARITY DATA FOR ADMINS =====
2200 if ($testing_data !== null && $this->last_similarity_analysis !== null) {
2201 // Update testing data with the REAL similarity analysis
2202 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
2203 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
2204 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
2205 $testing_data['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
2206 $testing_data['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
2207 }
2208 // ===== END SIMILARITY DATA CAPTURE =====
2209
2210 // NEW: Add valid URLs to testing data for admin panel display (AFTER similarity data)
2211 if ($testing_data !== null && !empty($this->current_valid_urls)) {
2212 $testing_data['approved_urls'] = array_values($this->current_valid_urls);
2213 //error_log("Added " . count($this->current_valid_urls) . " approved URLs to testing data");
2214 }
2215
2216 $kb_block = !empty($relevant_content)
2217 ? "===== OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n" . $relevant_content . "\n===== END OF OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n\n"
2218 : "===== NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE =====\n";
2219
2220 // {context} placeholder (plan 59bc1b): when the resolved instructions
2221 // carry the token, the KB block is injected at that spot by
2222 // get_system_instructions() (every provider handler re-calls it) and is
2223 // NOT appended here — otherwise the block would ride twice.
2224 // $system_instructions above was resolved while context_kb_block was
2225 // still null, so the literal token is still visible for this check.
2226 if (!empty($system_instructions) && stripos($system_instructions, '{context}') !== false) {
2227 $this->context_kb_block = $kb_block;
2228 } else {
2229 $context_content .= $kb_block;
2230 }
2231
2232 // NEW: Add approved URLs list to context for AI (only if citation links enabled)
2233 if ($citation_links_enabled && !empty($this->current_valid_urls)) {
2234 $context_content .= "===== APPROVED URLS FOR CITATIONS =====\n";
2235 $context_content .= "You may ONLY use these exact URLs in your response:\n";
2236 foreach ($this->current_valid_urls as $url) {
2237 $context_content .= "- " . $url . "\n";
2238 }
2239 $context_content .= "\nCRITICAL: Do NOT create, modify, extend, or invent any other URLs. ";
2240 $context_content .= "===== END APPROVED URLS =====\n\n";
2241 }
2242
2243 // Check for and include PDF content
2244 $pdf_url = get_transient('mxchat_pdf_url_' . $session_id);
2245 $pdf_embeddings = get_transient('mxchat_pdf_embeddings_' . $session_id);
2246 $pdf_filename = get_transient('mxchat_pdf_filename_' . $session_id);
2247 if ($pdf_url && $pdf_embeddings && get_transient('mxchat_include_pdf_in_context_' . $session_id)) {
2248 $relevant_pdf_pages = $this->find_relevant_pdf_pages($user_message_embedding, $pdf_embeddings);
2249 if (!empty($relevant_pdf_pages)) {
2250 $context_content .= "Relevant content from PDF document '{$pdf_filename}':\n";
2251 foreach ($relevant_pdf_pages as $page_data) {
2252 $context_content .= "Page {$page_data['page_number']} of '{$pdf_filename}': {$page_data['text']}\n";
2253 }
2254 $context_content .= "\n";
2255 }
2256 }
2257
2258 // Check for and include Word content
2259 $word_url = get_transient('mxchat_word_url_' . $session_id);
2260 $word_embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
2261 $word_filename = get_transient('mxchat_word_filename_' . $session_id);
2262 if ($word_url && $word_embeddings && get_transient('mxchat_include_word_in_context_' . $session_id)) {
2263 $relevant_word_chunks = $this->word_handler->mxchat_find_relevant_word_chunks($user_message_embedding, $word_embeddings);
2264 if (!empty($relevant_word_chunks)) {
2265 $context_content .= "Relevant content from Word document '{$word_filename}':\n";
2266 foreach ($relevant_word_chunks as $chunk_data) {
2267 $context_content .= "Section {$chunk_data['chunk_number']} of '{$word_filename}': {$chunk_data['text']}\n";
2268 }
2269 $context_content .= "\n";
2270 }
2271 }
2272
2273 $context_content = apply_filters('mxchat_prepare_context', $context_content, $session_id);
2274
2275 // Extract model from current options for bot-specific model support
2276 $selected_model = isset($current_options['model']) ? $current_options['model'] : 'gpt-5.1-chat-latest';
2277
2278 // ===== Native function-calling fallback (plan-mxchat-20260617-a41dee) =====
2279 // Intents already missed (we're past the intent router). If function
2280 // calling is enabled and the active model is tool-capable, let the model
2281 // SELECT and run registered callbacks as tools — independent of intents,
2282 // works with zero Actions. The tool round is buffered; the final answer is
2283 // emitted via the SAME envelopes the normal path uses. Default-off, so
2284 // existing installs never enter this branch.
2285 if ($this->mxchat_fc_should_run($selected_model)) {
2286 $fc_outcome = $this->mxchat_fc_attempt(
2287 $message,
2288 $context_content,
2289 $conversation_history,
2290 $selected_model,
2291 $current_options,
2292 $session_id,
2293 $user_id
2294 );
2295 if (is_array($fc_outcome) && !empty($fc_outcome['handled'])) {
2296 $fc_text = isset($fc_outcome['text']) ? $fc_outcome['text'] : '';
2297 if (!empty($this->current_valid_urls)) {
2298 $fc_text = $this->validate_and_clean_urls($fc_text, $this->current_valid_urls, $session_id, $bot_id);
2299 }
2300 // plan-mxchat-20260617-48a57a — surface any UI element a tool
2301 // produced (generated image / product card / image gallery) so the
2302 // widget RENDERS it, instead of emitting only the model's text.
2303 // The html was already saved to the transcript in
2304 // mxchat_fc_execute_tool (or by the callback itself for self-saving
2305 // core tools), so we persist ONLY the model's caption text here.
2306 $fc_html = isset($this->fc_ui_html) ? $this->fc_ui_html : '';
2307
2308 if ($fc_text !== '') {
2309 $this->mxchat_save_chat_message($session_id, 'bot', $fc_text, null, null);
2310 }
2311
2312 // A video-backed KB source queued during retrieval (03ba33) must
2313 // surface on the FC path too — the FC envelopes below are the ONLY
2314 // exit for this turn, so append it to the html channel and persist
2315 // it (tool html was already saved in mxchat_fc_execute_tool; the
2316 // video embed has no other save point on this path).
2317 if (!empty($this->videoEmbedHtml)) {
2318 $fc_html .= $this->videoEmbedHtml;
2319 $this->mxchat_save_chat_message($session_id, 'bot', $this->videoEmbedHtml);
2320 }
2321
2322 if ($is_streaming) {
2323 // The frontend SSE reader routes any event carrying text/html
2324 // to handleNonStreamResponse(), which renders text + html in a
2325 // single bot message — so emit one complete event (mirrors the
2326 // intent path's text/html envelope).
2327 $sse = array('session_id' => $session_id);
2328 if ($fc_text !== '') $sse['text'] = $fc_text;
2329 if ($fc_html !== '') $sse['html'] = $fc_html;
2330 if ($fc_text === '' && $fc_html === '') $sse['text'] = $this->mxchat_fc_giveup_text();
2331 echo "data: " . wp_json_encode($sse) . "\n\n";
2332 echo "data: [DONE]\n\n";
2333 flush();
2334 } else {
2335 $fc_response_data = array('text' => $fc_text, 'html' => $fc_html, 'session_id' => $session_id);
2336 if ($testing_data !== null) {
2337 $fc_response_data['testing_data'] = $testing_data;
2338 }
2339 wp_send_json($fc_response_data);
2340 }
2341 wp_die();
2342 }
2343 }
2344 // ===== end function-calling fallback =====
2345
2346 // Streaming + a queued video embed (03ba33): the provider handlers own the
2347 // token stream and the [DONE] terminator, so the embed rides a dedicated
2348 // append_html SSE event emitted BEFORE the stream starts. The client
2349 // stashes it and appends it as its own bot bubble after [DONE] — old
2350 // cached widget JS simply ignores the unknown key (no content/text/html/
2351 // error field, so no branch matches). Transcript save happens after the
2352 // stream completes, so history order matches the live order (text, then
2353 // embed).
2354 if ($is_streaming && !empty($this->videoEmbedHtml)) {
2355 echo "data: " . wp_json_encode(array(
2356 'append_html' => $this->videoEmbedHtml,
2357 'session_id' => $session_id,
2358 )) . "\n\n";
2359 flush();
2360 }
2361
2362 $response = $this->mxchat_generate_response(
2363 $context_content,
2364 $current_options['api_key'] ?? $this->options['api_key'],
2365 $current_options['xai_api_key'] ?? $this->options['xai_api_key'],
2366 $current_options['claude_api_key'] ?? $this->options['claude_api_key'],
2367 $current_options['deepseek_api_key'] ?? $this->options['deepseek_api_key'],
2368 $current_options['gemini_api_key'] ?? $this->options['gemini_api_key'],
2369 $current_options['openrouter_api_key'] ?? $this->options['openrouter_api_key'],
2370 $conversation_history,
2371 $is_streaming,
2372 $session_id,
2373 $testing_data,
2374 $selected_model
2375 );
2376
2377 // Handle streaming vs non-streaming responses
2378 if ($is_streaming) {
2379 // Check if streaming actually happened or if it fell back to regular response
2380 if ($response === true) {
2381 // Persist the video embed AFTER the provider saved the streamed
2382 // text, so history replays in the same order the visitor saw
2383 // (text bubble, then embed bubble). See 03ba33.
2384 if (!empty($this->videoEmbedHtml)) {
2385 $this->mxchat_save_chat_message($session_id, 'bot', $this->videoEmbedHtml);
2386 }
2387 wp_die();
2388 }
2389 // If we get here, streaming fell back to regular response, continue
2390 // But if there's an error, we need to send it as SSE format since headers are already set
2391 if (is_array($response) && isset($response['error'])) {
2392 $error_message = $response['error'];
2393 $error_code = $response['error_code'] ?? 'api_error';
2394 // Send error in SSE format that the client JS can handle
2395 echo "data: " . json_encode([
2396 'error' => true,
2397 'error_message' => $error_message,
2398 'error_code' => $error_code,
2399 'text' => $error_message, // Also include as text for fallback handling
2400 'message' => $error_message
2401 ]) . "\n\n";
2402 echo "data: [DONE]\n\n";
2403 flush();
2404 wp_die();
2405 }
2406 }
2407
2408 // Check if the response is an error array (non-streaming mode)
2409 if (is_array($response) && isset($response['error'])) {
2410 wp_send_json_error([
2411 'error_message' => $response['error'],
2412 'error_code' => $response['error_code'] ?? 'api_error'
2413 ]);
2414 wp_die();
2415 }
2416
2417 // DEBUG: Check what we have
2418 //error_log("=== BEFORE URL VALIDATION ===");
2419 //error_log("current_valid_urls is empty? " . (empty($this->current_valid_urls) ? 'YES' : 'NO'));
2420 //error_log("current_valid_urls count: " . count($this->current_valid_urls));
2421 //error_log("current_valid_urls content: " . print_r($this->current_valid_urls, true));
2422
2423 // If we get here, the response is valid text - now validate URLs
2424 if (!empty($this->current_valid_urls)) {
2425 //error_log("CALLING validate_and_clean_urls");
2426 $response = $this->validate_and_clean_urls($response, $this->current_valid_urls, $session_id, $bot_id);
2427 } else {
2428 //error_log("SKIPPING validation - current_valid_urls is empty");
2429 }
2430 // ===== END URL VALIDATION =====
2431
2432 // Prepare RAG context data for storage (only include documents used for context)
2433 $rag_context_for_storage = null;
2434 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
2435 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
2436
2437 if ($has_rag_data || $has_action_data) {
2438 $rag_context_for_storage = [];
2439
2440 // Add RAG/source data if available
2441 if ($has_rag_data) {
2442 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
2443 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
2444 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
2445 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
2446 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
2447 $rag_context_for_storage['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
2448 $rag_context_for_storage['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
2449 }
2450
2451 // Add action analysis data if available
2452 if ($has_action_data) {
2453 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
2454 }
2455 }
2456
2457 // Save the cleaned response with RAG context
2458 $this->mxchat_save_chat_message($session_id, 'bot', $response, null, $rag_context_for_storage);
2459
2460 // Step 5: Save additional content if available
2461 if (!empty($this->productCardHtml)) {
2462 $this->mxchat_save_chat_message($session_id, 'bot', $this->productCardHtml);
2463 }
2464
2465 if (!empty($this->fallbackResponse['html'])) {
2466 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
2467 }
2468
2469 if (!empty($this->videoEmbedHtml)) {
2470 $this->mxchat_save_chat_message($session_id, 'bot', $this->videoEmbedHtml);
2471 }
2472
2473 // Step 6: Return the response
2474 // DEBUG: Check if newlines exist in the response
2475 //error_log("=== MXCHAT NON-STREAMING RESPONSE DEBUG ===");
2476 //error_log("Response has newlines: " . (strpos($response, "\n") !== false ? 'YES' : 'NO'));
2477 //error_log("Response first 500 chars: " . substr($response, 0, 500));
2478
2479 // Product cards and action html keep their existing either/or precedence;
2480 // a queued video embed (03ba33) is APPENDED so it can coexist with both.
2481 $additional_html = !empty($this->productCardHtml) ? $this->productCardHtml : ($this->fallbackResponse['html'] ?? '');
2482 if (!empty($this->videoEmbedHtml)) {
2483 $additional_html .= $this->videoEmbedHtml;
2484 }
2485
2486 $response_data = [
2487 'text' => $response,
2488 'html' => $additional_html,
2489 'session_id' => $session_id
2490 ];
2491
2492 // Include vectorstore error info for admin debugging (only visible to admins via testing_data)
2493 if (!empty($this->last_vectorstore_error) && $testing_data !== null) {
2494 $testing_data['vectorstore_error'] = $this->last_vectorstore_error;
2495 }
2496
2497 // Also pass it as a top-level field so JS can show a better error message to admins
2498 if (!empty($this->last_vectorstore_error) && current_user_can('manage_options')) {
2499 $response_data['vectorstore_error'] = $this->last_vectorstore_error;
2500 }
2501
2502 // Always add testing data for admins (no toggle needed)
2503 if ($testing_data !== null) {
2504 $response_data['testing_data'] = $testing_data;
2505 }
2506
2507 wp_send_json($response_data);
2508 wp_die();
2509 }
2510
2511 /**
2512 * Get bot-specific options for multi-bot functionality
2513 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
2514 */
2515 // Also debug the bot options retrieval
2516 private function get_bot_options($bot_id = 'default') {
2517 //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
2518
2519 // The admin Testing tab renders the real widget as bot_id "testing", which
2520 // is not a registered multi-bot. It must resolve the DEFAULT bot's config
2521 // so the Testing chat behaves exactly like the front-end (same precedent
2522 // as the Actions enabled_bots check).
2523 if ($bot_id === 'testing') {
2524 $bot_id = 'default';
2525 }
2526
2527 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2528 //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
2529 return array();
2530 }
2531
2532 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
2533
2534 if (!empty($bot_options)) {
2535 //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
2536 if (isset($bot_options['similarity_threshold'])) {
2537 //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
2538 }
2539 }
2540
2541 return is_array($bot_options) ? $bot_options : array();
2542 }
2543
2544 /**
2545 * Get bot-specific Pinecone configuration
2546 * Used in the knowledge retrieval functions
2547 */
2548 // Also add debugging to your get_bot_pinecone_config function
2549 private function get_bot_pinecone_config($bot_id = 'default') {
2550 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
2551
2552 // Admin Testing tab bot → resolve the DEFAULT bot's backend. Without this,
2553 // on a multi-bot + Pinecone site the filter below gets an unknown bot id
2554 // with an EMPTY default, returns array(), and the dispatcher silently
2555 // searches the WordPress DB while the front-end searches Pinecone — the
2556 // Testing panel then reports similarity results from a different KB.
2557 if ($bot_id === 'testing') {
2558 $bot_id = 'default';
2559 }
2560
2561 // If default bot or multi-bot add-on not active, use default Pinecone config
2562 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2563 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
2564 $addon_options = get_option('mxchat_pinecone_addon_options', array());
2565 $config = array(
2566 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
2567 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
2568 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
2569 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
2570 );
2571 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
2572 return $config;
2573 }
2574
2575 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
2576
2577 // Hook for multi-bot add-on to provide bot-specific Pinecone config
2578 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
2579
2580 if (!empty($bot_pinecone_config)) {
2581 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
2582 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
2583 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
2584 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
2585 } else {
2586 //error_log("MXCHAT DEBUG: Filter returned empty config!");
2587 }
2588
2589 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
2590 }
2591
2592
2593 // Updated function to check intents and invoke the callback function
2594 private function mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id) {
2595 global $wpdb;
2596 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
2597
2598 // Get the current bot_id
2599 $current_bot_id = $this->get_current_bot_id($session_id);
2600
2601 // Generate the user embedding
2602 $user_embedding = $this->mxchat_generate_embedding($message, $this->options['api_key']);
2603
2604 // Check if embedding generation returned an error
2605 if (is_array($user_embedding) && isset($user_embedding['error'])) {
2606 $error_message = $user_embedding['error'];
2607 $error_code = $user_embedding['error_code'] ?? 'embedding_error';
2608
2609 // FIXED: Send error in appropriate format based on streaming mode
2610 if ($this->is_streaming) {
2611 echo "data: " . json_encode([
2612 'error' => true,
2613 'error_message' => $error_message,
2614 'error_code' => $error_code,
2615 'text' => $error_message,
2616 'message' => $error_message
2617 ]) . "\n\n";
2618 echo "data: [DONE]\n\n";
2619 flush();
2620 } else {
2621 wp_send_json_error([
2622 'error_message' => $error_message,
2623 'error_code' => $error_code
2624 ]);
2625 }
2626 wp_die();
2627 }
2628
2629 // Check if embedding is valid
2630 if (!is_array($user_embedding) || empty($user_embedding)) {
2631 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
2632
2633 // FIXED: Send error in appropriate format based on streaming mode
2634 if ($this->is_streaming) {
2635 echo "data: " . json_encode([
2636 'error' => true,
2637 'error_message' => $error_message,
2638 'error_code' => 'invalid_embedding',
2639 'text' => $error_message,
2640 'message' => $error_message
2641 ]) . "\n\n";
2642 echo "data: [DONE]\n\n";
2643 flush();
2644 } else {
2645 wp_send_json_error([
2646 'error_message' => $error_message,
2647 'error_code' => 'invalid_embedding'
2648 ]);
2649 }
2650 wp_die();
2651 }
2652
2653 // Fetch intents from the database
2654 $table_name = $wpdb->prefix . 'mxchat_intents';
2655 if ($chat_mode === 'agent') {
2656 $query = $wpdb->prepare(
2657 "SELECT * FROM $table_name WHERE callback_function = %s AND (enabled = 1 OR enabled IS NULL)",
2658 'mxchat_handle_switch_to_chatbot_intent'
2659 );
2660 $intents = $wpdb->get_results($query);
2661 } else {
2662 $intents = $wpdb->get_results("SELECT * FROM $table_name WHERE enabled = 1 OR enabled IS NULL");
2663 }
2664
2665 if (empty($intents)) {
2666 return false;
2667 }
2668
2669 // Prefetch individual phrase vectors from wp_mxchat_intent_phrases (grouped by intent_id)
2670 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
2671 $phrases_by_intent = [];
2672 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
2673 $all_phrases = $wpdb->get_results("SELECT intent_id, phrase, embedding_vector FROM $phrases_table");
2674 foreach ($all_phrases as $p) {
2675 $phrases_by_intent[$p->intent_id][] = $p;
2676 }
2677 }
2678
2679 $highest_similarity = -INF;
2680 $matched_intent = null;
2681
2682 // Array to store action analysis for testing panel
2683 $action_analysis = [];
2684
2685 foreach ($intents as $intent) {
2686 // Additional check for enabled state
2687 $is_enabled = isset($intent->enabled) ? (bool)$intent->enabled : true;
2688 if (!$is_enabled) {
2689 continue;
2690 }
2691
2692 // Check if this action is enabled for the current bot
2693 if (!$this->is_action_enabled_for_bot($intent, $current_bot_id)) {
2694 continue;
2695 }
2696
2697 $best_similarity = -INF;
2698 $matched_phrase_text = '';
2699
2700 // Check legacy embedding vector (existing behavior)
2701 $intent_embedding_serialized = $intent->embedding_vector;
2702 $intent_embedding = $intent_embedding_serialized
2703 ? unserialize($intent_embedding_serialized, ['allowed_classes' => false])
2704 : null;
2705
2706 if (is_array($intent_embedding) && !empty($intent_embedding)) {
2707 $legacy_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $intent_embedding);
2708 if ($legacy_similarity > $best_similarity) {
2709 $best_similarity = $legacy_similarity;
2710 $matched_phrase_text = 'legacy';
2711 }
2712 }
2713
2714 // Check individual phrase vectors
2715 if (isset($phrases_by_intent[$intent->id])) {
2716 foreach ($phrases_by_intent[$intent->id] as $phrase_row) {
2717 $phrase_embedding = $phrase_row->embedding_vector
2718 ? unserialize($phrase_row->embedding_vector, ['allowed_classes' => false])
2719 : null;
2720 if (!is_array($phrase_embedding)) {
2721 continue;
2722 }
2723 $phrase_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $phrase_embedding);
2724 if ($phrase_similarity > $best_similarity) {
2725 $best_similarity = $phrase_similarity;
2726 $matched_phrase_text = $phrase_row->phrase;
2727 }
2728 }
2729 }
2730
2731 // Skip if no valid embedding was found at all
2732 if ($best_similarity === -INF) {
2733 continue;
2734 }
2735
2736 $similarity = $best_similarity;
2737 $intent_threshold = isset($intent->similarity_threshold) ? $intent->similarity_threshold : 0.85;
2738
2739 // Store action analysis data for testing panel
2740 $action_analysis[] = [
2741 'intent_label' => $intent->intent_label,
2742 'callback_function' => $intent->callback_function,
2743 'similarity' => round($similarity, 4),
2744 'similarity_percentage' => round($similarity * 100, 2),
2745 'threshold' => $intent_threshold,
2746 'threshold_percentage' => round($intent_threshold * 100, 2),
2747 'above_threshold' => $similarity >= $intent_threshold,
2748 'matched_phrase' => $matched_phrase_text,
2749 'triggered' => false // Will be updated below if this intent is triggered
2750 ];
2751
2752 if ($similarity >= $intent_threshold && $similarity > $highest_similarity) {
2753 $highest_similarity = $similarity;
2754 $matched_intent = $intent;
2755 }
2756 }
2757
2758 // Mark the triggered action if any
2759 if ($matched_intent) {
2760 foreach ($action_analysis as &$action) {
2761 if ($action['intent_label'] === $matched_intent->intent_label) {
2762 $action['triggered'] = true;
2763 break;
2764 }
2765 }
2766 }
2767
2768 // Sort actions by similarity (highest first) and store for testing panel
2769 usort($action_analysis, function($a, $b) {
2770 return $b['similarity'] <=> $a['similarity'];
2771 });
2772
2773 // Store action analysis for testing panel capture
2774 $this->last_action_analysis = $action_analysis;
2775
2776 // Around line 715 in your mxchat_check_intent_and_invoke_callback function
2777 if ($matched_intent) {
2778 // If the callback is a method on this instance (core callback), call it directly
2779 if (method_exists($this, $matched_intent->callback_function)) {
2780 $callback_result = call_user_func(
2781 [$this, $matched_intent->callback_function],
2782 $message,
2783 $user_id,
2784 $session_id,
2785 $matched_intent,
2786 $user_context ?? null
2787 );
2788 } else {
2789 // Otherwise, use apply_filters for add-on callbacks
2790 $callback_result = apply_filters(
2791 $matched_intent->callback_function,
2792 false,
2793 $message,
2794 $user_id,
2795 $session_id,
2796 $matched_intent
2797 );
2798 }
2799
2800 // Handle the callback result properly
2801 if ($callback_result !== false) {
2802 // If callback returned an array with chat_mode, use it directly
2803 if (is_array($callback_result) && isset($callback_result['chat_mode'])) {
2804 $this->fallbackResponse = $callback_result;
2805 return $callback_result; // Return the full array
2806 } else {
2807 $this->fallbackResponse = $callback_result;
2808 return true;
2809 }
2810 }
2811 }
2812
2813 return false;
2814 }
2815
2816 /**
2817 * Check if an action is enabled for a specific bot
2818 */
2819 private function is_action_enabled_for_bot($intent, $bot_id) {
2820 // If enabled_bots column doesn't exist or is null, assume it's enabled for all bots (backward compatibility)
2821 if (!isset($intent->enabled_bots) || empty($intent->enabled_bots)) {
2822 return true;
2823 }
2824
2825 $enabled_bots = json_decode($intent->enabled_bots, true);
2826
2827 // If JSON decode fails or returns empty array, assume enabled for all (backward compatibility)
2828 if (!is_array($enabled_bots) || empty($enabled_bots)) {
2829 return true;
2830 }
2831
2832 // Admin testing tab uses bot_id "testing" — treat it as "default" so all
2833 // default-bot actions are testable from the admin panel
2834 if ($bot_id === 'testing') {
2835 $bot_id = 'default';
2836 }
2837
2838 // Check if the current bot is in the enabled bots list
2839 return in_array($bot_id, $enabled_bots);
2840 }
2841
2842 // Helper function to clear PDF and Word document related transients
2843 private function clear_pdf_transients($session_id) {
2844 // PDF transients
2845 delete_transient('mxchat_pdf_url_' . $session_id);
2846 delete_transient('mxchat_pdf_embeddings_' . $session_id);
2847 delete_transient('mxchat_include_pdf_in_context_' . $session_id);
2848 delete_transient('mxchat_waiting_for_pdf_url_' . $session_id);
2849
2850 // Word document transients
2851 delete_transient('mxchat_word_url_' . $session_id);
2852 delete_transient('mxchat_word_filename_' . $session_id);
2853 delete_transient('mxchat_word_embeddings_' . $session_id);
2854 delete_transient('mxchat_include_word_in_context_' . $session_id);
2855 delete_transient('mxchat_waiting_for_word_' . $session_id);
2856 }
2857
2858
2859
2860 //verified good
2861 public function mxchat_handle_email_capture($message, $user_id, $session_id) {
2862 // Get the user's original instruction/message
2863 $user_instruction = esc_html($this->options['triggered_phrase_response'] ?? esc_html__("Please provide your email address.", 'mxchat'));
2864
2865 // Set instruction for AI - just pass along what the user wanted to say
2866 $this->current_action_instruction = $user_instruction;
2867
2868 // Set the transient to track email capture flow
2869 set_transient('mxchat_email_capture_' . $user_id, true, 5 * MINUTE_IN_SECONDS);
2870
2871 // Return false to let the AI generate the response
2872 return false;
2873 }
2874
2875 public function mxchat_generate_image($message, $user_id, $session_id) {
2876 //error_log("Starting image generation for message: " . $message);
2877
2878 // Prepare a prompt for OpenAI image generation
2879 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2880
2881 // Opt-in routing: when 'custom_provider_for_images' is on, route image gen
2882 // through the configured Custom (OpenAI-compatible) /images/generations route.
2883 if (!empty($this->options['custom_provider_for_images']) && $this->options['custom_provider_for_images'] === 'on') {
2884 $image_response = $this->mxchat_generate_custom_image($prompt);
2885 } else {
2886 // Use the existing OpenAI API key
2887 $openai_api_key = sanitize_text_field($this->options['api_key']);
2888 // Call OpenAI GPT Image to generate an image
2889 $image_response = $this->mxchat_generate_openai_image($prompt, $openai_api_key);
2890 }
2891
2892 // Check if the response contains an image URL
2893 if (isset($image_response['imageUrl'])) {
2894 $image_url = esc_url_raw($image_response['imageUrl']);
2895
2896 // Construct the HTML with a CSS class instead of inline styles
2897 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2898 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2899
2900 // Save the bot message with both text and HTML
2901 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2902 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2903
2904 // Set the fallback response for the chat handler
2905 $this->fallbackResponse = [
2906 'text' => $response_text,
2907 'html' => $response_html,
2908 'images' => [$image_url]
2909 ];
2910
2911 // For debugging/verification - Use json_encode to verify what's being set
2912 //error_log("Image generation successful - fallbackResponse set: " . json_encode($this->fallbackResponse));
2913
2914 // Return the response directly instead of relying on the property
2915 return $this->fallbackResponse;
2916 } else {
2917 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2918
2919 // Save the error message
2920 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2921
2922 // Set the fallback response for the chat handler
2923 $this->fallbackResponse = [
2924 'text' => $response_text,
2925 'html' => '',
2926 'images' => []
2927 ];
2928
2929 //error_log("DALL-E image generation error: " . esc_html($image_response['error'] ?? 'Unknown error.'));
2930 //error_log("Error fallbackResponse set: " . json_encode($this->fallbackResponse));
2931
2932 // Return the response directly instead of relying on the property
2933 return $this->fallbackResponse;
2934 }
2935 }
2936
2937 public function mxchat_generate_gemini_image($message, $user_id, $session_id) {
2938 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2939
2940 $gemini_api_key = sanitize_text_field($this->options['gemini_api_key'] ?? '');
2941 if (empty($gemini_api_key)) {
2942 $response_text = esc_html__("Gemini API key is not configured.", 'mxchat');
2943 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2944 return ['text' => $response_text, 'html' => '', 'images' => []];
2945 }
2946
2947 $image_response = $this->mxchat_generate_imagen_image($prompt, $gemini_api_key);
2948
2949 if (isset($image_response['imageUrl'])) {
2950 $image_url = esc_url_raw($image_response['imageUrl']);
2951
2952 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2953 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2954
2955 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2956 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2957
2958 $this->fallbackResponse = [
2959 'text' => $response_text,
2960 'html' => $response_html,
2961 'images' => [$image_url]
2962 ];
2963
2964 return $this->fallbackResponse;
2965 } else {
2966 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2967
2968 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2969
2970 $this->fallbackResponse = [
2971 'text' => $response_text,
2972 'html' => '',
2973 'images' => []
2974 ];
2975
2976 return $this->fallbackResponse;
2977 }
2978 }
2979
2980 private function mxchat_save_generated_image($base64_data, $mime_type = 'image/png', $prefix = 'mxchat-generated') {
2981 // Map the real mime type to a matching file extension so the saved file's
2982 // extension always agrees with its bytes. A mismatch (e.g. Imagen returning
2983 // webp bytes that were written into a ".png" file) makes the browser refuse
2984 // to render the image even though the file saved successfully and the bot
2985 // reported success — that was the Gemini/Imagen "image never renders" bug.
2986 // OpenAI + custom-provider paths pass 'image/png' explicitly, so they are
2987 // unaffected; this only matters for providers that return another type.
2988 $mime_to_ext = [
2989 'image/jpeg' => 'jpg',
2990 'image/jpg' => 'jpg',
2991 'image/png' => 'png',
2992 'image/webp' => 'webp',
2993 'image/gif' => 'gif',
2994 ];
2995 $mime_type = strtolower(trim((string) $mime_type));
2996 if (isset($mime_to_ext[$mime_type])) {
2997 $extension = $mime_to_ext[$mime_type];
2998 } else {
2999 // Unknown/unsupported type: fall back to png and normalize the stored
3000 // mime so the attachment record and the file extension stay consistent.
3001 $extension = 'png';
3002 $mime_type = 'image/png';
3003 }
3004 $filename = sanitize_file_name($prefix . '-' . wp_generate_uuid4() . '.' . $extension);
3005 $decoded = base64_decode($base64_data);
3006
3007 if ($decoded === false) {
3008 return new \WP_Error('decode_failed', esc_html__('Failed to decode image data.', 'mxchat'));
3009 }
3010
3011 $upload = wp_upload_bits($filename, null, $decoded);
3012
3013 if (!empty($upload['error'])) {
3014 return new \WP_Error('upload_failed', $upload['error']);
3015 }
3016
3017 $attach_id = wp_insert_attachment([
3018 'post_mime_type' => $mime_type,
3019 'post_title' => $prefix,
3020 'post_content' => '',
3021 'post_status' => 'inherit',
3022 ], $upload['file']);
3023
3024 if (is_wp_error($attach_id)) {
3025 return $attach_id;
3026 }
3027
3028 require_once ABSPATH . 'wp-admin/includes/image.php';
3029 $metadata = wp_generate_attachment_metadata($attach_id, $upload['file']);
3030 wp_update_attachment_metadata($attach_id, $metadata);
3031
3032 return esc_url_raw(wp_get_attachment_url($attach_id));
3033 }
3034
3035 private function mxchat_generate_openai_image($prompt, $api_key, $model = 'gpt-image-1', $timeout = 60) {
3036 $api_url = 'https://api.openai.com/v1/images/generations';
3037 $body = json_encode([
3038 'prompt' => sanitize_text_field($prompt),
3039 'n' => 1,
3040 'size' => '1024x1024',
3041 'quality' => 'medium',
3042 'output_format' => 'png',
3043 'model' => sanitize_text_field($model),
3044 ]);
3045
3046 $args = [
3047 'body' => $body,
3048 'headers' => [
3049 'Content-Type' => 'application/json',
3050 'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
3051 ],
3052 'method' => 'POST',
3053 'timeout' => absint($timeout),
3054 ];
3055
3056 $response = wp_remote_post($api_url, $args);
3057
3058 if (is_wp_error($response)) {
3059 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
3060 }
3061
3062 $response_body = json_decode(wp_remote_retrieve_body($response), true);
3063
3064 $b64 = $response_body['data'][0]['b64_json'] ?? $response_body['data'][0]['b64'] ?? null;
3065 if ($b64) {
3066 $saved_url = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-openai');
3067 if (is_wp_error($saved_url)) {
3068 return ['error' => $saved_url->get_error_message()];
3069 }
3070 return ['imageUrl' => $saved_url];
3071 } else {
3072 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
3073 }
3074 }
3075
3076 /**
3077 * Generate an image via a Custom (OpenAI-compatible) provider's /images/generations route.
3078 * Only called when the opt-in 'custom_provider_for_images' setting is on.
3079 */
3080 private function mxchat_generate_custom_image($prompt, $timeout = 90) {
3081 $cfg = $this->mxchat_resolve_custom_provider();
3082 if (empty($cfg['base_url'])) {
3083 return ['error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat')];
3084 }
3085 $url = $cfg['base_url'] . '/images/generations';
3086 if (!empty($cfg['api_version'])) {
3087 $url .= (strpos($url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($cfg['api_version']);
3088 }
3089 $body = wp_json_encode([
3090 'prompt' => sanitize_text_field($prompt),
3091 'n' => 1,
3092 'size' => '1024x1024',
3093 'model' => $cfg['model'],
3094 ]);
3095 $response = wp_remote_post($url, [
3096 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
3097 'body' => $body,
3098 'method' => 'POST',
3099 'timeout' => absint($timeout),
3100 ]);
3101 if (is_wp_error($response)) {
3102 return ['error' => esc_html__('Error generating image (custom provider): ', 'mxchat') . $response->get_error_message()];
3103 }
3104 $resp = json_decode(wp_remote_retrieve_body($response), true);
3105 // Try b64 first (matches OpenAI shape), then url-based fallback.
3106 $b64 = $resp['data'][0]['b64_json'] ?? $resp['data'][0]['b64'] ?? null;
3107 if ($b64) {
3108 $saved = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-custom');
3109 if (is_wp_error($saved)) {
3110 return ['error' => $saved->get_error_message()];
3111 }
3112 return ['imageUrl' => $saved];
3113 }
3114 $remote_url = $resp['data'][0]['url'] ?? null;
3115 if ($remote_url) {
3116 return ['imageUrl' => esc_url_raw($remote_url)];
3117 }
3118 $err_msg = $this->extract_provider_error($resp, esc_html__('Custom provider did not return an image.', 'mxchat'));
3119 return ['error' => esc_html($err_msg)];
3120 }
3121
3122 private function mxchat_generate_imagen_image($prompt, $api_key, $timeout = 60) {
3123 $api_url = 'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict';
3124
3125 $body = json_encode([
3126 'instances' => [['prompt' => sanitize_text_field($prompt)]],
3127 'parameters' => [
3128 'sampleCount' => 1,
3129 'aspectRatio' => '1:1',
3130 ],
3131 ]);
3132
3133 $args = [
3134 'body' => $body,
3135 'headers' => [
3136 'Content-Type' => 'application/json',
3137 'x-goog-api-key' => sanitize_text_field($api_key),
3138 ],
3139 'method' => 'POST',
3140 'timeout' => absint($timeout),
3141 ];
3142
3143 $response = wp_remote_post($api_url, $args);
3144
3145 if (is_wp_error($response)) {
3146 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
3147 }
3148
3149 $response_body = json_decode(wp_remote_retrieve_body($response), true);
3150
3151 $b64 = $response_body['predictions'][0]['bytesBase64Encoded'] ?? $response_body['predictions'][0]['imageBytes'] ?? null;
3152 if ($b64) {
3153 $mime = $response_body['predictions'][0]['mimeType'] ?? 'image/png';
3154 $saved_url = $this->mxchat_save_generated_image($b64, $mime, 'mxchat-gemini');
3155 if (is_wp_error($saved_url)) {
3156 return ['error' => $saved_url->get_error_message()];
3157 }
3158 return ['imageUrl' => $saved_url];
3159 } else {
3160 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
3161 }
3162 }
3163
3164 /**
3165 * Handle web search requests.
3166 *
3167 * Sends the refined search query to the Brave Search API and uses the
3168 * results to generate a conversational response with the AI model.
3169 *
3170 * @since 1.0.0
3171 * @param string $message The user's search query.
3172 * @param string $user_id The user identifier.
3173 * @param string $session_id The current session ID.
3174 * @return array Response array containing text with embedded HTML links
3175 */
3176 public function mxchat_handle_search_request($message, $user_id, $session_id) {
3177 // Step 1: Interpret and refine the search query
3178 $refined_search_query = $this->mxchat_interpret_search_query($message);
3179 if (empty($refined_search_query)) {
3180 return array(
3181 'text' => esc_html__('I apologize, but could you please rephrase your search request?', 'mxchat'),
3182 'html' => ''
3183 );
3184 }
3185
3186 // Retrieve and validate API settings
3187 $options = get_option('mxchat_options');
3188 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
3189 $results_count = isset($options['brave_results_count']) ? absint($options['brave_results_count']) : 5;
3190
3191 if (empty($api_key)) {
3192 return array(
3193 'text' => esc_html__('Search functionality is temporarily unavailable. Please try again later.', 'mxchat'),
3194 'html' => ''
3195 );
3196 }
3197
3198 // Build the API request URL
3199 $api_url = add_query_arg(
3200 array(
3201 'q' => rawurlencode($refined_search_query),
3202 'count' => $results_count,
3203 'text_decorations' => 'true',
3204 'rich_data' => 'true',
3205 ),
3206 'https://api.search.brave.com/res/v1/web/search'
3207 );
3208
3209 // Attempt to retrieve cached results first
3210 $transient_key = 'mxchat_search_' . md5($refined_search_query);
3211 $results = get_transient($transient_key);
3212
3213 if (false === $results) {
3214 // SECURITY FIX: Changed to wp_safe_remote_get
3215 $response = wp_safe_remote_get(
3216 $api_url,
3217 array(
3218 'headers' => array(
3219 'Accept' => 'application/json',
3220 'Accept-Encoding' => 'gzip',
3221 'X-Subscription-Token'=> $api_key,
3222 ),
3223 'timeout' => 10,
3224 )
3225 );
3226
3227 if (is_wp_error($response)) {
3228 return array(
3229 'text' => esc_html__('I encountered an error while searching. Please try again.', 'mxchat'),
3230 'html' => ''
3231 );
3232 }
3233
3234 $results = json_decode(wp_remote_retrieve_body($response), true);
3235
3236 if (json_last_error() !== JSON_ERROR_NONE) {
3237 return array(
3238 'text' => esc_html__('I received an invalid response from the search service.', 'mxchat'),
3239 'html' => ''
3240 );
3241 }
3242
3243 // Cache results for one hour
3244 set_transient($transient_key, $results, HOUR_IN_SECONDS);
3245 }
3246
3247 // Process results
3248 if (!empty($results['web']['results']) && is_array($results['web']['results'])) {
3249 // Create a more straightforward summary with HTML links
3250 $search_results_text = '';
3251
3252 // Add a simple intro
3253 $search_results_text .= sprintf(
3254 esc_html__("Here's what I found about '%s':", 'mxchat'),
3255 esc_html($refined_search_query)
3256 );
3257
3258 // Add the top results with HTML links
3259 foreach (array_slice($results['web']['results'], 0, 5) as $result) {
3260 $title = isset($result['title']) ? wp_strip_all_tags($result['title']) : '';
3261 $url = isset($result['url']) ? esc_url($result['url']) : '';
3262 $description = isset($result['description']) ? wp_strip_all_tags($result['description']) : '';
3263
3264 // Add a line break after the intro
3265 $search_results_text .= '<br><br>';
3266
3267 // Add title as a link
3268 $search_results_text .= sprintf(
3269 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a><br>',
3270 $url,
3271 $title
3272 );
3273
3274 // Add a condensed description
3275 $search_results_text .= sprintf("%s", $description);
3276 }
3277
3278 // Save to chat history
3279 $this->mxchat_save_chat_message($session_id, 'bot', $search_results_text);
3280
3281 // Return the formatted text with embedded HTML links
3282 return array(
3283 'text' => $search_results_text,
3284 'html' => ''
3285 );
3286 } else {
3287 return array(
3288 'text' => sprintf(
3289 esc_html__('I searched for "%s" but couldn\'t find any relevant results. Would you like to try different search terms?', 'mxchat'),
3290 esc_html($refined_search_query)
3291 ),
3292 'html' => ''
3293 );
3294 }
3295 }
3296
3297 //very good
3298 /**
3299 * Handle image search requests from the chatbot
3300 *
3301 * @param string $message The user's search query
3302 * @param int $user_id The user's ID
3303 * @param string $session_id The chat session ID
3304 * @return array Response array with text and HTML content
3305 */
3306 public function mxchat_handle_image_search_request($message, $user_id, $session_id) {
3307 // Step 1: Interpret the search query using the user's selected AI model
3308 $refined_search_query = $this->mxchat_interpret_search_query($message);
3309
3310 // If no query was interpreted, return a fallback message
3311 if (empty($refined_search_query)) {
3312 return array(
3313 'text' => __("I'm sorry, I couldn't interpret your search query. Please specify what you'd like to see images of.", 'mxchat'),
3314 'html' => "",
3315 );
3316 }
3317
3318 // Brave API URL
3319 $api_url = 'https://api.search.brave.com/res/v1/images/search';
3320
3321 // Retrieve Brave API settings
3322 $options = get_option('mxchat_options');
3323 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
3324
3325 if (empty($api_key)) {
3326 return array(
3327 'text' => __("API key is not configured. Please set it in the Brave Search Settings.", 'mxchat'),
3328 'html' => "",
3329 );
3330 }
3331
3332 $image_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
3333 $safe_search = isset($options['brave_safe_search']) ? sanitize_text_field($options['brave_safe_search']) : 'strict';
3334
3335 // Append query parameters based on settings
3336 $api_url = add_query_arg([
3337 'q' => rawurlencode($refined_search_query),
3338 'count' => $image_count,
3339 'safesearch' => $safe_search,
3340 ], $api_url);
3341
3342 // Implement caching
3343 $transient_key = 'mxchat_image_search_' . md5($refined_search_query);
3344 $body = get_transient($transient_key);
3345
3346 if (false === $body) {
3347 $args = [
3348 'headers' => [
3349 'Accept' => 'application/json',
3350 'Accept-Encoding' => 'gzip',
3351 'X-Subscription-Token' => $api_key,
3352 ],
3353 'timeout' => 10,
3354 ];
3355
3356 // SECURITY FIX: Changed to wp_safe_remote_get
3357 $response = wp_safe_remote_get($api_url, $args);
3358
3359 if (is_wp_error($response)) {
3360 return array(
3361 'text' => __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat'),
3362 'html' => "",
3363 );
3364 }
3365
3366 $body = json_decode(wp_remote_retrieve_body($response), true);
3367 set_transient($transient_key, $body, HOUR_IN_SECONDS);
3368 }
3369
3370 // Process the API response
3371 if (isset($body['results']) && is_array($body['results']) && count($body['results']) > 0) {
3372 $html_output = '<div class="mxchat-image-gallery">';
3373
3374 // Get the configured image count (1-6)
3375 $display_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
3376 $display_count = min($display_count, count($body['results'])); // Make sure we don't exceed available images
3377
3378 // Use only the requested number of images
3379 for ($i = 0; $i < $display_count; $i++) {
3380 $image = $body['results'][$i];
3381 $image_url = isset($image['url']) ? esc_url($image['url']) : '';
3382 $thumbnail_url = isset($image['thumbnail']['src']) ? esc_url($image['thumbnail']['src']) : '';
3383 $title = isset($image['title']) ? esc_html($image['title']) : esc_html__('Image', 'mxchat');
3384
3385 if ($image_url && $thumbnail_url) {
3386 $html_output .= '<div class="mxchat-image-item">';
3387 $html_output .= '<strong class="mxchat-image-title">' . $title . '</strong>';
3388 $html_output .= '<a href="' . $image_url . '" target="_blank" rel="noopener noreferrer" class="mxchat-image-link">';
3389 $html_output .= '<img src="' . $thumbnail_url . '" alt="' . $title . '" class="mxchat-image-thumbnail">';
3390 $html_output .= '</a></div>';
3391 }
3392 }
3393
3394 $html_output .= '</div>';
3395
3396 // Create response text
3397 $response_text = sprintf(__("Here are some images of %s:", 'mxchat'), $refined_search_query);
3398
3399 // Save both response text and HTML to chat history
3400 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
3401 $this->mxchat_save_chat_message($session_id, 'bot', $html_output);
3402
3403 // Return the combined response
3404 return array(
3405 'text' => $response_text,
3406 'html' => $html_output,
3407 );
3408 } else {
3409 $response_text = __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat');
3410
3411 // Save the error message to chat history
3412 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
3413
3414 return array(
3415 'text' => $response_text,
3416 'html' => "",
3417 );
3418 }
3419 }
3420
3421 /**
3422 * Interpret the search query using the user's selected AI model
3423 *
3424 * @param string $user_query The original query from the user
3425 * @return string The refined search query
3426 */
3427 public function mxchat_interpret_search_query($user_query) {
3428 $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');
3429
3430 // Get options and determine the selected model
3431 $options = $this->options ?? get_option('mxchat_options');
3432 $selected_model = isset($options['model']) ? $options['model'] : 'gpt-5.1-chat-latest';
3433
3434 // Custom (OpenAI-compatible) provider routes by model id, not prefix.
3435 if ($selected_model === 'custom-provider') {
3436 return $this->interpret_query_with_custom($user_query, $system_prompt);
3437 }
3438
3439 // Extract model prefix to determine the provider
3440 $model_parts = explode('-', $selected_model);
3441 $provider = strtolower($model_parts[0]);
3442
3443 // Determine which API key to use based on the provider
3444 switch ($provider) {
3445 case 'gemini':
3446 $api_key = isset($options['gemini_api_key']) ? sanitize_text_field($options['gemini_api_key']) : '';
3447 if (empty($api_key)) {
3448 return sanitize_text_field($user_query); // Default to original query if API key missing
3449 }
3450 return $this->interpret_query_with_gemini($user_query, $system_prompt, $api_key, $selected_model);
3451
3452 case 'claude':
3453 $api_key = isset($options['claude_api_key']) ? sanitize_text_field($options['claude_api_key']) : '';
3454 if (empty($api_key)) {
3455 return sanitize_text_field($user_query);
3456 }
3457 return $this->interpret_query_with_claude($user_query, $system_prompt, $api_key, $selected_model);
3458
3459 case 'grok':
3460 $api_key = isset($options['xai_api_key']) ? sanitize_text_field($options['xai_api_key']) : '';
3461 if (empty($api_key)) {
3462 return sanitize_text_field($user_query);
3463 }
3464 return $this->interpret_query_with_xai($user_query, $system_prompt, $api_key, $selected_model);
3465
3466 case 'deepseek':
3467 $api_key = isset($options['deepseek_api_key']) ? sanitize_text_field($options['deepseek_api_key']) : '';
3468 if (empty($api_key)) {
3469 return sanitize_text_field($user_query);
3470 }
3471 return $this->interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $selected_model);
3472
3473 case 'gpt':
3474 default:
3475 // Default to OpenAI for custom models or unrecognized prefixes
3476 $api_key = isset($options['api_key']) ? sanitize_text_field($options['api_key']) : '';
3477 if (empty($api_key)) {
3478 return sanitize_text_field($user_query);
3479 }
3480 return $this->interpret_query_with_openai($user_query, $system_prompt, $api_key, $selected_model);
3481 }
3482 }
3483
3484 /**
3485 * Interpret query against the configured Custom (OpenAI-compatible) provider.
3486 * Uses the same base URL + auth scheme as the chat dispatcher.
3487 */
3488 private function interpret_query_with_custom($user_query, $system_prompt) {
3489 $cfg = $this->mxchat_resolve_custom_provider();
3490 if (empty($cfg['base_url'])) {
3491 return sanitize_text_field($user_query);
3492 }
3493 // plan-mxchat-20260715-7124f4: a custom OpenAI-compatible endpoint pointed at
3494 // a gpt-5-class model rejects temperature!=1 and the legacy max_tokens key.
3495 // Byte-identical for ordinary custom models (temperature kept, max_tokens
3496 // used); only gpt-5-class custom models change (best-effort — custom
3497 // endpoints vary).
3498 $token_key = $this->mxchat_openai_token_param_for($cfg['model']);
3499 $payload = [
3500 'model' => $cfg['model'],
3501 'messages' => [
3502 ['role' => 'system', 'content' => $system_prompt],
3503 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3504 ],
3505 $token_key => 20,
3506 ];
3507 if ($this->mxchat_openai_supports_temperature_for($cfg['model'])) {
3508 $payload['temperature'] = 0.2;
3509 }
3510 $args = [
3511 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
3512 'body' => wp_json_encode($payload),
3513 'method' => 'POST',
3514 'timeout' => 15,
3515 ];
3516 $response = wp_remote_post($cfg['chat_url'], $args);
3517 if (is_wp_error($response)) {
3518 return sanitize_text_field($user_query);
3519 }
3520 $body = json_decode(wp_remote_retrieve_body($response), true);
3521 return isset($body['choices'][0]['message']['content'])
3522 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
3523 : sanitize_text_field($user_query);
3524 }
3525
3526 /**
3527 * Convert the colon-style header list returned by mxchat_resolve_custom_provider
3528 * into the assoc-array form wp_remote_post expects.
3529 */
3530 private function mxchat_custom_provider_assoc_headers($cfg) {
3531 $headers = ['Content-Type' => 'application/json'];
3532 if (!empty($cfg['api_key'])) {
3533 if (($cfg['auth_scheme'] ?? 'bearer') === 'api-key') {
3534 $headers['api-key'] = $cfg['api_key'];
3535 } else {
3536 $headers['Authorization'] = 'Bearer ' . $cfg['api_key'];
3537 }
3538 }
3539 return $headers;
3540 }
3541
3542 /**
3543 * Interpret query using OpenAI models
3544 */
3545 private function interpret_query_with_openai($user_query, $system_prompt, $api_key, $model = 'gpt-5.1-chat-latest') {
3546 $url = 'https://api.openai.com/v1/chat/completions';
3547 // plan-mxchat-20260715-7124f4: the default chat model is gpt-5.1-chat-latest
3548 // and every gpt-5* rejects both a non-default temperature and the legacy
3549 // max_tokens key (400). This call swallowed the 400 and silently degraded to
3550 // the raw query on every gpt-5 install, quietly disabling product/image
3551 // search-query interpretation. Derive capability from the core catalog
3552 // (dcb71c) so this tracks future model adds; strpos fallback for a
3553 // partial-upgrade window where the catalog method isn't loaded.
3554 $token_key = $this->mxchat_openai_token_param_for($model);
3555 $payload = [
3556 'model' => $model,
3557 'messages' => [
3558 ['role' => 'system', 'content' => $system_prompt],
3559 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3560 ],
3561 $token_key => 20,
3562 ];
3563 if ($this->mxchat_openai_supports_temperature_for($model)) {
3564 $payload['temperature'] = 0.2;
3565 }
3566 $args = [
3567 'headers' => [
3568 'Authorization' => 'Bearer ' . $api_key,
3569 'Content-Type' => 'application/json',
3570 ],
3571 'body' => wp_json_encode($payload),
3572 'method' => 'POST',
3573 'timeout' => 15,
3574 ];
3575
3576 $response = wp_remote_post($url, $args);
3577 if (is_wp_error($response)) {
3578 return sanitize_text_field($user_query);
3579 }
3580
3581 $body = json_decode(wp_remote_retrieve_body($response), true);
3582 return isset($body['choices'][0]['message']['content'])
3583 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
3584 : sanitize_text_field($user_query);
3585 }
3586
3587 /**
3588 * Anthropic removed temperature/top_p/top_k starting with Opus 4.7 (the API
3589 * returns 400 if sent) — add new flagship model ids here. (We don't send
3590 * top_p/top_k in any Claude body, so the list only needs to gate temperature
3591 * stripping. We never send a `thinking` param either, which is required for
3592 * claude-fable-5: it rejects an explicit thinking "disabled" — omit only.)
3593 */
3594 private function mxchat_claude_omits_temperature($model) {
3595 // plan-mxchat-20260714-dcb71c: derive from the core model catalog (single
3596 // source of truth). Every caller here passes a Claude model, so
3597 // !supports_temperature() reproduces the old 4-id in_array() result exactly.
3598 // Frozen list kept as fallback for a partial-upgrade window where the
3599 // catalog method isn't loaded.
3600 if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'supports_temperature')) {
3601 return !MxChat_Model_Catalog::supports_temperature($model);
3602 }
3603 $no_temp = array('claude-opus-5', 'claude-opus-4-7', 'claude-opus-4-8', 'claude-fable-5', 'claude-sonnet-5');
3604 return in_array($model, $no_temp, true);
3605 }
3606
3607 /**
3608 * plan-mxchat-20260715-7124f4: OpenAI completion-token key for this model,
3609 * sourced from the core catalog (gpt-5* → max_completion_tokens; else
3610 * max_tokens). strpos fallback for a partial-upgrade window where the catalog
3611 * method isn't loaded.
3612 *
3613 * @param string $model OpenAI(-compatible) model id.
3614 * @return string 'max_completion_tokens' | 'max_tokens'
3615 */
3616 private function mxchat_openai_token_param_for($model) {
3617 if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'openai_token_param')) {
3618 return MxChat_Model_Catalog::openai_token_param($model);
3619 }
3620 return strpos((string) $model, 'gpt-5') === 0 ? 'max_completion_tokens' : 'max_tokens';
3621 }
3622
3623 /**
3624 * plan-mxchat-20260715-7124f4: whether a NON-default temperature may be sent to
3625 * this OpenAI(-compatible) model. gpt-5* accept only the default (1) — sending
3626 * any other value 400s. Sourced from the core catalog; strpos fallback for a
3627 * partial-upgrade window.
3628 *
3629 * @param string $model OpenAI(-compatible) model id.
3630 * @return bool
3631 */
3632 private function mxchat_openai_supports_temperature_for($model) {
3633 if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'supports_temperature')) {
3634 return MxChat_Model_Catalog::supports_temperature($model);
3635 }
3636 return strpos((string) $model, 'gpt-5') !== 0;
3637 }
3638
3639 /**
3640 * plan-mxchat-20260714-dcb71c: per-surface reasoning_effort, sourced from the
3641 * core model catalog so a model add propagates automatically. The fallback is
3642 * the frozen pre-dcb71c inline ladder, used only if the catalog method is
3643 * unavailable (a partial-upgrade window). Byte-identical to the old inline
3644 * blocks by construction — proven by the dcb71c equivalence harness.
3645 *
3646 * @param string $model Chat model id.
3647 * @param string $context 'chat' | 'websearch'.
3648 * @return string|null Effort to send, or null to omit the param.
3649 */
3650 private function mxchat_reasoning_effort_for($model, $context) {
3651 if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'reasoning_effort_for')) {
3652 return MxChat_Model_Catalog::reasoning_effort_for($model, $context);
3653 }
3654 return $this->mxchat_reasoning_effort_fallback($model, $context);
3655 }
3656
3657 private function mxchat_reasoning_effort_fallback($model, $context) {
3658 if (strpos($model, 'gpt-5') !== 0) {
3659 return null;
3660 }
3661 if ($context === 'websearch') {
3662 $no_reasoning_web = array('gpt-5.2', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
3663 if (in_array($model, $no_reasoning_web, true)) return null;
3664 if ($model === 'gpt-5.1-2025-11-13') return 'low';
3665 if ($model === 'gpt-5.5') return 'low';
3666 if ($model === 'gpt-5.4') return 'low';
3667 if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low';
3668 return null;
3669 }
3670 // 'chat'
3671 $no_reasoning_models = array('gpt-5.2', 'gpt-5.1-chat-latest', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
3672 if (in_array($model, $no_reasoning_models, true)) return null;
3673 if ($model === 'gpt-5.1-2025-11-13') return 'low';
3674 if ($model === 'gpt-5.5') return 'none';
3675 if ($model === 'gpt-5.4') return 'none';
3676 if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low';
3677 return 'minimal';
3678 }
3679
3680 /**
3681 * Interpret query using Claude models
3682 */
3683 private function interpret_query_with_claude($user_query, $system_prompt, $api_key, $model) {
3684 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
3685 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
3686 if ($model === 'claude-opus-4-20250514') { $model = 'claude-opus-4-8'; }
3687 elseif ($model === 'claude-sonnet-4-20250514') { $model = 'claude-sonnet-4-6'; }
3688 $url = 'https://api.anthropic.com/v1/messages';
3689
3690 $payload = [
3691 'model' => $model,
3692 'system' => $system_prompt,
3693 'messages' => [
3694 ['role' => 'user', 'content' => sanitize_text_field($user_query)]
3695 ],
3696 'max_tokens' => 20,
3697 'temperature' => 0.2,
3698 ];
3699 if ($this->mxchat_claude_omits_temperature($model)) { unset($payload['temperature']); }
3700
3701 $args = [
3702 'headers' => [
3703 'Content-Type' => 'application/json',
3704 'x-api-key' => $api_key,
3705 'anthropic-version' => '2023-06-01',
3706 ],
3707 'body' => wp_json_encode($payload),
3708 'method' => 'POST',
3709 'timeout' => 15,
3710 ];
3711
3712 $response = wp_remote_post($url, $args);
3713 if (is_wp_error($response)) {
3714 return sanitize_text_field($user_query);
3715 }
3716
3717 $body = json_decode(wp_remote_retrieve_body($response), true);
3718 // claude-fable-5 prepends a thinking block to content — take the first
3719 // TEXT block, not content[0].
3720 foreach ((array) ($body['content'] ?? array()) as $block) {
3721 if (isset($block['type'], $block['text']) && $block['type'] === 'text' && trim($block['text']) !== '') {
3722 return sanitize_text_field(trim($block['text']));
3723 }
3724 }
3725
3726 return sanitize_text_field($user_query);
3727 }
3728
3729 /**
3730 * Interpret query using Gemini models
3731 */
3732 private function interpret_query_with_gemini($user_query, $system_prompt, $api_key, $model) {
3733 if ($model === 'gemini-3-pro-preview') {
3734 $model = 'gemini-3.1-pro-preview';
3735 }
3736 // Use v1beta for preview models, v1 for stable models
3737 $api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
3738
3739 $url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key=" . urlencode($api_key);
3740
3741 $args = [
3742 'headers' => [
3743 'Content-Type' => 'application/json',
3744 ],
3745 'body' => wp_json_encode([
3746 'contents' => [
3747 [
3748 'role' => 'user',
3749 'parts' => [
3750 ['text' => $system_prompt . "\n\nQuery: " . sanitize_text_field($user_query)]
3751 ]
3752 ]
3753 ],
3754 'generationConfig' => [
3755 'temperature' => 0.2,
3756 'maxOutputTokens' => 20,
3757 ],
3758 ]),
3759 'method' => 'POST',
3760 'timeout' => 15,
3761 ];
3762
3763 $response = wp_remote_post($url, $args);
3764 if (is_wp_error($response)) {
3765 return sanitize_text_field($user_query);
3766 }
3767
3768 $body = json_decode(wp_remote_retrieve_body($response), true);
3769 if (!empty($body['candidates'][0]['content']['parts'][0]['text'])) {
3770 return sanitize_text_field(trim($body['candidates'][0]['content']['parts'][0]['text']));
3771 }
3772
3773 return sanitize_text_field($user_query);
3774 }
3775
3776 /**
3777 * Interpret query using X.AI (Grok) models
3778 */
3779 private function interpret_query_with_xai($user_query, $system_prompt, $api_key, $model) {
3780 $url = 'https://api.xai.com/v1/chat/completions';
3781
3782 $args = [
3783 'headers' => [
3784 'Content-Type' => 'application/json',
3785 'Authorization' => 'Bearer ' . $api_key,
3786 ],
3787 'body' => wp_json_encode([
3788 'model' => $model,
3789 'messages' => [
3790 ['role' => 'system', 'content' => $system_prompt],
3791 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3792 ],
3793 'temperature' => 0.2,
3794 'max_tokens' => 20,
3795 ]),
3796 'method' => 'POST',
3797 'timeout' => 15,
3798 ];
3799
3800 $response = wp_remote_post($url, $args);
3801 if (is_wp_error($response)) {
3802 return sanitize_text_field($user_query);
3803 }
3804
3805 $body = json_decode(wp_remote_retrieve_body($response), true);
3806 if (isset($body['choices'][0]['message']['content'])) {
3807 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
3808 }
3809
3810 return sanitize_text_field($user_query);
3811 }
3812
3813 /**
3814 * Interpret query using DeepSeek models
3815 */
3816 private function interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $model) {
3817 $url = 'https://api.deepseek.com/v1/chat/completions';
3818
3819 $args = [
3820 'headers' => [
3821 'Content-Type' => 'application/json',
3822 'Authorization' => 'Bearer ' . $api_key,
3823 ],
3824 'body' => wp_json_encode([
3825 'model' => $model,
3826 'messages' => [
3827 ['role' => 'system', 'content' => $system_prompt],
3828 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3829 ],
3830 'temperature' => 0.2,
3831 'max_tokens' => 20,
3832 // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
3833 // reasoning burns the 20-token budget); keep the legacy
3834 // deepseek-chat semantics = non-thinking.
3835 'thinking' => ['type' => 'disabled'],
3836 ]),
3837 'method' => 'POST',
3838 'timeout' => 15,
3839 ];
3840
3841 $response = wp_remote_post($url, $args);
3842 if (is_wp_error($response)) {
3843 return sanitize_text_field($user_query);
3844 }
3845
3846 $body = json_decode(wp_remote_retrieve_body($response), true);
3847 if (isset($body['choices'][0]['message']['content'])) {
3848 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
3849 }
3850
3851 return sanitize_text_field($user_query);
3852 }
3853
3854 //very good
3855 private function add_email_to_loops($email) {
3856 // Sanitize the email
3857 $email = sanitize_email($email);
3858
3859 // Retrieve and sanitize options
3860 $api_key = isset($this->options['loops_api_key']) ? sanitize_text_field($this->options['loops_api_key']) : '';
3861 $mailing_list_id = isset($this->options['loops_mailing_list']) ? sanitize_text_field($this->options['loops_mailing_list']) : '';
3862
3863 // Check for missing API key or mailing list ID
3864 if (empty($api_key) || empty($mailing_list_id)) {
3865 //error_log(esc_html__('Loops API key or mailing list ID is missing.', 'mxchat'));
3866 return;
3867 }
3868
3869 $data = array(
3870 'email' => $email,
3871 'subscribed' => true,
3872 'source' => __('MxChat AI Chatbot', 'mxchat'),
3873 'mailingLists' => array($mailing_list_id => true),
3874 );
3875
3876 $url = 'https://app.loops.so/api/v1/contacts/create';
3877 $args = array(
3878 'body' => wp_json_encode($data),
3879 'headers' => array(
3880 'Authorization' => 'Bearer ' . $api_key,
3881 'Content-Type' => 'application/json',
3882 ),
3883 'method' => 'POST',
3884 'timeout' => 45,
3885 );
3886
3887 $response = wp_remote_post($url, $args);
3888
3889 // Handle errors in the API request
3890 if (is_wp_error($response)) {
3891 //error_log(esc_html__('Error adding email to Loops: ', 'mxchat') . $response->get_error_message());
3892 return;
3893 }
3894
3895 // Check for non-200 HTTP responses
3896 $response_code = wp_remote_retrieve_response_code($response);
3897 if ($response_code != 200) {
3898 $response_body = wp_remote_retrieve_body($response);
3899 //error_log(esc_html__('Loops API responded with code ', 'mxchat') . $response_code . ': ' . $response_body);
3900 }
3901 }
3902
3903 public function mxchat_handle_pdf_discussion($message, $user_id, $session_id) {
3904 // Get the maximum number of pages allowed from admin settings
3905 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3906
3907 // Retrieve options for dynamic texts
3908 $trigger_text = $this->options['pdf_intent_trigger_text'] ?? __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
3909 $success_text = $this->options['pdf_intent_success_text'] ?? __("I've processed the PDF. What questions do you have about it?", 'mxchat');
3910 $error_text = $this->options['pdf_intent_error_text'] ?? __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
3911
3912 // Check for explicit request for new PDF
3913 $new_pdf_requested = stripos($message, 'new') !== false ||
3914 stripos($message, 'another') !== false ||
3915 stripos($message, 'different') !== false;
3916
3917 // If user mentions adding/reading a PDF, set waiting flag
3918 if (stripos($message, 'pdf') !== false ||
3919 stripos($message, 'document') !== false ||
3920 stripos($message, 'read') !== false) {
3921 set_transient('mxchat_waiting_for_pdf_url_' . $session_id, true, HOUR_IN_SECONDS);
3922 $this->fallbackResponse['text'] = $trigger_text;
3923 return;
3924 }
3925
3926 // If we're waiting for a URL or user requested new PDF
3927 if ($new_pdf_requested || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
3928 if (preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
3929 // Process URL... (rest of your existing URL processing code)
3930 } else {
3931 $this->fallbackResponse['text'] = $trigger_text;
3932 }
3933 return;
3934 }
3935
3936 // Default to proceeding with conversation if no specific PDF action is needed
3937 $this->fallbackResponse['text'] = '';
3938 }
3939
3940
3941 /**
3942 * Enhanced fetch_and_split_pdf_pages with SSRF protection
3943 */
3944 private function fetch_and_split_pdf_pages($pdf_source, $max_pages) {
3945 // CLEAR DEBUG LOGGING
3946 //error_log("=== MXCHAT PDF PROCESSING START ===");
3947 //error_log("PDF Source: " . $pdf_source);
3948 //error_log("Max Pages: " . $max_pages);
3949 //error_log("Session ID: " . ($this->session_id ?? 'not set'));
3950
3951 // Check if Advanced Claude Toolbar is available and enabled
3952 $claude_available = function_exists('mxchatACT_is_advanced_claude_enabled');
3953 $claude_enabled = $claude_available ? mxchatACT_is_advanced_claude_enabled() : false;
3954
3955 //error_log("Claude Function Available: " . ($claude_available ? 'YES' : 'NO'));
3956 //error_log("Claude Enabled: " . ($claude_enabled ? 'YES' : 'NO'));
3957
3958 if ($claude_available && $claude_enabled) {
3959 //error_log("🚀 ATTEMPTING CLAUDE PROCESSING...");
3960
3961 // Attempt Claude processing first
3962 $claude_result = apply_filters('mxchat_process_pdf_advanced', false, $pdf_source, $max_pages, $this->session_id);
3963
3964 if ($claude_result !== false && is_array($claude_result) && !empty($claude_result)) {
3965 //error_log("✅ CLAUDE PROCESSING SUCCESSFUL!");
3966 //error_log("Claude returned " . count($claude_result) . " processed pages");
3967
3968 // Log first page details for verification
3969 if (isset($claude_result[0])) {
3970 $first_page = $claude_result[0];
3971 //error_log("First page enhanced: " . (isset($first_page['enhanced']) && $first_page['enhanced'] ? 'YES' : 'NO'));
3972 //error_log("Processing method: " . ($first_page['processing_method'] ?? 'not set'));
3973 //error_log("First page text preview: " . substr($first_page['text'] ?? '', 0, 100) . "...");
3974 }
3975
3976 //error_log("=== MXCHAT PDF PROCESSING END (CLAUDE) ===");
3977 return $claude_result;
3978 } else {
3979 //error_log("❌ CLAUDE PROCESSING FAILED or returned invalid result");
3980 //error_log("Claude result type: " . gettype($claude_result));
3981 if (is_array($claude_result)) {
3982 //error_log("Claude result count: " . count($claude_result));
3983 }
3984 }
3985 }
3986
3987 // Fallback to basic processing
3988 //error_log("🔄 FALLING BACK TO BASIC PDF PROCESSING...");
3989
3990 $upload_dir = wp_upload_dir();
3991 $temp_file = null;
3992
3993 try {
3994 // Your existing basic processing code here...
3995 // (I'll include the key parts with debug logging)
3996
3997 if (filter_var($pdf_source, FILTER_VALIDATE_URL)) {
3998 //error_log("Downloading PDF from URL...");
3999
4000 // SECURITY FIX: Validate URL before processing
4001 if (!$this->mxchat_is_safe_pdf_url($pdf_source)) {
4002 //error_log("❌ SECURITY: Blocked unsafe PDF URL");
4003 return false;
4004 }
4005
4006 $temp_file = wp_tempnam($pdf_source);
4007
4008 // SECURITY FIX: Changed from wp_remote_get to wp_safe_remote_get
4009 // Route through the shared MXChat crawler identity (plan bae78f/b6d93c) so
4010 // every remote-content fetch presents one honest, versioned, filterable,
4011 // allowlistable User-Agent. function_exists guard keeps the front-end/nopriv
4012 // path safe if the helper (in the always-loaded main file) is ever unavailable.
4013 $response = wp_safe_remote_get($pdf_source, [
4014 'timeout' => 60,
4015 'headers' => ['User-Agent' => function_exists('mxchat_ingest_user_agent') ? mxchat_ingest_user_agent() : 'MxChat PDF Processor']
4016 ]);
4017
4018 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
4019 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code($response);
4020 //error_log("❌ BASIC PROCESSING: Failed to download PDF: " . $error_message);
4021 return false;
4022 }
4023
4024 global $wp_filesystem;
4025 if (empty($wp_filesystem)) {
4026 require_once ABSPATH . 'wp-admin/includes/file.php';
4027 WP_Filesystem();
4028 }
4029 $wp_filesystem->put_contents($temp_file, wp_remote_retrieve_body($response), FS_CHMOD_FILE);
4030 //error_log("✅ PDF downloaded successfully");
4031 } else {
4032 $temp_file = $pdf_source;
4033 //error_log("Using local PDF file: " . $temp_file);
4034 }
4035
4036 // Parse PDF
4037 //error_log("Parsing PDF with basic parser...");
4038 mxchat_load_pdf_parser();
4039 $parser = new \Smalot\PdfParser\Parser();
4040 $pdf = $parser->parseFile($temp_file);
4041 $pages = $pdf->getPages();
4042
4043 //error_log("PDF contains " . count($pages) . " pages");
4044
4045 if (count($pages) > $max_pages) {
4046 //error_log("❌ BASIC PROCESSING: Too many pages (" . count($pages) . " > " . $max_pages . ")");
4047 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file) {
4048 unlink($temp_file);
4049 }
4050 return 'too_many_pages';
4051 }
4052
4053 $embeddings = [];
4054 $processed_pages = 0;
4055
4056 foreach ($pages as $page_number => $page) {
4057 $text = $page->getText();
4058
4059 if (empty(trim($text))) {
4060 //error_log("Skipping empty page: " . ($page_number + 1));
4061 continue;
4062 }
4063
4064 $text = $this->mxchat_clean_text($text);
4065
4066 $embedding = $this->mxchat_generate_embedding(
4067 __("Page ", 'mxchat') . ($page_number + 1) . ": " . $text,
4068 $this->options['api_key']
4069 );
4070
4071 if ($embedding) {
4072 $embeddings[] = [
4073 'page_number' => $page_number + 1,
4074 'embedding' => $embedding,
4075 'text' => $text,
4076 'enhanced' => false, // CLEARLY MARK AS BASIC
4077 'processing_method' => 'basic_pdf_parser'
4078 ];
4079 $processed_pages++;
4080 }
4081 }
4082
4083 //error_log("✅ BASIC PROCESSING COMPLETE: " . $processed_pages . " pages processed");
4084
4085 // Cleanup
4086 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
4087 unlink($temp_file);
4088 }
4089
4090 //error_log("=== MXCHAT PDF PROCESSING END (BASIC) ===");
4091 return $embeddings;
4092
4093 } catch (\Exception $e) {
4094 //error_log("❌ BASIC PROCESSING ERROR: " . $e->getMessage());
4095 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
4096 unlink($temp_file);
4097 }
4098 //error_log("=== MXCHAT PDF PROCESSING END (ERROR) ===");
4099 return false;
4100 }
4101 }
4102
4103
4104 /**
4105 * Validate PDF URL for security
4106 * Prevents SSRF attacks by blocking dangerous URLs
4107 */
4108
4109 private function mxchat_is_safe_pdf_url($url) {
4110 // Use WordPress core function for comprehensive validation
4111 // This blocks localhost, private IPs, and reserved IP ranges
4112 $validated_url = wp_http_validate_url($url);
4113
4114 if ($validated_url === false) {
4115 return false;
4116 }
4117
4118 // Additional check: only allow HTTP/HTTPS schemes
4119 $parsed = parse_url($url);
4120 if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], ['http', 'https'], true)) {
4121 return false;
4122 }
4123
4124 return true;
4125 }
4126
4127
4128 private function mxchat_clean_text($text) {
4129 // Remove excessive whitespace
4130 $text = preg_replace('/\s+/', ' ', $text);
4131
4132 // Remove control characters except newlines and tabs
4133 $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
4134
4135 // Normalize line endings
4136 $text = str_replace(["\r\n", "\r"], "\n", $text);
4137
4138 // Trim whitespace
4139 $text = trim($text);
4140
4141 return $text;
4142 }
4143
4144 private function find_relevant_pdf_pages($query_embedding, $embeddings) {
4145 //error_log(esc_html__("find_relevant_pdf_pages called.", 'mxchat'));
4146
4147 $most_relevant = null;
4148 $highest_similarity = -INF;
4149
4150 foreach ($embeddings as $page_data) {
4151 $similarity = $this->mxchat_calculate_cosine_similarity($query_embedding, $page_data['embedding']);
4152
4153 if ($similarity > $highest_similarity) {
4154 $highest_similarity = $similarity;
4155 $most_relevant = $page_data['page_number'];
4156 }
4157 }
4158
4159 if (!is_null($most_relevant)) {
4160 $page_numbers = range(max(1, $most_relevant - 1), min(count($embeddings), $most_relevant + 1));
4161 return array_filter($embeddings, function ($page) use ($page_numbers) {
4162 return in_array($page['page_number'], $page_numbers);
4163 });
4164 }
4165
4166 return [];
4167 }
4168
4169
4170 public function handle_pdf_upload() {
4171 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce(wp_unslash((string) $_POST['nonce']))) {
4172 wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'mxchat')), 403);
4173 }
4174
4175 if (!isset($_FILES['pdf_file']) || !isset($_POST['session_id'])) {
4176 wp_send_json_error(esc_html__('Missing required parameters.', 'mxchat'));
4177 return;
4178 }
4179
4180 // SECURITY FIX: Check if PDF uploads are enabled in settings
4181 $options = get_option('mxchat_options', array());
4182 $show_pdf_button = isset($options['show_pdf_upload_button']) ? $options['show_pdf_upload_button'] : 'on';
4183
4184 if ($show_pdf_button !== 'on') {
4185 wp_send_json_error(esc_html__('PDF uploads are currently disabled.', 'mxchat'));
4186 return;
4187 }
4188
4189 $file = $_FILES['pdf_file'];
4190 $session_id = sanitize_text_field($_POST['session_id']);
4191 $original_filename = sanitize_text_field($file['name']);
4192
4193 // Update session owner if it changed (e.g. IP changed due to network switch)
4194 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
4195 $session_owner = get_option("mxchat_session_owner_{$session_id}");
4196
4197 if (!$session_owner || $session_owner !== $current_user_identifier) {
4198 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
4199 }
4200
4201 $file_type = wp_check_filetype($file['name'], ['pdf' => 'application/pdf']);
4202 if ($file_type['type'] !== 'application/pdf') {
4203 wp_send_json_error(esc_html__('Invalid file type. Only PDF files are allowed.', 'mxchat'));
4204 return;
4205 }
4206
4207 $upload_dir = wp_upload_dir();
4208
4209 // SECURITY FIX: Generate random filename without exposing session_id
4210 $random_string = wp_generate_password(20, false, false); // 20 char alphanumeric string
4211 $pdf_filename = 'mxchat_' . $random_string . '_' . time() . '.pdf';
4212 $pdf_path = $upload_dir['path'] . '/' . $pdf_filename;
4213
4214 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
4215 wp_send_json_error(esc_html__('Failed to upload file.', 'mxchat'));
4216 return;
4217 }
4218
4219 $this->clear_pdf_transients($session_id);
4220
4221 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
4222 $embeddings = $this->fetch_and_split_pdf_pages($pdf_path, $max_pages);
4223
4224 if ($embeddings === 'too_many_pages') {
4225 unlink($pdf_path);
4226 $error_message = sprintf(
4227 $this->options['pdf_intent_error_text'] ??
4228 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
4229 $max_pages
4230 );
4231 wp_send_json_error($error_message);
4232 return;
4233 }
4234
4235 if ($embeddings === false || empty($embeddings)) {
4236 unlink($pdf_path);
4237 $error_message = $this->options['pdf_intent_error_text'] ??
4238 esc_html__('The uploaded PDF appears to be empty or contains unsupported content.', 'mxchat');
4239 wp_send_json_error($error_message);
4240 return;
4241 }
4242
4243 if (!empty($embeddings)) {
4244 // Store the mapping between session and the random filename
4245 set_transient('mxchat_pdf_url_' . $session_id, $pdf_path, HOUR_IN_SECONDS);
4246 set_transient('mxchat_pdf_filename_' . $session_id, $original_filename, HOUR_IN_SECONDS);
4247 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
4248 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
4249
4250 $success_message = $this->options['pdf_intent_success_text'] ??
4251 esc_html__("I've processed the PDF. What questions do you have about it?", 'mxchat');
4252
4253 wp_send_json_success([
4254 'message' => $success_message,
4255 'filename' => $original_filename
4256 ]);
4257 return;
4258 }
4259
4260 unlink($pdf_path);
4261 $error_message = $this->options['pdf_intent_error_text'] ??
4262 esc_html__('Sorry, I couldn\'t process the PDF. Please ensure it\'s a valid file.', 'mxchat');
4263 wp_send_json_error($error_message);
4264 return;
4265 }
4266 public function handle_pdf_remove() {
4267 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce(wp_unslash((string) $_POST['nonce']))) {
4268 wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'mxchat')), 403);
4269 }
4270
4271 if (empty($_POST['session_id'])) {
4272 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
4273 wp_die();
4274 }
4275
4276 $session_id = sanitize_text_field($_POST['session_id']);
4277 $pdf_path = get_transient('mxchat_pdf_url_' . $session_id);
4278
4279 if ($pdf_path && file_exists($pdf_path)) {
4280 unlink($pdf_path);
4281 }
4282
4283 $this->clear_pdf_transients($session_id);
4284
4285 wp_send_json_success([
4286 'message' => esc_html__('PDF removed successfully.', 'mxchat')
4287 ]);
4288 wp_die();
4289 }
4290
4291
4292 function mxchat_fetch_new_messages() {
4293 $session_id = sanitize_text_field($_POST['session_id']);
4294 $last_seen_id = sanitize_text_field($_POST['last_seen_id']);
4295 $persistence_enabled = $_POST['persistence_enabled'] === 'true';
4296 $initial_timestamp = isset($_POST['initial_timestamp']) ? intval($_POST['initial_timestamp']) : 0;
4297
4298 if (empty($session_id)) {
4299 //error_log(esc_html__('Fetch new messages error: Session ID missing.', 'mxchat'));
4300 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
4301 wp_die();
4302 }
4303
4304 $history = get_option("mxchat_history_{$session_id}", []);
4305
4306 //error_log("MxChat WhatsApp DEBUG: Fetch new messages for session {$session_id}");
4307 //error_log("MxChat WhatsApp DEBUG: last_seen_id = " . var_export($last_seen_id, true));
4308 //error_log("MxChat WhatsApp DEBUG: History count = " . count($history));
4309 //error_log("MxChat WhatsApp DEBUG: Full history = " . print_r($history, true));
4310
4311 $new_messages = array_filter($history, function ($message) use ($last_seen_id, $persistence_enabled, $initial_timestamp) {
4312 //error_log("MxChat WhatsApp DEBUG: Checking message - ID: " . ($message['id'] ?? 'NO_ID') . ", Role: " . ($message['role'] ?? 'NO_ROLE'));
4313
4314 // If persistence is enabled, show all new messages
4315 if ($persistence_enabled) {
4316 $has_id = !empty($message['id']);
4317 $is_agent = $message['role'] === 'agent';
4318
4319 // If last_seen_id is empty, 'NaN', or invalid, show all agent messages
4320 if (empty($last_seen_id) || $last_seen_id === 'NaN' || $last_seen_id === 'undefined') {
4321 $is_newer = true;
4322 } else {
4323 $is_newer = strcmp($message['id'] ?? '', $last_seen_id) > 0;
4324 }
4325
4326 //error_log("MxChat WhatsApp DEBUG: has_id={$has_id}, is_newer={$is_newer}, is_agent={$is_agent}");
4327
4328 return $has_id && $is_newer && $is_agent;
4329 }
4330
4331 // If persistence is disabled, only show messages after initial timestamp
4332 return !empty($message['id']) &&
4333 $message['role'] === 'agent' &&
4334 $message['timestamp'] > $initial_timestamp;
4335 });
4336
4337 //error_log("MxChat WhatsApp DEBUG: Filtered messages count = " . count($new_messages));
4338
4339 // Include current chat mode so frontend can detect agent→AI transitions
4340 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
4341
4342 wp_send_json_success([
4343 'new_messages' => array_values($new_messages),
4344 'chat_mode' => $chat_mode
4345 ]);
4346 wp_die();
4347 }
4348 public function mxchat_live_agent_handover($message, $user_id, $session_id) {
4349 // First check if live agents are available.
4350 // Outside the SLACK availability schedule this behaves exactly like the
4351 // manual toggle being off — same away message, same stay-in-AI-mode path
4352 // (plans 8ccaa2 + 99d7a4: each channel owns its own schedule). The schedule
4353 // normally stops the tool being offered at all; this is the backstop for
4354 // any path that calls the handover directly.
4355 $live_agent_available = $this->options['live_agent_status'] ?? 'off';
4356 $within_hours = !class_exists('MxChat_Live_Agent_Schedule')
4357 || MxChat_Live_Agent_Schedule::is_within_hours('slack');
4358 if ($live_agent_available !== 'on' || !$within_hours) {
4359 $away_message = $this->options['live_agent_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
4360 $this->fallbackResponse = [
4361 'text' => $away_message,
4362 'html' => '',
4363 'images' => [],
4364 'chat_mode' => 'ai'
4365 ];
4366 wp_send_json([
4367 'text' => $away_message,
4368 'html' => '',
4369 'chat_mode' => 'ai',
4370 'session_id' => $session_id
4371 ]);
4372 wp_die();
4373 }
4374
4375 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4376
4377 if (empty($slack_bot_token)) {
4378 return false;
4379 }
4380
4381 // Check if channel already exists for this session
4382 $channel_id = get_option("mxchat_channel_{$session_id}", '');
4383
4384 // Shared-channel mode (plan 9f7756): when a shared handoff channel is
4385 // configured and this session doesn't already own a per-conversation
4386 // channel, the handoff posts into the shared channel as a new thread
4387 // (or into the session's existing thread on a re-handover). Any failure
4388 // to reach the shared channel falls back to per-conversation creation
4389 // below, so a misconfigured channel never drops a handoff.
4390 $shared_channel_setting = trim($this->options['live_agent_shared_channel'] ?? '');
4391 $shared_thread_ts = get_option("mxchat_thread_{$session_id}", '');
4392 $use_shared_channel = ($shared_channel_setting !== '' && empty($channel_id));
4393
4394 if (empty($channel_id) && !$use_shared_channel) {
4395 $channel_id = $this->mxchat_create_conversation_channel($session_id);
4396 if (empty($channel_id)) {
4397 return false; // Failed to create channel
4398 }
4399 }
4400
4401 // Get recent chat history
4402 $history = get_option("mxchat_history_{$session_id}", []);
4403 $recent_history = array_slice($history, -5);
4404
4405 // Format conversation context
4406 $conversation_context = "";
4407 if (!empty($recent_history)) {
4408 $conversation_context = "*Recent Conversation:*\n";
4409 foreach ($recent_history as $hist_message) {
4410 $role_display = $hist_message['role'] === 'user' ? 'User' : 'AI';
4411 $conversation_context .= ">{$role_display}: {$hist_message['content']}\n";
4412 }
4413 $conversation_context .= "\n";
4414 }
4415
4416 update_option("mxchat_mode_{$session_id}", 'agent');
4417
4418 // Send message to channel
4419 $channel_message = "🔔 *New Live Agent Request*\n\n";
4420 $channel_message .= "*Session ID:* `{$session_id}`\n";
4421 $channel_message .= "*User ID:* `{$user_id}`\n";
4422
4423 // Surface the captured visitor identity so the agent knows who they're talking to —
4424 // guest User IDs are 0, but the pre-chat gate / login / transcript often has name+email (plan-e2195b).
4425 $visitor = $this->mxchat_get_visitor_identity($session_id);
4426 if (!empty($visitor['name']) && !empty($visitor['email'])) {
4427 $channel_message .= "*Visitor:* {$visitor['name']} <{$visitor['email']}>\n";
4428 } elseif (!empty($visitor['email'])) {
4429 $channel_message .= "*Visitor:* <{$visitor['email']}>\n";
4430 } elseif (!empty($visitor['name'])) {
4431 $channel_message .= "*Visitor:* {$visitor['name']}\n";
4432 }
4433 $channel_message .= "\n";
4434
4435 if (!empty($conversation_context)) {
4436 $channel_message .= $conversation_context;
4437 }
4438
4439 $channel_message .= "*Current Message:*\n{$message}\n\n";
4440 if ($use_shared_channel) {
4441 $channel_message .= "_Reply in this thread - replies here go to the user. `!endchat` in this thread ends the chat._";
4442 } else {
4443 $channel_message .= "_Reply directly in this channel - all messages will go to the user_";
4444 }
4445
4446 if ($use_shared_channel) {
4447 $posted = $this->mxchat_post_shared_handoff($session_id, $channel_message, $shared_thread_ts);
4448 if (!$posted) {
4449 // Shared channel unreachable (wrong name/ID, bot not invited,
4450 // archived...). Fall back to the per-conversation flow so the
4451 // visitor still reaches an agent; the settings page surfaces the
4452 // recorded error to the admin.
4453 $use_shared_channel = false;
4454 $channel_id = $this->mxchat_create_conversation_channel($session_id);
4455 if (empty($channel_id)) {
4456 return false;
4457 }
4458 $channel_message = str_replace(
4459 "_Reply in this thread - replies here go to the user. `!endchat` in this thread ends the chat._",
4460 "_Reply directly in this channel - all messages will go to the user_",
4461 $channel_message
4462 );
4463 }
4464 }
4465
4466 if (!$use_shared_channel) {
4467 $handoff_post = wp_remote_post('https://slack.com/api/chat.postMessage', [
4468 'headers' => [
4469 'Content-Type' => 'application/json',
4470 'Authorization' => 'Bearer ' . $slack_bot_token
4471 ],
4472 'body' => json_encode([
4473 'channel' => $channel_id,
4474 'text' => $channel_message,
4475 'mrkdwn' => true
4476 ])
4477 ]);
4478 // Re-handover edge (plan 7458a7): the stored mxchat_channel_ may point
4479 // at a channel archived by the auto-archive toggle (or deleted by an
4480 // admin). Slack answers is_archived / channel_not_found — clear the
4481 // stale option, mint a fresh channel, and re-post ONCE so the handoff
4482 // is never silently dropped.
4483 if (!is_wp_error($handoff_post)) {
4484 $handoff_data = json_decode(wp_remote_retrieve_body($handoff_post), true);
4485 $handoff_err = isset($handoff_data['error']) ? $handoff_data['error'] : '';
4486 if (isset($handoff_data['ok']) && !$handoff_data['ok'] && in_array($handoff_err, array('is_archived', 'channel_not_found'), true)) {
4487 delete_option("mxchat_channel_{$session_id}");
4488 $channel_id = $this->mxchat_create_conversation_channel($session_id);
4489 if (!empty($channel_id)) {
4490 wp_remote_post('https://slack.com/api/chat.postMessage', [
4491 'headers' => [
4492 'Content-Type' => 'application/json',
4493 'Authorization' => 'Bearer ' . $slack_bot_token
4494 ],
4495 'body' => json_encode([
4496 'channel' => $channel_id,
4497 'text' => $channel_message,
4498 'mrkdwn' => true
4499 ])
4500 ]);
4501 }
4502 }
4503 }
4504 }
4505
4506 $success_message = $this->options['live_agent_notification_message'] ?? 'Live agent has been notified.';
4507 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
4508
4509 $this->fallbackResponse = [
4510 'text' => $success_message,
4511 'html' => '',
4512 'images' => [],
4513 'chat_mode' => 'agent'
4514 ];
4515
4516 wp_send_json([
4517 'success' => true,
4518 'text' => $success_message,
4519 'html' => '',
4520 'chat_mode' => 'agent',
4521 'session_id' => $session_id,
4522 'fallbackResponse' => $this->fallbackResponse
4523 ]);
4524 wp_die();
4525 }
4526
4527 /**
4528 * Archive a session's per-conversation chat- channel after !endchat / session
4529 * cleanup (plan 7458a7). HARD GUARDS, in order: the toggle must be on
4530 * (default off = zero change for existing installs); a session with
4531 * mxchat_thread_ set is a 9f7756 SHARED-channel session and is never
4532 * archived; only the channel this session owns via mxchat_channel_ is
4533 * archived, and only when it matches the channel the caller is acting on.
4534 * Best-effort by design — a failed archive is logged and never blocks the
4535 * mode flip or cleanup.
4536 *
4537 * @param string $session_id
4538 * @param string $event_channel_id Channel the caller is acting on.
4539 */
4540 private function mxchat_maybe_archive_conversation_channel($session_id, $event_channel_id) {
4541 $toggle = $this->options['live_agent_archive_on_end_toggle'] ?? 'off';
4542 if ($toggle !== 'on') {
4543 return;
4544 }
4545 if (get_option("mxchat_thread_{$session_id}", '') !== '') {
4546 return; // shared-channel session — the shared channel is NEVER archived
4547 }
4548 $owned_channel = get_option("mxchat_channel_{$session_id}", '');
4549 if ($owned_channel === '' || $owned_channel !== $event_channel_id) {
4550 return;
4551 }
4552 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4553 if (empty($slack_bot_token)) {
4554 return;
4555 }
4556 $response = wp_remote_post('https://slack.com/api/conversations.archive', [
4557 'headers' => [
4558 'Content-Type' => 'application/json',
4559 'Authorization' => 'Bearer ' . $slack_bot_token
4560 ],
4561 'body' => json_encode(['channel' => $owned_channel])
4562 ]);
4563 if (is_wp_error($response)) {
4564 error_log('MxChat: conversations.archive request failed: ' . $response->get_error_message());
4565 return;
4566 }
4567 $data = json_decode(wp_remote_retrieve_body($response), true);
4568 if (empty($data['ok'])) {
4569 error_log('MxChat: conversations.archive returned error: ' . (isset($data['error']) ? $data['error'] : 'unknown'));
4570 }
4571 }
4572
4573 /**
4574 * Create a dedicated per-conversation Slack channel for a session and invite
4575 * the configured agents. Extracted from mxchat_live_agent_handover so the
4576 * shared-channel mode (plan 9f7756) can reuse it as its fallback path.
4577 *
4578 * @param string $session_id
4579 * @return string Channel ID, or '' on failure.
4580 */
4581 private function mxchat_create_conversation_channel($session_id) {
4582 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4583 if (empty($slack_bot_token)) {
4584 return '';
4585 }
4586
4587 $channel_id = '';
4588 $channel_name = $this->generate_channel_name($session_id);
4589
4590 $response = wp_remote_post('https://slack.com/api/conversations.create', [
4591 'headers' => [
4592 'Content-Type' => 'application/json',
4593 'Authorization' => 'Bearer ' . $slack_bot_token
4594 ],
4595 'body' => json_encode([
4596 'name' => $channel_name,
4597 'is_private' => false // Public channel - anyone in workspace can join
4598 ])
4599 ]);
4600
4601 if (!is_wp_error($response)) {
4602 $response_data = json_decode(wp_remote_retrieve_body($response), true);
4603
4604 if (isset($response_data['ok']) && $response_data['ok']) {
4605 $channel_id = $response_data['channel']['id'];
4606 update_option("mxchat_channel_{$session_id}", $channel_id);
4607
4608 // Auto-invite agents to the channel
4609 $agent_user_ids = $this->options['live_agent_user_ids'] ?? '';
4610
4611 if (!empty($agent_user_ids)) {
4612 // Parse user IDs (one per line)
4613 $user_ids = array_filter(array_map('trim', explode("\n", $agent_user_ids)));
4614
4615 foreach ($user_ids as $user_id_to_invite) {
4616 wp_remote_post('https://slack.com/api/conversations.invite', [
4617 'headers' => [
4618 'Content-Type' => 'application/json',
4619 'Authorization' => 'Bearer ' . $slack_bot_token
4620 ],
4621 'body' => json_encode([
4622 'channel' => $channel_id,
4623 'users' => $user_id_to_invite
4624 ])
4625 ]);
4626 }
4627 }
4628 }
4629 }
4630
4631 return $channel_id;
4632 }
4633
4634 /**
4635 * Post a handoff (or a re-handover) into the configured shared channel.
4636 * First post per session becomes the conversation's thread root; its ts is
4637 * stored in mxchat_thread_{session} and every later message rides that
4638 * thread. Records the Slack error for the settings page on failure so the
4639 * caller can fall back to per-conversation creation.
4640 *
4641 * @param string $session_id
4642 * @param string $text Fully-built handoff message.
4643 * @param string $thread_ts Existing thread root for this session, '' if none.
4644 * @return bool True when the message reached the shared channel.
4645 */
4646 private function mxchat_post_shared_handoff($session_id, $text, $thread_ts = '') {
4647 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4648 $configured = trim($this->options['live_agent_shared_channel'] ?? '');
4649 if (empty($slack_bot_token) || $configured === '') {
4650 return false;
4651 }
4652
4653 // Posting by #name works once the bot is a member; the response carries
4654 // the real channel ID, cached so the inbound webhook and user-relay
4655 // don't depend on how the admin wrote the setting.
4656 $cache = get_option('mxchat_slack_shared_channel_id', array());
4657 $target = (is_array($cache) && ($cache['configured'] ?? '') === $configured && !empty($cache['id']))
4658 ? $cache['id']
4659 : ltrim($configured, '#');
4660
4661 $body = [
4662 'channel' => $target,
4663 'text' => $text,
4664 'mrkdwn' => true
4665 ];
4666 if ($thread_ts !== '') {
4667 $body['thread_ts'] = $thread_ts;
4668 }
4669
4670 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
4671 'headers' => [
4672 'Content-Type' => 'application/json',
4673 'Authorization' => 'Bearer ' . $slack_bot_token
4674 ],
4675 'body' => json_encode($body)
4676 ]);
4677
4678 if (is_wp_error($response)) {
4679 update_option('mxchat_slack_shared_channel_error', array(
4680 'error' => $response->get_error_message(),
4681 'configured' => $configured,
4682 'time' => time(),
4683 ), false);
4684 return false;
4685 }
4686
4687 $data = json_decode(wp_remote_retrieve_body($response), true);
4688 if (empty($data['ok'])) {
4689 update_option('mxchat_slack_shared_channel_error', array(
4690 'error' => $data['error'] ?? 'unknown_error',
4691 'configured' => $configured,
4692 'time' => time(),
4693 ), false);
4694 return false;
4695 }
4696
4697 delete_option('mxchat_slack_shared_channel_error');
4698
4699 if (!empty($data['channel'])) {
4700 update_option('mxchat_slack_shared_channel_id', array(
4701 'configured' => $configured,
4702 'id' => $data['channel'],
4703 ), false);
4704 }
4705 if ($thread_ts === '' && !empty($data['ts'])) {
4706 update_option("mxchat_thread_{$session_id}", $data['ts'], 'no');
4707 }
4708
4709 return true;
4710 }
4711
4712 private function generate_channel_name($session_id) {
4713 $email = null;
4714 $name = null;
4715
4716 // 1. First priority: Check if user is logged in and get their info
4717 if (is_user_logged_in()) {
4718 $current_user = wp_get_current_user();
4719 if (!empty($current_user->user_email)) {
4720 $email = $current_user->user_email;
4721 //error_log("[DEBUG] Using logged-in user email for channel: {$email}");
4722 }
4723 if (!empty($current_user->display_name)) {
4724 $name = $current_user->display_name;
4725 //error_log("[DEBUG] Using logged-in user name for channel: {$name}");
4726 }
4727 }
4728
4729 // 2. Second priority: Check for saved email/name from "require email to chat" option
4730 if (empty($email)) {
4731 $email_option_key = "mxchat_email_{$session_id}";
4732 $saved_email = get_option($email_option_key);
4733 if (!empty($saved_email)) {
4734 $email = $saved_email;
4735 //error_log("[DEBUG] Using saved email from session for channel: {$email}");
4736 }
4737 }
4738
4739 if (empty($name)) {
4740 $name_option_key = "mxchat_name_{$session_id}";
4741 $saved_name = get_option($name_option_key);
4742 if (!empty($saved_name)) {
4743 $name = $saved_name;
4744 //error_log("[DEBUG] Using saved name from session for channel: {$name}");
4745 }
4746 }
4747
4748 // 3. Third priority: Check existing chat transcript for email/name
4749 if (empty($email) || empty($name)) {
4750 global $wpdb;
4751 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
4752 $existing_data = $wpdb->get_row($wpdb->prepare(
4753 "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",
4754 $session_id
4755 ));
4756
4757 if ($existing_data) {
4758 if (empty($email) && !empty($existing_data->user_email)) {
4759 $email = $existing_data->user_email;
4760 //error_log("[DEBUG] Using email from chat transcript for channel: {$email}");
4761 }
4762 if (empty($name) && !empty($existing_data->user_name)) {
4763 $name = $existing_data->user_name;
4764 //error_log("[DEBUG] Using name from chat transcript for channel: {$name}");
4765 }
4766 }
4767 }
4768
4769 // 4. Generate channel name based on priority: Name > Email > Session ID
4770 $channel_name = '';
4771
4772 if (!empty($name)) {
4773 // Convert name to valid Slack channel name
4774 $base_name = strtolower(trim($name));
4775 // Replace spaces and invalid characters
4776 $base_name = preg_replace('/[^a-z0-9\s]/', '', $base_name);
4777 $base_name = preg_replace('/\s+/', '-', $base_name);
4778 $base_name = trim($base_name, '-');
4779
4780 // Get last 4 characters of session ID for uniqueness
4781 $session_suffix = substr($session_id, -4);
4782 $channel_name = 'chat-' . $base_name . '-' . strtolower($session_suffix);
4783
4784 // Slack channel names have a 21 character limit
4785 if (strlen($channel_name) > 21) {
4786 // Calculate available space for name (21 - 'chat-' - '-' - session_suffix)
4787 $available_space = 21 - 5 - 1 - strlen($session_suffix); // 'chat-' = 5, '-' = 1
4788 $truncated_name = substr($base_name, 0, $available_space);
4789 $truncated_name = rtrim($truncated_name, '-'); // Remove trailing hyphen
4790 $channel_name = 'chat-' . $truncated_name . '-' . strtolower($session_suffix);
4791 }
4792
4793 //error_log("[DEBUG] Using name for channel: {$channel_name} (from name: {$name})");
4794
4795 } elseif (!empty($email)) {
4796 // Convert email to valid Slack channel name (your existing logic)
4797 $channel_name = 'chat-' . strtolower(str_replace(['@', '.', '+', '_'], ['-at-', '-', '-plus-', '-'], $email));
4798 // Remove any remaining invalid characters
4799 $channel_name = preg_replace('/[^a-z0-9\-]/', '', $channel_name);
4800 // Ensure it doesn't end with a hyphen
4801 $channel_name = rtrim($channel_name, '-');
4802 // Slack channel names have a 21 character limit, so truncate if needed
4803 if (strlen($channel_name) > 21) {
4804 $channel_name = substr($channel_name, 0, 21);
4805 $channel_name = rtrim($channel_name, '-'); // Remove trailing hyphen if truncation created one
4806 }
4807
4808 //error_log("[DEBUG] Using email for channel: {$channel_name} (from email: {$email})");
4809
4810 } else {
4811 // Fallback to session ID if no name or email found
4812 $channel_name = 'chat-' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $session_id));
4813 //error_log("[DEBUG] No name or email found, using session ID for channel: {$channel_name}");
4814 }
4815
4816 // Final validation - ensure channel name meets Slack requirements
4817 if (strlen($channel_name) > 21) {
4818 $channel_name = substr($channel_name, 0, 21);
4819 $channel_name = rtrim($channel_name, '-');
4820 }
4821
4822 //error_log("[DEBUG] Generated channel name: {$channel_name}");
4823 return $channel_name;
4824 }
4825
4826 /**
4827 * Telegram Live Agent Handover
4828 * Creates a forum topic in the Telegram group and notifies agents
4829 */
4830 public function mxchat_telegram_live_agent_handover($message, $user_id, $session_id) {
4831 // Check if Telegram agents are available. Telegram has its OWN availability
4832 // schedule, independent of Slack's (plan 99d7a4 — each Integrations tab
4833 // owns its scheduler). Backstop only; the tool is normally withheld
4834 // off-hours.
4835 $telegram_available = $this->options['telegram_status'] ?? 'off';
4836 $within_hours = !class_exists('MxChat_Live_Agent_Schedule')
4837 || MxChat_Live_Agent_Schedule::is_within_hours('telegram');
4838 if ($telegram_available !== 'on' || !$within_hours) {
4839 $away_message = $this->options['telegram_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
4840 $this->fallbackResponse = [
4841 'text' => $away_message,
4842 'html' => '',
4843 'images' => [],
4844 'chat_mode' => 'ai'
4845 ];
4846 wp_send_json([
4847 'text' => $away_message,
4848 'html' => '',
4849 'chat_mode' => 'ai',
4850 'session_id' => $session_id
4851 ]);
4852 wp_die();
4853 }
4854
4855 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4856 $telegram_group_id = $this->options['telegram_group_id'] ?? '';
4857
4858 if (empty($telegram_bot_token) || empty($telegram_group_id)) {
4859 return false;
4860 }
4861
4862 // Check if topic already exists for this session
4863 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
4864
4865 if (empty($topic_id)) {
4866 // Generate topic name
4867 $topic_name = $this->generate_telegram_topic_name($session_id);
4868
4869 // Random icon color (Telegram forum topic colors)
4870 $icon_colors = [0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F];
4871 $icon_color = $icon_colors[array_rand($icon_colors)];
4872
4873 // Create forum topic
4874 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/createForumTopic", [
4875 'headers' => ['Content-Type' => 'application/json'],
4876 'body' => json_encode([
4877 'chat_id' => $telegram_group_id,
4878 'name' => $topic_name,
4879 'icon_color' => $icon_color
4880 ])
4881 ]);
4882
4883 if (!is_wp_error($response)) {
4884 $response_body = wp_remote_retrieve_body($response);
4885 $response_data = json_decode($response_body, true);
4886
4887 if (isset($response_data['ok']) && $response_data['ok']) {
4888 $topic_id = $response_data['result']['message_thread_id'];
4889 update_option("mxchat_telegram_topic_{$session_id}", $topic_id);
4890 update_option("mxchat_telegram_group_{$session_id}", $telegram_group_id);
4891 }
4892 }
4893
4894 if (empty($topic_id)) {
4895 return false; // Failed to create topic
4896 }
4897 }
4898
4899 // Get recent chat history
4900 $history = get_option("mxchat_history_{$session_id}", []);
4901 $recent_history = array_slice($history, -5);
4902
4903 // Format conversation context for Telegram (HTML format)
4904 $conversation_context = "";
4905 if (!empty($recent_history)) {
4906 $conversation_context = "<b>Recent Conversation:</b>\n";
4907 foreach ($recent_history as $hist_message) {
4908 $role_display = $hist_message['role'] === 'user' ? '👤 User' : '🤖 AI';
4909 $escaped_content = htmlspecialchars($hist_message['content'], ENT_QUOTES, 'UTF-8');
4910 $conversation_context .= "{$role_display}: {$escaped_content}\n";
4911 }
4912 $conversation_context .= "\n";
4913 }
4914
4915 // Get user info
4916 $user_email = get_option("mxchat_email_{$session_id}", 'Not provided');
4917 $user_name = get_option("mxchat_name_{$session_id}", 'Anonymous');
4918
4919 // Update session mode
4920 update_option("mxchat_mode_{$session_id}", 'agent');
4921
4922 // Send initial message to topic
4923 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
4924 $topic_message = "🔔 <b>New Live Agent Request</b>\n\n";
4925 $topic_message .= "<b>Session ID:</b> <code>{$session_id}</code>\n";
4926 $topic_message .= "<b>User:</b> {$user_name}\n";
4927 $topic_message .= "<b>Email:</b> {$user_email}\n\n";
4928
4929 if (!empty($conversation_context)) {
4930 $topic_message .= $conversation_context;
4931 }
4932
4933 $topic_message .= "<b>Current Message:</b>\n{$escaped_message}\n\n";
4934 $topic_message .= "<i>Reply in this topic - messages will be sent to the user</i>\n";
4935 $topic_message .= "<i>Type #close, #end, #disconnect, or #done to end the session</i>";
4936
4937 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4938 'headers' => ['Content-Type' => 'application/json'],
4939 'body' => json_encode([
4940 'chat_id' => $telegram_group_id,
4941 'message_thread_id' => $topic_id,
4942 'text' => $topic_message,
4943 'parse_mode' => 'HTML'
4944 ])
4945 ]);
4946
4947 $success_message = $this->options['telegram_notification_message'] ?? "I've notified a support agent. Please allow a moment for them to respond.";
4948 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
4949
4950 $this->fallbackResponse = [
4951 'text' => $success_message,
4952 'html' => '',
4953 'images' => [],
4954 'chat_mode' => 'agent'
4955 ];
4956
4957 wp_send_json([
4958 'success' => true,
4959 'text' => $success_message,
4960 'html' => '',
4961 'chat_mode' => 'agent',
4962 'session_id' => $session_id,
4963 'fallbackResponse' => $this->fallbackResponse
4964 ]);
4965 wp_die();
4966 }
4967
4968 /**
4969 * Generate topic name for Telegram forum
4970 */
4971 private function generate_telegram_topic_name($session_id) {
4972 $name = null;
4973 $email = null;
4974
4975 // Check logged in user
4976 if (is_user_logged_in()) {
4977 $current_user = wp_get_current_user();
4978 if (!empty($current_user->display_name)) {
4979 $name = $current_user->display_name;
4980 }
4981 if (!empty($current_user->user_email)) {
4982 $email = $current_user->user_email;
4983 }
4984 }
4985
4986 // Check session data
4987 if (empty($name)) {
4988 $name = get_option("mxchat_name_{$session_id}");
4989 }
4990 if (empty($email)) {
4991 $email = get_option("mxchat_email_{$session_id}");
4992 }
4993
4994 // Generate topic name
4995 $session_suffix = substr($session_id, -6);
4996
4997 if (!empty($name)) {
4998 // Clean name for topic (max 128 chars in Telegram)
4999 $clean_name = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $name);
5000 $clean_name = trim($clean_name);
5001 if (strlen($clean_name) > 50) {
5002 $clean_name = substr($clean_name, 0, 50);
5003 }
5004 return "Chat - {$clean_name} ({$session_suffix})";
5005 } elseif (!empty($email)) {
5006 // Use email prefix
5007 $email_prefix = explode('@', $email)[0];
5008 if (strlen($email_prefix) > 30) {
5009 $email_prefix = substr($email_prefix, 0, 30);
5010 }
5011 return "Chat - {$email_prefix} ({$session_suffix})";
5012 }
5013
5014 return "Chat - {$session_suffix}";
5015 }
5016
5017 /**
5018 * Send user message to Telegram agent
5019 */
5020 public function mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id) {
5021 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
5022 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
5023 $group_id = get_option("mxchat_telegram_group_{$session_id}", '');
5024
5025 if (empty($telegram_bot_token) || empty($topic_id) || empty($group_id)) {
5026 return false;
5027 }
5028
5029 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
5030 $user_message = "👤 <b>User:</b> {$escaped_message}";
5031
5032 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
5033 'headers' => ['Content-Type' => 'application/json'],
5034 'body' => json_encode([
5035 'chat_id' => $group_id,
5036 'message_thread_id' => $topic_id,
5037 'text' => $user_message,
5038 'parse_mode' => 'HTML'
5039 ])
5040 ]);
5041
5042 return !is_wp_error($response);
5043 }
5044
5045 /**
5046 * Handle incoming Telegram webhook
5047 */
5048 public function handle_telegram_webhook(WP_REST_Request $request) {
5049 $body = $request->get_body();
5050 $data = json_decode($body, true);
5051
5052 //error_log('[MxChat Telegram DEBUG] Webhook received: ' . $body);
5053
5054 // Handle message events from forum topics
5055 if (isset($data['message'])) {
5056 $message_data = $data['message'];
5057
5058 // Skip if not from a forum topic
5059 if (!isset($message_data['message_thread_id'])) {
5060 //error_log('[MxChat Telegram DEBUG] Skipped: No message_thread_id (not a forum topic message)');
5061 return new WP_REST_Response(['ok' => true]);
5062 }
5063
5064 // Skip bot messages
5065 if (isset($message_data['from']['is_bot']) && $message_data['from']['is_bot']) {
5066 //error_log('[MxChat Telegram DEBUG] Skipped: Message from bot');
5067 return new WP_REST_Response(['ok' => true]);
5068 }
5069
5070 $chat_id = $message_data['chat']['id'] ?? '';
5071 $topic_id = $message_data['message_thread_id'];
5072 $message_text = $message_data['text'] ?? '';
5073 $message_id = $message_data['message_id'] ?? '';
5074 $from = $message_data['from'] ?? [];
5075 $agent_name = trim(($from['first_name'] ?? '') . ' ' . ($from['last_name'] ?? ''));
5076 if (empty($agent_name)) {
5077 $agent_name = $from['username'] ?? 'Agent';
5078 }
5079
5080 //error_log("[MxChat Telegram DEBUG] Parsed: chat_id={$chat_id}, topic_id={$topic_id}, agent={$agent_name}, text={$message_text}");
5081
5082 // Skip empty messages
5083 if (empty($message_text)) {
5084 //error_log('[MxChat Telegram DEBUG] Skipped: Empty message text');
5085 return new WP_REST_Response(['ok' => true]);
5086 }
5087
5088 // Find session ID by topic ID - cast to string for comparison
5089 global $wpdb;
5090 $topic_id_str = strval($topic_id);
5091 $session_option = $wpdb->get_var(
5092 $wpdb->prepare(
5093 "SELECT option_name FROM {$wpdb->options}
5094 WHERE option_name LIKE %s
5095 AND option_value = %s",
5096 'mxchat_telegram_topic_%',
5097 $topic_id_str
5098 )
5099 );
5100
5101 //error_log("[MxChat Telegram DEBUG] Looking for topic_id={$topic_id_str} in options, found: " . ($session_option ?: 'NULL'));
5102
5103 if ($session_option) {
5104 $session_id = str_replace('mxchat_telegram_topic_', '', $session_option);
5105 //error_log("[MxChat Telegram DEBUG] Session ID: {$session_id}");
5106
5107 // Verify the group ID matches
5108 $stored_group_id = get_option("mxchat_telegram_group_{$session_id}", '');
5109 //error_log("[MxChat Telegram DEBUG] Stored group_id={$stored_group_id}, received chat_id={$chat_id}");
5110
5111 if (strval($stored_group_id) != strval($chat_id)) {
5112 //error_log('[MxChat Telegram DEBUG] Skipped: Group ID mismatch');
5113 return new WP_REST_Response(['ok' => true]);
5114 }
5115
5116 // Check for closure commands
5117 $lower_text = strtolower(trim($message_text));
5118 if (in_array($lower_text, ['#close', '#end', '#disconnect', '#done'])) {
5119 //error_log("[MxChat Telegram DEBUG] Closure command received: {$lower_text}");
5120 // End the live agent session
5121 update_option("mxchat_mode_{$session_id}", 'ai');
5122
5123 // Save disconnect message
5124 $disconnect_message = "Live agent session ended. You're now chatting with the AI assistant.";
5125 $this->mxchat_save_chat_message($session_id, 'bot', $disconnect_message);
5126
5127 // Notify in Telegram
5128 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
5129 if (!empty($telegram_bot_token)) {
5130 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
5131 'headers' => ['Content-Type' => 'application/json'],
5132 'body' => json_encode([
5133 'chat_id' => $chat_id,
5134 'message_thread_id' => $topic_id,
5135 'text' => "✅ Session closed. User returned to AI chatbot.",
5136 'parse_mode' => 'HTML'
5137 ])
5138 ]);
5139
5140 // Optionally close the topic
5141 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/closeForumTopic", [
5142 'headers' => ['Content-Type' => 'application/json'],
5143 'body' => json_encode([
5144 'chat_id' => $chat_id,
5145 'message_thread_id' => $topic_id
5146 ])
5147 ]);
5148 }
5149
5150 return new WP_REST_Response(['ok' => true]);
5151 }
5152
5153 // Deduplicate messages
5154 $message_key = md5($session_id . $message_id . $message_text);
5155 $processed_messages = get_transient('mxchat_telegram_messages_' . $session_id) ?: [];
5156
5157 if (in_array($message_key, $processed_messages)) {
5158 //error_log('[MxChat Telegram DEBUG] Skipped: Duplicate message');
5159 return new WP_REST_Response(['ok' => true]);
5160 }
5161
5162 $processed_messages[] = $message_key;
5163 if (count($processed_messages) > 50) {
5164 $processed_messages = array_slice($processed_messages, -50);
5165 }
5166 set_transient('mxchat_telegram_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
5167
5168 // Save the agent message - format with agent name prefix for proper parsing
5169 $formatted_message = "Agent: {$agent_name} - {$message_text}";
5170 //error_log("[MxChat Telegram DEBUG] Saving agent message: {$formatted_message}");
5171
5172 $this->mxchat_save_chat_message($session_id, 'agent', $formatted_message);
5173
5174 // Verify the message was saved to history
5175 $history = get_option("mxchat_history_{$session_id}", []);
5176 $last_message = end($history);
5177 //error_log("[MxChat Telegram DEBUG] History after save - count: " . count($history) . ", last message role: " . ($last_message['role'] ?? 'none'));
5178
5179 // Send confirmation back to Telegram
5180 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
5181 if (!empty($telegram_bot_token)) {
5182 $confirm_key = 'mxchat_telegram_confirm_' . $message_key;
5183 if (!get_transient($confirm_key)) {
5184 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
5185 'headers' => ['Content-Type' => 'application/json'],
5186 'body' => json_encode([
5187 'chat_id' => $chat_id,
5188 'message_thread_id' => $topic_id,
5189 'text' => "✅ <i>Message sent to user</i>",
5190 'parse_mode' => 'HTML',
5191 'reply_to_message_id' => $message_id
5192 ])
5193 ]);
5194 set_transient($confirm_key, true, 300);
5195 }
5196 }
5197 } else {
5198 //error_log("[MxChat Telegram DEBUG] No session found for topic_id={$topic_id}");
5199 }
5200 } else {
5201 //error_log('[MxChat Telegram DEBUG] No message in webhook data');
5202 }
5203
5204 return new WP_REST_Response(['ok' => true]);
5205 }
5206
5207 public function mxchat_send_user_message_to_agent($message, $user_id, $session_id) {
5208 // Check if this is a Telegram agent session
5209 $telegram_topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
5210 if (!empty($telegram_topic_id)) {
5211 return $this->mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id);
5212 }
5213
5214 // Otherwise, try Slack
5215 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
5216
5217 // Shared-channel session: the conversation lives in a thread of the
5218 // shared channel (plan 9f7756); relay user messages into that thread.
5219 $thread_ts = get_option("mxchat_thread_{$session_id}", '');
5220 if (!empty($thread_ts)) {
5221 $cache = get_option('mxchat_slack_shared_channel_id', array());
5222 $channel_id = is_array($cache) ? ($cache['id'] ?? '') : '';
5223 } else {
5224 $channel_id = get_option("mxchat_channel_{$session_id}", '');
5225 }
5226
5227 if (empty($slack_bot_token) || empty($channel_id)) {
5228 return false;
5229 }
5230
5231 $user_message = "💬 *User:* {$message}";
5232
5233 $body = [
5234 'channel' => $channel_id,
5235 'text' => $user_message,
5236 'mrkdwn' => true
5237 ];
5238 if (!empty($thread_ts)) {
5239 $body['thread_ts'] = $thread_ts;
5240 }
5241
5242 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
5243 'headers' => [
5244 'Content-Type' => 'application/json',
5245 'Authorization' => 'Bearer ' . $slack_bot_token
5246 ],
5247 'body' => json_encode($body)
5248 ]);
5249
5250 return !is_wp_error($response);
5251 }
5252 public function handle_slack_interaction(WP_REST_Request $request) {
5253 //error_log('Received Slack interaction');
5254
5255 $payload = json_decode($request->get_param('payload'), true);
5256 //error_log('Payload: ' . print_r($payload, true));
5257
5258 // Handle button click
5259 if ($payload['type'] === 'block_actions' && $payload['actions'][0]['action_id'] === 'reply_to_user') {
5260 $session_id = $payload['actions'][0]['value'];
5261 $trigger_id = $payload['trigger_id'];
5262
5263 // Get Bot Token from settings
5264 $slack_token = $this->options['live_agent_bot_token'] ?? '';
5265
5266 if (empty($slack_token)) {
5267 //error_log('Slack Bot Token not configured');
5268 return new WP_REST_Response(['error' => esc_html__('Bot token not configured', 'mxchat')], 400);
5269 }
5270 $response = wp_remote_post('https://slack.com/api/views.open', [
5271 'headers' => [
5272 'Content-Type' => 'application/json',
5273 'Authorization' => 'Bearer ' . $slack_token
5274 ],
5275 'body' => json_encode([
5276 'trigger_id' => $trigger_id,
5277 'view' => [
5278 'type' => 'modal',
5279 'callback_id' => 'reply_modal',
5280 'title' => [
5281 'type' => 'plain_text',
5282 'text' => __('Reply to User', 'mxchat')
5283 ],
5284 'submit' => [
5285 'type' => 'plain_text',
5286 'text' => __('Send', 'mxchat')
5287 ],
5288 'close' => [
5289 'type' => 'plain_text',
5290 'text' => __('Cancel', 'mxchat')
5291 ],
5292 'blocks' => [
5293 [
5294 'type' => 'input',
5295 'block_id' => 'reply_block',
5296 'label' => [
5297 'type' => 'plain_text',
5298 'text' => sprintf(__('Reply to session: %s', 'mxchat'), $session_id)
5299 ],
5300 'element' => [
5301 'type' => 'plain_text_input',
5302 'action_id' => 'message',
5303 'multiline' => true,
5304 'placeholder' => [
5305 'type' => 'plain_text',
5306 'text' => __('Type your message here...', 'mxchat')
5307 ]
5308 ]
5309 ]
5310 ],
5311 'private_metadata' => $session_id
5312 ]
5313 ])
5314 ]);
5315
5316 //error_log('Views.open response: ' . print_r($response, true));
5317
5318 // Return immediate acknowledgment
5319 return new WP_REST_Response(['ok' => true]);
5320 }
5321
5322 // Handle modal submission
5323 // Handle modal submission
5324 if ($payload['type'] === 'view_submission') {
5325 $session_id = $payload['view']['private_metadata'];
5326 $message = $payload['view']['state']['values']['reply_block']['message']['value'];
5327
5328 // Save the message (keep the message_id but don't include in response)
5329 $this->mxchat_save_chat_message($session_id, 'agent', $message);
5330
5331 // Keep the original response format for Slack
5332 return new WP_REST_Response([
5333 'response_action' => 'clear'
5334 ]);
5335 }
5336
5337 // Default acknowledgment
5338 return new WP_REST_Response(['ok' => true]);
5339 }
5340 public function mxchat_handle_agent_response(WP_REST_Request $request) {
5341 //error_log('Received agent response request');
5342 //error_log('Request data: ' . print_r($request->get_params(), true));
5343 // //error_log('Raw body: ' . file_get_contents('php://input'));
5344
5345 // Get the data from Slack's slash command format
5346 $command_text = $request->get_param('text');
5347 // //error_log('Command text: ' . $command_text);
5348
5349 if (empty($command_text)) {
5350 //error_log(esc_html__('Agent response error: No command text received', 'mxchat'));
5351 return new WP_REST_Response([
5352 'error' => esc_html__('Command text is required. Format: /reply session_id message', 'mxchat')
5353 ], 400);
5354 }
5355
5356 // Split the command text into session_id and message
5357 $parts = explode(' ', $command_text, 2);
5358 if (count($parts) !== 2) {
5359 //error_log('Agent response error: Invalid command format');
5360 return new WP_REST_Response([
5361 'error' => esc_html__('Invalid format. Use: /reply session_id message', 'mxchat')
5362 ], 400);
5363 }
5364
5365 $session_id = sanitize_text_field($parts[0]);
5366 $message = sanitize_text_field($parts[1]);
5367
5368 //error_log("Processing agent response - Session ID: $session_id, Message: $message");
5369
5370 // Save the message
5371 $message_id = $this->mxchat_save_chat_message($session_id, 'agent', $message);
5372
5373 if (!$message_id) {
5374 // //error_log('Failed to save agent message');
5375 return new WP_REST_Response([
5376 'error' => esc_html__('Failed to save message', 'mxchat')
5377 ], 500);
5378 }
5379
5380 // Return success response in Slack's expected format
5381 return new WP_REST_Response([
5382 'response_type' => 'in_channel',
5383 'text' => esc_html__("Message sent successfully to session $session_id", 'mxchat')
5384 ], 200);
5385 }
5386 public function mxchat_handle_switch_to_chatbot_intent($message, $user_id, $session_id) {
5387 // Update mode to AI
5388 update_option("mxchat_mode_{$session_id}", 'ai');
5389
5390 // Clear any existing PDF context to start fresh
5391 $this->clear_pdf_transients($session_id);
5392
5393 // Set the response with explicit chat_mode
5394 $this->fallbackResponse = [
5395 'text' => esc_html__('You are now chatting with the AI chatbot.', 'mxchat'),
5396 'html' => '',
5397 'images' => [],
5398 'chat_mode' => 'ai' // Ensure this is set
5399 ];
5400
5401 // Return the complete response array instead of just true
5402 return $this->fallbackResponse;
5403 }
5404
5405 /**
5406 * Normalize Slack mrkdwn before relaying an agent's message to the web visitor.
5407 * Slack's Events API auto-wraps URLs as <https://url> or <https://url|Label>, wraps
5408 * mentions as <@U…>/<#C…|name>, and HTML-escapes &, <, >. Relayed raw, the visitor
5409 * sees a broken/doubled link with a trailing > (plan-e2195b). Unwrap links FIRST, then
5410 * unescape entities LAST so extracted URLs (which can contain &amp;) are not corrupted.
5411 */
5412 private function normalize_slack_text($text) {
5413 if (!is_string($text) || $text === '') {
5414 return $text;
5415 }
5416
5417 $text = preg_replace_callback('/<([^>|]+)(?:\|([^>]*))?>/', function ($m) {
5418 $target = $m[1];
5419 $label = isset($m[2]) ? $m[2] : '';
5420
5421 // User/channel mentions: <@U…> or <#C…|name> — prefer the human label, else drop the id.
5422 if (isset($target[0]) && ($target[0] === '@' || $target[0] === '#')) {
5423 return $label !== '' ? $label : '';
5424 }
5425 // mailto:/tel: — strip the scheme for display.
5426 if (stripos($target, 'mailto:') === 0) {
5427 $addr = substr($target, 7);
5428 return ($label !== '' && $label !== $addr) ? "{$label} ({$addr})" : $addr;
5429 }
5430 if (stripos($target, 'tel:') === 0) {
5431 $num = substr($target, 4);
5432 return ($label !== '' && $label !== $num) ? "{$label} ({$num})" : $num;
5433 }
5434 // Regular URL: <url|Label> -> "Label (url)"; bare <url> -> "url".
5435 if ($label !== '' && $label !== $target) {
5436 return "{$label} ({$target})";
5437 }
5438 return $target;
5439 }, $text);
5440
5441 // Entity-unescape LAST (after link extraction) so &amp; inside URLs is repaired too.
5442 $text = str_replace(array('&amp;', '&lt;', '&gt;'), array('&', '<', '>'), $text);
5443
5444 return $text;
5445 }
5446
5447 /**
5448 * Resolve the visitor's name + email for a session, mirroring generate_channel_name()'s
5449 * priority order: logged-in user, then the pre-chat gate options (mxchat_email_/mxchat_name_),
5450 * then the chat transcript. Returns ['name' => ..., 'email' => ...] (either may be ''). plan-e2195b.
5451 */
5452 private function mxchat_get_visitor_identity($session_id) {
5453 $email = '';
5454 $name = '';
5455
5456 if (is_user_logged_in()) {
5457 $current_user = wp_get_current_user();
5458 if (!empty($current_user->user_email)) { $email = $current_user->user_email; }
5459 if (!empty($current_user->display_name)) { $name = $current_user->display_name; }
5460 }
5461
5462 if (empty($email)) {
5463 $saved_email = get_option("mxchat_email_{$session_id}", '');
5464 if (!empty($saved_email)) { $email = $saved_email; }
5465 }
5466 if (empty($name)) {
5467 $saved_name = get_option("mxchat_name_{$session_id}", '');
5468 if (!empty($saved_name)) { $name = $saved_name; }
5469 }
5470
5471 if (empty($email) || empty($name)) {
5472 global $wpdb;
5473 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
5474 $existing_data = $wpdb->get_row($wpdb->prepare(
5475 "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",
5476 $session_id
5477 ));
5478 if ($existing_data) {
5479 if (empty($email) && !empty($existing_data->user_email)) { $email = $existing_data->user_email; }
5480 if (empty($name) && !empty($existing_data->user_name)) { $name = $existing_data->user_name; }
5481 }
5482 }
5483
5484 return array('name' => $name, 'email' => $email);
5485 }
5486
5487 public function handle_slack_messages(WP_REST_Request $request) {
5488 // Log the incoming request for debugging
5489 //error_log('Slack events request received: ' . $request->get_body());
5490
5491 $body = $request->get_body();
5492 $data = json_decode($body, true);
5493
5494 // Handle Slack URL verification
5495 if (isset($data['type']) && $data['type'] === 'url_verification') {
5496 //error_log('Slack URL verification challenge: ' . $data['challenge']);
5497 return new WP_REST_Response($data['challenge'], 200, ['Content-Type' => 'text/plain']);
5498 }
5499
5500 // IMPORTANT: Handle Slack's event deduplication
5501 if (isset($data['event_id'])) {
5502 $event_id = $data['event_id'];
5503 $processed_events = get_transient('mxchat_slack_events') ?: [];
5504
5505 // Check if we've already processed this event
5506 if (in_array($event_id, $processed_events)) {
5507 //error_log("Duplicate event detected: $event_id");
5508 return new WP_REST_Response(['ok' => true]);
5509 }
5510
5511 // Add this event to processed list
5512 $processed_events[] = $event_id;
5513 // Keep only last 100 events to prevent memory issues
5514 if (count($processed_events) > 100) {
5515 $processed_events = array_slice($processed_events, -100);
5516 }
5517 // Store for 1 hour
5518 set_transient('mxchat_slack_events', $processed_events, HOUR_IN_SECONDS);
5519 }
5520
5521 // Handle message events
5522 if (isset($data['event']) && $data['event']['type'] === 'message') {
5523 $event = $data['event'];
5524
5525 // Skip bot messages and messages with subtypes (like bot_message)
5526 if (isset($event['bot_id']) || isset($event['subtype'])) {
5527 return new WP_REST_Response(['ok' => true]);
5528 }
5529
5530 // Threaded replies: in shared-channel mode every conversation lives in
5531 // a thread rooted at its handoff message — route those to their session
5532 // by thread root (plan 9f7756). Any other threaded reply (e.g. under a
5533 // per-conversation channel's confirmation message) finds no session and
5534 // is skipped, exactly as before.
5535 if (isset($event['thread_ts']) && $event['thread_ts'] !== $event['ts']) {
5536 return $this->mxchat_route_shared_thread_reply($event);
5537 }
5538
5539 $channel_id = $event['channel'];
5540 $message_text = $event['text'] ?? '';
5541 $message_ts = $event['ts'] ?? '';
5542
5543 // Find session ID by looking for matching channel
5544 global $wpdb;
5545 $session_option = $wpdb->get_var(
5546 $wpdb->prepare(
5547 "SELECT option_name FROM {$wpdb->options}
5548 WHERE option_name LIKE 'mxchat_channel_%'
5549 AND option_value = %s",
5550 $channel_id
5551 )
5552 );
5553
5554 if ($session_option) {
5555 $session_id = str_replace('mxchat_channel_', '', $session_option);
5556
5557 // Create a unique key for this specific message
5558 $message_key = md5($session_id . $message_ts . $message_text);
5559 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
5560
5561 // Check if we've already processed this exact message
5562 if (in_array($message_key, $processed_messages)) {
5563 //error_log("Duplicate message detected for session $session_id");
5564 return new WP_REST_Response(['ok' => true]);
5565 }
5566
5567 // Add to processed messages
5568 $processed_messages[] = $message_key;
5569 // Keep only last 50 messages per session
5570 if (count($processed_messages) > 50) {
5571 $processed_messages = array_slice($processed_messages, -50);
5572 }
5573 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
5574
5575 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
5576
5577 // Handle agent ending the chat — transfer back to AI
5578 // Format: "!endchat" or "!endchat <custom message to user>"
5579 if (preg_match('/^!endchat\b/i', trim($message_text))) {
5580 update_option("mxchat_mode_{$session_id}", 'ai');
5581
5582 // Extract custom message after !endchat, or use empty string
5583 $custom_message = trim(preg_replace('/^!endchat\s*/i', '', trim($message_text)));
5584
5585 // Send the agent's custom farewell message if provided
5586 if (!empty($custom_message)) {
5587 $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($custom_message));
5588 }
5589
5590 // Confirm in Slack channel
5591 if (!empty($slack_bot_token)) {
5592 wp_remote_post('https://slack.com/api/chat.postMessage', [
5593 'headers' => [
5594 'Content-Type' => 'application/json',
5595 'Authorization' => 'Bearer ' . $slack_bot_token
5596 ],
5597 'body' => json_encode([
5598 'channel' => $channel_id,
5599 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.",
5600 'mrkdwn' => true
5601 ])
5602 ]);
5603 }
5604
5605 // Auto-archive the ended conversation's channel (plan 7458a7).
5606 // Toggle-gated, best-effort — never blocks the mode flip.
5607 $this->mxchat_maybe_archive_conversation_channel($session_id, $channel_id);
5608
5609 return new WP_REST_Response(['ok' => true]);
5610 }
5611
5612 // Save the agent message (normalize Slack link/entity formatting first — plan-e2195b)
5613 $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($message_text));
5614
5615 // Send confirmation back to Slack (only once)
5616 if (!empty($slack_bot_token)) {
5617 // Use a transient to prevent duplicate confirmations
5618 $confirm_key = 'mxchat_confirm_' . $message_key;
5619 if (!get_transient($confirm_key)) {
5620 wp_remote_post('https://slack.com/api/chat.postMessage', [
5621 'headers' => [
5622 'Content-Type' => 'application/json',
5623 'Authorization' => 'Bearer ' . $slack_bot_token
5624 ],
5625 'body' => json_encode([
5626 'channel' => $channel_id,
5627 'text' => "✅ _Message sent to user_",
5628 'thread_ts' => $event['ts'] // Reply in thread
5629 ])
5630 ]);
5631 // Set transient to prevent duplicate confirmations
5632 set_transient($confirm_key, true, 300); // 5 minutes
5633 }
5634 }
5635 }
5636 }
5637
5638 return new WP_REST_Response(['ok' => true]);
5639 }
5640
5641 /**
5642 * Route an agent's threaded Slack reply to the session whose shared-channel
5643 * conversation is rooted at that thread (plan 9f7756). Sessions are keyed by
5644 * the thread root ts stored in mxchat_thread_{session}, so two visitors in
5645 * the same shared channel can never cross-wire. Unknown threads are ignored.
5646 *
5647 * @param array $event Slack message event (has thread_ts !== ts).
5648 * @return WP_REST_Response
5649 */
5650 private function mxchat_route_shared_thread_reply($event) {
5651 $thread_root = $event['thread_ts'] ?? '';
5652 $message_text = $event['text'] ?? '';
5653 $message_ts = $event['ts'] ?? '';
5654 $channel_id = $event['channel'] ?? '';
5655
5656 if ($thread_root === '') {
5657 return new WP_REST_Response(['ok' => true]);
5658 }
5659
5660 // Find the session owning this thread root (same reverse-lookup shape as
5661 // the per-conversation channel mapping).
5662 global $wpdb;
5663 $session_option = $wpdb->get_var(
5664 $wpdb->prepare(
5665 "SELECT option_name FROM {$wpdb->options}
5666 WHERE option_name LIKE 'mxchat_thread_%'
5667 AND option_value = %s",
5668 $thread_root
5669 )
5670 );
5671
5672 if (!$session_option) {
5673 // Not a shared-channel conversation thread (e.g. a reply under a
5674 // per-conversation confirmation) — ignore, as before.
5675 return new WP_REST_Response(['ok' => true]);
5676 }
5677
5678 $session_id = str_replace('mxchat_thread_', '', $session_option);
5679
5680 // Per-message dedupe — same transient pattern as the top-level handler.
5681 $message_key = md5($session_id . $message_ts . $message_text);
5682 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
5683 if (in_array($message_key, $processed_messages)) {
5684 return new WP_REST_Response(['ok' => true]);
5685 }
5686 $processed_messages[] = $message_key;
5687 if (count($processed_messages) > 50) {
5688 $processed_messages = array_slice($processed_messages, -50);
5689 }
5690 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
5691
5692 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
5693
5694 // Agent ending the chat from inside the thread — same command contract as
5695 // per-conversation channels: "!endchat" or "!endchat <farewell>".
5696 if (preg_match('/^!endchat\b/i', trim($message_text))) {
5697 update_option("mxchat_mode_{$session_id}", 'ai');
5698
5699 $custom_message = trim(preg_replace('/^!endchat\s*/i', '', trim($message_text)));
5700 if (!empty($custom_message)) {
5701 $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($custom_message));
5702 }
5703
5704 if (!empty($slack_bot_token) && $channel_id !== '') {
5705 wp_remote_post('https://slack.com/api/chat.postMessage', [
5706 'headers' => [
5707 'Content-Type' => 'application/json',
5708 'Authorization' => 'Bearer ' . $slack_bot_token
5709 ],
5710 'body' => json_encode([
5711 'channel' => $channel_id,
5712 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.",
5713 'thread_ts' => $thread_root,
5714 'mrkdwn' => true
5715 ])
5716 ]);
5717 }
5718
5719 return new WP_REST_Response(['ok' => true]);
5720 }
5721
5722 // Save the agent message for the widget (normalized like the channel path).
5723 $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($message_text));
5724
5725 // Confirmation stays inside the conversation's thread.
5726 if (!empty($slack_bot_token) && $channel_id !== '') {
5727 $confirm_key = 'mxchat_confirm_' . $message_key;
5728 if (!get_transient($confirm_key)) {
5729 wp_remote_post('https://slack.com/api/chat.postMessage', [
5730 'headers' => [
5731 'Content-Type' => 'application/json',
5732 'Authorization' => 'Bearer ' . $slack_bot_token
5733 ],
5734 'body' => json_encode([
5735 'channel' => $channel_id,
5736 'text' => "✅ _Message sent to user_",
5737 'thread_ts' => $thread_root
5738 ])
5739 ]);
5740 set_transient($confirm_key, true, 300);
5741 }
5742 }
5743
5744 return new WP_REST_Response(['ok' => true]);
5745 }
5746
5747 // For the word upload handler
5748 public function mxchat_handle_word_upload() {
5749 // Delegate to word handler
5750 $this->word_handler->mxchat_handle_word_upload();
5751 }
5752
5753 // For the word removal handler
5754 public function mxchat_handle_word_remove() {
5755 // Delegate to word handler
5756 $this->word_handler->mxchat_handle_word_remove();
5757 }
5758
5759 // For the word status check
5760 public function mxchat_check_word_status() {
5761 // Delegate to word handler
5762 $this->word_handler->mxchat_check_word_status();
5763 }
5764
5765
5766 private function mxchat_get_user_identifier() {
5767 return MxChat_User::mxchat_get_user_identifier();
5768 }
5769
5770 private function mxchat_generate_embedding($text, $api_key) {
5771 try {
5772 // Get options and selected model
5773 $options = get_option('mxchat_options');
5774 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5775
5776 // Opt-in: route embeddings through the Custom (OpenAI-compatible) provider.
5777 // Off by default so existing sites see byte-identical behavior.
5778 if (!empty($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
5779 return $this->mxchat_generate_embedding_custom($text);
5780 }
5781
5782 // Determine endpoint and API key based on model
5783 if (strpos($selected_model, 'voyage') === 0) {
5784 $endpoint = 'https://api.voyageai.com/v1/embeddings';
5785 $api_key = $options['voyage_api_key'] ?? '';
5786
5787 // Check if Voyage API key is missing
5788 if (empty($api_key)) {
5789 //error_log('Voyage API key is missing');
5790 return [
5791 'error' => esc_html__('Voyage AI API key is not configured', 'mxchat'),
5792 'error_code' => 'missing_voyage_api_key'
5793 ];
5794 }
5795 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5796 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
5797 $api_key = $options['gemini_api_key'] ?? '';
5798
5799 // Check if Gemini API key is missing
5800 if (empty($api_key)) {
5801 //error_log('Gemini API key is missing');
5802 return [
5803 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
5804 'error_code' => 'missing_gemini_api_key'
5805 ];
5806 }
5807 } else {
5808 $endpoint = 'https://api.openai.com/v1/embeddings';
5809 // Use the passed API key for OpenAI
5810
5811 // Check if OpenAI API key is missing
5812 if (empty($api_key)) {
5813 //error_log('OpenAI API key is missing');
5814 return [
5815 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
5816 'error_code' => 'missing_openai_api_key'
5817 ];
5818 }
5819 }
5820
5821 // Check if text is empty
5822 if (empty($text)) {
5823 //error_log('Empty text provided for embedding generation');
5824 return [
5825 'error' => esc_html__('No text provided for embedding generation', 'mxchat'),
5826 'error_code' => 'empty_embedding_text'
5827 ];
5828 }
5829
5830 // Prepare request body based on provider
5831 if (strpos($selected_model, 'gemini-embedding') === 0) {
5832 // Gemini API format
5833 $request_body = [
5834 'model' => 'models/' . $selected_model,
5835 'content' => [
5836 'parts' => [
5837 ['text' => $text]
5838 ]
5839 ],
5840 'outputDimensionality' => 1536
5841 ];
5842
5843 // Prepare headers for Gemini (API key as query parameter)
5844 $endpoint .= '?key=' . $api_key;
5845 $headers = [
5846 'Content-Type' => 'application/json'
5847 ];
5848 } else {
5849 // OpenAI/Voyage API format
5850 $request_body = [
5851 'input' => $text,
5852 'model' => $selected_model
5853 ];
5854
5855 // Add output_dimension for voyage-3-large
5856 if ($selected_model === 'voyage-3-large') {
5857 $request_body['output_dimension'] = 2048;
5858 }
5859
5860 // Prepare headers for OpenAI/Voyage
5861 $headers = [
5862 'Content-Type' => 'application/json',
5863 'Authorization' => 'Bearer ' . $api_key
5864 ];
5865 }
5866
5867 // Prepare request arguments
5868 $args = [
5869 'body' => wp_json_encode($request_body),
5870 'headers' => $headers,
5871 'timeout' => 60,
5872 'redirection' => 5,
5873 'blocking' => true,
5874 'httpversion' => '1.0',
5875 'sslverify' => true,
5876 ];
5877
5878 // Make the request
5879 $response = wp_remote_post($endpoint, $args);
5880
5881 // Handle WordPress errors
5882 if (is_wp_error($response)) {
5883 $error_message = $response->get_error_message();
5884 //error_log('Embedding Generation Error: ' . $error_message);
5885 return [
5886 'error' => esc_html__('Connection error when generating embeddings: ', 'mxchat') . esc_html($error_message),
5887 'error_code' => 'embedding_connection_error'
5888 ];
5889 }
5890
5891 // Check HTTP status code
5892 $status_code = wp_remote_retrieve_response_code($response);
5893 if ($status_code !== 200) {
5894 $response_body = json_decode(wp_remote_retrieve_body($response), true);
5895
5896 $error_message = $this->extract_provider_error($response_body, 'HTTP Error ' . $status_code);
5897
5898 $error_type = isset($response_body['error']['type'])
5899 ? $response_body['error']['type']
5900 : 'unknown';
5901
5902 //error_log('Embedding API HTTP Error: ' . $status_code . ' - ' . $error_message);
5903
5904 // Handle specific error types
5905 switch ($error_type) {
5906 case 'invalid_request_error':
5907 if (strpos($error_message, 'API key') !== false) {
5908 return [
5909 'error' => esc_html__('Invalid API key for embedding generation. Please check your API key configuration.', 'mxchat'),
5910 'error_code' => 'embedding_invalid_api_key'
5911 ];
5912 }
5913 break;
5914
5915 case 'authentication_error':
5916 return [
5917 'error' => esc_html__('Authentication failed for embedding generation. Please check your API key.', 'mxchat'),
5918 'error_code' => 'embedding_auth_error'
5919 ];
5920
5921 case 'rate_limit_exceeded':
5922 return [
5923 'error' => esc_html__('Rate limit exceeded for embedding generation. Please try again later.', 'mxchat'),
5924 'error_code' => 'embedding_rate_limit'
5925 ];
5926
5927 case 'quota_exceeded':
5928 return [
5929 'error' => esc_html__('API quota exceeded for embedding generation. Please check your billing details.', 'mxchat'),
5930 'error_code' => 'embedding_quota_exceeded'
5931 ];
5932 }
5933
5934 // Generic error fallback
5935 return [
5936 'error' => esc_html__('Embedding API error - check embedding API key.: ', 'mxchat') . esc_html($error_message),
5937 'error_code' => 'embedding_api_error',
5938 'status_code' => $status_code
5939 ];
5940 }
5941
5942 $response_body = json_decode(wp_remote_retrieve_body($response), true);
5943
5944 // Handle different response formats based on provider
5945 if (strpos($selected_model, 'gemini-embedding') === 0) {
5946 // Gemini API response format
5947 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
5948 return $response_body['embedding']['values'];
5949 } else {
5950 //error_log('Invalid Gemini embedding response: ' . wp_json_encode($response_body));
5951 return [
5952 'error' => esc_html__('Received invalid embedding data from the Gemini API.', 'mxchat'),
5953 'error_code' => 'invalid_gemini_embedding_response'
5954 ];
5955 }
5956 } else {
5957 // OpenAI/Voyage API response format
5958 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
5959 return $response_body['data'][0]['embedding'];
5960 } else {
5961 //error_log('Invalid embedding response: ' . wp_json_encode($response_body));
5962 return [
5963 'error' => esc_html__('Received invalid embedding data from the API.', 'mxchat'),
5964 'error_code' => 'invalid_embedding_response'
5965 ];
5966 }
5967 }
5968 } catch (Exception $e) {
5969 //error_log('Embedding Exception: ' . $e->getMessage());
5970 return [
5971 'error' => esc_html__('System error when generating embeddings: ', 'mxchat') . esc_html($e->getMessage()),
5972 'error_code' => 'embedding_exception'
5973 ];
5974 }
5975 }
5976
5977
5978 /**
5979 * Generate embedding via a Custom (OpenAI-compatible) provider's /embeddings route.
5980 * Only called when the opt-in 'custom_provider_for_embeddings' setting is on.
5981 * Returns a numeric array (the embedding vector) on success, or ['error','error_code'] on failure.
5982 */
5983 private function mxchat_generate_embedding_custom($text) {
5984 if (empty($text)) {
5985 return ['error' => esc_html__('No text provided for embedding generation', 'mxchat'), 'error_code' => 'empty_embedding_text'];
5986 }
5987 $cfg = $this->mxchat_resolve_custom_provider();
5988 if (empty($cfg['base_url'])) {
5989 return ['error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url'];
5990 }
5991
5992 $options = get_option('mxchat_options');
5993 $embed_url = $cfg['base_url'] . '/embeddings';
5994 if (!empty($cfg['api_version'])) {
5995 $embed_url .= (strpos($embed_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($cfg['api_version']);
5996 }
5997 $model = isset($options['custom_provider_embedding_model']) && trim((string) $options['custom_provider_embedding_model']) !== ''
5998 ? trim((string) $options['custom_provider_embedding_model'])
5999 : $cfg['model'];
6000
6001 $response = wp_remote_post($embed_url, [
6002 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
6003 'body' => wp_json_encode(['input' => $text, 'model' => $model]),
6004 'timeout' => 60,
6005 ]);
6006 if (is_wp_error($response)) {
6007 return [
6008 'error' => esc_html__('Connection error when generating embeddings (custom provider): ', 'mxchat') . esc_html($response->get_error_message()),
6009 'error_code' => 'embedding_custom_connection_error',
6010 ];
6011 }
6012 $status = wp_remote_retrieve_response_code($response);
6013 $body = json_decode(wp_remote_retrieve_body($response), true);
6014 if ($status !== 200) {
6015 $msg = $this->extract_provider_error($body, 'HTTP ' . $status);
6016 return [
6017 'error' => esc_html__('Custom embedding endpoint error: ', 'mxchat') . esc_html($msg),
6018 'error_code' => 'embedding_custom_api_error',
6019 'status_code' => $status,
6020 ];
6021 }
6022 if (isset($body['data'][0]['embedding']) && is_array($body['data'][0]['embedding'])) {
6023 return $body['data'][0]['embedding'];
6024 }
6025 return [
6026 'error' => esc_html__('Invalid embedding response from custom provider.', 'mxchat'),
6027 'error_code' => 'embedding_custom_invalid_response',
6028 ];
6029 }
6030
6031 private function mxchat_find_relevant_content($user_embedding, $bot_id = 'default', $user_query = '') {
6032 //error_log("MXCHAT DEBUG: find_relevant_content called with bot_id: " . $bot_id);
6033
6034 // Check for OpenAI Vector Store first (takes priority when enabled)
6035 $bot_vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
6036
6037 if ($bot_vectorstore_config['use_vectorstore']) {
6038 // Get current model to verify it's an OpenAI model
6039 $bot_options = $this->get_bot_options($bot_id);
6040 $mxchat_options = get_option('mxchat_options', array());
6041 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
6042 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
6043
6044 if ($this->is_openai_chat_model($selected_model)) {
6045 //error_log("MXCHAT DEBUG: Using OpenAI Vector Store for knowledge retrieval");
6046 return $this->find_relevant_content_openai_vectorstore($user_query, $bot_id, $bot_vectorstore_config);
6047 } else {
6048 //error_log("MXCHAT DEBUG: Vector Store enabled but model is not OpenAI (" . $selected_model . "), skipping Vector Store");
6049 }
6050 }
6051
6052 // Get bot-specific Pinecone configuration
6053 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
6054
6055 // Debug: Log the Pinecone configuration
6056 //error_log("MXCHAT DEBUG: Pinecone config for bot '$bot_id':");
6057 //error_log(" - use_pinecone: " . ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false'));
6058 //error_log(" - api_key: " . (empty($bot_pinecone_config['api_key']) ? 'EMPTY' : 'SET (hidden)'));
6059 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'NOT SET'));
6060 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'NOT SET'));
6061
6062 // Determine whether to use Pinecone based on bot configuration
6063 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
6064
6065 //error_log("MXCHAT DEBUG: Using " . ($use_pinecone ? "Pinecone" : "WordPress Database") . " for knowledge retrieval");
6066
6067 if ($use_pinecone) {
6068 return $this->find_relevant_content_pinecone($user_embedding, $bot_id, $bot_pinecone_config);
6069 } else {
6070 return $this->find_relevant_content_wordpress($user_embedding, $bot_id);
6071 }
6072 }
6073
6074 private function find_relevant_content_wordpress($user_embedding, $bot_id = 'default') {
6075 global $wpdb;
6076 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6077 // Initialize similarity analysis storage
6078 $this->last_similarity_analysis = [
6079 'knowledge_base_type' => 'WordPress Database',
6080 'bot_id' => $bot_id,
6081 'top_matches' => [],
6082 'threshold_used' => 0,
6083 'total_checked' => 0
6084 ];
6085
6086 // NEW: Initialize valid URLs array
6087 $valid_urls = [];
6088
6089 // Get bot-specific options for similarity threshold
6090 $bot_options = $this->get_bot_options($bot_id);
6091 $current_options = !empty($bot_options) ? $bot_options : $this->options;
6092
6093 // Get knowledge manager instance for role checking
6094 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
6095
6096 // Get base similarity threshold from bot options or default options
6097 $similarity_threshold = isset($current_options['similarity_threshold'])
6098 ? ((int) $current_options['similarity_threshold']) / 100
6099 : 0.35;
6100 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
6101
6102 // Precompute bot_filter once, outside the streaming loop
6103 $bot_filter = '';
6104 if ($bot_id !== 'default') {
6105 $column_exists = $wpdb->get_var("SHOW COLUMNS FROM {$system_prompt_table} LIKE 'bot_metadata'");
6106 if ($column_exists) {
6107 $bot_filter = $wpdb->prepare(" AND (bot_metadata = %s OR bot_metadata IS NULL OR bot_metadata = '')", $bot_id);
6108 }
6109 }
6110
6111 // ===== STREAMING TOP-K PASS =====
6112 // Stream rows in small batches, compute cosine similarity per row, and keep only:
6113 // - top 10 by raw similarity (for the testing/debug display panel)
6114 // - candidates above threshold with access (capped) for context assembly
6115 // This bounds peak memory regardless of knowledge base size and avoids loading
6116 // article_content for every row. article_content is fetched in Phase 2 for winners only.
6117 $batch_size = 250;
6118 $max_candidates = 200; // safety cap, well above rag_sources_limit * max_chunks_per_source
6119 $top_display = [];
6120 $candidates = [];
6121 $total_checked = 0;
6122 $offset = 0;
6123
6124 do {
6125 $batch = $wpdb->get_results($wpdb->prepare(
6126 "SELECT id, embedding_vector, source_url, role_restriction
6127 FROM {$system_prompt_table}
6128 WHERE 1=1 {$bot_filter}
6129 LIMIT %d OFFSET %d",
6130 $batch_size,
6131 $offset
6132 ));
6133
6134 if (empty($batch)) {
6135 break;
6136 }
6137
6138 foreach ($batch as $row) {
6139 $database_embedding = $row->embedding_vector
6140 ? unserialize($row->embedding_vector, ['allowed_classes' => false])
6141 : null;
6142
6143 if (!is_array($database_embedding) || !is_array($user_embedding)) {
6144 unset($database_embedding);
6145 continue;
6146 }
6147
6148 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
6149 unset($database_embedding);
6150
6151 $role_restriction = $row->role_restriction ?? 'public';
6152 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
6153 $source_url = $row->source_url ?? '';
6154
6155 // Maintain top 10 display buffer (insert-if-beats-worst)
6156 if (count($top_display) < 10) {
6157 $top_display[] = [
6158 'id' => $row->id,
6159 'similarity' => $similarity,
6160 'source_url' => $source_url,
6161 'role_restriction' => $role_restriction,
6162 'has_access' => $has_access,
6163 ];
6164 usort($top_display, function ($a, $b) {
6165 return $b['similarity'] <=> $a['similarity'];
6166 });
6167 } elseif ($similarity > $top_display[9]['similarity']) {
6168 $top_display[9] = [
6169 'id' => $row->id,
6170 'similarity' => $similarity,
6171 'source_url' => $source_url,
6172 'role_restriction' => $role_restriction,
6173 'has_access' => $has_access,
6174 ];
6175 usort($top_display, function ($a, $b) {
6176 return $b['similarity'] <=> $a['similarity'];
6177 });
6178 }
6179
6180 // Track candidates for context assembly (above threshold + has access)
6181 if ($similarity >= $similarity_threshold && $has_access) {
6182 $candidates[] = [
6183 'id' => $row->id,
6184 'similarity' => $similarity,
6185 'source_url' => $source_url,
6186 ];
6187 }
6188
6189 $total_checked++;
6190 }
6191
6192 unset($batch);
6193
6194 // Trim candidates periodically to cap memory during long scans
6195 if (count($candidates) > $max_candidates) {
6196 usort($candidates, function ($a, $b) {
6197 return $b['similarity'] <=> $a['similarity'];
6198 });
6199 $candidates = array_slice($candidates, 0, $max_candidates);
6200 }
6201
6202 $offset += $batch_size;
6203 } while (true);
6204
6205 if ($total_checked === 0) {
6206 $this->current_valid_urls = [];
6207 return '';
6208 }
6209
6210 // Final candidates sort (best first)
6211 if (count($candidates) > 1) {
6212 usort($candidates, function ($a, $b) {
6213 return $b['similarity'] <=> $a['similarity'];
6214 });
6215 }
6216
6217 // ===== PHASE 2: FETCH ARTICLE CONTENT ONLY FOR WINNERS =====
6218 // Gather unique IDs we actually need (top_display + candidates) and pull
6219 // article_content in bounded IN() batches. This avoids loading content for
6220 // every row during the similarity scan.
6221 $needed_ids = [];
6222 foreach ($top_display as $item) {
6223 $needed_ids[$item['id']] = true;
6224 }
6225 foreach ($candidates as $item) {
6226 $needed_ids[$item['id']] = true;
6227 }
6228 $needed_ids = array_keys($needed_ids);
6229
6230 $content_map = [];
6231 if (!empty($needed_ids)) {
6232 foreach (array_chunk($needed_ids, 250) as $chunk_ids) {
6233 $placeholders = implode(',', array_fill(0, count($chunk_ids), '%d'));
6234 $rows = $wpdb->get_results($wpdb->prepare(
6235 "SELECT id, article_content FROM {$system_prompt_table} WHERE id IN ($placeholders)",
6236 ...$chunk_ids
6237 ));
6238 foreach ($rows as $r) {
6239 $content_map[$r->id] = $r->article_content;
6240 }
6241 unset($rows);
6242 }
6243 }
6244
6245 // Build the all_similarities display array from the top 10
6246 $all_similarities = [];
6247 foreach ($top_display as $item) {
6248 $article_content_for_parse = $content_map[$item['id']] ?? '';
6249 $parsed_for_display = MxChat_Chunker::parse_stored_chunk($article_content_for_parse);
6250 $is_chunk = $parsed_for_display['is_chunked'];
6251 $chunk_meta = $parsed_for_display['metadata'];
6252
6253 if (!empty($item['source_url']) && $item['source_url'] !== '#') {
6254 $source_display = $item['source_url'];
6255 } else {
6256 $content_preview = strip_tags($article_content_for_parse);
6257 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
6258 $source_display = substr(trim($content_preview), 0, 50) . '...';
6259 }
6260
6261 $all_similarities[] = [
6262 'document_id' => $item['id'],
6263 'similarity' => $item['similarity'],
6264 'similarity_percentage' => round($item['similarity'] * 100, 2),
6265 'above_threshold' => $item['similarity'] >= $similarity_threshold,
6266 'source_display' => $source_display,
6267 'content_preview' => substr(strip_tags($parsed_for_display['text'] ?? ''), 0, 100) . '...',
6268 'used_for_context' => false,
6269 'role_restriction' => $item['role_restriction'],
6270 'has_access' => $item['has_access'],
6271 'filtered_out' => !$item['has_access'],
6272 'is_chunk' => $is_chunk,
6273 'chunk_index' => $is_chunk ? ($chunk_meta['chunk_index'] ?? 0) : null,
6274 'total_chunks' => $is_chunk ? ($chunk_meta['total_chunks'] ?? 1) : null
6275 ];
6276 }
6277
6278 // Build url_groups from candidates for chunk reassembly
6279 $url_groups = array();
6280 foreach ($candidates as $cand) {
6281 $article_content = $content_map[$cand['id']] ?? '';
6282 $parsed = MxChat_Chunker::parse_stored_chunk($article_content);
6283 $is_chunked = $parsed['is_chunked'];
6284 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
6285 $text_content = $parsed['text'];
6286
6287 $source_url = $cand['source_url'];
6288 $group_key = !empty($source_url) ? $source_url : '_manual_' . $cand['id'];
6289
6290 if (!isset($url_groups[$group_key])) {
6291 $url_groups[$group_key] = array(
6292 'source_url' => $source_url,
6293 'best_score' => 0,
6294 'is_chunked' => $is_chunked,
6295 'chunks' => array(),
6296 'single_text' => '',
6297 'single_id' => null
6298 );
6299 }
6300
6301 if ($cand['similarity'] > $url_groups[$group_key]['best_score']) {
6302 $url_groups[$group_key]['best_score'] = $cand['similarity'];
6303 }
6304
6305 if ($is_chunked) {
6306 $url_groups[$group_key]['is_chunked'] = true;
6307 $url_groups[$group_key]['chunks'][] = array(
6308 'id' => $cand['id'],
6309 'score' => $cand['similarity'],
6310 'chunk_index' => $chunk_index,
6311 'text' => $text_content
6312 );
6313 } else {
6314 $url_groups[$group_key]['single_text'] = $text_content;
6315 $url_groups[$group_key]['single_id'] = $cand['id'];
6316 }
6317 }
6318
6319 // Sort ALL similarities for testing display (highest first)
6320 usort($all_similarities, function ($a, $b) {
6321 return $b['similarity'] <=> $a['similarity'];
6322 });
6323
6324 // Sort URL groups by best score (highest first)
6325 uasort($url_groups, function($a, $b) {
6326 return $b['best_score'] <=> $a['best_score'];
6327 });
6328
6329 // Get RAG sources limit from options (default 6, min 3, max 10)
6330 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
6331 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
6332 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
6333
6334 // Take top N unique URLs based on user setting
6335 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
6336
6337 // Track which document IDs are used for context
6338 $used_document_ids = [];
6339 foreach ($top_urls as $group) {
6340 if ($group['is_chunked']) {
6341 foreach ($group['chunks'] as $chunk) {
6342 $used_document_ids[] = $chunk['id'];
6343 }
6344 } elseif ($group['single_id']) {
6345 $used_document_ids[] = $group['single_id'];
6346 }
6347 }
6348
6349 // Update the all_similarities array to mark which were actually used
6350 foreach ($all_similarities as &$similarity_item) {
6351 $similarity_item['used_for_context'] = in_array($similarity_item['document_id'], $used_document_ids);
6352 }
6353
6354 // Store top 10 for testing panel
6355 $this->last_similarity_analysis['top_matches'] = array_slice($all_similarities, 0, 10);
6356 $this->last_similarity_analysis['total_checked'] = $total_checked;
6357
6358 // Initialize final content
6359 $content = '';
6360 $matches_used = 0;
6361 $total_chunks_used = 0;
6362 $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
6363 if ($max_total_chunks < 8) $max_total_chunks = 8;
6364 if ($max_total_chunks > 20) $max_total_chunks = 20;
6365 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
6366
6367 // Check if citation links are enabled (default to 'on' for backwards compatibility)
6368 // Use fresh options to ensure we get the latest setting value
6369 $fresh_options = get_option('mxchat_options', []);
6370 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6371
6372 // Build content from top sources
6373 foreach ($top_urls as $group_key => $group) {
6374 $source_url = $group['source_url']; // Use actual source_url, not the group key
6375
6376 // Stop if we've hit the total chunk limit
6377 if ($total_chunks_used >= $max_total_chunks) {
6378 break;
6379 }
6380
6381 $full_text = '';
6382 $chunks_in_this_source = 1; // Default for non-chunked content
6383
6384 if ($group['is_chunked']) {
6385 // Calculate how many chunks we can still use (respect both total and per-source caps)
6386 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
6387
6388 // Fetch chunks for this URL with limit
6389 $full_text = $this->reassemble_chunks_from_wordpress($source_url, $chunks_remaining, $chunks_in_this_source);
6390
6391 // If fetching all chunks fails, fall back to matched chunks
6392 if (empty($full_text)) {
6393 // Sort matched chunks by index and concatenate
6394 usort($group['chunks'], function($a, $b) {
6395 return $a['chunk_index'] <=> $b['chunk_index'];
6396 });
6397
6398 $chunk_texts = array();
6399 $chunks_in_this_source = 0;
6400 foreach ($group['chunks'] as $chunk) {
6401 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
6402 break;
6403 }
6404 $chunk_texts[] = $chunk['text'];
6405 $chunks_in_this_source++;
6406 }
6407 $full_text = implode("\n\n", $chunk_texts);
6408 }
6409 } else {
6410 $full_text = $group['single_text'];
6411 $chunks_in_this_source = 1;
6412 }
6413
6414 if (!empty($full_text)) {
6415 // Strip URLs from content if citation links are disabled
6416 if (!$citation_links_enabled) {
6417 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
6418 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
6419 }
6420
6421 // Use numbered reference for URL-based entries, plain info label for manual entries
6422 // Manual entries are stored with an internal mxchat:// placeholder URL — never expose them as citations
6423 if (!empty($source_url) && $source_url !== '#' && strpos($source_url, 'mxchat://') !== 0) {
6424 $matches_used++;
6425 $content .= "## Reference " . $matches_used . " ##\n";
6426 $content .= $full_text . "\n\n";
6427
6428 // Only include citation URLs if citation links are enabled
6429 if ($citation_links_enabled) {
6430 $valid_urls[] = $source_url;
6431 $content .= "URL: " . $source_url . "\n\n";
6432 }
6433
6434 // Video-backed source → queue the consent-safe embed (03ba33)
6435 $this->maybe_queue_youtube_embed($source_url, $full_text);
6436 } else {
6437 // Manual entry — no reference number, no citation
6438 $content .= "## Information ##\n";
6439 $content .= $full_text . "\n\n";
6440 }
6441
6442 // Extract any URLs from the text content itself (only if citation links enabled)
6443 if ($citation_links_enabled) {
6444 preg_match_all(
6445 '#\bhttps?://[^\s<>"\']+#i',
6446 $full_text,
6447 $content_urls
6448 );
6449 if (!empty($content_urls[0])) {
6450 $valid_urls = array_merge($valid_urls, $content_urls[0]);
6451 }
6452 }
6453
6454 $total_chunks_used += $chunks_in_this_source;
6455 }
6456 }
6457
6458 // NEW: Store unique valid URLs for validation
6459 $this->current_valid_urls = array_unique($valid_urls);
6460
6461 // Store sources and chunks counts for testing/transcript display
6462 $this->last_similarity_analysis['sources_used'] = $matches_used;
6463 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
6464
6465 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
6466 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
6467
6468 // Add response guidelines
6469 if (empty($top_urls)) {
6470 $content = "No reference information was found for this query.\n\n";
6471 } else {
6472 // Build response guidelines based on citation links setting
6473 $content .= "\n## Response Guidelines ##\n" .
6474 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
6475 "Be conversational and friendly, but never mention your knowledge base or training data. " .
6476 "If you don't have specific information or are uncertain about any details, it's always " .
6477 "better to honestly say you don't know rather than making up or guessing at answers. " .
6478 "When information is incomplete, let them know you are unsure.\n\n";
6479
6480 // Only add hyperlink instructions if citation links are enabled
6481 if ($citation_links_enabled) {
6482 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
6483 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
6484 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
6485 } else {
6486 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
6487 "Simply provide helpful answers based on the reference information without citing sources.";
6488 }
6489 }
6490
6491 return trim($content);
6492 }
6493
6494 /**
6495 * plan-mxchat-20260717-03ba33 — if a KB source used for context is a single
6496 * YouTube video, queue ONE consent-safe embed for the response html channel.
6497 * Called from BOTH retrieval builders (WordPress DB + Pinecone) inside their
6498 * real-URL winner branch, in ranked order — so the first (best) video wins and
6499 * later matches are ignored. Only KB/admin-ingested sources ever reach this
6500 * point; a URL a visitor pastes in chat never does.
6501 */
6502 private function maybe_queue_youtube_embed($source_url, $full_text) {
6503 if (!empty($this->videoEmbedHtml)) {
6504 return; // one video per response
6505 }
6506 $video_id = MxChat_Utils::parse_youtube_id($source_url);
6507 if (empty($video_id)) {
6508 return;
6509 }
6510 // Ingestion writes "YouTube Video: {title}" / "Channel: {name}" / "URL: …"
6511 // header lines into the indexed text. NOTE: when citation links are
6512 // disabled the winner loop collapses ALL whitespace to single spaces
6513 // before this runs, so the title must be terminated by the next header
6514 // label, not by end-of-line. Fall back to a generic label when absent
6515 // (e.g. a YouTube watch page imported through the plain URL source).
6516 $title = '';
6517 if (preg_match('/YouTube Video:\s*(.+?)(?=\s+Channel:\s|\s+URL:\s|\r|\n|$)/i', (string) $full_text, $m)) {
6518 $title = trim(mb_substr(trim($m[1]), 0, 140));
6519 if (preg_match('#^https?://#i', $title)) {
6520 $title = ''; // header carried the URL, not a real title
6521 }
6522 }
6523 $this->videoEmbedHtml = $this->build_youtube_embed_html($video_id, $title, $source_url);
6524 }
6525
6526 /**
6527 * Consent-safe click-to-load YouTube facade. No Google iframe is created until
6528 * the visitor taps play (chat-script.js swaps the facade for a
6529 * youtube-nocookie.com iframe). The caption always carries a plain "Watch on
6530 * YouTube" link, which is also the graceful degrade on strict-CSP sites where
6531 * third-party frames are blocked.
6532 */
6533 private function build_youtube_embed_html($video_id, $title, $watch_url) {
6534 $video_id = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $video_id);
6535 if ($video_id === '') {
6536 return '';
6537 }
6538 $thumb = 'https://i.ytimg.com/vi/' . $video_id . '/hqdefault.jpg';
6539 $label = ($title !== '') ? $title : __('YouTube video', 'mxchat');
6540
6541 $html = '<div class="mxchat-youtube-embed" data-video-id="' . esc_attr($video_id) . '">';
6542 $html .= '<button type="button" class="mxchat-youtube-facade" aria-label="' . esc_attr(sprintf(__('Play video: %s', 'mxchat'), $label)) . '">';
6543 $html .= '<img class="mxchat-youtube-thumb" src="' . esc_url($thumb) . '" alt="' . esc_attr($label) . '" loading="lazy" />';
6544 $html .= '<span class="mxchat-youtube-play" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg></span>';
6545 $html .= '</button>';
6546 $html .= '<div class="mxchat-youtube-caption">';
6547 $html .= '<span class="mxchat-youtube-title">' . esc_html($label) . '</span>';
6548 $html .= '<a class="mxchat-youtube-link" href="' . esc_url($watch_url) . '" target="_blank" rel="noopener noreferrer">' . esc_html__('Watch on YouTube', 'mxchat') . '</a>';
6549 $html .= '</div>';
6550 $html .= '</div>';
6551 return $html;
6552 }
6553
6554 /**
6555 * Fetch and reassemble chunks for a URL from WordPress database
6556 *
6557 * @param string $source_url The source URL to fetch chunks for
6558 * @param int $max_chunks Maximum number of chunks to return (0 = unlimited)
6559 * @param int &$chunk_count Reference to store the actual number of chunks returned
6560 * @return string Reassembled content from chunks
6561 */
6562 private function reassemble_chunks_from_wordpress($source_url, $max_chunks = 0, &$chunk_count = 0) {
6563 global $wpdb;
6564 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
6565
6566 // Fetch all rows with this source_url
6567 $rows = $wpdb->get_results($wpdb->prepare(
6568 "SELECT article_content FROM {$table}
6569 WHERE source_url = %s
6570 ORDER BY id ASC",
6571 $source_url
6572 ));
6573
6574 if (empty($rows)) {
6575 $chunk_count = 0;
6576 return '';
6577 }
6578
6579 // Parse and sort chunks by index
6580 $chunks = array();
6581 foreach ($rows as $row) {
6582 $parsed = MxChat_Chunker::parse_stored_chunk($row->article_content);
6583
6584 if ($parsed['is_chunked']) {
6585 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
6586 $chunks[$chunk_index] = $parsed['text'];
6587 } else {
6588 // Non-chunked content - just return it
6589 $chunks[] = $parsed['text'];
6590 }
6591 }
6592
6593 // Sort by chunk index
6594 ksort($chunks);
6595
6596 // Apply chunk limit if specified
6597 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
6598 $chunks = array_slice($chunks, 0, $max_chunks, true);
6599 }
6600
6601 // Store actual chunk count
6602 $chunk_count = count($chunks);
6603
6604 // Reassemble content
6605 return implode("\n\n", $chunks);
6606 }
6607
6608 private function find_relevant_content_pinecone($user_embedding, $bot_id = 'default', $bot_config = null) {
6609 global $wpdb;
6610
6611 //error_log("MXCHAT DEBUG: find_relevant_content_pinecone called");
6612 //error_log(" - bot_id: " . $bot_id);
6613 //error_log(" - user_embedding is array: " . (is_array($user_embedding) ? 'yes' : 'no'));
6614 //error_log(" - user_embedding count: " . (is_array($user_embedding) ? count($user_embedding) : 'N/A'));
6615
6616 // Use bot-specific config or fall back to default
6617 if ($bot_config === null) {
6618 $bot_config = $this->get_bot_pinecone_config($bot_id);
6619 }
6620
6621 $api_key = $bot_config['api_key'] ?? '';
6622 $host = $bot_config['host'] ?? '';
6623 $namespace = $bot_config['namespace'] ?? '';
6624
6625 //error_log("MXCHAT DEBUG: Pinecone query parameters:");
6626 //error_log(" - API Key: " . (empty($api_key) ? 'EMPTY - ERROR!' : 'Present (length: ' . strlen($api_key) . ')'));
6627 //error_log(" - Host: " . (empty($host) ? 'EMPTY - ERROR!' : $host));
6628 //error_log(" - Namespace: " . (empty($namespace) ? 'EMPTY (will use default)' : $namespace));
6629
6630 // Initialize similarity analysis storage
6631 $this->last_similarity_analysis = [
6632 'knowledge_base_type' => 'Pinecone',
6633 'bot_id' => $bot_id,
6634 'namespace' => $namespace,
6635 'top_matches' => [],
6636 'threshold_used' => 0,
6637 'total_checked' => 0
6638 ];
6639
6640 // NEW: Initialize valid URLs array
6641 $valid_urls = [];
6642
6643 if (empty($host) || empty($api_key)) {
6644 //error_log("MXCHAT DEBUG ERROR: Missing Pinecone host or API key!");
6645 //error_log(" - Host empty: " . (empty($host) ? 'YES' : 'NO'));
6646 //error_log(" - API key empty: " . (empty($api_key) ? 'YES' : 'NO'));
6647 // Store empty array for valid URLs since we can't proceed
6648 $this->current_valid_urls = [];
6649 return '';
6650 }
6651
6652 // Get knowledge manager instance for role checking
6653 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
6654
6655 // Get the similarity threshold from the bot options or main options
6656 $bot_options = $this->get_bot_options($bot_id);
6657 $current_options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', []);
6658
6659 $similarity_threshold = isset($current_options['similarity_threshold'])
6660 ? ((int) $current_options['similarity_threshold']) / 100
6661 : 0.35;
6662
6663 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
6664
6665 // Prepare the query request for Pinecone
6666 $api_endpoint = "https://{$host}/query";
6667
6668 $request_body = array(
6669 'vector' => $user_embedding,
6670 'topK' => 50, // Increased for chunked content grouping - need more candidates to find top N unique URLs
6671 'includeMetadata' => true,
6672 'includeValues' => true
6673 );
6674
6675 // Add namespace if specified for this bot
6676 if (!empty($namespace)) {
6677 $request_body['namespace'] = $namespace;
6678 }
6679
6680 //error_log("MXCHAT DEBUG: About to call Pinecone API");
6681 //error_log(" - Endpoint: " . $api_endpoint);
6682 //error_log(" - Namespace in request: " . (!empty($namespace) ? $namespace : 'NOT SET'));
6683
6684 $response = wp_remote_post($api_endpoint, array(
6685 'headers' => array(
6686 'Api-Key' => $api_key,
6687 'accept' => 'application/json',
6688 'content-type' => 'application/json'
6689 ),
6690 'body' => wp_json_encode($request_body),
6691 'timeout' => 30
6692 ));
6693
6694 if (is_wp_error($response)) {
6695 //error_log("MXCHAT DEBUG ERROR: WP Error in Pinecone request: " . $response->get_error_message());
6696 // Store empty array for valid URLs
6697 $this->current_valid_urls = [];
6698 return '';
6699 }
6700
6701 $response_code = wp_remote_retrieve_response_code($response);
6702 //error_log("MXCHAT DEBUG: Pinecone response code: " . $response_code);
6703
6704 if ($response_code !== 200) {
6705 $response_body = wp_remote_retrieve_body($response);
6706 //error_log("MXCHAT DEBUG ERROR: Pinecone API error response: " . substr($response_body, 0, 500));
6707 // Store empty array for valid URLs
6708 $this->current_valid_urls = [];
6709 return '';
6710 }
6711
6712 // ADD DETAILED DEBUG SECTION HERE
6713 $response_body = wp_remote_retrieve_body($response);
6714 //error_log("MXCHAT DEBUG: Raw Pinecone response length: " . strlen($response_body));
6715
6716 $results = json_decode($response_body, true);
6717
6718 if (json_last_error() !== JSON_ERROR_NONE) {
6719 //error_log("MXCHAT DEBUG ERROR: JSON decode error: " . json_last_error_msg());
6720 //error_log("MXCHAT DEBUG: First 500 chars of response: " . substr($response_body, 0, 500));
6721 // Store empty array for valid URLs
6722 $this->current_valid_urls = [];
6723 return '';
6724 }
6725
6726 //error_log("MXCHAT DEBUG: Pinecone response structure:");
6727 //error_log(" - Has 'matches' key: " . (isset($results['matches']) ? 'yes' : 'no'));
6728 //error_log(" - Has 'namespace' key: " . (isset($results['namespace']) ? 'yes (' . $results['namespace'] . ')' : 'no'));
6729
6730 if (empty($results['matches'])) {
6731 //error_log("MXCHAT DEBUG: No matches found in Pinecone response");
6732 //error_log("MXCHAT DEBUG: Response keys: " . implode(', ', array_keys($results)));
6733 // Store empty array for valid URLs
6734 $this->current_valid_urls = [];
6735 return '';
6736 }
6737
6738 //error_log("MXCHAT DEBUG: Found " . count($results['matches']) . " matches in Pinecone");
6739
6740 // Log first match details for debugging
6741 if (!empty($results['matches'][0])) {
6742 $first_match = $results['matches'][0];
6743 //error_log("MXCHAT DEBUG: First match details:");
6744 //error_log(" - Score: " . ($first_match['score'] ?? 'no score'));
6745 //error_log(" - Has metadata: " . (isset($first_match['metadata']) ? 'yes' : 'no'));
6746 if (isset($first_match['metadata'])) {
6747 //error_log(" - Metadata keys: " . implode(', ', array_keys($first_match['metadata'])));
6748 }
6749 }
6750
6751 // Initialize the final content
6752 $content = '';
6753 $matches_used = 0;
6754 $matches_used_for_context = [];
6755 $total_chunks_used = 0;
6756 $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
6757 if ($max_total_chunks < 8) $max_total_chunks = 8;
6758 if ($max_total_chunks > 20) $max_total_chunks = 20;
6759 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
6760
6761 // Check if citation links are enabled (default to 'on' for backwards compatibility)
6762 // Use fresh options to ensure we get the latest setting value
6763 $fresh_options = get_option('mxchat_options', []);
6764 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6765
6766 // NEW CHUNKING LOGIC: Group results by source_url for chunk reassembly
6767 $url_groups = array();
6768
6769 foreach ($results['matches'] as $index => $match) {
6770 // Skip if similarity is below threshold
6771 if ($match['score'] < $similarity_threshold) {
6772 continue;
6773 }
6774
6775 $metadata = $match['metadata'] ?? array();
6776 $source_url = $metadata['source_url'] ?? '';
6777 $match_id = $match['id'] ?? '';
6778
6779 // LAZY ROLE CHECK: Only check role for content we're actually considering
6780 $role_restriction = $this->get_single_vector_role($match_id, $metadata);
6781 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
6782
6783 // Skip if user doesn't have access
6784 if (!$has_access) {
6785 continue;
6786 }
6787
6788 // Use a unique key for manual entries without a source URL
6789 $group_key = !empty($source_url) ? $source_url : '_manual_' . $match_id;
6790
6791 // Group by source URL (or unique key for manual entries)
6792 if (!isset($url_groups[$group_key])) {
6793 $url_groups[$group_key] = array(
6794 'source_url' => $source_url,
6795 'best_score' => 0,
6796 'is_chunked' => isset($metadata['is_chunked']) && $metadata['is_chunked'],
6797 'chunks' => array(),
6798 'single_text' => ''
6799 );
6800 }
6801
6802 // Track best score for this group
6803 if ($match['score'] > $url_groups[$group_key]['best_score']) {
6804 $url_groups[$group_key]['best_score'] = $match['score'];
6805 }
6806
6807 // Store chunk info or single text
6808 if ($url_groups[$group_key]['is_chunked']) {
6809 $url_groups[$group_key]['chunks'][] = array(
6810 'id' => $match_id,
6811 'score' => $match['score'],
6812 'chunk_index' => $metadata['chunk_index'] ?? 0,
6813 'text' => $metadata['text'] ?? ''
6814 );
6815 } else {
6816 // Non-chunked content - just store the text
6817 $url_groups[$group_key]['single_text'] = $metadata['text'] ?? '';
6818 $url_groups[$group_key]['single_id'] = $match_id;
6819 }
6820 }
6821
6822 // Sort URL groups by best score (highest first)
6823 uasort($url_groups, function($a, $b) {
6824 return $b['best_score'] <=> $a['best_score'];
6825 });
6826
6827 // Get RAG sources limit from options (default 6, min 3, max 10)
6828 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
6829 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
6830 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
6831
6832 // Take top N unique URLs based on user setting
6833 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
6834
6835 // Track which match IDs are actually used for context
6836 foreach ($top_urls as $group) {
6837 if ($group['is_chunked']) {
6838 foreach ($group['chunks'] as $chunk) {
6839 $matches_used_for_context[] = $chunk['id'];
6840 }
6841 } elseif (!empty($group['single_id'])) {
6842 $matches_used_for_context[] = $group['single_id'];
6843 }
6844 }
6845
6846 // Build content from top sources
6847 foreach ($top_urls as $group_key => $group) {
6848 $source_url = $group['source_url']; // Use actual source_url, not the group key
6849
6850 // Stop if we've hit the total chunk limit
6851 if ($total_chunks_used >= $max_total_chunks) {
6852 break;
6853 }
6854
6855 $full_text = '';
6856 $chunks_in_this_source = 1; // Default for non-chunked content
6857
6858 if ($group['is_chunked']) {
6859 // Calculate how many chunks we can still use (respect both total and per-source caps)
6860 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
6861
6862 // Fetch chunks for this URL with limit
6863 $full_text = $this->reassemble_chunks_from_pinecone($source_url, $bot_config, $chunks_remaining, $chunks_in_this_source);
6864
6865 // If fetching all chunks fails, fall back to matched chunks
6866 if (empty($full_text)) {
6867 // Sort matched chunks by index and concatenate
6868 usort($group['chunks'], function($a, $b) {
6869 return $a['chunk_index'] <=> $b['chunk_index'];
6870 });
6871
6872 $chunk_texts = array();
6873 $chunks_in_this_source = 0;
6874 foreach ($group['chunks'] as $chunk) {
6875 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
6876 break;
6877 }
6878 $chunk_texts[] = $chunk['text'];
6879 $chunks_in_this_source++;
6880 }
6881 $full_text = implode("\n\n", $chunk_texts);
6882 }
6883 } else {
6884 $full_text = $group['single_text'];
6885 $chunks_in_this_source = 1;
6886 }
6887
6888 if (!empty($full_text)) {
6889 // Strip URLs from content if citation links are disabled
6890 if (!$citation_links_enabled) {
6891 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
6892 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
6893 }
6894
6895 // Use numbered reference for URL-based entries, plain info label for manual entries
6896 // Manual entries are stored with an internal mxchat:// placeholder URL — never expose them as citations
6897 if (!empty($source_url) && $source_url !== '#' && strpos($source_url, 'mxchat://') !== 0) {
6898 $matches_used++;
6899 $content .= "## Reference " . $matches_used . " ##\n";
6900 $content .= $full_text . "\n\n";
6901
6902 // Only include citation URLs if citation links are enabled
6903 if ($citation_links_enabled) {
6904 $valid_urls[] = $source_url;
6905 $content .= "URL: " . $source_url . "\n\n";
6906 }
6907
6908 // Video-backed source → queue the consent-safe embed (03ba33)
6909 $this->maybe_queue_youtube_embed($source_url, $full_text);
6910 } else {
6911 // Manual entry — no reference number, no citation. Count it as a USED
6912 // source (plan-mxchat-20260622-c1fe6a): without this, manual/Direct-Content
6913 // entries (empty or mxchat:// source_url) never increment $matches_used, so
6914 // the gate below (`if ($matches_used === 0)`) discards manual-only context on
6915 // the Pinecone backend and the model is told "No reference information was
6916 // found" — even though the testing panel reports used_for_context:true. It
6917 // also corrects the cosmetic sources_used:0 the panel/transcript showed. The
6918 // sibling local/WP-DB builder gates on empty($top_urls), so it never had this
6919 // bug; this brings Pinecone to parity. Manual entries are still uncited (not
6920 // added to $valid_urls, no "URL:" line).
6921 $matches_used++;
6922 $content .= "## Information ##\n";
6923 $content .= $full_text . "\n\n";
6924 }
6925
6926 // Extract any URLs from the text content itself (only if citation links enabled)
6927 if ($citation_links_enabled) {
6928 preg_match_all(
6929 '#\bhttps?://[^\s<>"\']+#i',
6930 $full_text,
6931 $content_urls
6932 );
6933 if (!empty($content_urls[0])) {
6934 $valid_urls = array_merge($valid_urls, $content_urls[0]);
6935 }
6936 }
6937
6938 $total_chunks_used += $chunks_in_this_source;
6939 }
6940 }
6941
6942 // Process ALL matches for testing data (top 10) - with role checking for testing display
6943 $all_matches = [];
6944 foreach ($results['matches'] as $index => $match) {
6945 if ($index >= 10) break; // Limit to top 10 for testing
6946
6947 $match_id = $match['id'] ?? '';
6948
6949 // Check role access for testing display (use cache if available)
6950 $role_restriction = $this->get_single_vector_role($match_id, $match['metadata']);
6951 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
6952
6953 $source_display = '';
6954 if (!empty($match['metadata']['source_url'])) {
6955 $source_display = $match['metadata']['source_url'];
6956 } else {
6957 $content_preview = strip_tags($match['metadata']['text'] ?? '');
6958 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
6959 $source_display = substr(trim($content_preview), 0, 50) . '...';
6960 }
6961
6962 $match_id_for_display = $match['id'] ?? $index;
6963
6964 // Check for chunk metadata in Pinecone
6965 $is_chunk = isset($match['metadata']['is_chunked']) && $match['metadata']['is_chunked'];
6966 $chunk_index = isset($match['metadata']['chunk_index']) ? intval($match['metadata']['chunk_index']) : null;
6967 $total_chunks = isset($match['metadata']['total_chunks']) ? intval($match['metadata']['total_chunks']) : null;
6968
6969 // Also detect chunk from vector ID pattern: {hash}_chunk_{index}
6970 if (!$is_chunk && MxChat_Chunker::is_chunk_vector_id($match_id_for_display)) {
6971 $is_chunk = true;
6972 }
6973
6974 $all_matches[] = [
6975 'document_id' => $match_id_for_display,
6976 'similarity' => $match['score'],
6977 'similarity_percentage' => round($match['score'] * 100, 2),
6978 'above_threshold' => $match['score'] >= $similarity_threshold,
6979 'source_display' => $source_display,
6980 'content_preview' => substr(strip_tags($match['metadata']['text'] ?? ''), 0, 100) . '...',
6981 'used_for_context' => in_array($match_id_for_display, $matches_used_for_context),
6982 'role_restriction' => $role_restriction,
6983 'has_access' => $has_access,
6984 'filtered_out' => !$has_access,
6985 'is_chunk' => $is_chunk,
6986 'chunk_index' => $chunk_index,
6987 'total_chunks' => $total_chunks
6988 ];
6989 }
6990
6991 // Store for testing panel
6992 $this->last_similarity_analysis['top_matches'] = $all_matches;
6993 $this->last_similarity_analysis['total_checked'] = count($results['matches']);
6994 $this->last_similarity_analysis['sources_used'] = $matches_used;
6995 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
6996
6997 // NEW: Store unique valid URLs for validation
6998 $this->current_valid_urls = array_unique($valid_urls);
6999
7000 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
7001 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
7002
7003 // Add response guidelines
7004 if ($matches_used === 0) {
7005 $content = "No reference information was found for this query.\n\n";
7006 } else {
7007 // Build response guidelines based on citation links setting
7008 $content .= "\n## Response Guidelines ##\n" .
7009 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
7010 "Be conversational and friendly, but never mention your knowledge base or training data. " .
7011 "If you don't have specific information or are uncertain about any details, it's always " .
7012 "better to honestly say you don't know rather than making up or guessing at answers. " .
7013 "When information is incomplete, let them know you are unsure.\n\n";
7014
7015 // Only add hyperlink instructions if citation links are enabled
7016 if ($citation_links_enabled) {
7017 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
7018 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
7019 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
7020 } else {
7021 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
7022 "Simply provide helpful answers based on the reference information without citing sources.";
7023 }
7024 }
7025
7026 return trim($content);
7027 }
7028
7029 /**
7030 * Get role restriction for a single vector (with caching)
7031 */
7032 private function get_single_vector_role($vector_id, $metadata = array()) {
7033 global $wpdb;
7034
7035 if (empty($vector_id)) {
7036 return 'public';
7037 }
7038
7039 // Check cache first
7040 $cache_key = 'mxchat_vector_role_' . $vector_id;
7041 $cached_role = wp_cache_get($cache_key, 'mxchat_vector_roles');
7042
7043 if ($cached_role !== false) {
7044 return $cached_role;
7045 }
7046
7047 $role_restriction = 'public';
7048
7049 // First try Pinecone metadata
7050 if (!empty($metadata['role_restriction'])) {
7051 $role_restriction = $metadata['role_restriction'];
7052 } else {
7053 // Check WordPress table for user-modified roles
7054 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
7055 $stored_role = $wpdb->get_var($wpdb->prepare(
7056 "SELECT role_restriction FROM {$roles_table} WHERE vector_id = %s",
7057 $vector_id
7058 ));
7059
7060 if ($stored_role) {
7061 $role_restriction = $stored_role;
7062 }
7063 }
7064
7065 // Cache individual role for 1 hour
7066 wp_cache_set($cache_key, $role_restriction, 'mxchat_vector_roles', 3600);
7067
7068 return $role_restriction;
7069 }
7070
7071 /**
7072 * Fetch and reassemble all chunks for a URL from Pinecone
7073 *
7074 * @param string $source_url The source URL to fetch chunks for
7075 * @param array $bot_config Bot-specific Pinecone configuration
7076 * @return string Reassembled content from all chunks
7077 */
7078 private function reassemble_chunks_from_pinecone($source_url, $bot_config, $max_chunks = 0, &$chunk_count = 0) {
7079 $api_key = $bot_config['api_key'] ?? '';
7080 $host = $bot_config['host'] ?? '';
7081 $namespace = $bot_config['namespace'] ?? '';
7082
7083 if (empty($host) || empty($api_key)) {
7084 $chunk_count = 0;
7085 return '';
7086 }
7087
7088 $base_hash = md5($source_url);
7089
7090 // Use Pinecone list API to find all chunk vectors with this prefix
7091 $list_url = "https://{$host}/vectors/list";
7092
7093 // Limit to max_chunks if specified, otherwise fetch up to 100
7094 $fetch_limit = ($max_chunks > 0 && $max_chunks < 100) ? $max_chunks : 100;
7095
7096 $list_body = array(
7097 'prefix' => $base_hash . '_chunk_',
7098 'limit' => $fetch_limit
7099 );
7100
7101 if (!empty($namespace)) {
7102 $list_body['namespace'] = $namespace;
7103 }
7104
7105 $list_response = wp_remote_post($list_url, array(
7106 'headers' => array(
7107 'Api-Key' => $api_key,
7108 'accept' => 'application/json',
7109 'content-type' => 'application/json'
7110 ),
7111 'body' => wp_json_encode($list_body),
7112 'timeout' => 30
7113 ));
7114
7115 if (is_wp_error($list_response)) {
7116 //error_log('[MXCHAT-CHUNK] List API error: ' . $list_response->get_error_message());
7117 return '';
7118 }
7119
7120 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
7121
7122 if (empty($list_data['vectors'])) {
7123 //error_log('[MXCHAT-CHUNK] No chunk vectors found for URL: ' . $source_url);
7124 return '';
7125 }
7126
7127 // Extract vector IDs
7128 $vector_ids = array();
7129 foreach ($list_data['vectors'] as $vector) {
7130 if (isset($vector['id'])) {
7131 $vector_ids[] = $vector['id'];
7132 }
7133 }
7134
7135 if (empty($vector_ids)) {
7136 return '';
7137 }
7138
7139 // Fetch all chunk content
7140 $fetch_url = "https://{$host}/vectors/fetch";
7141
7142 $fetch_body = array(
7143 'ids' => $vector_ids
7144 );
7145
7146 if (!empty($namespace)) {
7147 $fetch_body['namespace'] = $namespace;
7148 }
7149
7150 $fetch_response = wp_remote_post($fetch_url, array(
7151 'headers' => array(
7152 'Api-Key' => $api_key,
7153 'accept' => 'application/json',
7154 'content-type' => 'application/json'
7155 ),
7156 'body' => wp_json_encode($fetch_body),
7157 'timeout' => 30
7158 ));
7159
7160 if (is_wp_error($fetch_response)) {
7161 //error_log('[MXCHAT-CHUNK] Fetch API error: ' . $fetch_response->get_error_message());
7162 return '';
7163 }
7164
7165 $fetch_data = json_decode(wp_remote_retrieve_body($fetch_response), true);
7166
7167 if (empty($fetch_data['vectors'])) {
7168 return '';
7169 }
7170
7171 // Sort chunks by index and reassemble
7172 $chunks = array();
7173 foreach ($fetch_data['vectors'] as $id => $vector) {
7174 $metadata = $vector['metadata'] ?? array();
7175 $chunk_index = $metadata['chunk_index'] ?? 0;
7176 $text = $metadata['text'] ?? '';
7177
7178 // Store chunk with its index
7179 $chunks[$chunk_index] = $text;
7180 }
7181
7182 // Sort by chunk index
7183 ksort($chunks);
7184
7185 // Apply chunk limit if specified
7186 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
7187 $chunks = array_slice($chunks, 0, $max_chunks, true);
7188 }
7189
7190 // Store actual chunk count
7191 $chunk_count = count($chunks);
7192
7193 // Reassemble content
7194 return implode("\n\n", $chunks);
7195 }
7196
7197 /**
7198 * Search for relevant content using OpenAI Vector Store (File Search)
7199 *
7200 * @param string $user_query The user's query text
7201 * @param string $bot_id The bot ID
7202 * @param array $vectorstore_config Vector Store configuration
7203 * @return string Formatted context string with references
7204 */
7205 private function find_relevant_content_openai_vectorstore($user_query, $bot_id = 'default', $vectorstore_config = array()) {
7206 //error_log("MXCHAT DEBUG: find_relevant_content_openai_vectorstore called");
7207 //error_log(" - bot_id: " . $bot_id);
7208 //error_log(" - user_query length: " . strlen($user_query));
7209
7210 // Get OpenAI API key
7211 $mxchat_options = get_option('mxchat_options', array());
7212 $api_key = $mxchat_options['api_key'] ?? '';
7213
7214 // Reset vectorstore error tracking
7215 $this->last_vectorstore_error = null;
7216
7217 if (empty($api_key)) {
7218 //error_log("MXCHAT DEBUG ERROR: OpenAI API key not configured");
7219 $this->last_vectorstore_error = 'Vector Store search failed: OpenAI API key is not configured.';
7220 $this->current_valid_urls = [];
7221 return '';
7222 }
7223
7224 // Get Vector Store configuration
7225 if (empty($vectorstore_config)) {
7226 $vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
7227 }
7228
7229 $vectorstore_ids_string = $vectorstore_config['vectorstore_ids'] ?? '';
7230 $max_results = $vectorstore_config['max_results'] ?? 5;
7231
7232 if (empty($vectorstore_ids_string)) {
7233 //error_log("MXCHAT DEBUG ERROR: No Vector Store IDs configured");
7234 $this->last_vectorstore_error = 'Vector Store search failed: No Vector Store IDs are configured for this bot.';
7235 $this->current_valid_urls = [];
7236 return '';
7237 }
7238
7239 // Parse Vector Store IDs
7240 $vectorstore_ids = array_map('trim', explode(',', $vectorstore_ids_string));
7241 $vectorstore_ids = array_filter($vectorstore_ids); // Remove empty values
7242
7243 //error_log("MXCHAT DEBUG: Vector Store IDs: " . implode(', ', $vectorstore_ids));
7244 //error_log("MXCHAT DEBUG: Max results: " . $max_results);
7245
7246 // Initialize similarity analysis storage
7247 $this->last_similarity_analysis = [
7248 'knowledge_base_type' => 'OpenAI Vector Store',
7249 'bot_id' => $bot_id,
7250 'vectorstore_ids' => $vectorstore_ids,
7251 'top_matches' => [],
7252 'threshold_used' => 0,
7253 'total_checked' => 0
7254 ];
7255
7256 $valid_urls = [];
7257
7258 // Get the selected model
7259 $bot_options = $this->get_bot_options($bot_id);
7260 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
7261 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
7262
7263 // Verify it's an OpenAI model
7264 if (!$this->is_openai_chat_model($selected_model)) {
7265 //error_log("MXCHAT DEBUG ERROR: Vector Store search requires OpenAI model. Current: " . $selected_model);
7266 $this->last_vectorstore_error = 'Vector Store search requires an OpenAI model. Current model: ' . $selected_model;
7267 $this->current_valid_urls = [];
7268 return '';
7269 }
7270
7271 // Use OpenAI Responses API with file_search tool
7272 $request_body = array(
7273 'model' => $selected_model,
7274 'input' => $user_query,
7275 'tools' => array(
7276 array(
7277 'type' => 'file_search',
7278 'vector_store_ids' => $vectorstore_ids,
7279 'max_num_results' => intval($max_results)
7280 )
7281 ),
7282 'include' => array('output[*].file_search_call.search_results')
7283 );
7284
7285 //error_log("MXCHAT VECTORSTORE: ========== REQUEST START ==========");
7286 //error_log("MXCHAT VECTORSTORE: Model: " . $selected_model);
7287 //error_log("MXCHAT VECTORSTORE: Query: " . substr($user_query, 0, 200));
7288 //error_log("MXCHAT VECTORSTORE: Vector Store IDs: " . implode(', ', $vectorstore_ids));
7289 //error_log("MXCHAT VECTORSTORE: Max Results: " . $max_results);
7290 //error_log("MXCHAT VECTORSTORE: Request body: " . wp_json_encode($request_body));
7291
7292 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
7293 'headers' => array(
7294 'Authorization' => 'Bearer ' . $api_key,
7295 'Content-Type' => 'application/json'
7296 ),
7297 'body' => wp_json_encode($request_body),
7298 'timeout' => 60
7299 ));
7300
7301 if (is_wp_error($response)) {
7302 //error_log("MXCHAT VECTORSTORE ERROR: WP Error: " . $response->get_error_message());
7303 $this->last_vectorstore_error = 'Vector Store API request failed: ' . $response->get_error_message();
7304 $this->current_valid_urls = [];
7305 return '';
7306 }
7307
7308 $response_code = wp_remote_retrieve_response_code($response);
7309 //error_log("MXCHAT VECTORSTORE: Response code: " . $response_code);
7310
7311 $response_body = wp_remote_retrieve_body($response);
7312 //error_log("MXCHAT VECTORSTORE: Raw response (first 2000 chars): " . substr($response_body, 0, 2000));
7313
7314 if ($response_code !== 200) {
7315 //error_log("MXCHAT VECTORSTORE ERROR: API error response: " . $response_body);
7316 $decoded_error = json_decode($response_body, true);
7317 $api_error_detail = $this->extract_provider_error($decoded_error, '');
7318 $this->last_vectorstore_error = 'Vector Store API returned HTTP ' . $response_code . ($api_error_detail ? ': ' . $api_error_detail : '');
7319 $this->current_valid_urls = [];
7320 return '';
7321 }
7322 $result = json_decode($response_body, true);
7323
7324 if (json_last_error() !== JSON_ERROR_NONE) {
7325 //error_log("MXCHAT VECTORSTORE ERROR: JSON decode error: " . json_last_error_msg());
7326 $this->last_vectorstore_error = 'Vector Store response could not be parsed: ' . json_last_error_msg();
7327 $this->current_valid_urls = [];
7328 return '';
7329 }
7330
7331 // Debug: Log the structure of the result
7332 //error_log("MXCHAT VECTORSTORE: Result keys: " . implode(', ', array_keys($result)));
7333 if (isset($result['output'])) {
7334 //error_log("MXCHAT VECTORSTORE: Output count: " . count($result['output']));
7335 foreach ($result['output'] as $idx => $out) {
7336 //error_log("MXCHAT VECTORSTORE: Output[$idx] type: " . ($out['type'] ?? 'unknown'));
7337 //error_log("MXCHAT VECTORSTORE: Output[$idx] keys: " . implode(', ', array_keys($out)));
7338 }
7339 } else {
7340 //error_log("MXCHAT VECTORSTORE: No 'output' key in result!");
7341 }
7342
7343 // Extract file search results from the response
7344 $content = '';
7345 $matches_used = 0;
7346 $all_matches = [];
7347
7348 // The Responses API returns output array with tool results
7349 if (isset($result['output']) && is_array($result['output'])) {
7350 foreach ($result['output'] as $output_item) {
7351 // Look for file_search_call results
7352 if (isset($output_item['type']) && $output_item['type'] === 'file_search_call') {
7353 //error_log("MXCHAT VECTORSTORE: Found file_search_call output item");
7354 //error_log("MXCHAT VECTORSTORE: file_search_call keys: " . implode(', ', array_keys($output_item)));
7355
7356 // Check for search_results in the output item directly
7357 $search_results = $output_item['search_results'] ?? $output_item['results'] ?? [];
7358 //error_log("MXCHAT VECTORSTORE: Search results count: " . count($search_results));
7359
7360 if (empty($search_results)) {
7361 //error_log("MXCHAT VECTORSTORE: No search results found in file_search_call");
7362 //error_log("MXCHAT VECTORSTORE: file_search_call content: " . wp_json_encode($output_item));
7363 }
7364
7365 foreach ($search_results as $index => $search_result) {
7366 $filename = $search_result['filename'] ?? '';
7367 $score = $search_result['score'] ?? 0;
7368 $text_content = '';
7369
7370 // Extract text content from the result
7371 // The text can be directly on the result OR nested under content array
7372 if (isset($search_result['text']) && !empty($search_result['text'])) {
7373 // Direct text field (OpenAI's actual format)
7374 $text_content = $search_result['text'];
7375 //error_log("MXCHAT VECTORSTORE: Found text directly on result[$index], length: " . strlen($text_content));
7376 } elseif (isset($search_result['content']) && is_array($search_result['content'])) {
7377 // Nested content array format
7378 foreach ($search_result['content'] as $content_item) {
7379 if (isset($content_item['text'])) {
7380 $text_content .= $content_item['text'] . "\n";
7381 }
7382 }
7383 //error_log("MXCHAT VECTORSTORE: Found text in content array for result[$index], length: " . strlen($text_content));
7384 } else {
7385 //error_log("MXCHAT VECTORSTORE: No text found for result[$index]. Keys: " . implode(', ', array_keys($search_result)));
7386 }
7387
7388 if (!empty($text_content)) {
7389 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
7390 $content .= trim($text_content) . "\n\n";
7391
7392 if (!empty($filename)) {
7393 $content .= "Source: " . $filename . "\n\n";
7394 }
7395
7396 // Extract URLs from content
7397 preg_match_all(
7398 '#\bhttps?://[^\s<>"\']+#i',
7399 $text_content,
7400 $content_urls
7401 );
7402 if (!empty($content_urls[0])) {
7403 $valid_urls = array_merge($valid_urls, $content_urls[0]);
7404 }
7405
7406 $matches_used++;
7407 }
7408
7409 // Store for similarity analysis
7410 $all_matches[] = [
7411 'document_id' => $filename ?: ('result_' . $index),
7412 'similarity' => $score,
7413 'similarity_percentage' => round($score * 100, 2),
7414 'above_threshold' => true,
7415 'source_display' => $filename,
7416 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
7417 'used_for_context' => true,
7418 'role_restriction' => 'public',
7419 'has_access' => true,
7420 'filtered_out' => false
7421 ];
7422 }
7423 }
7424
7425 // Also check for message content with annotations (citations)
7426 if (isset($output_item['type']) && $output_item['type'] === 'message') {
7427 if (isset($output_item['content']) && is_array($output_item['content'])) {
7428 foreach ($output_item['content'] as $content_block) {
7429 if (isset($content_block['annotations']) && is_array($content_block['annotations'])) {
7430 foreach ($content_block['annotations'] as $annotation) {
7431 if (isset($annotation['filename'])) {
7432 $filename = $annotation['filename'];
7433 $score = $annotation['score'] ?? 0;
7434 $text_content = '';
7435
7436 if (isset($annotation['content']) && is_array($annotation['content'])) {
7437 foreach ($annotation['content'] as $ann_content) {
7438 if (isset($ann_content['text'])) {
7439 $text_content .= $ann_content['text'] . "\n";
7440 }
7441 }
7442 }
7443
7444 if (!empty($text_content) && $matches_used < $max_results) {
7445 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
7446 $content .= trim($text_content) . "\n\n";
7447 $content .= "Source: " . $filename . "\n\n";
7448
7449 preg_match_all(
7450 '#\bhttps?://[^\s<>"\']+#i',
7451 $text_content,
7452 $content_urls
7453 );
7454 if (!empty($content_urls[0])) {
7455 $valid_urls = array_merge($valid_urls, $content_urls[0]);
7456 }
7457
7458 $matches_used++;
7459
7460 $all_matches[] = [
7461 'document_id' => $filename,
7462 'similarity' => $score,
7463 'similarity_percentage' => round($score * 100, 2),
7464 'above_threshold' => true,
7465 'source_display' => $filename,
7466 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
7467 'used_for_context' => true,
7468 'role_restriction' => 'public',
7469 'has_access' => true,
7470 'filtered_out' => false
7471 ];
7472 }
7473 }
7474 }
7475 }
7476 }
7477 }
7478 }
7479 }
7480 }
7481
7482 // Store for testing panel
7483 $this->last_similarity_analysis['top_matches'] = $all_matches;
7484 $this->last_similarity_analysis['total_checked'] = count($all_matches);
7485
7486 // Store unique valid URLs for validation
7487 $this->current_valid_urls = array_unique($valid_urls);
7488
7489 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
7490 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
7491
7492 //error_log("MXCHAT VECTORSTORE: ========== SEARCH COMPLETE ==========");
7493 //error_log("MXCHAT VECTORSTORE: Matches used: " . $matches_used);
7494 //error_log("MXCHAT VECTORSTORE: All matches count: " . count($all_matches));
7495 //error_log("MXCHAT VECTORSTORE: Content length: " . strlen($content));
7496 if ($matches_used > 0) {
7497 //error_log("MXCHAT VECTORSTORE: Content preview: " . substr($content, 0, 500));
7498 }
7499
7500 // Check if citation links are enabled
7501 $citation_links_enabled = ($mxchat_options['citation_links_toggle'] ?? 'on') === 'on';
7502
7503 // Add response guidelines
7504 if ($matches_used === 0) {
7505 //error_log("MXCHAT VECTORSTORE: No matches found - returning empty reference message");
7506 $content = "No reference information was found for this query.\n\n";
7507 } else {
7508 // Build response guidelines based on citation links setting
7509 $content .= "\n## Response Guidelines ##\n" .
7510 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
7511 "Be conversational and friendly, but never mention your knowledge base or training data. " .
7512 "If you don't have specific information or are uncertain about any details, it's always " .
7513 "better to honestly say you don't know rather than making up or guessing at answers. " .
7514 "When information is incomplete, let them know you are unsure.\n\n";
7515
7516 // Only add hyperlink instructions if citation links are enabled
7517 if ($citation_links_enabled) {
7518 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
7519 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
7520 } else {
7521 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
7522 "Simply provide helpful answers based on the reference information without citing sources.";
7523 }
7524 }
7525
7526 //error_log("MXCHAT DEBUG: Vector Store search complete. Matches used: " . $matches_used);
7527
7528 return trim($content);
7529 }
7530
7531 /**
7532 * Check if the given model is an OpenAI chat model
7533 *
7534 * @param string $model The model ID
7535 * @return bool True if it's an OpenAI model
7536 */
7537 private function is_openai_chat_model($model) {
7538 $openai_prefixes = array('gpt-', 'o1-', 'o3-');
7539 foreach ($openai_prefixes as $prefix) {
7540 if (strpos($model, $prefix) === 0) {
7541 return true;
7542 }
7543 }
7544 return false;
7545 }
7546
7547 /**
7548 * Get bot-specific Vector Store configuration
7549 *
7550 * @param string $bot_id The bot ID
7551 * @return array Configuration array
7552 */
7553 private function get_bot_vectorstore_config($bot_id = 'default') {
7554 // Admin Testing tab bot → resolve the DEFAULT bot's backend (see
7555 // get_bot_pinecone_config). This getter already passes the real default
7556 // config into the filter, so it was not broken — normalized anyway so the
7557 // Testing bot can never drift from the front-end default.
7558 if ($bot_id === 'testing') {
7559 $bot_id = 'default';
7560 }
7561
7562 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
7563
7564 // Default global settings
7565 $default_config = array(
7566 'use_vectorstore' => ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1',
7567 'vectorstore_ids' => $vectorstore_options['mxchat_vectorstore_ids'] ?? '',
7568 'max_results' => $vectorstore_options['mxchat_vectorstore_max_results'] ?? 5
7569 );
7570
7571 // Allow multi-bot plugin to override with bot-specific settings
7572 $bot_config = apply_filters('mxchat_get_bot_vectorstore_config', $default_config, $bot_id);
7573
7574 // Preserve max_results from global settings if not set in bot config
7575 if (!isset($bot_config['max_results'])) {
7576 $bot_config['max_results'] = $default_config['max_results'];
7577 }
7578
7579 return $bot_config;
7580 }
7581
7582 private function mxchat_find_relevant_products($user_embedding) {
7583 //error_log('MXChat Vector Search: Starting product search...');
7584
7585 // Retrieve the add-on settings from the database
7586 $addon_options = get_option('mxchat_pinecone_addon_options', array());
7587
7588 // Determine whether Pinecone is enabled
7589 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1') ? 1 : 0;
7590
7591 //error_log('Pinecone enabled flag: ' . $use_pinecone);
7592
7593 if ($use_pinecone === 1) {
7594 //error_log('MXChat Vector Search: Using Pinecone database for products');
7595 return $this->find_relevant_products_pinecone($user_embedding);
7596 } else {
7597 //error_log('MXChat Vector Search: Using WordPress database for products');
7598 return $this->find_relevant_products_wordpress($user_embedding);
7599 }
7600 }
7601 private function find_relevant_products_wordpress($user_embedding) {
7602 global $wpdb;
7603 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
7604
7605 if (!is_array($user_embedding)) {
7606 return '';
7607 }
7608
7609 // Streaming top-K pass: scan rows in small batches, keep only the top 3
7610 // results above the similarity threshold. Peak memory is bounded by
7611 // $batch_size embedding rows plus a 3-element top list.
7612 $batch_size = 250;
7613 $similarity_threshold = 0.85;
7614 $top_k = 3;
7615 $top_results = [];
7616 $offset = 0;
7617
7618 do {
7619 $batch = $wpdb->get_results($wpdb->prepare(
7620 "SELECT id, embedding_vector
7621 FROM {$system_prompt_table}
7622 LIMIT %d OFFSET %d",
7623 $batch_size,
7624 $offset
7625 ));
7626
7627 if (empty($batch)) {
7628 break;
7629 }
7630
7631 foreach ($batch as $row) {
7632 $database_embedding = $row->embedding_vector
7633 ? unserialize($row->embedding_vector, ['allowed_classes' => false])
7634 : null;
7635
7636 if (!is_array($database_embedding)) {
7637 unset($database_embedding);
7638 continue;
7639 }
7640
7641 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
7642 unset($database_embedding);
7643
7644 if ($similarity < $similarity_threshold) {
7645 continue;
7646 }
7647
7648 // Insert into bounded top-K (kept sorted descending)
7649 if (count($top_results) < $top_k) {
7650 $top_results[] = ['id' => $row->id, 'similarity' => $similarity];
7651 usort($top_results, function ($a, $b) {
7652 return $b['similarity'] <=> $a['similarity'];
7653 });
7654 } elseif ($similarity > $top_results[$top_k - 1]['similarity']) {
7655 $top_results[$top_k - 1] = ['id' => $row->id, 'similarity' => $similarity];
7656 usort($top_results, function ($a, $b) {
7657 return $b['similarity'] <=> $a['similarity'];
7658 });
7659 }
7660 }
7661
7662 unset($batch);
7663 $offset += $batch_size;
7664 } while (true);
7665
7666 if (empty($top_results)) {
7667 return '';
7668 }
7669
7670 $content = '';
7671 foreach ($top_results as $result) {
7672 $chunk_content = $this->fetch_content_with_product_links($result['id']);
7673 $content .= $chunk_content . "\n\n";
7674 }
7675
7676 return trim($content);
7677 }
7678
7679
7680 private function find_relevant_products_pinecone($user_embedding) {
7681 //error_log('Starting Pinecone product search...');
7682
7683 $options = get_option('mxchat_pinecone_addon_options', array());
7684 $api_key = $options['mxchat_pinecone_api_key'] ?? '';
7685 $host = $options['mxchat_pinecone_host'] ?? '';
7686
7687 if (empty($host) || empty($api_key)) {
7688 //error_log('Pinecone credentials not properly configured for product search');
7689 return '';
7690 }
7691
7692 $similarity_threshold = 0.85;
7693 $api_endpoint = "https://{$host}/query";
7694
7695 $request_body = array(
7696 'vector' => $user_embedding,
7697 'topK' => 5,
7698 'includeMetadata' => true,
7699 'includeValues' => true,
7700 'filter' => array(
7701 'type' => 'product'
7702 )
7703 );
7704
7705 //error_log('Sending request to Pinecone with body: ' . wp_json_encode($request_body));
7706
7707 $response = wp_remote_post($api_endpoint, array(
7708 'headers' => array(
7709 'Api-Key' => $api_key,
7710 'accept' => 'application/json',
7711 'content-type' => 'application/json'
7712 ),
7713 'body' => wp_json_encode($request_body),
7714 'timeout' => 30
7715 ));
7716
7717 if (is_wp_error($response)) {
7718 //error_log('Pinecone product query error: ' . $response->get_error_message());
7719 return '';
7720 }
7721
7722 $response_code = wp_remote_retrieve_response_code($response);
7723 //error_log('Pinecone response code: ' . $response_code);
7724
7725 if ($response_code !== 200) {
7726 //error_log('Pinecone API error during product search: ' . wp_remote_retrieve_body($response));
7727 return '';
7728 }
7729
7730 $results = json_decode(wp_remote_retrieve_body($response), true);
7731 //error_log('Pinecone raw response: ' . wp_remote_retrieve_body($response));
7732
7733 if (empty($results['matches'])) {
7734 //error_log('No matches found in Pinecone response');
7735 return '';
7736 }
7737
7738 $content = '';
7739 foreach ($results['matches'] as $match) {
7740 if ($match['score'] < $similarity_threshold) {
7741 //error_log("Match below threshold: " . $match['score']);
7742 continue;
7743 }
7744
7745 if (!empty($match['metadata']['text'])) {
7746 $content .= $match['metadata']['text'];
7747 if (!empty($match['metadata']['source_url'])) {
7748 $content .= "\n\nFor more details, check out this product: " . esc_url($match['metadata']['source_url']);
7749 }
7750 $content .= "\n\n";
7751 }
7752 }
7753
7754 return trim($content);
7755 }
7756
7757
7758 private function fetch_content_with_product_links($most_relevant_id) {
7759 global $wpdb;
7760 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
7761
7762 // Fetch the article content and associated product URL
7763 $query = $wpdb->prepare("SELECT article_content, source_url FROM {$system_prompt_table} WHERE id = %d", $most_relevant_id);
7764 $result = $wpdb->get_row($query);
7765
7766 if ($result) {
7767 // Append the product link to the content if available
7768 $content = $result->article_content;
7769 if (!empty($result->source_url)) {
7770 $content .= "\n\nFor more details, check out this product: " . esc_url($result->source_url);
7771 }
7772 return $content;
7773 }
7774
7775 return null;
7776 }
7777
7778 /**
7779 * Get system instructions for a specific bot or default
7780 * Checks for multi-bot add-on and uses bot-specific instructions if available
7781 * Automatically strips URLs if citation links are disabled
7782 * Replaces {visitor_name} placeholder with actual visitor name if available
7783 *
7784 * @param string $bot_id The bot ID to get instructions for
7785 * @param string $session_id Optional session ID to lookup visitor name
7786 */
7787 private function get_system_instructions($bot_id = 'default', $session_id = '') {
7788 $instructions = '';
7789
7790 // Check if multi-bot add-on is active
7791 if (class_exists('MxChat_Multi_Bot_Core_Manager') && $bot_id !== 'default') {
7792 // Get bot-specific options from multi-bot add-on
7793 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
7794
7795 // If bot has custom system instructions, use those
7796 if (!empty($bot_options['system_prompt_instructions'])) {
7797 $instructions = $bot_options['system_prompt_instructions'];
7798 }
7799 }
7800
7801 // Fall back to default system instructions
7802 if (empty($instructions)) {
7803 $instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
7804 }
7805
7806 // Check if citation links are disabled - if so, strip URLs from instructions
7807 $fresh_options = get_option('mxchat_options', []);
7808 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
7809
7810 if (!$citation_links_enabled && !empty($instructions)) {
7811 $instructions = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $instructions);
7812 $instructions = preg_replace('/\s+/', ' ', trim($instructions)); // Clean up extra spaces
7813 }
7814
7815 // Replace {visitor_name} placeholder with actual visitor name if available
7816 if (!empty($instructions) && !empty($session_id) && stripos($instructions, '{visitor_name}') !== false) {
7817 $name_option_key = "mxchat_name_{$session_id}";
7818 $visitor_name = get_option($name_option_key, '');
7819
7820 if (!empty($visitor_name)) {
7821 $instructions = str_ireplace('{visitor_name}', sanitize_text_field($visitor_name), $instructions);
7822 } else {
7823 // Remove placeholder if no name is available
7824 $instructions = str_ireplace('{visitor_name}', '', $instructions);
7825 $instructions = preg_replace('/\s{2,}/', ' ', trim($instructions)); // Clean up extra spaces
7826 }
7827 }
7828
7829 // {context} placeholder (plan 59bc1b): inject the assembled knowledge-base
7830 // block where the owner placed the token. Runs after the URL-strip and
7831 // {visitor_name} handling and before the developer filter, so filtered
7832 // instructions already show the final prompt. Only active once the KB
7833 // assembly has stashed the block (context_kb_block non-null) — the early
7834 // URL-extraction call happens before assembly and leaves the token alone.
7835 if ($this->context_kb_block !== null && !empty($instructions) && stripos($instructions, '{context}') !== false) {
7836 $pos = stripos($instructions, '{context}');
7837 $instructions = substr($instructions, 0, $pos)
7838 . rtrim($this->context_kb_block) . "\n"
7839 . substr($instructions, $pos + strlen('{context}'));
7840 // Additional occurrences are stripped — never duplicate the KB block.
7841 $instructions = str_ireplace('{context}', '', $instructions);
7842 }
7843
7844 // Allow developers to filter system instructions and process shortcodes
7845 $instructions = apply_filters('mxchat_system_instructions', $instructions, $bot_id, $session_id);
7846 $instructions = do_shortcode($instructions);
7847
7848 return $instructions;
7849 }
7850 /**
7851 * Get the current bot ID from session or request context
7852 */
7853 private function get_current_bot_id($session_id = '') {
7854 // First, check if bot_id is passed in the current request
7855 if (isset($_POST['bot_id']) && !empty($_POST['bot_id'])) {
7856 return sanitize_key($_POST['bot_id']);
7857 }
7858
7859 // If not in POST, try to get it from session data
7860 if (!empty($session_id)) {
7861 $bot_id = get_option("mxchat_session_bot_{$session_id}", '');
7862 if (!empty($bot_id)) {
7863 return $bot_id;
7864 }
7865 }
7866
7867 // Fall back to default
7868 return 'default';
7869 }
7870 /* ====================================================================== *
7871 * Native function-calling loop (plan-mxchat-20260617-a41dee)
7872 *
7873 * Model-driven tool use. The model is offered MxChat's enabled callbacks as
7874 * tools (sourced from MxChat_Tool_Registry, the single source the admin AI
7875 * Tools checklist also reads). When the model calls a tool, the matching
7876 * callback runs through its EXISTING permission checks, its output is fed
7877 * back, and the loop continues up to a depth cap. INDEPENDENT of the
7878 * intent→callback router — it runs only after intents miss, and works with
7879 * ZERO Actions created.
7880 *
7881 * Entered ONLY when: function calling is enabled + the active model is
7882 * tool-capable + at least one tool is enabled. Default-off, so existing
7883 * installs never enter this branch (byte-for-byte unchanged behavior). The
7884 * tool round is buffered (non-streaming) per the plan; the final answer is
7885 * emitted via the same SSE/JSON envelopes the normal path uses.
7886 * ====================================================================== */
7887
7888 /** Gate: should the function-calling loop handle this turn? */
7889 private function mxchat_fc_should_run($selected_model) {
7890 if (!class_exists('MxChat_Tool_Registry') || !MxChat_Tool_Registry::is_enabled()) {
7891 return false;
7892 }
7893 if (class_exists('MxChat_Model_Catalog') && !MxChat_Model_Catalog::supports_tools($selected_model)) {
7894 return false;
7895 }
7896 $tools = MxChat_Tool_Registry::enabled_tools();
7897 return !empty($tools);
7898 }
7899
7900 private function mxchat_fc_log($msg) {
7901 if (defined('MXCHAT_DEV_MODE') && MXCHAT_DEV_MODE) {
7902 error_log('[MxChat FC] ' . $msg);
7903 }
7904 }
7905
7906 /**
7907 * Resolve provider transport details. Returns null when FC can't run for this
7908 * model/config (missing key, unsupported provider) so the caller falls back to
7909 * the normal path. OpenAI/xAI/DeepSeek/OpenRouter/Custom share the
7910 * OpenAI-compatible 'openai' family; Claude and Gemini are distinct.
7911 */
7912 private function mxchat_fc_resolve_provider($selected_model, $opts) {
7913 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
7914 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
7915 if ($selected_model === 'claude-opus-4-20250514') { $selected_model = 'claude-opus-4-8'; }
7916 elseif ($selected_model === 'claude-sonnet-4-20250514') { $selected_model = 'claude-sonnet-4-6'; }
7917 if ($selected_model === 'openrouter') {
7918 $model = isset($opts['openrouter_selected_model']) ? $opts['openrouter_selected_model'] : '';
7919 $key = isset($opts['openrouter_api_key']) ? $opts['openrouter_api_key'] : '';
7920 if ($model === '' || $key === '') return null;
7921 return array('family'=>'openai','model'=>$model,'url'=>'https://openrouter.ai/api/v1/chat/completions',
7922 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'openai');
7923 }
7924 $prefix = strtolower(explode('-', $selected_model)[0]);
7925 switch ($prefix) {
7926 case 'gpt': case 'o1': case 'o3': case 'o4':
7927 $key = isset($opts['api_key']) ? $opts['api_key'] : '';
7928 if ($key === '') return null;
7929 return array('family'=>'openai','model'=>$selected_model,'url'=>'https://api.openai.com/v1/chat/completions',
7930 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'openai');
7931 case 'claude':
7932 $key = isset($opts['claude_api_key']) ? $opts['claude_api_key'] : '';
7933 if ($key === '') return null;
7934 return array('family'=>'anthropic','model'=>$selected_model,'url'=>'https://api.anthropic.com/v1/messages',
7935 'headers'=>array('Content-Type'=>'application/json','x-api-key'=>$key,'anthropic-version'=>'2023-06-01'),'tag'=>'anthropic');
7936 case 'gemini':
7937 $key = isset($opts['gemini_api_key']) ? $opts['gemini_api_key'] : '';
7938 if ($key === '') return null;
7939 return array('family'=>'gemini','model'=>$selected_model,'key'=>$key,'tag'=>'gemini');
7940 case 'grok': case 'xai':
7941 $key = isset($opts['xai_api_key']) ? $opts['xai_api_key'] : '';
7942 if ($key === '') return null;
7943 return array('family'=>'openai','model'=>$selected_model,'url'=>'https://api.x.ai/v1/chat/completions',
7944 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'xai');
7945 case 'deepseek':
7946 $key = isset($opts['deepseek_api_key']) ? $opts['deepseek_api_key'] : '';
7947 if ($key === '') return null;
7948 return array('family'=>'openai','model'=>$selected_model,'url'=>'https://api.deepseek.com/v1/chat/completions',
7949 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'openai');
7950 case 'custom':
7951 $base = isset($opts['custom_provider_base_url']) ? rtrim($opts['custom_provider_base_url'], '/') : '';
7952 $key = isset($opts['custom_provider_api_key']) ? $opts['custom_provider_api_key'] : '';
7953 $model = isset($opts['custom_provider_model']) ? $opts['custom_provider_model'] : '';
7954 if ($base === '' || $model === '') return null;
7955 $url = (strpos($base, 'chat/completions') !== false) ? $base : $base . '/chat/completions';
7956 $headers = array('Content-Type'=>'application/json');
7957 if ($key !== '') $headers['Authorization'] = 'Bearer '.$key;
7958 return array('family'=>'openai','model'=>$model,'url'=>$url,'headers'=>$headers,'tag'=>'openai');
7959 }
7960 return null;
7961 }
7962
7963 /**
7964 * Top-level function-calling attempt. Returns:
7965 * ['handled'=>true, 'text'=>'<final answer>'] when the model used ≥1 tool
7966 * ['handled'=>false] otherwise (caller falls back
7967 * to the normal streamed path)
7968 */
7969 private function mxchat_fc_attempt($message, $relevant_content, $conversation_history, $selected_model, $opts, $session_id, $user_id) {
7970 $prov = $this->mxchat_fc_resolve_provider($selected_model, $opts);
7971 if (!$prov) {
7972 return array('handled' => false);
7973 }
7974 $tools = MxChat_Tool_Registry::enabled_tools();
7975 if (empty($tools)) {
7976 return array('handled' => false);
7977 }
7978
7979 $bot_id = $this->get_current_bot_id($session_id);
7980 $system = $this->get_system_instructions($bot_id, $session_id);
7981
7982 // Force callbacks into return-mode (some echo SSE directly when streaming);
7983 // we buffer the whole tool round, then emit once. Restored in finally.
7984 $prev_streaming = $this->is_streaming;
7985 $this->is_streaming = false;
7986 try {
7987 if ($prov['family'] === 'anthropic') {
7988 return $this->mxchat_fc_loop_anthropic($prov, $system, $relevant_content, $conversation_history, $tools, $message, $user_id, $session_id);
7989 } elseif ($prov['family'] === 'gemini') {
7990 return $this->mxchat_fc_loop_gemini($prov, $system, $relevant_content, $conversation_history, $tools, $message, $user_id, $session_id);
7991 }
7992 return $this->mxchat_fc_loop_openai($prov, $system, $relevant_content, $conversation_history, $tools, $message, $user_id, $session_id);
7993 } catch (\Throwable $e) {
7994 $this->mxchat_fc_log('attempt threw: ' . $e->getMessage());
7995 return array('handled' => false);
7996 } finally {
7997 $this->is_streaming = $prev_streaming;
7998 }
7999 }
8000
8001 /** Normalize MxChat history rows to [{role:user|assistant, content}]. */
8002 private function mxchat_fc_normalize_history($conversation_history) {
8003 $out = array();
8004 if (!is_array($conversation_history)) return $out;
8005 foreach ($conversation_history as $m) {
8006 if (!is_array($m) || !isset($m['role']) || !isset($m['content'])) continue;
8007 $role = $m['role'];
8008 if ($role === 'bot' || $role === 'agent') $role = 'assistant';
8009 if (!in_array($role, array('user', 'assistant'), true)) $role = 'user';
8010 $out[] = array('role' => $role, 'content' => (string) $m['content']);
8011 }
8012 return $out;
8013 }
8014
8015 /** Execute the matched callback for a tool call. Returns ['ok'=>bool,'content'=>string]. */
8016 private function mxchat_fc_execute_tool($tool_name, $args, $orig_message, $user_id, $session_id) {
8017 $tool = MxChat_Tool_Registry::tool_by_name($tool_name, true); // enabled-only
8018 if (!$tool) {
8019 return array('ok' => false, 'content' => 'This tool is not available or not enabled.');
8020 }
8021 $fn = $tool['callback'];
8022
8023 // MxChat callbacks are message-driven: hand them the model's `query`
8024 // (falling back to the original user message).
8025 $query = '';
8026 if (is_array($args) && isset($args['query']) && is_string($args['query'])) {
8027 $query = $args['query'];
8028 }
8029 if ($query === '') $query = $orig_message;
8030
8031 // Synthetic intent row (matches wp_mxchat_intents columns → no undefined-prop warnings).
8032 $synthetic_intent = (object) array(
8033 'id' => 0, 'intent_label' => $tool['label'], 'phrases' => '',
8034 'embedding_vector' => '', 'callback_function' => $fn,
8035 'similarity_threshold' => 0.0, 'enabled' => 1, 'enabled_bots' => null,
8036 );
8037
8038 try {
8039 if (!empty($tool['is_addon'])) {
8040 $result = apply_filters($fn, false, $query, $user_id, $session_id, $synthetic_intent);
8041 } elseif (method_exists($this, $fn)) {
8042 $result = call_user_func(array($this, $fn), $query, $user_id, $session_id, $synthetic_intent, null);
8043 } else {
8044 return array('ok' => false, 'content' => 'Tool implementation not found.');
8045 }
8046 } catch (\Throwable $e) {
8047 $this->mxchat_fc_log("tool {$fn} threw: " . $e->getMessage());
8048 return array('ok' => false, 'content' => 'The tool failed to run.');
8049 }
8050
8051 // plan-mxchat-20260617-48a57a — surface UI-bearing tool output.
8052 // If the callback produced a UI element (generated image, product card, image
8053 // gallery), its html MUST reach the FRONTEND as a real rendered bot message —
8054 // NOT be stripped to text and handed to the model to paraphrase (that was the
8055 // bug: under function calling, UI-bearing actions rendered nothing). Capture
8056 // the html here; the FC outcome handler emits it in the response envelope.
8057 $ui = $this->mxchat_fc_ui_payload_from($result);
8058 if ($ui['html'] !== '' || !empty($ui['images'])) {
8059 if ($ui['html'] !== '') {
8060 $this->fc_ui_html .= ($this->fc_ui_html !== '' ? "\n" : '') . $ui['html'];
8061 }
8062 if (!empty($ui['images']) && is_array($ui['images'])) {
8063 $this->fc_ui_images = array_merge($this->fc_ui_images, $ui['images']);
8064 }
8065 $this->fc_ui_captured = true;
8066
8067 // Persist the html to the transcript ONLY if the callback did not already
8068 // do so itself. Core image/search callbacks self-save (text + html);
8069 // add-on callbacks (e.g. woo product cards) return html for the caller to
8070 // save. ui_self_saves carries this from the registry; default by source
8071 // (core self-saves, add-on does not) when a tool predates the flag.
8072 $self_saves = array_key_exists('ui_self_saves', $tool)
8073 ? !empty($tool['ui_self_saves'])
8074 : empty($tool['is_addon']);
8075 if ($ui['html'] !== '' && !$self_saves) {
8076 $this->mxchat_save_chat_message($session_id, 'bot', $ui['html']);
8077 }
8078
8079 // Hand the MODEL a short acknowledgment (never the raw or stripped html)
8080 // so the loop can add a one-line caption without trying to re-describe a
8081 // visual it cannot see and without duplicating the displayed element.
8082 $summary = isset($ui['text']) ? trim((string) $ui['text']) : '';
8083 $ack = __('[A visual result has already been shown to the user in the chat. Do not repeat or describe it in detail — reply with at most a brief one-line caption.]', 'mxchat');
8084 $content = $summary !== '' ? ($ack . ' ' . $summary) : $ack;
8085 $this->mxchat_fc_log("executed {$fn} → [ui payload surfaced] " . substr($content, 0, 120));
8086 return array('ok' => true, 'content' => $content);
8087 }
8088
8089 $content = $this->mxchat_fc_stringify_result($result);
8090 $this->mxchat_fc_log("executed {$fn} → " . substr($content, 0, 160));
8091 return array('ok' => true, 'content' => $content);
8092 }
8093
8094 /**
8095 * Extract a UI payload (html + images + text) from a tool callback's return,
8096 * falling back to $this->fallbackResponse for callbacks that return true after
8097 * setting it. plan-mxchat-20260617-48a57a.
8098 *
8099 * @return array{html:string,images:array,text:string}
8100 */
8101 private function mxchat_fc_ui_payload_from($result) {
8102 $src = null;
8103 if (is_array($result)) {
8104 $src = $result;
8105 } elseif ($result === true && isset($this->fallbackResponse) && is_array($this->fallbackResponse)) {
8106 $src = $this->fallbackResponse;
8107 }
8108 $html = (is_array($src) && isset($src['html']) && is_string($src['html'])) ? $src['html'] : '';
8109 $images = (is_array($src) && isset($src['images']) && is_array($src['images'])) ? $src['images'] : array();
8110 $text = (is_array($src) && isset($src['text'])) ? (string) $src['text'] : '';
8111 return array('html' => $html, 'images' => $images, 'text' => $text);
8112 }
8113
8114 /** Coerce a callback's return (string|array|true|false) into a tool-result string. */
8115 private function mxchat_fc_stringify_result($result) {
8116 if (is_string($result)) {
8117 return $result === '' ? 'No result.' : $result;
8118 }
8119 if ($result === true) {
8120 // Callbacks that set fallbackResponse and return true.
8121 $fb = isset($this->fallbackResponse) ? $this->fallbackResponse : null;
8122 if (is_array($fb)) {
8123 if (!empty($fb['text'])) return (string) $fb['text'];
8124 if (!empty($fb['html'])) return wp_strip_all_tags((string) $fb['html']);
8125 }
8126 return 'Done.';
8127 }
8128 if ($result === false || $result === null) {
8129 return 'No result.';
8130 }
8131 if (is_array($result)) {
8132 if (isset($result['text']) && $result['text'] !== '') return (string) $result['text'];
8133 if (isset($result['html']) && $result['html'] !== '') return wp_strip_all_tags((string) $result['html']);
8134 $json = wp_json_encode($result);
8135 return $json !== false ? $json : 'No result.';
8136 }
8137 return (string) $result;
8138 }
8139
8140 /** HTTP code + decoded body for a function-calling request. */
8141 private function mxchat_fc_post($url, $body, $headers, $tag) {
8142 $args = array(
8143 'body' => wp_json_encode($body),
8144 'headers' => $headers,
8145 'timeout' => 60,
8146 'redirection' => 5,
8147 'blocking' => true,
8148 'httpversion' => '1.0',
8149 'sslverify' => true,
8150 );
8151 $response = $this->mxchat_provider_call_with_retry($url, $args, $tag);
8152 if (is_wp_error($response)) {
8153 return array('code' => 0, 'data' => null, 'error' => $response->get_error_message());
8154 }
8155 $code = (int) wp_remote_retrieve_response_code($response);
8156 $data = json_decode(wp_remote_retrieve_body($response), true);
8157 return array('code' => $code, 'data' => $data, 'error' => null);
8158 }
8159
8160 /* ---------------- OpenAI-compatible loop (OpenAI/xAI/DeepSeek/OpenRouter/Custom) -------------- */
8161 private function mxchat_fc_loop_openai($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) {
8162 $messages = array();
8163 $messages[] = array('role' => 'system', 'content' => $system . ' ' . $relevant_content);
8164 foreach ($this->mxchat_fc_normalize_history($conversation_history) as $m) {
8165 $messages[] = $m;
8166 }
8167
8168 $depth = MxChat_Tool_Registry::max_depth();
8169 $budget = MxChat_Tool_Registry::max_tool_calls_per_turn();
8170 $tool_schema = MxChat_Tool_Registry::to_openai_tools($tools);
8171 $used_tool = false;
8172 $calls_made = 0;
8173
8174 for ($step = 0; $step <= $depth; $step++) {
8175 $offer_tools = ($step < $depth) && !empty($tool_schema);
8176 $body = array('model' => $prov['model'], 'messages' => $messages, 'temperature' => 1, 'stream' => false);
8177 if (strpos($prov['url'], 'api.deepseek.com') !== false) {
8178 // DeepSeek V4 defaults to thinking mode ON; tool loops want fast
8179 // deterministic non-thinking turns (legacy deepseek-chat semantics).
8180 $body['thinking'] = array('type' => 'disabled');
8181 }
8182 if ($offer_tools) {
8183 $body['tools'] = $tool_schema;
8184 $body['tool_choice'] = 'auto';
8185 }
8186 $r = $this->mxchat_fc_post($prov['url'], $body, $prov['headers'], $prov['tag']);
8187 if ($r['code'] !== 200 || !is_array($r['data'])) {
8188 $this->mxchat_fc_log('openai call failed: code=' . $r['code'] . ' err=' . ($r['error'] ?? ''));
8189 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8190 }
8191 $msg = isset($r['data']['choices'][0]['message']) ? $r['data']['choices'][0]['message'] : null;
8192 if (!$msg) {
8193 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8194 }
8195 $tool_calls = isset($msg['tool_calls']) && is_array($msg['tool_calls']) ? $msg['tool_calls'] : array();
8196 if (empty($tool_calls)) {
8197 $text = isset($msg['content']) ? trim((string) $msg['content']) : '';
8198 if (!$used_tool) return array('handled' => false); // model never used a tool → normal path
8199 return array('handled' => true, 'text' => ($text !== '' ? $text : $this->mxchat_fc_giveup_text()));
8200 }
8201 // Append the assistant tool-call turn verbatim, then a tool result per call.
8202 $used_tool = true;
8203 $messages[] = $msg;
8204 foreach ($tool_calls as $tc) {
8205 if ($calls_made >= $budget) break;
8206 $calls_made++;
8207 $name = isset($tc['function']['name']) ? $tc['function']['name'] : '';
8208 $args = array();
8209 if (isset($tc['function']['arguments'])) {
8210 $decoded = json_decode($tc['function']['arguments'], true);
8211 if (is_array($decoded)) $args = $decoded;
8212 }
8213 $exec = $this->mxchat_fc_execute_tool($name, $args, $orig_message, $user_id, $session_id);
8214 $messages[] = array(
8215 'role' => 'tool',
8216 'tool_call_id' => isset($tc['id']) ? $tc['id'] : '',
8217 'content' => $exec['content'],
8218 );
8219 }
8220 }
8221 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8222 }
8223
8224 /* ---------------- Anthropic Claude loop ---------------- */
8225 private function mxchat_fc_loop_anthropic($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) {
8226 $messages = $this->mxchat_fc_normalize_history($conversation_history);
8227 $messages[] = array('role' => 'user', 'content' => $relevant_content);
8228
8229 $depth = MxChat_Tool_Registry::max_depth();
8230 $budget = MxChat_Tool_Registry::max_tool_calls_per_turn();
8231 $tool_schema = MxChat_Tool_Registry::to_anthropic_tools($tools);
8232 $omit_temp = $this->mxchat_claude_omits_temperature($prov['model']);
8233 $used_tool = false;
8234 $calls_made = 0;
8235
8236 for ($step = 0; $step <= $depth; $step++) {
8237 $offer_tools = ($step < $depth) && !empty($tool_schema);
8238 $body = array('model' => $prov['model'], 'max_tokens' => 1024, 'temperature' => 0.8,
8239 'messages' => $messages, 'system' => $system);
8240 if ($omit_temp) unset($body['temperature']);
8241 if ($offer_tools) {
8242 $body['tools'] = $tool_schema;
8243 $body['tool_choice'] = array('type' => 'auto');
8244 }
8245 $r = $this->mxchat_fc_post($prov['url'], $body, $prov['headers'], $prov['tag']);
8246 if ($r['code'] !== 200 || !is_array($r['data'])) {
8247 $this->mxchat_fc_log('anthropic call failed: code=' . $r['code'] . ' err=' . ($r['error'] ?? ''));
8248 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8249 }
8250 $content = isset($r['data']['content']) && is_array($r['data']['content']) ? $r['data']['content'] : array();
8251 $tool_uses = array();
8252 $text_out = '';
8253 foreach ($content as $block) {
8254 if (!isset($block['type'])) continue;
8255 if ($block['type'] === 'tool_use') {
8256 $tool_uses[] = $block;
8257 } elseif ($block['type'] === 'text' && isset($block['text'])) {
8258 $text_out .= $block['text'];
8259 }
8260 }
8261 if (empty($tool_uses)) {
8262 if (!$used_tool) return array('handled' => false);
8263 $text_out = trim($text_out);
8264 return array('handled' => true, 'text' => ($text_out !== '' ? $text_out : $this->mxchat_fc_giveup_text()));
8265 }
8266 // Append the assistant turn (the full content array), then a user turn of tool_result blocks.
8267 $used_tool = true;
8268 $messages[] = array('role' => 'assistant', 'content' => $content);
8269 $results = array();
8270 foreach ($tool_uses as $tu) {
8271 if ($calls_made >= $budget) break;
8272 $calls_made++;
8273 $name = isset($tu['name']) ? $tu['name'] : '';
8274 $args = isset($tu['input']) && is_array($tu['input']) ? $tu['input'] : array();
8275 $exec = $this->mxchat_fc_execute_tool($name, $args, $orig_message, $user_id, $session_id);
8276 $results[] = array(
8277 'type' => 'tool_result',
8278 'tool_use_id' => isset($tu['id']) ? $tu['id'] : '',
8279 'content' => $exec['content'],
8280 );
8281 }
8282 $messages[] = array('role' => 'user', 'content' => $results);
8283 }
8284 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8285 }
8286
8287 /* ---------------- Google Gemini loop ---------------- */
8288 private function mxchat_fc_loop_gemini($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) {
8289 $contents = array();
8290 $contents[] = array('role' => 'user', 'parts' => array(array('text' => '[System Instructions] ' . $system . ' ' . $relevant_content)));
8291 $contents[] = array('role' => 'model', 'parts' => array(array('text' => 'I understand and will follow these instructions.')));
8292 foreach ($this->mxchat_fc_normalize_history($conversation_history) as $m) {
8293 $contents[] = array('role' => ($m['role'] === 'assistant' ? 'model' : 'user'),
8294 'parts' => array(array('text' => $m['content'])));
8295 }
8296
8297 $depth = MxChat_Tool_Registry::max_depth();
8298 $budget = MxChat_Tool_Registry::max_tool_calls_per_turn();
8299 $tool_schema = MxChat_Tool_Registry::to_gemini_tools($tools);
8300 // Function calling (tools + functionDeclarations + toolConfig) is a v1beta feature on the
8301 // Generative Language REST API. The v1 endpoint silently ignores the tools array, so a
8302 // non-preview model (e.g. gemini-2.5-pro, gemini-3.5-flash, gemini-3.1-flash-lite) would
8303 // just answer in text and never emit a tool call. Always use v1beta for the FC loop —
8304 // confirmed against Google's function-calling docs (their REST example targets
8305 // v1beta/models/gemini-3.5-flash:generateContent). v1beta is a superset, so every model
8306 // reachable on v1 is also reachable here.
8307 $api_version = 'v1beta';
8308 $url = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $prov['model'] . ':generateContent?key=' . $prov['key'];
8309 $headers = array('Content-Type' => 'application/json');
8310 $used_tool = false;
8311 $calls_made = 0;
8312
8313 for ($step = 0; $step <= $depth; $step++) {
8314 $offer_tools = ($step < $depth) && !empty($tool_schema);
8315 $body = array(
8316 'contents' => $contents,
8317 'generationConfig' => array('temperature' => 0.7, 'topP' => 0.95, 'topK' => 40, 'maxOutputTokens' => 8192),
8318 );
8319 if ($offer_tools) {
8320 $body['tools'] = $tool_schema;
8321 $body['toolConfig'] = array('functionCallingConfig' => array('mode' => 'AUTO'));
8322 }
8323 $r = $this->mxchat_fc_post($url, $body, $headers, 'gemini');
8324 if ($r['code'] !== 200 || !is_array($r['data']) || isset($r['data']['error'])) {
8325 $this->mxchat_fc_log('gemini call failed: code=' . $r['code'] . ' err=' . ($r['error'] ?? ''));
8326 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8327 }
8328 $parts = isset($r['data']['candidates'][0]['content']['parts']) && is_array($r['data']['candidates'][0]['content']['parts'])
8329 ? $r['data']['candidates'][0]['content']['parts'] : array();
8330 $fn_calls = array();
8331 $text_out = '';
8332 foreach ($parts as $p) {
8333 if (isset($p['functionCall'])) {
8334 $fn_calls[] = $p['functionCall'];
8335 } elseif (isset($p['text'])) {
8336 $text_out .= $p['text'];
8337 }
8338 }
8339 if (empty($fn_calls)) {
8340 if (!$used_tool) return array('handled' => false);
8341 $text_out = trim($text_out);
8342 return array('handled' => true, 'text' => ($text_out !== '' ? $text_out : $this->mxchat_fc_giveup_text()));
8343 }
8344 // Append the model turn (its parts) then a user turn of functionResponse parts.
8345 $used_tool = true;
8346 $contents[] = array('role' => 'model', 'parts' => $parts);
8347 $resp_parts = array();
8348 foreach ($fn_calls as $fcall) {
8349 if ($calls_made >= $budget) break;
8350 $calls_made++;
8351 $name = isset($fcall['name']) ? $fcall['name'] : '';
8352 $args = isset($fcall['args']) && is_array($fcall['args']) ? $fcall['args'] : array();
8353 $exec = $this->mxchat_fc_execute_tool($name, $args, $orig_message, $user_id, $session_id);
8354 $fr = array('name' => $name, 'response' => array('result' => $exec['content']));
8355 // Gemini 3 function calls carry a unique id; echo the matching id back in the
8356 // functionResponse so the model maps the result to the right call (Google REST
8357 // guidance). Older models omit the id — then we send none, exactly as before.
8358 if (isset($fcall['id']) && $fcall['id'] !== '') { $fr['id'] = $fcall['id']; }
8359 $resp_parts[] = array('functionResponse' => $fr);
8360 }
8361 $contents[] = array('role' => 'user', 'parts' => $resp_parts);
8362 }
8363 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8364 }
8365
8366 private function mxchat_fc_giveup_text() {
8367 return esc_html__('I looked into that but could not put together a final answer. Please try rephrasing your request.', 'mxchat');
8368 }
8369
8370 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') {
8371 try {
8372 if (!$relevant_content) {
8373 $error_response = [
8374 'error' => esc_html__("I couldn't find relevant information on that topic.", 'mxchat'),
8375 'error_code' => 'no_relevant_content'
8376 ];
8377
8378 if ($testing_data !== null) {
8379 $error_response['testing_data'] = $testing_data;
8380 }
8381
8382 return $error_response;
8383 }
8384
8385 if (!is_array($conversation_history)) {
8386 $conversation_history = array();
8387 }
8388
8389 // Check if this is an OpenRouter model
8390 if ($selected_model === 'openrouter') {
8391 // Get the actual OpenRouter model from options
8392 $openrouter_selected_model = $this->options['openrouter_selected_model'] ?? '';
8393
8394 if (empty($openrouter_selected_model)) {
8395 $error_response = [
8396 'error' => esc_html__('No OpenRouter model selected. Please select a model in settings.', 'mxchat'),
8397 'error_code' => 'no_openrouter_model_selected'
8398 ];
8399 if ($testing_data !== null) {
8400 $error_response['testing_data'] = $testing_data;
8401 }
8402 return $error_response;
8403 }
8404
8405 if (empty($openrouter_api_key)) {
8406 $error_response = [
8407 'error' => esc_html__('OpenRouter API key is not configured', 'mxchat'),
8408 'error_code' => 'missing_openrouter_api_key'
8409 ];
8410 if ($testing_data !== null) {
8411 $error_response['testing_data'] = $testing_data;
8412 }
8413 return $error_response;
8414 }
8415
8416 if ($streaming) {
8417 return $this->mxchat_generate_response_openrouter_stream(
8418 $openrouter_selected_model,
8419 $openrouter_api_key,
8420 $conversation_history,
8421 $relevant_content,
8422 $session_id,
8423 $testing_data
8424 );
8425 } else {
8426 $response = $this->mxchat_generate_response_openrouter(
8427 $openrouter_selected_model,
8428 $openrouter_api_key,
8429 $conversation_history,
8430 $relevant_content,
8431 $session_id
8432 );
8433 }
8434
8435 if (is_array($response) && isset($response['error'])) {
8436 if ($testing_data !== null) {
8437 $response['testing_data'] = $testing_data;
8438 }
8439 return $response;
8440 }
8441
8442 return $response;
8443 }
8444
8445 // Extract model prefix to determine the provider
8446 $model_parts = explode('-', $selected_model);
8447 $provider = strtolower($model_parts[0]);
8448
8449 // Handle model selection based on provider prefix
8450 switch ($provider) {
8451 case 'gemini':
8452 if (empty($gemini_api_key)) {
8453 $error_response = [
8454 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
8455 'error_code' => 'missing_gemini_api_key'
8456 ];
8457 if ($testing_data !== null) {
8458 $error_response['testing_data'] = $testing_data;
8459 }
8460 return $error_response;
8461 }
8462 $response = $this->mxchat_generate_response_gemini(
8463 $selected_model,
8464 $gemini_api_key,
8465 $conversation_history,
8466 $relevant_content,
8467 $session_id
8468 );
8469 break;
8470
8471 case 'claude':
8472 if (empty($claude_api_key)) {
8473 $error_response = [
8474 'error' => esc_html__('Claude API key is not configured', 'mxchat'),
8475 'error_code' => 'missing_claude_api_key'
8476 ];
8477 if ($testing_data !== null) {
8478 $error_response['testing_data'] = $testing_data;
8479 }
8480 return $error_response;
8481 }
8482 if ($streaming) {
8483 return $this->mxchat_generate_response_claude_stream(
8484 $selected_model,
8485 $claude_api_key,
8486 $conversation_history,
8487 $relevant_content,
8488 $session_id,
8489 $testing_data
8490 );
8491 } else {
8492 $response = $this->mxchat_generate_response_claude(
8493 $selected_model,
8494 $claude_api_key,
8495 $conversation_history,
8496 $relevant_content,
8497 $session_id
8498 );
8499 }
8500 break;
8501
8502 case 'grok':
8503 if (empty($xai_api_key)) {
8504 $error_response = [
8505 'error' => esc_html__('X.AI API key is not configured', 'mxchat'),
8506 'error_code' => 'missing_xai_api_key'
8507 ];
8508 if ($testing_data !== null) {
8509 $error_response['testing_data'] = $testing_data;
8510 }
8511 return $error_response;
8512 }
8513 if ($streaming) {
8514 return $this->mxchat_generate_response_xai_stream(
8515 $selected_model,
8516 $xai_api_key,
8517 $conversation_history,
8518 $relevant_content,
8519 $session_id,
8520 $testing_data
8521 );
8522 } else {
8523 $response = $this->mxchat_generate_response_xai(
8524 $selected_model,
8525 $xai_api_key,
8526 $conversation_history,
8527 $relevant_content,
8528 $session_id
8529 );
8530 }
8531 break;
8532
8533 case 'deepseek':
8534 if (empty($deepseek_api_key)) {
8535 $error_response = [
8536 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
8537 'error_code' => 'missing_deepseek_api_key'
8538 ];
8539 if ($testing_data !== null) {
8540 $error_response['testing_data'] = $testing_data;
8541 }
8542 return $error_response;
8543 }
8544 if ($streaming) {
8545 return $this->mxchat_generate_response_deepseek_stream(
8546 $selected_model,
8547 $deepseek_api_key,
8548 $conversation_history,
8549 $relevant_content,
8550 $session_id,
8551 $testing_data
8552 );
8553 } else {
8554 $response = $this->mxchat_generate_response_deepseek(
8555 $selected_model,
8556 $deepseek_api_key,
8557 $conversation_history,
8558 $relevant_content,
8559 $session_id
8560 );
8561 }
8562 break;
8563
8564 case 'custom':
8565 // Custom (OpenAI-compatible) provider — Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI
8566 $cp_base_url = isset($this->options['custom_provider_base_url']) ? trim((string) $this->options['custom_provider_base_url']) : '';
8567 if (empty($cp_base_url)) {
8568 $error_response = [
8569 'error' => esc_html__('Custom provider is not configured. Set Base URL in MxChat → API Keys → Custom Provider.', 'mxchat'),
8570 'error_code' => 'missing_custom_provider_base_url'
8571 ];
8572 if ($testing_data !== null) {
8573 $error_response['testing_data'] = $testing_data;
8574 }
8575 return $error_response;
8576 }
8577 if ($streaming) {
8578 return $this->mxchat_generate_response_custom_stream(
8579 $selected_model,
8580 $conversation_history,
8581 $relevant_content,
8582 $session_id,
8583 $testing_data
8584 );
8585 } else {
8586 $response = $this->mxchat_generate_response_custom(
8587 $selected_model,
8588 $conversation_history,
8589 $relevant_content
8590 );
8591 }
8592 break;
8593
8594 case 'gpt':
8595 case 'o1':
8596 if (empty($api_key)) {
8597 $error_response = [
8598 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
8599 'error_code' => 'missing_openai_api_key'
8600 ];
8601 if ($testing_data !== null) {
8602 $error_response['testing_data'] = $testing_data;
8603 }
8604 return $error_response;
8605 }
8606
8607 // Check if web search is enabled for this OpenAI model
8608 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
8609 // Models that don't support web search
8610 $unsupported_web_search_models = array('gpt-4.1-nano');
8611 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
8612
8613 if ($web_search_enabled && $model_supports_web_search) {
8614 // Use Responses API (required for some models, or when web search is enabled)
8615 return $this->mxchat_generate_response_openai_web_search(
8616 $selected_model,
8617 $api_key,
8618 $conversation_history,
8619 $relevant_content,
8620 $session_id,
8621 $testing_data,
8622 $streaming
8623 );
8624 } elseif ($streaming) {
8625 return $this->mxchat_generate_response_openai_stream(
8626 $selected_model,
8627 $api_key,
8628 $conversation_history,
8629 $relevant_content,
8630 $session_id,
8631 $testing_data
8632 );
8633 } else {
8634 $response = $this->mxchat_generate_response_openai(
8635 $selected_model,
8636 $api_key,
8637 $conversation_history,
8638 $relevant_content,
8639 $session_id
8640 );
8641 }
8642 break;
8643
8644 default:
8645 if (empty($api_key)) {
8646 $error_response = [
8647 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
8648 'error_code' => 'missing_openai_api_key'
8649 ];
8650 if ($testing_data !== null) {
8651 $error_response['testing_data'] = $testing_data;
8652 }
8653 return $error_response;
8654 }
8655
8656 // Check if web search is enabled (default case also handles OpenAI models)
8657 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
8658 $unsupported_web_search_models = array('gpt-4.1-nano');
8659 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
8660
8661 if ($web_search_enabled && $model_supports_web_search) {
8662 return $this->mxchat_generate_response_openai_web_search(
8663 $selected_model,
8664 $api_key,
8665 $conversation_history,
8666 $relevant_content,
8667 $session_id,
8668 $testing_data,
8669 $streaming
8670 );
8671 } elseif ($streaming) {
8672 return $this->mxchat_generate_response_openai_stream(
8673 $selected_model,
8674 $api_key,
8675 $conversation_history,
8676 $relevant_content,
8677 $session_id,
8678 $testing_data
8679 );
8680 } else {
8681 $response = $this->mxchat_generate_response_openai(
8682 $selected_model,
8683 $api_key,
8684 $conversation_history,
8685 $relevant_content,
8686 $session_id
8687 );
8688 }
8689 break;
8690 }
8691
8692 if (is_array($response) && isset($response['error'])) {
8693 if ($testing_data !== null) {
8694 $response['testing_data'] = $testing_data;
8695 }
8696 return $response;
8697 }
8698
8699 return $response;
8700
8701 } catch (Exception $e) {
8702 $error_response = [
8703 'error' => sprintf(esc_html__('An error occurred: %s', 'mxchat'), esc_html($e->getMessage())),
8704 'error_code' => 'system_exception',
8705 'exception_details' => $e->getMessage()
8706 ];
8707
8708 if ($testing_data !== null) {
8709 $error_response['testing_data'] = $testing_data;
8710 }
8711
8712 return $error_response;
8713 }
8714 }
8715 private function mxchat_generate_response_openrouter_stream($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
8716 try {
8717 $bot_id = $this->get_current_bot_id($session_id);
8718 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8719
8720 if (!is_array($conversation_history)) {
8721 $conversation_history = array();
8722 }
8723
8724 $formatted_conversation = array();
8725
8726 $formatted_conversation[] = array(
8727 'role' => 'system',
8728 'content' => $system_prompt_instructions . " " . $relevant_content
8729 );
8730
8731 foreach ($conversation_history as $message) {
8732 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8733 $role = $message['role'];
8734 if ($role === 'bot' || $role === 'agent') {
8735 $role = 'assistant';
8736 }
8737 if (!in_array($role, ['system', 'assistant', 'user'])) {
8738 $role = 'user';
8739 }
8740 $formatted_conversation[] = array(
8741 'role' => $role,
8742 'content' => $message['content']
8743 );
8744 }
8745 }
8746
8747 if (headers_sent() || !function_exists('curl_init')) {
8748 $regular_response = $this->mxchat_generate_response_openrouter(
8749 $selected_model,
8750 $openrouter_api_key,
8751 $conversation_history,
8752 $relevant_content,
8753 $session_id
8754 );
8755
8756 // Save bot response to transcript
8757 if (!empty($regular_response) && !empty($session_id)) {
8758 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
8759 }
8760
8761 $response_data = [
8762 'text' => $regular_response,
8763 'html' => '',
8764 'session_id' => $session_id
8765 ];
8766
8767 if ($testing_data !== null) {
8768 $response_data['testing_data'] = $testing_data;
8769 }
8770
8771 header('Content-Type: application/json');
8772 echo json_encode($response_data);
8773 return true;
8774 }
8775
8776 $body = json_encode([
8777 'model' => $selected_model,
8778 'messages' => $formatted_conversation,
8779 'temperature' => 1,
8780 'stream' => true
8781 ]);
8782
8783 // V2 retry-on-initial-connect: setup_streaming_headers is now lazy-fired
8784 // inside WRITEFUNCTION on first byte of a successful upstream.
8785
8786 $captured_status_code = 0;
8787 $captured_body_pre_stream = '';
8788 $full_response = '';
8789 $stream_started = false;
8790 $buffer = '';
8791 $errno = 0;
8792 $last_curl_error = '';
8793 $http_code = 0;
8794 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
8795 $backoff_ms = array(0, 750, 2000);
8796
8797 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
8798 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
8799 usleep($backoff_ms[$attempt] * 1000);
8800 }
8801
8802 $captured_status_code = 0;
8803 $captured_body_pre_stream = '';
8804 $full_response = '';
8805 $stream_started = false;
8806 $buffer = '';
8807
8808 $ch = curl_init();
8809 curl_setopt($ch, CURLOPT_URL, 'https://openrouter.ai/api/v1/chat/completions');
8810 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
8811 curl_setopt($ch, CURLOPT_POST, true);
8812 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
8813 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
8814 'Content-Type: application/json',
8815 'Authorization: Bearer ' . $openrouter_api_key,
8816 'HTTP-Referer: ' . home_url(),
8817 'X-Title: ' . get_bloginfo('name')
8818 ));
8819 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8820 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
8821
8822 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
8823 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
8824 $captured_status_code = (int) $m[1];
8825 }
8826 return strlen($header);
8827 });
8828
8829 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
8830 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
8831 $captured_body_pre_stream .= $data;
8832 return strlen($data);
8833 }
8834
8835 if (!$this->streaming_headers_sent) {
8836 $this->setup_streaming_headers();
8837 }
8838
8839 if (!$stream_started && $testing_data !== null) {
8840 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
8841 flush();
8842 $stream_started = true;
8843 }
8844
8845 $buffer .= $data;
8846 $lines = explode("\n", $buffer);
8847 $buffer = array_pop($lines);
8848
8849 foreach ($lines as $line) {
8850 if (trim($line) === '') {
8851 continue;
8852 }
8853 if (strpos($line, 'data: ') !== 0) {
8854 continue;
8855 }
8856
8857 $json_str = substr($line, 6);
8858
8859 if (trim($json_str) === '[DONE]') {
8860 echo "data: [DONE]\n\n";
8861 flush();
8862 continue;
8863 }
8864
8865 $json = json_decode(trim($json_str), true);
8866 if ($json && isset($json['choices'][0]['delta']['content'])) {
8867 $content = $json['choices'][0]['delta']['content'];
8868 $full_response .= $content;
8869
8870 echo "data: " . json_encode(['content' => $content]) . "\n\n";
8871 flush();
8872 }
8873 }
8874
8875 return strlen($data);
8876 });
8877
8878 $response = curl_exec($ch);
8879 $errno = curl_errno($ch);
8880 $last_curl_error = curl_error($ch);
8881 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
8882 curl_close($ch);
8883
8884 if (!$errno && $http_code === 200) {
8885 break;
8886 }
8887
8888 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
8889 $can_retry = !$this->streaming_headers_sent
8890 && ($attempt + 1) < $max_attempts
8891 && $is_transient;
8892
8893 if (defined('WP_DEBUG') && WP_DEBUG) {
8894 error_log(sprintf(
8895 '[MxChat] openrouter_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
8896 $attempt + 1, $max_attempts, $http_code, $errno,
8897 $is_transient ? 'yes' : 'no',
8898 $can_retry ? 'Retrying.' : 'Giving up.'
8899 ));
8900 }
8901
8902 if (!$can_retry) {
8903 break;
8904 }
8905 }
8906
8907 if (!$errno && $http_code === 200) {
8908 if (!empty($full_response) && !empty($session_id)) {
8909 $rag_context_for_storage = null;
8910 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
8911 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
8912
8913 if ($has_rag_data || $has_action_data) {
8914 $rag_context_for_storage = [];
8915
8916 if ($has_rag_data) {
8917 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
8918 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
8919 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
8920 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
8921 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
8922 }
8923
8924 if ($has_action_data) {
8925 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
8926 }
8927 }
8928 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
8929 }
8930 return true;
8931 }
8932
8933 return $this->mxchat_stream_emit_fallback(
8934 'openai',
8935 $this->mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id),
8936 $session_id,
8937 $testing_data
8938 );
8939
8940 } catch (Exception $e) {
8941 return $this->mxchat_stream_emit_fallback(
8942 'openai',
8943 $this->mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id),
8944 $session_id,
8945 $testing_data
8946 );
8947 }
8948 }
8949 private function mxchat_generate_response_openai_stream($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
8950 try {
8951 $bot_id = $this->get_current_bot_id($session_id);
8952
8953 // Get system prompt instructions using centralized function
8954 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8955
8956 // Ensure conversation_history is an array
8957 if (!is_array($conversation_history)) {
8958 $conversation_history = array();
8959 }
8960
8961 // Format conversation history for OpenAI
8962 $formatted_conversation = array();
8963
8964 $formatted_conversation[] = array(
8965 'role' => 'system',
8966 'content' => $system_prompt_instructions . " " . $relevant_content
8967 );
8968
8969 foreach ($conversation_history as $message) {
8970 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8971 $role = $message['role'];
8972 if ($role === 'bot' || $role === 'agent') {
8973 $role = 'assistant';
8974 }
8975 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8976 $role = 'user';
8977 }
8978 $formatted_conversation[] = array(
8979 'role' => $role,
8980 'content' => $message['content']
8981 );
8982 }
8983 }
8984
8985 // Check if we can actually stream
8986 if (headers_sent() || !function_exists('curl_init')) {
8987 // Fallback to regular response with testing data
8988 $regular_response = $this->mxchat_generate_response_openai(
8989 $selected_model,
8990 $api_key,
8991 $conversation_history,
8992 $relevant_content,
8993 $session_id
8994 );
8995
8996 // Save bot response to transcript
8997 if (!empty($regular_response) && !empty($session_id)) {
8998 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
8999 }
9000
9001 $response_data = [
9002 'text' => $regular_response,
9003 'html' => '',
9004 'session_id' => $session_id
9005 ];
9006
9007 if ($testing_data !== null) {
9008 $response_data['testing_data'] = $testing_data;
9009 }
9010
9011 header('Content-Type: application/json');
9012 echo json_encode($response_data);
9013 return true;
9014 }
9015
9016 // Build request body with optimal settings for fast streaming
9017 $request_body = [
9018 'model' => $selected_model,
9019 'messages' => $formatted_conversation,
9020 'temperature' => 1,
9021 'stream' => true
9022 ];
9023
9024 // reasoning_effort — sourced from the core model catalog (plan-dcb71c);
9025 // frozen inline ladder lives in mxchat_reasoning_effort_fallback().
9026 $effort = $this->mxchat_reasoning_effort_for($selected_model, 'chat');
9027 if ($effort !== null) {
9028 $request_body['reasoning_effort'] = $effort;
9029 }
9030
9031 $body = json_encode($request_body);
9032
9033 // V2 retry-on-initial-connect: do NOT call setup_streaming_headers() here.
9034 // It is now lazy-fired inside the WRITEFUNCTION on the first byte of a
9035 // SUCCESSFUL upstream response, gated by the captured HTTP status.
9036
9037 $captured_status_code = 0;
9038 $captured_body_pre_stream = '';
9039 $full_response = '';
9040 $stream_started = false;
9041 $buffer = '';
9042 $errno = 0;
9043 $last_curl_error = '';
9044 $http_code = 0;
9045 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
9046 $backoff_ms = array(0, 750, 2000);
9047
9048 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
9049 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
9050 usleep($backoff_ms[$attempt] * 1000);
9051 }
9052
9053 // Reset per-attempt capture state.
9054 $captured_status_code = 0;
9055 $captured_body_pre_stream = '';
9056 $full_response = '';
9057 $stream_started = false;
9058 $buffer = '';
9059
9060 $ch = curl_init();
9061 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
9062 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9063 curl_setopt($ch, CURLOPT_POST, true);
9064 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
9065 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
9066 'Content-Type: application/json',
9067 'Authorization: Bearer ' . $api_key
9068 ));
9069 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9070 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
9071
9072 // Capture HTTP status as soon as response headers arrive — fires before WRITEFUNCTION.
9073 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
9074 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
9075 $captured_status_code = (int) $m[1];
9076 }
9077 return strlen($header);
9078 });
9079
9080 // Buffer control for real-time streaming
9081 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
9082 // V2 guard: if upstream returned non-200, buffer body for transient
9083 // classification and DO NOT emit to client. Stream channel must NOT open.
9084 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
9085 $captured_body_pre_stream .= $data;
9086 return strlen($data);
9087 }
9088
9089 // Lazy-fire streaming headers on first byte of a SUCCESSFUL upstream.
9090 // After this point streaming_headers_sent === true → retry is structurally blocked.
9091 if (!$this->streaming_headers_sent) {
9092 $this->setup_streaming_headers();
9093 }
9094
9095 // Send testing data as the first event if available
9096 if (!$stream_started && $testing_data !== null) {
9097 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
9098 flush();
9099 $stream_started = true;
9100 }
9101
9102 // CRITICAL FIX: Append new data to buffer
9103 $buffer .= $data;
9104
9105 // Process complete lines only
9106 $lines = explode("\n", $buffer);
9107
9108 // CRITICAL FIX: Keep the last incomplete line in the buffer
9109 $buffer = array_pop($lines);
9110
9111 foreach ($lines as $line) {
9112 if (trim($line) === '') {
9113 continue;
9114 }
9115 if (strpos($line, 'data: ') !== 0) {
9116 continue;
9117 }
9118
9119 $json_str = substr($line, 6);
9120
9121 if (trim($json_str) === '[DONE]') {
9122 echo "data: [DONE]\n\n";
9123 flush();
9124 continue;
9125 }
9126
9127 $json = json_decode(trim($json_str), true);
9128 if ($json && isset($json['choices'][0]['delta']['content'])) {
9129 $content = $json['choices'][0]['delta']['content'];
9130 $full_response .= $content;
9131
9132 echo "data: " . json_encode(['content' => $content]) . "\n\n";
9133 flush();
9134 }
9135 }
9136
9137 return strlen($data);
9138 });
9139
9140 $response = curl_exec($ch);
9141 $errno = curl_errno($ch);
9142 $last_curl_error = curl_error($ch);
9143 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
9144 curl_close($ch);
9145
9146 if (!$errno && $http_code === 200) {
9147 break; // Happy path — WRITEFUNCTION already streamed everything.
9148 }
9149
9150 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
9151 $can_retry = !$this->streaming_headers_sent
9152 && ($attempt + 1) < $max_attempts
9153 && $is_transient;
9154
9155 if (defined('WP_DEBUG') && WP_DEBUG) {
9156 error_log(sprintf(
9157 '[MxChat] openai_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
9158 $attempt + 1, $max_attempts, $http_code, $errno,
9159 $is_transient ? 'yes' : 'no',
9160 $can_retry ? 'Retrying.' : 'Giving up.'
9161 ));
9162 }
9163
9164 if (!$can_retry) {
9165 break;
9166 }
9167 }
9168
9169 // Post-loop branch.
9170 if (!$errno && $http_code === 200) {
9171 // Happy path — save the complete response to maintain chat persistence.
9172 if (!empty($full_response) && !empty($session_id)) {
9173 $rag_context_for_storage = null;
9174 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
9175 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
9176
9177 if ($has_rag_data || $has_action_data) {
9178 $rag_context_for_storage = [];
9179
9180 if ($has_rag_data) {
9181 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
9182 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
9183 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
9184 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
9185 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
9186 }
9187
9188 if ($has_action_data) {
9189 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
9190 }
9191 }
9192 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
9193 }
9194
9195 return true;
9196 }
9197
9198 // Failure path — branch on whether SSE channel was opened.
9199 return $this->mxchat_stream_emit_fallback(
9200 'openai',
9201 $this->mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content, $session_id),
9202 $session_id,
9203 $testing_data
9204 );
9205
9206 } catch (Exception $e) {
9207 return $this->mxchat_stream_emit_fallback(
9208 'openai',
9209 $this->mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content, $session_id),
9210 $session_id,
9211 $testing_data
9212 );
9213 }
9214 }
9215
9216 /**
9217 * Shared fallback emitter for streaming chat functions. Two outcomes:
9218 * - streaming_headers_sent === true: SSE channel is open. Emit fallback content
9219 * as `data: {...}\n\n` + `data: [DONE]\n\n` so the widget renders it as a
9220 * normal bot bubble. Transcript row is persisted.
9221 * - streaming_headers_sent === false: SSE channel never opened (retries
9222 * exhausted on initial connect). Emit a clean JSON response — the path
9223 * the widget would normally hit if streaming wasn't even attempted.
9224 *
9225 * Used by all six *_stream functions after their per-attempt retry loop.
9226 */
9227 private function mxchat_stream_emit_fallback($provider_hint, $regular_response, $session_id, $testing_data = null) {
9228 $is_error_array = is_array($regular_response) && isset($regular_response['error']);
9229
9230 if ($this->streaming_headers_sent) {
9231 if ($is_error_array) {
9232 echo "data: " . json_encode([
9233 'error' => true,
9234 'error_message' => $regular_response['error'],
9235 'error_code' => $regular_response['error_code'] ?? 'api_error',
9236 'text' => $regular_response['error'],
9237 'message' => $regular_response['error']
9238 ]) . "\n\n";
9239 echo "data: [DONE]\n\n";
9240 flush();
9241 return true;
9242 }
9243 $fallback_message = (string) $regular_response;
9244 if (!empty($fallback_message) && !empty($session_id)) {
9245 $this->mxchat_save_chat_message($session_id, 'bot', $fallback_message);
9246 }
9247 echo "data: " . json_encode(['content' => $fallback_message]) . "\n\n";
9248 echo "data: [DONE]\n\n";
9249 flush();
9250 return true;
9251 }
9252
9253 // SSE channel never opened — clean JSON fallback.
9254 if ($is_error_array) {
9255 header('Content-Type: application/json');
9256 echo json_encode(array(
9257 'error' => true,
9258 'error_message' => $regular_response['error'],
9259 'error_code' => $regular_response['error_code'] ?? 'api_error',
9260 'text' => $regular_response['error'],
9261 'message' => $regular_response['error'],
9262 ));
9263 return true;
9264 }
9265
9266 $fallback_message = (string) $regular_response;
9267 if (!empty($fallback_message) && !empty($session_id)) {
9268 $this->mxchat_save_chat_message($session_id, 'bot', $fallback_message);
9269 }
9270 $response_data = array(
9271 'text' => $fallback_message,
9272 'html' => '',
9273 'session_id' => $session_id,
9274 );
9275 if ($testing_data !== null) {
9276 $response_data['testing_data'] = $testing_data;
9277 }
9278 header('Content-Type: application/json');
9279 echo json_encode($response_data);
9280 return true;
9281 }
9282
9283 /**
9284 * Resolve custom (OpenAI-compatible) provider config from settings.
9285 * Returns ['base_url','api_key','model','auth_scheme','api_version','chat_url','headers'].
9286 */
9287 private function mxchat_resolve_custom_provider() {
9288 $base_url = isset($this->options['custom_provider_base_url']) ? rtrim(trim((string) $this->options['custom_provider_base_url']), '/') : '';
9289 $api_key = isset($this->options['custom_provider_api_key']) ? trim((string) $this->options['custom_provider_api_key']) : '';
9290 $model = isset($this->options['custom_provider_model']) ? trim((string) $this->options['custom_provider_model']) : '';
9291 $auth_scheme = isset($this->options['custom_provider_auth_scheme']) ? $this->options['custom_provider_auth_scheme'] : 'bearer';
9292 $api_version = isset($this->options['custom_provider_api_version']) ? trim((string) $this->options['custom_provider_api_version']) : '';
9293
9294 $chat_url = $base_url . '/chat/completions';
9295 if (!empty($api_version)) {
9296 $chat_url .= (strpos($chat_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($api_version);
9297 }
9298
9299 $headers = array('Content-Type: application/json');
9300 if (!empty($api_key)) {
9301 if ($auth_scheme === 'api-key') {
9302 $headers[] = 'api-key: ' . $api_key;
9303 } else {
9304 $headers[] = 'Authorization: Bearer ' . $api_key;
9305 }
9306 }
9307
9308 return array(
9309 'base_url' => $base_url,
9310 'api_key' => $api_key,
9311 'model' => $model !== '' ? $model : 'default',
9312 'auth_scheme' => $auth_scheme,
9313 'api_version' => $api_version,
9314 'chat_url' => $chat_url,
9315 'headers' => $headers,
9316 );
9317 }
9318
9319 /**
9320 * Streaming chat completion against an OpenAI-compatible custom provider
9321 * (Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.).
9322 * Mirrors mxchat_generate_response_openai_stream but with parameterized URL/auth/model.
9323 */
9324 private function mxchat_generate_response_custom_stream($selected_model, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
9325 try {
9326 $cfg = $this->mxchat_resolve_custom_provider();
9327 if (empty($cfg['base_url'])) {
9328 return array('error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url');
9329 }
9330
9331 $bot_id = $this->get_current_bot_id($session_id);
9332 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9333 if (!is_array($conversation_history)) {
9334 $conversation_history = array();
9335 }
9336
9337 $formatted_conversation = array();
9338 $formatted_conversation[] = array(
9339 'role' => 'system',
9340 'content' => $system_prompt_instructions . ' ' . $relevant_content,
9341 );
9342 foreach ($conversation_history as $message) {
9343 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9344 $role = $message['role'];
9345 if ($role === 'bot' || $role === 'agent') { $role = 'assistant'; }
9346 if (!in_array($role, array('system', 'assistant', 'user', 'function', 'tool'))) { $role = 'user'; }
9347 $formatted_conversation[] = array('role' => $role, 'content' => $message['content']);
9348 }
9349 }
9350
9351 if (headers_sent() || !function_exists('curl_init')) {
9352 // No streaming capability — fall through to non-stream wrapper
9353 $regular = $this->mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content);
9354 if (!empty($regular) && !empty($session_id) && is_string($regular)) {
9355 $this->mxchat_save_chat_message($session_id, 'bot', $regular);
9356 }
9357 $response_data = array('text' => is_string($regular) ? $regular : '', 'html' => '', 'session_id' => $session_id);
9358 if ($testing_data !== null) { $response_data['testing_data'] = $testing_data; }
9359 header('Content-Type: application/json');
9360 echo json_encode($response_data);
9361 return true;
9362 }
9363
9364 $request_body = array(
9365 'model' => $cfg['model'],
9366 'messages' => $formatted_conversation,
9367 'stream' => true,
9368 );
9369 $body = json_encode($request_body);
9370
9371 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
9372
9373 $captured_status_code = 0;
9374 $captured_body_pre_stream = '';
9375 $full_response = '';
9376 $stream_started = false;
9377 $buffer = '';
9378 $errno = 0;
9379 $http_code = 0;
9380 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
9381 $backoff_ms = array(0, 750, 2000);
9382
9383 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
9384 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
9385 usleep($backoff_ms[$attempt] * 1000);
9386 }
9387
9388 $captured_status_code = 0;
9389 $captured_body_pre_stream = '';
9390 $full_response = '';
9391 $stream_started = false;
9392 $buffer = '';
9393
9394 $ch = curl_init();
9395 curl_setopt($ch, CURLOPT_URL, $cfg['chat_url']);
9396 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9397 curl_setopt($ch, CURLOPT_POST, true);
9398 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
9399 curl_setopt($ch, CURLOPT_HTTPHEADER, $cfg['headers']);
9400 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9401 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
9402
9403 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
9404 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
9405 $captured_status_code = (int) $m[1];
9406 }
9407 return strlen($header);
9408 });
9409
9410 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
9411 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
9412 $captured_body_pre_stream .= $data;
9413 return strlen($data);
9414 }
9415
9416 if (!$this->streaming_headers_sent) {
9417 $this->setup_streaming_headers();
9418 }
9419
9420 if (!$stream_started && $testing_data !== null) {
9421 echo "data: " . json_encode(array('testing_data' => $testing_data)) . "\n\n";
9422 flush();
9423 $stream_started = true;
9424 }
9425 $buffer .= $data;
9426 $lines = explode("\n", $buffer);
9427 $buffer = array_pop($lines);
9428 foreach ($lines as $line) {
9429 if (trim($line) === '') { continue; }
9430 if (strpos($line, 'data: ') !== 0) { continue; }
9431 $json_str = substr($line, 6);
9432 if (trim($json_str) === '[DONE]') {
9433 echo "data: [DONE]\n\n";
9434 flush();
9435 continue;
9436 }
9437 $json = json_decode(trim($json_str), true);
9438 if ($json && isset($json['choices'][0]['delta']['content'])) {
9439 $content = $json['choices'][0]['delta']['content'];
9440 $full_response .= $content;
9441 echo "data: " . json_encode(array('content' => $content)) . "\n\n";
9442 flush();
9443 }
9444 }
9445 return strlen($data);
9446 });
9447
9448 $response = curl_exec($ch);
9449 $errno = curl_errno($ch);
9450 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
9451 curl_close($ch);
9452
9453 if (!$errno && $http_code === 200) {
9454 break;
9455 }
9456
9457 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
9458 $can_retry = !$this->streaming_headers_sent
9459 && ($attempt + 1) < $max_attempts
9460 && $is_transient;
9461
9462 if (defined('WP_DEBUG') && WP_DEBUG) {
9463 error_log(sprintf(
9464 '[MxChat] custom_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
9465 $attempt + 1, $max_attempts, $http_code, $errno,
9466 $is_transient ? 'yes' : 'no',
9467 $can_retry ? 'Retrying.' : 'Giving up.'
9468 ));
9469 }
9470
9471 if (!$can_retry) {
9472 break;
9473 }
9474 }
9475
9476 if (!$errno && $http_code === 200) {
9477 if (!empty($full_response) && !empty($session_id)) {
9478 $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
9479 }
9480 return true;
9481 }
9482
9483 return $this->mxchat_stream_emit_fallback(
9484 'openai',
9485 $this->mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content),
9486 $session_id,
9487 $testing_data
9488 );
9489
9490 } catch (Exception $e) {
9491 return array('error' => sprintf(esc_html__('Custom provider error: %s', 'mxchat'), $e->getMessage()), 'error_code' => 'custom_provider_exception');
9492 }
9493 }
9494
9495 /**
9496 * Non-streaming chat completion against a custom OpenAI-compatible provider.
9497 * Returns string content on success, array['error'=>...] on failure.
9498 */
9499 private function mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content) {
9500 $cfg = $this->mxchat_resolve_custom_provider();
9501 if (empty($cfg['base_url'])) {
9502 return array('error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url');
9503 }
9504
9505 $bot_id = $this->get_current_bot_id(null);
9506 $system_prompt_instructions = $this->get_system_instructions($bot_id, null);
9507 if (!is_array($conversation_history)) {
9508 $conversation_history = array();
9509 }
9510
9511 $messages = array(array(
9512 'role' => 'system',
9513 'content' => $system_prompt_instructions . ' ' . $relevant_content,
9514 ));
9515 foreach ($conversation_history as $message) {
9516 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9517 $role = $message['role'];
9518 if ($role === 'bot' || $role === 'agent') { $role = 'assistant'; }
9519 if (!in_array($role, array('system', 'assistant', 'user', 'function', 'tool'))) { $role = 'user'; }
9520 $messages[] = array('role' => $role, 'content' => $message['content']);
9521 }
9522 }
9523
9524 $headers_assoc = array('Content-Type' => 'application/json');
9525 if (!empty($cfg['api_key'])) {
9526 if ($cfg['auth_scheme'] === 'api-key') {
9527 $headers_assoc['api-key'] = $cfg['api_key'];
9528 } else {
9529 $headers_assoc['Authorization'] = 'Bearer ' . $cfg['api_key'];
9530 }
9531 }
9532
9533 $response = $this->mxchat_provider_call_with_retry($cfg['chat_url'], array(
9534 'headers' => $headers_assoc,
9535 'body' => wp_json_encode(array(
9536 'model' => $cfg['model'],
9537 'messages' => $messages,
9538 )),
9539 'timeout' => 120,
9540 ), 'openai');
9541
9542 if (is_wp_error($response)) {
9543 return array('error' => sprintf(esc_html__('Custom provider request failed: %s', 'mxchat'), $response->get_error_message()), 'error_code' => 'custom_provider_network_error');
9544 }
9545 $code = (int) wp_remote_retrieve_response_code($response);
9546 if ($code < 200 || $code >= 300) {
9547 return array('error' => sprintf(esc_html__('Custom provider returned HTTP %d.', 'mxchat'), $code), 'error_code' => 'custom_provider_http_error');
9548 }
9549 $body = json_decode(wp_remote_retrieve_body($response), true);
9550 if (isset($body['choices'][0]['message']['content'])) {
9551 return (string) $body['choices'][0]['message']['content'];
9552 }
9553 return array('error' => esc_html__('Custom provider returned an unexpected response shape.', 'mxchat'), 'error_code' => 'custom_provider_response_shape');
9554 }
9555
9556 /**
9557 * Generate response using OpenAI Responses API with web search tool
9558 * This uses the newer Responses API which supports web search functionality
9559 */
9560 private function mxchat_generate_response_openai_web_search($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null, $streaming = false) {
9561 try {
9562 $bot_id = $this->get_current_bot_id($session_id);
9563 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9564
9565 if (!is_array($conversation_history)) {
9566 $conversation_history = array();
9567 }
9568
9569 // Build the input for Responses API
9570 // The Responses API uses a different format - we need to construct the input properly
9571 $input_parts = [];
9572
9573 // Add system instructions as context
9574 $system_context = $system_prompt_instructions . "\n\n" . $relevant_content;
9575
9576 // Build conversation as input items for Responses API
9577 foreach ($conversation_history as $message) {
9578 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9579 $role = $message['role'];
9580 if ($role === 'bot' || $role === 'agent') {
9581 $role = 'assistant';
9582 }
9583 if (!in_array($role, ['assistant', 'user'])) {
9584 $role = 'user';
9585 }
9586 $input_parts[] = [
9587 'type' => 'message',
9588 'role' => $role,
9589 'content' => $message['content']
9590 ];
9591 }
9592 }
9593
9594 // Build request body for Responses API
9595 $request_body = [
9596 'model' => $selected_model,
9597 'input' => $input_parts,
9598 'instructions' => $system_context,
9599 'stream' => $streaming
9600 ];
9601
9602 // Only add web search tool if web search is enabled in settings
9603 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
9604 if ($web_search_enabled) {
9605 $request_body['tools'] = [
9606 ['type' => 'web_search']
9607 ];
9608 }
9609
9610 // reasoning.effort — sourced from the core model catalog (plan-dcb71c),
9611 // 'websearch' surface; frozen inline ladder in mxchat_reasoning_effort_fallback().
9612 $effort = $this->mxchat_reasoning_effort_for($selected_model, 'websearch');
9613 if ($effort !== null) {
9614 $request_body['reasoning'] = ['effort' => $effort];
9615 }
9616
9617 //error_log("MXCHAT WEB SEARCH: Request body: " . json_encode($request_body));
9618
9619 if ($streaming) {
9620 return $this->mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data);
9621 } else {
9622 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
9623 }
9624
9625 } catch (Exception $e) {
9626 //error_log("MXCHAT WEB SEARCH ERROR: " . $e->getMessage());
9627 return [
9628 'error' => sprintf(esc_html__('Web search error: %s', 'mxchat'), esc_html($e->getMessage())),
9629 'error_code' => 'web_search_exception'
9630 ];
9631 }
9632 }
9633
9634 /**
9635 * Handle non-streaming web search response
9636 */
9637 private function mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data) {
9638 $request_body['stream'] = false;
9639
9640 $response = $this->mxchat_provider_call_with_retry('https://api.openai.com/v1/responses', array(
9641 'headers' => array(
9642 'Authorization' => 'Bearer ' . $api_key,
9643 'Content-Type' => 'application/json'
9644 ),
9645 'body' => json_encode($request_body),
9646 'timeout' => 90
9647 ), 'openai');
9648
9649 if (is_wp_error($response)) {
9650 //error_log("MXCHAT WEB SEARCH ERROR: WP Error: " . $response->get_error_message());
9651 return [
9652 'error' => esc_html__('Failed to connect to OpenAI web search API', 'mxchat'),
9653 'error_code' => 'web_search_connection_error'
9654 ];
9655 }
9656
9657 $response_code = wp_remote_retrieve_response_code($response);
9658 $response_body = wp_remote_retrieve_body($response);
9659
9660 //error_log("MXCHAT WEB SEARCH: Response code: " . $response_code);
9661 //error_log("MXCHAT WEB SEARCH: Response body (first 2000): " . substr($response_body, 0, 2000));
9662
9663 if ($response_code !== 200) {
9664 $error_data = json_decode($response_body, true);
9665 $error_message = $this->extract_provider_error($error_data, 'Unknown API error');
9666 return [
9667 'error' => sprintf(esc_html__('OpenAI API error: %s', 'mxchat'), esc_html($error_message)),
9668 'error_code' => 'web_search_api_error'
9669 ];
9670 }
9671
9672 $result = json_decode($response_body, true);
9673
9674 if (json_last_error() !== JSON_ERROR_NONE) {
9675 return [
9676 'error' => esc_html__('Invalid response from OpenAI', 'mxchat'),
9677 'error_code' => 'web_search_json_error'
9678 ];
9679 }
9680
9681 // Extract the response text and citations from Responses API format
9682 $output_text = '';
9683 $citations = [];
9684
9685 if (isset($result['output'])) {
9686 foreach ($result['output'] as $output_item) {
9687 if ($output_item['type'] === 'message' && isset($output_item['content'])) {
9688 foreach ($output_item['content'] as $content_item) {
9689 if ($content_item['type'] === 'output_text') {
9690 $output_text .= $content_item['text'];
9691
9692 // Extract citations/annotations
9693 if (isset($content_item['annotations'])) {
9694 foreach ($content_item['annotations'] as $annotation) {
9695 if ($annotation['type'] === 'url_citation') {
9696 $citations[] = [
9697 'url' => $annotation['url'],
9698 'title' => $annotation['title'] ?? ''
9699 ];
9700 }
9701 }
9702 }
9703 }
9704 }
9705 }
9706 }
9707 }
9708
9709 // If we have citations, append them to the response
9710 if (!empty($citations)) {
9711 $output_text .= "\n\n**Sources:**\n";
9712 $seen_urls = [];
9713 foreach ($citations as $citation) {
9714 if (!in_array($citation['url'], $seen_urls)) {
9715 $seen_urls[] = $citation['url'];
9716 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
9717 $output_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
9718 }
9719 }
9720 }
9721
9722 // Transcript save is handled by the main handler (mxchat_handle_chat_request)
9723 // which includes rag_context for the "sources" link in transcripts.
9724
9725 // plan-4aa8e5: a 200 whose output carries no output_text (status
9726 // "incomplete" with max_output_tokens exhausted, content-filter-emptied
9727 // output, shape drift) previously fell through and returned '' — a
9728 // silent empty bot bubble. This is the DEFAULT model path
9729 // (gpt-5.1-chat-latest routes through /v1/responses).
9730 if (trim($output_text) === '') {
9731 return $this->mxchat_empty_completion_error($result, 'OpenAI');
9732 }
9733
9734 return $output_text;
9735 }
9736
9737 /**
9738 * Handle streaming web search response using Responses API
9739 */
9740 private function mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data) {
9741 $request_body['stream'] = true;
9742
9743 // Check if we can stream
9744 if (headers_sent() || !function_exists('curl_init')) {
9745 // Fallback to non-streaming
9746 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
9747 }
9748
9749 // Setup streaming headers
9750 $this->setup_streaming_headers();
9751
9752 $ch = curl_init();
9753 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/responses');
9754 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9755 curl_setopt($ch, CURLOPT_POST, true);
9756 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request_body));
9757 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
9758 'Content-Type: application/json',
9759 'Authorization: Bearer ' . $api_key
9760 ));
9761 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9762 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
9763
9764 $full_response = '';
9765 $stream_started = false;
9766 $buffer = '';
9767 $citations = [];
9768 $empty_error_emitted = false;
9769
9770 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$citations, &$empty_error_emitted, $testing_data) {
9771 // Send testing data as first event if available
9772 if (!$stream_started && $testing_data !== null) {
9773 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
9774 flush();
9775 $stream_started = true;
9776 }
9777
9778 $buffer .= $data;
9779 $lines = explode("\n", $buffer);
9780 $buffer = array_pop($lines);
9781
9782 foreach ($lines as $line) {
9783 if (trim($line) === '') continue;
9784 if (strpos($line, 'data: ') !== 0) continue;
9785
9786 $json_str = substr($line, 6);
9787
9788 if (trim($json_str) === '[DONE]') {
9789 // Append citations if we have any
9790 if (!empty($citations)) {
9791 $citation_text = "\n\n**Sources:**\n";
9792 $seen_urls = [];
9793 foreach ($citations as $citation) {
9794 if (!in_array($citation['url'], $seen_urls)) {
9795 $seen_urls[] = $citation['url'];
9796 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
9797 $citation_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
9798 }
9799 }
9800 echo "data: " . json_encode(['content' => $citation_text]) . "\n\n";
9801 $full_response .= $citation_text;
9802 flush();
9803 }
9804 // plan-4aa8e5: zero deltas streamed → say so instead of
9805 // closing a silent empty bubble (client renders text events).
9806 if (trim($full_response) === '' && !$empty_error_emitted) {
9807 $empty_error_emitted = true;
9808 echo "data: " . json_encode(['content' => esc_html__('The AI provider returned an empty response. Please try again.', 'mxchat')]) . "\n\n";
9809 }
9810 echo "data: [DONE]\n\n";
9811 flush();
9812 continue;
9813 }
9814
9815 $json = json_decode(trim($json_str), true);
9816 if (!$json) continue;
9817
9818 // Handle Responses API streaming events
9819 // The format is different from Chat Completions
9820 if (isset($json['type'])) {
9821 switch ($json['type']) {
9822 case 'response.output_text.delta':
9823 // Text content delta
9824 if (isset($json['delta'])) {
9825 $content = $json['delta'];
9826 $full_response .= $content;
9827 echo "data: " . json_encode(['content' => $content]) . "\n\n";
9828 flush();
9829 }
9830 break;
9831
9832 case 'response.output_item.done':
9833 // Check for citations in completed items
9834 if (isset($json['item']['content'])) {
9835 foreach ($json['item']['content'] as $content_item) {
9836 if (isset($content_item['annotations'])) {
9837 foreach ($content_item['annotations'] as $annotation) {
9838 if ($annotation['type'] === 'url_citation') {
9839 $citations[] = [
9840 'url' => $annotation['url'],
9841 'title' => $annotation['title'] ?? ''
9842 ];
9843 }
9844 }
9845 }
9846 }
9847 }
9848 break;
9849 }
9850 }
9851 }
9852
9853 return strlen($data);
9854 });
9855
9856 $response = curl_exec($ch);
9857 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
9858
9859 if (curl_errno($ch) || $http_code !== 200) {
9860 $curl_error = curl_error($ch);
9861 curl_close($ch);
9862
9863 //error_log("MXCHAT WEB SEARCH STREAM ERROR: HTTP $http_code, cURL error: $curl_error");
9864
9865 return $this->mxchat_stream_emit_fallback(
9866 'web_search',
9867 $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data),
9868 $session_id,
9869 $testing_data
9870 );
9871 }
9872
9873 curl_close($ch);
9874
9875 // plan-4aa8e5: the Responses API can end its stream via typed events
9876 // without a [DONE] line — if nothing was streamed at all, close out with
9877 // the empty-completion message instead of leaving a silent bubble.
9878 if (trim($full_response) === '' && !$empty_error_emitted) {
9879 echo "data: " . json_encode(['content' => esc_html__('The AI provider returned an empty response. Please try again.', 'mxchat')]) . "\n\n";
9880 echo "data: [DONE]\n\n";
9881 flush();
9882 }
9883
9884 // Save the complete response with RAG context so the "sources" link
9885 // appears in transcripts — mirrors the pattern used by Claude/OpenAI streaming.
9886 if (!empty($full_response) && !empty($session_id)) {
9887 $rag_context_for_storage = null;
9888 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
9889 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
9890
9891 if ($has_rag_data || $has_action_data) {
9892 $rag_context_for_storage = [];
9893
9894 if ($has_rag_data) {
9895 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
9896 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
9897 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
9898 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
9899 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
9900 }
9901
9902 if ($has_action_data) {
9903 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
9904 }
9905 }
9906 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
9907 }
9908
9909 return true;
9910 }
9911
9912 private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
9913 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
9914 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
9915 if ($selected_model === 'claude-opus-4-20250514') { $selected_model = 'claude-opus-4-8'; }
9916 elseif ($selected_model === 'claude-sonnet-4-20250514') { $selected_model = 'claude-sonnet-4-6'; }
9917 try {
9918 // Get bot ID from session or request
9919 $bot_id = $this->get_current_bot_id($session_id);
9920
9921 // Get system prompt instructions using centralized function
9922 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9923 // Ensure conversation_history is an array
9924 if (!is_array($conversation_history)) {
9925 $conversation_history = array();
9926 }
9927
9928 // Clean and validate conversation history
9929 foreach ($conversation_history as &$message) {
9930 // Convert bot and agent roles to assistant
9931 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
9932 $message['role'] = 'assistant';
9933 }
9934
9935 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
9936 if (!in_array($message['role'], ['assistant', 'user'])) {
9937 $message['role'] = 'user';
9938 }
9939
9940 // Ensure content field exists
9941 if (!isset($message['content']) || empty($message['content'])) {
9942 $message['content'] = '';
9943 }
9944
9945 // Remove any unsupported fields
9946 $message = array_intersect_key($message, array_flip(['role', 'content']));
9947 }
9948
9949 // Add relevant content as the latest user message
9950 $conversation_history[] = [
9951 'role' => 'user',
9952 'content' => $relevant_content
9953 ];
9954
9955 // Prepare the request body with stream: true
9956 $payload = [
9957 'model' => $selected_model,
9958 'messages' => $conversation_history,
9959 'max_tokens' => 1000,
9960 'temperature' => 0.8,
9961 'system' => $system_prompt_instructions,
9962 'stream' => true
9963 ];
9964 if ($this->mxchat_claude_omits_temperature($selected_model)) { unset($payload['temperature']); }
9965 $body = json_encode($payload);
9966
9967 // Check if we can actually stream (headers not sent, etc.)
9968 if (headers_sent() || !function_exists('curl_init')) {
9969 // Fallback to regular response with testing data
9970 //error_log("MxChat: Streaming not possible, falling back to regular response");
9971 $regular_response = $this->mxchat_generate_response_claude(
9972 $selected_model,
9973 $claude_api_key,
9974 array_slice($conversation_history, 0, -1), // Remove the added content
9975 $relevant_content,
9976 $session_id
9977 );
9978
9979 // Save bot response to transcript
9980 if (!empty($regular_response) && !empty($session_id)) {
9981 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
9982 }
9983
9984 // Return as JSON with testing data
9985 $response_data = [
9986 'text' => $regular_response,
9987 'html' => '',
9988 'session_id' => $session_id
9989 ];
9990
9991 if ($testing_data !== null) {
9992 $response_data['testing_data'] = $testing_data;
9993 //error_log("MxChat Testing: Added testing data to Claude fallback response");
9994 }
9995
9996 // Clear any streaming headers and send JSON
9997 if (headers_sent() === false) {
9998 header('Content-Type: application/json');
9999 }
10000 echo json_encode($response_data);
10001 return true; // Indicate we handled the response
10002 }
10003
10004 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
10005
10006 $captured_status_code = 0;
10007 $captured_body_pre_stream = '';
10008 $full_response = '';
10009 $stream_started = false;
10010 $buffer = '';
10011 $errno = 0;
10012 $http_code = 0;
10013 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
10014 $backoff_ms = array(0, 750, 2000);
10015
10016 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
10017 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
10018 usleep($backoff_ms[$attempt] * 1000);
10019 }
10020
10021 $captured_status_code = 0;
10022 $captured_body_pre_stream = '';
10023 $full_response = '';
10024 $stream_started = false;
10025 $buffer = '';
10026
10027 $ch = curl_init();
10028 curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
10029 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
10030 curl_setopt($ch, CURLOPT_POST, true);
10031 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
10032 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
10033 'Content-Type: application/json',
10034 'x-api-key: ' . $claude_api_key,
10035 'anthropic-version: 2023-06-01'
10036 ));
10037 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
10038 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
10039
10040 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
10041 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
10042 $captured_status_code = (int) $m[1];
10043 }
10044 return strlen($header);
10045 });
10046
10047 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
10048 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
10049 $captured_body_pre_stream .= $data;
10050 return strlen($data);
10051 }
10052
10053 if (!$this->streaming_headers_sent) {
10054 $this->setup_streaming_headers();
10055 }
10056
10057 if (!$stream_started && $testing_data !== null) {
10058 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
10059 flush();
10060 $stream_started = true;
10061 }
10062
10063 $buffer .= $data;
10064 $lines = explode("\n", $buffer);
10065 $buffer = array_pop($lines);
10066
10067 foreach ($lines as $line) {
10068 if (trim($line) === '') {
10069 continue;
10070 }
10071
10072 if (strpos($line, 'event: ') === 0) {
10073 continue;
10074 }
10075
10076 if (strpos($line, 'data: ') === 0) {
10077 $json_str = substr($line, 6);
10078
10079 $json = json_decode(trim($json_str), true);
10080 if (json_last_error() !== JSON_ERROR_NONE) {
10081 continue;
10082 }
10083
10084 if (isset($json['type'])) {
10085 switch ($json['type']) {
10086 case 'content_block_delta':
10087 if (isset($json['delta']['text'])) {
10088 $content = $json['delta']['text'];
10089 $full_response .= $content;
10090 echo "data: " . json_encode(['content' => $content]) . "\n\n";
10091 flush();
10092 }
10093 break;
10094
10095 case 'message_stop':
10096 echo "data: [DONE]\n\n";
10097 flush();
10098 break;
10099
10100 case 'error':
10101 echo "data: " . json_encode(['error' => $this->extract_provider_error($json, 'Unknown error')]) . "\n\n";
10102 flush();
10103 break;
10104 }
10105 }
10106 }
10107 }
10108
10109 return strlen($data);
10110 });
10111
10112 $response = curl_exec($ch);
10113 $errno = curl_errno($ch);
10114 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
10115 curl_close($ch);
10116
10117 if (!$errno && $http_code === 200) {
10118 break;
10119 }
10120
10121 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'anthropic', $errno);
10122 $can_retry = !$this->streaming_headers_sent
10123 && ($attempt + 1) < $max_attempts
10124 && $is_transient;
10125
10126 if (defined('WP_DEBUG') && WP_DEBUG) {
10127 error_log(sprintf(
10128 '[MxChat] claude_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
10129 $attempt + 1, $max_attempts, $http_code, $errno,
10130 $is_transient ? 'yes' : 'no',
10131 $can_retry ? 'Retrying.' : 'Giving up.'
10132 ));
10133 }
10134
10135 if (!$can_retry) {
10136 break;
10137 }
10138 }
10139
10140 if ($errno || $http_code !== 200) {
10141 return $this->mxchat_stream_emit_fallback(
10142 'anthropic',
10143 $this->mxchat_generate_response_claude($selected_model, $claude_api_key, array_slice($conversation_history, 0, -1), $relevant_content, $session_id),
10144 $session_id,
10145 $testing_data
10146 );
10147 }
10148
10149 // Save the complete response to maintain chat persistence
10150 if (!empty($full_response) && !empty($session_id)) {
10151 // Prepare RAG context for streaming response
10152 $rag_context_for_storage = null;
10153 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
10154 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
10155
10156 if ($has_rag_data || $has_action_data) {
10157 $rag_context_for_storage = [];
10158
10159 if ($has_rag_data) {
10160 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
10161 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
10162 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
10163 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
10164 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10165 }
10166
10167 if ($has_action_data) {
10168 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10169 }
10170 }
10171 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
10172 }
10173
10174 return true; // Indicate streaming completed successfully
10175
10176 } catch (Exception $e) {
10177 return $this->mxchat_stream_emit_fallback(
10178 'anthropic',
10179 $this->mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id),
10180 $session_id,
10181 $testing_data
10182 );
10183 }
10184 }
10185 private function mxchat_generate_response_xai_stream($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
10186 try {
10187 // Get bot ID from session or request
10188 $bot_id = $this->get_current_bot_id($session_id);
10189
10190 // Get system prompt instructions using centralized function
10191 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10192
10193 // Ensure conversation_history is an array
10194 if (!is_array($conversation_history)) {
10195 $conversation_history = array();
10196 }
10197
10198 // Format conversation history for X.AI (same as OpenAI format)
10199 $formatted_conversation = array();
10200
10201 $formatted_conversation[] = array(
10202 'role' => 'system',
10203 'content' => $system_prompt_instructions . " " . $relevant_content
10204 );
10205
10206 foreach ($conversation_history as $message) {
10207 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
10208 $role = $message['role'];
10209 if ($role === 'bot' || $role === 'agent') {
10210 $role = 'assistant';
10211 }
10212 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
10213 $role = 'user';
10214 }
10215 $formatted_conversation[] = array(
10216 'role' => $role,
10217 'content' => $message['content']
10218 );
10219 }
10220 }
10221
10222 // Check if we can actually stream
10223 if (headers_sent() || !function_exists('curl_init')) {
10224 // Fallback to regular response with testing data
10225 //error_log("MxChat: X.AI streaming not possible, falling back to regular response");
10226 $regular_response = $this->mxchat_generate_response_xai(
10227 $selected_model,
10228 $xai_api_key,
10229 $conversation_history,
10230 $relevant_content,
10231 $session_id
10232 );
10233
10234 // Save bot response to transcript
10235 if (!empty($regular_response) && !empty($session_id)) {
10236 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
10237 }
10238
10239 $response_data = [
10240 'text' => $regular_response,
10241 'html' => '',
10242 'session_id' => $session_id
10243 ];
10244
10245 if ($testing_data !== null) {
10246 $response_data['testing_data'] = $testing_data;
10247 //error_log("MxChat Testing: Added testing data to X.AI fallback response");
10248 }
10249
10250 header('Content-Type: application/json');
10251 echo json_encode($response_data);
10252 return true;
10253 }
10254
10255 // Prepare the request body with stream: true
10256 $body = json_encode([
10257 'model' => $selected_model,
10258 'messages' => $formatted_conversation,
10259 'temperature' => 0.8,
10260 'stream' => true
10261 ]);
10262
10263 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
10264
10265 $captured_status_code = 0;
10266 $captured_body_pre_stream = '';
10267 $full_response = '';
10268 $stream_started = false;
10269 $buffer = '';
10270 $errno = 0;
10271 $http_code = 0;
10272 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
10273 $backoff_ms = array(0, 750, 2000);
10274
10275 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
10276 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
10277 usleep($backoff_ms[$attempt] * 1000);
10278 }
10279
10280 $captured_status_code = 0;
10281 $captured_body_pre_stream = '';
10282 $full_response = '';
10283 $stream_started = false;
10284 $buffer = '';
10285
10286 $ch = curl_init();
10287 curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
10288 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
10289 curl_setopt($ch, CURLOPT_POST, true);
10290 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
10291 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
10292 'Content-Type: application/json',
10293 'Authorization: Bearer ' . $xai_api_key
10294 ));
10295 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
10296 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
10297
10298 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
10299 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
10300 $captured_status_code = (int) $m[1];
10301 }
10302 return strlen($header);
10303 });
10304
10305 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
10306 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
10307 $captured_body_pre_stream .= $data;
10308 return strlen($data);
10309 }
10310
10311 if (!$this->streaming_headers_sent) {
10312 $this->setup_streaming_headers();
10313 }
10314
10315 if (!$stream_started && $testing_data !== null) {
10316 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
10317 flush();
10318 $stream_started = true;
10319 }
10320
10321 $buffer .= $data;
10322 $lines = explode("\n", $buffer);
10323 $buffer = array_pop($lines);
10324
10325 foreach ($lines as $line) {
10326 if (trim($line) === '') {
10327 continue;
10328 }
10329 if (strpos($line, 'data: ') !== 0) {
10330 continue;
10331 }
10332
10333 $json_str = substr($line, 6);
10334
10335 if (trim($json_str) === '[DONE]') {
10336 echo "data: [DONE]\n\n";
10337 flush();
10338 continue;
10339 }
10340
10341 $json = json_decode(trim($json_str), true);
10342 if ($json && isset($json['choices'][0]['delta']['content'])) {
10343 $content = $json['choices'][0]['delta']['content'];
10344 $full_response .= $content;
10345 echo "data: " . json_encode(['content' => $content]) . "\n\n";
10346 flush();
10347 }
10348 }
10349
10350 return strlen($data);
10351 });
10352
10353 $response = curl_exec($ch);
10354 $errno = curl_errno($ch);
10355 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
10356 curl_close($ch);
10357
10358 if (!$errno && $http_code === 200) {
10359 break;
10360 }
10361
10362 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'xai', $errno);
10363 $can_retry = !$this->streaming_headers_sent
10364 && ($attempt + 1) < $max_attempts
10365 && $is_transient;
10366
10367 if (defined('WP_DEBUG') && WP_DEBUG) {
10368 error_log(sprintf(
10369 '[MxChat] xai_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
10370 $attempt + 1, $max_attempts, $http_code, $errno,
10371 $is_transient ? 'yes' : 'no',
10372 $can_retry ? 'Retrying.' : 'Giving up.'
10373 ));
10374 }
10375
10376 if (!$can_retry) {
10377 break;
10378 }
10379 }
10380
10381 if ($errno || $http_code !== 200) {
10382 return $this->mxchat_stream_emit_fallback(
10383 'xai',
10384 $this->mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id),
10385 $session_id,
10386 $testing_data
10387 );
10388 }
10389
10390 // Save the complete response to maintain chat persistence
10391 if (!empty($full_response) && !empty($session_id)) {
10392 // Prepare RAG context for streaming response
10393 $rag_context_for_storage = null;
10394 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
10395 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
10396
10397 if ($has_rag_data || $has_action_data) {
10398 $rag_context_for_storage = [];
10399
10400 if ($has_rag_data) {
10401 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
10402 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
10403 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
10404 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
10405 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10406 }
10407
10408 if ($has_action_data) {
10409 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10410 }
10411 }
10412 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
10413 }
10414
10415 return true; // Indicate streaming completed successfully
10416
10417 } catch (Exception $e) {
10418 return $this->mxchat_stream_emit_fallback(
10419 'xai',
10420 $this->mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content),
10421 $session_id,
10422 $testing_data
10423 );
10424 }
10425 }
10426 private function mxchat_generate_response_deepseek_stream($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
10427 try {
10428 // Get bot ID from session or request
10429 $bot_id = $this->get_current_bot_id($session_id);
10430
10431 // Get system prompt instructions using centralized function
10432 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10433
10434 // Ensure conversation_history is an array
10435 if (!is_array($conversation_history)) {
10436 $conversation_history = array();
10437 }
10438
10439 // Format conversation history for DeepSeek
10440 $formatted_conversation = array();
10441
10442 $formatted_conversation[] = array(
10443 'role' => 'system',
10444 'content' => $system_prompt_instructions . " " . $relevant_content
10445 );
10446
10447 foreach ($conversation_history as $message) {
10448 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
10449 $role = $message['role'];
10450 if ($role === 'bot' || $role === 'agent') {
10451 $role = 'assistant';
10452 }
10453 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
10454 $role = 'user';
10455 }
10456 $formatted_conversation[] = array(
10457 'role' => $role,
10458 'content' => $message['content']
10459 );
10460 }
10461 }
10462
10463 // Check if we can actually stream
10464 if (headers_sent() || !function_exists('curl_init')) {
10465 // Fallback to regular response with testing data
10466 //error_log("MxChat: DeepSeek streaming not possible, falling back to regular response");
10467 $regular_response = $this->mxchat_generate_response_deepseek(
10468 $selected_model,
10469 $deepseek_api_key,
10470 $conversation_history,
10471 $relevant_content,
10472 $session_id
10473 );
10474
10475 // Save bot response to transcript
10476 if (!empty($regular_response) && !empty($session_id)) {
10477 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
10478 }
10479
10480 $response_data = [
10481 'text' => $regular_response,
10482 'html' => '',
10483 'session_id' => $session_id
10484 ];
10485
10486 if ($testing_data !== null) {
10487 $response_data['testing_data'] = $testing_data;
10488 //error_log("MxChat Testing: Added testing data to DeepSeek fallback response");
10489 }
10490
10491 header('Content-Type: application/json');
10492 echo json_encode($response_data);
10493 return true;
10494 }
10495
10496 // Prepare the request body with stream: true
10497 $body = json_encode([
10498 'model' => $selected_model,
10499 'messages' => $formatted_conversation,
10500 'temperature' => 0.8,
10501 'stream' => true,
10502 // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
10503 // long silent reasoning before the first delta); the widget wants
10504 // the legacy deepseek-chat semantics = non-thinking.
10505 'thinking' => ['type' => 'disabled']
10506 ]);
10507
10508 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
10509
10510 $captured_status_code = 0;
10511 $captured_body_pre_stream = '';
10512 $full_response = '';
10513 $stream_started = false;
10514 $buffer = '';
10515 $errno = 0;
10516 $http_code = 0;
10517 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
10518 $backoff_ms = array(0, 750, 2000);
10519
10520 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
10521 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
10522 usleep($backoff_ms[$attempt] * 1000);
10523 }
10524
10525 $captured_status_code = 0;
10526 $captured_body_pre_stream = '';
10527 $full_response = '';
10528 $stream_started = false;
10529 $buffer = '';
10530
10531 $ch = curl_init();
10532 curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
10533 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
10534 curl_setopt($ch, CURLOPT_POST, true);
10535 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
10536 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
10537 'Content-Type: application/json',
10538 'Authorization: Bearer ' . $deepseek_api_key
10539 ));
10540 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
10541 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
10542
10543 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
10544 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
10545 $captured_status_code = (int) $m[1];
10546 }
10547 return strlen($header);
10548 });
10549
10550 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
10551 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
10552 $captured_body_pre_stream .= $data;
10553 return strlen($data);
10554 }
10555
10556 if (!$this->streaming_headers_sent) {
10557 $this->setup_streaming_headers();
10558 }
10559
10560 if (!$stream_started && $testing_data !== null) {
10561 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
10562 flush();
10563 $stream_started = true;
10564 }
10565
10566 $buffer .= $data;
10567 $lines = explode("\n", $buffer);
10568 $buffer = array_pop($lines);
10569
10570 foreach ($lines as $line) {
10571 if (trim($line) === '') {
10572 continue;
10573 }
10574 if (strpos($line, 'data: ') !== 0) {
10575 continue;
10576 }
10577
10578 $json_str = substr($line, 6);
10579
10580 if (trim($json_str) === '[DONE]') {
10581 echo "data: [DONE]\n\n";
10582 flush();
10583 continue;
10584 }
10585
10586 $json = json_decode(trim($json_str), true);
10587 if ($json && isset($json['choices'][0]['delta']['content'])) {
10588 $content = $json['choices'][0]['delta']['content'];
10589 $full_response .= $content;
10590 echo "data: " . json_encode(['content' => $content]) . "\n\n";
10591 flush();
10592 }
10593 }
10594
10595 return strlen($data);
10596 });
10597
10598 $response = curl_exec($ch);
10599 $errno = curl_errno($ch);
10600 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
10601 curl_close($ch);
10602
10603 if (!$errno && $http_code === 200) {
10604 break;
10605 }
10606
10607 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
10608 $can_retry = !$this->streaming_headers_sent
10609 && ($attempt + 1) < $max_attempts
10610 && $is_transient;
10611
10612 if (defined('WP_DEBUG') && WP_DEBUG) {
10613 error_log(sprintf(
10614 '[MxChat] deepseek_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
10615 $attempt + 1, $max_attempts, $http_code, $errno,
10616 $is_transient ? 'yes' : 'no',
10617 $can_retry ? 'Retrying.' : 'Giving up.'
10618 ));
10619 }
10620
10621 if (!$can_retry) {
10622 break;
10623 }
10624 }
10625
10626 if ($errno || $http_code !== 200) {
10627 return $this->mxchat_stream_emit_fallback(
10628 'openai',
10629 $this->mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id),
10630 $session_id,
10631 $testing_data
10632 );
10633 }
10634
10635 // Save the complete response to maintain chat persistence
10636 if (!empty($full_response) && !empty($session_id)) {
10637 // Prepare RAG context for streaming response
10638 $rag_context_for_storage = null;
10639 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
10640 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
10641
10642 if ($has_rag_data || $has_action_data) {
10643 $rag_context_for_storage = [];
10644
10645 if ($has_rag_data) {
10646 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
10647 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
10648 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
10649 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
10650 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10651 }
10652
10653 if ($has_action_data) {
10654 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10655 }
10656 }
10657 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
10658 }
10659
10660 return true; // Indicate streaming completed successfully
10661
10662 } catch (Exception $e) {
10663 return $this->mxchat_stream_emit_fallback(
10664 'openai',
10665 $this->mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content),
10666 $session_id,
10667 $testing_data
10668 );
10669 }
10670 }
10671
10672
10673 /**
10674 * Extract a human-readable error message from a decoded provider response body.
10675 * Providers disagree on shape: OpenAI/Anthropic/Google nest it (error.message),
10676 * xAI returns a plain string under 'error'. Mirrors mxchat-vision's shipped
10677 * extract_provider_error(); deliberately hint-free in core (vision's too-small
10678 * image hint is an upload concern that doesn't apply here).
10679 *
10680 * @param mixed $decoded_body Decoded JSON body (array), or whatever json_decode returned.
10681 * @param string $fallback Message to return when no provider text is found.
10682 * @return string
10683 */
10684 private function extract_provider_error($decoded_body, $fallback) {
10685 $message = '';
10686 if (isset($decoded_body['error']['message']) && is_string($decoded_body['error']['message']) && $decoded_body['error']['message'] !== '') {
10687 $message = $decoded_body['error']['message'];
10688 } elseif (isset($decoded_body['error']) && is_string($decoded_body['error']) && $decoded_body['error'] !== '') {
10689 $message = $decoded_body['error'];
10690 }
10691
10692 if ($message === '') {
10693 return $fallback;
10694 }
10695
10696 return $message;
10697 }
10698
10699 /**
10700 * plan-4aa8e5: a provider 200 whose body parses to no text must never reach
10701 * the widget as a silent empty bot bubble. Standard error shape for that
10702 * case, preferring the body's own explanation — error.message first (the
10703 * 950731 passthrough pattern), then the Responses API's
10704 * incomplete_details.reason (e.g. "max_output_tokens") — before the generic
10705 * retry message.
10706 */
10707 private function mxchat_empty_completion_error($decoded_body, $provider_label) {
10708 $reason = '';
10709 if (isset($decoded_body['error']['message']) && is_string($decoded_body['error']['message']) && $decoded_body['error']['message'] !== '') {
10710 $reason = $decoded_body['error']['message'];
10711 } elseif (isset($decoded_body['incomplete_details']['reason']) && is_string($decoded_body['incomplete_details']['reason']) && $decoded_body['incomplete_details']['reason'] !== '') {
10712 $reason = sprintf(__('response incomplete: %s', 'mxchat'), $decoded_body['incomplete_details']['reason']);
10713 }
10714
10715 $message = ($reason !== '')
10716 ? sprintf(esc_html__('%1$s returned an empty response (%2$s). Please try again.', 'mxchat'), $provider_label, esc_html($reason))
10717 : sprintf(esc_html__('%s returned an empty response. Please try again.', 'mxchat'), $provider_label);
10718
10719 return [
10720 'error' => $message,
10721 'error_code' => 'empty_completion',
10722 'provider' => strtolower($provider_label),
10723 ];
10724 }
10725
10726 private function mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id = '') {
10727 try {
10728 if (!is_array($conversation_history)) {
10729 $conversation_history = array();
10730 }
10731
10732 $bot_id = $this->get_current_bot_id($session_id);
10733 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10734
10735 $formatted_conversation = array();
10736
10737 $formatted_conversation[] = array(
10738 'role' => 'system',
10739 'content' => $system_prompt_instructions . " " . $relevant_content
10740 );
10741
10742 foreach ($conversation_history as $message) {
10743 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
10744 $role = $message['role'];
10745
10746 if ($role === 'bot' || $role === 'agent') {
10747 $role = 'assistant';
10748 }
10749 if (!in_array($role, ['system', 'assistant', 'user'])) {
10750 $role = 'user';
10751 }
10752
10753 $formatted_conversation[] = array(
10754 'role' => $role,
10755 'content' => $message['content']
10756 );
10757 }
10758 }
10759
10760 $body = json_encode([
10761 'model' => $selected_model,
10762 'messages' => $formatted_conversation,
10763 'temperature' => 1,
10764 ]);
10765
10766 $args = [
10767 'body' => $body,
10768 'headers' => [
10769 'Content-Type' => 'application/json',
10770 'Authorization' => 'Bearer ' . $openrouter_api_key,
10771 'HTTP-Referer' => home_url(),
10772 'X-Title' => get_bloginfo('name'),
10773 ],
10774 'timeout' => 60,
10775 'redirection' => 5,
10776 'blocking' => true,
10777 'httpversion' => '1.0',
10778 'sslverify' => true,
10779 ];
10780
10781 $response = $this->mxchat_provider_call_with_retry('https://openrouter.ai/api/v1/chat/completions', $args, 'openai');
10782
10783 if (is_wp_error($response)) {
10784 $error_message = $response->get_error_message();
10785 return [
10786 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'OpenRouter'),
10787 'error_code' => 'openrouter_connection_error',
10788 'provider' => 'openrouter'
10789 ];
10790 }
10791
10792 $status_code = wp_remote_retrieve_response_code($response);
10793 if ($status_code !== 200) {
10794 $response_body = wp_remote_retrieve_body($response);
10795 $decoded_response = json_decode($response_body, true);
10796
10797 $error_message = $this->extract_provider_error($decoded_response, 'HTTP Error ' . $status_code);
10798
10799 return [
10800 'error' => esc_html__('OpenRouter API error: ', 'mxchat') . esc_html($error_message),
10801 'error_code' => 'openrouter_api_error',
10802 'provider' => 'openrouter',
10803 'status_code' => $status_code
10804 ];
10805 }
10806
10807 $response_body = wp_remote_retrieve_body($response);
10808 $decoded_response = json_decode($response_body, true);
10809
10810 if (isset($decoded_response['choices'][0]['message']['content'])) {
10811 $text = trim($decoded_response['choices'][0]['message']['content']);
10812 if ($text !== '') {
10813 return $text;
10814 }
10815 return $this->mxchat_empty_completion_error($decoded_response, 'OpenRouter');
10816 } else {
10817 return [
10818 'error' => esc_html__('Unexpected response format from OpenRouter.', 'mxchat'),
10819 'error_code' => 'openrouter_response_format_error',
10820 'provider' => 'openrouter'
10821 ];
10822 }
10823 } catch (Exception $e) {
10824 return [
10825 'error' => esc_html__('System error when processing OpenRouter request: ', 'mxchat') . esc_html($e->getMessage()),
10826 'error_code' => 'openrouter_exception',
10827 'provider' => 'openrouter'
10828 ];
10829 }
10830 }
10831
10832 /**
10833 * Build a chat-bubble-safe message for a non-200 provider (chat) error.
10834 *
10835 * Visitors must NEVER see raw API internals (model names, key/billing/quota
10836 * text). Admins (manage_options) get an actionable hint — and, for the common
10837 * "model not available on this key" case, a direct pointer to change the model
10838 * (the site owner can fix it in one click). Anthropic returns model-access as a
10839 * 4xx with a message like "Claude Fable 5 is not available. Please use Opus 4.8."
10840 *
10841 * Provider-agnostic by design (reusable for the xai/gemini/deepseek branches),
10842 * but Anthropic is the confirmed, reproduced case wired up here (plan 1d3b0f).
10843 *
10844 * @param int $http_code HTTP status from the provider.
10845 * @param string $error_message Raw provider error.message (may be empty).
10846 * @param string $provider_label Human provider name, e.g. 'Anthropic'.
10847 * @return string Message safe to render as a chat bubble.
10848 */
10849 private function mxchat_friendly_chat_error($http_code, $error_message, $provider_label = '') {
10850 $raw = trim((string) $error_message);
10851
10852 // Detect a model-access / availability problem the site owner can fix by
10853 // choosing a different model. (Anthropic phrasing + the common API shapes.)
10854 $low = strtolower($raw);
10855 $is_model_access = (strpos($low, 'not available') !== false)
10856 || (strpos($low, 'does not have access') !== false)
10857 || (strpos($low, 'do not have access') !== false)
10858 || (strpos($low, 'does not exist') !== false) // OpenAI: "model `x` does not exist or you do not have access"
10859 || (strpos($low, 'model_not_found') !== false)
10860 || (strpos($low, 'not_found_error') !== false)
10861 || (strpos($low, 'model not found') !== false) // xAI
10862 || (strpos($low, 'not found') !== false) // Gemini: "models/x is not found for API version ..."
10863 || (strpos($low, 'permission_denied') !== false) // Gemini gated model
10864 || (strpos($low, 'permission denied') !== false);
10865
10866 if (current_user_can('manage_options')) {
10867 if ($is_model_access) {
10868 return $raw !== ''
10869 ? sprintf(
10870 /* translators: %s: raw provider error detail */
10871 esc_html__('The selected AI model isn\'t available on your API key. Choose another model in MxChat → Settings. (Details: %s)', 'mxchat'),
10872 $raw
10873 )
10874 : esc_html__('The selected AI model isn\'t available on your API key. Choose another model in MxChat → Settings.', 'mxchat');
10875 }
10876 return $raw !== ''
10877 ? sprintf(
10878 /* translators: 1: provider label, 2: raw provider error detail */
10879 esc_html__('The AI provider (%1$s) returned an error: %2$s. Check your model and API key in MxChat → Settings.', 'mxchat'),
10880 $provider_label !== '' ? $provider_label : esc_html__('AI', 'mxchat'),
10881 $raw
10882 )
10883 : esc_html__('The AI provider returned an error. Check your model and API key in MxChat → Settings.', 'mxchat');
10884 }
10885
10886 // Visitors: friendly, generic, no internals leaked.
10887 return esc_html__('Sorry, I\'m having trouble responding right now. Please try again in a moment.', 'mxchat');
10888 }
10889
10890 private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id = '') {
10891 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
10892 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
10893 if ($selected_model === 'claude-opus-4-20250514') { $selected_model = 'claude-opus-4-8'; }
10894 elseif ($selected_model === 'claude-sonnet-4-20250514') { $selected_model = 'claude-sonnet-4-6'; }
10895
10896 // Get bot ID from session or request
10897 $bot_id = $this->get_current_bot_id($session_id);
10898
10899 // Get system prompt instructions using centralized function
10900 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10901
10902 // Clean and validate conversation history
10903 foreach ($conversation_history as &$message) {
10904 // Convert bot and agent roles to assistant
10905 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
10906 $message['role'] = 'assistant';
10907 }
10908
10909 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
10910 if (!in_array($message['role'], ['assistant', 'user'])) {
10911 $message['role'] = 'user';
10912 }
10913
10914 // Ensure content field exists
10915 if (!isset($message['content']) || empty($message['content'])) {
10916 $message['content'] = '';
10917 }
10918
10919 // Remove any unsupported fields
10920 $message = array_intersect_key($message, array_flip(['role', 'content']));
10921 }
10922
10923 // Add relevant content as the latest user message
10924 $conversation_history[] = [
10925 'role' => 'user',
10926 'content' => $relevant_content
10927 ];
10928
10929 // Build request body
10930 $payload = [
10931 'model' => $selected_model,
10932 'max_tokens' => 1000,
10933 'temperature' => 0.8,
10934 'messages' => $conversation_history,
10935 'system' => $system_prompt_instructions
10936 ];
10937 if ($this->mxchat_claude_omits_temperature($selected_model)) { unset($payload['temperature']); }
10938 $body = json_encode($payload);
10939
10940 // Set up API request
10941 $args = [
10942 'body' => $body,
10943 'headers' => [
10944 'Content-Type' => 'application/json',
10945 'x-api-key' => $claude_api_key,
10946 'anthropic-version' => '2023-06-01'
10947 ],
10948 'timeout' => 60,
10949 'redirection' => 5,
10950 'blocking' => true,
10951 'httpversion' => '1.0',
10952 'sslverify' => true,
10953 ];
10954
10955 // Make API request
10956 $response = $this->mxchat_provider_call_with_retry('https://api.anthropic.com/v1/messages', $args, 'anthropic');
10957
10958 // Check for WordPress errors
10959 if (is_wp_error($response)) {
10960 //error_log("Claude API request error: " . $response->get_error_message());
10961 return "Sorry, there was an error connecting to the API.";
10962 }
10963
10964 // Check HTTP response code
10965 $http_code = wp_remote_retrieve_response_code($response);
10966 if ($http_code !== 200) {
10967 $error_body = wp_remote_retrieve_body($response);
10968 //error_log("Claude API HTTP error: " . $http_code . " - " . $error_body);
10969
10970 // Try to extract error message from response
10971 $error_data = json_decode($error_body, true);
10972 $error_message = isset($error_data['error']['message']) ?
10973 $error_data['error']['message'] :
10974 "HTTP error " . $http_code;
10975
10976 // Surface an admin-actionable message (and a model-change pointer for the
10977 // model-access case) without leaking raw API internals to visitors. This
10978 // is the single chokepoint for BOTH the non-streaming and streaming Claude
10979 // paths (the stream's non-200 fallback re-enters this method). plan 1d3b0f.
10980 return $this->mxchat_friendly_chat_error($http_code, $error_message, 'Anthropic');
10981 }
10982
10983 // Parse response
10984 $response_body = json_decode(wp_remote_retrieve_body($response), true);
10985
10986 // Check for JSON decode errors
10987 if (json_last_error() !== JSON_ERROR_NONE) {
10988 //error_log("Claude API JSON decode error: " . json_last_error_msg());
10989 return "Sorry, there was an error processing the API response.";
10990 }
10991
10992 // Extract and validate response content. claude-fable-5 prepends a
10993 // thinking block to content even with no thinking param — take the first
10994 // TEXT block rather than content[0].
10995 if (isset($response_body['content']) && is_array($response_body['content'])) {
10996 foreach ($response_body['content'] as $block) {
10997 // plan-4aa8e5: skip empty text blocks — a 200 whose only text
10998 // block trims to '' must not render as a silent empty bubble.
10999 if (isset($block['type'], $block['text']) && $block['type'] === 'text' && trim($block['text']) !== '') {
11000 return trim($block['text']);
11001 }
11002 }
11003 return $this->mxchat_empty_completion_error($response_body, 'Claude');
11004 }
11005
11006 // Log unexpected response format
11007 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
11008 return "Sorry, I received an unexpected response format from the API.";
11009 }
11010 private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content, $session_id = '') {
11011 try {
11012 // Ensure conversation_history is an array
11013 if (!is_array($conversation_history)) {
11014 $conversation_history = array();
11015 }
11016
11017 // Get bot ID from session or request. plan eb9c38: resolve the real bot
11018 // from the session (was hardcoded '' → always default bot on multi-bot
11019 // installs) and fix the undefined $session_id that fed get_system_instructions.
11020 $bot_id = $this->get_current_bot_id($session_id);
11021
11022 // Get system prompt instructions using centralized function
11023 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11024
11025 // Create a new array for the formatted conversation
11026 $formatted_conversation = array();
11027
11028 // Add system message first
11029 $formatted_conversation[] = array(
11030 'role' => 'system',
11031 'content' => $system_prompt_instructions . " " . $relevant_content
11032 );
11033
11034 // Add the rest of the conversation history
11035 foreach ($conversation_history as $message) {
11036 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
11037 $role = $message['role'];
11038
11039 // Convert roles to supported format
11040 if ($role === 'bot' || $role === 'agent') {
11041 $role = 'assistant';
11042 }
11043 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
11044 $role = 'user';
11045 }
11046
11047 $formatted_conversation[] = array(
11048 'role' => $role,
11049 'content' => $message['content']
11050 );
11051 }
11052 }
11053
11054 // Build request body with optimal settings for fast responses
11055 $request_body = [
11056 'model' => $selected_model,
11057 'messages' => $formatted_conversation,
11058 'temperature' => 1,
11059 'stream' => false
11060 ];
11061
11062 // reasoning_effort — sourced from the core model catalog (plan-dcb71c);
11063 // frozen inline ladder lives in mxchat_reasoning_effort_fallback().
11064 $effort = $this->mxchat_reasoning_effort_for($selected_model, 'chat');
11065 if ($effort !== null) {
11066 $request_body['reasoning_effort'] = $effort;
11067 }
11068
11069 $body = json_encode($request_body);
11070
11071 $args = [
11072 'body' => $body,
11073 'headers' => [
11074 'Content-Type' => 'application/json',
11075 'Authorization' => 'Bearer ' . $api_key,
11076 ],
11077 'timeout' => 60,
11078 'redirection' => 5,
11079 'blocking' => true,
11080 'httpversion' => '1.0',
11081 'sslverify' => true,
11082 ];
11083
11084 $response = $this->mxchat_provider_call_with_retry('https://api.openai.com/v1/chat/completions', $args, 'openai');
11085
11086 if (is_wp_error($response)) {
11087 $error_message = $response->get_error_message();
11088 return [
11089 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'OpenAI'),
11090 'error_code' => 'openai_connection_error',
11091 'provider' => 'openai'
11092 ];
11093 }
11094
11095 $status_code = wp_remote_retrieve_response_code($response);
11096 if ($status_code !== 200) {
11097 $response_body = wp_remote_retrieve_body($response);
11098 $decoded_response = json_decode($response_body, true);
11099
11100 $error_message = isset($decoded_response['error']['message'])
11101 ? $decoded_response['error']['message']
11102 : 'HTTP Error ' . $status_code;
11103
11104 $error_type = isset($decoded_response['error']['type'])
11105 ? $decoded_response['error']['type']
11106 : 'unknown';
11107
11108 // Handle specific error types
11109 switch ($error_type) {
11110 case 'invalid_request_error':
11111 if (strpos($error_message, 'API key') !== false) {
11112 return [
11113 'error' => esc_html__('Invalid OpenAI API key. Please check your API key configuration.', 'mxchat'),
11114 'error_code' => 'openai_invalid_api_key',
11115 'provider' => 'openai'
11116 ];
11117 }
11118 break;
11119
11120 case 'authentication_error':
11121 return [
11122 'error' => esc_html__('Authentication failed with OpenAI. Please check your API key.', 'mxchat'),
11123 'error_code' => 'openai_auth_error',
11124 'provider' => 'openai'
11125 ];
11126
11127 case 'rate_limit_exceeded':
11128 return [
11129 'error' => esc_html__('OpenAI rate limit exceeded. Please try again later.', 'mxchat'),
11130 'error_code' => 'openai_rate_limit',
11131 'provider' => 'openai'
11132 ];
11133
11134 case 'quota_exceeded':
11135 return [
11136 'error' => esc_html__('OpenAI API quota exceeded. Please check your billing details.', 'mxchat'),
11137 'error_code' => 'openai_quota_exceeded',
11138 'provider' => 'openai'
11139 ];
11140 }
11141
11142 // Generic error fallback only — the typed cases above already produce
11143 // clean messages. Route the raw-tail generic case through the leak-safe
11144 // helper so visitors never see provider internals. plan 5da59a.
11145 return [
11146 'error' => $this->mxchat_friendly_chat_error($status_code, $error_message, 'OpenAI'),
11147 'error_code' => 'openai_api_error',
11148 'provider' => 'openai',
11149 'status_code' => $status_code
11150 ];
11151 }
11152
11153 $response_body = wp_remote_retrieve_body($response);
11154 $decoded_response = json_decode($response_body, true);
11155
11156 if (isset($decoded_response['choices'][0]['message']['content'])) {
11157 $text = trim($decoded_response['choices'][0]['message']['content']);
11158 if ($text !== '') {
11159 return $text;
11160 }
11161 return $this->mxchat_empty_completion_error($decoded_response, 'OpenAI');
11162 } else {
11163 return [
11164 'error' => esc_html__('Unexpected response format from OpenAI.', 'mxchat'),
11165 'error_code' => 'openai_response_format_error',
11166 'provider' => 'openai'
11167 ];
11168 }
11169 } catch (Exception $e) {
11170 return [
11171 'error' => esc_html__('System error when processing OpenAI request: ', 'mxchat') . esc_html($e->getMessage()),
11172 'error_code' => 'openai_exception',
11173 'provider' => 'openai'
11174 ];
11175 }
11176 }
11177
11178 private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id = '') {
11179 try {
11180 // Get bot ID from session or request
11181 $bot_id = $this->get_current_bot_id($session_id);
11182
11183 // Get system prompt instructions using centralized function
11184 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11185
11186 // Add system prompt to relevant content
11187 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
11188
11189 // Prepend system instructions to the conversation history
11190 array_unshift($conversation_history, [
11191 'role' => 'system',
11192 'content' => "Here are your instructions: " . $content_with_instructions
11193 ]);
11194
11195 // Ensure consistency: Replace 'bot' and 'agent' roles with supported values
11196 foreach ($conversation_history as &$message) {
11197 if ($message['role'] === 'bot') {
11198 $message['role'] = 'assistant';
11199 } elseif ($message['role'] === 'agent') {
11200 // Tag the message as coming from a live agent
11201 $message['role'] = 'assistant';
11202 if (!isset($message['metadata'])) {
11203 $message['metadata'] = ['source' => 'live_agent'];
11204 }
11205 }
11206
11207 // Ensure all roles are valid
11208 if (!in_array($message['role'], ['system', 'assistant', 'user', 'function', 'tool'])) {
11209 $message['role'] = 'user'; // Default to 'user'
11210 }
11211 }
11212
11213 // Build the request body
11214 $body = json_encode([
11215 'model' => $selected_model,
11216 'messages' => $conversation_history,
11217 'temperature' => 0.8,
11218 'stream' => false
11219 ]);
11220
11221 // Set up the API request
11222 $args = [
11223 'body' => $body,
11224 'headers' => [
11225 'Content-Type' => 'application/json',
11226 'Authorization' => 'Bearer ' . $xai_api_key,
11227 ],
11228 'timeout' => 60,
11229 'redirection' => 5,
11230 'blocking' => true,
11231 'httpversion' => '1.0',
11232 'sslverify' => true,
11233 ];
11234
11235 // Make the API request
11236 $response = $this->mxchat_provider_call_with_retry('https://api.x.ai/v1/chat/completions', $args, 'xai');
11237
11238 // Process the response
11239 if (is_wp_error($response)) {
11240 $error_message = $response->get_error_message();
11241 //error_log('X.AI API Error: ' . $error_message);
11242 return [
11243 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'X.AI'),
11244 'error_code' => 'xai_connection_error',
11245 'provider' => 'xai'
11246 ];
11247 }
11248
11249 $status_code = wp_remote_retrieve_response_code($response);
11250 if ($status_code !== 200) {
11251 $response_body = wp_remote_retrieve_body($response);
11252 $decoded_response = json_decode($response_body, true);
11253
11254 // Log the full response for debugging
11255 //error_log('X.AI Error Response: ' . print_r($decoded_response, true));
11256
11257 // Extract error message from X.AI's specific format
11258 $error_message = '';
11259
11260 // Check for direct error string (as seen in your logs)
11261 if (isset($decoded_response['error']) && is_string($decoded_response['error'])) {
11262 $error_message = $decoded_response['error'];
11263 }
11264 // Check for nested error object (OpenAI style)
11265 elseif (isset($decoded_response['error']['message'])) {
11266 $error_message = $decoded_response['error']['message'];
11267 }
11268 // Check for top-level message
11269 elseif (isset($decoded_response['message'])) {
11270 $error_message = $decoded_response['message'];
11271 }
11272 // Fallback
11273 else {
11274 $error_message = 'HTTP Error ' . $status_code;
11275 }
11276
11277 //error_log('X.AI API HTTP Error: ' . $status_code . ' - ' . $error_message);
11278
11279 // Check for API key errors using string matching
11280 if (stripos($error_message, 'api key') !== false ||
11281 stripos($error_message, 'incorrect api key') !== false ||
11282 stripos($error_message, 'invalid api key') !== false) {
11283 return [
11284 'error' => esc_html__('Invalid X.AI API key. Please check your API key configuration.', 'mxchat'),
11285 'error_code' => 'xai_invalid_api_key',
11286 'provider' => 'xai'
11287 ];
11288 }
11289
11290 // Authentication errors
11291 if ($status_code === 401 || $status_code === 403 ||
11292 stripos($error_message, 'auth') !== false) {
11293 return [
11294 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat') . ' ' . esc_html($error_message),
11295 'error_code' => 'xai_auth_error',
11296 'provider' => 'xai'
11297 ];
11298 }
11299
11300 // Model errors — keep the canned category text as a prefix, but carry the
11301 // provider's extracted reason (e.g. "Model not found: <id>") so the owner
11302 // sees the specific model/reason instead of only the generic category.
11303 if (stripos($error_message, 'model') !== false) {
11304 return [
11305 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat') . ' ' . esc_html($error_message),
11306 'error_code' => 'xai_invalid_model',
11307 'provider' => 'xai'
11308 ];
11309 }
11310
11311 // Rate limit errors
11312 if ($status_code === 429 ||
11313 stripos($error_message, 'rate') !== false ||
11314 stripos($error_message, 'limit') !== false) {
11315 return [
11316 'error' => esc_html__('X.AI rate limit exceeded. Please try again later.', 'mxchat'),
11317 'error_code' => 'xai_rate_limit',
11318 'provider' => 'xai'
11319 ];
11320 }
11321
11322 // Quota errors
11323 if (stripos($error_message, 'quota') !== false ||
11324 stripos($error_message, 'billing') !== false) {
11325 return [
11326 'error' => esc_html__('X.AI API quota exceeded. Please check your billing details.', 'mxchat'),
11327 'error_code' => 'xai_quota_exceeded',
11328 'provider' => 'xai'
11329 ];
11330 }
11331
11332 // Server errors
11333 if ($status_code >= 500) {
11334 return [
11335 'error' => esc_html__('X.AI service is currently unavailable. Please try again later.', 'mxchat'),
11336 'error_code' => 'xai_service_unavailable',
11337 'provider' => 'xai'
11338 ];
11339 }
11340
11341 // Generic error fallback. Route the user-facing text through the
11342 // leak-safe helper (admins get an actionable hint, visitors a generic
11343 // fallback) instead of echoing raw provider internals. Preserve the
11344 // structured contract (error_code/provider/status_code) for logging. plan 5da59a.
11345 return [
11346 'error' => $this->mxchat_friendly_chat_error($status_code, $error_message, 'xAI'),
11347 'error_code' => 'xai_api_error',
11348 'provider' => 'xai',
11349 'status_code' => $status_code
11350 ];
11351 }
11352
11353 $response_body = wp_remote_retrieve_body($response);
11354 $decoded_response = json_decode($response_body, true);
11355
11356 if (isset($decoded_response['choices'][0]['message']['content'])) {
11357 $text = trim($decoded_response['choices'][0]['message']['content']);
11358 if ($text !== '') {
11359 return $text;
11360 }
11361 return $this->mxchat_empty_completion_error($decoded_response, 'X.AI');
11362 } else {
11363 //error_log('X.AI API Response Format Error: ' . print_r($decoded_response, true));
11364 return [
11365 'error' => esc_html__('Unexpected response format from X.AI.', 'mxchat'),
11366 'error_code' => 'xai_response_format_error',
11367 'provider' => 'xai'
11368 ];
11369 }
11370 } catch (Exception $e) {
11371 //error_log('X.AI Exception: ' . $e->getMessage());
11372 return [
11373 'error' => esc_html__('System error when processing X.AI request: ', 'mxchat') . esc_html($e->getMessage()),
11374 'error_code' => 'xai_exception',
11375 'provider' => 'xai'
11376 ];
11377 }
11378
11379
11380 }
11381 private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id = '') {
11382 try {
11383 // Ensure conversation_history is an array
11384 if (!is_array($conversation_history)) {
11385 $conversation_history = array();
11386 }
11387
11388 // Get bot ID from session or request
11389 $bot_id = $this->get_current_bot_id($session_id);
11390
11391 // Get system prompt instructions using centralized function
11392 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11393
11394 // Create a new array for the formatted conversation
11395 $formatted_conversation = array();
11396
11397 // Add system message first
11398 $formatted_conversation[] = array(
11399 'role' => 'system',
11400 'content' => $system_prompt_instructions . " " . $relevant_content
11401 );
11402
11403 // Add the rest of the conversation history
11404 foreach ($conversation_history as $message) {
11405 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
11406 $role = $message['role'];
11407
11408 // Convert roles to supported format
11409 if ($role === 'bot' || $role === 'agent') {
11410 $role = 'assistant';
11411 }
11412 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
11413 $role = 'user';
11414 }
11415
11416 $formatted_conversation[] = array(
11417 'role' => $role,
11418 'content' => $message['content']
11419 );
11420 }
11421 }
11422
11423 $body = json_encode([
11424 'model' => $selected_model,
11425 'messages' => $formatted_conversation,
11426 'temperature' => 0.8,
11427 'stream' => false,
11428 // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
11429 // slow reasoning-first responses); the widget wants the legacy
11430 // deepseek-chat semantics = non-thinking.
11431 'thinking' => ['type' => 'disabled']
11432 ]);
11433
11434 $args = [
11435 'body' => $body,
11436 'headers' => [
11437 'Content-Type' => 'application/json',
11438 'Authorization' => 'Bearer ' . $deepseek_api_key,
11439 ],
11440 'timeout' => 60,
11441 'redirection' => 5,
11442 'blocking' => true,
11443 'httpversion' => '1.0',
11444 'sslverify' => true,
11445 ];
11446
11447 $response = $this->mxchat_provider_call_with_retry('https://api.deepseek.com/v1/chat/completions', $args, 'openai');
11448
11449 if (is_wp_error($response)) {
11450 $error_message = $response->get_error_message();
11451 //error_log('DeepSeek API Error: ' . $error_message);
11452 return [
11453 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'DeepSeek'),
11454 'error_code' => 'deepseek_connection_error',
11455 'provider' => 'deepseek'
11456 ];
11457 }
11458
11459 $status_code = wp_remote_retrieve_response_code($response);
11460 if ($status_code !== 200) {
11461 $response_body = wp_remote_retrieve_body($response);
11462 $decoded_response = json_decode($response_body, true);
11463
11464 $error_message = isset($decoded_response['error']['message'])
11465 ? $decoded_response['error']['message']
11466 : 'HTTP Error ' . $status_code;
11467
11468 $error_type = isset($decoded_response['error']['type'])
11469 ? $decoded_response['error']['type']
11470 : 'unknown';
11471
11472 //error_log('DeepSeek API HTTP Error: ' . $status_code . ' - ' . $error_message);
11473
11474 // Handle specific error types
11475 switch ($status_code) {
11476 case 401:
11477 return [
11478 'error' => esc_html__('Authentication failed with DeepSeek. Please check your API key.', 'mxchat'),
11479 'error_code' => 'deepseek_auth_error',
11480 'provider' => 'deepseek'
11481 ];
11482
11483 case 400:
11484 if (strpos($error_message, 'API key') !== false) {
11485 return [
11486 'error' => esc_html__('Invalid DeepSeek API key. Please check your API key configuration.', 'mxchat'),
11487 'error_code' => 'deepseek_invalid_api_key',
11488 'provider' => 'deepseek'
11489 ];
11490 }
11491 break;
11492
11493 case 429:
11494 if (strpos($error_message, 'quota') !== false) {
11495 return [
11496 'error' => esc_html__('DeepSeek API quota exceeded. Please check your billing details.', 'mxchat'),
11497 'error_code' => 'deepseek_quota_exceeded',
11498 'provider' => 'deepseek'
11499 ];
11500 } else {
11501 return [
11502 'error' => esc_html__('DeepSeek rate limit exceeded. Please try again later.', 'mxchat'),
11503 'error_code' => 'deepseek_rate_limit',
11504 'provider' => 'deepseek'
11505 ];
11506 }
11507
11508 case 500:
11509 case 502:
11510 case 503:
11511 case 504:
11512 return [
11513 'error' => esc_html__('DeepSeek service is currently unavailable. Please try again later.', 'mxchat'),
11514 'error_code' => 'deepseek_service_unavailable',
11515 'provider' => 'deepseek'
11516 ];
11517 }
11518
11519 // Generic error fallback — leak-safe helper (see plan 5da59a / 1d3b0f).
11520 return [
11521 'error' => $this->mxchat_friendly_chat_error($status_code, $error_message, 'DeepSeek'),
11522 'error_code' => 'deepseek_api_error',
11523 'provider' => 'deepseek',
11524 'status_code' => $status_code
11525 ];
11526 }
11527
11528 $response_body = wp_remote_retrieve_body($response);
11529 $decoded_response = json_decode($response_body, true);
11530
11531 if (isset($decoded_response['choices'][0]['message']['content'])) {
11532 $text = trim($decoded_response['choices'][0]['message']['content']);
11533 if ($text !== '') {
11534 return $text;
11535 }
11536 return $this->mxchat_empty_completion_error($decoded_response, 'DeepSeek');
11537 } else {
11538 //error_log('DeepSeek API Response Format Error: ' . print_r($decoded_response, true));
11539 return [
11540 'error' => esc_html__('Unexpected response format from DeepSeek.', 'mxchat'),
11541 'error_code' => 'deepseek_response_format_error',
11542 'provider' => 'deepseek'
11543 ];
11544 }
11545 } catch (Exception $e) {
11546 //error_log('DeepSeek Exception: ' . $e->getMessage());
11547 return [
11548 'error' => esc_html__('System error when processing DeepSeek request: ', 'mxchat') . esc_html($e->getMessage()),
11549 'error_code' => 'deepseek_exception',
11550 'provider' => 'deepseek'
11551 ];
11552 }
11553 }
11554 private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content, $session_id = '') {
11555 // Read-time remap: gemini-3-pro-preview was shut down March 9, 2026.
11556 // Auto-rescue existing installs whose saved model is the dead ID.
11557 if ($selected_model === 'gemini-3-pro-preview') {
11558 $selected_model = 'gemini-3.1-pro-preview';
11559 }
11560 // Get bot ID from session or request
11561 $bot_id = $this->get_current_bot_id($session_id);
11562
11563 // Get system prompt instructions using centralized function
11564 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11565
11566 // Add system prompt to relevant content
11567 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
11568
11569 // Format messages for Gemini API
11570 $formatted_messages = [];
11571
11572 // Add system message as the first user message with role prefix
11573 // Note: Gemini doesn't have a dedicated system role, so we use a prefixed user message
11574 $formatted_messages[] = [
11575 'role' => 'user',
11576 'parts' => [
11577 ['text' => "[System Instructions] " . $content_with_instructions]
11578 ]
11579 ];
11580
11581 // Add model response to acknowledge system instructions
11582 $formatted_messages[] = [
11583 'role' => 'model',
11584 'parts' => [
11585 ['text' => "I understand and will follow these instructions."]
11586 ]
11587 ];
11588
11589 // Process the rest of the conversation history
11590 $current_role = null;
11591 $current_parts = [];
11592
11593 foreach ($conversation_history as $message) {
11594 // Skip the first system message as we already handled it
11595 if ($message['role'] === 'system') {
11596 continue;
11597 }
11598
11599 // Map roles to Gemini format
11600 $gemini_role = '';
11601 if ($message['role'] === 'user') {
11602 $gemini_role = 'user';
11603 } else if (in_array($message['role'], ['assistant', 'bot', 'agent'])) {
11604 $gemini_role = 'model';
11605 } else {
11606 // Skip unsupported roles
11607 continue;
11608 }
11609
11610 // If we have a new role, add the previous message
11611 if ($current_role !== null && $current_role !== $gemini_role && !empty($current_parts)) {
11612 $formatted_messages[] = [
11613 'role' => $current_role,
11614 'parts' => $current_parts
11615 ];
11616 $current_parts = [];
11617 }
11618
11619 // Set current role and add text to parts
11620 $current_role = $gemini_role;
11621 $current_parts[] = ['text' => $message['content']];
11622 }
11623
11624 // Add the last message if there's content
11625 if ($current_role !== null && !empty($current_parts)) {
11626 $formatted_messages[] = [
11627 'role' => $current_role,
11628 'parts' => $current_parts
11629 ];
11630 }
11631
11632 // Built-in Web Search grounding for Gemini (plan 46b9ea).
11633 // The enable_web_search toggle historically routed ONLY to OpenAI's web_search
11634 // tool; for a Gemini chat model it was a silent no-op. Gemini grounds natively
11635 // (and free) via the Google Search tool, so when the toggle is on we attach it
11636 // here on the PLAIN dispatch path. The function-calling loop (mxchat_fc_loop_gemini)
11637 // is a SEPARATE path reached only when AI Tools are active, so grounding here
11638 // never double-fires with function calling.
11639 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
11640 // Gemini ids that do NOT support Google Search grounding (none today — every
11641 // shipped chat model is 2.x/3.x and grounds natively). Kept as the explicit
11642 // opt-out list mirroring the OpenAI $unsupported_web_search_models pattern.
11643 $gemini_unsupported_grounding = array();
11644 $grounding_active = $web_search_enabled && !in_array($selected_model, $gemini_unsupported_grounding, true);
11645
11646 // Build the request body
11647 $request_payload = [
11648 'contents' => $formatted_messages,
11649 'generationConfig' => [
11650 'temperature' => 0.7,
11651 'topP' => 0.95,
11652 'topK' => 40,
11653 'maxOutputTokens' => 8192,
11654 ],
11655 'safetySettings' => [
11656 [
11657 'category' => 'HARM_CATEGORY_HARASSMENT',
11658 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
11659 ],
11660 [
11661 'category' => 'HARM_CATEGORY_HATE_SPEECH',
11662 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
11663 ],
11664 [
11665 'category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
11666 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
11667 ],
11668 [
11669 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT',
11670 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
11671 ]
11672 ]
11673 ];
11674
11675 if ($grounding_active) {
11676 // Gemini 1.5 used the older google_search_retrieval shape; 2.0+ uses the
11677 // bare google_search tool. Branch by model family so a future 1.5 id still
11678 // grounds (no 1.5 ships today, so this resolves to google_search). The empty
11679 // tool config must serialize as a JSON object {}, not an array [].
11680 if (strpos($selected_model, 'gemini-1.5') !== false) {
11681 $request_payload['tools'] = [ ['google_search_retrieval' => new \stdClass()] ];
11682 } else {
11683 $request_payload['tools'] = [ ['google_search' => new \stdClass()] ];
11684 }
11685 }
11686
11687 $body = json_encode($request_payload);
11688
11689 // Prepare the API endpoint
11690 // Use v1beta for preview models (Gemini 3, experimental), v1 for stable models.
11691 // Grounding (the google_search tool) is a v1beta feature, so force v1beta whenever
11692 // it's active — otherwise a stable model on v1 would silently drop the tool.
11693 $api_version = ($grounding_active || strpos($selected_model, 'preview') !== false || strpos($selected_model, 'exp') !== false) ? 'v1beta' : 'v1';
11694 $api_endpoint = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
11695
11696 // Set up the API request
11697 $args = [
11698 'body' => $body,
11699 'headers' => [
11700 'Content-Type' => 'application/json',
11701 ],
11702 'timeout' => 60,
11703 'redirection' => 5,
11704 'blocking' => true,
11705 'httpversion' => '1.0',
11706 'sslverify' => true,
11707 ];
11708
11709 // Make the API request
11710 $response = $this->mxchat_provider_call_with_retry($api_endpoint, $args, 'gemini');
11711
11712 // Process the response
11713 if (is_wp_error($response)) {
11714 // plan b13282: route the transport-error string through the leak-safe helper
11715 // (admin-actionable, generic for visitors) instead of echoing the raw WP HTTP
11716 // error. http_code 0 = no HTTP response, so the helper uses the generic branch.
11717 return $this->mxchat_friendly_chat_error(0, $response->get_error_message(), 'Gemini');
11718 }
11719
11720 $response_body = json_decode(wp_remote_retrieve_body($response), true);
11721
11722 // Handle potential errors in the response. Gemini surfaces errors as a
11723 // 200/non-200 body with an `error` envelope; route the user-facing text
11724 // through the leak-safe helper (admin-actionable, no visitor leak) rather
11725 // than echoing the raw provider message. plan 5da59a.
11726 if (isset($response_body['error'])) {
11727 //error_log('Gemini API Error: ' . json_encode($response_body['error']));
11728 $gemini_error_message = isset($response_body['error']['message'])
11729 ? $response_body['error']['message']
11730 : 'Unknown error';
11731 $gemini_http_code = wp_remote_retrieve_response_code($response);
11732 return $this->mxchat_friendly_chat_error($gemini_http_code, $gemini_error_message, 'Gemini');
11733 }
11734
11735 // Extract the response text
11736 if (isset($response_body['candidates'][0]['content']['parts'][0]['text'])) {
11737 $text = trim($response_body['candidates'][0]['content']['parts'][0]['text']);
11738 if ($text !== '') {
11739 return $text;
11740 }
11741 return $this->mxchat_empty_completion_error($response_body, 'Gemini');
11742 } else {
11743 //error_log('Unexpected Gemini API response format: ' . json_encode($response_body));
11744 return "Sorry, I couldn't process that request. The response format was unexpected.";
11745 }
11746 }
11747
11748
11749 public function test_streaming_request() {
11750 $options = get_option('mxchat_options', []);
11751 $model = $options['model'] ?? 'gpt-5.1-chat-latest';
11752
11753 // Detect provider from model prefix
11754 $provider = strtolower(explode('-', $model)[0]);
11755
11756 $sample_prompt = 'Hello! Can you stream this response back to me?';
11757 $messages = [['role' => 'user', 'content' => $sample_prompt]];
11758 $headers = [];
11759 $body = [];
11760 $url = '';
11761 $api_key = '';
11762
11763 switch ($provider) {
11764 case 'gpt':
11765 case 'o1':
11766 $api_key = $options['api_key'] ?? '';
11767 if (empty($api_key)) return '❌ Missing API key for OpenAI';
11768 $url = 'https://api.openai.com/v1/chat/completions';
11769 $headers = [
11770 'Content-Type: application/json',
11771 'Authorization: Bearer ' . $api_key
11772 ];
11773 $body = [
11774 'model' => $model,
11775 'messages' => $messages,
11776 'stream' => true
11777 ];
11778 break;
11779
11780 case 'claude':
11781 $api_key = $options['claude_api_key'] ?? '';
11782 if (empty($api_key)) return '❌ Missing API key for Claude';
11783 $url = 'https://api.anthropic.com/v1/messages';
11784 $headers = [
11785 'Content-Type: application/json',
11786 'x-api-key: ' . $api_key,
11787 'anthropic-version: 2023-06-01'
11788 ];
11789 $body = [
11790 'model' => $model,
11791 'messages' => $messages,
11792 'max_tokens' => 100,
11793 'stream' => true
11794 ];
11795 break;
11796
11797 case 'grok':
11798 $api_key = $options['xai_api_key'] ?? '';
11799 if (empty($api_key)) return '❌ Missing API key for X.AI';
11800 $url = 'https://api.x.ai/v1/chat/completions';
11801 $headers = [
11802 'Content-Type: application/json',
11803 'Authorization: Bearer ' . $api_key
11804 ];
11805 $body = [
11806 'model' => $model,
11807 'messages' => $messages,
11808 'stream' => true
11809 ];
11810 break;
11811
11812 case 'deepseek':
11813 if (empty($deepseek_api_key)) {
11814 $error_response = [
11815 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
11816 'error_code' => 'missing_deepseek_api_key'
11817 ];
11818 if ($testing_data !== null) {
11819 $error_response['testing_data'] = $testing_data;
11820 }
11821 return $error_response;
11822 }
11823 if ($streaming) {
11824 return $this->mxchat_generate_response_deepseek_stream(
11825 $selected_model,
11826 $deepseek_api_key,
11827 $conversation_history,
11828 $relevant_content,
11829 $session_id,
11830 $testing_data // Pass testing data
11831 );
11832 } else {
11833 $response = $this->mxchat_generate_response_deepseek(
11834 $selected_model,
11835 $deepseek_api_key,
11836 $conversation_history,
11837 $relevant_content,
11838 $session_id
11839 );
11840 }
11841 break;
11842
11843 case 'gemini':
11844 $api_key = $options['gemini_api_key'] ?? '';
11845 if (empty($api_key)) return '❌ Missing API key for Gemini';
11846 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':streamGenerateContent?key=' . $api_key;
11847 $headers = ['Content-Type: application/json'];
11848 $body = [
11849 'contents' => [['role' => 'user', 'parts' => [['text' => $sample_prompt]]]],
11850 'generationConfig' => ['temperature' => 0.7]
11851 ];
11852 break;
11853
11854 default:
11855 return '❌ Unsupported provider: ' . $provider;
11856 }
11857
11858 // Do the actual streaming test
11859 $ch = curl_init($url);
11860 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
11861 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
11862 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
11863 curl_setopt($ch, CURLOPT_TIMEOUT, 15);
11864 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
11865
11866 $response = curl_exec($ch);
11867 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
11868 $error = curl_error($ch);
11869 curl_close($ch);
11870
11871 if ($error) return "❌ cURL error: $error";
11872 if ($http_code !== 200) {
11873 $error_message = json_decode($response, true)['error']['message'] ?? 'Unknown';
11874 return "❌ HTTP $http_code: $error_message";
11875 }
11876
11877 return true;
11878 }
11879
11880 public function mxchat_dismiss_pre_chat_message() {
11881 // Get and sanitize the user identifier
11882 $user_id = $this->mxchat_get_user_identifier();
11883 $user_id = sanitize_key($user_id);
11884
11885 // Set a transient to track that the user has dismissed the pre-chat message
11886 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
11887 set_transient($transient_key, true, DAY_IN_SECONDS);
11888
11889 wp_send_json_success();
11890 }
11891
11892 public function mxchat_check_pre_chat_message_status() {
11893 // Get and sanitize the user identifier
11894 $user_id = $this->mxchat_get_user_identifier();
11895 $user_id = sanitize_key($user_id);
11896
11897 // Check if the transient exists (i.e., if the message was dismissed)
11898 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
11899 $dismissed = get_transient($transient_key);
11900
11901 // Log the result to see if it's being set correctly
11902 //error_log("Check pre-chat message dismissed for $user_id: " . ($dismissed ? 'Yes' : 'No'));
11903
11904 if ($dismissed) {
11905 wp_send_json_success(['dismissed' => true]);
11906 } else {
11907 wp_send_json_success(['dismissed' => false]);
11908 }
11909
11910 wp_die();
11911 }
11912
11913 private function mxchat_calculate_cosine_similarity($vectorA, $vectorB) {
11914 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
11915 return 0;
11916 }
11917
11918 $dotProduct = array_sum(array_map(function ($a, $b) {
11919 return $a * $b;
11920 }, $vectorA, $vectorB));
11921 $normA = sqrt(array_sum(array_map(function ($a) {
11922 return $a * $a;
11923 }, $vectorA)));
11924 $normB = sqrt(array_sum(array_map(function ($b) {
11925 return $b * $b;
11926 }, $vectorB)));
11927
11928 if ($normA == 0 || $normB == 0) {
11929 return 0;
11930 }
11931
11932 return $dotProduct / ($normA * $normB);
11933 }
11934
11935
11936 public function mxchat_enqueue_scripts_styles($force = false) {
11937 // Idempotency guard (plan-915355): the smart-asset-loading safety net in
11938 // render_chatbot_shortcode() may invoke this method a second time (or on
11939 // every shortcode render). Run the body at most once per request so the
11940 // nonce, dynamic-settings merge, delayed transient write, and wp_footer
11941 // loader action never happen twice.
11942 static $did_run = false;
11943 if ($did_run) {
11944 return;
11945 }
11946
11947 // Smart asset loading gate (plan-915355, opt-in, default OFF — toggle in
11948 // MxChat → Settings → Optimization → Script Loading). When enabled and the
11949 // shared display decision says the widget won't render on this request,
11950 // skip all front-end assets. $force (the shortcode safety net) bypasses
11951 // the gate because at that point the widget IS rendering. Note: bail
11952 // WITHOUT setting $did_run, so a later forced call can still enqueue.
11953 if (!$force
11954 && class_exists('MxChat_Public')
11955 && MxChat_Public::is_smart_asset_loading_enabled()
11956 && !MxChat_Public::should_load_assets()) {
11957 return;
11958 }
11959
11960 $did_run = true;
11961
11962 // Fetch options from the database first to check loading strategy
11963 $this->options = get_option('mxchat_options');
11964 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
11965
11966 // Always enqueue CSS immediately
11967 wp_enqueue_style(
11968 'mxchat-chat-css',
11969 plugin_dir_url(__FILE__) . '../css/chat-style.css',
11970 array(),
11971 MXCHAT_VERSION
11972 );
11973
11974 // Handle script loading based on strategy
11975 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
11976 // Enqueue the script normally
11977 wp_enqueue_script(
11978 'mxchat-chat-js',
11979 plugin_dir_url(__FILE__) . '../js/chat-script.js',
11980 array('jquery'),
11981 MXCHAT_VERSION,
11982 true
11983 );
11984
11985 // Add defer attribute if strategy is 'defer'
11986 if ($loading_strategy === 'defer') {
11987 wp_script_add_data('mxchat-chat-js', 'strategy', 'defer');
11988 }
11989 } else {
11990 // For delay or interaction-based loading, we'll use a custom loader
11991 // Don't enqueue the main script - we'll load it dynamically
11992 add_action('wp_footer', array($this, 'mxchat_output_delayed_script_loader'), 99);
11993 }
11994
11995 $prompts_options = get_option('mxchat_prompts_options', array());
11996
11997 // Check if AI theme is active - if so, skip inline colors in JavaScript
11998 $theme_options = get_option('mxchat_theme_options', array());
11999 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
12000 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
12001 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
12002
12003 // Prepare settings for JavaScript
12004 $style_settings = array(
12005 'ajax_url' => admin_url('admin-ajax.php'),
12006 // The chat-send nonce is now fetched per-request from /wp-json/mxchat/v1/nonce
12007 // (plan-6a68c9) so it never sits in cached HTML. We still emit a nonce here
12008 // as a one-shot fallback for the first interaction on a fresh page load
12009 // (so the very first chat-send doesn't need to wait for a REST round-trip),
12010 // but the widget refetches before each subsequent send.
12011 'nonce' => wp_create_nonce('mxchat_chat_send'),
12012 'rest_url' => esc_url_raw(trailingslashit(rest_url('mxchat/v1'))),
12013 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
12014 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
12015 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
12016 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
12017 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
12018 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
12019 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
12020 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
12021 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
12022 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
12023 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
12024 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
12025 'icon_color' => $this->options['icon_color'] ?? '#fff',
12026 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
12027 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
12028 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
12029 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
12030 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
12031 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
12032 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
12033 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
12034 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
12035 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off', // FIXED
12036 'initial_email_state' => null, // Also fixed this undefined variable
12037 'skip_email_check' => true,
12038 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
12039 'skip_inline_colors' => $skip_inline_colors,
12040 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array(),
12041 );
12042
12043 // Behavior gates + labels (model, streaming, rate-limit copy, toolbar,
12044 // print/transcript, satisfaction rating) come from the shared
12045 // dynamic-settings method so this inline payload and the first-open
12046 // refresh endpoint can never drift (plan-32db95).
12047 $style_settings = array_merge($style_settings, $this->get_dynamic_widget_settings());
12048
12049 // For normal/defer loading, use wp_localize_script.
12050 // For delayed loading, nothing is localized or stored here: the delayed
12051 // loader (mxchat_output_delayed_script_loader) rebuilds the full settings
12052 // array inline from options and never reads any stored copy.
12053 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
12054 wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
12055 } else {
12056 // Late-render fallback (plan-915355): when the shortcode safety net
12057 // forces this method during/after wp_footer (footer widget areas, late
12058 // builder regions), the wp_footer:99 loader action registered above may
12059 // already be past its slot. Emit the loader inline right now; its
12060 // emitted-once guard prevents double output if :99 still fires.
12061 if ($force && did_action('wp_footer')) {
12062 $this->mxchat_output_delayed_script_loader();
12063 }
12064 }
12065 }
12066
12067 /**
12068 * Output the delayed script loader for performance optimization
12069 */
12070 public function mxchat_output_delayed_script_loader() {
12071 // Emitted-once guard (plan-915355): this can now be reached both via the
12072 // wp_footer:99 action and via the late-render inline fallback in
12073 // mxchat_enqueue_scripts_styles(). The loader must print exactly once.
12074 static $emitted = false;
12075 if ($emitted) {
12076 return;
12077 }
12078 $emitted = true;
12079
12080 $this->options = get_option('mxchat_options');
12081 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
12082 $script_url = plugin_dir_url(__FILE__) . '../js/chat-script.js?ver=' . MXCHAT_VERSION;
12083
12084 // Get the stored settings
12085 $prompts_options = get_option('mxchat_prompts_options', array());
12086 $theme_options = get_option('mxchat_theme_options', array());
12087 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
12088 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
12089 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
12090
12091 $style_settings = array(
12092 'ajax_url' => admin_url('admin-ajax.php'),
12093 // Per-request nonce — see plan-6a68c9; widget fetches via /wp-json/mxchat/v1/nonce
12094 // before each send. This inline value is a one-shot fallback for the first interaction.
12095 'nonce' => wp_create_nonce('mxchat_chat_send'),
12096 'rest_url' => esc_url_raw(trailingslashit(rest_url('mxchat/v1'))),
12097 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
12098 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
12099 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
12100 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
12101 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
12102 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
12103 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
12104 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
12105 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
12106 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
12107 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
12108 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
12109 'icon_color' => $this->options['icon_color'] ?? '#fff',
12110 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
12111 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
12112 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
12113 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
12114 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
12115 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
12116 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
12117 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
12118 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
12119 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off',
12120 'initial_email_state' => null,
12121 'skip_email_check' => true,
12122 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
12123 'skip_inline_colors' => $skip_inline_colors,
12124 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array(),
12125 );
12126
12127 // Behavior gates + labels (model, streaming, rate-limit copy, toolbar,
12128 // print/transcript, satisfaction rating) come from the shared
12129 // dynamic-settings method so this inline payload and the first-open
12130 // refresh endpoint can never drift (plan-32db95).
12131 $style_settings = array_merge($style_settings, $this->get_dynamic_widget_settings());
12132
12133 // Determine delay time based on strategy
12134 $delay_ms = 0;
12135 switch ($loading_strategy) {
12136 case 'delay_1s':
12137 $delay_ms = 1000;
12138 break;
12139 case 'delay_3s':
12140 $delay_ms = 3000;
12141 break;
12142 case 'delay_5s':
12143 $delay_ms = 5000;
12144 break;
12145 }
12146
12147 ?>
12148 <script type="text/javascript">
12149 (function() {
12150 var mxchatLoaded = false;
12151 var mxchatChat = <?php echo wp_json_encode($style_settings); ?>;
12152 window.mxchatChat = mxchatChat;
12153
12154 function loadMxChatScript() {
12155 if (mxchatLoaded) return;
12156 mxchatLoaded = true;
12157
12158 function appendChatScript() {
12159 var script = document.createElement('script');
12160 script.src = <?php echo wp_json_encode($script_url); ?>;
12161 script.type = 'text/javascript';
12162 document.body.appendChild(script);
12163 }
12164
12165 if (typeof jQuery !== 'undefined') {
12166 appendChatScript();
12167 } else {
12168 var jq = document.createElement('script');
12169 jq.src = <?php echo wp_json_encode(includes_url('js/jquery/jquery.min.js')); ?>;
12170 jq.onload = appendChatScript;
12171 document.body.appendChild(jq);
12172 }
12173 }
12174
12175 <?php if ($loading_strategy === 'on_interaction'): ?>
12176 // Load on user interaction
12177 var events = ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'];
12178 events.forEach(function(evt) {
12179 window.addEventListener(evt, loadMxChatScript, {once: true, passive: true});
12180 });
12181 // Fallback: load after 8 seconds if no interaction
12182 setTimeout(loadMxChatScript, 8000);
12183 <?php else: ?>
12184 // Load after specified delay
12185 setTimeout(loadMxChatScript, <?php echo intval($delay_ms); ?>);
12186 <?php endif; ?>
12187 })();
12188 </script>
12189 <?php
12190 }
12191
12192 /**
12193 * Setup the cron jobs for rate limits with guard against multiple calls
12194 */
12195 public function setup_rate_limit_cron_jobs() {
12196 // Add a guard to prevent multiple rapid calls
12197 $last_setup = get_transient('mxchat_cron_setup_guard');
12198 if ($last_setup && (time() - $last_setup) < 60) {
12199 // Don't run again if we ran less than 60 seconds ago
12200 return;
12201 }
12202
12203 // Set the guard
12204 set_transient('mxchat_cron_setup_guard', time(), 300); // 5 minutes
12205
12206 try {
12207 // First, check if WordPress cron is disabled
12208 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
12209 //error_log('MxChat: WordPress cron is disabled (DISABLE_WP_CRON = true), using fallback system');
12210 $this->setup_fallback_rate_limit_system();
12211 return;
12212 }
12213
12214 // Check if cron is already scheduled - if so, don't mess with it
12215 if (wp_next_scheduled('mxchat_reset_rate_limits')) {
12216 //error_log('MxChat: Rate limit cron already scheduled, skipping setup');
12217 return;
12218 }
12219
12220 // Clear any orphaned hooks (but don't loop indefinitely)
12221 $hooks_to_clear = [
12222 'mxchat_reset_rate_limits',
12223 'mxchat_reset_hourly_rate_limits',
12224 'mxchat_reset_daily_rate_limits',
12225 'mxchat_reset_weekly_rate_limits',
12226 'mxchat_reset_monthly_rate_limits'
12227 ];
12228
12229 foreach ($hooks_to_clear as $hook) {
12230 // Only clear a maximum of 3 instances to prevent infinite loops
12231 $cleared = 0;
12232 while (wp_next_scheduled($hook) && $cleared < 3) {
12233 wp_clear_scheduled_hook($hook);
12234 $cleared++;
12235 }
12236 }
12237
12238 // Small delay after clearing
12239 usleep(100000); // 0.1 seconds
12240
12241 // Try to schedule the event
12242 $initial_time = time() + 300; // Start in 5 minutes
12243 $result = wp_schedule_event($initial_time, 'hourly', 'mxchat_reset_rate_limits');
12244
12245 if ($result === false) {
12246 //error_log('MxChat: Failed to schedule cron, using fallback system');
12247 $this->setup_fallback_rate_limit_system();
12248 } else {
12249 if (defined('MXCHAT_DEV_MODE') && MXCHAT_DEV_MODE) {
12250 error_log('MxChat: rate-limit reset cron event was missing and has been re-scheduled');
12251 }
12252 }
12253
12254 } catch (Exception $e) {
12255 //error_log('MxChat: Cron setup exception: ' . $e->getMessage());
12256 $this->setup_fallback_rate_limit_system();
12257 }
12258 }
12259
12260 /**
12261 * Try alternative cron scheduling methods
12262 */
12263 private function try_alternative_cron_scheduling($initial_time) {
12264 try {
12265 // Method 1: Try with current time instead of future time
12266 $result1 = wp_schedule_event(time(), 'hourly', 'mxchat_reset_rate_limits');
12267 if ($result1 !== false) {
12268 //error_log('MxChat: Alternative method 1 (current time) succeeded');
12269 return true;
12270 }
12271
12272 // Method 2: Try with a different interval
12273 $result2 = wp_schedule_event($initial_time, 'daily', 'mxchat_reset_rate_limits');
12274 if ($result2 !== false) {
12275 //error_log('MxChat: Alternative method 2 (daily interval) succeeded');
12276 return true;
12277 }
12278
12279 // Method 3: Try wp_schedule_single_event first, then recurring
12280 $result3 = wp_schedule_single_event($initial_time, 'mxchat_reset_rate_limits');
12281 if ($result3 !== false) {
12282 //error_log('MxChat: Alternative method 3 (single event) succeeded');
12283 // Schedule the next one manually in the handler
12284 return true;
12285 }
12286
12287 return false;
12288
12289 } catch (Exception $e) {
12290 //error_log('MxChat: Alternative cron scheduling exception: ' . $e->getMessage());
12291 return false;
12292 }
12293 }
12294
12295 /**
12296 * Enhanced fallback rate limit system
12297 */
12298 private function setup_fallback_rate_limit_system() {
12299 // Idempotence matters here: with setup_rate_limit_cron_jobs() hooked to
12300 // admin_init, a DISABLE_WP_CRON site reaches this on every guard pass.
12301 // Unconditionally rewriting mxchat_next_rate_limit_check to now+3600 would
12302 // slide the deadline forward forever and the fallback reset would never
12303 // fire. Only initialize the deadline on a genuine transition into fallback
12304 // mode (or if it's somehow missing).
12305 $already_active = get_option('mxchat_use_fallback_rate_limits', false);
12306
12307 // Set a flag to use database-based rate limit cleanup
12308 update_option('mxchat_use_fallback_rate_limits', true);
12309
12310 // Schedule a one-time check to happen on the next plugin load
12311 if (!$already_active || !get_option('mxchat_next_rate_limit_check', 0)) {
12312 update_option('mxchat_next_rate_limit_check', time() + 3600);
12313 }
12314
12315 // Also set up a more frequent fallback check (every 4 hours)
12316 update_option('mxchat_fallback_check_interval', 4 * 3600);
12317
12318 //error_log('MxChat: Fallback rate limit system activated');
12319 }
12320
12321 /**
12322 * Enhanced fallback check method
12323 * NOTE: mxchat_check_fallback_rate_limits() in mxchat-basic.php is a second
12324 * implementation of this same check — if either changes, change both.
12325 */
12326 public function check_fallback_rate_limits() {
12327 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
12328
12329 if (!$use_fallback) {
12330 return; // Regular cron is working
12331 }
12332
12333 $next_check = get_option('mxchat_next_rate_limit_check', 0);
12334 $check_interval = get_option('mxchat_fallback_check_interval', 3600);
12335
12336 if (time() >= $next_check) {
12337 //error_log('MxChat: Running fallback rate limit cleanup');
12338 $this->mxchat_reset_rate_limits();
12339
12340 // Schedule next check
12341 update_option('mxchat_next_rate_limit_check', time() + $check_interval);
12342 }
12343 }
12344 /**
12345 * Enhanced rate limit check that includes fallback cleanup and bot-specific rate limits
12346 */
12347 public function check_rate_limit() {
12348 // Check if we need to run fallback cleanup
12349 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
12350 $next_check = get_option('mxchat_next_rate_limit_check', 0);
12351
12352 if ($use_fallback && time() >= $next_check) {
12353 $this->mxchat_reset_rate_limits();
12354 update_option('mxchat_next_rate_limit_check', time() + 3600); // Next hour
12355 }
12356
12357 // Get bot ID from current request context
12358 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
12359
12360 // Get bot-specific options (includes rate limits if overridden)
12361 $bot_options = $this->get_bot_options($bot_id);
12362 $current_options = !empty($bot_options) ? $bot_options : $this->options;
12363
12364 // Use bot-specific rate limits if available, otherwise fall back to default
12365 $rate_limits_source = isset($current_options['rate_limits']) ? $current_options['rate_limits'] : get_option('mxchat_options', [])['rate_limits'] ?? [];
12366
12367 // -------------------------------------------------------------------
12368 // Whole-chatbot global cap (independent of role). Evaluated FIRST so
12369 // it acts as a hard ceiling across all users + all roles. Default is
12370 // 'unlimited' so existing installs are unchanged. Counter key drops
12371 // both <role> and <user_id> segments — single pool per bot.
12372 // -------------------------------------------------------------------
12373 $global_cfg = isset($current_options['rate_limits_global']) && is_array($current_options['rate_limits_global'])
12374 ? $current_options['rate_limits_global']
12375 : (isset(get_option('mxchat_options', [])['rate_limits_global']) ? get_option('mxchat_options', [])['rate_limits_global'] : []);
12376 $global_limit_raw = isset($global_cfg['limit']) ? (string) $global_cfg['limit'] : 'unlimited';
12377 $global_timeframe = isset($global_cfg['timeframe']) ? (string) $global_cfg['timeframe'] : 'daily';
12378 if ($global_limit_raw !== '' && $global_limit_raw !== 'unlimited' && (int) $global_limit_raw >= 1) {
12379 $bot_id_for_global = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
12380 $safe_bot_global = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id_for_global);
12381 $global_option = 'mxchat_chat_limit_' . $safe_bot_global . '_global';
12382 $global_data = get_option($global_option, ['count' => 0, 'timestamp' => time()]);
12383 if ((int) $global_data['count'] === 0) {
12384 $global_data['timestamp'] = time();
12385 update_option($global_option, $global_data);
12386 }
12387 $now = time();
12388 $ts = (int) $global_data['timestamp'];
12389 $reset = false;
12390 switch ($global_timeframe) {
12391 case 'hourly': $reset = ($now - $ts) >= 3600; break;
12392 case 'daily': $reset = ($now - $ts) >= 86400; break;
12393 case 'weekly': $reset = ($now - $ts) >= 604800; break;
12394 case 'monthly': $reset = ($now - $ts) >= 2592000; break;
12395 }
12396 if ($reset) {
12397 $global_data = ['count' => 0, 'timestamp' => $now];
12398 update_option($global_option, $global_data);
12399 }
12400 if ((int) $global_data['count'] >= (int) $global_limit_raw) {
12401 $global_msg = !empty($global_cfg['message'])
12402 ? $global_cfg['message']
12403 : __('This chatbot has reached its message limit. Please try again later.', 'mxchat');
12404 return [
12405 'error' => true,
12406 'message' => $this->process_rate_limit_message_html($global_msg),
12407 ];
12408 }
12409 // Reserve the slot for this request. Per-role check below also increments
12410 // its own counter — that is intentional, both ceilings apply independently.
12411 $global_data['count']++;
12412 update_option($global_option, $global_data);
12413 }
12414
12415 // Determine user role or if logged out
12416 if (is_user_logged_in()) {
12417 $user = wp_get_current_user();
12418 $user_id = $user->ID;
12419
12420 // Get the user's primary role using reset() to safely get the first element
12421 $user_roles = $user->roles;
12422
12423 // Safely get the first role regardless of array key structure
12424 if (!empty($user_roles) && is_array($user_roles)) {
12425 $role = reset($user_roles); // This safely gets the first element regardless of key
12426 } else {
12427 $role = 'subscriber'; // Default to subscriber if no role found
12428 }
12429 } else {
12430 $role = 'logged_out';
12431 // Use IP address for non-logged-in users
12432 $user_id = $this->get_client_ip();
12433 }
12434
12435 // Check if rate limits are configured for this role
12436 if (!isset($rate_limits_source[$role])) {
12437 return true; // No limit set for this role
12438 }
12439
12440 $limit = $rate_limits_source[$role]['limit'];
12441
12442 // If unlimited, return true immediately
12443 if ($limit === 'unlimited') {
12444 return true;
12445 }
12446
12447 // Get the option name for this user/role with safer naming (include bot_id for bot-specific limits)
12448 $safe_role = preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
12449 $safe_user_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $user_id);
12450 $safe_bot_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id);
12451
12452 // Include bot_id in option name so each bot has separate rate limits
12453 $option_name = 'mxchat_chat_limit_' . $safe_bot_id . '_' . $safe_role . '_' . $safe_user_id;
12454
12455 // Get the counter data
12456 $limit_data = get_option($option_name, ['count' => 0, 'timestamp' => time()]);
12457
12458 // If first request or counter reset needed, set the initial timestamp
12459 if ($limit_data['count'] === 0) {
12460 $limit_data['timestamp'] = time();
12461 update_option($option_name, $limit_data);
12462 }
12463
12464 // Get the timeframe
12465 $timeframe = isset($rate_limits_source[$role]['timeframe']) ?
12466 $rate_limits_source[$role]['timeframe'] : 'daily';
12467
12468 // Check if the counter needs to be reset based on timeframe
12469 $current_time = time();
12470 $timestamp = $limit_data['timestamp'];
12471 $should_reset = false;
12472
12473 switch ($timeframe) {
12474 case 'hourly':
12475 $should_reset = ($current_time - $timestamp) >= 3600; // 1 hour
12476 break;
12477 case 'daily':
12478 $should_reset = ($current_time - $timestamp) >= 86400; // 24 hours
12479 break;
12480 case 'weekly':
12481 $should_reset = ($current_time - $timestamp) >= 604800; // 7 days
12482 break;
12483 case 'monthly':
12484 $should_reset = ($current_time - $timestamp) >= 2592000; // 30 days
12485 break;
12486 }
12487
12488 // Reset the counter if the timeframe has passed
12489 if ($should_reset) {
12490 $limit_data = ['count' => 0, 'timestamp' => $current_time];
12491 update_option($option_name, $limit_data);
12492 }
12493
12494 // Check if user has exceeded their limit
12495 if ($limit_data['count'] >= intval($limit)) {
12496 // Get the custom message for this role
12497 $message = !empty($rate_limits_source[$role]['message'])
12498 ? $rate_limits_source[$role]['message']
12499 : __('Rate limit exceeded. Please try again later.', 'mxchat');
12500
12501 // Add timeframe information to the message if placeholders exist
12502 $timeframe_label = '';
12503 switch ($timeframe) {
12504 case 'hourly':
12505 $timeframe_label = __('hour', 'mxchat');
12506 break;
12507 case 'daily':
12508 $timeframe_label = __('day', 'mxchat');
12509 break;
12510 case 'weekly':
12511 $timeframe_label = __('week', 'mxchat');
12512 break;
12513 case 'monthly':
12514 $timeframe_label = __('month', 'mxchat');
12515 break;
12516 }
12517
12518 // Replace placeholders in the message
12519 $message = str_replace(
12520 ['{limit}', '{count}', '{remaining}', '{timeframe}'],
12521 [intval($limit), $limit_data['count'], max(0, intval($limit) - $limit_data['count']), $timeframe_label],
12522 $message
12523 );
12524
12525 // Process HTML links in the message
12526 $message = $this->process_rate_limit_message_html($message);
12527
12528 // Return error with the processed message
12529 return [
12530 'error' => true,
12531 'message' => $message
12532 ];
12533 }
12534
12535 // Increment the counter
12536 $limit_data['count']++;
12537 update_option($option_name, $limit_data);
12538
12539 return true;
12540 }
12541
12542 /**
12543 * Enhanced rate limit reset with better error handling
12544 */
12545 public function mxchat_reset_rate_limits() {
12546 try {
12547 global $wpdb;
12548 $all_options = get_option('mxchat_options', []);
12549 $current_time = time();
12550
12551 // Get rate limit options with a safer query and limit
12552 $option_names = $wpdb->get_col(
12553 $wpdb->prepare(
12554 "SELECT option_name FROM {$wpdb->options}
12555 WHERE option_name LIKE %s
12556 LIMIT 1000",
12557 'mxchat_chat_limit_%'
12558 )
12559 );
12560
12561 if (empty($option_names)) {
12562 return;
12563 }
12564
12565 $processed_count = 0;
12566 $max_processing_time = 30; // Maximum 30 seconds
12567 $start_time = time();
12568
12569 foreach ($option_names as $option_name) {
12570 // Check processing time limit
12571 if ((time() - $start_time) > $max_processing_time) {
12572 //error_log('MxChat: Rate limit reset timeout after processing ' . $processed_count . ' entries');
12573 break;
12574 }
12575
12576 // Parse the option name more safely
12577 if (!preg_match('/^mxchat_chat_limit_(.+)_(.+)$/', $option_name, $matches)) {
12578 continue;
12579 }
12580
12581 $role_and_user = $matches[1] . '_' . $matches[2];
12582 $parts = explode('_', $role_and_user);
12583
12584 if (count($parts) < 2) {
12585 continue;
12586 }
12587
12588 // Extract role (everything except the last part which is user ID)
12589 $user_id_part = array_pop($parts);
12590 $role = implode('_', $parts);
12591
12592 // Skip if role doesn't exist in our settings
12593 if (!isset($all_options['rate_limits'][$role])) {
12594 // Clean up orphaned entries
12595 delete_option($option_name);
12596 continue;
12597 }
12598
12599 $timeframe = $all_options['rate_limits'][$role]['timeframe'] ?? 'daily';
12600 $limit_data = get_option($option_name);
12601
12602 if (!$limit_data || !is_array($limit_data) || !isset($limit_data['timestamp'])) {
12603 // Clean up invalid entries
12604 delete_option($option_name);
12605 continue;
12606 }
12607
12608 $timestamp = $limit_data['timestamp'];
12609 $should_reset = false;
12610
12611 // Determine if we should reset based on the timeframe
12612 switch ($timeframe) {
12613 case 'hourly':
12614 $should_reset = ($current_time - $timestamp) >= 3600;
12615 break;
12616 case 'daily':
12617 $should_reset = ($current_time - $timestamp) >= 86400;
12618 break;
12619 case 'weekly':
12620 $should_reset = ($current_time - $timestamp) >= 604800;
12621 break;
12622 case 'monthly':
12623 $should_reset = ($current_time - $timestamp) >= 2592000;
12624 break;
12625 }
12626
12627 // Reset the counter if the timeframe has passed
12628 if ($should_reset) {
12629 delete_option($option_name);
12630 wp_cache_delete($option_name, 'options');
12631 $processed_count++;
12632 }
12633 }
12634
12635 // Clean up any orphaned cache entries
12636 wp_cache_delete('mxchat_all_chat_limits', 'options');
12637
12638 //error_log("MxChat: Rate limit reset completed. Processed {$processed_count} entries.");
12639
12640 } catch (Exception $e) {
12641 //error_log('MxChat: Rate limit reset error: ' . $e->getMessage());
12642 }
12643 }
12644
12645
12646 /**
12647 * Process HTML links in rate limit messages
12648 *
12649 * @param string $message The rate limit message
12650 * @return string The processed message with safe HTML links
12651 */
12652 private function process_rate_limit_message_html($message) {
12653 // Return original message if empty
12654 if (empty($message)) {
12655 return $message;
12656 }
12657
12658 // First, convert markdown links to HTML
12659 $message = $this->convert_markdown_links($message);
12660
12661 // Then, auto-convert any remaining plain URLs to links
12662 $message = $this->auto_link_urls($message);
12663
12664 // Allow basic HTML tags for links and formatting
12665 $allowed_tags = [
12666 'a' => [
12667 'href' => true,
12668 'target' => true,
12669 'rel' => true,
12670 'title' => true,
12671 'class' => true
12672 ],
12673 'strong' => [],
12674 'em' => [],
12675 'br' => [],
12676 'b' => [],
12677 'i' => [],
12678 'span' => ['class' => true]
12679 ];
12680
12681 // Sanitize but allow the specified HTML tags
12682 $processed_message = wp_kses($message, $allowed_tags);
12683
12684 // If wp_kses stripped everything, return the original message as plain text
12685 if (empty($processed_message) && !empty($message)) {
12686 // Strip all HTML and return plain text as fallback
12687 return wp_strip_all_tags($message);
12688 }
12689
12690 return $processed_message;
12691 }
12692
12693 /**
12694 * Convert markdown links to HTML
12695 *
12696 * @param string $text The text to process
12697 * @return string The text with markdown links converted to HTML
12698 */
12699 private function convert_markdown_links($text) {
12700 // Return original text if empty
12701 if (empty($text)) {
12702 return $text;
12703 }
12704
12705 // Pattern to match markdown links: [text](url)
12706 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
12707
12708 $processed_text = preg_replace_callback($pattern, function($matches) {
12709 $link_text = $matches[1];
12710 $url = $matches[2];
12711
12712 // Clean up any trailing punctuation from the URL
12713 $url = rtrim($url, '.,;:!?');
12714
12715 // Sanitize the link text and URL
12716 $safe_text = esc_html($link_text);
12717 $safe_url = esc_url($url);
12718
12719 // Create the HTML link
12720 return '<a href="' . $safe_url . '" target="_blank" rel="noopener noreferrer">' . $safe_text . '</a>';
12721 }, $text);
12722
12723 // If preg_replace_callback failed, return original text
12724 if ($processed_text === null) {
12725 return $text;
12726 }
12727
12728 return $processed_text;
12729 }
12730
12731 /**
12732 * Auto-convert plain URLs to clickable links
12733 *
12734 * @param string $text The text to process
12735 * @return string The text with URLs converted to links
12736 */
12737 private function auto_link_urls($text) {
12738 // Return original text if empty
12739 if (empty($text)) {
12740 return $text;
12741 }
12742
12743 // Simple pattern that avoids complex lookbehinds
12744 // This will match URLs that are not already inside href attributes or markdown links
12745 $pattern = '/(?<!href=["\'])(?<!\]\()https?:\/\/[^\s<>"\')\]]+/i';
12746
12747 $processed_text = preg_replace_callback($pattern, function($matches) {
12748 $url = $matches[0];
12749 // Clean up any trailing punctuation that might have been captured
12750 $url = rtrim($url, '.,;:!?');
12751
12752 // Add target="_blank" and rel="noopener noreferrer" for security
12753 return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
12754 }, $text);
12755
12756 // If preg_replace_callback failed, return original text
12757 if ($processed_text === null) {
12758 return $text;
12759 }
12760
12761 return $processed_text;
12762 }
12763
12764
12765 // Helper function to get client IP address
12766 private function get_client_ip() {
12767 // Check for shared internet/ISP IP
12768 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
12769 return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
12770 }
12771
12772 // Check for IPs passing through proxies
12773 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
12774 // Use the first value in the comma-separated list
12775 $forwarded_for = explode(',', sanitize_text_field($_SERVER['HTTP_X_FORWARDED_FOR']));
12776 return trim($forwarded_for[0]);
12777 }
12778
12779 if (!empty($_SERVER['REMOTE_ADDR'])) {
12780 return sanitize_text_field($_SERVER['REMOTE_ADDR']);
12781 }
12782
12783 // Fallback
12784 return 'unknown';
12785 }
12786
12787 /**
12788 * AJAX handler to get system information for testing panel
12789 */
12790 /**
12791 * AJAX handler to get system information for testing panel
12792 */
12793 public function mxchat_get_system_info() {
12794 // Verify nonce for security
12795 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
12796 wp_send_json_error(['message' => 'Invalid nonce']);
12797 return;
12798 }
12799
12800 // Only allow admin users
12801 if (!current_user_can('administrator')) {
12802 wp_send_json_error(['message' => 'Unauthorized']);
12803 return;
12804 }
12805
12806 // Get system prompt from options
12807 $system_prompt = isset($this->options['system_prompt_instructions'])
12808 ? $this->options['system_prompt_instructions']
12809 : 'No system prompt configured';
12810
12811 // Get selected model
12812 $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest';
12813
12814 // Check if OpenRouter is being used
12815 $is_openrouter = ($selected_model === 'openrouter');
12816 $openrouter_model = '';
12817
12818 if ($is_openrouter) {
12819 // Get the actual OpenRouter model that's selected
12820 $openrouter_model = isset($this->options['openrouter_selected_model'])
12821 ? $this->options['openrouter_selected_model']
12822 : 'No OpenRouter model selected';
12823
12824 // Update selected_model display to show both
12825 $selected_model = 'OpenRouter: ' . $openrouter_model;
12826 }
12827
12828 // Get API key status (just check if they exist, don't expose the keys)
12829 $api_status = [];
12830 $api_status['openai'] = !empty($this->options['api_key']);
12831 $api_status['claude'] = !empty($this->options['claude_api_key']);
12832 $api_status['gemini'] = !empty($this->options['gemini_api_key']);
12833 $api_status['xai'] = !empty($this->options['xai_api_key']);
12834 $api_status['deepseek'] = !empty($this->options['deepseek_api_key']);
12835 $api_status['openrouter'] = !empty($this->options['openrouter_api_key']);
12836
12837 wp_send_json_success([
12838 'system_prompt' => $system_prompt,
12839 'selected_model' => $selected_model,
12840 'is_openrouter' => $is_openrouter,
12841 'openrouter_model' => $openrouter_model,
12842 'api_status' => $api_status
12843 ]);
12844 }
12845
12846 /**
12847 * AJAX handler to get similarity threshold
12848 */
12849 public function mxchat_get_similarity_threshold() {
12850 // Verify nonce for security
12851 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
12852 wp_send_json_error(['message' => 'Invalid nonce']);
12853 return;
12854 }
12855
12856 // Only allow admin users
12857 if (!current_user_can('administrator')) {
12858 wp_send_json_error(['message' => 'Unauthorized']);
12859 return;
12860 }
12861
12862 // Get similarity threshold from main options (default 35%)
12863 $similarity_threshold = isset($this->options['similarity_threshold'])
12864 ? ((int) $this->options['similarity_threshold']) / 100
12865 : 0.35;
12866
12867 wp_send_json_success([
12868 'threshold' => $similarity_threshold,
12869 'threshold_percentage' => ($similarity_threshold * 100) . '%'
12870 ]);
12871 }
12872
12873 /**
12874 * AJAX handler to get knowledge base status
12875 */
12876 public function mxchat_get_kb_status() {
12877 // Verify nonce for security
12878 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
12879 wp_send_json_error(['message' => 'Invalid nonce']);
12880 return;
12881 }
12882
12883 // Only allow admin users
12884 if (!current_user_can('administrator')) {
12885 wp_send_json_error(['message' => 'Unauthorized']);
12886 return;
12887 }
12888
12889 // Check OpenAI Vector Store first (takes priority)
12890 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
12891 $use_vectorstore = (isset($vectorstore_options['mxchat_use_openai_vectorstore']) && $vectorstore_options['mxchat_use_openai_vectorstore'] === '1');
12892
12893 if ($use_vectorstore) {
12894 $vectorstore_ids = $vectorstore_options['mxchat_openai_vectorstore_ids'] ?? '';
12895 $id_count = !empty($vectorstore_ids) ? count(array_filter(array_map('trim', explode(',', $vectorstore_ids)))) : 0;
12896
12897 $kb_info = [
12898 'type' => 'OpenAI Vector Store',
12899 'status' => 'Active',
12900 'documents' => $id_count > 0 ? $id_count . ' vector store' . ($id_count > 1 ? 's' : '') . ' configured' : 'No vector stores configured'
12901 ];
12902
12903 wp_send_json_success($kb_info);
12904 return;
12905 }
12906
12907 // Check Pinecone vs WordPress
12908 $addon_options = get_option('mxchat_pinecone_addon_options', array());
12909 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
12910
12911 $kb_info = [
12912 'type' => $use_pinecone ? 'Pinecone' : 'WordPress Database',
12913 'status' => 'Active'
12914 ];
12915
12916 // Get document count
12917 if ($use_pinecone) {
12918 $kb_info['documents'] = 'Connected to Pinecone';
12919 $kb_info['api_configured'] = !empty($addon_options['mxchat_pinecone_api_key']);
12920 } else {
12921 // Count documents in WordPress database
12922 global $wpdb;
12923 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
12924 $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
12925 $kb_info['documents'] = $count ? $count . ' documents' : 'No documents';
12926 }
12927
12928 wp_send_json_success($kb_info);
12929 }
12930
12931 /**
12932 * AJAX handler to start a completely fresh session (NEW - replaces old clear session)
12933 */
12934 public function mxchat_start_fresh_session() {
12935 // Verify nonce for security
12936 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
12937 wp_send_json_error(['message' => 'Invalid nonce']);
12938 return;
12939 }
12940
12941 // Only allow admin users
12942 if (!current_user_can('administrator')) {
12943 wp_send_json_error(['message' => 'Unauthorized']);
12944 return;
12945 }
12946
12947 $old_session_id = isset($_POST['old_session_id']) ? sanitize_text_field($_POST['old_session_id']) : '';
12948 $new_session_id = isset($_POST['new_session_id']) ? sanitize_text_field($_POST['new_session_id']) : '';
12949
12950 if (empty($old_session_id)) {
12951 wp_send_json_error(['message' => 'Old session ID required']);
12952 return;
12953 }
12954
12955 // If no new session ID provided, generate one
12956 if (empty($new_session_id)) {
12957 // Cryptographically strong session id (plan-0c17b5). Prefix preserved
12958 // exactly (other code pattern-matches on 'mxchat_chat_'). random_bytes
12959 // is guaranteed on all supported PHP (7+).
12960 $new_session_id = 'mxchat_chat_' . bin2hex(random_bytes(16));
12961 }
12962
12963 // Clear ALL data associated with the old session
12964 $this->clear_complete_session_data($old_session_id);
12965
12966 // Initialize the new session
12967 $this->initialize_fresh_session($new_session_id);
12968
12969 wp_send_json_success([
12970 'message' => 'Fresh session started successfully',
12971 'new_session_id' => $new_session_id,
12972 'old_session_id' => $old_session_id
12973 ]);
12974 }
12975
12976 /**
12977 * Clear ALL data associated with a session (ENHANCED)
12978 */
12979 private function clear_complete_session_data($session_id) {
12980 // Clear chat history
12981 delete_option("mxchat_history_{$session_id}");
12982
12983 // Clear chat mode
12984 delete_option("mxchat_mode_{$session_id}");
12985
12986 // Clear any PDF/Word transients
12987 $this->clear_pdf_transients($session_id);
12988 if (method_exists($this, 'clear_word_transients')) {
12989 $this->clear_word_transients($session_id);
12990 }
12991
12992 // Archive the session's per-conversation Slack channel before its option
12993 // is deleted (plan 7458a7 — covers transcript-retention cleanup paths).
12994 // Toggle-gated + shared-channel-guarded inside the helper; best-effort.
12995 $stale_channel = get_option("mxchat_channel_{$session_id}", '');
12996 if ($stale_channel !== '') {
12997 $this->mxchat_maybe_archive_conversation_channel($session_id, $stale_channel);
12998 }
12999
13000 // Clear agent-related data
13001 delete_option("mxchat_channel_{$session_id}");
13002 delete_option("mxchat_thread_{$session_id}");
13003 delete_option("mxchat_agent_name_{$session_id}");
13004 delete_option("mxchat_email_{$session_id}");
13005
13006 // Clear any recommendation flow state
13007 delete_option("mxchat_sr_flow_state_{$session_id}");
13008
13009 // Clear any cached embeddings or context
13010 delete_transient("mxchat_context_{$session_id}");
13011 delete_transient("mxchat_last_query_{$session_id}");
13012
13013 // Clear any testing data
13014 delete_transient("mxchat_testing_data_{$session_id}");
13015
13016 // Clear any rate limiting data for this session
13017 delete_transient("mxchat_rate_limit_{$session_id}");
13018
13019 // Clear any other session-specific transients
13020 delete_transient("mxchat_waiting_for_pdf_url_{$session_id}");
13021 delete_transient("mxchat_include_pdf_in_context_{$session_id}");
13022 delete_transient("mxchat_include_word_in_context_{$session_id}");
13023
13024 // Clear form addon state (pending forms and submitted forms)
13025 delete_option("mxchat_pending_form_{$session_id}");
13026 delete_option("mxchat_submitted_forms_{$session_id}");
13027
13028 //error_log("MxChat: Cleared all data for session: {$session_id}");
13029 }
13030
13031 /**
13032 * Initialize a fresh session with default data
13033 */
13034 private function initialize_fresh_session($session_id) {
13035 // Set default chat mode
13036 update_option("mxchat_mode_{$session_id}", 'ai');
13037
13038 //error_log("MxChat: Initialized fresh session: {$session_id}");
13039 }
13040
13041 /**
13042 * Helper method to clear Word document transients (if you have Word support)
13043 */
13044 private function clear_word_transients($session_id) {
13045 delete_transient('mxchat_word_url_' . $session_id);
13046 delete_transient('mxchat_word_filename_' . $session_id);
13047 delete_transient('mxchat_word_embeddings_' . $session_id);
13048 delete_transient('mxchat_include_word_in_context_' . $session_id);
13049 }
13050
13051 /**
13052 * Simplified testing data capture method (CLEANED UP)
13053 */
13054 private function capture_testing_data($user_embedding, $message, $session_id) {
13055 // Only capture for admin users
13056 if (!current_user_can('administrator')) {
13057 return null;
13058 }
13059
13060 $testing_data = [
13061 'query' => $message,
13062 'timestamp' => time(),
13063 'top_matches' => [],
13064 'action_matches' => [] // Add action matches
13065 ];
13066
13067 // Get similarity threshold
13068 $similarity_threshold = isset($this->options['similarity_threshold'])
13069 ? ((int) $this->options['similarity_threshold']) / 100
13070 : 0.35;
13071
13072 $testing_data['similarity_threshold'] = $similarity_threshold;
13073
13074 // Use the real similarity analysis if available
13075 if ($this->last_similarity_analysis !== null) {
13076 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
13077 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
13078 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
13079 } else {
13080 // Fallback: determine knowledge base type
13081 $addon_options = get_option('mxchat_pinecone_addon_options', array());
13082 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
13083
13084 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
13085 }
13086
13087 // Include action analysis if available
13088 if (isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
13089 $testing_data['action_matches'] = $this->last_action_analysis;
13090
13091 // Clear it after capturing to avoid stale data
13092 $this->last_action_analysis = null;
13093 }
13094
13095 return $testing_data;
13096 }
13097
13098
13099 /**
13100 * Track URL clicks from chatbot responses
13101 */
13102 public function mxchat_track_url_click() {
13103 // Verify nonce for security
13104 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
13105 wp_send_json_error(['message' => 'Invalid nonce']);
13106 wp_die();
13107 }
13108
13109 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
13110 $clicked_url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
13111 $message_context = isset($_POST['message_context']) ? sanitize_textarea_field($_POST['message_context']) : '';
13112
13113 if (empty($session_id) || empty($clicked_url)) {
13114 wp_send_json_error(['message' => 'Missing required data']);
13115 wp_die();
13116 }
13117
13118 global $wpdb;
13119 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
13120
13121 // Insert click tracking record
13122 $wpdb->insert(
13123 $table_name,
13124 [
13125 'session_id' => $session_id,
13126 'clicked_url' => $clicked_url,
13127 'message_context' => $message_context,
13128 'click_timestamp' => current_time('mysql', 1),
13129 'user_ip' => $_SERVER['REMOTE_ADDR'],
13130 'user_agent' => $_SERVER['HTTP_USER_AGENT']
13131 ]
13132 );
13133
13134 wp_send_json_success(['message' => 'Click tracked']);
13135 wp_die();
13136 }
13137
13138 /**
13139 * Get URL click analytics for a session
13140 */
13141 public function mxchat_get_url_clicks($session_id) {
13142 global $wpdb;
13143 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
13144
13145 $clicks = $wpdb->get_results($wpdb->prepare(
13146 "SELECT * FROM $table_name WHERE session_id = %s ORDER BY click_timestamp ASC",
13147 $session_id
13148 ));
13149
13150 return $clicks;
13151 }
13152 /**
13153 * Track the originating page where chat was started
13154 */
13155 public function mxchat_track_originating_page() {
13156 // Verify nonce
13157 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
13158 wp_send_json_error(['message' => 'Invalid nonce']);
13159 wp_die();
13160 }
13161
13162 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
13163 $page_url = isset($_POST['page_url']) ? esc_url_raw($_POST['page_url']) : '';
13164 $page_title = isset($_POST['page_title']) ? sanitize_text_field($_POST['page_title']) : '';
13165
13166 if (empty($session_id)) {
13167 wp_send_json_error(['message' => 'Missing session ID']);
13168 wp_die();
13169 }
13170
13171 global $wpdb;
13172 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
13173
13174 // Check if we've already tracked for this session
13175 $existing = $wpdb->get_var($wpdb->prepare(
13176 "SELECT COUNT(*) FROM $table_name
13177 WHERE session_id = %s
13178 AND originating_page_url IS NOT NULL",
13179 $session_id
13180 ));
13181
13182 if ($existing > 0) {
13183 wp_send_json_success(['message' => 'Already tracked']);
13184 wp_die();
13185 }
13186
13187 // Update the first message in this session with originating page info
13188 $wpdb->query($wpdb->prepare(
13189 "UPDATE $table_name
13190 SET originating_page_url = %s,
13191 originating_page_title = %s
13192 WHERE session_id = %s
13193 ORDER BY timestamp ASC
13194 LIMIT 1",
13195 $page_url,
13196 $page_title,
13197 $session_id
13198 ));
13199
13200 wp_send_json_success(['message' => 'Originating page tracked']);
13201 wp_die();
13202 }
13203
13204 /**
13205 * Validate and clean URLs from AI response
13206 * Removes any URLs that aren't in the knowledge base
13207 *
13208 * @param string $response_text The AI-generated response
13209 * @param array $valid_urls Array of URLs from the knowledge base
13210 * @return string Cleaned response with invalid URLs removed/flagged
13211 */
13212 private function validate_and_clean_urls($response_text, $valid_urls, $session_id = null, $bot_id = null) {
13213 /**
13214 * Filter the list of URLs treated as valid (allowlisted) BEFORE the
13215 * response URL sanitizer strips any link not in the list. Lets a site
13216 * owner / developer whitelist links their custom function-calling tools
13217 * return (e.g. session or speaker pages), which are otherwise absent from
13218 * the RAG/system-prompt-derived list and get stripped to plain text.
13219 *
13220 * Purely additive: with no hook registered, apply_filters returns
13221 * $valid_urls untouched, so there is zero behavior change for anyone who
13222 * does not use the filter. Applied before the empty-check so a hooked
13223 * allowlist can participate. (plan-mxchat-20260710-13a471)
13224 *
13225 * @param array $valid_urls URLs already known-valid (RAG + system prompt).
13226 * @param string|null $session_id Current chat session id, if available.
13227 * @param string|null $bot_id Current bot id, if available.
13228 */
13229 $valid_urls = apply_filters('mxchat_valid_urls', $valid_urls, $session_id, $bot_id);
13230
13231 // A bad mu-plugin returning a non-array (or non-string entries) must never
13232 // fatal the response path — coerce defensively before any use.
13233 if (!is_array($valid_urls)) {
13234 $valid_urls = array();
13235 }
13236 $valid_urls = array_values(array_filter($valid_urls, static function ($u) {
13237 return is_string($u) && $u !== '';
13238 }));
13239
13240 // If no valid URLs provided or empty response, return as-is
13241 if (empty($valid_urls) || empty($response_text)) {
13242 //error_log("Validation skipped - empty valid_urls or response");
13243 return $response_text;
13244 }
13245
13246 // Extract all URLs from the AI response
13247 // This regex matches http:// and https:// URLs
13248 preg_match_all(
13249 '#\bhttps?://[^\s<>"\')\]]+#i',
13250 $response_text,
13251 $matches
13252 );
13253
13254 // If no URLs found in response, return as-is
13255 if (empty($matches[0])) {
13256 //error_log("No URLs found in response");
13257 return $response_text;
13258 }
13259
13260 $found_urls = $matches[0];
13261 $cleaned_response = $response_text;
13262 $removed_count = 0;
13263
13264 // Normalize valid URLs for comparison (remove trailing slashes, fragments, etc.)
13265 $normalized_valid_urls = array_map(function($url) {
13266 // Remove trailing slash
13267 $url = rtrim($url, '/');
13268 // Remove URL fragments (#section)
13269 $url = preg_replace('/#.*$/', '', $url);
13270 // Remove trailing punctuation that might have been captured
13271 $url = rtrim($url, '.,;:!?');
13272 return $url;
13273 }, $valid_urls);
13274
13275 //error_log("Normalized valid URLs: " . print_r($normalized_valid_urls, true));
13276
13277 foreach ($found_urls as $found_url) {
13278 // Clean up the found URL (remove trailing punctuation that might have been captured)
13279 $clean_found_url = rtrim($found_url, '.,;:!?)');
13280
13281 // DEBUG: Log each URL being checked
13282 //error_log("Checking found URL: " . $found_url);
13283
13284 // Normalize for comparison
13285 $normalized_found = rtrim($clean_found_url, '/');
13286 $normalized_found = preg_replace('/#.*$/', '', $normalized_found);
13287
13288 //error_log("Normalized found URL: " . $normalized_found);
13289
13290 // Check if this URL exists in our valid URLs list
13291 $is_valid = false;
13292
13293 //error_log("Starting validation checks for: " . $normalized_found);
13294
13295 // First, try exact match
13296 if (in_array($normalized_found, $normalized_valid_urls)) {
13297 $is_valid = true;
13298 //error_log("EXACT MATCH FOUND");
13299 } else {
13300 //error_log("No exact match, checking variations...");
13301 // If no exact match, check if it's a variation (with query params, etc.)
13302 foreach ($normalized_valid_urls as $valid_url) {
13303 //error_log(" Comparing against valid URL: " . $valid_url);
13304
13305 // Check if the found URL starts with a valid URL (handles query params)
13306 if (strpos($normalized_found, $valid_url) === 0) {
13307 // Check what comes after the valid URL
13308 $remainder = substr($normalized_found, strlen($valid_url));
13309
13310 // Only valid if:
13311 // 1. Exact match (remainder is empty)
13312 // 2. Query params (starts with ?)
13313 // 3. Fragment (starts with #)
13314 if (empty($remainder) || $remainder[0] === '?' || $remainder[0] === '#') {
13315 $is_valid = true;
13316 //error_log(" MATCH: Found URL is valid variation of base URL");
13317 break;
13318 } else {
13319 //error_log(" NOT A MATCH: Found URL extends path beyond valid URL (remainder: " . $remainder . ")");
13320 }
13321 }
13322 // Also check the reverse (in case valid URL has query params)
13323 if (strpos($valid_url, $normalized_found) === 0) {
13324 $is_valid = true;
13325 //error_log(" MATCH: Valid URL starts with found URL");
13326 break;
13327 }
13328 }
13329
13330 if (!$is_valid) {
13331 //error_log("NO MATCH FOUND - URL should be removed");
13332 }
13333 }
13334
13335 // If URL is not valid, remove it from the response
13336 if (!$is_valid) {
13337 // Log the removal for debugging
13338 //error_log("MxChat: Removed hallucinated URL: " . $found_url);
13339 //error_log("MxChat: Valid URLs were: " . implode(', ', array_slice($normalized_valid_urls, 0, 5)));
13340
13341 $removed_count++;
13342
13343 // Check if URL is part of a markdown link: [text](url)
13344 $markdown_pattern = '/\[([^\]]+)\]\(' . preg_quote($found_url, '/') . '\)/';
13345 if (preg_match($markdown_pattern, $cleaned_response)) {
13346 //error_log("Found markdown link, removing but keeping text");
13347 // Remove the markdown link but keep the text
13348 $cleaned_response = preg_replace($markdown_pattern, '$1', $cleaned_response);
13349 }
13350 // Check if URL is part of an HTML link: <a href="url">text</a>
13351 else if (preg_match('/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>(.*?)<\/a>/i', $cleaned_response, $link_match)) {
13352 //error_log("Found HTML link, removing but keeping text");
13353 // Remove the HTML link but keep the text
13354 $link_text = $link_match[1];
13355 $cleaned_response = preg_replace(
13356 '/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>.*?<\/a>/i',
13357 $link_text,
13358 $cleaned_response
13359 );
13360 }
13361 // Otherwise just remove the bare URL
13362 else {
13363 //error_log("Removing bare URL");
13364 $cleaned_response = str_replace($found_url, '', $cleaned_response);
13365 }
13366 }
13367 }
13368
13369 // Log summary if any URLs were removed
13370 if ($removed_count > 0) {
13371 //error_log("MxChat: URL Validation Summary - Removed {$removed_count} hallucinated URL(s)");
13372 } else {
13373 //error_log("MxChat: URL Validation Summary - No URLs removed, all were valid");
13374 }
13375
13376 // Clean up any double spaces or awkward punctuation left behind
13377 // IMPORTANT: Only collapse horizontal whitespace (spaces/tabs), preserve newlines for markdown formatting
13378 $cleaned_response = preg_replace('/[^\S\n]+/', ' ', $cleaned_response); // Collapse spaces/tabs but NOT newlines
13379 $cleaned_response = preg_replace('/[^\S\n]+([.,;:!?])/', '$1', $cleaned_response); // Same for punctuation cleanup
13380
13381 //error_log("Final cleaned response: " . $cleaned_response);
13382
13383 return trim($cleaned_response);
13384 }
13385
13386 /**
13387 * AJAX handler to get current chat mode for a session
13388 */
13389 public function mxchat_get_current_chat_mode() {
13390 // Verify nonce for security
13391 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
13392 wp_send_json_error(['message' => 'Invalid nonce']);
13393 wp_die();
13394 }
13395
13396 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
13397
13398 if (empty($session_id)) {
13399 wp_send_json_error(['message' => 'Session ID missing']);
13400 wp_die();
13401 }
13402
13403 // Get the current chat mode for this session
13404 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
13405
13406 wp_send_json_success([
13407 'chat_mode' => $chat_mode
13408 ]);
13409 wp_die();
13410 }
13411
13412
13413
13414 }
13415 ?>
13416