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

13,765 lines 578.2 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 // Headers MUST be set BEFORE the buffers are torn down: flushing a
256 // buffer that holds any stray output commits the response and turns
257 // every later header() into a logged no-op — dropping all four SSE
258 // headers, including the X-Accel-Buffering that stops nginx-fronted
259 // hosts from de-streaming the reply (plan fe130d).
260 header('Content-Type: text/event-stream');
261 header('Cache-Control: no-cache');
262 header('Connection: keep-alive');
263 header('X-Accel-Buffering: no');
264
265 // Dev-mode diagnostic: with the reorder, stray buffered bytes become
266 // the first bytes of the SSE stream — record what they are so a future
267 // switch to ob_end_clean() can be decided on evidence (fe130d follow-up).
268 if (defined('MXCHAT_DEV_MODE') && MXCHAT_DEV_MODE && ob_get_level() > 0) {
269 $buffered = ob_get_contents();
270 if (is_string($buffered) && $buffered !== '') {
271 error_log('MxChat SSE teardown: output buffer held ' . strlen($buffered) . ' byte(s): ' . substr($buffered, 0, 200));
272 }
273 }
274
275 // Disable output buffering
276 while (ob_get_level()) {
277 ob_end_flush();
278 }
279
280 ob_implicit_flush(true);
281 flush();
282
283 $this->streaming_headers_sent = true;
284 return true;
285 }
286
287 /**
288 * Class constructor
289 */
290 public function __construct() {
291 $this->options = get_option('mxchat_options');
292 $this->prompts_options = get_option('mxchat_prompts_options', array());
293 $this->chat_count = get_option('mxchat_chat_count', 0);
294 $this->word_handler = new MXChat_Word_Handler($this->options);
295
296 // Add all action hooks
297 add_action('wp_enqueue_scripts', array($this, 'mxchat_enqueue_scripts_styles'));
298 add_action('wp_ajax_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
299 add_action('wp_ajax_nopriv_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
300 add_action('wp_ajax_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
301 add_action('wp_ajax_nopriv_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
302
303 // Add the AJAX actions for checking if the pre-chat message was dismissed
304 add_action('wp_ajax_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
305 add_action('wp_ajax_nopriv_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
306 add_action('wp_ajax_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
307 add_action('wp_ajax_nopriv_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
308 add_action('wp_ajax_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
309 add_action('wp_ajax_nopriv_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
310
311 // Add REST API routes registration
312 add_action('rest_api_init', array($this, 'register_routes'));
313 add_action('wp_ajax_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
314 add_action('wp_ajax_nopriv_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
315
316 // Rate limit action - notice we removed the old schedule setup
317 add_action('mxchat_reset_rate_limits', array($this, 'mxchat_reset_rate_limits'));
318
319 // Self-heal: if the reset event is ever lost (cron row cleared, botched
320 // migration, deactivate/reactivate race), an admin-context request brings it
321 // back. Cheap by construction: 60s transient guard + early return when the
322 // event is already scheduled. Without this, a lost event with the fallback
323 // flag unset leaves visitors rate-limited forever.
324 add_action('admin_init', array($this, 'setup_rate_limit_cron_jobs'));
325
326 // File upload and handling actions
327 add_action('wp_ajax_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
328 add_action('wp_ajax_nopriv_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
329 add_action('wp_ajax_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
330 add_action('wp_ajax_nopriv_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
331
332 // Word document handling actions
333 add_action('wp_ajax_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
334 add_action('wp_ajax_nopriv_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
335 add_action('wp_ajax_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
336 add_action('wp_ajax_nopriv_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
337 add_action('wp_ajax_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
338 add_action('wp_ajax_nopriv_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
339
340 // Email handling actions
341 add_action('wp_ajax_nopriv_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
342 add_action('wp_ajax_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
343 add_action('wp_ajax_nopriv_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
344 add_action('wp_ajax_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
345
346 add_action('wp_ajax_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
347 add_action('wp_ajax_nopriv_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
348
349 // Testing panel AJAX actions
350 add_action('wp_ajax_mxchat_get_system_info', array($this, 'mxchat_get_system_info'));
351 add_action('wp_ajax_mxchat_get_similarity_threshold', array($this, 'mxchat_get_similarity_threshold'));
352 add_action('wp_ajax_mxchat_get_kb_status', array($this, 'mxchat_get_kb_status'));
353 add_action('wp_ajax_mxchat_start_fresh_session', array($this, 'mxchat_start_fresh_session'));
354 // Add to your existing constructor, in the section with other AJAX actions:
355 add_action('wp_ajax_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
356 add_action('wp_ajax_nopriv_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
357 add_action('wp_ajax_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
358 add_action('wp_ajax_nopriv_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
359 // Add chat mode checking actions
360 add_action('wp_ajax_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
361 add_action('wp_ajax_nopriv_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
362
363 // Nonce refresh for page-cache compatibility (WP Rocket, LiteSpeed, etc.)
364 add_action('wp_ajax_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
365 add_action('wp_ajax_nopriv_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
366
367 // Auto-email transcript action
368 add_action('mxchat_send_delayed_transcript', array($this, 'mxchat_send_delayed_transcript'), 10, 1);
369
370 add_filter('mxchat_check_actions_only', array($this, 'check_actions_for_addons'), 10, 4);
371
372
373 }
374
375 /**
376 * Return a fresh nonce so cached pages can replace the stale one.
377 * With `with_settings`, also returns the current behavior-gate settings so
378 * the widget can correct stale inline-localized values (plan-32db95).
379 */
380 public function mxchat_refresh_nonce() {
381 nocache_headers();
382 $payload = array('nonce' => wp_create_nonce('mxchat_chat_nonce'));
383 if (!empty($_REQUEST['with_settings'])) {
384 $payload['settings'] = $this->get_dynamic_widget_settings(true);
385 }
386 wp_send_json_success($payload);
387 }
388
389 /**
390 * Behavior-gate settings the widget may re-fetch at runtime (plan-32db95).
391 *
392 * Every widget setting ships inline in page HTML via wp_localize_script, so
393 * full-page caches (host caches, WP Rocket, LiteSpeed, W3TC, FlyingPress,
394 * WP Super Cache, Cloudflare APO, the browser itself) keep serving a stale
395 * snapshot after an admin changes a setting. MxChat_Cache_Purge clears the
396 * caches PHP can reach; this payload covers the rest — the widget requests
397 * it on first open (via the nonce-refresh endpoints) and merges it over
398 * `mxchatChat`, the same distrust-cached-HTML pattern the 3.2.7 per-request
399 * nonce uses.
400 *
401 * Behavior gates + labels ONLY — colors stay inline because they're also
402 * server-inline-styled, and a runtime swap would visibly flash.
403 *
404 * Both wp_localize_script blocks merge this exact array, so the inline and
405 * refreshed payloads cannot drift.
406 *
407 * @param bool $fresh Re-read mxchat_options from the DB (endpoint paths)
408 * instead of trusting the instance copy.
409 * @return array
410 */
411 public function get_dynamic_widget_settings($fresh = false) {
412 $options = $fresh ? get_option('mxchat_options', array()) : $this->options;
413 if (!is_array($options)) {
414 $options = array();
415 }
416 return array(
417 'model' => isset($options['model']) ? $options['model'] : 'gpt-5.6-sol',
418 'enable_streaming_toggle' => isset($options['enable_streaming_toggle']) ? $options['enable_streaming_toggle'] : 'on',
419 'rate_limit_message' => $options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
420 'chat_toolbar_toggle' => $options['chat_toolbar_toggle'] ?? 'off',
421 'print_button_enabled' => $options['print_button_enabled'] ?? 'on',
422 'print_button_label' => esc_html__('Download Transcript', 'mxchat'),
423 // "Start new chat" header-menu item (plan ac2e81). Default OFF.
424 'reset_chat_enabled' => $options['reset_chat_enabled'] ?? 'off',
425 'reset_chat_label' => !empty($options['reset_chat_label']) ? esc_html($options['reset_chat_label']) : esc_html__('Start new chat', 'mxchat'),
426 'reset_chat_confirm' => esc_html__('Start a new chat? This clears the current conversation.', 'mxchat'),
427 'stop_button_label' => esc_html__('Stop response', 'mxchat'),
428 'print_header_title' => esc_html(get_bloginfo('name')) . ' — ' . esc_html__('Chat transcript', 'mxchat'),
429 // Emit 'on'/'off' STRINGS, never booleans: wp_localize_script casts
430 // scalars to string, and (string) false === '' — which the widget's
431 // old gate read as enabled (plan-4bba64). The filter keeps its
432 // boolean contract; only the emitted value is stringified.
433 'satisfaction_rating_enabled' => apply_filters(
434 'mxchat_satisfaction_rating_enabled',
435 ($options['satisfaction_rating_enabled'] ?? 'off') === 'on'
436 ) ? 'on' : 'off',
437 'satisfaction_rating_idle_seconds' => max(5, min(600, intval($options['satisfaction_rating_idle_seconds'] ?? 60))),
438 'satisfaction_rating_copy' => array(
439 'question' => !empty($options['satisfaction_rating_question']) ? esc_html($options['satisfaction_rating_question']) : esc_html__('Was this helpful?', 'mxchat'),
440 'helpful' => esc_html__('Helpful', 'mxchat'),
441 'not_helpful' => esc_html__('Not helpful', 'mxchat'),
442 'dismiss' => esc_html__('Dismiss', 'mxchat'),
443 'thanks' => !empty($options['satisfaction_rating_thanks']) ? esc_html($options['satisfaction_rating_thanks']) : esc_html__('Thanks! Anything we should improve? (optional)', 'mxchat'),
444 'placeholder' => !empty($options['satisfaction_rating_placeholder']) ? esc_html($options['satisfaction_rating_placeholder']) : esc_html__('Tell us what could be better…', 'mxchat'),
445 'send' => esc_html__('Send', 'mxchat'),
446 'skip' => esc_html__('Skip', 'mxchat'),
447 'saved' => !empty($options['satisfaction_rating_saved']) ? esc_html($options['satisfaction_rating_saved']) : esc_html__('Thanks for the feedback.', 'mxchat'),
448 ),
449 );
450 }
451
452 // In your core plugin's check_actions_for_addons method:
453 public function check_actions_for_addons($default, $message, $user_id, $session_id) {
454 //error_log('MxChat Core: check_actions_for_addons called with message: ' . $message);
455
456 $result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
457
458 //error_log('MxChat Core: Intent check result = ' . ($result === false ? 'false' : 'true'));
459
460 return $result;
461 }
462
463 private function mxchat_increment_chat_count() {
464 $chat_count = get_option('mxchat_chat_count', 0);
465 $chat_count++;
466 update_option('mxchat_chat_count', $chat_count);
467 }
468
469 function mxchat_fetch_conversation_history() {
470 if (empty($_POST['session_id'])) {
471 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
472 wp_die();
473 }
474
475 $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
476
477 // SECURITY FIX: Verify session ownership before retrieving data
478 // If IP/user changed, signal frontend to reset session instead of blocking
479 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
480
481 // Check if this session has an owner recorded
482 $session_owner = get_option("mxchat_session_owner_{$session_id}");
483
484 // Update session owner if it changed (e.g. IP changed due to network switch)
485 // The session ID itself is the authentication — if the client has it, they own it
486 if (!$session_owner || $session_owner !== $current_user_identifier) {
487 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
488 }
489
490 $history = get_option("mxchat_history_{$session_id}", []); // Retrieve stored history
491 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai'); // Get current chat mode
492
493 if (empty($history)) {
494 // Even if history is empty, return the chat mode
495 wp_send_json_success([
496 'conversation' => [],
497 'chat_mode' => $chat_mode
498 ]);
499 wp_die();
500 }
501
502 wp_send_json_success([
503 'conversation' => $history,
504 'chat_mode' => $chat_mode
505 ]);
506 wp_die();
507 }
508 private function mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp = 0) {
509 $history = get_option("mxchat_history_{$session_id}", []);
510
511 // Check persistence setting - when OFF, only include messages from current page load
512 $options = get_option('mxchat_options', []);
513 $persistence_enabled = isset($options['chat_persistence_toggle']) && $options['chat_persistence_toggle'] === 'on';
514
515 // Filter history when persistence is OFF to match what the user sees
516 if (!$persistence_enabled && $session_start_timestamp > 0) {
517 $history = array_filter($history, function($entry) use ($session_start_timestamp) {
518 // Include messages from this page load onwards
519 return isset($entry['timestamp']) && $entry['timestamp'] >= $session_start_timestamp;
520 });
521 // Re-index array after filtering
522 $history = array_values($history);
523 }
524
525 $formatted_history = [];
526
527 // Adjusted for code-heavy conversations
528 $max_tokens = 120000; // Context window size
529 $reserved_tokens = 5000; // Space for system prompts + current query
530 $current_token_count = 0;
531
532 // Allowed HTML tags for content sanitization
533 $allowed_tags = [
534 'pre' => ['class' => true],
535 'code' => ['class' => true],
536 'span' => ['class' => true],
537 'div' => ['class' => true],
538 'strong' => [],
539 'em' => []
540 ];
541
542 foreach (array_reverse($history) as $entry) {
543 // Preserve code blocks while sanitizing other HTML
544 $clean_content = wp_kses($entry['content'], $allowed_tags);
545
546 // Detect code blocks in content
547 $has_code = false;
548 // Replace the HTML check with:
549 // Allow messages that contain code blocks or are plain text
550 if (strpos($clean_content, '<pre') === false &&
551 strpos($clean_content, '<code') === false &&
552 $clean_content !== strip_tags($entry['content'])) {
553 continue;
554 }
555
556 // Skip entries that lost significant content during sanitization
557 if (!$has_code && $clean_content !== strip_tags($entry['content'])) {
558 continue;
559 }
560
561 // More accurate token estimation (1 token ≈ 4 characters)
562 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
563
564 // Check token budget with the new estimate
565 if (($current_token_count + $token_estimate + $reserved_tokens) > $max_tokens) {
566 // Try to fit partial content if it's the first entry
567 if (empty($formatted_history)) {
568 $clean_content = mb_substr($clean_content, 0, ($max_tokens - $reserved_tokens) * 4);
569 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
570 } else {
571 break;
572 }
573 }
574
575 // Add to formatted history
576 $formatted_history[] = [
577 'role' => $entry['role'],
578 'content' => $clean_content
579 ];
580
581 $current_token_count += $token_estimate;
582 }
583
584 // Reverse back to maintain chronological order
585 $formatted_history = array_reverse($formatted_history);
586
587 // Add system message about code context
588 array_unshift($formatted_history, [
589 'role' => 'system',
590 'content' => 'Preserved code blocks are marked with [CODE BLOCK PRESERVED]. '
591 . 'Maintain formatting and syntax highlighting when referencing code.'
592 ]);
593
594 return $formatted_history;
595 }
596
597 public function register_routes() {
598 //error_log(esc_html__('Registering MxChat REST routes', 'mxchat'));
599
600 // Per-request chat-send nonce endpoint — issues a fresh nonce on demand
601 // so the chat widget never depends on a stale nonce embedded in cached HTML.
602 // Public (no auth), rate-limited (1 call / IP / second via a transient).
603 register_rest_route('mxchat/v1', '/nonce', [
604 'methods' => 'GET',
605 'callback' => [$this, 'mxchat_issue_chat_send_nonce'],
606 'permission_callback' => '__return_true',
607 ]);
608
609 register_rest_route('mxchat/v1', '/stream', [
610 'methods' => 'GET',
611 'callback' => [$this, 'mxchat_stream_events'],
612 'permission_callback' => [$this, 'verify_chat_session'],
613 ]);
614
615 register_rest_route('mxchat/v1', '/agent-response', [
616 'methods' => 'POST',
617 'callback' => [$this, 'mxchat_handle_agent_response'],
618 'permission_callback' => [$this, 'verify_slack_request'],
619 ]);
620
621 register_rest_route('mxchat/v1', '/slack-interaction', [
622 'methods' => 'POST',
623 'callback' => [$this, 'handle_slack_interaction'],
624 'permission_callback' => [$this, 'verify_slack_request'],
625 ]);
626
627 register_rest_route('mxchat/v1', '/slack-messages', [
628 'methods' => 'POST',
629 'callback' => [$this, 'handle_slack_messages'],
630 'permission_callback' => [$this, 'verify_slack_request'],
631 ]);
632
633 // Telegram webhook endpoint
634 register_rest_route('mxchat/v1', '/telegram-webhook', [
635 'methods' => 'POST',
636 'callback' => [$this, 'handle_telegram_webhook'],
637 'permission_callback' => [$this, 'verify_telegram_request'],
638 ]);
639
640 //error_log(esc_html__('MxChat REST routes registered', 'mxchat'));
641 }
642
643 /**
644 * Issue a fresh per-request nonce for chat-send. Returned to the widget which
645 * caches it for the session and includes it on every chat-send / stream-send /
646 * upload call. By moving the nonce out of inline `window.mxchatChat = {...}` HTML
647 * we eliminate the entire class of "first-message Access denied" failures that
648 * plague WP installs behind a full-page cache (WP Rocket, LiteSpeed, FlyingPress,
649 * W3 Total Cache, Cloudflare APO) — the nonce is never cached because it never
650 * lives in the HTML body.
651 *
652 * Public endpoint. Rate-limited to 1 call / IP / 1s via a transient so a single
653 * client browser can't be used to flood the nonce-issuance path.
654 *
655 * Nonce action: `mxchat_chat_send` (new). The chat-send AJAX handlers accept
656 * BOTH this action AND the legacy `mxchat_chat_nonce` action for a 30-day
657 * backwards-compat window so cached pages still in users' browsers don't break
658 * mid-session.
659 *
660 * @since 3.2.7
661 */
662 public function mxchat_issue_chat_send_nonce(WP_REST_Request $request) {
663 $ip = '';
664 if (!empty($_SERVER['REMOTE_ADDR'])) {
665 $ip = preg_replace('#[^0-9a-fA-F:\.]#', '', wp_unslash((string) $_SERVER['REMOTE_ADDR']));
666 }
667 if ($ip !== '') {
668 // Best-effort rate limit. WP transients with sub-second TTL are racy
669 // (parallel bursts can squeak through before set_transient completes);
670 // we use 2s to make the gate slightly more reliable. Real production
671 // rate-limiting at sub-second granularity needs Redis or DB row locks
672 // — out of scope for this endpoint, which is already cheap.
673 $key = 'mxchat_nonce_rl_' . md5($ip);
674 if (get_transient($key)) {
675 return new WP_REST_Response(array(
676 'error' => 'rate_limited',
677 'message' => __('Too many nonce requests. Try again shortly.', 'mxchat'),
678 ), 429);
679 }
680 set_transient($key, 1, 2);
681 }
682
683 // The widget calls this endpoint without an X-WP-Nonce header, so WordPress does not
684 // honor the auth cookie and the request runs as uid=0 even for logged-in users. That
685 // makes wp_create_nonce() bind the nonce to uid=0, which then fails wp_verify_nonce()
686 // at admin-ajax (which runs as the real uid) -> logged-in users get a 403 on upload.
687 // Resolve the real user from the logged_in cookie so the nonce binds to the correct uid.
688 if ( ! is_user_logged_in() ) {
689 $maybe_uid = wp_validate_auth_cookie( '', 'logged_in' );
690 if ( $maybe_uid ) {
691 wp_set_current_user( $maybe_uid );
692 }
693 }
694
695 $payload = array(
696 'nonce' => wp_create_nonce('mxchat_chat_send'),
697 'expires_in' => 86400, // WP nonces live 24h; widget caches for 12h conservatively.
698 );
699
700 // plan-32db95: the widget's first-open refresh asks for current behavior
701 // settings in the same round-trip, so stale inline-localized values on
702 // cached pages get corrected without a second request. All values in
703 // this payload already ship in public page HTML — nothing sensitive.
704 if ($request->get_param('with_settings')) {
705 $payload['settings'] = $this->get_dynamic_widget_settings(true);
706 }
707
708 return new WP_REST_Response($payload, 200);
709 }
710
711 /**
712 * Verify a chat-send nonce. Accepts BOTH the new `mxchat_chat_send` action
713 * (issued by /wp-json/mxchat/v1/nonce) AND the legacy `mxchat_chat_nonce`
714 * action (inline-localized in older cached HTML). The legacy acceptance is
715 * a 30-day backwards-compat window — to be removed in a follow-up release
716 * after 2026-06-27.
717 *
718 * @param string $posted_nonce
719 * @return bool
720 */
721 public static function mxchat_verify_chat_send_nonce($posted_nonce) {
722 if (!is_string($posted_nonce) || $posted_nonce === '') {
723 return false;
724 }
725 return (bool) wp_verify_nonce($posted_nonce, 'mxchat_chat_send')
726 || (bool) wp_verify_nonce($posted_nonce, 'mxchat_chat_nonce');
727 }
728
729 /**
730 * Verify valid chat session
731 */
732 public function verify_chat_session($request) {
733 $session_id = $request->get_param('session_id');
734 if (empty($session_id)) {
735 //error_log(esc_html__('Empty session ID in chat request', 'mxchat'));
736 return false;
737 }
738
739 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
740 return $chat_mode === 'agent';
741 }
742
743 /**
744 * Verify request is coming from Slack.
745 *
746 * @param WP_REST_Request $request
747 * @return bool True if valid, false otherwise.
748 */
749 public function verify_slack_request($request) {
750 // Get the Slack signing secret from your plugin options
751 $valid_key = $this->options['live_agent_secret_key'] ?? '';
752
753 if (empty($valid_key)) {
754 //error_log(esc_html__('Slack signing secret not configured', 'mxchat'));
755 return false;
756 }
757
758 $timestamp = $request->get_header('X-Slack-Request-Timestamp');
759 $slack_signature = $request->get_header('X-Slack-Signature');
760
761 // Verify timestamp to prevent replay attacks
762 if (abs(time() - intval($timestamp)) > 300) {
763 //error_log(esc_html__('Slack request timestamp too old', 'mxchat'));
764 return false;
765 }
766
767 // Get raw request body from the WP_REST_Request object
768 // (php://input may already be consumed by WordPress at this point)
769 $request_body = $request->get_body();
770
771 // Create the signature base string
772 $sig_basestring = "v0:{$timestamp}:{$request_body}";
773
774 // Calculate expected signature
775 $my_signature = 'v0=' . hash_hmac('sha256', $sig_basestring, $valid_key);
776
777 // Compare signatures
778 return hash_equals($my_signature, $slack_signature);
779 }
780
781 /**
782 * Verify request is coming from Telegram.
783 *
784 * @param WP_REST_Request $request
785 * @return bool True if valid, false otherwise.
786 */
787 public function verify_telegram_request($request) {
788 $secret_token = $this->options['telegram_webhook_secret'] ?? '';
789
790 //error_log('[MxChat Telegram DEBUG] verify_telegram_request called');
791 //error_log('[MxChat Telegram DEBUG] Stored secret: ' . (empty($secret_token) ? 'EMPTY' : substr($secret_token, 0, 10) . '...'));
792
793 if (empty($secret_token)) {
794 // No secret configured (legacy setup). Do NOT fail open to the whole
795 // internet — that lets an unauthenticated caller write agent-branded
796 // messages. Fall back to verifying the request originates from
797 // Telegram's published webhook IP ranges so existing no-secret installs
798 // keep working while an arbitrary-internet caller is blocked. Setting a
799 // real secret (see the admin notice) is the recommended path.
800 // (plan-0c17b5)
801 $peer = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '';
802 if ($this->mxchat_ip_in_telegram_ranges($peer)) {
803 return true;
804 }
805 error_log('MxChat: Telegram webhook has no secret configured and the request '
806 . 'is not from a Telegram IP range; rejected. Set a webhook secret to secure it.');
807 return false;
808 }
809
810 // Telegram sends the secret token in the X-Telegram-Bot-Api-Secret-Token header
811 $request_token = $request->get_header('X-Telegram-Bot-Api-Secret-Token');
812
813 //error_log('[MxChat Telegram DEBUG] Request token: ' . (empty($request_token) ? 'EMPTY' : substr($request_token, 0, 10) . '...'));
814
815 if (empty($request_token)) {
816 //error_log('[MxChat Telegram DEBUG] Request rejected: No token in header');
817 return false;
818 }
819
820 // Timing-safe comparison
821 $result = hash_equals($secret_token, $request_token);
822 //error_log('[MxChat Telegram DEBUG] Token comparison result: ' . ($result ? 'MATCH' : 'MISMATCH'));
823 return $result;
824 }
825
826 /**
827 * Whether $ip falls within Telegram's published webhook IPv4 ranges
828 * (149.154.160.0/20 and 91.108.4.0/22). Used as an authenticity fallback for
829 * the Telegram webhook when no secret token is configured, so a legacy
830 * no-secret install keeps working without failing open to the entire internet.
831 *
832 * Uses the real TCP peer (REMOTE_ADDR); a spoofable X-Forwarded-For is NOT
833 * consulted. Behind a reverse proxy / CDN that rewrites REMOTE_ADDR this may
834 * not match — which is exactly why configuring a real webhook secret is the
835 * recommended path. (plan-0c17b5)
836 *
837 * @param string $ip Candidate IPv4 address.
838 * @return bool
839 */
840 private function mxchat_ip_in_telegram_ranges($ip) {
841 if (!is_string($ip) || $ip === '' || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
842 return false;
843 }
844 $ip_long = ip2long($ip);
845 if ($ip_long === false) {
846 return false;
847 }
848 $ranges = array(
849 array('149.154.160.0', 20),
850 array('91.108.4.0', 22),
851 );
852 foreach ($ranges as $range) {
853 $subnet_long = ip2long($range[0]);
854 if ($subnet_long === false) {
855 continue;
856 }
857 $mask = (0xFFFFFFFF << (32 - $range[1])) & 0xFFFFFFFF;
858 if (($ip_long & $mask) === ($subnet_long & $mask)) {
859 return true;
860 }
861 }
862 return false;
863 }
864
865 public function mxchat_stream_events(WP_REST_Request $request) {
866 header('Content-Type: text/event-stream');
867 header('Cache-Control: no-cache');
868 header('Connection: keep-alive');
869
870 $session_id = MxChat_Utils::sanitize_session_id($request->get_param('session_id'));
871 $last_seen_id = sanitize_text_field($request->get_param('last_seen_id')) ?: '';
872
873 if (empty($session_id)) {
874 echo esc_html__("event: error\ndata: ", 'mxchat') . esc_html__('Missing session_id', 'mxchat') . "\n\n";
875 flush();
876 exit;
877 }
878
879 $history = get_option("mxchat_history_{$session_id}", []);
880
881 // Filter only new messages
882 $new_messages = array_filter($history, function ($message) use ($last_seen_id) {
883 return !empty($message['id']) && $message['id'] > $last_seen_id;
884 });
885
886 // Send new messages if available
887 if (!empty($new_messages)) {
888 echo esc_html__("event: newMessages\ndata: ", 'mxchat') . json_encode(array_values($new_messages)) . "\n\n";
889 } else {
890 // Keep the connection alive
891 echo esc_html__("event: keepAlive\ndata: ", 'mxchat') . "{}\n\n";
892 }
893 flush();
894 exit;
895 }
896
897
898
899
900 private function mxchat_save_chat_message($session_id, $role, $message, $originating_page = null, $rag_context = null) {
901 global $wpdb;
902 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
903 //error_log("[DEBUG] mxchat_save_chat_message -> START for session_id: {$session_id}, role: {$role}");
904
905 // Check if this is the first message in a new session (before any other database operations)
906 $is_new_session = false;
907 if ($role === 'user') { // Only check for user messages, not bot responses
908 $existing_messages = $wpdb->get_var($wpdb->prepare(
909 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
910 $session_id
911 ));
912 $is_new_session = ($existing_messages == 0);
913
914 // Log for debugging
915 if ($is_new_session) {
916 //error_log("[DEBUG] This is a NEW session - first message");
917 }
918 }
919
920 // SECURITY FIX: Set session ownership for new sessions
921 if ($is_new_session && $role === 'user') {
922 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
923 $session_owner_key = "mxchat_session_owner_{$session_id}";
924
925 // Only set ownership if not already set
926 if (!get_option($session_owner_key)) {
927 update_option($session_owner_key, $current_user_identifier, 'no');
928 //error_log("[DEBUG] Set session ownership for {$session_id} to {$current_user_identifier}");
929 }
930 }
931
932 // 1) Extract agent name if present
933 $agent_name = '';
934 if (preg_match('/^Agent: (.*?) - /', $message, $matches)) {
935 $agent_name = $matches[1];
936 $message = str_replace("Agent: $agent_name - ", '', $message);
937 $session_meta_key = "mxchat_agent_name_{$session_id}";
938 if (empty(get_option($session_meta_key))) {
939 update_option($session_meta_key, $agent_name);
940 //error_log("[DEBUG] mxchat_save_chat_message -> Stored agent_name in option: {$session_meta_key} => {$agent_name}");
941 }
942 }
943
944 // 2) Generate unique message_id
945 $message_id = uniqid();
946 //error_log("[DEBUG] mxchat_save_chat_message -> Generated message_id: {$message_id}");
947
948 // 3) Determine user_id
949 $user_id = is_user_logged_in() ? get_current_user_id() : 0;
950
951 // 4) Determine user_identifier
952 $user_identifier = $agent_name
953 ? $agent_name
954 : MxChat_User::mxchat_get_user_identifier();
955
956 // 5) Determine displayed_name
957 $user_email = MxChat_User::mxchat_get_user_email();
958 $displayed_name = $agent_name ? $agent_name : ($user_email ?: $user_identifier);
959
960 // 6) Check for a saved email in wp_options
961 $email_option_key = "mxchat_email_{$session_id}";
962 $saved_email = get_option($email_option_key);
963 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for email_option_key: {$email_option_key}, found: {$saved_email}");
964
965 // Check for a saved name in wp_options
966 $name_option_key = "mxchat_name_{$session_id}";
967 $saved_name = get_option($name_option_key);
968 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for name_option_key: {$name_option_key}, found: {$saved_name}");
969
970 // If found, update DB user_email and user_name
971 if ($saved_email || $saved_name) {
972 $update_data = [];
973 if ($saved_email) {
974 $update_data['user_email'] = $saved_email;
975 }
976 if ($saved_name) {
977 $update_data['user_name'] = $saved_name;
978 }
979
980 if (!empty($update_data)) {
981 $update_res = $wpdb->update(
982 $table_name,
983 $update_data,
984 ['session_id' => $session_id],
985 array_fill(0, count($update_data), '%s'),
986 ['%s']
987 );
988 //error_log("[DEBUG] mxchat_save_chat_message -> Attempted DB user_email/user_name update for session_id {$session_id}. update_res: {$update_res}");
989 }
990 }
991
992 // 7) Save to session history in wp_options
993 $history_key = "mxchat_history_{$session_id}";
994 $history = get_option($history_key, []);
995 $history[] = [
996 'id' => $message_id,
997 'role' => $role,
998 'content' => $message,
999 'timestamp' => round(microtime(true) * 1000),
1000 'agent_name' => $displayed_name,
1001 ];
1002 update_option($history_key, $history, 'no');
1003 //error_log("[DEBUG] mxchat_save_chat_message -> Updated session history in option: {$history_key}");
1004
1005 // 8) Save the message to DB (INSERT)
1006 $insert_data = [
1007 'user_id' => $user_id,
1008 'user_identifier'=> $user_identifier,
1009 'user_email' => $saved_email ?: $user_email,
1010 'user_name' => $saved_name ?: '', // Add name to insert data
1011 'session_id' => $session_id,
1012 'role' => $role,
1013 'message' => $message,
1014 'timestamp' => current_time('mysql', 1),
1015 ];
1016
1017 // IMPROVED: Handle originating page data
1018 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
1019
1020 if ($columns_exist) {
1021 if ($is_new_session && $role === 'user') {
1022 // For the first user message, set originating page data
1023
1024 // First check if we have it from the parameter
1025 if ($originating_page && !empty($originating_page['url'])) {
1026 $insert_data['originating_page_url'] = $originating_page['url'];
1027 $insert_data['originating_page_title'] = $originating_page['title'] ?? '';
1028
1029 //error_log("[DEBUG] Setting originating page from parameter: " . $originating_page['url']);
1030 }
1031 // Otherwise check if it's stored in the instance property
1032 else if (isset($this->pending_originating_page) && !empty($this->pending_originating_page['url'])) {
1033 $insert_data['originating_page_url'] = $this->pending_originating_page['url'];
1034 $insert_data['originating_page_title'] = $this->pending_originating_page['title'] ?? '';
1035
1036 //error_log("[DEBUG] Setting originating page from pending_originating_page: " . $this->pending_originating_page['url']);
1037
1038 // Clear after using (= null, not unset(): unset() undeclares the property
1039 // and the next assignment recreates it dynamic, re-triggering the PHP 8.2 deprecation)
1040 $this->pending_originating_page = null;
1041 }
1042 // Fallback to HTTP_REFERER if nothing else is available
1043 else if (isset($_SERVER['HTTP_REFERER'])) {
1044 $referer_url = esc_url_raw($_SERVER['HTTP_REFERER']);
1045 $insert_data['originating_page_url'] = $referer_url;
1046
1047 // Generate title from URL
1048 $parsed_url = parse_url($referer_url);
1049 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
1050
1051 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
1052 $insert_data['originating_page_title'] = 'Homepage';
1053 } else {
1054 $title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
1055 $insert_data['originating_page_title'] = ucwords(trim($title));
1056 }
1057
1058 //error_log("[DEBUG] Setting originating page from HTTP_REFERER: " . $referer_url);
1059 }
1060
1061 // Store for this session so all messages have the same originating page
1062 if (!empty($insert_data['originating_page_url'])) {
1063 update_option("mxchat_originating_page_{$session_id}", [
1064 'url' => $insert_data['originating_page_url'],
1065 'title' => $insert_data['originating_page_title']
1066 ], 'no');
1067 }
1068 } else {
1069 // For subsequent messages in the session, use the stored originating page
1070 $stored_originating = get_option("mxchat_originating_page_{$session_id}");
1071 if ($stored_originating && !empty($stored_originating['url'])) {
1072 $insert_data['originating_page_url'] = $stored_originating['url'];
1073 $insert_data['originating_page_title'] = $stored_originating['title'] ?? '';
1074 }
1075 }
1076 }
1077
1078 // Add RAG context if provided (for bot messages)
1079 if ($rag_context !== null && $role === 'bot') {
1080 $rag_context_column_exists = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'rag_context'");
1081 if ($rag_context_column_exists) {
1082 $insert_data['rag_context'] = is_array($rag_context) ? wp_json_encode($rag_context) : $rag_context;
1083 }
1084 }
1085
1086 $wpdb->insert($table_name, $insert_data);
1087 //error_log("[DEBUG] mxchat_save_chat_message -> Inserted message into DB. row_id: {$wpdb->insert_id}, data: " . print_r($insert_data, true));
1088
1089 // 9) Send notification email if this is the first user message in a new session
1090 if ($wpdb->insert_id && $is_new_session && $role === 'user') {
1091 $this->send_new_chat_notification($session_id, array(
1092 'identifier' => $user_identifier,
1093 'email' => $saved_email ?: $user_email,
1094 'ip' => $_SERVER['REMOTE_ADDR']
1095 ));
1096 }
1097
1098 // 10) Schedule delayed transcript email if enabled and message is from user
1099 if ($wpdb->insert_id && $role === 'user') {
1100 $this->schedule_delayed_transcript_email($session_id);
1101 }
1102
1103 //error_log("[DEBUG] mxchat_save_chat_message -> END for session_id: {$session_id}");
1104 return $message_id;
1105 }
1106
1107 private function send_new_chat_notification($session_id, $user_info = array()) {
1108 $options = get_option('mxchat_transcripts_options');
1109
1110 // Check if notifications are enabled
1111 if (empty($options['mxchat_enable_notifications'])) {
1112 return false;
1113 }
1114
1115 // Get notification email
1116 $to = !empty($options['mxchat_notification_email']) ?
1117 $options['mxchat_notification_email'] :
1118 get_option('admin_email');
1119
1120 if (!is_email($to)) {
1121 return false;
1122 }
1123
1124 // Prepare email content
1125 $subject = sprintf('[%s] New Chat Session Started', get_bloginfo('name'));
1126
1127 $user_identifier = isset($user_info['identifier']) ? $user_info['identifier'] : 'Guest';
1128 $user_email = isset($user_info['email']) ? $user_info['email'] : 'Not provided';
1129 $user_ip = isset($user_info['ip']) ? $user_info['ip'] : $_SERVER['REMOTE_ADDR'];
1130
1131 $message = sprintf(
1132 "A new chat session has started on your website.\n\n" .
1133 "Session ID: %s\n" .
1134 "User: %s\n" .
1135 "Email: %s\n" .
1136 "IP Address: %s\n" .
1137 "Time: %s\n\n" .
1138 "View transcripts: %s",
1139 $session_id,
1140 $user_identifier,
1141 $user_email,
1142 $user_ip,
1143 current_time('mysql'),
1144 admin_url('admin.php?page=mxchat-transcripts')
1145 );
1146
1147 // Send email
1148 return wp_mail($to, $subject, $message);
1149 }
1150
1151 /**
1152 * Schedule delayed transcript email for a session
1153 * Reschedules if a new user message is received
1154 */
1155 private function schedule_delayed_transcript_email($session_id) {
1156 $options = get_option('mxchat_transcripts_options');
1157
1158 // Check if auto-email is enabled
1159 if (empty($options['mxchat_auto_email_transcript_enabled'])) {
1160 return;
1161 }
1162
1163 // Get notification email
1164 $email = !empty($options['mxchat_notification_email']) ?
1165 $options['mxchat_notification_email'] :
1166 get_option('admin_email');
1167
1168 if (!is_email($email)) {
1169 return;
1170 }
1171
1172 // Get delay in minutes (default 30)
1173 $delay_minutes = isset($options['mxchat_auto_email_transcript_delay']) ?
1174 intval($options['mxchat_auto_email_transcript_delay']) : 30;
1175
1176 // Clear any existing scheduled event for this session
1177 $hook = 'mxchat_send_delayed_transcript';
1178 $args = array($session_id);
1179 $timestamp = wp_next_scheduled($hook, $args);
1180
1181 if ($timestamp) {
1182 wp_unschedule_event($timestamp, $hook, $args);
1183 }
1184
1185 // Schedule new event
1186 $schedule_time = time() + ($delay_minutes * 60);
1187 wp_schedule_single_event($schedule_time, $hook, $args);
1188 }
1189
1190 /**
1191 * Check if chat messages contain contact information (email or phone number)
1192 *
1193 * @param array $messages Array of message objects with 'message' property
1194 * @param object|null $session_data Session data object with user_email property
1195 * @return bool True if contact info found, false otherwise
1196 */
1197 private function chat_contains_contact_info($messages, $session_data = null) {
1198 // Check if session already has a stored email
1199 if ($session_data && !empty($session_data->user_email)) {
1200 return true;
1201 }
1202
1203 // Email regex pattern
1204 $email_pattern = '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/';
1205
1206 // Phone number patterns (covers various formats including international, WhatsApp style)
1207 // Matches: +1234567890, (123) 456-7890, 123-456-7890, 123.456.7890, 1234567890, +1 234 567 8900, etc.
1208 $phone_pattern = '/(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{2,4}[-.\s]?\d{2,4}(?:[-.\s]?\d{1,4})?/';
1209
1210 // Only check user messages (not assistant responses)
1211 foreach ($messages as $msg) {
1212 if ($msg->role !== 'user') {
1213 continue;
1214 }
1215
1216 $message_text = $msg->message;
1217
1218 // Check for email
1219 if (preg_match($email_pattern, $message_text)) {
1220 return true;
1221 }
1222
1223 // Check for phone number (must be at least 7 digits total to avoid false positives)
1224 if (preg_match($phone_pattern, $message_text, $matches)) {
1225 // Count actual digits to avoid matching short numbers
1226 $digits_only = preg_replace('/\D/', '', $matches[0]);
1227 if (strlen($digits_only) >= 7) {
1228 return true;
1229 }
1230 }
1231 }
1232
1233 return false;
1234 }
1235
1236 /**
1237 * Send the delayed transcript email with .txt attachment
1238 */
1239 public function mxchat_send_delayed_transcript($session_id) {
1240 global $wpdb;
1241
1242 // plan-mxchat-20260731-d42bec — this is the one place a session id becomes a
1243 // filesystem path segment (see the $temp_file build below), so validate here
1244 // too even though intake is now validated. This runs from a scheduled event,
1245 // so its argument comes from whatever was stored at schedule time rather than
1246 // straight from the current request.
1247 $session_id = MxChat_Utils::sanitize_session_id($session_id);
1248 if ($session_id === '') {
1249 return false;
1250 }
1251
1252 $options = get_option('mxchat_transcripts_options');
1253
1254 // Get notification email
1255 $to = !empty($options['mxchat_notification_email']) ?
1256 $options['mxchat_notification_email'] :
1257 get_option('admin_email');
1258
1259 if (!is_email($to)) {
1260 return false;
1261 }
1262
1263 // Get all messages for this session
1264 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1265 $messages = $wpdb->get_results($wpdb->prepare(
1266 "SELECT role, message, timestamp FROM {$table_name}
1267 WHERE session_id = %s
1268 ORDER BY timestamp ASC",
1269 $session_id
1270 ));
1271
1272 if (empty($messages)) {
1273 return false;
1274 }
1275
1276 // Get session metadata
1277 $sessions_table = $wpdb->prefix . 'mxchat_sessions';
1278 $session_data = $wpdb->get_row($wpdb->prepare(
1279 "SELECT * FROM {$sessions_table} WHERE session_id = %s",
1280 $session_id
1281 ));
1282
1283 // Check if contact info is required and if it's present
1284 $require_contact = !empty($options['mxchat_auto_email_transcript_require_contact']);
1285 if ($require_contact && !$this->chat_contains_contact_info($messages, $session_data)) {
1286 // Contact info required but not found - skip sending
1287 return false;
1288 }
1289
1290 // Build transcript content
1291 $transcript_content = "Chat Transcript\n";
1292 $transcript_content .= "================\n\n";
1293 $transcript_content .= "Session ID: " . $session_id . "\n";
1294
1295 if ($session_data) {
1296 $transcript_content .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
1297 $transcript_content .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
1298 $transcript_content .= "Started: " . $session_data->created_at . "\n";
1299 }
1300
1301 $transcript_content .= "\n" . str_repeat("=", 50) . "\n\n";
1302
1303 // Add messages
1304 foreach ($messages as $msg) {
1305 // 'agent' rows are live-agent (human) replies — label them as such in
1306 // the emailed transcript, same distinction the Transcripts viewer draws.
1307 $role_label = ($msg->role === 'user') ? 'User' : (($msg->role === 'agent') ? 'Live Agent' : 'Assistant');
1308 $transcript_content .= "[{$msg->timestamp}] {$role_label}:\n";
1309 $transcript_content .= $msg->message . "\n\n";
1310 }
1311
1312 // Create temporary file for attachment using WP_Filesystem
1313 $upload_dir = wp_upload_dir();
1314 // basename() is the SECOND independent control on this write
1315 // (plan-mxchat-20260731-d42bec). The validator above already rejects any id
1316 // containing a path separator; this survives someone loosening it later.
1317 $temp_file = $upload_dir['basedir'] . '/' . basename('mxchat-transcript-' . $session_id . '.txt');
1318 global $wp_filesystem;
1319 if (empty($wp_filesystem)) {
1320 require_once ABSPATH . 'wp-admin/includes/file.php';
1321 WP_Filesystem();
1322 }
1323 $wp_filesystem->put_contents($temp_file, $transcript_content, FS_CHMOD_FILE);
1324
1325 // Prepare email
1326 $subject = sprintf('[%s] Chat Transcript - Session %s', get_bloginfo('name'), substr($session_id, 0, 8));
1327
1328 $message = "Please find attached the full chat transcript.\n\n";
1329 $message .= "Session ID: {$session_id}\n";
1330
1331 if ($session_data) {
1332 $message .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
1333 $message .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
1334 }
1335
1336 $message .= "\nView online: " . admin_url('admin.php?page=mxchat-transcripts');
1337
1338 // Send email with attachment
1339 $attachments = array($temp_file);
1340 $result = wp_mail($to, $subject, $message, '', $attachments);
1341
1342 // Clean up temporary file
1343 if (file_exists($temp_file)) {
1344 unlink($temp_file);
1345 }
1346
1347 return $result;
1348 }
1349
1350
1351
1352 public function mxchat_handle_save_email_and_response() {
1353 //error_log('[DEBUG] ---------- mxchat_handle_save_email_and_response START ----------');
1354 //error_log('DEBUG: POST data: ' . print_r($_POST, true));
1355
1356 nocache_headers();
1357
1358 // Validate nonce
1359 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
1360 //error_log(esc_html__('[ERROR] Invalid nonce in mxchat_handle_save_email_and_response', 'mxchat'));
1361 wp_send_json_error(['message' => esc_html__('Invalid nonce.', 'mxchat')]);
1362 wp_die();
1363 }
1364
1365 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
1366 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
1367 $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
1368
1369 //error_log("[DEBUG] handle_save_email_and_response -> session_id: {$session_id}, email: {$email}, name: {$name}");
1370
1371 if (empty($session_id) || $session_id === 'null' || empty($email)) {
1372 //error_log("[ERROR] Missing session_id or email: session_id={$session_id}, email={$email}");
1373 wp_send_json_error(['message' => esc_html__('Session ID or email is missing.', 'mxchat')]);
1374 wp_die();
1375 }
1376
1377 // Validate name if provided (check if name field is enabled and name is required)
1378 $options = get_option('mxchat_options', []);
1379 $name_field_enabled = isset($options['enable_name_field']) &&
1380 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
1381
1382 if ($name_field_enabled && (empty($name) || strlen(trim($name)) < 2 || strlen(trim($name)) > 100)) {
1383 //error_log("[ERROR] Invalid name: {$name} (enabled: {$name_field_enabled})");
1384 wp_send_json_error(['message' => esc_html__('Name must be between 2 and 100 characters.', 'mxchat')]);
1385 wp_die();
1386 }
1387
1388 // 1) Always store email in wp_options
1389 $email_option_key = "mxchat_email_{$session_id}";
1390 update_option($email_option_key, $email, 'no');
1391 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$email_option_key} => {$email}");
1392
1393 // Store name in wp_options if provided
1394 if (!empty($name)) {
1395 $name_option_key = "mxchat_name_{$session_id}";
1396 update_option($name_option_key, $name, 'no');
1397 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$name_option_key} => {$name}");
1398 }
1399
1400 // 2) (Optional) Also store in DB if a row already exists
1401 global $wpdb;
1402 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1403
1404 // Make sure we have a valid placeholder in prepare
1405 $sql = $wpdb->prepare("SELECT COUNT(*) FROM {$table_name} WHERE session_id = %s", $session_id);
1406 $session_count = $wpdb->get_var($sql);
1407
1408 //error_log("[DEBUG] handle_save_email_and_response -> session_count for {$session_id}: {$session_count} (SQL: {$sql})");
1409
1410 if ($session_count) {
1411 // Update both user_email and user_name if row(s) exist
1412 if (!empty($name)) {
1413 $update_sql = $wpdb->prepare(
1414 "UPDATE {$table_name} SET user_email = %s, user_name = %s WHERE session_id = %s",
1415 $email,
1416 $name,
1417 $session_id
1418 );
1419 } else {
1420 $update_sql = $wpdb->prepare(
1421 "UPDATE {$table_name} SET user_email = %s WHERE session_id = %s",
1422 $email,
1423 $session_id
1424 );
1425 }
1426 $wpdb->query($update_sql);
1427 //error_log("[DEBUG] handle_save_email_and_response -> DB updated: {$update_sql}");
1428 } else {
1429 //error_log("[INFO] handle_save_email_and_response -> No DB entry for {$session_id}, so email/name is only in wp_options.");
1430 }
1431
1432 // Provide success response (same as original)
1433 $bot_message = __('Thanks for providing your email! You can continue chatting now.', 'mxchat');
1434 //error_log("[DEBUG] handle_save_email_and_response -> success, returning bot_message: {$bot_message}");
1435 wp_send_json_success(['message' => $bot_message]);
1436 wp_die();
1437 }
1438
1439 public function mxchat_check_email_provided() {
1440 //error_log('[DEBUG] ---------- mxchat_check_email_provided START ----------');
1441
1442 nocache_headers();
1443
1444 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
1445 //error_log('[ERROR] Invalid nonce in mxchat_check_email_provided');
1446 wp_send_json_error(['message' => esc_html__('Invalid nonce', 'mxchat')]);
1447 }
1448
1449 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
1450 if (empty($session_id) || $session_id === 'null') {
1451 //error_log('[ERROR] No session ID provided in mxchat_check_email_provided');
1452 wp_send_json_error(['message' => esc_html__('No session ID provided', 'mxchat')]);
1453 }
1454
1455 // Check if the user is logged in
1456 if (is_user_logged_in()) {
1457 $current_user = wp_get_current_user();
1458 //error_log("[DEBUG] User is logged in as {$current_user->user_email}");
1459
1460 // Get user's display name for logged in users
1461 $user_name = !empty($current_user->display_name) ? $current_user->display_name :
1462 (!empty($current_user->first_name) ? $current_user->first_name : '');
1463
1464 $response_data = ['logged_in' => true, 'email' => $current_user->user_email];
1465 if (!empty($user_name)) {
1466 $response_data['name'] = $user_name;
1467 }
1468
1469 wp_send_json_success($response_data);
1470 }
1471
1472 // Check if name field is required
1473 $options = get_option('mxchat_options', []);
1474 $name_field_enabled = isset($options['enable_name_field']) &&
1475 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
1476
1477 $email_option_key = "mxchat_email_{$session_id}";
1478 $stored_email = get_option($email_option_key, '');
1479
1480 // Check for stored name
1481 $name_option_key = "mxchat_name_{$session_id}";
1482 $stored_name = get_option($name_option_key, '');
1483
1484 //error_log("[DEBUG] mxchat_check_email_provided -> Checking email option: {$email_option_key}, found: {$stored_email}");
1485 //error_log("[DEBUG] mxchat_check_email_provided -> Checking name option: {$name_option_key}, found: {$stored_name}, required: " . ($name_field_enabled ? 'yes' : 'no'));
1486
1487 // Check if we have email and name (if name is required)
1488 $has_required_info = !empty($stored_email);
1489
1490 if ($name_field_enabled) {
1491 $has_required_info = $has_required_info && !empty($stored_name);
1492 }
1493
1494 if ($has_required_info) {
1495 //error_log("[DEBUG] mxchat_check_email_provided -> Required info found, returning success");
1496
1497 $response_data = ['email' => $stored_email];
1498 if (!empty($stored_name)) {
1499 $response_data['name'] = $stored_name;
1500 }
1501
1502 wp_send_json_success($response_data);
1503 } else {
1504 //error_log("[DEBUG] mxchat_check_email_provided -> Required info missing, returning error");
1505 wp_send_json_error(['message' => esc_html__('No email found', 'mxchat')]);
1506 }
1507 }
1508
1509 /**
1510 * Send error response in appropriate format based on streaming mode
1511 * ADDED: Helper method to consistently handle errors in both streaming and non-streaming modes
1512 *
1513 * @param string $error_message The error message to display
1514 * @param string $error_code Optional error code for debugging
1515 */
1516 private function send_error_response($error_message, $error_code = 'api_error') {
1517 if ($this->is_streaming) {
1518 echo "data: " . json_encode([
1519 'error' => true,
1520 'error_message' => $error_message,
1521 'error_code' => $error_code,
1522 'text' => $error_message,
1523 'message' => $error_message
1524 ]) . "\n\n";
1525 echo "data: [DONE]\n\n";
1526 flush();
1527 } else {
1528 wp_send_json_error([
1529 'error_message' => $error_message,
1530 'error_code' => $error_code
1531 ]);
1532 }
1533 wp_die();
1534 }
1535
1536 public function mxchat_handle_chat_request() {
1537 global $wpdb;
1538
1539 // Debug: Log incoming bot_id
1540 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1541 //error_log("=== MXCHAT DEBUG: Starting chat request ===");
1542 //error_log("MXCHAT DEBUG: Bot ID received: " . $bot_id);
1543
1544 // Get bot-specific options
1545 $bot_options = $this->get_bot_options($bot_id);
1546 $current_options = !empty($bot_options) ? $bot_options : $this->options;
1547
1548 // Check if this is a streaming request
1549 // Allow force_streaming_test parameter to bypass the setting check (for admin compatibility testing)
1550 $force_streaming_test = isset($_POST['force_streaming_test']) && $_POST['force_streaming_test'] === '1' && current_user_can('administrator');
1551 $is_streaming = isset($_POST['action']) && $_POST['action'] === 'mxchat_stream_chat' &&
1552 ($force_streaming_test || (isset($current_options['enable_streaming_toggle']) && $current_options['enable_streaming_toggle'] === 'on'));
1553
1554 // ADDED: Store streaming state in class property for use in private methods
1555 $this->is_streaming = $is_streaming;
1556
1557 // NOTE: Streaming headers are now set later via setup_streaming_headers()
1558 // This allows actions/forms to return JSON responses without header conflicts
1559
1560 // Check if MX Chat Moderation is active
1561 if (class_exists('MX_Chat_Moderation')) {
1562 // Get user email and IP
1563 $user_email = '';
1564 $user_ip = $_SERVER['REMOTE_ADDR'];
1565
1566 // If user is logged in, get their email
1567 if (is_user_logged_in()) {
1568 $current_user = wp_get_current_user();
1569 $user_email = $current_user->user_email;
1570 }
1571
1572 // Create ban handler instance
1573 $ban_handler = new MX_Chat_Ban_Handler();
1574
1575 // Check if user is banned by IP
1576 if ($ban_handler->check_ban($user_ip, 'ip')) {
1577 wp_send_json([
1578 'success' => false,
1579 'message' => esc_html__('Access denied. Your IP address has been banned.', 'mxchat'),
1580 'status' => 'banned'
1581 ]);
1582 wp_die();
1583 }
1584
1585 // If user is logged in, also check email
1586 if (!empty($user_email) && $ban_handler->check_ban($user_email, 'email')) {
1587 wp_send_json([
1588 'success' => false,
1589 'message' => esc_html__('Access denied. Your email address has been banned.', 'mxchat'),
1590 'status' => 'banned'
1591 ]);
1592 wp_die();
1593 }
1594 }
1595
1596 $this->fallbackResponse = ['text' => '', 'html' => '', 'images' => []];
1597 $this->productCardHtml = '';
1598 $this->videoEmbedHtml = '';
1599 // Reset the per-turn function-calling UI capture (plan 48a57a).
1600 $this->fc_ui_html = '';
1601 $this->fc_ui_images = array();
1602 $this->fc_ui_captured = false;
1603
1604 // Get the actual WordPress user ID if logged in
1605 $is_logged_in = is_user_logged_in();
1606 if ($is_logged_in) {
1607 $user_id = get_current_user_id(); // This will get the actual WordPress user ID
1608 } else {
1609 // For logged-out users, use your existing identifier method
1610 $user_id = $this->mxchat_get_user_identifier();
1611 }
1612
1613 // Get and sanitize the user identifier
1614 $user_id = sanitize_key($user_id);
1615
1616 // Check rate limit using new settings structure
1617 $rate_limit_result = $this->check_rate_limit();
1618
1619 if ($rate_limit_result !== true) {
1620 wp_send_json([
1621 'success' => false,
1622 'message' => $rate_limit_result['message'],
1623 'status' => 'rate_limit_exceeded'
1624 ]);
1625 wp_die();
1626 }
1627
1628 // Rest of your existing code...
1629 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
1630
1631 // Treat the literal strings 'null' / 'undefined' as missing too. Browser edge cases
1632 // (Safari ITP, private mode, cross-origin iframes with partitioned storage) can cause
1633 // the frontend FormData.append() to stringify a null session_id into the literal
1634 // "null", which would otherwise pass empty() and pollute the transcripts table with
1635 // ghost sessions that group every visitor's first message under one row.
1636 if ($session_id === 'null' || $session_id === 'undefined') {
1637 $session_id = '';
1638 }
1639
1640 if (empty($session_id)) {
1641 wp_send_json_error(esc_html__('Session ID is missing.', 'mxchat'));
1642 wp_die();
1643 }
1644
1645 // Update session owner if it changed (e.g. IP changed due to network switch)
1646 // The session ID itself is the authentication — if the client has it, they own it
1647 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
1648 $session_owner = get_option("mxchat_session_owner_{$session_id}");
1649
1650 if (!$session_owner || $session_owner !== $current_user_identifier) {
1651 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
1652 }
1653
1654 // Validate and sanitize the incoming message
1655 if (empty($_POST['message'])) {
1656 wp_send_json_error(esc_html__('No message received.', 'mxchat'));
1657 wp_die();
1658 }
1659
1660 // Enforce the configurable max input length (plan a3fae2 part C). 0 = unlimited.
1661 // Server-side guard backing the textarea's client-side maxlength (which is bypassable).
1662 // Reads the global core setting and measures characters (mb_strlen on the unslashed
1663 // raw POST), matching the maxlength semantics.
1664 $mxchat_max_input_length = isset($this->options['max_input_length']) ? intval($this->options['max_input_length']) : 0;
1665 if ($mxchat_max_input_length > 0) {
1666 $mxchat_incoming_raw = is_string($_POST['message']) ? wp_unslash($_POST['message']) : '';
1667 if (mb_strlen($mxchat_incoming_raw) > $mxchat_max_input_length) {
1668 wp_send_json([
1669 'success' => false,
1670 /* translators: %d: maximum allowed characters */
1671 'message' => sprintf(esc_html__('Your message is too long. Please keep it under %d characters.', 'mxchat'), $mxchat_max_input_length),
1672 'status' => 'message_too_long'
1673 ]);
1674 wp_die();
1675 }
1676 }
1677
1678
1679 // Track originating page for first message in session
1680 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1681
1682 // Check if originating page columns exist
1683 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
1684
1685 if ($columns_exist) {
1686 // Check if this session already has messages
1687 $message_count = $wpdb->get_var($wpdb->prepare(
1688 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
1689 $session_id
1690 ));
1691
1692 // If this is the first message in the session
1693 if ($message_count == 0) {
1694 // Get originating page from JavaScript (preferred) or HTTP_REFERER (fallback)
1695 $originating_url = '';
1696 $originating_title = '';
1697
1698 // Try to get from POST data first (sent by JavaScript)
1699 if (isset($_POST['current_page_url'])) {
1700 $originating_url = esc_url_raw($_POST['current_page_url']);
1701 $originating_title = isset($_POST['current_page_title'])
1702 ? sanitize_text_field($_POST['current_page_title'])
1703 : '';
1704 }
1705 // Fallback to HTTP_REFERER if not provided by JavaScript
1706 else if (isset($_SERVER['HTTP_REFERER'])) {
1707 $originating_url = esc_url_raw($_SERVER['HTTP_REFERER']);
1708 }
1709
1710 // Generate title if we have URL but no title
1711 if ($originating_url && empty($originating_title)) {
1712 $parsed_url = parse_url($originating_url);
1713 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
1714
1715 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
1716 $originating_title = 'Homepage';
1717 } else {
1718 // Clean up the path to make a readable title
1719 $originating_title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
1720 $originating_title = ucwords(trim($originating_title));
1721 }
1722 }
1723
1724 // Store for later use when saving the message
1725 $this->pending_originating_page = [
1726 'url' => $originating_url,
1727 'title' => $originating_title
1728 ];
1729 }
1730 }
1731
1732
1733
1734 // Get page context if provided
1735 $page_context = null;
1736 if (isset($_POST['page_context']) && !empty($_POST['page_context'])) {
1737 $page_context_raw = stripslashes($_POST['page_context']);
1738 $page_context = json_decode($page_context_raw, true);
1739
1740 // Validate page context structure
1741 if (is_array($page_context) &&
1742 isset($page_context['url']) &&
1743 isset($page_context['title']) &&
1744 isset($page_context['content'])) {
1745
1746 // Sanitize page context
1747 $page_context['url'] = esc_url_raw($page_context['url']);
1748 $page_context['title'] = sanitize_text_field($page_context['title']);
1749 $page_context['content'] = wp_kses_post($page_context['content']);
1750 } else {
1751 $page_context = null;
1752 }
1753 }
1754
1755 // Modify the message sanitization to preserve PHP tags in code blocks
1756 $allowed_tags = [
1757 'pre' => [],
1758 'code' => ['class' => true],
1759 'span' => ['class' => true],
1760 'div' => ['class' => true],
1761 ];
1762
1763 // First preserve code blocks
1764 $message = preg_replace_callback('/<pre><code.*?>.*?<\/code><\/pre>/s', function($matches) {
1765 return htmlspecialchars_decode($matches[0]);
1766 }, $_POST['message']);
1767
1768 // Then apply sanitization
1769 $message = wp_kses($message, $allowed_tags);
1770
1771 // Preserve code blocks from markdown conversion
1772 $message = preg_replace('/```(\w+)?\s*([\s\S]+?)```/s', '<pre><code class="$1">$2</code></pre>', $message);
1773 $message = apply_filters('mxchat_filter_message', $message, 'prompt', $session_id);
1774
1775 // ===== SIMPLIFIED TESTING PANEL INITIALIZATION =====
1776 // Always initialize testing data for admins (no toggle needed)
1777 $testing_data = null;
1778 if (current_user_can('administrator')) {
1779 // For vision messages, use the original user message for the query display
1780 $query_for_testing = $message;
1781 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1782 $query_for_testing = sanitize_textarea_field($_POST['original_user_message']);
1783 }
1784
1785 $testing_data = [
1786 'query' => $query_for_testing,
1787 'timestamp' => time(),
1788 'top_matches' => [],
1789 'action_matches' => [], // Initialize action matches array
1790 'page_context' => $page_context, // Include page context in testing data
1791 'is_vision' => isset($_POST['vision_processed']) && $_POST['vision_processed'],
1792 'bot_id' => $bot_id // Include bot ID in testing data
1793 ];
1794
1795 // Get similarity threshold from bot options or default options
1796 $similarity_threshold = isset($current_options['similarity_threshold'])
1797 ? ((int) $current_options['similarity_threshold']) / 100
1798 : 0.35;
1799
1800 $testing_data['similarity_threshold'] = $similarity_threshold;
1801
1802 // Determine knowledge base type using bot-specific config
1803 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
1804 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
1805 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
1806 }
1807 // ===== END SIMPLIFIED TESTING INITIALIZATION =====
1808
1809 // Add debug before and after:
1810 //error_log('MxChat Core: About to call mxchat_pre_process_message filter with message: ' . $message);
1811 $pre_processed_result = apply_filters('mxchat_pre_process_message', $message, $user_id, $session_id);
1812 //error_log('MxChat Core: Filter returned: ' . (is_array($pre_processed_result) ? 'array' : $pre_processed_result));
1813
1814
1815 // If the pre-processing returned a result (not the original message), use it directly
1816 if (is_array($pre_processed_result) && isset($pre_processed_result['text'])) {
1817 // Save the AI response
1818 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['text']);
1819
1820 // Save HTML content if provided
1821 if (!empty($pre_processed_result['html'])) {
1822 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['html']);
1823 }
1824
1825 // Add testing data if admin
1826 $response_data = [
1827 'text' => $pre_processed_result['text'],
1828 'html' => $pre_processed_result['html'] ?? '',
1829 'session_id' => $session_id
1830 ];
1831
1832 if ($testing_data !== null) {
1833 $response_data['testing_data'] = $testing_data;
1834 }
1835
1836 wp_send_json($response_data);
1837 wp_die();
1838 }
1839
1840 // Save the user's message - handle vision processed messages differently
1841 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1842 // For vision messages, save the original user message with image indicator
1843 $original_message = sanitize_textarea_field($_POST['original_user_message']);
1844 if (isset($_POST['vision_images_count']) && $_POST['vision_images_count'] > 0) {
1845 $image_count = intval($_POST['vision_images_count']);
1846 $original_message .= " [{$image_count} image(s)]";
1847 }
1848 $this->mxchat_save_chat_message($session_id, 'user', $original_message);
1849 } else {
1850 // Regular message - save as normal
1851 $this->mxchat_save_chat_message($session_id, 'user', $message);
1852 }
1853
1854
1855 if (is_email($message)) {
1856 // Add the email to Loops
1857 $this->add_email_to_loops($message);
1858
1859 // Get the user's success message instruction using current_options
1860 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1861
1862 // Set instruction for AI using the user's success message
1863 $this->current_action_instruction = $user_success_message;
1864
1865 // Clear the email capture transient since we got the email
1866 delete_transient('mxchat_email_capture_' . $user_id);
1867 }
1868
1869 // Check if we're in an email capture flow but user hasn't provided email yet
1870 elseif (get_transient('mxchat_email_capture_' . $user_id)) {
1871 // Check if the message contains an email (not the whole message being an email)
1872 if (preg_match('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', $message, $matches)) {
1873 $extracted_email = $matches[0];
1874
1875 // Add the extracted email to Loops
1876 $this->add_email_to_loops($extracted_email);
1877
1878 // Get the user's success message instruction using current_options
1879 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1880
1881 // Set instruction for AI using the user's success message
1882 $this->current_action_instruction = $user_success_message;
1883
1884 // Clear the email capture transient since we got the email
1885 delete_transient('mxchat_email_capture_' . $user_id);
1886 }
1887 // If no email found but we're in capture mode, remind them
1888 else {
1889 // Get the original instruction to remind them using current_options
1890 $original_instruction = $current_options['triggered_phrase_response'] ?? __("Please provide your email address.", 'mxchat');
1891 $this->current_action_instruction = $original_instruction;
1892 }
1893 }
1894
1895 $intent_info = '';
1896
1897 // Check chat mode
1898 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1899
1900 // Handle agent mode
1901 // Handle agent mode
1902 if ($chat_mode === 'agent') {
1903 // First, check for switch intent before doing anything else
1904 $intent_matched = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1905
1906 // Capture action analysis for testing panel after intent check
1907 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1908 $testing_data['action_matches'] = $this->last_action_analysis;
1909 }
1910
1911 // Around line 506, in the agent mode handling section:
1912 if ($intent_matched && !empty($this->fallbackResponse['text'])) {
1913 // Update chat mode first
1914 update_option("mxchat_mode_{$session_id}", 'ai');
1915
1916 // Clear any existing PDF context to start fresh
1917 $this->clear_pdf_transients($session_id);
1918
1919 // Prepare clean switch response with explicit chat_mode
1920 $response_data = [
1921 'text' => $this->fallbackResponse['text'],
1922 'html' => $this->fallbackResponse['html'] ?? '',
1923 'session_id' => $session_id,
1924 'chat_mode' => 'ai' // EXPLICITLY SET THIS
1925 ];
1926
1927 if ($testing_data !== null) {
1928 $response_data['testing_data'] = $testing_data;
1929 }
1930
1931 // Save the mode switch message
1932 $this->mxchat_save_chat_message($session_id, 'system', esc_html__('Switched to AI chat mode', 'mxchat'));
1933 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1934
1935 // Send response and exit
1936 wp_send_json($response_data);
1937 wp_die();
1938 } elseif (!$intent_matched) {
1939 // No intent matched, handle live agent message
1940 try {
1941 $this->mxchat_send_user_message_to_agent($message, $user_id, $session_id);
1942
1943 $agent_response = [
1944 'status' => 'waiting_for_agent',
1945 'message' => esc_html__('Message sent to live agent.', 'mxchat')
1946 ];
1947
1948 if ($testing_data !== null) {
1949 $agent_response['testing_data'] = $testing_data;
1950 }
1951
1952 wp_send_json_success($agent_response);
1953 } catch (\Exception $e) {
1954 wp_send_json_error(esc_html__('Failed to send message to agent', 'mxchat'));
1955 }
1956 wp_die();
1957 }
1958 }
1959
1960 // Step 1: Check for new PDF URL in the message
1961 if (!isset($_POST['vision_processed']) && preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
1962 $new_pdf_url = $matches[0];
1963
1964 // Check if this is likely a PDF-related request
1965 $pdf_keywords = ['pdf', 'document', 'read', 'analyze'];
1966 $is_pdf_request = false;
1967
1968 foreach ($pdf_keywords as $keyword) {
1969 if (stripos($message, $keyword) !== false) {
1970 $is_pdf_request = true;
1971 break;
1972 }
1973 }
1974
1975 // If it looks like a PDF request or we're waiting for a PDF URL
1976 if ($is_pdf_request || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
1977 // Validate HTTPS
1978 if (wp_http_validate_url($new_pdf_url) && parse_url($new_pdf_url, PHP_URL_SCHEME) === 'https') {
1979 // Extract filename from URL
1980 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1981
1982 // Clear previous PDF transients
1983 $this->clear_pdf_transients($session_id);
1984
1985 // Process new PDF using current_options
1986 $max_pages = $current_options['pdf_max_pages'] ?? 69;
1987 $embeddings = $this->fetch_and_split_pdf_pages($new_pdf_url, $max_pages);
1988
1989 if ($embeddings === 'too_many_pages') {
1990 $error_text = sprintf(
1991 $current_options['pdf_intent_error_text'] ??
1992 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
1993 $max_pages
1994 );
1995 $this->fallbackResponse['text'] = $error_text;
1996 } elseif ($embeddings) {
1997 // Store new PDF information
1998 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1999
2000 // If the filename is generic, create a more descriptive one
2001 if (in_array($pdf_filename, ['results_download.php', 'download.php', 'view.php', 'pdf.php']) ||
2002 strpos($pdf_filename, '.php') !== false) {
2003 $pdf_filename = 'Document_' . date('Y-m-d_H-i') . '.pdf';
2004 }
2005
2006 set_transient('mxchat_pdf_url_' . $session_id, $new_pdf_url, HOUR_IN_SECONDS);
2007 set_transient('mxchat_pdf_filename_' . $session_id, $pdf_filename, HOUR_IN_SECONDS);
2008 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
2009 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
2010
2011 $success_text = $current_options['pdf_intent_success_text'] ??
2012 esc_html__("I've processed the new PDF '{$pdf_filename}'. What questions do you have about it?", 'mxchat');
2013
2014 $pdf_response = [
2015 'success' => true,
2016 'message' => $success_text,
2017 'data' => [
2018 'filename' => $pdf_filename
2019 ]
2020 ];
2021
2022 if ($testing_data !== null) {
2023 $pdf_response['testing_data'] = $testing_data;
2024 }
2025
2026 wp_send_json($pdf_response);
2027 wp_die();
2028 } else {
2029 $error_text = $current_options['pdf_intent_error_text'] ??
2030 esc_html__("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
2031 $this->fallbackResponse['text'] = $error_text;
2032 }
2033
2034 $pdf_error_response = [
2035 'success' => false,
2036 'message' => $this->fallbackResponse['text']
2037 ];
2038
2039 if ($testing_data !== null) {
2040 $pdf_error_response['testing_data'] = $testing_data;
2041 }
2042
2043 wp_send_json($pdf_error_response);
2044 wp_die();
2045 }
2046 }
2047 }
2048
2049
2050 // Step 2: Detect intent and handle intent-based responses
2051 $intent_result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
2052
2053 // Capture action analysis for testing panel after intent check
2054 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
2055 $testing_data['action_matches'] = $this->last_action_analysis;
2056 }
2057
2058 // Step 3: Handle the intent result appropriately
2059 if ($intent_result !== false) {
2060 // Intent was matched - ALWAYS send as JSON response, never streaming
2061
2062 if (is_array($intent_result) && (isset($intent_result['text']) || isset($intent_result['html']))) {
2063 // Intent returned a direct response array
2064 $response_data = [
2065 'text' => $intent_result['text'] ?? '',
2066 'html' => $intent_result['html'] ?? '',
2067 'session_id' => $session_id
2068 ];
2069
2070 // IMPORTANT: Include chat_mode if present (for WhatsApp, Slack, etc.)
2071 if (isset($intent_result['chat_mode'])) {
2072 $response_data['chat_mode'] = $intent_result['chat_mode'];
2073 }
2074
2075 if ($testing_data !== null) {
2076 $response_data['testing_data'] = $testing_data;
2077 }
2078
2079 wp_send_json($response_data);
2080 wp_die();
2081 } else if ($intent_result === true && (!empty($this->fallbackResponse['text']) || !empty($this->fallbackResponse['html']))) {
2082 // Intent returned true and set fallbackResponse
2083
2084 // SAVE TO TRANSCRIPT
2085 if (!empty($this->fallbackResponse['text'])) {
2086 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
2087 }
2088 // Save action HTML (product cards, featured products, etc.) so it renders in transcripts
2089 if (!empty($this->fallbackResponse['html'])) {
2090 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
2091 }
2092
2093 $response_data = [
2094 'text' => $this->fallbackResponse['text'] ?? '',
2095 'html' => $this->fallbackResponse['html'] ?? '',
2096 'session_id' => $session_id
2097 ];
2098
2099 if (isset($this->fallbackResponse['chat_mode'])) {
2100 $response_data['chat_mode'] = $this->fallbackResponse['chat_mode'];
2101 }
2102
2103 if ($testing_data !== null) {
2104 $response_data['testing_data'] = $testing_data;
2105 }
2106
2107 wp_send_json($response_data);
2108 wp_die();
2109 }
2110 }
2111
2112 // If we get here, no intent matched OR the intent didn't provide a usable response
2113
2114 // Step 4: Generate AI response
2115 // Get session start timestamp - when persistence is OFF, only include messages from this page load
2116 $session_start_timestamp = isset($_POST['session_start_timestamp']) ? intval($_POST['session_start_timestamp']) : 0;
2117 $conversation_history = $this->mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp);
2118 $this->mxchat_increment_chat_count();
2119
2120 // Generate embedding for the user's query - USE BOT-SPECIFIC API KEY
2121 $api_key = $current_options['api_key'] ?? $this->options['api_key'];
2122 $user_message_embedding = $this->mxchat_generate_embedding($message, $api_key);
2123
2124 // Check if the embedding generation returned an error
2125 if (is_array($user_message_embedding) && isset($user_message_embedding['error'])) {
2126 $error_message = $user_message_embedding['error'];
2127 $error_code = $user_message_embedding['error_code'] ?? 'embedding_error';
2128
2129 // FIXED: Send error in appropriate format based on streaming mode
2130 if ($is_streaming) {
2131 echo "data: " . json_encode([
2132 'error' => true,
2133 'error_message' => $error_message,
2134 'error_code' => $error_code,
2135 'text' => $error_message,
2136 'message' => $error_message
2137 ]) . "\n\n";
2138 echo "data: [DONE]\n\n";
2139 flush();
2140 } else {
2141 wp_send_json_error([
2142 'error_message' => $error_message,
2143 'error_code' => $error_code
2144 ]);
2145 }
2146 wp_die();
2147 }
2148
2149 // Check if the embedding is valid
2150 if (!is_array($user_message_embedding) || empty($user_message_embedding)) {
2151 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
2152
2153 // FIXED: Send error in appropriate format based on streaming mode
2154 if ($is_streaming) {
2155 echo "data: " . json_encode([
2156 'error' => true,
2157 'error_message' => $error_message,
2158 'error_code' => 'invalid_embedding',
2159 'text' => $error_message,
2160 'message' => $error_message
2161 ]) . "\n\n";
2162 echo "data: [DONE]\n\n";
2163 flush();
2164 } else {
2165 wp_send_json_error([
2166 'error_message' => $error_message,
2167 'error_code' => 'invalid_embedding'
2168 ]);
2169 }
2170 wp_die();
2171 }
2172
2173 // Build context with both knowledge base and PDF content if available
2174 $context_content = "User asked: '{$message}'\n\n";
2175
2176 // Add action instruction if present (add this right after the above line)
2177 if (!empty($this->current_action_instruction)) {
2178 $context_content .= "===== SPECIAL INSTRUCTION =====\n";
2179 $context_content .= "IMPORTANT: " . $this->current_action_instruction . "\n";
2180 $context_content .= "Respond naturally and conversationally while following this instruction.\n";
2181 $context_content .= "===== END SPECIAL INSTRUCTION =====\n\n";
2182
2183 // Clear the instruction after using it
2184 $this->current_action_instruction = null;
2185 }
2186
2187
2188 // Add page context if available and contextual awareness is enabled using current_options
2189 if ($page_context && isset($current_options['contextual_awareness_toggle']) && $current_options['contextual_awareness_toggle'] === 'on') {
2190 $context_content .= "===== CURRENT PAGE CONTEXT =====\n";
2191 $context_content .= "Page URL: " . $page_context['url'] . "\n";
2192 $context_content .= "Page Title: " . $page_context['title'] . "\n";
2193 $context_content .= "Page Content: " . $page_context['content'] . "\n";
2194 $context_content .= "===== END CURRENT PAGE CONTEXT =====\n\n";
2195 }
2196
2197 // Get relevant content from knowledge base - PASS BOT_ID and MESSAGE for Vector Store
2198 $relevant_content = $this->mxchat_find_relevant_content($user_message_embedding, $bot_id, $message);
2199
2200 // NEW: Also extract URLs from system instructions (only if citation links enabled)
2201 // Use fresh options to ensure we get the latest setting value
2202 $fresh_options = get_option('mxchat_options', []);
2203 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
2204
2205 $system_instructions = $this->get_system_instructions($bot_id, $session_id);
2206 if ($citation_links_enabled && !empty($system_instructions)) {
2207 preg_match_all(
2208 '#\bhttps?://[^\s<>"\']+#i',
2209 $system_instructions,
2210 $system_instruction_urls
2211 );
2212
2213 if (!empty($system_instruction_urls[0])) {
2214 // Merge with existing valid URLs
2215 $this->current_valid_urls = array_merge(
2216 $this->current_valid_urls,
2217 $system_instruction_urls[0]
2218 );
2219 // Remove duplicates
2220 $this->current_valid_urls = array_unique($this->current_valid_urls);
2221
2222 //error_log("Added " . count($system_instruction_urls[0]) . " URLs from system instructions");
2223 }
2224 }
2225
2226 // ===== CAPTURE REAL SIMILARITY DATA FOR ADMINS =====
2227 if ($testing_data !== null && $this->last_similarity_analysis !== null) {
2228 // Update testing data with the REAL similarity analysis
2229 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
2230 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
2231 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
2232 $testing_data['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
2233 $testing_data['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
2234 }
2235 // ===== END SIMILARITY DATA CAPTURE =====
2236
2237 // NEW: Add valid URLs to testing data for admin panel display (AFTER similarity data)
2238 if ($testing_data !== null && !empty($this->current_valid_urls)) {
2239 $testing_data['approved_urls'] = array_values($this->current_valid_urls);
2240 //error_log("Added " . count($this->current_valid_urls) . " approved URLs to testing data");
2241 }
2242
2243 $kb_block = !empty($relevant_content)
2244 ? "===== OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n" . $relevant_content . "\n===== END OF OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n\n"
2245 : "===== NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE =====\n";
2246
2247 // {context} placeholder (plan 59bc1b): when the resolved instructions
2248 // carry the token, the KB block is injected at that spot by
2249 // get_system_instructions() (every provider handler re-calls it) and is
2250 // NOT appended here — otherwise the block would ride twice.
2251 // $system_instructions above was resolved while context_kb_block was
2252 // still null, so the literal token is still visible for this check.
2253 if (!empty($system_instructions) && stripos($system_instructions, '{context}') !== false) {
2254 $this->context_kb_block = $kb_block;
2255 } else {
2256 $context_content .= $kb_block;
2257 }
2258
2259 // NEW: Add approved URLs list to context for AI (only if citation links enabled)
2260 if ($citation_links_enabled && !empty($this->current_valid_urls)) {
2261 $context_content .= "===== APPROVED URLS FOR CITATIONS =====\n";
2262 $context_content .= "You may ONLY use these exact URLs in your response:\n";
2263 foreach ($this->current_valid_urls as $url) {
2264 $context_content .= "- " . $url . "\n";
2265 }
2266 $context_content .= "\nCRITICAL: Do NOT create, modify, extend, or invent any other URLs. ";
2267 $context_content .= "===== END APPROVED URLS =====\n\n";
2268 }
2269
2270 // Check for and include PDF content
2271 $pdf_url = get_transient('mxchat_pdf_url_' . $session_id);
2272 $pdf_embeddings = get_transient('mxchat_pdf_embeddings_' . $session_id);
2273 $pdf_filename = get_transient('mxchat_pdf_filename_' . $session_id);
2274 if ($pdf_url && $pdf_embeddings && get_transient('mxchat_include_pdf_in_context_' . $session_id)) {
2275 $relevant_pdf_pages = $this->find_relevant_pdf_pages($user_message_embedding, $pdf_embeddings);
2276 if (!empty($relevant_pdf_pages)) {
2277 $context_content .= "Relevant content from PDF document '{$pdf_filename}':\n";
2278 foreach ($relevant_pdf_pages as $page_data) {
2279 $context_content .= "Page {$page_data['page_number']} of '{$pdf_filename}': {$page_data['text']}\n";
2280 }
2281 $context_content .= "\n";
2282 }
2283 }
2284
2285 // Check for and include Word content
2286 $word_url = get_transient('mxchat_word_url_' . $session_id);
2287 $word_embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
2288 $word_filename = get_transient('mxchat_word_filename_' . $session_id);
2289 if ($word_url && $word_embeddings && get_transient('mxchat_include_word_in_context_' . $session_id)) {
2290 $relevant_word_chunks = $this->word_handler->mxchat_find_relevant_word_chunks($user_message_embedding, $word_embeddings);
2291 if (!empty($relevant_word_chunks)) {
2292 $context_content .= "Relevant content from Word document '{$word_filename}':\n";
2293 foreach ($relevant_word_chunks as $chunk_data) {
2294 $context_content .= "Section {$chunk_data['chunk_number']} of '{$word_filename}': {$chunk_data['text']}\n";
2295 }
2296 $context_content .= "\n";
2297 }
2298 }
2299
2300 $context_content = apply_filters('mxchat_prepare_context', $context_content, $session_id);
2301
2302 // Extract model from current options for bot-specific model support
2303 $selected_model = isset($current_options['model']) ? $current_options['model'] : 'gpt-5.6-sol';
2304
2305 // ===== Native function-calling fallback (plan-mxchat-20260617-a41dee) =====
2306 // Intents already missed (we're past the intent router). If function
2307 // calling is enabled and the active model is tool-capable, let the model
2308 // SELECT and run registered callbacks as tools — independent of intents,
2309 // works with zero Actions. The tool round is buffered; the final answer is
2310 // emitted via the SAME envelopes the normal path uses. Default-off, so
2311 // existing installs never enter this branch.
2312 if ($this->mxchat_fc_should_run($selected_model)) {
2313 $fc_outcome = $this->mxchat_fc_attempt(
2314 $message,
2315 $context_content,
2316 $conversation_history,
2317 $selected_model,
2318 $current_options,
2319 $session_id,
2320 $user_id
2321 );
2322 if (is_array($fc_outcome) && !empty($fc_outcome['handled'])) {
2323 $fc_text = isset($fc_outcome['text']) ? $fc_outcome['text'] : '';
2324 if (!empty($this->current_valid_urls)) {
2325 $fc_text = $this->validate_and_clean_urls($fc_text, $this->current_valid_urls, $session_id, $bot_id);
2326 }
2327 // plan-mxchat-20260617-48a57a — surface any UI element a tool
2328 // produced (generated image / product card / image gallery) so the
2329 // widget RENDERS it, instead of emitting only the model's text.
2330 // The html was already saved to the transcript in
2331 // mxchat_fc_execute_tool (or by the callback itself for self-saving
2332 // core tools), so we persist ONLY the model's caption text here.
2333 $fc_html = isset($this->fc_ui_html) ? $this->fc_ui_html : '';
2334
2335 if ($fc_text !== '') {
2336 $this->mxchat_save_chat_message($session_id, 'bot', $fc_text, null, null);
2337 }
2338
2339 // A video-backed KB source queued during retrieval (03ba33) must
2340 // surface on the FC path too — the FC envelopes below are the ONLY
2341 // exit for this turn, so append it to the html channel and persist
2342 // it (tool html was already saved in mxchat_fc_execute_tool; the
2343 // video embed has no other save point on this path).
2344 if (!empty($this->videoEmbedHtml)) {
2345 $fc_html .= $this->videoEmbedHtml;
2346 $this->mxchat_save_chat_message($session_id, 'bot', $this->videoEmbedHtml);
2347 }
2348
2349 if ($is_streaming) {
2350 // The frontend SSE reader routes any event carrying text/html
2351 // to handleNonStreamResponse(), which renders text + html in a
2352 // single bot message — so emit one complete event (mirrors the
2353 // intent path's text/html envelope).
2354 $sse = array('session_id' => $session_id);
2355 if ($fc_text !== '') $sse['text'] = $fc_text;
2356 if ($fc_html !== '') $sse['html'] = $fc_html;
2357 if ($fc_text === '' && $fc_html === '') $sse['text'] = $this->mxchat_fc_giveup_text();
2358 echo "data: " . wp_json_encode($sse) . "\n\n";
2359 echo "data: [DONE]\n\n";
2360 flush();
2361 } else {
2362 $fc_response_data = array('text' => $fc_text, 'html' => $fc_html, 'session_id' => $session_id);
2363 if ($testing_data !== null) {
2364 $fc_response_data['testing_data'] = $testing_data;
2365 }
2366 wp_send_json($fc_response_data);
2367 }
2368 wp_die();
2369 }
2370 }
2371 // ===== end function-calling fallback =====
2372
2373 // Streaming + a queued video embed (03ba33): the provider handlers own the
2374 // token stream and the [DONE] terminator, so the embed rides a dedicated
2375 // append_html SSE event emitted BEFORE the stream starts. The client
2376 // stashes it and appends it as its own bot bubble after [DONE] — old
2377 // cached widget JS simply ignores the unknown key (no content/text/html/
2378 // error field, so no branch matches). Transcript save happens after the
2379 // stream completes, so history order matches the live order (text, then
2380 // embed).
2381 if ($is_streaming && !empty($this->videoEmbedHtml)) {
2382 echo "data: " . wp_json_encode(array(
2383 'append_html' => $this->videoEmbedHtml,
2384 'session_id' => $session_id,
2385 )) . "\n\n";
2386 flush();
2387 }
2388
2389 $response = $this->mxchat_generate_response(
2390 $context_content,
2391 $current_options['api_key'] ?? $this->options['api_key'],
2392 $current_options['xai_api_key'] ?? $this->options['xai_api_key'],
2393 $current_options['claude_api_key'] ?? $this->options['claude_api_key'],
2394 $current_options['deepseek_api_key'] ?? $this->options['deepseek_api_key'],
2395 $current_options['gemini_api_key'] ?? $this->options['gemini_api_key'],
2396 $current_options['openrouter_api_key'] ?? $this->options['openrouter_api_key'],
2397 $conversation_history,
2398 $is_streaming,
2399 $session_id,
2400 $testing_data,
2401 $selected_model
2402 );
2403
2404 // Handle streaming vs non-streaming responses
2405 if ($is_streaming) {
2406 // Check if streaming actually happened or if it fell back to regular response
2407 if ($response === true) {
2408 // Persist the video embed AFTER the provider saved the streamed
2409 // text, so history replays in the same order the visitor saw
2410 // (text bubble, then embed bubble). See 03ba33.
2411 if (!empty($this->videoEmbedHtml)) {
2412 $this->mxchat_save_chat_message($session_id, 'bot', $this->videoEmbedHtml);
2413 }
2414 wp_die();
2415 }
2416 // If we get here, streaming fell back to regular response, continue
2417 // But if there's an error, we need to send it as SSE format since headers are already set
2418 if (is_array($response) && isset($response['error'])) {
2419 $error_message = $response['error'];
2420 $error_code = $response['error_code'] ?? 'api_error';
2421 // Send error in SSE format that the client JS can handle
2422 echo "data: " . json_encode([
2423 'error' => true,
2424 'error_message' => $error_message,
2425 'error_code' => $error_code,
2426 'text' => $error_message, // Also include as text for fallback handling
2427 'message' => $error_message
2428 ]) . "\n\n";
2429 echo "data: [DONE]\n\n";
2430 flush();
2431 wp_die();
2432 }
2433 }
2434
2435 // Check if the response is an error array (non-streaming mode)
2436 if (is_array($response) && isset($response['error'])) {
2437 wp_send_json_error([
2438 'error_message' => $response['error'],
2439 'error_code' => $response['error_code'] ?? 'api_error'
2440 ]);
2441 wp_die();
2442 }
2443
2444 // DEBUG: Check what we have
2445 //error_log("=== BEFORE URL VALIDATION ===");
2446 //error_log("current_valid_urls is empty? " . (empty($this->current_valid_urls) ? 'YES' : 'NO'));
2447 //error_log("current_valid_urls count: " . count($this->current_valid_urls));
2448 //error_log("current_valid_urls content: " . print_r($this->current_valid_urls, true));
2449
2450 // If we get here, the response is valid text - now validate URLs
2451 if (!empty($this->current_valid_urls)) {
2452 //error_log("CALLING validate_and_clean_urls");
2453 $response = $this->validate_and_clean_urls($response, $this->current_valid_urls, $session_id, $bot_id);
2454 } else {
2455 //error_log("SKIPPING validation - current_valid_urls is empty");
2456 }
2457 // ===== END URL VALIDATION =====
2458
2459 // Prepare RAG context data for storage (only include documents used for context)
2460 $rag_context_for_storage = null;
2461 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
2462 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
2463
2464 if ($has_rag_data || $has_action_data) {
2465 $rag_context_for_storage = [];
2466
2467 // Add RAG/source data if available
2468 if ($has_rag_data) {
2469 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
2470 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
2471 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
2472 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
2473 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
2474 $rag_context_for_storage['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
2475 $rag_context_for_storage['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
2476 }
2477
2478 // Add action analysis data if available
2479 if ($has_action_data) {
2480 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
2481 }
2482 }
2483
2484 // Save the cleaned response with RAG context
2485 $this->mxchat_save_chat_message($session_id, 'bot', $response, null, $rag_context_for_storage);
2486
2487 // Step 5: Save additional content if available
2488 if (!empty($this->productCardHtml)) {
2489 $this->mxchat_save_chat_message($session_id, 'bot', $this->productCardHtml);
2490 }
2491
2492 if (!empty($this->fallbackResponse['html'])) {
2493 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
2494 }
2495
2496 if (!empty($this->videoEmbedHtml)) {
2497 $this->mxchat_save_chat_message($session_id, 'bot', $this->videoEmbedHtml);
2498 }
2499
2500 // Step 6: Return the response
2501 // DEBUG: Check if newlines exist in the response
2502 //error_log("=== MXCHAT NON-STREAMING RESPONSE DEBUG ===");
2503 //error_log("Response has newlines: " . (strpos($response, "\n") !== false ? 'YES' : 'NO'));
2504 //error_log("Response first 500 chars: " . substr($response, 0, 500));
2505
2506 // Product cards and action html keep their existing either/or precedence;
2507 // a queued video embed (03ba33) is APPENDED so it can coexist with both.
2508 $additional_html = !empty($this->productCardHtml) ? $this->productCardHtml : ($this->fallbackResponse['html'] ?? '');
2509 if (!empty($this->videoEmbedHtml)) {
2510 $additional_html .= $this->videoEmbedHtml;
2511 }
2512
2513 $response_data = [
2514 'text' => $response,
2515 'html' => $additional_html,
2516 'session_id' => $session_id
2517 ];
2518
2519 // Include vectorstore error info for admin debugging (only visible to admins via testing_data)
2520 if (!empty($this->last_vectorstore_error) && $testing_data !== null) {
2521 $testing_data['vectorstore_error'] = $this->last_vectorstore_error;
2522 }
2523
2524 // Also pass it as a top-level field so JS can show a better error message to admins
2525 if (!empty($this->last_vectorstore_error) && current_user_can('manage_options')) {
2526 $response_data['vectorstore_error'] = $this->last_vectorstore_error;
2527 }
2528
2529 // Always add testing data for admins (no toggle needed)
2530 if ($testing_data !== null) {
2531 $response_data['testing_data'] = $testing_data;
2532 }
2533
2534 wp_send_json($response_data);
2535 wp_die();
2536 }
2537
2538 /**
2539 * Get bot-specific options for multi-bot functionality
2540 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
2541 */
2542 // Also debug the bot options retrieval
2543 private function get_bot_options($bot_id = 'default') {
2544 //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
2545
2546 // The admin Testing tab renders the real widget as bot_id "testing", which
2547 // is not a registered multi-bot. It must resolve the DEFAULT bot's config
2548 // so the Testing chat behaves exactly like the front-end (same precedent
2549 // as the Actions enabled_bots check).
2550 if ($bot_id === 'testing') {
2551 $bot_id = 'default';
2552 }
2553
2554 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2555 //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
2556 return array();
2557 }
2558
2559 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
2560
2561 if (!empty($bot_options)) {
2562 //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
2563 if (isset($bot_options['similarity_threshold'])) {
2564 //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
2565 }
2566 }
2567
2568 return is_array($bot_options) ? $bot_options : array();
2569 }
2570
2571 /**
2572 * Get bot-specific Pinecone configuration
2573 * Used in the knowledge retrieval functions
2574 */
2575 // Also add debugging to your get_bot_pinecone_config function
2576 private function get_bot_pinecone_config($bot_id = 'default') {
2577 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
2578
2579 // Admin Testing tab bot → resolve the DEFAULT bot's backend. Without this,
2580 // on a multi-bot + Pinecone site the filter below gets an unknown bot id
2581 // with an EMPTY default, returns array(), and the dispatcher silently
2582 // searches the WordPress DB while the front-end searches Pinecone — the
2583 // Testing panel then reports similarity results from a different KB.
2584 if ($bot_id === 'testing') {
2585 $bot_id = 'default';
2586 }
2587
2588 // If default bot or multi-bot add-on not active, use default Pinecone config
2589 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2590 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
2591 $addon_options = get_option('mxchat_pinecone_addon_options', array());
2592 $config = array(
2593 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
2594 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
2595 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
2596 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
2597 );
2598 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
2599 return $config;
2600 }
2601
2602 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
2603
2604 // Hook for multi-bot add-on to provide bot-specific Pinecone config
2605 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
2606
2607 if (!empty($bot_pinecone_config)) {
2608 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
2609 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
2610 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
2611 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
2612 } else {
2613 //error_log("MXCHAT DEBUG: Filter returned empty config!");
2614 }
2615
2616 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
2617 }
2618
2619
2620 // Updated function to check intents and invoke the callback function
2621 private function mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id) {
2622 global $wpdb;
2623 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
2624
2625 // Get the current bot_id
2626 $current_bot_id = $this->get_current_bot_id($session_id);
2627
2628 // Generate the user embedding
2629 $user_embedding = $this->mxchat_generate_embedding($message, $this->options['api_key']);
2630
2631 // Check if embedding generation returned an error
2632 if (is_array($user_embedding) && isset($user_embedding['error'])) {
2633 $error_message = $user_embedding['error'];
2634 $error_code = $user_embedding['error_code'] ?? 'embedding_error';
2635
2636 // FIXED: Send error in appropriate format based on streaming mode
2637 if ($this->is_streaming) {
2638 echo "data: " . json_encode([
2639 'error' => true,
2640 'error_message' => $error_message,
2641 'error_code' => $error_code,
2642 'text' => $error_message,
2643 'message' => $error_message
2644 ]) . "\n\n";
2645 echo "data: [DONE]\n\n";
2646 flush();
2647 } else {
2648 wp_send_json_error([
2649 'error_message' => $error_message,
2650 'error_code' => $error_code
2651 ]);
2652 }
2653 wp_die();
2654 }
2655
2656 // Check if embedding is valid
2657 if (!is_array($user_embedding) || empty($user_embedding)) {
2658 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
2659
2660 // FIXED: Send error in appropriate format based on streaming mode
2661 if ($this->is_streaming) {
2662 echo "data: " . json_encode([
2663 'error' => true,
2664 'error_message' => $error_message,
2665 'error_code' => 'invalid_embedding',
2666 'text' => $error_message,
2667 'message' => $error_message
2668 ]) . "\n\n";
2669 echo "data: [DONE]\n\n";
2670 flush();
2671 } else {
2672 wp_send_json_error([
2673 'error_message' => $error_message,
2674 'error_code' => 'invalid_embedding'
2675 ]);
2676 }
2677 wp_die();
2678 }
2679
2680 // Fetch intents from the database
2681 $table_name = $wpdb->prefix . 'mxchat_intents';
2682 if ($chat_mode === 'agent') {
2683 $query = $wpdb->prepare(
2684 "SELECT * FROM $table_name WHERE callback_function = %s AND (enabled = 1 OR enabled IS NULL)",
2685 'mxchat_handle_switch_to_chatbot_intent'
2686 );
2687 $intents = $wpdb->get_results($query);
2688 } else {
2689 $intents = $wpdb->get_results("SELECT * FROM $table_name WHERE enabled = 1 OR enabled IS NULL");
2690 }
2691
2692 if (empty($intents)) {
2693 return false;
2694 }
2695
2696 // Prefetch individual phrase vectors from wp_mxchat_intent_phrases (grouped by intent_id)
2697 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
2698 $phrases_by_intent = [];
2699 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
2700 $all_phrases = $wpdb->get_results("SELECT intent_id, phrase, embedding_vector FROM $phrases_table");
2701 foreach ($all_phrases as $p) {
2702 $phrases_by_intent[$p->intent_id][] = $p;
2703 }
2704 }
2705
2706 $highest_similarity = -INF;
2707 $matched_intent = null;
2708
2709 // Array to store action analysis for testing panel
2710 $action_analysis = [];
2711
2712 foreach ($intents as $intent) {
2713 // Additional check for enabled state
2714 $is_enabled = isset($intent->enabled) ? (bool)$intent->enabled : true;
2715 if (!$is_enabled) {
2716 continue;
2717 }
2718
2719 // Check if this action is enabled for the current bot
2720 if (!$this->is_action_enabled_for_bot($intent, $current_bot_id)) {
2721 continue;
2722 }
2723
2724 $best_similarity = -INF;
2725 $matched_phrase_text = '';
2726
2727 // Check legacy embedding vector (existing behavior)
2728 $intent_embedding_serialized = $intent->embedding_vector;
2729 $intent_embedding = $intent_embedding_serialized
2730 ? unserialize($intent_embedding_serialized, ['allowed_classes' => false])
2731 : null;
2732
2733 if (is_array($intent_embedding) && !empty($intent_embedding)) {
2734 $legacy_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $intent_embedding);
2735 if ($legacy_similarity > $best_similarity) {
2736 $best_similarity = $legacy_similarity;
2737 $matched_phrase_text = 'legacy';
2738 }
2739 }
2740
2741 // Check individual phrase vectors
2742 if (isset($phrases_by_intent[$intent->id])) {
2743 foreach ($phrases_by_intent[$intent->id] as $phrase_row) {
2744 $phrase_embedding = $phrase_row->embedding_vector
2745 ? unserialize($phrase_row->embedding_vector, ['allowed_classes' => false])
2746 : null;
2747 if (!is_array($phrase_embedding)) {
2748 continue;
2749 }
2750 $phrase_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $phrase_embedding);
2751 if ($phrase_similarity > $best_similarity) {
2752 $best_similarity = $phrase_similarity;
2753 $matched_phrase_text = $phrase_row->phrase;
2754 }
2755 }
2756 }
2757
2758 // Skip if no valid embedding was found at all
2759 if ($best_similarity === -INF) {
2760 continue;
2761 }
2762
2763 $similarity = $best_similarity;
2764 $intent_threshold = isset($intent->similarity_threshold) ? $intent->similarity_threshold : 0.85;
2765
2766 // Store action analysis data for testing panel
2767 $action_analysis[] = [
2768 'intent_label' => $intent->intent_label,
2769 'callback_function' => $intent->callback_function,
2770 'similarity' => round($similarity, 4),
2771 'similarity_percentage' => round($similarity * 100, 2),
2772 'threshold' => $intent_threshold,
2773 'threshold_percentage' => round($intent_threshold * 100, 2),
2774 'above_threshold' => $similarity >= $intent_threshold,
2775 'matched_phrase' => $matched_phrase_text,
2776 'triggered' => false // Will be updated below if this intent is triggered
2777 ];
2778
2779 if ($similarity >= $intent_threshold && $similarity > $highest_similarity) {
2780 $highest_similarity = $similarity;
2781 $matched_intent = $intent;
2782 }
2783 }
2784
2785 // Mark the triggered action if any
2786 if ($matched_intent) {
2787 foreach ($action_analysis as &$action) {
2788 if ($action['intent_label'] === $matched_intent->intent_label) {
2789 $action['triggered'] = true;
2790 break;
2791 }
2792 }
2793 }
2794
2795 // Sort actions by similarity (highest first) and store for testing panel
2796 usort($action_analysis, function($a, $b) {
2797 return $b['similarity'] <=> $a['similarity'];
2798 });
2799
2800 // Store action analysis for testing panel capture
2801 $this->last_action_analysis = $action_analysis;
2802
2803 // Around line 715 in your mxchat_check_intent_and_invoke_callback function
2804 if ($matched_intent) {
2805 // If the callback is a method on this instance (core callback), call it directly
2806 if (method_exists($this, $matched_intent->callback_function)) {
2807 $callback_result = call_user_func(
2808 [$this, $matched_intent->callback_function],
2809 $message,
2810 $user_id,
2811 $session_id,
2812 $matched_intent,
2813 $user_context ?? null
2814 );
2815 } else {
2816 // Otherwise, use apply_filters for add-on callbacks
2817 $callback_result = apply_filters(
2818 $matched_intent->callback_function,
2819 false,
2820 $message,
2821 $user_id,
2822 $session_id,
2823 $matched_intent
2824 );
2825 }
2826
2827 // Handle the callback result properly
2828 if ($callback_result !== false) {
2829 // If callback returned an array with chat_mode, use it directly
2830 if (is_array($callback_result) && isset($callback_result['chat_mode'])) {
2831 $this->fallbackResponse = $callback_result;
2832 return $callback_result; // Return the full array
2833 } else {
2834 $this->fallbackResponse = $callback_result;
2835 return true;
2836 }
2837 }
2838 }
2839
2840 return false;
2841 }
2842
2843 /**
2844 * Check if an action is enabled for a specific bot
2845 */
2846 private function is_action_enabled_for_bot($intent, $bot_id) {
2847 // If enabled_bots column doesn't exist or is null, assume it's enabled for all bots (backward compatibility)
2848 if (!isset($intent->enabled_bots) || empty($intent->enabled_bots)) {
2849 return true;
2850 }
2851
2852 $enabled_bots = json_decode($intent->enabled_bots, true);
2853
2854 // If JSON decode fails or returns empty array, assume enabled for all (backward compatibility)
2855 if (!is_array($enabled_bots) || empty($enabled_bots)) {
2856 return true;
2857 }
2858
2859 // Admin testing tab uses bot_id "testing" — treat it as "default" so all
2860 // default-bot actions are testable from the admin panel
2861 if ($bot_id === 'testing') {
2862 $bot_id = 'default';
2863 }
2864
2865 // Check if the current bot is in the enabled bots list
2866 return in_array($bot_id, $enabled_bots);
2867 }
2868
2869 // Helper function to clear PDF and Word document related transients
2870 private function clear_pdf_transients($session_id) {
2871 // PDF transients
2872 delete_transient('mxchat_pdf_url_' . $session_id);
2873 delete_transient('mxchat_pdf_embeddings_' . $session_id);
2874 delete_transient('mxchat_include_pdf_in_context_' . $session_id);
2875 delete_transient('mxchat_waiting_for_pdf_url_' . $session_id);
2876
2877 // Word document transients
2878 delete_transient('mxchat_word_url_' . $session_id);
2879 delete_transient('mxchat_word_filename_' . $session_id);
2880 delete_transient('mxchat_word_embeddings_' . $session_id);
2881 delete_transient('mxchat_include_word_in_context_' . $session_id);
2882 delete_transient('mxchat_waiting_for_word_' . $session_id);
2883 }
2884
2885
2886
2887 //verified good
2888 public function mxchat_handle_email_capture($message, $user_id, $session_id) {
2889 // Get the user's original instruction/message
2890 $user_instruction = esc_html($this->options['triggered_phrase_response'] ?? esc_html__("Please provide your email address.", 'mxchat'));
2891
2892 // Set instruction for AI - just pass along what the user wanted to say
2893 $this->current_action_instruction = $user_instruction;
2894
2895 // Set the transient to track email capture flow
2896 set_transient('mxchat_email_capture_' . $user_id, true, 5 * MINUTE_IN_SECONDS);
2897
2898 // Return false to let the AI generate the response
2899 return false;
2900 }
2901
2902 public function mxchat_generate_image($message, $user_id, $session_id) {
2903 //error_log("Starting image generation for message: " . $message);
2904
2905 // Prepare a prompt for OpenAI image generation
2906 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2907
2908 // Opt-in routing: when 'custom_provider_for_images' is on, route image gen
2909 // through the configured Custom (OpenAI-compatible) /images/generations route.
2910 if (!empty($this->options['custom_provider_for_images']) && $this->options['custom_provider_for_images'] === 'on') {
2911 $image_response = $this->mxchat_generate_custom_image($prompt);
2912 } else {
2913 // Use the existing OpenAI API key
2914 $openai_api_key = sanitize_text_field($this->options['api_key']);
2915 // Call OpenAI GPT Image to generate an image
2916 $image_response = $this->mxchat_generate_openai_image($prompt, $openai_api_key);
2917 }
2918
2919 // Check if the response contains an image URL
2920 if (isset($image_response['imageUrl'])) {
2921 $image_url = esc_url_raw($image_response['imageUrl']);
2922
2923 // Construct the HTML with a CSS class instead of inline styles
2924 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2925 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2926
2927 // Save the bot message with both text and HTML
2928 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2929 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2930
2931 // Set the fallback response for the chat handler
2932 $this->fallbackResponse = [
2933 'text' => $response_text,
2934 'html' => $response_html,
2935 'images' => [$image_url]
2936 ];
2937
2938 // For debugging/verification - Use json_encode to verify what's being set
2939 //error_log("Image generation successful - fallbackResponse set: " . json_encode($this->fallbackResponse));
2940
2941 // Return the response directly instead of relying on the property
2942 return $this->fallbackResponse;
2943 } else {
2944 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2945
2946 // Save the error message
2947 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2948
2949 // Set the fallback response for the chat handler
2950 $this->fallbackResponse = [
2951 'text' => $response_text,
2952 'html' => '',
2953 'images' => []
2954 ];
2955
2956 //error_log("DALL-E image generation error: " . esc_html($image_response['error'] ?? 'Unknown error.'));
2957 //error_log("Error fallbackResponse set: " . json_encode($this->fallbackResponse));
2958
2959 // Return the response directly instead of relying on the property
2960 return $this->fallbackResponse;
2961 }
2962 }
2963
2964 public function mxchat_generate_gemini_image($message, $user_id, $session_id) {
2965 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2966
2967 $gemini_api_key = sanitize_text_field($this->options['gemini_api_key'] ?? '');
2968 if (empty($gemini_api_key)) {
2969 $response_text = esc_html__("Gemini API key is not configured.", 'mxchat');
2970 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2971 return ['text' => $response_text, 'html' => '', 'images' => []];
2972 }
2973
2974 $image_response = $this->mxchat_generate_imagen_image($prompt, $gemini_api_key);
2975
2976 if (isset($image_response['imageUrl'])) {
2977 $image_url = esc_url_raw($image_response['imageUrl']);
2978
2979 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2980 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2981
2982 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2983 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2984
2985 $this->fallbackResponse = [
2986 'text' => $response_text,
2987 'html' => $response_html,
2988 'images' => [$image_url]
2989 ];
2990
2991 return $this->fallbackResponse;
2992 } else {
2993 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2994
2995 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2996
2997 $this->fallbackResponse = [
2998 'text' => $response_text,
2999 'html' => '',
3000 'images' => []
3001 ];
3002
3003 return $this->fallbackResponse;
3004 }
3005 }
3006
3007 private function mxchat_save_generated_image($base64_data, $mime_type = 'image/png', $prefix = 'mxchat-generated') {
3008 // Map the real mime type to a matching file extension so the saved file's
3009 // extension always agrees with its bytes. A mismatch (e.g. Imagen returning
3010 // webp bytes that were written into a ".png" file) makes the browser refuse
3011 // to render the image even though the file saved successfully and the bot
3012 // reported success — that was the Gemini/Imagen "image never renders" bug.
3013 // OpenAI + custom-provider paths pass 'image/png' explicitly, so they are
3014 // unaffected; this only matters for providers that return another type.
3015 $mime_to_ext = [
3016 'image/jpeg' => 'jpg',
3017 'image/jpg' => 'jpg',
3018 'image/png' => 'png',
3019 'image/webp' => 'webp',
3020 'image/gif' => 'gif',
3021 ];
3022 $mime_type = strtolower(trim((string) $mime_type));
3023 if (isset($mime_to_ext[$mime_type])) {
3024 $extension = $mime_to_ext[$mime_type];
3025 } else {
3026 // Unknown/unsupported type: fall back to png and normalize the stored
3027 // mime so the attachment record and the file extension stay consistent.
3028 $extension = 'png';
3029 $mime_type = 'image/png';
3030 }
3031 $filename = sanitize_file_name($prefix . '-' . wp_generate_uuid4() . '.' . $extension);
3032 $decoded = base64_decode($base64_data);
3033
3034 if ($decoded === false) {
3035 return new \WP_Error('decode_failed', esc_html__('Failed to decode image data.', 'mxchat'));
3036 }
3037
3038 $upload = wp_upload_bits($filename, null, $decoded);
3039
3040 if (!empty($upload['error'])) {
3041 return new \WP_Error('upload_failed', $upload['error']);
3042 }
3043
3044 $attach_id = wp_insert_attachment([
3045 'post_mime_type' => $mime_type,
3046 'post_title' => $prefix,
3047 'post_content' => '',
3048 'post_status' => 'inherit',
3049 ], $upload['file']);
3050
3051 if (is_wp_error($attach_id)) {
3052 return $attach_id;
3053 }
3054
3055 require_once ABSPATH . 'wp-admin/includes/image.php';
3056 $metadata = wp_generate_attachment_metadata($attach_id, $upload['file']);
3057 wp_update_attachment_metadata($attach_id, $metadata);
3058
3059 return esc_url_raw(wp_get_attachment_url($attach_id));
3060 }
3061
3062 private function mxchat_generate_openai_image($prompt, $api_key, $model = 'gpt-image-1', $timeout = 60) {
3063 $api_url = 'https://api.openai.com/v1/images/generations';
3064 $body = json_encode([
3065 'prompt' => sanitize_text_field($prompt),
3066 'n' => 1,
3067 'size' => '1024x1024',
3068 'quality' => 'medium',
3069 'output_format' => 'png',
3070 'model' => sanitize_text_field($model),
3071 ]);
3072
3073 $args = [
3074 'body' => $body,
3075 'headers' => [
3076 'Content-Type' => 'application/json',
3077 'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
3078 ],
3079 'method' => 'POST',
3080 'timeout' => absint($timeout),
3081 ];
3082
3083 $response = wp_remote_post($api_url, $args);
3084
3085 if (is_wp_error($response)) {
3086 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
3087 }
3088
3089 $response_body = json_decode(wp_remote_retrieve_body($response), true);
3090
3091 $b64 = $response_body['data'][0]['b64_json'] ?? $response_body['data'][0]['b64'] ?? null;
3092 if ($b64) {
3093 $saved_url = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-openai');
3094 if (is_wp_error($saved_url)) {
3095 return ['error' => $saved_url->get_error_message()];
3096 }
3097 return ['imageUrl' => $saved_url];
3098 } else {
3099 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
3100 }
3101 }
3102
3103 /**
3104 * Generate an image via a Custom (OpenAI-compatible) provider's /images/generations route.
3105 * Only called when the opt-in 'custom_provider_for_images' setting is on.
3106 */
3107 private function mxchat_generate_custom_image($prompt, $timeout = 90) {
3108 $cfg = $this->mxchat_resolve_custom_provider();
3109 if (empty($cfg['base_url'])) {
3110 return ['error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat')];
3111 }
3112 $url = $cfg['base_url'] . '/images/generations';
3113 if (!empty($cfg['api_version'])) {
3114 $url .= (strpos($url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($cfg['api_version']);
3115 }
3116 $body = wp_json_encode([
3117 'prompt' => sanitize_text_field($prompt),
3118 'n' => 1,
3119 'size' => '1024x1024',
3120 'model' => $cfg['model'],
3121 ]);
3122 $response = wp_remote_post($url, [
3123 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
3124 'body' => $body,
3125 'method' => 'POST',
3126 'timeout' => absint($timeout),
3127 ]);
3128 if (is_wp_error($response)) {
3129 return ['error' => esc_html__('Error generating image (custom provider): ', 'mxchat') . $response->get_error_message()];
3130 }
3131 $resp = json_decode(wp_remote_retrieve_body($response), true);
3132 // Try b64 first (matches OpenAI shape), then url-based fallback.
3133 $b64 = $resp['data'][0]['b64_json'] ?? $resp['data'][0]['b64'] ?? null;
3134 if ($b64) {
3135 $saved = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-custom');
3136 if (is_wp_error($saved)) {
3137 return ['error' => $saved->get_error_message()];
3138 }
3139 return ['imageUrl' => $saved];
3140 }
3141 $remote_url = $resp['data'][0]['url'] ?? null;
3142 if ($remote_url) {
3143 return ['imageUrl' => esc_url_raw($remote_url)];
3144 }
3145 $err_msg = $this->extract_provider_error($resp, esc_html__('Custom provider did not return an image.', 'mxchat'));
3146 return ['error' => esc_html($err_msg)];
3147 }
3148
3149 private function mxchat_generate_imagen_image($prompt, $api_key, $timeout = 60) {
3150 $api_url = 'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict';
3151
3152 $body = json_encode([
3153 'instances' => [['prompt' => sanitize_text_field($prompt)]],
3154 'parameters' => [
3155 'sampleCount' => 1,
3156 'aspectRatio' => '1:1',
3157 ],
3158 ]);
3159
3160 $args = [
3161 'body' => $body,
3162 'headers' => [
3163 'Content-Type' => 'application/json',
3164 'x-goog-api-key' => sanitize_text_field($api_key),
3165 ],
3166 'method' => 'POST',
3167 'timeout' => absint($timeout),
3168 ];
3169
3170 $response = wp_remote_post($api_url, $args);
3171
3172 if (is_wp_error($response)) {
3173 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
3174 }
3175
3176 $response_body = json_decode(wp_remote_retrieve_body($response), true);
3177
3178 $b64 = $response_body['predictions'][0]['bytesBase64Encoded'] ?? $response_body['predictions'][0]['imageBytes'] ?? null;
3179 if ($b64) {
3180 $mime = $response_body['predictions'][0]['mimeType'] ?? 'image/png';
3181 $saved_url = $this->mxchat_save_generated_image($b64, $mime, 'mxchat-gemini');
3182 if (is_wp_error($saved_url)) {
3183 return ['error' => $saved_url->get_error_message()];
3184 }
3185 return ['imageUrl' => $saved_url];
3186 } else {
3187 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
3188 }
3189 }
3190
3191 /**
3192 * Handle web search requests.
3193 *
3194 * Sends the refined search query to the Brave Search API and uses the
3195 * results to generate a conversational response with the AI model.
3196 *
3197 * @since 1.0.0
3198 * @param string $message The user's search query.
3199 * @param string $user_id The user identifier.
3200 * @param string $session_id The current session ID.
3201 * @return array Response array containing text with embedded HTML links
3202 */
3203 public function mxchat_handle_search_request($message, $user_id, $session_id) {
3204 // Step 1: Interpret and refine the search query
3205 $refined_search_query = $this->mxchat_interpret_search_query($message);
3206 if (empty($refined_search_query)) {
3207 return array(
3208 'text' => esc_html__('I apologize, but could you please rephrase your search request?', 'mxchat'),
3209 'html' => ''
3210 );
3211 }
3212
3213 // Retrieve and validate API settings
3214 $options = get_option('mxchat_options');
3215 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
3216 $results_count = isset($options['brave_results_count']) ? absint($options['brave_results_count']) : 5;
3217
3218 if (empty($api_key)) {
3219 return array(
3220 'text' => esc_html__('Search functionality is temporarily unavailable. Please try again later.', 'mxchat'),
3221 'html' => ''
3222 );
3223 }
3224
3225 // Build the API request URL
3226 $api_url = add_query_arg(
3227 array(
3228 'q' => rawurlencode($refined_search_query),
3229 'count' => $results_count,
3230 'text_decorations' => 'true',
3231 'rich_data' => 'true',
3232 ),
3233 'https://api.search.brave.com/res/v1/web/search'
3234 );
3235
3236 // Attempt to retrieve cached results first
3237 $transient_key = 'mxchat_search_' . md5($refined_search_query);
3238 $results = get_transient($transient_key);
3239
3240 if (false === $results) {
3241 // SECURITY FIX: Changed to wp_safe_remote_get
3242 $response = wp_safe_remote_get(
3243 $api_url,
3244 array(
3245 'headers' => array(
3246 'Accept' => 'application/json',
3247 'Accept-Encoding' => 'gzip',
3248 'X-Subscription-Token'=> $api_key,
3249 ),
3250 'timeout' => 10,
3251 )
3252 );
3253
3254 if (is_wp_error($response)) {
3255 return array(
3256 'text' => esc_html__('I encountered an error while searching. Please try again.', 'mxchat'),
3257 'html' => ''
3258 );
3259 }
3260
3261 $results = json_decode(wp_remote_retrieve_body($response), true);
3262
3263 if (json_last_error() !== JSON_ERROR_NONE) {
3264 return array(
3265 'text' => esc_html__('I received an invalid response from the search service.', 'mxchat'),
3266 'html' => ''
3267 );
3268 }
3269
3270 // Cache results for one hour
3271 set_transient($transient_key, $results, HOUR_IN_SECONDS);
3272 }
3273
3274 // Process results
3275 if (!empty($results['web']['results']) && is_array($results['web']['results'])) {
3276 // Create a more straightforward summary with HTML links
3277 $search_results_text = '';
3278
3279 // Add a simple intro
3280 $search_results_text .= sprintf(
3281 esc_html__("Here's what I found about '%s':", 'mxchat'),
3282 esc_html($refined_search_query)
3283 );
3284
3285 // Add the top results with HTML links
3286 foreach (array_slice($results['web']['results'], 0, 5) as $result) {
3287 $title = isset($result['title']) ? wp_strip_all_tags($result['title']) : '';
3288 $url = isset($result['url']) ? esc_url($result['url']) : '';
3289 $description = isset($result['description']) ? wp_strip_all_tags($result['description']) : '';
3290
3291 // Add a line break after the intro
3292 $search_results_text .= '<br><br>';
3293
3294 // Add title as a link
3295 $search_results_text .= sprintf(
3296 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a><br>',
3297 $url,
3298 $title
3299 );
3300
3301 // Add a condensed description
3302 $search_results_text .= sprintf("%s", $description);
3303 }
3304
3305 // Save to chat history
3306 $this->mxchat_save_chat_message($session_id, 'bot', $search_results_text);
3307
3308 // Return the formatted text with embedded HTML links
3309 return array(
3310 'text' => $search_results_text,
3311 'html' => ''
3312 );
3313 } else {
3314 return array(
3315 'text' => sprintf(
3316 esc_html__('I searched for "%s" but couldn\'t find any relevant results. Would you like to try different search terms?', 'mxchat'),
3317 esc_html($refined_search_query)
3318 ),
3319 'html' => ''
3320 );
3321 }
3322 }
3323
3324 //very good
3325 /**
3326 * Handle image search requests from the chatbot
3327 *
3328 * @param string $message The user's search query
3329 * @param int $user_id The user's ID
3330 * @param string $session_id The chat session ID
3331 * @return array Response array with text and HTML content
3332 */
3333 public function mxchat_handle_image_search_request($message, $user_id, $session_id) {
3334 // Step 1: Interpret the search query using the user's selected AI model
3335 $refined_search_query = $this->mxchat_interpret_search_query($message);
3336
3337 // If no query was interpreted, return a fallback message
3338 if (empty($refined_search_query)) {
3339 return array(
3340 'text' => __("I'm sorry, I couldn't interpret your search query. Please specify what you'd like to see images of.", 'mxchat'),
3341 'html' => "",
3342 );
3343 }
3344
3345 // Brave API URL
3346 $api_url = 'https://api.search.brave.com/res/v1/images/search';
3347
3348 // Retrieve Brave API settings
3349 $options = get_option('mxchat_options');
3350 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
3351
3352 if (empty($api_key)) {
3353 return array(
3354 'text' => __("API key is not configured. Please set it in the Brave Search Settings.", 'mxchat'),
3355 'html' => "",
3356 );
3357 }
3358
3359 $image_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
3360 $safe_search = isset($options['brave_safe_search']) ? sanitize_text_field($options['brave_safe_search']) : 'strict';
3361
3362 // Append query parameters based on settings
3363 $api_url = add_query_arg([
3364 'q' => rawurlencode($refined_search_query),
3365 'count' => $image_count,
3366 'safesearch' => $safe_search,
3367 ], $api_url);
3368
3369 // Implement caching
3370 $transient_key = 'mxchat_image_search_' . md5($refined_search_query);
3371 $body = get_transient($transient_key);
3372
3373 if (false === $body) {
3374 $args = [
3375 'headers' => [
3376 'Accept' => 'application/json',
3377 'Accept-Encoding' => 'gzip',
3378 'X-Subscription-Token' => $api_key,
3379 ],
3380 'timeout' => 10,
3381 ];
3382
3383 // SECURITY FIX: Changed to wp_safe_remote_get
3384 $response = wp_safe_remote_get($api_url, $args);
3385
3386 if (is_wp_error($response)) {
3387 return array(
3388 'text' => __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat'),
3389 'html' => "",
3390 );
3391 }
3392
3393 $body = json_decode(wp_remote_retrieve_body($response), true);
3394 set_transient($transient_key, $body, HOUR_IN_SECONDS);
3395 }
3396
3397 // Process the API response
3398 if (isset($body['results']) && is_array($body['results']) && count($body['results']) > 0) {
3399 $html_output = '<div class="mxchat-image-gallery">';
3400
3401 // Get the configured image count (1-6)
3402 $display_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
3403 $display_count = min($display_count, count($body['results'])); // Make sure we don't exceed available images
3404
3405 // Use only the requested number of images
3406 for ($i = 0; $i < $display_count; $i++) {
3407 $image = $body['results'][$i];
3408 $image_url = isset($image['url']) ? esc_url($image['url']) : '';
3409 $thumbnail_url = isset($image['thumbnail']['src']) ? esc_url($image['thumbnail']['src']) : '';
3410 $title = isset($image['title']) ? esc_html($image['title']) : esc_html__('Image', 'mxchat');
3411
3412 if ($image_url && $thumbnail_url) {
3413 $html_output .= '<div class="mxchat-image-item">';
3414 $html_output .= '<strong class="mxchat-image-title">' . $title . '</strong>';
3415 $html_output .= '<a href="' . $image_url . '" target="_blank" rel="noopener noreferrer" class="mxchat-image-link">';
3416 $html_output .= '<img src="' . $thumbnail_url . '" alt="' . $title . '" class="mxchat-image-thumbnail">';
3417 $html_output .= '</a></div>';
3418 }
3419 }
3420
3421 $html_output .= '</div>';
3422
3423 // Create response text
3424 $response_text = sprintf(__("Here are some images of %s:", 'mxchat'), $refined_search_query);
3425
3426 // Save both response text and HTML to chat history
3427 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
3428 $this->mxchat_save_chat_message($session_id, 'bot', $html_output);
3429
3430 // Return the combined response
3431 return array(
3432 'text' => $response_text,
3433 'html' => $html_output,
3434 );
3435 } else {
3436 $response_text = __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat');
3437
3438 // Save the error message to chat history
3439 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
3440
3441 return array(
3442 'text' => $response_text,
3443 'html' => "",
3444 );
3445 }
3446 }
3447
3448 /**
3449 * Interpret the search query using the user's selected AI model
3450 *
3451 * @param string $user_query The original query from the user
3452 * @return string The refined search query
3453 */
3454 public function mxchat_interpret_search_query($user_query) {
3455 $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');
3456
3457 // Get options and determine the selected model
3458 $options = $this->options ?? get_option('mxchat_options');
3459 $selected_model = isset($options['model']) ? $options['model'] : 'gpt-5.6-sol';
3460
3461 // Custom (OpenAI-compatible) provider routes by model id, not prefix.
3462 if ($selected_model === 'custom-provider') {
3463 return $this->interpret_query_with_custom($user_query, $system_prompt);
3464 }
3465
3466 // Extract model prefix to determine the provider
3467 $model_parts = explode('-', $selected_model);
3468 $provider = strtolower($model_parts[0]);
3469
3470 // Determine which API key to use based on the provider
3471 switch ($provider) {
3472 case 'gemini':
3473 $api_key = isset($options['gemini_api_key']) ? sanitize_text_field($options['gemini_api_key']) : '';
3474 if (empty($api_key)) {
3475 return sanitize_text_field($user_query); // Default to original query if API key missing
3476 }
3477 return $this->interpret_query_with_gemini($user_query, $system_prompt, $api_key, $selected_model);
3478
3479 case 'claude':
3480 $api_key = isset($options['claude_api_key']) ? sanitize_text_field($options['claude_api_key']) : '';
3481 if (empty($api_key)) {
3482 return sanitize_text_field($user_query);
3483 }
3484 return $this->interpret_query_with_claude($user_query, $system_prompt, $api_key, $selected_model);
3485
3486 case 'grok':
3487 $api_key = isset($options['xai_api_key']) ? sanitize_text_field($options['xai_api_key']) : '';
3488 if (empty($api_key)) {
3489 return sanitize_text_field($user_query);
3490 }
3491 return $this->interpret_query_with_xai($user_query, $system_prompt, $api_key, $selected_model);
3492
3493 case 'deepseek':
3494 $api_key = isset($options['deepseek_api_key']) ? sanitize_text_field($options['deepseek_api_key']) : '';
3495 if (empty($api_key)) {
3496 return sanitize_text_field($user_query);
3497 }
3498 return $this->interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $selected_model);
3499
3500 case 'gpt':
3501 default:
3502 // Default to OpenAI for custom models or unrecognized prefixes
3503 $api_key = isset($options['api_key']) ? sanitize_text_field($options['api_key']) : '';
3504 if (empty($api_key)) {
3505 return sanitize_text_field($user_query);
3506 }
3507 return $this->interpret_query_with_openai($user_query, $system_prompt, $api_key, $selected_model);
3508 }
3509 }
3510
3511 /**
3512 * Interpret query against the configured Custom (OpenAI-compatible) provider.
3513 * Uses the same base URL + auth scheme as the chat dispatcher.
3514 */
3515 private function interpret_query_with_custom($user_query, $system_prompt) {
3516 $cfg = $this->mxchat_resolve_custom_provider();
3517 if (empty($cfg['base_url'])) {
3518 return sanitize_text_field($user_query);
3519 }
3520 // plan-mxchat-20260715-7124f4: a custom OpenAI-compatible endpoint pointed at
3521 // a gpt-5-class model rejects temperature!=1 and the legacy max_tokens key.
3522 // Byte-identical for ordinary custom models (temperature kept, max_tokens
3523 // used); only gpt-5-class custom models change (best-effort — custom
3524 // endpoints vary).
3525 $token_key = $this->mxchat_openai_token_param_for($cfg['model']);
3526 $payload = [
3527 'model' => $cfg['model'],
3528 'messages' => [
3529 ['role' => 'system', 'content' => $system_prompt],
3530 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3531 ],
3532 $token_key => 20,
3533 ];
3534 if ($this->mxchat_openai_supports_temperature_for($cfg['model'])) {
3535 $payload['temperature'] = 0.2;
3536 }
3537 $args = [
3538 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
3539 'body' => wp_json_encode($payload),
3540 'method' => 'POST',
3541 'timeout' => 15,
3542 ];
3543 $response = wp_remote_post($cfg['chat_url'], $args);
3544 if (is_wp_error($response)) {
3545 return sanitize_text_field($user_query);
3546 }
3547 $body = json_decode(wp_remote_retrieve_body($response), true);
3548 return isset($body['choices'][0]['message']['content'])
3549 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
3550 : sanitize_text_field($user_query);
3551 }
3552
3553 /**
3554 * Convert the colon-style header list returned by mxchat_resolve_custom_provider
3555 * into the assoc-array form wp_remote_post expects.
3556 */
3557 private function mxchat_custom_provider_assoc_headers($cfg) {
3558 $headers = ['Content-Type' => 'application/json'];
3559 if (!empty($cfg['api_key'])) {
3560 if (($cfg['auth_scheme'] ?? 'bearer') === 'api-key') {
3561 $headers['api-key'] = $cfg['api_key'];
3562 } else {
3563 $headers['Authorization'] = 'Bearer ' . $cfg['api_key'];
3564 }
3565 }
3566 return $headers;
3567 }
3568
3569 /**
3570 * Interpret query using OpenAI models
3571 */
3572 private function interpret_query_with_openai($user_query, $system_prompt, $api_key, $model = 'gpt-5.6-sol') {
3573 $url = 'https://api.openai.com/v1/chat/completions';
3574 // plan-mxchat-20260715-7124f4: the default chat model is a gpt-5-family id
3575 // and every gpt-5* rejects both a non-default temperature and the legacy
3576 // max_tokens key (400). This call swallowed the 400 and silently degraded to
3577 // the raw query on every gpt-5 install, quietly disabling product/image
3578 // search-query interpretation. Derive capability from the core catalog
3579 // (dcb71c) so this tracks future model adds; strpos fallback for a
3580 // partial-upgrade window where the catalog method isn't loaded.
3581 $token_key = $this->mxchat_openai_token_param_for($model);
3582 $payload = [
3583 'model' => $model,
3584 'messages' => [
3585 ['role' => 'system', 'content' => $system_prompt],
3586 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3587 ],
3588 $token_key => 20,
3589 ];
3590 if ($this->mxchat_openai_supports_temperature_for($model)) {
3591 $payload['temperature'] = 0.2;
3592 }
3593 $args = [
3594 'headers' => [
3595 'Authorization' => 'Bearer ' . $api_key,
3596 'Content-Type' => 'application/json',
3597 ],
3598 'body' => wp_json_encode($payload),
3599 'method' => 'POST',
3600 'timeout' => 15,
3601 ];
3602
3603 $response = wp_remote_post($url, $args);
3604 if (is_wp_error($response)) {
3605 return sanitize_text_field($user_query);
3606 }
3607
3608 $body = json_decode(wp_remote_retrieve_body($response), true);
3609 return isset($body['choices'][0]['message']['content'])
3610 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
3611 : sanitize_text_field($user_query);
3612 }
3613
3614 /**
3615 * Anthropic removed temperature/top_p/top_k starting with Opus 4.7 (the API
3616 * returns 400 if sent) — add new flagship model ids here. (We don't send
3617 * top_p/top_k in any Claude body, so the list only needs to gate temperature
3618 * stripping. We never send a `thinking` param either, which is required for
3619 * claude-fable-5: it rejects an explicit thinking "disabled" — omit only.)
3620 */
3621 private function mxchat_claude_omits_temperature($model) {
3622 // plan-mxchat-20260714-dcb71c: derive from the core model catalog (single
3623 // source of truth). Every caller here passes a Claude model, so
3624 // !supports_temperature() reproduces the old 4-id in_array() result exactly.
3625 // Frozen list kept as fallback for a partial-upgrade window where the
3626 // catalog method isn't loaded.
3627 if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'supports_temperature')) {
3628 return !MxChat_Model_Catalog::supports_temperature($model);
3629 }
3630 $no_temp = array('claude-opus-5', 'claude-opus-4-7', 'claude-opus-4-8', 'claude-fable-5', 'claude-sonnet-5');
3631 return in_array($model, $no_temp, true);
3632 }
3633
3634 /**
3635 * plan-mxchat-20260715-7124f4: OpenAI completion-token key for this model,
3636 * sourced from the core catalog (gpt-5* → max_completion_tokens; else
3637 * max_tokens). strpos fallback for a partial-upgrade window where the catalog
3638 * method isn't loaded.
3639 *
3640 * @param string $model OpenAI(-compatible) model id.
3641 * @return string 'max_completion_tokens' | 'max_tokens'
3642 */
3643 private function mxchat_openai_token_param_for($model) {
3644 if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'openai_token_param')) {
3645 return MxChat_Model_Catalog::openai_token_param($model);
3646 }
3647 return strpos((string) $model, 'gpt-5') === 0 ? 'max_completion_tokens' : 'max_tokens';
3648 }
3649
3650 /**
3651 * plan-mxchat-20260715-7124f4: whether a NON-default temperature may be sent to
3652 * this OpenAI(-compatible) model. gpt-5* accept only the default (1) — sending
3653 * any other value 400s. Sourced from the core catalog; strpos fallback for a
3654 * partial-upgrade window.
3655 *
3656 * @param string $model OpenAI(-compatible) model id.
3657 * @return bool
3658 */
3659 private function mxchat_openai_supports_temperature_for($model) {
3660 if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'supports_temperature')) {
3661 return MxChat_Model_Catalog::supports_temperature($model);
3662 }
3663 return strpos((string) $model, 'gpt-5') !== 0;
3664 }
3665
3666 /**
3667 * plan-mxchat-20260714-dcb71c: per-surface reasoning_effort, sourced from the
3668 * core model catalog so a model add propagates automatically. The fallback is
3669 * the frozen pre-dcb71c inline ladder, used only if the catalog method is
3670 * unavailable (a partial-upgrade window). Byte-identical to the old inline
3671 * blocks by construction — proven by the dcb71c equivalence harness.
3672 *
3673 * @param string $model Chat model id.
3674 * @param string $context 'chat' | 'websearch'.
3675 * @return string|null Effort to send, or null to omit the param.
3676 */
3677 private function mxchat_reasoning_effort_for($model, $context) {
3678 if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'reasoning_effort_for')) {
3679 return MxChat_Model_Catalog::reasoning_effort_for($model, $context);
3680 }
3681 return $this->mxchat_reasoning_effort_fallback($model, $context);
3682 }
3683
3684 private function mxchat_reasoning_effort_fallback($model, $context) {
3685 if (strpos($model, 'gpt-5') !== 0) {
3686 return null;
3687 }
3688 if ($context === 'websearch') {
3689 $no_reasoning_web = array('gpt-5.2', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
3690 if (in_array($model, $no_reasoning_web, true)) return null;
3691 if ($model === 'gpt-5.1-2025-11-13') return 'low';
3692 if ($model === 'gpt-5.5') return 'low';
3693 if ($model === 'gpt-5.4') return 'low';
3694 if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low';
3695 return null;
3696 }
3697 // 'chat'
3698 // gpt-5.1/5.3-chat-latest stay listed after their 2026-08-10 retirement:
3699 // unmigrated bot-level / add-on-saved ids must keep routing correctly
3700 // until every surface is swept (plan e46b8f).
3701 $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');
3702 if (in_array($model, $no_reasoning_models, true)) return null;
3703 if ($model === 'gpt-5.1-2025-11-13') return 'low';
3704 if ($model === 'gpt-5.5') return 'none';
3705 if ($model === 'gpt-5.4') return 'none';
3706 if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low';
3707 return 'minimal';
3708 }
3709
3710 /**
3711 * Interpret query using Claude models
3712 */
3713 private function interpret_query_with_claude($user_query, $system_prompt, $api_key, $model) {
3714 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
3715 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
3716 if ($model === 'claude-opus-4-20250514') { $model = 'claude-opus-4-8'; }
3717 elseif ($model === 'claude-sonnet-4-20250514') { $model = 'claude-sonnet-4-6'; }
3718 $url = 'https://api.anthropic.com/v1/messages';
3719
3720 $payload = [
3721 'model' => $model,
3722 'system' => $system_prompt,
3723 'messages' => [
3724 ['role' => 'user', 'content' => sanitize_text_field($user_query)]
3725 ],
3726 'max_tokens' => 20,
3727 'temperature' => 0.2,
3728 ];
3729 if ($this->mxchat_claude_omits_temperature($model)) { unset($payload['temperature']); }
3730
3731 $args = [
3732 'headers' => [
3733 'Content-Type' => 'application/json',
3734 'x-api-key' => $api_key,
3735 'anthropic-version' => '2023-06-01',
3736 ],
3737 'body' => wp_json_encode($payload),
3738 'method' => 'POST',
3739 'timeout' => 15,
3740 ];
3741
3742 $response = wp_remote_post($url, $args);
3743 if (is_wp_error($response)) {
3744 return sanitize_text_field($user_query);
3745 }
3746
3747 $body = json_decode(wp_remote_retrieve_body($response), true);
3748 // claude-fable-5 prepends a thinking block to content — take the first
3749 // TEXT block, not content[0].
3750 foreach ((array) ($body['content'] ?? array()) as $block) {
3751 if (isset($block['type'], $block['text']) && $block['type'] === 'text' && trim($block['text']) !== '') {
3752 return sanitize_text_field(trim($block['text']));
3753 }
3754 }
3755
3756 return sanitize_text_field($user_query);
3757 }
3758
3759 /**
3760 * Interpret query using Gemini models
3761 */
3762 private function interpret_query_with_gemini($user_query, $system_prompt, $api_key, $model) {
3763 if ($model === 'gemini-3-pro-preview') {
3764 $model = 'gemini-3.1-pro-preview';
3765 }
3766 // Use v1beta for preview models, v1 for stable models
3767 $api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
3768
3769 $url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key=" . urlencode($api_key);
3770
3771 $args = [
3772 'headers' => [
3773 'Content-Type' => 'application/json',
3774 ],
3775 'body' => wp_json_encode([
3776 'contents' => [
3777 [
3778 'role' => 'user',
3779 'parts' => [
3780 ['text' => $system_prompt . "\n\nQuery: " . sanitize_text_field($user_query)]
3781 ]
3782 ]
3783 ],
3784 'generationConfig' => [
3785 'temperature' => 0.2,
3786 'maxOutputTokens' => 20,
3787 ],
3788 ]),
3789 'method' => 'POST',
3790 'timeout' => 15,
3791 ];
3792
3793 $response = wp_remote_post($url, $args);
3794 if (is_wp_error($response)) {
3795 return sanitize_text_field($user_query);
3796 }
3797
3798 $body = json_decode(wp_remote_retrieve_body($response), true);
3799 if (!empty($body['candidates'][0]['content']['parts'][0]['text'])) {
3800 return sanitize_text_field(trim($body['candidates'][0]['content']['parts'][0]['text']));
3801 }
3802
3803 return sanitize_text_field($user_query);
3804 }
3805
3806 /**
3807 * Interpret query using X.AI (Grok) models
3808 */
3809 private function interpret_query_with_xai($user_query, $system_prompt, $api_key, $model) {
3810 $url = 'https://api.xai.com/v1/chat/completions';
3811
3812 $args = [
3813 'headers' => [
3814 'Content-Type' => 'application/json',
3815 'Authorization' => 'Bearer ' . $api_key,
3816 ],
3817 'body' => wp_json_encode([
3818 'model' => $model,
3819 'messages' => [
3820 ['role' => 'system', 'content' => $system_prompt],
3821 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3822 ],
3823 'temperature' => 0.2,
3824 'max_tokens' => 20,
3825 ]),
3826 'method' => 'POST',
3827 'timeout' => 15,
3828 ];
3829
3830 $response = wp_remote_post($url, $args);
3831 if (is_wp_error($response)) {
3832 return sanitize_text_field($user_query);
3833 }
3834
3835 $body = json_decode(wp_remote_retrieve_body($response), true);
3836 if (isset($body['choices'][0]['message']['content'])) {
3837 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
3838 }
3839
3840 return sanitize_text_field($user_query);
3841 }
3842
3843 /**
3844 * Interpret query using DeepSeek models
3845 */
3846 private function interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $model) {
3847 $url = 'https://api.deepseek.com/v1/chat/completions';
3848
3849 $args = [
3850 'headers' => [
3851 'Content-Type' => 'application/json',
3852 'Authorization' => 'Bearer ' . $api_key,
3853 ],
3854 'body' => wp_json_encode([
3855 'model' => $model,
3856 'messages' => [
3857 ['role' => 'system', 'content' => $system_prompt],
3858 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3859 ],
3860 'temperature' => 0.2,
3861 'max_tokens' => 20,
3862 // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
3863 // reasoning burns the 20-token budget); keep the legacy
3864 // deepseek-chat semantics = non-thinking.
3865 'thinking' => ['type' => 'disabled'],
3866 ]),
3867 'method' => 'POST',
3868 'timeout' => 15,
3869 ];
3870
3871 $response = wp_remote_post($url, $args);
3872 if (is_wp_error($response)) {
3873 return sanitize_text_field($user_query);
3874 }
3875
3876 $body = json_decode(wp_remote_retrieve_body($response), true);
3877 if (isset($body['choices'][0]['message']['content'])) {
3878 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
3879 }
3880
3881 return sanitize_text_field($user_query);
3882 }
3883
3884 //very good
3885 private function add_email_to_loops($email) {
3886 // Sanitize the email
3887 $email = sanitize_email($email);
3888
3889 // Retrieve and sanitize options
3890 $api_key = isset($this->options['loops_api_key']) ? sanitize_text_field($this->options['loops_api_key']) : '';
3891 $mailing_list_id = isset($this->options['loops_mailing_list']) ? sanitize_text_field($this->options['loops_mailing_list']) : '';
3892
3893 // Check for missing API key or mailing list ID
3894 if (empty($api_key) || empty($mailing_list_id)) {
3895 //error_log(esc_html__('Loops API key or mailing list ID is missing.', 'mxchat'));
3896 return;
3897 }
3898
3899 $data = array(
3900 'email' => $email,
3901 'subscribed' => true,
3902 'source' => __('MxChat AI Chatbot', 'mxchat'),
3903 'mailingLists' => array($mailing_list_id => true),
3904 );
3905
3906 $url = 'https://app.loops.so/api/v1/contacts/create';
3907 $args = array(
3908 'body' => wp_json_encode($data),
3909 'headers' => array(
3910 'Authorization' => 'Bearer ' . $api_key,
3911 'Content-Type' => 'application/json',
3912 ),
3913 'method' => 'POST',
3914 'timeout' => 45,
3915 );
3916
3917 $response = wp_remote_post($url, $args);
3918
3919 // Handle errors in the API request
3920 if (is_wp_error($response)) {
3921 //error_log(esc_html__('Error adding email to Loops: ', 'mxchat') . $response->get_error_message());
3922 return;
3923 }
3924
3925 // Check for non-200 HTTP responses
3926 $response_code = wp_remote_retrieve_response_code($response);
3927 if ($response_code != 200) {
3928 $response_body = wp_remote_retrieve_body($response);
3929 //error_log(esc_html__('Loops API responded with code ', 'mxchat') . $response_code . ': ' . $response_body);
3930 }
3931 }
3932
3933 public function mxchat_handle_pdf_discussion($message, $user_id, $session_id) {
3934 // Get the maximum number of pages allowed from admin settings
3935 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3936
3937 // Retrieve options for dynamic texts
3938 $trigger_text = $this->options['pdf_intent_trigger_text'] ?? __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
3939 $success_text = $this->options['pdf_intent_success_text'] ?? __("I've processed the PDF. What questions do you have about it?", 'mxchat');
3940 $error_text = $this->options['pdf_intent_error_text'] ?? __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
3941
3942 // Check for explicit request for new PDF
3943 $new_pdf_requested = stripos($message, 'new') !== false ||
3944 stripos($message, 'another') !== false ||
3945 stripos($message, 'different') !== false;
3946
3947 // If user mentions adding/reading a PDF, set waiting flag
3948 if (stripos($message, 'pdf') !== false ||
3949 stripos($message, 'document') !== false ||
3950 stripos($message, 'read') !== false) {
3951 set_transient('mxchat_waiting_for_pdf_url_' . $session_id, true, HOUR_IN_SECONDS);
3952 $this->fallbackResponse['text'] = $trigger_text;
3953 return;
3954 }
3955
3956 // If we're waiting for a URL or user requested new PDF
3957 if ($new_pdf_requested || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
3958 if (preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
3959 // Process URL... (rest of your existing URL processing code)
3960 } else {
3961 $this->fallbackResponse['text'] = $trigger_text;
3962 }
3963 return;
3964 }
3965
3966 // Default to proceeding with conversation if no specific PDF action is needed
3967 $this->fallbackResponse['text'] = '';
3968 }
3969
3970
3971 /**
3972 * Enhanced fetch_and_split_pdf_pages with SSRF protection
3973 */
3974 private function fetch_and_split_pdf_pages($pdf_source, $max_pages) {
3975 // CLEAR DEBUG LOGGING
3976 //error_log("=== MXCHAT PDF PROCESSING START ===");
3977 //error_log("PDF Source: " . $pdf_source);
3978 //error_log("Max Pages: " . $max_pages);
3979 //error_log("Session ID: " . ($this->session_id ?? 'not set'));
3980
3981 // Check if Advanced Claude Toolbar is available and enabled
3982 $claude_available = function_exists('mxchatACT_is_advanced_claude_enabled');
3983 $claude_enabled = $claude_available ? mxchatACT_is_advanced_claude_enabled() : false;
3984
3985 //error_log("Claude Function Available: " . ($claude_available ? 'YES' : 'NO'));
3986 //error_log("Claude Enabled: " . ($claude_enabled ? 'YES' : 'NO'));
3987
3988 if ($claude_available && $claude_enabled) {
3989 //error_log("🚀 ATTEMPTING CLAUDE PROCESSING...");
3990
3991 // Attempt Claude processing first
3992 $claude_result = apply_filters('mxchat_process_pdf_advanced', false, $pdf_source, $max_pages, $this->session_id);
3993
3994 if ($claude_result !== false && is_array($claude_result) && !empty($claude_result)) {
3995 //error_log("✅ CLAUDE PROCESSING SUCCESSFUL!");
3996 //error_log("Claude returned " . count($claude_result) . " processed pages");
3997
3998 // Log first page details for verification
3999 if (isset($claude_result[0])) {
4000 $first_page = $claude_result[0];
4001 //error_log("First page enhanced: " . (isset($first_page['enhanced']) && $first_page['enhanced'] ? 'YES' : 'NO'));
4002 //error_log("Processing method: " . ($first_page['processing_method'] ?? 'not set'));
4003 //error_log("First page text preview: " . substr($first_page['text'] ?? '', 0, 100) . "...");
4004 }
4005
4006 //error_log("=== MXCHAT PDF PROCESSING END (CLAUDE) ===");
4007 return $claude_result;
4008 } else {
4009 //error_log("❌ CLAUDE PROCESSING FAILED or returned invalid result");
4010 //error_log("Claude result type: " . gettype($claude_result));
4011 if (is_array($claude_result)) {
4012 //error_log("Claude result count: " . count($claude_result));
4013 }
4014 }
4015 }
4016
4017 // Fallback to basic processing
4018 //error_log("🔄 FALLING BACK TO BASIC PDF PROCESSING...");
4019
4020 $upload_dir = wp_upload_dir();
4021 $temp_file = null;
4022
4023 try {
4024 // Your existing basic processing code here...
4025 // (I'll include the key parts with debug logging)
4026
4027 if (filter_var($pdf_source, FILTER_VALIDATE_URL)) {
4028 //error_log("Downloading PDF from URL...");
4029
4030 // SECURITY FIX: Validate URL before processing
4031 if (!$this->mxchat_is_safe_pdf_url($pdf_source)) {
4032 //error_log("❌ SECURITY: Blocked unsafe PDF URL");
4033 return false;
4034 }
4035
4036 $temp_file = wp_tempnam($pdf_source);
4037
4038 // SECURITY FIX: Changed from wp_remote_get to wp_safe_remote_get
4039 // Route through the shared MXChat crawler identity (plan bae78f/b6d93c) so
4040 // every remote-content fetch presents one honest, versioned, filterable,
4041 // allowlistable User-Agent. function_exists guard keeps the front-end/nopriv
4042 // path safe if the helper (in the always-loaded main file) is ever unavailable.
4043 $response = wp_safe_remote_get($pdf_source, [
4044 'timeout' => 60,
4045 'headers' => ['User-Agent' => function_exists('mxchat_ingest_user_agent') ? mxchat_ingest_user_agent() : 'MxChat PDF Processor']
4046 ]);
4047
4048 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
4049 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code($response);
4050 //error_log("❌ BASIC PROCESSING: Failed to download PDF: " . $error_message);
4051 return false;
4052 }
4053
4054 global $wp_filesystem;
4055 if (empty($wp_filesystem)) {
4056 require_once ABSPATH . 'wp-admin/includes/file.php';
4057 WP_Filesystem();
4058 }
4059 $wp_filesystem->put_contents($temp_file, wp_remote_retrieve_body($response), FS_CHMOD_FILE);
4060 //error_log("✅ PDF downloaded successfully");
4061 } else {
4062 $temp_file = $pdf_source;
4063 //error_log("Using local PDF file: " . $temp_file);
4064 }
4065
4066 // Parse PDF
4067 //error_log("Parsing PDF with basic parser...");
4068 mxchat_load_pdf_parser();
4069 $parser = new \Smalot\PdfParser\Parser();
4070 $pdf = $parser->parseFile($temp_file);
4071 $pages = $pdf->getPages();
4072
4073 //error_log("PDF contains " . count($pages) . " pages");
4074
4075 if (count($pages) > $max_pages) {
4076 //error_log("❌ BASIC PROCESSING: Too many pages (" . count($pages) . " > " . $max_pages . ")");
4077 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file) {
4078 unlink($temp_file);
4079 }
4080 return 'too_many_pages';
4081 }
4082
4083 $embeddings = [];
4084 $processed_pages = 0;
4085
4086 foreach ($pages as $page_number => $page) {
4087 $text = $page->getText();
4088
4089 if (empty(trim($text))) {
4090 //error_log("Skipping empty page: " . ($page_number + 1));
4091 continue;
4092 }
4093
4094 $text = $this->mxchat_clean_text($text);
4095
4096 $embedding = $this->mxchat_generate_embedding(
4097 __("Page ", 'mxchat') . ($page_number + 1) . ": " . $text,
4098 $this->options['api_key']
4099 );
4100
4101 if ($embedding) {
4102 $embeddings[] = [
4103 'page_number' => $page_number + 1,
4104 'embedding' => $embedding,
4105 'text' => $text,
4106 'enhanced' => false, // CLEARLY MARK AS BASIC
4107 'processing_method' => 'basic_pdf_parser'
4108 ];
4109 $processed_pages++;
4110 }
4111 }
4112
4113 //error_log("✅ BASIC PROCESSING COMPLETE: " . $processed_pages . " pages processed");
4114
4115 // Cleanup
4116 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
4117 unlink($temp_file);
4118 }
4119
4120 //error_log("=== MXCHAT PDF PROCESSING END (BASIC) ===");
4121 return $embeddings;
4122
4123 } catch (\Exception $e) {
4124 //error_log("❌ BASIC PROCESSING ERROR: " . $e->getMessage());
4125 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
4126 unlink($temp_file);
4127 }
4128 //error_log("=== MXCHAT PDF PROCESSING END (ERROR) ===");
4129 return false;
4130 }
4131 }
4132
4133
4134 /**
4135 * Validate PDF URL for security
4136 * Prevents SSRF attacks by blocking dangerous URLs
4137 */
4138
4139 private function mxchat_is_safe_pdf_url($url) {
4140 // Use WordPress core function for comprehensive validation
4141 // This blocks localhost, private IPs, and reserved IP ranges
4142 $validated_url = wp_http_validate_url($url);
4143
4144 if ($validated_url === false) {
4145 return false;
4146 }
4147
4148 // Additional check: only allow HTTP/HTTPS schemes
4149 $parsed = parse_url($url);
4150 if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], ['http', 'https'], true)) {
4151 return false;
4152 }
4153
4154 return true;
4155 }
4156
4157
4158 private function mxchat_clean_text($text) {
4159 // Remove excessive whitespace
4160 $text = preg_replace('/\s+/', ' ', $text);
4161
4162 // Remove control characters except newlines and tabs
4163 $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
4164
4165 // Normalize line endings
4166 $text = str_replace(["\r\n", "\r"], "\n", $text);
4167
4168 // Trim whitespace
4169 $text = trim($text);
4170
4171 return $text;
4172 }
4173
4174 private function find_relevant_pdf_pages($query_embedding, $embeddings) {
4175 //error_log(esc_html__("find_relevant_pdf_pages called.", 'mxchat'));
4176
4177 $most_relevant = null;
4178 $highest_similarity = -INF;
4179
4180 foreach ($embeddings as $page_data) {
4181 $similarity = $this->mxchat_calculate_cosine_similarity($query_embedding, $page_data['embedding']);
4182
4183 if ($similarity > $highest_similarity) {
4184 $highest_similarity = $similarity;
4185 $most_relevant = $page_data['page_number'];
4186 }
4187 }
4188
4189 if (!is_null($most_relevant)) {
4190 $page_numbers = range(max(1, $most_relevant - 1), min(count($embeddings), $most_relevant + 1));
4191 return array_filter($embeddings, function ($page) use ($page_numbers) {
4192 return in_array($page['page_number'], $page_numbers);
4193 });
4194 }
4195
4196 return [];
4197 }
4198
4199
4200 public function handle_pdf_upload() {
4201 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce(wp_unslash((string) $_POST['nonce']))) {
4202 wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'mxchat')), 403);
4203 }
4204
4205 if (!isset($_FILES['pdf_file']) || !isset($_POST['session_id'])) {
4206 wp_send_json_error(esc_html__('Missing required parameters.', 'mxchat'));
4207 return;
4208 }
4209
4210 // SECURITY FIX: Check if PDF uploads are enabled in settings
4211 $options = get_option('mxchat_options', array());
4212 $show_pdf_button = isset($options['show_pdf_upload_button']) ? $options['show_pdf_upload_button'] : 'on';
4213
4214 if ($show_pdf_button !== 'on') {
4215 wp_send_json_error(esc_html__('PDF uploads are currently disabled.', 'mxchat'));
4216 return;
4217 }
4218
4219 $file = $_FILES['pdf_file'];
4220 $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
4221 $original_filename = sanitize_text_field($file['name']);
4222
4223 // Update session owner if it changed (e.g. IP changed due to network switch)
4224 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
4225 $session_owner = get_option("mxchat_session_owner_{$session_id}");
4226
4227 if (!$session_owner || $session_owner !== $current_user_identifier) {
4228 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
4229 }
4230
4231 $file_type = wp_check_filetype($file['name'], ['pdf' => 'application/pdf']);
4232 if ($file_type['type'] !== 'application/pdf') {
4233 wp_send_json_error(esc_html__('Invalid file type. Only PDF files are allowed.', 'mxchat'));
4234 return;
4235 }
4236
4237 $upload_dir = wp_upload_dir();
4238
4239 // SECURITY FIX: Generate random filename without exposing session_id
4240 $random_string = wp_generate_password(20, false, false); // 20 char alphanumeric string
4241 $pdf_filename = 'mxchat_' . $random_string . '_' . time() . '.pdf';
4242 $pdf_path = $upload_dir['path'] . '/' . $pdf_filename;
4243
4244 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
4245 wp_send_json_error(esc_html__('Failed to upload file.', 'mxchat'));
4246 return;
4247 }
4248
4249 $this->clear_pdf_transients($session_id);
4250
4251 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
4252 $embeddings = $this->fetch_and_split_pdf_pages($pdf_path, $max_pages);
4253
4254 if ($embeddings === 'too_many_pages') {
4255 unlink($pdf_path);
4256 $error_message = sprintf(
4257 $this->options['pdf_intent_error_text'] ??
4258 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
4259 $max_pages
4260 );
4261 wp_send_json_error($error_message);
4262 return;
4263 }
4264
4265 if ($embeddings === false || empty($embeddings)) {
4266 unlink($pdf_path);
4267 $error_message = $this->options['pdf_intent_error_text'] ??
4268 esc_html__('The uploaded PDF appears to be empty or contains unsupported content.', 'mxchat');
4269 wp_send_json_error($error_message);
4270 return;
4271 }
4272
4273 if (!empty($embeddings)) {
4274 // Store the mapping between session and the random filename
4275 set_transient('mxchat_pdf_url_' . $session_id, $pdf_path, HOUR_IN_SECONDS);
4276 set_transient('mxchat_pdf_filename_' . $session_id, $original_filename, HOUR_IN_SECONDS);
4277 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
4278 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
4279
4280 $success_message = $this->options['pdf_intent_success_text'] ??
4281 esc_html__("I've processed the PDF. What questions do you have about it?", 'mxchat');
4282
4283 wp_send_json_success([
4284 'message' => $success_message,
4285 'filename' => $original_filename
4286 ]);
4287 return;
4288 }
4289
4290 unlink($pdf_path);
4291 $error_message = $this->options['pdf_intent_error_text'] ??
4292 esc_html__('Sorry, I couldn\'t process the PDF. Please ensure it\'s a valid file.', 'mxchat');
4293 wp_send_json_error($error_message);
4294 return;
4295 }
4296 public function handle_pdf_remove() {
4297 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce(wp_unslash((string) $_POST['nonce']))) {
4298 wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'mxchat')), 403);
4299 }
4300
4301 if (empty($_POST['session_id'])) {
4302 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
4303 wp_die();
4304 }
4305
4306 $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
4307 if ($session_id === '') {
4308 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
4309 wp_die();
4310 }
4311
4312 // Session-ownership bookkeeping (plan-mxchat-20260731-d42bec).
4313 //
4314 // Be clear about what this does and does not do. It mirrors the history
4315 // endpoint's rule exactly, as directed, INCLUDING its changed-IP tolerance:
4316 // possession of the session id IS the credential, so a mismatched identifier
4317 // re-owns the session instead of being refused. That means this does NOT
4318 // refuse a caller who supplies someone else's session id — it keeps the two
4319 // endpoints agreeing about who owns a session, and records the owner so a
4320 // future stricter policy has trustworthy data to enforce against.
4321 //
4322 // What actually protects another visitor's upload here is that session ids
4323 // are 128-bit CSPRNG values (plan-0c17b5) and therefore not guessable. If we
4324 // ever want a real boundary on this endpoint, it has to be decided for the
4325 // history endpoint at the same time.
4326 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
4327 $session_owner = get_option("mxchat_session_owner_{$session_id}");
4328 if (!$session_owner || $session_owner !== $current_user_identifier) {
4329 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
4330 }
4331
4332 $pdf_path = get_transient('mxchat_pdf_url_' . $session_id);
4333
4334 if ($pdf_path && file_exists($pdf_path)) {
4335 unlink($pdf_path);
4336 }
4337
4338 $this->clear_pdf_transients($session_id);
4339
4340 wp_send_json_success([
4341 'message' => esc_html__('PDF removed successfully.', 'mxchat')
4342 ]);
4343 wp_die();
4344 }
4345
4346
4347 function mxchat_fetch_new_messages() {
4348 $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
4349 $last_seen_id = sanitize_text_field($_POST['last_seen_id']);
4350 $persistence_enabled = $_POST['persistence_enabled'] === 'true';
4351 $initial_timestamp = isset($_POST['initial_timestamp']) ? intval($_POST['initial_timestamp']) : 0;
4352
4353 if (empty($session_id)) {
4354 //error_log(esc_html__('Fetch new messages error: Session ID missing.', 'mxchat'));
4355 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
4356 wp_die();
4357 }
4358
4359 $history = get_option("mxchat_history_{$session_id}", []);
4360
4361 //error_log("MxChat WhatsApp DEBUG: Fetch new messages for session {$session_id}");
4362 //error_log("MxChat WhatsApp DEBUG: last_seen_id = " . var_export($last_seen_id, true));
4363 //error_log("MxChat WhatsApp DEBUG: History count = " . count($history));
4364 //error_log("MxChat WhatsApp DEBUG: Full history = " . print_r($history, true));
4365
4366 $new_messages = array_filter($history, function ($message) use ($last_seen_id, $persistence_enabled, $initial_timestamp) {
4367 //error_log("MxChat WhatsApp DEBUG: Checking message - ID: " . ($message['id'] ?? 'NO_ID') . ", Role: " . ($message['role'] ?? 'NO_ROLE'));
4368
4369 // If persistence is enabled, show all new messages
4370 if ($persistence_enabled) {
4371 $has_id = !empty($message['id']);
4372 $is_agent = $message['role'] === 'agent';
4373
4374 // If last_seen_id is empty, 'NaN', or invalid, show all agent messages
4375 if (empty($last_seen_id) || $last_seen_id === 'NaN' || $last_seen_id === 'undefined') {
4376 $is_newer = true;
4377 } else {
4378 $is_newer = strcmp($message['id'] ?? '', $last_seen_id) > 0;
4379 }
4380
4381 //error_log("MxChat WhatsApp DEBUG: has_id={$has_id}, is_newer={$is_newer}, is_agent={$is_agent}");
4382
4383 return $has_id && $is_newer && $is_agent;
4384 }
4385
4386 // If persistence is disabled, only show messages after initial timestamp
4387 return !empty($message['id']) &&
4388 $message['role'] === 'agent' &&
4389 $message['timestamp'] > $initial_timestamp;
4390 });
4391
4392 //error_log("MxChat WhatsApp DEBUG: Filtered messages count = " . count($new_messages));
4393
4394 // Include current chat mode so frontend can detect agent→AI transitions
4395 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
4396
4397 wp_send_json_success([
4398 'new_messages' => array_values($new_messages),
4399 'chat_mode' => $chat_mode
4400 ]);
4401 wp_die();
4402 }
4403 public function mxchat_live_agent_handover($message, $user_id, $session_id) {
4404 // First check if live agents are available.
4405 // Outside the SLACK availability schedule this behaves exactly like the
4406 // manual toggle being off — same away message, same stay-in-AI-mode path
4407 // (plans 8ccaa2 + 99d7a4: each channel owns its own schedule). The schedule
4408 // normally stops the tool being offered at all; this is the backstop for
4409 // any path that calls the handover directly.
4410 $live_agent_available = $this->options['live_agent_status'] ?? 'off';
4411 $within_hours = !class_exists('MxChat_Live_Agent_Schedule')
4412 || MxChat_Live_Agent_Schedule::is_within_hours('slack');
4413 if ($live_agent_available !== 'on' || !$within_hours) {
4414 $away_message = $this->options['live_agent_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
4415 $this->fallbackResponse = [
4416 'text' => $away_message,
4417 'html' => '',
4418 'images' => [],
4419 'chat_mode' => 'ai'
4420 ];
4421 wp_send_json([
4422 'text' => $away_message,
4423 'html' => '',
4424 'chat_mode' => 'ai',
4425 'session_id' => $session_id
4426 ]);
4427 wp_die();
4428 }
4429
4430 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4431
4432 if (empty($slack_bot_token)) {
4433 return false;
4434 }
4435
4436 // Check if channel already exists for this session
4437 $channel_id = get_option("mxchat_channel_{$session_id}", '');
4438
4439 // Shared-channel mode (plan 9f7756): when a shared handoff channel is
4440 // configured and this session doesn't already own a per-conversation
4441 // channel, the handoff posts into the shared channel as a new thread
4442 // (or into the session's existing thread on a re-handover). Any failure
4443 // to reach the shared channel falls back to per-conversation creation
4444 // below, so a misconfigured channel never drops a handoff.
4445 $shared_channel_setting = trim($this->options['live_agent_shared_channel'] ?? '');
4446 $shared_thread_ts = get_option("mxchat_thread_{$session_id}", '');
4447 $use_shared_channel = ($shared_channel_setting !== '' && empty($channel_id));
4448
4449 if (empty($channel_id) && !$use_shared_channel) {
4450 $channel_id = $this->mxchat_create_conversation_channel($session_id);
4451 if (empty($channel_id)) {
4452 return false; // Failed to create channel
4453 }
4454 }
4455
4456 // Get recent chat history
4457 $history = get_option("mxchat_history_{$session_id}", []);
4458 $recent_history = array_slice($history, -5);
4459
4460 // Format conversation context
4461 $conversation_context = "";
4462 if (!empty($recent_history)) {
4463 $conversation_context = "*Recent Conversation:*\n";
4464 foreach ($recent_history as $hist_message) {
4465 $role_display = $hist_message['role'] === 'user' ? 'User' : 'AI';
4466 $conversation_context .= ">{$role_display}: {$hist_message['content']}\n";
4467 }
4468 $conversation_context .= "\n";
4469 }
4470
4471 update_option("mxchat_mode_{$session_id}", 'agent');
4472
4473 // Send message to channel
4474 $channel_message = "🔔 *New Live Agent Request*\n\n";
4475 $channel_message .= "*Session ID:* `{$session_id}`\n";
4476 $channel_message .= "*User ID:* `{$user_id}`\n";
4477
4478 // Surface the captured visitor identity so the agent knows who they're talking to —
4479 // guest User IDs are 0, but the pre-chat gate / login / transcript often has name+email (plan-e2195b).
4480 $visitor = $this->mxchat_get_visitor_identity($session_id);
4481 if (!empty($visitor['name']) && !empty($visitor['email'])) {
4482 $channel_message .= "*Visitor:* {$visitor['name']} <{$visitor['email']}>\n";
4483 } elseif (!empty($visitor['email'])) {
4484 $channel_message .= "*Visitor:* <{$visitor['email']}>\n";
4485 } elseif (!empty($visitor['name'])) {
4486 $channel_message .= "*Visitor:* {$visitor['name']}\n";
4487 }
4488 $channel_message .= "\n";
4489
4490 if (!empty($conversation_context)) {
4491 $channel_message .= $conversation_context;
4492 }
4493
4494 $channel_message .= "*Current Message:*\n{$message}\n\n";
4495 if ($use_shared_channel) {
4496 $channel_message .= "_Reply in this thread - replies here go to the user. `!endchat` in this thread ends the chat._";
4497 } else {
4498 $channel_message .= "_Reply directly in this channel - all messages will go to the user_";
4499 }
4500
4501 if ($use_shared_channel) {
4502 $posted = $this->mxchat_post_shared_handoff($session_id, $channel_message, $shared_thread_ts);
4503 if (!$posted) {
4504 // Shared channel unreachable (wrong name/ID, bot not invited,
4505 // archived...). Fall back to the per-conversation flow so the
4506 // visitor still reaches an agent; the settings page surfaces the
4507 // recorded error to the admin.
4508 $use_shared_channel = false;
4509 $channel_id = $this->mxchat_create_conversation_channel($session_id);
4510 if (empty($channel_id)) {
4511 return false;
4512 }
4513 $channel_message = str_replace(
4514 "_Reply in this thread - replies here go to the user. `!endchat` in this thread ends the chat._",
4515 "_Reply directly in this channel - all messages will go to the user_",
4516 $channel_message
4517 );
4518 }
4519 }
4520
4521 if (!$use_shared_channel) {
4522 $handoff_post = wp_remote_post('https://slack.com/api/chat.postMessage', [
4523 'headers' => [
4524 'Content-Type' => 'application/json',
4525 'Authorization' => 'Bearer ' . $slack_bot_token
4526 ],
4527 'body' => json_encode([
4528 'channel' => $channel_id,
4529 'text' => $channel_message,
4530 'mrkdwn' => true
4531 ])
4532 ]);
4533 // Re-handover edge (plan 7458a7): the stored mxchat_channel_ may point
4534 // at a channel archived by the auto-archive toggle (or deleted by an
4535 // admin). Slack answers is_archived / channel_not_found — clear the
4536 // stale option, mint a fresh channel, and re-post ONCE so the handoff
4537 // is never silently dropped.
4538 if (!is_wp_error($handoff_post)) {
4539 $handoff_data = json_decode(wp_remote_retrieve_body($handoff_post), true);
4540 $handoff_err = isset($handoff_data['error']) ? $handoff_data['error'] : '';
4541 if (isset($handoff_data['ok']) && !$handoff_data['ok'] && in_array($handoff_err, array('is_archived', 'channel_not_found'), true)) {
4542 delete_option("mxchat_channel_{$session_id}");
4543 $channel_id = $this->mxchat_create_conversation_channel($session_id);
4544 if (!empty($channel_id)) {
4545 wp_remote_post('https://slack.com/api/chat.postMessage', [
4546 'headers' => [
4547 'Content-Type' => 'application/json',
4548 'Authorization' => 'Bearer ' . $slack_bot_token
4549 ],
4550 'body' => json_encode([
4551 'channel' => $channel_id,
4552 'text' => $channel_message,
4553 'mrkdwn' => true
4554 ])
4555 ]);
4556 }
4557 }
4558 }
4559 }
4560
4561 $success_message = $this->options['live_agent_notification_message'] ?? 'Live agent has been notified.';
4562 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
4563
4564 $this->fallbackResponse = [
4565 'text' => $success_message,
4566 'html' => '',
4567 'images' => [],
4568 'chat_mode' => 'agent'
4569 ];
4570
4571 wp_send_json([
4572 'success' => true,
4573 'text' => $success_message,
4574 'html' => '',
4575 'chat_mode' => 'agent',
4576 'session_id' => $session_id,
4577 'fallbackResponse' => $this->fallbackResponse
4578 ]);
4579 wp_die();
4580 }
4581
4582 /**
4583 * Archive a session's per-conversation chat- channel after !endchat / session
4584 * cleanup (plan 7458a7). HARD GUARDS, in order: the toggle must be on
4585 * (default off = zero change for existing installs); a session with
4586 * mxchat_thread_ set is a 9f7756 SHARED-channel session and is never
4587 * archived; only the channel this session owns via mxchat_channel_ is
4588 * archived, and only when it matches the channel the caller is acting on.
4589 * Best-effort by design — a failed archive is logged and never blocks the
4590 * mode flip or cleanup.
4591 *
4592 * @param string $session_id
4593 * @param string $event_channel_id Channel the caller is acting on.
4594 */
4595 private function mxchat_maybe_archive_conversation_channel($session_id, $event_channel_id) {
4596 $toggle = $this->options['live_agent_archive_on_end_toggle'] ?? 'off';
4597 if ($toggle !== 'on') {
4598 return;
4599 }
4600 if (get_option("mxchat_thread_{$session_id}", '') !== '') {
4601 return; // shared-channel session — the shared channel is NEVER archived
4602 }
4603 $owned_channel = get_option("mxchat_channel_{$session_id}", '');
4604 if ($owned_channel === '' || $owned_channel !== $event_channel_id) {
4605 return;
4606 }
4607 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4608 if (empty($slack_bot_token)) {
4609 return;
4610 }
4611 $response = wp_remote_post('https://slack.com/api/conversations.archive', [
4612 'headers' => [
4613 'Content-Type' => 'application/json',
4614 'Authorization' => 'Bearer ' . $slack_bot_token
4615 ],
4616 'body' => json_encode(['channel' => $owned_channel])
4617 ]);
4618 if (is_wp_error($response)) {
4619 error_log('MxChat: conversations.archive request failed: ' . $response->get_error_message());
4620 return;
4621 }
4622 $data = json_decode(wp_remote_retrieve_body($response), true);
4623 if (empty($data['ok'])) {
4624 error_log('MxChat: conversations.archive returned error: ' . (isset($data['error']) ? $data['error'] : 'unknown'));
4625 }
4626 }
4627
4628 /**
4629 * Create a dedicated per-conversation Slack channel for a session and invite
4630 * the configured agents. Extracted from mxchat_live_agent_handover so the
4631 * shared-channel mode (plan 9f7756) can reuse it as its fallback path.
4632 *
4633 * @param string $session_id
4634 * @return string Channel ID, or '' on failure.
4635 */
4636 private function mxchat_create_conversation_channel($session_id) {
4637 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4638 if (empty($slack_bot_token)) {
4639 return '';
4640 }
4641
4642 $channel_id = '';
4643 $channel_name = $this->generate_channel_name($session_id);
4644
4645 $response = wp_remote_post('https://slack.com/api/conversations.create', [
4646 'headers' => [
4647 'Content-Type' => 'application/json',
4648 'Authorization' => 'Bearer ' . $slack_bot_token
4649 ],
4650 'body' => json_encode([
4651 'name' => $channel_name,
4652 'is_private' => false // Public channel - anyone in workspace can join
4653 ])
4654 ]);
4655
4656 if (!is_wp_error($response)) {
4657 $response_data = json_decode(wp_remote_retrieve_body($response), true);
4658
4659 if (isset($response_data['ok']) && $response_data['ok']) {
4660 $channel_id = $response_data['channel']['id'];
4661 update_option("mxchat_channel_{$session_id}", $channel_id);
4662
4663 // Auto-invite agents to the channel
4664 $agent_user_ids = $this->options['live_agent_user_ids'] ?? '';
4665
4666 if (!empty($agent_user_ids)) {
4667 // Parse user IDs (one per line)
4668 $user_ids = array_filter(array_map('trim', explode("\n", $agent_user_ids)));
4669
4670 foreach ($user_ids as $user_id_to_invite) {
4671 wp_remote_post('https://slack.com/api/conversations.invite', [
4672 'headers' => [
4673 'Content-Type' => 'application/json',
4674 'Authorization' => 'Bearer ' . $slack_bot_token
4675 ],
4676 'body' => json_encode([
4677 'channel' => $channel_id,
4678 'users' => $user_id_to_invite
4679 ])
4680 ]);
4681 }
4682 }
4683 }
4684 }
4685
4686 return $channel_id;
4687 }
4688
4689 /**
4690 * Post a handoff (or a re-handover) into the configured shared channel.
4691 * First post per session becomes the conversation's thread root; its ts is
4692 * stored in mxchat_thread_{session} and every later message rides that
4693 * thread. Records the Slack error for the settings page on failure so the
4694 * caller can fall back to per-conversation creation.
4695 *
4696 * @param string $session_id
4697 * @param string $text Fully-built handoff message.
4698 * @param string $thread_ts Existing thread root for this session, '' if none.
4699 * @return bool True when the message reached the shared channel.
4700 */
4701 private function mxchat_post_shared_handoff($session_id, $text, $thread_ts = '') {
4702 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4703 $configured = trim($this->options['live_agent_shared_channel'] ?? '');
4704 if (empty($slack_bot_token) || $configured === '') {
4705 return false;
4706 }
4707
4708 // Posting by #name works once the bot is a member; the response carries
4709 // the real channel ID, cached so the inbound webhook and user-relay
4710 // don't depend on how the admin wrote the setting.
4711 $cache = get_option('mxchat_slack_shared_channel_id', array());
4712 $target = (is_array($cache) && ($cache['configured'] ?? '') === $configured && !empty($cache['id']))
4713 ? $cache['id']
4714 : ltrim($configured, '#');
4715
4716 $body = [
4717 'channel' => $target,
4718 'text' => $text,
4719 'mrkdwn' => true
4720 ];
4721 if ($thread_ts !== '') {
4722 $body['thread_ts'] = $thread_ts;
4723 }
4724
4725 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
4726 'headers' => [
4727 'Content-Type' => 'application/json',
4728 'Authorization' => 'Bearer ' . $slack_bot_token
4729 ],
4730 'body' => json_encode($body)
4731 ]);
4732
4733 if (is_wp_error($response)) {
4734 update_option('mxchat_slack_shared_channel_error', array(
4735 'error' => $response->get_error_message(),
4736 'configured' => $configured,
4737 'time' => time(),
4738 ), false);
4739 return false;
4740 }
4741
4742 $data = json_decode(wp_remote_retrieve_body($response), true);
4743 if (empty($data['ok'])) {
4744 update_option('mxchat_slack_shared_channel_error', array(
4745 'error' => $data['error'] ?? 'unknown_error',
4746 'configured' => $configured,
4747 'time' => time(),
4748 ), false);
4749 return false;
4750 }
4751
4752 delete_option('mxchat_slack_shared_channel_error');
4753
4754 if (!empty($data['channel'])) {
4755 update_option('mxchat_slack_shared_channel_id', array(
4756 'configured' => $configured,
4757 'id' => $data['channel'],
4758 ), false);
4759 }
4760 if ($thread_ts === '' && !empty($data['ts'])) {
4761 update_option("mxchat_thread_{$session_id}", $data['ts'], 'no');
4762 }
4763
4764 return true;
4765 }
4766
4767 private function generate_channel_name($session_id) {
4768 $email = null;
4769 $name = null;
4770
4771 // 1. First priority: Check if user is logged in and get their info
4772 if (is_user_logged_in()) {
4773 $current_user = wp_get_current_user();
4774 if (!empty($current_user->user_email)) {
4775 $email = $current_user->user_email;
4776 //error_log("[DEBUG] Using logged-in user email for channel: {$email}");
4777 }
4778 if (!empty($current_user->display_name)) {
4779 $name = $current_user->display_name;
4780 //error_log("[DEBUG] Using logged-in user name for channel: {$name}");
4781 }
4782 }
4783
4784 // 2. Second priority: Check for saved email/name from "require email to chat" option
4785 if (empty($email)) {
4786 $email_option_key = "mxchat_email_{$session_id}";
4787 $saved_email = get_option($email_option_key);
4788 if (!empty($saved_email)) {
4789 $email = $saved_email;
4790 //error_log("[DEBUG] Using saved email from session for channel: {$email}");
4791 }
4792 }
4793
4794 if (empty($name)) {
4795 $name_option_key = "mxchat_name_{$session_id}";
4796 $saved_name = get_option($name_option_key);
4797 if (!empty($saved_name)) {
4798 $name = $saved_name;
4799 //error_log("[DEBUG] Using saved name from session for channel: {$name}");
4800 }
4801 }
4802
4803 // 3. Third priority: Check existing chat transcript for email/name
4804 if (empty($email) || empty($name)) {
4805 global $wpdb;
4806 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
4807 $existing_data = $wpdb->get_row($wpdb->prepare(
4808 "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",
4809 $session_id
4810 ));
4811
4812 if ($existing_data) {
4813 if (empty($email) && !empty($existing_data->user_email)) {
4814 $email = $existing_data->user_email;
4815 //error_log("[DEBUG] Using email from chat transcript for channel: {$email}");
4816 }
4817 if (empty($name) && !empty($existing_data->user_name)) {
4818 $name = $existing_data->user_name;
4819 //error_log("[DEBUG] Using name from chat transcript for channel: {$name}");
4820 }
4821 }
4822 }
4823
4824 // 4. Generate channel name based on priority: Name > Email > Session ID
4825 $channel_name = '';
4826
4827 if (!empty($name)) {
4828 // Convert name to valid Slack channel name
4829 $base_name = strtolower(trim($name));
4830 // Replace spaces and invalid characters
4831 $base_name = preg_replace('/[^a-z0-9\s]/', '', $base_name);
4832 $base_name = preg_replace('/\s+/', '-', $base_name);
4833 $base_name = trim($base_name, '-');
4834
4835 // Get last 4 characters of session ID for uniqueness
4836 $session_suffix = substr($session_id, -4);
4837 $channel_name = 'chat-' . $base_name . '-' . strtolower($session_suffix);
4838
4839 // Slack channel names have a 21 character limit
4840 if (strlen($channel_name) > 21) {
4841 // Calculate available space for name (21 - 'chat-' - '-' - session_suffix)
4842 $available_space = 21 - 5 - 1 - strlen($session_suffix); // 'chat-' = 5, '-' = 1
4843 $truncated_name = substr($base_name, 0, $available_space);
4844 $truncated_name = rtrim($truncated_name, '-'); // Remove trailing hyphen
4845 $channel_name = 'chat-' . $truncated_name . '-' . strtolower($session_suffix);
4846 }
4847
4848 //error_log("[DEBUG] Using name for channel: {$channel_name} (from name: {$name})");
4849
4850 } elseif (!empty($email)) {
4851 // Convert email to valid Slack channel name (your existing logic)
4852 $channel_name = 'chat-' . strtolower(str_replace(['@', '.', '+', '_'], ['-at-', '-', '-plus-', '-'], $email));
4853 // Remove any remaining invalid characters
4854 $channel_name = preg_replace('/[^a-z0-9\-]/', '', $channel_name);
4855 // Ensure it doesn't end with a hyphen
4856 $channel_name = rtrim($channel_name, '-');
4857 // Slack channel names have a 21 character limit, so truncate if needed
4858 if (strlen($channel_name) > 21) {
4859 $channel_name = substr($channel_name, 0, 21);
4860 $channel_name = rtrim($channel_name, '-'); // Remove trailing hyphen if truncation created one
4861 }
4862
4863 //error_log("[DEBUG] Using email for channel: {$channel_name} (from email: {$email})");
4864
4865 } else {
4866 // Fallback to session ID if no name or email found
4867 $channel_name = 'chat-' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $session_id));
4868 //error_log("[DEBUG] No name or email found, using session ID for channel: {$channel_name}");
4869 }
4870
4871 // Final validation - ensure channel name meets Slack requirements
4872 if (strlen($channel_name) > 21) {
4873 $channel_name = substr($channel_name, 0, 21);
4874 $channel_name = rtrim($channel_name, '-');
4875 }
4876
4877 //error_log("[DEBUG] Generated channel name: {$channel_name}");
4878 return $channel_name;
4879 }
4880
4881 /**
4882 * Telegram Live Agent Handover
4883 * Creates a forum topic in the Telegram group and notifies agents
4884 */
4885 public function mxchat_telegram_live_agent_handover($message, $user_id, $session_id) {
4886 // Check if Telegram agents are available. Telegram has its OWN availability
4887 // schedule, independent of Slack's (plan 99d7a4 — each Integrations tab
4888 // owns its scheduler). Backstop only; the tool is normally withheld
4889 // off-hours.
4890 $telegram_available = $this->options['telegram_status'] ?? 'off';
4891 $within_hours = !class_exists('MxChat_Live_Agent_Schedule')
4892 || MxChat_Live_Agent_Schedule::is_within_hours('telegram');
4893 if ($telegram_available !== 'on' || !$within_hours) {
4894 $away_message = $this->options['telegram_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
4895 $this->fallbackResponse = [
4896 'text' => $away_message,
4897 'html' => '',
4898 'images' => [],
4899 'chat_mode' => 'ai'
4900 ];
4901 wp_send_json([
4902 'text' => $away_message,
4903 'html' => '',
4904 'chat_mode' => 'ai',
4905 'session_id' => $session_id
4906 ]);
4907 wp_die();
4908 }
4909
4910 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4911 $telegram_group_id = $this->options['telegram_group_id'] ?? '';
4912
4913 if (empty($telegram_bot_token) || empty($telegram_group_id)) {
4914 return false;
4915 }
4916
4917 // Check if topic already exists for this session
4918 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
4919
4920 if (empty($topic_id)) {
4921 // Generate topic name
4922 $topic_name = $this->generate_telegram_topic_name($session_id);
4923
4924 // Random icon color (Telegram forum topic colors)
4925 $icon_colors = [0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F];
4926 $icon_color = $icon_colors[array_rand($icon_colors)];
4927
4928 // Create forum topic
4929 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/createForumTopic", [
4930 'headers' => ['Content-Type' => 'application/json'],
4931 'body' => json_encode([
4932 'chat_id' => $telegram_group_id,
4933 'name' => $topic_name,
4934 'icon_color' => $icon_color
4935 ])
4936 ]);
4937
4938 if (!is_wp_error($response)) {
4939 $response_body = wp_remote_retrieve_body($response);
4940 $response_data = json_decode($response_body, true);
4941
4942 if (isset($response_data['ok']) && $response_data['ok']) {
4943 $topic_id = $response_data['result']['message_thread_id'];
4944 update_option("mxchat_telegram_topic_{$session_id}", $topic_id);
4945 update_option("mxchat_telegram_group_{$session_id}", $telegram_group_id);
4946 }
4947 }
4948
4949 if (empty($topic_id)) {
4950 return false; // Failed to create topic
4951 }
4952 }
4953
4954 // Get recent chat history
4955 $history = get_option("mxchat_history_{$session_id}", []);
4956 $recent_history = array_slice($history, -5);
4957
4958 // Format conversation context for Telegram (HTML format)
4959 $conversation_context = "";
4960 if (!empty($recent_history)) {
4961 $conversation_context = "<b>Recent Conversation:</b>\n";
4962 foreach ($recent_history as $hist_message) {
4963 $role_display = $hist_message['role'] === 'user' ? '👤 User' : '🤖 AI';
4964 $escaped_content = htmlspecialchars($hist_message['content'], ENT_QUOTES, 'UTF-8');
4965 $conversation_context .= "{$role_display}: {$escaped_content}\n";
4966 }
4967 $conversation_context .= "\n";
4968 }
4969
4970 // Get user info
4971 $user_email = get_option("mxchat_email_{$session_id}", 'Not provided');
4972 $user_name = get_option("mxchat_name_{$session_id}", 'Anonymous');
4973
4974 // Update session mode
4975 update_option("mxchat_mode_{$session_id}", 'agent');
4976
4977 // Send initial message to topic
4978 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
4979 $topic_message = "🔔 <b>New Live Agent Request</b>\n\n";
4980 $topic_message .= "<b>Session ID:</b> <code>{$session_id}</code>\n";
4981 $topic_message .= "<b>User:</b> {$user_name}\n";
4982 $topic_message .= "<b>Email:</b> {$user_email}\n\n";
4983
4984 if (!empty($conversation_context)) {
4985 $topic_message .= $conversation_context;
4986 }
4987
4988 $topic_message .= "<b>Current Message:</b>\n{$escaped_message}\n\n";
4989 $topic_message .= "<i>Reply in this topic - messages will be sent to the user</i>\n";
4990 $topic_message .= "<i>Type #close, #end, #disconnect, or #done to end the session</i>";
4991
4992 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4993 'headers' => ['Content-Type' => 'application/json'],
4994 'body' => json_encode([
4995 'chat_id' => $telegram_group_id,
4996 'message_thread_id' => $topic_id,
4997 'text' => $topic_message,
4998 'parse_mode' => 'HTML'
4999 ])
5000 ]);
5001
5002 $success_message = $this->options['telegram_notification_message'] ?? "I've notified a support agent. Please allow a moment for them to respond.";
5003 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
5004
5005 $this->fallbackResponse = [
5006 'text' => $success_message,
5007 'html' => '',
5008 'images' => [],
5009 'chat_mode' => 'agent'
5010 ];
5011
5012 wp_send_json([
5013 'success' => true,
5014 'text' => $success_message,
5015 'html' => '',
5016 'chat_mode' => 'agent',
5017 'session_id' => $session_id,
5018 'fallbackResponse' => $this->fallbackResponse
5019 ]);
5020 wp_die();
5021 }
5022
5023 /**
5024 * Generate topic name for Telegram forum
5025 */
5026 private function generate_telegram_topic_name($session_id) {
5027 $name = null;
5028 $email = null;
5029
5030 // Check logged in user
5031 if (is_user_logged_in()) {
5032 $current_user = wp_get_current_user();
5033 if (!empty($current_user->display_name)) {
5034 $name = $current_user->display_name;
5035 }
5036 if (!empty($current_user->user_email)) {
5037 $email = $current_user->user_email;
5038 }
5039 }
5040
5041 // Check session data
5042 if (empty($name)) {
5043 $name = get_option("mxchat_name_{$session_id}");
5044 }
5045 if (empty($email)) {
5046 $email = get_option("mxchat_email_{$session_id}");
5047 }
5048
5049 // Generate topic name
5050 $session_suffix = substr($session_id, -6);
5051
5052 if (!empty($name)) {
5053 // Clean name for topic (max 128 chars in Telegram)
5054 $clean_name = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $name);
5055 $clean_name = trim($clean_name);
5056 if (strlen($clean_name) > 50) {
5057 $clean_name = substr($clean_name, 0, 50);
5058 }
5059 return "Chat - {$clean_name} ({$session_suffix})";
5060 } elseif (!empty($email)) {
5061 // Use email prefix
5062 $email_prefix = explode('@', $email)[0];
5063 if (strlen($email_prefix) > 30) {
5064 $email_prefix = substr($email_prefix, 0, 30);
5065 }
5066 return "Chat - {$email_prefix} ({$session_suffix})";
5067 }
5068
5069 return "Chat - {$session_suffix}";
5070 }
5071
5072 /**
5073 * Send user message to Telegram agent
5074 */
5075 public function mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id) {
5076 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
5077 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
5078 $group_id = get_option("mxchat_telegram_group_{$session_id}", '');
5079
5080 if (empty($telegram_bot_token) || empty($topic_id) || empty($group_id)) {
5081 return false;
5082 }
5083
5084 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
5085 $user_message = "👤 <b>User:</b> {$escaped_message}";
5086
5087 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
5088 'headers' => ['Content-Type' => 'application/json'],
5089 'body' => json_encode([
5090 'chat_id' => $group_id,
5091 'message_thread_id' => $topic_id,
5092 'text' => $user_message,
5093 'parse_mode' => 'HTML'
5094 ])
5095 ]);
5096
5097 return !is_wp_error($response);
5098 }
5099
5100 /**
5101 * Handle incoming Telegram webhook
5102 */
5103 public function handle_telegram_webhook(WP_REST_Request $request) {
5104 $body = $request->get_body();
5105 $data = json_decode($body, true);
5106
5107 //error_log('[MxChat Telegram DEBUG] Webhook received: ' . $body);
5108
5109 // Handle message events from forum topics
5110 if (isset($data['message'])) {
5111 $message_data = $data['message'];
5112
5113 // Skip if not from a forum topic
5114 if (!isset($message_data['message_thread_id'])) {
5115 //error_log('[MxChat Telegram DEBUG] Skipped: No message_thread_id (not a forum topic message)');
5116 return new WP_REST_Response(['ok' => true]);
5117 }
5118
5119 // Skip bot messages
5120 if (isset($message_data['from']['is_bot']) && $message_data['from']['is_bot']) {
5121 //error_log('[MxChat Telegram DEBUG] Skipped: Message from bot');
5122 return new WP_REST_Response(['ok' => true]);
5123 }
5124
5125 $chat_id = $message_data['chat']['id'] ?? '';
5126 $topic_id = $message_data['message_thread_id'];
5127 $message_text = $message_data['text'] ?? '';
5128 $message_id = $message_data['message_id'] ?? '';
5129 $from = $message_data['from'] ?? [];
5130 $agent_name = trim(($from['first_name'] ?? '') . ' ' . ($from['last_name'] ?? ''));
5131 if (empty($agent_name)) {
5132 $agent_name = $from['username'] ?? 'Agent';
5133 }
5134
5135 //error_log("[MxChat Telegram DEBUG] Parsed: chat_id={$chat_id}, topic_id={$topic_id}, agent={$agent_name}, text={$message_text}");
5136
5137 // Skip empty messages
5138 if (empty($message_text)) {
5139 //error_log('[MxChat Telegram DEBUG] Skipped: Empty message text');
5140 return new WP_REST_Response(['ok' => true]);
5141 }
5142
5143 // Find session ID by topic ID - cast to string for comparison
5144 global $wpdb;
5145 $topic_id_str = strval($topic_id);
5146 $session_option = $wpdb->get_var(
5147 $wpdb->prepare(
5148 "SELECT option_name FROM {$wpdb->options}
5149 WHERE option_name LIKE %s
5150 AND option_value = %s",
5151 'mxchat_telegram_topic_%',
5152 $topic_id_str
5153 )
5154 );
5155
5156 //error_log("[MxChat Telegram DEBUG] Looking for topic_id={$topic_id_str} in options, found: " . ($session_option ?: 'NULL'));
5157
5158 if ($session_option) {
5159 $session_id = str_replace('mxchat_telegram_topic_', '', $session_option);
5160 //error_log("[MxChat Telegram DEBUG] Session ID: {$session_id}");
5161
5162 // Verify the group ID matches
5163 $stored_group_id = get_option("mxchat_telegram_group_{$session_id}", '');
5164 //error_log("[MxChat Telegram DEBUG] Stored group_id={$stored_group_id}, received chat_id={$chat_id}");
5165
5166 if (strval($stored_group_id) != strval($chat_id)) {
5167 //error_log('[MxChat Telegram DEBUG] Skipped: Group ID mismatch');
5168 return new WP_REST_Response(['ok' => true]);
5169 }
5170
5171 // Check for closure commands
5172 $lower_text = strtolower(trim($message_text));
5173 if (in_array($lower_text, ['#close', '#end', '#disconnect', '#done'])) {
5174 //error_log("[MxChat Telegram DEBUG] Closure command received: {$lower_text}");
5175 // End the live agent session
5176 update_option("mxchat_mode_{$session_id}", 'ai');
5177
5178 // Save disconnect message
5179 $disconnect_message = "Live agent session ended. You're now chatting with the AI assistant.";
5180 $this->mxchat_save_chat_message($session_id, 'bot', $disconnect_message);
5181
5182 // Notify in Telegram
5183 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
5184 if (!empty($telegram_bot_token)) {
5185 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
5186 'headers' => ['Content-Type' => 'application/json'],
5187 'body' => json_encode([
5188 'chat_id' => $chat_id,
5189 'message_thread_id' => $topic_id,
5190 'text' => "✅ Session closed. User returned to AI chatbot.",
5191 'parse_mode' => 'HTML'
5192 ])
5193 ]);
5194
5195 // Optionally close the topic
5196 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/closeForumTopic", [
5197 'headers' => ['Content-Type' => 'application/json'],
5198 'body' => json_encode([
5199 'chat_id' => $chat_id,
5200 'message_thread_id' => $topic_id
5201 ])
5202 ]);
5203 }
5204
5205 return new WP_REST_Response(['ok' => true]);
5206 }
5207
5208 // Deduplicate messages
5209 $message_key = md5($session_id . $message_id . $message_text);
5210 $processed_messages = get_transient('mxchat_telegram_messages_' . $session_id) ?: [];
5211
5212 if (in_array($message_key, $processed_messages)) {
5213 //error_log('[MxChat Telegram DEBUG] Skipped: Duplicate message');
5214 return new WP_REST_Response(['ok' => true]);
5215 }
5216
5217 $processed_messages[] = $message_key;
5218 if (count($processed_messages) > 50) {
5219 $processed_messages = array_slice($processed_messages, -50);
5220 }
5221 set_transient('mxchat_telegram_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
5222
5223 // Save the agent message - format with agent name prefix for proper parsing
5224 $formatted_message = "Agent: {$agent_name} - {$message_text}";
5225 //error_log("[MxChat Telegram DEBUG] Saving agent message: {$formatted_message}");
5226
5227 $this->mxchat_save_chat_message($session_id, 'agent', $formatted_message);
5228
5229 // Verify the message was saved to history
5230 $history = get_option("mxchat_history_{$session_id}", []);
5231 $last_message = end($history);
5232 //error_log("[MxChat Telegram DEBUG] History after save - count: " . count($history) . ", last message role: " . ($last_message['role'] ?? 'none'));
5233
5234 // Send confirmation back to Telegram
5235 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
5236 if (!empty($telegram_bot_token)) {
5237 $confirm_key = 'mxchat_telegram_confirm_' . $message_key;
5238 if (!get_transient($confirm_key)) {
5239 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
5240 'headers' => ['Content-Type' => 'application/json'],
5241 'body' => json_encode([
5242 'chat_id' => $chat_id,
5243 'message_thread_id' => $topic_id,
5244 'text' => "✅ <i>Message sent to user</i>",
5245 'parse_mode' => 'HTML',
5246 'reply_to_message_id' => $message_id
5247 ])
5248 ]);
5249 set_transient($confirm_key, true, 300);
5250 }
5251 }
5252 } else {
5253 //error_log("[MxChat Telegram DEBUG] No session found for topic_id={$topic_id}");
5254 }
5255 } else {
5256 //error_log('[MxChat Telegram DEBUG] No message in webhook data');
5257 }
5258
5259 return new WP_REST_Response(['ok' => true]);
5260 }
5261
5262 public function mxchat_send_user_message_to_agent($message, $user_id, $session_id) {
5263 // Check if this is a Telegram agent session
5264 $telegram_topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
5265 if (!empty($telegram_topic_id)) {
5266 return $this->mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id);
5267 }
5268
5269 // Otherwise, try Slack
5270 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
5271
5272 // Shared-channel session: the conversation lives in a thread of the
5273 // shared channel (plan 9f7756); relay user messages into that thread.
5274 $thread_ts = get_option("mxchat_thread_{$session_id}", '');
5275 if (!empty($thread_ts)) {
5276 $cache = get_option('mxchat_slack_shared_channel_id', array());
5277 $channel_id = is_array($cache) ? ($cache['id'] ?? '') : '';
5278 } else {
5279 $channel_id = get_option("mxchat_channel_{$session_id}", '');
5280 }
5281
5282 if (empty($slack_bot_token) || empty($channel_id)) {
5283 return false;
5284 }
5285
5286 $user_message = "💬 *User:* {$message}";
5287
5288 $body = [
5289 'channel' => $channel_id,
5290 'text' => $user_message,
5291 'mrkdwn' => true
5292 ];
5293 if (!empty($thread_ts)) {
5294 $body['thread_ts'] = $thread_ts;
5295 }
5296
5297 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
5298 'headers' => [
5299 'Content-Type' => 'application/json',
5300 'Authorization' => 'Bearer ' . $slack_bot_token
5301 ],
5302 'body' => json_encode($body)
5303 ]);
5304
5305 return !is_wp_error($response);
5306 }
5307 public function handle_slack_interaction(WP_REST_Request $request) {
5308 //error_log('Received Slack interaction');
5309
5310 $payload = json_decode($request->get_param('payload'), true);
5311 //error_log('Payload: ' . print_r($payload, true));
5312
5313 // Handle button click
5314 if ($payload['type'] === 'block_actions' && $payload['actions'][0]['action_id'] === 'reply_to_user') {
5315 $session_id = $payload['actions'][0]['value'];
5316 $trigger_id = $payload['trigger_id'];
5317
5318 // Get Bot Token from settings
5319 $slack_token = $this->options['live_agent_bot_token'] ?? '';
5320
5321 if (empty($slack_token)) {
5322 //error_log('Slack Bot Token not configured');
5323 return new WP_REST_Response(['error' => esc_html__('Bot token not configured', 'mxchat')], 400);
5324 }
5325 $response = wp_remote_post('https://slack.com/api/views.open', [
5326 'headers' => [
5327 'Content-Type' => 'application/json',
5328 'Authorization' => 'Bearer ' . $slack_token
5329 ],
5330 'body' => json_encode([
5331 'trigger_id' => $trigger_id,
5332 'view' => [
5333 'type' => 'modal',
5334 'callback_id' => 'reply_modal',
5335 'title' => [
5336 'type' => 'plain_text',
5337 'text' => __('Reply to User', 'mxchat')
5338 ],
5339 'submit' => [
5340 'type' => 'plain_text',
5341 'text' => __('Send', 'mxchat')
5342 ],
5343 'close' => [
5344 'type' => 'plain_text',
5345 'text' => __('Cancel', 'mxchat')
5346 ],
5347 'blocks' => [
5348 [
5349 'type' => 'input',
5350 'block_id' => 'reply_block',
5351 'label' => [
5352 'type' => 'plain_text',
5353 'text' => sprintf(__('Reply to session: %s', 'mxchat'), $session_id)
5354 ],
5355 'element' => [
5356 'type' => 'plain_text_input',
5357 'action_id' => 'message',
5358 'multiline' => true,
5359 'placeholder' => [
5360 'type' => 'plain_text',
5361 'text' => __('Type your message here...', 'mxchat')
5362 ]
5363 ]
5364 ]
5365 ],
5366 'private_metadata' => $session_id
5367 ]
5368 ])
5369 ]);
5370
5371 //error_log('Views.open response: ' . print_r($response, true));
5372
5373 // Return immediate acknowledgment
5374 return new WP_REST_Response(['ok' => true]);
5375 }
5376
5377 // Handle modal submission
5378 // Handle modal submission
5379 if ($payload['type'] === 'view_submission') {
5380 $session_id = $payload['view']['private_metadata'];
5381 $message = $payload['view']['state']['values']['reply_block']['message']['value'];
5382
5383 // Save the message (keep the message_id but don't include in response)
5384 $this->mxchat_save_chat_message($session_id, 'agent', $message);
5385
5386 // Keep the original response format for Slack
5387 return new WP_REST_Response([
5388 'response_action' => 'clear'
5389 ]);
5390 }
5391
5392 // Default acknowledgment
5393 return new WP_REST_Response(['ok' => true]);
5394 }
5395 public function mxchat_handle_agent_response(WP_REST_Request $request) {
5396 //error_log('Received agent response request');
5397 //error_log('Request data: ' . print_r($request->get_params(), true));
5398 // //error_log('Raw body: ' . file_get_contents('php://input'));
5399
5400 // Get the data from Slack's slash command format
5401 $command_text = $request->get_param('text');
5402 // //error_log('Command text: ' . $command_text);
5403
5404 if (empty($command_text)) {
5405 //error_log(esc_html__('Agent response error: No command text received', 'mxchat'));
5406 return new WP_REST_Response([
5407 'error' => esc_html__('Command text is required. Format: /reply session_id message', 'mxchat')
5408 ], 400);
5409 }
5410
5411 // Split the command text into session_id and message
5412 $parts = explode(' ', $command_text, 2);
5413 if (count($parts) !== 2) {
5414 //error_log('Agent response error: Invalid command format');
5415 return new WP_REST_Response([
5416 'error' => esc_html__('Invalid format. Use: /reply session_id message', 'mxchat')
5417 ], 400);
5418 }
5419
5420 $session_id = sanitize_text_field($parts[0]);
5421 $message = sanitize_text_field($parts[1]);
5422
5423 //error_log("Processing agent response - Session ID: $session_id, Message: $message");
5424
5425 // Save the message
5426 $message_id = $this->mxchat_save_chat_message($session_id, 'agent', $message);
5427
5428 if (!$message_id) {
5429 // //error_log('Failed to save agent message');
5430 return new WP_REST_Response([
5431 'error' => esc_html__('Failed to save message', 'mxchat')
5432 ], 500);
5433 }
5434
5435 // Return success response in Slack's expected format
5436 return new WP_REST_Response([
5437 'response_type' => 'in_channel',
5438 'text' => esc_html__("Message sent successfully to session $session_id", 'mxchat')
5439 ], 200);
5440 }
5441 public function mxchat_handle_switch_to_chatbot_intent($message, $user_id, $session_id) {
5442 // Update mode to AI
5443 update_option("mxchat_mode_{$session_id}", 'ai');
5444
5445 // Clear any existing PDF context to start fresh
5446 $this->clear_pdf_transients($session_id);
5447
5448 // Set the response with explicit chat_mode
5449 $this->fallbackResponse = [
5450 'text' => esc_html__('You are now chatting with the AI chatbot.', 'mxchat'),
5451 'html' => '',
5452 'images' => [],
5453 'chat_mode' => 'ai' // Ensure this is set
5454 ];
5455
5456 // Return the complete response array instead of just true
5457 return $this->fallbackResponse;
5458 }
5459
5460 /**
5461 * Normalize Slack mrkdwn before relaying an agent's message to the web visitor.
5462 * Slack's Events API auto-wraps URLs as <https://url> or <https://url|Label>, wraps
5463 * mentions as <@U…>/<#C…|name>, and HTML-escapes &, <, >. Relayed raw, the visitor
5464 * sees a broken/doubled link with a trailing > (plan-e2195b). Unwrap links FIRST, then
5465 * unescape entities LAST so extracted URLs (which can contain &amp;) are not corrupted.
5466 */
5467 private function normalize_slack_text($text) {
5468 if (!is_string($text) || $text === '') {
5469 return $text;
5470 }
5471
5472 $text = preg_replace_callback('/<([^>|]+)(?:\|([^>]*))?>/', function ($m) {
5473 $target = $m[1];
5474 $label = isset($m[2]) ? $m[2] : '';
5475
5476 // User/channel mentions: <@U…> or <#C…|name> — prefer the human label, else drop the id.
5477 if (isset($target[0]) && ($target[0] === '@' || $target[0] === '#')) {
5478 return $label !== '' ? $label : '';
5479 }
5480 // mailto:/tel: — strip the scheme for display.
5481 if (stripos($target, 'mailto:') === 0) {
5482 $addr = substr($target, 7);
5483 return ($label !== '' && $label !== $addr) ? "{$label} ({$addr})" : $addr;
5484 }
5485 if (stripos($target, 'tel:') === 0) {
5486 $num = substr($target, 4);
5487 return ($label !== '' && $label !== $num) ? "{$label} ({$num})" : $num;
5488 }
5489 // Regular URL: <url|Label> -> "Label (url)"; bare <url> -> "url".
5490 if ($label !== '' && $label !== $target) {
5491 return "{$label} ({$target})";
5492 }
5493 return $target;
5494 }, $text);
5495
5496 // Entity-unescape LAST (after link extraction) so &amp; inside URLs is repaired too.
5497 $text = str_replace(array('&amp;', '&lt;', '&gt;'), array('&', '<', '>'), $text);
5498
5499 return $text;
5500 }
5501
5502 /**
5503 * Resolve the visitor's name + email for a session, mirroring generate_channel_name()'s
5504 * priority order: logged-in user, then the pre-chat gate options (mxchat_email_/mxchat_name_),
5505 * then the chat transcript. Returns ['name' => ..., 'email' => ...] (either may be ''). plan-e2195b.
5506 */
5507 private function mxchat_get_visitor_identity($session_id) {
5508 $email = '';
5509 $name = '';
5510
5511 if (is_user_logged_in()) {
5512 $current_user = wp_get_current_user();
5513 if (!empty($current_user->user_email)) { $email = $current_user->user_email; }
5514 if (!empty($current_user->display_name)) { $name = $current_user->display_name; }
5515 }
5516
5517 if (empty($email)) {
5518 $saved_email = get_option("mxchat_email_{$session_id}", '');
5519 if (!empty($saved_email)) { $email = $saved_email; }
5520 }
5521 if (empty($name)) {
5522 $saved_name = get_option("mxchat_name_{$session_id}", '');
5523 if (!empty($saved_name)) { $name = $saved_name; }
5524 }
5525
5526 if (empty($email) || empty($name)) {
5527 global $wpdb;
5528 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
5529 $existing_data = $wpdb->get_row($wpdb->prepare(
5530 "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",
5531 $session_id
5532 ));
5533 if ($existing_data) {
5534 if (empty($email) && !empty($existing_data->user_email)) { $email = $existing_data->user_email; }
5535 if (empty($name) && !empty($existing_data->user_name)) { $name = $existing_data->user_name; }
5536 }
5537 }
5538
5539 return array('name' => $name, 'email' => $email);
5540 }
5541
5542 public function handle_slack_messages(WP_REST_Request $request) {
5543 // Log the incoming request for debugging
5544 //error_log('Slack events request received: ' . $request->get_body());
5545
5546 $body = $request->get_body();
5547 $data = json_decode($body, true);
5548
5549 // Handle Slack URL verification
5550 if (isset($data['type']) && $data['type'] === 'url_verification') {
5551 //error_log('Slack URL verification challenge: ' . $data['challenge']);
5552 return new WP_REST_Response($data['challenge'], 200, ['Content-Type' => 'text/plain']);
5553 }
5554
5555 // IMPORTANT: Handle Slack's event deduplication
5556 if (isset($data['event_id'])) {
5557 $event_id = $data['event_id'];
5558 $processed_events = get_transient('mxchat_slack_events') ?: [];
5559
5560 // Check if we've already processed this event
5561 if (in_array($event_id, $processed_events)) {
5562 //error_log("Duplicate event detected: $event_id");
5563 return new WP_REST_Response(['ok' => true]);
5564 }
5565
5566 // Add this event to processed list
5567 $processed_events[] = $event_id;
5568 // Keep only last 100 events to prevent memory issues
5569 if (count($processed_events) > 100) {
5570 $processed_events = array_slice($processed_events, -100);
5571 }
5572 // Store for 1 hour
5573 set_transient('mxchat_slack_events', $processed_events, HOUR_IN_SECONDS);
5574 }
5575
5576 // Handle message events
5577 if (isset($data['event']) && $data['event']['type'] === 'message') {
5578 $event = $data['event'];
5579
5580 // Skip bot messages and messages with subtypes (like bot_message)
5581 if (isset($event['bot_id']) || isset($event['subtype'])) {
5582 return new WP_REST_Response(['ok' => true]);
5583 }
5584
5585 // Threaded replies: in shared-channel mode every conversation lives in
5586 // a thread rooted at its handoff message — route those to their session
5587 // by thread root (plan 9f7756). Any other threaded reply (e.g. under a
5588 // per-conversation channel's confirmation message) finds no session and
5589 // is skipped, exactly as before.
5590 if (isset($event['thread_ts']) && $event['thread_ts'] !== $event['ts']) {
5591 return $this->mxchat_route_shared_thread_reply($event);
5592 }
5593
5594 $channel_id = $event['channel'];
5595 $message_text = $event['text'] ?? '';
5596 $message_ts = $event['ts'] ?? '';
5597
5598 // Find session ID by looking for matching channel
5599 global $wpdb;
5600 $session_option = $wpdb->get_var(
5601 $wpdb->prepare(
5602 "SELECT option_name FROM {$wpdb->options}
5603 WHERE option_name LIKE 'mxchat_channel_%'
5604 AND option_value = %s",
5605 $channel_id
5606 )
5607 );
5608
5609 if ($session_option) {
5610 $session_id = str_replace('mxchat_channel_', '', $session_option);
5611
5612 // Create a unique key for this specific message
5613 $message_key = md5($session_id . $message_ts . $message_text);
5614 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
5615
5616 // Check if we've already processed this exact message
5617 if (in_array($message_key, $processed_messages)) {
5618 //error_log("Duplicate message detected for session $session_id");
5619 return new WP_REST_Response(['ok' => true]);
5620 }
5621
5622 // Add to processed messages
5623 $processed_messages[] = $message_key;
5624 // Keep only last 50 messages per session
5625 if (count($processed_messages) > 50) {
5626 $processed_messages = array_slice($processed_messages, -50);
5627 }
5628 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
5629
5630 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
5631
5632 // Handle agent ending the chat — transfer back to AI
5633 // Format: "!endchat" or "!endchat <custom message to user>"
5634 if (preg_match('/^!endchat\b/i', trim($message_text))) {
5635 update_option("mxchat_mode_{$session_id}", 'ai');
5636
5637 // Extract custom message after !endchat, or use empty string
5638 $custom_message = trim(preg_replace('/^!endchat\s*/i', '', trim($message_text)));
5639
5640 // Send the agent's custom farewell message if provided
5641 if (!empty($custom_message)) {
5642 $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($custom_message));
5643 }
5644
5645 // Confirm in Slack channel
5646 if (!empty($slack_bot_token)) {
5647 wp_remote_post('https://slack.com/api/chat.postMessage', [
5648 'headers' => [
5649 'Content-Type' => 'application/json',
5650 'Authorization' => 'Bearer ' . $slack_bot_token
5651 ],
5652 'body' => json_encode([
5653 'channel' => $channel_id,
5654 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.",
5655 'mrkdwn' => true
5656 ])
5657 ]);
5658 }
5659
5660 // Auto-archive the ended conversation's channel (plan 7458a7).
5661 // Toggle-gated, best-effort — never blocks the mode flip.
5662 $this->mxchat_maybe_archive_conversation_channel($session_id, $channel_id);
5663
5664 return new WP_REST_Response(['ok' => true]);
5665 }
5666
5667 // Save the agent message (normalize Slack link/entity formatting first — plan-e2195b)
5668 $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($message_text));
5669
5670 // Send confirmation back to Slack (only once)
5671 if (!empty($slack_bot_token)) {
5672 // Use a transient to prevent duplicate confirmations
5673 $confirm_key = 'mxchat_confirm_' . $message_key;
5674 if (!get_transient($confirm_key)) {
5675 wp_remote_post('https://slack.com/api/chat.postMessage', [
5676 'headers' => [
5677 'Content-Type' => 'application/json',
5678 'Authorization' => 'Bearer ' . $slack_bot_token
5679 ],
5680 'body' => json_encode([
5681 'channel' => $channel_id,
5682 'text' => "✅ _Message sent to user_",
5683 'thread_ts' => $event['ts'] // Reply in thread
5684 ])
5685 ]);
5686 // Set transient to prevent duplicate confirmations
5687 set_transient($confirm_key, true, 300); // 5 minutes
5688 }
5689 }
5690 }
5691 }
5692
5693 return new WP_REST_Response(['ok' => true]);
5694 }
5695
5696 /**
5697 * Route an agent's threaded Slack reply to the session whose shared-channel
5698 * conversation is rooted at that thread (plan 9f7756). Sessions are keyed by
5699 * the thread root ts stored in mxchat_thread_{session}, so two visitors in
5700 * the same shared channel can never cross-wire. Unknown threads are ignored.
5701 *
5702 * @param array $event Slack message event (has thread_ts !== ts).
5703 * @return WP_REST_Response
5704 */
5705 private function mxchat_route_shared_thread_reply($event) {
5706 $thread_root = $event['thread_ts'] ?? '';
5707 $message_text = $event['text'] ?? '';
5708 $message_ts = $event['ts'] ?? '';
5709 $channel_id = $event['channel'] ?? '';
5710
5711 if ($thread_root === '') {
5712 return new WP_REST_Response(['ok' => true]);
5713 }
5714
5715 // Find the session owning this thread root (same reverse-lookup shape as
5716 // the per-conversation channel mapping).
5717 global $wpdb;
5718 $session_option = $wpdb->get_var(
5719 $wpdb->prepare(
5720 "SELECT option_name FROM {$wpdb->options}
5721 WHERE option_name LIKE 'mxchat_thread_%'
5722 AND option_value = %s",
5723 $thread_root
5724 )
5725 );
5726
5727 if (!$session_option) {
5728 // Not a shared-channel conversation thread (e.g. a reply under a
5729 // per-conversation confirmation) — ignore, as before.
5730 return new WP_REST_Response(['ok' => true]);
5731 }
5732
5733 $session_id = str_replace('mxchat_thread_', '', $session_option);
5734
5735 // Per-message dedupe — same transient pattern as the top-level handler.
5736 $message_key = md5($session_id . $message_ts . $message_text);
5737 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
5738 if (in_array($message_key, $processed_messages)) {
5739 return new WP_REST_Response(['ok' => true]);
5740 }
5741 $processed_messages[] = $message_key;
5742 if (count($processed_messages) > 50) {
5743 $processed_messages = array_slice($processed_messages, -50);
5744 }
5745 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
5746
5747 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
5748
5749 // Agent ending the chat from inside the thread — same command contract as
5750 // per-conversation channels: "!endchat" or "!endchat <farewell>".
5751 if (preg_match('/^!endchat\b/i', trim($message_text))) {
5752 update_option("mxchat_mode_{$session_id}", 'ai');
5753
5754 $custom_message = trim(preg_replace('/^!endchat\s*/i', '', trim($message_text)));
5755 if (!empty($custom_message)) {
5756 $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($custom_message));
5757 }
5758
5759 if (!empty($slack_bot_token) && $channel_id !== '') {
5760 wp_remote_post('https://slack.com/api/chat.postMessage', [
5761 'headers' => [
5762 'Content-Type' => 'application/json',
5763 'Authorization' => 'Bearer ' . $slack_bot_token
5764 ],
5765 'body' => json_encode([
5766 'channel' => $channel_id,
5767 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.",
5768 'thread_ts' => $thread_root,
5769 'mrkdwn' => true
5770 ])
5771 ]);
5772 }
5773
5774 return new WP_REST_Response(['ok' => true]);
5775 }
5776
5777 // Save the agent message for the widget (normalized like the channel path).
5778 $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($message_text));
5779
5780 // Confirmation stays inside the conversation's thread.
5781 if (!empty($slack_bot_token) && $channel_id !== '') {
5782 $confirm_key = 'mxchat_confirm_' . $message_key;
5783 if (!get_transient($confirm_key)) {
5784 wp_remote_post('https://slack.com/api/chat.postMessage', [
5785 'headers' => [
5786 'Content-Type' => 'application/json',
5787 'Authorization' => 'Bearer ' . $slack_bot_token
5788 ],
5789 'body' => json_encode([
5790 'channel' => $channel_id,
5791 'text' => "✅ _Message sent to user_",
5792 'thread_ts' => $thread_root
5793 ])
5794 ]);
5795 set_transient($confirm_key, true, 300);
5796 }
5797 }
5798
5799 return new WP_REST_Response(['ok' => true]);
5800 }
5801
5802 // For the word upload handler
5803 public function mxchat_handle_word_upload() {
5804 // Delegate to word handler
5805 $this->word_handler->mxchat_handle_word_upload();
5806 }
5807
5808 // For the word removal handler
5809 public function mxchat_handle_word_remove() {
5810 // Delegate to word handler
5811 $this->word_handler->mxchat_handle_word_remove();
5812 }
5813
5814 // For the word status check
5815 public function mxchat_check_word_status() {
5816 // Delegate to word handler
5817 $this->word_handler->mxchat_check_word_status();
5818 }
5819
5820
5821 private function mxchat_get_user_identifier() {
5822 return MxChat_User::mxchat_get_user_identifier();
5823 }
5824
5825 private function mxchat_generate_embedding($text, $api_key) {
5826 try {
5827 // Get options and selected model
5828 $options = get_option('mxchat_options');
5829 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5830
5831 // Opt-in: route embeddings through the Custom (OpenAI-compatible) provider.
5832 // Off by default so existing sites see byte-identical behavior.
5833 if (!empty($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
5834 return $this->mxchat_generate_embedding_custom($text);
5835 }
5836
5837 // Determine endpoint and API key based on model
5838 if (strpos($selected_model, 'voyage') === 0) {
5839 $endpoint = 'https://api.voyageai.com/v1/embeddings';
5840 $api_key = $options['voyage_api_key'] ?? '';
5841
5842 // Check if Voyage API key is missing
5843 if (empty($api_key)) {
5844 //error_log('Voyage API key is missing');
5845 return [
5846 'error' => esc_html__('Voyage AI API key is not configured', 'mxchat'),
5847 'error_code' => 'missing_voyage_api_key'
5848 ];
5849 }
5850 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5851 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
5852 $api_key = $options['gemini_api_key'] ?? '';
5853
5854 // Check if Gemini API key is missing
5855 if (empty($api_key)) {
5856 //error_log('Gemini API key is missing');
5857 return [
5858 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
5859 'error_code' => 'missing_gemini_api_key'
5860 ];
5861 }
5862 } else {
5863 $endpoint = 'https://api.openai.com/v1/embeddings';
5864 // Use the passed API key for OpenAI
5865
5866 // Check if OpenAI API key is missing
5867 if (empty($api_key)) {
5868 //error_log('OpenAI API key is missing');
5869 return [
5870 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
5871 'error_code' => 'missing_openai_api_key'
5872 ];
5873 }
5874 }
5875
5876 // Check if text is empty
5877 if (empty($text)) {
5878 //error_log('Empty text provided for embedding generation');
5879 return [
5880 'error' => esc_html__('No text provided for embedding generation', 'mxchat'),
5881 'error_code' => 'empty_embedding_text'
5882 ];
5883 }
5884
5885 // Prepare request body based on provider
5886 if (strpos($selected_model, 'gemini-embedding') === 0) {
5887 // Gemini API format
5888 $request_body = [
5889 'model' => 'models/' . $selected_model,
5890 'content' => [
5891 'parts' => [
5892 ['text' => $text]
5893 ]
5894 ],
5895 'outputDimensionality' => 1536
5896 ];
5897
5898 // Prepare headers for Gemini (API key as query parameter)
5899 $endpoint .= '?key=' . $api_key;
5900 $headers = [
5901 'Content-Type' => 'application/json'
5902 ];
5903 } else {
5904 // OpenAI/Voyage API format
5905 $request_body = [
5906 'input' => $text,
5907 'model' => $selected_model
5908 ];
5909
5910 // Add output_dimension for voyage-3-large
5911 if ($selected_model === 'voyage-3-large') {
5912 $request_body['output_dimension'] = 2048;
5913 }
5914
5915 // Prepare headers for OpenAI/Voyage
5916 $headers = [
5917 'Content-Type' => 'application/json',
5918 'Authorization' => 'Bearer ' . $api_key
5919 ];
5920 }
5921
5922 // Prepare request arguments
5923 $args = [
5924 'body' => wp_json_encode($request_body),
5925 'headers' => $headers,
5926 'timeout' => 60,
5927 'redirection' => 5,
5928 'blocking' => true,
5929 'httpversion' => '1.0',
5930 'sslverify' => true,
5931 ];
5932
5933 // Make the request
5934 $response = wp_remote_post($endpoint, $args);
5935
5936 // Handle WordPress errors
5937 if (is_wp_error($response)) {
5938 $error_message = $response->get_error_message();
5939 //error_log('Embedding Generation Error: ' . $error_message);
5940 return [
5941 'error' => esc_html__('Connection error when generating embeddings: ', 'mxchat') . esc_html($error_message),
5942 'error_code' => 'embedding_connection_error'
5943 ];
5944 }
5945
5946 // Check HTTP status code
5947 $status_code = wp_remote_retrieve_response_code($response);
5948 if ($status_code !== 200) {
5949 $response_body = json_decode(wp_remote_retrieve_body($response), true);
5950
5951 $error_message = $this->extract_provider_error($response_body, 'HTTP Error ' . $status_code);
5952
5953 $error_type = isset($response_body['error']['type'])
5954 ? $response_body['error']['type']
5955 : 'unknown';
5956
5957 //error_log('Embedding API HTTP Error: ' . $status_code . ' - ' . $error_message);
5958
5959 // Handle specific error types
5960 switch ($error_type) {
5961 case 'invalid_request_error':
5962 if (strpos($error_message, 'API key') !== false) {
5963 return [
5964 'error' => esc_html__('Invalid API key for embedding generation. Please check your API key configuration.', 'mxchat'),
5965 'error_code' => 'embedding_invalid_api_key'
5966 ];
5967 }
5968 break;
5969
5970 case 'authentication_error':
5971 return [
5972 'error' => esc_html__('Authentication failed for embedding generation. Please check your API key.', 'mxchat'),
5973 'error_code' => 'embedding_auth_error'
5974 ];
5975
5976 case 'rate_limit_exceeded':
5977 return [
5978 'error' => esc_html__('Rate limit exceeded for embedding generation. Please try again later.', 'mxchat'),
5979 'error_code' => 'embedding_rate_limit'
5980 ];
5981
5982 case 'quota_exceeded':
5983 return [
5984 'error' => esc_html__('API quota exceeded for embedding generation. Please check your billing details.', 'mxchat'),
5985 'error_code' => 'embedding_quota_exceeded'
5986 ];
5987 }
5988
5989 // Generic error fallback
5990 return [
5991 'error' => esc_html__('Embedding API error - check embedding API key.: ', 'mxchat') . esc_html($error_message),
5992 'error_code' => 'embedding_api_error',
5993 'status_code' => $status_code
5994 ];
5995 }
5996
5997 $response_body = json_decode(wp_remote_retrieve_body($response), true);
5998
5999 // Handle different response formats based on provider
6000 if (strpos($selected_model, 'gemini-embedding') === 0) {
6001 // Gemini API response format
6002 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
6003 return $response_body['embedding']['values'];
6004 } else {
6005 //error_log('Invalid Gemini embedding response: ' . wp_json_encode($response_body));
6006 return [
6007 'error' => esc_html__('Received invalid embedding data from the Gemini API.', 'mxchat'),
6008 'error_code' => 'invalid_gemini_embedding_response'
6009 ];
6010 }
6011 } else {
6012 // OpenAI/Voyage API response format
6013 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
6014 return $response_body['data'][0]['embedding'];
6015 } else {
6016 //error_log('Invalid embedding response: ' . wp_json_encode($response_body));
6017 return [
6018 'error' => esc_html__('Received invalid embedding data from the API.', 'mxchat'),
6019 'error_code' => 'invalid_embedding_response'
6020 ];
6021 }
6022 }
6023 } catch (Exception $e) {
6024 //error_log('Embedding Exception: ' . $e->getMessage());
6025 return [
6026 'error' => esc_html__('System error when generating embeddings: ', 'mxchat') . esc_html($e->getMessage()),
6027 'error_code' => 'embedding_exception'
6028 ];
6029 }
6030 }
6031
6032
6033 /**
6034 * Generate embedding via a Custom (OpenAI-compatible) provider's /embeddings route.
6035 * Only called when the opt-in 'custom_provider_for_embeddings' setting is on.
6036 * Returns a numeric array (the embedding vector) on success, or ['error','error_code'] on failure.
6037 */
6038 private function mxchat_generate_embedding_custom($text) {
6039 if (empty($text)) {
6040 return ['error' => esc_html__('No text provided for embedding generation', 'mxchat'), 'error_code' => 'empty_embedding_text'];
6041 }
6042 $cfg = $this->mxchat_resolve_custom_provider();
6043 if (empty($cfg['base_url'])) {
6044 return ['error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url'];
6045 }
6046
6047 $options = get_option('mxchat_options');
6048 $embed_url = $cfg['base_url'] . '/embeddings';
6049 if (!empty($cfg['api_version'])) {
6050 $embed_url .= (strpos($embed_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($cfg['api_version']);
6051 }
6052 $model = isset($options['custom_provider_embedding_model']) && trim((string) $options['custom_provider_embedding_model']) !== ''
6053 ? trim((string) $options['custom_provider_embedding_model'])
6054 : $cfg['model'];
6055
6056 $response = wp_remote_post($embed_url, [
6057 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
6058 'body' => wp_json_encode(['input' => $text, 'model' => $model]),
6059 'timeout' => 60,
6060 ]);
6061 if (is_wp_error($response)) {
6062 return [
6063 'error' => esc_html__('Connection error when generating embeddings (custom provider): ', 'mxchat') . esc_html($response->get_error_message()),
6064 'error_code' => 'embedding_custom_connection_error',
6065 ];
6066 }
6067 $status = wp_remote_retrieve_response_code($response);
6068 $body = json_decode(wp_remote_retrieve_body($response), true);
6069 if ($status !== 200) {
6070 $msg = $this->extract_provider_error($body, 'HTTP ' . $status);
6071 return [
6072 'error' => esc_html__('Custom embedding endpoint error: ', 'mxchat') . esc_html($msg),
6073 'error_code' => 'embedding_custom_api_error',
6074 'status_code' => $status,
6075 ];
6076 }
6077 if (isset($body['data'][0]['embedding']) && is_array($body['data'][0]['embedding'])) {
6078 return $body['data'][0]['embedding'];
6079 }
6080 return [
6081 'error' => esc_html__('Invalid embedding response from custom provider.', 'mxchat'),
6082 'error_code' => 'embedding_custom_invalid_response',
6083 ];
6084 }
6085
6086 private function mxchat_find_relevant_content($user_embedding, $bot_id = 'default', $user_query = '') {
6087 //error_log("MXCHAT DEBUG: find_relevant_content called with bot_id: " . $bot_id);
6088
6089 // Check for OpenAI Vector Store first (takes priority when enabled)
6090 $bot_vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
6091
6092 if ($bot_vectorstore_config['use_vectorstore']) {
6093 // Get current model to verify it's an OpenAI model
6094 $bot_options = $this->get_bot_options($bot_id);
6095 $mxchat_options = get_option('mxchat_options', array());
6096 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
6097 $selected_model = $current_options['model'] ?? 'gpt-5.6-sol';
6098
6099 if ($this->is_openai_chat_model($selected_model)) {
6100 //error_log("MXCHAT DEBUG: Using OpenAI Vector Store for knowledge retrieval");
6101 return $this->find_relevant_content_openai_vectorstore($user_query, $bot_id, $bot_vectorstore_config);
6102 } else {
6103 //error_log("MXCHAT DEBUG: Vector Store enabled but model is not OpenAI (" . $selected_model . "), skipping Vector Store");
6104 }
6105 }
6106
6107 // Get bot-specific Pinecone configuration
6108 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
6109
6110 // Debug: Log the Pinecone configuration
6111 //error_log("MXCHAT DEBUG: Pinecone config for bot '$bot_id':");
6112 //error_log(" - use_pinecone: " . ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false'));
6113 //error_log(" - api_key: " . (empty($bot_pinecone_config['api_key']) ? 'EMPTY' : 'SET (hidden)'));
6114 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'NOT SET'));
6115 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'NOT SET'));
6116
6117 // Determine whether to use Pinecone based on bot configuration
6118 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
6119
6120 //error_log("MXCHAT DEBUG: Using " . ($use_pinecone ? "Pinecone" : "WordPress Database") . " for knowledge retrieval");
6121
6122 if ($use_pinecone) {
6123 return $this->find_relevant_content_pinecone($user_embedding, $bot_id, $bot_pinecone_config);
6124 } else {
6125 return $this->find_relevant_content_wordpress($user_embedding, $bot_id, $user_query);
6126 }
6127 }
6128
6129 private function find_relevant_content_wordpress($user_embedding, $bot_id = 'default', $user_query = '') {
6130 global $wpdb;
6131 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6132 // Initialize similarity analysis storage
6133 $this->last_similarity_analysis = [
6134 'knowledge_base_type' => 'WordPress Database',
6135 'bot_id' => $bot_id,
6136 'top_matches' => [],
6137 'threshold_used' => 0,
6138 'total_checked' => 0
6139 ];
6140
6141 // NEW: Initialize valid URLs array
6142 $valid_urls = [];
6143
6144 // Get bot-specific options for similarity threshold
6145 $bot_options = $this->get_bot_options($bot_id);
6146 $current_options = !empty($bot_options) ? $bot_options : $this->options;
6147
6148 // Get knowledge manager instance for role checking
6149 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
6150
6151 // Get base similarity threshold from bot options or default options
6152 $similarity_threshold = isset($current_options['similarity_threshold'])
6153 ? ((int) $current_options['similarity_threshold']) / 100
6154 : 0.35;
6155 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
6156
6157 // Precompute bot_filter once, outside the streaming loop
6158 $bot_filter = '';
6159 if ($bot_id !== 'default') {
6160 $column_exists = $wpdb->get_var("SHOW COLUMNS FROM {$system_prompt_table} LIKE 'bot_metadata'");
6161 if ($column_exists) {
6162 $bot_filter = $wpdb->prepare(" AND (bot_metadata = %s OR bot_metadata IS NULL OR bot_metadata = '')", $bot_id);
6163 }
6164 }
6165
6166 // Hybrid keyword boost (plan-38ffa1, default OFF). Runs a ranked keyword
6167 // query alongside the vector scan and fuses the two lists by reciprocal
6168 // rank, so exact-token queries (SKUs, error codes, names) hit even when
6169 // their embedding similarity is semantic mush. The keyword leg runs FIRST
6170 // so the vector scan below can record true cosine similarity for its hits
6171 // (the display keeps cosine % as the anchor).
6172 $hybrid_enabled = get_option('mxchat_hybrid_keyword_toggle', 'off') === 'on'
6173 && trim((string) $user_query) !== '';
6174 $keyword_hits = array(); // ranked + access-filtered, max 20
6175 $keyword_ids = array(); // id => keyword rank (1-based)
6176 $keyword_similarities = array(); // id => cosine recorded during the scan
6177 if ($hybrid_enabled) {
6178 $keyword_hits = $this->mxchat_hybrid_keyword_search($user_query, $system_prompt_table, $bot_filter, $knowledge_manager);
6179 foreach ($keyword_hits as $kw_i => $kw_hit) {
6180 $keyword_ids[$kw_hit['id']] = $kw_i + 1;
6181 }
6182 }
6183
6184 // ===== STREAMING TOP-K PASS =====
6185 // Stream rows in small batches, compute cosine similarity per row, and keep only:
6186 // - top 10 by raw similarity (for the testing/debug display panel)
6187 // - candidates above threshold with access (capped) for context assembly
6188 // This bounds peak memory regardless of knowledge base size and avoids loading
6189 // article_content for every row. article_content is fetched in Phase 2 for winners only.
6190 $batch_size = 250;
6191 $max_candidates = 200; // safety cap, well above rag_sources_limit * max_chunks_per_source
6192 $top_display = [];
6193 $candidates = [];
6194 $total_checked = 0;
6195 $offset = 0;
6196
6197 do {
6198 $batch = $wpdb->get_results($wpdb->prepare(
6199 "SELECT id, embedding_vector, source_url, role_restriction
6200 FROM {$system_prompt_table}
6201 WHERE 1=1 {$bot_filter}
6202 LIMIT %d OFFSET %d",
6203 $batch_size,
6204 $offset
6205 ));
6206
6207 if (empty($batch)) {
6208 break;
6209 }
6210
6211 foreach ($batch as $row) {
6212 $database_embedding = $row->embedding_vector
6213 ? unserialize($row->embedding_vector, ['allowed_classes' => false])
6214 : null;
6215
6216 if (!is_array($database_embedding) || !is_array($user_embedding)) {
6217 unset($database_embedding);
6218 continue;
6219 }
6220
6221 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
6222 unset($database_embedding);
6223
6224 $role_restriction = $row->role_restriction ?? 'public';
6225 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
6226 $source_url = $row->source_url ?? '';
6227
6228 // Maintain top 10 display buffer (insert-if-beats-worst)
6229 if (count($top_display) < 10) {
6230 $top_display[] = [
6231 'id' => $row->id,
6232 'similarity' => $similarity,
6233 'source_url' => $source_url,
6234 'role_restriction' => $role_restriction,
6235 'has_access' => $has_access,
6236 ];
6237 usort($top_display, function ($a, $b) {
6238 return $b['similarity'] <=> $a['similarity'];
6239 });
6240 } elseif ($similarity > $top_display[9]['similarity']) {
6241 $top_display[9] = [
6242 'id' => $row->id,
6243 'similarity' => $similarity,
6244 'source_url' => $source_url,
6245 'role_restriction' => $role_restriction,
6246 'has_access' => $has_access,
6247 ];
6248 usort($top_display, function ($a, $b) {
6249 return $b['similarity'] <=> $a['similarity'];
6250 });
6251 }
6252
6253 // Record cosine for keyword-leg hits so fusion/display can anchor
6254 // on the true similarity % even for below-threshold rescues.
6255 if ($hybrid_enabled && isset($keyword_ids[$row->id])) {
6256 $keyword_similarities[$row->id] = $similarity;
6257 }
6258
6259 // Track candidates for context assembly (above threshold + has access)
6260 if ($similarity >= $similarity_threshold && $has_access) {
6261 $candidates[] = [
6262 'id' => $row->id,
6263 'similarity' => $similarity,
6264 'source_url' => $source_url,
6265 ];
6266 }
6267
6268 $total_checked++;
6269 }
6270
6271 unset($batch);
6272
6273 // Trim candidates periodically to cap memory during long scans
6274 if (count($candidates) > $max_candidates) {
6275 usort($candidates, function ($a, $b) {
6276 return $b['similarity'] <=> $a['similarity'];
6277 });
6278 $candidates = array_slice($candidates, 0, $max_candidates);
6279 }
6280
6281 $offset += $batch_size;
6282 } while (true);
6283
6284 if ($total_checked === 0) {
6285 $this->current_valid_urls = [];
6286 return '';
6287 }
6288
6289 // Final candidates sort (best first)
6290 if (count($candidates) > 1) {
6291 usort($candidates, function ($a, $b) {
6292 return $b['similarity'] <=> $a['similarity'];
6293 });
6294 }
6295
6296 // ===== HYBRID FUSION (plan-38ffa1) =====
6297 // Reciprocal-rank fusion over the top-20 of each leg (k=60 standard).
6298 // Rank-based, so the incomparable score scales (cosine 0-1 vs FULLTEXT
6299 // relevance) never need calibrating. A below-threshold vector row can
6300 // enter via a strong keyword rank — that is the point of the feature.
6301 // Every candidate gets a 'rank_score' the downstream source ordering
6302 // uses: with hybrid OFF it is exactly the cosine similarity, so the
6303 // legacy path is byte-identical.
6304 $fused_rank_map = array(); // id => 1-based fused rank
6305 $matched_via_map = array(); // id => 'vector' | 'keyword' | 'both'
6306 if (!$hybrid_enabled) {
6307 foreach ($candidates as &$cand_ref) {
6308 $cand_ref['rank_score'] = $cand_ref['similarity'];
6309 }
6310 unset($cand_ref);
6311 } else {
6312 $rrf_k = 60;
6313 $fused = array();
6314 foreach (array_slice($candidates, 0, 20) as $leg_rank => $cand) {
6315 $fused[$cand['id']] = array(
6316 'id' => $cand['id'],
6317 'similarity' => $cand['similarity'],
6318 'source_url' => $cand['source_url'],
6319 'rrf' => 1 / ($rrf_k + $leg_rank + 1),
6320 'via' => 'vector',
6321 );
6322 }
6323 foreach ($keyword_hits as $leg_rank => $hit) {
6324 $rrf = 1 / ($rrf_k + $leg_rank + 1);
6325 if (isset($fused[$hit['id']])) {
6326 $fused[$hit['id']]['rrf'] += $rrf;
6327 $fused[$hit['id']]['via'] = 'both';
6328 } else {
6329 $fused[$hit['id']] = array(
6330 'id' => $hit['id'],
6331 'similarity' => $keyword_similarities[$hit['id']] ?? 0.0,
6332 'source_url' => $hit['source_url'],
6333 'rrf' => $rrf,
6334 'via' => 'keyword',
6335 );
6336 }
6337 }
6338 uasort($fused, function ($a, $b) {
6339 return $b['rrf'] <=> $a['rrf'];
6340 });
6341
6342 // Vector candidates beyond the top-20 leg keep flowing to the prompt
6343 // builders after the fused block, in their vector order — the result
6344 // count/shape downstream stays unchanged.
6345 $tail = array_slice($candidates, 20);
6346 $candidates = array();
6347 $rank = 0;
6348 foreach ($fused as $f) {
6349 $rank++;
6350 $fused_rank_map[$f['id']] = $rank;
6351 $matched_via_map[$f['id']] = $f['via'];
6352 $candidates[] = array(
6353 'id' => $f['id'],
6354 'similarity' => $f['similarity'],
6355 'source_url' => $f['source_url'],
6356 'rank_score' => $f['rrf'],
6357 );
6358 }
6359 foreach ($tail as $cand) {
6360 // Below any fused rrf (min possible fused rrf is 1/(60+40)=0.01;
6361 // similarity * 1e-6 <= 1e-6), preserving relative vector order.
6362 $cand['rank_score'] = $cand['similarity'] * 1e-6;
6363 $candidates[] = $cand;
6364 }
6365 if (count($candidates) > $max_candidates) {
6366 $candidates = array_slice($candidates, 0, $max_candidates);
6367 }
6368 }
6369
6370 // ===== PHASE 2: FETCH ARTICLE CONTENT ONLY FOR WINNERS =====
6371 // Gather unique IDs we actually need (top_display + candidates) and pull
6372 // article_content in bounded IN() batches. This avoids loading content for
6373 // every row during the similarity scan.
6374 $needed_ids = [];
6375 foreach ($top_display as $item) {
6376 $needed_ids[$item['id']] = true;
6377 }
6378 foreach ($candidates as $item) {
6379 $needed_ids[$item['id']] = true;
6380 }
6381 $needed_ids = array_keys($needed_ids);
6382
6383 $content_map = [];
6384 if (!empty($needed_ids)) {
6385 foreach (array_chunk($needed_ids, 250) as $chunk_ids) {
6386 $placeholders = implode(',', array_fill(0, count($chunk_ids), '%d'));
6387 $rows = $wpdb->get_results($wpdb->prepare(
6388 "SELECT id, article_content FROM {$system_prompt_table} WHERE id IN ($placeholders)",
6389 ...$chunk_ids
6390 ));
6391 foreach ($rows as $r) {
6392 $content_map[$r->id] = $r->article_content;
6393 }
6394 unset($rows);
6395 }
6396 }
6397
6398 // Build the all_similarities display array from the top 10
6399 $all_similarities = [];
6400 foreach ($top_display as $item) {
6401 $article_content_for_parse = $content_map[$item['id']] ?? '';
6402 $parsed_for_display = MxChat_Chunker::parse_stored_chunk($article_content_for_parse);
6403 $is_chunk = $parsed_for_display['is_chunked'];
6404 $chunk_meta = $parsed_for_display['metadata'];
6405
6406 if (!empty($item['source_url']) && $item['source_url'] !== '#') {
6407 $source_display = $item['source_url'];
6408 } else {
6409 $content_preview = strip_tags($article_content_for_parse);
6410 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
6411 $source_display = substr(trim($content_preview), 0, 50) . '...';
6412 }
6413
6414 $all_similarities[] = [
6415 'document_id' => $item['id'],
6416 'similarity' => $item['similarity'],
6417 'similarity_percentage' => round($item['similarity'] * 100, 2),
6418 'above_threshold' => $item['similarity'] >= $similarity_threshold,
6419 'source_display' => $source_display,
6420 'content_preview' => substr(strip_tags($parsed_for_display['text'] ?? ''), 0, 100) . '...',
6421 'used_for_context' => false,
6422 'role_restriction' => $item['role_restriction'],
6423 'has_access' => $item['has_access'],
6424 'filtered_out' => !$item['has_access'],
6425 'is_chunk' => $is_chunk,
6426 'chunk_index' => $is_chunk ? ($chunk_meta['chunk_index'] ?? 0) : null,
6427 'total_chunks' => $is_chunk ? ($chunk_meta['total_chunks'] ?? 1) : null
6428 ];
6429 }
6430
6431 // Build url_groups from candidates for chunk reassembly
6432 $url_groups = array();
6433 foreach ($candidates as $cand) {
6434 $article_content = $content_map[$cand['id']] ?? '';
6435 $parsed = MxChat_Chunker::parse_stored_chunk($article_content);
6436 $is_chunked = $parsed['is_chunked'];
6437 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
6438 $text_content = $parsed['text'];
6439
6440 $source_url = $cand['source_url'];
6441 $group_key = !empty($source_url) ? $source_url : '_manual_' . $cand['id'];
6442
6443 if (!isset($url_groups[$group_key])) {
6444 $url_groups[$group_key] = array(
6445 'source_url' => $source_url,
6446 'best_score' => 0,
6447 'is_chunked' => $is_chunked,
6448 'chunks' => array(),
6449 'single_text' => '',
6450 'single_id' => null
6451 );
6452 }
6453
6454 // rank_score == similarity with hybrid off (byte-identical ordering);
6455 // with hybrid on it carries the fused rank so keyword rescues sort up.
6456 $cand_rank_score = $cand['rank_score'] ?? $cand['similarity'];
6457 if ($cand_rank_score > $url_groups[$group_key]['best_score']) {
6458 $url_groups[$group_key]['best_score'] = $cand_rank_score;
6459 }
6460
6461 if ($is_chunked) {
6462 $url_groups[$group_key]['is_chunked'] = true;
6463 $url_groups[$group_key]['chunks'][] = array(
6464 'id' => $cand['id'],
6465 'score' => $cand['similarity'],
6466 'chunk_index' => $chunk_index,
6467 'text' => $text_content
6468 );
6469 } else {
6470 $url_groups[$group_key]['single_text'] = $text_content;
6471 $url_groups[$group_key]['single_id'] = $cand['id'];
6472 }
6473 }
6474
6475 // Hybrid display augmentation (plan-38ffa1, Maxwell's approval note):
6476 // make sure every fused-top-10 row appears in the debug panel — a
6477 // keyword-only rescue may sit below the vector top-10 buffer — and stamp
6478 // matched_via + fused_rank on every row. Cosine % stays the anchor; no
6479 // raw RRF numbers surface.
6480 if ($hybrid_enabled) {
6481 $displayed_ids = array();
6482 foreach ($all_similarities as $disp_item) {
6483 $displayed_ids[$disp_item['document_id']] = true;
6484 }
6485 $kw_info_by_id = array();
6486 foreach ($keyword_hits as $hit) {
6487 $kw_info_by_id[$hit['id']] = $hit;
6488 }
6489 foreach ($fused_rank_map as $fused_id => $fused_rank) {
6490 if ($fused_rank > 10 || isset($displayed_ids[$fused_id])) {
6491 continue;
6492 }
6493 $aug_content = $content_map[$fused_id] ?? '';
6494 $aug_parsed = MxChat_Chunker::parse_stored_chunk($aug_content);
6495 $aug_hit = $kw_info_by_id[$fused_id] ?? array();
6496 $aug_similarity = $keyword_similarities[$fused_id] ?? 0.0;
6497 $aug_source_url = $aug_hit['source_url'] ?? '';
6498 if (!empty($aug_source_url) && $aug_source_url !== '#') {
6499 $aug_source_display = $aug_source_url;
6500 } else {
6501 $aug_preview = preg_replace('/\s+/', ' ', strip_tags($aug_content));
6502 $aug_source_display = substr(trim($aug_preview), 0, 50) . '...';
6503 }
6504 $all_similarities[] = [
6505 'document_id' => $fused_id,
6506 'similarity' => $aug_similarity,
6507 'similarity_percentage' => round($aug_similarity * 100, 2),
6508 'above_threshold' => $aug_similarity >= $similarity_threshold,
6509 'source_display' => $aug_source_display,
6510 'content_preview' => substr(strip_tags($aug_parsed['text'] ?? ''), 0, 100) . '...',
6511 'used_for_context' => false,
6512 'role_restriction' => $aug_hit['role_restriction'] ?? 'public',
6513 'has_access' => $aug_hit['has_access'] ?? true,
6514 'filtered_out' => false,
6515 'is_chunk' => $aug_parsed['is_chunked'],
6516 'chunk_index' => $aug_parsed['is_chunked'] ? ($aug_parsed['metadata']['chunk_index'] ?? 0) : null,
6517 'total_chunks' => $aug_parsed['is_chunked'] ? ($aug_parsed['metadata']['total_chunks'] ?? 1) : null,
6518 ];
6519 }
6520 foreach ($all_similarities as &$disp_ref) {
6521 $disp_ref['matched_via'] = $matched_via_map[$disp_ref['document_id']] ?? null;
6522 $disp_ref['fused_rank'] = $fused_rank_map[$disp_ref['document_id']] ?? null;
6523 }
6524 unset($disp_ref);
6525 }
6526
6527 // Sort for the testing/debug display: fused rank when hybrid is on
6528 // (nulls last, cosine as tie-break), raw similarity otherwise.
6529 if ($hybrid_enabled) {
6530 usort($all_similarities, function ($a, $b) {
6531 $ar = $a['fused_rank'] ?? PHP_INT_MAX;
6532 $br = $b['fused_rank'] ?? PHP_INT_MAX;
6533 if ($ar !== $br) {
6534 return $ar <=> $br;
6535 }
6536 return $b['similarity'] <=> $a['similarity'];
6537 });
6538 } else {
6539 usort($all_similarities, function ($a, $b) {
6540 return $b['similarity'] <=> $a['similarity'];
6541 });
6542 }
6543
6544 // Sort URL groups by best score (highest first)
6545 uasort($url_groups, function($a, $b) {
6546 return $b['best_score'] <=> $a['best_score'];
6547 });
6548
6549 // Get RAG sources limit from options (default 6, min 3, max 10)
6550 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
6551 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
6552 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
6553
6554 // Take top N unique URLs based on user setting
6555 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
6556
6557 // Track which document IDs are used for context
6558 $used_document_ids = [];
6559 foreach ($top_urls as $group) {
6560 if ($group['is_chunked']) {
6561 foreach ($group['chunks'] as $chunk) {
6562 $used_document_ids[] = $chunk['id'];
6563 }
6564 } elseif ($group['single_id']) {
6565 $used_document_ids[] = $group['single_id'];
6566 }
6567 }
6568
6569 // Update the all_similarities array to mark which were actually used
6570 foreach ($all_similarities as &$similarity_item) {
6571 $similarity_item['used_for_context'] = in_array($similarity_item['document_id'], $used_document_ids);
6572 }
6573
6574 // Store top 10 for testing panel
6575 $this->last_similarity_analysis['top_matches'] = array_slice($all_similarities, 0, 10);
6576 $this->last_similarity_analysis['total_checked'] = $total_checked;
6577
6578 // Initialize final content
6579 $content = '';
6580 $matches_used = 0;
6581 $total_chunks_used = 0;
6582 $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
6583 if ($max_total_chunks < 8) $max_total_chunks = 8;
6584 if ($max_total_chunks > 20) $max_total_chunks = 20;
6585 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
6586
6587 // Check if citation links are enabled (default to 'on' for backwards compatibility)
6588 // Use fresh options to ensure we get the latest setting value
6589 $fresh_options = get_option('mxchat_options', []);
6590 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6591
6592 // Build content from top sources
6593 foreach ($top_urls as $group_key => $group) {
6594 $source_url = $group['source_url']; // Use actual source_url, not the group key
6595
6596 // Stop if we've hit the total chunk limit
6597 if ($total_chunks_used >= $max_total_chunks) {
6598 break;
6599 }
6600
6601 $full_text = '';
6602 $chunks_in_this_source = 1; // Default for non-chunked content
6603
6604 if ($group['is_chunked']) {
6605 // Calculate how many chunks we can still use (respect both total and per-source caps)
6606 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
6607
6608 // Fetch chunks for this URL with limit
6609 $full_text = $this->reassemble_chunks_from_wordpress($source_url, $chunks_remaining, $chunks_in_this_source);
6610
6611 // If fetching all chunks fails, fall back to matched chunks
6612 if (empty($full_text)) {
6613 // Sort matched chunks by index and concatenate
6614 usort($group['chunks'], function($a, $b) {
6615 return $a['chunk_index'] <=> $b['chunk_index'];
6616 });
6617
6618 $chunk_texts = array();
6619 $chunks_in_this_source = 0;
6620 foreach ($group['chunks'] as $chunk) {
6621 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
6622 break;
6623 }
6624 $chunk_texts[] = $chunk['text'];
6625 $chunks_in_this_source++;
6626 }
6627 $full_text = implode("\n\n", $chunk_texts);
6628 }
6629 } else {
6630 $full_text = $group['single_text'];
6631 $chunks_in_this_source = 1;
6632 }
6633
6634 if (!empty($full_text)) {
6635 // Strip URLs from content if citation links are disabled
6636 if (!$citation_links_enabled) {
6637 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
6638 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
6639 }
6640
6641 // Use numbered reference for URL-based entries, plain info label for manual entries
6642 // Manual entries are stored with an internal mxchat:// placeholder URL — never expose them as citations
6643 if (!empty($source_url) && $source_url !== '#' && strpos($source_url, 'mxchat://') !== 0) {
6644 $matches_used++;
6645 $content .= "## Reference " . $matches_used . " ##\n";
6646 $content .= $full_text . "\n\n";
6647
6648 // Only include citation URLs if citation links are enabled
6649 if ($citation_links_enabled) {
6650 $valid_urls[] = $source_url;
6651 $content .= "URL: " . $source_url . "\n\n";
6652 }
6653
6654 // Video-backed source → queue the consent-safe embed (03ba33)
6655 $this->maybe_queue_youtube_embed($source_url, $full_text);
6656 } else {
6657 // Manual entry — no reference number, no citation
6658 $content .= "## Information ##\n";
6659 $content .= $full_text . "\n\n";
6660 }
6661
6662 // Extract any URLs from the text content itself (only if citation links enabled)
6663 if ($citation_links_enabled) {
6664 preg_match_all(
6665 '#\bhttps?://[^\s<>"\']+#i',
6666 $full_text,
6667 $content_urls
6668 );
6669 if (!empty($content_urls[0])) {
6670 $valid_urls = array_merge($valid_urls, $content_urls[0]);
6671 }
6672 }
6673
6674 $total_chunks_used += $chunks_in_this_source;
6675 }
6676 }
6677
6678 // NEW: Store unique valid URLs for validation
6679 $this->current_valid_urls = array_unique($valid_urls);
6680
6681 // Store sources and chunks counts for testing/transcript display
6682 $this->last_similarity_analysis['sources_used'] = $matches_used;
6683 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
6684
6685 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
6686 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
6687
6688 // Add response guidelines
6689 if (empty($top_urls)) {
6690 // No matched sources: return empty so the prompt assembler's
6691 // "NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE" branch fires —
6692 // a no-info sentence wrapped in OFFICIAL KNOWLEDGE markers reads to
6693 // the model as authoritative content (plan d7daf8).
6694 $content = '';
6695 } else {
6696 // Build response guidelines based on citation links setting
6697 $content .= "\n## Response Guidelines ##\n" .
6698 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
6699 "Be conversational and friendly, but never mention your knowledge base or training data. " .
6700 "If you don't have specific information or are uncertain about any details, it's always " .
6701 "better to honestly say you don't know rather than making up or guessing at answers. " .
6702 "When information is incomplete, let them know you are unsure.\n\n";
6703
6704 // Only add hyperlink instructions if citation links are enabled
6705 if ($citation_links_enabled) {
6706 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
6707 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
6708 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
6709 } else {
6710 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
6711 "Simply provide helpful answers based on the reference information without citing sources.";
6712 }
6713 }
6714
6715 return trim($content);
6716 }
6717
6718 /**
6719 * plan-mxchat-20260717-03ba33 — if a KB source used for context is a single
6720 * YouTube video, queue ONE consent-safe embed for the response html channel.
6721 * Called from BOTH retrieval builders (WordPress DB + Pinecone) inside their
6722 * real-URL winner branch, in ranked order — so the first (best) video wins and
6723 * later matches are ignored. Only KB/admin-ingested sources ever reach this
6724 * point; a URL a visitor pastes in chat never does.
6725 */
6726 private function maybe_queue_youtube_embed($source_url, $full_text) {
6727 if (!empty($this->videoEmbedHtml)) {
6728 return; // one video per response
6729 }
6730 $video_id = MxChat_Utils::parse_youtube_id($source_url);
6731 if (empty($video_id)) {
6732 return;
6733 }
6734 // Ingestion writes "YouTube Video: {title}" / "Channel: {name}" / "URL: …"
6735 // header lines into the indexed text. NOTE: when citation links are
6736 // disabled the winner loop collapses ALL whitespace to single spaces
6737 // before this runs, so the title must be terminated by the next header
6738 // label, not by end-of-line. Fall back to a generic label when absent
6739 // (e.g. a YouTube watch page imported through the plain URL source).
6740 $title = '';
6741 if (preg_match('/YouTube Video:\s*(.+?)(?=\s+Channel:\s|\s+URL:\s|\r|\n|$)/i', (string) $full_text, $m)) {
6742 $title = trim(mb_substr(trim($m[1]), 0, 140));
6743 if (preg_match('#^https?://#i', $title)) {
6744 $title = ''; // header carried the URL, not a real title
6745 }
6746 }
6747 $this->videoEmbedHtml = $this->build_youtube_embed_html($video_id, $title, $source_url);
6748 }
6749
6750 /**
6751 * Consent-safe click-to-load YouTube facade. No Google iframe is created until
6752 * the visitor taps play (chat-script.js swaps the facade for a
6753 * youtube-nocookie.com iframe). The caption always carries a plain "Watch on
6754 * YouTube" link, which is also the graceful degrade on strict-CSP sites where
6755 * third-party frames are blocked.
6756 */
6757 private function build_youtube_embed_html($video_id, $title, $watch_url) {
6758 $video_id = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $video_id);
6759 if ($video_id === '') {
6760 return '';
6761 }
6762 $thumb = 'https://i.ytimg.com/vi/' . $video_id . '/hqdefault.jpg';
6763 $label = ($title !== '') ? $title : __('YouTube video', 'mxchat');
6764
6765 $html = '<div class="mxchat-youtube-embed" data-video-id="' . esc_attr($video_id) . '">';
6766 $html .= '<button type="button" class="mxchat-youtube-facade" aria-label="' . esc_attr(sprintf(__('Play video: %s', 'mxchat'), $label)) . '">';
6767 $html .= '<img class="mxchat-youtube-thumb" src="' . esc_url($thumb) . '" alt="' . esc_attr($label) . '" loading="lazy" />';
6768 $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>';
6769 $html .= '</button>';
6770 $html .= '<div class="mxchat-youtube-caption">';
6771 $html .= '<span class="mxchat-youtube-title">' . esc_html($label) . '</span>';
6772 $html .= '<a class="mxchat-youtube-link" href="' . esc_url($watch_url) . '" target="_blank" rel="noopener noreferrer">' . esc_html__('Watch on YouTube', 'mxchat') . '</a>';
6773 $html .= '</div>';
6774 $html .= '</div>';
6775 return $html;
6776 }
6777
6778 /**
6779 * Fetch and reassemble chunks for a URL from WordPress database
6780 *
6781 * @param string $source_url The source URL to fetch chunks for
6782 * @param int $max_chunks Maximum number of chunks to return (0 = unlimited)
6783 * @param int &$chunk_count Reference to store the actual number of chunks returned
6784 * @return string Reassembled content from chunks
6785 */
6786 private function reassemble_chunks_from_wordpress($source_url, $max_chunks = 0, &$chunk_count = 0) {
6787 global $wpdb;
6788 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
6789
6790 // Fetch all rows with this source_url
6791 $rows = $wpdb->get_results($wpdb->prepare(
6792 "SELECT article_content FROM {$table}
6793 WHERE source_url = %s
6794 ORDER BY id ASC",
6795 $source_url
6796 ));
6797
6798 if (empty($rows)) {
6799 $chunk_count = 0;
6800 return '';
6801 }
6802
6803 // Parse and sort chunks by index
6804 $chunks = array();
6805 foreach ($rows as $row) {
6806 $parsed = MxChat_Chunker::parse_stored_chunk($row->article_content);
6807
6808 if ($parsed['is_chunked']) {
6809 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
6810 $chunks[$chunk_index] = $parsed['text'];
6811 } else {
6812 // Non-chunked content - just return it
6813 $chunks[] = $parsed['text'];
6814 }
6815 }
6816
6817 // Sort by chunk index
6818 ksort($chunks);
6819
6820 // Apply chunk limit if specified
6821 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
6822 $chunks = array_slice($chunks, 0, $max_chunks, true);
6823 }
6824
6825 // Store actual chunk count
6826 $chunk_count = count($chunks);
6827
6828 // Reassemble content
6829 return implode("\n\n", $chunks);
6830 }
6831
6832 private function find_relevant_content_pinecone($user_embedding, $bot_id = 'default', $bot_config = null) {
6833 global $wpdb;
6834
6835 //error_log("MXCHAT DEBUG: find_relevant_content_pinecone called");
6836 //error_log(" - bot_id: " . $bot_id);
6837 //error_log(" - user_embedding is array: " . (is_array($user_embedding) ? 'yes' : 'no'));
6838 //error_log(" - user_embedding count: " . (is_array($user_embedding) ? count($user_embedding) : 'N/A'));
6839
6840 // Use bot-specific config or fall back to default
6841 if ($bot_config === null) {
6842 $bot_config = $this->get_bot_pinecone_config($bot_id);
6843 }
6844
6845 $api_key = $bot_config['api_key'] ?? '';
6846 $host = $bot_config['host'] ?? '';
6847 $namespace = $bot_config['namespace'] ?? '';
6848
6849 //error_log("MXCHAT DEBUG: Pinecone query parameters:");
6850 //error_log(" - API Key: " . (empty($api_key) ? 'EMPTY - ERROR!' : 'Present (length: ' . strlen($api_key) . ')'));
6851 //error_log(" - Host: " . (empty($host) ? 'EMPTY - ERROR!' : $host));
6852 //error_log(" - Namespace: " . (empty($namespace) ? 'EMPTY (will use default)' : $namespace));
6853
6854 // Initialize similarity analysis storage
6855 $this->last_similarity_analysis = [
6856 'knowledge_base_type' => 'Pinecone',
6857 'bot_id' => $bot_id,
6858 'namespace' => $namespace,
6859 'top_matches' => [],
6860 'threshold_used' => 0,
6861 'total_checked' => 0
6862 ];
6863
6864 // NEW: Initialize valid URLs array
6865 $valid_urls = [];
6866
6867 if (empty($host) || empty($api_key)) {
6868 //error_log("MXCHAT DEBUG ERROR: Missing Pinecone host or API key!");
6869 //error_log(" - Host empty: " . (empty($host) ? 'YES' : 'NO'));
6870 //error_log(" - API key empty: " . (empty($api_key) ? 'YES' : 'NO'));
6871 // Store empty array for valid URLs since we can't proceed
6872 $this->current_valid_urls = [];
6873 return '';
6874 }
6875
6876 // Get knowledge manager instance for role checking
6877 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
6878
6879 // Get the similarity threshold from the bot options or main options
6880 $bot_options = $this->get_bot_options($bot_id);
6881 $current_options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', []);
6882
6883 $similarity_threshold = isset($current_options['similarity_threshold'])
6884 ? ((int) $current_options['similarity_threshold']) / 100
6885 : 0.35;
6886
6887 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
6888
6889 // Prepare the query request for Pinecone
6890 $api_endpoint = "https://{$host}/query";
6891
6892 $request_body = array(
6893 'vector' => $user_embedding,
6894 'topK' => 50, // Increased for chunked content grouping - need more candidates to find top N unique URLs
6895 'includeMetadata' => true,
6896 'includeValues' => true
6897 );
6898
6899 // Add namespace if specified for this bot
6900 if (!empty($namespace)) {
6901 $request_body['namespace'] = $namespace;
6902 }
6903
6904 //error_log("MXCHAT DEBUG: About to call Pinecone API");
6905 //error_log(" - Endpoint: " . $api_endpoint);
6906 //error_log(" - Namespace in request: " . (!empty($namespace) ? $namespace : 'NOT SET'));
6907
6908 $response = wp_remote_post($api_endpoint, array(
6909 'headers' => array(
6910 'Api-Key' => $api_key,
6911 'accept' => 'application/json',
6912 'content-type' => 'application/json'
6913 ),
6914 'body' => wp_json_encode($request_body),
6915 'timeout' => 30
6916 ));
6917
6918 if (is_wp_error($response)) {
6919 //error_log("MXCHAT DEBUG ERROR: WP Error in Pinecone request: " . $response->get_error_message());
6920 // Store empty array for valid URLs
6921 $this->current_valid_urls = [];
6922 return '';
6923 }
6924
6925 $response_code = wp_remote_retrieve_response_code($response);
6926 //error_log("MXCHAT DEBUG: Pinecone response code: " . $response_code);
6927
6928 if ($response_code !== 200) {
6929 $response_body = wp_remote_retrieve_body($response);
6930 //error_log("MXCHAT DEBUG ERROR: Pinecone API error response: " . substr($response_body, 0, 500));
6931 // Store empty array for valid URLs
6932 $this->current_valid_urls = [];
6933 return '';
6934 }
6935
6936 // ADD DETAILED DEBUG SECTION HERE
6937 $response_body = wp_remote_retrieve_body($response);
6938 //error_log("MXCHAT DEBUG: Raw Pinecone response length: " . strlen($response_body));
6939
6940 $results = json_decode($response_body, true);
6941
6942 if (json_last_error() !== JSON_ERROR_NONE) {
6943 //error_log("MXCHAT DEBUG ERROR: JSON decode error: " . json_last_error_msg());
6944 //error_log("MXCHAT DEBUG: First 500 chars of response: " . substr($response_body, 0, 500));
6945 // Store empty array for valid URLs
6946 $this->current_valid_urls = [];
6947 return '';
6948 }
6949
6950 //error_log("MXCHAT DEBUG: Pinecone response structure:");
6951 //error_log(" - Has 'matches' key: " . (isset($results['matches']) ? 'yes' : 'no'));
6952 //error_log(" - Has 'namespace' key: " . (isset($results['namespace']) ? 'yes (' . $results['namespace'] . ')' : 'no'));
6953
6954 if (empty($results['matches'])) {
6955 //error_log("MXCHAT DEBUG: No matches found in Pinecone response");
6956 //error_log("MXCHAT DEBUG: Response keys: " . implode(', ', array_keys($results)));
6957 // Store empty array for valid URLs
6958 $this->current_valid_urls = [];
6959 return '';
6960 }
6961
6962 //error_log("MXCHAT DEBUG: Found " . count($results['matches']) . " matches in Pinecone");
6963
6964 // Log first match details for debugging
6965 if (!empty($results['matches'][0])) {
6966 $first_match = $results['matches'][0];
6967 //error_log("MXCHAT DEBUG: First match details:");
6968 //error_log(" - Score: " . ($first_match['score'] ?? 'no score'));
6969 //error_log(" - Has metadata: " . (isset($first_match['metadata']) ? 'yes' : 'no'));
6970 if (isset($first_match['metadata'])) {
6971 //error_log(" - Metadata keys: " . implode(', ', array_keys($first_match['metadata'])));
6972 }
6973 }
6974
6975 // Initialize the final content
6976 $content = '';
6977 $matches_used = 0;
6978 $matches_used_for_context = [];
6979 $total_chunks_used = 0;
6980 $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
6981 if ($max_total_chunks < 8) $max_total_chunks = 8;
6982 if ($max_total_chunks > 20) $max_total_chunks = 20;
6983 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
6984
6985 // Check if citation links are enabled (default to 'on' for backwards compatibility)
6986 // Use fresh options to ensure we get the latest setting value
6987 $fresh_options = get_option('mxchat_options', []);
6988 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6989
6990 // NEW CHUNKING LOGIC: Group results by source_url for chunk reassembly
6991 $url_groups = array();
6992
6993 foreach ($results['matches'] as $index => $match) {
6994 // Skip if similarity is below threshold
6995 if ($match['score'] < $similarity_threshold) {
6996 continue;
6997 }
6998
6999 $metadata = $match['metadata'] ?? array();
7000 $source_url = $metadata['source_url'] ?? '';
7001 $match_id = $match['id'] ?? '';
7002
7003 // LAZY ROLE CHECK: Only check role for content we're actually considering
7004 $role_restriction = $this->get_single_vector_role($match_id, $metadata);
7005 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
7006
7007 // Skip if user doesn't have access
7008 if (!$has_access) {
7009 continue;
7010 }
7011
7012 // Use a unique key for manual entries without a source URL
7013 $group_key = !empty($source_url) ? $source_url : '_manual_' . $match_id;
7014
7015 // Group by source URL (or unique key for manual entries)
7016 if (!isset($url_groups[$group_key])) {
7017 $url_groups[$group_key] = array(
7018 'source_url' => $source_url,
7019 'best_score' => 0,
7020 'is_chunked' => isset($metadata['is_chunked']) && $metadata['is_chunked'],
7021 'chunks' => array(),
7022 'single_text' => ''
7023 );
7024 }
7025
7026 // Track best score for this group
7027 if ($match['score'] > $url_groups[$group_key]['best_score']) {
7028 $url_groups[$group_key]['best_score'] = $match['score'];
7029 }
7030
7031 // Store chunk info or single text
7032 if ($url_groups[$group_key]['is_chunked']) {
7033 $url_groups[$group_key]['chunks'][] = array(
7034 'id' => $match_id,
7035 'score' => $match['score'],
7036 'chunk_index' => $metadata['chunk_index'] ?? 0,
7037 'text' => $metadata['text'] ?? ''
7038 );
7039 } else {
7040 // Non-chunked content - just store the text
7041 $url_groups[$group_key]['single_text'] = $metadata['text'] ?? '';
7042 $url_groups[$group_key]['single_id'] = $match_id;
7043 }
7044 }
7045
7046 // Sort URL groups by best score (highest first)
7047 uasort($url_groups, function($a, $b) {
7048 return $b['best_score'] <=> $a['best_score'];
7049 });
7050
7051 // Get RAG sources limit from options (default 6, min 3, max 10)
7052 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
7053 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
7054 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
7055
7056 // Take top N unique URLs based on user setting
7057 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
7058
7059 // Track which match IDs are actually used for context
7060 foreach ($top_urls as $group) {
7061 if ($group['is_chunked']) {
7062 foreach ($group['chunks'] as $chunk) {
7063 $matches_used_for_context[] = $chunk['id'];
7064 }
7065 } elseif (!empty($group['single_id'])) {
7066 $matches_used_for_context[] = $group['single_id'];
7067 }
7068 }
7069
7070 // Build content from top sources
7071 foreach ($top_urls as $group_key => $group) {
7072 $source_url = $group['source_url']; // Use actual source_url, not the group key
7073
7074 // Stop if we've hit the total chunk limit
7075 if ($total_chunks_used >= $max_total_chunks) {
7076 break;
7077 }
7078
7079 $full_text = '';
7080 $chunks_in_this_source = 1; // Default for non-chunked content
7081
7082 if ($group['is_chunked']) {
7083 // Calculate how many chunks we can still use (respect both total and per-source caps)
7084 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
7085
7086 // Fetch chunks for this URL with limit
7087 $full_text = $this->reassemble_chunks_from_pinecone($source_url, $bot_config, $chunks_remaining, $chunks_in_this_source);
7088
7089 // If fetching all chunks fails, fall back to matched chunks
7090 if (empty($full_text)) {
7091 // Sort matched chunks by index and concatenate
7092 usort($group['chunks'], function($a, $b) {
7093 return $a['chunk_index'] <=> $b['chunk_index'];
7094 });
7095
7096 $chunk_texts = array();
7097 $chunks_in_this_source = 0;
7098 foreach ($group['chunks'] as $chunk) {
7099 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
7100 break;
7101 }
7102 $chunk_texts[] = $chunk['text'];
7103 $chunks_in_this_source++;
7104 }
7105 $full_text = implode("\n\n", $chunk_texts);
7106 }
7107 } else {
7108 $full_text = $group['single_text'];
7109 $chunks_in_this_source = 1;
7110 }
7111
7112 if (!empty($full_text)) {
7113 // Strip URLs from content if citation links are disabled
7114 if (!$citation_links_enabled) {
7115 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
7116 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
7117 }
7118
7119 // Use numbered reference for URL-based entries, plain info label for manual entries
7120 // Manual entries are stored with an internal mxchat:// placeholder URL — never expose them as citations
7121 if (!empty($source_url) && $source_url !== '#' && strpos($source_url, 'mxchat://') !== 0) {
7122 $matches_used++;
7123 $content .= "## Reference " . $matches_used . " ##\n";
7124 $content .= $full_text . "\n\n";
7125
7126 // Only include citation URLs if citation links are enabled
7127 if ($citation_links_enabled) {
7128 $valid_urls[] = $source_url;
7129 $content .= "URL: " . $source_url . "\n\n";
7130 }
7131
7132 // Video-backed source → queue the consent-safe embed (03ba33)
7133 $this->maybe_queue_youtube_embed($source_url, $full_text);
7134 } else {
7135 // Manual entry — no reference number, no citation. Count it as a USED
7136 // source (plan-mxchat-20260622-c1fe6a): without this, manual/Direct-Content
7137 // entries (empty or mxchat:// source_url) never increment $matches_used, so
7138 // the gate below (`if ($matches_used === 0)`) discards manual-only context on
7139 // the Pinecone backend and the model is told "No reference information was
7140 // found" — even though the testing panel reports used_for_context:true. It
7141 // also corrects the cosmetic sources_used:0 the panel/transcript showed. The
7142 // sibling local/WP-DB builder gates on empty($top_urls), so it never had this
7143 // bug; this brings Pinecone to parity. Manual entries are still uncited (not
7144 // added to $valid_urls, no "URL:" line).
7145 $matches_used++;
7146 $content .= "## Information ##\n";
7147 $content .= $full_text . "\n\n";
7148 }
7149
7150 // Extract any URLs from the text content itself (only if citation links enabled)
7151 if ($citation_links_enabled) {
7152 preg_match_all(
7153 '#\bhttps?://[^\s<>"\']+#i',
7154 $full_text,
7155 $content_urls
7156 );
7157 if (!empty($content_urls[0])) {
7158 $valid_urls = array_merge($valid_urls, $content_urls[0]);
7159 }
7160 }
7161
7162 $total_chunks_used += $chunks_in_this_source;
7163 }
7164 }
7165
7166 // Process ALL matches for testing data (top 10) - with role checking for testing display
7167 $all_matches = [];
7168 foreach ($results['matches'] as $index => $match) {
7169 if ($index >= 10) break; // Limit to top 10 for testing
7170
7171 $match_id = $match['id'] ?? '';
7172
7173 // Check role access for testing display (use cache if available)
7174 $role_restriction = $this->get_single_vector_role($match_id, $match['metadata']);
7175 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
7176
7177 $source_display = '';
7178 if (!empty($match['metadata']['source_url'])) {
7179 $source_display = $match['metadata']['source_url'];
7180 } else {
7181 $content_preview = strip_tags($match['metadata']['text'] ?? '');
7182 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
7183 $source_display = substr(trim($content_preview), 0, 50) . '...';
7184 }
7185
7186 $match_id_for_display = $match['id'] ?? $index;
7187
7188 // Check for chunk metadata in Pinecone
7189 $is_chunk = isset($match['metadata']['is_chunked']) && $match['metadata']['is_chunked'];
7190 $chunk_index = isset($match['metadata']['chunk_index']) ? intval($match['metadata']['chunk_index']) : null;
7191 $total_chunks = isset($match['metadata']['total_chunks']) ? intval($match['metadata']['total_chunks']) : null;
7192
7193 // Also detect chunk from vector ID pattern: {hash}_chunk_{index}
7194 if (!$is_chunk && MxChat_Chunker::is_chunk_vector_id($match_id_for_display)) {
7195 $is_chunk = true;
7196 }
7197
7198 $all_matches[] = [
7199 'document_id' => $match_id_for_display,
7200 'similarity' => $match['score'],
7201 'similarity_percentage' => round($match['score'] * 100, 2),
7202 'above_threshold' => $match['score'] >= $similarity_threshold,
7203 'source_display' => $source_display,
7204 'content_preview' => substr(strip_tags($match['metadata']['text'] ?? ''), 0, 100) . '...',
7205 'used_for_context' => in_array($match_id_for_display, $matches_used_for_context),
7206 'role_restriction' => $role_restriction,
7207 'has_access' => $has_access,
7208 'filtered_out' => !$has_access,
7209 'is_chunk' => $is_chunk,
7210 'chunk_index' => $chunk_index,
7211 'total_chunks' => $total_chunks
7212 ];
7213 }
7214
7215 // Store for testing panel
7216 $this->last_similarity_analysis['top_matches'] = $all_matches;
7217 $this->last_similarity_analysis['total_checked'] = count($results['matches']);
7218 $this->last_similarity_analysis['sources_used'] = $matches_used;
7219 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
7220
7221 // NEW: Store unique valid URLs for validation
7222 $this->current_valid_urls = array_unique($valid_urls);
7223
7224 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
7225 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
7226
7227 // Add response guidelines
7228 if ($matches_used === 0) {
7229 // Empty return → assembler's NO RELEVANT CONTENT branch (plan d7daf8).
7230 $content = '';
7231 } else {
7232 // Build response guidelines based on citation links setting
7233 $content .= "\n## Response Guidelines ##\n" .
7234 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
7235 "Be conversational and friendly, but never mention your knowledge base or training data. " .
7236 "If you don't have specific information or are uncertain about any details, it's always " .
7237 "better to honestly say you don't know rather than making up or guessing at answers. " .
7238 "When information is incomplete, let them know you are unsure.\n\n";
7239
7240 // Only add hyperlink instructions if citation links are enabled
7241 if ($citation_links_enabled) {
7242 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
7243 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
7244 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
7245 } else {
7246 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
7247 "Simply provide helpful answers based on the reference information without citing sources.";
7248 }
7249 }
7250
7251 return trim($content);
7252 }
7253
7254 /**
7255 * Get role restriction for a single vector (with caching)
7256 */
7257 private function get_single_vector_role($vector_id, $metadata = array()) {
7258 global $wpdb;
7259
7260 if (empty($vector_id)) {
7261 return 'public';
7262 }
7263
7264 // Check cache first
7265 $cache_key = 'mxchat_vector_role_' . $vector_id;
7266 $cached_role = wp_cache_get($cache_key, 'mxchat_vector_roles');
7267
7268 if ($cached_role !== false) {
7269 return $cached_role;
7270 }
7271
7272 $role_restriction = 'public';
7273
7274 // First try Pinecone metadata
7275 if (!empty($metadata['role_restriction'])) {
7276 $role_restriction = $metadata['role_restriction'];
7277 } else {
7278 // Check WordPress table for user-modified roles
7279 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
7280 $stored_role = $wpdb->get_var($wpdb->prepare(
7281 "SELECT role_restriction FROM {$roles_table} WHERE vector_id = %s",
7282 $vector_id
7283 ));
7284
7285 if ($stored_role) {
7286 $role_restriction = $stored_role;
7287 }
7288 }
7289
7290 // Cache individual role for 1 hour
7291 wp_cache_set($cache_key, $role_restriction, 'mxchat_vector_roles', 3600);
7292
7293 return $role_restriction;
7294 }
7295
7296 /**
7297 * Fetch and reassemble all chunks for a URL from Pinecone
7298 *
7299 * @param string $source_url The source URL to fetch chunks for
7300 * @param array $bot_config Bot-specific Pinecone configuration
7301 * @return string Reassembled content from all chunks
7302 */
7303 private function reassemble_chunks_from_pinecone($source_url, $bot_config, $max_chunks = 0, &$chunk_count = 0) {
7304 $api_key = $bot_config['api_key'] ?? '';
7305 $host = $bot_config['host'] ?? '';
7306 $namespace = $bot_config['namespace'] ?? '';
7307
7308 if (empty($host) || empty($api_key)) {
7309 $chunk_count = 0;
7310 return '';
7311 }
7312
7313 $base_hash = md5($source_url);
7314
7315 // Use Pinecone list API to find all chunk vectors with this prefix
7316 $list_url = "https://{$host}/vectors/list";
7317
7318 // Limit to max_chunks if specified, otherwise fetch up to 100
7319 $fetch_limit = ($max_chunks > 0 && $max_chunks < 100) ? $max_chunks : 100;
7320
7321 $list_body = array(
7322 'prefix' => $base_hash . '_chunk_',
7323 'limit' => $fetch_limit
7324 );
7325
7326 if (!empty($namespace)) {
7327 $list_body['namespace'] = $namespace;
7328 }
7329
7330 $list_response = wp_remote_post($list_url, array(
7331 'headers' => array(
7332 'Api-Key' => $api_key,
7333 'accept' => 'application/json',
7334 'content-type' => 'application/json'
7335 ),
7336 'body' => wp_json_encode($list_body),
7337 'timeout' => 30
7338 ));
7339
7340 if (is_wp_error($list_response)) {
7341 //error_log('[MXCHAT-CHUNK] List API error: ' . $list_response->get_error_message());
7342 return '';
7343 }
7344
7345 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
7346
7347 if (empty($list_data['vectors'])) {
7348 //error_log('[MXCHAT-CHUNK] No chunk vectors found for URL: ' . $source_url);
7349 return '';
7350 }
7351
7352 // Extract vector IDs
7353 $vector_ids = array();
7354 foreach ($list_data['vectors'] as $vector) {
7355 if (isset($vector['id'])) {
7356 $vector_ids[] = $vector['id'];
7357 }
7358 }
7359
7360 if (empty($vector_ids)) {
7361 return '';
7362 }
7363
7364 // Fetch all chunk content
7365 $fetch_url = "https://{$host}/vectors/fetch";
7366
7367 $fetch_body = array(
7368 'ids' => $vector_ids
7369 );
7370
7371 if (!empty($namespace)) {
7372 $fetch_body['namespace'] = $namespace;
7373 }
7374
7375 $fetch_response = wp_remote_post($fetch_url, array(
7376 'headers' => array(
7377 'Api-Key' => $api_key,
7378 'accept' => 'application/json',
7379 'content-type' => 'application/json'
7380 ),
7381 'body' => wp_json_encode($fetch_body),
7382 'timeout' => 30
7383 ));
7384
7385 if (is_wp_error($fetch_response)) {
7386 //error_log('[MXCHAT-CHUNK] Fetch API error: ' . $fetch_response->get_error_message());
7387 return '';
7388 }
7389
7390 $fetch_data = json_decode(wp_remote_retrieve_body($fetch_response), true);
7391
7392 if (empty($fetch_data['vectors'])) {
7393 return '';
7394 }
7395
7396 // Sort chunks by index and reassemble
7397 $chunks = array();
7398 foreach ($fetch_data['vectors'] as $id => $vector) {
7399 $metadata = $vector['metadata'] ?? array();
7400 $chunk_index = $metadata['chunk_index'] ?? 0;
7401 $text = $metadata['text'] ?? '';
7402
7403 // Store chunk with its index
7404 $chunks[$chunk_index] = $text;
7405 }
7406
7407 // Sort by chunk index
7408 ksort($chunks);
7409
7410 // Apply chunk limit if specified
7411 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
7412 $chunks = array_slice($chunks, 0, $max_chunks, true);
7413 }
7414
7415 // Store actual chunk count
7416 $chunk_count = count($chunks);
7417
7418 // Reassemble content
7419 return implode("\n\n", $chunks);
7420 }
7421
7422 /**
7423 * Search for relevant content using OpenAI Vector Store (File Search)
7424 *
7425 * @param string $user_query The user's query text
7426 * @param string $bot_id The bot ID
7427 * @param array $vectorstore_config Vector Store configuration
7428 * @return string Formatted context string with references
7429 */
7430 private function find_relevant_content_openai_vectorstore($user_query, $bot_id = 'default', $vectorstore_config = array()) {
7431 //error_log("MXCHAT DEBUG: find_relevant_content_openai_vectorstore called");
7432 //error_log(" - bot_id: " . $bot_id);
7433 //error_log(" - user_query length: " . strlen($user_query));
7434
7435 // Get OpenAI API key
7436 $mxchat_options = get_option('mxchat_options', array());
7437 $api_key = $mxchat_options['api_key'] ?? '';
7438
7439 // Reset vectorstore error tracking
7440 $this->last_vectorstore_error = null;
7441
7442 if (empty($api_key)) {
7443 //error_log("MXCHAT DEBUG ERROR: OpenAI API key not configured");
7444 $this->last_vectorstore_error = 'Vector Store search failed: OpenAI API key is not configured.';
7445 $this->current_valid_urls = [];
7446 return '';
7447 }
7448
7449 // Get Vector Store configuration
7450 if (empty($vectorstore_config)) {
7451 $vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
7452 }
7453
7454 $vectorstore_ids_string = $vectorstore_config['vectorstore_ids'] ?? '';
7455 $max_results = $vectorstore_config['max_results'] ?? 5;
7456
7457 if (empty($vectorstore_ids_string)) {
7458 //error_log("MXCHAT DEBUG ERROR: No Vector Store IDs configured");
7459 $this->last_vectorstore_error = 'Vector Store search failed: No Vector Store IDs are configured for this bot.';
7460 $this->current_valid_urls = [];
7461 return '';
7462 }
7463
7464 // Parse Vector Store IDs
7465 $vectorstore_ids = array_map('trim', explode(',', $vectorstore_ids_string));
7466 $vectorstore_ids = array_filter($vectorstore_ids); // Remove empty values
7467
7468 //error_log("MXCHAT DEBUG: Vector Store IDs: " . implode(', ', $vectorstore_ids));
7469 //error_log("MXCHAT DEBUG: Max results: " . $max_results);
7470
7471 // Initialize similarity analysis storage
7472 $this->last_similarity_analysis = [
7473 'knowledge_base_type' => 'OpenAI Vector Store',
7474 'bot_id' => $bot_id,
7475 'vectorstore_ids' => $vectorstore_ids,
7476 'top_matches' => [],
7477 'threshold_used' => 0,
7478 'total_checked' => 0
7479 ];
7480
7481 $valid_urls = [];
7482
7483 // Get the selected model
7484 $bot_options = $this->get_bot_options($bot_id);
7485 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
7486 $selected_model = $current_options['model'] ?? 'gpt-5.6-sol';
7487
7488 // Verify it's an OpenAI model
7489 if (!$this->is_openai_chat_model($selected_model)) {
7490 //error_log("MXCHAT DEBUG ERROR: Vector Store search requires OpenAI model. Current: " . $selected_model);
7491 $this->last_vectorstore_error = 'Vector Store search requires an OpenAI model. Current model: ' . $selected_model;
7492 $this->current_valid_urls = [];
7493 return '';
7494 }
7495
7496 // Use OpenAI Responses API with file_search tool
7497 $request_body = array(
7498 'model' => $selected_model,
7499 'input' => $user_query,
7500 'tools' => array(
7501 array(
7502 'type' => 'file_search',
7503 'vector_store_ids' => $vectorstore_ids,
7504 'max_num_results' => intval($max_results)
7505 )
7506 ),
7507 'include' => array('output[*].file_search_call.search_results')
7508 );
7509
7510 //error_log("MXCHAT VECTORSTORE: ========== REQUEST START ==========");
7511 //error_log("MXCHAT VECTORSTORE: Model: " . $selected_model);
7512 //error_log("MXCHAT VECTORSTORE: Query: " . substr($user_query, 0, 200));
7513 //error_log("MXCHAT VECTORSTORE: Vector Store IDs: " . implode(', ', $vectorstore_ids));
7514 //error_log("MXCHAT VECTORSTORE: Max Results: " . $max_results);
7515 //error_log("MXCHAT VECTORSTORE: Request body: " . wp_json_encode($request_body));
7516
7517 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
7518 'headers' => array(
7519 'Authorization' => 'Bearer ' . $api_key,
7520 'Content-Type' => 'application/json'
7521 ),
7522 'body' => wp_json_encode($request_body),
7523 'timeout' => 60
7524 ));
7525
7526 if (is_wp_error($response)) {
7527 //error_log("MXCHAT VECTORSTORE ERROR: WP Error: " . $response->get_error_message());
7528 $this->last_vectorstore_error = 'Vector Store API request failed: ' . $response->get_error_message();
7529 $this->current_valid_urls = [];
7530 return '';
7531 }
7532
7533 $response_code = wp_remote_retrieve_response_code($response);
7534 //error_log("MXCHAT VECTORSTORE: Response code: " . $response_code);
7535
7536 $response_body = wp_remote_retrieve_body($response);
7537 //error_log("MXCHAT VECTORSTORE: Raw response (first 2000 chars): " . substr($response_body, 0, 2000));
7538
7539 if ($response_code !== 200) {
7540 //error_log("MXCHAT VECTORSTORE ERROR: API error response: " . $response_body);
7541 $decoded_error = json_decode($response_body, true);
7542 $api_error_detail = $this->extract_provider_error($decoded_error, '');
7543 $this->last_vectorstore_error = 'Vector Store API returned HTTP ' . $response_code . ($api_error_detail ? ': ' . $api_error_detail : '');
7544 $this->current_valid_urls = [];
7545 return '';
7546 }
7547 $result = json_decode($response_body, true);
7548
7549 if (json_last_error() !== JSON_ERROR_NONE) {
7550 //error_log("MXCHAT VECTORSTORE ERROR: JSON decode error: " . json_last_error_msg());
7551 $this->last_vectorstore_error = 'Vector Store response could not be parsed: ' . json_last_error_msg();
7552 $this->current_valid_urls = [];
7553 return '';
7554 }
7555
7556 // Debug: Log the structure of the result
7557 //error_log("MXCHAT VECTORSTORE: Result keys: " . implode(', ', array_keys($result)));
7558 if (isset($result['output'])) {
7559 //error_log("MXCHAT VECTORSTORE: Output count: " . count($result['output']));
7560 foreach ($result['output'] as $idx => $out) {
7561 //error_log("MXCHAT VECTORSTORE: Output[$idx] type: " . ($out['type'] ?? 'unknown'));
7562 //error_log("MXCHAT VECTORSTORE: Output[$idx] keys: " . implode(', ', array_keys($out)));
7563 }
7564 } else {
7565 //error_log("MXCHAT VECTORSTORE: No 'output' key in result!");
7566 }
7567
7568 // Extract file search results from the response
7569 $content = '';
7570 $matches_used = 0;
7571 $all_matches = [];
7572
7573 // The Responses API returns output array with tool results
7574 if (isset($result['output']) && is_array($result['output'])) {
7575 foreach ($result['output'] as $output_item) {
7576 // Look for file_search_call results
7577 if (isset($output_item['type']) && $output_item['type'] === 'file_search_call') {
7578 //error_log("MXCHAT VECTORSTORE: Found file_search_call output item");
7579 //error_log("MXCHAT VECTORSTORE: file_search_call keys: " . implode(', ', array_keys($output_item)));
7580
7581 // Check for search_results in the output item directly
7582 $search_results = $output_item['search_results'] ?? $output_item['results'] ?? [];
7583 //error_log("MXCHAT VECTORSTORE: Search results count: " . count($search_results));
7584
7585 if (empty($search_results)) {
7586 //error_log("MXCHAT VECTORSTORE: No search results found in file_search_call");
7587 //error_log("MXCHAT VECTORSTORE: file_search_call content: " . wp_json_encode($output_item));
7588 }
7589
7590 foreach ($search_results as $index => $search_result) {
7591 $filename = $search_result['filename'] ?? '';
7592 $score = $search_result['score'] ?? 0;
7593 $text_content = '';
7594
7595 // Extract text content from the result
7596 // The text can be directly on the result OR nested under content array
7597 if (isset($search_result['text']) && !empty($search_result['text'])) {
7598 // Direct text field (OpenAI's actual format)
7599 $text_content = $search_result['text'];
7600 //error_log("MXCHAT VECTORSTORE: Found text directly on result[$index], length: " . strlen($text_content));
7601 } elseif (isset($search_result['content']) && is_array($search_result['content'])) {
7602 // Nested content array format
7603 foreach ($search_result['content'] as $content_item) {
7604 if (isset($content_item['text'])) {
7605 $text_content .= $content_item['text'] . "\n";
7606 }
7607 }
7608 //error_log("MXCHAT VECTORSTORE: Found text in content array for result[$index], length: " . strlen($text_content));
7609 } else {
7610 //error_log("MXCHAT VECTORSTORE: No text found for result[$index]. Keys: " . implode(', ', array_keys($search_result)));
7611 }
7612
7613 if (!empty($text_content)) {
7614 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
7615 $content .= trim($text_content) . "\n\n";
7616
7617 if (!empty($filename)) {
7618 $content .= "Source: " . $filename . "\n\n";
7619 }
7620
7621 // Extract URLs from content
7622 preg_match_all(
7623 '#\bhttps?://[^\s<>"\']+#i',
7624 $text_content,
7625 $content_urls
7626 );
7627 if (!empty($content_urls[0])) {
7628 $valid_urls = array_merge($valid_urls, $content_urls[0]);
7629 }
7630
7631 $matches_used++;
7632 }
7633
7634 // Store for similarity analysis
7635 $all_matches[] = [
7636 'document_id' => $filename ?: ('result_' . $index),
7637 'similarity' => $score,
7638 'similarity_percentage' => round($score * 100, 2),
7639 'above_threshold' => true,
7640 'source_display' => $filename,
7641 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
7642 'used_for_context' => true,
7643 'role_restriction' => 'public',
7644 'has_access' => true,
7645 'filtered_out' => false
7646 ];
7647 }
7648 }
7649
7650 // Also check for message content with annotations (citations)
7651 if (isset($output_item['type']) && $output_item['type'] === 'message') {
7652 if (isset($output_item['content']) && is_array($output_item['content'])) {
7653 foreach ($output_item['content'] as $content_block) {
7654 if (isset($content_block['annotations']) && is_array($content_block['annotations'])) {
7655 foreach ($content_block['annotations'] as $annotation) {
7656 if (isset($annotation['filename'])) {
7657 $filename = $annotation['filename'];
7658 $score = $annotation['score'] ?? 0;
7659 $text_content = '';
7660
7661 if (isset($annotation['content']) && is_array($annotation['content'])) {
7662 foreach ($annotation['content'] as $ann_content) {
7663 if (isset($ann_content['text'])) {
7664 $text_content .= $ann_content['text'] . "\n";
7665 }
7666 }
7667 }
7668
7669 if (!empty($text_content) && $matches_used < $max_results) {
7670 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
7671 $content .= trim($text_content) . "\n\n";
7672 $content .= "Source: " . $filename . "\n\n";
7673
7674 preg_match_all(
7675 '#\bhttps?://[^\s<>"\']+#i',
7676 $text_content,
7677 $content_urls
7678 );
7679 if (!empty($content_urls[0])) {
7680 $valid_urls = array_merge($valid_urls, $content_urls[0]);
7681 }
7682
7683 $matches_used++;
7684
7685 $all_matches[] = [
7686 'document_id' => $filename,
7687 'similarity' => $score,
7688 'similarity_percentage' => round($score * 100, 2),
7689 'above_threshold' => true,
7690 'source_display' => $filename,
7691 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
7692 'used_for_context' => true,
7693 'role_restriction' => 'public',
7694 'has_access' => true,
7695 'filtered_out' => false
7696 ];
7697 }
7698 }
7699 }
7700 }
7701 }
7702 }
7703 }
7704 }
7705 }
7706
7707 // Store for testing panel
7708 $this->last_similarity_analysis['top_matches'] = $all_matches;
7709 $this->last_similarity_analysis['total_checked'] = count($all_matches);
7710
7711 // Store unique valid URLs for validation
7712 $this->current_valid_urls = array_unique($valid_urls);
7713
7714 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
7715 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
7716
7717 //error_log("MXCHAT VECTORSTORE: ========== SEARCH COMPLETE ==========");
7718 //error_log("MXCHAT VECTORSTORE: Matches used: " . $matches_used);
7719 //error_log("MXCHAT VECTORSTORE: All matches count: " . count($all_matches));
7720 //error_log("MXCHAT VECTORSTORE: Content length: " . strlen($content));
7721 if ($matches_used > 0) {
7722 //error_log("MXCHAT VECTORSTORE: Content preview: " . substr($content, 0, 500));
7723 }
7724
7725 // Check if citation links are enabled
7726 $citation_links_enabled = ($mxchat_options['citation_links_toggle'] ?? 'on') === 'on';
7727
7728 // Add response guidelines
7729 if ($matches_used === 0) {
7730 //error_log("MXCHAT VECTORSTORE: No matches found - returning empty reference message");
7731 // Empty return → assembler's NO RELEVANT CONTENT branch (plan d7daf8).
7732 $content = '';
7733 } else {
7734 // Build response guidelines based on citation links setting
7735 $content .= "\n## Response Guidelines ##\n" .
7736 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
7737 "Be conversational and friendly, but never mention your knowledge base or training data. " .
7738 "If you don't have specific information or are uncertain about any details, it's always " .
7739 "better to honestly say you don't know rather than making up or guessing at answers. " .
7740 "When information is incomplete, let them know you are unsure.\n\n";
7741
7742 // Only add hyperlink instructions if citation links are enabled
7743 if ($citation_links_enabled) {
7744 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
7745 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
7746 } else {
7747 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
7748 "Simply provide helpful answers based on the reference information without citing sources.";
7749 }
7750 }
7751
7752 //error_log("MXCHAT DEBUG: Vector Store search complete. Matches used: " . $matches_used);
7753
7754 return trim($content);
7755 }
7756
7757 /**
7758 * Check if the given model is an OpenAI chat model
7759 *
7760 * @param string $model The model ID
7761 * @return bool True if it's an OpenAI model
7762 */
7763 private function is_openai_chat_model($model) {
7764 $openai_prefixes = array('gpt-', 'o1-', 'o3-');
7765 foreach ($openai_prefixes as $prefix) {
7766 if (strpos($model, $prefix) === 0) {
7767 return true;
7768 }
7769 }
7770 return false;
7771 }
7772
7773 /**
7774 * Get bot-specific Vector Store configuration
7775 *
7776 * @param string $bot_id The bot ID
7777 * @return array Configuration array
7778 */
7779 private function get_bot_vectorstore_config($bot_id = 'default') {
7780 // Admin Testing tab bot → resolve the DEFAULT bot's backend (see
7781 // get_bot_pinecone_config). This getter already passes the real default
7782 // config into the filter, so it was not broken — normalized anyway so the
7783 // Testing bot can never drift from the front-end default.
7784 if ($bot_id === 'testing') {
7785 $bot_id = 'default';
7786 }
7787
7788 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
7789
7790 // Default global settings
7791 $default_config = array(
7792 'use_vectorstore' => ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1',
7793 'vectorstore_ids' => $vectorstore_options['mxchat_vectorstore_ids'] ?? '',
7794 'max_results' => $vectorstore_options['mxchat_vectorstore_max_results'] ?? 5
7795 );
7796
7797 // Allow multi-bot plugin to override with bot-specific settings
7798 $bot_config = apply_filters('mxchat_get_bot_vectorstore_config', $default_config, $bot_id);
7799
7800 // Preserve max_results from global settings if not set in bot config
7801 if (!isset($bot_config['max_results'])) {
7802 $bot_config['max_results'] = $default_config['max_results'];
7803 }
7804
7805 return $bot_config;
7806 }
7807
7808 private function mxchat_find_relevant_products($user_embedding) {
7809 //error_log('MXChat Vector Search: Starting product search...');
7810
7811 // Retrieve the add-on settings from the database
7812 $addon_options = get_option('mxchat_pinecone_addon_options', array());
7813
7814 // Determine whether Pinecone is enabled
7815 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1') ? 1 : 0;
7816
7817 //error_log('Pinecone enabled flag: ' . $use_pinecone);
7818
7819 if ($use_pinecone === 1) {
7820 //error_log('MXChat Vector Search: Using Pinecone database for products');
7821 return $this->find_relevant_products_pinecone($user_embedding);
7822 } else {
7823 //error_log('MXChat Vector Search: Using WordPress database for products');
7824 return $this->find_relevant_products_wordpress($user_embedding);
7825 }
7826 }
7827 private function find_relevant_products_wordpress($user_embedding) {
7828 global $wpdb;
7829 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
7830
7831 if (!is_array($user_embedding)) {
7832 return '';
7833 }
7834
7835 // Streaming top-K pass: scan rows in small batches, keep only the top 3
7836 // results above the similarity threshold. Peak memory is bounded by
7837 // $batch_size embedding rows plus a 3-element top list.
7838 $batch_size = 250;
7839 $similarity_threshold = 0.85;
7840 $top_k = 3;
7841 $top_results = [];
7842 $offset = 0;
7843
7844 do {
7845 $batch = $wpdb->get_results($wpdb->prepare(
7846 "SELECT id, embedding_vector
7847 FROM {$system_prompt_table}
7848 LIMIT %d OFFSET %d",
7849 $batch_size,
7850 $offset
7851 ));
7852
7853 if (empty($batch)) {
7854 break;
7855 }
7856
7857 foreach ($batch as $row) {
7858 $database_embedding = $row->embedding_vector
7859 ? unserialize($row->embedding_vector, ['allowed_classes' => false])
7860 : null;
7861
7862 if (!is_array($database_embedding)) {
7863 unset($database_embedding);
7864 continue;
7865 }
7866
7867 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
7868 unset($database_embedding);
7869
7870 if ($similarity < $similarity_threshold) {
7871 continue;
7872 }
7873
7874 // Insert into bounded top-K (kept sorted descending)
7875 if (count($top_results) < $top_k) {
7876 $top_results[] = ['id' => $row->id, 'similarity' => $similarity];
7877 usort($top_results, function ($a, $b) {
7878 return $b['similarity'] <=> $a['similarity'];
7879 });
7880 } elseif ($similarity > $top_results[$top_k - 1]['similarity']) {
7881 $top_results[$top_k - 1] = ['id' => $row->id, 'similarity' => $similarity];
7882 usort($top_results, function ($a, $b) {
7883 return $b['similarity'] <=> $a['similarity'];
7884 });
7885 }
7886 }
7887
7888 unset($batch);
7889 $offset += $batch_size;
7890 } while (true);
7891
7892 if (empty($top_results)) {
7893 return '';
7894 }
7895
7896 $content = '';
7897 foreach ($top_results as $result) {
7898 $chunk_content = $this->fetch_content_with_product_links($result['id']);
7899 $content .= $chunk_content . "\n\n";
7900 }
7901
7902 return trim($content);
7903 }
7904
7905
7906 private function find_relevant_products_pinecone($user_embedding) {
7907 //error_log('Starting Pinecone product search...');
7908
7909 $options = get_option('mxchat_pinecone_addon_options', array());
7910 $api_key = $options['mxchat_pinecone_api_key'] ?? '';
7911 $host = $options['mxchat_pinecone_host'] ?? '';
7912
7913 if (empty($host) || empty($api_key)) {
7914 //error_log('Pinecone credentials not properly configured for product search');
7915 return '';
7916 }
7917
7918 $similarity_threshold = 0.85;
7919 $api_endpoint = "https://{$host}/query";
7920
7921 $request_body = array(
7922 'vector' => $user_embedding,
7923 'topK' => 5,
7924 'includeMetadata' => true,
7925 'includeValues' => true,
7926 'filter' => array(
7927 'type' => 'product'
7928 )
7929 );
7930
7931 //error_log('Sending request to Pinecone with body: ' . wp_json_encode($request_body));
7932
7933 $response = wp_remote_post($api_endpoint, array(
7934 'headers' => array(
7935 'Api-Key' => $api_key,
7936 'accept' => 'application/json',
7937 'content-type' => 'application/json'
7938 ),
7939 'body' => wp_json_encode($request_body),
7940 'timeout' => 30
7941 ));
7942
7943 if (is_wp_error($response)) {
7944 //error_log('Pinecone product query error: ' . $response->get_error_message());
7945 return '';
7946 }
7947
7948 $response_code = wp_remote_retrieve_response_code($response);
7949 //error_log('Pinecone response code: ' . $response_code);
7950
7951 if ($response_code !== 200) {
7952 //error_log('Pinecone API error during product search: ' . wp_remote_retrieve_body($response));
7953 return '';
7954 }
7955
7956 $results = json_decode(wp_remote_retrieve_body($response), true);
7957 //error_log('Pinecone raw response: ' . wp_remote_retrieve_body($response));
7958
7959 if (empty($results['matches'])) {
7960 //error_log('No matches found in Pinecone response');
7961 return '';
7962 }
7963
7964 $content = '';
7965 foreach ($results['matches'] as $match) {
7966 if ($match['score'] < $similarity_threshold) {
7967 //error_log("Match below threshold: " . $match['score']);
7968 continue;
7969 }
7970
7971 if (!empty($match['metadata']['text'])) {
7972 $content .= $match['metadata']['text'];
7973 if (!empty($match['metadata']['source_url'])) {
7974 $content .= "\n\nFor more details, check out this product: " . esc_url($match['metadata']['source_url']);
7975 }
7976 $content .= "\n\n";
7977 }
7978 }
7979
7980 return trim($content);
7981 }
7982
7983
7984 private function fetch_content_with_product_links($most_relevant_id) {
7985 global $wpdb;
7986 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
7987
7988 // Fetch the article content and associated product URL
7989 $query = $wpdb->prepare("SELECT article_content, source_url FROM {$system_prompt_table} WHERE id = %d", $most_relevant_id);
7990 $result = $wpdb->get_row($query);
7991
7992 if ($result) {
7993 // Append the product link to the content if available
7994 $content = $result->article_content;
7995 if (!empty($result->source_url)) {
7996 $content .= "\n\nFor more details, check out this product: " . esc_url($result->source_url);
7997 }
7998 return $content;
7999 }
8000
8001 return null;
8002 }
8003
8004 /**
8005 * Get system instructions for a specific bot or default
8006 * Checks for multi-bot add-on and uses bot-specific instructions if available
8007 * Automatically strips URLs if citation links are disabled
8008 * Replaces {visitor_name} placeholder with actual visitor name if available
8009 *
8010 * @param string $bot_id The bot ID to get instructions for
8011 * @param string $session_id Optional session ID to lookup visitor name
8012 */
8013 private function get_system_instructions($bot_id = 'default', $session_id = '') {
8014 $instructions = '';
8015
8016 // Check if multi-bot add-on is active
8017 if (class_exists('MxChat_Multi_Bot_Core_Manager') && $bot_id !== 'default') {
8018 // Get bot-specific options from multi-bot add-on
8019 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
8020
8021 // If bot has custom system instructions, use those
8022 if (!empty($bot_options['system_prompt_instructions'])) {
8023 $instructions = $bot_options['system_prompt_instructions'];
8024 }
8025 }
8026
8027 // Fall back to default system instructions
8028 if (empty($instructions)) {
8029 $instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
8030 }
8031
8032 // Check if citation links are disabled - if so, strip URLs from instructions
8033 $fresh_options = get_option('mxchat_options', []);
8034 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
8035
8036 if (!$citation_links_enabled && !empty($instructions)) {
8037 $instructions = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $instructions);
8038 $instructions = preg_replace('/\s+/', ' ', trim($instructions)); // Clean up extra spaces
8039 }
8040
8041 // Replace {visitor_name} placeholder with actual visitor name if available
8042 if (!empty($instructions) && !empty($session_id) && stripos($instructions, '{visitor_name}') !== false) {
8043 $name_option_key = "mxchat_name_{$session_id}";
8044 $visitor_name = get_option($name_option_key, '');
8045
8046 if (!empty($visitor_name)) {
8047 $instructions = str_ireplace('{visitor_name}', sanitize_text_field($visitor_name), $instructions);
8048 } else {
8049 // Remove placeholder if no name is available
8050 $instructions = str_ireplace('{visitor_name}', '', $instructions);
8051 $instructions = preg_replace('/\s{2,}/', ' ', trim($instructions)); // Clean up extra spaces
8052 }
8053 }
8054
8055 // {context} placeholder (plan 59bc1b): inject the assembled knowledge-base
8056 // block where the owner placed the token. Runs after the URL-strip and
8057 // {visitor_name} handling and before the developer filter, so filtered
8058 // instructions already show the final prompt. Only active once the KB
8059 // assembly has stashed the block (context_kb_block non-null) — the early
8060 // URL-extraction call happens before assembly and leaves the token alone.
8061 if ($this->context_kb_block !== null && !empty($instructions) && stripos($instructions, '{context}') !== false) {
8062 $pos = stripos($instructions, '{context}');
8063 $instructions = substr($instructions, 0, $pos)
8064 . rtrim($this->context_kb_block) . "\n"
8065 . substr($instructions, $pos + strlen('{context}'));
8066 // Additional occurrences are stripped — never duplicate the KB block.
8067 $instructions = str_ireplace('{context}', '', $instructions);
8068 }
8069
8070 // Allow developers to filter system instructions and process shortcodes
8071 $instructions = apply_filters('mxchat_system_instructions', $instructions, $bot_id, $session_id);
8072 $instructions = do_shortcode($instructions);
8073
8074 return $instructions;
8075 }
8076 /**
8077 * Get the current bot ID from session or request context
8078 */
8079 private function get_current_bot_id($session_id = '') {
8080 // First, check if bot_id is passed in the current request
8081 if (isset($_POST['bot_id']) && !empty($_POST['bot_id'])) {
8082 return sanitize_key($_POST['bot_id']);
8083 }
8084
8085 // If not in POST, try to get it from session data
8086 if (!empty($session_id)) {
8087 $bot_id = get_option("mxchat_session_bot_{$session_id}", '');
8088 if (!empty($bot_id)) {
8089 return $bot_id;
8090 }
8091 }
8092
8093 // Fall back to default
8094 return 'default';
8095 }
8096 /* ====================================================================== *
8097 * Native function-calling loop (plan-mxchat-20260617-a41dee)
8098 *
8099 * Model-driven tool use. The model is offered MxChat's enabled callbacks as
8100 * tools (sourced from MxChat_Tool_Registry, the single source the admin AI
8101 * Tools checklist also reads). When the model calls a tool, the matching
8102 * callback runs through its EXISTING permission checks, its output is fed
8103 * back, and the loop continues up to a depth cap. INDEPENDENT of the
8104 * intent→callback router — it runs only after intents miss, and works with
8105 * ZERO Actions created.
8106 *
8107 * Entered ONLY when: function calling is enabled + the active model is
8108 * tool-capable + at least one tool is enabled. Default-off, so existing
8109 * installs never enter this branch (byte-for-byte unchanged behavior). The
8110 * tool round is buffered (non-streaming) per the plan; the final answer is
8111 * emitted via the same SSE/JSON envelopes the normal path uses.
8112 * ====================================================================== */
8113
8114 /** Gate: should the function-calling loop handle this turn? */
8115 private function mxchat_fc_should_run($selected_model) {
8116 if (!class_exists('MxChat_Tool_Registry') || !MxChat_Tool_Registry::is_enabled()) {
8117 return false;
8118 }
8119 if (class_exists('MxChat_Model_Catalog') && !MxChat_Model_Catalog::supports_tools($selected_model)) {
8120 return false;
8121 }
8122 $tools = MxChat_Tool_Registry::enabled_tools();
8123 return !empty($tools);
8124 }
8125
8126 private function mxchat_fc_log($msg) {
8127 if (defined('MXCHAT_DEV_MODE') && MXCHAT_DEV_MODE) {
8128 error_log('[MxChat FC] ' . $msg);
8129 }
8130 }
8131
8132 /**
8133 * Resolve provider transport details. Returns null when FC can't run for this
8134 * model/config (missing key, unsupported provider) so the caller falls back to
8135 * the normal path. OpenAI/xAI/DeepSeek/OpenRouter/Custom share the
8136 * OpenAI-compatible 'openai' family; Claude and Gemini are distinct.
8137 */
8138 private function mxchat_fc_resolve_provider($selected_model, $opts) {
8139 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
8140 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
8141 if ($selected_model === 'claude-opus-4-20250514') { $selected_model = 'claude-opus-4-8'; }
8142 elseif ($selected_model === 'claude-sonnet-4-20250514') { $selected_model = 'claude-sonnet-4-6'; }
8143 if ($selected_model === 'openrouter') {
8144 $model = isset($opts['openrouter_selected_model']) ? $opts['openrouter_selected_model'] : '';
8145 $key = isset($opts['openrouter_api_key']) ? $opts['openrouter_api_key'] : '';
8146 if ($model === '' || $key === '') return null;
8147 return array('family'=>'openai','model'=>$model,'url'=>'https://openrouter.ai/api/v1/chat/completions',
8148 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'openai');
8149 }
8150 $prefix = strtolower(explode('-', $selected_model)[0]);
8151 switch ($prefix) {
8152 case 'gpt': case 'o1': case 'o3': case 'o4':
8153 $key = isset($opts['api_key']) ? $opts['api_key'] : '';
8154 if ($key === '') return null;
8155 return array('family'=>'openai','model'=>$selected_model,'url'=>'https://api.openai.com/v1/chat/completions',
8156 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'openai');
8157 case 'claude':
8158 $key = isset($opts['claude_api_key']) ? $opts['claude_api_key'] : '';
8159 if ($key === '') return null;
8160 return array('family'=>'anthropic','model'=>$selected_model,'url'=>'https://api.anthropic.com/v1/messages',
8161 'headers'=>array('Content-Type'=>'application/json','x-api-key'=>$key,'anthropic-version'=>'2023-06-01'),'tag'=>'anthropic');
8162 case 'gemini':
8163 $key = isset($opts['gemini_api_key']) ? $opts['gemini_api_key'] : '';
8164 if ($key === '') return null;
8165 return array('family'=>'gemini','model'=>$selected_model,'key'=>$key,'tag'=>'gemini');
8166 case 'grok': case 'xai':
8167 $key = isset($opts['xai_api_key']) ? $opts['xai_api_key'] : '';
8168 if ($key === '') return null;
8169 return array('family'=>'openai','model'=>$selected_model,'url'=>'https://api.x.ai/v1/chat/completions',
8170 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'xai');
8171 case 'deepseek':
8172 $key = isset($opts['deepseek_api_key']) ? $opts['deepseek_api_key'] : '';
8173 if ($key === '') return null;
8174 return array('family'=>'openai','model'=>$selected_model,'url'=>'https://api.deepseek.com/v1/chat/completions',
8175 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'openai');
8176 case 'custom':
8177 $base = isset($opts['custom_provider_base_url']) ? rtrim($opts['custom_provider_base_url'], '/') : '';
8178 $key = isset($opts['custom_provider_api_key']) ? $opts['custom_provider_api_key'] : '';
8179 $model = isset($opts['custom_provider_model']) ? $opts['custom_provider_model'] : '';
8180 if ($base === '' || $model === '') return null;
8181 $url = (strpos($base, 'chat/completions') !== false) ? $base : $base . '/chat/completions';
8182 $headers = array('Content-Type'=>'application/json');
8183 if ($key !== '') $headers['Authorization'] = 'Bearer '.$key;
8184 return array('family'=>'openai','model'=>$model,'url'=>$url,'headers'=>$headers,'tag'=>'openai');
8185 }
8186 return null;
8187 }
8188
8189 /**
8190 * Top-level function-calling attempt. Returns:
8191 * ['handled'=>true, 'text'=>'<final answer>'] when the model used ≥1 tool
8192 * ['handled'=>false] otherwise (caller falls back
8193 * to the normal streamed path)
8194 */
8195 private function mxchat_fc_attempt($message, $relevant_content, $conversation_history, $selected_model, $opts, $session_id, $user_id) {
8196 $prov = $this->mxchat_fc_resolve_provider($selected_model, $opts);
8197 if (!$prov) {
8198 return array('handled' => false);
8199 }
8200 $tools = MxChat_Tool_Registry::enabled_tools();
8201 if (empty($tools)) {
8202 return array('handled' => false);
8203 }
8204
8205 $bot_id = $this->get_current_bot_id($session_id);
8206 $system = $this->get_system_instructions($bot_id, $session_id);
8207
8208 // Force callbacks into return-mode (some echo SSE directly when streaming);
8209 // we buffer the whole tool round, then emit once. Restored in finally.
8210 $prev_streaming = $this->is_streaming;
8211 $this->is_streaming = false;
8212 try {
8213 if ($prov['family'] === 'anthropic') {
8214 return $this->mxchat_fc_loop_anthropic($prov, $system, $relevant_content, $conversation_history, $tools, $message, $user_id, $session_id);
8215 } elseif ($prov['family'] === 'gemini') {
8216 return $this->mxchat_fc_loop_gemini($prov, $system, $relevant_content, $conversation_history, $tools, $message, $user_id, $session_id);
8217 }
8218 return $this->mxchat_fc_loop_openai($prov, $system, $relevant_content, $conversation_history, $tools, $message, $user_id, $session_id);
8219 } catch (\Throwable $e) {
8220 $this->mxchat_fc_log('attempt threw: ' . $e->getMessage());
8221 return array('handled' => false);
8222 } finally {
8223 $this->is_streaming = $prev_streaming;
8224 }
8225 }
8226
8227 /** Normalize MxChat history rows to [{role:user|assistant, content}]. */
8228 private function mxchat_fc_normalize_history($conversation_history) {
8229 $out = array();
8230 if (!is_array($conversation_history)) return $out;
8231 foreach ($conversation_history as $m) {
8232 if (!is_array($m) || !isset($m['role']) || !isset($m['content'])) continue;
8233 $role = $m['role'];
8234 if ($role === 'bot' || $role === 'agent') $role = 'assistant';
8235 if (!in_array($role, array('user', 'assistant'), true)) $role = 'user';
8236 $out[] = array('role' => $role, 'content' => (string) $m['content']);
8237 }
8238 return $out;
8239 }
8240
8241 /** Execute the matched callback for a tool call. Returns ['ok'=>bool,'content'=>string]. */
8242 private function mxchat_fc_execute_tool($tool_name, $args, $orig_message, $user_id, $session_id) {
8243 $tool = MxChat_Tool_Registry::tool_by_name($tool_name, true); // enabled-only
8244 if (!$tool) {
8245 return array('ok' => false, 'content' => 'This tool is not available or not enabled.');
8246 }
8247 $fn = $tool['callback'];
8248
8249 // MxChat callbacks are message-driven: hand them the model's `query`
8250 // (falling back to the original user message).
8251 $query = '';
8252 if (is_array($args) && isset($args['query']) && is_string($args['query'])) {
8253 $query = $args['query'];
8254 }
8255 if ($query === '') $query = $orig_message;
8256
8257 // Synthetic intent row (matches wp_mxchat_intents columns → no undefined-prop warnings).
8258 $synthetic_intent = (object) array(
8259 'id' => 0, 'intent_label' => $tool['label'], 'phrases' => '',
8260 'embedding_vector' => '', 'callback_function' => $fn,
8261 'similarity_threshold' => 0.0, 'enabled' => 1, 'enabled_bots' => null,
8262 );
8263
8264 try {
8265 if (!empty($tool['is_addon'])) {
8266 $result = apply_filters($fn, false, $query, $user_id, $session_id, $synthetic_intent);
8267 } elseif (method_exists($this, $fn)) {
8268 $result = call_user_func(array($this, $fn), $query, $user_id, $session_id, $synthetic_intent, null);
8269 } else {
8270 return array('ok' => false, 'content' => 'Tool implementation not found.');
8271 }
8272 } catch (\Throwable $e) {
8273 $this->mxchat_fc_log("tool {$fn} threw: " . $e->getMessage());
8274 return array('ok' => false, 'content' => 'The tool failed to run.');
8275 }
8276
8277 // plan-mxchat-20260617-48a57a — surface UI-bearing tool output.
8278 // If the callback produced a UI element (generated image, product card, image
8279 // gallery), its html MUST reach the FRONTEND as a real rendered bot message —
8280 // NOT be stripped to text and handed to the model to paraphrase (that was the
8281 // bug: under function calling, UI-bearing actions rendered nothing). Capture
8282 // the html here; the FC outcome handler emits it in the response envelope.
8283 $ui = $this->mxchat_fc_ui_payload_from($result);
8284 if ($ui['html'] !== '' || !empty($ui['images'])) {
8285 if ($ui['html'] !== '') {
8286 $this->fc_ui_html .= ($this->fc_ui_html !== '' ? "\n" : '') . $ui['html'];
8287 }
8288 if (!empty($ui['images']) && is_array($ui['images'])) {
8289 $this->fc_ui_images = array_merge($this->fc_ui_images, $ui['images']);
8290 }
8291 $this->fc_ui_captured = true;
8292
8293 // Persist the html to the transcript ONLY if the callback did not already
8294 // do so itself. Core image/search callbacks self-save (text + html);
8295 // add-on callbacks (e.g. woo product cards) return html for the caller to
8296 // save. ui_self_saves carries this from the registry; default by source
8297 // (core self-saves, add-on does not) when a tool predates the flag.
8298 $self_saves = array_key_exists('ui_self_saves', $tool)
8299 ? !empty($tool['ui_self_saves'])
8300 : empty($tool['is_addon']);
8301 if ($ui['html'] !== '' && !$self_saves) {
8302 $this->mxchat_save_chat_message($session_id, 'bot', $ui['html']);
8303 }
8304
8305 // Hand the MODEL a short acknowledgment (never the raw or stripped html)
8306 // so the loop can add a one-line caption without trying to re-describe a
8307 // visual it cannot see and without duplicating the displayed element.
8308 $summary = isset($ui['text']) ? trim((string) $ui['text']) : '';
8309 $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');
8310 $content = $summary !== '' ? ($ack . ' ' . $summary) : $ack;
8311 $this->mxchat_fc_log("executed {$fn} → [ui payload surfaced] " . substr($content, 0, 120));
8312 return array('ok' => true, 'content' => $content);
8313 }
8314
8315 $content = $this->mxchat_fc_stringify_result($result);
8316 $this->mxchat_fc_log("executed {$fn} → " . substr($content, 0, 160));
8317 return array('ok' => true, 'content' => $content);
8318 }
8319
8320 /**
8321 * Extract a UI payload (html + images + text) from a tool callback's return,
8322 * falling back to $this->fallbackResponse for callbacks that return true after
8323 * setting it. plan-mxchat-20260617-48a57a.
8324 *
8325 * @return array{html:string,images:array,text:string}
8326 */
8327 private function mxchat_fc_ui_payload_from($result) {
8328 $src = null;
8329 if (is_array($result)) {
8330 $src = $result;
8331 } elseif ($result === true && isset($this->fallbackResponse) && is_array($this->fallbackResponse)) {
8332 $src = $this->fallbackResponse;
8333 }
8334 $html = (is_array($src) && isset($src['html']) && is_string($src['html'])) ? $src['html'] : '';
8335 $images = (is_array($src) && isset($src['images']) && is_array($src['images'])) ? $src['images'] : array();
8336 $text = (is_array($src) && isset($src['text'])) ? (string) $src['text'] : '';
8337 return array('html' => $html, 'images' => $images, 'text' => $text);
8338 }
8339
8340 /** Coerce a callback's return (string|array|true|false) into a tool-result string. */
8341 private function mxchat_fc_stringify_result($result) {
8342 if (is_string($result)) {
8343 return $result === '' ? 'No result.' : $result;
8344 }
8345 if ($result === true) {
8346 // Callbacks that set fallbackResponse and return true.
8347 $fb = isset($this->fallbackResponse) ? $this->fallbackResponse : null;
8348 if (is_array($fb)) {
8349 if (!empty($fb['text'])) return (string) $fb['text'];
8350 if (!empty($fb['html'])) return wp_strip_all_tags((string) $fb['html']);
8351 }
8352 return 'Done.';
8353 }
8354 if ($result === false || $result === null) {
8355 return 'No result.';
8356 }
8357 if (is_array($result)) {
8358 if (isset($result['text']) && $result['text'] !== '') return (string) $result['text'];
8359 if (isset($result['html']) && $result['html'] !== '') return wp_strip_all_tags((string) $result['html']);
8360 $json = wp_json_encode($result);
8361 return $json !== false ? $json : 'No result.';
8362 }
8363 return (string) $result;
8364 }
8365
8366 /** HTTP code + decoded body for a function-calling request. */
8367 private function mxchat_fc_post($url, $body, $headers, $tag) {
8368 $args = array(
8369 'body' => wp_json_encode($body),
8370 'headers' => $headers,
8371 'timeout' => 60,
8372 'redirection' => 5,
8373 'blocking' => true,
8374 'httpversion' => '1.0',
8375 'sslverify' => true,
8376 );
8377 $response = $this->mxchat_provider_call_with_retry($url, $args, $tag);
8378 if (is_wp_error($response)) {
8379 return array('code' => 0, 'data' => null, 'error' => $response->get_error_message());
8380 }
8381 $code = (int) wp_remote_retrieve_response_code($response);
8382 $data = json_decode(wp_remote_retrieve_body($response), true);
8383 return array('code' => $code, 'data' => $data, 'error' => null);
8384 }
8385
8386 /* ---------------- OpenAI-compatible loop (OpenAI/xAI/DeepSeek/OpenRouter/Custom) -------------- */
8387 private function mxchat_fc_loop_openai($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) {
8388 $messages = array();
8389 $messages[] = array('role' => 'system', 'content' => $system . ' ' . $relevant_content);
8390 foreach ($this->mxchat_fc_normalize_history($conversation_history) as $m) {
8391 $messages[] = $m;
8392 }
8393
8394 $depth = MxChat_Tool_Registry::max_depth();
8395 $budget = MxChat_Tool_Registry::max_tool_calls_per_turn();
8396 $tool_schema = MxChat_Tool_Registry::to_openai_tools($tools);
8397 $used_tool = false;
8398 $calls_made = 0;
8399
8400 for ($step = 0; $step <= $depth; $step++) {
8401 $offer_tools = ($step < $depth) && !empty($tool_schema);
8402 $body = array('model' => $prov['model'], 'messages' => $messages, 'temperature' => 1, 'stream' => false);
8403 if (strpos($prov['url'], 'api.deepseek.com') !== false) {
8404 // DeepSeek V4 defaults to thinking mode ON; tool loops want fast
8405 // deterministic non-thinking turns (legacy deepseek-chat semantics).
8406 $body['thinking'] = array('type' => 'disabled');
8407 }
8408 if ($offer_tools) {
8409 $body['tools'] = $tool_schema;
8410 $body['tool_choice'] = 'auto';
8411 }
8412 $r = $this->mxchat_fc_post($prov['url'], $body, $prov['headers'], $prov['tag']);
8413 if ($r['code'] !== 200 || !is_array($r['data'])) {
8414 $this->mxchat_fc_log('openai call failed: code=' . $r['code'] . ' err=' . ($r['error'] ?? ''));
8415 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8416 }
8417 $msg = isset($r['data']['choices'][0]['message']) ? $r['data']['choices'][0]['message'] : null;
8418 if (!$msg) {
8419 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8420 }
8421 $tool_calls = isset($msg['tool_calls']) && is_array($msg['tool_calls']) ? $msg['tool_calls'] : array();
8422 if (empty($tool_calls)) {
8423 $text = isset($msg['content']) ? trim((string) $msg['content']) : '';
8424 if (!$used_tool) return array('handled' => false); // model never used a tool → normal path
8425 return array('handled' => true, 'text' => ($text !== '' ? $text : $this->mxchat_fc_giveup_text()));
8426 }
8427 // Append the assistant tool-call turn verbatim, then a tool result per call.
8428 $used_tool = true;
8429 $messages[] = $msg;
8430 foreach ($tool_calls as $tc) {
8431 if ($calls_made >= $budget) break;
8432 $calls_made++;
8433 $name = isset($tc['function']['name']) ? $tc['function']['name'] : '';
8434 $args = array();
8435 if (isset($tc['function']['arguments'])) {
8436 $decoded = json_decode($tc['function']['arguments'], true);
8437 if (is_array($decoded)) $args = $decoded;
8438 }
8439 $exec = $this->mxchat_fc_execute_tool($name, $args, $orig_message, $user_id, $session_id);
8440 $messages[] = array(
8441 'role' => 'tool',
8442 'tool_call_id' => isset($tc['id']) ? $tc['id'] : '',
8443 'content' => $exec['content'],
8444 );
8445 }
8446 }
8447 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8448 }
8449
8450 /* ---------------- Anthropic Claude loop ---------------- */
8451 private function mxchat_fc_loop_anthropic($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) {
8452 $messages = $this->mxchat_fc_normalize_history($conversation_history);
8453 $messages[] = array('role' => 'user', 'content' => $relevant_content);
8454
8455 $depth = MxChat_Tool_Registry::max_depth();
8456 $budget = MxChat_Tool_Registry::max_tool_calls_per_turn();
8457 $tool_schema = MxChat_Tool_Registry::to_anthropic_tools($tools);
8458 $omit_temp = $this->mxchat_claude_omits_temperature($prov['model']);
8459 $used_tool = false;
8460 $calls_made = 0;
8461
8462 for ($step = 0; $step <= $depth; $step++) {
8463 $offer_tools = ($step < $depth) && !empty($tool_schema);
8464 $body = array('model' => $prov['model'], 'max_tokens' => 1024, 'temperature' => 0.8,
8465 'messages' => $messages, 'system' => $system);
8466 if ($omit_temp) unset($body['temperature']);
8467 if ($offer_tools) {
8468 $body['tools'] = $tool_schema;
8469 $body['tool_choice'] = array('type' => 'auto');
8470 }
8471 $r = $this->mxchat_fc_post($prov['url'], $body, $prov['headers'], $prov['tag']);
8472 if ($r['code'] !== 200 || !is_array($r['data'])) {
8473 $this->mxchat_fc_log('anthropic call failed: code=' . $r['code'] . ' err=' . ($r['error'] ?? ''));
8474 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8475 }
8476 $content = isset($r['data']['content']) && is_array($r['data']['content']) ? $r['data']['content'] : array();
8477 $tool_uses = array();
8478 $text_out = '';
8479 foreach ($content as $block) {
8480 if (!isset($block['type'])) continue;
8481 if ($block['type'] === 'tool_use') {
8482 $tool_uses[] = $block;
8483 } elseif ($block['type'] === 'text' && isset($block['text'])) {
8484 $text_out .= $block['text'];
8485 }
8486 }
8487 if (empty($tool_uses)) {
8488 if (!$used_tool) return array('handled' => false);
8489 $text_out = trim($text_out);
8490 return array('handled' => true, 'text' => ($text_out !== '' ? $text_out : $this->mxchat_fc_giveup_text()));
8491 }
8492 // Append the assistant turn (the full content array), then a user turn of tool_result blocks.
8493 $used_tool = true;
8494 $messages[] = array('role' => 'assistant', 'content' => $content);
8495 $results = array();
8496 foreach ($tool_uses as $tu) {
8497 if ($calls_made >= $budget) break;
8498 $calls_made++;
8499 $name = isset($tu['name']) ? $tu['name'] : '';
8500 $args = isset($tu['input']) && is_array($tu['input']) ? $tu['input'] : array();
8501 $exec = $this->mxchat_fc_execute_tool($name, $args, $orig_message, $user_id, $session_id);
8502 $results[] = array(
8503 'type' => 'tool_result',
8504 'tool_use_id' => isset($tu['id']) ? $tu['id'] : '',
8505 'content' => $exec['content'],
8506 );
8507 }
8508 $messages[] = array('role' => 'user', 'content' => $results);
8509 }
8510 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8511 }
8512
8513 /* ---------------- Google Gemini loop ---------------- */
8514 private function mxchat_fc_loop_gemini($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) {
8515 $contents = array();
8516 $contents[] = array('role' => 'user', 'parts' => array(array('text' => '[System Instructions] ' . $system . ' ' . $relevant_content)));
8517 $contents[] = array('role' => 'model', 'parts' => array(array('text' => 'I understand and will follow these instructions.')));
8518 foreach ($this->mxchat_fc_normalize_history($conversation_history) as $m) {
8519 $contents[] = array('role' => ($m['role'] === 'assistant' ? 'model' : 'user'),
8520 'parts' => array(array('text' => $m['content'])));
8521 }
8522
8523 $depth = MxChat_Tool_Registry::max_depth();
8524 $budget = MxChat_Tool_Registry::max_tool_calls_per_turn();
8525 $tool_schema = MxChat_Tool_Registry::to_gemini_tools($tools);
8526 // Function calling (tools + functionDeclarations + toolConfig) is a v1beta feature on the
8527 // Generative Language REST API. The v1 endpoint silently ignores the tools array, so a
8528 // non-preview model (e.g. gemini-2.5-pro, gemini-3.5-flash, gemini-3.1-flash-lite) would
8529 // just answer in text and never emit a tool call. Always use v1beta for the FC loop —
8530 // confirmed against Google's function-calling docs (their REST example targets
8531 // v1beta/models/gemini-3.5-flash:generateContent). v1beta is a superset, so every model
8532 // reachable on v1 is also reachable here.
8533 $api_version = 'v1beta';
8534 $url = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $prov['model'] . ':generateContent?key=' . $prov['key'];
8535 $headers = array('Content-Type' => 'application/json');
8536 $used_tool = false;
8537 $calls_made = 0;
8538
8539 for ($step = 0; $step <= $depth; $step++) {
8540 $offer_tools = ($step < $depth) && !empty($tool_schema);
8541 $body = array(
8542 'contents' => $contents,
8543 'generationConfig' => array('temperature' => 0.7, 'topP' => 0.95, 'topK' => 40, 'maxOutputTokens' => 8192),
8544 );
8545 if ($offer_tools) {
8546 $body['tools'] = $tool_schema;
8547 $body['toolConfig'] = array('functionCallingConfig' => array('mode' => 'AUTO'));
8548 }
8549 $r = $this->mxchat_fc_post($url, $body, $headers, 'gemini');
8550 if ($r['code'] !== 200 || !is_array($r['data']) || isset($r['data']['error'])) {
8551 $this->mxchat_fc_log('gemini call failed: code=' . $r['code'] . ' err=' . ($r['error'] ?? ''));
8552 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8553 }
8554 $parts = isset($r['data']['candidates'][0]['content']['parts']) && is_array($r['data']['candidates'][0]['content']['parts'])
8555 ? $r['data']['candidates'][0]['content']['parts'] : array();
8556 $fn_calls = array();
8557 $text_out = '';
8558 foreach ($parts as $p) {
8559 if (isset($p['functionCall'])) {
8560 $fn_calls[] = $p['functionCall'];
8561 } elseif (isset($p['text'])) {
8562 $text_out .= $p['text'];
8563 }
8564 }
8565 if (empty($fn_calls)) {
8566 if (!$used_tool) return array('handled' => false);
8567 $text_out = trim($text_out);
8568 return array('handled' => true, 'text' => ($text_out !== '' ? $text_out : $this->mxchat_fc_giveup_text()));
8569 }
8570 // Append the model turn (its parts) then a user turn of functionResponse parts.
8571 $used_tool = true;
8572 $contents[] = array('role' => 'model', 'parts' => $parts);
8573 $resp_parts = array();
8574 foreach ($fn_calls as $fcall) {
8575 if ($calls_made >= $budget) break;
8576 $calls_made++;
8577 $name = isset($fcall['name']) ? $fcall['name'] : '';
8578 $args = isset($fcall['args']) && is_array($fcall['args']) ? $fcall['args'] : array();
8579 $exec = $this->mxchat_fc_execute_tool($name, $args, $orig_message, $user_id, $session_id);
8580 $fr = array('name' => $name, 'response' => array('result' => $exec['content']));
8581 // Gemini 3 function calls carry a unique id; echo the matching id back in the
8582 // functionResponse so the model maps the result to the right call (Google REST
8583 // guidance). Older models omit the id — then we send none, exactly as before.
8584 if (isset($fcall['id']) && $fcall['id'] !== '') { $fr['id'] = $fcall['id']; }
8585 $resp_parts[] = array('functionResponse' => $fr);
8586 }
8587 $contents[] = array('role' => 'user', 'parts' => $resp_parts);
8588 }
8589 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8590 }
8591
8592 private function mxchat_fc_giveup_text() {
8593 return esc_html__('I looked into that but could not put together a final answer. Please try rephrasing your request.', 'mxchat');
8594 }
8595
8596 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.6-sol') {
8597 try {
8598 if (!$relevant_content) {
8599 $error_response = [
8600 'error' => esc_html__("I couldn't find relevant information on that topic.", 'mxchat'),
8601 'error_code' => 'no_relevant_content'
8602 ];
8603
8604 if ($testing_data !== null) {
8605 $error_response['testing_data'] = $testing_data;
8606 }
8607
8608 return $error_response;
8609 }
8610
8611 if (!is_array($conversation_history)) {
8612 $conversation_history = array();
8613 }
8614
8615 // Check if this is an OpenRouter model
8616 if ($selected_model === 'openrouter') {
8617 // Get the actual OpenRouter model from options
8618 $openrouter_selected_model = $this->options['openrouter_selected_model'] ?? '';
8619
8620 if (empty($openrouter_selected_model)) {
8621 $error_response = [
8622 'error' => esc_html__('No OpenRouter model selected. Please select a model in settings.', 'mxchat'),
8623 'error_code' => 'no_openrouter_model_selected'
8624 ];
8625 if ($testing_data !== null) {
8626 $error_response['testing_data'] = $testing_data;
8627 }
8628 return $error_response;
8629 }
8630
8631 if (empty($openrouter_api_key)) {
8632 $error_response = [
8633 'error' => esc_html__('OpenRouter API key is not configured', 'mxchat'),
8634 'error_code' => 'missing_openrouter_api_key'
8635 ];
8636 if ($testing_data !== null) {
8637 $error_response['testing_data'] = $testing_data;
8638 }
8639 return $error_response;
8640 }
8641
8642 if ($streaming) {
8643 return $this->mxchat_generate_response_openrouter_stream(
8644 $openrouter_selected_model,
8645 $openrouter_api_key,
8646 $conversation_history,
8647 $relevant_content,
8648 $session_id,
8649 $testing_data
8650 );
8651 } else {
8652 $response = $this->mxchat_generate_response_openrouter(
8653 $openrouter_selected_model,
8654 $openrouter_api_key,
8655 $conversation_history,
8656 $relevant_content,
8657 $session_id
8658 );
8659 }
8660
8661 if (is_array($response) && isset($response['error'])) {
8662 if ($testing_data !== null) {
8663 $response['testing_data'] = $testing_data;
8664 }
8665 return $response;
8666 }
8667
8668 return $response;
8669 }
8670
8671 // Extract model prefix to determine the provider
8672 $model_parts = explode('-', $selected_model);
8673 $provider = strtolower($model_parts[0]);
8674
8675 // Handle model selection based on provider prefix
8676 switch ($provider) {
8677 case 'gemini':
8678 if (empty($gemini_api_key)) {
8679 $error_response = [
8680 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
8681 'error_code' => 'missing_gemini_api_key'
8682 ];
8683 if ($testing_data !== null) {
8684 $error_response['testing_data'] = $testing_data;
8685 }
8686 return $error_response;
8687 }
8688 $response = $this->mxchat_generate_response_gemini(
8689 $selected_model,
8690 $gemini_api_key,
8691 $conversation_history,
8692 $relevant_content,
8693 $session_id
8694 );
8695 break;
8696
8697 case 'claude':
8698 if (empty($claude_api_key)) {
8699 $error_response = [
8700 'error' => esc_html__('Claude API key is not configured', 'mxchat'),
8701 'error_code' => 'missing_claude_api_key'
8702 ];
8703 if ($testing_data !== null) {
8704 $error_response['testing_data'] = $testing_data;
8705 }
8706 return $error_response;
8707 }
8708 if ($streaming) {
8709 return $this->mxchat_generate_response_claude_stream(
8710 $selected_model,
8711 $claude_api_key,
8712 $conversation_history,
8713 $relevant_content,
8714 $session_id,
8715 $testing_data
8716 );
8717 } else {
8718 $response = $this->mxchat_generate_response_claude(
8719 $selected_model,
8720 $claude_api_key,
8721 $conversation_history,
8722 $relevant_content,
8723 $session_id
8724 );
8725 }
8726 break;
8727
8728 case 'grok':
8729 if (empty($xai_api_key)) {
8730 $error_response = [
8731 'error' => esc_html__('X.AI API key is not configured', 'mxchat'),
8732 'error_code' => 'missing_xai_api_key'
8733 ];
8734 if ($testing_data !== null) {
8735 $error_response['testing_data'] = $testing_data;
8736 }
8737 return $error_response;
8738 }
8739 if ($streaming) {
8740 return $this->mxchat_generate_response_xai_stream(
8741 $selected_model,
8742 $xai_api_key,
8743 $conversation_history,
8744 $relevant_content,
8745 $session_id,
8746 $testing_data
8747 );
8748 } else {
8749 $response = $this->mxchat_generate_response_xai(
8750 $selected_model,
8751 $xai_api_key,
8752 $conversation_history,
8753 $relevant_content,
8754 $session_id
8755 );
8756 }
8757 break;
8758
8759 case 'deepseek':
8760 if (empty($deepseek_api_key)) {
8761 $error_response = [
8762 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
8763 'error_code' => 'missing_deepseek_api_key'
8764 ];
8765 if ($testing_data !== null) {
8766 $error_response['testing_data'] = $testing_data;
8767 }
8768 return $error_response;
8769 }
8770 if ($streaming) {
8771 return $this->mxchat_generate_response_deepseek_stream(
8772 $selected_model,
8773 $deepseek_api_key,
8774 $conversation_history,
8775 $relevant_content,
8776 $session_id,
8777 $testing_data
8778 );
8779 } else {
8780 $response = $this->mxchat_generate_response_deepseek(
8781 $selected_model,
8782 $deepseek_api_key,
8783 $conversation_history,
8784 $relevant_content,
8785 $session_id
8786 );
8787 }
8788 break;
8789
8790 case 'custom':
8791 // Custom (OpenAI-compatible) provider — Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI
8792 $cp_base_url = isset($this->options['custom_provider_base_url']) ? trim((string) $this->options['custom_provider_base_url']) : '';
8793 if (empty($cp_base_url)) {
8794 $error_response = [
8795 'error' => esc_html__('Custom provider is not configured. Set Base URL in MxChat → API Keys → Custom Provider.', 'mxchat'),
8796 'error_code' => 'missing_custom_provider_base_url'
8797 ];
8798 if ($testing_data !== null) {
8799 $error_response['testing_data'] = $testing_data;
8800 }
8801 return $error_response;
8802 }
8803 if ($streaming) {
8804 return $this->mxchat_generate_response_custom_stream(
8805 $selected_model,
8806 $conversation_history,
8807 $relevant_content,
8808 $session_id,
8809 $testing_data
8810 );
8811 } else {
8812 $response = $this->mxchat_generate_response_custom(
8813 $selected_model,
8814 $conversation_history,
8815 $relevant_content
8816 );
8817 }
8818 break;
8819
8820 case 'gpt':
8821 case 'o1':
8822 if (empty($api_key)) {
8823 $error_response = [
8824 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
8825 'error_code' => 'missing_openai_api_key'
8826 ];
8827 if ($testing_data !== null) {
8828 $error_response['testing_data'] = $testing_data;
8829 }
8830 return $error_response;
8831 }
8832
8833 // Check if web search is enabled for this OpenAI model
8834 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
8835 // Models that don't support web search
8836 $unsupported_web_search_models = array('gpt-4.1-nano');
8837 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
8838
8839 if ($web_search_enabled && $model_supports_web_search) {
8840 // Use Responses API (required for some models, or when web search is enabled)
8841 return $this->mxchat_generate_response_openai_web_search(
8842 $selected_model,
8843 $api_key,
8844 $conversation_history,
8845 $relevant_content,
8846 $session_id,
8847 $testing_data,
8848 $streaming
8849 );
8850 } elseif ($streaming) {
8851 return $this->mxchat_generate_response_openai_stream(
8852 $selected_model,
8853 $api_key,
8854 $conversation_history,
8855 $relevant_content,
8856 $session_id,
8857 $testing_data
8858 );
8859 } else {
8860 $response = $this->mxchat_generate_response_openai(
8861 $selected_model,
8862 $api_key,
8863 $conversation_history,
8864 $relevant_content,
8865 $session_id
8866 );
8867 }
8868 break;
8869
8870 default:
8871 if (empty($api_key)) {
8872 $error_response = [
8873 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
8874 'error_code' => 'missing_openai_api_key'
8875 ];
8876 if ($testing_data !== null) {
8877 $error_response['testing_data'] = $testing_data;
8878 }
8879 return $error_response;
8880 }
8881
8882 // Check if web search is enabled (default case also handles OpenAI models)
8883 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
8884 $unsupported_web_search_models = array('gpt-4.1-nano');
8885 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
8886
8887 if ($web_search_enabled && $model_supports_web_search) {
8888 return $this->mxchat_generate_response_openai_web_search(
8889 $selected_model,
8890 $api_key,
8891 $conversation_history,
8892 $relevant_content,
8893 $session_id,
8894 $testing_data,
8895 $streaming
8896 );
8897 } elseif ($streaming) {
8898 return $this->mxchat_generate_response_openai_stream(
8899 $selected_model,
8900 $api_key,
8901 $conversation_history,
8902 $relevant_content,
8903 $session_id,
8904 $testing_data
8905 );
8906 } else {
8907 $response = $this->mxchat_generate_response_openai(
8908 $selected_model,
8909 $api_key,
8910 $conversation_history,
8911 $relevant_content,
8912 $session_id
8913 );
8914 }
8915 break;
8916 }
8917
8918 if (is_array($response) && isset($response['error'])) {
8919 if ($testing_data !== null) {
8920 $response['testing_data'] = $testing_data;
8921 }
8922 return $response;
8923 }
8924
8925 return $response;
8926
8927 } catch (Exception $e) {
8928 $error_response = [
8929 'error' => sprintf(esc_html__('An error occurred: %s', 'mxchat'), esc_html($e->getMessage())),
8930 'error_code' => 'system_exception',
8931 'exception_details' => $e->getMessage()
8932 ];
8933
8934 if ($testing_data !== null) {
8935 $error_response['testing_data'] = $testing_data;
8936 }
8937
8938 return $error_response;
8939 }
8940 }
8941 private function mxchat_generate_response_openrouter_stream($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
8942 try {
8943 $bot_id = $this->get_current_bot_id($session_id);
8944 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8945
8946 if (!is_array($conversation_history)) {
8947 $conversation_history = array();
8948 }
8949
8950 $formatted_conversation = array();
8951
8952 $formatted_conversation[] = array(
8953 'role' => 'system',
8954 'content' => $system_prompt_instructions . " " . $relevant_content
8955 );
8956
8957 foreach ($conversation_history as $message) {
8958 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8959 $role = $message['role'];
8960 if ($role === 'bot' || $role === 'agent') {
8961 $role = 'assistant';
8962 }
8963 if (!in_array($role, ['system', 'assistant', 'user'])) {
8964 $role = 'user';
8965 }
8966 $formatted_conversation[] = array(
8967 'role' => $role,
8968 'content' => $message['content']
8969 );
8970 }
8971 }
8972
8973 if (headers_sent() || !function_exists('curl_init')) {
8974 $regular_response = $this->mxchat_generate_response_openrouter(
8975 $selected_model,
8976 $openrouter_api_key,
8977 $conversation_history,
8978 $relevant_content,
8979 $session_id
8980 );
8981
8982 // Save bot response to transcript
8983 if (!empty($regular_response) && !empty($session_id)) {
8984 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
8985 }
8986
8987 $response_data = [
8988 'text' => $regular_response,
8989 'html' => '',
8990 'session_id' => $session_id
8991 ];
8992
8993 if ($testing_data !== null) {
8994 $response_data['testing_data'] = $testing_data;
8995 }
8996
8997 header('Content-Type: application/json');
8998 echo json_encode($response_data);
8999 return true;
9000 }
9001
9002 $body = json_encode([
9003 'model' => $selected_model,
9004 'messages' => $formatted_conversation,
9005 'temperature' => 1,
9006 'stream' => true
9007 ]);
9008
9009 // V2 retry-on-initial-connect: setup_streaming_headers is now lazy-fired
9010 // inside WRITEFUNCTION on first byte of a successful upstream.
9011
9012 $captured_status_code = 0;
9013 $captured_body_pre_stream = '';
9014 $full_response = '';
9015 $stream_started = false;
9016 $buffer = '';
9017 $errno = 0;
9018 $last_curl_error = '';
9019 $http_code = 0;
9020 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
9021 $backoff_ms = array(0, 750, 2000);
9022
9023 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
9024 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
9025 usleep($backoff_ms[$attempt] * 1000);
9026 }
9027
9028 $captured_status_code = 0;
9029 $captured_body_pre_stream = '';
9030 $full_response = '';
9031 $stream_started = false;
9032 $buffer = '';
9033
9034 $ch = curl_init();
9035 curl_setopt($ch, CURLOPT_URL, 'https://openrouter.ai/api/v1/chat/completions');
9036 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9037 curl_setopt($ch, CURLOPT_POST, true);
9038 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
9039 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
9040 'Content-Type: application/json',
9041 'Authorization: Bearer ' . $openrouter_api_key,
9042 'HTTP-Referer: ' . home_url(),
9043 'X-Title: ' . get_bloginfo('name')
9044 ));
9045 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9046 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
9047
9048 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
9049 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
9050 $captured_status_code = (int) $m[1];
9051 }
9052 return strlen($header);
9053 });
9054
9055 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
9056 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
9057 $captured_body_pre_stream .= $data;
9058 return strlen($data);
9059 }
9060
9061 if (!$this->streaming_headers_sent) {
9062 $this->setup_streaming_headers();
9063 }
9064
9065 if (!$stream_started && $testing_data !== null) {
9066 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
9067 flush();
9068 $stream_started = true;
9069 }
9070
9071 $buffer .= $data;
9072 $lines = explode("\n", $buffer);
9073 $buffer = array_pop($lines);
9074
9075 foreach ($lines as $line) {
9076 if (trim($line) === '') {
9077 continue;
9078 }
9079 if (strpos($line, 'data: ') !== 0) {
9080 continue;
9081 }
9082
9083 $json_str = substr($line, 6);
9084
9085 if (trim($json_str) === '[DONE]') {
9086 echo "data: [DONE]\n\n";
9087 flush();
9088 continue;
9089 }
9090
9091 $json = json_decode(trim($json_str), true);
9092 if ($json && isset($json['choices'][0]['delta']['content'])) {
9093 $content = $json['choices'][0]['delta']['content'];
9094 $full_response .= $content;
9095
9096 echo "data: " . json_encode(['content' => $content]) . "\n\n";
9097 flush();
9098 }
9099 }
9100
9101 return strlen($data);
9102 });
9103
9104 $response = curl_exec($ch);
9105 $errno = curl_errno($ch);
9106 $last_curl_error = curl_error($ch);
9107 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
9108 curl_close($ch);
9109
9110 if (!$errno && $http_code === 200) {
9111 break;
9112 }
9113
9114 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
9115 $can_retry = !$this->streaming_headers_sent
9116 && ($attempt + 1) < $max_attempts
9117 && $is_transient;
9118
9119 if (defined('WP_DEBUG') && WP_DEBUG) {
9120 error_log(sprintf(
9121 '[MxChat] openrouter_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
9122 $attempt + 1, $max_attempts, $http_code, $errno,
9123 $is_transient ? 'yes' : 'no',
9124 $can_retry ? 'Retrying.' : 'Giving up.'
9125 ));
9126 }
9127
9128 if (!$can_retry) {
9129 break;
9130 }
9131 }
9132
9133 if (!$errno && $http_code === 200) {
9134 if (!empty($full_response) && !empty($session_id)) {
9135 $rag_context_for_storage = null;
9136 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
9137 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
9138
9139 if ($has_rag_data || $has_action_data) {
9140 $rag_context_for_storage = [];
9141
9142 if ($has_rag_data) {
9143 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
9144 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
9145 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
9146 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
9147 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
9148 }
9149
9150 if ($has_action_data) {
9151 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
9152 }
9153 }
9154 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
9155 }
9156 return true;
9157 }
9158
9159 return $this->mxchat_stream_emit_fallback(
9160 'openai',
9161 $this->mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id),
9162 $session_id,
9163 $testing_data
9164 );
9165
9166 } catch (Exception $e) {
9167 return $this->mxchat_stream_emit_fallback(
9168 'openai',
9169 $this->mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id),
9170 $session_id,
9171 $testing_data
9172 );
9173 }
9174 }
9175 private function mxchat_generate_response_openai_stream($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
9176 // OpenAI retires gpt-5.1-chat-latest / gpt-5.3-chat-latest on 2026-08-10
9177 // (replacement gpt-5.6-sol). Read-time rescue mirrors the non-streaming
9178 // path (plan e46b8f).
9179 if ($selected_model === 'gpt-5.1-chat-latest' || $selected_model === 'gpt-5.3-chat-latest') { $selected_model = 'gpt-5.6-sol'; }
9180 try {
9181 $bot_id = $this->get_current_bot_id($session_id);
9182
9183 // Get system prompt instructions using centralized function
9184 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9185
9186 // Ensure conversation_history is an array
9187 if (!is_array($conversation_history)) {
9188 $conversation_history = array();
9189 }
9190
9191 // Format conversation history for OpenAI
9192 $formatted_conversation = array();
9193
9194 $formatted_conversation[] = array(
9195 'role' => 'system',
9196 'content' => $system_prompt_instructions . " " . $relevant_content
9197 );
9198
9199 foreach ($conversation_history as $message) {
9200 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9201 $role = $message['role'];
9202 if ($role === 'bot' || $role === 'agent') {
9203 $role = 'assistant';
9204 }
9205 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
9206 $role = 'user';
9207 }
9208 $formatted_conversation[] = array(
9209 'role' => $role,
9210 'content' => $message['content']
9211 );
9212 }
9213 }
9214
9215 // Check if we can actually stream
9216 if (headers_sent() || !function_exists('curl_init')) {
9217 // Fallback to regular response with testing data
9218 $regular_response = $this->mxchat_generate_response_openai(
9219 $selected_model,
9220 $api_key,
9221 $conversation_history,
9222 $relevant_content,
9223 $session_id
9224 );
9225
9226 // Save bot response to transcript
9227 if (!empty($regular_response) && !empty($session_id)) {
9228 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
9229 }
9230
9231 $response_data = [
9232 'text' => $regular_response,
9233 'html' => '',
9234 'session_id' => $session_id
9235 ];
9236
9237 if ($testing_data !== null) {
9238 $response_data['testing_data'] = $testing_data;
9239 }
9240
9241 header('Content-Type: application/json');
9242 echo json_encode($response_data);
9243 return true;
9244 }
9245
9246 // Build request body with optimal settings for fast streaming
9247 $request_body = [
9248 'model' => $selected_model,
9249 'messages' => $formatted_conversation,
9250 'temperature' => 1,
9251 'stream' => true
9252 ];
9253
9254 // reasoning_effort — sourced from the core model catalog (plan-dcb71c);
9255 // frozen inline ladder lives in mxchat_reasoning_effort_fallback().
9256 $effort = $this->mxchat_reasoning_effort_for($selected_model, 'chat');
9257 if ($effort !== null) {
9258 $request_body['reasoning_effort'] = $effort;
9259 }
9260
9261 $body = json_encode($request_body);
9262
9263 // V2 retry-on-initial-connect: do NOT call setup_streaming_headers() here.
9264 // It is now lazy-fired inside the WRITEFUNCTION on the first byte of a
9265 // SUCCESSFUL upstream response, gated by the captured HTTP status.
9266
9267 $captured_status_code = 0;
9268 $captured_body_pre_stream = '';
9269 $full_response = '';
9270 $stream_started = false;
9271 $buffer = '';
9272 $errno = 0;
9273 $last_curl_error = '';
9274 $http_code = 0;
9275 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
9276 $backoff_ms = array(0, 750, 2000);
9277
9278 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
9279 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
9280 usleep($backoff_ms[$attempt] * 1000);
9281 }
9282
9283 // Reset per-attempt capture state.
9284 $captured_status_code = 0;
9285 $captured_body_pre_stream = '';
9286 $full_response = '';
9287 $stream_started = false;
9288 $buffer = '';
9289
9290 $ch = curl_init();
9291 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
9292 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9293 curl_setopt($ch, CURLOPT_POST, true);
9294 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
9295 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
9296 'Content-Type: application/json',
9297 'Authorization: Bearer ' . $api_key
9298 ));
9299 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9300 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
9301
9302 // Capture HTTP status as soon as response headers arrive — fires before WRITEFUNCTION.
9303 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
9304 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
9305 $captured_status_code = (int) $m[1];
9306 }
9307 return strlen($header);
9308 });
9309
9310 // Buffer control for real-time streaming
9311 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
9312 // V2 guard: if upstream returned non-200, buffer body for transient
9313 // classification and DO NOT emit to client. Stream channel must NOT open.
9314 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
9315 $captured_body_pre_stream .= $data;
9316 return strlen($data);
9317 }
9318
9319 // Lazy-fire streaming headers on first byte of a SUCCESSFUL upstream.
9320 // After this point streaming_headers_sent === true → retry is structurally blocked.
9321 if (!$this->streaming_headers_sent) {
9322 $this->setup_streaming_headers();
9323 }
9324
9325 // Send testing data as the first event if available
9326 if (!$stream_started && $testing_data !== null) {
9327 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
9328 flush();
9329 $stream_started = true;
9330 }
9331
9332 // CRITICAL FIX: Append new data to buffer
9333 $buffer .= $data;
9334
9335 // Process complete lines only
9336 $lines = explode("\n", $buffer);
9337
9338 // CRITICAL FIX: Keep the last incomplete line in the buffer
9339 $buffer = array_pop($lines);
9340
9341 foreach ($lines as $line) {
9342 if (trim($line) === '') {
9343 continue;
9344 }
9345 if (strpos($line, 'data: ') !== 0) {
9346 continue;
9347 }
9348
9349 $json_str = substr($line, 6);
9350
9351 if (trim($json_str) === '[DONE]') {
9352 echo "data: [DONE]\n\n";
9353 flush();
9354 continue;
9355 }
9356
9357 $json = json_decode(trim($json_str), true);
9358 if ($json && isset($json['choices'][0]['delta']['content'])) {
9359 $content = $json['choices'][0]['delta']['content'];
9360 $full_response .= $content;
9361
9362 echo "data: " . json_encode(['content' => $content]) . "\n\n";
9363 flush();
9364 }
9365 }
9366
9367 return strlen($data);
9368 });
9369
9370 $response = curl_exec($ch);
9371 $errno = curl_errno($ch);
9372 $last_curl_error = curl_error($ch);
9373 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
9374 curl_close($ch);
9375
9376 if (!$errno && $http_code === 200) {
9377 break; // Happy path — WRITEFUNCTION already streamed everything.
9378 }
9379
9380 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
9381 $can_retry = !$this->streaming_headers_sent
9382 && ($attempt + 1) < $max_attempts
9383 && $is_transient;
9384
9385 if (defined('WP_DEBUG') && WP_DEBUG) {
9386 error_log(sprintf(
9387 '[MxChat] openai_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
9388 $attempt + 1, $max_attempts, $http_code, $errno,
9389 $is_transient ? 'yes' : 'no',
9390 $can_retry ? 'Retrying.' : 'Giving up.'
9391 ));
9392 }
9393
9394 if (!$can_retry) {
9395 break;
9396 }
9397 }
9398
9399 // Post-loop branch.
9400 if (!$errno && $http_code === 200) {
9401 // Happy path — save the complete response to maintain chat persistence.
9402 if (!empty($full_response) && !empty($session_id)) {
9403 $rag_context_for_storage = null;
9404 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
9405 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
9406
9407 if ($has_rag_data || $has_action_data) {
9408 $rag_context_for_storage = [];
9409
9410 if ($has_rag_data) {
9411 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
9412 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
9413 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
9414 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
9415 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
9416 }
9417
9418 if ($has_action_data) {
9419 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
9420 }
9421 }
9422 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
9423 }
9424
9425 return true;
9426 }
9427
9428 // Failure path — branch on whether SSE channel was opened.
9429 return $this->mxchat_stream_emit_fallback(
9430 'openai',
9431 $this->mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content, $session_id),
9432 $session_id,
9433 $testing_data
9434 );
9435
9436 } catch (Exception $e) {
9437 return $this->mxchat_stream_emit_fallback(
9438 'openai',
9439 $this->mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content, $session_id),
9440 $session_id,
9441 $testing_data
9442 );
9443 }
9444 }
9445
9446 /**
9447 * Shared fallback emitter for streaming chat functions. Two outcomes:
9448 * - streaming_headers_sent === true: SSE channel is open. Emit fallback content
9449 * as `data: {...}\n\n` + `data: [DONE]\n\n` so the widget renders it as a
9450 * normal bot bubble. Transcript row is persisted.
9451 * - streaming_headers_sent === false: SSE channel never opened (retries
9452 * exhausted on initial connect). Emit a clean JSON response — the path
9453 * the widget would normally hit if streaming wasn't even attempted.
9454 *
9455 * Used by all six *_stream functions after their per-attempt retry loop.
9456 */
9457 private function mxchat_stream_emit_fallback($provider_hint, $regular_response, $session_id, $testing_data = null) {
9458 $is_error_array = is_array($regular_response) && isset($regular_response['error']);
9459
9460 if ($this->streaming_headers_sent) {
9461 if ($is_error_array) {
9462 echo "data: " . json_encode([
9463 'error' => true,
9464 'error_message' => $regular_response['error'],
9465 'error_code' => $regular_response['error_code'] ?? 'api_error',
9466 'text' => $regular_response['error'],
9467 'message' => $regular_response['error']
9468 ]) . "\n\n";
9469 echo "data: [DONE]\n\n";
9470 flush();
9471 return true;
9472 }
9473 $fallback_message = (string) $regular_response;
9474 if (!empty($fallback_message) && !empty($session_id)) {
9475 $this->mxchat_save_chat_message($session_id, 'bot', $fallback_message);
9476 }
9477 echo "data: " . json_encode(['content' => $fallback_message]) . "\n\n";
9478 echo "data: [DONE]\n\n";
9479 flush();
9480 return true;
9481 }
9482
9483 // SSE channel never opened — clean JSON fallback.
9484 if ($is_error_array) {
9485 header('Content-Type: application/json');
9486 echo json_encode(array(
9487 'error' => true,
9488 'error_message' => $regular_response['error'],
9489 'error_code' => $regular_response['error_code'] ?? 'api_error',
9490 'text' => $regular_response['error'],
9491 'message' => $regular_response['error'],
9492 ));
9493 return true;
9494 }
9495
9496 $fallback_message = (string) $regular_response;
9497 if (!empty($fallback_message) && !empty($session_id)) {
9498 $this->mxchat_save_chat_message($session_id, 'bot', $fallback_message);
9499 }
9500 $response_data = array(
9501 'text' => $fallback_message,
9502 'html' => '',
9503 'session_id' => $session_id,
9504 );
9505 if ($testing_data !== null) {
9506 $response_data['testing_data'] = $testing_data;
9507 }
9508 header('Content-Type: application/json');
9509 echo json_encode($response_data);
9510 return true;
9511 }
9512
9513 /**
9514 * Resolve custom (OpenAI-compatible) provider config from settings.
9515 * Returns ['base_url','api_key','model','auth_scheme','api_version','chat_url','headers'].
9516 */
9517 private function mxchat_resolve_custom_provider() {
9518 $base_url = isset($this->options['custom_provider_base_url']) ? rtrim(trim((string) $this->options['custom_provider_base_url']), '/') : '';
9519 $api_key = isset($this->options['custom_provider_api_key']) ? trim((string) $this->options['custom_provider_api_key']) : '';
9520 $model = isset($this->options['custom_provider_model']) ? trim((string) $this->options['custom_provider_model']) : '';
9521 $auth_scheme = isset($this->options['custom_provider_auth_scheme']) ? $this->options['custom_provider_auth_scheme'] : 'bearer';
9522 $api_version = isset($this->options['custom_provider_api_version']) ? trim((string) $this->options['custom_provider_api_version']) : '';
9523
9524 $chat_url = $base_url . '/chat/completions';
9525 if (!empty($api_version)) {
9526 $chat_url .= (strpos($chat_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($api_version);
9527 }
9528
9529 $headers = array('Content-Type: application/json');
9530 if (!empty($api_key)) {
9531 if ($auth_scheme === 'api-key') {
9532 $headers[] = 'api-key: ' . $api_key;
9533 } else {
9534 $headers[] = 'Authorization: Bearer ' . $api_key;
9535 }
9536 }
9537
9538 return array(
9539 'base_url' => $base_url,
9540 'api_key' => $api_key,
9541 'model' => $model !== '' ? $model : 'default',
9542 'auth_scheme' => $auth_scheme,
9543 'api_version' => $api_version,
9544 'chat_url' => $chat_url,
9545 'headers' => $headers,
9546 );
9547 }
9548
9549 /**
9550 * Streaming chat completion against an OpenAI-compatible custom provider
9551 * (Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.).
9552 * Mirrors mxchat_generate_response_openai_stream but with parameterized URL/auth/model.
9553 */
9554 private function mxchat_generate_response_custom_stream($selected_model, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
9555 try {
9556 $cfg = $this->mxchat_resolve_custom_provider();
9557 if (empty($cfg['base_url'])) {
9558 return array('error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url');
9559 }
9560
9561 $bot_id = $this->get_current_bot_id($session_id);
9562 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9563 if (!is_array($conversation_history)) {
9564 $conversation_history = array();
9565 }
9566
9567 $formatted_conversation = array();
9568 $formatted_conversation[] = array(
9569 'role' => 'system',
9570 'content' => $system_prompt_instructions . ' ' . $relevant_content,
9571 );
9572 foreach ($conversation_history as $message) {
9573 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9574 $role = $message['role'];
9575 if ($role === 'bot' || $role === 'agent') { $role = 'assistant'; }
9576 if (!in_array($role, array('system', 'assistant', 'user', 'function', 'tool'))) { $role = 'user'; }
9577 $formatted_conversation[] = array('role' => $role, 'content' => $message['content']);
9578 }
9579 }
9580
9581 if (headers_sent() || !function_exists('curl_init')) {
9582 // No streaming capability — fall through to non-stream wrapper
9583 $regular = $this->mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content);
9584 if (!empty($regular) && !empty($session_id) && is_string($regular)) {
9585 $this->mxchat_save_chat_message($session_id, 'bot', $regular);
9586 }
9587 $response_data = array('text' => is_string($regular) ? $regular : '', 'html' => '', 'session_id' => $session_id);
9588 if ($testing_data !== null) { $response_data['testing_data'] = $testing_data; }
9589 header('Content-Type: application/json');
9590 echo json_encode($response_data);
9591 return true;
9592 }
9593
9594 $request_body = array(
9595 'model' => $cfg['model'],
9596 'messages' => $formatted_conversation,
9597 'stream' => true,
9598 );
9599 $body = json_encode($request_body);
9600
9601 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
9602
9603 $captured_status_code = 0;
9604 $captured_body_pre_stream = '';
9605 $full_response = '';
9606 $stream_started = false;
9607 $buffer = '';
9608 $errno = 0;
9609 $http_code = 0;
9610 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
9611 $backoff_ms = array(0, 750, 2000);
9612
9613 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
9614 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
9615 usleep($backoff_ms[$attempt] * 1000);
9616 }
9617
9618 $captured_status_code = 0;
9619 $captured_body_pre_stream = '';
9620 $full_response = '';
9621 $stream_started = false;
9622 $buffer = '';
9623
9624 $ch = curl_init();
9625 curl_setopt($ch, CURLOPT_URL, $cfg['chat_url']);
9626 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9627 curl_setopt($ch, CURLOPT_POST, true);
9628 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
9629 curl_setopt($ch, CURLOPT_HTTPHEADER, $cfg['headers']);
9630 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9631 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
9632
9633 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
9634 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
9635 $captured_status_code = (int) $m[1];
9636 }
9637 return strlen($header);
9638 });
9639
9640 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
9641 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
9642 $captured_body_pre_stream .= $data;
9643 return strlen($data);
9644 }
9645
9646 if (!$this->streaming_headers_sent) {
9647 $this->setup_streaming_headers();
9648 }
9649
9650 if (!$stream_started && $testing_data !== null) {
9651 echo "data: " . json_encode(array('testing_data' => $testing_data)) . "\n\n";
9652 flush();
9653 $stream_started = true;
9654 }
9655 $buffer .= $data;
9656 $lines = explode("\n", $buffer);
9657 $buffer = array_pop($lines);
9658 foreach ($lines as $line) {
9659 if (trim($line) === '') { continue; }
9660 if (strpos($line, 'data: ') !== 0) { continue; }
9661 $json_str = substr($line, 6);
9662 if (trim($json_str) === '[DONE]') {
9663 echo "data: [DONE]\n\n";
9664 flush();
9665 continue;
9666 }
9667 $json = json_decode(trim($json_str), true);
9668 if ($json && isset($json['choices'][0]['delta']['content'])) {
9669 $content = $json['choices'][0]['delta']['content'];
9670 $full_response .= $content;
9671 echo "data: " . json_encode(array('content' => $content)) . "\n\n";
9672 flush();
9673 }
9674 }
9675 return strlen($data);
9676 });
9677
9678 $response = curl_exec($ch);
9679 $errno = curl_errno($ch);
9680 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
9681 curl_close($ch);
9682
9683 if (!$errno && $http_code === 200) {
9684 break;
9685 }
9686
9687 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
9688 $can_retry = !$this->streaming_headers_sent
9689 && ($attempt + 1) < $max_attempts
9690 && $is_transient;
9691
9692 if (defined('WP_DEBUG') && WP_DEBUG) {
9693 error_log(sprintf(
9694 '[MxChat] custom_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
9695 $attempt + 1, $max_attempts, $http_code, $errno,
9696 $is_transient ? 'yes' : 'no',
9697 $can_retry ? 'Retrying.' : 'Giving up.'
9698 ));
9699 }
9700
9701 if (!$can_retry) {
9702 break;
9703 }
9704 }
9705
9706 if (!$errno && $http_code === 200) {
9707 if (!empty($full_response) && !empty($session_id)) {
9708 $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
9709 }
9710 return true;
9711 }
9712
9713 return $this->mxchat_stream_emit_fallback(
9714 'openai',
9715 $this->mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content),
9716 $session_id,
9717 $testing_data
9718 );
9719
9720 } catch (Exception $e) {
9721 return array('error' => sprintf(esc_html__('Custom provider error: %s', 'mxchat'), $e->getMessage()), 'error_code' => 'custom_provider_exception');
9722 }
9723 }
9724
9725 /**
9726 * Non-streaming chat completion against a custom OpenAI-compatible provider.
9727 * Returns string content on success, array['error'=>...] on failure.
9728 */
9729 private function mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content) {
9730 $cfg = $this->mxchat_resolve_custom_provider();
9731 if (empty($cfg['base_url'])) {
9732 return array('error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url');
9733 }
9734
9735 $bot_id = $this->get_current_bot_id(null);
9736 $system_prompt_instructions = $this->get_system_instructions($bot_id, null);
9737 if (!is_array($conversation_history)) {
9738 $conversation_history = array();
9739 }
9740
9741 $messages = array(array(
9742 'role' => 'system',
9743 'content' => $system_prompt_instructions . ' ' . $relevant_content,
9744 ));
9745 foreach ($conversation_history as $message) {
9746 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9747 $role = $message['role'];
9748 if ($role === 'bot' || $role === 'agent') { $role = 'assistant'; }
9749 if (!in_array($role, array('system', 'assistant', 'user', 'function', 'tool'))) { $role = 'user'; }
9750 $messages[] = array('role' => $role, 'content' => $message['content']);
9751 }
9752 }
9753
9754 $headers_assoc = array('Content-Type' => 'application/json');
9755 if (!empty($cfg['api_key'])) {
9756 if ($cfg['auth_scheme'] === 'api-key') {
9757 $headers_assoc['api-key'] = $cfg['api_key'];
9758 } else {
9759 $headers_assoc['Authorization'] = 'Bearer ' . $cfg['api_key'];
9760 }
9761 }
9762
9763 $response = $this->mxchat_provider_call_with_retry($cfg['chat_url'], array(
9764 'headers' => $headers_assoc,
9765 'body' => wp_json_encode(array(
9766 'model' => $cfg['model'],
9767 'messages' => $messages,
9768 )),
9769 'timeout' => 120,
9770 ), 'openai');
9771
9772 if (is_wp_error($response)) {
9773 return array('error' => sprintf(esc_html__('Custom provider request failed: %s', 'mxchat'), $response->get_error_message()), 'error_code' => 'custom_provider_network_error');
9774 }
9775 $code = (int) wp_remote_retrieve_response_code($response);
9776 if ($code < 200 || $code >= 300) {
9777 return array('error' => sprintf(esc_html__('Custom provider returned HTTP %d.', 'mxchat'), $code), 'error_code' => 'custom_provider_http_error');
9778 }
9779 $body = json_decode(wp_remote_retrieve_body($response), true);
9780 if (isset($body['choices'][0]['message']['content'])) {
9781 return (string) $body['choices'][0]['message']['content'];
9782 }
9783 return array('error' => esc_html__('Custom provider returned an unexpected response shape.', 'mxchat'), 'error_code' => 'custom_provider_response_shape');
9784 }
9785
9786 /**
9787 * Generate response using OpenAI Responses API with web search tool
9788 * This uses the newer Responses API which supports web search functionality
9789 */
9790 private function mxchat_generate_response_openai_web_search($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null, $streaming = false) {
9791 // OpenAI retires gpt-5.1-chat-latest / gpt-5.3-chat-latest on 2026-08-10
9792 // (replacement gpt-5.6-sol). Read-time rescue mirrors the chat paths
9793 // (plan e46b8f).
9794 if ($selected_model === 'gpt-5.1-chat-latest' || $selected_model === 'gpt-5.3-chat-latest') { $selected_model = 'gpt-5.6-sol'; }
9795 try {
9796 $bot_id = $this->get_current_bot_id($session_id);
9797 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9798
9799 if (!is_array($conversation_history)) {
9800 $conversation_history = array();
9801 }
9802
9803 // Build the input for Responses API
9804 // The Responses API uses a different format - we need to construct the input properly
9805 $input_parts = [];
9806
9807 // Add system instructions as context
9808 $system_context = $system_prompt_instructions . "\n\n" . $relevant_content;
9809
9810 // Build conversation as input items for Responses API
9811 foreach ($conversation_history as $message) {
9812 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9813 $role = $message['role'];
9814 if ($role === 'bot' || $role === 'agent') {
9815 $role = 'assistant';
9816 }
9817 if (!in_array($role, ['assistant', 'user'])) {
9818 $role = 'user';
9819 }
9820 $input_parts[] = [
9821 'type' => 'message',
9822 'role' => $role,
9823 'content' => $message['content']
9824 ];
9825 }
9826 }
9827
9828 // Build request body for Responses API
9829 $request_body = [
9830 'model' => $selected_model,
9831 'input' => $input_parts,
9832 'instructions' => $system_context,
9833 'stream' => $streaming
9834 ];
9835
9836 // Only add web search tool if web search is enabled in settings
9837 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
9838 if ($web_search_enabled) {
9839 $request_body['tools'] = [
9840 ['type' => 'web_search']
9841 ];
9842 }
9843
9844 // reasoning.effort — sourced from the core model catalog (plan-dcb71c),
9845 // 'websearch' surface; frozen inline ladder in mxchat_reasoning_effort_fallback().
9846 $effort = $this->mxchat_reasoning_effort_for($selected_model, 'websearch');
9847 if ($effort !== null) {
9848 $request_body['reasoning'] = ['effort' => $effort];
9849 }
9850
9851 //error_log("MXCHAT WEB SEARCH: Request body: " . json_encode($request_body));
9852
9853 if ($streaming) {
9854 return $this->mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data);
9855 } else {
9856 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
9857 }
9858
9859 } catch (Exception $e) {
9860 //error_log("MXCHAT WEB SEARCH ERROR: " . $e->getMessage());
9861 return [
9862 'error' => sprintf(esc_html__('Web search error: %s', 'mxchat'), esc_html($e->getMessage())),
9863 'error_code' => 'web_search_exception'
9864 ];
9865 }
9866 }
9867
9868 /**
9869 * Handle non-streaming web search response
9870 */
9871 private function mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data) {
9872 $request_body['stream'] = false;
9873
9874 $response = $this->mxchat_provider_call_with_retry('https://api.openai.com/v1/responses', array(
9875 'headers' => array(
9876 'Authorization' => 'Bearer ' . $api_key,
9877 'Content-Type' => 'application/json'
9878 ),
9879 'body' => json_encode($request_body),
9880 'timeout' => 90
9881 ), 'openai');
9882
9883 if (is_wp_error($response)) {
9884 //error_log("MXCHAT WEB SEARCH ERROR: WP Error: " . $response->get_error_message());
9885 return [
9886 'error' => esc_html__('Failed to connect to OpenAI web search API', 'mxchat'),
9887 'error_code' => 'web_search_connection_error'
9888 ];
9889 }
9890
9891 $response_code = wp_remote_retrieve_response_code($response);
9892 $response_body = wp_remote_retrieve_body($response);
9893
9894 //error_log("MXCHAT WEB SEARCH: Response code: " . $response_code);
9895 //error_log("MXCHAT WEB SEARCH: Response body (first 2000): " . substr($response_body, 0, 2000));
9896
9897 if ($response_code !== 200) {
9898 $error_data = json_decode($response_body, true);
9899 $error_message = $this->extract_provider_error($error_data, 'Unknown API error');
9900 return [
9901 'error' => sprintf(esc_html__('OpenAI API error: %s', 'mxchat'), esc_html($error_message)),
9902 'error_code' => 'web_search_api_error'
9903 ];
9904 }
9905
9906 $result = json_decode($response_body, true);
9907
9908 if (json_last_error() !== JSON_ERROR_NONE) {
9909 return [
9910 'error' => esc_html__('Invalid response from OpenAI', 'mxchat'),
9911 'error_code' => 'web_search_json_error'
9912 ];
9913 }
9914
9915 // Extract the response text and citations from Responses API format
9916 $output_text = '';
9917 $citations = [];
9918
9919 if (isset($result['output'])) {
9920 foreach ($result['output'] as $output_item) {
9921 if ($output_item['type'] === 'message' && isset($output_item['content'])) {
9922 foreach ($output_item['content'] as $content_item) {
9923 if ($content_item['type'] === 'output_text') {
9924 $output_text .= $content_item['text'];
9925
9926 // Extract citations/annotations
9927 if (isset($content_item['annotations'])) {
9928 foreach ($content_item['annotations'] as $annotation) {
9929 if ($annotation['type'] === 'url_citation') {
9930 $citations[] = [
9931 'url' => $annotation['url'],
9932 'title' => $annotation['title'] ?? ''
9933 ];
9934 }
9935 }
9936 }
9937 }
9938 }
9939 }
9940 }
9941 }
9942
9943 // If we have citations, append them to the response
9944 if (!empty($citations)) {
9945 $output_text .= "\n\n**Sources:**\n";
9946 $seen_urls = [];
9947 foreach ($citations as $citation) {
9948 if (!in_array($citation['url'], $seen_urls)) {
9949 $seen_urls[] = $citation['url'];
9950 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
9951 $output_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
9952 }
9953 }
9954 }
9955
9956 // Transcript save is handled by the main handler (mxchat_handle_chat_request)
9957 // which includes rag_context for the "sources" link in transcripts.
9958
9959 // plan-4aa8e5: a 200 whose output carries no output_text (status
9960 // "incomplete" with max_output_tokens exhausted, content-filter-emptied
9961 // output, shape drift) previously fell through and returned '' — a
9962 // silent empty bot bubble. This is the DEFAULT model path
9963 // (the default OpenAI chat model routes through /v1/responses).
9964 if (trim($output_text) === '') {
9965 return $this->mxchat_empty_completion_error($result, 'OpenAI');
9966 }
9967
9968 return $output_text;
9969 }
9970
9971 /**
9972 * Handle streaming web search response using Responses API
9973 */
9974 private function mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data) {
9975 $request_body['stream'] = true;
9976
9977 // Check if we can stream
9978 if (headers_sent() || !function_exists('curl_init')) {
9979 // Fallback to non-streaming
9980 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
9981 }
9982
9983 // Setup streaming headers
9984 $this->setup_streaming_headers();
9985
9986 $ch = curl_init();
9987 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/responses');
9988 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9989 curl_setopt($ch, CURLOPT_POST, true);
9990 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request_body));
9991 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
9992 'Content-Type: application/json',
9993 'Authorization: Bearer ' . $api_key
9994 ));
9995 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9996 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
9997
9998 $full_response = '';
9999 $stream_started = false;
10000 $buffer = '';
10001 $citations = [];
10002 $empty_error_emitted = false;
10003
10004 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$citations, &$empty_error_emitted, $testing_data) {
10005 // Send testing data as first event if available
10006 if (!$stream_started && $testing_data !== null) {
10007 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
10008 flush();
10009 $stream_started = true;
10010 }
10011
10012 $buffer .= $data;
10013 $lines = explode("\n", $buffer);
10014 $buffer = array_pop($lines);
10015
10016 foreach ($lines as $line) {
10017 if (trim($line) === '') continue;
10018 if (strpos($line, 'data: ') !== 0) continue;
10019
10020 $json_str = substr($line, 6);
10021
10022 if (trim($json_str) === '[DONE]') {
10023 // Append citations if we have any
10024 if (!empty($citations)) {
10025 $citation_text = "\n\n**Sources:**\n";
10026 $seen_urls = [];
10027 foreach ($citations as $citation) {
10028 if (!in_array($citation['url'], $seen_urls)) {
10029 $seen_urls[] = $citation['url'];
10030 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
10031 $citation_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
10032 }
10033 }
10034 echo "data: " . json_encode(['content' => $citation_text]) . "\n\n";
10035 $full_response .= $citation_text;
10036 flush();
10037 }
10038 // plan-4aa8e5: zero deltas streamed → say so instead of
10039 // closing a silent empty bubble (client renders text events).
10040 if (trim($full_response) === '' && !$empty_error_emitted) {
10041 $empty_error_emitted = true;
10042 echo "data: " . json_encode(['content' => esc_html__('The AI provider returned an empty response. Please try again.', 'mxchat')]) . "\n\n";
10043 }
10044 echo "data: [DONE]\n\n";
10045 flush();
10046 continue;
10047 }
10048
10049 $json = json_decode(trim($json_str), true);
10050 if (!$json) continue;
10051
10052 // Handle Responses API streaming events
10053 // The format is different from Chat Completions
10054 if (isset($json['type'])) {
10055 switch ($json['type']) {
10056 case 'response.output_text.delta':
10057 // Text content delta
10058 if (isset($json['delta'])) {
10059 $content = $json['delta'];
10060 $full_response .= $content;
10061 echo "data: " . json_encode(['content' => $content]) . "\n\n";
10062 flush();
10063 }
10064 break;
10065
10066 case 'response.output_item.done':
10067 // Check for citations in completed items
10068 if (isset($json['item']['content'])) {
10069 foreach ($json['item']['content'] as $content_item) {
10070 if (isset($content_item['annotations'])) {
10071 foreach ($content_item['annotations'] as $annotation) {
10072 if ($annotation['type'] === 'url_citation') {
10073 $citations[] = [
10074 'url' => $annotation['url'],
10075 'title' => $annotation['title'] ?? ''
10076 ];
10077 }
10078 }
10079 }
10080 }
10081 }
10082 break;
10083 }
10084 }
10085 }
10086
10087 return strlen($data);
10088 });
10089
10090 $response = curl_exec($ch);
10091 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
10092
10093 if (curl_errno($ch) || $http_code !== 200) {
10094 $curl_error = curl_error($ch);
10095 curl_close($ch);
10096
10097 //error_log("MXCHAT WEB SEARCH STREAM ERROR: HTTP $http_code, cURL error: $curl_error");
10098
10099 return $this->mxchat_stream_emit_fallback(
10100 'web_search',
10101 $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data),
10102 $session_id,
10103 $testing_data
10104 );
10105 }
10106
10107 curl_close($ch);
10108
10109 // plan-4aa8e5: the Responses API can end its stream via typed events
10110 // without a [DONE] line — if nothing was streamed at all, close out with
10111 // the empty-completion message instead of leaving a silent bubble.
10112 if (trim($full_response) === '' && !$empty_error_emitted) {
10113 echo "data: " . json_encode(['content' => esc_html__('The AI provider returned an empty response. Please try again.', 'mxchat')]) . "\n\n";
10114 echo "data: [DONE]\n\n";
10115 flush();
10116 }
10117
10118 // Save the complete response with RAG context so the "sources" link
10119 // appears in transcripts — mirrors the pattern used by Claude/OpenAI streaming.
10120 if (!empty($full_response) && !empty($session_id)) {
10121 $rag_context_for_storage = null;
10122 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
10123 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
10124
10125 if ($has_rag_data || $has_action_data) {
10126 $rag_context_for_storage = [];
10127
10128 if ($has_rag_data) {
10129 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
10130 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
10131 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
10132 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
10133 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10134 }
10135
10136 if ($has_action_data) {
10137 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10138 }
10139 }
10140 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
10141 }
10142
10143 return true;
10144 }
10145
10146 private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
10147 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
10148 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
10149 if ($selected_model === 'claude-opus-4-20250514') { $selected_model = 'claude-opus-4-8'; }
10150 elseif ($selected_model === 'claude-sonnet-4-20250514') { $selected_model = 'claude-sonnet-4-6'; }
10151 try {
10152 // Get bot ID from session or request
10153 $bot_id = $this->get_current_bot_id($session_id);
10154
10155 // Get system prompt instructions using centralized function
10156 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10157 // Ensure conversation_history is an array
10158 if (!is_array($conversation_history)) {
10159 $conversation_history = array();
10160 }
10161
10162 // Clean and validate conversation history
10163 foreach ($conversation_history as &$message) {
10164 // Convert bot and agent roles to assistant
10165 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
10166 $message['role'] = 'assistant';
10167 }
10168
10169 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
10170 if (!in_array($message['role'], ['assistant', 'user'])) {
10171 $message['role'] = 'user';
10172 }
10173
10174 // Ensure content field exists
10175 if (!isset($message['content']) || empty($message['content'])) {
10176 $message['content'] = '';
10177 }
10178
10179 // Remove any unsupported fields
10180 $message = array_intersect_key($message, array_flip(['role', 'content']));
10181 }
10182
10183 // Add relevant content as the latest user message
10184 $conversation_history[] = [
10185 'role' => 'user',
10186 'content' => $relevant_content
10187 ];
10188
10189 // Prepare the request body with stream: true
10190 $payload = [
10191 'model' => $selected_model,
10192 'messages' => $conversation_history,
10193 'max_tokens' => 1000,
10194 'temperature' => 0.8,
10195 'system' => $system_prompt_instructions,
10196 'stream' => true
10197 ];
10198 if ($this->mxchat_claude_omits_temperature($selected_model)) { unset($payload['temperature']); }
10199 $body = json_encode($payload);
10200
10201 // Check if we can actually stream (headers not sent, etc.)
10202 if (headers_sent() || !function_exists('curl_init')) {
10203 // Fallback to regular response with testing data
10204 //error_log("MxChat: Streaming not possible, falling back to regular response");
10205 $regular_response = $this->mxchat_generate_response_claude(
10206 $selected_model,
10207 $claude_api_key,
10208 array_slice($conversation_history, 0, -1), // Remove the added content
10209 $relevant_content,
10210 $session_id
10211 );
10212
10213 // Save bot response to transcript
10214 if (!empty($regular_response) && !empty($session_id)) {
10215 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
10216 }
10217
10218 // Return as JSON with testing data
10219 $response_data = [
10220 'text' => $regular_response,
10221 'html' => '',
10222 'session_id' => $session_id
10223 ];
10224
10225 if ($testing_data !== null) {
10226 $response_data['testing_data'] = $testing_data;
10227 //error_log("MxChat Testing: Added testing data to Claude fallback response");
10228 }
10229
10230 // Clear any streaming headers and send JSON
10231 if (headers_sent() === false) {
10232 header('Content-Type: application/json');
10233 }
10234 echo json_encode($response_data);
10235 return true; // Indicate we handled the response
10236 }
10237
10238 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
10239
10240 $captured_status_code = 0;
10241 $captured_body_pre_stream = '';
10242 $full_response = '';
10243 $stream_started = false;
10244 $buffer = '';
10245 $errno = 0;
10246 $http_code = 0;
10247 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
10248 $backoff_ms = array(0, 750, 2000);
10249
10250 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
10251 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
10252 usleep($backoff_ms[$attempt] * 1000);
10253 }
10254
10255 $captured_status_code = 0;
10256 $captured_body_pre_stream = '';
10257 $full_response = '';
10258 $stream_started = false;
10259 $buffer = '';
10260
10261 $ch = curl_init();
10262 curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
10263 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
10264 curl_setopt($ch, CURLOPT_POST, true);
10265 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
10266 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
10267 'Content-Type: application/json',
10268 'x-api-key: ' . $claude_api_key,
10269 'anthropic-version: 2023-06-01'
10270 ));
10271 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
10272 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
10273
10274 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
10275 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
10276 $captured_status_code = (int) $m[1];
10277 }
10278 return strlen($header);
10279 });
10280
10281 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
10282 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
10283 $captured_body_pre_stream .= $data;
10284 return strlen($data);
10285 }
10286
10287 if (!$this->streaming_headers_sent) {
10288 $this->setup_streaming_headers();
10289 }
10290
10291 if (!$stream_started && $testing_data !== null) {
10292 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
10293 flush();
10294 $stream_started = true;
10295 }
10296
10297 $buffer .= $data;
10298 $lines = explode("\n", $buffer);
10299 $buffer = array_pop($lines);
10300
10301 foreach ($lines as $line) {
10302 if (trim($line) === '') {
10303 continue;
10304 }
10305
10306 if (strpos($line, 'event: ') === 0) {
10307 continue;
10308 }
10309
10310 if (strpos($line, 'data: ') === 0) {
10311 $json_str = substr($line, 6);
10312
10313 $json = json_decode(trim($json_str), true);
10314 if (json_last_error() !== JSON_ERROR_NONE) {
10315 continue;
10316 }
10317
10318 if (isset($json['type'])) {
10319 switch ($json['type']) {
10320 case 'content_block_delta':
10321 if (isset($json['delta']['text'])) {
10322 $content = $json['delta']['text'];
10323 $full_response .= $content;
10324 echo "data: " . json_encode(['content' => $content]) . "\n\n";
10325 flush();
10326 }
10327 break;
10328
10329 case 'message_stop':
10330 echo "data: [DONE]\n\n";
10331 flush();
10332 break;
10333
10334 case 'error':
10335 echo "data: " . json_encode(['error' => $this->extract_provider_error($json, 'Unknown error')]) . "\n\n";
10336 flush();
10337 break;
10338 }
10339 }
10340 }
10341 }
10342
10343 return strlen($data);
10344 });
10345
10346 $response = curl_exec($ch);
10347 $errno = curl_errno($ch);
10348 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
10349 curl_close($ch);
10350
10351 if (!$errno && $http_code === 200) {
10352 break;
10353 }
10354
10355 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'anthropic', $errno);
10356 $can_retry = !$this->streaming_headers_sent
10357 && ($attempt + 1) < $max_attempts
10358 && $is_transient;
10359
10360 if (defined('WP_DEBUG') && WP_DEBUG) {
10361 error_log(sprintf(
10362 '[MxChat] claude_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
10363 $attempt + 1, $max_attempts, $http_code, $errno,
10364 $is_transient ? 'yes' : 'no',
10365 $can_retry ? 'Retrying.' : 'Giving up.'
10366 ));
10367 }
10368
10369 if (!$can_retry) {
10370 break;
10371 }
10372 }
10373
10374 if ($errno || $http_code !== 200) {
10375 return $this->mxchat_stream_emit_fallback(
10376 'anthropic',
10377 $this->mxchat_generate_response_claude($selected_model, $claude_api_key, array_slice($conversation_history, 0, -1), $relevant_content, $session_id),
10378 $session_id,
10379 $testing_data
10380 );
10381 }
10382
10383 // Save the complete response to maintain chat persistence
10384 if (!empty($full_response) && !empty($session_id)) {
10385 // Prepare RAG context for streaming response
10386 $rag_context_for_storage = null;
10387 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
10388 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
10389
10390 if ($has_rag_data || $has_action_data) {
10391 $rag_context_for_storage = [];
10392
10393 if ($has_rag_data) {
10394 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
10395 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
10396 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
10397 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
10398 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10399 }
10400
10401 if ($has_action_data) {
10402 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10403 }
10404 }
10405 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
10406 }
10407
10408 return true; // Indicate streaming completed successfully
10409
10410 } catch (Exception $e) {
10411 return $this->mxchat_stream_emit_fallback(
10412 'anthropic',
10413 $this->mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id),
10414 $session_id,
10415 $testing_data
10416 );
10417 }
10418 }
10419 private function mxchat_generate_response_xai_stream($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
10420 try {
10421 // Get bot ID from session or request
10422 $bot_id = $this->get_current_bot_id($session_id);
10423
10424 // Get system prompt instructions using centralized function
10425 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10426
10427 // Ensure conversation_history is an array
10428 if (!is_array($conversation_history)) {
10429 $conversation_history = array();
10430 }
10431
10432 // Format conversation history for X.AI (same as OpenAI format)
10433 $formatted_conversation = array();
10434
10435 $formatted_conversation[] = array(
10436 'role' => 'system',
10437 'content' => $system_prompt_instructions . " " . $relevant_content
10438 );
10439
10440 foreach ($conversation_history as $message) {
10441 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
10442 $role = $message['role'];
10443 if ($role === 'bot' || $role === 'agent') {
10444 $role = 'assistant';
10445 }
10446 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
10447 $role = 'user';
10448 }
10449 $formatted_conversation[] = array(
10450 'role' => $role,
10451 'content' => $message['content']
10452 );
10453 }
10454 }
10455
10456 // Check if we can actually stream
10457 if (headers_sent() || !function_exists('curl_init')) {
10458 // Fallback to regular response with testing data
10459 //error_log("MxChat: X.AI streaming not possible, falling back to regular response");
10460 $regular_response = $this->mxchat_generate_response_xai(
10461 $selected_model,
10462 $xai_api_key,
10463 $conversation_history,
10464 $relevant_content,
10465 $session_id
10466 );
10467
10468 // Save bot response to transcript
10469 if (!empty($regular_response) && !empty($session_id)) {
10470 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
10471 }
10472
10473 $response_data = [
10474 'text' => $regular_response,
10475 'html' => '',
10476 'session_id' => $session_id
10477 ];
10478
10479 if ($testing_data !== null) {
10480 $response_data['testing_data'] = $testing_data;
10481 //error_log("MxChat Testing: Added testing data to X.AI fallback response");
10482 }
10483
10484 header('Content-Type: application/json');
10485 echo json_encode($response_data);
10486 return true;
10487 }
10488
10489 // Prepare the request body with stream: true
10490 $body = json_encode([
10491 'model' => $selected_model,
10492 'messages' => $formatted_conversation,
10493 'temperature' => 0.8,
10494 'stream' => true
10495 ]);
10496
10497 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
10498
10499 $captured_status_code = 0;
10500 $captured_body_pre_stream = '';
10501 $full_response = '';
10502 $stream_started = false;
10503 $buffer = '';
10504 $errno = 0;
10505 $http_code = 0;
10506 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
10507 $backoff_ms = array(0, 750, 2000);
10508
10509 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
10510 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
10511 usleep($backoff_ms[$attempt] * 1000);
10512 }
10513
10514 $captured_status_code = 0;
10515 $captured_body_pre_stream = '';
10516 $full_response = '';
10517 $stream_started = false;
10518 $buffer = '';
10519
10520 $ch = curl_init();
10521 curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
10522 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
10523 curl_setopt($ch, CURLOPT_POST, true);
10524 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
10525 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
10526 'Content-Type: application/json',
10527 'Authorization: Bearer ' . $xai_api_key
10528 ));
10529 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
10530 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
10531
10532 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
10533 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
10534 $captured_status_code = (int) $m[1];
10535 }
10536 return strlen($header);
10537 });
10538
10539 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
10540 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
10541 $captured_body_pre_stream .= $data;
10542 return strlen($data);
10543 }
10544
10545 if (!$this->streaming_headers_sent) {
10546 $this->setup_streaming_headers();
10547 }
10548
10549 if (!$stream_started && $testing_data !== null) {
10550 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
10551 flush();
10552 $stream_started = true;
10553 }
10554
10555 $buffer .= $data;
10556 $lines = explode("\n", $buffer);
10557 $buffer = array_pop($lines);
10558
10559 foreach ($lines as $line) {
10560 if (trim($line) === '') {
10561 continue;
10562 }
10563 if (strpos($line, 'data: ') !== 0) {
10564 continue;
10565 }
10566
10567 $json_str = substr($line, 6);
10568
10569 if (trim($json_str) === '[DONE]') {
10570 echo "data: [DONE]\n\n";
10571 flush();
10572 continue;
10573 }
10574
10575 $json = json_decode(trim($json_str), true);
10576 if ($json && isset($json['choices'][0]['delta']['content'])) {
10577 $content = $json['choices'][0]['delta']['content'];
10578 $full_response .= $content;
10579 echo "data: " . json_encode(['content' => $content]) . "\n\n";
10580 flush();
10581 }
10582 }
10583
10584 return strlen($data);
10585 });
10586
10587 $response = curl_exec($ch);
10588 $errno = curl_errno($ch);
10589 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
10590 curl_close($ch);
10591
10592 if (!$errno && $http_code === 200) {
10593 break;
10594 }
10595
10596 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'xai', $errno);
10597 $can_retry = !$this->streaming_headers_sent
10598 && ($attempt + 1) < $max_attempts
10599 && $is_transient;
10600
10601 if (defined('WP_DEBUG') && WP_DEBUG) {
10602 error_log(sprintf(
10603 '[MxChat] xai_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
10604 $attempt + 1, $max_attempts, $http_code, $errno,
10605 $is_transient ? 'yes' : 'no',
10606 $can_retry ? 'Retrying.' : 'Giving up.'
10607 ));
10608 }
10609
10610 if (!$can_retry) {
10611 break;
10612 }
10613 }
10614
10615 if ($errno || $http_code !== 200) {
10616 return $this->mxchat_stream_emit_fallback(
10617 'xai',
10618 $this->mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id),
10619 $session_id,
10620 $testing_data
10621 );
10622 }
10623
10624 // Save the complete response to maintain chat persistence
10625 if (!empty($full_response) && !empty($session_id)) {
10626 // Prepare RAG context for streaming response
10627 $rag_context_for_storage = null;
10628 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
10629 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
10630
10631 if ($has_rag_data || $has_action_data) {
10632 $rag_context_for_storage = [];
10633
10634 if ($has_rag_data) {
10635 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
10636 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
10637 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
10638 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
10639 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10640 }
10641
10642 if ($has_action_data) {
10643 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10644 }
10645 }
10646 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
10647 }
10648
10649 return true; // Indicate streaming completed successfully
10650
10651 } catch (Exception $e) {
10652 return $this->mxchat_stream_emit_fallback(
10653 'xai',
10654 $this->mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content),
10655 $session_id,
10656 $testing_data
10657 );
10658 }
10659 }
10660 private function mxchat_generate_response_deepseek_stream($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
10661 try {
10662 // Get bot ID from session or request
10663 $bot_id = $this->get_current_bot_id($session_id);
10664
10665 // Get system prompt instructions using centralized function
10666 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10667
10668 // Ensure conversation_history is an array
10669 if (!is_array($conversation_history)) {
10670 $conversation_history = array();
10671 }
10672
10673 // Format conversation history for DeepSeek
10674 $formatted_conversation = array();
10675
10676 $formatted_conversation[] = array(
10677 'role' => 'system',
10678 'content' => $system_prompt_instructions . " " . $relevant_content
10679 );
10680
10681 foreach ($conversation_history as $message) {
10682 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
10683 $role = $message['role'];
10684 if ($role === 'bot' || $role === 'agent') {
10685 $role = 'assistant';
10686 }
10687 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
10688 $role = 'user';
10689 }
10690 $formatted_conversation[] = array(
10691 'role' => $role,
10692 'content' => $message['content']
10693 );
10694 }
10695 }
10696
10697 // Check if we can actually stream
10698 if (headers_sent() || !function_exists('curl_init')) {
10699 // Fallback to regular response with testing data
10700 //error_log("MxChat: DeepSeek streaming not possible, falling back to regular response");
10701 $regular_response = $this->mxchat_generate_response_deepseek(
10702 $selected_model,
10703 $deepseek_api_key,
10704 $conversation_history,
10705 $relevant_content,
10706 $session_id
10707 );
10708
10709 // Save bot response to transcript
10710 if (!empty($regular_response) && !empty($session_id)) {
10711 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
10712 }
10713
10714 $response_data = [
10715 'text' => $regular_response,
10716 'html' => '',
10717 'session_id' => $session_id
10718 ];
10719
10720 if ($testing_data !== null) {
10721 $response_data['testing_data'] = $testing_data;
10722 //error_log("MxChat Testing: Added testing data to DeepSeek fallback response");
10723 }
10724
10725 header('Content-Type: application/json');
10726 echo json_encode($response_data);
10727 return true;
10728 }
10729
10730 // Prepare the request body with stream: true
10731 $body = json_encode([
10732 'model' => $selected_model,
10733 'messages' => $formatted_conversation,
10734 'temperature' => 0.8,
10735 'stream' => true,
10736 // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
10737 // long silent reasoning before the first delta); the widget wants
10738 // the legacy deepseek-chat semantics = non-thinking.
10739 'thinking' => ['type' => 'disabled']
10740 ]);
10741
10742 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
10743
10744 $captured_status_code = 0;
10745 $captured_body_pre_stream = '';
10746 $full_response = '';
10747 $stream_started = false;
10748 $buffer = '';
10749 $errno = 0;
10750 $http_code = 0;
10751 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
10752 $backoff_ms = array(0, 750, 2000);
10753
10754 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
10755 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
10756 usleep($backoff_ms[$attempt] * 1000);
10757 }
10758
10759 $captured_status_code = 0;
10760 $captured_body_pre_stream = '';
10761 $full_response = '';
10762 $stream_started = false;
10763 $buffer = '';
10764
10765 $ch = curl_init();
10766 curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
10767 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
10768 curl_setopt($ch, CURLOPT_POST, true);
10769 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
10770 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
10771 'Content-Type: application/json',
10772 'Authorization: Bearer ' . $deepseek_api_key
10773 ));
10774 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
10775 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
10776
10777 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
10778 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
10779 $captured_status_code = (int) $m[1];
10780 }
10781 return strlen($header);
10782 });
10783
10784 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
10785 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
10786 $captured_body_pre_stream .= $data;
10787 return strlen($data);
10788 }
10789
10790 if (!$this->streaming_headers_sent) {
10791 $this->setup_streaming_headers();
10792 }
10793
10794 if (!$stream_started && $testing_data !== null) {
10795 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
10796 flush();
10797 $stream_started = true;
10798 }
10799
10800 $buffer .= $data;
10801 $lines = explode("\n", $buffer);
10802 $buffer = array_pop($lines);
10803
10804 foreach ($lines as $line) {
10805 if (trim($line) === '') {
10806 continue;
10807 }
10808 if (strpos($line, 'data: ') !== 0) {
10809 continue;
10810 }
10811
10812 $json_str = substr($line, 6);
10813
10814 if (trim($json_str) === '[DONE]') {
10815 echo "data: [DONE]\n\n";
10816 flush();
10817 continue;
10818 }
10819
10820 $json = json_decode(trim($json_str), true);
10821 if ($json && isset($json['choices'][0]['delta']['content'])) {
10822 $content = $json['choices'][0]['delta']['content'];
10823 $full_response .= $content;
10824 echo "data: " . json_encode(['content' => $content]) . "\n\n";
10825 flush();
10826 }
10827 }
10828
10829 return strlen($data);
10830 });
10831
10832 $response = curl_exec($ch);
10833 $errno = curl_errno($ch);
10834 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
10835 curl_close($ch);
10836
10837 if (!$errno && $http_code === 200) {
10838 break;
10839 }
10840
10841 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
10842 $can_retry = !$this->streaming_headers_sent
10843 && ($attempt + 1) < $max_attempts
10844 && $is_transient;
10845
10846 if (defined('WP_DEBUG') && WP_DEBUG) {
10847 error_log(sprintf(
10848 '[MxChat] deepseek_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
10849 $attempt + 1, $max_attempts, $http_code, $errno,
10850 $is_transient ? 'yes' : 'no',
10851 $can_retry ? 'Retrying.' : 'Giving up.'
10852 ));
10853 }
10854
10855 if (!$can_retry) {
10856 break;
10857 }
10858 }
10859
10860 if ($errno || $http_code !== 200) {
10861 return $this->mxchat_stream_emit_fallback(
10862 'openai',
10863 $this->mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id),
10864 $session_id,
10865 $testing_data
10866 );
10867 }
10868
10869 // Save the complete response to maintain chat persistence
10870 if (!empty($full_response) && !empty($session_id)) {
10871 // Prepare RAG context for streaming response
10872 $rag_context_for_storage = null;
10873 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
10874 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
10875
10876 if ($has_rag_data || $has_action_data) {
10877 $rag_context_for_storage = [];
10878
10879 if ($has_rag_data) {
10880 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
10881 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
10882 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
10883 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
10884 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10885 }
10886
10887 if ($has_action_data) {
10888 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10889 }
10890 }
10891 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
10892 }
10893
10894 return true; // Indicate streaming completed successfully
10895
10896 } catch (Exception $e) {
10897 return $this->mxchat_stream_emit_fallback(
10898 'openai',
10899 $this->mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content),
10900 $session_id,
10901 $testing_data
10902 );
10903 }
10904 }
10905
10906
10907 /**
10908 * Extract a human-readable error message from a decoded provider response body.
10909 * Providers disagree on shape: OpenAI/Anthropic/Google nest it (error.message),
10910 * xAI returns a plain string under 'error'. Mirrors mxchat-vision's shipped
10911 * extract_provider_error(); deliberately hint-free in core (vision's too-small
10912 * image hint is an upload concern that doesn't apply here).
10913 *
10914 * @param mixed $decoded_body Decoded JSON body (array), or whatever json_decode returned.
10915 * @param string $fallback Message to return when no provider text is found.
10916 * @return string
10917 */
10918 private function extract_provider_error($decoded_body, $fallback) {
10919 $message = '';
10920 if (isset($decoded_body['error']['message']) && is_string($decoded_body['error']['message']) && $decoded_body['error']['message'] !== '') {
10921 $message = $decoded_body['error']['message'];
10922 } elseif (isset($decoded_body['error']) && is_string($decoded_body['error']) && $decoded_body['error'] !== '') {
10923 $message = $decoded_body['error'];
10924 }
10925
10926 if ($message === '') {
10927 return $fallback;
10928 }
10929
10930 return $message;
10931 }
10932
10933 /**
10934 * plan-4aa8e5: a provider 200 whose body parses to no text must never reach
10935 * the widget as a silent empty bot bubble. Standard error shape for that
10936 * case, preferring the body's own explanation — error.message first (the
10937 * 950731 passthrough pattern), then the Responses API's
10938 * incomplete_details.reason (e.g. "max_output_tokens") — before the generic
10939 * retry message.
10940 */
10941 private function mxchat_empty_completion_error($decoded_body, $provider_label) {
10942 $reason = '';
10943 if (isset($decoded_body['error']['message']) && is_string($decoded_body['error']['message']) && $decoded_body['error']['message'] !== '') {
10944 $reason = $decoded_body['error']['message'];
10945 } elseif (isset($decoded_body['incomplete_details']['reason']) && is_string($decoded_body['incomplete_details']['reason']) && $decoded_body['incomplete_details']['reason'] !== '') {
10946 $reason = sprintf(__('response incomplete: %s', 'mxchat'), $decoded_body['incomplete_details']['reason']);
10947 }
10948
10949 $message = ($reason !== '')
10950 ? sprintf(esc_html__('%1$s returned an empty response (%2$s). Please try again.', 'mxchat'), $provider_label, esc_html($reason))
10951 : sprintf(esc_html__('%s returned an empty response. Please try again.', 'mxchat'), $provider_label);
10952
10953 return [
10954 'error' => $message,
10955 'error_code' => 'empty_completion',
10956 'provider' => strtolower($provider_label),
10957 ];
10958 }
10959
10960 private function mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id = '') {
10961 try {
10962 if (!is_array($conversation_history)) {
10963 $conversation_history = array();
10964 }
10965
10966 $bot_id = $this->get_current_bot_id($session_id);
10967 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10968
10969 $formatted_conversation = array();
10970
10971 $formatted_conversation[] = array(
10972 'role' => 'system',
10973 'content' => $system_prompt_instructions . " " . $relevant_content
10974 );
10975
10976 foreach ($conversation_history as $message) {
10977 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
10978 $role = $message['role'];
10979
10980 if ($role === 'bot' || $role === 'agent') {
10981 $role = 'assistant';
10982 }
10983 if (!in_array($role, ['system', 'assistant', 'user'])) {
10984 $role = 'user';
10985 }
10986
10987 $formatted_conversation[] = array(
10988 'role' => $role,
10989 'content' => $message['content']
10990 );
10991 }
10992 }
10993
10994 $body = json_encode([
10995 'model' => $selected_model,
10996 'messages' => $formatted_conversation,
10997 'temperature' => 1,
10998 ]);
10999
11000 $args = [
11001 'body' => $body,
11002 'headers' => [
11003 'Content-Type' => 'application/json',
11004 'Authorization' => 'Bearer ' . $openrouter_api_key,
11005 'HTTP-Referer' => home_url(),
11006 'X-Title' => get_bloginfo('name'),
11007 ],
11008 'timeout' => 60,
11009 'redirection' => 5,
11010 'blocking' => true,
11011 'httpversion' => '1.0',
11012 'sslverify' => true,
11013 ];
11014
11015 $response = $this->mxchat_provider_call_with_retry('https://openrouter.ai/api/v1/chat/completions', $args, 'openai');
11016
11017 if (is_wp_error($response)) {
11018 $error_message = $response->get_error_message();
11019 return [
11020 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'OpenRouter', $selected_model),
11021 'error_code' => 'openrouter_connection_error',
11022 'provider' => 'openrouter'
11023 ];
11024 }
11025
11026 $status_code = wp_remote_retrieve_response_code($response);
11027 if ($status_code !== 200) {
11028 $response_body = wp_remote_retrieve_body($response);
11029 $decoded_response = json_decode($response_body, true);
11030
11031 $error_message = $this->extract_provider_error($decoded_response, 'HTTP Error ' . $status_code);
11032
11033 return [
11034 'error' => esc_html__('OpenRouter API error: ', 'mxchat') . esc_html($error_message),
11035 'error_code' => 'openrouter_api_error',
11036 'provider' => 'openrouter',
11037 'status_code' => $status_code
11038 ];
11039 }
11040
11041 $response_body = wp_remote_retrieve_body($response);
11042 $decoded_response = json_decode($response_body, true);
11043
11044 if (isset($decoded_response['choices'][0]['message']['content'])) {
11045 $text = trim($decoded_response['choices'][0]['message']['content']);
11046 if ($text !== '') {
11047 return $text;
11048 }
11049 return $this->mxchat_empty_completion_error($decoded_response, 'OpenRouter');
11050 } else {
11051 return [
11052 'error' => esc_html__('Unexpected response format from OpenRouter.', 'mxchat'),
11053 'error_code' => 'openrouter_response_format_error',
11054 'provider' => 'openrouter'
11055 ];
11056 }
11057 } catch (Exception $e) {
11058 return [
11059 'error' => esc_html__('System error when processing OpenRouter request: ', 'mxchat') . esc_html($e->getMessage()),
11060 'error_code' => 'openrouter_exception',
11061 'provider' => 'openrouter'
11062 ];
11063 }
11064 }
11065
11066 /**
11067 * Build a chat-bubble-safe message for a non-200 provider (chat) error.
11068 *
11069 * Visitors must NEVER see raw API internals (model names, key/billing/quota
11070 * text). Admins (manage_options) get an actionable hint — and, for the common
11071 * "model not available on this key" case, a direct pointer to change the model
11072 * (the site owner can fix it in one click). Anthropic returns model-access as a
11073 * 4xx with a message like "Claude Fable 5 is not available. Please use Opus 4.8."
11074 *
11075 * Provider-agnostic by design (reusable for the xai/gemini/deepseek branches),
11076 * but Anthropic is the confirmed, reproduced case wired up here (plan 1d3b0f).
11077 *
11078 * @param int $http_code HTTP status from the provider.
11079 * @param string $error_message Raw provider error.message (may be empty).
11080 * @param string $provider_label Human provider name, e.g. 'Anthropic'.
11081 * @param string $model The model id the failing request used. When a
11082 * model-access failure is detected and this is
11083 * non-empty, a persistent admin notice is armed
11084 * (mxchat_show_model_access_notice) so the OWNER
11085 * learns about it even when only anonymous
11086 * visitors hit the broken bot (plan e46b8f).
11087 * @return string Message safe to render as a chat bubble.
11088 */
11089 private function mxchat_friendly_chat_error($http_code, $error_message, $provider_label = '', $model = '') {
11090 $raw = trim((string) $error_message);
11091
11092 // Detect a model-access / availability problem the site owner can fix by
11093 // choosing a different model. (Anthropic phrasing + the common API shapes.)
11094 $low = strtolower($raw);
11095 $is_model_access = (strpos($low, 'not available') !== false)
11096 || (strpos($low, 'does not have access') !== false)
11097 || (strpos($low, 'do not have access') !== false)
11098 || (strpos($low, 'does not exist') !== false) // OpenAI: "model `x` does not exist or you do not have access"
11099 || (strpos($low, 'model_not_found') !== false)
11100 || (strpos($low, 'not_found_error') !== false)
11101 || (strpos($low, 'model not found') !== false) // xAI
11102 || (strpos($low, 'not found') !== false) // Gemini: "models/x is not found for API version ..."
11103 || (strpos($low, 'permission_denied') !== false) // Gemini gated model
11104 || (strpos($low, 'permission denied') !== false);
11105
11106 // Arm the persistent admin notice (throttled: skip if the same model was
11107 // flagged within the last hour — chat errors can fire per message).
11108 if ($is_model_access && $model !== '') {
11109 $existing = get_option('mxchat_model_access_notice');
11110 $stale = !is_array($existing)
11111 || !isset($existing['model'], $existing['time'])
11112 || $existing['model'] !== $model
11113 || (time() - (int) $existing['time']) > HOUR_IN_SECONDS;
11114 if ($stale) {
11115 update_option('mxchat_model_access_notice', array(
11116 'model' => (string) $model,
11117 'provider' => (string) $provider_label,
11118 'time' => time(),
11119 ), false);
11120 }
11121 }
11122
11123 if (current_user_can('manage_options')) {
11124 if ($is_model_access) {
11125 return $raw !== ''
11126 ? sprintf(
11127 /* translators: %s: raw provider error detail */
11128 esc_html__('The selected AI model isn\'t available on your API key. Choose another model in MxChat → Settings. (Details: %s)', 'mxchat'),
11129 $raw
11130 )
11131 : esc_html__('The selected AI model isn\'t available on your API key. Choose another model in MxChat → Settings.', 'mxchat');
11132 }
11133 return $raw !== ''
11134 ? sprintf(
11135 /* translators: 1: provider label, 2: raw provider error detail */
11136 esc_html__('The AI provider (%1$s) returned an error: %2$s. Check your model and API key in MxChat → Settings.', 'mxchat'),
11137 $provider_label !== '' ? $provider_label : esc_html__('AI', 'mxchat'),
11138 $raw
11139 )
11140 : esc_html__('The AI provider returned an error. Check your model and API key in MxChat → Settings.', 'mxchat');
11141 }
11142
11143 // Visitors: friendly, generic, no internals leaked.
11144 return esc_html__('Sorry, I\'m having trouble responding right now. Please try again in a moment.', 'mxchat');
11145 }
11146
11147 private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id = '') {
11148 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
11149 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
11150 if ($selected_model === 'claude-opus-4-20250514') { $selected_model = 'claude-opus-4-8'; }
11151 elseif ($selected_model === 'claude-sonnet-4-20250514') { $selected_model = 'claude-sonnet-4-6'; }
11152
11153 // Get bot ID from session or request
11154 $bot_id = $this->get_current_bot_id($session_id);
11155
11156 // Get system prompt instructions using centralized function
11157 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11158
11159 // Clean and validate conversation history
11160 foreach ($conversation_history as &$message) {
11161 // Convert bot and agent roles to assistant
11162 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
11163 $message['role'] = 'assistant';
11164 }
11165
11166 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
11167 if (!in_array($message['role'], ['assistant', 'user'])) {
11168 $message['role'] = 'user';
11169 }
11170
11171 // Ensure content field exists
11172 if (!isset($message['content']) || empty($message['content'])) {
11173 $message['content'] = '';
11174 }
11175
11176 // Remove any unsupported fields
11177 $message = array_intersect_key($message, array_flip(['role', 'content']));
11178 }
11179
11180 // Add relevant content as the latest user message
11181 $conversation_history[] = [
11182 'role' => 'user',
11183 'content' => $relevant_content
11184 ];
11185
11186 // Build request body
11187 $payload = [
11188 'model' => $selected_model,
11189 'max_tokens' => 1000,
11190 'temperature' => 0.8,
11191 'messages' => $conversation_history,
11192 'system' => $system_prompt_instructions
11193 ];
11194 if ($this->mxchat_claude_omits_temperature($selected_model)) { unset($payload['temperature']); }
11195 $body = json_encode($payload);
11196
11197 // Set up API request
11198 $args = [
11199 'body' => $body,
11200 'headers' => [
11201 'Content-Type' => 'application/json',
11202 'x-api-key' => $claude_api_key,
11203 'anthropic-version' => '2023-06-01'
11204 ],
11205 'timeout' => 60,
11206 'redirection' => 5,
11207 'blocking' => true,
11208 'httpversion' => '1.0',
11209 'sslverify' => true,
11210 ];
11211
11212 // Make API request
11213 $response = $this->mxchat_provider_call_with_retry('https://api.anthropic.com/v1/messages', $args, 'anthropic');
11214
11215 // Check for WordPress errors
11216 if (is_wp_error($response)) {
11217 //error_log("Claude API request error: " . $response->get_error_message());
11218 return "Sorry, there was an error connecting to the API.";
11219 }
11220
11221 // Check HTTP response code
11222 $http_code = wp_remote_retrieve_response_code($response);
11223 if ($http_code !== 200) {
11224 $error_body = wp_remote_retrieve_body($response);
11225 //error_log("Claude API HTTP error: " . $http_code . " - " . $error_body);
11226
11227 // Try to extract error message from response
11228 $error_data = json_decode($error_body, true);
11229 $error_message = isset($error_data['error']['message']) ?
11230 $error_data['error']['message'] :
11231 "HTTP error " . $http_code;
11232
11233 // Surface an admin-actionable message (and a model-change pointer for the
11234 // model-access case) without leaking raw API internals to visitors. This
11235 // is the single chokepoint for BOTH the non-streaming and streaming Claude
11236 // paths (the stream's non-200 fallback re-enters this method). plan 1d3b0f.
11237 return $this->mxchat_friendly_chat_error($http_code, $error_message, 'Anthropic', $selected_model);
11238 }
11239
11240 // Parse response
11241 $response_body = json_decode(wp_remote_retrieve_body($response), true);
11242
11243 // Check for JSON decode errors
11244 if (json_last_error() !== JSON_ERROR_NONE) {
11245 //error_log("Claude API JSON decode error: " . json_last_error_msg());
11246 return "Sorry, there was an error processing the API response.";
11247 }
11248
11249 // Extract and validate response content. claude-fable-5 prepends a
11250 // thinking block to content even with no thinking param — take the first
11251 // TEXT block rather than content[0].
11252 if (isset($response_body['content']) && is_array($response_body['content'])) {
11253 foreach ($response_body['content'] as $block) {
11254 // plan-4aa8e5: skip empty text blocks — a 200 whose only text
11255 // block trims to '' must not render as a silent empty bubble.
11256 if (isset($block['type'], $block['text']) && $block['type'] === 'text' && trim($block['text']) !== '') {
11257 return trim($block['text']);
11258 }
11259 }
11260 return $this->mxchat_empty_completion_error($response_body, 'Claude');
11261 }
11262
11263 // Log unexpected response format
11264 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
11265 return "Sorry, I received an unexpected response format from the API.";
11266 }
11267 private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content, $session_id = '') {
11268 // OpenAI retires gpt-5.1-chat-latest / gpt-5.3-chat-latest on 2026-08-10
11269 // (replacement gpt-5.6-sol). Read-time rescue for saved / bot-level ids
11270 // that missed mxchat_migrate_deprecated_models() (plan e46b8f).
11271 if ($selected_model === 'gpt-5.1-chat-latest' || $selected_model === 'gpt-5.3-chat-latest') { $selected_model = 'gpt-5.6-sol'; }
11272 try {
11273 // Ensure conversation_history is an array
11274 if (!is_array($conversation_history)) {
11275 $conversation_history = array();
11276 }
11277
11278 // Get bot ID from session or request. plan eb9c38: resolve the real bot
11279 // from the session (was hardcoded '' → always default bot on multi-bot
11280 // installs) and fix the undefined $session_id that fed get_system_instructions.
11281 $bot_id = $this->get_current_bot_id($session_id);
11282
11283 // Get system prompt instructions using centralized function
11284 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11285
11286 // Create a new array for the formatted conversation
11287 $formatted_conversation = array();
11288
11289 // Add system message first
11290 $formatted_conversation[] = array(
11291 'role' => 'system',
11292 'content' => $system_prompt_instructions . " " . $relevant_content
11293 );
11294
11295 // Add the rest of the conversation history
11296 foreach ($conversation_history as $message) {
11297 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
11298 $role = $message['role'];
11299
11300 // Convert roles to supported format
11301 if ($role === 'bot' || $role === 'agent') {
11302 $role = 'assistant';
11303 }
11304 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
11305 $role = 'user';
11306 }
11307
11308 $formatted_conversation[] = array(
11309 'role' => $role,
11310 'content' => $message['content']
11311 );
11312 }
11313 }
11314
11315 // Build request body with optimal settings for fast responses
11316 $request_body = [
11317 'model' => $selected_model,
11318 'messages' => $formatted_conversation,
11319 'temperature' => 1,
11320 'stream' => false
11321 ];
11322
11323 // reasoning_effort — sourced from the core model catalog (plan-dcb71c);
11324 // frozen inline ladder lives in mxchat_reasoning_effort_fallback().
11325 $effort = $this->mxchat_reasoning_effort_for($selected_model, 'chat');
11326 if ($effort !== null) {
11327 $request_body['reasoning_effort'] = $effort;
11328 }
11329
11330 $body = json_encode($request_body);
11331
11332 $args = [
11333 'body' => $body,
11334 'headers' => [
11335 'Content-Type' => 'application/json',
11336 'Authorization' => 'Bearer ' . $api_key,
11337 ],
11338 'timeout' => 60,
11339 'redirection' => 5,
11340 'blocking' => true,
11341 'httpversion' => '1.0',
11342 'sslverify' => true,
11343 ];
11344
11345 $response = $this->mxchat_provider_call_with_retry('https://api.openai.com/v1/chat/completions', $args, 'openai');
11346
11347 if (is_wp_error($response)) {
11348 $error_message = $response->get_error_message();
11349 return [
11350 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'OpenAI', $selected_model),
11351 'error_code' => 'openai_connection_error',
11352 'provider' => 'openai'
11353 ];
11354 }
11355
11356 $status_code = wp_remote_retrieve_response_code($response);
11357 if ($status_code !== 200) {
11358 $response_body = wp_remote_retrieve_body($response);
11359 $decoded_response = json_decode($response_body, true);
11360
11361 $error_message = isset($decoded_response['error']['message'])
11362 ? $decoded_response['error']['message']
11363 : 'HTTP Error ' . $status_code;
11364
11365 $error_type = isset($decoded_response['error']['type'])
11366 ? $decoded_response['error']['type']
11367 : 'unknown';
11368
11369 // Handle specific error types
11370 switch ($error_type) {
11371 case 'invalid_request_error':
11372 if (strpos($error_message, 'API key') !== false) {
11373 return [
11374 'error' => esc_html__('Invalid OpenAI API key. Please check your API key configuration.', 'mxchat'),
11375 'error_code' => 'openai_invalid_api_key',
11376 'provider' => 'openai'
11377 ];
11378 }
11379 break;
11380
11381 case 'authentication_error':
11382 return [
11383 'error' => esc_html__('Authentication failed with OpenAI. Please check your API key.', 'mxchat'),
11384 'error_code' => 'openai_auth_error',
11385 'provider' => 'openai'
11386 ];
11387
11388 case 'rate_limit_exceeded':
11389 return [
11390 'error' => esc_html__('OpenAI rate limit exceeded. Please try again later.', 'mxchat'),
11391 'error_code' => 'openai_rate_limit',
11392 'provider' => 'openai'
11393 ];
11394
11395 case 'quota_exceeded':
11396 return [
11397 'error' => esc_html__('OpenAI API quota exceeded. Please check your billing details.', 'mxchat'),
11398 'error_code' => 'openai_quota_exceeded',
11399 'provider' => 'openai'
11400 ];
11401 }
11402
11403 // Generic error fallback only — the typed cases above already produce
11404 // clean messages. Route the raw-tail generic case through the leak-safe
11405 // helper so visitors never see provider internals. plan 5da59a.
11406 return [
11407 'error' => $this->mxchat_friendly_chat_error($status_code, $error_message, 'OpenAI', $selected_model),
11408 'error_code' => 'openai_api_error',
11409 'provider' => 'openai',
11410 'status_code' => $status_code
11411 ];
11412 }
11413
11414 $response_body = wp_remote_retrieve_body($response);
11415 $decoded_response = json_decode($response_body, true);
11416
11417 if (isset($decoded_response['choices'][0]['message']['content'])) {
11418 $text = trim($decoded_response['choices'][0]['message']['content']);
11419 if ($text !== '') {
11420 return $text;
11421 }
11422 return $this->mxchat_empty_completion_error($decoded_response, 'OpenAI');
11423 } else {
11424 return [
11425 'error' => esc_html__('Unexpected response format from OpenAI.', 'mxchat'),
11426 'error_code' => 'openai_response_format_error',
11427 'provider' => 'openai'
11428 ];
11429 }
11430 } catch (Exception $e) {
11431 return [
11432 'error' => esc_html__('System error when processing OpenAI request: ', 'mxchat') . esc_html($e->getMessage()),
11433 'error_code' => 'openai_exception',
11434 'provider' => 'openai'
11435 ];
11436 }
11437 }
11438
11439 private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id = '') {
11440 try {
11441 // Get bot ID from session or request
11442 $bot_id = $this->get_current_bot_id($session_id);
11443
11444 // Get system prompt instructions using centralized function
11445 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11446
11447 // Add system prompt to relevant content
11448 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
11449
11450 // Prepend system instructions to the conversation history
11451 array_unshift($conversation_history, [
11452 'role' => 'system',
11453 'content' => "Here are your instructions: " . $content_with_instructions
11454 ]);
11455
11456 // Ensure consistency: Replace 'bot' and 'agent' roles with supported values
11457 foreach ($conversation_history as &$message) {
11458 if ($message['role'] === 'bot') {
11459 $message['role'] = 'assistant';
11460 } elseif ($message['role'] === 'agent') {
11461 // Tag the message as coming from a live agent
11462 $message['role'] = 'assistant';
11463 if (!isset($message['metadata'])) {
11464 $message['metadata'] = ['source' => 'live_agent'];
11465 }
11466 }
11467
11468 // Ensure all roles are valid
11469 if (!in_array($message['role'], ['system', 'assistant', 'user', 'function', 'tool'])) {
11470 $message['role'] = 'user'; // Default to 'user'
11471 }
11472 }
11473
11474 // Build the request body
11475 $body = json_encode([
11476 'model' => $selected_model,
11477 'messages' => $conversation_history,
11478 'temperature' => 0.8,
11479 'stream' => false
11480 ]);
11481
11482 // Set up the API request
11483 $args = [
11484 'body' => $body,
11485 'headers' => [
11486 'Content-Type' => 'application/json',
11487 'Authorization' => 'Bearer ' . $xai_api_key,
11488 ],
11489 'timeout' => 60,
11490 'redirection' => 5,
11491 'blocking' => true,
11492 'httpversion' => '1.0',
11493 'sslverify' => true,
11494 ];
11495
11496 // Make the API request
11497 $response = $this->mxchat_provider_call_with_retry('https://api.x.ai/v1/chat/completions', $args, 'xai');
11498
11499 // Process the response
11500 if (is_wp_error($response)) {
11501 $error_message = $response->get_error_message();
11502 //error_log('X.AI API Error: ' . $error_message);
11503 return [
11504 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'X.AI', $selected_model),
11505 'error_code' => 'xai_connection_error',
11506 'provider' => 'xai'
11507 ];
11508 }
11509
11510 $status_code = wp_remote_retrieve_response_code($response);
11511 if ($status_code !== 200) {
11512 $response_body = wp_remote_retrieve_body($response);
11513 $decoded_response = json_decode($response_body, true);
11514
11515 // Log the full response for debugging
11516 //error_log('X.AI Error Response: ' . print_r($decoded_response, true));
11517
11518 // Extract error message from X.AI's specific format
11519 $error_message = '';
11520
11521 // Check for direct error string (as seen in your logs)
11522 if (isset($decoded_response['error']) && is_string($decoded_response['error'])) {
11523 $error_message = $decoded_response['error'];
11524 }
11525 // Check for nested error object (OpenAI style)
11526 elseif (isset($decoded_response['error']['message'])) {
11527 $error_message = $decoded_response['error']['message'];
11528 }
11529 // Check for top-level message
11530 elseif (isset($decoded_response['message'])) {
11531 $error_message = $decoded_response['message'];
11532 }
11533 // Fallback
11534 else {
11535 $error_message = 'HTTP Error ' . $status_code;
11536 }
11537
11538 //error_log('X.AI API HTTP Error: ' . $status_code . ' - ' . $error_message);
11539
11540 // Check for API key errors using string matching
11541 if (stripos($error_message, 'api key') !== false ||
11542 stripos($error_message, 'incorrect api key') !== false ||
11543 stripos($error_message, 'invalid api key') !== false) {
11544 return [
11545 'error' => esc_html__('Invalid X.AI API key. Please check your API key configuration.', 'mxchat'),
11546 'error_code' => 'xai_invalid_api_key',
11547 'provider' => 'xai'
11548 ];
11549 }
11550
11551 // Authentication errors
11552 if ($status_code === 401 || $status_code === 403 ||
11553 stripos($error_message, 'auth') !== false) {
11554 return [
11555 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat') . ' ' . esc_html($error_message),
11556 'error_code' => 'xai_auth_error',
11557 'provider' => 'xai'
11558 ];
11559 }
11560
11561 // Model errors — keep the canned category text as a prefix, but carry the
11562 // provider's extracted reason (e.g. "Model not found: <id>") so the owner
11563 // sees the specific model/reason instead of only the generic category.
11564 if (stripos($error_message, 'model') !== false) {
11565 return [
11566 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat') . ' ' . esc_html($error_message),
11567 'error_code' => 'xai_invalid_model',
11568 'provider' => 'xai'
11569 ];
11570 }
11571
11572 // Rate limit errors
11573 if ($status_code === 429 ||
11574 stripos($error_message, 'rate') !== false ||
11575 stripos($error_message, 'limit') !== false) {
11576 return [
11577 'error' => esc_html__('X.AI rate limit exceeded. Please try again later.', 'mxchat'),
11578 'error_code' => 'xai_rate_limit',
11579 'provider' => 'xai'
11580 ];
11581 }
11582
11583 // Quota errors
11584 if (stripos($error_message, 'quota') !== false ||
11585 stripos($error_message, 'billing') !== false) {
11586 return [
11587 'error' => esc_html__('X.AI API quota exceeded. Please check your billing details.', 'mxchat'),
11588 'error_code' => 'xai_quota_exceeded',
11589 'provider' => 'xai'
11590 ];
11591 }
11592
11593 // Server errors
11594 if ($status_code >= 500) {
11595 return [
11596 'error' => esc_html__('X.AI service is currently unavailable. Please try again later.', 'mxchat'),
11597 'error_code' => 'xai_service_unavailable',
11598 'provider' => 'xai'
11599 ];
11600 }
11601
11602 // Generic error fallback. Route the user-facing text through the
11603 // leak-safe helper (admins get an actionable hint, visitors a generic
11604 // fallback) instead of echoing raw provider internals. Preserve the
11605 // structured contract (error_code/provider/status_code) for logging. plan 5da59a.
11606 return [
11607 'error' => $this->mxchat_friendly_chat_error($status_code, $error_message, 'xAI', $selected_model),
11608 'error_code' => 'xai_api_error',
11609 'provider' => 'xai',
11610 'status_code' => $status_code
11611 ];
11612 }
11613
11614 $response_body = wp_remote_retrieve_body($response);
11615 $decoded_response = json_decode($response_body, true);
11616
11617 if (isset($decoded_response['choices'][0]['message']['content'])) {
11618 $text = trim($decoded_response['choices'][0]['message']['content']);
11619 if ($text !== '') {
11620 return $text;
11621 }
11622 return $this->mxchat_empty_completion_error($decoded_response, 'X.AI');
11623 } else {
11624 //error_log('X.AI API Response Format Error: ' . print_r($decoded_response, true));
11625 return [
11626 'error' => esc_html__('Unexpected response format from X.AI.', 'mxchat'),
11627 'error_code' => 'xai_response_format_error',
11628 'provider' => 'xai'
11629 ];
11630 }
11631 } catch (Exception $e) {
11632 //error_log('X.AI Exception: ' . $e->getMessage());
11633 return [
11634 'error' => esc_html__('System error when processing X.AI request: ', 'mxchat') . esc_html($e->getMessage()),
11635 'error_code' => 'xai_exception',
11636 'provider' => 'xai'
11637 ];
11638 }
11639
11640
11641 }
11642 private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id = '') {
11643 try {
11644 // Ensure conversation_history is an array
11645 if (!is_array($conversation_history)) {
11646 $conversation_history = array();
11647 }
11648
11649 // Get bot ID from session or request
11650 $bot_id = $this->get_current_bot_id($session_id);
11651
11652 // Get system prompt instructions using centralized function
11653 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11654
11655 // Create a new array for the formatted conversation
11656 $formatted_conversation = array();
11657
11658 // Add system message first
11659 $formatted_conversation[] = array(
11660 'role' => 'system',
11661 'content' => $system_prompt_instructions . " " . $relevant_content
11662 );
11663
11664 // Add the rest of the conversation history
11665 foreach ($conversation_history as $message) {
11666 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
11667 $role = $message['role'];
11668
11669 // Convert roles to supported format
11670 if ($role === 'bot' || $role === 'agent') {
11671 $role = 'assistant';
11672 }
11673 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
11674 $role = 'user';
11675 }
11676
11677 $formatted_conversation[] = array(
11678 'role' => $role,
11679 'content' => $message['content']
11680 );
11681 }
11682 }
11683
11684 $body = json_encode([
11685 'model' => $selected_model,
11686 'messages' => $formatted_conversation,
11687 'temperature' => 0.8,
11688 'stream' => false,
11689 // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
11690 // slow reasoning-first responses); the widget wants the legacy
11691 // deepseek-chat semantics = non-thinking.
11692 'thinking' => ['type' => 'disabled']
11693 ]);
11694
11695 $args = [
11696 'body' => $body,
11697 'headers' => [
11698 'Content-Type' => 'application/json',
11699 'Authorization' => 'Bearer ' . $deepseek_api_key,
11700 ],
11701 'timeout' => 60,
11702 'redirection' => 5,
11703 'blocking' => true,
11704 'httpversion' => '1.0',
11705 'sslverify' => true,
11706 ];
11707
11708 $response = $this->mxchat_provider_call_with_retry('https://api.deepseek.com/v1/chat/completions', $args, 'openai');
11709
11710 if (is_wp_error($response)) {
11711 $error_message = $response->get_error_message();
11712 //error_log('DeepSeek API Error: ' . $error_message);
11713 return [
11714 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'DeepSeek', $selected_model),
11715 'error_code' => 'deepseek_connection_error',
11716 'provider' => 'deepseek'
11717 ];
11718 }
11719
11720 $status_code = wp_remote_retrieve_response_code($response);
11721 if ($status_code !== 200) {
11722 $response_body = wp_remote_retrieve_body($response);
11723 $decoded_response = json_decode($response_body, true);
11724
11725 $error_message = isset($decoded_response['error']['message'])
11726 ? $decoded_response['error']['message']
11727 : 'HTTP Error ' . $status_code;
11728
11729 $error_type = isset($decoded_response['error']['type'])
11730 ? $decoded_response['error']['type']
11731 : 'unknown';
11732
11733 //error_log('DeepSeek API HTTP Error: ' . $status_code . ' - ' . $error_message);
11734
11735 // Handle specific error types
11736 switch ($status_code) {
11737 case 401:
11738 return [
11739 'error' => esc_html__('Authentication failed with DeepSeek. Please check your API key.', 'mxchat'),
11740 'error_code' => 'deepseek_auth_error',
11741 'provider' => 'deepseek'
11742 ];
11743
11744 case 400:
11745 if (strpos($error_message, 'API key') !== false) {
11746 return [
11747 'error' => esc_html__('Invalid DeepSeek API key. Please check your API key configuration.', 'mxchat'),
11748 'error_code' => 'deepseek_invalid_api_key',
11749 'provider' => 'deepseek'
11750 ];
11751 }
11752 break;
11753
11754 case 429:
11755 if (strpos($error_message, 'quota') !== false) {
11756 return [
11757 'error' => esc_html__('DeepSeek API quota exceeded. Please check your billing details.', 'mxchat'),
11758 'error_code' => 'deepseek_quota_exceeded',
11759 'provider' => 'deepseek'
11760 ];
11761 } else {
11762 return [
11763 'error' => esc_html__('DeepSeek rate limit exceeded. Please try again later.', 'mxchat'),
11764 'error_code' => 'deepseek_rate_limit',
11765 'provider' => 'deepseek'
11766 ];
11767 }
11768
11769 case 500:
11770 case 502:
11771 case 503:
11772 case 504:
11773 return [
11774 'error' => esc_html__('DeepSeek service is currently unavailable. Please try again later.', 'mxchat'),
11775 'error_code' => 'deepseek_service_unavailable',
11776 'provider' => 'deepseek'
11777 ];
11778 }
11779
11780 // Generic error fallback — leak-safe helper (see plan 5da59a / 1d3b0f).
11781 return [
11782 'error' => $this->mxchat_friendly_chat_error($status_code, $error_message, 'DeepSeek', $selected_model),
11783 'error_code' => 'deepseek_api_error',
11784 'provider' => 'deepseek',
11785 'status_code' => $status_code
11786 ];
11787 }
11788
11789 $response_body = wp_remote_retrieve_body($response);
11790 $decoded_response = json_decode($response_body, true);
11791
11792 if (isset($decoded_response['choices'][0]['message']['content'])) {
11793 $text = trim($decoded_response['choices'][0]['message']['content']);
11794 if ($text !== '') {
11795 return $text;
11796 }
11797 return $this->mxchat_empty_completion_error($decoded_response, 'DeepSeek');
11798 } else {
11799 //error_log('DeepSeek API Response Format Error: ' . print_r($decoded_response, true));
11800 return [
11801 'error' => esc_html__('Unexpected response format from DeepSeek.', 'mxchat'),
11802 'error_code' => 'deepseek_response_format_error',
11803 'provider' => 'deepseek'
11804 ];
11805 }
11806 } catch (Exception $e) {
11807 //error_log('DeepSeek Exception: ' . $e->getMessage());
11808 return [
11809 'error' => esc_html__('System error when processing DeepSeek request: ', 'mxchat') . esc_html($e->getMessage()),
11810 'error_code' => 'deepseek_exception',
11811 'provider' => 'deepseek'
11812 ];
11813 }
11814 }
11815 private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content, $session_id = '') {
11816 // Read-time remap: gemini-3-pro-preview was shut down March 9, 2026.
11817 // Auto-rescue existing installs whose saved model is the dead ID.
11818 if ($selected_model === 'gemini-3-pro-preview') {
11819 $selected_model = 'gemini-3.1-pro-preview';
11820 }
11821 // Get bot ID from session or request
11822 $bot_id = $this->get_current_bot_id($session_id);
11823
11824 // Get system prompt instructions using centralized function
11825 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11826
11827 // Add system prompt to relevant content
11828 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
11829
11830 // Format messages for Gemini API
11831 $formatted_messages = [];
11832
11833 // Add system message as the first user message with role prefix
11834 // Note: Gemini doesn't have a dedicated system role, so we use a prefixed user message
11835 $formatted_messages[] = [
11836 'role' => 'user',
11837 'parts' => [
11838 ['text' => "[System Instructions] " . $content_with_instructions]
11839 ]
11840 ];
11841
11842 // Add model response to acknowledge system instructions
11843 $formatted_messages[] = [
11844 'role' => 'model',
11845 'parts' => [
11846 ['text' => "I understand and will follow these instructions."]
11847 ]
11848 ];
11849
11850 // Process the rest of the conversation history
11851 $current_role = null;
11852 $current_parts = [];
11853
11854 foreach ($conversation_history as $message) {
11855 // Skip the first system message as we already handled it
11856 if ($message['role'] === 'system') {
11857 continue;
11858 }
11859
11860 // Map roles to Gemini format
11861 $gemini_role = '';
11862 if ($message['role'] === 'user') {
11863 $gemini_role = 'user';
11864 } else if (in_array($message['role'], ['assistant', 'bot', 'agent'])) {
11865 $gemini_role = 'model';
11866 } else {
11867 // Skip unsupported roles
11868 continue;
11869 }
11870
11871 // If we have a new role, add the previous message
11872 if ($current_role !== null && $current_role !== $gemini_role && !empty($current_parts)) {
11873 $formatted_messages[] = [
11874 'role' => $current_role,
11875 'parts' => $current_parts
11876 ];
11877 $current_parts = [];
11878 }
11879
11880 // Set current role and add text to parts
11881 $current_role = $gemini_role;
11882 $current_parts[] = ['text' => $message['content']];
11883 }
11884
11885 // Add the last message if there's content
11886 if ($current_role !== null && !empty($current_parts)) {
11887 $formatted_messages[] = [
11888 'role' => $current_role,
11889 'parts' => $current_parts
11890 ];
11891 }
11892
11893 // Built-in Web Search grounding for Gemini (plan 46b9ea).
11894 // The enable_web_search toggle historically routed ONLY to OpenAI's web_search
11895 // tool; for a Gemini chat model it was a silent no-op. Gemini grounds natively
11896 // (and free) via the Google Search tool, so when the toggle is on we attach it
11897 // here on the PLAIN dispatch path. The function-calling loop (mxchat_fc_loop_gemini)
11898 // is a SEPARATE path reached only when AI Tools are active, so grounding here
11899 // never double-fires with function calling.
11900 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
11901 // Gemini ids that do NOT support Google Search grounding (none today — every
11902 // shipped chat model is 2.x/3.x and grounds natively). Kept as the explicit
11903 // opt-out list mirroring the OpenAI $unsupported_web_search_models pattern.
11904 $gemini_unsupported_grounding = array();
11905 $grounding_active = $web_search_enabled && !in_array($selected_model, $gemini_unsupported_grounding, true);
11906
11907 // Build the request body
11908 $request_payload = [
11909 'contents' => $formatted_messages,
11910 'generationConfig' => [
11911 'temperature' => 0.7,
11912 'topP' => 0.95,
11913 'topK' => 40,
11914 'maxOutputTokens' => 8192,
11915 ],
11916 'safetySettings' => [
11917 [
11918 'category' => 'HARM_CATEGORY_HARASSMENT',
11919 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
11920 ],
11921 [
11922 'category' => 'HARM_CATEGORY_HATE_SPEECH',
11923 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
11924 ],
11925 [
11926 'category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
11927 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
11928 ],
11929 [
11930 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT',
11931 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
11932 ]
11933 ]
11934 ];
11935
11936 if ($grounding_active) {
11937 // Gemini 1.5 used the older google_search_retrieval shape; 2.0+ uses the
11938 // bare google_search tool. Branch by model family so a future 1.5 id still
11939 // grounds (no 1.5 ships today, so this resolves to google_search). The empty
11940 // tool config must serialize as a JSON object {}, not an array [].
11941 if (strpos($selected_model, 'gemini-1.5') !== false) {
11942 $request_payload['tools'] = [ ['google_search_retrieval' => new \stdClass()] ];
11943 } else {
11944 $request_payload['tools'] = [ ['google_search' => new \stdClass()] ];
11945 }
11946 }
11947
11948 $body = json_encode($request_payload);
11949
11950 // Prepare the API endpoint
11951 // Use v1beta for preview models (Gemini 3, experimental), v1 for stable models.
11952 // Grounding (the google_search tool) is a v1beta feature, so force v1beta whenever
11953 // it's active — otherwise a stable model on v1 would silently drop the tool.
11954 $api_version = ($grounding_active || strpos($selected_model, 'preview') !== false || strpos($selected_model, 'exp') !== false) ? 'v1beta' : 'v1';
11955 $api_endpoint = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
11956
11957 // Set up the API request
11958 $args = [
11959 'body' => $body,
11960 'headers' => [
11961 'Content-Type' => 'application/json',
11962 ],
11963 'timeout' => 60,
11964 'redirection' => 5,
11965 'blocking' => true,
11966 'httpversion' => '1.0',
11967 'sslverify' => true,
11968 ];
11969
11970 // Make the API request
11971 $response = $this->mxchat_provider_call_with_retry($api_endpoint, $args, 'gemini');
11972
11973 // Process the response
11974 if (is_wp_error($response)) {
11975 // plan b13282: route the transport-error string through the leak-safe helper
11976 // (admin-actionable, generic for visitors) instead of echoing the raw WP HTTP
11977 // error. http_code 0 = no HTTP response, so the helper uses the generic branch.
11978 return $this->mxchat_friendly_chat_error(0, $response->get_error_message(), 'Gemini', $selected_model);
11979 }
11980
11981 $response_body = json_decode(wp_remote_retrieve_body($response), true);
11982
11983 // Handle potential errors in the response. Gemini surfaces errors as a
11984 // 200/non-200 body with an `error` envelope; route the user-facing text
11985 // through the leak-safe helper (admin-actionable, no visitor leak) rather
11986 // than echoing the raw provider message. plan 5da59a.
11987 if (isset($response_body['error'])) {
11988 //error_log('Gemini API Error: ' . json_encode($response_body['error']));
11989 $gemini_error_message = isset($response_body['error']['message'])
11990 ? $response_body['error']['message']
11991 : 'Unknown error';
11992 $gemini_http_code = wp_remote_retrieve_response_code($response);
11993 return $this->mxchat_friendly_chat_error($gemini_http_code, $gemini_error_message, 'Gemini', $selected_model);
11994 }
11995
11996 // Extract the response text
11997 if (isset($response_body['candidates'][0]['content']['parts'][0]['text'])) {
11998 $text = trim($response_body['candidates'][0]['content']['parts'][0]['text']);
11999 if ($text !== '') {
12000 return $text;
12001 }
12002 return $this->mxchat_empty_completion_error($response_body, 'Gemini');
12003 } else {
12004 //error_log('Unexpected Gemini API response format: ' . json_encode($response_body));
12005 return "Sorry, I couldn't process that request. The response format was unexpected.";
12006 }
12007 }
12008
12009
12010 public function test_streaming_request() {
12011 $options = get_option('mxchat_options', []);
12012 $model = $options['model'] ?? 'gpt-5.6-sol';
12013
12014 // Detect provider from model prefix
12015 $provider = strtolower(explode('-', $model)[0]);
12016
12017 $sample_prompt = 'Hello! Can you stream this response back to me?';
12018 $messages = [['role' => 'user', 'content' => $sample_prompt]];
12019 $headers = [];
12020 $body = [];
12021 $url = '';
12022 $api_key = '';
12023
12024 switch ($provider) {
12025 case 'gpt':
12026 case 'o1':
12027 $api_key = $options['api_key'] ?? '';
12028 if (empty($api_key)) return '❌ Missing API key for OpenAI';
12029 $url = 'https://api.openai.com/v1/chat/completions';
12030 $headers = [
12031 'Content-Type: application/json',
12032 'Authorization: Bearer ' . $api_key
12033 ];
12034 $body = [
12035 'model' => $model,
12036 'messages' => $messages,
12037 'stream' => true
12038 ];
12039 break;
12040
12041 case 'claude':
12042 $api_key = $options['claude_api_key'] ?? '';
12043 if (empty($api_key)) return '❌ Missing API key for Claude';
12044 $url = 'https://api.anthropic.com/v1/messages';
12045 $headers = [
12046 'Content-Type: application/json',
12047 'x-api-key: ' . $api_key,
12048 'anthropic-version: 2023-06-01'
12049 ];
12050 $body = [
12051 'model' => $model,
12052 'messages' => $messages,
12053 'max_tokens' => 100,
12054 'stream' => true
12055 ];
12056 break;
12057
12058 case 'grok':
12059 $api_key = $options['xai_api_key'] ?? '';
12060 if (empty($api_key)) return '❌ Missing API key for X.AI';
12061 $url = 'https://api.x.ai/v1/chat/completions';
12062 $headers = [
12063 'Content-Type: application/json',
12064 'Authorization: Bearer ' . $api_key
12065 ];
12066 $body = [
12067 'model' => $model,
12068 'messages' => $messages,
12069 'stream' => true
12070 ];
12071 break;
12072
12073 case 'deepseek':
12074 if (empty($deepseek_api_key)) {
12075 $error_response = [
12076 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
12077 'error_code' => 'missing_deepseek_api_key'
12078 ];
12079 if ($testing_data !== null) {
12080 $error_response['testing_data'] = $testing_data;
12081 }
12082 return $error_response;
12083 }
12084 if ($streaming) {
12085 return $this->mxchat_generate_response_deepseek_stream(
12086 $selected_model,
12087 $deepseek_api_key,
12088 $conversation_history,
12089 $relevant_content,
12090 $session_id,
12091 $testing_data // Pass testing data
12092 );
12093 } else {
12094 $response = $this->mxchat_generate_response_deepseek(
12095 $selected_model,
12096 $deepseek_api_key,
12097 $conversation_history,
12098 $relevant_content,
12099 $session_id
12100 );
12101 }
12102 break;
12103
12104 case 'gemini':
12105 $api_key = $options['gemini_api_key'] ?? '';
12106 if (empty($api_key)) return '❌ Missing API key for Gemini';
12107 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':streamGenerateContent?key=' . $api_key;
12108 $headers = ['Content-Type: application/json'];
12109 $body = [
12110 'contents' => [['role' => 'user', 'parts' => [['text' => $sample_prompt]]]],
12111 'generationConfig' => ['temperature' => 0.7]
12112 ];
12113 break;
12114
12115 default:
12116 return '❌ Unsupported provider: ' . $provider;
12117 }
12118
12119 // Do the actual streaming test
12120 $ch = curl_init($url);
12121 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
12122 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
12123 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
12124 curl_setopt($ch, CURLOPT_TIMEOUT, 15);
12125 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
12126
12127 $response = curl_exec($ch);
12128 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
12129 $error = curl_error($ch);
12130 curl_close($ch);
12131
12132 if ($error) return "❌ cURL error: $error";
12133 if ($http_code !== 200) {
12134 $error_message = json_decode($response, true)['error']['message'] ?? 'Unknown';
12135 return "❌ HTTP $http_code: $error_message";
12136 }
12137
12138 return true;
12139 }
12140
12141 public function mxchat_dismiss_pre_chat_message() {
12142 // Get and sanitize the user identifier
12143 $user_id = $this->mxchat_get_user_identifier();
12144 $user_id = sanitize_key($user_id);
12145
12146 // Set a transient to track that the user has dismissed the pre-chat message
12147 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
12148 set_transient($transient_key, true, DAY_IN_SECONDS);
12149
12150 wp_send_json_success();
12151 }
12152
12153 public function mxchat_check_pre_chat_message_status() {
12154 // Get and sanitize the user identifier
12155 $user_id = $this->mxchat_get_user_identifier();
12156 $user_id = sanitize_key($user_id);
12157
12158 // Check if the transient exists (i.e., if the message was dismissed)
12159 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
12160 $dismissed = get_transient($transient_key);
12161
12162 // Log the result to see if it's being set correctly
12163 //error_log("Check pre-chat message dismissed for $user_id: " . ($dismissed ? 'Yes' : 'No'));
12164
12165 if ($dismissed) {
12166 wp_send_json_success(['dismissed' => true]);
12167 } else {
12168 wp_send_json_success(['dismissed' => false]);
12169 }
12170
12171 wp_die();
12172 }
12173
12174 /**
12175 * Keyword leg for hybrid retrieval (plan-38ffa1): ranked keyword query over
12176 * the WP-DB knowledge table. FULLTEXT when the index is available, LIKE on
12177 * the top query terms otherwise (capability detected once and cached by
12178 * MxChat_Utils::mxchat_hybrid_detect_capability). Respects the same bot
12179 * scoping as the vector query ($bot_filter) and the same role-restriction
12180 * access rules as vector candidates.
12181 *
12182 * @return array[] Ranked hits: [id, source_url, role_restriction, has_access]
12183 */
12184 private function mxchat_hybrid_keyword_search($user_query, $system_prompt_table, $bot_filter, $knowledge_manager) {
12185 global $wpdb;
12186
12187 $capability = get_option('mxchat_hybrid_keyword_capability', '');
12188 if (!in_array($capability, array('fulltext', 'like'), true)) {
12189 $capability = MxChat_Utils::mxchat_hybrid_detect_capability();
12190 }
12191
12192 $limit = 20;
12193 $rows = array();
12194
12195 if ($capability === 'fulltext') {
12196 $rows = $wpdb->get_results($wpdb->prepare(
12197 "SELECT id, source_url, role_restriction,
12198 MATCH(article_content) AGAINST (%s IN NATURAL LANGUAGE MODE) AS kw_score
12199 FROM {$system_prompt_table}
12200 WHERE MATCH(article_content) AGAINST (%s IN NATURAL LANGUAGE MODE) {$bot_filter}
12201 ORDER BY kw_score DESC, id ASC
12202 LIMIT %d",
12203 $user_query,
12204 $user_query,
12205 $limit
12206 ));
12207 } else {
12208 // LIKE fallback: length-weighted term scoring. Longer, rarer tokens
12209 // (the SKU, the error code) must outrank ubiquitous short words — an
12210 // equal-weight score lets "the" + one common word tie with the exact
12211 // token and the tie-break pick the wrong row (caught by the 38ffa1
12212 // verification harness). Stopwords are dropped outright.
12213 $stopwords = array('the', 'and', 'for', 'you', 'your', 'with', 'this', 'that', 'are', 'was', 'can', 'how', 'what', 'does', 'have', 'has', 'about', 'from', 'not', 'but', 'all', 'any', 'our', 'their');
12214 $terms = preg_split('/[^\p{L}\p{N}_-]+/u', (string) $user_query, -1, PREG_SPLIT_NO_EMPTY);
12215 $terms = array_filter($terms, function ($t) use ($stopwords) {
12216 return mb_strlen($t) >= 3 && !in_array(mb_strtolower($t), $stopwords, true);
12217 });
12218 $terms = array_values(array_unique(array_map('mb_strtolower', $terms)));
12219 usort($terms, function ($a, $b) {
12220 return mb_strlen($b) <=> mb_strlen($a);
12221 });
12222 $terms = array_slice($terms, 0, 5);
12223 if (empty($terms)) {
12224 return array();
12225 }
12226
12227 $score_parts = array();
12228 $where_parts = array();
12229 $like_params = array();
12230 foreach ($terms as $term) {
12231 $score_parts[] = '((article_content LIKE %s) * ' . (int) mb_strlen($term) . ')';
12232 $where_parts[] = 'article_content LIKE %s';
12233 $like_params[] = '%' . $wpdb->esc_like($term) . '%';
12234 }
12235 $sql = "SELECT id, source_url, role_restriction, ("
12236 . implode(' + ', $score_parts)
12237 . ") AS kw_score FROM {$system_prompt_table} WHERE ("
12238 . implode(' OR ', $where_parts)
12239 . ") {$bot_filter} ORDER BY kw_score DESC, id ASC LIMIT %d";
12240 $rows = $wpdb->get_results($wpdb->prepare(
12241 $sql,
12242 array_merge($like_params, $like_params, array($limit))
12243 ));
12244 }
12245
12246 $hits = array();
12247 foreach ((array) $rows as $row) {
12248 $role_restriction = $row->role_restriction ?? 'public';
12249 if (!$knowledge_manager->mxchat_user_has_content_access($role_restriction)) {
12250 continue;
12251 }
12252 $hits[] = array(
12253 'id' => (int) $row->id,
12254 'source_url' => $row->source_url ?? '',
12255 'role_restriction' => $role_restriction,
12256 'has_access' => true,
12257 );
12258 }
12259 return $hits;
12260 }
12261
12262 private function mxchat_calculate_cosine_similarity($vectorA, $vectorB) {
12263 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
12264 return 0;
12265 }
12266
12267 $dotProduct = array_sum(array_map(function ($a, $b) {
12268 return $a * $b;
12269 }, $vectorA, $vectorB));
12270 $normA = sqrt(array_sum(array_map(function ($a) {
12271 return $a * $a;
12272 }, $vectorA)));
12273 $normB = sqrt(array_sum(array_map(function ($b) {
12274 return $b * $b;
12275 }, $vectorB)));
12276
12277 if ($normA == 0 || $normB == 0) {
12278 return 0;
12279 }
12280
12281 return $dotProduct / ($normA * $normB);
12282 }
12283
12284
12285 public function mxchat_enqueue_scripts_styles($force = false) {
12286 // Idempotency guard (plan-915355): the smart-asset-loading safety net in
12287 // render_chatbot_shortcode() may invoke this method a second time (or on
12288 // every shortcode render). Run the body at most once per request so the
12289 // nonce, dynamic-settings merge, delayed transient write, and wp_footer
12290 // loader action never happen twice.
12291 static $did_run = false;
12292 if ($did_run) {
12293 return;
12294 }
12295
12296 // Smart asset loading gate (plan-915355, opt-in, default OFF — toggle in
12297 // MxChat → Settings → Optimization → Script Loading). When enabled and the
12298 // shared display decision says the widget won't render on this request,
12299 // skip all front-end assets. $force (the shortcode safety net) bypasses
12300 // the gate because at that point the widget IS rendering. Note: bail
12301 // WITHOUT setting $did_run, so a later forced call can still enqueue.
12302 if (!$force
12303 && class_exists('MxChat_Public')
12304 && MxChat_Public::is_smart_asset_loading_enabled()
12305 && !MxChat_Public::should_load_assets()) {
12306 return;
12307 }
12308
12309 $did_run = true;
12310
12311 // Fetch options from the database first to check loading strategy
12312 $this->options = get_option('mxchat_options');
12313 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
12314
12315 // Always enqueue CSS immediately
12316 wp_enqueue_style(
12317 'mxchat-chat-css',
12318 plugin_dir_url(__FILE__) . '../css/chat-style.css',
12319 array(),
12320 MXCHAT_VERSION
12321 );
12322
12323 // Handle script loading based on strategy
12324 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
12325 // Enqueue the script normally
12326 wp_enqueue_script(
12327 'mxchat-chat-js',
12328 plugin_dir_url(__FILE__) . '../js/chat-script.js',
12329 array('jquery'),
12330 MXCHAT_VERSION,
12331 true
12332 );
12333
12334 // Add defer attribute if strategy is 'defer'
12335 if ($loading_strategy === 'defer') {
12336 wp_script_add_data('mxchat-chat-js', 'strategy', 'defer');
12337 }
12338 } else {
12339 // For delay or interaction-based loading, we'll use a custom loader
12340 // Don't enqueue the main script - we'll load it dynamically
12341 add_action('wp_footer', array($this, 'mxchat_output_delayed_script_loader'), 99);
12342 }
12343
12344 $prompts_options = get_option('mxchat_prompts_options', array());
12345
12346 // Check if AI theme is active - if so, skip inline colors in JavaScript
12347 $theme_options = get_option('mxchat_theme_options', array());
12348 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
12349 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
12350 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
12351
12352 // Prepare settings for JavaScript
12353 $style_settings = array(
12354 'ajax_url' => admin_url('admin-ajax.php'),
12355 // The chat-send nonce is now fetched per-request from /wp-json/mxchat/v1/nonce
12356 // (plan-6a68c9) so it never sits in cached HTML. We still emit a nonce here
12357 // as a one-shot fallback for the first interaction on a fresh page load
12358 // (so the very first chat-send doesn't need to wait for a REST round-trip),
12359 // but the widget refetches before each subsequent send.
12360 'nonce' => wp_create_nonce('mxchat_chat_send'),
12361 'rest_url' => esc_url_raw(trailingslashit(rest_url('mxchat/v1'))),
12362 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
12363 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
12364 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
12365 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
12366 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
12367 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
12368 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
12369 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
12370 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
12371 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
12372 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
12373 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
12374 'icon_color' => $this->options['icon_color'] ?? '#fff',
12375 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
12376 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
12377 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
12378 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
12379 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
12380 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
12381 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
12382 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
12383 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
12384 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off', // FIXED
12385 'initial_email_state' => null, // Also fixed this undefined variable
12386 'skip_email_check' => true,
12387 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
12388 'skip_inline_colors' => $skip_inline_colors,
12389 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array(),
12390 );
12391
12392 // Behavior gates + labels (model, streaming, rate-limit copy, toolbar,
12393 // print/transcript, satisfaction rating) come from the shared
12394 // dynamic-settings method so this inline payload and the first-open
12395 // refresh endpoint can never drift (plan-32db95).
12396 $style_settings = array_merge($style_settings, $this->get_dynamic_widget_settings());
12397
12398 // For normal/defer loading, use wp_localize_script.
12399 // For delayed loading, nothing is localized or stored here: the delayed
12400 // loader (mxchat_output_delayed_script_loader) rebuilds the full settings
12401 // array inline from options and never reads any stored copy.
12402 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
12403 wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
12404 } else {
12405 // Late-render fallback (plan-915355): when the shortcode safety net
12406 // forces this method during/after wp_footer (footer widget areas, late
12407 // builder regions), the wp_footer:99 loader action registered above may
12408 // already be past its slot. Emit the loader inline right now; its
12409 // emitted-once guard prevents double output if :99 still fires.
12410 if ($force && did_action('wp_footer')) {
12411 $this->mxchat_output_delayed_script_loader();
12412 }
12413 }
12414 }
12415
12416 /**
12417 * Output the delayed script loader for performance optimization
12418 */
12419 public function mxchat_output_delayed_script_loader() {
12420 // Emitted-once guard (plan-915355): this can now be reached both via the
12421 // wp_footer:99 action and via the late-render inline fallback in
12422 // mxchat_enqueue_scripts_styles(). The loader must print exactly once.
12423 static $emitted = false;
12424 if ($emitted) {
12425 return;
12426 }
12427 $emitted = true;
12428
12429 $this->options = get_option('mxchat_options');
12430 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
12431 $script_url = plugin_dir_url(__FILE__) . '../js/chat-script.js?ver=' . MXCHAT_VERSION;
12432
12433 // Get the stored settings
12434 $prompts_options = get_option('mxchat_prompts_options', array());
12435 $theme_options = get_option('mxchat_theme_options', array());
12436 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
12437 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
12438 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
12439
12440 $style_settings = array(
12441 'ajax_url' => admin_url('admin-ajax.php'),
12442 // Per-request nonce — see plan-6a68c9; widget fetches via /wp-json/mxchat/v1/nonce
12443 // before each send. This inline value is a one-shot fallback for the first interaction.
12444 'nonce' => wp_create_nonce('mxchat_chat_send'),
12445 'rest_url' => esc_url_raw(trailingslashit(rest_url('mxchat/v1'))),
12446 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
12447 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
12448 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
12449 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
12450 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
12451 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
12452 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
12453 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
12454 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
12455 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
12456 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
12457 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
12458 'icon_color' => $this->options['icon_color'] ?? '#fff',
12459 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
12460 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
12461 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
12462 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
12463 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
12464 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
12465 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
12466 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
12467 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
12468 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off',
12469 'initial_email_state' => null,
12470 'skip_email_check' => true,
12471 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
12472 'skip_inline_colors' => $skip_inline_colors,
12473 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array(),
12474 );
12475
12476 // Behavior gates + labels (model, streaming, rate-limit copy, toolbar,
12477 // print/transcript, satisfaction rating) come from the shared
12478 // dynamic-settings method so this inline payload and the first-open
12479 // refresh endpoint can never drift (plan-32db95).
12480 $style_settings = array_merge($style_settings, $this->get_dynamic_widget_settings());
12481
12482 // Determine delay time based on strategy
12483 $delay_ms = 0;
12484 switch ($loading_strategy) {
12485 case 'delay_1s':
12486 $delay_ms = 1000;
12487 break;
12488 case 'delay_3s':
12489 $delay_ms = 3000;
12490 break;
12491 case 'delay_5s':
12492 $delay_ms = 5000;
12493 break;
12494 }
12495
12496 ?>
12497 <script type="text/javascript">
12498 (function() {
12499 var mxchatLoaded = false;
12500 var mxchatChat = <?php echo wp_json_encode($style_settings); ?>;
12501 window.mxchatChat = mxchatChat;
12502
12503 function loadMxChatScript() {
12504 if (mxchatLoaded) return;
12505 mxchatLoaded = true;
12506
12507 function appendChatScript() {
12508 var script = document.createElement('script');
12509 script.src = <?php echo wp_json_encode($script_url); ?>;
12510 script.type = 'text/javascript';
12511 document.body.appendChild(script);
12512 }
12513
12514 if (typeof jQuery !== 'undefined') {
12515 appendChatScript();
12516 } else {
12517 var jq = document.createElement('script');
12518 jq.src = <?php echo wp_json_encode(includes_url('js/jquery/jquery.min.js')); ?>;
12519 jq.onload = appendChatScript;
12520 document.body.appendChild(jq);
12521 }
12522 }
12523
12524 <?php if ($loading_strategy === 'on_interaction'): ?>
12525 // Load on user interaction
12526 var events = ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'];
12527 events.forEach(function(evt) {
12528 window.addEventListener(evt, loadMxChatScript, {once: true, passive: true});
12529 });
12530 // Fallback: load after 8 seconds if no interaction
12531 setTimeout(loadMxChatScript, 8000);
12532 <?php else: ?>
12533 // Load after specified delay
12534 setTimeout(loadMxChatScript, <?php echo intval($delay_ms); ?>);
12535 <?php endif; ?>
12536 })();
12537 </script>
12538 <?php
12539 }
12540
12541 /**
12542 * Setup the cron jobs for rate limits with guard against multiple calls
12543 */
12544 public function setup_rate_limit_cron_jobs() {
12545 // Add a guard to prevent multiple rapid calls
12546 $last_setup = get_transient('mxchat_cron_setup_guard');
12547 if ($last_setup && (time() - $last_setup) < 60) {
12548 // Don't run again if we ran less than 60 seconds ago
12549 return;
12550 }
12551
12552 // Set the guard
12553 set_transient('mxchat_cron_setup_guard', time(), 300); // 5 minutes
12554
12555 try {
12556 // First, check if WordPress cron is disabled
12557 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
12558 //error_log('MxChat: WordPress cron is disabled (DISABLE_WP_CRON = true), using fallback system');
12559 $this->setup_fallback_rate_limit_system();
12560 return;
12561 }
12562
12563 // Check if cron is already scheduled - if so, don't mess with it
12564 if (wp_next_scheduled('mxchat_reset_rate_limits')) {
12565 //error_log('MxChat: Rate limit cron already scheduled, skipping setup');
12566 return;
12567 }
12568
12569 // Clear any orphaned hooks (but don't loop indefinitely)
12570 $hooks_to_clear = [
12571 'mxchat_reset_rate_limits',
12572 'mxchat_reset_hourly_rate_limits',
12573 'mxchat_reset_daily_rate_limits',
12574 'mxchat_reset_weekly_rate_limits',
12575 'mxchat_reset_monthly_rate_limits'
12576 ];
12577
12578 foreach ($hooks_to_clear as $hook) {
12579 // Only clear a maximum of 3 instances to prevent infinite loops
12580 $cleared = 0;
12581 while (wp_next_scheduled($hook) && $cleared < 3) {
12582 wp_clear_scheduled_hook($hook);
12583 $cleared++;
12584 }
12585 }
12586
12587 // Small delay after clearing
12588 usleep(100000); // 0.1 seconds
12589
12590 // Try to schedule the event
12591 $initial_time = time() + 300; // Start in 5 minutes
12592 $result = wp_schedule_event($initial_time, 'hourly', 'mxchat_reset_rate_limits');
12593
12594 if ($result === false) {
12595 //error_log('MxChat: Failed to schedule cron, using fallback system');
12596 $this->setup_fallback_rate_limit_system();
12597 } else {
12598 if (defined('MXCHAT_DEV_MODE') && MXCHAT_DEV_MODE) {
12599 error_log('MxChat: rate-limit reset cron event was missing and has been re-scheduled');
12600 }
12601 }
12602
12603 } catch (Exception $e) {
12604 //error_log('MxChat: Cron setup exception: ' . $e->getMessage());
12605 $this->setup_fallback_rate_limit_system();
12606 }
12607 }
12608
12609 /**
12610 * Try alternative cron scheduling methods
12611 */
12612 private function try_alternative_cron_scheduling($initial_time) {
12613 try {
12614 // Method 1: Try with current time instead of future time
12615 $result1 = wp_schedule_event(time(), 'hourly', 'mxchat_reset_rate_limits');
12616 if ($result1 !== false) {
12617 //error_log('MxChat: Alternative method 1 (current time) succeeded');
12618 return true;
12619 }
12620
12621 // Method 2: Try with a different interval
12622 $result2 = wp_schedule_event($initial_time, 'daily', 'mxchat_reset_rate_limits');
12623 if ($result2 !== false) {
12624 //error_log('MxChat: Alternative method 2 (daily interval) succeeded');
12625 return true;
12626 }
12627
12628 // Method 3: Try wp_schedule_single_event first, then recurring
12629 $result3 = wp_schedule_single_event($initial_time, 'mxchat_reset_rate_limits');
12630 if ($result3 !== false) {
12631 //error_log('MxChat: Alternative method 3 (single event) succeeded');
12632 // Schedule the next one manually in the handler
12633 return true;
12634 }
12635
12636 return false;
12637
12638 } catch (Exception $e) {
12639 //error_log('MxChat: Alternative cron scheduling exception: ' . $e->getMessage());
12640 return false;
12641 }
12642 }
12643
12644 /**
12645 * Enhanced fallback rate limit system
12646 */
12647 private function setup_fallback_rate_limit_system() {
12648 // Idempotence matters here: with setup_rate_limit_cron_jobs() hooked to
12649 // admin_init, a DISABLE_WP_CRON site reaches this on every guard pass.
12650 // Unconditionally rewriting mxchat_next_rate_limit_check to now+3600 would
12651 // slide the deadline forward forever and the fallback reset would never
12652 // fire. Only initialize the deadline on a genuine transition into fallback
12653 // mode (or if it's somehow missing).
12654 $already_active = get_option('mxchat_use_fallback_rate_limits', false);
12655
12656 // Set a flag to use database-based rate limit cleanup
12657 update_option('mxchat_use_fallback_rate_limits', true);
12658
12659 // Schedule a one-time check to happen on the next plugin load
12660 if (!$already_active || !get_option('mxchat_next_rate_limit_check', 0)) {
12661 update_option('mxchat_next_rate_limit_check', time() + 3600);
12662 }
12663
12664 // Also set up a more frequent fallback check (every 4 hours)
12665 update_option('mxchat_fallback_check_interval', 4 * 3600);
12666
12667 //error_log('MxChat: Fallback rate limit system activated');
12668 }
12669
12670 /**
12671 * Enhanced fallback check method
12672 * NOTE: mxchat_check_fallback_rate_limits() in mxchat-basic.php is a second
12673 * implementation of this same check — if either changes, change both.
12674 */
12675 public function check_fallback_rate_limits() {
12676 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
12677
12678 if (!$use_fallback) {
12679 return; // Regular cron is working
12680 }
12681
12682 $next_check = get_option('mxchat_next_rate_limit_check', 0);
12683 $check_interval = get_option('mxchat_fallback_check_interval', 3600);
12684
12685 if (time() >= $next_check) {
12686 //error_log('MxChat: Running fallback rate limit cleanup');
12687 $this->mxchat_reset_rate_limits();
12688
12689 // Schedule next check
12690 update_option('mxchat_next_rate_limit_check', time() + $check_interval);
12691 }
12692 }
12693 /**
12694 * Enhanced rate limit check that includes fallback cleanup and bot-specific rate limits
12695 */
12696 public function check_rate_limit() {
12697 // Check if we need to run fallback cleanup
12698 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
12699 $next_check = get_option('mxchat_next_rate_limit_check', 0);
12700
12701 if ($use_fallback && time() >= $next_check) {
12702 $this->mxchat_reset_rate_limits();
12703 update_option('mxchat_next_rate_limit_check', time() + 3600); // Next hour
12704 }
12705
12706 // Get bot ID from current request context
12707 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
12708
12709 // Get bot-specific options (includes rate limits if overridden)
12710 $bot_options = $this->get_bot_options($bot_id);
12711 $current_options = !empty($bot_options) ? $bot_options : $this->options;
12712
12713 // Use bot-specific rate limits if available, otherwise fall back to default
12714 $rate_limits_source = isset($current_options['rate_limits']) ? $current_options['rate_limits'] : get_option('mxchat_options', [])['rate_limits'] ?? [];
12715
12716 // -------------------------------------------------------------------
12717 // Whole-chatbot global cap (independent of role). Evaluated FIRST so
12718 // it acts as a hard ceiling across all users + all roles. Default is
12719 // 'unlimited' so existing installs are unchanged. Counter key drops
12720 // both <role> and <user_id> segments — single pool per bot.
12721 // -------------------------------------------------------------------
12722 $global_cfg = isset($current_options['rate_limits_global']) && is_array($current_options['rate_limits_global'])
12723 ? $current_options['rate_limits_global']
12724 : (isset(get_option('mxchat_options', [])['rate_limits_global']) ? get_option('mxchat_options', [])['rate_limits_global'] : []);
12725 $global_limit_raw = isset($global_cfg['limit']) ? (string) $global_cfg['limit'] : 'unlimited';
12726 $global_timeframe = isset($global_cfg['timeframe']) ? (string) $global_cfg['timeframe'] : 'daily';
12727 if ($global_limit_raw !== '' && $global_limit_raw !== 'unlimited' && (int) $global_limit_raw >= 1) {
12728 $bot_id_for_global = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
12729 $safe_bot_global = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id_for_global);
12730 $global_option = 'mxchat_chat_limit_' . $safe_bot_global . '_global';
12731 $global_data = get_option($global_option, ['count' => 0, 'timestamp' => time()]);
12732 if ((int) $global_data['count'] === 0) {
12733 $global_data['timestamp'] = time();
12734 update_option($global_option, $global_data);
12735 }
12736 $now = time();
12737 $ts = (int) $global_data['timestamp'];
12738 $reset = false;
12739 switch ($global_timeframe) {
12740 case 'hourly': $reset = ($now - $ts) >= 3600; break;
12741 case 'daily': $reset = ($now - $ts) >= 86400; break;
12742 case 'weekly': $reset = ($now - $ts) >= 604800; break;
12743 case 'monthly': $reset = ($now - $ts) >= 2592000; break;
12744 }
12745 if ($reset) {
12746 $global_data = ['count' => 0, 'timestamp' => $now];
12747 update_option($global_option, $global_data);
12748 }
12749 if ((int) $global_data['count'] >= (int) $global_limit_raw) {
12750 $global_msg = !empty($global_cfg['message'])
12751 ? $global_cfg['message']
12752 : __('This chatbot has reached its message limit. Please try again later.', 'mxchat');
12753 return [
12754 'error' => true,
12755 'message' => $this->process_rate_limit_message_html($global_msg),
12756 ];
12757 }
12758 // Reserve the slot for this request. Per-role check below also increments
12759 // its own counter — that is intentional, both ceilings apply independently.
12760 $global_data['count']++;
12761 update_option($global_option, $global_data);
12762 }
12763
12764 // Determine user role or if logged out
12765 if (is_user_logged_in()) {
12766 $user = wp_get_current_user();
12767 $user_id = $user->ID;
12768
12769 // Get the user's primary role using reset() to safely get the first element
12770 $user_roles = $user->roles;
12771
12772 // Safely get the first role regardless of array key structure
12773 if (!empty($user_roles) && is_array($user_roles)) {
12774 $role = reset($user_roles); // This safely gets the first element regardless of key
12775 } else {
12776 $role = 'subscriber'; // Default to subscriber if no role found
12777 }
12778 } else {
12779 $role = 'logged_out';
12780 // Use IP address for non-logged-in users
12781 $user_id = $this->get_client_ip();
12782 }
12783
12784 // Check if rate limits are configured for this role
12785 if (!isset($rate_limits_source[$role])) {
12786 return true; // No limit set for this role
12787 }
12788
12789 $limit = $rate_limits_source[$role]['limit'];
12790
12791 // If unlimited, return true immediately
12792 if ($limit === 'unlimited') {
12793 return true;
12794 }
12795
12796 // Get the option name for this user/role with safer naming (include bot_id for bot-specific limits)
12797 $safe_role = preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
12798 $safe_user_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $user_id);
12799 $safe_bot_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id);
12800
12801 // Include bot_id in option name so each bot has separate rate limits
12802 $option_name = 'mxchat_chat_limit_' . $safe_bot_id . '_' . $safe_role . '_' . $safe_user_id;
12803
12804 // Get the counter data
12805 $limit_data = get_option($option_name, ['count' => 0, 'timestamp' => time()]);
12806
12807 // If first request or counter reset needed, set the initial timestamp
12808 if ($limit_data['count'] === 0) {
12809 $limit_data['timestamp'] = time();
12810 update_option($option_name, $limit_data);
12811 }
12812
12813 // Get the timeframe
12814 $timeframe = isset($rate_limits_source[$role]['timeframe']) ?
12815 $rate_limits_source[$role]['timeframe'] : 'daily';
12816
12817 // Check if the counter needs to be reset based on timeframe
12818 $current_time = time();
12819 $timestamp = $limit_data['timestamp'];
12820 $should_reset = false;
12821
12822 switch ($timeframe) {
12823 case 'hourly':
12824 $should_reset = ($current_time - $timestamp) >= 3600; // 1 hour
12825 break;
12826 case 'daily':
12827 $should_reset = ($current_time - $timestamp) >= 86400; // 24 hours
12828 break;
12829 case 'weekly':
12830 $should_reset = ($current_time - $timestamp) >= 604800; // 7 days
12831 break;
12832 case 'monthly':
12833 $should_reset = ($current_time - $timestamp) >= 2592000; // 30 days
12834 break;
12835 }
12836
12837 // Reset the counter if the timeframe has passed
12838 if ($should_reset) {
12839 $limit_data = ['count' => 0, 'timestamp' => $current_time];
12840 update_option($option_name, $limit_data);
12841 }
12842
12843 // Check if user has exceeded their limit
12844 if ($limit_data['count'] >= intval($limit)) {
12845 // Get the custom message for this role
12846 $message = !empty($rate_limits_source[$role]['message'])
12847 ? $rate_limits_source[$role]['message']
12848 : __('Rate limit exceeded. Please try again later.', 'mxchat');
12849
12850 // Add timeframe information to the message if placeholders exist
12851 $timeframe_label = '';
12852 switch ($timeframe) {
12853 case 'hourly':
12854 $timeframe_label = __('hour', 'mxchat');
12855 break;
12856 case 'daily':
12857 $timeframe_label = __('day', 'mxchat');
12858 break;
12859 case 'weekly':
12860 $timeframe_label = __('week', 'mxchat');
12861 break;
12862 case 'monthly':
12863 $timeframe_label = __('month', 'mxchat');
12864 break;
12865 }
12866
12867 // Replace placeholders in the message
12868 $message = str_replace(
12869 ['{limit}', '{count}', '{remaining}', '{timeframe}'],
12870 [intval($limit), $limit_data['count'], max(0, intval($limit) - $limit_data['count']), $timeframe_label],
12871 $message
12872 );
12873
12874 // Process HTML links in the message
12875 $message = $this->process_rate_limit_message_html($message);
12876
12877 // Return error with the processed message
12878 return [
12879 'error' => true,
12880 'message' => $message
12881 ];
12882 }
12883
12884 // Increment the counter
12885 $limit_data['count']++;
12886 update_option($option_name, $limit_data);
12887
12888 return true;
12889 }
12890
12891 /**
12892 * Enhanced rate limit reset with better error handling
12893 */
12894 public function mxchat_reset_rate_limits() {
12895 try {
12896 global $wpdb;
12897 $all_options = get_option('mxchat_options', []);
12898 $current_time = time();
12899
12900 // Get rate limit options with a safer query and limit
12901 $option_names = $wpdb->get_col(
12902 $wpdb->prepare(
12903 "SELECT option_name FROM {$wpdb->options}
12904 WHERE option_name LIKE %s
12905 LIMIT 1000",
12906 'mxchat_chat_limit_%'
12907 )
12908 );
12909
12910 if (empty($option_names)) {
12911 return;
12912 }
12913
12914 $processed_count = 0;
12915 $max_processing_time = 30; // Maximum 30 seconds
12916 $start_time = time();
12917
12918 foreach ($option_names as $option_name) {
12919 // Check processing time limit
12920 if ((time() - $start_time) > $max_processing_time) {
12921 //error_log('MxChat: Rate limit reset timeout after processing ' . $processed_count . ' entries');
12922 break;
12923 }
12924
12925 // Parse the option name more safely
12926 if (!preg_match('/^mxchat_chat_limit_(.+)_(.+)$/', $option_name, $matches)) {
12927 continue;
12928 }
12929
12930 $role_and_user = $matches[1] . '_' . $matches[2];
12931 $parts = explode('_', $role_and_user);
12932
12933 if (count($parts) < 2) {
12934 continue;
12935 }
12936
12937 // Extract role (everything except the last part which is user ID)
12938 $user_id_part = array_pop($parts);
12939 $role = implode('_', $parts);
12940
12941 // Skip if role doesn't exist in our settings
12942 if (!isset($all_options['rate_limits'][$role])) {
12943 // Clean up orphaned entries
12944 delete_option($option_name);
12945 continue;
12946 }
12947
12948 $timeframe = $all_options['rate_limits'][$role]['timeframe'] ?? 'daily';
12949 $limit_data = get_option($option_name);
12950
12951 if (!$limit_data || !is_array($limit_data) || !isset($limit_data['timestamp'])) {
12952 // Clean up invalid entries
12953 delete_option($option_name);
12954 continue;
12955 }
12956
12957 $timestamp = $limit_data['timestamp'];
12958 $should_reset = false;
12959
12960 // Determine if we should reset based on the timeframe
12961 switch ($timeframe) {
12962 case 'hourly':
12963 $should_reset = ($current_time - $timestamp) >= 3600;
12964 break;
12965 case 'daily':
12966 $should_reset = ($current_time - $timestamp) >= 86400;
12967 break;
12968 case 'weekly':
12969 $should_reset = ($current_time - $timestamp) >= 604800;
12970 break;
12971 case 'monthly':
12972 $should_reset = ($current_time - $timestamp) >= 2592000;
12973 break;
12974 }
12975
12976 // Reset the counter if the timeframe has passed
12977 if ($should_reset) {
12978 delete_option($option_name);
12979 wp_cache_delete($option_name, 'options');
12980 $processed_count++;
12981 }
12982 }
12983
12984 // Clean up any orphaned cache entries
12985 wp_cache_delete('mxchat_all_chat_limits', 'options');
12986
12987 //error_log("MxChat: Rate limit reset completed. Processed {$processed_count} entries.");
12988
12989 } catch (Exception $e) {
12990 //error_log('MxChat: Rate limit reset error: ' . $e->getMessage());
12991 }
12992 }
12993
12994
12995 /**
12996 * Process HTML links in rate limit messages
12997 *
12998 * @param string $message The rate limit message
12999 * @return string The processed message with safe HTML links
13000 */
13001 private function process_rate_limit_message_html($message) {
13002 // Return original message if empty
13003 if (empty($message)) {
13004 return $message;
13005 }
13006
13007 // First, convert markdown links to HTML
13008 $message = $this->convert_markdown_links($message);
13009
13010 // Then, auto-convert any remaining plain URLs to links
13011 $message = $this->auto_link_urls($message);
13012
13013 // Allow basic HTML tags for links and formatting
13014 $allowed_tags = [
13015 'a' => [
13016 'href' => true,
13017 'target' => true,
13018 'rel' => true,
13019 'title' => true,
13020 'class' => true
13021 ],
13022 'strong' => [],
13023 'em' => [],
13024 'br' => [],
13025 'b' => [],
13026 'i' => [],
13027 'span' => ['class' => true]
13028 ];
13029
13030 // Sanitize but allow the specified HTML tags
13031 $processed_message = wp_kses($message, $allowed_tags);
13032
13033 // If wp_kses stripped everything, return the original message as plain text
13034 if (empty($processed_message) && !empty($message)) {
13035 // Strip all HTML and return plain text as fallback
13036 return wp_strip_all_tags($message);
13037 }
13038
13039 return $processed_message;
13040 }
13041
13042 /**
13043 * Convert markdown links to HTML
13044 *
13045 * @param string $text The text to process
13046 * @return string The text with markdown links converted to HTML
13047 */
13048 private function convert_markdown_links($text) {
13049 // Return original text if empty
13050 if (empty($text)) {
13051 return $text;
13052 }
13053
13054 // Pattern to match markdown links: [text](url)
13055 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
13056
13057 $processed_text = preg_replace_callback($pattern, function($matches) {
13058 $link_text = $matches[1];
13059 $url = $matches[2];
13060
13061 // Clean up any trailing punctuation from the URL
13062 $url = rtrim($url, '.,;:!?');
13063
13064 // Sanitize the link text and URL
13065 $safe_text = esc_html($link_text);
13066 $safe_url = esc_url($url);
13067
13068 // Create the HTML link
13069 return '<a href="' . $safe_url . '" target="_blank" rel="noopener noreferrer">' . $safe_text . '</a>';
13070 }, $text);
13071
13072 // If preg_replace_callback failed, return original text
13073 if ($processed_text === null) {
13074 return $text;
13075 }
13076
13077 return $processed_text;
13078 }
13079
13080 /**
13081 * Auto-convert plain URLs to clickable links
13082 *
13083 * @param string $text The text to process
13084 * @return string The text with URLs converted to links
13085 */
13086 private function auto_link_urls($text) {
13087 // Return original text if empty
13088 if (empty($text)) {
13089 return $text;
13090 }
13091
13092 // Simple pattern that avoids complex lookbehinds
13093 // This will match URLs that are not already inside href attributes or markdown links
13094 $pattern = '/(?<!href=["\'])(?<!\]\()https?:\/\/[^\s<>"\')\]]+/i';
13095
13096 $processed_text = preg_replace_callback($pattern, function($matches) {
13097 $url = $matches[0];
13098 // Clean up any trailing punctuation that might have been captured
13099 $url = rtrim($url, '.,;:!?');
13100
13101 // Add target="_blank" and rel="noopener noreferrer" for security
13102 return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
13103 }, $text);
13104
13105 // If preg_replace_callback failed, return original text
13106 if ($processed_text === null) {
13107 return $text;
13108 }
13109
13110 return $processed_text;
13111 }
13112
13113
13114 // Helper function to get client IP address
13115 private function get_client_ip() {
13116 // Check for shared internet/ISP IP
13117 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
13118 return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
13119 }
13120
13121 // Check for IPs passing through proxies
13122 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
13123 // Use the first value in the comma-separated list
13124 $forwarded_for = explode(',', sanitize_text_field($_SERVER['HTTP_X_FORWARDED_FOR']));
13125 return trim($forwarded_for[0]);
13126 }
13127
13128 if (!empty($_SERVER['REMOTE_ADDR'])) {
13129 return sanitize_text_field($_SERVER['REMOTE_ADDR']);
13130 }
13131
13132 // Fallback
13133 return 'unknown';
13134 }
13135
13136 /**
13137 * AJAX handler to get system information for testing panel
13138 */
13139 /**
13140 * AJAX handler to get system information for testing panel
13141 */
13142 public function mxchat_get_system_info() {
13143 // Verify nonce for security
13144 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
13145 wp_send_json_error(['message' => 'Invalid nonce']);
13146 return;
13147 }
13148
13149 // Only allow admin users
13150 if (!current_user_can('administrator')) {
13151 wp_send_json_error(['message' => 'Unauthorized']);
13152 return;
13153 }
13154
13155 // Get system prompt from options
13156 $system_prompt = isset($this->options['system_prompt_instructions'])
13157 ? $this->options['system_prompt_instructions']
13158 : 'No system prompt configured';
13159
13160 // Get selected model
13161 $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.6-sol';
13162
13163 // Check if OpenRouter is being used
13164 $is_openrouter = ($selected_model === 'openrouter');
13165 $openrouter_model = '';
13166
13167 if ($is_openrouter) {
13168 // Get the actual OpenRouter model that's selected
13169 $openrouter_model = isset($this->options['openrouter_selected_model'])
13170 ? $this->options['openrouter_selected_model']
13171 : 'No OpenRouter model selected';
13172
13173 // Update selected_model display to show both
13174 $selected_model = 'OpenRouter: ' . $openrouter_model;
13175 }
13176
13177 // Get API key status (just check if they exist, don't expose the keys)
13178 $api_status = [];
13179 $api_status['openai'] = !empty($this->options['api_key']);
13180 $api_status['claude'] = !empty($this->options['claude_api_key']);
13181 $api_status['gemini'] = !empty($this->options['gemini_api_key']);
13182 $api_status['xai'] = !empty($this->options['xai_api_key']);
13183 $api_status['deepseek'] = !empty($this->options['deepseek_api_key']);
13184 $api_status['openrouter'] = !empty($this->options['openrouter_api_key']);
13185
13186 wp_send_json_success([
13187 'system_prompt' => $system_prompt,
13188 'selected_model' => $selected_model,
13189 'is_openrouter' => $is_openrouter,
13190 'openrouter_model' => $openrouter_model,
13191 'api_status' => $api_status
13192 ]);
13193 }
13194
13195 /**
13196 * AJAX handler to get similarity threshold
13197 */
13198 public function mxchat_get_similarity_threshold() {
13199 // Verify nonce for security
13200 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
13201 wp_send_json_error(['message' => 'Invalid nonce']);
13202 return;
13203 }
13204
13205 // Only allow admin users
13206 if (!current_user_can('administrator')) {
13207 wp_send_json_error(['message' => 'Unauthorized']);
13208 return;
13209 }
13210
13211 // Get similarity threshold from main options (default 35%)
13212 $similarity_threshold = isset($this->options['similarity_threshold'])
13213 ? ((int) $this->options['similarity_threshold']) / 100
13214 : 0.35;
13215
13216 wp_send_json_success([
13217 'threshold' => $similarity_threshold,
13218 'threshold_percentage' => ($similarity_threshold * 100) . '%'
13219 ]);
13220 }
13221
13222 /**
13223 * AJAX handler to get knowledge base status
13224 */
13225 public function mxchat_get_kb_status() {
13226 // Verify nonce for security
13227 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
13228 wp_send_json_error(['message' => 'Invalid nonce']);
13229 return;
13230 }
13231
13232 // Only allow admin users
13233 if (!current_user_can('administrator')) {
13234 wp_send_json_error(['message' => 'Unauthorized']);
13235 return;
13236 }
13237
13238 // Check OpenAI Vector Store first (takes priority)
13239 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
13240 $use_vectorstore = (isset($vectorstore_options['mxchat_use_openai_vectorstore']) && $vectorstore_options['mxchat_use_openai_vectorstore'] === '1');
13241
13242 if ($use_vectorstore) {
13243 $vectorstore_ids = $vectorstore_options['mxchat_openai_vectorstore_ids'] ?? '';
13244 $id_count = !empty($vectorstore_ids) ? count(array_filter(array_map('trim', explode(',', $vectorstore_ids)))) : 0;
13245
13246 $kb_info = [
13247 'type' => 'OpenAI Vector Store',
13248 'status' => 'Active',
13249 'documents' => $id_count > 0 ? $id_count . ' vector store' . ($id_count > 1 ? 's' : '') . ' configured' : 'No vector stores configured'
13250 ];
13251
13252 wp_send_json_success($kb_info);
13253 return;
13254 }
13255
13256 // Check Pinecone vs WordPress
13257 $addon_options = get_option('mxchat_pinecone_addon_options', array());
13258 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
13259
13260 $kb_info = [
13261 'type' => $use_pinecone ? 'Pinecone' : 'WordPress Database',
13262 'status' => 'Active'
13263 ];
13264
13265 // Get document count
13266 if ($use_pinecone) {
13267 $kb_info['documents'] = 'Connected to Pinecone';
13268 $kb_info['api_configured'] = !empty($addon_options['mxchat_pinecone_api_key']);
13269 } else {
13270 // Count documents in WordPress database
13271 global $wpdb;
13272 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
13273 $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
13274 $kb_info['documents'] = $count ? $count . ' documents' : 'No documents';
13275 }
13276
13277 wp_send_json_success($kb_info);
13278 }
13279
13280 /**
13281 * AJAX handler to start a completely fresh session (NEW - replaces old clear session)
13282 */
13283 public function mxchat_start_fresh_session() {
13284 // Verify nonce for security
13285 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
13286 wp_send_json_error(['message' => 'Invalid nonce']);
13287 return;
13288 }
13289
13290 // Only allow admin users
13291 if (!current_user_can('administrator')) {
13292 wp_send_json_error(['message' => 'Unauthorized']);
13293 return;
13294 }
13295
13296 $old_session_id = isset($_POST['old_session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['old_session_id'])) : '';
13297 $new_session_id = isset($_POST['new_session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['new_session_id'])) : '';
13298
13299 if (empty($old_session_id)) {
13300 wp_send_json_error(['message' => 'Old session ID required']);
13301 return;
13302 }
13303
13304 // If no new session ID provided, generate one
13305 if (empty($new_session_id)) {
13306 // Cryptographically strong session id (plan-0c17b5). Prefix preserved
13307 // exactly (other code pattern-matches on 'mxchat_chat_'). random_bytes
13308 // is guaranteed on all supported PHP (7+).
13309 $new_session_id = 'mxchat_chat_' . bin2hex(random_bytes(16));
13310 }
13311
13312 // Clear ALL data associated with the old session
13313 $this->clear_complete_session_data($old_session_id);
13314
13315 // Initialize the new session
13316 $this->initialize_fresh_session($new_session_id);
13317
13318 wp_send_json_success([
13319 'message' => 'Fresh session started successfully',
13320 'new_session_id' => $new_session_id,
13321 'old_session_id' => $old_session_id
13322 ]);
13323 }
13324
13325 /**
13326 * Clear ALL data associated with a session (ENHANCED)
13327 */
13328 private function clear_complete_session_data($session_id) {
13329 // Clear chat history
13330 delete_option("mxchat_history_{$session_id}");
13331
13332 // Clear chat mode
13333 delete_option("mxchat_mode_{$session_id}");
13334
13335 // Clear any PDF/Word transients
13336 $this->clear_pdf_transients($session_id);
13337 if (method_exists($this, 'clear_word_transients')) {
13338 $this->clear_word_transients($session_id);
13339 }
13340
13341 // Archive the session's per-conversation Slack channel before its option
13342 // is deleted (plan 7458a7 — covers transcript-retention cleanup paths).
13343 // Toggle-gated + shared-channel-guarded inside the helper; best-effort.
13344 $stale_channel = get_option("mxchat_channel_{$session_id}", '');
13345 if ($stale_channel !== '') {
13346 $this->mxchat_maybe_archive_conversation_channel($session_id, $stale_channel);
13347 }
13348
13349 // Clear agent-related data
13350 delete_option("mxchat_channel_{$session_id}");
13351 delete_option("mxchat_thread_{$session_id}");
13352 delete_option("mxchat_agent_name_{$session_id}");
13353 delete_option("mxchat_email_{$session_id}");
13354
13355 // Clear any recommendation flow state
13356 delete_option("mxchat_sr_flow_state_{$session_id}");
13357
13358 // Clear any cached embeddings or context
13359 delete_transient("mxchat_context_{$session_id}");
13360 delete_transient("mxchat_last_query_{$session_id}");
13361
13362 // Clear any testing data
13363 delete_transient("mxchat_testing_data_{$session_id}");
13364
13365 // Clear any rate limiting data for this session
13366 delete_transient("mxchat_rate_limit_{$session_id}");
13367
13368 // Clear any other session-specific transients
13369 delete_transient("mxchat_waiting_for_pdf_url_{$session_id}");
13370 delete_transient("mxchat_include_pdf_in_context_{$session_id}");
13371 delete_transient("mxchat_include_word_in_context_{$session_id}");
13372
13373 // Clear form addon state (pending forms and submitted forms)
13374 delete_option("mxchat_pending_form_{$session_id}");
13375 delete_option("mxchat_submitted_forms_{$session_id}");
13376
13377 //error_log("MxChat: Cleared all data for session: {$session_id}");
13378 }
13379
13380 /**
13381 * Initialize a fresh session with default data
13382 */
13383 private function initialize_fresh_session($session_id) {
13384 // Set default chat mode
13385 update_option("mxchat_mode_{$session_id}", 'ai');
13386
13387 //error_log("MxChat: Initialized fresh session: {$session_id}");
13388 }
13389
13390 /**
13391 * Helper method to clear Word document transients (if you have Word support)
13392 */
13393 private function clear_word_transients($session_id) {
13394 delete_transient('mxchat_word_url_' . $session_id);
13395 delete_transient('mxchat_word_filename_' . $session_id);
13396 delete_transient('mxchat_word_embeddings_' . $session_id);
13397 delete_transient('mxchat_include_word_in_context_' . $session_id);
13398 }
13399
13400 /**
13401 * Simplified testing data capture method (CLEANED UP)
13402 */
13403 private function capture_testing_data($user_embedding, $message, $session_id) {
13404 // Only capture for admin users
13405 if (!current_user_can('administrator')) {
13406 return null;
13407 }
13408
13409 $testing_data = [
13410 'query' => $message,
13411 'timestamp' => time(),
13412 'top_matches' => [],
13413 'action_matches' => [] // Add action matches
13414 ];
13415
13416 // Get similarity threshold
13417 $similarity_threshold = isset($this->options['similarity_threshold'])
13418 ? ((int) $this->options['similarity_threshold']) / 100
13419 : 0.35;
13420
13421 $testing_data['similarity_threshold'] = $similarity_threshold;
13422
13423 // Use the real similarity analysis if available
13424 if ($this->last_similarity_analysis !== null) {
13425 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
13426 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
13427 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
13428 } else {
13429 // Fallback: determine knowledge base type
13430 $addon_options = get_option('mxchat_pinecone_addon_options', array());
13431 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
13432
13433 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
13434 }
13435
13436 // Include action analysis if available
13437 if (isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
13438 $testing_data['action_matches'] = $this->last_action_analysis;
13439
13440 // Clear it after capturing to avoid stale data
13441 $this->last_action_analysis = null;
13442 }
13443
13444 return $testing_data;
13445 }
13446
13447
13448 /**
13449 * Track URL clicks from chatbot responses
13450 */
13451 public function mxchat_track_url_click() {
13452 // Verify nonce for security
13453 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
13454 wp_send_json_error(['message' => 'Invalid nonce']);
13455 wp_die();
13456 }
13457
13458 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
13459 $clicked_url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
13460 $message_context = isset($_POST['message_context']) ? sanitize_textarea_field($_POST['message_context']) : '';
13461
13462 if (empty($session_id) || empty($clicked_url)) {
13463 wp_send_json_error(['message' => 'Missing required data']);
13464 wp_die();
13465 }
13466
13467 global $wpdb;
13468 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
13469
13470 // Insert click tracking record
13471 $wpdb->insert(
13472 $table_name,
13473 [
13474 'session_id' => $session_id,
13475 'clicked_url' => $clicked_url,
13476 'message_context' => $message_context,
13477 'click_timestamp' => current_time('mysql', 1),
13478 'user_ip' => $_SERVER['REMOTE_ADDR'],
13479 'user_agent' => $_SERVER['HTTP_USER_AGENT']
13480 ]
13481 );
13482
13483 wp_send_json_success(['message' => 'Click tracked']);
13484 wp_die();
13485 }
13486
13487 /**
13488 * Get URL click analytics for a session
13489 */
13490 public function mxchat_get_url_clicks($session_id) {
13491 global $wpdb;
13492 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
13493
13494 $clicks = $wpdb->get_results($wpdb->prepare(
13495 "SELECT * FROM $table_name WHERE session_id = %s ORDER BY click_timestamp ASC",
13496 $session_id
13497 ));
13498
13499 return $clicks;
13500 }
13501 /**
13502 * Track the originating page where chat was started
13503 */
13504 public function mxchat_track_originating_page() {
13505 // Verify nonce
13506 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
13507 wp_send_json_error(['message' => 'Invalid nonce']);
13508 wp_die();
13509 }
13510
13511 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
13512 $page_url = isset($_POST['page_url']) ? esc_url_raw($_POST['page_url']) : '';
13513 $page_title = isset($_POST['page_title']) ? sanitize_text_field($_POST['page_title']) : '';
13514
13515 if (empty($session_id)) {
13516 wp_send_json_error(['message' => 'Missing session ID']);
13517 wp_die();
13518 }
13519
13520 global $wpdb;
13521 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
13522
13523 // Check if we've already tracked for this session
13524 $existing = $wpdb->get_var($wpdb->prepare(
13525 "SELECT COUNT(*) FROM $table_name
13526 WHERE session_id = %s
13527 AND originating_page_url IS NOT NULL",
13528 $session_id
13529 ));
13530
13531 if ($existing > 0) {
13532 wp_send_json_success(['message' => 'Already tracked']);
13533 wp_die();
13534 }
13535
13536 // Update the first message in this session with originating page info
13537 $wpdb->query($wpdb->prepare(
13538 "UPDATE $table_name
13539 SET originating_page_url = %s,
13540 originating_page_title = %s
13541 WHERE session_id = %s
13542 ORDER BY timestamp ASC
13543 LIMIT 1",
13544 $page_url,
13545 $page_title,
13546 $session_id
13547 ));
13548
13549 wp_send_json_success(['message' => 'Originating page tracked']);
13550 wp_die();
13551 }
13552
13553 /**
13554 * Validate and clean URLs from AI response
13555 * Removes any URLs that aren't in the knowledge base
13556 *
13557 * @param string $response_text The AI-generated response
13558 * @param array $valid_urls Array of URLs from the knowledge base
13559 * @return string Cleaned response with invalid URLs removed/flagged
13560 */
13561 private function validate_and_clean_urls($response_text, $valid_urls, $session_id = null, $bot_id = null) {
13562 /**
13563 * Filter the list of URLs treated as valid (allowlisted) BEFORE the
13564 * response URL sanitizer strips any link not in the list. Lets a site
13565 * owner / developer whitelist links their custom function-calling tools
13566 * return (e.g. session or speaker pages), which are otherwise absent from
13567 * the RAG/system-prompt-derived list and get stripped to plain text.
13568 *
13569 * Purely additive: with no hook registered, apply_filters returns
13570 * $valid_urls untouched, so there is zero behavior change for anyone who
13571 * does not use the filter. Applied before the empty-check so a hooked
13572 * allowlist can participate. (plan-mxchat-20260710-13a471)
13573 *
13574 * @param array $valid_urls URLs already known-valid (RAG + system prompt).
13575 * @param string|null $session_id Current chat session id, if available.
13576 * @param string|null $bot_id Current bot id, if available.
13577 */
13578 $valid_urls = apply_filters('mxchat_valid_urls', $valid_urls, $session_id, $bot_id);
13579
13580 // A bad mu-plugin returning a non-array (or non-string entries) must never
13581 // fatal the response path — coerce defensively before any use.
13582 if (!is_array($valid_urls)) {
13583 $valid_urls = array();
13584 }
13585 $valid_urls = array_values(array_filter($valid_urls, static function ($u) {
13586 return is_string($u) && $u !== '';
13587 }));
13588
13589 // If no valid URLs provided or empty response, return as-is
13590 if (empty($valid_urls) || empty($response_text)) {
13591 //error_log("Validation skipped - empty valid_urls or response");
13592 return $response_text;
13593 }
13594
13595 // Extract all URLs from the AI response
13596 // This regex matches http:// and https:// URLs
13597 preg_match_all(
13598 '#\bhttps?://[^\s<>"\')\]]+#i',
13599 $response_text,
13600 $matches
13601 );
13602
13603 // If no URLs found in response, return as-is
13604 if (empty($matches[0])) {
13605 //error_log("No URLs found in response");
13606 return $response_text;
13607 }
13608
13609 $found_urls = $matches[0];
13610 $cleaned_response = $response_text;
13611 $removed_count = 0;
13612
13613 // Normalize valid URLs for comparison (remove trailing slashes, fragments, etc.)
13614 $normalized_valid_urls = array_map(function($url) {
13615 // Remove trailing slash
13616 $url = rtrim($url, '/');
13617 // Remove URL fragments (#section)
13618 $url = preg_replace('/#.*$/', '', $url);
13619 // Remove trailing punctuation that might have been captured
13620 $url = rtrim($url, '.,;:!?');
13621 return $url;
13622 }, $valid_urls);
13623
13624 //error_log("Normalized valid URLs: " . print_r($normalized_valid_urls, true));
13625
13626 foreach ($found_urls as $found_url) {
13627 // Clean up the found URL (remove trailing punctuation that might have been captured)
13628 $clean_found_url = rtrim($found_url, '.,;:!?)');
13629
13630 // DEBUG: Log each URL being checked
13631 //error_log("Checking found URL: " . $found_url);
13632
13633 // Normalize for comparison
13634 $normalized_found = rtrim($clean_found_url, '/');
13635 $normalized_found = preg_replace('/#.*$/', '', $normalized_found);
13636
13637 //error_log("Normalized found URL: " . $normalized_found);
13638
13639 // Check if this URL exists in our valid URLs list
13640 $is_valid = false;
13641
13642 //error_log("Starting validation checks for: " . $normalized_found);
13643
13644 // First, try exact match
13645 if (in_array($normalized_found, $normalized_valid_urls)) {
13646 $is_valid = true;
13647 //error_log("EXACT MATCH FOUND");
13648 } else {
13649 //error_log("No exact match, checking variations...");
13650 // If no exact match, check if it's a variation (with query params, etc.)
13651 foreach ($normalized_valid_urls as $valid_url) {
13652 //error_log(" Comparing against valid URL: " . $valid_url);
13653
13654 // Check if the found URL starts with a valid URL (handles query params)
13655 if (strpos($normalized_found, $valid_url) === 0) {
13656 // Check what comes after the valid URL
13657 $remainder = substr($normalized_found, strlen($valid_url));
13658
13659 // Only valid if:
13660 // 1. Exact match (remainder is empty)
13661 // 2. Query params (starts with ?)
13662 // 3. Fragment (starts with #)
13663 if (empty($remainder) || $remainder[0] === '?' || $remainder[0] === '#') {
13664 $is_valid = true;
13665 //error_log(" MATCH: Found URL is valid variation of base URL");
13666 break;
13667 } else {
13668 //error_log(" NOT A MATCH: Found URL extends path beyond valid URL (remainder: " . $remainder . ")");
13669 }
13670 }
13671 // Also check the reverse (in case valid URL has query params)
13672 if (strpos($valid_url, $normalized_found) === 0) {
13673 $is_valid = true;
13674 //error_log(" MATCH: Valid URL starts with found URL");
13675 break;
13676 }
13677 }
13678
13679 if (!$is_valid) {
13680 //error_log("NO MATCH FOUND - URL should be removed");
13681 }
13682 }
13683
13684 // If URL is not valid, remove it from the response
13685 if (!$is_valid) {
13686 // Log the removal for debugging
13687 //error_log("MxChat: Removed hallucinated URL: " . $found_url);
13688 //error_log("MxChat: Valid URLs were: " . implode(', ', array_slice($normalized_valid_urls, 0, 5)));
13689
13690 $removed_count++;
13691
13692 // Check if URL is part of a markdown link: [text](url)
13693 $markdown_pattern = '/\[([^\]]+)\]\(' . preg_quote($found_url, '/') . '\)/';
13694 if (preg_match($markdown_pattern, $cleaned_response)) {
13695 //error_log("Found markdown link, removing but keeping text");
13696 // Remove the markdown link but keep the text
13697 $cleaned_response = preg_replace($markdown_pattern, '$1', $cleaned_response);
13698 }
13699 // Check if URL is part of an HTML link: <a href="url">text</a>
13700 else if (preg_match('/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>(.*?)<\/a>/i', $cleaned_response, $link_match)) {
13701 //error_log("Found HTML link, removing but keeping text");
13702 // Remove the HTML link but keep the text
13703 $link_text = $link_match[1];
13704 $cleaned_response = preg_replace(
13705 '/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>.*?<\/a>/i',
13706 $link_text,
13707 $cleaned_response
13708 );
13709 }
13710 // Otherwise just remove the bare URL
13711 else {
13712 //error_log("Removing bare URL");
13713 $cleaned_response = str_replace($found_url, '', $cleaned_response);
13714 }
13715 }
13716 }
13717
13718 // Log summary if any URLs were removed
13719 if ($removed_count > 0) {
13720 //error_log("MxChat: URL Validation Summary - Removed {$removed_count} hallucinated URL(s)");
13721 } else {
13722 //error_log("MxChat: URL Validation Summary - No URLs removed, all were valid");
13723 }
13724
13725 // Clean up any double spaces or awkward punctuation left behind
13726 // IMPORTANT: Only collapse horizontal whitespace (spaces/tabs), preserve newlines for markdown formatting
13727 $cleaned_response = preg_replace('/[^\S\n]+/', ' ', $cleaned_response); // Collapse spaces/tabs but NOT newlines
13728 $cleaned_response = preg_replace('/[^\S\n]+([.,;:!?])/', '$1', $cleaned_response); // Same for punctuation cleanup
13729
13730 //error_log("Final cleaned response: " . $cleaned_response);
13731
13732 return trim($cleaned_response);
13733 }
13734
13735 /**
13736 * AJAX handler to get current chat mode for a session
13737 */
13738 public function mxchat_get_current_chat_mode() {
13739 // Verify nonce for security
13740 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
13741 wp_send_json_error(['message' => 'Invalid nonce']);
13742 wp_die();
13743 }
13744
13745 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
13746
13747 if (empty($session_id)) {
13748 wp_send_json_error(['message' => 'Session ID missing']);
13749 wp_die();
13750 }
13751
13752 // Get the current chat mode for this session
13753 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
13754
13755 wp_send_json_success([
13756 'chat_mode' => $chat_mode
13757 ]);
13758 wp_die();
13759 }
13760
13761
13762
13763 }
13764 ?>
13765