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

13,725 lines 577.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 class MxChat_Integrator {
7 private $options;
8 private $prompts_options;
9 private $chat_count;
10 private $fallbackResponse;
11 private $productCardHtml;
12 // 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 $last_pdf_embedding_error = null; // First embedding failure reason from the most recent PDF split (104a75)
38 private $is_streaming = false; // ADDED: Track if current request is streaming
39 private $streaming_headers_sent = false; // Track if streaming headers have been sent
40 private $pending_originating_page = null; // Originating page captured at session start, consumed on row insert
41 private $current_action_instruction = null; // Success-message instruction injected into the next system context
42 private $last_action_analysis = null; // Last action-match analysis for testing_data payloads
43
44 /**
45 * Setup streaming headers - call this right before actually streaming
46 * This delays header setup to allow actions/forms to return JSON responses
47 */
48 /**
49 * Auto-retry wrapper around wp_remote_post for chat-send provider calls.
50 *
51 * Retries up to twice (750ms then 2000ms backoff) when the upstream provider
52 * returns a TRANSIENT error: WP timeout, 429, 502, 503, 504, or a provider-
53 * specific "overloaded" / "rate limit" body string. Returns immediately on
54 * permanent errors (401/403/404/422) so misconfiguration surfaces fast.
55 *
56 * Drop-in replacement for wp_remote_post — returns the same shape
57 * (WP_Error or response array) so the caller's existing error-handling
58 * code path is unchanged.
59 *
60 * STREAMING PATH NOTE: this helper is ONLY for non-streaming chat-send
61 * paths (the *_response_openai / *_response_claude / etc functions).
62 * For the *_stream variants, the cURL initial-connect happens inside a
63 * read-chunks loop — retrying there safely (without re-emitting partial
64 * stream chunks to the client) is a separate problem. Streaming paths
65 * are NOT wrapped in this build; tracked as a follow-on.
66 *
67 * Honors the `mxchat_options['auto_retry_on_transient_error']` toggle
68 * (default true). When false, behavior is identical to plain wp_remote_post.
69 */
70 private function mxchat_provider_call_with_retry($url, $args, $provider_hint = '') {
71 $opts = is_array($this->options ?? null) ? $this->options : array();
72 $enabled = !isset($opts['auto_retry_on_transient_error']) ||
73 (string) $opts['auto_retry_on_transient_error'] !== '0';
74
75 if (!$enabled) {
76 return wp_remote_post($url, $args);
77 }
78
79 $backoffs = array(0, 750, 2000); // ms — first attempt 0, then retry waits
80 $last_response = null;
81
82 foreach ($backoffs as $i => $delay_ms) {
83 if ($delay_ms > 0) {
84 usleep($delay_ms * 1000);
85 }
86 $response = wp_remote_post($url, $args);
87 $last_response = $response;
88
89 if (!$this->mxchat_is_transient_provider_error($response, $provider_hint)) {
90 return $response;
91 }
92
93 if (defined('WP_DEBUG') && WP_DEBUG) {
94 $code_for_log = is_wp_error($response) ? 'wp_error:' . $response->get_error_code()
95 : (int) wp_remote_retrieve_response_code($response);
96 error_log(sprintf(
97 '[MxChat] Transient provider error (provider=%s, attempt=%d/3, status=%s). %s',
98 $provider_hint ?: 'unknown',
99 $i + 1,
100 $code_for_log,
101 ($i + 1) < count($backoffs) ? 'Retrying.' : 'Giving up.'
102 ));
103 }
104 }
105
106 return $last_response;
107 }
108
109 /**
110 * Returns true if a wp_remote_post response represents a TRANSIENT
111 * provider error worth retrying. Conservative — only retries on signals
112 * that are very likely to clear within a few seconds.
113 *
114 * Transient signals:
115 * - WP_Error with timeout / connection / dns / ssl
116 * - HTTP 429, 502, 503, 504
117 * - Provider-specific overload bodies (gemini "overloaded", openai
118 * "server_error", anthropic "overloaded_error", xai/grok "Rate limit")
119 *
120 * NOT transient (return false — fail-fast):
121 * - 200/2xx (success)
122 * - 401, 403, 404, 422 (auth / config errors — retrying wastes the
123 * budget; the user needs to fix something)
124 * - Any other 4xx (assume permanent unless explicitly listed above)
125 * - 5xx other than the four listed above (e.g. 500 generic server error
126 * is often a malformed request on our side, not a transient outage)
127 */
128 private function mxchat_is_transient_provider_error($response, $provider_hint = '') {
129 if (is_wp_error($response)) {
130 $code = $response->get_error_code();
131 return in_array($code, array('http_request_failed', 'connection_failed', 'connection_timeout'), true)
132 || stripos((string) $response->get_error_message(), 'timed out') !== false
133 || stripos((string) $response->get_error_message(), 'timeout') !== false;
134 }
135
136 $status = (int) wp_remote_retrieve_response_code($response);
137 if (in_array($status, array(429, 502, 503, 504), true)) {
138 return true;
139 }
140 if ($status >= 200 && $status < 300) {
141 return false;
142 }
143 // Permanent 4xx that should fail fast — even with no body.
144 if (in_array($status, array(401, 403, 404, 405, 422), true)) {
145 return false;
146 }
147
148 // Provider-specific body inspection for the cases where the upstream
149 // returns 200 with an error envelope (gemini does this for overload).
150 $body = (string) wp_remote_retrieve_body($response);
151 if ($body === '') {
152 return false;
153 }
154 $lower = strtolower($body);
155 $hint = strtolower((string) $provider_hint);
156
157 if ($hint === 'gemini' && (strpos($lower, 'overloaded') !== false
158 || strpos($lower, 'high demand') !== false
159 || strpos($lower, 'model is overloaded') !== false)) {
160 return true;
161 }
162 if ($hint === 'openai' && (strpos($lower, 'rate limit reached') !== false
163 || strpos($lower, '"type":"server_error"') !== false
164 || strpos($lower, '"code":"server_error"') !== false)) {
165 return true;
166 }
167 if ($hint === 'anthropic' && (strpos($lower, '"type":"overloaded_error"') !== false
168 || strpos($lower, 'overloaded_error') !== false)) {
169 return true;
170 }
171 if (($hint === 'xai' || $hint === 'grok') && strpos($lower, 'rate limit') !== false) {
172 return true;
173 }
174
175 return false;
176 }
177
178 /**
179 * Streaming-path classifier: same rules as mxchat_is_transient_provider_error
180 * but takes a raw (http_code, body, provider_hint, curl_errno) tuple as
181 * captured during a cURL streaming exec. cURL's WRITEFUNCTION/HEADERFUNCTION
182 * collect status separately from a plain wp_remote_post array shape, so the
183 * non-streaming helper above can't be called directly. This delegate keeps
184 * the classification rules identical across both paths.
185 */
186 private function mxchat_is_transient_provider_error_raw($http_code, $body, $provider_hint = '', $curl_errno = 0) {
187 if ($curl_errno) {
188 // cURL transport-level error (timeout, connection failure, DNS, etc.)
189 // Match the same WP_Error timeout/connection signals the array variant treats as transient.
190 return in_array($curl_errno, array(
191 CURLE_OPERATION_TIMEDOUT,
192 CURLE_COULDNT_CONNECT,
193 CURLE_COULDNT_RESOLVE_HOST,
194 CURLE_SSL_CONNECT_ERROR,
195 CURLE_GOT_NOTHING,
196 CURLE_SEND_ERROR,
197 CURLE_RECV_ERROR,
198 ), true);
199 }
200
201 $status = (int) $http_code;
202 if (in_array($status, array(429, 502, 503, 504), true)) {
203 return true;
204 }
205 if ($status >= 200 && $status < 300) {
206 return false;
207 }
208 if (in_array($status, array(401, 403, 404, 405, 422), true)) {
209 return false;
210 }
211
212 $body = (string) $body;
213 if ($body === '') {
214 return false;
215 }
216 $lower = strtolower($body);
217 $hint = strtolower((string) $provider_hint);
218
219 if ($hint === 'gemini' && (strpos($lower, 'overloaded') !== false
220 || strpos($lower, 'high demand') !== false
221 || strpos($lower, 'model is overloaded') !== false)) {
222 return true;
223 }
224 if ($hint === 'openai' && (strpos($lower, 'rate limit reached') !== false
225 || strpos($lower, '"type":"server_error"') !== false
226 || strpos($lower, '"code":"server_error"') !== false)) {
227 return true;
228 }
229 if ($hint === 'anthropic' && (strpos($lower, '"type":"overloaded_error"') !== false
230 || strpos($lower, 'overloaded_error') !== false)) {
231 return true;
232 }
233 if (($hint === 'xai' || $hint === 'grok') && strpos($lower, 'rate limit') !== false) {
234 return true;
235 }
236
237 return false;
238 }
239
240 /**
241 * Whether transient-error auto-retry is enabled in admin settings.
242 * Default true unless explicitly set to '0'. Used by both wp_remote_post
243 * (mxchat_provider_call_with_retry) and cURL streaming paths.
244 */
245 private function mxchat_retry_enabled() {
246 $opts = is_array($this->options ?? null) ? $this->options : array();
247 return !isset($opts['auto_retry_on_transient_error']) ||
248 (string) $opts['auto_retry_on_transient_error'] !== '0';
249 }
250
251 private function setup_streaming_headers() {
252 if ($this->streaming_headers_sent || headers_sent()) {
253 return false;
254 }
255
256 // Headers MUST be set BEFORE the buffers are torn down: flushing a
257 // buffer that holds any stray output commits the response and turns
258 // every later header() into a logged no-op — dropping all four SSE
259 // headers, including the X-Accel-Buffering that stops nginx-fronted
260 // hosts from de-streaming the reply (plan fe130d).
261 header('Content-Type: text/event-stream');
262 header('Cache-Control: no-cache');
263 header('Connection: keep-alive');
264 header('X-Accel-Buffering: no');
265
266 // Dev-mode diagnostic: with the reorder, stray buffered bytes become
267 // the first bytes of the SSE stream — record what they are so a future
268 // switch to ob_end_clean() can be decided on evidence (fe130d follow-up).
269 if (defined('MXCHAT_DEV_MODE') && MXCHAT_DEV_MODE && ob_get_level() > 0) {
270 $buffered = ob_get_contents();
271 if (is_string($buffered) && $buffered !== '') {
272 error_log('MxChat SSE teardown: output buffer held ' . strlen($buffered) . ' byte(s): ' . substr($buffered, 0, 200));
273 }
274 }
275
276 // Disable output buffering
277 while (ob_get_level()) {
278 ob_end_flush();
279 }
280
281 ob_implicit_flush(true);
282 flush();
283
284 $this->streaming_headers_sent = true;
285 return true;
286 }
287
288 /**
289 * Class constructor
290 */
291 public function __construct() {
292 $this->options = get_option('mxchat_options');
293 $this->prompts_options = get_option('mxchat_prompts_options', array());
294 $this->chat_count = get_option('mxchat_chat_count', 0);
295 $this->word_handler = new MXChat_Word_Handler($this->options);
296
297 // Add all action hooks
298 add_action('wp_enqueue_scripts', array($this, 'mxchat_enqueue_scripts_styles'));
299 add_action('wp_ajax_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
300 add_action('wp_ajax_nopriv_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
301 add_action('wp_ajax_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
302 add_action('wp_ajax_nopriv_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
303
304 // Add the AJAX actions for checking if the pre-chat message was dismissed
305 add_action('wp_ajax_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
306 add_action('wp_ajax_nopriv_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
307 add_action('wp_ajax_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
308 add_action('wp_ajax_nopriv_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
309 add_action('wp_ajax_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
310 add_action('wp_ajax_nopriv_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
311
312 // Add REST API routes registration
313 add_action('rest_api_init', array($this, 'register_routes'));
314 add_action('wp_ajax_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
315 add_action('wp_ajax_nopriv_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
316
317 // Rate limit action - notice we removed the old schedule setup
318 add_action('mxchat_reset_rate_limits', array($this, 'mxchat_reset_rate_limits'));
319
320 // Self-heal: if the reset event is ever lost (cron row cleared, botched
321 // migration, deactivate/reactivate race), an admin-context request brings it
322 // back. Cheap by construction: 60s transient guard + early return when the
323 // event is already scheduled. Without this, a lost event with the fallback
324 // flag unset leaves visitors rate-limited forever.
325 add_action('admin_init', array($this, 'setup_rate_limit_cron_jobs'));
326
327 // File upload and handling actions
328 add_action('wp_ajax_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
329 add_action('wp_ajax_nopriv_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
330 add_action('wp_ajax_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
331 add_action('wp_ajax_nopriv_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
332
333 // Word document handling actions
334 add_action('wp_ajax_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
335 add_action('wp_ajax_nopriv_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
336 add_action('wp_ajax_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
337 add_action('wp_ajax_nopriv_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
338 add_action('wp_ajax_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
339 add_action('wp_ajax_nopriv_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
340
341 // Email handling actions
342 add_action('wp_ajax_nopriv_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
343 add_action('wp_ajax_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
344 add_action('wp_ajax_nopriv_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
345 add_action('wp_ajax_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
346
347 add_action('wp_ajax_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
348 add_action('wp_ajax_nopriv_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
349
350 // Testing panel AJAX actions
351 add_action('wp_ajax_mxchat_get_system_info', array($this, 'mxchat_get_system_info'));
352 add_action('wp_ajax_mxchat_get_similarity_threshold', array($this, 'mxchat_get_similarity_threshold'));
353 add_action('wp_ajax_mxchat_get_kb_status', array($this, 'mxchat_get_kb_status'));
354 add_action('wp_ajax_mxchat_start_fresh_session', array($this, 'mxchat_start_fresh_session'));
355 // Add to your existing constructor, in the section with other AJAX actions:
356 add_action('wp_ajax_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
357 add_action('wp_ajax_nopriv_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
358 add_action('wp_ajax_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
359 add_action('wp_ajax_nopriv_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
360 // Add chat mode checking actions
361 add_action('wp_ajax_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
362 add_action('wp_ajax_nopriv_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
363
364 // Nonce refresh for page-cache compatibility (WP Rocket, LiteSpeed, etc.)
365 add_action('wp_ajax_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
366 add_action('wp_ajax_nopriv_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
367
368 // Auto-email transcript action
369 add_action('mxchat_send_delayed_transcript', array($this, 'mxchat_send_delayed_transcript'), 10, 1);
370
371 add_filter('mxchat_check_actions_only', array($this, 'check_actions_for_addons'), 10, 4);
372
373
374 }
375
376 /**
377 * Return a fresh nonce so cached pages can replace the stale one.
378 * With `with_settings`, also returns the current behavior-gate settings so
379 * the widget can correct stale inline-localized values (plan-32db95).
380 */
381 public function mxchat_refresh_nonce() {
382 nocache_headers();
383 $payload = array('nonce' => wp_create_nonce('mxchat_chat_nonce'));
384 if (!empty($_REQUEST['with_settings'])) {
385 $payload['settings'] = $this->get_dynamic_widget_settings(true);
386 }
387 wp_send_json_success($payload);
388 }
389
390 /**
391 * Behavior-gate settings the widget may re-fetch at runtime (plan-32db95).
392 *
393 * Every widget setting ships inline in page HTML via wp_localize_script, so
394 * full-page caches (host caches, WP Rocket, LiteSpeed, W3TC, FlyingPress,
395 * WP Super Cache, Cloudflare APO, the browser itself) keep serving a stale
396 * snapshot after an admin changes a setting. MxChat_Cache_Purge clears the
397 * caches PHP can reach; this payload covers the rest — the widget requests
398 * it on first open (via the nonce-refresh endpoints) and merges it over
399 * `mxchatChat`, the same distrust-cached-HTML pattern the 3.2.7 per-request
400 * nonce uses.
401 *
402 * Behavior gates + labels ONLY — colors stay inline because they're also
403 * server-inline-styled, and a runtime swap would visibly flash.
404 *
405 * Both wp_localize_script blocks merge this exact array, so the inline and
406 * refreshed payloads cannot drift.
407 *
408 * @param bool $fresh Re-read mxchat_options from the DB (endpoint paths)
409 * instead of trusting the instance copy.
410 * @return array
411 */
412 public function get_dynamic_widget_settings($fresh = false) {
413 $options = $fresh ? get_option('mxchat_options', array()) : $this->options;
414 if (!is_array($options)) {
415 $options = array();
416 }
417 return array(
418 'model' => isset($options['model']) ? $options['model'] : 'gpt-5.6-sol',
419 'enable_streaming_toggle' => isset($options['enable_streaming_toggle']) ? $options['enable_streaming_toggle'] : 'on',
420 'rate_limit_message' => $options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
421 'chat_toolbar_toggle' => $options['chat_toolbar_toggle'] ?? 'off',
422 'print_button_enabled' => $options['print_button_enabled'] ?? 'on',
423 'print_button_label' => esc_html__('Download Transcript', 'mxchat'),
424 // "Start new chat" header-menu item (plan ac2e81). Default OFF.
425 'reset_chat_enabled' => $options['reset_chat_enabled'] ?? 'off',
426 'reset_chat_label' => !empty($options['reset_chat_label']) ? esc_html($options['reset_chat_label']) : esc_html__('Start new chat', 'mxchat'),
427 'reset_chat_confirm' => esc_html__('Start a new chat? This clears the current conversation.', 'mxchat'),
428 'stop_button_label' => esc_html__('Stop response', 'mxchat'),
429 'print_header_title' => esc_html(get_bloginfo('name')) . ' — ' . esc_html__('Chat transcript', 'mxchat'),
430 // Emit 'on'/'off' STRINGS, never booleans: wp_localize_script casts
431 // scalars to string, and (string) false === '' — which the widget's
432 // old gate read as enabled (plan-4bba64). The filter keeps its
433 // boolean contract; only the emitted value is stringified.
434 'satisfaction_rating_enabled' => apply_filters(
435 'mxchat_satisfaction_rating_enabled',
436 ($options['satisfaction_rating_enabled'] ?? 'off') === 'on'
437 ) ? 'on' : 'off',
438 'satisfaction_rating_idle_seconds' => max(5, min(600, intval($options['satisfaction_rating_idle_seconds'] ?? 60))),
439 'satisfaction_rating_copy' => array(
440 'question' => !empty($options['satisfaction_rating_question']) ? esc_html($options['satisfaction_rating_question']) : esc_html__('Was this helpful?', 'mxchat'),
441 'helpful' => esc_html__('Helpful', 'mxchat'),
442 'not_helpful' => esc_html__('Not helpful', 'mxchat'),
443 'dismiss' => esc_html__('Dismiss', 'mxchat'),
444 'thanks' => !empty($options['satisfaction_rating_thanks']) ? esc_html($options['satisfaction_rating_thanks']) : esc_html__('Thanks! Anything we should improve? (optional)', 'mxchat'),
445 'placeholder' => !empty($options['satisfaction_rating_placeholder']) ? esc_html($options['satisfaction_rating_placeholder']) : esc_html__('Tell us what could be better…', 'mxchat'),
446 'send' => esc_html__('Send', 'mxchat'),
447 'skip' => esc_html__('Skip', 'mxchat'),
448 'saved' => !empty($options['satisfaction_rating_saved']) ? esc_html($options['satisfaction_rating_saved']) : esc_html__('Thanks for the feedback.', 'mxchat'),
449 ),
450 );
451 }
452
453 // In your core plugin's check_actions_for_addons method:
454 public function check_actions_for_addons($default, $message, $user_id, $session_id) {
455 //error_log('MxChat Core: check_actions_for_addons called with message: ' . $message);
456
457 $result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
458
459 //error_log('MxChat Core: Intent check result = ' . ($result === false ? 'false' : 'true'));
460
461 return $result;
462 }
463
464 private function mxchat_increment_chat_count() {
465 $chat_count = get_option('mxchat_chat_count', 0);
466 $chat_count++;
467 update_option('mxchat_chat_count', $chat_count);
468 }
469
470 function mxchat_fetch_conversation_history() {
471 if (empty($_POST['session_id'])) {
472 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
473 wp_die();
474 }
475
476 $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
477
478 // SECURITY FIX: Verify session ownership before retrieving data
479 // If IP/user changed, signal frontend to reset session instead of blocking
480 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
481
482 // Check if this session has an owner recorded
483 $session_owner = MxChat_Session_Store::get($session_id, 'owner');
484
485 // Update session owner if it changed (e.g. IP changed due to network switch)
486 // The session ID itself is the authentication — if the client has it, they own it
487 if (!$session_owner || $session_owner !== $current_user_identifier) {
488 MxChat_Session_Store::set($session_id, 'owner', $current_user_identifier);
489 }
490
491 $history = get_option("mxchat_history_{$session_id}", []); // Retrieve stored history
492 $chat_mode = MxChat_Session_Store::get($session_id, 'mode', 'ai'); // Get current chat mode
493
494 if (empty($history)) {
495 // Even if history is empty, return the chat mode
496 wp_send_json_success([
497 'conversation' => [],
498 'chat_mode' => $chat_mode
499 ]);
500 wp_die();
501 }
502
503 wp_send_json_success([
504 'conversation' => $history,
505 'chat_mode' => $chat_mode
506 ]);
507 wp_die();
508 }
509 private function mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp = 0) {
510 $history = get_option("mxchat_history_{$session_id}", []);
511
512 // Check persistence setting - when OFF, only include messages from current page load
513 $options = get_option('mxchat_options', []);
514 $persistence_enabled = isset($options['chat_persistence_toggle']) && $options['chat_persistence_toggle'] === 'on';
515
516 // Filter history when persistence is OFF to match what the user sees
517 if (!$persistence_enabled && $session_start_timestamp > 0) {
518 $history = array_filter($history, function($entry) use ($session_start_timestamp) {
519 // Include messages from this page load onwards
520 return isset($entry['timestamp']) && $entry['timestamp'] >= $session_start_timestamp;
521 });
522 // Re-index array after filtering
523 $history = array_values($history);
524 }
525
526 $formatted_history = [];
527
528 // Adjusted for code-heavy conversations
529 $max_tokens = 120000; // Context window size
530 $reserved_tokens = 5000; // Space for system prompts + current query
531 $current_token_count = 0;
532
533 // Allowed HTML tags for content sanitization
534 $allowed_tags = [
535 'pre' => ['class' => true],
536 'code' => ['class' => true],
537 'span' => ['class' => true],
538 'div' => ['class' => true],
539 'strong' => [],
540 'em' => []
541 ];
542
543 foreach (array_reverse($history) as $entry) {
544 // Preserve code blocks while sanitizing other HTML
545 $clean_content = wp_kses($entry['content'], $allowed_tags);
546
547 // Detect code blocks in content
548 $has_code = false;
549 // Replace the HTML check with:
550 // Allow messages that contain code blocks or are plain text
551 if (strpos($clean_content, '<pre') === false &&
552 strpos($clean_content, '<code') === false &&
553 $clean_content !== strip_tags($entry['content'])) {
554 continue;
555 }
556
557 // Skip entries that lost significant content during sanitization
558 if (!$has_code && $clean_content !== strip_tags($entry['content'])) {
559 continue;
560 }
561
562 // More accurate token estimation (1 token ≈ 4 characters)
563 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
564
565 // Check token budget with the new estimate
566 if (($current_token_count + $token_estimate + $reserved_tokens) > $max_tokens) {
567 // Try to fit partial content if it's the first entry
568 if (empty($formatted_history)) {
569 $clean_content = mb_substr($clean_content, 0, ($max_tokens - $reserved_tokens) * 4);
570 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
571 } else {
572 break;
573 }
574 }
575
576 // Add to formatted history
577 $formatted_history[] = [
578 'role' => $entry['role'],
579 'content' => $clean_content
580 ];
581
582 $current_token_count += $token_estimate;
583 }
584
585 // Reverse back to maintain chronological order
586 $formatted_history = array_reverse($formatted_history);
587
588 // Add system message about code context
589 array_unshift($formatted_history, [
590 'role' => 'system',
591 'content' => 'Preserved code blocks are marked with [CODE BLOCK PRESERVED]. '
592 . 'Maintain formatting and syntax highlighting when referencing code.'
593 ]);
594
595 return $formatted_history;
596 }
597
598 public function register_routes() {
599 //error_log(esc_html__('Registering MxChat REST routes', 'mxchat'));
600
601 // Per-request chat-send nonce endpoint — issues a fresh nonce on demand
602 // so the chat widget never depends on a stale nonce embedded in cached HTML.
603 // Public (no auth), rate-limited (1 call / IP / second via a transient).
604 register_rest_route('mxchat/v1', '/nonce', [
605 'methods' => 'GET',
606 'callback' => [$this, 'mxchat_issue_chat_send_nonce'],
607 'permission_callback' => '__return_true',
608 ]);
609
610 register_rest_route('mxchat/v1', '/stream', [
611 'methods' => 'GET',
612 'callback' => [$this, 'mxchat_stream_events'],
613 'permission_callback' => [$this, 'verify_chat_session'],
614 ]);
615
616 register_rest_route('mxchat/v1', '/agent-response', [
617 'methods' => 'POST',
618 'callback' => [$this, 'mxchat_handle_agent_response'],
619 'permission_callback' => [$this, 'verify_slack_request'],
620 ]);
621
622 register_rest_route('mxchat/v1', '/slack-interaction', [
623 'methods' => 'POST',
624 'callback' => [$this, 'handle_slack_interaction'],
625 'permission_callback' => [$this, 'verify_slack_request'],
626 ]);
627
628 register_rest_route('mxchat/v1', '/slack-messages', [
629 'methods' => 'POST',
630 'callback' => [$this, 'handle_slack_messages'],
631 'permission_callback' => [$this, 'verify_slack_request'],
632 ]);
633
634 // Telegram webhook endpoint
635 register_rest_route('mxchat/v1', '/telegram-webhook', [
636 'methods' => 'POST',
637 'callback' => [$this, 'handle_telegram_webhook'],
638 'permission_callback' => [$this, 'verify_telegram_request'],
639 ]);
640
641 //error_log(esc_html__('MxChat REST routes registered', 'mxchat'));
642 }
643
644 /**
645 * Issue a fresh per-request nonce for chat-send. Returned to the widget which
646 * caches it for the session and includes it on every chat-send / stream-send /
647 * upload call. By moving the nonce out of inline `window.mxchatChat = {...}` HTML
648 * we eliminate the entire class of "first-message Access denied" failures that
649 * plague WP installs behind a full-page cache (WP Rocket, LiteSpeed, FlyingPress,
650 * W3 Total Cache, Cloudflare APO) — the nonce is never cached because it never
651 * lives in the HTML body.
652 *
653 * Public endpoint. Rate-limited to 1 call / IP / 1s via a transient so a single
654 * client browser can't be used to flood the nonce-issuance path.
655 *
656 * Nonce action: `mxchat_chat_send` (new). The chat-send AJAX handlers accept
657 * BOTH this action AND the legacy `mxchat_chat_nonce` action for a 30-day
658 * backwards-compat window so cached pages still in users' browsers don't break
659 * mid-session.
660 *
661 * @since 3.2.7
662 */
663 public function mxchat_issue_chat_send_nonce(WP_REST_Request $request) {
664 $ip = '';
665 if (!empty($_SERVER['REMOTE_ADDR'])) {
666 $ip = preg_replace('#[^0-9a-fA-F:\.]#', '', wp_unslash((string) $_SERVER['REMOTE_ADDR']));
667 }
668 if ($ip !== '') {
669 // Best-effort rate limit. WP transients with sub-second TTL are racy
670 // (parallel bursts can squeak through before set_transient completes);
671 // we use 2s to make the gate slightly more reliable. Real production
672 // rate-limiting at sub-second granularity needs Redis or DB row locks
673 // — out of scope for this endpoint, which is already cheap.
674 $key = 'mxchat_nonce_rl_' . md5($ip);
675 if (get_transient($key)) {
676 return new WP_REST_Response(array(
677 'error' => 'rate_limited',
678 'message' => __('Too many nonce requests. Try again shortly.', 'mxchat'),
679 ), 429);
680 }
681 set_transient($key, 1, 2);
682 }
683
684 // The widget calls this endpoint without an X-WP-Nonce header, so WordPress does not
685 // honor the auth cookie and the request runs as uid=0 even for logged-in users. That
686 // makes wp_create_nonce() bind the nonce to uid=0, which then fails wp_verify_nonce()
687 // at admin-ajax (which runs as the real uid) -> logged-in users get a 403 on upload.
688 // Resolve the real user from the logged_in cookie so the nonce binds to the correct uid.
689 if ( ! is_user_logged_in() ) {
690 $maybe_uid = wp_validate_auth_cookie( '', 'logged_in' );
691 if ( $maybe_uid ) {
692 wp_set_current_user( $maybe_uid );
693 }
694 }
695
696 $payload = array(
697 'nonce' => wp_create_nonce('mxchat_chat_send'),
698 'expires_in' => 86400, // WP nonces live 24h; widget caches for 12h conservatively.
699 );
700
701 // plan-32db95: the widget's first-open refresh asks for current behavior
702 // settings in the same round-trip, so stale inline-localized values on
703 // cached pages get corrected without a second request. All values in
704 // this payload already ship in public page HTML — nothing sensitive.
705 if ($request->get_param('with_settings')) {
706 $payload['settings'] = $this->get_dynamic_widget_settings(true);
707 }
708
709 return new WP_REST_Response($payload, 200);
710 }
711
712 /**
713 * Verify a chat-send nonce. Accepts BOTH the new `mxchat_chat_send` action
714 * (issued by /wp-json/mxchat/v1/nonce) AND the legacy `mxchat_chat_nonce`
715 * action (inline-localized in older cached HTML). The legacy acceptance is
716 * a 30-day backwards-compat window — to be removed in a follow-up release
717 * after 2026-06-27.
718 *
719 * @param string $posted_nonce
720 * @return bool
721 */
722 public static function mxchat_verify_chat_send_nonce($posted_nonce) {
723 if (!is_string($posted_nonce) || $posted_nonce === '') {
724 return false;
725 }
726 return (bool) wp_verify_nonce($posted_nonce, 'mxchat_chat_send')
727 || (bool) wp_verify_nonce($posted_nonce, 'mxchat_chat_nonce');
728 }
729
730 /**
731 * Verify valid chat session
732 */
733 public function verify_chat_session($request) {
734 $session_id = $request->get_param('session_id');
735 if (empty($session_id)) {
736 //error_log(esc_html__('Empty session ID in chat request', 'mxchat'));
737 return false;
738 }
739
740 $chat_mode = MxChat_Session_Store::get($session_id, 'mode', 'ai');
741 return $chat_mode === 'agent';
742 }
743
744 /**
745 * Verify request is coming from Slack.
746 *
747 * @param WP_REST_Request $request
748 * @return bool True if valid, false otherwise.
749 */
750 public function verify_slack_request($request) {
751 // Get the Slack signing secret from your plugin options
752 $valid_key = $this->options['live_agent_secret_key'] ?? '';
753
754 if (empty($valid_key)) {
755 //error_log(esc_html__('Slack signing secret not configured', 'mxchat'));
756 return false;
757 }
758
759 $timestamp = $request->get_header('X-Slack-Request-Timestamp');
760 $slack_signature = $request->get_header('X-Slack-Signature');
761
762 // Verify timestamp to prevent replay attacks
763 if (abs(time() - intval($timestamp)) > 300) {
764 //error_log(esc_html__('Slack request timestamp too old', 'mxchat'));
765 return false;
766 }
767
768 // Get raw request body from the WP_REST_Request object
769 // (php://input may already be consumed by WordPress at this point)
770 $request_body = $request->get_body();
771
772 // Create the signature base string
773 $sig_basestring = "v0:{$timestamp}:{$request_body}";
774
775 // Calculate expected signature
776 $my_signature = 'v0=' . hash_hmac('sha256', $sig_basestring, $valid_key);
777
778 // Compare signatures
779 return hash_equals($my_signature, $slack_signature);
780 }
781
782 /**
783 * Verify request is coming from Telegram.
784 *
785 * @param WP_REST_Request $request
786 * @return bool True if valid, false otherwise.
787 */
788 public function verify_telegram_request($request) {
789 $secret_token = $this->options['telegram_webhook_secret'] ?? '';
790
791 //error_log('[MxChat Telegram DEBUG] verify_telegram_request called');
792 //error_log('[MxChat Telegram DEBUG] Stored secret: ' . (empty($secret_token) ? 'EMPTY' : substr($secret_token, 0, 10) . '...'));
793
794 if (empty($secret_token)) {
795 // No secret configured (legacy setup). Do NOT fail open to the whole
796 // internet — that lets an unauthenticated caller write agent-branded
797 // messages. Fall back to verifying the request originates from
798 // Telegram's published webhook IP ranges so existing no-secret installs
799 // keep working while an arbitrary-internet caller is blocked. Setting a
800 // real secret (see the admin notice) is the recommended path.
801 // (plan-0c17b5)
802 $peer = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '';
803 if ($this->mxchat_ip_in_telegram_ranges($peer)) {
804 return true;
805 }
806 error_log('MxChat: Telegram webhook has no secret configured and the request '
807 . 'is not from a Telegram IP range; rejected. Set a webhook secret to secure it.');
808 return false;
809 }
810
811 // Telegram sends the secret token in the X-Telegram-Bot-Api-Secret-Token header
812 $request_token = $request->get_header('X-Telegram-Bot-Api-Secret-Token');
813
814 //error_log('[MxChat Telegram DEBUG] Request token: ' . (empty($request_token) ? 'EMPTY' : substr($request_token, 0, 10) . '...'));
815
816 if (empty($request_token)) {
817 //error_log('[MxChat Telegram DEBUG] Request rejected: No token in header');
818 return false;
819 }
820
821 // Timing-safe comparison
822 $result = hash_equals($secret_token, $request_token);
823 //error_log('[MxChat Telegram DEBUG] Token comparison result: ' . ($result ? 'MATCH' : 'MISMATCH'));
824 return $result;
825 }
826
827 /**
828 * Whether $ip falls within Telegram's published webhook IPv4 ranges
829 * (149.154.160.0/20 and 91.108.4.0/22). Used as an authenticity fallback for
830 * the Telegram webhook when no secret token is configured, so a legacy
831 * no-secret install keeps working without failing open to the entire internet.
832 *
833 * Uses the real TCP peer (REMOTE_ADDR); a spoofable X-Forwarded-For is NOT
834 * consulted. Behind a reverse proxy / CDN that rewrites REMOTE_ADDR this may
835 * not match — which is exactly why configuring a real webhook secret is the
836 * recommended path. (plan-0c17b5)
837 *
838 * @param string $ip Candidate IPv4 address.
839 * @return bool
840 */
841 private function mxchat_ip_in_telegram_ranges($ip) {
842 if (!is_string($ip) || $ip === '' || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
843 return false;
844 }
845 $ip_long = ip2long($ip);
846 if ($ip_long === false) {
847 return false;
848 }
849 $ranges = array(
850 array('149.154.160.0', 20),
851 array('91.108.4.0', 22),
852 );
853 foreach ($ranges as $range) {
854 $subnet_long = ip2long($range[0]);
855 if ($subnet_long === false) {
856 continue;
857 }
858 $mask = (0xFFFFFFFF << (32 - $range[1])) & 0xFFFFFFFF;
859 if (($ip_long & $mask) === ($subnet_long & $mask)) {
860 return true;
861 }
862 }
863 return false;
864 }
865
866 public function mxchat_stream_events(WP_REST_Request $request) {
867 header('Content-Type: text/event-stream');
868 header('Cache-Control: no-cache');
869 header('Connection: keep-alive');
870
871 $session_id = MxChat_Utils::sanitize_session_id($request->get_param('session_id'));
872 $last_seen_id = sanitize_text_field($request->get_param('last_seen_id')) ?: '';
873
874 if (empty($session_id)) {
875 echo esc_html__("event: error\ndata: ", 'mxchat') . esc_html__('Missing session_id', 'mxchat') . "\n\n";
876 flush();
877 exit;
878 }
879
880 $history = get_option("mxchat_history_{$session_id}", []);
881
882 // Filter only new messages
883 $new_messages = array_filter($history, function ($message) use ($last_seen_id) {
884 return !empty($message['id']) && $message['id'] > $last_seen_id;
885 });
886
887 // Send new messages if available
888 if (!empty($new_messages)) {
889 echo esc_html__("event: newMessages\ndata: ", 'mxchat') . json_encode(array_values($new_messages)) . "\n\n";
890 } else {
891 // Keep the connection alive
892 echo esc_html__("event: keepAlive\ndata: ", 'mxchat') . "{}\n\n";
893 }
894 flush();
895 exit;
896 }
897
898
899
900
901 private function mxchat_save_chat_message($session_id, $role, $message, $originating_page = null, $rag_context = null) {
902 global $wpdb;
903 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
904 //error_log("[DEBUG] mxchat_save_chat_message -> START for session_id: {$session_id}, role: {$role}");
905
906 // Check if this is the first message in a new session (before any other database operations)
907 $is_new_session = false;
908 if ($role === 'user') { // Only check for user messages, not bot responses
909 $existing_messages = $wpdb->get_var($wpdb->prepare(
910 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
911 $session_id
912 ));
913 $is_new_session = ($existing_messages == 0);
914
915 // Log for debugging
916 if ($is_new_session) {
917 //error_log("[DEBUG] This is a NEW session - first message");
918 }
919 }
920
921 // SECURITY FIX: Set session ownership for new sessions
922 if ($is_new_session && $role === 'user') {
923 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
924
925 // Only set ownership if not already set
926 if (!MxChat_Session_Store::get($session_id, 'owner')) {
927 MxChat_Session_Store::set($session_id, 'owner', $current_user_identifier);
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 MxChat_Session_Store::set($session_id, 'originating_page', [
1064 'url' => $insert_data['originating_page_url'],
1065 'title' => $insert_data['originating_page_title']
1066 ]);
1067 }
1068 } else {
1069 // For subsequent messages in the session, use the stored originating page
1070 $stored_originating = MxChat_Session_Store::get($session_id, 'originating_page');
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 = MxChat_Session_Store::get($session_id, 'owner');
1649
1650 if (!$session_owner || $session_owner !== $current_user_identifier) {
1651 MxChat_Session_Store::set($session_id, 'owner', $current_user_identifier);
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 = MxChat_Session_Store::get($session_id, 'mode', '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 MxChat_Session_Store::set($session_id, 'mode', '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 // Surface the embedding provider's reason when that is why zero
2032 // pages came back, rather than blaming the file (104a75).
2033 $this->fallbackResponse['text'] = $this->mxchat_pdf_error_text_with_reason($error_text);
2034 }
2035
2036 $pdf_error_response = [
2037 'success' => false,
2038 'message' => $this->fallbackResponse['text']
2039 ];
2040
2041 if ($testing_data !== null) {
2042 $pdf_error_response['testing_data'] = $testing_data;
2043 }
2044
2045 wp_send_json($pdf_error_response);
2046 wp_die();
2047 }
2048 }
2049 }
2050
2051
2052 // Step 2: Detect intent and handle intent-based responses
2053 $intent_result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
2054
2055 // Capture action analysis for testing panel after intent check
2056 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
2057 $testing_data['action_matches'] = $this->last_action_analysis;
2058 }
2059
2060 // Step 3: Handle the intent result appropriately
2061 if ($intent_result !== false) {
2062 // Intent was matched - ALWAYS send as JSON response, never streaming
2063
2064 if (is_array($intent_result) && (isset($intent_result['text']) || isset($intent_result['html']))) {
2065 // Intent returned a direct response array
2066 $response_data = [
2067 'text' => $intent_result['text'] ?? '',
2068 'html' => $intent_result['html'] ?? '',
2069 'session_id' => $session_id
2070 ];
2071
2072 // IMPORTANT: Include chat_mode if present (for WhatsApp, Slack, etc.)
2073 if (isset($intent_result['chat_mode'])) {
2074 $response_data['chat_mode'] = $intent_result['chat_mode'];
2075 }
2076
2077 if ($testing_data !== null) {
2078 $response_data['testing_data'] = $testing_data;
2079 }
2080
2081 wp_send_json($response_data);
2082 wp_die();
2083 } else if ($intent_result === true && (!empty($this->fallbackResponse['text']) || !empty($this->fallbackResponse['html']))) {
2084 // Intent returned true and set fallbackResponse
2085
2086 // SAVE TO TRANSCRIPT
2087 if (!empty($this->fallbackResponse['text'])) {
2088 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
2089 }
2090 // Save action HTML (product cards, featured products, etc.) so it renders in transcripts
2091 if (!empty($this->fallbackResponse['html'])) {
2092 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
2093 }
2094
2095 $response_data = [
2096 'text' => $this->fallbackResponse['text'] ?? '',
2097 'html' => $this->fallbackResponse['html'] ?? '',
2098 'session_id' => $session_id
2099 ];
2100
2101 if (isset($this->fallbackResponse['chat_mode'])) {
2102 $response_data['chat_mode'] = $this->fallbackResponse['chat_mode'];
2103 }
2104
2105 if ($testing_data !== null) {
2106 $response_data['testing_data'] = $testing_data;
2107 }
2108
2109 wp_send_json($response_data);
2110 wp_die();
2111 }
2112 }
2113
2114 // If we get here, no intent matched OR the intent didn't provide a usable response
2115
2116 // Step 4: Generate AI response
2117 // Get session start timestamp - when persistence is OFF, only include messages from this page load
2118 $session_start_timestamp = isset($_POST['session_start_timestamp']) ? intval($_POST['session_start_timestamp']) : 0;
2119 $conversation_history = $this->mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp);
2120 $this->mxchat_increment_chat_count();
2121
2122 // Generate embedding for the user's query - USE BOT-SPECIFIC API KEY
2123 $api_key = $current_options['api_key'] ?? $this->options['api_key'];
2124 $user_message_embedding = $this->mxchat_generate_embedding($message, $api_key);
2125
2126 // Check if the embedding generation returned an error
2127 if (is_array($user_message_embedding) && isset($user_message_embedding['error'])) {
2128 $error_message = $user_message_embedding['error'];
2129 $error_code = $user_message_embedding['error_code'] ?? 'embedding_error';
2130
2131 // FIXED: Send error in appropriate format based on streaming mode
2132 if ($is_streaming) {
2133 echo "data: " . json_encode([
2134 'error' => true,
2135 'error_message' => $error_message,
2136 'error_code' => $error_code,
2137 'text' => $error_message,
2138 'message' => $error_message
2139 ]) . "\n\n";
2140 echo "data: [DONE]\n\n";
2141 flush();
2142 } else {
2143 wp_send_json_error([
2144 'error_message' => $error_message,
2145 'error_code' => $error_code
2146 ]);
2147 }
2148 wp_die();
2149 }
2150
2151 // Check if the embedding is valid
2152 if (!is_array($user_message_embedding) || empty($user_message_embedding)) {
2153 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
2154
2155 // FIXED: Send error in appropriate format based on streaming mode
2156 if ($is_streaming) {
2157 echo "data: " . json_encode([
2158 'error' => true,
2159 'error_message' => $error_message,
2160 'error_code' => 'invalid_embedding',
2161 'text' => $error_message,
2162 'message' => $error_message
2163 ]) . "\n\n";
2164 echo "data: [DONE]\n\n";
2165 flush();
2166 } else {
2167 wp_send_json_error([
2168 'error_message' => $error_message,
2169 'error_code' => 'invalid_embedding'
2170 ]);
2171 }
2172 wp_die();
2173 }
2174
2175 // Build context with both knowledge base and PDF content if available
2176 $context_content = "User asked: '{$message}'\n\n";
2177
2178 // Add action instruction if present (add this right after the above line)
2179 if (!empty($this->current_action_instruction)) {
2180 $context_content .= "===== SPECIAL INSTRUCTION =====\n";
2181 $context_content .= "IMPORTANT: " . $this->current_action_instruction . "\n";
2182 $context_content .= "Respond naturally and conversationally while following this instruction.\n";
2183 $context_content .= "===== END SPECIAL INSTRUCTION =====\n\n";
2184
2185 // Clear the instruction after using it
2186 $this->current_action_instruction = null;
2187 }
2188
2189
2190 // Add page context if available and contextual awareness is enabled using current_options
2191 if ($page_context && isset($current_options['contextual_awareness_toggle']) && $current_options['contextual_awareness_toggle'] === 'on') {
2192 $context_content .= "===== CURRENT PAGE CONTEXT =====\n";
2193 $context_content .= "Page URL: " . $page_context['url'] . "\n";
2194 $context_content .= "Page Title: " . $page_context['title'] . "\n";
2195 $context_content .= "Page Content: " . $page_context['content'] . "\n";
2196 $context_content .= "===== END CURRENT PAGE CONTEXT =====\n\n";
2197 }
2198
2199 // Get relevant content from knowledge base - PASS BOT_ID and MESSAGE for Vector Store
2200 $relevant_content = $this->mxchat_find_relevant_content($user_message_embedding, $bot_id, $message);
2201
2202 // NEW: Also extract URLs from system instructions (only if citation links enabled)
2203 // Use fresh options to ensure we get the latest setting value
2204 $fresh_options = get_option('mxchat_options', []);
2205 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
2206
2207 $system_instructions = $this->get_system_instructions($bot_id, $session_id);
2208 if ($citation_links_enabled && !empty($system_instructions)) {
2209 preg_match_all(
2210 '#\bhttps?://[^\s<>"\']+#i',
2211 $system_instructions,
2212 $system_instruction_urls
2213 );
2214
2215 if (!empty($system_instruction_urls[0])) {
2216 // Merge with existing valid URLs
2217 $this->current_valid_urls = array_merge(
2218 $this->current_valid_urls,
2219 $system_instruction_urls[0]
2220 );
2221 // Remove duplicates
2222 $this->current_valid_urls = array_unique($this->current_valid_urls);
2223
2224 //error_log("Added " . count($system_instruction_urls[0]) . " URLs from system instructions");
2225 }
2226 }
2227
2228 // ===== CAPTURE REAL SIMILARITY DATA FOR ADMINS =====
2229 if ($testing_data !== null && $this->last_similarity_analysis !== null) {
2230 // Update testing data with the REAL similarity analysis
2231 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
2232 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
2233 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
2234 $testing_data['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
2235 $testing_data['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
2236 }
2237 // ===== END SIMILARITY DATA CAPTURE =====
2238
2239 // NEW: Add valid URLs to testing data for admin panel display (AFTER similarity data)
2240 if ($testing_data !== null && !empty($this->current_valid_urls)) {
2241 $testing_data['approved_urls'] = array_values($this->current_valid_urls);
2242 //error_log("Added " . count($this->current_valid_urls) . " approved URLs to testing data");
2243 }
2244
2245 $kb_block = !empty($relevant_content)
2246 ? "===== OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n" . $relevant_content . "\n===== END OF OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n\n"
2247 : "===== NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE =====\n";
2248
2249 // {context} placeholder (plan 59bc1b): when the resolved instructions
2250 // carry the token, the KB block is injected at that spot by
2251 // get_system_instructions() (every provider handler re-calls it) and is
2252 // NOT appended here — otherwise the block would ride twice.
2253 // $system_instructions above was resolved while context_kb_block was
2254 // still null, so the literal token is still visible for this check.
2255 if (!empty($system_instructions) && stripos($system_instructions, '{context}') !== false) {
2256 $this->context_kb_block = $kb_block;
2257 } else {
2258 $context_content .= $kb_block;
2259 }
2260
2261 // NEW: Add approved URLs list to context for AI (only if citation links enabled)
2262 if ($citation_links_enabled && !empty($this->current_valid_urls)) {
2263 $context_content .= "===== APPROVED URLS FOR CITATIONS =====\n";
2264 $context_content .= "You may ONLY use these exact URLs in your response:\n";
2265 foreach ($this->current_valid_urls as $url) {
2266 $context_content .= "- " . $url . "\n";
2267 }
2268 $context_content .= "\nCRITICAL: Do NOT create, modify, extend, or invent any other URLs. ";
2269 $context_content .= "===== END APPROVED URLS =====\n\n";
2270 }
2271
2272 // Check for and include PDF content
2273 $pdf_url = get_transient('mxchat_pdf_url_' . $session_id);
2274 $pdf_embeddings = get_transient('mxchat_pdf_embeddings_' . $session_id);
2275 $pdf_filename = get_transient('mxchat_pdf_filename_' . $session_id);
2276 if ($pdf_url && $pdf_embeddings && get_transient('mxchat_include_pdf_in_context_' . $session_id)) {
2277 $relevant_pdf_pages = $this->find_relevant_pdf_pages($user_message_embedding, $pdf_embeddings);
2278 if (!empty($relevant_pdf_pages)) {
2279 $context_content .= "Relevant content from PDF document '{$pdf_filename}':\n";
2280 foreach ($relevant_pdf_pages as $page_data) {
2281 $context_content .= "Page {$page_data['page_number']} of '{$pdf_filename}': {$page_data['text']}\n";
2282 }
2283 $context_content .= "\n";
2284 }
2285 }
2286
2287 // Check for and include Word content
2288 $word_url = get_transient('mxchat_word_url_' . $session_id);
2289 $word_embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
2290 $word_filename = get_transient('mxchat_word_filename_' . $session_id);
2291 if ($word_url && $word_embeddings && get_transient('mxchat_include_word_in_context_' . $session_id)) {
2292 $relevant_word_chunks = $this->word_handler->mxchat_find_relevant_word_chunks($user_message_embedding, $word_embeddings);
2293 if (!empty($relevant_word_chunks)) {
2294 $context_content .= "Relevant content from Word document '{$word_filename}':\n";
2295 foreach ($relevant_word_chunks as $chunk_data) {
2296 $context_content .= "Section {$chunk_data['chunk_number']} of '{$word_filename}': {$chunk_data['text']}\n";
2297 }
2298 $context_content .= "\n";
2299 }
2300 }
2301
2302 $context_content = apply_filters('mxchat_prepare_context', $context_content, $session_id);
2303
2304 // Extract model from current options for bot-specific model support
2305 $selected_model = isset($current_options['model']) ? $current_options['model'] : 'gpt-5.6-sol';
2306
2307 // ===== Native function-calling fallback (plan-mxchat-20260617-a41dee) =====
2308 // Intents already missed (we're past the intent router). If function
2309 // calling is enabled and the active model is tool-capable, let the model
2310 // SELECT and run registered callbacks as tools — independent of intents,
2311 // works with zero Actions. The tool round is buffered; the final answer is
2312 // emitted via the SAME envelopes the normal path uses. Default-off, so
2313 // existing installs never enter this branch.
2314 if ($this->mxchat_fc_should_run($selected_model)) {
2315 $fc_outcome = $this->mxchat_fc_attempt(
2316 $message,
2317 $context_content,
2318 $conversation_history,
2319 $selected_model,
2320 $current_options,
2321 $session_id,
2322 $user_id
2323 );
2324 if (is_array($fc_outcome) && !empty($fc_outcome['handled'])) {
2325 $fc_text = isset($fc_outcome['text']) ? $fc_outcome['text'] : '';
2326 if (!empty($this->current_valid_urls)) {
2327 $fc_text = $this->validate_and_clean_urls($fc_text, $this->current_valid_urls, $session_id, $bot_id);
2328 }
2329 // plan-mxchat-20260617-48a57a — surface any UI element a tool
2330 // produced (generated image / product card / image gallery) so the
2331 // widget RENDERS it, instead of emitting only the model's text.
2332 // The html was already saved to the transcript in
2333 // mxchat_fc_execute_tool (or by the callback itself for self-saving
2334 // core tools), so we persist ONLY the model's caption text here.
2335 $fc_html = isset($this->fc_ui_html) ? $this->fc_ui_html : '';
2336
2337 if ($fc_text !== '') {
2338 $this->mxchat_save_chat_message($session_id, 'bot', $fc_text, null, null);
2339 }
2340
2341 // A video-backed KB source queued during retrieval (03ba33) must
2342 // surface on the FC path too — the FC envelopes below are the ONLY
2343 // exit for this turn, so append it to the html channel and persist
2344 // it (tool html was already saved in mxchat_fc_execute_tool; the
2345 // video embed has no other save point on this path).
2346 if (!empty($this->videoEmbedHtml)) {
2347 $fc_html .= $this->videoEmbedHtml;
2348 $this->mxchat_save_chat_message($session_id, 'bot', $this->videoEmbedHtml);
2349 }
2350
2351 if ($is_streaming) {
2352 // The frontend SSE reader routes any event carrying text/html
2353 // to handleNonStreamResponse(), which renders text + html in a
2354 // single bot message — so emit one complete event (mirrors the
2355 // intent path's text/html envelope).
2356 $sse = array('session_id' => $session_id);
2357 if ($fc_text !== '') $sse['text'] = $fc_text;
2358 if ($fc_html !== '') $sse['html'] = $fc_html;
2359 if ($fc_text === '' && $fc_html === '') $sse['text'] = $this->mxchat_fc_giveup_text();
2360 echo "data: " . wp_json_encode($sse) . "\n\n";
2361 echo "data: [DONE]\n\n";
2362 flush();
2363 } else {
2364 $fc_response_data = array('text' => $fc_text, 'html' => $fc_html, 'session_id' => $session_id);
2365 if ($testing_data !== null) {
2366 $fc_response_data['testing_data'] = $testing_data;
2367 }
2368 wp_send_json($fc_response_data);
2369 }
2370 wp_die();
2371 }
2372 }
2373 // ===== end function-calling fallback =====
2374
2375 // Streaming + a queued video embed (03ba33): the provider handlers own the
2376 // token stream and the [DONE] terminator, so the embed rides a dedicated
2377 // append_html SSE event emitted BEFORE the stream starts. The client
2378 // stashes it and appends it as its own bot bubble after [DONE] — old
2379 // cached widget JS simply ignores the unknown key (no content/text/html/
2380 // error field, so no branch matches). Transcript save happens after the
2381 // stream completes, so history order matches the live order (text, then
2382 // embed).
2383 if ($is_streaming && !empty($this->videoEmbedHtml)) {
2384 echo "data: " . wp_json_encode(array(
2385 'append_html' => $this->videoEmbedHtml,
2386 'session_id' => $session_id,
2387 )) . "\n\n";
2388 flush();
2389 }
2390
2391 $response = $this->mxchat_generate_response(
2392 $context_content,
2393 $current_options['api_key'] ?? $this->options['api_key'],
2394 $current_options['xai_api_key'] ?? $this->options['xai_api_key'],
2395 $current_options['claude_api_key'] ?? $this->options['claude_api_key'],
2396 $current_options['deepseek_api_key'] ?? $this->options['deepseek_api_key'],
2397 $current_options['gemini_api_key'] ?? $this->options['gemini_api_key'],
2398 $current_options['openrouter_api_key'] ?? $this->options['openrouter_api_key'],
2399 $conversation_history,
2400 $is_streaming,
2401 $session_id,
2402 $testing_data,
2403 $selected_model
2404 );
2405
2406 // Handle streaming vs non-streaming responses
2407 if ($is_streaming) {
2408 // Check if streaming actually happened or if it fell back to regular response
2409 if ($response === true) {
2410 // Persist the video embed AFTER the provider saved the streamed
2411 // text, so history replays in the same order the visitor saw
2412 // (text bubble, then embed bubble). See 03ba33.
2413 if (!empty($this->videoEmbedHtml)) {
2414 $this->mxchat_save_chat_message($session_id, 'bot', $this->videoEmbedHtml);
2415 }
2416 wp_die();
2417 }
2418 // If we get here, streaming fell back to regular response, continue
2419 // But if there's an error, we need to send it as SSE format since headers are already set
2420 if (is_array($response) && isset($response['error'])) {
2421 $error_message = $response['error'];
2422 $error_code = $response['error_code'] ?? 'api_error';
2423 // Send error in SSE format that the client JS can handle
2424 echo "data: " . json_encode([
2425 'error' => true,
2426 'error_message' => $error_message,
2427 'error_code' => $error_code,
2428 'text' => $error_message, // Also include as text for fallback handling
2429 'message' => $error_message
2430 ]) . "\n\n";
2431 echo "data: [DONE]\n\n";
2432 flush();
2433 wp_die();
2434 }
2435 }
2436
2437 // Check if the response is an error array (non-streaming mode)
2438 if (is_array($response) && isset($response['error'])) {
2439 wp_send_json_error([
2440 'error_message' => $response['error'],
2441 'error_code' => $response['error_code'] ?? 'api_error'
2442 ]);
2443 wp_die();
2444 }
2445
2446 // DEBUG: Check what we have
2447 //error_log("=== BEFORE URL VALIDATION ===");
2448 //error_log("current_valid_urls is empty? " . (empty($this->current_valid_urls) ? 'YES' : 'NO'));
2449 //error_log("current_valid_urls count: " . count($this->current_valid_urls));
2450 //error_log("current_valid_urls content: " . print_r($this->current_valid_urls, true));
2451
2452 // If we get here, the response is valid text - now validate URLs
2453 if (!empty($this->current_valid_urls)) {
2454 //error_log("CALLING validate_and_clean_urls");
2455 $response = $this->validate_and_clean_urls($response, $this->current_valid_urls, $session_id, $bot_id);
2456 } else {
2457 //error_log("SKIPPING validation - current_valid_urls is empty");
2458 }
2459 // ===== END URL VALIDATION =====
2460
2461 // Prepare RAG context data for storage (only include documents used for context)
2462 $rag_context_for_storage = null;
2463 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
2464 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
2465
2466 if ($has_rag_data || $has_action_data) {
2467 $rag_context_for_storage = [];
2468
2469 // Add RAG/source data if available
2470 if ($has_rag_data) {
2471 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
2472 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
2473 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
2474 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
2475 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
2476 $rag_context_for_storage['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
2477 $rag_context_for_storage['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
2478 }
2479
2480 // Add action analysis data if available
2481 if ($has_action_data) {
2482 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
2483 }
2484 }
2485
2486 // Save the cleaned response with RAG context
2487 $this->mxchat_save_chat_message($session_id, 'bot', $response, null, $rag_context_for_storage);
2488
2489 // Step 5: Save additional content if available
2490 if (!empty($this->productCardHtml)) {
2491 $this->mxchat_save_chat_message($session_id, 'bot', $this->productCardHtml);
2492 }
2493
2494 if (!empty($this->fallbackResponse['html'])) {
2495 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
2496 }
2497
2498 if (!empty($this->videoEmbedHtml)) {
2499 $this->mxchat_save_chat_message($session_id, 'bot', $this->videoEmbedHtml);
2500 }
2501
2502 // Step 6: Return the response
2503 // DEBUG: Check if newlines exist in the response
2504 //error_log("=== MXCHAT NON-STREAMING RESPONSE DEBUG ===");
2505 //error_log("Response has newlines: " . (strpos($response, "\n") !== false ? 'YES' : 'NO'));
2506 //error_log("Response first 500 chars: " . substr($response, 0, 500));
2507
2508 // Product cards and action html keep their existing either/or precedence;
2509 // a queued video embed (03ba33) is APPENDED so it can coexist with both.
2510 $additional_html = !empty($this->productCardHtml) ? $this->productCardHtml : ($this->fallbackResponse['html'] ?? '');
2511 if (!empty($this->videoEmbedHtml)) {
2512 $additional_html .= $this->videoEmbedHtml;
2513 }
2514
2515 $response_data = [
2516 'text' => $response,
2517 'html' => $additional_html,
2518 'session_id' => $session_id
2519 ];
2520
2521 // Include vectorstore error info for admin debugging (only visible to admins via testing_data)
2522 if (!empty($this->last_vectorstore_error) && $testing_data !== null) {
2523 $testing_data['vectorstore_error'] = $this->last_vectorstore_error;
2524 }
2525
2526 // Also pass it as a top-level field so JS can show a better error message to admins
2527 if (!empty($this->last_vectorstore_error) && current_user_can('manage_options')) {
2528 $response_data['vectorstore_error'] = $this->last_vectorstore_error;
2529 }
2530
2531 // Always add testing data for admins (no toggle needed)
2532 if ($testing_data !== null) {
2533 $response_data['testing_data'] = $testing_data;
2534 }
2535
2536 wp_send_json($response_data);
2537 wp_die();
2538 }
2539
2540 /**
2541 * Get bot-specific options for multi-bot functionality
2542 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
2543 */
2544 // Also debug the bot options retrieval
2545 private function get_bot_options($bot_id = 'default') {
2546 //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
2547
2548 // The admin Testing tab renders the real widget as bot_id "testing", which
2549 // is not a registered multi-bot. It must resolve the DEFAULT bot's config
2550 // so the Testing chat behaves exactly like the front-end (same precedent
2551 // as the Actions enabled_bots check).
2552 if ($bot_id === 'testing') {
2553 $bot_id = 'default';
2554 }
2555
2556 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2557 //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
2558 return array();
2559 }
2560
2561 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
2562
2563 if (!empty($bot_options)) {
2564 //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
2565 if (isset($bot_options['similarity_threshold'])) {
2566 //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
2567 }
2568 }
2569
2570 return is_array($bot_options) ? $bot_options : array();
2571 }
2572
2573 /**
2574 * Get bot-specific Pinecone configuration
2575 * Used in the knowledge retrieval functions
2576 */
2577 // Also add debugging to your get_bot_pinecone_config function
2578 private function get_bot_pinecone_config($bot_id = 'default') {
2579 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
2580
2581 // Admin Testing tab bot → resolve the DEFAULT bot's backend. Without this,
2582 // on a multi-bot + Pinecone site the filter below gets an unknown bot id
2583 // with an EMPTY default, returns array(), and the dispatcher silently
2584 // searches the WordPress DB while the front-end searches Pinecone — the
2585 // Testing panel then reports similarity results from a different KB.
2586 if ($bot_id === 'testing') {
2587 $bot_id = 'default';
2588 }
2589
2590 // If default bot or multi-bot add-on not active, use default Pinecone config
2591 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2592 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
2593 $addon_options = get_option('mxchat_pinecone_addon_options', array());
2594 $config = array(
2595 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
2596 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
2597 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
2598 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
2599 );
2600 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
2601 return $config;
2602 }
2603
2604 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
2605
2606 // Hook for multi-bot add-on to provide bot-specific Pinecone config
2607 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
2608
2609 if (!empty($bot_pinecone_config)) {
2610 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
2611 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
2612 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
2613 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
2614 } else {
2615 //error_log("MXCHAT DEBUG: Filter returned empty config!");
2616 }
2617
2618 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
2619 }
2620
2621
2622 // Updated function to check intents and invoke the callback function
2623 private function mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id) {
2624 global $wpdb;
2625 $chat_mode = MxChat_Session_Store::get($session_id, 'mode', 'ai');
2626
2627 // Get the current bot_id
2628 $current_bot_id = $this->get_current_bot_id($session_id);
2629
2630 // Generate the user embedding
2631 $user_embedding = $this->mxchat_generate_embedding($message, $this->options['api_key']);
2632
2633 // Check if embedding generation returned an error
2634 if (is_array($user_embedding) && isset($user_embedding['error'])) {
2635 $error_message = $user_embedding['error'];
2636 $error_code = $user_embedding['error_code'] ?? 'embedding_error';
2637
2638 // FIXED: Send error in appropriate format based on streaming mode
2639 if ($this->is_streaming) {
2640 echo "data: " . json_encode([
2641 'error' => true,
2642 'error_message' => $error_message,
2643 'error_code' => $error_code,
2644 'text' => $error_message,
2645 'message' => $error_message
2646 ]) . "\n\n";
2647 echo "data: [DONE]\n\n";
2648 flush();
2649 } else {
2650 wp_send_json_error([
2651 'error_message' => $error_message,
2652 'error_code' => $error_code
2653 ]);
2654 }
2655 wp_die();
2656 }
2657
2658 // Check if embedding is valid
2659 if (!is_array($user_embedding) || empty($user_embedding)) {
2660 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
2661
2662 // FIXED: Send error in appropriate format based on streaming mode
2663 if ($this->is_streaming) {
2664 echo "data: " . json_encode([
2665 'error' => true,
2666 'error_message' => $error_message,
2667 'error_code' => 'invalid_embedding',
2668 'text' => $error_message,
2669 'message' => $error_message
2670 ]) . "\n\n";
2671 echo "data: [DONE]\n\n";
2672 flush();
2673 } else {
2674 wp_send_json_error([
2675 'error_message' => $error_message,
2676 'error_code' => 'invalid_embedding'
2677 ]);
2678 }
2679 wp_die();
2680 }
2681
2682 // Fetch intents from the database
2683 $table_name = $wpdb->prefix . 'mxchat_intents';
2684 if ($chat_mode === 'agent') {
2685 $query = $wpdb->prepare(
2686 "SELECT * FROM $table_name WHERE callback_function = %s AND (enabled = 1 OR enabled IS NULL)",
2687 'mxchat_handle_switch_to_chatbot_intent'
2688 );
2689 $intents = $wpdb->get_results($query);
2690 } else {
2691 $intents = $wpdb->get_results("SELECT * FROM $table_name WHERE enabled = 1 OR enabled IS NULL");
2692 }
2693
2694 if (empty($intents)) {
2695 return false;
2696 }
2697
2698 // Prefetch individual phrase vectors from wp_mxchat_intent_phrases (grouped by intent_id)
2699 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
2700 $phrases_by_intent = [];
2701 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
2702 $all_phrases = $wpdb->get_results("SELECT intent_id, phrase, embedding_vector FROM $phrases_table");
2703 foreach ($all_phrases as $p) {
2704 $phrases_by_intent[$p->intent_id][] = $p;
2705 }
2706 }
2707
2708 $highest_similarity = -INF;
2709 $matched_intent = null;
2710
2711 // Array to store action analysis for testing panel
2712 $action_analysis = [];
2713
2714 foreach ($intents as $intent) {
2715 // Additional check for enabled state
2716 $is_enabled = isset($intent->enabled) ? (bool)$intent->enabled : true;
2717 if (!$is_enabled) {
2718 continue;
2719 }
2720
2721 // Check if this action is enabled for the current bot
2722 if (!$this->is_action_enabled_for_bot($intent, $current_bot_id)) {
2723 continue;
2724 }
2725
2726 $best_similarity = -INF;
2727 $matched_phrase_text = '';
2728
2729 // Check legacy embedding vector (existing behavior)
2730 $intent_embedding_serialized = $intent->embedding_vector;
2731 $intent_embedding = $intent_embedding_serialized
2732 ? unserialize($intent_embedding_serialized, ['allowed_classes' => false])
2733 : null;
2734
2735 if (is_array($intent_embedding) && !empty($intent_embedding)) {
2736 $legacy_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $intent_embedding);
2737 if ($legacy_similarity > $best_similarity) {
2738 $best_similarity = $legacy_similarity;
2739 $matched_phrase_text = 'legacy';
2740 }
2741 }
2742
2743 // Check individual phrase vectors
2744 if (isset($phrases_by_intent[$intent->id])) {
2745 foreach ($phrases_by_intent[$intent->id] as $phrase_row) {
2746 $phrase_embedding = $phrase_row->embedding_vector
2747 ? unserialize($phrase_row->embedding_vector, ['allowed_classes' => false])
2748 : null;
2749 if (!is_array($phrase_embedding)) {
2750 continue;
2751 }
2752 $phrase_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $phrase_embedding);
2753 if ($phrase_similarity > $best_similarity) {
2754 $best_similarity = $phrase_similarity;
2755 $matched_phrase_text = $phrase_row->phrase;
2756 }
2757 }
2758 }
2759
2760 // Skip if no valid embedding was found at all
2761 if ($best_similarity === -INF) {
2762 continue;
2763 }
2764
2765 $similarity = $best_similarity;
2766 $intent_threshold = isset($intent->similarity_threshold) ? $intent->similarity_threshold : 0.85;
2767
2768 // Store action analysis data for testing panel
2769 $action_analysis[] = [
2770 'intent_label' => $intent->intent_label,
2771 'callback_function' => $intent->callback_function,
2772 'similarity' => round($similarity, 4),
2773 'similarity_percentage' => round($similarity * 100, 2),
2774 'threshold' => $intent_threshold,
2775 'threshold_percentage' => round($intent_threshold * 100, 2),
2776 'above_threshold' => $similarity >= $intent_threshold,
2777 'matched_phrase' => $matched_phrase_text,
2778 'triggered' => false // Will be updated below if this intent is triggered
2779 ];
2780
2781 if ($similarity >= $intent_threshold && $similarity > $highest_similarity) {
2782 $highest_similarity = $similarity;
2783 $matched_intent = $intent;
2784 }
2785 }
2786
2787 // Mark the triggered action if any
2788 if ($matched_intent) {
2789 foreach ($action_analysis as &$action) {
2790 if ($action['intent_label'] === $matched_intent->intent_label) {
2791 $action['triggered'] = true;
2792 break;
2793 }
2794 }
2795 }
2796
2797 // Sort actions by similarity (highest first) and store for testing panel
2798 usort($action_analysis, function($a, $b) {
2799 return $b['similarity'] <=> $a['similarity'];
2800 });
2801
2802 // Store action analysis for testing panel capture
2803 $this->last_action_analysis = $action_analysis;
2804
2805 // Around line 715 in your mxchat_check_intent_and_invoke_callback function
2806 if ($matched_intent) {
2807 // If the callback is a method on this instance (core callback), call it directly
2808 if (method_exists($this, $matched_intent->callback_function)) {
2809 $callback_result = call_user_func(
2810 [$this, $matched_intent->callback_function],
2811 $message,
2812 $user_id,
2813 $session_id,
2814 $matched_intent,
2815 $user_context ?? null
2816 );
2817 } else {
2818 // Otherwise, use apply_filters for add-on callbacks
2819 $callback_result = apply_filters(
2820 $matched_intent->callback_function,
2821 false,
2822 $message,
2823 $user_id,
2824 $session_id,
2825 $matched_intent
2826 );
2827 }
2828
2829 // Handle the callback result properly
2830 if ($callback_result !== false) {
2831 // If callback returned an array with chat_mode, use it directly
2832 if (is_array($callback_result) && isset($callback_result['chat_mode'])) {
2833 $this->fallbackResponse = $callback_result;
2834 return $callback_result; // Return the full array
2835 } else {
2836 $this->fallbackResponse = $callback_result;
2837 return true;
2838 }
2839 }
2840 }
2841
2842 return false;
2843 }
2844
2845 /**
2846 * Check if an action is enabled for a specific bot
2847 */
2848 private function is_action_enabled_for_bot($intent, $bot_id) {
2849 // If enabled_bots column doesn't exist or is null, assume it's enabled for all bots (backward compatibility)
2850 if (!isset($intent->enabled_bots) || empty($intent->enabled_bots)) {
2851 return true;
2852 }
2853
2854 $enabled_bots = json_decode($intent->enabled_bots, true);
2855
2856 // If JSON decode fails or returns empty array, assume enabled for all (backward compatibility)
2857 if (!is_array($enabled_bots) || empty($enabled_bots)) {
2858 return true;
2859 }
2860
2861 // Admin testing tab uses bot_id "testing" — treat it as "default" so all
2862 // default-bot actions are testable from the admin panel
2863 if ($bot_id === 'testing') {
2864 $bot_id = 'default';
2865 }
2866
2867 // Check if the current bot is in the enabled bots list
2868 return in_array($bot_id, $enabled_bots);
2869 }
2870
2871 // Helper function to clear PDF and Word document related transients
2872 private function clear_pdf_transients($session_id) {
2873 // PDF transients
2874 delete_transient('mxchat_pdf_url_' . $session_id);
2875 delete_transient('mxchat_pdf_embeddings_' . $session_id);
2876 delete_transient('mxchat_include_pdf_in_context_' . $session_id);
2877 delete_transient('mxchat_waiting_for_pdf_url_' . $session_id);
2878
2879 // Word document transients
2880 delete_transient('mxchat_word_url_' . $session_id);
2881 delete_transient('mxchat_word_filename_' . $session_id);
2882 delete_transient('mxchat_word_embeddings_' . $session_id);
2883 delete_transient('mxchat_include_word_in_context_' . $session_id);
2884 delete_transient('mxchat_waiting_for_word_' . $session_id);
2885 }
2886
2887
2888
2889 //verified good
2890 public function mxchat_handle_email_capture($message, $user_id, $session_id) {
2891 // Get the user's original instruction/message
2892 $user_instruction = esc_html($this->options['triggered_phrase_response'] ?? esc_html__("Please provide your email address.", 'mxchat'));
2893
2894 // Set instruction for AI - just pass along what the user wanted to say
2895 $this->current_action_instruction = $user_instruction;
2896
2897 // Set the transient to track email capture flow
2898 set_transient('mxchat_email_capture_' . $user_id, true, 5 * MINUTE_IN_SECONDS);
2899
2900 // Return false to let the AI generate the response
2901 return false;
2902 }
2903
2904 public function mxchat_generate_image($message, $user_id, $session_id) {
2905 //error_log("Starting image generation for message: " . $message);
2906
2907 // Prepare a prompt for OpenAI image generation
2908 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2909
2910 // Opt-in routing: when 'custom_provider_for_images' is on, route image gen
2911 // through the configured Custom (OpenAI-compatible) /images/generations route.
2912 if (!empty($this->options['custom_provider_for_images']) && $this->options['custom_provider_for_images'] === 'on') {
2913 $image_response = $this->mxchat_generate_custom_image($prompt);
2914 } else {
2915 // Use the existing OpenAI API key
2916 $openai_api_key = sanitize_text_field($this->options['api_key']);
2917 // Call OpenAI GPT Image to generate an image
2918 $image_response = $this->mxchat_generate_openai_image($prompt, $openai_api_key);
2919 }
2920
2921 // Check if the response contains an image URL
2922 if (isset($image_response['imageUrl'])) {
2923 $image_url = esc_url_raw($image_response['imageUrl']);
2924
2925 // Construct the HTML with a CSS class instead of inline styles
2926 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2927 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2928
2929 // Save the bot message with both text and HTML
2930 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2931 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2932
2933 // Set the fallback response for the chat handler
2934 $this->fallbackResponse = [
2935 'text' => $response_text,
2936 'html' => $response_html,
2937 'images' => [$image_url]
2938 ];
2939
2940 // For debugging/verification - Use json_encode to verify what's being set
2941 //error_log("Image generation successful - fallbackResponse set: " . json_encode($this->fallbackResponse));
2942
2943 // Return the response directly instead of relying on the property
2944 return $this->fallbackResponse;
2945 } else {
2946 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2947
2948 // Save the error message
2949 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2950
2951 // Set the fallback response for the chat handler
2952 $this->fallbackResponse = [
2953 'text' => $response_text,
2954 'html' => '',
2955 'images' => []
2956 ];
2957
2958 //error_log("DALL-E image generation error: " . esc_html($image_response['error'] ?? 'Unknown error.'));
2959 //error_log("Error fallbackResponse set: " . json_encode($this->fallbackResponse));
2960
2961 // Return the response directly instead of relying on the property
2962 return $this->fallbackResponse;
2963 }
2964 }
2965
2966 public function mxchat_generate_gemini_image($message, $user_id, $session_id) {
2967 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2968
2969 $gemini_api_key = sanitize_text_field($this->options['gemini_api_key'] ?? '');
2970 if (empty($gemini_api_key)) {
2971 $response_text = esc_html__("Gemini API key is not configured.", 'mxchat');
2972 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2973 return ['text' => $response_text, 'html' => '', 'images' => []];
2974 }
2975
2976 $image_response = $this->mxchat_generate_imagen_image($prompt, $gemini_api_key);
2977
2978 if (isset($image_response['imageUrl'])) {
2979 $image_url = esc_url_raw($image_response['imageUrl']);
2980
2981 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2982 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2983
2984 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2985 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2986
2987 $this->fallbackResponse = [
2988 'text' => $response_text,
2989 'html' => $response_html,
2990 'images' => [$image_url]
2991 ];
2992
2993 return $this->fallbackResponse;
2994 } else {
2995 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2996
2997 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2998
2999 $this->fallbackResponse = [
3000 'text' => $response_text,
3001 'html' => '',
3002 'images' => []
3003 ];
3004
3005 return $this->fallbackResponse;
3006 }
3007 }
3008
3009 private function mxchat_save_generated_image($base64_data, $mime_type = 'image/png', $prefix = 'mxchat-generated') {
3010 // Map the real mime type to a matching file extension so the saved file's
3011 // extension always agrees with its bytes. A mismatch (e.g. Imagen returning
3012 // webp bytes that were written into a ".png" file) makes the browser refuse
3013 // to render the image even though the file saved successfully and the bot
3014 // reported success — that was the Gemini/Imagen "image never renders" bug.
3015 // OpenAI + custom-provider paths pass 'image/png' explicitly, so they are
3016 // unaffected; this only matters for providers that return another type.
3017 $mime_to_ext = [
3018 'image/jpeg' => 'jpg',
3019 'image/jpg' => 'jpg',
3020 'image/png' => 'png',
3021 'image/webp' => 'webp',
3022 'image/gif' => 'gif',
3023 ];
3024 $mime_type = strtolower(trim((string) $mime_type));
3025 if (isset($mime_to_ext[$mime_type])) {
3026 $extension = $mime_to_ext[$mime_type];
3027 } else {
3028 // Unknown/unsupported type: fall back to png and normalize the stored
3029 // mime so the attachment record and the file extension stay consistent.
3030 $extension = 'png';
3031 $mime_type = 'image/png';
3032 }
3033 $filename = sanitize_file_name($prefix . '-' . wp_generate_uuid4() . '.' . $extension);
3034 $decoded = base64_decode($base64_data);
3035
3036 if ($decoded === false) {
3037 return new \WP_Error('decode_failed', esc_html__('Failed to decode image data.', 'mxchat'));
3038 }
3039
3040 $upload = wp_upload_bits($filename, null, $decoded);
3041
3042 if (!empty($upload['error'])) {
3043 return new \WP_Error('upload_failed', $upload['error']);
3044 }
3045
3046 $attach_id = wp_insert_attachment([
3047 'post_mime_type' => $mime_type,
3048 'post_title' => $prefix,
3049 'post_content' => '',
3050 'post_status' => 'inherit',
3051 ], $upload['file']);
3052
3053 if (is_wp_error($attach_id)) {
3054 return $attach_id;
3055 }
3056
3057 require_once ABSPATH . 'wp-admin/includes/image.php';
3058 $metadata = wp_generate_attachment_metadata($attach_id, $upload['file']);
3059 wp_update_attachment_metadata($attach_id, $metadata);
3060
3061 return esc_url_raw(wp_get_attachment_url($attach_id));
3062 }
3063
3064 private function mxchat_generate_openai_image($prompt, $api_key, $model = 'gpt-image-1', $timeout = 60) {
3065 $api_url = 'https://api.openai.com/v1/images/generations';
3066 $body = json_encode([
3067 'prompt' => sanitize_text_field($prompt),
3068 'n' => 1,
3069 'size' => '1024x1024',
3070 'quality' => 'medium',
3071 'output_format' => 'png',
3072 'model' => sanitize_text_field($model),
3073 ]);
3074
3075 $args = [
3076 'body' => $body,
3077 'headers' => [
3078 'Content-Type' => 'application/json',
3079 'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
3080 ],
3081 'method' => 'POST',
3082 'timeout' => absint($timeout),
3083 ];
3084
3085 $response = wp_remote_post($api_url, $args);
3086
3087 if (is_wp_error($response)) {
3088 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
3089 }
3090
3091 $response_body = json_decode(wp_remote_retrieve_body($response), true);
3092
3093 $b64 = $response_body['data'][0]['b64_json'] ?? $response_body['data'][0]['b64'] ?? null;
3094 if ($b64) {
3095 $saved_url = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-openai');
3096 if (is_wp_error($saved_url)) {
3097 return ['error' => $saved_url->get_error_message()];
3098 }
3099 return ['imageUrl' => $saved_url];
3100 } else {
3101 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
3102 }
3103 }
3104
3105 /**
3106 * Generate an image via a Custom (OpenAI-compatible) provider's /images/generations route.
3107 * Only called when the opt-in 'custom_provider_for_images' setting is on.
3108 */
3109 private function mxchat_generate_custom_image($prompt, $timeout = 90) {
3110 $cfg = $this->mxchat_resolve_custom_provider();
3111 if (empty($cfg['base_url'])) {
3112 return ['error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat')];
3113 }
3114 $url = $cfg['base_url'] . '/images/generations';
3115 if (!empty($cfg['api_version'])) {
3116 $url .= (strpos($url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($cfg['api_version']);
3117 }
3118 $body = wp_json_encode([
3119 'prompt' => sanitize_text_field($prompt),
3120 'n' => 1,
3121 'size' => '1024x1024',
3122 'model' => $cfg['model'],
3123 ]);
3124 $response = wp_remote_post($url, [
3125 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
3126 'body' => $body,
3127 'method' => 'POST',
3128 'timeout' => absint($timeout),
3129 ]);
3130 if (is_wp_error($response)) {
3131 return ['error' => esc_html__('Error generating image (custom provider): ', 'mxchat') . $response->get_error_message()];
3132 }
3133 $resp = json_decode(wp_remote_retrieve_body($response), true);
3134 // Try b64 first (matches OpenAI shape), then url-based fallback.
3135 $b64 = $resp['data'][0]['b64_json'] ?? $resp['data'][0]['b64'] ?? null;
3136 if ($b64) {
3137 $saved = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-custom');
3138 if (is_wp_error($saved)) {
3139 return ['error' => $saved->get_error_message()];
3140 }
3141 return ['imageUrl' => $saved];
3142 }
3143 $remote_url = $resp['data'][0]['url'] ?? null;
3144 if ($remote_url) {
3145 return ['imageUrl' => esc_url_raw($remote_url)];
3146 }
3147 $err_msg = $this->extract_provider_error($resp, esc_html__('Custom provider did not return an image.', 'mxchat'));
3148 return ['error' => esc_html($err_msg)];
3149 }
3150
3151 private function mxchat_generate_imagen_image($prompt, $api_key, $timeout = 60) {
3152 $api_url = 'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict';
3153
3154 $body = json_encode([
3155 'instances' => [['prompt' => sanitize_text_field($prompt)]],
3156 'parameters' => [
3157 'sampleCount' => 1,
3158 'aspectRatio' => '1:1',
3159 ],
3160 ]);
3161
3162 $args = [
3163 'body' => $body,
3164 'headers' => [
3165 'Content-Type' => 'application/json',
3166 'x-goog-api-key' => sanitize_text_field($api_key),
3167 ],
3168 'method' => 'POST',
3169 'timeout' => absint($timeout),
3170 ];
3171
3172 $response = wp_remote_post($api_url, $args);
3173
3174 if (is_wp_error($response)) {
3175 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
3176 }
3177
3178 $response_body = json_decode(wp_remote_retrieve_body($response), true);
3179
3180 $b64 = $response_body['predictions'][0]['bytesBase64Encoded'] ?? $response_body['predictions'][0]['imageBytes'] ?? null;
3181 if ($b64) {
3182 $mime = $response_body['predictions'][0]['mimeType'] ?? 'image/png';
3183 $saved_url = $this->mxchat_save_generated_image($b64, $mime, 'mxchat-gemini');
3184 if (is_wp_error($saved_url)) {
3185 return ['error' => $saved_url->get_error_message()];
3186 }
3187 return ['imageUrl' => $saved_url];
3188 } else {
3189 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
3190 }
3191 }
3192
3193 /**
3194 * Handle web search requests.
3195 *
3196 * Sends the refined search query to the Brave Search API and uses the
3197 * results to generate a conversational response with the AI model.
3198 *
3199 * @since 1.0.0
3200 * @param string $message The user's search query.
3201 * @param string $user_id The user identifier.
3202 * @param string $session_id The current session ID.
3203 * @return array Response array containing text with embedded HTML links
3204 */
3205 public function mxchat_handle_search_request($message, $user_id, $session_id) {
3206 // Step 1: Interpret and refine the search query
3207 $refined_search_query = $this->mxchat_interpret_search_query($message);
3208 if (empty($refined_search_query)) {
3209 return array(
3210 'text' => esc_html__('I apologize, but could you please rephrase your search request?', 'mxchat'),
3211 'html' => ''
3212 );
3213 }
3214
3215 // Retrieve and validate API settings
3216 $options = get_option('mxchat_options');
3217 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
3218 $results_count = isset($options['brave_results_count']) ? absint($options['brave_results_count']) : 5;
3219
3220 if (empty($api_key)) {
3221 return array(
3222 'text' => esc_html__('Search functionality is temporarily unavailable. Please try again later.', 'mxchat'),
3223 'html' => ''
3224 );
3225 }
3226
3227 // Build the API request URL
3228 $api_url = add_query_arg(
3229 array(
3230 'q' => rawurlencode($refined_search_query),
3231 'count' => $results_count,
3232 'text_decorations' => 'true',
3233 'rich_data' => 'true',
3234 ),
3235 'https://api.search.brave.com/res/v1/web/search'
3236 );
3237
3238 // Attempt to retrieve cached results first
3239 $transient_key = 'mxchat_search_' . md5($refined_search_query);
3240 $results = get_transient($transient_key);
3241
3242 if (false === $results) {
3243 // SECURITY FIX: Changed to wp_safe_remote_get
3244 $response = wp_safe_remote_get(
3245 $api_url,
3246 array(
3247 'headers' => array(
3248 'Accept' => 'application/json',
3249 'Accept-Encoding' => 'gzip',
3250 'X-Subscription-Token'=> $api_key,
3251 ),
3252 'timeout' => 10,
3253 )
3254 );
3255
3256 if (is_wp_error($response)) {
3257 return array(
3258 'text' => esc_html__('I encountered an error while searching. Please try again.', 'mxchat'),
3259 'html' => ''
3260 );
3261 }
3262
3263 $results = json_decode(wp_remote_retrieve_body($response), true);
3264
3265 if (json_last_error() !== JSON_ERROR_NONE) {
3266 return array(
3267 'text' => esc_html__('I received an invalid response from the search service.', 'mxchat'),
3268 'html' => ''
3269 );
3270 }
3271
3272 // Cache results for one hour
3273 set_transient($transient_key, $results, HOUR_IN_SECONDS);
3274 }
3275
3276 // Process results
3277 if (!empty($results['web']['results']) && is_array($results['web']['results'])) {
3278 // Create a more straightforward summary with HTML links
3279 $search_results_text = '';
3280
3281 // Add a simple intro
3282 $search_results_text .= sprintf(
3283 esc_html__("Here's what I found about '%s':", 'mxchat'),
3284 esc_html($refined_search_query)
3285 );
3286
3287 // Add the top results with HTML links
3288 foreach (array_slice($results['web']['results'], 0, 5) as $result) {
3289 $title = isset($result['title']) ? wp_strip_all_tags($result['title']) : '';
3290 $url = isset($result['url']) ? esc_url($result['url']) : '';
3291 $description = isset($result['description']) ? wp_strip_all_tags($result['description']) : '';
3292
3293 // Add a line break after the intro
3294 $search_results_text .= '<br><br>';
3295
3296 // Add title as a link
3297 $search_results_text .= sprintf(
3298 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a><br>',
3299 $url,
3300 $title
3301 );
3302
3303 // Add a condensed description
3304 $search_results_text .= sprintf("%s", $description);
3305 }
3306
3307 // Save to chat history
3308 $this->mxchat_save_chat_message($session_id, 'bot', $search_results_text);
3309
3310 // Return the formatted text with embedded HTML links
3311 return array(
3312 'text' => $search_results_text,
3313 'html' => ''
3314 );
3315 } else {
3316 return array(
3317 'text' => sprintf(
3318 esc_html__('I searched for "%s" but couldn\'t find any relevant results. Would you like to try different search terms?', 'mxchat'),
3319 esc_html($refined_search_query)
3320 ),
3321 'html' => ''
3322 );
3323 }
3324 }
3325
3326 //very good
3327 /**
3328 * Handle image search requests from the chatbot
3329 *
3330 * @param string $message The user's search query
3331 * @param int $user_id The user's ID
3332 * @param string $session_id The chat session ID
3333 * @return array Response array with text and HTML content
3334 */
3335 public function mxchat_handle_image_search_request($message, $user_id, $session_id) {
3336 // Step 1: Interpret the search query using the user's selected AI model
3337 $refined_search_query = $this->mxchat_interpret_search_query($message);
3338
3339 // If no query was interpreted, return a fallback message
3340 if (empty($refined_search_query)) {
3341 return array(
3342 'text' => __("I'm sorry, I couldn't interpret your search query. Please specify what you'd like to see images of.", 'mxchat'),
3343 'html' => "",
3344 );
3345 }
3346
3347 // Brave API URL
3348 $api_url = 'https://api.search.brave.com/res/v1/images/search';
3349
3350 // Retrieve Brave API settings
3351 $options = get_option('mxchat_options');
3352 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
3353
3354 if (empty($api_key)) {
3355 return array(
3356 'text' => __("API key is not configured. Please set it in the Brave Search Settings.", 'mxchat'),
3357 'html' => "",
3358 );
3359 }
3360
3361 $image_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
3362 $safe_search = isset($options['brave_safe_search']) ? sanitize_text_field($options['brave_safe_search']) : 'strict';
3363
3364 // Append query parameters based on settings
3365 $api_url = add_query_arg([
3366 'q' => rawurlencode($refined_search_query),
3367 'count' => $image_count,
3368 'safesearch' => $safe_search,
3369 ], $api_url);
3370
3371 // Implement caching
3372 $transient_key = 'mxchat_image_search_' . md5($refined_search_query);
3373 $body = get_transient($transient_key);
3374
3375 if (false === $body) {
3376 $args = [
3377 'headers' => [
3378 'Accept' => 'application/json',
3379 'Accept-Encoding' => 'gzip',
3380 'X-Subscription-Token' => $api_key,
3381 ],
3382 'timeout' => 10,
3383 ];
3384
3385 // SECURITY FIX: Changed to wp_safe_remote_get
3386 $response = wp_safe_remote_get($api_url, $args);
3387
3388 if (is_wp_error($response)) {
3389 return array(
3390 'text' => __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat'),
3391 'html' => "",
3392 );
3393 }
3394
3395 $body = json_decode(wp_remote_retrieve_body($response), true);
3396 set_transient($transient_key, $body, HOUR_IN_SECONDS);
3397 }
3398
3399 // Process the API response
3400 if (isset($body['results']) && is_array($body['results']) && count($body['results']) > 0) {
3401 $html_output = '<div class="mxchat-image-gallery">';
3402
3403 // Get the configured image count (1-6)
3404 $display_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
3405 $display_count = min($display_count, count($body['results'])); // Make sure we don't exceed available images
3406
3407 // Use only the requested number of images
3408 for ($i = 0; $i < $display_count; $i++) {
3409 $image = $body['results'][$i];
3410 $image_url = isset($image['url']) ? esc_url($image['url']) : '';
3411 $thumbnail_url = isset($image['thumbnail']['src']) ? esc_url($image['thumbnail']['src']) : '';
3412 $title = isset($image['title']) ? esc_html($image['title']) : esc_html__('Image', 'mxchat');
3413
3414 if ($image_url && $thumbnail_url) {
3415 $html_output .= '<div class="mxchat-image-item">';
3416 $html_output .= '<strong class="mxchat-image-title">' . $title . '</strong>';
3417 $html_output .= '<a href="' . $image_url . '" target="_blank" rel="noopener noreferrer" class="mxchat-image-link">';
3418 $html_output .= '<img src="' . $thumbnail_url . '" alt="' . $title . '" class="mxchat-image-thumbnail">';
3419 $html_output .= '</a></div>';
3420 }
3421 }
3422
3423 $html_output .= '</div>';
3424
3425 // Create response text
3426 $response_text = sprintf(__("Here are some images of %s:", 'mxchat'), $refined_search_query);
3427
3428 // Save both response text and HTML to chat history
3429 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
3430 $this->mxchat_save_chat_message($session_id, 'bot', $html_output);
3431
3432 // Return the combined response
3433 return array(
3434 'text' => $response_text,
3435 'html' => $html_output,
3436 );
3437 } else {
3438 $response_text = __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat');
3439
3440 // Save the error message to chat history
3441 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
3442
3443 return array(
3444 'text' => $response_text,
3445 'html' => "",
3446 );
3447 }
3448 }
3449
3450 /**
3451 * Interpret the search query using the user's selected AI model
3452 *
3453 * @param string $user_query The original query from the user
3454 * @return string The refined search query
3455 */
3456 public function mxchat_interpret_search_query($user_query) {
3457 $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');
3458
3459 // Get options and determine the selected model
3460 $options = $this->options ?? get_option('mxchat_options');
3461 $selected_model = isset($options['model']) ? $options['model'] : 'gpt-5.6-sol';
3462
3463 // Custom (OpenAI-compatible) provider routes by model id, not prefix.
3464 if ($selected_model === 'custom-provider') {
3465 return $this->interpret_query_with_custom($user_query, $system_prompt);
3466 }
3467
3468 // Extract model prefix to determine the provider
3469 $model_parts = explode('-', $selected_model);
3470 $provider = strtolower($model_parts[0]);
3471
3472 // Determine which API key to use based on the provider
3473 switch ($provider) {
3474 case 'gemini':
3475 $api_key = isset($options['gemini_api_key']) ? sanitize_text_field($options['gemini_api_key']) : '';
3476 if (empty($api_key)) {
3477 return sanitize_text_field($user_query); // Default to original query if API key missing
3478 }
3479 return $this->interpret_query_with_gemini($user_query, $system_prompt, $api_key, $selected_model);
3480
3481 case 'claude':
3482 $api_key = isset($options['claude_api_key']) ? sanitize_text_field($options['claude_api_key']) : '';
3483 if (empty($api_key)) {
3484 return sanitize_text_field($user_query);
3485 }
3486 return $this->interpret_query_with_claude($user_query, $system_prompt, $api_key, $selected_model);
3487
3488 case 'grok':
3489 $api_key = isset($options['xai_api_key']) ? sanitize_text_field($options['xai_api_key']) : '';
3490 if (empty($api_key)) {
3491 return sanitize_text_field($user_query);
3492 }
3493 return $this->interpret_query_with_xai($user_query, $system_prompt, $api_key, $selected_model);
3494
3495 case 'deepseek':
3496 $api_key = isset($options['deepseek_api_key']) ? sanitize_text_field($options['deepseek_api_key']) : '';
3497 if (empty($api_key)) {
3498 return sanitize_text_field($user_query);
3499 }
3500 return $this->interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $selected_model);
3501
3502 case 'gpt':
3503 default:
3504 // Default to OpenAI for custom models or unrecognized prefixes
3505 $api_key = isset($options['api_key']) ? sanitize_text_field($options['api_key']) : '';
3506 if (empty($api_key)) {
3507 return sanitize_text_field($user_query);
3508 }
3509 return $this->interpret_query_with_openai($user_query, $system_prompt, $api_key, $selected_model);
3510 }
3511 }
3512
3513 /**
3514 * Interpret query against the configured Custom (OpenAI-compatible) provider.
3515 * Uses the same base URL + auth scheme as the chat dispatcher.
3516 */
3517 private function interpret_query_with_custom($user_query, $system_prompt) {
3518 $cfg = $this->mxchat_resolve_custom_provider();
3519 if (empty($cfg['base_url'])) {
3520 return sanitize_text_field($user_query);
3521 }
3522 // plan-mxchat-20260715-7124f4: a custom OpenAI-compatible endpoint pointed at
3523 // a gpt-5-class model rejects temperature!=1 and the legacy max_tokens key.
3524 // Byte-identical for ordinary custom models (temperature kept, max_tokens
3525 // used); only gpt-5-class custom models change (best-effort — custom
3526 // endpoints vary).
3527 $token_key = $this->mxchat_openai_token_param_for($cfg['model']);
3528 $payload = [
3529 'model' => $cfg['model'],
3530 'messages' => [
3531 ['role' => 'system', 'content' => $system_prompt],
3532 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3533 ],
3534 $token_key => 20,
3535 ];
3536 if ($this->mxchat_openai_supports_temperature_for($cfg['model'])) {
3537 $payload['temperature'] = 0.2;
3538 }
3539 $args = [
3540 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
3541 'body' => wp_json_encode($payload),
3542 'method' => 'POST',
3543 'timeout' => 15,
3544 ];
3545 $response = wp_remote_post($cfg['chat_url'], $args);
3546 if (is_wp_error($response)) {
3547 return sanitize_text_field($user_query);
3548 }
3549 $body = json_decode(wp_remote_retrieve_body($response), true);
3550 return isset($body['choices'][0]['message']['content'])
3551 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
3552 : sanitize_text_field($user_query);
3553 }
3554
3555 /**
3556 * Convert the colon-style header list returned by mxchat_resolve_custom_provider
3557 * into the assoc-array form wp_remote_post expects.
3558 */
3559 private function mxchat_custom_provider_assoc_headers($cfg) {
3560 $headers = ['Content-Type' => 'application/json'];
3561 if (!empty($cfg['api_key'])) {
3562 if (($cfg['auth_scheme'] ?? 'bearer') === 'api-key') {
3563 $headers['api-key'] = $cfg['api_key'];
3564 } else {
3565 $headers['Authorization'] = 'Bearer ' . $cfg['api_key'];
3566 }
3567 }
3568 return $headers;
3569 }
3570
3571 /**
3572 * Interpret query using OpenAI models
3573 */
3574 private function interpret_query_with_openai($user_query, $system_prompt, $api_key, $model = 'gpt-5.6-sol') {
3575 $url = 'https://api.openai.com/v1/chat/completions';
3576 // plan-mxchat-20260715-7124f4: the default chat model is a gpt-5-family id
3577 // and every gpt-5* rejects both a non-default temperature and the legacy
3578 // max_tokens key (400). This call swallowed the 400 and silently degraded to
3579 // the raw query on every gpt-5 install, quietly disabling product/image
3580 // search-query interpretation. Derive capability from the core catalog
3581 // (dcb71c) so this tracks future model adds; strpos fallback for a
3582 // partial-upgrade window where the catalog method isn't loaded.
3583 $token_key = $this->mxchat_openai_token_param_for($model);
3584 $payload = [
3585 'model' => $model,
3586 'messages' => [
3587 ['role' => 'system', 'content' => $system_prompt],
3588 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3589 ],
3590 $token_key => 20,
3591 ];
3592 if ($this->mxchat_openai_supports_temperature_for($model)) {
3593 $payload['temperature'] = 0.2;
3594 }
3595 $args = [
3596 'headers' => [
3597 'Authorization' => 'Bearer ' . $api_key,
3598 'Content-Type' => 'application/json',
3599 ],
3600 'body' => wp_json_encode($payload),
3601 'method' => 'POST',
3602 'timeout' => 15,
3603 ];
3604
3605 $response = wp_remote_post($url, $args);
3606 if (is_wp_error($response)) {
3607 return sanitize_text_field($user_query);
3608 }
3609
3610 $body = json_decode(wp_remote_retrieve_body($response), true);
3611 return isset($body['choices'][0]['message']['content'])
3612 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
3613 : sanitize_text_field($user_query);
3614 }
3615
3616 /**
3617 * Anthropic removed temperature/top_p/top_k starting with Opus 4.7 (the API
3618 * returns 400 if sent) — add new flagship model ids here. (We don't send
3619 * top_p/top_k in any Claude body, so the list only needs to gate temperature
3620 * stripping. We never send a `thinking` param either, which is required for
3621 * claude-fable-5: it rejects an explicit thinking "disabled" — omit only.)
3622 */
3623 private function mxchat_claude_omits_temperature($model) {
3624 // plan-mxchat-20260714-dcb71c: derive from the core model catalog (single
3625 // source of truth). Every caller here passes a Claude model, so
3626 // !supports_temperature() reproduces the old 4-id in_array() result exactly.
3627 // Frozen list kept as fallback for a partial-upgrade window where the
3628 // catalog method isn't loaded.
3629 if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'supports_temperature')) {
3630 return !MxChat_Model_Catalog::supports_temperature($model);
3631 }
3632 $no_temp = array('claude-opus-5', 'claude-opus-4-7', 'claude-opus-4-8', 'claude-fable-5', 'claude-sonnet-5');
3633 return in_array($model, $no_temp, true);
3634 }
3635
3636 /**
3637 * plan-mxchat-20260715-7124f4: OpenAI completion-token key for this model,
3638 * sourced from the core catalog (gpt-5* → max_completion_tokens; else
3639 * max_tokens). strpos fallback for a partial-upgrade window where the catalog
3640 * method isn't loaded.
3641 *
3642 * @param string $model OpenAI(-compatible) model id.
3643 * @return string 'max_completion_tokens' | 'max_tokens'
3644 */
3645 private function mxchat_openai_token_param_for($model) {
3646 if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'openai_token_param')) {
3647 return MxChat_Model_Catalog::openai_token_param($model);
3648 }
3649 return strpos((string) $model, 'gpt-5') === 0 ? 'max_completion_tokens' : 'max_tokens';
3650 }
3651
3652 /**
3653 * plan-mxchat-20260715-7124f4: whether a NON-default temperature may be sent to
3654 * this OpenAI(-compatible) model. gpt-5* accept only the default (1) — sending
3655 * any other value 400s. Sourced from the core catalog; strpos fallback for a
3656 * partial-upgrade window.
3657 *
3658 * @param string $model OpenAI(-compatible) model id.
3659 * @return bool
3660 */
3661 private function mxchat_openai_supports_temperature_for($model) {
3662 if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'supports_temperature')) {
3663 return MxChat_Model_Catalog::supports_temperature($model);
3664 }
3665 return strpos((string) $model, 'gpt-5') !== 0;
3666 }
3667
3668 /**
3669 * plan-mxchat-20260714-dcb71c: per-surface reasoning_effort, sourced from the
3670 * core model catalog so a model add propagates automatically. The fallback is
3671 * the frozen pre-dcb71c inline ladder, used only if the catalog method is
3672 * unavailable (a partial-upgrade window). Byte-identical to the old inline
3673 * blocks by construction — proven by the dcb71c equivalence harness.
3674 *
3675 * @param string $model Chat model id.
3676 * @param string $context 'chat' | 'websearch'.
3677 * @return string|null Effort to send, or null to omit the param.
3678 */
3679 private function mxchat_reasoning_effort_for($model, $context) {
3680 if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'reasoning_effort_for')) {
3681 return MxChat_Model_Catalog::reasoning_effort_for($model, $context);
3682 }
3683 return $this->mxchat_reasoning_effort_fallback($model, $context);
3684 }
3685
3686 private function mxchat_reasoning_effort_fallback($model, $context) {
3687 if (strpos($model, 'gpt-5') !== 0) {
3688 return null;
3689 }
3690 if ($context === 'websearch') {
3691 $no_reasoning_web = array('gpt-5.2', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
3692 if (in_array($model, $no_reasoning_web, true)) return null;
3693 if ($model === 'gpt-5.1-2025-11-13') return 'low';
3694 if ($model === 'gpt-5.5') return 'low';
3695 if ($model === 'gpt-5.4') return 'low';
3696 if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low';
3697 return null;
3698 }
3699 // 'chat'
3700 // gpt-5.1/5.3-chat-latest stay listed after their 2026-08-10 retirement:
3701 // unmigrated bot-level / add-on-saved ids must keep routing correctly
3702 // until every surface is swept (plan e46b8f).
3703 $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');
3704 if (in_array($model, $no_reasoning_models, true)) return null;
3705 if ($model === 'gpt-5.1-2025-11-13') return 'low';
3706 if ($model === 'gpt-5.5') return 'none';
3707 if ($model === 'gpt-5.4') return 'none';
3708 if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low';
3709 return 'minimal';
3710 }
3711
3712 /**
3713 * Interpret query using Claude models
3714 */
3715 private function interpret_query_with_claude($user_query, $system_prompt, $api_key, $model) {
3716 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
3717 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
3718 if ($model === 'claude-opus-4-20250514') { $model = 'claude-opus-4-8'; }
3719 elseif ($model === 'claude-sonnet-4-20250514') { $model = 'claude-sonnet-4-6'; }
3720 $url = 'https://api.anthropic.com/v1/messages';
3721
3722 $payload = [
3723 'model' => $model,
3724 'system' => $system_prompt,
3725 'messages' => [
3726 ['role' => 'user', 'content' => sanitize_text_field($user_query)]
3727 ],
3728 'max_tokens' => 20,
3729 'temperature' => 0.2,
3730 ];
3731 if ($this->mxchat_claude_omits_temperature($model)) { unset($payload['temperature']); }
3732
3733 $args = [
3734 'headers' => [
3735 'Content-Type' => 'application/json',
3736 'x-api-key' => $api_key,
3737 'anthropic-version' => '2023-06-01',
3738 ],
3739 'body' => wp_json_encode($payload),
3740 'method' => 'POST',
3741 'timeout' => 15,
3742 ];
3743
3744 $response = wp_remote_post($url, $args);
3745 if (is_wp_error($response)) {
3746 return sanitize_text_field($user_query);
3747 }
3748
3749 $body = json_decode(wp_remote_retrieve_body($response), true);
3750 // claude-fable-5 prepends a thinking block to content — take the first
3751 // TEXT block, not content[0].
3752 foreach ((array) ($body['content'] ?? array()) as $block) {
3753 if (isset($block['type'], $block['text']) && $block['type'] === 'text' && trim($block['text']) !== '') {
3754 return sanitize_text_field(trim($block['text']));
3755 }
3756 }
3757
3758 return sanitize_text_field($user_query);
3759 }
3760
3761 /**
3762 * Interpret query using Gemini models
3763 */
3764 private function interpret_query_with_gemini($user_query, $system_prompt, $api_key, $model) {
3765 if ($model === 'gemini-3-pro-preview') {
3766 $model = 'gemini-3.1-pro-preview';
3767 }
3768 // Use v1beta for preview models, v1 for stable models
3769 $api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
3770
3771 $url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key=" . urlencode($api_key);
3772
3773 $args = [
3774 'headers' => [
3775 'Content-Type' => 'application/json',
3776 ],
3777 'body' => wp_json_encode([
3778 'contents' => [
3779 [
3780 'role' => 'user',
3781 'parts' => [
3782 ['text' => $system_prompt . "\n\nQuery: " . sanitize_text_field($user_query)]
3783 ]
3784 ]
3785 ],
3786 'generationConfig' => [
3787 'temperature' => 0.2,
3788 'maxOutputTokens' => 20,
3789 ],
3790 ]),
3791 'method' => 'POST',
3792 'timeout' => 15,
3793 ];
3794
3795 $response = wp_remote_post($url, $args);
3796 if (is_wp_error($response)) {
3797 return sanitize_text_field($user_query);
3798 }
3799
3800 $body = json_decode(wp_remote_retrieve_body($response), true);
3801 if (!empty($body['candidates'][0]['content']['parts'][0]['text'])) {
3802 return sanitize_text_field(trim($body['candidates'][0]['content']['parts'][0]['text']));
3803 }
3804
3805 return sanitize_text_field($user_query);
3806 }
3807
3808 /**
3809 * Interpret query using X.AI (Grok) models
3810 */
3811 private function interpret_query_with_xai($user_query, $system_prompt, $api_key, $model) {
3812 $url = 'https://api.xai.com/v1/chat/completions';
3813
3814 $args = [
3815 'headers' => [
3816 'Content-Type' => 'application/json',
3817 'Authorization' => 'Bearer ' . $api_key,
3818 ],
3819 'body' => wp_json_encode([
3820 'model' => $model,
3821 'messages' => [
3822 ['role' => 'system', 'content' => $system_prompt],
3823 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3824 ],
3825 'temperature' => 0.2,
3826 'max_tokens' => 20,
3827 ]),
3828 'method' => 'POST',
3829 'timeout' => 15,
3830 ];
3831
3832 $response = wp_remote_post($url, $args);
3833 if (is_wp_error($response)) {
3834 return sanitize_text_field($user_query);
3835 }
3836
3837 $body = json_decode(wp_remote_retrieve_body($response), true);
3838 if (isset($body['choices'][0]['message']['content'])) {
3839 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
3840 }
3841
3842 return sanitize_text_field($user_query);
3843 }
3844
3845 /**
3846 * Interpret query using DeepSeek models
3847 */
3848 private function interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $model) {
3849 $url = 'https://api.deepseek.com/v1/chat/completions';
3850
3851 $args = [
3852 'headers' => [
3853 'Content-Type' => 'application/json',
3854 'Authorization' => 'Bearer ' . $api_key,
3855 ],
3856 'body' => wp_json_encode([
3857 'model' => $model,
3858 'messages' => [
3859 ['role' => 'system', 'content' => $system_prompt],
3860 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3861 ],
3862 'temperature' => 0.2,
3863 'max_tokens' => 20,
3864 // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
3865 // reasoning burns the 20-token budget); keep the legacy
3866 // deepseek-chat semantics = non-thinking.
3867 'thinking' => ['type' => 'disabled'],
3868 ]),
3869 'method' => 'POST',
3870 'timeout' => 15,
3871 ];
3872
3873 $response = wp_remote_post($url, $args);
3874 if (is_wp_error($response)) {
3875 return sanitize_text_field($user_query);
3876 }
3877
3878 $body = json_decode(wp_remote_retrieve_body($response), true);
3879 if (isset($body['choices'][0]['message']['content'])) {
3880 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
3881 }
3882
3883 return sanitize_text_field($user_query);
3884 }
3885
3886 //very good
3887 private function add_email_to_loops($email) {
3888 // Sanitize the email
3889 $email = sanitize_email($email);
3890
3891 // Retrieve and sanitize options
3892 $api_key = isset($this->options['loops_api_key']) ? sanitize_text_field($this->options['loops_api_key']) : '';
3893 $mailing_list_id = isset($this->options['loops_mailing_list']) ? sanitize_text_field($this->options['loops_mailing_list']) : '';
3894
3895 // Check for missing API key or mailing list ID
3896 if (empty($api_key) || empty($mailing_list_id)) {
3897 //error_log(esc_html__('Loops API key or mailing list ID is missing.', 'mxchat'));
3898 return;
3899 }
3900
3901 $data = array(
3902 'email' => $email,
3903 'subscribed' => true,
3904 'source' => __('MxChat AI Chatbot', 'mxchat'),
3905 'mailingLists' => array($mailing_list_id => true),
3906 );
3907
3908 $url = 'https://app.loops.so/api/v1/contacts/create';
3909 $args = array(
3910 'body' => wp_json_encode($data),
3911 'headers' => array(
3912 'Authorization' => 'Bearer ' . $api_key,
3913 'Content-Type' => 'application/json',
3914 ),
3915 'method' => 'POST',
3916 'timeout' => 45,
3917 );
3918
3919 $response = wp_remote_post($url, $args);
3920
3921 // Handle errors in the API request
3922 if (is_wp_error($response)) {
3923 //error_log(esc_html__('Error adding email to Loops: ', 'mxchat') . $response->get_error_message());
3924 return;
3925 }
3926
3927 // Check for non-200 HTTP responses
3928 $response_code = wp_remote_retrieve_response_code($response);
3929 if ($response_code != 200) {
3930 $response_body = wp_remote_retrieve_body($response);
3931 //error_log(esc_html__('Loops API responded with code ', 'mxchat') . $response_code . ': ' . $response_body);
3932 }
3933 }
3934
3935 public function mxchat_handle_pdf_discussion($message, $user_id, $session_id) {
3936 // Get the maximum number of pages allowed from admin settings
3937 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3938
3939 // Retrieve options for dynamic texts
3940 $trigger_text = $this->options['pdf_intent_trigger_text'] ?? __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
3941 $success_text = $this->options['pdf_intent_success_text'] ?? __("I've processed the PDF. What questions do you have about it?", 'mxchat');
3942 $error_text = $this->options['pdf_intent_error_text'] ?? __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
3943
3944 // Check for explicit request for new PDF
3945 $new_pdf_requested = stripos($message, 'new') !== false ||
3946 stripos($message, 'another') !== false ||
3947 stripos($message, 'different') !== false;
3948
3949 // If user mentions adding/reading a PDF, set waiting flag
3950 if (stripos($message, 'pdf') !== false ||
3951 stripos($message, 'document') !== false ||
3952 stripos($message, 'read') !== false) {
3953 set_transient('mxchat_waiting_for_pdf_url_' . $session_id, true, HOUR_IN_SECONDS);
3954 $this->fallbackResponse['text'] = $trigger_text;
3955 return;
3956 }
3957
3958 // If we're waiting for a URL or user requested new PDF
3959 if ($new_pdf_requested || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
3960 if (preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
3961 // Process URL... (rest of your existing URL processing code)
3962 } else {
3963 $this->fallbackResponse['text'] = $trigger_text;
3964 }
3965 return;
3966 }
3967
3968 // Default to proceeding with conversation if no specific PDF action is needed
3969 $this->fallbackResponse['text'] = '';
3970 }
3971
3972
3973 /**
3974 * Enhanced fetch_and_split_pdf_pages with SSRF protection
3975 */
3976 private function fetch_and_split_pdf_pages($pdf_source, $max_pages) {
3977 // Reset the per-call embedding-failure reason (104a75) — callers read it via
3978 // get_last_pdf_embedding_error() when zero pages come back.
3979 $this->last_pdf_embedding_error = null;
3980
3981 // CLEAR DEBUG LOGGING
3982 //error_log("=== MXCHAT PDF PROCESSING START ===");
3983 //error_log("PDF Source: " . $pdf_source);
3984 //error_log("Max Pages: " . $max_pages);
3985 //error_log("Session ID: " . ($this->session_id ?? 'not set'));
3986
3987 // Check if Advanced Claude Toolbar is available and enabled
3988 $claude_available = function_exists('mxchatACT_is_advanced_claude_enabled');
3989 $claude_enabled = $claude_available ? mxchatACT_is_advanced_claude_enabled() : false;
3990
3991 //error_log("Claude Function Available: " . ($claude_available ? 'YES' : 'NO'));
3992 //error_log("Claude Enabled: " . ($claude_enabled ? 'YES' : 'NO'));
3993
3994 if ($claude_available && $claude_enabled) {
3995 //error_log("🚀 ATTEMPTING CLAUDE PROCESSING...");
3996
3997 // Attempt Claude processing first
3998 $claude_result = apply_filters('mxchat_process_pdf_advanced', false, $pdf_source, $max_pages, $this->session_id);
3999
4000 if ($claude_result !== false && is_array($claude_result) && !empty($claude_result)) {
4001 //error_log("✅ CLAUDE PROCESSING SUCCESSFUL!");
4002 //error_log("Claude returned " . count($claude_result) . " processed pages");
4003
4004 // Log first page details for verification
4005 if (isset($claude_result[0])) {
4006 $first_page = $claude_result[0];
4007 //error_log("First page enhanced: " . (isset($first_page['enhanced']) && $first_page['enhanced'] ? 'YES' : 'NO'));
4008 //error_log("Processing method: " . ($first_page['processing_method'] ?? 'not set'));
4009 //error_log("First page text preview: " . substr($first_page['text'] ?? '', 0, 100) . "...");
4010 }
4011
4012 //error_log("=== MXCHAT PDF PROCESSING END (CLAUDE) ===");
4013 return $claude_result;
4014 } else {
4015 //error_log("❌ CLAUDE PROCESSING FAILED or returned invalid result");
4016 //error_log("Claude result type: " . gettype($claude_result));
4017 if (is_array($claude_result)) {
4018 //error_log("Claude result count: " . count($claude_result));
4019 }
4020 }
4021 }
4022
4023 // Fallback to basic processing
4024 //error_log("🔄 FALLING BACK TO BASIC PDF PROCESSING...");
4025
4026 $upload_dir = wp_upload_dir();
4027 $temp_file = null;
4028
4029 try {
4030 // Your existing basic processing code here...
4031 // (I'll include the key parts with debug logging)
4032
4033 if (filter_var($pdf_source, FILTER_VALIDATE_URL)) {
4034 //error_log("Downloading PDF from URL...");
4035
4036 // SECURITY FIX: Validate URL before processing
4037 if (!$this->mxchat_is_safe_pdf_url($pdf_source)) {
4038 //error_log("❌ SECURITY: Blocked unsafe PDF URL");
4039 return false;
4040 }
4041
4042 $temp_file = wp_tempnam($pdf_source);
4043
4044 // SECURITY FIX: Changed from wp_remote_get to wp_safe_remote_get
4045 // Route through the shared MXChat crawler identity (plan bae78f/b6d93c) so
4046 // every remote-content fetch presents one honest, versioned, filterable,
4047 // allowlistable User-Agent. function_exists guard keeps the front-end/nopriv
4048 // path safe if the helper (in the always-loaded main file) is ever unavailable.
4049 $response = wp_safe_remote_get($pdf_source, [
4050 'timeout' => 60,
4051 'headers' => ['User-Agent' => function_exists('mxchat_ingest_user_agent') ? mxchat_ingest_user_agent() : 'MxChat PDF Processor']
4052 ]);
4053
4054 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
4055 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code($response);
4056 //error_log("❌ BASIC PROCESSING: Failed to download PDF: " . $error_message);
4057 return false;
4058 }
4059
4060 global $wp_filesystem;
4061 if (empty($wp_filesystem)) {
4062 require_once ABSPATH . 'wp-admin/includes/file.php';
4063 WP_Filesystem();
4064 }
4065 $wp_filesystem->put_contents($temp_file, wp_remote_retrieve_body($response), FS_CHMOD_FILE);
4066 //error_log("✅ PDF downloaded successfully");
4067 } else {
4068 $temp_file = $pdf_source;
4069 //error_log("Using local PDF file: " . $temp_file);
4070 }
4071
4072 // Parse PDF
4073 //error_log("Parsing PDF with basic parser...");
4074 mxchat_load_pdf_parser();
4075 $parser = new \Smalot\PdfParser\Parser();
4076 $pdf = $parser->parseFile($temp_file);
4077 $pages = $pdf->getPages();
4078
4079 //error_log("PDF contains " . count($pages) . " pages");
4080
4081 if (count($pages) > $max_pages) {
4082 //error_log("❌ BASIC PROCESSING: Too many pages (" . count($pages) . " > " . $max_pages . ")");
4083 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file) {
4084 unlink($temp_file);
4085 }
4086 return 'too_many_pages';
4087 }
4088
4089 $embeddings = [];
4090 $processed_pages = 0;
4091 $skipped_pages = 0;
4092
4093 foreach ($pages as $page_number => $page) {
4094 $text = $page->getText();
4095
4096 if (empty(trim($text))) {
4097 //error_log("Skipping empty page: " . ($page_number + 1));
4098 continue;
4099 }
4100
4101 $text = $this->mxchat_clean_text($text);
4102
4103 $embedding = $this->mxchat_generate_embedding(
4104 __("Page ", 'mxchat') . ($page_number + 1) . ": " . $text,
4105 $this->options['api_key']
4106 );
4107
4108 // The embedding failure contract is an ARRAY ['error','error_code'] — which is
4109 // TRUTHY. A bare `if ($embedding)` therefore stored error arrays AS the page's
4110 // vector, poisoning cosine similarity for the rest of the session (104a75).
4111 // Accept only a real vector: an array with no 'error' key.
4112 if (is_array($embedding) && !isset($embedding['error'])) {
4113 $embeddings[] = [
4114 'page_number' => $page_number + 1,
4115 'embedding' => $embedding,
4116 'text' => $text,
4117 'enhanced' => false, // CLEARLY MARK AS BASIC
4118 'processing_method' => 'basic_pdf_parser'
4119 ];
4120 $processed_pages++;
4121 } else {
4122 $skipped_pages++;
4123 // Keep the FIRST failure reason so the callers can surface it instead of
4124 // the generic "couldn't process the PDF" text.
4125 if ($this->last_pdf_embedding_error === null && is_array($embedding) && isset($embedding['error'])) {
4126 $this->last_pdf_embedding_error = (string) $embedding['error'];
4127 }
4128 }
4129 }
4130
4131 if ($skipped_pages > 0 && class_exists('MxChat_Admin')) {
4132 MxChat_Admin::mxchat_log_debug(
4133 'embedding_error',
4134 sprintf(
4135 /* translators: 1: skipped page count, 2: successfully embedded page count */
4136 __('PDF chat: %1$d page(s) skipped because embedding failed; %2$d page(s) stored.', 'mxchat'),
4137 $skipped_pages,
4138 $processed_pages
4139 ),
4140 array(
4141 'first_error' => $this->last_pdf_embedding_error,
4142 'skipped' => $skipped_pages,
4143 'stored' => $processed_pages,
4144 )
4145 );
4146 }
4147
4148 //error_log("✅ BASIC PROCESSING COMPLETE: " . $processed_pages . " pages processed");
4149
4150 // Cleanup
4151 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
4152 unlink($temp_file);
4153 }
4154
4155 //error_log("=== MXCHAT PDF PROCESSING END (BASIC) ===");
4156 return $embeddings;
4157
4158 } catch (\Exception $e) {
4159 //error_log("❌ BASIC PROCESSING ERROR: " . $e->getMessage());
4160 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
4161 unlink($temp_file);
4162 }
4163 //error_log("=== MXCHAT PDF PROCESSING END (ERROR) ===");
4164 return false;
4165 }
4166 }
4167
4168 /**
4169 * Append the embedding provider's own failure reason to a generic PDF error string,
4170 * when the most recent split captured one (104a75). Mirrors the 46b596/4a7c0a rule:
4171 * never discard a diagnosis the layer below already produced. Returns $base_text
4172 * unchanged when no reason was captured, so the healthy/unsupported-file wording
4173 * is byte-identical to before.
4174 */
4175 private function mxchat_pdf_error_text_with_reason($base_text) {
4176 if (empty($this->last_pdf_embedding_error)) {
4177 return $base_text;
4178 }
4179
4180 return $base_text . ' ' . sprintf(
4181 /* translators: %s: error reason reported by the embedding provider */
4182 __('(%s)', 'mxchat'),
4183 $this->last_pdf_embedding_error
4184 );
4185 }
4186
4187
4188 /**
4189 * Validate PDF URL for security
4190 * Prevents SSRF attacks by blocking dangerous URLs
4191 */
4192
4193 private function mxchat_is_safe_pdf_url($url) {
4194 // Use WordPress core function for comprehensive validation
4195 // This blocks localhost, private IPs, and reserved IP ranges
4196 $validated_url = wp_http_validate_url($url);
4197
4198 if ($validated_url === false) {
4199 return false;
4200 }
4201
4202 // Additional check: only allow HTTP/HTTPS schemes
4203 $parsed = parse_url($url);
4204 if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], ['http', 'https'], true)) {
4205 return false;
4206 }
4207
4208 return true;
4209 }
4210
4211
4212 private function mxchat_clean_text($text) {
4213 // Remove excessive whitespace
4214 $text = preg_replace('/\s+/', ' ', $text);
4215
4216 // Remove control characters except newlines and tabs
4217 $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
4218
4219 // Normalize line endings
4220 $text = str_replace(["\r\n", "\r"], "\n", $text);
4221
4222 // Trim whitespace
4223 $text = trim($text);
4224
4225 return $text;
4226 }
4227
4228 private function find_relevant_pdf_pages($query_embedding, $embeddings) {
4229 //error_log(esc_html__("find_relevant_pdf_pages called.", 'mxchat'));
4230
4231 $most_relevant = null;
4232 $highest_similarity = -INF;
4233
4234 foreach ($embeddings as $page_data) {
4235 $similarity = $this->mxchat_calculate_cosine_similarity($query_embedding, $page_data['embedding']);
4236
4237 if ($similarity > $highest_similarity) {
4238 $highest_similarity = $similarity;
4239 $most_relevant = $page_data['page_number'];
4240 }
4241 }
4242
4243 if (!is_null($most_relevant)) {
4244 $page_numbers = range(max(1, $most_relevant - 1), min(count($embeddings), $most_relevant + 1));
4245 return array_filter($embeddings, function ($page) use ($page_numbers) {
4246 return in_array($page['page_number'], $page_numbers);
4247 });
4248 }
4249
4250 return [];
4251 }
4252
4253
4254 public function handle_pdf_upload() {
4255 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce(wp_unslash((string) $_POST['nonce']))) {
4256 wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'mxchat')), 403);
4257 }
4258
4259 if (!isset($_FILES['pdf_file']) || !isset($_POST['session_id'])) {
4260 wp_send_json_error(esc_html__('Missing required parameters.', 'mxchat'));
4261 return;
4262 }
4263
4264 // SECURITY FIX: Check if PDF uploads are enabled in settings
4265 $options = get_option('mxchat_options', array());
4266 $show_pdf_button = isset($options['show_pdf_upload_button']) ? $options['show_pdf_upload_button'] : 'on';
4267
4268 if ($show_pdf_button !== 'on') {
4269 wp_send_json_error(esc_html__('PDF uploads are currently disabled.', 'mxchat'));
4270 return;
4271 }
4272
4273 $file = $_FILES['pdf_file'];
4274 $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
4275 $original_filename = sanitize_text_field($file['name']);
4276
4277 // Update session owner if it changed (e.g. IP changed due to network switch)
4278 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
4279 $session_owner = MxChat_Session_Store::get($session_id, 'owner');
4280
4281 if (!$session_owner || $session_owner !== $current_user_identifier) {
4282 MxChat_Session_Store::set($session_id, 'owner', $current_user_identifier);
4283 }
4284
4285 $file_type = wp_check_filetype($file['name'], ['pdf' => 'application/pdf']);
4286 if ($file_type['type'] !== 'application/pdf') {
4287 wp_send_json_error(esc_html__('Invalid file type. Only PDF files are allowed.', 'mxchat'));
4288 return;
4289 }
4290
4291 $upload_dir = wp_upload_dir();
4292
4293 // SECURITY FIX: Generate random filename without exposing session_id
4294 $random_string = wp_generate_password(20, false, false); // 20 char alphanumeric string
4295 $pdf_filename = 'mxchat_' . $random_string . '_' . time() . '.pdf';
4296 $pdf_path = $upload_dir['path'] . '/' . $pdf_filename;
4297
4298 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
4299 wp_send_json_error(esc_html__('Failed to upload file.', 'mxchat'));
4300 return;
4301 }
4302
4303 $this->clear_pdf_transients($session_id);
4304
4305 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
4306 $embeddings = $this->fetch_and_split_pdf_pages($pdf_path, $max_pages);
4307
4308 if ($embeddings === 'too_many_pages') {
4309 unlink($pdf_path);
4310 $error_message = sprintf(
4311 $this->options['pdf_intent_error_text'] ??
4312 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
4313 $max_pages
4314 );
4315 wp_send_json_error($error_message);
4316 return;
4317 }
4318
4319 if ($embeddings === false || empty($embeddings)) {
4320 unlink($pdf_path);
4321 $error_message = $this->options['pdf_intent_error_text'] ??
4322 esc_html__('The uploaded PDF appears to be empty or contains unsupported content.', 'mxchat');
4323 // Zero pages can also mean every embedding call failed — say so instead of
4324 // blaming the file (104a75).
4325 wp_send_json_error($this->mxchat_pdf_error_text_with_reason($error_message));
4326 return;
4327 }
4328
4329 if (!empty($embeddings)) {
4330 // Store the mapping between session and the random filename
4331 set_transient('mxchat_pdf_url_' . $session_id, $pdf_path, HOUR_IN_SECONDS);
4332 set_transient('mxchat_pdf_filename_' . $session_id, $original_filename, HOUR_IN_SECONDS);
4333 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
4334 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
4335
4336 $success_message = $this->options['pdf_intent_success_text'] ??
4337 esc_html__("I've processed the PDF. What questions do you have about it?", 'mxchat');
4338
4339 wp_send_json_success([
4340 'message' => $success_message,
4341 'filename' => $original_filename
4342 ]);
4343 return;
4344 }
4345
4346 unlink($pdf_path);
4347 $error_message = $this->options['pdf_intent_error_text'] ??
4348 esc_html__('Sorry, I couldn\'t process the PDF. Please ensure it\'s a valid file.', 'mxchat');
4349 wp_send_json_error($error_message);
4350 return;
4351 }
4352 public function handle_pdf_remove() {
4353 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce(wp_unslash((string) $_POST['nonce']))) {
4354 wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'mxchat')), 403);
4355 }
4356
4357 if (empty($_POST['session_id'])) {
4358 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
4359 wp_die();
4360 }
4361
4362 $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
4363 if ($session_id === '') {
4364 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
4365 wp_die();
4366 }
4367
4368 // Session-ownership bookkeeping (plan-mxchat-20260731-d42bec).
4369 //
4370 // Be clear about what this does and does not do. It mirrors the history
4371 // endpoint's rule exactly, as directed, INCLUDING its changed-IP tolerance:
4372 // possession of the session id IS the credential, so a mismatched identifier
4373 // re-owns the session instead of being refused. That means this does NOT
4374 // refuse a caller who supplies someone else's session id — it keeps the two
4375 // endpoints agreeing about who owns a session, and records the owner so a
4376 // future stricter policy has trustworthy data to enforce against.
4377 //
4378 // What actually protects another visitor's upload here is that session ids
4379 // are 128-bit CSPRNG values (plan-0c17b5) and therefore not guessable. If we
4380 // ever want a real boundary on this endpoint, it has to be decided for the
4381 // history endpoint at the same time.
4382 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
4383 $session_owner = MxChat_Session_Store::get($session_id, 'owner');
4384 if (!$session_owner || $session_owner !== $current_user_identifier) {
4385 MxChat_Session_Store::set($session_id, 'owner', $current_user_identifier);
4386 }
4387
4388 $pdf_path = get_transient('mxchat_pdf_url_' . $session_id);
4389
4390 if ($pdf_path && file_exists($pdf_path)) {
4391 unlink($pdf_path);
4392 }
4393
4394 $this->clear_pdf_transients($session_id);
4395
4396 wp_send_json_success([
4397 'message' => esc_html__('PDF removed successfully.', 'mxchat')
4398 ]);
4399 wp_die();
4400 }
4401
4402
4403 function mxchat_fetch_new_messages() {
4404 $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
4405 $last_seen_id = sanitize_text_field($_POST['last_seen_id']);
4406 $persistence_enabled = $_POST['persistence_enabled'] === 'true';
4407 $initial_timestamp = isset($_POST['initial_timestamp']) ? intval($_POST['initial_timestamp']) : 0;
4408
4409 if (empty($session_id)) {
4410 //error_log(esc_html__('Fetch new messages error: Session ID missing.', 'mxchat'));
4411 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
4412 wp_die();
4413 }
4414
4415 $history = get_option("mxchat_history_{$session_id}", []);
4416
4417 //error_log("MxChat WhatsApp DEBUG: Fetch new messages for session {$session_id}");
4418 //error_log("MxChat WhatsApp DEBUG: last_seen_id = " . var_export($last_seen_id, true));
4419 //error_log("MxChat WhatsApp DEBUG: History count = " . count($history));
4420 //error_log("MxChat WhatsApp DEBUG: Full history = " . print_r($history, true));
4421
4422 $new_messages = array_filter($history, function ($message) use ($last_seen_id, $persistence_enabled, $initial_timestamp) {
4423 //error_log("MxChat WhatsApp DEBUG: Checking message - ID: " . ($message['id'] ?? 'NO_ID') . ", Role: " . ($message['role'] ?? 'NO_ROLE'));
4424
4425 // If persistence is enabled, show all new messages
4426 if ($persistence_enabled) {
4427 $has_id = !empty($message['id']);
4428 $is_agent = $message['role'] === 'agent';
4429
4430 // If last_seen_id is empty, 'NaN', or invalid, show all agent messages
4431 if (empty($last_seen_id) || $last_seen_id === 'NaN' || $last_seen_id === 'undefined') {
4432 $is_newer = true;
4433 } else {
4434 $is_newer = strcmp($message['id'] ?? '', $last_seen_id) > 0;
4435 }
4436
4437 //error_log("MxChat WhatsApp DEBUG: has_id={$has_id}, is_newer={$is_newer}, is_agent={$is_agent}");
4438
4439 return $has_id && $is_newer && $is_agent;
4440 }
4441
4442 // If persistence is disabled, only show messages after initial timestamp
4443 return !empty($message['id']) &&
4444 $message['role'] === 'agent' &&
4445 $message['timestamp'] > $initial_timestamp;
4446 });
4447
4448 //error_log("MxChat WhatsApp DEBUG: Filtered messages count = " . count($new_messages));
4449
4450 // Include current chat mode so frontend can detect agent→AI transitions
4451 $chat_mode = MxChat_Session_Store::get($session_id, 'mode', 'ai');
4452
4453 wp_send_json_success([
4454 'new_messages' => array_values($new_messages),
4455 'chat_mode' => $chat_mode
4456 ]);
4457 wp_die();
4458 }
4459 public function mxchat_live_agent_handover($message, $user_id, $session_id) {
4460 // First check if live agents are available.
4461 // Outside the SLACK availability schedule this behaves exactly like the
4462 // manual toggle being off — same away message, same stay-in-AI-mode path
4463 // (plans 8ccaa2 + 99d7a4: each channel owns its own schedule). The schedule
4464 // normally stops the tool being offered at all; this is the backstop for
4465 // any path that calls the handover directly.
4466 $live_agent_available = $this->options['live_agent_status'] ?? 'off';
4467 $within_hours = !class_exists('MxChat_Live_Agent_Schedule')
4468 || MxChat_Live_Agent_Schedule::is_within_hours('slack');
4469 if ($live_agent_available !== 'on' || !$within_hours) {
4470 $away_message = $this->options['live_agent_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
4471 $this->fallbackResponse = [
4472 'text' => $away_message,
4473 'html' => '',
4474 'images' => [],
4475 'chat_mode' => 'ai'
4476 ];
4477 wp_send_json([
4478 'text' => $away_message,
4479 'html' => '',
4480 'chat_mode' => 'ai',
4481 'session_id' => $session_id
4482 ]);
4483 wp_die();
4484 }
4485
4486 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4487
4488 if (empty($slack_bot_token)) {
4489 return false;
4490 }
4491
4492 // Check if channel already exists for this session
4493 $channel_id = MxChat_Session_Store::get($session_id, 'channel', '');
4494
4495 // Shared-channel mode (plan 9f7756): when a shared handoff channel is
4496 // configured and this session doesn't already own a per-conversation
4497 // channel, the handoff posts into the shared channel as a new thread
4498 // (or into the session's existing thread on a re-handover). Any failure
4499 // to reach the shared channel falls back to per-conversation creation
4500 // below, so a misconfigured channel never drops a handoff.
4501 $shared_channel_setting = trim($this->options['live_agent_shared_channel'] ?? '');
4502 $shared_thread_ts = get_option("mxchat_thread_{$session_id}", '');
4503 $use_shared_channel = ($shared_channel_setting !== '' && empty($channel_id));
4504
4505 if (empty($channel_id) && !$use_shared_channel) {
4506 $channel_id = $this->mxchat_create_conversation_channel($session_id);
4507 if (empty($channel_id)) {
4508 return false; // Failed to create channel
4509 }
4510 }
4511
4512 // Get recent chat history
4513 $history = get_option("mxchat_history_{$session_id}", []);
4514 $recent_history = array_slice($history, -5);
4515
4516 // Format conversation context
4517 $conversation_context = "";
4518 if (!empty($recent_history)) {
4519 $conversation_context = "*Recent Conversation:*\n";
4520 foreach ($recent_history as $hist_message) {
4521 $role_display = $hist_message['role'] === 'user' ? 'User' : 'AI';
4522 $conversation_context .= ">{$role_display}: {$hist_message['content']}\n";
4523 }
4524 $conversation_context .= "\n";
4525 }
4526
4527 MxChat_Session_Store::set($session_id, 'mode', 'agent');
4528
4529 // Send message to channel
4530 $channel_message = "🔔 *New Live Agent Request*\n\n";
4531 $channel_message .= "*Session ID:* `{$session_id}`\n";
4532 $channel_message .= "*User ID:* `{$user_id}`\n";
4533
4534 // Surface the captured visitor identity so the agent knows who they're talking to —
4535 // guest User IDs are 0, but the pre-chat gate / login / transcript often has name+email (plan-e2195b).
4536 $visitor = $this->mxchat_get_visitor_identity($session_id);
4537 if (!empty($visitor['name']) && !empty($visitor['email'])) {
4538 $channel_message .= "*Visitor:* {$visitor['name']} <{$visitor['email']}>\n";
4539 } elseif (!empty($visitor['email'])) {
4540 $channel_message .= "*Visitor:* <{$visitor['email']}>\n";
4541 } elseif (!empty($visitor['name'])) {
4542 $channel_message .= "*Visitor:* {$visitor['name']}\n";
4543 }
4544 $channel_message .= "\n";
4545
4546 if (!empty($conversation_context)) {
4547 $channel_message .= $conversation_context;
4548 }
4549
4550 $channel_message .= "*Current Message:*\n{$message}\n\n";
4551 if ($use_shared_channel) {
4552 $channel_message .= "_Reply in this thread - replies here go to the user. `!endchat` in this thread ends the chat._";
4553 } else {
4554 $channel_message .= "_Reply directly in this channel - all messages will go to the user_";
4555 }
4556
4557 if ($use_shared_channel) {
4558 $posted = $this->mxchat_post_shared_handoff($session_id, $channel_message, $shared_thread_ts);
4559 if (!$posted) {
4560 // Shared channel unreachable (wrong name/ID, bot not invited,
4561 // archived...). Fall back to the per-conversation flow so the
4562 // visitor still reaches an agent; the settings page surfaces the
4563 // recorded error to the admin.
4564 $use_shared_channel = false;
4565 $channel_id = $this->mxchat_create_conversation_channel($session_id);
4566 if (empty($channel_id)) {
4567 return false;
4568 }
4569 $channel_message = str_replace(
4570 "_Reply in this thread - replies here go to the user. `!endchat` in this thread ends the chat._",
4571 "_Reply directly in this channel - all messages will go to the user_",
4572 $channel_message
4573 );
4574 }
4575 }
4576
4577 if (!$use_shared_channel) {
4578 $handoff_post = wp_remote_post('https://slack.com/api/chat.postMessage', [
4579 'headers' => [
4580 'Content-Type' => 'application/json',
4581 'Authorization' => 'Bearer ' . $slack_bot_token
4582 ],
4583 'body' => json_encode([
4584 'channel' => $channel_id,
4585 'text' => $channel_message,
4586 'mrkdwn' => true
4587 ])
4588 ]);
4589 // Re-handover edge (plan 7458a7): the stored mxchat_channel_ may point
4590 // at a channel archived by the auto-archive toggle (or deleted by an
4591 // admin). Slack answers is_archived / channel_not_found — clear the
4592 // stale option, mint a fresh channel, and re-post ONCE so the handoff
4593 // is never silently dropped.
4594 if (!is_wp_error($handoff_post)) {
4595 $handoff_data = json_decode(wp_remote_retrieve_body($handoff_post), true);
4596 $handoff_err = isset($handoff_data['error']) ? $handoff_data['error'] : '';
4597 if (isset($handoff_data['ok']) && !$handoff_data['ok'] && in_array($handoff_err, array('is_archived', 'channel_not_found'), true)) {
4598 MxChat_Session_Store::delete($session_id, 'channel');
4599 $channel_id = $this->mxchat_create_conversation_channel($session_id);
4600 if (!empty($channel_id)) {
4601 wp_remote_post('https://slack.com/api/chat.postMessage', [
4602 'headers' => [
4603 'Content-Type' => 'application/json',
4604 'Authorization' => 'Bearer ' . $slack_bot_token
4605 ],
4606 'body' => json_encode([
4607 'channel' => $channel_id,
4608 'text' => $channel_message,
4609 'mrkdwn' => true
4610 ])
4611 ]);
4612 }
4613 }
4614 }
4615 }
4616
4617 $success_message = $this->options['live_agent_notification_message'] ?? 'Live agent has been notified.';
4618 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
4619
4620 $this->fallbackResponse = [
4621 'text' => $success_message,
4622 'html' => '',
4623 'images' => [],
4624 'chat_mode' => 'agent'
4625 ];
4626
4627 wp_send_json([
4628 'success' => true,
4629 'text' => $success_message,
4630 'html' => '',
4631 'chat_mode' => 'agent',
4632 'session_id' => $session_id,
4633 'fallbackResponse' => $this->fallbackResponse
4634 ]);
4635 wp_die();
4636 }
4637
4638 /**
4639 * Archive a session's per-conversation chat- channel after !endchat / session
4640 * cleanup (plan 7458a7). HARD GUARDS, in order: the toggle must be on
4641 * (default off = zero change for existing installs); a session with
4642 * mxchat_thread_ set is a 9f7756 SHARED-channel session and is never
4643 * archived; only the channel this session owns via mxchat_channel_ is
4644 * archived, and only when it matches the channel the caller is acting on.
4645 * Best-effort by design — a failed archive is logged and never blocks the
4646 * mode flip or cleanup.
4647 *
4648 * @param string $session_id
4649 * @param string $event_channel_id Channel the caller is acting on.
4650 */
4651 private function mxchat_maybe_archive_conversation_channel($session_id, $event_channel_id) {
4652 $toggle = $this->options['live_agent_archive_on_end_toggle'] ?? 'off';
4653 if ($toggle !== 'on') {
4654 return;
4655 }
4656 if (get_option("mxchat_thread_{$session_id}", '') !== '') {
4657 return; // shared-channel session — the shared channel is NEVER archived
4658 }
4659 $owned_channel = MxChat_Session_Store::get($session_id, 'channel', '');
4660 if ($owned_channel === '' || $owned_channel !== $event_channel_id) {
4661 return;
4662 }
4663 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4664 if (empty($slack_bot_token)) {
4665 return;
4666 }
4667 $response = wp_remote_post('https://slack.com/api/conversations.archive', [
4668 'headers' => [
4669 'Content-Type' => 'application/json',
4670 'Authorization' => 'Bearer ' . $slack_bot_token
4671 ],
4672 'body' => json_encode(['channel' => $owned_channel])
4673 ]);
4674 if (is_wp_error($response)) {
4675 error_log('MxChat: conversations.archive request failed: ' . $response->get_error_message());
4676 return;
4677 }
4678 $data = json_decode(wp_remote_retrieve_body($response), true);
4679 if (empty($data['ok'])) {
4680 error_log('MxChat: conversations.archive returned error: ' . (isset($data['error']) ? $data['error'] : 'unknown'));
4681 }
4682 }
4683
4684 /**
4685 * Create a dedicated per-conversation Slack channel for a session and invite
4686 * the configured agents. Extracted from mxchat_live_agent_handover so the
4687 * shared-channel mode (plan 9f7756) can reuse it as its fallback path.
4688 *
4689 * @param string $session_id
4690 * @return string Channel ID, or '' on failure.
4691 */
4692 private function mxchat_create_conversation_channel($session_id) {
4693 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4694 if (empty($slack_bot_token)) {
4695 return '';
4696 }
4697
4698 $channel_id = '';
4699 $channel_name = $this->generate_channel_name($session_id);
4700
4701 $response = wp_remote_post('https://slack.com/api/conversations.create', [
4702 'headers' => [
4703 'Content-Type' => 'application/json',
4704 'Authorization' => 'Bearer ' . $slack_bot_token
4705 ],
4706 'body' => json_encode([
4707 'name' => $channel_name,
4708 'is_private' => false // Public channel - anyone in workspace can join
4709 ])
4710 ]);
4711
4712 if (!is_wp_error($response)) {
4713 $response_data = json_decode(wp_remote_retrieve_body($response), true);
4714
4715 if (isset($response_data['ok']) && $response_data['ok']) {
4716 $channel_id = $response_data['channel']['id'];
4717 MxChat_Session_Store::set($session_id, 'channel', $channel_id);
4718
4719 // Auto-invite agents to the channel
4720 $agent_user_ids = $this->options['live_agent_user_ids'] ?? '';
4721
4722 if (!empty($agent_user_ids)) {
4723 // Parse user IDs (one per line)
4724 $user_ids = array_filter(array_map('trim', explode("\n", $agent_user_ids)));
4725
4726 foreach ($user_ids as $user_id_to_invite) {
4727 wp_remote_post('https://slack.com/api/conversations.invite', [
4728 'headers' => [
4729 'Content-Type' => 'application/json',
4730 'Authorization' => 'Bearer ' . $slack_bot_token
4731 ],
4732 'body' => json_encode([
4733 'channel' => $channel_id,
4734 'users' => $user_id_to_invite
4735 ])
4736 ]);
4737 }
4738 }
4739 }
4740 }
4741
4742 return $channel_id;
4743 }
4744
4745 /**
4746 * Post a handoff (or a re-handover) into the configured shared channel.
4747 * First post per session becomes the conversation's thread root; its ts is
4748 * stored in mxchat_thread_{session} and every later message rides that
4749 * thread. Records the Slack error for the settings page on failure so the
4750 * caller can fall back to per-conversation creation.
4751 *
4752 * @param string $session_id
4753 * @param string $text Fully-built handoff message.
4754 * @param string $thread_ts Existing thread root for this session, '' if none.
4755 * @return bool True when the message reached the shared channel.
4756 */
4757 private function mxchat_post_shared_handoff($session_id, $text, $thread_ts = '') {
4758 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4759 $configured = trim($this->options['live_agent_shared_channel'] ?? '');
4760 if (empty($slack_bot_token) || $configured === '') {
4761 return false;
4762 }
4763
4764 // Posting by #name works once the bot is a member; the response carries
4765 // the real channel ID, cached so the inbound webhook and user-relay
4766 // don't depend on how the admin wrote the setting.
4767 $cache = get_option('mxchat_slack_shared_channel_id', array());
4768 $target = (is_array($cache) && ($cache['configured'] ?? '') === $configured && !empty($cache['id']))
4769 ? $cache['id']
4770 : ltrim($configured, '#');
4771
4772 $body = [
4773 'channel' => $target,
4774 'text' => $text,
4775 'mrkdwn' => true
4776 ];
4777 if ($thread_ts !== '') {
4778 $body['thread_ts'] = $thread_ts;
4779 }
4780
4781 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
4782 'headers' => [
4783 'Content-Type' => 'application/json',
4784 'Authorization' => 'Bearer ' . $slack_bot_token
4785 ],
4786 'body' => json_encode($body)
4787 ]);
4788
4789 if (is_wp_error($response)) {
4790 update_option('mxchat_slack_shared_channel_error', array(
4791 'error' => $response->get_error_message(),
4792 'configured' => $configured,
4793 'time' => time(),
4794 ), false);
4795 return false;
4796 }
4797
4798 $data = json_decode(wp_remote_retrieve_body($response), true);
4799 if (empty($data['ok'])) {
4800 update_option('mxchat_slack_shared_channel_error', array(
4801 'error' => $data['error'] ?? 'unknown_error',
4802 'configured' => $configured,
4803 'time' => time(),
4804 ), false);
4805 return false;
4806 }
4807
4808 delete_option('mxchat_slack_shared_channel_error');
4809
4810 if (!empty($data['channel'])) {
4811 update_option('mxchat_slack_shared_channel_id', array(
4812 'configured' => $configured,
4813 'id' => $data['channel'],
4814 ), false);
4815 }
4816 if ($thread_ts === '' && !empty($data['ts'])) {
4817 update_option("mxchat_thread_{$session_id}", $data['ts'], 'no');
4818 }
4819
4820 return true;
4821 }
4822
4823 private function generate_channel_name($session_id) {
4824 $email = null;
4825 $name = null;
4826
4827 // 1. First priority: Check if user is logged in and get their info
4828 if (is_user_logged_in()) {
4829 $current_user = wp_get_current_user();
4830 if (!empty($current_user->user_email)) {
4831 $email = $current_user->user_email;
4832 //error_log("[DEBUG] Using logged-in user email for channel: {$email}");
4833 }
4834 if (!empty($current_user->display_name)) {
4835 $name = $current_user->display_name;
4836 //error_log("[DEBUG] Using logged-in user name for channel: {$name}");
4837 }
4838 }
4839
4840 // 2. Second priority: Check for saved email/name from "require email to chat" option
4841 if (empty($email)) {
4842 $email_option_key = "mxchat_email_{$session_id}";
4843 $saved_email = get_option($email_option_key);
4844 if (!empty($saved_email)) {
4845 $email = $saved_email;
4846 //error_log("[DEBUG] Using saved email from session for channel: {$email}");
4847 }
4848 }
4849
4850 if (empty($name)) {
4851 $name_option_key = "mxchat_name_{$session_id}";
4852 $saved_name = get_option($name_option_key);
4853 if (!empty($saved_name)) {
4854 $name = $saved_name;
4855 //error_log("[DEBUG] Using saved name from session for channel: {$name}");
4856 }
4857 }
4858
4859 // 3. Third priority: Check existing chat transcript for email/name
4860 if (empty($email) || empty($name)) {
4861 global $wpdb;
4862 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
4863 $existing_data = $wpdb->get_row($wpdb->prepare(
4864 "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",
4865 $session_id
4866 ));
4867
4868 if ($existing_data) {
4869 if (empty($email) && !empty($existing_data->user_email)) {
4870 $email = $existing_data->user_email;
4871 //error_log("[DEBUG] Using email from chat transcript for channel: {$email}");
4872 }
4873 if (empty($name) && !empty($existing_data->user_name)) {
4874 $name = $existing_data->user_name;
4875 //error_log("[DEBUG] Using name from chat transcript for channel: {$name}");
4876 }
4877 }
4878 }
4879
4880 // 4. Generate channel name based on priority: Name > Email > Session ID
4881 $channel_name = '';
4882
4883 if (!empty($name)) {
4884 // Convert name to valid Slack channel name
4885 $base_name = strtolower(trim($name));
4886 // Replace spaces and invalid characters
4887 $base_name = preg_replace('/[^a-z0-9\s]/', '', $base_name);
4888 $base_name = preg_replace('/\s+/', '-', $base_name);
4889 $base_name = trim($base_name, '-');
4890
4891 // Get last 4 characters of session ID for uniqueness
4892 $session_suffix = substr($session_id, -4);
4893 $channel_name = 'chat-' . $base_name . '-' . strtolower($session_suffix);
4894
4895 // Slack channel names have a 21 character limit
4896 if (strlen($channel_name) > 21) {
4897 // Calculate available space for name (21 - 'chat-' - '-' - session_suffix)
4898 $available_space = 21 - 5 - 1 - strlen($session_suffix); // 'chat-' = 5, '-' = 1
4899 $truncated_name = substr($base_name, 0, $available_space);
4900 $truncated_name = rtrim($truncated_name, '-'); // Remove trailing hyphen
4901 $channel_name = 'chat-' . $truncated_name . '-' . strtolower($session_suffix);
4902 }
4903
4904 //error_log("[DEBUG] Using name for channel: {$channel_name} (from name: {$name})");
4905
4906 } elseif (!empty($email)) {
4907 // Convert email to valid Slack channel name (your existing logic)
4908 $channel_name = 'chat-' . strtolower(str_replace(['@', '.', '+', '_'], ['-at-', '-', '-plus-', '-'], $email));
4909 // Remove any remaining invalid characters
4910 $channel_name = preg_replace('/[^a-z0-9\-]/', '', $channel_name);
4911 // Ensure it doesn't end with a hyphen
4912 $channel_name = rtrim($channel_name, '-');
4913 // Slack channel names have a 21 character limit, so truncate if needed
4914 if (strlen($channel_name) > 21) {
4915 $channel_name = substr($channel_name, 0, 21);
4916 $channel_name = rtrim($channel_name, '-'); // Remove trailing hyphen if truncation created one
4917 }
4918
4919 //error_log("[DEBUG] Using email for channel: {$channel_name} (from email: {$email})");
4920
4921 } else {
4922 // Fallback to session ID if no name or email found
4923 $channel_name = 'chat-' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $session_id));
4924 //error_log("[DEBUG] No name or email found, using session ID for channel: {$channel_name}");
4925 }
4926
4927 // Final validation - ensure channel name meets Slack requirements
4928 if (strlen($channel_name) > 21) {
4929 $channel_name = substr($channel_name, 0, 21);
4930 $channel_name = rtrim($channel_name, '-');
4931 }
4932
4933 //error_log("[DEBUG] Generated channel name: {$channel_name}");
4934 return $channel_name;
4935 }
4936
4937 /**
4938 * Telegram Live Agent Handover
4939 * Creates a forum topic in the Telegram group and notifies agents
4940 */
4941 public function mxchat_telegram_live_agent_handover($message, $user_id, $session_id) {
4942 // Check if Telegram agents are available. Telegram has its OWN availability
4943 // schedule, independent of Slack's (plan 99d7a4 — each Integrations tab
4944 // owns its scheduler). Backstop only; the tool is normally withheld
4945 // off-hours.
4946 $telegram_available = $this->options['telegram_status'] ?? 'off';
4947 $within_hours = !class_exists('MxChat_Live_Agent_Schedule')
4948 || MxChat_Live_Agent_Schedule::is_within_hours('telegram');
4949 if ($telegram_available !== 'on' || !$within_hours) {
4950 $away_message = $this->options['telegram_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
4951 $this->fallbackResponse = [
4952 'text' => $away_message,
4953 'html' => '',
4954 'images' => [],
4955 'chat_mode' => 'ai'
4956 ];
4957 wp_send_json([
4958 'text' => $away_message,
4959 'html' => '',
4960 'chat_mode' => 'ai',
4961 'session_id' => $session_id
4962 ]);
4963 wp_die();
4964 }
4965
4966 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4967 $telegram_group_id = $this->options['telegram_group_id'] ?? '';
4968
4969 if (empty($telegram_bot_token) || empty($telegram_group_id)) {
4970 return false;
4971 }
4972
4973 // Check if topic already exists for this session
4974 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
4975
4976 if (empty($topic_id)) {
4977 // Generate topic name
4978 $topic_name = $this->generate_telegram_topic_name($session_id);
4979
4980 // Random icon color (Telegram forum topic colors)
4981 $icon_colors = [0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F];
4982 $icon_color = $icon_colors[array_rand($icon_colors)];
4983
4984 // Create forum topic
4985 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/createForumTopic", [
4986 'headers' => ['Content-Type' => 'application/json'],
4987 'body' => json_encode([
4988 'chat_id' => $telegram_group_id,
4989 'name' => $topic_name,
4990 'icon_color' => $icon_color
4991 ])
4992 ]);
4993
4994 if (!is_wp_error($response)) {
4995 $response_body = wp_remote_retrieve_body($response);
4996 $response_data = json_decode($response_body, true);
4997
4998 if (isset($response_data['ok']) && $response_data['ok']) {
4999 $topic_id = $response_data['result']['message_thread_id'];
5000 update_option("mxchat_telegram_topic_{$session_id}", $topic_id);
5001 update_option("mxchat_telegram_group_{$session_id}", $telegram_group_id);
5002 }
5003 }
5004
5005 if (empty($topic_id)) {
5006 return false; // Failed to create topic
5007 }
5008 }
5009
5010 // Get recent chat history
5011 $history = get_option("mxchat_history_{$session_id}", []);
5012 $recent_history = array_slice($history, -5);
5013
5014 // Format conversation context for Telegram (HTML format)
5015 $conversation_context = "";
5016 if (!empty($recent_history)) {
5017 $conversation_context = "<b>Recent Conversation:</b>\n";
5018 foreach ($recent_history as $hist_message) {
5019 $role_display = $hist_message['role'] === 'user' ? '👤 User' : '🤖 AI';
5020 $escaped_content = htmlspecialchars($hist_message['content'], ENT_QUOTES, 'UTF-8');
5021 $conversation_context .= "{$role_display}: {$escaped_content}\n";
5022 }
5023 $conversation_context .= "\n";
5024 }
5025
5026 // Get user info
5027 $user_email = get_option("mxchat_email_{$session_id}", 'Not provided');
5028 $user_name = get_option("mxchat_name_{$session_id}", 'Anonymous');
5029
5030 // Update session mode
5031 MxChat_Session_Store::set($session_id, 'mode', 'agent');
5032
5033 // Send initial message to topic
5034 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
5035 $topic_message = "🔔 <b>New Live Agent Request</b>\n\n";
5036 $topic_message .= "<b>Session ID:</b> <code>{$session_id}</code>\n";
5037 $topic_message .= "<b>User:</b> {$user_name}\n";
5038 $topic_message .= "<b>Email:</b> {$user_email}\n\n";
5039
5040 if (!empty($conversation_context)) {
5041 $topic_message .= $conversation_context;
5042 }
5043
5044 $topic_message .= "<b>Current Message:</b>\n{$escaped_message}\n\n";
5045 $topic_message .= "<i>Reply in this topic - messages will be sent to the user</i>\n";
5046 $topic_message .= "<i>Type #close, #end, #disconnect, or #done to end the session</i>";
5047
5048 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
5049 'headers' => ['Content-Type' => 'application/json'],
5050 'body' => json_encode([
5051 'chat_id' => $telegram_group_id,
5052 'message_thread_id' => $topic_id,
5053 'text' => $topic_message,
5054 'parse_mode' => 'HTML'
5055 ])
5056 ]);
5057
5058 $success_message = $this->options['telegram_notification_message'] ?? "I've notified a support agent. Please allow a moment for them to respond.";
5059 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
5060
5061 $this->fallbackResponse = [
5062 'text' => $success_message,
5063 'html' => '',
5064 'images' => [],
5065 'chat_mode' => 'agent'
5066 ];
5067
5068 wp_send_json([
5069 'success' => true,
5070 'text' => $success_message,
5071 'html' => '',
5072 'chat_mode' => 'agent',
5073 'session_id' => $session_id,
5074 'fallbackResponse' => $this->fallbackResponse
5075 ]);
5076 wp_die();
5077 }
5078
5079 /**
5080 * Generate topic name for Telegram forum
5081 */
5082 private function generate_telegram_topic_name($session_id) {
5083 $name = null;
5084 $email = null;
5085
5086 // Check logged in user
5087 if (is_user_logged_in()) {
5088 $current_user = wp_get_current_user();
5089 if (!empty($current_user->display_name)) {
5090 $name = $current_user->display_name;
5091 }
5092 if (!empty($current_user->user_email)) {
5093 $email = $current_user->user_email;
5094 }
5095 }
5096
5097 // Check session data
5098 if (empty($name)) {
5099 $name = get_option("mxchat_name_{$session_id}");
5100 }
5101 if (empty($email)) {
5102 $email = get_option("mxchat_email_{$session_id}");
5103 }
5104
5105 // Generate topic name
5106 $session_suffix = substr($session_id, -6);
5107
5108 if (!empty($name)) {
5109 // Clean name for topic (max 128 chars in Telegram)
5110 $clean_name = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $name);
5111 $clean_name = trim($clean_name);
5112 if (strlen($clean_name) > 50) {
5113 $clean_name = substr($clean_name, 0, 50);
5114 }
5115 return "Chat - {$clean_name} ({$session_suffix})";
5116 } elseif (!empty($email)) {
5117 // Use email prefix
5118 $email_prefix = explode('@', $email)[0];
5119 if (strlen($email_prefix) > 30) {
5120 $email_prefix = substr($email_prefix, 0, 30);
5121 }
5122 return "Chat - {$email_prefix} ({$session_suffix})";
5123 }
5124
5125 return "Chat - {$session_suffix}";
5126 }
5127
5128 /**
5129 * Send user message to Telegram agent
5130 */
5131 public function mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id) {
5132 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
5133 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
5134 $group_id = get_option("mxchat_telegram_group_{$session_id}", '');
5135
5136 if (empty($telegram_bot_token) || empty($topic_id) || empty($group_id)) {
5137 return false;
5138 }
5139
5140 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
5141 $user_message = "👤 <b>User:</b> {$escaped_message}";
5142
5143 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
5144 'headers' => ['Content-Type' => 'application/json'],
5145 'body' => json_encode([
5146 'chat_id' => $group_id,
5147 'message_thread_id' => $topic_id,
5148 'text' => $user_message,
5149 'parse_mode' => 'HTML'
5150 ])
5151 ]);
5152
5153 return !is_wp_error($response);
5154 }
5155
5156 /**
5157 * Handle incoming Telegram webhook
5158 */
5159 public function handle_telegram_webhook(WP_REST_Request $request) {
5160 $body = $request->get_body();
5161 $data = json_decode($body, true);
5162
5163 //error_log('[MxChat Telegram DEBUG] Webhook received: ' . $body);
5164
5165 // Handle message events from forum topics
5166 if (isset($data['message'])) {
5167 $message_data = $data['message'];
5168
5169 // Skip if not from a forum topic
5170 if (!isset($message_data['message_thread_id'])) {
5171 //error_log('[MxChat Telegram DEBUG] Skipped: No message_thread_id (not a forum topic message)');
5172 return new WP_REST_Response(['ok' => true]);
5173 }
5174
5175 // Skip bot messages
5176 if (isset($message_data['from']['is_bot']) && $message_data['from']['is_bot']) {
5177 //error_log('[MxChat Telegram DEBUG] Skipped: Message from bot');
5178 return new WP_REST_Response(['ok' => true]);
5179 }
5180
5181 $chat_id = $message_data['chat']['id'] ?? '';
5182 $topic_id = $message_data['message_thread_id'];
5183 $message_text = $message_data['text'] ?? '';
5184 $message_id = $message_data['message_id'] ?? '';
5185 $from = $message_data['from'] ?? [];
5186 $agent_name = trim(($from['first_name'] ?? '') . ' ' . ($from['last_name'] ?? ''));
5187 if (empty($agent_name)) {
5188 $agent_name = $from['username'] ?? 'Agent';
5189 }
5190
5191 //error_log("[MxChat Telegram DEBUG] Parsed: chat_id={$chat_id}, topic_id={$topic_id}, agent={$agent_name}, text={$message_text}");
5192
5193 // Skip empty messages
5194 if (empty($message_text)) {
5195 //error_log('[MxChat Telegram DEBUG] Skipped: Empty message text');
5196 return new WP_REST_Response(['ok' => true]);
5197 }
5198
5199 // Find session ID by topic ID - cast to string for comparison
5200 global $wpdb;
5201 $topic_id_str = strval($topic_id);
5202 $session_option = $wpdb->get_var(
5203 $wpdb->prepare(
5204 "SELECT option_name FROM {$wpdb->options}
5205 WHERE option_name LIKE %s
5206 AND option_value = %s",
5207 'mxchat_telegram_topic_%',
5208 $topic_id_str
5209 )
5210 );
5211
5212 //error_log("[MxChat Telegram DEBUG] Looking for topic_id={$topic_id_str} in options, found: " . ($session_option ?: 'NULL'));
5213
5214 if ($session_option) {
5215 $session_id = str_replace('mxchat_telegram_topic_', '', $session_option);
5216 //error_log("[MxChat Telegram DEBUG] Session ID: {$session_id}");
5217
5218 // Verify the group ID matches
5219 $stored_group_id = get_option("mxchat_telegram_group_{$session_id}", '');
5220 //error_log("[MxChat Telegram DEBUG] Stored group_id={$stored_group_id}, received chat_id={$chat_id}");
5221
5222 if (strval($stored_group_id) != strval($chat_id)) {
5223 //error_log('[MxChat Telegram DEBUG] Skipped: Group ID mismatch');
5224 return new WP_REST_Response(['ok' => true]);
5225 }
5226
5227 // Check for closure commands
5228 $lower_text = strtolower(trim($message_text));
5229 if (in_array($lower_text, ['#close', '#end', '#disconnect', '#done'])) {
5230 //error_log("[MxChat Telegram DEBUG] Closure command received: {$lower_text}");
5231 // End the live agent session
5232 MxChat_Session_Store::set($session_id, 'mode', 'ai');
5233
5234 // Save disconnect message
5235 $disconnect_message = "Live agent session ended. You're now chatting with the AI assistant.";
5236 $this->mxchat_save_chat_message($session_id, 'bot', $disconnect_message);
5237
5238 // Notify in Telegram
5239 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
5240 if (!empty($telegram_bot_token)) {
5241 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
5242 'headers' => ['Content-Type' => 'application/json'],
5243 'body' => json_encode([
5244 'chat_id' => $chat_id,
5245 'message_thread_id' => $topic_id,
5246 'text' => "✅ Session closed. User returned to AI chatbot.",
5247 'parse_mode' => 'HTML'
5248 ])
5249 ]);
5250
5251 // Optionally close the topic
5252 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/closeForumTopic", [
5253 'headers' => ['Content-Type' => 'application/json'],
5254 'body' => json_encode([
5255 'chat_id' => $chat_id,
5256 'message_thread_id' => $topic_id
5257 ])
5258 ]);
5259 }
5260
5261 return new WP_REST_Response(['ok' => true]);
5262 }
5263
5264 // Deduplicate messages
5265 $message_key = md5($session_id . $message_id . $message_text);
5266 $processed_messages = get_transient('mxchat_telegram_messages_' . $session_id) ?: [];
5267
5268 if (in_array($message_key, $processed_messages)) {
5269 //error_log('[MxChat Telegram DEBUG] Skipped: Duplicate message');
5270 return new WP_REST_Response(['ok' => true]);
5271 }
5272
5273 $processed_messages[] = $message_key;
5274 if (count($processed_messages) > 50) {
5275 $processed_messages = array_slice($processed_messages, -50);
5276 }
5277 set_transient('mxchat_telegram_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
5278
5279 // Save the agent message - format with agent name prefix for proper parsing
5280 $formatted_message = "Agent: {$agent_name} - {$message_text}";
5281 //error_log("[MxChat Telegram DEBUG] Saving agent message: {$formatted_message}");
5282
5283 $this->mxchat_save_chat_message($session_id, 'agent', $formatted_message);
5284
5285 // Verify the message was saved to history
5286 $history = get_option("mxchat_history_{$session_id}", []);
5287 $last_message = end($history);
5288 //error_log("[MxChat Telegram DEBUG] History after save - count: " . count($history) . ", last message role: " . ($last_message['role'] ?? 'none'));
5289
5290 // Send confirmation back to Telegram
5291 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
5292 if (!empty($telegram_bot_token)) {
5293 $confirm_key = 'mxchat_telegram_confirm_' . $message_key;
5294 if (!get_transient($confirm_key)) {
5295 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
5296 'headers' => ['Content-Type' => 'application/json'],
5297 'body' => json_encode([
5298 'chat_id' => $chat_id,
5299 'message_thread_id' => $topic_id,
5300 'text' => "✅ <i>Message sent to user</i>",
5301 'parse_mode' => 'HTML',
5302 'reply_to_message_id' => $message_id
5303 ])
5304 ]);
5305 set_transient($confirm_key, true, 300);
5306 }
5307 }
5308 } else {
5309 //error_log("[MxChat Telegram DEBUG] No session found for topic_id={$topic_id}");
5310 }
5311 } else {
5312 //error_log('[MxChat Telegram DEBUG] No message in webhook data');
5313 }
5314
5315 return new WP_REST_Response(['ok' => true]);
5316 }
5317
5318 public function mxchat_send_user_message_to_agent($message, $user_id, $session_id) {
5319 // Check if this is a Telegram agent session
5320 $telegram_topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
5321 if (!empty($telegram_topic_id)) {
5322 return $this->mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id);
5323 }
5324
5325 // Otherwise, try Slack
5326 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
5327
5328 // Shared-channel session: the conversation lives in a thread of the
5329 // shared channel (plan 9f7756); relay user messages into that thread.
5330 $thread_ts = get_option("mxchat_thread_{$session_id}", '');
5331 if (!empty($thread_ts)) {
5332 $cache = get_option('mxchat_slack_shared_channel_id', array());
5333 $channel_id = is_array($cache) ? ($cache['id'] ?? '') : '';
5334 } else {
5335 $channel_id = MxChat_Session_Store::get($session_id, 'channel', '');
5336 }
5337
5338 if (empty($slack_bot_token) || empty($channel_id)) {
5339 return false;
5340 }
5341
5342 $user_message = "💬 *User:* {$message}";
5343
5344 $body = [
5345 'channel' => $channel_id,
5346 'text' => $user_message,
5347 'mrkdwn' => true
5348 ];
5349 if (!empty($thread_ts)) {
5350 $body['thread_ts'] = $thread_ts;
5351 }
5352
5353 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
5354 'headers' => [
5355 'Content-Type' => 'application/json',
5356 'Authorization' => 'Bearer ' . $slack_bot_token
5357 ],
5358 'body' => json_encode($body)
5359 ]);
5360
5361 return !is_wp_error($response);
5362 }
5363 public function handle_slack_interaction(WP_REST_Request $request) {
5364 //error_log('Received Slack interaction');
5365
5366 $payload = json_decode($request->get_param('payload'), true);
5367 //error_log('Payload: ' . print_r($payload, true));
5368
5369 // Handle button click
5370 if ($payload['type'] === 'block_actions' && $payload['actions'][0]['action_id'] === 'reply_to_user') {
5371 $session_id = $payload['actions'][0]['value'];
5372 $trigger_id = $payload['trigger_id'];
5373
5374 // Get Bot Token from settings
5375 $slack_token = $this->options['live_agent_bot_token'] ?? '';
5376
5377 if (empty($slack_token)) {
5378 //error_log('Slack Bot Token not configured');
5379 return new WP_REST_Response(['error' => esc_html__('Bot token not configured', 'mxchat')], 400);
5380 }
5381 $response = wp_remote_post('https://slack.com/api/views.open', [
5382 'headers' => [
5383 'Content-Type' => 'application/json',
5384 'Authorization' => 'Bearer ' . $slack_token
5385 ],
5386 'body' => json_encode([
5387 'trigger_id' => $trigger_id,
5388 'view' => [
5389 'type' => 'modal',
5390 'callback_id' => 'reply_modal',
5391 'title' => [
5392 'type' => 'plain_text',
5393 'text' => __('Reply to User', 'mxchat')
5394 ],
5395 'submit' => [
5396 'type' => 'plain_text',
5397 'text' => __('Send', 'mxchat')
5398 ],
5399 'close' => [
5400 'type' => 'plain_text',
5401 'text' => __('Cancel', 'mxchat')
5402 ],
5403 'blocks' => [
5404 [
5405 'type' => 'input',
5406 'block_id' => 'reply_block',
5407 'label' => [
5408 'type' => 'plain_text',
5409 'text' => sprintf(__('Reply to session: %s', 'mxchat'), $session_id)
5410 ],
5411 'element' => [
5412 'type' => 'plain_text_input',
5413 'action_id' => 'message',
5414 'multiline' => true,
5415 'placeholder' => [
5416 'type' => 'plain_text',
5417 'text' => __('Type your message here...', 'mxchat')
5418 ]
5419 ]
5420 ]
5421 ],
5422 'private_metadata' => $session_id
5423 ]
5424 ])
5425 ]);
5426
5427 //error_log('Views.open response: ' . print_r($response, true));
5428
5429 // Return immediate acknowledgment
5430 return new WP_REST_Response(['ok' => true]);
5431 }
5432
5433 // Handle modal submission
5434 // Handle modal submission
5435 if ($payload['type'] === 'view_submission') {
5436 $session_id = $payload['view']['private_metadata'];
5437 $message = $payload['view']['state']['values']['reply_block']['message']['value'];
5438
5439 // Save the message (keep the message_id but don't include in response)
5440 $this->mxchat_save_chat_message($session_id, 'agent', $message);
5441
5442 // Keep the original response format for Slack
5443 return new WP_REST_Response([
5444 'response_action' => 'clear'
5445 ]);
5446 }
5447
5448 // Default acknowledgment
5449 return new WP_REST_Response(['ok' => true]);
5450 }
5451 public function mxchat_handle_agent_response(WP_REST_Request $request) {
5452 //error_log('Received agent response request');
5453 //error_log('Request data: ' . print_r($request->get_params(), true));
5454 // //error_log('Raw body: ' . file_get_contents('php://input'));
5455
5456 // Get the data from Slack's slash command format
5457 $command_text = $request->get_param('text');
5458 // //error_log('Command text: ' . $command_text);
5459
5460 if (empty($command_text)) {
5461 //error_log(esc_html__('Agent response error: No command text received', 'mxchat'));
5462 return new WP_REST_Response([
5463 'error' => esc_html__('Command text is required. Format: /reply session_id message', 'mxchat')
5464 ], 400);
5465 }
5466
5467 // Split the command text into session_id and message
5468 $parts = explode(' ', $command_text, 2);
5469 if (count($parts) !== 2) {
5470 //error_log('Agent response error: Invalid command format');
5471 return new WP_REST_Response([
5472 'error' => esc_html__('Invalid format. Use: /reply session_id message', 'mxchat')
5473 ], 400);
5474 }
5475
5476 $session_id = sanitize_text_field($parts[0]);
5477 $message = sanitize_text_field($parts[1]);
5478
5479 //error_log("Processing agent response - Session ID: $session_id, Message: $message");
5480
5481 // Save the message
5482 $message_id = $this->mxchat_save_chat_message($session_id, 'agent', $message);
5483
5484 if (!$message_id) {
5485 // //error_log('Failed to save agent message');
5486 return new WP_REST_Response([
5487 'error' => esc_html__('Failed to save message', 'mxchat')
5488 ], 500);
5489 }
5490
5491 // Return success response in Slack's expected format
5492 return new WP_REST_Response([
5493 'response_type' => 'in_channel',
5494 'text' => esc_html__("Message sent successfully to session $session_id", 'mxchat')
5495 ], 200);
5496 }
5497 public function mxchat_handle_switch_to_chatbot_intent($message, $user_id, $session_id) {
5498 // Update mode to AI
5499 MxChat_Session_Store::set($session_id, 'mode', 'ai');
5500
5501 // Clear any existing PDF context to start fresh
5502 $this->clear_pdf_transients($session_id);
5503
5504 // Set the response with explicit chat_mode
5505 $this->fallbackResponse = [
5506 'text' => esc_html__('You are now chatting with the AI chatbot.', 'mxchat'),
5507 'html' => '',
5508 'images' => [],
5509 'chat_mode' => 'ai' // Ensure this is set
5510 ];
5511
5512 // Return the complete response array instead of just true
5513 return $this->fallbackResponse;
5514 }
5515
5516 /**
5517 * Normalize Slack mrkdwn before relaying an agent's message to the web visitor.
5518 * Slack's Events API auto-wraps URLs as <https://url> or <https://url|Label>, wraps
5519 * mentions as <@U…>/<#C…|name>, and HTML-escapes &, <, >. Relayed raw, the visitor
5520 * sees a broken/doubled link with a trailing > (plan-e2195b). Unwrap links FIRST, then
5521 * unescape entities LAST so extracted URLs (which can contain &amp;) are not corrupted.
5522 */
5523 private function normalize_slack_text($text) {
5524 if (!is_string($text) || $text === '') {
5525 return $text;
5526 }
5527
5528 $text = preg_replace_callback('/<([^>|]+)(?:\|([^>]*))?>/', function ($m) {
5529 $target = $m[1];
5530 $label = isset($m[2]) ? $m[2] : '';
5531
5532 // User/channel mentions: <@U…> or <#C…|name> — prefer the human label, else drop the id.
5533 if (isset($target[0]) && ($target[0] === '@' || $target[0] === '#')) {
5534 return $label !== '' ? $label : '';
5535 }
5536 // mailto:/tel: — strip the scheme for display.
5537 if (stripos($target, 'mailto:') === 0) {
5538 $addr = substr($target, 7);
5539 return ($label !== '' && $label !== $addr) ? "{$label} ({$addr})" : $addr;
5540 }
5541 if (stripos($target, 'tel:') === 0) {
5542 $num = substr($target, 4);
5543 return ($label !== '' && $label !== $num) ? "{$label} ({$num})" : $num;
5544 }
5545 // Regular URL: <url|Label> -> "Label (url)"; bare <url> -> "url".
5546 if ($label !== '' && $label !== $target) {
5547 return "{$label} ({$target})";
5548 }
5549 return $target;
5550 }, $text);
5551
5552 // Entity-unescape LAST (after link extraction) so &amp; inside URLs is repaired too.
5553 $text = str_replace(array('&amp;', '&lt;', '&gt;'), array('&', '<', '>'), $text);
5554
5555 return $text;
5556 }
5557
5558 /**
5559 * Resolve the visitor's name + email for a session, mirroring generate_channel_name()'s
5560 * priority order: logged-in user, then the pre-chat gate options (mxchat_email_/mxchat_name_),
5561 * then the chat transcript. Returns ['name' => ..., 'email' => ...] (either may be ''). plan-e2195b.
5562 */
5563 private function mxchat_get_visitor_identity($session_id) {
5564 $email = '';
5565 $name = '';
5566
5567 if (is_user_logged_in()) {
5568 $current_user = wp_get_current_user();
5569 if (!empty($current_user->user_email)) { $email = $current_user->user_email; }
5570 if (!empty($current_user->display_name)) { $name = $current_user->display_name; }
5571 }
5572
5573 if (empty($email)) {
5574 $saved_email = get_option("mxchat_email_{$session_id}", '');
5575 if (!empty($saved_email)) { $email = $saved_email; }
5576 }
5577 if (empty($name)) {
5578 $saved_name = get_option("mxchat_name_{$session_id}", '');
5579 if (!empty($saved_name)) { $name = $saved_name; }
5580 }
5581
5582 if (empty($email) || empty($name)) {
5583 global $wpdb;
5584 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
5585 $existing_data = $wpdb->get_row($wpdb->prepare(
5586 "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",
5587 $session_id
5588 ));
5589 if ($existing_data) {
5590 if (empty($email) && !empty($existing_data->user_email)) { $email = $existing_data->user_email; }
5591 if (empty($name) && !empty($existing_data->user_name)) { $name = $existing_data->user_name; }
5592 }
5593 }
5594
5595 return array('name' => $name, 'email' => $email);
5596 }
5597
5598 public function handle_slack_messages(WP_REST_Request $request) {
5599 // Log the incoming request for debugging
5600 //error_log('Slack events request received: ' . $request->get_body());
5601
5602 $body = $request->get_body();
5603 $data = json_decode($body, true);
5604
5605 // Handle Slack URL verification
5606 if (isset($data['type']) && $data['type'] === 'url_verification') {
5607 //error_log('Slack URL verification challenge: ' . $data['challenge']);
5608 return new WP_REST_Response($data['challenge'], 200, ['Content-Type' => 'text/plain']);
5609 }
5610
5611 // IMPORTANT: Handle Slack's event deduplication
5612 if (isset($data['event_id'])) {
5613 $event_id = $data['event_id'];
5614 $processed_events = get_transient('mxchat_slack_events') ?: [];
5615
5616 // Check if we've already processed this event
5617 if (in_array($event_id, $processed_events)) {
5618 //error_log("Duplicate event detected: $event_id");
5619 return new WP_REST_Response(['ok' => true]);
5620 }
5621
5622 // Add this event to processed list
5623 $processed_events[] = $event_id;
5624 // Keep only last 100 events to prevent memory issues
5625 if (count($processed_events) > 100) {
5626 $processed_events = array_slice($processed_events, -100);
5627 }
5628 // Store for 1 hour
5629 set_transient('mxchat_slack_events', $processed_events, HOUR_IN_SECONDS);
5630 }
5631
5632 // Handle message events
5633 if (isset($data['event']) && $data['event']['type'] === 'message') {
5634 $event = $data['event'];
5635
5636 // Skip bot messages and messages with subtypes (like bot_message)
5637 if (isset($event['bot_id']) || isset($event['subtype'])) {
5638 return new WP_REST_Response(['ok' => true]);
5639 }
5640
5641 // Threaded replies: in shared-channel mode every conversation lives in
5642 // a thread rooted at its handoff message — route those to their session
5643 // by thread root (plan 9f7756). Any other threaded reply (e.g. under a
5644 // per-conversation channel's confirmation message) finds no session and
5645 // is skipped, exactly as before.
5646 if (isset($event['thread_ts']) && $event['thread_ts'] !== $event['ts']) {
5647 return $this->mxchat_route_shared_thread_reply($event);
5648 }
5649
5650 $channel_id = $event['channel'];
5651 $message_text = $event['text'] ?? '';
5652 $message_ts = $event['ts'] ?? '';
5653
5654 // Find the session that owns this channel. Channel state lives in the
5655 // sessions table since b64b77 — the migration moves the legacy
5656 // mxchat_channel_ option rows there and DELETES them, so the old
5657 // wp_options lookup found nothing and every per-conversation agent
5658 // reply (including !endchat) was silently dropped (plan 71e4b6). The
5659 // legacy query remains only as a fallback for installs mid-migration
5660 // whose channel row has not moved yet.
5661 $session_id = MxChat_Session_Store::find_by_channel($channel_id);
5662
5663 if ($session_id === '') {
5664 global $wpdb;
5665 $session_option = $wpdb->get_var(
5666 $wpdb->prepare(
5667 "SELECT option_name FROM {$wpdb->options}
5668 WHERE option_name LIKE 'mxchat_channel_%'
5669 AND option_value = %s",
5670 $channel_id
5671 )
5672 );
5673 if ($session_option) {
5674 $session_id = str_replace('mxchat_channel_', '', $session_option);
5675 }
5676 }
5677
5678 if ($session_id !== '') {
5679
5680 // Create a unique key for this specific message
5681 $message_key = md5($session_id . $message_ts . $message_text);
5682 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
5683
5684 // Check if we've already processed this exact message
5685 if (in_array($message_key, $processed_messages)) {
5686 //error_log("Duplicate message detected for session $session_id");
5687 return new WP_REST_Response(['ok' => true]);
5688 }
5689
5690 // Add to processed messages
5691 $processed_messages[] = $message_key;
5692 // Keep only last 50 messages per session
5693 if (count($processed_messages) > 50) {
5694 $processed_messages = array_slice($processed_messages, -50);
5695 }
5696 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
5697
5698 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
5699
5700 // Handle agent ending the chat — transfer back to AI
5701 // Format: "!endchat" or "!endchat <custom message to user>"
5702 if (preg_match('/^!endchat\b/i', trim($message_text))) {
5703 MxChat_Session_Store::set($session_id, 'mode', 'ai');
5704
5705 // Extract custom message after !endchat, or use empty string
5706 $custom_message = trim(preg_replace('/^!endchat\s*/i', '', trim($message_text)));
5707
5708 // Send the agent's custom farewell message if provided
5709 if (!empty($custom_message)) {
5710 $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($custom_message));
5711 }
5712
5713 // Confirm in Slack channel
5714 if (!empty($slack_bot_token)) {
5715 wp_remote_post('https://slack.com/api/chat.postMessage', [
5716 'headers' => [
5717 'Content-Type' => 'application/json',
5718 'Authorization' => 'Bearer ' . $slack_bot_token
5719 ],
5720 'body' => json_encode([
5721 'channel' => $channel_id,
5722 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.",
5723 'mrkdwn' => true
5724 ])
5725 ]);
5726 }
5727
5728 // Auto-archive the ended conversation's channel (plan 7458a7).
5729 // Toggle-gated, best-effort — never blocks the mode flip.
5730 $this->mxchat_maybe_archive_conversation_channel($session_id, $channel_id);
5731
5732 return new WP_REST_Response(['ok' => true]);
5733 }
5734
5735 // Save the agent message (normalize Slack link/entity formatting first — plan-e2195b)
5736 $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($message_text));
5737
5738 // Send confirmation back to Slack (only once)
5739 if (!empty($slack_bot_token)) {
5740 // Use a transient to prevent duplicate confirmations
5741 $confirm_key = 'mxchat_confirm_' . $message_key;
5742 if (!get_transient($confirm_key)) {
5743 wp_remote_post('https://slack.com/api/chat.postMessage', [
5744 'headers' => [
5745 'Content-Type' => 'application/json',
5746 'Authorization' => 'Bearer ' . $slack_bot_token
5747 ],
5748 'body' => json_encode([
5749 'channel' => $channel_id,
5750 'text' => "✅ _Message sent to user_",
5751 'thread_ts' => $event['ts'] // Reply in thread
5752 ])
5753 ]);
5754 // Set transient to prevent duplicate confirmations
5755 set_transient($confirm_key, true, 300); // 5 minutes
5756 }
5757 }
5758 }
5759 }
5760
5761 return new WP_REST_Response(['ok' => true]);
5762 }
5763
5764 /**
5765 * Route an agent's threaded Slack reply to the session whose shared-channel
5766 * conversation is rooted at that thread (plan 9f7756). Sessions are keyed by
5767 * the thread root ts stored in mxchat_thread_{session}, so two visitors in
5768 * the same shared channel can never cross-wire. Unknown threads are ignored.
5769 *
5770 * @param array $event Slack message event (has thread_ts !== ts).
5771 * @return WP_REST_Response
5772 */
5773 private function mxchat_route_shared_thread_reply($event) {
5774 $thread_root = $event['thread_ts'] ?? '';
5775 $message_text = $event['text'] ?? '';
5776 $message_ts = $event['ts'] ?? '';
5777 $channel_id = $event['channel'] ?? '';
5778
5779 if ($thread_root === '') {
5780 return new WP_REST_Response(['ok' => true]);
5781 }
5782
5783 // Find the session owning this thread root (same reverse-lookup shape as
5784 // the per-conversation channel mapping).
5785 global $wpdb;
5786 $session_option = $wpdb->get_var(
5787 $wpdb->prepare(
5788 "SELECT option_name FROM {$wpdb->options}
5789 WHERE option_name LIKE 'mxchat_thread_%'
5790 AND option_value = %s",
5791 $thread_root
5792 )
5793 );
5794
5795 if (!$session_option) {
5796 // Not a shared-channel conversation thread (e.g. a reply under a
5797 // per-conversation confirmation) — ignore, as before.
5798 return new WP_REST_Response(['ok' => true]);
5799 }
5800
5801 $session_id = str_replace('mxchat_thread_', '', $session_option);
5802
5803 // Per-message dedupe — same transient pattern as the top-level handler.
5804 $message_key = md5($session_id . $message_ts . $message_text);
5805 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
5806 if (in_array($message_key, $processed_messages)) {
5807 return new WP_REST_Response(['ok' => true]);
5808 }
5809 $processed_messages[] = $message_key;
5810 if (count($processed_messages) > 50) {
5811 $processed_messages = array_slice($processed_messages, -50);
5812 }
5813 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
5814
5815 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
5816
5817 // Agent ending the chat from inside the thread — same command contract as
5818 // per-conversation channels: "!endchat" or "!endchat <farewell>".
5819 if (preg_match('/^!endchat\b/i', trim($message_text))) {
5820 MxChat_Session_Store::set($session_id, 'mode', 'ai');
5821
5822 $custom_message = trim(preg_replace('/^!endchat\s*/i', '', trim($message_text)));
5823 if (!empty($custom_message)) {
5824 $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($custom_message));
5825 }
5826
5827 if (!empty($slack_bot_token) && $channel_id !== '') {
5828 wp_remote_post('https://slack.com/api/chat.postMessage', [
5829 'headers' => [
5830 'Content-Type' => 'application/json',
5831 'Authorization' => 'Bearer ' . $slack_bot_token
5832 ],
5833 'body' => json_encode([
5834 'channel' => $channel_id,
5835 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.",
5836 'thread_ts' => $thread_root,
5837 'mrkdwn' => true
5838 ])
5839 ]);
5840 }
5841
5842 return new WP_REST_Response(['ok' => true]);
5843 }
5844
5845 // Save the agent message for the widget (normalized like the channel path).
5846 $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($message_text));
5847
5848 // Confirmation stays inside the conversation's thread.
5849 if (!empty($slack_bot_token) && $channel_id !== '') {
5850 $confirm_key = 'mxchat_confirm_' . $message_key;
5851 if (!get_transient($confirm_key)) {
5852 wp_remote_post('https://slack.com/api/chat.postMessage', [
5853 'headers' => [
5854 'Content-Type' => 'application/json',
5855 'Authorization' => 'Bearer ' . $slack_bot_token
5856 ],
5857 'body' => json_encode([
5858 'channel' => $channel_id,
5859 'text' => "✅ _Message sent to user_",
5860 'thread_ts' => $thread_root
5861 ])
5862 ]);
5863 set_transient($confirm_key, true, 300);
5864 }
5865 }
5866
5867 return new WP_REST_Response(['ok' => true]);
5868 }
5869
5870 // For the word upload handler
5871 public function mxchat_handle_word_upload() {
5872 // Delegate to word handler
5873 $this->word_handler->mxchat_handle_word_upload();
5874 }
5875
5876 // For the word removal handler
5877 public function mxchat_handle_word_remove() {
5878 // Delegate to word handler
5879 $this->word_handler->mxchat_handle_word_remove();
5880 }
5881
5882 // For the word status check
5883 public function mxchat_check_word_status() {
5884 // Delegate to word handler
5885 $this->word_handler->mxchat_check_word_status();
5886 }
5887
5888
5889 private function mxchat_get_user_identifier() {
5890 return MxChat_User::mxchat_get_user_identifier();
5891 }
5892
5893 private function mxchat_generate_embedding($text, $api_key) {
5894 try {
5895 // Get options and selected model
5896 $options = get_option('mxchat_options');
5897 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5898
5899 // Contract checks live HERE — the widget surfaces these exact strings
5900 // and codes. Transport lives in MxChat_Utils::generate_query_embedding()
5901 // (single provider-routing implementation for query + index, 876edb).
5902 // The custom-provider branch skips them: Utils routes custom-first and
5903 // its own checks map back through mxchat_map_embedding_error().
5904 if (empty($options['custom_provider_for_embeddings']) || $options['custom_provider_for_embeddings'] !== 'on') {
5905 if (strpos($selected_model, 'voyage') === 0) {
5906 // Check if Voyage API key is missing
5907 if (empty($options['voyage_api_key'] ?? '')) {
5908 return [
5909 'error' => esc_html__('Voyage AI API key is not configured', 'mxchat'),
5910 'error_code' => 'missing_voyage_api_key'
5911 ];
5912 }
5913 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5914 // Check if Gemini API key is missing
5915 if (empty($options['gemini_api_key'] ?? '')) {
5916 return [
5917 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
5918 'error_code' => 'missing_gemini_api_key'
5919 ];
5920 }
5921 } else {
5922 // OpenAI uses the caller-passed (per-bot) key
5923 if (empty($api_key)) {
5924 return [
5925 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
5926 'error_code' => 'missing_openai_api_key'
5927 ];
5928 }
5929 }
5930
5931 // Check if text is empty
5932 if (empty($text)) {
5933 return [
5934 'error' => esc_html__('No text provided for embedding generation', 'mxchat'),
5935 'error_code' => 'empty_embedding_text'
5936 ];
5937 }
5938 }
5939
5940 $result = MxChat_Utils::generate_query_embedding($text, $api_key);
5941
5942 if (is_wp_error($result)) {
5943 return $this->mxchat_map_embedding_error($result);
5944 }
5945
5946 return $result;
5947 } catch (Exception $e) {
5948 //error_log('Embedding Exception: ' . $e->getMessage());
5949 return [
5950 'error' => esc_html__('System error when generating embeddings: ', 'mxchat') . esc_html($e->getMessage()),
5951 'error_code' => 'embedding_exception'
5952 ];
5953 }
5954 }
5955
5956 /**
5957 * Translate a WP_Error from MxChat_Utils::generate_query_embedding() into this
5958 * class's long-standing ['error','error_code'] contract. Every code string and
5959 * user-facing message below predates 876edb — the chat pipeline and widget
5960 * consume them; preserve verbatim. The structured data (branch/status/
5961 * error_type/reason/model) is attached by Utils on every failure path.
5962 */
5963 private function mxchat_map_embedding_error($err) {
5964 $data = $err->get_error_data();
5965 $data = is_array($data) ? $data : [];
5966 $message = $err->get_error_message();
5967
5968 // Custom-provider branch: Utils carries the human-readable string verbatim;
5969 // its prefixes are stable — map them back onto the existing codes.
5970 if (($data['branch'] ?? '') === 'custom') {
5971 if ($message === 'No text provided for embedding generation') {
5972 return ['error' => esc_html__('No text provided for embedding generation', 'mxchat'), 'error_code' => 'empty_embedding_text'];
5973 }
5974 if ($message === 'Custom provider Base URL is not configured.') {
5975 return ['error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url'];
5976 }
5977 if (strpos($message, 'Connection error when generating embeddings (custom provider): ') === 0) {
5978 return ['error' => esc_html($message), 'error_code' => 'embedding_custom_connection_error'];
5979 }
5980 if (strpos($message, 'Custom embedding endpoint error: ') === 0) {
5981 return ['error' => esc_html($message), 'error_code' => 'embedding_custom_api_error'];
5982 }
5983 return ['error' => esc_html__('Invalid embedding response from custom provider.', 'mxchat'), 'error_code' => 'embedding_custom_invalid_response'];
5984 }
5985
5986 // Cloud connection failure (wp_remote_post WP_Error)
5987 if (($data['kind'] ?? '') === 'connection') {
5988 return [
5989 'error' => esc_html__('Connection error when generating embeddings: ', 'mxchat') . esc_html($data['reason'] ?? ''),
5990 'error_code' => 'embedding_connection_error'
5991 ];
5992 }
5993
5994 $status = isset($data['status']) ? (int) $data['status'] : 0;
5995 $error_type = isset($data['error_type']) ? (string) $data['error_type'] : '';
5996 $reason = isset($data['reason']) ? (string) $data['reason'] : $message;
5997 $model = isset($data['model']) ? (string) $data['model'] : '';
5998
5999 // HTTP 200 with an unusable body — the invalid-response shapes.
6000 if ($status === 200) {
6001 if (strpos($model, 'gemini-embedding') === 0) {
6002 return ['error' => esc_html__('Received invalid embedding data from the Gemini API.', 'mxchat'), 'error_code' => 'invalid_gemini_embedding_response'];
6003 }
6004 return ['error' => esc_html__('Received invalid embedding data from the API.', 'mxchat'), 'error_code' => 'invalid_embedding_response'];
6005 }
6006
6007 // Handle specific error types
6008 switch ($error_type) {
6009 case 'invalid_request_error':
6010 if (strpos($reason, 'API key') !== false) {
6011 return [
6012 'error' => esc_html__('Invalid API key for embedding generation. Please check your API key configuration.', 'mxchat'),
6013 'error_code' => 'embedding_invalid_api_key'
6014 ];
6015 }
6016 break;
6017
6018 case 'authentication_error':
6019 return [
6020 'error' => esc_html__('Authentication failed for embedding generation. Please check your API key.', 'mxchat'),
6021 'error_code' => 'embedding_auth_error'
6022 ];
6023
6024 case 'rate_limit_exceeded':
6025 return [
6026 'error' => esc_html__('Rate limit exceeded for embedding generation. Please try again later.', 'mxchat'),
6027 'error_code' => 'embedding_rate_limit'
6028 ];
6029
6030 case 'quota_exceeded':
6031 return [
6032 'error' => esc_html__('API quota exceeded for embedding generation. Please check your billing details.', 'mxchat'),
6033 'error_code' => 'embedding_quota_exceeded'
6034 ];
6035 }
6036
6037 // Generic error fallback
6038 return [
6039 'error' => esc_html__('Embedding API error - check embedding API key.: ', 'mxchat') . esc_html($reason),
6040 'error_code' => 'embedding_api_error',
6041 'status_code' => $status
6042 ];
6043 }
6044
6045 private function mxchat_find_relevant_content($user_embedding, $bot_id = 'default', $user_query = '') {
6046 //error_log("MXCHAT DEBUG: find_relevant_content called with bot_id: " . $bot_id);
6047
6048 // Check for OpenAI Vector Store first (takes priority when enabled)
6049 $bot_vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
6050
6051 if ($bot_vectorstore_config['use_vectorstore']) {
6052 // Get current model to verify it's an OpenAI model
6053 $bot_options = $this->get_bot_options($bot_id);
6054 $mxchat_options = get_option('mxchat_options', array());
6055 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
6056 $selected_model = $current_options['model'] ?? 'gpt-5.6-sol';
6057
6058 if ($this->is_openai_chat_model($selected_model)) {
6059 //error_log("MXCHAT DEBUG: Using OpenAI Vector Store for knowledge retrieval");
6060 return $this->find_relevant_content_openai_vectorstore($user_query, $bot_id, $bot_vectorstore_config);
6061 } else {
6062 //error_log("MXCHAT DEBUG: Vector Store enabled but model is not OpenAI (" . $selected_model . "), skipping Vector Store");
6063 }
6064 }
6065
6066 // Get bot-specific Pinecone configuration
6067 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
6068
6069 // Debug: Log the Pinecone configuration
6070 //error_log("MXCHAT DEBUG: Pinecone config for bot '$bot_id':");
6071 //error_log(" - use_pinecone: " . ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false'));
6072 //error_log(" - api_key: " . (empty($bot_pinecone_config['api_key']) ? 'EMPTY' : 'SET (hidden)'));
6073 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'NOT SET'));
6074 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'NOT SET'));
6075
6076 // Determine whether to use Pinecone based on bot configuration
6077 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
6078
6079 //error_log("MXCHAT DEBUG: Using " . ($use_pinecone ? "Pinecone" : "WordPress Database") . " for knowledge retrieval");
6080
6081 if ($use_pinecone) {
6082 return $this->find_relevant_content_pinecone($user_embedding, $bot_id, $bot_pinecone_config);
6083 } else {
6084 return $this->find_relevant_content_wordpress($user_embedding, $bot_id, $user_query);
6085 }
6086 }
6087
6088 private function find_relevant_content_wordpress($user_embedding, $bot_id = 'default', $user_query = '') {
6089 global $wpdb;
6090 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6091 // Initialize similarity analysis storage
6092 $this->last_similarity_analysis = [
6093 'knowledge_base_type' => 'WordPress Database',
6094 'bot_id' => $bot_id,
6095 'top_matches' => [],
6096 'threshold_used' => 0,
6097 'total_checked' => 0
6098 ];
6099
6100 // NEW: Initialize valid URLs array
6101 $valid_urls = [];
6102
6103 // Get bot-specific options for similarity threshold
6104 $bot_options = $this->get_bot_options($bot_id);
6105 $current_options = !empty($bot_options) ? $bot_options : $this->options;
6106
6107 // Get knowledge manager instance for role checking
6108 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
6109
6110 // Get base similarity threshold from bot options or default options
6111 $similarity_threshold = isset($current_options['similarity_threshold'])
6112 ? ((int) $current_options['similarity_threshold']) / 100
6113 : 0.35;
6114 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
6115
6116 // Precompute bot_filter once, outside the streaming loop
6117 $bot_filter = '';
6118 if ($bot_id !== 'default') {
6119 $column_exists = $wpdb->get_var("SHOW COLUMNS FROM {$system_prompt_table} LIKE 'bot_metadata'");
6120 if ($column_exists) {
6121 $bot_filter = $wpdb->prepare(" AND (bot_metadata = %s OR bot_metadata IS NULL OR bot_metadata = '')", $bot_id);
6122 }
6123 }
6124
6125 // Hybrid keyword boost (plan-38ffa1, default OFF). Runs a ranked keyword
6126 // query alongside the vector scan and fuses the two lists by reciprocal
6127 // rank, so exact-token queries (SKUs, error codes, names) hit even when
6128 // their embedding similarity is semantic mush. The keyword leg runs FIRST
6129 // so the vector scan below can record true cosine similarity for its hits
6130 // (the display keeps cosine % as the anchor).
6131 $hybrid_enabled = get_option('mxchat_hybrid_keyword_toggle', 'off') === 'on'
6132 && trim((string) $user_query) !== '';
6133 $keyword_hits = array(); // ranked + access-filtered, max 20
6134 $keyword_ids = array(); // id => keyword rank (1-based)
6135 $keyword_similarities = array(); // id => cosine recorded during the scan
6136 if ($hybrid_enabled) {
6137 $keyword_hits = $this->mxchat_hybrid_keyword_search($user_query, $system_prompt_table, $bot_filter, $knowledge_manager);
6138 foreach ($keyword_hits as $kw_i => $kw_hit) {
6139 $keyword_ids[$kw_hit['id']] = $kw_i + 1;
6140 }
6141 }
6142
6143 // ===== STREAMING TOP-K PASS =====
6144 // Stream rows in small batches, compute cosine similarity per row, and keep only:
6145 // - top 10 by raw similarity (for the testing/debug display panel)
6146 // - candidates above threshold with access (capped) for context assembly
6147 // This bounds peak memory regardless of knowledge base size and avoids loading
6148 // article_content for every row. article_content is fetched in Phase 2 for winners only.
6149 $batch_size = 250;
6150 $max_candidates = 200; // safety cap, well above rag_sources_limit * max_chunks_per_source
6151 $top_display = [];
6152 $candidates = [];
6153 $total_checked = 0;
6154 $offset = 0;
6155
6156 do {
6157 $batch = $wpdb->get_results($wpdb->prepare(
6158 "SELECT id, embedding_vector, source_url, role_restriction
6159 FROM {$system_prompt_table}
6160 WHERE 1=1 {$bot_filter}
6161 LIMIT %d OFFSET %d",
6162 $batch_size,
6163 $offset
6164 ));
6165
6166 if (empty($batch)) {
6167 break;
6168 }
6169
6170 foreach ($batch as $row) {
6171 $database_embedding = $row->embedding_vector
6172 ? unserialize($row->embedding_vector, ['allowed_classes' => false])
6173 : null;
6174
6175 if (!is_array($database_embedding) || !is_array($user_embedding)) {
6176 unset($database_embedding);
6177 continue;
6178 }
6179
6180 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
6181 unset($database_embedding);
6182
6183 $role_restriction = $row->role_restriction ?? 'public';
6184 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
6185 $source_url = $row->source_url ?? '';
6186
6187 // Maintain top 10 display buffer (insert-if-beats-worst)
6188 if (count($top_display) < 10) {
6189 $top_display[] = [
6190 'id' => $row->id,
6191 'similarity' => $similarity,
6192 'source_url' => $source_url,
6193 'role_restriction' => $role_restriction,
6194 'has_access' => $has_access,
6195 ];
6196 usort($top_display, function ($a, $b) {
6197 return $b['similarity'] <=> $a['similarity'];
6198 });
6199 } elseif ($similarity > $top_display[9]['similarity']) {
6200 $top_display[9] = [
6201 'id' => $row->id,
6202 'similarity' => $similarity,
6203 'source_url' => $source_url,
6204 'role_restriction' => $role_restriction,
6205 'has_access' => $has_access,
6206 ];
6207 usort($top_display, function ($a, $b) {
6208 return $b['similarity'] <=> $a['similarity'];
6209 });
6210 }
6211
6212 // Record cosine for keyword-leg hits so fusion/display can anchor
6213 // on the true similarity % even for below-threshold rescues.
6214 if ($hybrid_enabled && isset($keyword_ids[$row->id])) {
6215 $keyword_similarities[$row->id] = $similarity;
6216 }
6217
6218 // Track candidates for context assembly (above threshold + has access)
6219 if ($similarity >= $similarity_threshold && $has_access) {
6220 $candidates[] = [
6221 'id' => $row->id,
6222 'similarity' => $similarity,
6223 'source_url' => $source_url,
6224 ];
6225 }
6226
6227 $total_checked++;
6228 }
6229
6230 unset($batch);
6231
6232 // Trim candidates periodically to cap memory during long scans
6233 if (count($candidates) > $max_candidates) {
6234 usort($candidates, function ($a, $b) {
6235 return $b['similarity'] <=> $a['similarity'];
6236 });
6237 $candidates = array_slice($candidates, 0, $max_candidates);
6238 }
6239
6240 $offset += $batch_size;
6241 } while (true);
6242
6243 if ($total_checked === 0) {
6244 $this->current_valid_urls = [];
6245 return '';
6246 }
6247
6248 // Final candidates sort (best first)
6249 if (count($candidates) > 1) {
6250 usort($candidates, function ($a, $b) {
6251 return $b['similarity'] <=> $a['similarity'];
6252 });
6253 }
6254
6255 // ===== HYBRID FUSION (plan-38ffa1) =====
6256 // Reciprocal-rank fusion over the top-20 of each leg (k=60 standard).
6257 // Rank-based, so the incomparable score scales (cosine 0-1 vs FULLTEXT
6258 // relevance) never need calibrating. A below-threshold vector row can
6259 // enter via a strong keyword rank — that is the point of the feature.
6260 // Every candidate gets a 'rank_score' the downstream source ordering
6261 // uses: with hybrid OFF it is exactly the cosine similarity, so the
6262 // legacy path is byte-identical.
6263 $fused_rank_map = array(); // id => 1-based fused rank
6264 $matched_via_map = array(); // id => 'vector' | 'keyword' | 'both'
6265 if (!$hybrid_enabled) {
6266 foreach ($candidates as &$cand_ref) {
6267 $cand_ref['rank_score'] = $cand_ref['similarity'];
6268 }
6269 unset($cand_ref);
6270 } else {
6271 $rrf_k = 60;
6272 $fused = array();
6273 foreach (array_slice($candidates, 0, 20) as $leg_rank => $cand) {
6274 $fused[$cand['id']] = array(
6275 'id' => $cand['id'],
6276 'similarity' => $cand['similarity'],
6277 'source_url' => $cand['source_url'],
6278 'rrf' => 1 / ($rrf_k + $leg_rank + 1),
6279 'via' => 'vector',
6280 );
6281 }
6282 foreach ($keyword_hits as $leg_rank => $hit) {
6283 $rrf = 1 / ($rrf_k + $leg_rank + 1);
6284 if (isset($fused[$hit['id']])) {
6285 $fused[$hit['id']]['rrf'] += $rrf;
6286 $fused[$hit['id']]['via'] = 'both';
6287 } else {
6288 $fused[$hit['id']] = array(
6289 'id' => $hit['id'],
6290 'similarity' => $keyword_similarities[$hit['id']] ?? 0.0,
6291 'source_url' => $hit['source_url'],
6292 'rrf' => $rrf,
6293 'via' => 'keyword',
6294 );
6295 }
6296 }
6297 uasort($fused, function ($a, $b) {
6298 return $b['rrf'] <=> $a['rrf'];
6299 });
6300
6301 // Vector candidates beyond the top-20 leg keep flowing to the prompt
6302 // builders after the fused block, in their vector order — the result
6303 // count/shape downstream stays unchanged.
6304 $tail = array_slice($candidates, 20);
6305 $candidates = array();
6306 $rank = 0;
6307 foreach ($fused as $f) {
6308 $rank++;
6309 $fused_rank_map[$f['id']] = $rank;
6310 $matched_via_map[$f['id']] = $f['via'];
6311 $candidates[] = array(
6312 'id' => $f['id'],
6313 'similarity' => $f['similarity'],
6314 'source_url' => $f['source_url'],
6315 'rank_score' => $f['rrf'],
6316 );
6317 }
6318 foreach ($tail as $cand) {
6319 // Below any fused rrf (min possible fused rrf is 1/(60+40)=0.01;
6320 // similarity * 1e-6 <= 1e-6), preserving relative vector order.
6321 $cand['rank_score'] = $cand['similarity'] * 1e-6;
6322 $candidates[] = $cand;
6323 }
6324 if (count($candidates) > $max_candidates) {
6325 $candidates = array_slice($candidates, 0, $max_candidates);
6326 }
6327 }
6328
6329 // ===== PHASE 2: FETCH ARTICLE CONTENT ONLY FOR WINNERS =====
6330 // Gather unique IDs we actually need (top_display + candidates) and pull
6331 // article_content in bounded IN() batches. This avoids loading content for
6332 // every row during the similarity scan.
6333 $needed_ids = [];
6334 foreach ($top_display as $item) {
6335 $needed_ids[$item['id']] = true;
6336 }
6337 foreach ($candidates as $item) {
6338 $needed_ids[$item['id']] = true;
6339 }
6340 $needed_ids = array_keys($needed_ids);
6341
6342 $content_map = [];
6343 if (!empty($needed_ids)) {
6344 foreach (array_chunk($needed_ids, 250) as $chunk_ids) {
6345 $placeholders = implode(',', array_fill(0, count($chunk_ids), '%d'));
6346 $rows = $wpdb->get_results($wpdb->prepare(
6347 "SELECT id, article_content FROM {$system_prompt_table} WHERE id IN ($placeholders)",
6348 ...$chunk_ids
6349 ));
6350 foreach ($rows as $r) {
6351 $content_map[$r->id] = $r->article_content;
6352 }
6353 unset($rows);
6354 }
6355 }
6356
6357 // Build the all_similarities display array from the top 10
6358 $all_similarities = [];
6359 foreach ($top_display as $item) {
6360 $article_content_for_parse = $content_map[$item['id']] ?? '';
6361 $parsed_for_display = MxChat_Chunker::parse_stored_chunk($article_content_for_parse);
6362 $is_chunk = $parsed_for_display['is_chunked'];
6363 $chunk_meta = $parsed_for_display['metadata'];
6364
6365 if (!empty($item['source_url']) && $item['source_url'] !== '#') {
6366 $source_display = $item['source_url'];
6367 } else {
6368 $content_preview = strip_tags($article_content_for_parse);
6369 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
6370 $source_display = substr(trim($content_preview), 0, 50) . '...';
6371 }
6372
6373 $all_similarities[] = [
6374 'document_id' => $item['id'],
6375 'similarity' => $item['similarity'],
6376 'similarity_percentage' => round($item['similarity'] * 100, 2),
6377 'above_threshold' => $item['similarity'] >= $similarity_threshold,
6378 'source_display' => $source_display,
6379 'content_preview' => substr(strip_tags($parsed_for_display['text'] ?? ''), 0, 100) . '...',
6380 'used_for_context' => false,
6381 'role_restriction' => $item['role_restriction'],
6382 'has_access' => $item['has_access'],
6383 'filtered_out' => !$item['has_access'],
6384 'is_chunk' => $is_chunk,
6385 'chunk_index' => $is_chunk ? ($chunk_meta['chunk_index'] ?? 0) : null,
6386 'total_chunks' => $is_chunk ? ($chunk_meta['total_chunks'] ?? 1) : null
6387 ];
6388 }
6389
6390 // Build url_groups from candidates for chunk reassembly
6391 $url_groups = array();
6392 foreach ($candidates as $cand) {
6393 $article_content = $content_map[$cand['id']] ?? '';
6394 $parsed = MxChat_Chunker::parse_stored_chunk($article_content);
6395 $is_chunked = $parsed['is_chunked'];
6396 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
6397 $text_content = $parsed['text'];
6398
6399 $source_url = $cand['source_url'];
6400 $group_key = !empty($source_url) ? $source_url : '_manual_' . $cand['id'];
6401
6402 if (!isset($url_groups[$group_key])) {
6403 $url_groups[$group_key] = array(
6404 'source_url' => $source_url,
6405 'best_score' => 0,
6406 'is_chunked' => $is_chunked,
6407 'chunks' => array(),
6408 'single_text' => '',
6409 'single_id' => null
6410 );
6411 }
6412
6413 // rank_score == similarity with hybrid off (byte-identical ordering);
6414 // with hybrid on it carries the fused rank so keyword rescues sort up.
6415 $cand_rank_score = $cand['rank_score'] ?? $cand['similarity'];
6416 if ($cand_rank_score > $url_groups[$group_key]['best_score']) {
6417 $url_groups[$group_key]['best_score'] = $cand_rank_score;
6418 }
6419
6420 if ($is_chunked) {
6421 $url_groups[$group_key]['is_chunked'] = true;
6422 $url_groups[$group_key]['chunks'][] = array(
6423 'id' => $cand['id'],
6424 'score' => $cand['similarity'],
6425 'chunk_index' => $chunk_index,
6426 'text' => $text_content
6427 );
6428 } else {
6429 $url_groups[$group_key]['single_text'] = $text_content;
6430 $url_groups[$group_key]['single_id'] = $cand['id'];
6431 }
6432 }
6433
6434 // Hybrid display augmentation (plan-38ffa1, Maxwell's approval note):
6435 // make sure every fused-top-10 row appears in the debug panel — a
6436 // keyword-only rescue may sit below the vector top-10 buffer — and stamp
6437 // matched_via + fused_rank on every row. Cosine % stays the anchor; no
6438 // raw RRF numbers surface.
6439 if ($hybrid_enabled) {
6440 $displayed_ids = array();
6441 foreach ($all_similarities as $disp_item) {
6442 $displayed_ids[$disp_item['document_id']] = true;
6443 }
6444 $kw_info_by_id = array();
6445 foreach ($keyword_hits as $hit) {
6446 $kw_info_by_id[$hit['id']] = $hit;
6447 }
6448 foreach ($fused_rank_map as $fused_id => $fused_rank) {
6449 if ($fused_rank > 10 || isset($displayed_ids[$fused_id])) {
6450 continue;
6451 }
6452 $aug_content = $content_map[$fused_id] ?? '';
6453 $aug_parsed = MxChat_Chunker::parse_stored_chunk($aug_content);
6454 $aug_hit = $kw_info_by_id[$fused_id] ?? array();
6455 $aug_similarity = $keyword_similarities[$fused_id] ?? 0.0;
6456 $aug_source_url = $aug_hit['source_url'] ?? '';
6457 if (!empty($aug_source_url) && $aug_source_url !== '#') {
6458 $aug_source_display = $aug_source_url;
6459 } else {
6460 $aug_preview = preg_replace('/\s+/', ' ', strip_tags($aug_content));
6461 $aug_source_display = substr(trim($aug_preview), 0, 50) . '...';
6462 }
6463 $all_similarities[] = [
6464 'document_id' => $fused_id,
6465 'similarity' => $aug_similarity,
6466 'similarity_percentage' => round($aug_similarity * 100, 2),
6467 'above_threshold' => $aug_similarity >= $similarity_threshold,
6468 'source_display' => $aug_source_display,
6469 'content_preview' => substr(strip_tags($aug_parsed['text'] ?? ''), 0, 100) . '...',
6470 'used_for_context' => false,
6471 'role_restriction' => $aug_hit['role_restriction'] ?? 'public',
6472 'has_access' => $aug_hit['has_access'] ?? true,
6473 'filtered_out' => false,
6474 'is_chunk' => $aug_parsed['is_chunked'],
6475 'chunk_index' => $aug_parsed['is_chunked'] ? ($aug_parsed['metadata']['chunk_index'] ?? 0) : null,
6476 'total_chunks' => $aug_parsed['is_chunked'] ? ($aug_parsed['metadata']['total_chunks'] ?? 1) : null,
6477 ];
6478 }
6479 foreach ($all_similarities as &$disp_ref) {
6480 $disp_ref['matched_via'] = $matched_via_map[$disp_ref['document_id']] ?? null;
6481 $disp_ref['fused_rank'] = $fused_rank_map[$disp_ref['document_id']] ?? null;
6482 }
6483 unset($disp_ref);
6484 }
6485
6486 // Sort for the testing/debug display: fused rank when hybrid is on
6487 // (nulls last, cosine as tie-break), raw similarity otherwise.
6488 if ($hybrid_enabled) {
6489 usort($all_similarities, function ($a, $b) {
6490 $ar = $a['fused_rank'] ?? PHP_INT_MAX;
6491 $br = $b['fused_rank'] ?? PHP_INT_MAX;
6492 if ($ar !== $br) {
6493 return $ar <=> $br;
6494 }
6495 return $b['similarity'] <=> $a['similarity'];
6496 });
6497 } else {
6498 usort($all_similarities, function ($a, $b) {
6499 return $b['similarity'] <=> $a['similarity'];
6500 });
6501 }
6502
6503 // Sort URL groups by best score (highest first)
6504 uasort($url_groups, function($a, $b) {
6505 return $b['best_score'] <=> $a['best_score'];
6506 });
6507
6508 // Get RAG sources limit from options (default 6, min 3, max 10)
6509 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
6510 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
6511 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
6512
6513 // Take top N unique URLs based on user setting
6514 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
6515
6516 // Track which document IDs are used for context
6517 $used_document_ids = [];
6518 foreach ($top_urls as $group) {
6519 if ($group['is_chunked']) {
6520 foreach ($group['chunks'] as $chunk) {
6521 $used_document_ids[] = $chunk['id'];
6522 }
6523 } elseif ($group['single_id']) {
6524 $used_document_ids[] = $group['single_id'];
6525 }
6526 }
6527
6528 // Update the all_similarities array to mark which were actually used
6529 foreach ($all_similarities as &$similarity_item) {
6530 $similarity_item['used_for_context'] = in_array($similarity_item['document_id'], $used_document_ids);
6531 }
6532
6533 // Store top 10 for testing panel
6534 $this->last_similarity_analysis['top_matches'] = array_slice($all_similarities, 0, 10);
6535 $this->last_similarity_analysis['total_checked'] = $total_checked;
6536
6537 // Initialize final content
6538 $content = '';
6539 $matches_used = 0;
6540 $total_chunks_used = 0;
6541 $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
6542 if ($max_total_chunks < 8) $max_total_chunks = 8;
6543 if ($max_total_chunks > 20) $max_total_chunks = 20;
6544 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
6545
6546 // Check if citation links are enabled (default to 'on' for backwards compatibility)
6547 // Use fresh options to ensure we get the latest setting value
6548 $fresh_options = get_option('mxchat_options', []);
6549 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6550
6551 // Build content from top sources
6552 foreach ($top_urls as $group_key => $group) {
6553 $source_url = $group['source_url']; // Use actual source_url, not the group key
6554
6555 // Stop if we've hit the total chunk limit
6556 if ($total_chunks_used >= $max_total_chunks) {
6557 break;
6558 }
6559
6560 $full_text = '';
6561 $chunks_in_this_source = 1; // Default for non-chunked content
6562
6563 if ($group['is_chunked']) {
6564 // Calculate how many chunks we can still use (respect both total and per-source caps)
6565 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
6566
6567 // Fetch chunks for this URL with limit
6568 $full_text = $this->reassemble_chunks_from_wordpress($source_url, $chunks_remaining, $chunks_in_this_source);
6569
6570 // If fetching all chunks fails, fall back to matched chunks
6571 if (empty($full_text)) {
6572 // Sort matched chunks by index and concatenate
6573 usort($group['chunks'], function($a, $b) {
6574 return $a['chunk_index'] <=> $b['chunk_index'];
6575 });
6576
6577 $chunk_texts = array();
6578 $chunks_in_this_source = 0;
6579 foreach ($group['chunks'] as $chunk) {
6580 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
6581 break;
6582 }
6583 $chunk_texts[] = $chunk['text'];
6584 $chunks_in_this_source++;
6585 }
6586 $full_text = implode("\n\n", $chunk_texts);
6587 }
6588 } else {
6589 $full_text = $group['single_text'];
6590 $chunks_in_this_source = 1;
6591 }
6592
6593 if (!empty($full_text)) {
6594 // Strip URLs from content if citation links are disabled
6595 if (!$citation_links_enabled) {
6596 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
6597 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
6598 }
6599
6600 // Use numbered reference for URL-based entries, plain info label for manual entries
6601 // Manual entries are stored with an internal mxchat:// placeholder URL — never expose them as citations
6602 if (!empty($source_url) && $source_url !== '#' && strpos($source_url, 'mxchat://') !== 0) {
6603 $matches_used++;
6604 $content .= "## Reference " . $matches_used . " ##\n";
6605 $content .= $full_text . "\n\n";
6606
6607 // Only include citation URLs if citation links are enabled
6608 if ($citation_links_enabled) {
6609 $valid_urls[] = $source_url;
6610 $content .= "URL: " . $source_url . "\n\n";
6611 }
6612
6613 // Video-backed source → queue the consent-safe embed (03ba33)
6614 $this->maybe_queue_youtube_embed($source_url, $full_text);
6615 } else {
6616 // Manual entry — no reference number, no citation
6617 $content .= "## Information ##\n";
6618 $content .= $full_text . "\n\n";
6619 }
6620
6621 // Extract any URLs from the text content itself (only if citation links enabled)
6622 if ($citation_links_enabled) {
6623 preg_match_all(
6624 '#\bhttps?://[^\s<>"\']+#i',
6625 $full_text,
6626 $content_urls
6627 );
6628 if (!empty($content_urls[0])) {
6629 $valid_urls = array_merge($valid_urls, $content_urls[0]);
6630 }
6631 }
6632
6633 $total_chunks_used += $chunks_in_this_source;
6634 }
6635 }
6636
6637 // NEW: Store unique valid URLs for validation
6638 $this->current_valid_urls = array_unique($valid_urls);
6639
6640 // Store sources and chunks counts for testing/transcript display
6641 $this->last_similarity_analysis['sources_used'] = $matches_used;
6642 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
6643
6644 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
6645 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
6646
6647 // Add response guidelines
6648 if (empty($top_urls)) {
6649 // No matched sources: return empty so the prompt assembler's
6650 // "NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE" branch fires —
6651 // a no-info sentence wrapped in OFFICIAL KNOWLEDGE markers reads to
6652 // the model as authoritative content (plan d7daf8).
6653 $content = '';
6654 } else {
6655 // Build response guidelines based on citation links setting
6656 $content .= "\n## Response Guidelines ##\n" .
6657 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
6658 "Be conversational and friendly, but never mention your knowledge base or training data. " .
6659 "If you don't have specific information or are uncertain about any details, it's always " .
6660 "better to honestly say you don't know rather than making up or guessing at answers. " .
6661 "When information is incomplete, let them know you are unsure.\n\n";
6662
6663 // Only add hyperlink instructions if citation links are enabled
6664 if ($citation_links_enabled) {
6665 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
6666 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
6667 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
6668 } else {
6669 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
6670 "Simply provide helpful answers based on the reference information without citing sources.";
6671 }
6672 }
6673
6674 return trim($content);
6675 }
6676
6677 /**
6678 * plan-mxchat-20260717-03ba33 — if a KB source used for context is a single
6679 * YouTube video, queue ONE consent-safe embed for the response html channel.
6680 * Called from BOTH retrieval builders (WordPress DB + Pinecone) inside their
6681 * real-URL winner branch, in ranked order — so the first (best) video wins and
6682 * later matches are ignored. Only KB/admin-ingested sources ever reach this
6683 * point; a URL a visitor pastes in chat never does.
6684 */
6685 private function maybe_queue_youtube_embed($source_url, $full_text) {
6686 if (!empty($this->videoEmbedHtml)) {
6687 return; // one video per response
6688 }
6689 $video_id = MxChat_Utils::parse_youtube_id($source_url);
6690 if (empty($video_id)) {
6691 return;
6692 }
6693 // Ingestion writes "YouTube Video: {title}" / "Channel: {name}" / "URL: …"
6694 // header lines into the indexed text. NOTE: when citation links are
6695 // disabled the winner loop collapses ALL whitespace to single spaces
6696 // before this runs, so the title must be terminated by the next header
6697 // label, not by end-of-line. Fall back to a generic label when absent
6698 // (e.g. a YouTube watch page imported through the plain URL source).
6699 $title = '';
6700 if (preg_match('/YouTube Video:\s*(.+?)(?=\s+Channel:\s|\s+URL:\s|\r|\n|$)/i', (string) $full_text, $m)) {
6701 $title = trim(mb_substr(trim($m[1]), 0, 140));
6702 if (preg_match('#^https?://#i', $title)) {
6703 $title = ''; // header carried the URL, not a real title
6704 }
6705 }
6706 $this->videoEmbedHtml = $this->build_youtube_embed_html($video_id, $title, $source_url);
6707 }
6708
6709 /**
6710 * Consent-safe click-to-load YouTube facade. No Google iframe is created until
6711 * the visitor taps play (chat-script.js swaps the facade for a
6712 * youtube-nocookie.com iframe). The caption always carries a plain "Watch on
6713 * YouTube" link, which is also the graceful degrade on strict-CSP sites where
6714 * third-party frames are blocked.
6715 */
6716 private function build_youtube_embed_html($video_id, $title, $watch_url) {
6717 $video_id = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $video_id);
6718 if ($video_id === '') {
6719 return '';
6720 }
6721 $thumb = 'https://i.ytimg.com/vi/' . $video_id . '/hqdefault.jpg';
6722 $label = ($title !== '') ? $title : __('YouTube video', 'mxchat');
6723
6724 $html = '<div class="mxchat-youtube-embed" data-video-id="' . esc_attr($video_id) . '">';
6725 $html .= '<button type="button" class="mxchat-youtube-facade" aria-label="' . esc_attr(sprintf(__('Play video: %s', 'mxchat'), $label)) . '">';
6726 $html .= '<img class="mxchat-youtube-thumb" src="' . esc_url($thumb) . '" alt="' . esc_attr($label) . '" loading="lazy" />';
6727 $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>';
6728 $html .= '</button>';
6729 $html .= '<div class="mxchat-youtube-caption">';
6730 $html .= '<span class="mxchat-youtube-title">' . esc_html($label) . '</span>';
6731 $html .= '<a class="mxchat-youtube-link" href="' . esc_url($watch_url) . '" target="_blank" rel="noopener noreferrer">' . esc_html__('Watch on YouTube', 'mxchat') . '</a>';
6732 $html .= '</div>';
6733 $html .= '</div>';
6734 return $html;
6735 }
6736
6737 /**
6738 * Fetch and reassemble chunks for a URL from WordPress database
6739 *
6740 * @param string $source_url The source URL to fetch chunks for
6741 * @param int $max_chunks Maximum number of chunks to return (0 = unlimited)
6742 * @param int &$chunk_count Reference to store the actual number of chunks returned
6743 * @return string Reassembled content from chunks
6744 */
6745 private function reassemble_chunks_from_wordpress($source_url, $max_chunks = 0, &$chunk_count = 0) {
6746 global $wpdb;
6747 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
6748
6749 // Fetch all rows with this source_url
6750 $rows = $wpdb->get_results($wpdb->prepare(
6751 "SELECT article_content FROM {$table}
6752 WHERE source_url = %s
6753 ORDER BY id ASC",
6754 $source_url
6755 ));
6756
6757 if (empty($rows)) {
6758 $chunk_count = 0;
6759 return '';
6760 }
6761
6762 // Parse and sort chunks by index
6763 $chunks = array();
6764 foreach ($rows as $row) {
6765 $parsed = MxChat_Chunker::parse_stored_chunk($row->article_content);
6766
6767 if ($parsed['is_chunked']) {
6768 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
6769 $chunks[$chunk_index] = $parsed['text'];
6770 } else {
6771 // Non-chunked content - just return it
6772 $chunks[] = $parsed['text'];
6773 }
6774 }
6775
6776 // Sort by chunk index
6777 ksort($chunks);
6778
6779 // Apply chunk limit if specified
6780 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
6781 $chunks = array_slice($chunks, 0, $max_chunks, true);
6782 }
6783
6784 // Store actual chunk count
6785 $chunk_count = count($chunks);
6786
6787 // Reassemble content
6788 return implode("\n\n", $chunks);
6789 }
6790
6791 private function find_relevant_content_pinecone($user_embedding, $bot_id = 'default', $bot_config = null) {
6792 global $wpdb;
6793
6794 //error_log("MXCHAT DEBUG: find_relevant_content_pinecone called");
6795 //error_log(" - bot_id: " . $bot_id);
6796 //error_log(" - user_embedding is array: " . (is_array($user_embedding) ? 'yes' : 'no'));
6797 //error_log(" - user_embedding count: " . (is_array($user_embedding) ? count($user_embedding) : 'N/A'));
6798
6799 // Use bot-specific config or fall back to default
6800 if ($bot_config === null) {
6801 $bot_config = $this->get_bot_pinecone_config($bot_id);
6802 }
6803
6804 $api_key = $bot_config['api_key'] ?? '';
6805 $host = $bot_config['host'] ?? '';
6806 $namespace = $bot_config['namespace'] ?? '';
6807
6808 //error_log("MXCHAT DEBUG: Pinecone query parameters:");
6809 //error_log(" - API Key: " . (empty($api_key) ? 'EMPTY - ERROR!' : 'Present (length: ' . strlen($api_key) . ')'));
6810 //error_log(" - Host: " . (empty($host) ? 'EMPTY - ERROR!' : $host));
6811 //error_log(" - Namespace: " . (empty($namespace) ? 'EMPTY (will use default)' : $namespace));
6812
6813 // Initialize similarity analysis storage
6814 $this->last_similarity_analysis = [
6815 'knowledge_base_type' => 'Pinecone',
6816 'bot_id' => $bot_id,
6817 'namespace' => $namespace,
6818 'top_matches' => [],
6819 'threshold_used' => 0,
6820 'total_checked' => 0
6821 ];
6822
6823 // NEW: Initialize valid URLs array
6824 $valid_urls = [];
6825
6826 if (empty($host) || empty($api_key)) {
6827 //error_log("MXCHAT DEBUG ERROR: Missing Pinecone host or API key!");
6828 //error_log(" - Host empty: " . (empty($host) ? 'YES' : 'NO'));
6829 //error_log(" - API key empty: " . (empty($api_key) ? 'YES' : 'NO'));
6830 // Store empty array for valid URLs since we can't proceed
6831 $this->current_valid_urls = [];
6832 return '';
6833 }
6834
6835 // Get knowledge manager instance for role checking
6836 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
6837
6838 // Get the similarity threshold from the bot options or main options
6839 $bot_options = $this->get_bot_options($bot_id);
6840 $current_options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', []);
6841
6842 $similarity_threshold = isset($current_options['similarity_threshold'])
6843 ? ((int) $current_options['similarity_threshold']) / 100
6844 : 0.35;
6845
6846 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
6847
6848 // Prepare the query request for Pinecone
6849 $api_endpoint = "https://{$host}/query";
6850
6851 $request_body = array(
6852 'vector' => $user_embedding,
6853 'topK' => 50, // Increased for chunked content grouping - need more candidates to find top N unique URLs
6854 'includeMetadata' => true,
6855 'includeValues' => true
6856 );
6857
6858 // Add namespace if specified for this bot
6859 if (!empty($namespace)) {
6860 $request_body['namespace'] = $namespace;
6861 }
6862
6863 //error_log("MXCHAT DEBUG: About to call Pinecone API");
6864 //error_log(" - Endpoint: " . $api_endpoint);
6865 //error_log(" - Namespace in request: " . (!empty($namespace) ? $namespace : 'NOT SET'));
6866
6867 $response = wp_remote_post($api_endpoint, array(
6868 'headers' => array(
6869 'Api-Key' => $api_key,
6870 'accept' => 'application/json',
6871 'content-type' => 'application/json'
6872 ),
6873 'body' => wp_json_encode($request_body),
6874 'timeout' => 30
6875 ));
6876
6877 if (is_wp_error($response)) {
6878 //error_log("MXCHAT DEBUG ERROR: WP Error in Pinecone request: " . $response->get_error_message());
6879 // Store empty array for valid URLs
6880 $this->current_valid_urls = [];
6881 return '';
6882 }
6883
6884 $response_code = wp_remote_retrieve_response_code($response);
6885 //error_log("MXCHAT DEBUG: Pinecone response code: " . $response_code);
6886
6887 if ($response_code !== 200) {
6888 $response_body = wp_remote_retrieve_body($response);
6889 //error_log("MXCHAT DEBUG ERROR: Pinecone API error response: " . substr($response_body, 0, 500));
6890 // Store empty array for valid URLs
6891 $this->current_valid_urls = [];
6892 return '';
6893 }
6894
6895 // ADD DETAILED DEBUG SECTION HERE
6896 $response_body = wp_remote_retrieve_body($response);
6897 //error_log("MXCHAT DEBUG: Raw Pinecone response length: " . strlen($response_body));
6898
6899 $results = json_decode($response_body, true);
6900
6901 if (json_last_error() !== JSON_ERROR_NONE) {
6902 //error_log("MXCHAT DEBUG ERROR: JSON decode error: " . json_last_error_msg());
6903 //error_log("MXCHAT DEBUG: First 500 chars of response: " . substr($response_body, 0, 500));
6904 // Store empty array for valid URLs
6905 $this->current_valid_urls = [];
6906 return '';
6907 }
6908
6909 //error_log("MXCHAT DEBUG: Pinecone response structure:");
6910 //error_log(" - Has 'matches' key: " . (isset($results['matches']) ? 'yes' : 'no'));
6911 //error_log(" - Has 'namespace' key: " . (isset($results['namespace']) ? 'yes (' . $results['namespace'] . ')' : 'no'));
6912
6913 if (empty($results['matches'])) {
6914 //error_log("MXCHAT DEBUG: No matches found in Pinecone response");
6915 //error_log("MXCHAT DEBUG: Response keys: " . implode(', ', array_keys($results)));
6916 // Store empty array for valid URLs
6917 $this->current_valid_urls = [];
6918 return '';
6919 }
6920
6921 //error_log("MXCHAT DEBUG: Found " . count($results['matches']) . " matches in Pinecone");
6922
6923 // Log first match details for debugging
6924 if (!empty($results['matches'][0])) {
6925 $first_match = $results['matches'][0];
6926 //error_log("MXCHAT DEBUG: First match details:");
6927 //error_log(" - Score: " . ($first_match['score'] ?? 'no score'));
6928 //error_log(" - Has metadata: " . (isset($first_match['metadata']) ? 'yes' : 'no'));
6929 if (isset($first_match['metadata'])) {
6930 //error_log(" - Metadata keys: " . implode(', ', array_keys($first_match['metadata'])));
6931 }
6932 }
6933
6934 // Initialize the final content
6935 $content = '';
6936 $matches_used = 0;
6937 $matches_used_for_context = [];
6938 $total_chunks_used = 0;
6939 $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
6940 if ($max_total_chunks < 8) $max_total_chunks = 8;
6941 if ($max_total_chunks > 20) $max_total_chunks = 20;
6942 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
6943
6944 // Check if citation links are enabled (default to 'on' for backwards compatibility)
6945 // Use fresh options to ensure we get the latest setting value
6946 $fresh_options = get_option('mxchat_options', []);
6947 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6948
6949 // NEW CHUNKING LOGIC: Group results by source_url for chunk reassembly
6950 $url_groups = array();
6951
6952 foreach ($results['matches'] as $index => $match) {
6953 // Skip if similarity is below threshold
6954 if ($match['score'] < $similarity_threshold) {
6955 continue;
6956 }
6957
6958 $metadata = $match['metadata'] ?? array();
6959 $source_url = $metadata['source_url'] ?? '';
6960 $match_id = $match['id'] ?? '';
6961
6962 // LAZY ROLE CHECK: Only check role for content we're actually considering
6963 $role_restriction = $this->get_single_vector_role($match_id, $metadata);
6964 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
6965
6966 // Skip if user doesn't have access
6967 if (!$has_access) {
6968 continue;
6969 }
6970
6971 // Use a unique key for manual entries without a source URL
6972 $group_key = !empty($source_url) ? $source_url : '_manual_' . $match_id;
6973
6974 // Group by source URL (or unique key for manual entries)
6975 if (!isset($url_groups[$group_key])) {
6976 $url_groups[$group_key] = array(
6977 'source_url' => $source_url,
6978 'best_score' => 0,
6979 'is_chunked' => isset($metadata['is_chunked']) && $metadata['is_chunked'],
6980 'chunks' => array(),
6981 'single_text' => ''
6982 );
6983 }
6984
6985 // Track best score for this group
6986 if ($match['score'] > $url_groups[$group_key]['best_score']) {
6987 $url_groups[$group_key]['best_score'] = $match['score'];
6988 }
6989
6990 // Store chunk info or single text
6991 if ($url_groups[$group_key]['is_chunked']) {
6992 $url_groups[$group_key]['chunks'][] = array(
6993 'id' => $match_id,
6994 'score' => $match['score'],
6995 'chunk_index' => $metadata['chunk_index'] ?? 0,
6996 'text' => $metadata['text'] ?? ''
6997 );
6998 } else {
6999 // Non-chunked content - just store the text
7000 $url_groups[$group_key]['single_text'] = $metadata['text'] ?? '';
7001 $url_groups[$group_key]['single_id'] = $match_id;
7002 }
7003 }
7004
7005 // Sort URL groups by best score (highest first)
7006 uasort($url_groups, function($a, $b) {
7007 return $b['best_score'] <=> $a['best_score'];
7008 });
7009
7010 // Get RAG sources limit from options (default 6, min 3, max 10)
7011 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
7012 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
7013 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
7014
7015 // Take top N unique URLs based on user setting
7016 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
7017
7018 // Track which match IDs are actually used for context
7019 foreach ($top_urls as $group) {
7020 if ($group['is_chunked']) {
7021 foreach ($group['chunks'] as $chunk) {
7022 $matches_used_for_context[] = $chunk['id'];
7023 }
7024 } elseif (!empty($group['single_id'])) {
7025 $matches_used_for_context[] = $group['single_id'];
7026 }
7027 }
7028
7029 // Build content from top sources
7030 foreach ($top_urls as $group_key => $group) {
7031 $source_url = $group['source_url']; // Use actual source_url, not the group key
7032
7033 // Stop if we've hit the total chunk limit
7034 if ($total_chunks_used >= $max_total_chunks) {
7035 break;
7036 }
7037
7038 $full_text = '';
7039 $chunks_in_this_source = 1; // Default for non-chunked content
7040
7041 if ($group['is_chunked']) {
7042 // Calculate how many chunks we can still use (respect both total and per-source caps)
7043 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
7044
7045 // Fetch chunks for this URL with limit
7046 $full_text = $this->reassemble_chunks_from_pinecone($source_url, $bot_config, $chunks_remaining, $chunks_in_this_source);
7047
7048 // If fetching all chunks fails, fall back to matched chunks
7049 if (empty($full_text)) {
7050 // Sort matched chunks by index and concatenate
7051 usort($group['chunks'], function($a, $b) {
7052 return $a['chunk_index'] <=> $b['chunk_index'];
7053 });
7054
7055 $chunk_texts = array();
7056 $chunks_in_this_source = 0;
7057 foreach ($group['chunks'] as $chunk) {
7058 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
7059 break;
7060 }
7061 $chunk_texts[] = $chunk['text'];
7062 $chunks_in_this_source++;
7063 }
7064 $full_text = implode("\n\n", $chunk_texts);
7065 }
7066 } else {
7067 $full_text = $group['single_text'];
7068 $chunks_in_this_source = 1;
7069 }
7070
7071 if (!empty($full_text)) {
7072 // Strip URLs from content if citation links are disabled
7073 if (!$citation_links_enabled) {
7074 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
7075 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
7076 }
7077
7078 // Use numbered reference for URL-based entries, plain info label for manual entries
7079 // Manual entries are stored with an internal mxchat:// placeholder URL — never expose them as citations
7080 if (!empty($source_url) && $source_url !== '#' && strpos($source_url, 'mxchat://') !== 0) {
7081 $matches_used++;
7082 $content .= "## Reference " . $matches_used . " ##\n";
7083 $content .= $full_text . "\n\n";
7084
7085 // Only include citation URLs if citation links are enabled
7086 if ($citation_links_enabled) {
7087 $valid_urls[] = $source_url;
7088 $content .= "URL: " . $source_url . "\n\n";
7089 }
7090
7091 // Video-backed source → queue the consent-safe embed (03ba33)
7092 $this->maybe_queue_youtube_embed($source_url, $full_text);
7093 } else {
7094 // Manual entry — no reference number, no citation. Count it as a USED
7095 // source (plan-mxchat-20260622-c1fe6a): without this, manual/Direct-Content
7096 // entries (empty or mxchat:// source_url) never increment $matches_used, so
7097 // the gate below (`if ($matches_used === 0)`) discards manual-only context on
7098 // the Pinecone backend and the model is told "No reference information was
7099 // found" — even though the testing panel reports used_for_context:true. It
7100 // also corrects the cosmetic sources_used:0 the panel/transcript showed. The
7101 // sibling local/WP-DB builder gates on empty($top_urls), so it never had this
7102 // bug; this brings Pinecone to parity. Manual entries are still uncited (not
7103 // added to $valid_urls, no "URL:" line).
7104 $matches_used++;
7105 $content .= "## Information ##\n";
7106 $content .= $full_text . "\n\n";
7107 }
7108
7109 // Extract any URLs from the text content itself (only if citation links enabled)
7110 if ($citation_links_enabled) {
7111 preg_match_all(
7112 '#\bhttps?://[^\s<>"\']+#i',
7113 $full_text,
7114 $content_urls
7115 );
7116 if (!empty($content_urls[0])) {
7117 $valid_urls = array_merge($valid_urls, $content_urls[0]);
7118 }
7119 }
7120
7121 $total_chunks_used += $chunks_in_this_source;
7122 }
7123 }
7124
7125 // Process ALL matches for testing data (top 10) - with role checking for testing display
7126 $all_matches = [];
7127 foreach ($results['matches'] as $index => $match) {
7128 if ($index >= 10) break; // Limit to top 10 for testing
7129
7130 $match_id = $match['id'] ?? '';
7131
7132 // Check role access for testing display (use cache if available)
7133 $role_restriction = $this->get_single_vector_role($match_id, $match['metadata']);
7134 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
7135
7136 $source_display = '';
7137 if (!empty($match['metadata']['source_url'])) {
7138 $source_display = $match['metadata']['source_url'];
7139 } else {
7140 $content_preview = strip_tags($match['metadata']['text'] ?? '');
7141 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
7142 $source_display = substr(trim($content_preview), 0, 50) . '...';
7143 }
7144
7145 $match_id_for_display = $match['id'] ?? $index;
7146
7147 // Check for chunk metadata in Pinecone
7148 $is_chunk = isset($match['metadata']['is_chunked']) && $match['metadata']['is_chunked'];
7149 $chunk_index = isset($match['metadata']['chunk_index']) ? intval($match['metadata']['chunk_index']) : null;
7150 $total_chunks = isset($match['metadata']['total_chunks']) ? intval($match['metadata']['total_chunks']) : null;
7151
7152 // Also detect chunk from vector ID pattern: {hash}_chunk_{index}
7153 if (!$is_chunk && MxChat_Chunker::is_chunk_vector_id($match_id_for_display)) {
7154 $is_chunk = true;
7155 }
7156
7157 $all_matches[] = [
7158 'document_id' => $match_id_for_display,
7159 'similarity' => $match['score'],
7160 'similarity_percentage' => round($match['score'] * 100, 2),
7161 'above_threshold' => $match['score'] >= $similarity_threshold,
7162 'source_display' => $source_display,
7163 'content_preview' => substr(strip_tags($match['metadata']['text'] ?? ''), 0, 100) . '...',
7164 'used_for_context' => in_array($match_id_for_display, $matches_used_for_context),
7165 'role_restriction' => $role_restriction,
7166 'has_access' => $has_access,
7167 'filtered_out' => !$has_access,
7168 'is_chunk' => $is_chunk,
7169 'chunk_index' => $chunk_index,
7170 'total_chunks' => $total_chunks
7171 ];
7172 }
7173
7174 // Store for testing panel
7175 $this->last_similarity_analysis['top_matches'] = $all_matches;
7176 $this->last_similarity_analysis['total_checked'] = count($results['matches']);
7177 $this->last_similarity_analysis['sources_used'] = $matches_used;
7178 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
7179
7180 // NEW: Store unique valid URLs for validation
7181 $this->current_valid_urls = array_unique($valid_urls);
7182
7183 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
7184 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
7185
7186 // Add response guidelines
7187 if ($matches_used === 0) {
7188 // Empty return → assembler's NO RELEVANT CONTENT branch (plan d7daf8).
7189 $content = '';
7190 } else {
7191 // Build response guidelines based on citation links setting
7192 $content .= "\n## Response Guidelines ##\n" .
7193 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
7194 "Be conversational and friendly, but never mention your knowledge base or training data. " .
7195 "If you don't have specific information or are uncertain about any details, it's always " .
7196 "better to honestly say you don't know rather than making up or guessing at answers. " .
7197 "When information is incomplete, let them know you are unsure.\n\n";
7198
7199 // Only add hyperlink instructions if citation links are enabled
7200 if ($citation_links_enabled) {
7201 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
7202 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
7203 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
7204 } else {
7205 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
7206 "Simply provide helpful answers based on the reference information without citing sources.";
7207 }
7208 }
7209
7210 return trim($content);
7211 }
7212
7213 /**
7214 * Get role restriction for a single vector (with caching)
7215 */
7216 private function get_single_vector_role($vector_id, $metadata = array()) {
7217 global $wpdb;
7218
7219 if (empty($vector_id)) {
7220 return 'public';
7221 }
7222
7223 // Check cache first
7224 $cache_key = 'mxchat_vector_role_' . $vector_id;
7225 $cached_role = wp_cache_get($cache_key, 'mxchat_vector_roles');
7226
7227 if ($cached_role !== false) {
7228 return $cached_role;
7229 }
7230
7231 $role_restriction = 'public';
7232
7233 // First try Pinecone metadata
7234 if (!empty($metadata['role_restriction'])) {
7235 $role_restriction = $metadata['role_restriction'];
7236 } else {
7237 // Check WordPress table for user-modified roles
7238 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
7239 $stored_role = $wpdb->get_var($wpdb->prepare(
7240 "SELECT role_restriction FROM {$roles_table} WHERE vector_id = %s",
7241 $vector_id
7242 ));
7243
7244 if ($stored_role) {
7245 $role_restriction = $stored_role;
7246 }
7247 }
7248
7249 // Cache individual role for 1 hour
7250 wp_cache_set($cache_key, $role_restriction, 'mxchat_vector_roles', 3600);
7251
7252 return $role_restriction;
7253 }
7254
7255 /**
7256 * Fetch and reassemble all chunks for a URL from Pinecone
7257 *
7258 * @param string $source_url The source URL to fetch chunks for
7259 * @param array $bot_config Bot-specific Pinecone configuration
7260 * @return string Reassembled content from all chunks
7261 */
7262 private function reassemble_chunks_from_pinecone($source_url, $bot_config, $max_chunks = 0, &$chunk_count = 0) {
7263 $api_key = $bot_config['api_key'] ?? '';
7264 $host = $bot_config['host'] ?? '';
7265 $namespace = $bot_config['namespace'] ?? '';
7266
7267 if (empty($host) || empty($api_key)) {
7268 $chunk_count = 0;
7269 return '';
7270 }
7271
7272 $base_hash = md5($source_url);
7273
7274 // Use Pinecone list API to find all chunk vectors with this prefix
7275 $list_url = "https://{$host}/vectors/list";
7276
7277 // Limit to max_chunks if specified, otherwise fetch up to 100
7278 $fetch_limit = ($max_chunks > 0 && $max_chunks < 100) ? $max_chunks : 100;
7279
7280 $list_body = array(
7281 'prefix' => $base_hash . '_chunk_',
7282 'limit' => $fetch_limit
7283 );
7284
7285 if (!empty($namespace)) {
7286 $list_body['namespace'] = $namespace;
7287 }
7288
7289 $list_response = wp_remote_post($list_url, array(
7290 'headers' => array(
7291 'Api-Key' => $api_key,
7292 'accept' => 'application/json',
7293 'content-type' => 'application/json'
7294 ),
7295 'body' => wp_json_encode($list_body),
7296 'timeout' => 30
7297 ));
7298
7299 if (is_wp_error($list_response)) {
7300 //error_log('[MXCHAT-CHUNK] List API error: ' . $list_response->get_error_message());
7301 return '';
7302 }
7303
7304 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
7305
7306 if (empty($list_data['vectors'])) {
7307 //error_log('[MXCHAT-CHUNK] No chunk vectors found for URL: ' . $source_url);
7308 return '';
7309 }
7310
7311 // Extract vector IDs
7312 $vector_ids = array();
7313 foreach ($list_data['vectors'] as $vector) {
7314 if (isset($vector['id'])) {
7315 $vector_ids[] = $vector['id'];
7316 }
7317 }
7318
7319 if (empty($vector_ids)) {
7320 return '';
7321 }
7322
7323 // Fetch all chunk content
7324 $fetch_url = "https://{$host}/vectors/fetch";
7325
7326 $fetch_body = array(
7327 'ids' => $vector_ids
7328 );
7329
7330 if (!empty($namespace)) {
7331 $fetch_body['namespace'] = $namespace;
7332 }
7333
7334 $fetch_response = wp_remote_post($fetch_url, array(
7335 'headers' => array(
7336 'Api-Key' => $api_key,
7337 'accept' => 'application/json',
7338 'content-type' => 'application/json'
7339 ),
7340 'body' => wp_json_encode($fetch_body),
7341 'timeout' => 30
7342 ));
7343
7344 if (is_wp_error($fetch_response)) {
7345 //error_log('[MXCHAT-CHUNK] Fetch API error: ' . $fetch_response->get_error_message());
7346 return '';
7347 }
7348
7349 $fetch_data = json_decode(wp_remote_retrieve_body($fetch_response), true);
7350
7351 if (empty($fetch_data['vectors'])) {
7352 return '';
7353 }
7354
7355 // Sort chunks by index and reassemble
7356 $chunks = array();
7357 foreach ($fetch_data['vectors'] as $id => $vector) {
7358 $metadata = $vector['metadata'] ?? array();
7359 $chunk_index = $metadata['chunk_index'] ?? 0;
7360 $text = $metadata['text'] ?? '';
7361
7362 // Store chunk with its index
7363 $chunks[$chunk_index] = $text;
7364 }
7365
7366 // Sort by chunk index
7367 ksort($chunks);
7368
7369 // Apply chunk limit if specified
7370 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
7371 $chunks = array_slice($chunks, 0, $max_chunks, true);
7372 }
7373
7374 // Store actual chunk count
7375 $chunk_count = count($chunks);
7376
7377 // Reassemble content
7378 return implode("\n\n", $chunks);
7379 }
7380
7381 /**
7382 * Search for relevant content using OpenAI Vector Store (File Search)
7383 *
7384 * @param string $user_query The user's query text
7385 * @param string $bot_id The bot ID
7386 * @param array $vectorstore_config Vector Store configuration
7387 * @return string Formatted context string with references
7388 */
7389 private function find_relevant_content_openai_vectorstore($user_query, $bot_id = 'default', $vectorstore_config = array()) {
7390 //error_log("MXCHAT DEBUG: find_relevant_content_openai_vectorstore called");
7391 //error_log(" - bot_id: " . $bot_id);
7392 //error_log(" - user_query length: " . strlen($user_query));
7393
7394 // Get OpenAI API key
7395 $mxchat_options = get_option('mxchat_options', array());
7396 $api_key = $mxchat_options['api_key'] ?? '';
7397
7398 // Reset vectorstore error tracking
7399 $this->last_vectorstore_error = null;
7400
7401 if (empty($api_key)) {
7402 //error_log("MXCHAT DEBUG ERROR: OpenAI API key not configured");
7403 $this->last_vectorstore_error = 'Vector Store search failed: OpenAI API key is not configured.';
7404 $this->current_valid_urls = [];
7405 return '';
7406 }
7407
7408 // Get Vector Store configuration
7409 if (empty($vectorstore_config)) {
7410 $vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
7411 }
7412
7413 $vectorstore_ids_string = $vectorstore_config['vectorstore_ids'] ?? '';
7414 $max_results = $vectorstore_config['max_results'] ?? 5;
7415
7416 if (empty($vectorstore_ids_string)) {
7417 //error_log("MXCHAT DEBUG ERROR: No Vector Store IDs configured");
7418 $this->last_vectorstore_error = 'Vector Store search failed: No Vector Store IDs are configured for this bot.';
7419 $this->current_valid_urls = [];
7420 return '';
7421 }
7422
7423 // Parse Vector Store IDs
7424 $vectorstore_ids = array_map('trim', explode(',', $vectorstore_ids_string));
7425 $vectorstore_ids = array_filter($vectorstore_ids); // Remove empty values
7426
7427 //error_log("MXCHAT DEBUG: Vector Store IDs: " . implode(', ', $vectorstore_ids));
7428 //error_log("MXCHAT DEBUG: Max results: " . $max_results);
7429
7430 // Initialize similarity analysis storage
7431 $this->last_similarity_analysis = [
7432 'knowledge_base_type' => 'OpenAI Vector Store',
7433 'bot_id' => $bot_id,
7434 'vectorstore_ids' => $vectorstore_ids,
7435 'top_matches' => [],
7436 'threshold_used' => 0,
7437 'total_checked' => 0
7438 ];
7439
7440 $valid_urls = [];
7441
7442 // Get the selected model
7443 $bot_options = $this->get_bot_options($bot_id);
7444 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
7445 $selected_model = $current_options['model'] ?? 'gpt-5.6-sol';
7446
7447 // Verify it's an OpenAI model
7448 if (!$this->is_openai_chat_model($selected_model)) {
7449 //error_log("MXCHAT DEBUG ERROR: Vector Store search requires OpenAI model. Current: " . $selected_model);
7450 $this->last_vectorstore_error = 'Vector Store search requires an OpenAI model. Current model: ' . $selected_model;
7451 $this->current_valid_urls = [];
7452 return '';
7453 }
7454
7455 // Use OpenAI Responses API with file_search tool
7456 $request_body = array(
7457 'model' => $selected_model,
7458 'input' => $user_query,
7459 'tools' => array(
7460 array(
7461 'type' => 'file_search',
7462 'vector_store_ids' => $vectorstore_ids,
7463 'max_num_results' => intval($max_results)
7464 )
7465 ),
7466 'include' => array('file_search_call.results')
7467 );
7468
7469 //error_log("MXCHAT VECTORSTORE: ========== REQUEST START ==========");
7470 //error_log("MXCHAT VECTORSTORE: Model: " . $selected_model);
7471 //error_log("MXCHAT VECTORSTORE: Query: " . substr($user_query, 0, 200));
7472 //error_log("MXCHAT VECTORSTORE: Vector Store IDs: " . implode(', ', $vectorstore_ids));
7473 //error_log("MXCHAT VECTORSTORE: Max Results: " . $max_results);
7474 //error_log("MXCHAT VECTORSTORE: Request body: " . wp_json_encode($request_body));
7475
7476 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
7477 'headers' => array(
7478 'Authorization' => 'Bearer ' . $api_key,
7479 'Content-Type' => 'application/json'
7480 ),
7481 'body' => wp_json_encode($request_body),
7482 'timeout' => 60
7483 ));
7484
7485 if (is_wp_error($response)) {
7486 //error_log("MXCHAT VECTORSTORE ERROR: WP Error: " . $response->get_error_message());
7487 $this->last_vectorstore_error = 'Vector Store API request failed: ' . $response->get_error_message();
7488 $this->current_valid_urls = [];
7489 return '';
7490 }
7491
7492 $response_code = wp_remote_retrieve_response_code($response);
7493 //error_log("MXCHAT VECTORSTORE: Response code: " . $response_code);
7494
7495 $response_body = wp_remote_retrieve_body($response);
7496 //error_log("MXCHAT VECTORSTORE: Raw response (first 2000 chars): " . substr($response_body, 0, 2000));
7497
7498 if ($response_code !== 200) {
7499 //error_log("MXCHAT VECTORSTORE ERROR: API error response: " . $response_body);
7500 $decoded_error = json_decode($response_body, true);
7501 $api_error_detail = $this->extract_provider_error($decoded_error, '');
7502 $this->last_vectorstore_error = 'Vector Store API returned HTTP ' . $response_code . ($api_error_detail ? ': ' . $api_error_detail : '');
7503 $this->current_valid_urls = [];
7504 return '';
7505 }
7506 $result = json_decode($response_body, true);
7507
7508 if (json_last_error() !== JSON_ERROR_NONE) {
7509 //error_log("MXCHAT VECTORSTORE ERROR: JSON decode error: " . json_last_error_msg());
7510 $this->last_vectorstore_error = 'Vector Store response could not be parsed: ' . json_last_error_msg();
7511 $this->current_valid_urls = [];
7512 return '';
7513 }
7514
7515 // Debug: Log the structure of the result
7516 //error_log("MXCHAT VECTORSTORE: Result keys: " . implode(', ', array_keys($result)));
7517 if (isset($result['output'])) {
7518 //error_log("MXCHAT VECTORSTORE: Output count: " . count($result['output']));
7519 foreach ($result['output'] as $idx => $out) {
7520 //error_log("MXCHAT VECTORSTORE: Output[$idx] type: " . ($out['type'] ?? 'unknown'));
7521 //error_log("MXCHAT VECTORSTORE: Output[$idx] keys: " . implode(', ', array_keys($out)));
7522 }
7523 } else {
7524 //error_log("MXCHAT VECTORSTORE: No 'output' key in result!");
7525 }
7526
7527 // Extract file search results from the response
7528 $content = '';
7529 $matches_used = 0;
7530 $all_matches = [];
7531
7532 // The Responses API returns output array with tool results
7533 if (isset($result['output']) && is_array($result['output'])) {
7534 foreach ($result['output'] as $output_item) {
7535 // Look for file_search_call results
7536 if (isset($output_item['type']) && $output_item['type'] === 'file_search_call') {
7537 //error_log("MXCHAT VECTORSTORE: Found file_search_call output item");
7538 //error_log("MXCHAT VECTORSTORE: file_search_call keys: " . implode(', ', array_keys($output_item)));
7539
7540 // Check for search_results in the output item directly
7541 $search_results = $output_item['search_results'] ?? $output_item['results'] ?? [];
7542 //error_log("MXCHAT VECTORSTORE: Search results count: " . count($search_results));
7543
7544 if (empty($search_results)) {
7545 //error_log("MXCHAT VECTORSTORE: No search results found in file_search_call");
7546 //error_log("MXCHAT VECTORSTORE: file_search_call content: " . wp_json_encode($output_item));
7547 }
7548
7549 foreach ($search_results as $index => $search_result) {
7550 $filename = $search_result['filename'] ?? '';
7551 $score = $search_result['score'] ?? 0;
7552 $text_content = '';
7553
7554 // Extract text content from the result
7555 // The text can be directly on the result OR nested under content array
7556 if (isset($search_result['text']) && !empty($search_result['text'])) {
7557 // Direct text field (OpenAI's actual format)
7558 $text_content = $search_result['text'];
7559 //error_log("MXCHAT VECTORSTORE: Found text directly on result[$index], length: " . strlen($text_content));
7560 } elseif (isset($search_result['content']) && is_array($search_result['content'])) {
7561 // Nested content array format
7562 foreach ($search_result['content'] as $content_item) {
7563 if (isset($content_item['text'])) {
7564 $text_content .= $content_item['text'] . "\n";
7565 }
7566 }
7567 //error_log("MXCHAT VECTORSTORE: Found text in content array for result[$index], length: " . strlen($text_content));
7568 } else {
7569 //error_log("MXCHAT VECTORSTORE: No text found for result[$index]. Keys: " . implode(', ', array_keys($search_result)));
7570 }
7571
7572 if (!empty($text_content)) {
7573 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
7574 $content .= trim($text_content) . "\n\n";
7575
7576 if (!empty($filename)) {
7577 $content .= "Source: " . $filename . "\n\n";
7578 }
7579
7580 // Extract URLs from content
7581 preg_match_all(
7582 '#\bhttps?://[^\s<>"\']+#i',
7583 $text_content,
7584 $content_urls
7585 );
7586 if (!empty($content_urls[0])) {
7587 $valid_urls = array_merge($valid_urls, $content_urls[0]);
7588 }
7589
7590 $matches_used++;
7591 }
7592
7593 // Store for similarity analysis
7594 $all_matches[] = [
7595 'document_id' => $filename ?: ('result_' . $index),
7596 'similarity' => $score,
7597 'similarity_percentage' => round($score * 100, 2),
7598 'above_threshold' => true,
7599 'source_display' => $filename,
7600 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
7601 'used_for_context' => true,
7602 'role_restriction' => 'public',
7603 'has_access' => true,
7604 'filtered_out' => false
7605 ];
7606 }
7607 }
7608
7609 // Also check for message content with annotations (citations)
7610 if (isset($output_item['type']) && $output_item['type'] === 'message') {
7611 if (isset($output_item['content']) && is_array($output_item['content'])) {
7612 foreach ($output_item['content'] as $content_block) {
7613 if (isset($content_block['annotations']) && is_array($content_block['annotations'])) {
7614 foreach ($content_block['annotations'] as $annotation) {
7615 if (isset($annotation['filename'])) {
7616 $filename = $annotation['filename'];
7617 $score = $annotation['score'] ?? 0;
7618 $text_content = '';
7619
7620 if (isset($annotation['content']) && is_array($annotation['content'])) {
7621 foreach ($annotation['content'] as $ann_content) {
7622 if (isset($ann_content['text'])) {
7623 $text_content .= $ann_content['text'] . "\n";
7624 }
7625 }
7626 }
7627
7628 if (!empty($text_content) && $matches_used < $max_results) {
7629 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
7630 $content .= trim($text_content) . "\n\n";
7631 $content .= "Source: " . $filename . "\n\n";
7632
7633 preg_match_all(
7634 '#\bhttps?://[^\s<>"\']+#i',
7635 $text_content,
7636 $content_urls
7637 );
7638 if (!empty($content_urls[0])) {
7639 $valid_urls = array_merge($valid_urls, $content_urls[0]);
7640 }
7641
7642 $matches_used++;
7643
7644 $all_matches[] = [
7645 'document_id' => $filename,
7646 'similarity' => $score,
7647 'similarity_percentage' => round($score * 100, 2),
7648 'above_threshold' => true,
7649 'source_display' => $filename,
7650 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
7651 'used_for_context' => true,
7652 'role_restriction' => 'public',
7653 'has_access' => true,
7654 'filtered_out' => false
7655 ];
7656 }
7657 }
7658 }
7659 }
7660 }
7661 }
7662 }
7663 }
7664 }
7665
7666 // Store for testing panel
7667 $this->last_similarity_analysis['top_matches'] = $all_matches;
7668 $this->last_similarity_analysis['total_checked'] = count($all_matches);
7669
7670 // Store unique valid URLs for validation
7671 $this->current_valid_urls = array_unique($valid_urls);
7672
7673 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
7674 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
7675
7676 //error_log("MXCHAT VECTORSTORE: ========== SEARCH COMPLETE ==========");
7677 //error_log("MXCHAT VECTORSTORE: Matches used: " . $matches_used);
7678 //error_log("MXCHAT VECTORSTORE: All matches count: " . count($all_matches));
7679 //error_log("MXCHAT VECTORSTORE: Content length: " . strlen($content));
7680 if ($matches_used > 0) {
7681 //error_log("MXCHAT VECTORSTORE: Content preview: " . substr($content, 0, 500));
7682 }
7683
7684 // Check if citation links are enabled
7685 $citation_links_enabled = ($mxchat_options['citation_links_toggle'] ?? 'on') === 'on';
7686
7687 // Add response guidelines
7688 if ($matches_used === 0) {
7689 //error_log("MXCHAT VECTORSTORE: No matches found - returning empty reference message");
7690 // Empty return → assembler's NO RELEVANT CONTENT branch (plan d7daf8).
7691 $content = '';
7692 } else {
7693 // Build response guidelines based on citation links setting
7694 $content .= "\n## Response Guidelines ##\n" .
7695 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
7696 "Be conversational and friendly, but never mention your knowledge base or training data. " .
7697 "If you don't have specific information or are uncertain about any details, it's always " .
7698 "better to honestly say you don't know rather than making up or guessing at answers. " .
7699 "When information is incomplete, let them know you are unsure.\n\n";
7700
7701 // Only add hyperlink instructions if citation links are enabled
7702 if ($citation_links_enabled) {
7703 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
7704 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
7705 } else {
7706 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
7707 "Simply provide helpful answers based on the reference information without citing sources.";
7708 }
7709 }
7710
7711 //error_log("MXCHAT DEBUG: Vector Store search complete. Matches used: " . $matches_used);
7712
7713 return trim($content);
7714 }
7715
7716 /**
7717 * Check if the given model is an OpenAI chat model
7718 *
7719 * @param string $model The model ID
7720 * @return bool True if it's an OpenAI model
7721 */
7722 private function is_openai_chat_model($model) {
7723 $openai_prefixes = array('gpt-', 'o1-', 'o3-');
7724 foreach ($openai_prefixes as $prefix) {
7725 if (strpos($model, $prefix) === 0) {
7726 return true;
7727 }
7728 }
7729 return false;
7730 }
7731
7732 /**
7733 * Get bot-specific Vector Store configuration
7734 *
7735 * @param string $bot_id The bot ID
7736 * @return array Configuration array
7737 */
7738 private function get_bot_vectorstore_config($bot_id = 'default') {
7739 // Admin Testing tab bot → resolve the DEFAULT bot's backend (see
7740 // get_bot_pinecone_config). This getter already passes the real default
7741 // config into the filter, so it was not broken — normalized anyway so the
7742 // Testing bot can never drift from the front-end default.
7743 if ($bot_id === 'testing') {
7744 $bot_id = 'default';
7745 }
7746
7747 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
7748
7749 // Default global settings
7750 $default_config = array(
7751 'use_vectorstore' => ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1',
7752 'vectorstore_ids' => $vectorstore_options['mxchat_vectorstore_ids'] ?? '',
7753 'max_results' => $vectorstore_options['mxchat_vectorstore_max_results'] ?? 5
7754 );
7755
7756 // Allow multi-bot plugin to override with bot-specific settings
7757 $bot_config = apply_filters('mxchat_get_bot_vectorstore_config', $default_config, $bot_id);
7758
7759 // Preserve max_results from global settings if not set in bot config
7760 if (!isset($bot_config['max_results'])) {
7761 $bot_config['max_results'] = $default_config['max_results'];
7762 }
7763
7764 return $bot_config;
7765 }
7766
7767 private function mxchat_find_relevant_products($user_embedding) {
7768 //error_log('MXChat Vector Search: Starting product search...');
7769
7770 // Retrieve the add-on settings from the database
7771 $addon_options = get_option('mxchat_pinecone_addon_options', array());
7772
7773 // Determine whether Pinecone is enabled
7774 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1') ? 1 : 0;
7775
7776 //error_log('Pinecone enabled flag: ' . $use_pinecone);
7777
7778 if ($use_pinecone === 1) {
7779 //error_log('MXChat Vector Search: Using Pinecone database for products');
7780 return $this->find_relevant_products_pinecone($user_embedding);
7781 } else {
7782 //error_log('MXChat Vector Search: Using WordPress database for products');
7783 return $this->find_relevant_products_wordpress($user_embedding);
7784 }
7785 }
7786 private function find_relevant_products_wordpress($user_embedding) {
7787 global $wpdb;
7788 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
7789
7790 if (!is_array($user_embedding)) {
7791 return '';
7792 }
7793
7794 // Streaming top-K pass: scan rows in small batches, keep only the top 3
7795 // results above the similarity threshold. Peak memory is bounded by
7796 // $batch_size embedding rows plus a 3-element top list.
7797 $batch_size = 250;
7798 $similarity_threshold = 0.85;
7799 $top_k = 3;
7800 $top_results = [];
7801 $offset = 0;
7802
7803 do {
7804 $batch = $wpdb->get_results($wpdb->prepare(
7805 "SELECT id, embedding_vector
7806 FROM {$system_prompt_table}
7807 LIMIT %d OFFSET %d",
7808 $batch_size,
7809 $offset
7810 ));
7811
7812 if (empty($batch)) {
7813 break;
7814 }
7815
7816 foreach ($batch as $row) {
7817 $database_embedding = $row->embedding_vector
7818 ? unserialize($row->embedding_vector, ['allowed_classes' => false])
7819 : null;
7820
7821 if (!is_array($database_embedding)) {
7822 unset($database_embedding);
7823 continue;
7824 }
7825
7826 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
7827 unset($database_embedding);
7828
7829 if ($similarity < $similarity_threshold) {
7830 continue;
7831 }
7832
7833 // Insert into bounded top-K (kept sorted descending)
7834 if (count($top_results) < $top_k) {
7835 $top_results[] = ['id' => $row->id, 'similarity' => $similarity];
7836 usort($top_results, function ($a, $b) {
7837 return $b['similarity'] <=> $a['similarity'];
7838 });
7839 } elseif ($similarity > $top_results[$top_k - 1]['similarity']) {
7840 $top_results[$top_k - 1] = ['id' => $row->id, 'similarity' => $similarity];
7841 usort($top_results, function ($a, $b) {
7842 return $b['similarity'] <=> $a['similarity'];
7843 });
7844 }
7845 }
7846
7847 unset($batch);
7848 $offset += $batch_size;
7849 } while (true);
7850
7851 if (empty($top_results)) {
7852 return '';
7853 }
7854
7855 $content = '';
7856 foreach ($top_results as $result) {
7857 $chunk_content = $this->fetch_content_with_product_links($result['id']);
7858 $content .= $chunk_content . "\n\n";
7859 }
7860
7861 return trim($content);
7862 }
7863
7864
7865 private function find_relevant_products_pinecone($user_embedding) {
7866 //error_log('Starting Pinecone product search...');
7867
7868 $options = get_option('mxchat_pinecone_addon_options', array());
7869 $api_key = $options['mxchat_pinecone_api_key'] ?? '';
7870 $host = $options['mxchat_pinecone_host'] ?? '';
7871
7872 if (empty($host) || empty($api_key)) {
7873 //error_log('Pinecone credentials not properly configured for product search');
7874 return '';
7875 }
7876
7877 $similarity_threshold = 0.85;
7878 $api_endpoint = "https://{$host}/query";
7879
7880 $request_body = array(
7881 'vector' => $user_embedding,
7882 'topK' => 5,
7883 'includeMetadata' => true,
7884 'includeValues' => true,
7885 'filter' => array(
7886 'type' => 'product'
7887 )
7888 );
7889
7890 //error_log('Sending request to Pinecone with body: ' . wp_json_encode($request_body));
7891
7892 $response = wp_remote_post($api_endpoint, array(
7893 'headers' => array(
7894 'Api-Key' => $api_key,
7895 'accept' => 'application/json',
7896 'content-type' => 'application/json'
7897 ),
7898 'body' => wp_json_encode($request_body),
7899 'timeout' => 30
7900 ));
7901
7902 if (is_wp_error($response)) {
7903 //error_log('Pinecone product query error: ' . $response->get_error_message());
7904 return '';
7905 }
7906
7907 $response_code = wp_remote_retrieve_response_code($response);
7908 //error_log('Pinecone response code: ' . $response_code);
7909
7910 if ($response_code !== 200) {
7911 //error_log('Pinecone API error during product search: ' . wp_remote_retrieve_body($response));
7912 return '';
7913 }
7914
7915 $results = json_decode(wp_remote_retrieve_body($response), true);
7916 //error_log('Pinecone raw response: ' . wp_remote_retrieve_body($response));
7917
7918 if (empty($results['matches'])) {
7919 //error_log('No matches found in Pinecone response');
7920 return '';
7921 }
7922
7923 $content = '';
7924 foreach ($results['matches'] as $match) {
7925 if ($match['score'] < $similarity_threshold) {
7926 //error_log("Match below threshold: " . $match['score']);
7927 continue;
7928 }
7929
7930 if (!empty($match['metadata']['text'])) {
7931 $content .= $match['metadata']['text'];
7932 if (!empty($match['metadata']['source_url'])) {
7933 $content .= "\n\nFor more details, check out this product: " . esc_url($match['metadata']['source_url']);
7934 }
7935 $content .= "\n\n";
7936 }
7937 }
7938
7939 return trim($content);
7940 }
7941
7942
7943 private function fetch_content_with_product_links($most_relevant_id) {
7944 global $wpdb;
7945 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
7946
7947 // Fetch the article content and associated product URL
7948 $query = $wpdb->prepare("SELECT article_content, source_url FROM {$system_prompt_table} WHERE id = %d", $most_relevant_id);
7949 $result = $wpdb->get_row($query);
7950
7951 if ($result) {
7952 // Append the product link to the content if available
7953 $content = $result->article_content;
7954 if (!empty($result->source_url)) {
7955 $content .= "\n\nFor more details, check out this product: " . esc_url($result->source_url);
7956 }
7957 return $content;
7958 }
7959
7960 return null;
7961 }
7962
7963 /**
7964 * Get system instructions for a specific bot or default
7965 * Checks for multi-bot add-on and uses bot-specific instructions if available
7966 * Automatically strips URLs if citation links are disabled
7967 * Replaces {visitor_name} placeholder with actual visitor name if available
7968 *
7969 * @param string $bot_id The bot ID to get instructions for
7970 * @param string $session_id Optional session ID to lookup visitor name
7971 */
7972 private function get_system_instructions($bot_id = 'default', $session_id = '') {
7973 $instructions = '';
7974
7975 // Check if multi-bot add-on is active
7976 if (class_exists('MxChat_Multi_Bot_Core_Manager') && $bot_id !== 'default') {
7977 // Get bot-specific options from multi-bot add-on
7978 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
7979
7980 // If bot has custom system instructions, use those
7981 if (!empty($bot_options['system_prompt_instructions'])) {
7982 $instructions = $bot_options['system_prompt_instructions'];
7983 }
7984 }
7985
7986 // Fall back to default system instructions
7987 if (empty($instructions)) {
7988 $instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
7989 }
7990
7991 // Check if citation links are disabled - if so, strip URLs from instructions
7992 $fresh_options = get_option('mxchat_options', []);
7993 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
7994
7995 if (!$citation_links_enabled && !empty($instructions)) {
7996 $instructions = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $instructions);
7997 $instructions = preg_replace('/\s+/', ' ', trim($instructions)); // Clean up extra spaces
7998 }
7999
8000 // Replace {visitor_name} placeholder with actual visitor name if available
8001 if (!empty($instructions) && !empty($session_id) && stripos($instructions, '{visitor_name}') !== false) {
8002 $name_option_key = "mxchat_name_{$session_id}";
8003 $visitor_name = get_option($name_option_key, '');
8004
8005 if (!empty($visitor_name)) {
8006 $instructions = str_ireplace('{visitor_name}', sanitize_text_field($visitor_name), $instructions);
8007 } else {
8008 // Remove placeholder if no name is available
8009 $instructions = str_ireplace('{visitor_name}', '', $instructions);
8010 $instructions = preg_replace('/\s{2,}/', ' ', trim($instructions)); // Clean up extra spaces
8011 }
8012 }
8013
8014 // {context} placeholder (plan 59bc1b): inject the assembled knowledge-base
8015 // block where the owner placed the token. Runs after the URL-strip and
8016 // {visitor_name} handling and before the developer filter, so filtered
8017 // instructions already show the final prompt. Only active once the KB
8018 // assembly has stashed the block (context_kb_block non-null) — the early
8019 // URL-extraction call happens before assembly and leaves the token alone.
8020 if ($this->context_kb_block !== null && !empty($instructions) && stripos($instructions, '{context}') !== false) {
8021 $pos = stripos($instructions, '{context}');
8022 $instructions = substr($instructions, 0, $pos)
8023 . rtrim($this->context_kb_block) . "\n"
8024 . substr($instructions, $pos + strlen('{context}'));
8025 // Additional occurrences are stripped — never duplicate the KB block.
8026 $instructions = str_ireplace('{context}', '', $instructions);
8027 }
8028
8029 // Allow developers to filter system instructions and process shortcodes
8030 $instructions = apply_filters('mxchat_system_instructions', $instructions, $bot_id, $session_id);
8031 $instructions = do_shortcode($instructions);
8032
8033 return $instructions;
8034 }
8035 /**
8036 * Get the current bot ID from session or request context
8037 */
8038 private function get_current_bot_id($session_id = '') {
8039 // First, check if bot_id is passed in the current request
8040 if (isset($_POST['bot_id']) && !empty($_POST['bot_id'])) {
8041 return sanitize_key($_POST['bot_id']);
8042 }
8043
8044 // If not in POST, try to get it from session data
8045 if (!empty($session_id)) {
8046 $bot_id = get_option("mxchat_session_bot_{$session_id}", '');
8047 if (!empty($bot_id)) {
8048 return $bot_id;
8049 }
8050 }
8051
8052 // Fall back to default
8053 return 'default';
8054 }
8055 /* ====================================================================== *
8056 * Native function-calling loop (plan-mxchat-20260617-a41dee)
8057 *
8058 * Model-driven tool use. The model is offered MxChat's enabled callbacks as
8059 * tools (sourced from MxChat_Tool_Registry, the single source the admin AI
8060 * Tools checklist also reads). When the model calls a tool, the matching
8061 * callback runs through its EXISTING permission checks, its output is fed
8062 * back, and the loop continues up to a depth cap. INDEPENDENT of the
8063 * intent→callback router — it runs only after intents miss, and works with
8064 * ZERO Actions created.
8065 *
8066 * Entered ONLY when: function calling is enabled + the active model is
8067 * tool-capable + at least one tool is enabled. Default-off, so existing
8068 * installs never enter this branch (byte-for-byte unchanged behavior). The
8069 * tool round is buffered (non-streaming) per the plan; the final answer is
8070 * emitted via the same SSE/JSON envelopes the normal path uses.
8071 * ====================================================================== */
8072
8073 /** Gate: should the function-calling loop handle this turn? */
8074 private function mxchat_fc_should_run($selected_model) {
8075 if (!class_exists('MxChat_Tool_Registry') || !MxChat_Tool_Registry::is_enabled()) {
8076 return false;
8077 }
8078 if (class_exists('MxChat_Model_Catalog') && !MxChat_Model_Catalog::supports_tools($selected_model)) {
8079 return false;
8080 }
8081 $tools = MxChat_Tool_Registry::enabled_tools();
8082 return !empty($tools);
8083 }
8084
8085 private function mxchat_fc_log($msg) {
8086 if (defined('MXCHAT_DEV_MODE') && MXCHAT_DEV_MODE) {
8087 error_log('[MxChat FC] ' . $msg);
8088 }
8089 }
8090
8091 /**
8092 * Resolve provider transport details. Returns null when FC can't run for this
8093 * model/config (missing key, unsupported provider) so the caller falls back to
8094 * the normal path. OpenAI/xAI/DeepSeek/OpenRouter/Custom share the
8095 * OpenAI-compatible 'openai' family; Claude and Gemini are distinct.
8096 */
8097 private function mxchat_fc_resolve_provider($selected_model, $opts) {
8098 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
8099 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
8100 if ($selected_model === 'claude-opus-4-20250514') { $selected_model = 'claude-opus-4-8'; }
8101 elseif ($selected_model === 'claude-sonnet-4-20250514') { $selected_model = 'claude-sonnet-4-6'; }
8102 if ($selected_model === 'openrouter') {
8103 $model = isset($opts['openrouter_selected_model']) ? $opts['openrouter_selected_model'] : '';
8104 $key = isset($opts['openrouter_api_key']) ? $opts['openrouter_api_key'] : '';
8105 if ($model === '' || $key === '') return null;
8106 return array('family'=>'openai','model'=>$model,'url'=>'https://openrouter.ai/api/v1/chat/completions',
8107 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'openai');
8108 }
8109 $prefix = strtolower(explode('-', $selected_model)[0]);
8110 switch ($prefix) {
8111 case 'gpt': case 'o1': case 'o3': case 'o4':
8112 $key = isset($opts['api_key']) ? $opts['api_key'] : '';
8113 if ($key === '') return null;
8114 return array('family'=>'openai','model'=>$selected_model,'url'=>'https://api.openai.com/v1/chat/completions',
8115 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'openai');
8116 case 'claude':
8117 $key = isset($opts['claude_api_key']) ? $opts['claude_api_key'] : '';
8118 if ($key === '') return null;
8119 return array('family'=>'anthropic','model'=>$selected_model,'url'=>'https://api.anthropic.com/v1/messages',
8120 'headers'=>array('Content-Type'=>'application/json','x-api-key'=>$key,'anthropic-version'=>'2023-06-01'),'tag'=>'anthropic');
8121 case 'gemini':
8122 $key = isset($opts['gemini_api_key']) ? $opts['gemini_api_key'] : '';
8123 if ($key === '') return null;
8124 return array('family'=>'gemini','model'=>$selected_model,'key'=>$key,'tag'=>'gemini');
8125 case 'grok': case 'xai':
8126 $key = isset($opts['xai_api_key']) ? $opts['xai_api_key'] : '';
8127 if ($key === '') return null;
8128 return array('family'=>'openai','model'=>$selected_model,'url'=>'https://api.x.ai/v1/chat/completions',
8129 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'xai');
8130 case 'deepseek':
8131 $key = isset($opts['deepseek_api_key']) ? $opts['deepseek_api_key'] : '';
8132 if ($key === '') return null;
8133 return array('family'=>'openai','model'=>$selected_model,'url'=>'https://api.deepseek.com/v1/chat/completions',
8134 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'openai');
8135 case 'custom':
8136 $base = isset($opts['custom_provider_base_url']) ? rtrim($opts['custom_provider_base_url'], '/') : '';
8137 $key = isset($opts['custom_provider_api_key']) ? $opts['custom_provider_api_key'] : '';
8138 $model = isset($opts['custom_provider_model']) ? $opts['custom_provider_model'] : '';
8139 if ($base === '' || $model === '') return null;
8140 $url = (strpos($base, 'chat/completions') !== false) ? $base : $base . '/chat/completions';
8141 $headers = array('Content-Type'=>'application/json');
8142 if ($key !== '') $headers['Authorization'] = 'Bearer '.$key;
8143 return array('family'=>'openai','model'=>$model,'url'=>$url,'headers'=>$headers,'tag'=>'openai');
8144 }
8145 return null;
8146 }
8147
8148 /**
8149 * Top-level function-calling attempt. Returns:
8150 * ['handled'=>true, 'text'=>'<final answer>'] when the model used ≥1 tool
8151 * ['handled'=>false] otherwise (caller falls back
8152 * to the normal streamed path)
8153 */
8154 private function mxchat_fc_attempt($message, $relevant_content, $conversation_history, $selected_model, $opts, $session_id, $user_id) {
8155 $prov = $this->mxchat_fc_resolve_provider($selected_model, $opts);
8156 if (!$prov) {
8157 return array('handled' => false);
8158 }
8159 $tools = MxChat_Tool_Registry::enabled_tools();
8160 if (empty($tools)) {
8161 return array('handled' => false);
8162 }
8163
8164 $bot_id = $this->get_current_bot_id($session_id);
8165 $system = $this->get_system_instructions($bot_id, $session_id);
8166
8167 // Force callbacks into return-mode (some echo SSE directly when streaming);
8168 // we buffer the whole tool round, then emit once. Restored in finally.
8169 $prev_streaming = $this->is_streaming;
8170 $this->is_streaming = false;
8171 try {
8172 if ($prov['family'] === 'anthropic') {
8173 return $this->mxchat_fc_loop_anthropic($prov, $system, $relevant_content, $conversation_history, $tools, $message, $user_id, $session_id);
8174 } elseif ($prov['family'] === 'gemini') {
8175 return $this->mxchat_fc_loop_gemini($prov, $system, $relevant_content, $conversation_history, $tools, $message, $user_id, $session_id);
8176 }
8177 return $this->mxchat_fc_loop_openai($prov, $system, $relevant_content, $conversation_history, $tools, $message, $user_id, $session_id);
8178 } catch (\Throwable $e) {
8179 $this->mxchat_fc_log('attempt threw: ' . $e->getMessage());
8180 return array('handled' => false);
8181 } finally {
8182 $this->is_streaming = $prev_streaming;
8183 }
8184 }
8185
8186 /** Normalize MxChat history rows to [{role:user|assistant, content}]. */
8187 private function mxchat_fc_normalize_history($conversation_history) {
8188 $out = array();
8189 if (!is_array($conversation_history)) return $out;
8190 foreach ($conversation_history as $m) {
8191 if (!is_array($m) || !isset($m['role']) || !isset($m['content'])) continue;
8192 $role = $m['role'];
8193 if ($role === 'bot' || $role === 'agent') $role = 'assistant';
8194 if (!in_array($role, array('user', 'assistant'), true)) $role = 'user';
8195 $out[] = array('role' => $role, 'content' => (string) $m['content']);
8196 }
8197 return $out;
8198 }
8199
8200 /** Execute the matched callback for a tool call. Returns ['ok'=>bool,'content'=>string]. */
8201 private function mxchat_fc_execute_tool($tool_name, $args, $orig_message, $user_id, $session_id) {
8202 $tool = MxChat_Tool_Registry::tool_by_name($tool_name, true); // enabled-only
8203 if (!$tool) {
8204 return array('ok' => false, 'content' => 'This tool is not available or not enabled.');
8205 }
8206 $fn = $tool['callback'];
8207
8208 // MxChat callbacks are message-driven: hand them the model's `query`
8209 // (falling back to the original user message).
8210 $query = '';
8211 if (is_array($args) && isset($args['query']) && is_string($args['query'])) {
8212 $query = $args['query'];
8213 }
8214 if ($query === '') $query = $orig_message;
8215
8216 // Synthetic intent row (matches wp_mxchat_intents columns → no undefined-prop warnings).
8217 $synthetic_intent = (object) array(
8218 'id' => 0, 'intent_label' => $tool['label'], 'phrases' => '',
8219 'embedding_vector' => '', 'callback_function' => $fn,
8220 'similarity_threshold' => 0.0, 'enabled' => 1, 'enabled_bots' => null,
8221 );
8222
8223 try {
8224 if (!empty($tool['is_addon'])) {
8225 $result = apply_filters($fn, false, $query, $user_id, $session_id, $synthetic_intent);
8226 } elseif (method_exists($this, $fn)) {
8227 $result = call_user_func(array($this, $fn), $query, $user_id, $session_id, $synthetic_intent, null);
8228 } else {
8229 return array('ok' => false, 'content' => 'Tool implementation not found.');
8230 }
8231 } catch (\Throwable $e) {
8232 $this->mxchat_fc_log("tool {$fn} threw: " . $e->getMessage());
8233 return array('ok' => false, 'content' => 'The tool failed to run.');
8234 }
8235
8236 // plan-mxchat-20260617-48a57a — surface UI-bearing tool output.
8237 // If the callback produced a UI element (generated image, product card, image
8238 // gallery), its html MUST reach the FRONTEND as a real rendered bot message —
8239 // NOT be stripped to text and handed to the model to paraphrase (that was the
8240 // bug: under function calling, UI-bearing actions rendered nothing). Capture
8241 // the html here; the FC outcome handler emits it in the response envelope.
8242 $ui = $this->mxchat_fc_ui_payload_from($result);
8243 if ($ui['html'] !== '' || !empty($ui['images'])) {
8244 if ($ui['html'] !== '') {
8245 $this->fc_ui_html .= ($this->fc_ui_html !== '' ? "\n" : '') . $ui['html'];
8246 }
8247 if (!empty($ui['images']) && is_array($ui['images'])) {
8248 $this->fc_ui_images = array_merge($this->fc_ui_images, $ui['images']);
8249 }
8250 $this->fc_ui_captured = true;
8251
8252 // Persist the html to the transcript ONLY if the callback did not already
8253 // do so itself. Core image/search callbacks self-save (text + html);
8254 // add-on callbacks (e.g. woo product cards) return html for the caller to
8255 // save. ui_self_saves carries this from the registry; default by source
8256 // (core self-saves, add-on does not) when a tool predates the flag.
8257 $self_saves = array_key_exists('ui_self_saves', $tool)
8258 ? !empty($tool['ui_self_saves'])
8259 : empty($tool['is_addon']);
8260 if ($ui['html'] !== '' && !$self_saves) {
8261 $this->mxchat_save_chat_message($session_id, 'bot', $ui['html']);
8262 }
8263
8264 // Hand the MODEL a short acknowledgment (never the raw or stripped html)
8265 // so the loop can add a one-line caption without trying to re-describe a
8266 // visual it cannot see and without duplicating the displayed element.
8267 $summary = isset($ui['text']) ? trim((string) $ui['text']) : '';
8268 $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');
8269 $content = $summary !== '' ? ($ack . ' ' . $summary) : $ack;
8270 $this->mxchat_fc_log("executed {$fn} → [ui payload surfaced] " . substr($content, 0, 120));
8271 return array('ok' => true, 'content' => $content);
8272 }
8273
8274 $content = $this->mxchat_fc_stringify_result($result);
8275 $this->mxchat_fc_log("executed {$fn} → " . substr($content, 0, 160));
8276 return array('ok' => true, 'content' => $content);
8277 }
8278
8279 /**
8280 * Extract a UI payload (html + images + text) from a tool callback's return,
8281 * falling back to $this->fallbackResponse for callbacks that return true after
8282 * setting it. plan-mxchat-20260617-48a57a.
8283 *
8284 * @return array{html:string,images:array,text:string}
8285 */
8286 private function mxchat_fc_ui_payload_from($result) {
8287 $src = null;
8288 if (is_array($result)) {
8289 $src = $result;
8290 } elseif ($result === true && isset($this->fallbackResponse) && is_array($this->fallbackResponse)) {
8291 $src = $this->fallbackResponse;
8292 }
8293 $html = (is_array($src) && isset($src['html']) && is_string($src['html'])) ? $src['html'] : '';
8294 $images = (is_array($src) && isset($src['images']) && is_array($src['images'])) ? $src['images'] : array();
8295 $text = (is_array($src) && isset($src['text'])) ? (string) $src['text'] : '';
8296 return array('html' => $html, 'images' => $images, 'text' => $text);
8297 }
8298
8299 /** Coerce a callback's return (string|array|true|false) into a tool-result string. */
8300 private function mxchat_fc_stringify_result($result) {
8301 if (is_string($result)) {
8302 return $result === '' ? 'No result.' : $result;
8303 }
8304 if ($result === true) {
8305 // Callbacks that set fallbackResponse and return true.
8306 $fb = isset($this->fallbackResponse) ? $this->fallbackResponse : null;
8307 if (is_array($fb)) {
8308 if (!empty($fb['text'])) return (string) $fb['text'];
8309 if (!empty($fb['html'])) return wp_strip_all_tags((string) $fb['html']);
8310 }
8311 return 'Done.';
8312 }
8313 if ($result === false || $result === null) {
8314 return 'No result.';
8315 }
8316 if (is_array($result)) {
8317 if (isset($result['text']) && $result['text'] !== '') return (string) $result['text'];
8318 if (isset($result['html']) && $result['html'] !== '') return wp_strip_all_tags((string) $result['html']);
8319 $json = wp_json_encode($result);
8320 return $json !== false ? $json : 'No result.';
8321 }
8322 return (string) $result;
8323 }
8324
8325 /** HTTP code + decoded body for a function-calling request. */
8326 private function mxchat_fc_post($url, $body, $headers, $tag) {
8327 $args = array(
8328 'body' => wp_json_encode($body),
8329 'headers' => $headers,
8330 'timeout' => 60,
8331 'redirection' => 5,
8332 'blocking' => true,
8333 'httpversion' => '1.0',
8334 'sslverify' => true,
8335 );
8336 $response = $this->mxchat_provider_call_with_retry($url, $args, $tag);
8337 if (is_wp_error($response)) {
8338 return array('code' => 0, 'data' => null, 'error' => $response->get_error_message());
8339 }
8340 $code = (int) wp_remote_retrieve_response_code($response);
8341 $data = json_decode(wp_remote_retrieve_body($response), true);
8342 return array('code' => $code, 'data' => $data, 'error' => null);
8343 }
8344
8345 /* ---------------- OpenAI-compatible loop (OpenAI/xAI/DeepSeek/OpenRouter/Custom) -------------- */
8346 private function mxchat_fc_loop_openai($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) {
8347 $messages = array();
8348 $messages[] = array('role' => 'system', 'content' => $system . ' ' . $relevant_content);
8349 foreach ($this->mxchat_fc_normalize_history($conversation_history) as $m) {
8350 $messages[] = $m;
8351 }
8352
8353 $depth = MxChat_Tool_Registry::max_depth();
8354 $budget = MxChat_Tool_Registry::max_tool_calls_per_turn();
8355 $tool_schema = MxChat_Tool_Registry::to_openai_tools($tools);
8356 $used_tool = false;
8357 $calls_made = 0;
8358
8359 for ($step = 0; $step <= $depth; $step++) {
8360 $offer_tools = ($step < $depth) && !empty($tool_schema);
8361 $body = array('model' => $prov['model'], 'messages' => $messages, 'temperature' => 1, 'stream' => false);
8362 if (strpos($prov['url'], 'api.deepseek.com') !== false) {
8363 // DeepSeek V4 defaults to thinking mode ON; tool loops want fast
8364 // deterministic non-thinking turns (legacy deepseek-chat semantics).
8365 $body['thinking'] = array('type' => 'disabled');
8366 }
8367 if ($offer_tools) {
8368 $body['tools'] = $tool_schema;
8369 $body['tool_choice'] = 'auto';
8370 }
8371 $r = $this->mxchat_fc_post($prov['url'], $body, $prov['headers'], $prov['tag']);
8372 if ($r['code'] !== 200 || !is_array($r['data'])) {
8373 $this->mxchat_fc_log('openai call failed: code=' . $r['code'] . ' err=' . ($r['error'] ?? ''));
8374 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8375 }
8376 $msg = isset($r['data']['choices'][0]['message']) ? $r['data']['choices'][0]['message'] : null;
8377 if (!$msg) {
8378 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8379 }
8380 $tool_calls = isset($msg['tool_calls']) && is_array($msg['tool_calls']) ? $msg['tool_calls'] : array();
8381 if (empty($tool_calls)) {
8382 $text = isset($msg['content']) ? trim((string) $msg['content']) : '';
8383 if (!$used_tool) return array('handled' => false); // model never used a tool → normal path
8384 return array('handled' => true, 'text' => ($text !== '' ? $text : $this->mxchat_fc_giveup_text()));
8385 }
8386 // Append the assistant tool-call turn verbatim, then a tool result per call.
8387 $used_tool = true;
8388 $messages[] = $msg;
8389 foreach ($tool_calls as $tc) {
8390 if ($calls_made >= $budget) break;
8391 $calls_made++;
8392 $name = isset($tc['function']['name']) ? $tc['function']['name'] : '';
8393 $args = array();
8394 if (isset($tc['function']['arguments'])) {
8395 $decoded = json_decode($tc['function']['arguments'], true);
8396 if (is_array($decoded)) $args = $decoded;
8397 }
8398 $exec = $this->mxchat_fc_execute_tool($name, $args, $orig_message, $user_id, $session_id);
8399 $messages[] = array(
8400 'role' => 'tool',
8401 'tool_call_id' => isset($tc['id']) ? $tc['id'] : '',
8402 'content' => $exec['content'],
8403 );
8404 }
8405 }
8406 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8407 }
8408
8409 /* ---------------- Anthropic Claude loop ---------------- */
8410 private function mxchat_fc_loop_anthropic($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) {
8411 $messages = $this->mxchat_fc_normalize_history($conversation_history);
8412 $messages[] = array('role' => 'user', 'content' => $relevant_content);
8413
8414 $depth = MxChat_Tool_Registry::max_depth();
8415 $budget = MxChat_Tool_Registry::max_tool_calls_per_turn();
8416 $tool_schema = MxChat_Tool_Registry::to_anthropic_tools($tools);
8417 $omit_temp = $this->mxchat_claude_omits_temperature($prov['model']);
8418 $used_tool = false;
8419 $calls_made = 0;
8420
8421 for ($step = 0; $step <= $depth; $step++) {
8422 $offer_tools = ($step < $depth) && !empty($tool_schema);
8423 $body = array('model' => $prov['model'], 'max_tokens' => 1024, 'temperature' => 0.8,
8424 'messages' => $messages, 'system' => $system);
8425 if ($omit_temp) unset($body['temperature']);
8426 if ($offer_tools) {
8427 $body['tools'] = $tool_schema;
8428 $body['tool_choice'] = array('type' => 'auto');
8429 }
8430 $r = $this->mxchat_fc_post($prov['url'], $body, $prov['headers'], $prov['tag']);
8431 if ($r['code'] !== 200 || !is_array($r['data'])) {
8432 $this->mxchat_fc_log('anthropic call failed: code=' . $r['code'] . ' err=' . ($r['error'] ?? ''));
8433 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8434 }
8435 $content = isset($r['data']['content']) && is_array($r['data']['content']) ? $r['data']['content'] : array();
8436 $tool_uses = array();
8437 $text_out = '';
8438 foreach ($content as $block) {
8439 if (!isset($block['type'])) continue;
8440 if ($block['type'] === 'tool_use') {
8441 $tool_uses[] = $block;
8442 } elseif ($block['type'] === 'text' && isset($block['text'])) {
8443 $text_out .= $block['text'];
8444 }
8445 }
8446 if (empty($tool_uses)) {
8447 if (!$used_tool) return array('handled' => false);
8448 $text_out = trim($text_out);
8449 return array('handled' => true, 'text' => ($text_out !== '' ? $text_out : $this->mxchat_fc_giveup_text()));
8450 }
8451 // Append the assistant turn (the full content array), then a user turn of tool_result blocks.
8452 $used_tool = true;
8453 $messages[] = array('role' => 'assistant', 'content' => $content);
8454 $results = array();
8455 foreach ($tool_uses as $tu) {
8456 if ($calls_made >= $budget) break;
8457 $calls_made++;
8458 $name = isset($tu['name']) ? $tu['name'] : '';
8459 $args = isset($tu['input']) && is_array($tu['input']) ? $tu['input'] : array();
8460 $exec = $this->mxchat_fc_execute_tool($name, $args, $orig_message, $user_id, $session_id);
8461 $results[] = array(
8462 'type' => 'tool_result',
8463 'tool_use_id' => isset($tu['id']) ? $tu['id'] : '',
8464 'content' => $exec['content'],
8465 );
8466 }
8467 $messages[] = array('role' => 'user', 'content' => $results);
8468 }
8469 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8470 }
8471
8472 /* ---------------- Google Gemini loop ---------------- */
8473 private function mxchat_fc_loop_gemini($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) {
8474 $contents = array();
8475 $contents[] = array('role' => 'user', 'parts' => array(array('text' => '[System Instructions] ' . $system . ' ' . $relevant_content)));
8476 $contents[] = array('role' => 'model', 'parts' => array(array('text' => 'I understand and will follow these instructions.')));
8477 foreach ($this->mxchat_fc_normalize_history($conversation_history) as $m) {
8478 $contents[] = array('role' => ($m['role'] === 'assistant' ? 'model' : 'user'),
8479 'parts' => array(array('text' => $m['content'])));
8480 }
8481
8482 $depth = MxChat_Tool_Registry::max_depth();
8483 $budget = MxChat_Tool_Registry::max_tool_calls_per_turn();
8484 $tool_schema = MxChat_Tool_Registry::to_gemini_tools($tools);
8485 // Function calling (tools + functionDeclarations + toolConfig) is a v1beta feature on the
8486 // Generative Language REST API. The v1 endpoint silently ignores the tools array, so a
8487 // non-preview model (e.g. gemini-2.5-pro, gemini-3.5-flash, gemini-3.1-flash-lite) would
8488 // just answer in text and never emit a tool call. Always use v1beta for the FC loop —
8489 // confirmed against Google's function-calling docs (their REST example targets
8490 // v1beta/models/gemini-3.5-flash:generateContent). v1beta is a superset, so every model
8491 // reachable on v1 is also reachable here.
8492 $api_version = 'v1beta';
8493 $url = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $prov['model'] . ':generateContent?key=' . $prov['key'];
8494 $headers = array('Content-Type' => 'application/json');
8495 $used_tool = false;
8496 $calls_made = 0;
8497
8498 for ($step = 0; $step <= $depth; $step++) {
8499 $offer_tools = ($step < $depth) && !empty($tool_schema);
8500 $body = array(
8501 'contents' => $contents,
8502 'generationConfig' => array('temperature' => 0.7, 'topP' => 0.95, 'topK' => 40, 'maxOutputTokens' => 8192),
8503 );
8504 if ($offer_tools) {
8505 $body['tools'] = $tool_schema;
8506 $body['toolConfig'] = array('functionCallingConfig' => array('mode' => 'AUTO'));
8507 }
8508 $r = $this->mxchat_fc_post($url, $body, $headers, 'gemini');
8509 if ($r['code'] !== 200 || !is_array($r['data']) || isset($r['data']['error'])) {
8510 $this->mxchat_fc_log('gemini call failed: code=' . $r['code'] . ' err=' . ($r['error'] ?? ''));
8511 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8512 }
8513 $parts = isset($r['data']['candidates'][0]['content']['parts']) && is_array($r['data']['candidates'][0]['content']['parts'])
8514 ? $r['data']['candidates'][0]['content']['parts'] : array();
8515 $fn_calls = array();
8516 $text_out = '';
8517 foreach ($parts as $p) {
8518 if (isset($p['functionCall'])) {
8519 $fn_calls[] = $p['functionCall'];
8520 } elseif (isset($p['text'])) {
8521 $text_out .= $p['text'];
8522 }
8523 }
8524 if (empty($fn_calls)) {
8525 if (!$used_tool) return array('handled' => false);
8526 $text_out = trim($text_out);
8527 return array('handled' => true, 'text' => ($text_out !== '' ? $text_out : $this->mxchat_fc_giveup_text()));
8528 }
8529 // Append the model turn (its parts) then a user turn of functionResponse parts.
8530 $used_tool = true;
8531 $contents[] = array('role' => 'model', 'parts' => $parts);
8532 $resp_parts = array();
8533 foreach ($fn_calls as $fcall) {
8534 if ($calls_made >= $budget) break;
8535 $calls_made++;
8536 $name = isset($fcall['name']) ? $fcall['name'] : '';
8537 $args = isset($fcall['args']) && is_array($fcall['args']) ? $fcall['args'] : array();
8538 $exec = $this->mxchat_fc_execute_tool($name, $args, $orig_message, $user_id, $session_id);
8539 $fr = array('name' => $name, 'response' => array('result' => $exec['content']));
8540 // Gemini 3 function calls carry a unique id; echo the matching id back in the
8541 // functionResponse so the model maps the result to the right call (Google REST
8542 // guidance). Older models omit the id — then we send none, exactly as before.
8543 if (isset($fcall['id']) && $fcall['id'] !== '') { $fr['id'] = $fcall['id']; }
8544 $resp_parts[] = array('functionResponse' => $fr);
8545 }
8546 $contents[] = array('role' => 'user', 'parts' => $resp_parts);
8547 }
8548 return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8549 }
8550
8551 private function mxchat_fc_giveup_text() {
8552 return esc_html__('I looked into that but could not put together a final answer. Please try rephrasing your request.', 'mxchat');
8553 }
8554
8555 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') {
8556 try {
8557 if (!$relevant_content) {
8558 $error_response = [
8559 'error' => esc_html__("I couldn't find relevant information on that topic.", 'mxchat'),
8560 'error_code' => 'no_relevant_content'
8561 ];
8562
8563 if ($testing_data !== null) {
8564 $error_response['testing_data'] = $testing_data;
8565 }
8566
8567 return $error_response;
8568 }
8569
8570 if (!is_array($conversation_history)) {
8571 $conversation_history = array();
8572 }
8573
8574 // Check if this is an OpenRouter model
8575 if ($selected_model === 'openrouter') {
8576 // Get the actual OpenRouter model from options
8577 $openrouter_selected_model = $this->options['openrouter_selected_model'] ?? '';
8578
8579 if (empty($openrouter_selected_model)) {
8580 $error_response = [
8581 'error' => esc_html__('No OpenRouter model selected. Please select a model in settings.', 'mxchat'),
8582 'error_code' => 'no_openrouter_model_selected'
8583 ];
8584 if ($testing_data !== null) {
8585 $error_response['testing_data'] = $testing_data;
8586 }
8587 return $error_response;
8588 }
8589
8590 if (empty($openrouter_api_key)) {
8591 $error_response = [
8592 'error' => esc_html__('OpenRouter API key is not configured', 'mxchat'),
8593 'error_code' => 'missing_openrouter_api_key'
8594 ];
8595 if ($testing_data !== null) {
8596 $error_response['testing_data'] = $testing_data;
8597 }
8598 return $error_response;
8599 }
8600
8601 if ($streaming) {
8602 return $this->mxchat_generate_response_openrouter_stream(
8603 $openrouter_selected_model,
8604 $openrouter_api_key,
8605 $conversation_history,
8606 $relevant_content,
8607 $session_id,
8608 $testing_data
8609 );
8610 } else {
8611 $response = $this->mxchat_generate_response_openrouter(
8612 $openrouter_selected_model,
8613 $openrouter_api_key,
8614 $conversation_history,
8615 $relevant_content,
8616 $session_id
8617 );
8618 }
8619
8620 if (is_array($response) && isset($response['error'])) {
8621 if ($testing_data !== null) {
8622 $response['testing_data'] = $testing_data;
8623 }
8624 return $response;
8625 }
8626
8627 return $response;
8628 }
8629
8630 // Extract model prefix to determine the provider
8631 $model_parts = explode('-', $selected_model);
8632 $provider = strtolower($model_parts[0]);
8633
8634 // Handle model selection based on provider prefix
8635 switch ($provider) {
8636 case 'gemini':
8637 if (empty($gemini_api_key)) {
8638 $error_response = [
8639 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
8640 'error_code' => 'missing_gemini_api_key'
8641 ];
8642 if ($testing_data !== null) {
8643 $error_response['testing_data'] = $testing_data;
8644 }
8645 return $error_response;
8646 }
8647 $response = $this->mxchat_generate_response_gemini(
8648 $selected_model,
8649 $gemini_api_key,
8650 $conversation_history,
8651 $relevant_content,
8652 $session_id
8653 );
8654 break;
8655
8656 case 'claude':
8657 if (empty($claude_api_key)) {
8658 $error_response = [
8659 'error' => esc_html__('Claude API key is not configured', 'mxchat'),
8660 'error_code' => 'missing_claude_api_key'
8661 ];
8662 if ($testing_data !== null) {
8663 $error_response['testing_data'] = $testing_data;
8664 }
8665 return $error_response;
8666 }
8667 if ($streaming) {
8668 return $this->mxchat_generate_response_claude_stream(
8669 $selected_model,
8670 $claude_api_key,
8671 $conversation_history,
8672 $relevant_content,
8673 $session_id,
8674 $testing_data
8675 );
8676 } else {
8677 $response = $this->mxchat_generate_response_claude(
8678 $selected_model,
8679 $claude_api_key,
8680 $conversation_history,
8681 $relevant_content,
8682 $session_id
8683 );
8684 }
8685 break;
8686
8687 case 'grok':
8688 if (empty($xai_api_key)) {
8689 $error_response = [
8690 'error' => esc_html__('X.AI API key is not configured', 'mxchat'),
8691 'error_code' => 'missing_xai_api_key'
8692 ];
8693 if ($testing_data !== null) {
8694 $error_response['testing_data'] = $testing_data;
8695 }
8696 return $error_response;
8697 }
8698 if ($streaming) {
8699 return $this->mxchat_generate_response_xai_stream(
8700 $selected_model,
8701 $xai_api_key,
8702 $conversation_history,
8703 $relevant_content,
8704 $session_id,
8705 $testing_data
8706 );
8707 } else {
8708 $response = $this->mxchat_generate_response_xai(
8709 $selected_model,
8710 $xai_api_key,
8711 $conversation_history,
8712 $relevant_content,
8713 $session_id
8714 );
8715 }
8716 break;
8717
8718 case 'deepseek':
8719 if (empty($deepseek_api_key)) {
8720 $error_response = [
8721 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
8722 'error_code' => 'missing_deepseek_api_key'
8723 ];
8724 if ($testing_data !== null) {
8725 $error_response['testing_data'] = $testing_data;
8726 }
8727 return $error_response;
8728 }
8729 if ($streaming) {
8730 return $this->mxchat_generate_response_deepseek_stream(
8731 $selected_model,
8732 $deepseek_api_key,
8733 $conversation_history,
8734 $relevant_content,
8735 $session_id,
8736 $testing_data
8737 );
8738 } else {
8739 $response = $this->mxchat_generate_response_deepseek(
8740 $selected_model,
8741 $deepseek_api_key,
8742 $conversation_history,
8743 $relevant_content,
8744 $session_id
8745 );
8746 }
8747 break;
8748
8749 case 'custom':
8750 // Custom (OpenAI-compatible) provider — Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI
8751 $cp_base_url = isset($this->options['custom_provider_base_url']) ? trim((string) $this->options['custom_provider_base_url']) : '';
8752 if (empty($cp_base_url)) {
8753 $error_response = [
8754 'error' => esc_html__('Custom provider is not configured. Set Base URL in MxChat → API Keys → Custom Provider.', 'mxchat'),
8755 'error_code' => 'missing_custom_provider_base_url'
8756 ];
8757 if ($testing_data !== null) {
8758 $error_response['testing_data'] = $testing_data;
8759 }
8760 return $error_response;
8761 }
8762 if ($streaming) {
8763 return $this->mxchat_generate_response_custom_stream(
8764 $selected_model,
8765 $conversation_history,
8766 $relevant_content,
8767 $session_id,
8768 $testing_data
8769 );
8770 } else {
8771 $response = $this->mxchat_generate_response_custom(
8772 $selected_model,
8773 $conversation_history,
8774 $relevant_content
8775 );
8776 }
8777 break;
8778
8779 case 'gpt':
8780 case 'o1':
8781 if (empty($api_key)) {
8782 $error_response = [
8783 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
8784 'error_code' => 'missing_openai_api_key'
8785 ];
8786 if ($testing_data !== null) {
8787 $error_response['testing_data'] = $testing_data;
8788 }
8789 return $error_response;
8790 }
8791
8792 // Check if web search is enabled for this OpenAI model
8793 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
8794 // Models that don't support web search
8795 $unsupported_web_search_models = array('gpt-4.1-nano');
8796 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
8797
8798 if ($web_search_enabled && $model_supports_web_search) {
8799 // Use Responses API (required for some models, or when web search is enabled)
8800 return $this->mxchat_generate_response_openai_web_search(
8801 $selected_model,
8802 $api_key,
8803 $conversation_history,
8804 $relevant_content,
8805 $session_id,
8806 $testing_data,
8807 $streaming
8808 );
8809 } elseif ($streaming) {
8810 return $this->mxchat_generate_response_openai_stream(
8811 $selected_model,
8812 $api_key,
8813 $conversation_history,
8814 $relevant_content,
8815 $session_id,
8816 $testing_data
8817 );
8818 } else {
8819 $response = $this->mxchat_generate_response_openai(
8820 $selected_model,
8821 $api_key,
8822 $conversation_history,
8823 $relevant_content,
8824 $session_id
8825 );
8826 }
8827 break;
8828
8829 default:
8830 if (empty($api_key)) {
8831 $error_response = [
8832 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
8833 'error_code' => 'missing_openai_api_key'
8834 ];
8835 if ($testing_data !== null) {
8836 $error_response['testing_data'] = $testing_data;
8837 }
8838 return $error_response;
8839 }
8840
8841 // Check if web search is enabled (default case also handles OpenAI models)
8842 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
8843 $unsupported_web_search_models = array('gpt-4.1-nano');
8844 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
8845
8846 if ($web_search_enabled && $model_supports_web_search) {
8847 return $this->mxchat_generate_response_openai_web_search(
8848 $selected_model,
8849 $api_key,
8850 $conversation_history,
8851 $relevant_content,
8852 $session_id,
8853 $testing_data,
8854 $streaming
8855 );
8856 } elseif ($streaming) {
8857 return $this->mxchat_generate_response_openai_stream(
8858 $selected_model,
8859 $api_key,
8860 $conversation_history,
8861 $relevant_content,
8862 $session_id,
8863 $testing_data
8864 );
8865 } else {
8866 $response = $this->mxchat_generate_response_openai(
8867 $selected_model,
8868 $api_key,
8869 $conversation_history,
8870 $relevant_content,
8871 $session_id
8872 );
8873 }
8874 break;
8875 }
8876
8877 if (is_array($response) && isset($response['error'])) {
8878 if ($testing_data !== null) {
8879 $response['testing_data'] = $testing_data;
8880 }
8881 return $response;
8882 }
8883
8884 return $response;
8885
8886 } catch (Exception $e) {
8887 $error_response = [
8888 'error' => sprintf(esc_html__('An error occurred: %s', 'mxchat'), esc_html($e->getMessage())),
8889 'error_code' => 'system_exception',
8890 'exception_details' => $e->getMessage()
8891 ];
8892
8893 if ($testing_data !== null) {
8894 $error_response['testing_data'] = $testing_data;
8895 }
8896
8897 return $error_response;
8898 }
8899 }
8900 private function mxchat_generate_response_openrouter_stream($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
8901 try {
8902 $bot_id = $this->get_current_bot_id($session_id);
8903 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8904
8905 if (!is_array($conversation_history)) {
8906 $conversation_history = array();
8907 }
8908
8909 $formatted_conversation = array();
8910
8911 $formatted_conversation[] = array(
8912 'role' => 'system',
8913 'content' => $system_prompt_instructions . " " . $relevant_content
8914 );
8915
8916 foreach ($conversation_history as $message) {
8917 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8918 $role = $message['role'];
8919 if ($role === 'bot' || $role === 'agent') {
8920 $role = 'assistant';
8921 }
8922 if (!in_array($role, ['system', 'assistant', 'user'])) {
8923 $role = 'user';
8924 }
8925 $formatted_conversation[] = array(
8926 'role' => $role,
8927 'content' => $message['content']
8928 );
8929 }
8930 }
8931
8932 if (headers_sent() || !function_exists('curl_init')) {
8933 $regular_response = $this->mxchat_generate_response_openrouter(
8934 $selected_model,
8935 $openrouter_api_key,
8936 $conversation_history,
8937 $relevant_content,
8938 $session_id
8939 );
8940
8941 // Save bot response to transcript
8942 if (!empty($regular_response) && !empty($session_id)) {
8943 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
8944 }
8945
8946 $response_data = [
8947 'text' => $regular_response,
8948 'html' => '',
8949 'session_id' => $session_id
8950 ];
8951
8952 if ($testing_data !== null) {
8953 $response_data['testing_data'] = $testing_data;
8954 }
8955
8956 header('Content-Type: application/json');
8957 echo json_encode($response_data);
8958 return true;
8959 }
8960
8961 $body = json_encode([
8962 'model' => $selected_model,
8963 'messages' => $formatted_conversation,
8964 'temperature' => 1,
8965 'stream' => true
8966 ]);
8967
8968 // V2 retry-on-initial-connect: setup_streaming_headers is now lazy-fired
8969 // inside WRITEFUNCTION on first byte of a successful upstream.
8970
8971 $captured_status_code = 0;
8972 $captured_body_pre_stream = '';
8973 $full_response = '';
8974 $stream_started = false;
8975 $buffer = '';
8976 $errno = 0;
8977 $last_curl_error = '';
8978 $http_code = 0;
8979 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
8980 $backoff_ms = array(0, 750, 2000);
8981
8982 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
8983 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
8984 usleep($backoff_ms[$attempt] * 1000);
8985 }
8986
8987 $captured_status_code = 0;
8988 $captured_body_pre_stream = '';
8989 $full_response = '';
8990 $stream_started = false;
8991 $buffer = '';
8992
8993 $ch = curl_init();
8994 curl_setopt($ch, CURLOPT_URL, 'https://openrouter.ai/api/v1/chat/completions');
8995 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
8996 curl_setopt($ch, CURLOPT_POST, true);
8997 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
8998 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
8999 'Content-Type: application/json',
9000 'Authorization: Bearer ' . $openrouter_api_key,
9001 'HTTP-Referer: ' . home_url(),
9002 'X-Title: ' . get_bloginfo('name')
9003 ));
9004 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9005 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
9006
9007 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
9008 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
9009 $captured_status_code = (int) $m[1];
9010 }
9011 return strlen($header);
9012 });
9013
9014 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
9015 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
9016 $captured_body_pre_stream .= $data;
9017 return strlen($data);
9018 }
9019
9020 if (!$this->streaming_headers_sent) {
9021 $this->setup_streaming_headers();
9022 }
9023
9024 if (!$stream_started && $testing_data !== null) {
9025 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
9026 flush();
9027 $stream_started = true;
9028 }
9029
9030 $buffer .= $data;
9031 $lines = explode("\n", $buffer);
9032 $buffer = array_pop($lines);
9033
9034 foreach ($lines as $line) {
9035 if (trim($line) === '') {
9036 continue;
9037 }
9038 if (strpos($line, 'data: ') !== 0) {
9039 continue;
9040 }
9041
9042 $json_str = substr($line, 6);
9043
9044 if (trim($json_str) === '[DONE]') {
9045 echo "data: [DONE]\n\n";
9046 flush();
9047 continue;
9048 }
9049
9050 $json = json_decode(trim($json_str), true);
9051 if ($json && isset($json['choices'][0]['delta']['content'])) {
9052 $content = $json['choices'][0]['delta']['content'];
9053 $full_response .= $content;
9054
9055 echo "data: " . json_encode(['content' => $content]) . "\n\n";
9056 flush();
9057 }
9058 }
9059
9060 return strlen($data);
9061 });
9062
9063 $response = curl_exec($ch);
9064 $errno = curl_errno($ch);
9065 $last_curl_error = curl_error($ch);
9066 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
9067 curl_close($ch);
9068
9069 if (!$errno && $http_code === 200) {
9070 break;
9071 }
9072
9073 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
9074 $can_retry = !$this->streaming_headers_sent
9075 && ($attempt + 1) < $max_attempts
9076 && $is_transient;
9077
9078 if (defined('WP_DEBUG') && WP_DEBUG) {
9079 error_log(sprintf(
9080 '[MxChat] openrouter_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
9081 $attempt + 1, $max_attempts, $http_code, $errno,
9082 $is_transient ? 'yes' : 'no',
9083 $can_retry ? 'Retrying.' : 'Giving up.'
9084 ));
9085 }
9086
9087 if (!$can_retry) {
9088 break;
9089 }
9090 }
9091
9092 if (!$errno && $http_code === 200) {
9093 if (!empty($full_response) && !empty($session_id)) {
9094 $rag_context_for_storage = null;
9095 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
9096 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
9097
9098 if ($has_rag_data || $has_action_data) {
9099 $rag_context_for_storage = [];
9100
9101 if ($has_rag_data) {
9102 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
9103 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
9104 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
9105 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
9106 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
9107 }
9108
9109 if ($has_action_data) {
9110 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
9111 }
9112 }
9113 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
9114 }
9115 return true;
9116 }
9117
9118 return $this->mxchat_stream_emit_fallback(
9119 'openai',
9120 $this->mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id),
9121 $session_id,
9122 $testing_data
9123 );
9124
9125 } catch (Exception $e) {
9126 return $this->mxchat_stream_emit_fallback(
9127 'openai',
9128 $this->mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id),
9129 $session_id,
9130 $testing_data
9131 );
9132 }
9133 }
9134 private function mxchat_generate_response_openai_stream($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
9135 // OpenAI retires gpt-5.1-chat-latest / gpt-5.3-chat-latest on 2026-08-10
9136 // (replacement gpt-5.6-sol). Read-time rescue mirrors the non-streaming
9137 // path (plan e46b8f).
9138 if ($selected_model === 'gpt-5.1-chat-latest' || $selected_model === 'gpt-5.3-chat-latest') { $selected_model = 'gpt-5.6-sol'; }
9139 try {
9140 $bot_id = $this->get_current_bot_id($session_id);
9141
9142 // Get system prompt instructions using centralized function
9143 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9144
9145 // Ensure conversation_history is an array
9146 if (!is_array($conversation_history)) {
9147 $conversation_history = array();
9148 }
9149
9150 // Format conversation history for OpenAI
9151 $formatted_conversation = array();
9152
9153 $formatted_conversation[] = array(
9154 'role' => 'system',
9155 'content' => $system_prompt_instructions . " " . $relevant_content
9156 );
9157
9158 foreach ($conversation_history as $message) {
9159 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9160 $role = $message['role'];
9161 if ($role === 'bot' || $role === 'agent') {
9162 $role = 'assistant';
9163 }
9164 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
9165 $role = 'user';
9166 }
9167 $formatted_conversation[] = array(
9168 'role' => $role,
9169 'content' => $message['content']
9170 );
9171 }
9172 }
9173
9174 // Check if we can actually stream
9175 if (headers_sent() || !function_exists('curl_init')) {
9176 // Fallback to regular response with testing data
9177 $regular_response = $this->mxchat_generate_response_openai(
9178 $selected_model,
9179 $api_key,
9180 $conversation_history,
9181 $relevant_content,
9182 $session_id
9183 );
9184
9185 // Save bot response to transcript
9186 if (!empty($regular_response) && !empty($session_id)) {
9187 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
9188 }
9189
9190 $response_data = [
9191 'text' => $regular_response,
9192 'html' => '',
9193 'session_id' => $session_id
9194 ];
9195
9196 if ($testing_data !== null) {
9197 $response_data['testing_data'] = $testing_data;
9198 }
9199
9200 header('Content-Type: application/json');
9201 echo json_encode($response_data);
9202 return true;
9203 }
9204
9205 // Build request body with optimal settings for fast streaming
9206 $request_body = [
9207 'model' => $selected_model,
9208 'messages' => $formatted_conversation,
9209 'temperature' => 1,
9210 'stream' => true
9211 ];
9212
9213 // reasoning_effort — sourced from the core model catalog (plan-dcb71c);
9214 // frozen inline ladder lives in mxchat_reasoning_effort_fallback().
9215 $effort = $this->mxchat_reasoning_effort_for($selected_model, 'chat');
9216 if ($effort !== null) {
9217 $request_body['reasoning_effort'] = $effort;
9218 }
9219
9220 $body = json_encode($request_body);
9221
9222 // V2 retry-on-initial-connect: do NOT call setup_streaming_headers() here.
9223 // It is now lazy-fired inside the WRITEFUNCTION on the first byte of a
9224 // SUCCESSFUL upstream response, gated by the captured HTTP status.
9225
9226 $captured_status_code = 0;
9227 $captured_body_pre_stream = '';
9228 $full_response = '';
9229 $stream_started = false;
9230 $buffer = '';
9231 $errno = 0;
9232 $last_curl_error = '';
9233 $http_code = 0;
9234 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
9235 $backoff_ms = array(0, 750, 2000);
9236
9237 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
9238 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
9239 usleep($backoff_ms[$attempt] * 1000);
9240 }
9241
9242 // Reset per-attempt capture state.
9243 $captured_status_code = 0;
9244 $captured_body_pre_stream = '';
9245 $full_response = '';
9246 $stream_started = false;
9247 $buffer = '';
9248
9249 $ch = curl_init();
9250 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
9251 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9252 curl_setopt($ch, CURLOPT_POST, true);
9253 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
9254 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
9255 'Content-Type: application/json',
9256 'Authorization: Bearer ' . $api_key
9257 ));
9258 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9259 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
9260
9261 // Capture HTTP status as soon as response headers arrive — fires before WRITEFUNCTION.
9262 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
9263 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
9264 $captured_status_code = (int) $m[1];
9265 }
9266 return strlen($header);
9267 });
9268
9269 // Buffer control for real-time streaming
9270 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
9271 // V2 guard: if upstream returned non-200, buffer body for transient
9272 // classification and DO NOT emit to client. Stream channel must NOT open.
9273 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
9274 $captured_body_pre_stream .= $data;
9275 return strlen($data);
9276 }
9277
9278 // Lazy-fire streaming headers on first byte of a SUCCESSFUL upstream.
9279 // After this point streaming_headers_sent === true → retry is structurally blocked.
9280 if (!$this->streaming_headers_sent) {
9281 $this->setup_streaming_headers();
9282 }
9283
9284 // Send testing data as the first event if available
9285 if (!$stream_started && $testing_data !== null) {
9286 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
9287 flush();
9288 $stream_started = true;
9289 }
9290
9291 // CRITICAL FIX: Append new data to buffer
9292 $buffer .= $data;
9293
9294 // Process complete lines only
9295 $lines = explode("\n", $buffer);
9296
9297 // CRITICAL FIX: Keep the last incomplete line in the buffer
9298 $buffer = array_pop($lines);
9299
9300 foreach ($lines as $line) {
9301 if (trim($line) === '') {
9302 continue;
9303 }
9304 if (strpos($line, 'data: ') !== 0) {
9305 continue;
9306 }
9307
9308 $json_str = substr($line, 6);
9309
9310 if (trim($json_str) === '[DONE]') {
9311 echo "data: [DONE]\n\n";
9312 flush();
9313 continue;
9314 }
9315
9316 $json = json_decode(trim($json_str), true);
9317 if ($json && isset($json['choices'][0]['delta']['content'])) {
9318 $content = $json['choices'][0]['delta']['content'];
9319 $full_response .= $content;
9320
9321 echo "data: " . json_encode(['content' => $content]) . "\n\n";
9322 flush();
9323 }
9324 }
9325
9326 return strlen($data);
9327 });
9328
9329 $response = curl_exec($ch);
9330 $errno = curl_errno($ch);
9331 $last_curl_error = curl_error($ch);
9332 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
9333 curl_close($ch);
9334
9335 if (!$errno && $http_code === 200) {
9336 break; // Happy path — WRITEFUNCTION already streamed everything.
9337 }
9338
9339 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
9340 $can_retry = !$this->streaming_headers_sent
9341 && ($attempt + 1) < $max_attempts
9342 && $is_transient;
9343
9344 if (defined('WP_DEBUG') && WP_DEBUG) {
9345 error_log(sprintf(
9346 '[MxChat] openai_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
9347 $attempt + 1, $max_attempts, $http_code, $errno,
9348 $is_transient ? 'yes' : 'no',
9349 $can_retry ? 'Retrying.' : 'Giving up.'
9350 ));
9351 }
9352
9353 if (!$can_retry) {
9354 break;
9355 }
9356 }
9357
9358 // Post-loop branch.
9359 if (!$errno && $http_code === 200) {
9360 // Happy path — save the complete response to maintain chat persistence.
9361 if (!empty($full_response) && !empty($session_id)) {
9362 $rag_context_for_storage = null;
9363 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
9364 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
9365
9366 if ($has_rag_data || $has_action_data) {
9367 $rag_context_for_storage = [];
9368
9369 if ($has_rag_data) {
9370 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
9371 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
9372 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
9373 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
9374 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
9375 }
9376
9377 if ($has_action_data) {
9378 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
9379 }
9380 }
9381 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
9382 }
9383
9384 return true;
9385 }
9386
9387 // Failure path — branch on whether SSE channel was opened.
9388 return $this->mxchat_stream_emit_fallback(
9389 'openai',
9390 $this->mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content, $session_id),
9391 $session_id,
9392 $testing_data
9393 );
9394
9395 } catch (Exception $e) {
9396 return $this->mxchat_stream_emit_fallback(
9397 'openai',
9398 $this->mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content, $session_id),
9399 $session_id,
9400 $testing_data
9401 );
9402 }
9403 }
9404
9405 /**
9406 * Shared fallback emitter for streaming chat functions. Two outcomes:
9407 * - streaming_headers_sent === true: SSE channel is open. Emit fallback content
9408 * as `data: {...}\n\n` + `data: [DONE]\n\n` so the widget renders it as a
9409 * normal bot bubble. Transcript row is persisted.
9410 * - streaming_headers_sent === false: SSE channel never opened (retries
9411 * exhausted on initial connect). Emit a clean JSON response — the path
9412 * the widget would normally hit if streaming wasn't even attempted.
9413 *
9414 * Used by all six *_stream functions after their per-attempt retry loop.
9415 */
9416 private function mxchat_stream_emit_fallback($provider_hint, $regular_response, $session_id, $testing_data = null) {
9417 $is_error_array = is_array($regular_response) && isset($regular_response['error']);
9418
9419 if ($this->streaming_headers_sent) {
9420 if ($is_error_array) {
9421 echo "data: " . json_encode([
9422 'error' => true,
9423 'error_message' => $regular_response['error'],
9424 'error_code' => $regular_response['error_code'] ?? 'api_error',
9425 'text' => $regular_response['error'],
9426 'message' => $regular_response['error']
9427 ]) . "\n\n";
9428 echo "data: [DONE]\n\n";
9429 flush();
9430 return true;
9431 }
9432 $fallback_message = (string) $regular_response;
9433 if (!empty($fallback_message) && !empty($session_id)) {
9434 $this->mxchat_save_chat_message($session_id, 'bot', $fallback_message);
9435 }
9436 echo "data: " . json_encode(['content' => $fallback_message]) . "\n\n";
9437 echo "data: [DONE]\n\n";
9438 flush();
9439 return true;
9440 }
9441
9442 // SSE channel never opened — clean JSON fallback.
9443 if ($is_error_array) {
9444 header('Content-Type: application/json');
9445 echo json_encode(array(
9446 'error' => true,
9447 'error_message' => $regular_response['error'],
9448 'error_code' => $regular_response['error_code'] ?? 'api_error',
9449 'text' => $regular_response['error'],
9450 'message' => $regular_response['error'],
9451 ));
9452 return true;
9453 }
9454
9455 $fallback_message = (string) $regular_response;
9456 if (!empty($fallback_message) && !empty($session_id)) {
9457 $this->mxchat_save_chat_message($session_id, 'bot', $fallback_message);
9458 }
9459 $response_data = array(
9460 'text' => $fallback_message,
9461 'html' => '',
9462 'session_id' => $session_id,
9463 );
9464 if ($testing_data !== null) {
9465 $response_data['testing_data'] = $testing_data;
9466 }
9467 header('Content-Type: application/json');
9468 echo json_encode($response_data);
9469 return true;
9470 }
9471
9472 /**
9473 * Resolve custom (OpenAI-compatible) provider config from settings.
9474 * Returns ['base_url','api_key','model','auth_scheme','api_version','chat_url','headers'].
9475 */
9476 private function mxchat_resolve_custom_provider() {
9477 $base_url = isset($this->options['custom_provider_base_url']) ? rtrim(trim((string) $this->options['custom_provider_base_url']), '/') : '';
9478 $api_key = isset($this->options['custom_provider_api_key']) ? trim((string) $this->options['custom_provider_api_key']) : '';
9479 $model = isset($this->options['custom_provider_model']) ? trim((string) $this->options['custom_provider_model']) : '';
9480 $auth_scheme = isset($this->options['custom_provider_auth_scheme']) ? $this->options['custom_provider_auth_scheme'] : 'bearer';
9481 $api_version = isset($this->options['custom_provider_api_version']) ? trim((string) $this->options['custom_provider_api_version']) : '';
9482
9483 $chat_url = $base_url . '/chat/completions';
9484 if (!empty($api_version)) {
9485 $chat_url .= (strpos($chat_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($api_version);
9486 }
9487
9488 $headers = array('Content-Type: application/json');
9489 if (!empty($api_key)) {
9490 if ($auth_scheme === 'api-key') {
9491 $headers[] = 'api-key: ' . $api_key;
9492 } else {
9493 $headers[] = 'Authorization: Bearer ' . $api_key;
9494 }
9495 }
9496
9497 return array(
9498 'base_url' => $base_url,
9499 'api_key' => $api_key,
9500 'model' => $model !== '' ? $model : 'default',
9501 'auth_scheme' => $auth_scheme,
9502 'api_version' => $api_version,
9503 'chat_url' => $chat_url,
9504 'headers' => $headers,
9505 );
9506 }
9507
9508 /**
9509 * Streaming chat completion against an OpenAI-compatible custom provider
9510 * (Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.).
9511 * Mirrors mxchat_generate_response_openai_stream but with parameterized URL/auth/model.
9512 */
9513 private function mxchat_generate_response_custom_stream($selected_model, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
9514 try {
9515 $cfg = $this->mxchat_resolve_custom_provider();
9516 if (empty($cfg['base_url'])) {
9517 return array('error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url');
9518 }
9519
9520 $bot_id = $this->get_current_bot_id($session_id);
9521 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9522 if (!is_array($conversation_history)) {
9523 $conversation_history = array();
9524 }
9525
9526 $formatted_conversation = array();
9527 $formatted_conversation[] = array(
9528 'role' => 'system',
9529 'content' => $system_prompt_instructions . ' ' . $relevant_content,
9530 );
9531 foreach ($conversation_history as $message) {
9532 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9533 $role = $message['role'];
9534 if ($role === 'bot' || $role === 'agent') { $role = 'assistant'; }
9535 if (!in_array($role, array('system', 'assistant', 'user', 'function', 'tool'))) { $role = 'user'; }
9536 $formatted_conversation[] = array('role' => $role, 'content' => $message['content']);
9537 }
9538 }
9539
9540 if (headers_sent() || !function_exists('curl_init')) {
9541 // No streaming capability — fall through to non-stream wrapper
9542 $regular = $this->mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content);
9543 if (!empty($regular) && !empty($session_id) && is_string($regular)) {
9544 $this->mxchat_save_chat_message($session_id, 'bot', $regular);
9545 }
9546 $response_data = array('text' => is_string($regular) ? $regular : '', 'html' => '', 'session_id' => $session_id);
9547 if ($testing_data !== null) { $response_data['testing_data'] = $testing_data; }
9548 header('Content-Type: application/json');
9549 echo json_encode($response_data);
9550 return true;
9551 }
9552
9553 $request_body = array(
9554 'model' => $cfg['model'],
9555 'messages' => $formatted_conversation,
9556 'stream' => true,
9557 );
9558 $body = json_encode($request_body);
9559
9560 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
9561
9562 $captured_status_code = 0;
9563 $captured_body_pre_stream = '';
9564 $full_response = '';
9565 $stream_started = false;
9566 $buffer = '';
9567 $errno = 0;
9568 $http_code = 0;
9569 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
9570 $backoff_ms = array(0, 750, 2000);
9571
9572 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
9573 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
9574 usleep($backoff_ms[$attempt] * 1000);
9575 }
9576
9577 $captured_status_code = 0;
9578 $captured_body_pre_stream = '';
9579 $full_response = '';
9580 $stream_started = false;
9581 $buffer = '';
9582
9583 $ch = curl_init();
9584 curl_setopt($ch, CURLOPT_URL, $cfg['chat_url']);
9585 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9586 curl_setopt($ch, CURLOPT_POST, true);
9587 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
9588 curl_setopt($ch, CURLOPT_HTTPHEADER, $cfg['headers']);
9589 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9590 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
9591
9592 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
9593 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
9594 $captured_status_code = (int) $m[1];
9595 }
9596 return strlen($header);
9597 });
9598
9599 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
9600 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
9601 $captured_body_pre_stream .= $data;
9602 return strlen($data);
9603 }
9604
9605 if (!$this->streaming_headers_sent) {
9606 $this->setup_streaming_headers();
9607 }
9608
9609 if (!$stream_started && $testing_data !== null) {
9610 echo "data: " . json_encode(array('testing_data' => $testing_data)) . "\n\n";
9611 flush();
9612 $stream_started = true;
9613 }
9614 $buffer .= $data;
9615 $lines = explode("\n", $buffer);
9616 $buffer = array_pop($lines);
9617 foreach ($lines as $line) {
9618 if (trim($line) === '') { continue; }
9619 if (strpos($line, 'data: ') !== 0) { continue; }
9620 $json_str = substr($line, 6);
9621 if (trim($json_str) === '[DONE]') {
9622 echo "data: [DONE]\n\n";
9623 flush();
9624 continue;
9625 }
9626 $json = json_decode(trim($json_str), true);
9627 if ($json && isset($json['choices'][0]['delta']['content'])) {
9628 $content = $json['choices'][0]['delta']['content'];
9629 $full_response .= $content;
9630 echo "data: " . json_encode(array('content' => $content)) . "\n\n";
9631 flush();
9632 }
9633 }
9634 return strlen($data);
9635 });
9636
9637 $response = curl_exec($ch);
9638 $errno = curl_errno($ch);
9639 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
9640 curl_close($ch);
9641
9642 if (!$errno && $http_code === 200) {
9643 break;
9644 }
9645
9646 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
9647 $can_retry = !$this->streaming_headers_sent
9648 && ($attempt + 1) < $max_attempts
9649 && $is_transient;
9650
9651 if (defined('WP_DEBUG') && WP_DEBUG) {
9652 error_log(sprintf(
9653 '[MxChat] custom_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
9654 $attempt + 1, $max_attempts, $http_code, $errno,
9655 $is_transient ? 'yes' : 'no',
9656 $can_retry ? 'Retrying.' : 'Giving up.'
9657 ));
9658 }
9659
9660 if (!$can_retry) {
9661 break;
9662 }
9663 }
9664
9665 if (!$errno && $http_code === 200) {
9666 if (!empty($full_response) && !empty($session_id)) {
9667 $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
9668 }
9669 return true;
9670 }
9671
9672 return $this->mxchat_stream_emit_fallback(
9673 'openai',
9674 $this->mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content),
9675 $session_id,
9676 $testing_data
9677 );
9678
9679 } catch (Exception $e) {
9680 return array('error' => sprintf(esc_html__('Custom provider error: %s', 'mxchat'), $e->getMessage()), 'error_code' => 'custom_provider_exception');
9681 }
9682 }
9683
9684 /**
9685 * Non-streaming chat completion against a custom OpenAI-compatible provider.
9686 * Returns string content on success, array['error'=>...] on failure.
9687 */
9688 private function mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content) {
9689 $cfg = $this->mxchat_resolve_custom_provider();
9690 if (empty($cfg['base_url'])) {
9691 return array('error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url');
9692 }
9693
9694 $bot_id = $this->get_current_bot_id(null);
9695 $system_prompt_instructions = $this->get_system_instructions($bot_id, null);
9696 if (!is_array($conversation_history)) {
9697 $conversation_history = array();
9698 }
9699
9700 $messages = array(array(
9701 'role' => 'system',
9702 'content' => $system_prompt_instructions . ' ' . $relevant_content,
9703 ));
9704 foreach ($conversation_history as $message) {
9705 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9706 $role = $message['role'];
9707 if ($role === 'bot' || $role === 'agent') { $role = 'assistant'; }
9708 if (!in_array($role, array('system', 'assistant', 'user', 'function', 'tool'))) { $role = 'user'; }
9709 $messages[] = array('role' => $role, 'content' => $message['content']);
9710 }
9711 }
9712
9713 $headers_assoc = array('Content-Type' => 'application/json');
9714 if (!empty($cfg['api_key'])) {
9715 if ($cfg['auth_scheme'] === 'api-key') {
9716 $headers_assoc['api-key'] = $cfg['api_key'];
9717 } else {
9718 $headers_assoc['Authorization'] = 'Bearer ' . $cfg['api_key'];
9719 }
9720 }
9721
9722 $response = $this->mxchat_provider_call_with_retry($cfg['chat_url'], array(
9723 'headers' => $headers_assoc,
9724 'body' => wp_json_encode(array(
9725 'model' => $cfg['model'],
9726 'messages' => $messages,
9727 )),
9728 'timeout' => 120,
9729 ), 'openai');
9730
9731 if (is_wp_error($response)) {
9732 return array('error' => sprintf(esc_html__('Custom provider request failed: %s', 'mxchat'), $response->get_error_message()), 'error_code' => 'custom_provider_network_error');
9733 }
9734 $code = (int) wp_remote_retrieve_response_code($response);
9735 if ($code < 200 || $code >= 300) {
9736 return array('error' => sprintf(esc_html__('Custom provider returned HTTP %d.', 'mxchat'), $code), 'error_code' => 'custom_provider_http_error');
9737 }
9738 $body = json_decode(wp_remote_retrieve_body($response), true);
9739 if (isset($body['choices'][0]['message']['content'])) {
9740 return (string) $body['choices'][0]['message']['content'];
9741 }
9742 return array('error' => esc_html__('Custom provider returned an unexpected response shape.', 'mxchat'), 'error_code' => 'custom_provider_response_shape');
9743 }
9744
9745 /**
9746 * Generate response using OpenAI Responses API with web search tool
9747 * This uses the newer Responses API which supports web search functionality
9748 */
9749 private function mxchat_generate_response_openai_web_search($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null, $streaming = false) {
9750 // OpenAI retires gpt-5.1-chat-latest / gpt-5.3-chat-latest on 2026-08-10
9751 // (replacement gpt-5.6-sol). Read-time rescue mirrors the chat paths
9752 // (plan e46b8f).
9753 if ($selected_model === 'gpt-5.1-chat-latest' || $selected_model === 'gpt-5.3-chat-latest') { $selected_model = 'gpt-5.6-sol'; }
9754 try {
9755 $bot_id = $this->get_current_bot_id($session_id);
9756 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9757
9758 if (!is_array($conversation_history)) {
9759 $conversation_history = array();
9760 }
9761
9762 // Build the input for Responses API
9763 // The Responses API uses a different format - we need to construct the input properly
9764 $input_parts = [];
9765
9766 // Add system instructions as context
9767 $system_context = $system_prompt_instructions . "\n\n" . $relevant_content;
9768
9769 // Build conversation as input items for Responses API
9770 foreach ($conversation_history as $message) {
9771 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9772 $role = $message['role'];
9773 if ($role === 'bot' || $role === 'agent') {
9774 $role = 'assistant';
9775 }
9776 if (!in_array($role, ['assistant', 'user'])) {
9777 $role = 'user';
9778 }
9779 $input_parts[] = [
9780 'type' => 'message',
9781 'role' => $role,
9782 'content' => $message['content']
9783 ];
9784 }
9785 }
9786
9787 // Build request body for Responses API
9788 $request_body = [
9789 'model' => $selected_model,
9790 'input' => $input_parts,
9791 'instructions' => $system_context,
9792 'stream' => $streaming
9793 ];
9794
9795 // Only add web search tool if web search is enabled in settings
9796 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
9797 if ($web_search_enabled) {
9798 $request_body['tools'] = [
9799 ['type' => 'web_search']
9800 ];
9801 }
9802
9803 // reasoning.effort — sourced from the core model catalog (plan-dcb71c),
9804 // 'websearch' surface; frozen inline ladder in mxchat_reasoning_effort_fallback().
9805 $effort = $this->mxchat_reasoning_effort_for($selected_model, 'websearch');
9806 if ($effort !== null) {
9807 $request_body['reasoning'] = ['effort' => $effort];
9808 }
9809
9810 //error_log("MXCHAT WEB SEARCH: Request body: " . json_encode($request_body));
9811
9812 if ($streaming) {
9813 return $this->mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data);
9814 } else {
9815 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
9816 }
9817
9818 } catch (Exception $e) {
9819 //error_log("MXCHAT WEB SEARCH ERROR: " . $e->getMessage());
9820 return [
9821 'error' => sprintf(esc_html__('Web search error: %s', 'mxchat'), esc_html($e->getMessage())),
9822 'error_code' => 'web_search_exception'
9823 ];
9824 }
9825 }
9826
9827 /**
9828 * Handle non-streaming web search response
9829 */
9830 private function mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data) {
9831 $request_body['stream'] = false;
9832
9833 $response = $this->mxchat_provider_call_with_retry('https://api.openai.com/v1/responses', array(
9834 'headers' => array(
9835 'Authorization' => 'Bearer ' . $api_key,
9836 'Content-Type' => 'application/json'
9837 ),
9838 'body' => json_encode($request_body),
9839 'timeout' => 90
9840 ), 'openai');
9841
9842 if (is_wp_error($response)) {
9843 //error_log("MXCHAT WEB SEARCH ERROR: WP Error: " . $response->get_error_message());
9844 return [
9845 'error' => esc_html__('Failed to connect to OpenAI web search API', 'mxchat'),
9846 'error_code' => 'web_search_connection_error'
9847 ];
9848 }
9849
9850 $response_code = wp_remote_retrieve_response_code($response);
9851 $response_body = wp_remote_retrieve_body($response);
9852
9853 //error_log("MXCHAT WEB SEARCH: Response code: " . $response_code);
9854 //error_log("MXCHAT WEB SEARCH: Response body (first 2000): " . substr($response_body, 0, 2000));
9855
9856 if ($response_code !== 200) {
9857 $error_data = json_decode($response_body, true);
9858 $error_message = $this->extract_provider_error($error_data, 'Unknown API error');
9859 return [
9860 'error' => sprintf(esc_html__('OpenAI API error: %s', 'mxchat'), esc_html($error_message)),
9861 'error_code' => 'web_search_api_error'
9862 ];
9863 }
9864
9865 $result = json_decode($response_body, true);
9866
9867 if (json_last_error() !== JSON_ERROR_NONE) {
9868 return [
9869 'error' => esc_html__('Invalid response from OpenAI', 'mxchat'),
9870 'error_code' => 'web_search_json_error'
9871 ];
9872 }
9873
9874 // Extract the response text and citations from Responses API format
9875 $output_text = '';
9876 $citations = [];
9877
9878 if (isset($result['output'])) {
9879 foreach ($result['output'] as $output_item) {
9880 if ($output_item['type'] === 'message' && isset($output_item['content'])) {
9881 foreach ($output_item['content'] as $content_item) {
9882 if ($content_item['type'] === 'output_text') {
9883 $output_text .= $content_item['text'];
9884
9885 // Extract citations/annotations
9886 if (isset($content_item['annotations'])) {
9887 foreach ($content_item['annotations'] as $annotation) {
9888 if ($annotation['type'] === 'url_citation') {
9889 $citations[] = [
9890 'url' => $annotation['url'],
9891 'title' => $annotation['title'] ?? ''
9892 ];
9893 }
9894 }
9895 }
9896 }
9897 }
9898 }
9899 }
9900 }
9901
9902 // If we have citations, append them to the response
9903 if (!empty($citations)) {
9904 $output_text .= "\n\n**Sources:**\n";
9905 $seen_urls = [];
9906 foreach ($citations as $citation) {
9907 if (!in_array($citation['url'], $seen_urls)) {
9908 $seen_urls[] = $citation['url'];
9909 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
9910 $output_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
9911 }
9912 }
9913 }
9914
9915 // Transcript save is handled by the main handler (mxchat_handle_chat_request)
9916 // which includes rag_context for the "sources" link in transcripts.
9917
9918 // plan-4aa8e5: a 200 whose output carries no output_text (status
9919 // "incomplete" with max_output_tokens exhausted, content-filter-emptied
9920 // output, shape drift) previously fell through and returned '' — a
9921 // silent empty bot bubble. This is the DEFAULT model path
9922 // (the default OpenAI chat model routes through /v1/responses).
9923 if (trim($output_text) === '') {
9924 return $this->mxchat_empty_completion_error($result, 'OpenAI');
9925 }
9926
9927 return $output_text;
9928 }
9929
9930 /**
9931 * Handle streaming web search response using Responses API
9932 */
9933 private function mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data) {
9934 $request_body['stream'] = true;
9935
9936 // Check if we can stream
9937 if (headers_sent() || !function_exists('curl_init')) {
9938 // Fallback to non-streaming
9939 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
9940 }
9941
9942 // Setup streaming headers
9943 $this->setup_streaming_headers();
9944
9945 $ch = curl_init();
9946 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/responses');
9947 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9948 curl_setopt($ch, CURLOPT_POST, true);
9949 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request_body));
9950 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
9951 'Content-Type: application/json',
9952 'Authorization: Bearer ' . $api_key
9953 ));
9954 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9955 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
9956
9957 $full_response = '';
9958 $stream_started = false;
9959 $buffer = '';
9960 $citations = [];
9961 $empty_error_emitted = false;
9962
9963 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$citations, &$empty_error_emitted, $testing_data) {
9964 // Send testing data as first event if available
9965 if (!$stream_started && $testing_data !== null) {
9966 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
9967 flush();
9968 $stream_started = true;
9969 }
9970
9971 $buffer .= $data;
9972 $lines = explode("\n", $buffer);
9973 $buffer = array_pop($lines);
9974
9975 foreach ($lines as $line) {
9976 if (trim($line) === '') continue;
9977 if (strpos($line, 'data: ') !== 0) continue;
9978
9979 $json_str = substr($line, 6);
9980
9981 if (trim($json_str) === '[DONE]') {
9982 // Append citations if we have any
9983 if (!empty($citations)) {
9984 $citation_text = "\n\n**Sources:**\n";
9985 $seen_urls = [];
9986 foreach ($citations as $citation) {
9987 if (!in_array($citation['url'], $seen_urls)) {
9988 $seen_urls[] = $citation['url'];
9989 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
9990 $citation_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
9991 }
9992 }
9993 echo "data: " . json_encode(['content' => $citation_text]) . "\n\n";
9994 $full_response .= $citation_text;
9995 flush();
9996 }
9997 // plan-4aa8e5: zero deltas streamed → say so instead of
9998 // closing a silent empty bubble (client renders text events).
9999 if (trim($full_response) === '' && !$empty_error_emitted) {
10000 $empty_error_emitted = true;
10001 echo "data: " . json_encode(['content' => esc_html__('The AI provider returned an empty response. Please try again.', 'mxchat')]) . "\n\n";
10002 }
10003 echo "data: [DONE]\n\n";
10004 flush();
10005 continue;
10006 }
10007
10008 $json = json_decode(trim($json_str), true);
10009 if (!$json) continue;
10010
10011 // Handle Responses API streaming events
10012 // The format is different from Chat Completions
10013 if (isset($json['type'])) {
10014 switch ($json['type']) {
10015 case 'response.output_text.delta':
10016 // Text content delta
10017 if (isset($json['delta'])) {
10018 $content = $json['delta'];
10019 $full_response .= $content;
10020 echo "data: " . json_encode(['content' => $content]) . "\n\n";
10021 flush();
10022 }
10023 break;
10024
10025 case 'response.output_item.done':
10026 // Check for citations in completed items
10027 if (isset($json['item']['content'])) {
10028 foreach ($json['item']['content'] as $content_item) {
10029 if (isset($content_item['annotations'])) {
10030 foreach ($content_item['annotations'] as $annotation) {
10031 if ($annotation['type'] === 'url_citation') {
10032 $citations[] = [
10033 'url' => $annotation['url'],
10034 'title' => $annotation['title'] ?? ''
10035 ];
10036 }
10037 }
10038 }
10039 }
10040 }
10041 break;
10042 }
10043 }
10044 }
10045
10046 return strlen($data);
10047 });
10048
10049 $response = curl_exec($ch);
10050 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
10051
10052 if (curl_errno($ch) || $http_code !== 200) {
10053 $curl_error = curl_error($ch);
10054 curl_close($ch);
10055
10056 //error_log("MXCHAT WEB SEARCH STREAM ERROR: HTTP $http_code, cURL error: $curl_error");
10057
10058 return $this->mxchat_stream_emit_fallback(
10059 'web_search',
10060 $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data),
10061 $session_id,
10062 $testing_data
10063 );
10064 }
10065
10066 curl_close($ch);
10067
10068 // plan-4aa8e5: the Responses API can end its stream via typed events
10069 // without a [DONE] line — if nothing was streamed at all, close out with
10070 // the empty-completion message instead of leaving a silent bubble.
10071 if (trim($full_response) === '' && !$empty_error_emitted) {
10072 echo "data: " . json_encode(['content' => esc_html__('The AI provider returned an empty response. Please try again.', 'mxchat')]) . "\n\n";
10073 echo "data: [DONE]\n\n";
10074 flush();
10075 }
10076
10077 // Save the complete response with RAG context so the "sources" link
10078 // appears in transcripts — mirrors the pattern used by Claude/OpenAI streaming.
10079 if (!empty($full_response) && !empty($session_id)) {
10080 $rag_context_for_storage = null;
10081 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
10082 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
10083
10084 if ($has_rag_data || $has_action_data) {
10085 $rag_context_for_storage = [];
10086
10087 if ($has_rag_data) {
10088 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
10089 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
10090 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
10091 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
10092 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10093 }
10094
10095 if ($has_action_data) {
10096 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10097 }
10098 }
10099 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
10100 }
10101
10102 return true;
10103 }
10104
10105 private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
10106 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
10107 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
10108 if ($selected_model === 'claude-opus-4-20250514') { $selected_model = 'claude-opus-4-8'; }
10109 elseif ($selected_model === 'claude-sonnet-4-20250514') { $selected_model = 'claude-sonnet-4-6'; }
10110 try {
10111 // Get bot ID from session or request
10112 $bot_id = $this->get_current_bot_id($session_id);
10113
10114 // Get system prompt instructions using centralized function
10115 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10116 // Ensure conversation_history is an array
10117 if (!is_array($conversation_history)) {
10118 $conversation_history = array();
10119 }
10120
10121 // Clean and validate conversation history
10122 foreach ($conversation_history as &$message) {
10123 // Convert bot and agent roles to assistant
10124 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
10125 $message['role'] = 'assistant';
10126 }
10127
10128 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
10129 if (!in_array($message['role'], ['assistant', 'user'])) {
10130 $message['role'] = 'user';
10131 }
10132
10133 // Ensure content field exists
10134 if (!isset($message['content']) || empty($message['content'])) {
10135 $message['content'] = '';
10136 }
10137
10138 // Remove any unsupported fields
10139 $message = array_intersect_key($message, array_flip(['role', 'content']));
10140 }
10141
10142 // Add relevant content as the latest user message
10143 $conversation_history[] = [
10144 'role' => 'user',
10145 'content' => $relevant_content
10146 ];
10147
10148 // Prepare the request body with stream: true
10149 $payload = [
10150 'model' => $selected_model,
10151 'messages' => $conversation_history,
10152 'max_tokens' => 1000,
10153 'temperature' => 0.8,
10154 'system' => $system_prompt_instructions,
10155 'stream' => true
10156 ];
10157 if ($this->mxchat_claude_omits_temperature($selected_model)) { unset($payload['temperature']); }
10158 $body = json_encode($payload);
10159
10160 // Check if we can actually stream (headers not sent, etc.)
10161 if (headers_sent() || !function_exists('curl_init')) {
10162 // Fallback to regular response with testing data
10163 //error_log("MxChat: Streaming not possible, falling back to regular response");
10164 $regular_response = $this->mxchat_generate_response_claude(
10165 $selected_model,
10166 $claude_api_key,
10167 array_slice($conversation_history, 0, -1), // Remove the added content
10168 $relevant_content,
10169 $session_id
10170 );
10171
10172 // Save bot response to transcript
10173 if (!empty($regular_response) && !empty($session_id)) {
10174 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
10175 }
10176
10177 // Return as JSON with testing data
10178 $response_data = [
10179 'text' => $regular_response,
10180 'html' => '',
10181 'session_id' => $session_id
10182 ];
10183
10184 if ($testing_data !== null) {
10185 $response_data['testing_data'] = $testing_data;
10186 //error_log("MxChat Testing: Added testing data to Claude fallback response");
10187 }
10188
10189 // Clear any streaming headers and send JSON
10190 if (headers_sent() === false) {
10191 header('Content-Type: application/json');
10192 }
10193 echo json_encode($response_data);
10194 return true; // Indicate we handled the response
10195 }
10196
10197 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
10198
10199 $captured_status_code = 0;
10200 $captured_body_pre_stream = '';
10201 $full_response = '';
10202 $stream_started = false;
10203 $buffer = '';
10204 $errno = 0;
10205 $http_code = 0;
10206 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
10207 $backoff_ms = array(0, 750, 2000);
10208
10209 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
10210 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
10211 usleep($backoff_ms[$attempt] * 1000);
10212 }
10213
10214 $captured_status_code = 0;
10215 $captured_body_pre_stream = '';
10216 $full_response = '';
10217 $stream_started = false;
10218 $buffer = '';
10219
10220 $ch = curl_init();
10221 curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
10222 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
10223 curl_setopt($ch, CURLOPT_POST, true);
10224 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
10225 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
10226 'Content-Type: application/json',
10227 'x-api-key: ' . $claude_api_key,
10228 'anthropic-version: 2023-06-01'
10229 ));
10230 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
10231 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
10232
10233 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
10234 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
10235 $captured_status_code = (int) $m[1];
10236 }
10237 return strlen($header);
10238 });
10239
10240 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
10241 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
10242 $captured_body_pre_stream .= $data;
10243 return strlen($data);
10244 }
10245
10246 if (!$this->streaming_headers_sent) {
10247 $this->setup_streaming_headers();
10248 }
10249
10250 if (!$stream_started && $testing_data !== null) {
10251 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
10252 flush();
10253 $stream_started = true;
10254 }
10255
10256 $buffer .= $data;
10257 $lines = explode("\n", $buffer);
10258 $buffer = array_pop($lines);
10259
10260 foreach ($lines as $line) {
10261 if (trim($line) === '') {
10262 continue;
10263 }
10264
10265 if (strpos($line, 'event: ') === 0) {
10266 continue;
10267 }
10268
10269 if (strpos($line, 'data: ') === 0) {
10270 $json_str = substr($line, 6);
10271
10272 $json = json_decode(trim($json_str), true);
10273 if (json_last_error() !== JSON_ERROR_NONE) {
10274 continue;
10275 }
10276
10277 if (isset($json['type'])) {
10278 switch ($json['type']) {
10279 case 'content_block_delta':
10280 if (isset($json['delta']['text'])) {
10281 $content = $json['delta']['text'];
10282 $full_response .= $content;
10283 echo "data: " . json_encode(['content' => $content]) . "\n\n";
10284 flush();
10285 }
10286 break;
10287
10288 case 'message_stop':
10289 echo "data: [DONE]\n\n";
10290 flush();
10291 break;
10292
10293 case 'error':
10294 echo "data: " . json_encode(['error' => $this->extract_provider_error($json, 'Unknown error')]) . "\n\n";
10295 flush();
10296 break;
10297 }
10298 }
10299 }
10300 }
10301
10302 return strlen($data);
10303 });
10304
10305 $response = curl_exec($ch);
10306 $errno = curl_errno($ch);
10307 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
10308 curl_close($ch);
10309
10310 if (!$errno && $http_code === 200) {
10311 break;
10312 }
10313
10314 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'anthropic', $errno);
10315 $can_retry = !$this->streaming_headers_sent
10316 && ($attempt + 1) < $max_attempts
10317 && $is_transient;
10318
10319 if (defined('WP_DEBUG') && WP_DEBUG) {
10320 error_log(sprintf(
10321 '[MxChat] claude_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
10322 $attempt + 1, $max_attempts, $http_code, $errno,
10323 $is_transient ? 'yes' : 'no',
10324 $can_retry ? 'Retrying.' : 'Giving up.'
10325 ));
10326 }
10327
10328 if (!$can_retry) {
10329 break;
10330 }
10331 }
10332
10333 if ($errno || $http_code !== 200) {
10334 return $this->mxchat_stream_emit_fallback(
10335 'anthropic',
10336 $this->mxchat_generate_response_claude($selected_model, $claude_api_key, array_slice($conversation_history, 0, -1), $relevant_content, $session_id),
10337 $session_id,
10338 $testing_data
10339 );
10340 }
10341
10342 // Save the complete response to maintain chat persistence
10343 if (!empty($full_response) && !empty($session_id)) {
10344 // Prepare RAG context for streaming response
10345 $rag_context_for_storage = null;
10346 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
10347 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
10348
10349 if ($has_rag_data || $has_action_data) {
10350 $rag_context_for_storage = [];
10351
10352 if ($has_rag_data) {
10353 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
10354 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
10355 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
10356 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
10357 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10358 }
10359
10360 if ($has_action_data) {
10361 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10362 }
10363 }
10364 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
10365 }
10366
10367 return true; // Indicate streaming completed successfully
10368
10369 } catch (Exception $e) {
10370 return $this->mxchat_stream_emit_fallback(
10371 'anthropic',
10372 $this->mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id),
10373 $session_id,
10374 $testing_data
10375 );
10376 }
10377 }
10378 private function mxchat_generate_response_xai_stream($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
10379 try {
10380 // Get bot ID from session or request
10381 $bot_id = $this->get_current_bot_id($session_id);
10382
10383 // Get system prompt instructions using centralized function
10384 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10385
10386 // Ensure conversation_history is an array
10387 if (!is_array($conversation_history)) {
10388 $conversation_history = array();
10389 }
10390
10391 // Format conversation history for X.AI (same as OpenAI format)
10392 $formatted_conversation = array();
10393
10394 $formatted_conversation[] = array(
10395 'role' => 'system',
10396 'content' => $system_prompt_instructions . " " . $relevant_content
10397 );
10398
10399 foreach ($conversation_history as $message) {
10400 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
10401 $role = $message['role'];
10402 if ($role === 'bot' || $role === 'agent') {
10403 $role = 'assistant';
10404 }
10405 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
10406 $role = 'user';
10407 }
10408 $formatted_conversation[] = array(
10409 'role' => $role,
10410 'content' => $message['content']
10411 );
10412 }
10413 }
10414
10415 // Check if we can actually stream
10416 if (headers_sent() || !function_exists('curl_init')) {
10417 // Fallback to regular response with testing data
10418 //error_log("MxChat: X.AI streaming not possible, falling back to regular response");
10419 $regular_response = $this->mxchat_generate_response_xai(
10420 $selected_model,
10421 $xai_api_key,
10422 $conversation_history,
10423 $relevant_content,
10424 $session_id
10425 );
10426
10427 // Save bot response to transcript
10428 if (!empty($regular_response) && !empty($session_id)) {
10429 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
10430 }
10431
10432 $response_data = [
10433 'text' => $regular_response,
10434 'html' => '',
10435 'session_id' => $session_id
10436 ];
10437
10438 if ($testing_data !== null) {
10439 $response_data['testing_data'] = $testing_data;
10440 //error_log("MxChat Testing: Added testing data to X.AI fallback response");
10441 }
10442
10443 header('Content-Type: application/json');
10444 echo json_encode($response_data);
10445 return true;
10446 }
10447
10448 // Prepare the request body with stream: true
10449 $body = json_encode([
10450 'model' => $selected_model,
10451 'messages' => $formatted_conversation,
10452 'temperature' => 0.8,
10453 'stream' => true
10454 ]);
10455
10456 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
10457
10458 $captured_status_code = 0;
10459 $captured_body_pre_stream = '';
10460 $full_response = '';
10461 $stream_started = false;
10462 $buffer = '';
10463 $errno = 0;
10464 $http_code = 0;
10465 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
10466 $backoff_ms = array(0, 750, 2000);
10467
10468 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
10469 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
10470 usleep($backoff_ms[$attempt] * 1000);
10471 }
10472
10473 $captured_status_code = 0;
10474 $captured_body_pre_stream = '';
10475 $full_response = '';
10476 $stream_started = false;
10477 $buffer = '';
10478
10479 $ch = curl_init();
10480 curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
10481 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
10482 curl_setopt($ch, CURLOPT_POST, true);
10483 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
10484 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
10485 'Content-Type: application/json',
10486 'Authorization: Bearer ' . $xai_api_key
10487 ));
10488 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
10489 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
10490
10491 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
10492 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
10493 $captured_status_code = (int) $m[1];
10494 }
10495 return strlen($header);
10496 });
10497
10498 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
10499 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
10500 $captured_body_pre_stream .= $data;
10501 return strlen($data);
10502 }
10503
10504 if (!$this->streaming_headers_sent) {
10505 $this->setup_streaming_headers();
10506 }
10507
10508 if (!$stream_started && $testing_data !== null) {
10509 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
10510 flush();
10511 $stream_started = true;
10512 }
10513
10514 $buffer .= $data;
10515 $lines = explode("\n", $buffer);
10516 $buffer = array_pop($lines);
10517
10518 foreach ($lines as $line) {
10519 if (trim($line) === '') {
10520 continue;
10521 }
10522 if (strpos($line, 'data: ') !== 0) {
10523 continue;
10524 }
10525
10526 $json_str = substr($line, 6);
10527
10528 if (trim($json_str) === '[DONE]') {
10529 echo "data: [DONE]\n\n";
10530 flush();
10531 continue;
10532 }
10533
10534 $json = json_decode(trim($json_str), true);
10535 if ($json && isset($json['choices'][0]['delta']['content'])) {
10536 $content = $json['choices'][0]['delta']['content'];
10537 $full_response .= $content;
10538 echo "data: " . json_encode(['content' => $content]) . "\n\n";
10539 flush();
10540 }
10541 }
10542
10543 return strlen($data);
10544 });
10545
10546 $response = curl_exec($ch);
10547 $errno = curl_errno($ch);
10548 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
10549 curl_close($ch);
10550
10551 if (!$errno && $http_code === 200) {
10552 break;
10553 }
10554
10555 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'xai', $errno);
10556 $can_retry = !$this->streaming_headers_sent
10557 && ($attempt + 1) < $max_attempts
10558 && $is_transient;
10559
10560 if (defined('WP_DEBUG') && WP_DEBUG) {
10561 error_log(sprintf(
10562 '[MxChat] xai_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
10563 $attempt + 1, $max_attempts, $http_code, $errno,
10564 $is_transient ? 'yes' : 'no',
10565 $can_retry ? 'Retrying.' : 'Giving up.'
10566 ));
10567 }
10568
10569 if (!$can_retry) {
10570 break;
10571 }
10572 }
10573
10574 if ($errno || $http_code !== 200) {
10575 return $this->mxchat_stream_emit_fallback(
10576 'xai',
10577 $this->mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id),
10578 $session_id,
10579 $testing_data
10580 );
10581 }
10582
10583 // Save the complete response to maintain chat persistence
10584 if (!empty($full_response) && !empty($session_id)) {
10585 // Prepare RAG context for streaming response
10586 $rag_context_for_storage = null;
10587 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
10588 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
10589
10590 if ($has_rag_data || $has_action_data) {
10591 $rag_context_for_storage = [];
10592
10593 if ($has_rag_data) {
10594 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
10595 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
10596 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
10597 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
10598 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10599 }
10600
10601 if ($has_action_data) {
10602 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10603 }
10604 }
10605 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
10606 }
10607
10608 return true; // Indicate streaming completed successfully
10609
10610 } catch (Exception $e) {
10611 return $this->mxchat_stream_emit_fallback(
10612 'xai',
10613 $this->mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content),
10614 $session_id,
10615 $testing_data
10616 );
10617 }
10618 }
10619 private function mxchat_generate_response_deepseek_stream($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
10620 try {
10621 // Get bot ID from session or request
10622 $bot_id = $this->get_current_bot_id($session_id);
10623
10624 // Get system prompt instructions using centralized function
10625 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10626
10627 // Ensure conversation_history is an array
10628 if (!is_array($conversation_history)) {
10629 $conversation_history = array();
10630 }
10631
10632 // Format conversation history for DeepSeek
10633 $formatted_conversation = array();
10634
10635 $formatted_conversation[] = array(
10636 'role' => 'system',
10637 'content' => $system_prompt_instructions . " " . $relevant_content
10638 );
10639
10640 foreach ($conversation_history as $message) {
10641 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
10642 $role = $message['role'];
10643 if ($role === 'bot' || $role === 'agent') {
10644 $role = 'assistant';
10645 }
10646 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
10647 $role = 'user';
10648 }
10649 $formatted_conversation[] = array(
10650 'role' => $role,
10651 'content' => $message['content']
10652 );
10653 }
10654 }
10655
10656 // Check if we can actually stream
10657 if (headers_sent() || !function_exists('curl_init')) {
10658 // Fallback to regular response with testing data
10659 //error_log("MxChat: DeepSeek streaming not possible, falling back to regular response");
10660 $regular_response = $this->mxchat_generate_response_deepseek(
10661 $selected_model,
10662 $deepseek_api_key,
10663 $conversation_history,
10664 $relevant_content,
10665 $session_id
10666 );
10667
10668 // Save bot response to transcript
10669 if (!empty($regular_response) && !empty($session_id)) {
10670 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
10671 }
10672
10673 $response_data = [
10674 'text' => $regular_response,
10675 'html' => '',
10676 'session_id' => $session_id
10677 ];
10678
10679 if ($testing_data !== null) {
10680 $response_data['testing_data'] = $testing_data;
10681 //error_log("MxChat Testing: Added testing data to DeepSeek fallback response");
10682 }
10683
10684 header('Content-Type: application/json');
10685 echo json_encode($response_data);
10686 return true;
10687 }
10688
10689 // Prepare the request body with stream: true
10690 $body = json_encode([
10691 'model' => $selected_model,
10692 'messages' => $formatted_conversation,
10693 'temperature' => 0.8,
10694 'stream' => true,
10695 // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
10696 // long silent reasoning before the first delta); the widget wants
10697 // the legacy deepseek-chat semantics = non-thinking.
10698 'thinking' => ['type' => 'disabled']
10699 ]);
10700
10701 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
10702
10703 $captured_status_code = 0;
10704 $captured_body_pre_stream = '';
10705 $full_response = '';
10706 $stream_started = false;
10707 $buffer = '';
10708 $errno = 0;
10709 $http_code = 0;
10710 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
10711 $backoff_ms = array(0, 750, 2000);
10712
10713 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
10714 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
10715 usleep($backoff_ms[$attempt] * 1000);
10716 }
10717
10718 $captured_status_code = 0;
10719 $captured_body_pre_stream = '';
10720 $full_response = '';
10721 $stream_started = false;
10722 $buffer = '';
10723
10724 $ch = curl_init();
10725 curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
10726 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
10727 curl_setopt($ch, CURLOPT_POST, true);
10728 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
10729 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
10730 'Content-Type: application/json',
10731 'Authorization: Bearer ' . $deepseek_api_key
10732 ));
10733 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
10734 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
10735
10736 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
10737 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
10738 $captured_status_code = (int) $m[1];
10739 }
10740 return strlen($header);
10741 });
10742
10743 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
10744 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
10745 $captured_body_pre_stream .= $data;
10746 return strlen($data);
10747 }
10748
10749 if (!$this->streaming_headers_sent) {
10750 $this->setup_streaming_headers();
10751 }
10752
10753 if (!$stream_started && $testing_data !== null) {
10754 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
10755 flush();
10756 $stream_started = true;
10757 }
10758
10759 $buffer .= $data;
10760 $lines = explode("\n", $buffer);
10761 $buffer = array_pop($lines);
10762
10763 foreach ($lines as $line) {
10764 if (trim($line) === '') {
10765 continue;
10766 }
10767 if (strpos($line, 'data: ') !== 0) {
10768 continue;
10769 }
10770
10771 $json_str = substr($line, 6);
10772
10773 if (trim($json_str) === '[DONE]') {
10774 echo "data: [DONE]\n\n";
10775 flush();
10776 continue;
10777 }
10778
10779 $json = json_decode(trim($json_str), true);
10780 if ($json && isset($json['choices'][0]['delta']['content'])) {
10781 $content = $json['choices'][0]['delta']['content'];
10782 $full_response .= $content;
10783 echo "data: " . json_encode(['content' => $content]) . "\n\n";
10784 flush();
10785 }
10786 }
10787
10788 return strlen($data);
10789 });
10790
10791 $response = curl_exec($ch);
10792 $errno = curl_errno($ch);
10793 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
10794 curl_close($ch);
10795
10796 if (!$errno && $http_code === 200) {
10797 break;
10798 }
10799
10800 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
10801 $can_retry = !$this->streaming_headers_sent
10802 && ($attempt + 1) < $max_attempts
10803 && $is_transient;
10804
10805 if (defined('WP_DEBUG') && WP_DEBUG) {
10806 error_log(sprintf(
10807 '[MxChat] deepseek_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
10808 $attempt + 1, $max_attempts, $http_code, $errno,
10809 $is_transient ? 'yes' : 'no',
10810 $can_retry ? 'Retrying.' : 'Giving up.'
10811 ));
10812 }
10813
10814 if (!$can_retry) {
10815 break;
10816 }
10817 }
10818
10819 if ($errno || $http_code !== 200) {
10820 return $this->mxchat_stream_emit_fallback(
10821 'openai',
10822 $this->mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id),
10823 $session_id,
10824 $testing_data
10825 );
10826 }
10827
10828 // Save the complete response to maintain chat persistence
10829 if (!empty($full_response) && !empty($session_id)) {
10830 // Prepare RAG context for streaming response
10831 $rag_context_for_storage = null;
10832 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
10833 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
10834
10835 if ($has_rag_data || $has_action_data) {
10836 $rag_context_for_storage = [];
10837
10838 if ($has_rag_data) {
10839 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
10840 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
10841 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
10842 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
10843 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10844 }
10845
10846 if ($has_action_data) {
10847 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10848 }
10849 }
10850 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
10851 }
10852
10853 return true; // Indicate streaming completed successfully
10854
10855 } catch (Exception $e) {
10856 return $this->mxchat_stream_emit_fallback(
10857 'openai',
10858 $this->mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content),
10859 $session_id,
10860 $testing_data
10861 );
10862 }
10863 }
10864
10865
10866 /**
10867 * Extract a human-readable error message from a decoded provider response body.
10868 * Providers disagree on shape: OpenAI/Anthropic/Google nest it (error.message),
10869 * xAI returns a plain string under 'error'. Mirrors mxchat-vision's shipped
10870 * extract_provider_error(); deliberately hint-free in core (vision's too-small
10871 * image hint is an upload concern that doesn't apply here).
10872 *
10873 * @param mixed $decoded_body Decoded JSON body (array), or whatever json_decode returned.
10874 * @param string $fallback Message to return when no provider text is found.
10875 * @return string
10876 */
10877 private function extract_provider_error($decoded_body, $fallback) {
10878 $message = '';
10879 if (isset($decoded_body['error']['message']) && is_string($decoded_body['error']['message']) && $decoded_body['error']['message'] !== '') {
10880 $message = $decoded_body['error']['message'];
10881 } elseif (isset($decoded_body['error']) && is_string($decoded_body['error']) && $decoded_body['error'] !== '') {
10882 $message = $decoded_body['error'];
10883 }
10884
10885 if ($message === '') {
10886 return $fallback;
10887 }
10888
10889 return $message;
10890 }
10891
10892 /**
10893 * plan-4aa8e5: a provider 200 whose body parses to no text must never reach
10894 * the widget as a silent empty bot bubble. Standard error shape for that
10895 * case, preferring the body's own explanation — error.message first (the
10896 * 950731 passthrough pattern), then the Responses API's
10897 * incomplete_details.reason (e.g. "max_output_tokens") — before the generic
10898 * retry message.
10899 */
10900 private function mxchat_empty_completion_error($decoded_body, $provider_label) {
10901 $reason = '';
10902 if (isset($decoded_body['error']['message']) && is_string($decoded_body['error']['message']) && $decoded_body['error']['message'] !== '') {
10903 $reason = $decoded_body['error']['message'];
10904 } elseif (isset($decoded_body['incomplete_details']['reason']) && is_string($decoded_body['incomplete_details']['reason']) && $decoded_body['incomplete_details']['reason'] !== '') {
10905 $reason = sprintf(__('response incomplete: %s', 'mxchat'), $decoded_body['incomplete_details']['reason']);
10906 }
10907
10908 $message = ($reason !== '')
10909 ? sprintf(esc_html__('%1$s returned an empty response (%2$s). Please try again.', 'mxchat'), $provider_label, esc_html($reason))
10910 : sprintf(esc_html__('%s returned an empty response. Please try again.', 'mxchat'), $provider_label);
10911
10912 return [
10913 'error' => $message,
10914 'error_code' => 'empty_completion',
10915 'provider' => strtolower($provider_label),
10916 ];
10917 }
10918
10919 private function mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id = '') {
10920 try {
10921 if (!is_array($conversation_history)) {
10922 $conversation_history = array();
10923 }
10924
10925 $bot_id = $this->get_current_bot_id($session_id);
10926 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10927
10928 $formatted_conversation = array();
10929
10930 $formatted_conversation[] = array(
10931 'role' => 'system',
10932 'content' => $system_prompt_instructions . " " . $relevant_content
10933 );
10934
10935 foreach ($conversation_history as $message) {
10936 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
10937 $role = $message['role'];
10938
10939 if ($role === 'bot' || $role === 'agent') {
10940 $role = 'assistant';
10941 }
10942 if (!in_array($role, ['system', 'assistant', 'user'])) {
10943 $role = 'user';
10944 }
10945
10946 $formatted_conversation[] = array(
10947 'role' => $role,
10948 'content' => $message['content']
10949 );
10950 }
10951 }
10952
10953 $body = json_encode([
10954 'model' => $selected_model,
10955 'messages' => $formatted_conversation,
10956 'temperature' => 1,
10957 ]);
10958
10959 $args = [
10960 'body' => $body,
10961 'headers' => [
10962 'Content-Type' => 'application/json',
10963 'Authorization' => 'Bearer ' . $openrouter_api_key,
10964 'HTTP-Referer' => home_url(),
10965 'X-Title' => get_bloginfo('name'),
10966 ],
10967 'timeout' => 60,
10968 'redirection' => 5,
10969 'blocking' => true,
10970 'httpversion' => '1.0',
10971 'sslverify' => true,
10972 ];
10973
10974 $response = $this->mxchat_provider_call_with_retry('https://openrouter.ai/api/v1/chat/completions', $args, 'openai');
10975
10976 if (is_wp_error($response)) {
10977 $error_message = $response->get_error_message();
10978 return [
10979 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'OpenRouter', $selected_model),
10980 'error_code' => 'openrouter_connection_error',
10981 'provider' => 'openrouter'
10982 ];
10983 }
10984
10985 $status_code = wp_remote_retrieve_response_code($response);
10986 if ($status_code !== 200) {
10987 $response_body = wp_remote_retrieve_body($response);
10988 $decoded_response = json_decode($response_body, true);
10989
10990 $error_message = $this->extract_provider_error($decoded_response, 'HTTP Error ' . $status_code);
10991
10992 return [
10993 'error' => esc_html__('OpenRouter API error: ', 'mxchat') . esc_html($error_message),
10994 'error_code' => 'openrouter_api_error',
10995 'provider' => 'openrouter',
10996 'status_code' => $status_code
10997 ];
10998 }
10999
11000 $response_body = wp_remote_retrieve_body($response);
11001 $decoded_response = json_decode($response_body, true);
11002
11003 if (isset($decoded_response['choices'][0]['message']['content'])) {
11004 $text = trim($decoded_response['choices'][0]['message']['content']);
11005 if ($text !== '') {
11006 return $text;
11007 }
11008 return $this->mxchat_empty_completion_error($decoded_response, 'OpenRouter');
11009 } else {
11010 return [
11011 'error' => esc_html__('Unexpected response format from OpenRouter.', 'mxchat'),
11012 'error_code' => 'openrouter_response_format_error',
11013 'provider' => 'openrouter'
11014 ];
11015 }
11016 } catch (Exception $e) {
11017 return [
11018 'error' => esc_html__('System error when processing OpenRouter request: ', 'mxchat') . esc_html($e->getMessage()),
11019 'error_code' => 'openrouter_exception',
11020 'provider' => 'openrouter'
11021 ];
11022 }
11023 }
11024
11025 /**
11026 * Build a chat-bubble-safe message for a non-200 provider (chat) error.
11027 *
11028 * Visitors must NEVER see raw API internals (model names, key/billing/quota
11029 * text). Admins (manage_options) get an actionable hint — and, for the common
11030 * "model not available on this key" case, a direct pointer to change the model
11031 * (the site owner can fix it in one click). Anthropic returns model-access as a
11032 * 4xx with a message like "Claude Fable 5 is not available. Please use Opus 4.8."
11033 *
11034 * Provider-agnostic by design (reusable for the xai/gemini/deepseek branches),
11035 * but Anthropic is the confirmed, reproduced case wired up here (plan 1d3b0f).
11036 *
11037 * @param int $http_code HTTP status from the provider.
11038 * @param string $error_message Raw provider error.message (may be empty).
11039 * @param string $provider_label Human provider name, e.g. 'Anthropic'.
11040 * @param string $model The model id the failing request used. When a
11041 * model-access failure is detected and this is
11042 * non-empty, a persistent admin notice is armed
11043 * (mxchat_show_model_access_notice) so the OWNER
11044 * learns about it even when only anonymous
11045 * visitors hit the broken bot (plan e46b8f).
11046 * @return string Message safe to render as a chat bubble.
11047 */
11048 private function mxchat_friendly_chat_error($http_code, $error_message, $provider_label = '', $model = '') {
11049 $raw = trim((string) $error_message);
11050
11051 // Detect a model-access / availability problem the site owner can fix by
11052 // choosing a different model. (Anthropic phrasing + the common API shapes.)
11053 $low = strtolower($raw);
11054 $is_model_access = (strpos($low, 'not available') !== false)
11055 || (strpos($low, 'does not have access') !== false)
11056 || (strpos($low, 'do not have access') !== false)
11057 || (strpos($low, 'does not exist') !== false) // OpenAI: "model `x` does not exist or you do not have access"
11058 || (strpos($low, 'model_not_found') !== false)
11059 || (strpos($low, 'not_found_error') !== false)
11060 || (strpos($low, 'model not found') !== false) // xAI
11061 || (strpos($low, 'not found') !== false) // Gemini: "models/x is not found for API version ..."
11062 || (strpos($low, 'permission_denied') !== false) // Gemini gated model
11063 || (strpos($low, 'permission denied') !== false);
11064
11065 // Arm the persistent admin notice (throttled: skip if the same model was
11066 // flagged within the last hour — chat errors can fire per message).
11067 if ($is_model_access && $model !== '') {
11068 $existing = get_option('mxchat_model_access_notice');
11069 $stale = !is_array($existing)
11070 || !isset($existing['model'], $existing['time'])
11071 || $existing['model'] !== $model
11072 || (time() - (int) $existing['time']) > HOUR_IN_SECONDS;
11073 if ($stale) {
11074 update_option('mxchat_model_access_notice', array(
11075 'model' => (string) $model,
11076 'provider' => (string) $provider_label,
11077 'time' => time(),
11078 ), false);
11079 }
11080 }
11081
11082 if (current_user_can('manage_options')) {
11083 if ($is_model_access) {
11084 return $raw !== ''
11085 ? sprintf(
11086 /* translators: %s: raw provider error detail */
11087 esc_html__('The selected AI model isn\'t available on your API key. Choose another model in MxChat → Settings. (Details: %s)', 'mxchat'),
11088 $raw
11089 )
11090 : esc_html__('The selected AI model isn\'t available on your API key. Choose another model in MxChat → Settings.', 'mxchat');
11091 }
11092 return $raw !== ''
11093 ? sprintf(
11094 /* translators: 1: provider label, 2: raw provider error detail */
11095 esc_html__('The AI provider (%1$s) returned an error: %2$s. Check your model and API key in MxChat → Settings.', 'mxchat'),
11096 $provider_label !== '' ? $provider_label : esc_html__('AI', 'mxchat'),
11097 $raw
11098 )
11099 : esc_html__('The AI provider returned an error. Check your model and API key in MxChat → Settings.', 'mxchat');
11100 }
11101
11102 // Visitors: friendly, generic, no internals leaked.
11103 return esc_html__('Sorry, I\'m having trouble responding right now. Please try again in a moment.', 'mxchat');
11104 }
11105
11106 private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id = '') {
11107 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
11108 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
11109 if ($selected_model === 'claude-opus-4-20250514') { $selected_model = 'claude-opus-4-8'; }
11110 elseif ($selected_model === 'claude-sonnet-4-20250514') { $selected_model = 'claude-sonnet-4-6'; }
11111
11112 // Get bot ID from session or request
11113 $bot_id = $this->get_current_bot_id($session_id);
11114
11115 // Get system prompt instructions using centralized function
11116 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11117
11118 // Clean and validate conversation history
11119 foreach ($conversation_history as &$message) {
11120 // Convert bot and agent roles to assistant
11121 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
11122 $message['role'] = 'assistant';
11123 }
11124
11125 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
11126 if (!in_array($message['role'], ['assistant', 'user'])) {
11127 $message['role'] = 'user';
11128 }
11129
11130 // Ensure content field exists
11131 if (!isset($message['content']) || empty($message['content'])) {
11132 $message['content'] = '';
11133 }
11134
11135 // Remove any unsupported fields
11136 $message = array_intersect_key($message, array_flip(['role', 'content']));
11137 }
11138
11139 // Add relevant content as the latest user message
11140 $conversation_history[] = [
11141 'role' => 'user',
11142 'content' => $relevant_content
11143 ];
11144
11145 // Build request body
11146 $payload = [
11147 'model' => $selected_model,
11148 'max_tokens' => 1000,
11149 'temperature' => 0.8,
11150 'messages' => $conversation_history,
11151 'system' => $system_prompt_instructions
11152 ];
11153 if ($this->mxchat_claude_omits_temperature($selected_model)) { unset($payload['temperature']); }
11154 $body = json_encode($payload);
11155
11156 // Set up API request
11157 $args = [
11158 'body' => $body,
11159 'headers' => [
11160 'Content-Type' => 'application/json',
11161 'x-api-key' => $claude_api_key,
11162 'anthropic-version' => '2023-06-01'
11163 ],
11164 'timeout' => 60,
11165 'redirection' => 5,
11166 'blocking' => true,
11167 'httpversion' => '1.0',
11168 'sslverify' => true,
11169 ];
11170
11171 // Make API request
11172 $response = $this->mxchat_provider_call_with_retry('https://api.anthropic.com/v1/messages', $args, 'anthropic');
11173
11174 // Check for WordPress errors
11175 if (is_wp_error($response)) {
11176 //error_log("Claude API request error: " . $response->get_error_message());
11177 return "Sorry, there was an error connecting to the API.";
11178 }
11179
11180 // Check HTTP response code
11181 $http_code = wp_remote_retrieve_response_code($response);
11182 if ($http_code !== 200) {
11183 $error_body = wp_remote_retrieve_body($response);
11184 //error_log("Claude API HTTP error: " . $http_code . " - " . $error_body);
11185
11186 // Try to extract error message from response
11187 $error_data = json_decode($error_body, true);
11188 $error_message = isset($error_data['error']['message']) ?
11189 $error_data['error']['message'] :
11190 "HTTP error " . $http_code;
11191
11192 // Surface an admin-actionable message (and a model-change pointer for the
11193 // model-access case) without leaking raw API internals to visitors. This
11194 // is the single chokepoint for BOTH the non-streaming and streaming Claude
11195 // paths (the stream's non-200 fallback re-enters this method). plan 1d3b0f.
11196 return $this->mxchat_friendly_chat_error($http_code, $error_message, 'Anthropic', $selected_model);
11197 }
11198
11199 // Parse response
11200 $response_body = json_decode(wp_remote_retrieve_body($response), true);
11201
11202 // Check for JSON decode errors
11203 if (json_last_error() !== JSON_ERROR_NONE) {
11204 //error_log("Claude API JSON decode error: " . json_last_error_msg());
11205 return "Sorry, there was an error processing the API response.";
11206 }
11207
11208 // Extract and validate response content. claude-fable-5 prepends a
11209 // thinking block to content even with no thinking param — take the first
11210 // TEXT block rather than content[0].
11211 if (isset($response_body['content']) && is_array($response_body['content'])) {
11212 foreach ($response_body['content'] as $block) {
11213 // plan-4aa8e5: skip empty text blocks — a 200 whose only text
11214 // block trims to '' must not render as a silent empty bubble.
11215 if (isset($block['type'], $block['text']) && $block['type'] === 'text' && trim($block['text']) !== '') {
11216 return trim($block['text']);
11217 }
11218 }
11219 return $this->mxchat_empty_completion_error($response_body, 'Claude');
11220 }
11221
11222 // Log unexpected response format
11223 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
11224 return "Sorry, I received an unexpected response format from the API.";
11225 }
11226 private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content, $session_id = '') {
11227 // OpenAI retires gpt-5.1-chat-latest / gpt-5.3-chat-latest on 2026-08-10
11228 // (replacement gpt-5.6-sol). Read-time rescue for saved / bot-level ids
11229 // that missed mxchat_migrate_deprecated_models() (plan e46b8f).
11230 if ($selected_model === 'gpt-5.1-chat-latest' || $selected_model === 'gpt-5.3-chat-latest') { $selected_model = 'gpt-5.6-sol'; }
11231 try {
11232 // Ensure conversation_history is an array
11233 if (!is_array($conversation_history)) {
11234 $conversation_history = array();
11235 }
11236
11237 // Get bot ID from session or request. plan eb9c38: resolve the real bot
11238 // from the session (was hardcoded '' → always default bot on multi-bot
11239 // installs) and fix the undefined $session_id that fed get_system_instructions.
11240 $bot_id = $this->get_current_bot_id($session_id);
11241
11242 // Get system prompt instructions using centralized function
11243 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11244
11245 // Create a new array for the formatted conversation
11246 $formatted_conversation = array();
11247
11248 // Add system message first
11249 $formatted_conversation[] = array(
11250 'role' => 'system',
11251 'content' => $system_prompt_instructions . " " . $relevant_content
11252 );
11253
11254 // Add the rest of the conversation history
11255 foreach ($conversation_history as $message) {
11256 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
11257 $role = $message['role'];
11258
11259 // Convert roles to supported format
11260 if ($role === 'bot' || $role === 'agent') {
11261 $role = 'assistant';
11262 }
11263 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
11264 $role = 'user';
11265 }
11266
11267 $formatted_conversation[] = array(
11268 'role' => $role,
11269 'content' => $message['content']
11270 );
11271 }
11272 }
11273
11274 // Build request body with optimal settings for fast responses
11275 $request_body = [
11276 'model' => $selected_model,
11277 'messages' => $formatted_conversation,
11278 'temperature' => 1,
11279 'stream' => false
11280 ];
11281
11282 // reasoning_effort — sourced from the core model catalog (plan-dcb71c);
11283 // frozen inline ladder lives in mxchat_reasoning_effort_fallback().
11284 $effort = $this->mxchat_reasoning_effort_for($selected_model, 'chat');
11285 if ($effort !== null) {
11286 $request_body['reasoning_effort'] = $effort;
11287 }
11288
11289 $body = json_encode($request_body);
11290
11291 $args = [
11292 'body' => $body,
11293 'headers' => [
11294 'Content-Type' => 'application/json',
11295 'Authorization' => 'Bearer ' . $api_key,
11296 ],
11297 'timeout' => 60,
11298 'redirection' => 5,
11299 'blocking' => true,
11300 'httpversion' => '1.0',
11301 'sslverify' => true,
11302 ];
11303
11304 $response = $this->mxchat_provider_call_with_retry('https://api.openai.com/v1/chat/completions', $args, 'openai');
11305
11306 if (is_wp_error($response)) {
11307 $error_message = $response->get_error_message();
11308 return [
11309 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'OpenAI', $selected_model),
11310 'error_code' => 'openai_connection_error',
11311 'provider' => 'openai'
11312 ];
11313 }
11314
11315 $status_code = wp_remote_retrieve_response_code($response);
11316 if ($status_code !== 200) {
11317 $response_body = wp_remote_retrieve_body($response);
11318 $decoded_response = json_decode($response_body, true);
11319
11320 $error_message = isset($decoded_response['error']['message'])
11321 ? $decoded_response['error']['message']
11322 : 'HTTP Error ' . $status_code;
11323
11324 $error_type = isset($decoded_response['error']['type'])
11325 ? $decoded_response['error']['type']
11326 : 'unknown';
11327
11328 // Handle specific error types
11329 switch ($error_type) {
11330 case 'invalid_request_error':
11331 if (strpos($error_message, 'API key') !== false) {
11332 return [
11333 'error' => esc_html__('Invalid OpenAI API key. Please check your API key configuration.', 'mxchat'),
11334 'error_code' => 'openai_invalid_api_key',
11335 'provider' => 'openai'
11336 ];
11337 }
11338 break;
11339
11340 case 'authentication_error':
11341 return [
11342 'error' => esc_html__('Authentication failed with OpenAI. Please check your API key.', 'mxchat'),
11343 'error_code' => 'openai_auth_error',
11344 'provider' => 'openai'
11345 ];
11346
11347 case 'rate_limit_exceeded':
11348 return [
11349 'error' => esc_html__('OpenAI rate limit exceeded. Please try again later.', 'mxchat'),
11350 'error_code' => 'openai_rate_limit',
11351 'provider' => 'openai'
11352 ];
11353
11354 case 'quota_exceeded':
11355 return [
11356 'error' => esc_html__('OpenAI API quota exceeded. Please check your billing details.', 'mxchat'),
11357 'error_code' => 'openai_quota_exceeded',
11358 'provider' => 'openai'
11359 ];
11360 }
11361
11362 // Generic error fallback only — the typed cases above already produce
11363 // clean messages. Route the raw-tail generic case through the leak-safe
11364 // helper so visitors never see provider internals. plan 5da59a.
11365 return [
11366 'error' => $this->mxchat_friendly_chat_error($status_code, $error_message, 'OpenAI', $selected_model),
11367 'error_code' => 'openai_api_error',
11368 'provider' => 'openai',
11369 'status_code' => $status_code
11370 ];
11371 }
11372
11373 $response_body = wp_remote_retrieve_body($response);
11374 $decoded_response = json_decode($response_body, true);
11375
11376 if (isset($decoded_response['choices'][0]['message']['content'])) {
11377 $text = trim($decoded_response['choices'][0]['message']['content']);
11378 if ($text !== '') {
11379 return $text;
11380 }
11381 return $this->mxchat_empty_completion_error($decoded_response, 'OpenAI');
11382 } else {
11383 return [
11384 'error' => esc_html__('Unexpected response format from OpenAI.', 'mxchat'),
11385 'error_code' => 'openai_response_format_error',
11386 'provider' => 'openai'
11387 ];
11388 }
11389 } catch (Exception $e) {
11390 return [
11391 'error' => esc_html__('System error when processing OpenAI request: ', 'mxchat') . esc_html($e->getMessage()),
11392 'error_code' => 'openai_exception',
11393 'provider' => 'openai'
11394 ];
11395 }
11396 }
11397
11398 private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id = '') {
11399 try {
11400 // Get bot ID from session or request
11401 $bot_id = $this->get_current_bot_id($session_id);
11402
11403 // Get system prompt instructions using centralized function
11404 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11405
11406 // Add system prompt to relevant content
11407 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
11408
11409 // Prepend system instructions to the conversation history
11410 array_unshift($conversation_history, [
11411 'role' => 'system',
11412 'content' => "Here are your instructions: " . $content_with_instructions
11413 ]);
11414
11415 // Ensure consistency: Replace 'bot' and 'agent' roles with supported values
11416 foreach ($conversation_history as &$message) {
11417 if ($message['role'] === 'bot') {
11418 $message['role'] = 'assistant';
11419 } elseif ($message['role'] === 'agent') {
11420 // Tag the message as coming from a live agent
11421 $message['role'] = 'assistant';
11422 if (!isset($message['metadata'])) {
11423 $message['metadata'] = ['source' => 'live_agent'];
11424 }
11425 }
11426
11427 // Ensure all roles are valid
11428 if (!in_array($message['role'], ['system', 'assistant', 'user', 'function', 'tool'])) {
11429 $message['role'] = 'user'; // Default to 'user'
11430 }
11431 }
11432
11433 // Build the request body
11434 $body = json_encode([
11435 'model' => $selected_model,
11436 'messages' => $conversation_history,
11437 'temperature' => 0.8,
11438 'stream' => false
11439 ]);
11440
11441 // Set up the API request
11442 $args = [
11443 'body' => $body,
11444 'headers' => [
11445 'Content-Type' => 'application/json',
11446 'Authorization' => 'Bearer ' . $xai_api_key,
11447 ],
11448 'timeout' => 60,
11449 'redirection' => 5,
11450 'blocking' => true,
11451 'httpversion' => '1.0',
11452 'sslverify' => true,
11453 ];
11454
11455 // Make the API request
11456 $response = $this->mxchat_provider_call_with_retry('https://api.x.ai/v1/chat/completions', $args, 'xai');
11457
11458 // Process the response
11459 if (is_wp_error($response)) {
11460 $error_message = $response->get_error_message();
11461 //error_log('X.AI API Error: ' . $error_message);
11462 return [
11463 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'X.AI', $selected_model),
11464 'error_code' => 'xai_connection_error',
11465 'provider' => 'xai'
11466 ];
11467 }
11468
11469 $status_code = wp_remote_retrieve_response_code($response);
11470 if ($status_code !== 200) {
11471 $response_body = wp_remote_retrieve_body($response);
11472 $decoded_response = json_decode($response_body, true);
11473
11474 // Log the full response for debugging
11475 //error_log('X.AI Error Response: ' . print_r($decoded_response, true));
11476
11477 // Extract error message from X.AI's specific format
11478 $error_message = '';
11479
11480 // Check for direct error string (as seen in your logs)
11481 if (isset($decoded_response['error']) && is_string($decoded_response['error'])) {
11482 $error_message = $decoded_response['error'];
11483 }
11484 // Check for nested error object (OpenAI style)
11485 elseif (isset($decoded_response['error']['message'])) {
11486 $error_message = $decoded_response['error']['message'];
11487 }
11488 // Check for top-level message
11489 elseif (isset($decoded_response['message'])) {
11490 $error_message = $decoded_response['message'];
11491 }
11492 // Fallback
11493 else {
11494 $error_message = 'HTTP Error ' . $status_code;
11495 }
11496
11497 //error_log('X.AI API HTTP Error: ' . $status_code . ' - ' . $error_message);
11498
11499 // Check for API key errors using string matching
11500 if (stripos($error_message, 'api key') !== false ||
11501 stripos($error_message, 'incorrect api key') !== false ||
11502 stripos($error_message, 'invalid api key') !== false) {
11503 return [
11504 'error' => esc_html__('Invalid X.AI API key. Please check your API key configuration.', 'mxchat'),
11505 'error_code' => 'xai_invalid_api_key',
11506 'provider' => 'xai'
11507 ];
11508 }
11509
11510 // Authentication errors
11511 if ($status_code === 401 || $status_code === 403 ||
11512 stripos($error_message, 'auth') !== false) {
11513 return [
11514 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat') . ' ' . esc_html($error_message),
11515 'error_code' => 'xai_auth_error',
11516 'provider' => 'xai'
11517 ];
11518 }
11519
11520 // Model errors — keep the canned category text as a prefix, but carry the
11521 // provider's extracted reason (e.g. "Model not found: <id>") so the owner
11522 // sees the specific model/reason instead of only the generic category.
11523 if (stripos($error_message, 'model') !== false) {
11524 return [
11525 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat') . ' ' . esc_html($error_message),
11526 'error_code' => 'xai_invalid_model',
11527 'provider' => 'xai'
11528 ];
11529 }
11530
11531 // Rate limit errors
11532 if ($status_code === 429 ||
11533 stripos($error_message, 'rate') !== false ||
11534 stripos($error_message, 'limit') !== false) {
11535 return [
11536 'error' => esc_html__('X.AI rate limit exceeded. Please try again later.', 'mxchat'),
11537 'error_code' => 'xai_rate_limit',
11538 'provider' => 'xai'
11539 ];
11540 }
11541
11542 // Quota errors
11543 if (stripos($error_message, 'quota') !== false ||
11544 stripos($error_message, 'billing') !== false) {
11545 return [
11546 'error' => esc_html__('X.AI API quota exceeded. Please check your billing details.', 'mxchat'),
11547 'error_code' => 'xai_quota_exceeded',
11548 'provider' => 'xai'
11549 ];
11550 }
11551
11552 // Server errors
11553 if ($status_code >= 500) {
11554 return [
11555 'error' => esc_html__('X.AI service is currently unavailable. Please try again later.', 'mxchat'),
11556 'error_code' => 'xai_service_unavailable',
11557 'provider' => 'xai'
11558 ];
11559 }
11560
11561 // Generic error fallback. Route the user-facing text through the
11562 // leak-safe helper (admins get an actionable hint, visitors a generic
11563 // fallback) instead of echoing raw provider internals. Preserve the
11564 // structured contract (error_code/provider/status_code) for logging. plan 5da59a.
11565 return [
11566 'error' => $this->mxchat_friendly_chat_error($status_code, $error_message, 'xAI', $selected_model),
11567 'error_code' => 'xai_api_error',
11568 'provider' => 'xai',
11569 'status_code' => $status_code
11570 ];
11571 }
11572
11573 $response_body = wp_remote_retrieve_body($response);
11574 $decoded_response = json_decode($response_body, true);
11575
11576 if (isset($decoded_response['choices'][0]['message']['content'])) {
11577 $text = trim($decoded_response['choices'][0]['message']['content']);
11578 if ($text !== '') {
11579 return $text;
11580 }
11581 return $this->mxchat_empty_completion_error($decoded_response, 'X.AI');
11582 } else {
11583 //error_log('X.AI API Response Format Error: ' . print_r($decoded_response, true));
11584 return [
11585 'error' => esc_html__('Unexpected response format from X.AI.', 'mxchat'),
11586 'error_code' => 'xai_response_format_error',
11587 'provider' => 'xai'
11588 ];
11589 }
11590 } catch (Exception $e) {
11591 //error_log('X.AI Exception: ' . $e->getMessage());
11592 return [
11593 'error' => esc_html__('System error when processing X.AI request: ', 'mxchat') . esc_html($e->getMessage()),
11594 'error_code' => 'xai_exception',
11595 'provider' => 'xai'
11596 ];
11597 }
11598
11599
11600 }
11601 private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id = '') {
11602 try {
11603 // Ensure conversation_history is an array
11604 if (!is_array($conversation_history)) {
11605 $conversation_history = array();
11606 }
11607
11608 // Get bot ID from session or request
11609 $bot_id = $this->get_current_bot_id($session_id);
11610
11611 // Get system prompt instructions using centralized function
11612 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11613
11614 // Create a new array for the formatted conversation
11615 $formatted_conversation = array();
11616
11617 // Add system message first
11618 $formatted_conversation[] = array(
11619 'role' => 'system',
11620 'content' => $system_prompt_instructions . " " . $relevant_content
11621 );
11622
11623 // Add the rest of the conversation history
11624 foreach ($conversation_history as $message) {
11625 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
11626 $role = $message['role'];
11627
11628 // Convert roles to supported format
11629 if ($role === 'bot' || $role === 'agent') {
11630 $role = 'assistant';
11631 }
11632 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
11633 $role = 'user';
11634 }
11635
11636 $formatted_conversation[] = array(
11637 'role' => $role,
11638 'content' => $message['content']
11639 );
11640 }
11641 }
11642
11643 $body = json_encode([
11644 'model' => $selected_model,
11645 'messages' => $formatted_conversation,
11646 'temperature' => 0.8,
11647 'stream' => false,
11648 // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
11649 // slow reasoning-first responses); the widget wants the legacy
11650 // deepseek-chat semantics = non-thinking.
11651 'thinking' => ['type' => 'disabled']
11652 ]);
11653
11654 $args = [
11655 'body' => $body,
11656 'headers' => [
11657 'Content-Type' => 'application/json',
11658 'Authorization' => 'Bearer ' . $deepseek_api_key,
11659 ],
11660 'timeout' => 60,
11661 'redirection' => 5,
11662 'blocking' => true,
11663 'httpversion' => '1.0',
11664 'sslverify' => true,
11665 ];
11666
11667 $response = $this->mxchat_provider_call_with_retry('https://api.deepseek.com/v1/chat/completions', $args, 'openai');
11668
11669 if (is_wp_error($response)) {
11670 $error_message = $response->get_error_message();
11671 //error_log('DeepSeek API Error: ' . $error_message);
11672 return [
11673 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'DeepSeek', $selected_model),
11674 'error_code' => 'deepseek_connection_error',
11675 'provider' => 'deepseek'
11676 ];
11677 }
11678
11679 $status_code = wp_remote_retrieve_response_code($response);
11680 if ($status_code !== 200) {
11681 $response_body = wp_remote_retrieve_body($response);
11682 $decoded_response = json_decode($response_body, true);
11683
11684 $error_message = isset($decoded_response['error']['message'])
11685 ? $decoded_response['error']['message']
11686 : 'HTTP Error ' . $status_code;
11687
11688 $error_type = isset($decoded_response['error']['type'])
11689 ? $decoded_response['error']['type']
11690 : 'unknown';
11691
11692 //error_log('DeepSeek API HTTP Error: ' . $status_code . ' - ' . $error_message);
11693
11694 // Handle specific error types
11695 switch ($status_code) {
11696 case 401:
11697 return [
11698 'error' => esc_html__('Authentication failed with DeepSeek. Please check your API key.', 'mxchat'),
11699 'error_code' => 'deepseek_auth_error',
11700 'provider' => 'deepseek'
11701 ];
11702
11703 case 400:
11704 if (strpos($error_message, 'API key') !== false) {
11705 return [
11706 'error' => esc_html__('Invalid DeepSeek API key. Please check your API key configuration.', 'mxchat'),
11707 'error_code' => 'deepseek_invalid_api_key',
11708 'provider' => 'deepseek'
11709 ];
11710 }
11711 break;
11712
11713 case 429:
11714 if (strpos($error_message, 'quota') !== false) {
11715 return [
11716 'error' => esc_html__('DeepSeek API quota exceeded. Please check your billing details.', 'mxchat'),
11717 'error_code' => 'deepseek_quota_exceeded',
11718 'provider' => 'deepseek'
11719 ];
11720 } else {
11721 return [
11722 'error' => esc_html__('DeepSeek rate limit exceeded. Please try again later.', 'mxchat'),
11723 'error_code' => 'deepseek_rate_limit',
11724 'provider' => 'deepseek'
11725 ];
11726 }
11727
11728 case 500:
11729 case 502:
11730 case 503:
11731 case 504:
11732 return [
11733 'error' => esc_html__('DeepSeek service is currently unavailable. Please try again later.', 'mxchat'),
11734 'error_code' => 'deepseek_service_unavailable',
11735 'provider' => 'deepseek'
11736 ];
11737 }
11738
11739 // Generic error fallback — leak-safe helper (see plan 5da59a / 1d3b0f).
11740 return [
11741 'error' => $this->mxchat_friendly_chat_error($status_code, $error_message, 'DeepSeek', $selected_model),
11742 'error_code' => 'deepseek_api_error',
11743 'provider' => 'deepseek',
11744 'status_code' => $status_code
11745 ];
11746 }
11747
11748 $response_body = wp_remote_retrieve_body($response);
11749 $decoded_response = json_decode($response_body, true);
11750
11751 if (isset($decoded_response['choices'][0]['message']['content'])) {
11752 $text = trim($decoded_response['choices'][0]['message']['content']);
11753 if ($text !== '') {
11754 return $text;
11755 }
11756 return $this->mxchat_empty_completion_error($decoded_response, 'DeepSeek');
11757 } else {
11758 //error_log('DeepSeek API Response Format Error: ' . print_r($decoded_response, true));
11759 return [
11760 'error' => esc_html__('Unexpected response format from DeepSeek.', 'mxchat'),
11761 'error_code' => 'deepseek_response_format_error',
11762 'provider' => 'deepseek'
11763 ];
11764 }
11765 } catch (Exception $e) {
11766 //error_log('DeepSeek Exception: ' . $e->getMessage());
11767 return [
11768 'error' => esc_html__('System error when processing DeepSeek request: ', 'mxchat') . esc_html($e->getMessage()),
11769 'error_code' => 'deepseek_exception',
11770 'provider' => 'deepseek'
11771 ];
11772 }
11773 }
11774 private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content, $session_id = '') {
11775 // Read-time remap: gemini-3-pro-preview was shut down March 9, 2026.
11776 // Auto-rescue existing installs whose saved model is the dead ID.
11777 if ($selected_model === 'gemini-3-pro-preview') {
11778 $selected_model = 'gemini-3.1-pro-preview';
11779 }
11780 // Get bot ID from session or request
11781 $bot_id = $this->get_current_bot_id($session_id);
11782
11783 // Get system prompt instructions using centralized function
11784 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11785
11786 // Add system prompt to relevant content
11787 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
11788
11789 // Format messages for Gemini API
11790 $formatted_messages = [];
11791
11792 // Add system message as the first user message with role prefix
11793 // Note: Gemini doesn't have a dedicated system role, so we use a prefixed user message
11794 $formatted_messages[] = [
11795 'role' => 'user',
11796 'parts' => [
11797 ['text' => "[System Instructions] " . $content_with_instructions]
11798 ]
11799 ];
11800
11801 // Add model response to acknowledge system instructions
11802 $formatted_messages[] = [
11803 'role' => 'model',
11804 'parts' => [
11805 ['text' => "I understand and will follow these instructions."]
11806 ]
11807 ];
11808
11809 // Process the rest of the conversation history
11810 $current_role = null;
11811 $current_parts = [];
11812
11813 foreach ($conversation_history as $message) {
11814 // Skip the first system message as we already handled it
11815 if ($message['role'] === 'system') {
11816 continue;
11817 }
11818
11819 // Map roles to Gemini format
11820 $gemini_role = '';
11821 if ($message['role'] === 'user') {
11822 $gemini_role = 'user';
11823 } else if (in_array($message['role'], ['assistant', 'bot', 'agent'])) {
11824 $gemini_role = 'model';
11825 } else {
11826 // Skip unsupported roles
11827 continue;
11828 }
11829
11830 // If we have a new role, add the previous message
11831 if ($current_role !== null && $current_role !== $gemini_role && !empty($current_parts)) {
11832 $formatted_messages[] = [
11833 'role' => $current_role,
11834 'parts' => $current_parts
11835 ];
11836 $current_parts = [];
11837 }
11838
11839 // Set current role and add text to parts
11840 $current_role = $gemini_role;
11841 $current_parts[] = ['text' => $message['content']];
11842 }
11843
11844 // Add the last message if there's content
11845 if ($current_role !== null && !empty($current_parts)) {
11846 $formatted_messages[] = [
11847 'role' => $current_role,
11848 'parts' => $current_parts
11849 ];
11850 }
11851
11852 // Built-in Web Search grounding for Gemini (plan 46b9ea).
11853 // The enable_web_search toggle historically routed ONLY to OpenAI's web_search
11854 // tool; for a Gemini chat model it was a silent no-op. Gemini grounds natively
11855 // (and free) via the Google Search tool, so when the toggle is on we attach it
11856 // here on the PLAIN dispatch path. The function-calling loop (mxchat_fc_loop_gemini)
11857 // is a SEPARATE path reached only when AI Tools are active, so grounding here
11858 // never double-fires with function calling.
11859 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
11860 // Gemini ids that do NOT support Google Search grounding (none today — every
11861 // shipped chat model is 2.x/3.x and grounds natively). Kept as the explicit
11862 // opt-out list mirroring the OpenAI $unsupported_web_search_models pattern.
11863 $gemini_unsupported_grounding = array();
11864 $grounding_active = $web_search_enabled && !in_array($selected_model, $gemini_unsupported_grounding, true);
11865
11866 // Build the request body
11867 $request_payload = [
11868 'contents' => $formatted_messages,
11869 'generationConfig' => [
11870 'temperature' => 0.7,
11871 'topP' => 0.95,
11872 'topK' => 40,
11873 'maxOutputTokens' => 8192,
11874 ],
11875 'safetySettings' => [
11876 [
11877 'category' => 'HARM_CATEGORY_HARASSMENT',
11878 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
11879 ],
11880 [
11881 'category' => 'HARM_CATEGORY_HATE_SPEECH',
11882 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
11883 ],
11884 [
11885 'category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
11886 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
11887 ],
11888 [
11889 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT',
11890 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
11891 ]
11892 ]
11893 ];
11894
11895 if ($grounding_active) {
11896 // Gemini 1.5 used the older google_search_retrieval shape; 2.0+ uses the
11897 // bare google_search tool. Branch by model family so a future 1.5 id still
11898 // grounds (no 1.5 ships today, so this resolves to google_search). The empty
11899 // tool config must serialize as a JSON object {}, not an array [].
11900 if (strpos($selected_model, 'gemini-1.5') !== false) {
11901 $request_payload['tools'] = [ ['google_search_retrieval' => new \stdClass()] ];
11902 } else {
11903 $request_payload['tools'] = [ ['google_search' => new \stdClass()] ];
11904 }
11905 }
11906
11907 $body = json_encode($request_payload);
11908
11909 // Prepare the API endpoint
11910 // Use v1beta for preview models (Gemini 3, experimental), v1 for stable models.
11911 // Grounding (the google_search tool) is a v1beta feature, so force v1beta whenever
11912 // it's active — otherwise a stable model on v1 would silently drop the tool.
11913 $api_version = ($grounding_active || strpos($selected_model, 'preview') !== false || strpos($selected_model, 'exp') !== false) ? 'v1beta' : 'v1';
11914 $api_endpoint = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
11915
11916 // Set up the API request
11917 $args = [
11918 'body' => $body,
11919 'headers' => [
11920 'Content-Type' => 'application/json',
11921 ],
11922 'timeout' => 60,
11923 'redirection' => 5,
11924 'blocking' => true,
11925 'httpversion' => '1.0',
11926 'sslverify' => true,
11927 ];
11928
11929 // Make the API request
11930 $response = $this->mxchat_provider_call_with_retry($api_endpoint, $args, 'gemini');
11931
11932 // Process the response
11933 if (is_wp_error($response)) {
11934 // plan b13282: route the transport-error string through the leak-safe helper
11935 // (admin-actionable, generic for visitors) instead of echoing the raw WP HTTP
11936 // error. http_code 0 = no HTTP response, so the helper uses the generic branch.
11937 return $this->mxchat_friendly_chat_error(0, $response->get_error_message(), 'Gemini', $selected_model);
11938 }
11939
11940 $response_body = json_decode(wp_remote_retrieve_body($response), true);
11941
11942 // Handle potential errors in the response. Gemini surfaces errors as a
11943 // 200/non-200 body with an `error` envelope; route the user-facing text
11944 // through the leak-safe helper (admin-actionable, no visitor leak) rather
11945 // than echoing the raw provider message. plan 5da59a.
11946 if (isset($response_body['error'])) {
11947 //error_log('Gemini API Error: ' . json_encode($response_body['error']));
11948 $gemini_error_message = isset($response_body['error']['message'])
11949 ? $response_body['error']['message']
11950 : 'Unknown error';
11951 $gemini_http_code = wp_remote_retrieve_response_code($response);
11952 return $this->mxchat_friendly_chat_error($gemini_http_code, $gemini_error_message, 'Gemini', $selected_model);
11953 }
11954
11955 // Extract the response text
11956 if (isset($response_body['candidates'][0]['content']['parts'][0]['text'])) {
11957 $text = trim($response_body['candidates'][0]['content']['parts'][0]['text']);
11958 if ($text !== '') {
11959 return $text;
11960 }
11961 return $this->mxchat_empty_completion_error($response_body, 'Gemini');
11962 } else {
11963 //error_log('Unexpected Gemini API response format: ' . json_encode($response_body));
11964 return "Sorry, I couldn't process that request. The response format was unexpected.";
11965 }
11966 }
11967
11968
11969 public function test_streaming_request() {
11970 $options = get_option('mxchat_options', []);
11971 $model = $options['model'] ?? 'gpt-5.6-sol';
11972
11973 // Detect provider from model prefix
11974 $provider = strtolower(explode('-', $model)[0]);
11975
11976 $sample_prompt = 'Hello! Can you stream this response back to me?';
11977 $messages = [['role' => 'user', 'content' => $sample_prompt]];
11978 $headers = [];
11979 $body = [];
11980 $url = '';
11981 $api_key = '';
11982
11983 switch ($provider) {
11984 case 'gpt':
11985 case 'o1':
11986 $api_key = $options['api_key'] ?? '';
11987 if (empty($api_key)) return '❌ Missing API key for OpenAI';
11988 $url = 'https://api.openai.com/v1/chat/completions';
11989 $headers = [
11990 'Content-Type: application/json',
11991 'Authorization: Bearer ' . $api_key
11992 ];
11993 $body = [
11994 'model' => $model,
11995 'messages' => $messages,
11996 'stream' => true
11997 ];
11998 break;
11999
12000 case 'claude':
12001 $api_key = $options['claude_api_key'] ?? '';
12002 if (empty($api_key)) return '❌ Missing API key for Claude';
12003 $url = 'https://api.anthropic.com/v1/messages';
12004 $headers = [
12005 'Content-Type: application/json',
12006 'x-api-key: ' . $api_key,
12007 'anthropic-version: 2023-06-01'
12008 ];
12009 $body = [
12010 'model' => $model,
12011 'messages' => $messages,
12012 'max_tokens' => 100,
12013 'stream' => true
12014 ];
12015 break;
12016
12017 case 'grok':
12018 $api_key = $options['xai_api_key'] ?? '';
12019 if (empty($api_key)) return '❌ Missing API key for X.AI';
12020 $url = 'https://api.x.ai/v1/chat/completions';
12021 $headers = [
12022 'Content-Type: application/json',
12023 'Authorization: Bearer ' . $api_key
12024 ];
12025 $body = [
12026 'model' => $model,
12027 'messages' => $messages,
12028 'stream' => true
12029 ];
12030 break;
12031
12032 case 'deepseek':
12033 if (empty($deepseek_api_key)) {
12034 $error_response = [
12035 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
12036 'error_code' => 'missing_deepseek_api_key'
12037 ];
12038 if ($testing_data !== null) {
12039 $error_response['testing_data'] = $testing_data;
12040 }
12041 return $error_response;
12042 }
12043 if ($streaming) {
12044 return $this->mxchat_generate_response_deepseek_stream(
12045 $selected_model,
12046 $deepseek_api_key,
12047 $conversation_history,
12048 $relevant_content,
12049 $session_id,
12050 $testing_data // Pass testing data
12051 );
12052 } else {
12053 $response = $this->mxchat_generate_response_deepseek(
12054 $selected_model,
12055 $deepseek_api_key,
12056 $conversation_history,
12057 $relevant_content,
12058 $session_id
12059 );
12060 }
12061 break;
12062
12063 case 'gemini':
12064 $api_key = $options['gemini_api_key'] ?? '';
12065 if (empty($api_key)) return '❌ Missing API key for Gemini';
12066 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':streamGenerateContent?key=' . $api_key;
12067 $headers = ['Content-Type: application/json'];
12068 $body = [
12069 'contents' => [['role' => 'user', 'parts' => [['text' => $sample_prompt]]]],
12070 'generationConfig' => ['temperature' => 0.7]
12071 ];
12072 break;
12073
12074 default:
12075 return '❌ Unsupported provider: ' . $provider;
12076 }
12077
12078 // Do the actual streaming test
12079 $ch = curl_init($url);
12080 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
12081 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
12082 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
12083 curl_setopt($ch, CURLOPT_TIMEOUT, 15);
12084 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
12085
12086 $response = curl_exec($ch);
12087 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
12088 $error = curl_error($ch);
12089 curl_close($ch);
12090
12091 if ($error) return "❌ cURL error: $error";
12092 if ($http_code !== 200) {
12093 $error_message = json_decode($response, true)['error']['message'] ?? 'Unknown';
12094 return "❌ HTTP $http_code: $error_message";
12095 }
12096
12097 return true;
12098 }
12099
12100 public function mxchat_dismiss_pre_chat_message() {
12101 // Get and sanitize the user identifier
12102 $user_id = $this->mxchat_get_user_identifier();
12103 $user_id = sanitize_key($user_id);
12104
12105 // Set a transient to track that the user has dismissed the pre-chat message
12106 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
12107 set_transient($transient_key, true, DAY_IN_SECONDS);
12108
12109 wp_send_json_success();
12110 }
12111
12112 public function mxchat_check_pre_chat_message_status() {
12113 // Get and sanitize the user identifier
12114 $user_id = $this->mxchat_get_user_identifier();
12115 $user_id = sanitize_key($user_id);
12116
12117 // Check if the transient exists (i.e., if the message was dismissed)
12118 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
12119 $dismissed = get_transient($transient_key);
12120
12121 // Log the result to see if it's being set correctly
12122 //error_log("Check pre-chat message dismissed for $user_id: " . ($dismissed ? 'Yes' : 'No'));
12123
12124 if ($dismissed) {
12125 wp_send_json_success(['dismissed' => true]);
12126 } else {
12127 wp_send_json_success(['dismissed' => false]);
12128 }
12129
12130 wp_die();
12131 }
12132
12133 /**
12134 * Keyword leg for hybrid retrieval (plan-38ffa1): ranked keyword query over
12135 * the WP-DB knowledge table. FULLTEXT when the index is available, LIKE on
12136 * the top query terms otherwise (capability detected once and cached by
12137 * MxChat_Utils::mxchat_hybrid_detect_capability). Respects the same bot
12138 * scoping as the vector query ($bot_filter) and the same role-restriction
12139 * access rules as vector candidates.
12140 *
12141 * @return array[] Ranked hits: [id, source_url, role_restriction, has_access]
12142 */
12143 private function mxchat_hybrid_keyword_search($user_query, $system_prompt_table, $bot_filter, $knowledge_manager) {
12144 global $wpdb;
12145
12146 $capability = get_option('mxchat_hybrid_keyword_capability', '');
12147 if (!in_array($capability, array('fulltext', 'like'), true)) {
12148 $capability = MxChat_Utils::mxchat_hybrid_detect_capability();
12149 }
12150
12151 $limit = 20;
12152 $rows = array();
12153
12154 if ($capability === 'fulltext') {
12155 $rows = $wpdb->get_results($wpdb->prepare(
12156 "SELECT id, source_url, role_restriction,
12157 MATCH(article_content) AGAINST (%s IN NATURAL LANGUAGE MODE) AS kw_score
12158 FROM {$system_prompt_table}
12159 WHERE MATCH(article_content) AGAINST (%s IN NATURAL LANGUAGE MODE) {$bot_filter}
12160 ORDER BY kw_score DESC, id ASC
12161 LIMIT %d",
12162 $user_query,
12163 $user_query,
12164 $limit
12165 ));
12166 } else {
12167 // LIKE fallback: length-weighted term scoring. Longer, rarer tokens
12168 // (the SKU, the error code) must outrank ubiquitous short words — an
12169 // equal-weight score lets "the" + one common word tie with the exact
12170 // token and the tie-break pick the wrong row (caught by the 38ffa1
12171 // verification harness). Stopwords are dropped outright.
12172 $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');
12173 $terms = preg_split('/[^\p{L}\p{N}_-]+/u', (string) $user_query, -1, PREG_SPLIT_NO_EMPTY);
12174 $terms = array_filter($terms, function ($t) use ($stopwords) {
12175 return mb_strlen($t) >= 3 && !in_array(mb_strtolower($t), $stopwords, true);
12176 });
12177 $terms = array_values(array_unique(array_map('mb_strtolower', $terms)));
12178 usort($terms, function ($a, $b) {
12179 return mb_strlen($b) <=> mb_strlen($a);
12180 });
12181 $terms = array_slice($terms, 0, 5);
12182 if (empty($terms)) {
12183 return array();
12184 }
12185
12186 $score_parts = array();
12187 $where_parts = array();
12188 $like_params = array();
12189 foreach ($terms as $term) {
12190 $score_parts[] = '((article_content LIKE %s) * ' . (int) mb_strlen($term) . ')';
12191 $where_parts[] = 'article_content LIKE %s';
12192 $like_params[] = '%' . $wpdb->esc_like($term) . '%';
12193 }
12194 $sql = "SELECT id, source_url, role_restriction, ("
12195 . implode(' + ', $score_parts)
12196 . ") AS kw_score FROM {$system_prompt_table} WHERE ("
12197 . implode(' OR ', $where_parts)
12198 . ") {$bot_filter} ORDER BY kw_score DESC, id ASC LIMIT %d";
12199 $rows = $wpdb->get_results($wpdb->prepare(
12200 $sql,
12201 array_merge($like_params, $like_params, array($limit))
12202 ));
12203 }
12204
12205 $hits = array();
12206 foreach ((array) $rows as $row) {
12207 $role_restriction = $row->role_restriction ?? 'public';
12208 if (!$knowledge_manager->mxchat_user_has_content_access($role_restriction)) {
12209 continue;
12210 }
12211 $hits[] = array(
12212 'id' => (int) $row->id,
12213 'source_url' => $row->source_url ?? '',
12214 'role_restriction' => $role_restriction,
12215 'has_access' => true,
12216 );
12217 }
12218 return $hits;
12219 }
12220
12221 private function mxchat_calculate_cosine_similarity($vectorA, $vectorB) {
12222 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
12223 return 0;
12224 }
12225
12226 $dotProduct = array_sum(array_map(function ($a, $b) {
12227 return $a * $b;
12228 }, $vectorA, $vectorB));
12229 $normA = sqrt(array_sum(array_map(function ($a) {
12230 return $a * $a;
12231 }, $vectorA)));
12232 $normB = sqrt(array_sum(array_map(function ($b) {
12233 return $b * $b;
12234 }, $vectorB)));
12235
12236 if ($normA == 0 || $normB == 0) {
12237 return 0;
12238 }
12239
12240 return $dotProduct / ($normA * $normB);
12241 }
12242
12243
12244 public function mxchat_enqueue_scripts_styles($force = false) {
12245 // Idempotency guard (plan-915355): the smart-asset-loading safety net in
12246 // render_chatbot_shortcode() may invoke this method a second time (or on
12247 // every shortcode render). Run the body at most once per request so the
12248 // nonce, dynamic-settings merge, delayed transient write, and wp_footer
12249 // loader action never happen twice.
12250 static $did_run = false;
12251 if ($did_run) {
12252 return;
12253 }
12254
12255 // Smart asset loading gate (plan-915355, opt-in, default OFF — toggle in
12256 // MxChat → Settings → Optimization → Script Loading). When enabled and the
12257 // shared display decision says the widget won't render on this request,
12258 // skip all front-end assets. $force (the shortcode safety net) bypasses
12259 // the gate because at that point the widget IS rendering. Note: bail
12260 // WITHOUT setting $did_run, so a later forced call can still enqueue.
12261 if (!$force
12262 && class_exists('MxChat_Public')
12263 && MxChat_Public::is_smart_asset_loading_enabled()
12264 && !MxChat_Public::should_load_assets()) {
12265 return;
12266 }
12267
12268 $did_run = true;
12269
12270 // Fetch options from the database first to check loading strategy
12271 $this->options = get_option('mxchat_options');
12272 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
12273
12274 // Always enqueue CSS immediately
12275 wp_enqueue_style(
12276 'mxchat-chat-css',
12277 plugin_dir_url(__FILE__) . '../css/chat-style.css',
12278 array(),
12279 MXCHAT_VERSION
12280 );
12281
12282 // Handle script loading based on strategy
12283 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
12284 // Enqueue the script normally
12285 wp_enqueue_script(
12286 'mxchat-chat-js',
12287 plugin_dir_url(__FILE__) . '../js/chat-script.js',
12288 array('jquery'),
12289 MXCHAT_VERSION,
12290 true
12291 );
12292
12293 // Add defer attribute if strategy is 'defer'
12294 if ($loading_strategy === 'defer') {
12295 wp_script_add_data('mxchat-chat-js', 'strategy', 'defer');
12296 }
12297 } else {
12298 // For delay or interaction-based loading, we'll use a custom loader
12299 // Don't enqueue the main script - we'll load it dynamically
12300 add_action('wp_footer', array($this, 'mxchat_output_delayed_script_loader'), 99);
12301 }
12302
12303 $prompts_options = get_option('mxchat_prompts_options', array());
12304
12305 // Check if AI theme is active - if so, skip inline colors in JavaScript
12306 $theme_options = get_option('mxchat_theme_options', array());
12307 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
12308 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
12309 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
12310
12311 // Prepare settings for JavaScript
12312 $style_settings = array(
12313 'ajax_url' => admin_url('admin-ajax.php'),
12314 // The chat-send nonce is now fetched per-request from /wp-json/mxchat/v1/nonce
12315 // (plan-6a68c9) so it never sits in cached HTML. We still emit a nonce here
12316 // as a one-shot fallback for the first interaction on a fresh page load
12317 // (so the very first chat-send doesn't need to wait for a REST round-trip),
12318 // but the widget refetches before each subsequent send.
12319 'nonce' => wp_create_nonce('mxchat_chat_send'),
12320 'rest_url' => esc_url_raw(trailingslashit(rest_url('mxchat/v1'))),
12321 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
12322 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
12323 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
12324 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
12325 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
12326 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
12327 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
12328 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
12329 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
12330 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
12331 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
12332 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
12333 'icon_color' => $this->options['icon_color'] ?? '#fff',
12334 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
12335 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
12336 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
12337 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
12338 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
12339 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
12340 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
12341 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
12342 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
12343 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off', // FIXED
12344 'initial_email_state' => null, // Also fixed this undefined variable
12345 'skip_email_check' => true,
12346 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
12347 'skip_inline_colors' => $skip_inline_colors,
12348 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array(),
12349 );
12350
12351 // Behavior gates + labels (model, streaming, rate-limit copy, toolbar,
12352 // print/transcript, satisfaction rating) come from the shared
12353 // dynamic-settings method so this inline payload and the first-open
12354 // refresh endpoint can never drift (plan-32db95).
12355 $style_settings = array_merge($style_settings, $this->get_dynamic_widget_settings());
12356
12357 // For normal/defer loading, use wp_localize_script.
12358 // For delayed loading, nothing is localized or stored here: the delayed
12359 // loader (mxchat_output_delayed_script_loader) rebuilds the full settings
12360 // array inline from options and never reads any stored copy.
12361 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
12362 wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
12363 } else {
12364 // Late-render fallback (plan-915355): when the shortcode safety net
12365 // forces this method during/after wp_footer (footer widget areas, late
12366 // builder regions), the wp_footer:99 loader action registered above may
12367 // already be past its slot. Emit the loader inline right now; its
12368 // emitted-once guard prevents double output if :99 still fires.
12369 if ($force && did_action('wp_footer')) {
12370 $this->mxchat_output_delayed_script_loader();
12371 }
12372 }
12373 }
12374
12375 /**
12376 * Output the delayed script loader for performance optimization
12377 */
12378 public function mxchat_output_delayed_script_loader() {
12379 // Emitted-once guard (plan-915355): this can now be reached both via the
12380 // wp_footer:99 action and via the late-render inline fallback in
12381 // mxchat_enqueue_scripts_styles(). The loader must print exactly once.
12382 static $emitted = false;
12383 if ($emitted) {
12384 return;
12385 }
12386 $emitted = true;
12387
12388 $this->options = get_option('mxchat_options');
12389 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
12390 $script_url = plugin_dir_url(__FILE__) . '../js/chat-script.js?ver=' . MXCHAT_VERSION;
12391
12392 // Get the stored settings
12393 $prompts_options = get_option('mxchat_prompts_options', array());
12394 $theme_options = get_option('mxchat_theme_options', array());
12395 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
12396 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
12397 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
12398
12399 $style_settings = array(
12400 'ajax_url' => admin_url('admin-ajax.php'),
12401 // Per-request nonce — see plan-6a68c9; widget fetches via /wp-json/mxchat/v1/nonce
12402 // before each send. This inline value is a one-shot fallback for the first interaction.
12403 'nonce' => wp_create_nonce('mxchat_chat_send'),
12404 'rest_url' => esc_url_raw(trailingslashit(rest_url('mxchat/v1'))),
12405 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
12406 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
12407 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
12408 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
12409 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
12410 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
12411 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
12412 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
12413 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
12414 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
12415 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
12416 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
12417 'icon_color' => $this->options['icon_color'] ?? '#fff',
12418 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
12419 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
12420 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
12421 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
12422 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
12423 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
12424 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
12425 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
12426 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
12427 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off',
12428 'initial_email_state' => null,
12429 'skip_email_check' => true,
12430 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
12431 'skip_inline_colors' => $skip_inline_colors,
12432 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array(),
12433 );
12434
12435 // Behavior gates + labels (model, streaming, rate-limit copy, toolbar,
12436 // print/transcript, satisfaction rating) come from the shared
12437 // dynamic-settings method so this inline payload and the first-open
12438 // refresh endpoint can never drift (plan-32db95).
12439 $style_settings = array_merge($style_settings, $this->get_dynamic_widget_settings());
12440
12441 // Determine delay time based on strategy
12442 $delay_ms = 0;
12443 switch ($loading_strategy) {
12444 case 'delay_1s':
12445 $delay_ms = 1000;
12446 break;
12447 case 'delay_3s':
12448 $delay_ms = 3000;
12449 break;
12450 case 'delay_5s':
12451 $delay_ms = 5000;
12452 break;
12453 }
12454
12455 ?>
12456 <script type="text/javascript">
12457 (function() {
12458 var mxchatLoaded = false;
12459 var mxchatChat = <?php echo wp_json_encode($style_settings); ?>;
12460 window.mxchatChat = mxchatChat;
12461
12462 function loadMxChatScript() {
12463 if (mxchatLoaded) return;
12464 mxchatLoaded = true;
12465
12466 function appendChatScript() {
12467 var script = document.createElement('script');
12468 script.src = <?php echo wp_json_encode($script_url); ?>;
12469 script.type = 'text/javascript';
12470 document.body.appendChild(script);
12471 }
12472
12473 if (typeof jQuery !== 'undefined') {
12474 appendChatScript();
12475 } else {
12476 var jq = document.createElement('script');
12477 jq.src = <?php echo wp_json_encode(includes_url('js/jquery/jquery.min.js')); ?>;
12478 jq.onload = appendChatScript;
12479 document.body.appendChild(jq);
12480 }
12481 }
12482
12483 <?php if ($loading_strategy === 'on_interaction'): ?>
12484 // Load on user interaction
12485 var events = ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'];
12486 events.forEach(function(evt) {
12487 window.addEventListener(evt, loadMxChatScript, {once: true, passive: true});
12488 });
12489 // Fallback: load after 8 seconds if no interaction
12490 setTimeout(loadMxChatScript, 8000);
12491 <?php else: ?>
12492 // Load after specified delay
12493 setTimeout(loadMxChatScript, <?php echo intval($delay_ms); ?>);
12494 <?php endif; ?>
12495 })();
12496 </script>
12497 <?php
12498 }
12499
12500 /**
12501 * Setup the cron jobs for rate limits with guard against multiple calls
12502 */
12503 public function setup_rate_limit_cron_jobs() {
12504 // Add a guard to prevent multiple rapid calls
12505 $last_setup = get_transient('mxchat_cron_setup_guard');
12506 if ($last_setup && (time() - $last_setup) < 60) {
12507 // Don't run again if we ran less than 60 seconds ago
12508 return;
12509 }
12510
12511 // Set the guard
12512 set_transient('mxchat_cron_setup_guard', time(), 300); // 5 minutes
12513
12514 try {
12515 // First, check if WordPress cron is disabled
12516 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
12517 //error_log('MxChat: WordPress cron is disabled (DISABLE_WP_CRON = true), using fallback system');
12518 $this->setup_fallback_rate_limit_system();
12519 return;
12520 }
12521
12522 // Check if cron is already scheduled - if so, don't mess with it
12523 if (wp_next_scheduled('mxchat_reset_rate_limits')) {
12524 //error_log('MxChat: Rate limit cron already scheduled, skipping setup');
12525 return;
12526 }
12527
12528 // Clear any orphaned hooks (but don't loop indefinitely)
12529 $hooks_to_clear = [
12530 'mxchat_reset_rate_limits',
12531 'mxchat_reset_hourly_rate_limits',
12532 'mxchat_reset_daily_rate_limits',
12533 'mxchat_reset_weekly_rate_limits',
12534 'mxchat_reset_monthly_rate_limits'
12535 ];
12536
12537 foreach ($hooks_to_clear as $hook) {
12538 // Only clear a maximum of 3 instances to prevent infinite loops
12539 $cleared = 0;
12540 while (wp_next_scheduled($hook) && $cleared < 3) {
12541 wp_clear_scheduled_hook($hook);
12542 $cleared++;
12543 }
12544 }
12545
12546 // Small delay after clearing
12547 usleep(100000); // 0.1 seconds
12548
12549 // Try to schedule the event
12550 $initial_time = time() + 300; // Start in 5 minutes
12551 $result = wp_schedule_event($initial_time, 'hourly', 'mxchat_reset_rate_limits');
12552
12553 if ($result === false) {
12554 //error_log('MxChat: Failed to schedule cron, using fallback system');
12555 $this->setup_fallback_rate_limit_system();
12556 } else {
12557 if (defined('MXCHAT_DEV_MODE') && MXCHAT_DEV_MODE) {
12558 error_log('MxChat: rate-limit reset cron event was missing and has been re-scheduled');
12559 }
12560 }
12561
12562 } catch (Exception $e) {
12563 //error_log('MxChat: Cron setup exception: ' . $e->getMessage());
12564 $this->setup_fallback_rate_limit_system();
12565 }
12566 }
12567
12568 /**
12569 * Try alternative cron scheduling methods
12570 */
12571 private function try_alternative_cron_scheduling($initial_time) {
12572 try {
12573 // Method 1: Try with current time instead of future time
12574 $result1 = wp_schedule_event(time(), 'hourly', 'mxchat_reset_rate_limits');
12575 if ($result1 !== false) {
12576 //error_log('MxChat: Alternative method 1 (current time) succeeded');
12577 return true;
12578 }
12579
12580 // Method 2: Try with a different interval
12581 $result2 = wp_schedule_event($initial_time, 'daily', 'mxchat_reset_rate_limits');
12582 if ($result2 !== false) {
12583 //error_log('MxChat: Alternative method 2 (daily interval) succeeded');
12584 return true;
12585 }
12586
12587 // Method 3: Try wp_schedule_single_event first, then recurring
12588 $result3 = wp_schedule_single_event($initial_time, 'mxchat_reset_rate_limits');
12589 if ($result3 !== false) {
12590 //error_log('MxChat: Alternative method 3 (single event) succeeded');
12591 // Schedule the next one manually in the handler
12592 return true;
12593 }
12594
12595 return false;
12596
12597 } catch (Exception $e) {
12598 //error_log('MxChat: Alternative cron scheduling exception: ' . $e->getMessage());
12599 return false;
12600 }
12601 }
12602
12603 /**
12604 * Enhanced fallback rate limit system
12605 */
12606 private function setup_fallback_rate_limit_system() {
12607 // Idempotence matters here: with setup_rate_limit_cron_jobs() hooked to
12608 // admin_init, a DISABLE_WP_CRON site reaches this on every guard pass.
12609 // Unconditionally rewriting mxchat_next_rate_limit_check to now+3600 would
12610 // slide the deadline forward forever and the fallback reset would never
12611 // fire. Only initialize the deadline on a genuine transition into fallback
12612 // mode (or if it's somehow missing).
12613 $already_active = get_option('mxchat_use_fallback_rate_limits', false);
12614
12615 // Set a flag to use database-based rate limit cleanup
12616 update_option('mxchat_use_fallback_rate_limits', true);
12617
12618 // Schedule a one-time check to happen on the next plugin load
12619 if (!$already_active || !get_option('mxchat_next_rate_limit_check', 0)) {
12620 update_option('mxchat_next_rate_limit_check', time() + 3600);
12621 }
12622
12623 // Also set up a more frequent fallback check (every 4 hours)
12624 update_option('mxchat_fallback_check_interval', 4 * 3600);
12625
12626 //error_log('MxChat: Fallback rate limit system activated');
12627 }
12628
12629 /**
12630 * Enhanced fallback check method
12631 * NOTE: mxchat_check_fallback_rate_limits() in mxchat-basic.php is a second
12632 * implementation of this same check — if either changes, change both.
12633 */
12634 public function check_fallback_rate_limits() {
12635 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
12636
12637 if (!$use_fallback) {
12638 return; // Regular cron is working
12639 }
12640
12641 $next_check = get_option('mxchat_next_rate_limit_check', 0);
12642 $check_interval = get_option('mxchat_fallback_check_interval', 3600);
12643
12644 if (time() >= $next_check) {
12645 //error_log('MxChat: Running fallback rate limit cleanup');
12646 $this->mxchat_reset_rate_limits();
12647
12648 // Schedule next check
12649 update_option('mxchat_next_rate_limit_check', time() + $check_interval);
12650 }
12651 }
12652 /**
12653 * Enhanced rate limit check that includes fallback cleanup and bot-specific rate limits
12654 */
12655 public function check_rate_limit() {
12656 // Check if we need to run fallback cleanup
12657 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
12658 $next_check = get_option('mxchat_next_rate_limit_check', 0);
12659
12660 if ($use_fallback && time() >= $next_check) {
12661 $this->mxchat_reset_rate_limits();
12662 update_option('mxchat_next_rate_limit_check', time() + 3600); // Next hour
12663 }
12664
12665 // Get bot ID from current request context
12666 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
12667
12668 // Get bot-specific options (includes rate limits if overridden)
12669 $bot_options = $this->get_bot_options($bot_id);
12670 $current_options = !empty($bot_options) ? $bot_options : $this->options;
12671
12672 // Use bot-specific rate limits if available, otherwise fall back to default
12673 $rate_limits_source = isset($current_options['rate_limits']) ? $current_options['rate_limits'] : get_option('mxchat_options', [])['rate_limits'] ?? [];
12674
12675 // -------------------------------------------------------------------
12676 // Whole-chatbot global cap (independent of role). Evaluated FIRST so
12677 // it acts as a hard ceiling across all users + all roles. Default is
12678 // 'unlimited' so existing installs are unchanged. Counter key drops
12679 // both <role> and <user_id> segments — single pool per bot.
12680 // -------------------------------------------------------------------
12681 $global_cfg = isset($current_options['rate_limits_global']) && is_array($current_options['rate_limits_global'])
12682 ? $current_options['rate_limits_global']
12683 : (isset(get_option('mxchat_options', [])['rate_limits_global']) ? get_option('mxchat_options', [])['rate_limits_global'] : []);
12684 $global_limit_raw = isset($global_cfg['limit']) ? (string) $global_cfg['limit'] : 'unlimited';
12685 $global_timeframe = isset($global_cfg['timeframe']) ? (string) $global_cfg['timeframe'] : 'daily';
12686 if ($global_limit_raw !== '' && $global_limit_raw !== 'unlimited' && (int) $global_limit_raw >= 1) {
12687 $bot_id_for_global = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
12688 $safe_bot_global = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id_for_global);
12689 $global_option = 'mxchat_chat_limit_' . $safe_bot_global . '_global';
12690 $global_data = get_option($global_option, ['count' => 0, 'timestamp' => time()]);
12691 if ((int) $global_data['count'] === 0) {
12692 $global_data['timestamp'] = time();
12693 update_option($global_option, $global_data);
12694 }
12695 $now = time();
12696 $ts = (int) $global_data['timestamp'];
12697 $reset = false;
12698 switch ($global_timeframe) {
12699 case 'hourly': $reset = ($now - $ts) >= 3600; break;
12700 case 'daily': $reset = ($now - $ts) >= 86400; break;
12701 case 'weekly': $reset = ($now - $ts) >= 604800; break;
12702 case 'monthly': $reset = ($now - $ts) >= 2592000; break;
12703 }
12704 if ($reset) {
12705 $global_data = ['count' => 0, 'timestamp' => $now];
12706 update_option($global_option, $global_data);
12707 }
12708 if ((int) $global_data['count'] >= (int) $global_limit_raw) {
12709 $global_msg = !empty($global_cfg['message'])
12710 ? $global_cfg['message']
12711 : __('This chatbot has reached its message limit. Please try again later.', 'mxchat');
12712 return [
12713 'error' => true,
12714 'message' => $this->process_rate_limit_message_html($global_msg),
12715 ];
12716 }
12717 // Reserve the slot for this request. Per-role check below also increments
12718 // its own counter — that is intentional, both ceilings apply independently.
12719 $global_data['count']++;
12720 update_option($global_option, $global_data);
12721 }
12722
12723 // Determine user role or if logged out
12724 if (is_user_logged_in()) {
12725 $user = wp_get_current_user();
12726 $user_id = $user->ID;
12727
12728 // Get the user's primary role using reset() to safely get the first element
12729 $user_roles = $user->roles;
12730
12731 // Safely get the first role regardless of array key structure
12732 if (!empty($user_roles) && is_array($user_roles)) {
12733 $role = reset($user_roles); // This safely gets the first element regardless of key
12734 } else {
12735 $role = 'subscriber'; // Default to subscriber if no role found
12736 }
12737 } else {
12738 $role = 'logged_out';
12739 // Use IP address for non-logged-in users
12740 $user_id = $this->get_client_ip();
12741 }
12742
12743 // Check if rate limits are configured for this role
12744 if (!isset($rate_limits_source[$role])) {
12745 return true; // No limit set for this role
12746 }
12747
12748 $limit = $rate_limits_source[$role]['limit'];
12749
12750 // If unlimited, return true immediately
12751 if ($limit === 'unlimited') {
12752 return true;
12753 }
12754
12755 // Get the option name for this user/role with safer naming (include bot_id for bot-specific limits)
12756 $safe_role = preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
12757 $safe_user_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $user_id);
12758 $safe_bot_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id);
12759
12760 // Include bot_id in option name so each bot has separate rate limits
12761 $option_name = 'mxchat_chat_limit_' . $safe_bot_id . '_' . $safe_role . '_' . $safe_user_id;
12762
12763 // Get the counter data
12764 $limit_data = get_option($option_name, ['count' => 0, 'timestamp' => time()]);
12765
12766 // If first request or counter reset needed, set the initial timestamp
12767 if ($limit_data['count'] === 0) {
12768 $limit_data['timestamp'] = time();
12769 update_option($option_name, $limit_data);
12770 }
12771
12772 // Get the timeframe
12773 $timeframe = isset($rate_limits_source[$role]['timeframe']) ?
12774 $rate_limits_source[$role]['timeframe'] : 'daily';
12775
12776 // Check if the counter needs to be reset based on timeframe
12777 $current_time = time();
12778 $timestamp = $limit_data['timestamp'];
12779 $should_reset = false;
12780
12781 switch ($timeframe) {
12782 case 'hourly':
12783 $should_reset = ($current_time - $timestamp) >= 3600; // 1 hour
12784 break;
12785 case 'daily':
12786 $should_reset = ($current_time - $timestamp) >= 86400; // 24 hours
12787 break;
12788 case 'weekly':
12789 $should_reset = ($current_time - $timestamp) >= 604800; // 7 days
12790 break;
12791 case 'monthly':
12792 $should_reset = ($current_time - $timestamp) >= 2592000; // 30 days
12793 break;
12794 }
12795
12796 // Reset the counter if the timeframe has passed
12797 if ($should_reset) {
12798 $limit_data = ['count' => 0, 'timestamp' => $current_time];
12799 update_option($option_name, $limit_data);
12800 }
12801
12802 // Check if user has exceeded their limit
12803 if ($limit_data['count'] >= intval($limit)) {
12804 // Get the custom message for this role
12805 $message = !empty($rate_limits_source[$role]['message'])
12806 ? $rate_limits_source[$role]['message']
12807 : __('Rate limit exceeded. Please try again later.', 'mxchat');
12808
12809 // Add timeframe information to the message if placeholders exist
12810 $timeframe_label = '';
12811 switch ($timeframe) {
12812 case 'hourly':
12813 $timeframe_label = __('hour', 'mxchat');
12814 break;
12815 case 'daily':
12816 $timeframe_label = __('day', 'mxchat');
12817 break;
12818 case 'weekly':
12819 $timeframe_label = __('week', 'mxchat');
12820 break;
12821 case 'monthly':
12822 $timeframe_label = __('month', 'mxchat');
12823 break;
12824 }
12825
12826 // Replace placeholders in the message
12827 $message = str_replace(
12828 ['{limit}', '{count}', '{remaining}', '{timeframe}'],
12829 [intval($limit), $limit_data['count'], max(0, intval($limit) - $limit_data['count']), $timeframe_label],
12830 $message
12831 );
12832
12833 // Process HTML links in the message
12834 $message = $this->process_rate_limit_message_html($message);
12835
12836 // Return error with the processed message
12837 return [
12838 'error' => true,
12839 'message' => $message
12840 ];
12841 }
12842
12843 // Increment the counter
12844 $limit_data['count']++;
12845 update_option($option_name, $limit_data);
12846
12847 return true;
12848 }
12849
12850 /**
12851 * Enhanced rate limit reset with better error handling
12852 */
12853 public function mxchat_reset_rate_limits() {
12854 try {
12855 global $wpdb;
12856 $all_options = get_option('mxchat_options', []);
12857 $current_time = time();
12858
12859 // Get rate limit options with a safer query and limit
12860 $option_names = $wpdb->get_col(
12861 $wpdb->prepare(
12862 "SELECT option_name FROM {$wpdb->options}
12863 WHERE option_name LIKE %s
12864 LIMIT 1000",
12865 'mxchat_chat_limit_%'
12866 )
12867 );
12868
12869 if (empty($option_names)) {
12870 return;
12871 }
12872
12873 $processed_count = 0;
12874 $max_processing_time = 30; // Maximum 30 seconds
12875 $start_time = time();
12876
12877 foreach ($option_names as $option_name) {
12878 // Check processing time limit
12879 if ((time() - $start_time) > $max_processing_time) {
12880 //error_log('MxChat: Rate limit reset timeout after processing ' . $processed_count . ' entries');
12881 break;
12882 }
12883
12884 // Parse the option name more safely
12885 if (!preg_match('/^mxchat_chat_limit_(.+)_(.+)$/', $option_name, $matches)) {
12886 continue;
12887 }
12888
12889 $role_and_user = $matches[1] . '_' . $matches[2];
12890 $parts = explode('_', $role_and_user);
12891
12892 if (count($parts) < 2) {
12893 continue;
12894 }
12895
12896 // Extract role (everything except the last part which is user ID)
12897 $user_id_part = array_pop($parts);
12898 $role = implode('_', $parts);
12899
12900 // Skip if role doesn't exist in our settings
12901 if (!isset($all_options['rate_limits'][$role])) {
12902 // Clean up orphaned entries
12903 delete_option($option_name);
12904 continue;
12905 }
12906
12907 $timeframe = $all_options['rate_limits'][$role]['timeframe'] ?? 'daily';
12908 $limit_data = get_option($option_name);
12909
12910 if (!$limit_data || !is_array($limit_data) || !isset($limit_data['timestamp'])) {
12911 // Clean up invalid entries
12912 delete_option($option_name);
12913 continue;
12914 }
12915
12916 $timestamp = $limit_data['timestamp'];
12917 $should_reset = false;
12918
12919 // Determine if we should reset based on the timeframe
12920 switch ($timeframe) {
12921 case 'hourly':
12922 $should_reset = ($current_time - $timestamp) >= 3600;
12923 break;
12924 case 'daily':
12925 $should_reset = ($current_time - $timestamp) >= 86400;
12926 break;
12927 case 'weekly':
12928 $should_reset = ($current_time - $timestamp) >= 604800;
12929 break;
12930 case 'monthly':
12931 $should_reset = ($current_time - $timestamp) >= 2592000;
12932 break;
12933 }
12934
12935 // Reset the counter if the timeframe has passed
12936 if ($should_reset) {
12937 delete_option($option_name);
12938 wp_cache_delete($option_name, 'options');
12939 $processed_count++;
12940 }
12941 }
12942
12943 // Clean up any orphaned cache entries
12944 wp_cache_delete('mxchat_all_chat_limits', 'options');
12945
12946 //error_log("MxChat: Rate limit reset completed. Processed {$processed_count} entries.");
12947
12948 } catch (Exception $e) {
12949 //error_log('MxChat: Rate limit reset error: ' . $e->getMessage());
12950 }
12951 }
12952
12953
12954 /**
12955 * Process HTML links in rate limit messages
12956 *
12957 * @param string $message The rate limit message
12958 * @return string The processed message with safe HTML links
12959 */
12960 private function process_rate_limit_message_html($message) {
12961 // Return original message if empty
12962 if (empty($message)) {
12963 return $message;
12964 }
12965
12966 // First, convert markdown links to HTML
12967 $message = $this->convert_markdown_links($message);
12968
12969 // Then, auto-convert any remaining plain URLs to links
12970 $message = $this->auto_link_urls($message);
12971
12972 // Allow basic HTML tags for links and formatting
12973 $allowed_tags = [
12974 'a' => [
12975 'href' => true,
12976 'target' => true,
12977 'rel' => true,
12978 'title' => true,
12979 'class' => true
12980 ],
12981 'strong' => [],
12982 'em' => [],
12983 'br' => [],
12984 'b' => [],
12985 'i' => [],
12986 'span' => ['class' => true]
12987 ];
12988
12989 // Sanitize but allow the specified HTML tags
12990 $processed_message = wp_kses($message, $allowed_tags);
12991
12992 // If wp_kses stripped everything, return the original message as plain text
12993 if (empty($processed_message) && !empty($message)) {
12994 // Strip all HTML and return plain text as fallback
12995 return wp_strip_all_tags($message);
12996 }
12997
12998 return $processed_message;
12999 }
13000
13001 /**
13002 * Convert markdown links to HTML
13003 *
13004 * @param string $text The text to process
13005 * @return string The text with markdown links converted to HTML
13006 */
13007 private function convert_markdown_links($text) {
13008 // Return original text if empty
13009 if (empty($text)) {
13010 return $text;
13011 }
13012
13013 // Pattern to match markdown links: [text](url)
13014 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
13015
13016 $processed_text = preg_replace_callback($pattern, function($matches) {
13017 $link_text = $matches[1];
13018 $url = $matches[2];
13019
13020 // Clean up any trailing punctuation from the URL
13021 $url = rtrim($url, '.,;:!?');
13022
13023 // Sanitize the link text and URL
13024 $safe_text = esc_html($link_text);
13025 $safe_url = esc_url($url);
13026
13027 // Create the HTML link
13028 return '<a href="' . $safe_url . '" target="_blank" rel="noopener noreferrer">' . $safe_text . '</a>';
13029 }, $text);
13030
13031 // If preg_replace_callback failed, return original text
13032 if ($processed_text === null) {
13033 return $text;
13034 }
13035
13036 return $processed_text;
13037 }
13038
13039 /**
13040 * Auto-convert plain URLs to clickable links
13041 *
13042 * @param string $text The text to process
13043 * @return string The text with URLs converted to links
13044 */
13045 private function auto_link_urls($text) {
13046 // Return original text if empty
13047 if (empty($text)) {
13048 return $text;
13049 }
13050
13051 // Simple pattern that avoids complex lookbehinds
13052 // This will match URLs that are not already inside href attributes or markdown links
13053 $pattern = '/(?<!href=["\'])(?<!\]\()https?:\/\/[^\s<>"\')\]]+/i';
13054
13055 $processed_text = preg_replace_callback($pattern, function($matches) {
13056 $url = $matches[0];
13057 // Clean up any trailing punctuation that might have been captured
13058 $url = rtrim($url, '.,;:!?');
13059
13060 // Add target="_blank" and rel="noopener noreferrer" for security
13061 return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
13062 }, $text);
13063
13064 // If preg_replace_callback failed, return original text
13065 if ($processed_text === null) {
13066 return $text;
13067 }
13068
13069 return $processed_text;
13070 }
13071
13072
13073 // Helper function to get client IP address
13074 private function get_client_ip() {
13075 // Check for shared internet/ISP IP
13076 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
13077 return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
13078 }
13079
13080 // Check for IPs passing through proxies
13081 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
13082 // Use the first value in the comma-separated list
13083 $forwarded_for = explode(',', sanitize_text_field($_SERVER['HTTP_X_FORWARDED_FOR']));
13084 return trim($forwarded_for[0]);
13085 }
13086
13087 if (!empty($_SERVER['REMOTE_ADDR'])) {
13088 return sanitize_text_field($_SERVER['REMOTE_ADDR']);
13089 }
13090
13091 // Fallback
13092 return 'unknown';
13093 }
13094
13095 /**
13096 * AJAX handler to get system information for testing panel
13097 */
13098 /**
13099 * AJAX handler to get system information for testing panel
13100 */
13101 public function mxchat_get_system_info() {
13102 // Verify nonce for security
13103 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
13104 wp_send_json_error(['message' => 'Invalid nonce']);
13105 return;
13106 }
13107
13108 // Only allow admin users
13109 if (!current_user_can('administrator')) {
13110 wp_send_json_error(['message' => 'Unauthorized']);
13111 return;
13112 }
13113
13114 // Get system prompt from options
13115 $system_prompt = isset($this->options['system_prompt_instructions'])
13116 ? $this->options['system_prompt_instructions']
13117 : 'No system prompt configured';
13118
13119 // Get selected model
13120 $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.6-sol';
13121
13122 // Check if OpenRouter is being used
13123 $is_openrouter = ($selected_model === 'openrouter');
13124 $openrouter_model = '';
13125
13126 if ($is_openrouter) {
13127 // Get the actual OpenRouter model that's selected
13128 $openrouter_model = isset($this->options['openrouter_selected_model'])
13129 ? $this->options['openrouter_selected_model']
13130 : 'No OpenRouter model selected';
13131
13132 // Update selected_model display to show both
13133 $selected_model = 'OpenRouter: ' . $openrouter_model;
13134 }
13135
13136 // Get API key status (just check if they exist, don't expose the keys)
13137 $api_status = [];
13138 $api_status['openai'] = !empty($this->options['api_key']);
13139 $api_status['claude'] = !empty($this->options['claude_api_key']);
13140 $api_status['gemini'] = !empty($this->options['gemini_api_key']);
13141 $api_status['xai'] = !empty($this->options['xai_api_key']);
13142 $api_status['deepseek'] = !empty($this->options['deepseek_api_key']);
13143 $api_status['openrouter'] = !empty($this->options['openrouter_api_key']);
13144
13145 wp_send_json_success([
13146 'system_prompt' => $system_prompt,
13147 'selected_model' => $selected_model,
13148 'is_openrouter' => $is_openrouter,
13149 'openrouter_model' => $openrouter_model,
13150 'api_status' => $api_status
13151 ]);
13152 }
13153
13154 /**
13155 * AJAX handler to get similarity threshold
13156 */
13157 public function mxchat_get_similarity_threshold() {
13158 // Verify nonce for security
13159 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
13160 wp_send_json_error(['message' => 'Invalid nonce']);
13161 return;
13162 }
13163
13164 // Only allow admin users
13165 if (!current_user_can('administrator')) {
13166 wp_send_json_error(['message' => 'Unauthorized']);
13167 return;
13168 }
13169
13170 // Get similarity threshold from main options (default 35%)
13171 $similarity_threshold = isset($this->options['similarity_threshold'])
13172 ? ((int) $this->options['similarity_threshold']) / 100
13173 : 0.35;
13174
13175 wp_send_json_success([
13176 'threshold' => $similarity_threshold,
13177 'threshold_percentage' => ($similarity_threshold * 100) . '%'
13178 ]);
13179 }
13180
13181 /**
13182 * AJAX handler to get knowledge base status
13183 */
13184 public function mxchat_get_kb_status() {
13185 // Verify nonce for security
13186 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
13187 wp_send_json_error(['message' => 'Invalid nonce']);
13188 return;
13189 }
13190
13191 // Only allow admin users
13192 if (!current_user_can('administrator')) {
13193 wp_send_json_error(['message' => 'Unauthorized']);
13194 return;
13195 }
13196
13197 // Check OpenAI Vector Store first (takes priority)
13198 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
13199 $use_vectorstore = (isset($vectorstore_options['mxchat_use_openai_vectorstore']) && $vectorstore_options['mxchat_use_openai_vectorstore'] === '1');
13200
13201 if ($use_vectorstore) {
13202 $vectorstore_ids = $vectorstore_options['mxchat_vectorstore_ids'] ?? '';
13203 $id_count = !empty($vectorstore_ids) ? count(array_filter(array_map('trim', explode(',', $vectorstore_ids)))) : 0;
13204
13205 $kb_info = [
13206 'type' => 'OpenAI Vector Store',
13207 'status' => 'Active',
13208 'documents' => $id_count > 0 ? $id_count . ' vector store' . ($id_count > 1 ? 's' : '') . ' configured' : 'No vector stores configured'
13209 ];
13210
13211 wp_send_json_success($kb_info);
13212 return;
13213 }
13214
13215 // Check Pinecone vs WordPress
13216 $addon_options = get_option('mxchat_pinecone_addon_options', array());
13217 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
13218
13219 $kb_info = [
13220 'type' => $use_pinecone ? 'Pinecone' : 'WordPress Database',
13221 'status' => 'Active'
13222 ];
13223
13224 // Get document count
13225 if ($use_pinecone) {
13226 $kb_info['documents'] = 'Connected to Pinecone';
13227 $kb_info['api_configured'] = !empty($addon_options['mxchat_pinecone_api_key']);
13228 } else {
13229 // Count documents in WordPress database
13230 global $wpdb;
13231 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
13232 $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
13233 $kb_info['documents'] = $count ? $count . ' documents' : 'No documents';
13234 }
13235
13236 wp_send_json_success($kb_info);
13237 }
13238
13239 /**
13240 * AJAX handler to start a completely fresh session (NEW - replaces old clear session)
13241 */
13242 public function mxchat_start_fresh_session() {
13243 // Verify nonce for security
13244 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
13245 wp_send_json_error(['message' => 'Invalid nonce']);
13246 return;
13247 }
13248
13249 // Only allow admin users
13250 if (!current_user_can('administrator')) {
13251 wp_send_json_error(['message' => 'Unauthorized']);
13252 return;
13253 }
13254
13255 $old_session_id = isset($_POST['old_session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['old_session_id'])) : '';
13256 $new_session_id = isset($_POST['new_session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['new_session_id'])) : '';
13257
13258 if (empty($old_session_id)) {
13259 wp_send_json_error(['message' => 'Old session ID required']);
13260 return;
13261 }
13262
13263 // If no new session ID provided, generate one
13264 if (empty($new_session_id)) {
13265 // Cryptographically strong session id (plan-0c17b5). Prefix preserved
13266 // exactly (other code pattern-matches on 'mxchat_chat_'). random_bytes
13267 // is guaranteed on all supported PHP (7+).
13268 $new_session_id = 'mxchat_chat_' . bin2hex(random_bytes(16));
13269 }
13270
13271 // Clear ALL data associated with the old session
13272 $this->clear_complete_session_data($old_session_id);
13273
13274 // Initialize the new session
13275 $this->initialize_fresh_session($new_session_id);
13276
13277 wp_send_json_success([
13278 'message' => 'Fresh session started successfully',
13279 'new_session_id' => $new_session_id,
13280 'old_session_id' => $old_session_id
13281 ]);
13282 }
13283
13284 /**
13285 * Clear ALL data associated with a session (ENHANCED)
13286 */
13287 private function clear_complete_session_data($session_id) {
13288 // Clear chat history
13289 delete_option("mxchat_history_{$session_id}");
13290
13291 // Clear any PDF/Word transients
13292 $this->clear_pdf_transients($session_id);
13293 if (method_exists($this, 'clear_word_transients')) {
13294 $this->clear_word_transients($session_id);
13295 }
13296
13297 // Archive the session's per-conversation Slack channel before its option
13298 // is deleted (plan 7458a7 — covers transcript-retention cleanup paths).
13299 // Toggle-gated + shared-channel-guarded inside the helper; best-effort.
13300 $stale_channel = MxChat_Session_Store::get($session_id, 'channel', '');
13301 if ($stale_channel !== '') {
13302 $this->mxchat_maybe_archive_conversation_channel($session_id, $stale_channel);
13303 }
13304
13305 // Clear agent-related data. delete_session() drops the whole session row —
13306 // mode, channel, owner and originating_page in one statement. The old code
13307 // deleted mode and channel by hand and never touched owner or
13308 // originating_page, which is why those two prefixes accumulated one row per
13309 // session forever (b64b77).
13310 MxChat_Session_Store::delete_session($session_id);
13311 delete_option("mxchat_thread_{$session_id}");
13312 delete_option("mxchat_agent_name_{$session_id}");
13313 delete_option("mxchat_email_{$session_id}");
13314
13315 // Clear any recommendation flow state
13316 delete_option("mxchat_sr_flow_state_{$session_id}");
13317
13318 // Clear any cached embeddings or context
13319 delete_transient("mxchat_context_{$session_id}");
13320 delete_transient("mxchat_last_query_{$session_id}");
13321
13322 // Clear any testing data
13323 delete_transient("mxchat_testing_data_{$session_id}");
13324
13325 // Clear any rate limiting data for this session
13326 delete_transient("mxchat_rate_limit_{$session_id}");
13327
13328 // Clear any other session-specific transients
13329 delete_transient("mxchat_waiting_for_pdf_url_{$session_id}");
13330 delete_transient("mxchat_include_pdf_in_context_{$session_id}");
13331 delete_transient("mxchat_include_word_in_context_{$session_id}");
13332
13333 // Clear form addon state (pending forms and submitted forms)
13334 delete_option("mxchat_pending_form_{$session_id}");
13335 delete_option("mxchat_submitted_forms_{$session_id}");
13336
13337 //error_log("MxChat: Cleared all data for session: {$session_id}");
13338 }
13339
13340 /**
13341 * Initialize a fresh session with default data
13342 */
13343 private function initialize_fresh_session($session_id) {
13344 // Set default chat mode
13345 MxChat_Session_Store::set($session_id, 'mode', 'ai');
13346
13347 //error_log("MxChat: Initialized fresh session: {$session_id}");
13348 }
13349
13350 /**
13351 * Helper method to clear Word document transients (if you have Word support)
13352 */
13353 private function clear_word_transients($session_id) {
13354 delete_transient('mxchat_word_url_' . $session_id);
13355 delete_transient('mxchat_word_filename_' . $session_id);
13356 delete_transient('mxchat_word_embeddings_' . $session_id);
13357 delete_transient('mxchat_include_word_in_context_' . $session_id);
13358 }
13359
13360 /**
13361 * Simplified testing data capture method (CLEANED UP)
13362 */
13363 private function capture_testing_data($user_embedding, $message, $session_id) {
13364 // Only capture for admin users
13365 if (!current_user_can('administrator')) {
13366 return null;
13367 }
13368
13369 $testing_data = [
13370 'query' => $message,
13371 'timestamp' => time(),
13372 'top_matches' => [],
13373 'action_matches' => [] // Add action matches
13374 ];
13375
13376 // Get similarity threshold
13377 $similarity_threshold = isset($this->options['similarity_threshold'])
13378 ? ((int) $this->options['similarity_threshold']) / 100
13379 : 0.35;
13380
13381 $testing_data['similarity_threshold'] = $similarity_threshold;
13382
13383 // Use the real similarity analysis if available
13384 if ($this->last_similarity_analysis !== null) {
13385 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
13386 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
13387 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
13388 } else {
13389 // Fallback: determine knowledge base type
13390 $addon_options = get_option('mxchat_pinecone_addon_options', array());
13391 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
13392
13393 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
13394 }
13395
13396 // Include action analysis if available
13397 if (isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
13398 $testing_data['action_matches'] = $this->last_action_analysis;
13399
13400 // Clear it after capturing to avoid stale data
13401 $this->last_action_analysis = null;
13402 }
13403
13404 return $testing_data;
13405 }
13406
13407
13408 /**
13409 * Track URL clicks from chatbot responses
13410 */
13411 public function mxchat_track_url_click() {
13412 // Verify nonce for security
13413 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
13414 wp_send_json_error(['message' => 'Invalid nonce']);
13415 wp_die();
13416 }
13417
13418 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
13419 $clicked_url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
13420 $message_context = isset($_POST['message_context']) ? sanitize_textarea_field($_POST['message_context']) : '';
13421
13422 if (empty($session_id) || empty($clicked_url)) {
13423 wp_send_json_error(['message' => 'Missing required data']);
13424 wp_die();
13425 }
13426
13427 global $wpdb;
13428 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
13429
13430 // Insert click tracking record
13431 $wpdb->insert(
13432 $table_name,
13433 [
13434 'session_id' => $session_id,
13435 'clicked_url' => $clicked_url,
13436 'message_context' => $message_context,
13437 'click_timestamp' => current_time('mysql', 1),
13438 'user_ip' => $_SERVER['REMOTE_ADDR'],
13439 'user_agent' => $_SERVER['HTTP_USER_AGENT']
13440 ]
13441 );
13442
13443 wp_send_json_success(['message' => 'Click tracked']);
13444 wp_die();
13445 }
13446
13447 /**
13448 * Get URL click analytics for a session
13449 */
13450 public function mxchat_get_url_clicks($session_id) {
13451 global $wpdb;
13452 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
13453
13454 $clicks = $wpdb->get_results($wpdb->prepare(
13455 "SELECT * FROM $table_name WHERE session_id = %s ORDER BY click_timestamp ASC",
13456 $session_id
13457 ));
13458
13459 return $clicks;
13460 }
13461 /**
13462 * Track the originating page where chat was started
13463 */
13464 public function mxchat_track_originating_page() {
13465 // Verify nonce
13466 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
13467 wp_send_json_error(['message' => 'Invalid nonce']);
13468 wp_die();
13469 }
13470
13471 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
13472 $page_url = isset($_POST['page_url']) ? esc_url_raw($_POST['page_url']) : '';
13473 $page_title = isset($_POST['page_title']) ? sanitize_text_field($_POST['page_title']) : '';
13474
13475 if (empty($session_id)) {
13476 wp_send_json_error(['message' => 'Missing session ID']);
13477 wp_die();
13478 }
13479
13480 global $wpdb;
13481 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
13482
13483 // Check if we've already tracked for this session
13484 $existing = $wpdb->get_var($wpdb->prepare(
13485 "SELECT COUNT(*) FROM $table_name
13486 WHERE session_id = %s
13487 AND originating_page_url IS NOT NULL",
13488 $session_id
13489 ));
13490
13491 if ($existing > 0) {
13492 wp_send_json_success(['message' => 'Already tracked']);
13493 wp_die();
13494 }
13495
13496 // Update the first message in this session with originating page info
13497 $wpdb->query($wpdb->prepare(
13498 "UPDATE $table_name
13499 SET originating_page_url = %s,
13500 originating_page_title = %s
13501 WHERE session_id = %s
13502 ORDER BY timestamp ASC
13503 LIMIT 1",
13504 $page_url,
13505 $page_title,
13506 $session_id
13507 ));
13508
13509 wp_send_json_success(['message' => 'Originating page tracked']);
13510 wp_die();
13511 }
13512
13513 /**
13514 * Validate and clean URLs from AI response
13515 * Removes any URLs that aren't in the knowledge base
13516 *
13517 * @param string $response_text The AI-generated response
13518 * @param array $valid_urls Array of URLs from the knowledge base
13519 * @return string Cleaned response with invalid URLs removed/flagged
13520 */
13521 private function validate_and_clean_urls($response_text, $valid_urls, $session_id = null, $bot_id = null) {
13522 /**
13523 * Filter the list of URLs treated as valid (allowlisted) BEFORE the
13524 * response URL sanitizer strips any link not in the list. Lets a site
13525 * owner / developer whitelist links their custom function-calling tools
13526 * return (e.g. session or speaker pages), which are otherwise absent from
13527 * the RAG/system-prompt-derived list and get stripped to plain text.
13528 *
13529 * Purely additive: with no hook registered, apply_filters returns
13530 * $valid_urls untouched, so there is zero behavior change for anyone who
13531 * does not use the filter. Applied before the empty-check so a hooked
13532 * allowlist can participate. (plan-mxchat-20260710-13a471)
13533 *
13534 * @param array $valid_urls URLs already known-valid (RAG + system prompt).
13535 * @param string|null $session_id Current chat session id, if available.
13536 * @param string|null $bot_id Current bot id, if available.
13537 */
13538 $valid_urls = apply_filters('mxchat_valid_urls', $valid_urls, $session_id, $bot_id);
13539
13540 // A bad mu-plugin returning a non-array (or non-string entries) must never
13541 // fatal the response path — coerce defensively before any use.
13542 if (!is_array($valid_urls)) {
13543 $valid_urls = array();
13544 }
13545 $valid_urls = array_values(array_filter($valid_urls, static function ($u) {
13546 return is_string($u) && $u !== '';
13547 }));
13548
13549 // If no valid URLs provided or empty response, return as-is
13550 if (empty($valid_urls) || empty($response_text)) {
13551 //error_log("Validation skipped - empty valid_urls or response");
13552 return $response_text;
13553 }
13554
13555 // Extract all URLs from the AI response
13556 // This regex matches http:// and https:// URLs
13557 preg_match_all(
13558 '#\bhttps?://[^\s<>"\')\]]+#i',
13559 $response_text,
13560 $matches
13561 );
13562
13563 // If no URLs found in response, return as-is
13564 if (empty($matches[0])) {
13565 //error_log("No URLs found in response");
13566 return $response_text;
13567 }
13568
13569 $found_urls = $matches[0];
13570 $cleaned_response = $response_text;
13571 $removed_count = 0;
13572
13573 // Normalize valid URLs for comparison (remove trailing slashes, fragments, etc.)
13574 $normalized_valid_urls = array_map(function($url) {
13575 // Remove trailing slash
13576 $url = rtrim($url, '/');
13577 // Remove URL fragments (#section)
13578 $url = preg_replace('/#.*$/', '', $url);
13579 // Remove trailing punctuation that might have been captured
13580 $url = rtrim($url, '.,;:!?');
13581 return $url;
13582 }, $valid_urls);
13583
13584 //error_log("Normalized valid URLs: " . print_r($normalized_valid_urls, true));
13585
13586 foreach ($found_urls as $found_url) {
13587 // Clean up the found URL (remove trailing punctuation that might have been captured)
13588 $clean_found_url = rtrim($found_url, '.,;:!?)');
13589
13590 // DEBUG: Log each URL being checked
13591 //error_log("Checking found URL: " . $found_url);
13592
13593 // Normalize for comparison
13594 $normalized_found = rtrim($clean_found_url, '/');
13595 $normalized_found = preg_replace('/#.*$/', '', $normalized_found);
13596
13597 //error_log("Normalized found URL: " . $normalized_found);
13598
13599 // Check if this URL exists in our valid URLs list
13600 $is_valid = false;
13601
13602 //error_log("Starting validation checks for: " . $normalized_found);
13603
13604 // First, try exact match
13605 if (in_array($normalized_found, $normalized_valid_urls)) {
13606 $is_valid = true;
13607 //error_log("EXACT MATCH FOUND");
13608 } else {
13609 //error_log("No exact match, checking variations...");
13610 // If no exact match, check if it's a variation (with query params, etc.)
13611 foreach ($normalized_valid_urls as $valid_url) {
13612 //error_log(" Comparing against valid URL: " . $valid_url);
13613
13614 // Check if the found URL starts with a valid URL (handles query params)
13615 if (strpos($normalized_found, $valid_url) === 0) {
13616 // Check what comes after the valid URL
13617 $remainder = substr($normalized_found, strlen($valid_url));
13618
13619 // Only valid if:
13620 // 1. Exact match (remainder is empty)
13621 // 2. Query params (starts with ?)
13622 // 3. Fragment (starts with #)
13623 if (empty($remainder) || $remainder[0] === '?' || $remainder[0] === '#') {
13624 $is_valid = true;
13625 //error_log(" MATCH: Found URL is valid variation of base URL");
13626 break;
13627 } else {
13628 //error_log(" NOT A MATCH: Found URL extends path beyond valid URL (remainder: " . $remainder . ")");
13629 }
13630 }
13631 // Also check the reverse (in case valid URL has query params)
13632 if (strpos($valid_url, $normalized_found) === 0) {
13633 $is_valid = true;
13634 //error_log(" MATCH: Valid URL starts with found URL");
13635 break;
13636 }
13637 }
13638
13639 if (!$is_valid) {
13640 //error_log("NO MATCH FOUND - URL should be removed");
13641 }
13642 }
13643
13644 // If URL is not valid, remove it from the response
13645 if (!$is_valid) {
13646 // Log the removal for debugging
13647 //error_log("MxChat: Removed hallucinated URL: " . $found_url);
13648 //error_log("MxChat: Valid URLs were: " . implode(', ', array_slice($normalized_valid_urls, 0, 5)));
13649
13650 $removed_count++;
13651
13652 // Check if URL is part of a markdown link: [text](url)
13653 $markdown_pattern = '/\[([^\]]+)\]\(' . preg_quote($found_url, '/') . '\)/';
13654 if (preg_match($markdown_pattern, $cleaned_response)) {
13655 //error_log("Found markdown link, removing but keeping text");
13656 // Remove the markdown link but keep the text
13657 $cleaned_response = preg_replace($markdown_pattern, '$1', $cleaned_response);
13658 }
13659 // Check if URL is part of an HTML link: <a href="url">text</a>
13660 else if (preg_match('/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>(.*?)<\/a>/i', $cleaned_response, $link_match)) {
13661 //error_log("Found HTML link, removing but keeping text");
13662 // Remove the HTML link but keep the text
13663 $link_text = $link_match[1];
13664 $cleaned_response = preg_replace(
13665 '/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>.*?<\/a>/i',
13666 $link_text,
13667 $cleaned_response
13668 );
13669 }
13670 // Otherwise just remove the bare URL
13671 else {
13672 //error_log("Removing bare URL");
13673 $cleaned_response = str_replace($found_url, '', $cleaned_response);
13674 }
13675 }
13676 }
13677
13678 // Log summary if any URLs were removed
13679 if ($removed_count > 0) {
13680 //error_log("MxChat: URL Validation Summary - Removed {$removed_count} hallucinated URL(s)");
13681 } else {
13682 //error_log("MxChat: URL Validation Summary - No URLs removed, all were valid");
13683 }
13684
13685 // Clean up any double spaces or awkward punctuation left behind
13686 // IMPORTANT: Only collapse horizontal whitespace (spaces/tabs), preserve newlines for markdown formatting
13687 $cleaned_response = preg_replace('/[^\S\n]+/', ' ', $cleaned_response); // Collapse spaces/tabs but NOT newlines
13688 $cleaned_response = preg_replace('/[^\S\n]+([.,;:!?])/', '$1', $cleaned_response); // Same for punctuation cleanup
13689
13690 //error_log("Final cleaned response: " . $cleaned_response);
13691
13692 return trim($cleaned_response);
13693 }
13694
13695 /**
13696 * AJAX handler to get current chat mode for a session
13697 */
13698 public function mxchat_get_current_chat_mode() {
13699 // Verify nonce for security
13700 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
13701 wp_send_json_error(['message' => 'Invalid nonce']);
13702 wp_die();
13703 }
13704
13705 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
13706
13707 if (empty($session_id)) {
13708 wp_send_json_error(['message' => 'Session ID missing']);
13709 wp_die();
13710 }
13711
13712 // Get the current chat mode for this session
13713 $chat_mode = MxChat_Session_Store::get($session_id, 'mode', 'ai');
13714
13715 wp_send_json_success([
13716 'chat_mode' => $chat_mode
13717 ]);
13718 wp_die();
13719 }
13720
13721
13722
13723 }
13724 ?>
13725