PluginProbe
wpForo Forum / 3.0.9
wpForo Forum v3.0.9
3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 1.4.12 1.4.13 1.4.2 All 137 releases
wpforo / classes / AIClient.php

AIClient.php in wpForo Forum 3.0.9, at classes/AIClient.php

9,478 lines 316.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace wpforo\classes;
4
5 /**
6 * wpForo AI Features API Client
7 *
8 * Handles communication with the wpForo AI backend service including:
9 * - Tenant registration and API key generation
10 * - Subscription status retrieval
11 * - API key regeneration
12 * - Service disconnection
13 *
14 * @since 3.0.0
15 */
16 class AIClient {
17 use AIAjaxTrait;
18 use AIUserTrait;
19
20 /**
21 * API base URL
22 *
23 * @var string
24 */
25 private $api_base_url = 'https://api.gvectors.com/v1';
26
27 /**
28 * Fallback API base URL (used when primary domain is blocked)
29 *
30 * @var string
31 */
32 private $fallback_api_url = 'https://api.gvectors.net/v1';
33
34 /**
35 * Image extensions supported for multimodal indexing
36 *
37 * @var array
38 */
39 private static $image_extensions = [ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp' ];
40
41 /**
42 * Document extensions supported for document indexing
43 *
44 * @var array
45 */
46 private static $document_extensions = [ 'pdf', 'docx', 'doc', 'pptx', 'txt', 'rtf' ];
47
48 /**
49 * Request timeout in seconds
50 *
51 * @var int
52 */
53 private $timeout = 30;
54
55 /**
56 * Constructor
57 */
58 public function __construct() {
59 if ( defined( 'WPFORO_AI_API' ) && WPFORO_AI_API ) {
60 $this->api_base_url = WPFORO_AI_API;
61 }
62
63 // Allow API URLs to be filtered for different environments
64 $this->api_base_url = apply_filters( 'wpforo_ai_api_base_url', $this->api_base_url );
65 $this->fallback_api_url = apply_filters( 'wpforo_ai_fallback_api_url', $this->fallback_api_url );
66
67 // Register admin-only AJAX handlers
68 if ( is_admin() ) {
69 add_action( 'wp_ajax_wpforo_ai_get_rag_status', [ $this, 'ajax_get_rag_status' ] );
70 add_action( 'wp_ajax_wpforo_ai_cancel_cloud_indexing', [ $this, 'ajax_cancel_cloud_indexing' ] );
71 add_action( 'wp_ajax_wpforo_ai_cleanup_indexing_session', [ $this, 'ajax_cleanup_indexing_session' ] );
72 add_action( 'wp_ajax_wpforo_ai_action', [ $this, 'ajax_generic_action' ] );
73 add_action( 'wp_ajax_wpforo_ai_save_storage_mode', [ $this, 'ajax_save_storage_mode' ] );
74 add_action( 'wp_ajax_wpforo_ai_save_auto_indexing', [ $this, 'ajax_save_auto_indexing' ] );
75 add_action( 'wp_ajax_wpforo_ai_save_image_indexing', [ $this, 'ajax_save_image_indexing' ] );
76 add_action( 'wp_ajax_wpforo_ai_save_document_indexing', [ $this, 'ajax_save_document_indexing' ] );
77 add_action( 'wp_ajax_wpforo_ai_save_wp_indexing_option', [ $this, 'ajax_save_wp_indexing_option' ] );
78 add_action( 'wp_ajax_wpforo_ai_get_analytics', [ $this, 'ajax_get_analytics' ] );
79 add_action( 'wp_ajax_wpforo_ai_run_insight', [ $this, 'ajax_run_insight' ] );
80 add_action( 'wp_ajax_wpforo_ai_link_subscription', [ $this, 'ajax_link_subscription' ] );
81 add_action( 'wp_ajax_wpforo_ai_activate_license', [ $this, 'ajax_activate_license' ] );
82 add_action( 'wp_ajax_wpforo_ai_paddle_checkout', [ $this, 'ajax_paddle_checkout' ] );
83 add_action( 'wp_ajax_wpforo_ai_link_paddle_subscription', [ $this, 'ajax_link_paddle_subscription' ] );
84 add_action( 'wp_ajax_wpforo_ai_activate_paddle_transaction', [ $this, 'ajax_activate_paddle_transaction' ] );
85 add_action( 'wp_ajax_wpforo_ai_search_bot_users', [ $this, 'ajax_search_bot_users' ] );
86 add_action( 'wp_ajax_wpforo_ai_request_bonus_credits', [ $this, 'ajax_request_bonus_credits' ] );
87
88 // Register privacy policy content for AI features
89 add_action( 'admin_init', [ $this, 'register_privacy_policy_content' ] );
90
91 // Self-heal stalled WP-Cron on wpForo AI admin page loads.
92 // Runs once per page refresh (not on AJAX polls) to avoid any
93 // interference with the Stop Indexing flow. Hosts where AJAX
94 // responses are unreliable (proxies stripping bodies, etc.)
95 // still get the nudge whenever an admin reloads the page.
96 add_action( 'admin_init', [ $this, 'maybe_nudge_wp_cron_on_admin_page' ] );
97 }
98
99 // Register front-end and admin AJAX handlers (for semantic search, antispam, etc.)
100 add_action( 'wp_ajax_wpforo_ai_semantic_search', [ $this, 'ajax_semantic_search' ] );
101 add_action( 'wp_ajax_nopriv_wpforo_ai_semantic_search', [ $this, 'ajax_semantic_search' ] );
102
103 // Register public front-end semantic search (no admin permissions required)
104 add_action( 'wp_ajax_wpforo_ai_public_search', [ $this, 'ajax_public_semantic_search' ] );
105 add_action( 'wp_ajax_nopriv_wpforo_ai_public_search', [ $this, 'ajax_public_semantic_search' ] );
106
107 // Register translation AJAX handlers (for logged-in and guest users)
108 add_action( 'wp_ajax_wpforo_ai_translate', [ $this, 'ajax_translate_content' ] );
109 add_action( 'wp_ajax_nopriv_wpforo_ai_translate', [ $this, 'ajax_translate_content' ] );
110
111 // Register topic summarization AJAX handlers (for logged-in and guest users)
112 add_action( 'wp_ajax_wpforo_ai_summarize_topic', [ $this, 'ajax_summarize_topic' ] );
113 add_action( 'wp_ajax_nopriv_wpforo_ai_summarize_topic', [ $this, 'ajax_summarize_topic' ] );
114
115 // Register topic suggestions AJAX handlers (for logged-in and guest users)
116 add_action( 'wp_ajax_wpforo_ai_get_topic_suggestions', [ $this, 'ajax_get_topic_suggestions' ] );
117 add_action( 'wp_ajax_nopriv_wpforo_ai_get_topic_suggestions', [ $this, 'ajax_get_topic_suggestions' ] );
118
119 // Register translation button hook for post content
120 add_action( 'wpforo_post_content_top_left', [ $this, 'render_translation_button' ] );
121
122 // Register AI Bot Reply button hook for post action buttons
123 add_filter( 'wpforo_template_buttons_bottom', [ $this, 'render_bot_reply_button' ], 8, 5 );
124
125 // Register Suggest Reply button hook for reply form
126 add_action( 'wpforo_editor_post_submit_button_before', [ $this, 'render_suggest_reply_button' ], 10, 3 );
127
128 // Register Bot Reply AJAX handlers (logged-in users only)
129 add_action( 'wp_ajax_wpforo_ai_bot_reply', [ $this, 'ajax_bot_reply' ] );
130 add_action( 'wp_ajax_wpforo_ai_suggest_reply', [ $this, 'ajax_suggest_reply' ] );
131
132 // Register topic summarization button hook (in head-bar with subscribe button)
133 add_action( 'wpforo_template_post_head_bar_action_links', [ $this, 'render_topic_summary_button' ], 11, 3 );
134
135 // Register topic summary container hook (after head-bar, for slide-down area)
136 add_action( 'wpforo_template_post_head_bar', [ $this, 'render_topic_summary_container_standalone' ], 10, 3 );
137
138 // Register user AI preferences handler (logged-in users only)
139 add_action( 'wp_ajax_wpforo_save_ai_preferences', [ $this, 'ajax_save_ai_preferences' ] );
140
141 // Register WP Cron handler for background batch processing
142 // IMPORTANT: Must be registered unconditionally (not only in admin context)
143 // because WP Cron runs in a separate request where is_admin() returns FALSE
144 // Accept 3 args for backwards compatibility with old cron format
145 add_action( 'wpforo_ai_process_batch', [ $this, 'cron_process_batch' ], 10, 3 );
146
147 // Register WP Cron handler for local indexing queue (self-rescheduling pattern)
148 // This processes batches from the queue and reschedules itself until queue is empty
149 add_action( 'wpforo_ai_process_queue', [ $this, 'cron_process_queue' ], 10, 1 );
150
151 // Register mode-specific WP Cron handlers for auto-indexing queues
152 // These ensure local topics are processed with local indexing and cloud topics with cloud indexing
153 add_action( 'wpforo_ai_process_queue_local', [ $this, 'cron_process_queue_local' ], 10, 1 );
154 add_action( 'wpforo_ai_process_queue_cloud', [ $this, 'cron_process_queue_cloud' ], 10, 1 );
155
156 // Register WP Cron handler for AI cache cleanup (daily)
157 add_action( 'wpforo_ai_cache_cleanup', [ $this, 'cron_cache_cleanup' ] );
158
159 // Register WP Cron handler for daily pending topics indexing
160 add_action( 'wpforo_ai_pending_topics_indexing', [ $this, 'cron_pending_topics_indexing' ] );
161
162 // Register WP Cron handler for daily subscription status sync
163 add_action( 'wpforo_ai_daily_subscription_sync', [ $this, 'cron_daily_subscription_sync' ] );
164
165 // Clear translation cache and invalidate indexed status when posts change
166 add_action( 'wpforo_after_add_post', [ $this, 'on_post_add' ], 10, 2 );
167 add_action( 'wpforo_after_edit_post', [ $this, 'on_post_edit' ], 10, 4 );
168 add_action( 'wpforo_after_delete_post', [ $this, 'on_post_delete' ], 10, 1 );
169 add_action( 'wpforo_post_approve', [ $this, 'on_post_approve' ], 10, 1 );
170
171 // Auto-index new approved topics and topics that get approved
172 add_action( 'wpforo_after_add_topic', [ $this, 'on_topic_add' ], 10, 2 );
173 add_action( 'wpforo_topic_approve', [ $this, 'on_topic_approve' ], 10, 1 );
174
175 // Clean up embeddings when topics are deleted
176 add_action( 'wpforo_after_delete_topic', [ $this, 'on_topic_delete' ], 10, 1 );
177
178 // Clean up embeddings when topics become private (priority 15 to run after Forums/PostMeta hooks)
179 add_action( 'wpforo_topic_private_update', [ $this, 'on_topic_private_update' ], 15, 2 );
180
181 // Add AI suggestions panel right after title field using form fields filter
182 add_filter( 'wpforo_form_fields', [ $this, 'add_ai_suggestions_after_title' ] );
183
184 // Disable built-in wpForo topic suggestions when AI Topic Suggestions is enabled
185 add_filter( 'wpforo_topic_suggestion', [ $this, 'filter_built_in_suggestions' ] );
186 }
187
188 /**
189 * Get API base URL
190 *
191 * @return string API base URL
192 */
193 public function get_api_base_url() {
194 return $this->api_base_url;
195 }
196
197 /**
198 * Register suggested privacy policy content for AI features
199 *
200 * Adds a suggestion to Settings > Privacy so site admins can include
201 * AI data processing disclosure in their site's privacy policy.
202 */
203 public function register_privacy_policy_content() {
204 $content = '<h2>' . __( 'wpForo AI Features', 'wpforo' ) . '</h2>' .
205 '<p>' . __( 'When AI features are enabled by the forum administrator, this site sends forum content (topics, posts, and metadata) to the gVectors AI API (api.gvectors.com) for processing. This processing includes semantic search, AI translation, summarization, content moderation, AI chat, and topic suggestions.', 'wpforo' ) . '</p>' .
206 '<p>' . __( 'No forum data is sent to external servers unless the site administrator has explicitly enabled AI features and configured the service. The data is processed on secure AWS infrastructure and is used solely to provide the requested AI functionality.', 'wpforo' ) . '</p>' .
207 '<p>' . sprintf(
208 __( 'For more information, see the gVectors %1$sTerms of Service%2$s and %3$sPrivacy Policy%4$s.', 'wpforo' ),
209 '<a href="https://gvectors.com/terms-and-conditions/">',
210 '</a>',
211 '<a href="https://gvectors.com/privacy-policy/">',
212 '</a>'
213 ) . '</p>';
214
215 wp_add_privacy_policy_content( 'wpForo Forum', $content );
216 }
217
218 // =========================================================================
219 // GLOBAL AI OPTIONS
220 // =========================================================================
221 // These options are shared across ALL boards (use base prefix 'wpforo_')
222 // Unlike board-specific options, these don't use the board prefix.
223 //
224 // Global options:
225 // - ai_api_key : API key for authentication (shared)
226 // - ai_tenant_id : Tenant identifier (shared)
227 //
228 // Board-specific options (use wpforo_get_option/wpforo_update_option):
229 // - ai_chunk_size : Chunking size per board
230 // - ai_overlap_percent : Overlap percentage per board
231 // - ai_pagination_size : Pagination size per board
232 // =========================================================================
233
234 /**
235 * Get a global AI option (shared across all boards)
236 *
237 * Uses base prefix 'wpforo_' regardless of current board context.
238 * This ensures API key and tenant ID are always found.
239 *
240 * @param string $option Option name without prefix (e.g., 'ai_api_key')
241 * @param mixed $default Default value if option not found
242 * @return mixed Option value
243 */
244 public function get_global_option( $option, $default = '' ) {
245 return get_option( 'wpforo_' . $option, $default );
246 }
247
248 /**
249 * Update a global AI option (shared across all boards)
250 *
251 * Uses base prefix 'wpforo_' regardless of current board context.
252 *
253 * @param string $option Option name without prefix (e.g., 'ai_api_key')
254 * @param mixed $value Value to save
255 * @return bool True on success
256 */
257 public function update_global_option( $option, $value ) {
258 $result = update_option( 'wpforo_' . $option, $value );
259 wpforo_clean_cache( 'option' );
260 return $result;
261 }
262
263 /**
264 * Delete a global AI option (shared across all boards)
265 *
266 * Uses base prefix 'wpforo_' regardless of current board context.
267 *
268 * @param string $option Option name without prefix (e.g., 'ai_api_key')
269 * @return bool True on success
270 */
271 public function delete_global_option( $option ) {
272 $result = delete_option( 'wpforo_' . $option );
273 wpforo_clean_cache( 'option' );
274 return $result;
275 }
276
277 /**
278 * Get the API key (global option)
279 *
280 * @return string Encrypted API key or empty string
281 */
282 public function get_api_key() {
283 return $this->get_global_option( 'ai_api_key', '' );
284 }
285
286 /**
287 * Get the tenant ID (global option)
288 *
289 * @return string Tenant ID or empty string
290 */
291 public function get_tenant_id() {
292 return $this->get_global_option( 'ai_tenant_id', '' );
293 }
294
295 /**
296 * Check if tenant is connected to AI service
297 *
298 * @return bool True if API key and tenant ID are configured
299 */
300 public function is_connected() {
301 $api_key = $this->get_api_key();
302 $tenant_id = $this->get_tenant_id();
303 return ! empty( $api_key ) && ! empty( $tenant_id );
304 }
305
306 /**
307 * Check if AI service is available for use
308 *
309 * This checks both connection AND subscription status.
310 * AI features should only work when:
311 * 1. Tenant is connected (has API key and tenant ID)
312 * 2. Subscription status is active or trial
313 *
314 * Returns false for: inactive, pending_approval, disconnected, expired
315 *
316 * @return bool True if AI features can be used
317 */
318 public function is_service_available() {
319 // First check connection
320 if ( ! $this->is_connected() ) {
321 return false;
322 }
323
324 // Use cached subscription status from database options (no API calls)
325 // Status is synced when:
326 // 1. AI Features > Overview tab is loaded
327 // 2. User clicks refresh button
328 // 3. On first connection/registration
329 $sub_status = $this->get_subscription_status();
330
331 // Only allow active and trial statuses
332 return in_array( $sub_status, [ 'active', 'trial' ], true );
333 }
334
335 /**
336 * Check API health status
337 *
338 * @return array|WP_Error Health status or error object
339 */
340 public function health_check() {
341 $response = $this->get( '/tenant/health' );
342
343 if ( is_wp_error( $response ) ) {
344 $this->log_error( 'health_check_failed', $response->get_error_message() );
345 return $response;
346 }
347
348 return $response;
349 }
350
351 /**
352 * Register new tenant and generate API key
353 *
354 * Creates a new tenant account with free trial (500 credits, 30 days)
355 *
356 * @return array|WP_Error Response data or error object
357 */
358 public function register_tenant() {
359 $site_url = get_site_url();
360
361 // For localhost development, allow URL override via filter or constant
362 if ( strpos( $site_url, 'localhost' ) !== false || strpos( $site_url, '127.0.0.1' ) !== false ) {
363 // Check for development mode override
364 if ( defined( 'WPFORO_AI_DEV_URL' ) && WPFORO_AI_DEV_URL ) {
365 $site_url = WPFORO_AI_DEV_URL;
366 }
367 }
368
369 $data = [
370 'site_url' => $site_url,
371 'admin_email' => get_option( 'admin_email' ),
372 'wordpress_version' => get_bloginfo( 'version' ),
373 'wpforo_version' => defined( 'WPFORO_VERSION' ) ? WPFORO_VERSION : 'unknown',
374 'site_name' => get_bloginfo( 'name' ),
375 'language' => get_bloginfo( 'language' ),
376 'timezone' => wp_timezone_string(),
377 ];
378
379 // Allow filtering registration data
380 $data = apply_filters( 'wpforo_ai_registration_data', $data );
381
382 // Log the registration attempt
383 $this->log_info( 'attempting_tenant_registration', [
384 'site_url' => $data['site_url'],
385 'admin_email' => $data['admin_email'],
386 ] );
387
388 $response = $this->post( '/tenant/register', $data );
389
390 if ( is_wp_error( $response ) ) {
391 $this->log_error( 'registration_failed', $response->get_error_message() );
392 return $response;
393 }
394
395 // Log successful registration
396 $this->log_info( 'tenant_registered', [
397 'tenant_id' => wpfval( $response, 'tenant_id' ),
398 'plan' => wpfval( $response, 'subscription', 'plan' ),
399 ] );
400
401 do_action( 'wpforo_ai_tenant_registered', $response );
402
403 return $response;
404 }
405
406 /**
407 * Get current tenant status and subscription info
408 *
409 * @param bool $force_fresh Whether to bypass the cache and fetch fresh data
410 * @return array|WP_Error Status data or error object
411 */
412 public function get_tenant_status( $force_fresh = false ) {
413 // Check cache first (5 minute cache)
414 $cache_key = 'wpforo_ai_tenant_status';
415 $cached = get_transient( $cache_key );
416
417 if ( false !== $cached && ! $force_fresh && ! $this->is_debug_mode() ) {
418 return $cached;
419 }
420
421 // Validate credentials exist before making API call (use global options)
422 $api_key = $this->get_stored_api_key();
423 $tenant_id = $this->get_tenant_id();
424
425 if ( empty( $api_key ) || empty( $tenant_id ) ) {
426 return new \WP_Error(
427 'no_credentials',
428 wpforo_phrase( 'No credentials found. Please connect to the service first.', false )
429 );
430 }
431
432 $response = $this->get( '/tenant/status' );
433
434 if ( is_wp_error( $response ) ) {
435 $this->log_error( 'status_fetch_failed', $response->get_error_message() );
436 return $response;
437 }
438
439 // Cache the response for 5 minutes
440 set_transient( $cache_key, $response, 5 * MINUTE_IN_SECONDS );
441
442 // Also update persistent subscription info for frontend feature gating
443 // This allows checking plan without API calls on every page load
444 $this->update_cached_subscription_info( $response );
445
446 return $response;
447 }
448
449 /**
450 * Update cached subscription info from status response
451 *
452 * Stores plan and features in WordPress options for quick access
453 * without making API calls on every page load.
454 *
455 * @param array $status_response Response from /tenant/status API
456 */
457 private function update_cached_subscription_info( $status_response ) {
458 if ( ! is_array( $status_response ) ) {
459 return;
460 }
461
462 $subscription = isset( $status_response['subscription'] ) ? $status_response['subscription'] : [];
463 $features_enabled = isset( $status_response['features_enabled'] ) ? $status_response['features_enabled'] : [];
464
465 // Store subscription status (e.g., 'active', 'trial', 'inactive', 'pending_approval')
466 $sub_status = isset( $subscription['status'] ) ? sanitize_text_field( $subscription['status'] ) : '';
467 $this->update_global_option( 'ai_subscription_status', $sub_status );
468
469 // Store plan (e.g., 'free_trial', 'starter', 'professional', 'business', 'enterprise')
470 $plan = isset( $subscription['plan'] ) ? sanitize_text_field( $subscription['plan'] ) : 'free_trial';
471 $this->update_global_option( 'ai_subscription_plan', $plan );
472
473 // Store features enabled (array of feature IDs)
474 $this->update_global_option( 'ai_features_enabled', array_map( 'sanitize_text_field', $features_enabled ) );
475
476 // Store payment provider (freemius, paddle, or empty for free trial)
477 $payment_provider = isset( $subscription['payment_provider'] ) ? sanitize_text_field( $subscription['payment_provider'] ) : '';
478 if ( $payment_provider ) {
479 update_option( 'wpforo_ai_payment_provider', $payment_provider );
480 }
481
482 // Store all payment providers list (for tenants with both Freemius and Paddle)
483 if ( isset( $subscription['payment_providers'] ) && is_array( $subscription['payment_providers'] ) ) {
484 update_option( 'wpforo_ai_payment_providers', array_map( 'sanitize_text_field', $subscription['payment_providers'] ) );
485 }
486
487 // Store last sync time for debugging
488 $this->update_global_option( 'ai_subscription_synced_at', current_time( 'mysql', true ) );
489 }
490
491 /**
492 * Get cached subscription plan
493 *
494 * Returns the plan stored in WordPress options.
495 * This doesn't make API calls - use get_tenant_status() to refresh.
496 *
497 * @return string Plan name (free_trial, starter, professional, business, enterprise)
498 */
499 public function get_subscription_plan() {
500 return $this->get_global_option( 'ai_subscription_plan', 'free_trial' );
501 }
502
503 /**
504 * Get cached subscription status
505 *
506 * Returns the subscription status stored in WordPress options.
507 * This doesn't make API calls - use get_tenant_status() to refresh.
508 *
509 * @return string Status (active, trial, inactive, pending_approval, etc.)
510 */
511 public function get_subscription_status() {
512 return $this->get_global_option( 'ai_subscription_status', '' );
513 }
514
515 /**
516 * Get cached features enabled list
517 *
518 * Returns the features_enabled array from last status sync.
519 *
520 * @return array List of enabled feature IDs
521 */
522 public function get_features_enabled() {
523 $features = $this->get_global_option( 'ai_features_enabled', [] );
524 return is_array( $features ) ? $features : [];
525 }
526
527 /**
528 * Check if a specific feature is available based on subscription plan
529 *
530 * This method checks locally cached plan data to avoid API calls.
531 * Use this for frontend feature gating (showing/hiding UI elements).
532 *
533 * Note: Backend APIs still verify plan independently for security.
534 *
535 * @param string $feature_id Feature identifier (e.g., 'ai_assistant_chatbot', 'multi_language_translation')
536 * @return bool True if feature is available for current plan
537 */
538 public function is_feature_available( $feature_id ) {
539 // Service must be available (connected + active subscription)
540 if ( ! $this->is_service_available() ) {
541 return false;
542 }
543
544 // Get current plan from cache
545 $current_plan = $this->get_subscription_plan();
546
547 // Get feature definitions to find required plan
548 $all_features = $this->get_feature_definitions();
549 $feature = isset( $all_features[ $feature_id ] ) ? $all_features[ $feature_id ] : null;
550
551 // Unknown feature - deny by default
552 if ( ! $feature ) {
553 return false;
554 }
555
556 $required_plan = isset( $feature['plan'] ) ? $feature['plan'] : 'enterprise';
557
558 // Check if current plan meets requirement
559 return $this->plan_meets_requirement( $current_plan, $required_plan );
560 }
561
562 /**
563 * Check if current plan meets or exceeds required plan level
564 *
565 * @param string $current_plan Current subscription plan
566 * @param string $required_plan Required plan for feature
567 * @return bool True if current plan is sufficient
568 */
569 private function plan_meets_requirement( $current_plan, $required_plan ) {
570 // Plan hierarchy (lower to higher)
571 $plan_hierarchy = [
572 'free_trial' => 0,
573 'starter' => 0, // Starter and free_trial are same level
574 'professional' => 1,
575 'business' => 2,
576 'enterprise' => 3,
577 ];
578
579 $current_level = isset( $plan_hierarchy[ $current_plan ] ) ? $plan_hierarchy[ $current_plan ] : 0;
580 $required_level = isset( $plan_hierarchy[ $required_plan ] ) ? $plan_hierarchy[ $required_plan ] : 0;
581
582 return $current_level >= $required_level;
583 }
584
585 /**
586 * Get feature definitions with plan requirements
587 *
588 * Returns a simplified version of feature definitions for plan checking.
589 * This is a subset of what wpforo_ai_get_all_features() returns.
590 *
591 * @return array Feature ID => ['plan' => required_plan]
592 */
593 private function get_feature_definitions() {
594 return [
595 // Starter Plan Features (also available on free_trial)
596 'semantic_search' => [ 'plan' => 'starter' ],
597 'search_enhance' => [ 'plan' => 'starter' ],
598 'content_indexing' => [ 'plan' => 'starter' ],
599 'multi_language_translation' => [ 'plan' => 'starter' ],
600 'topic_summary' => [ 'plan' => 'starter' ],
601 'smart_topic_suggestions' => [ 'plan' => 'starter' ],
602 'ai_spam_detection' => [ 'plan' => 'starter' ],
603 'ai_toxicity_detection' => [ 'plan' => 'starter' ],
604 'ai_rule_compliance' => [ 'plan' => 'starter' ],
605
606 // Professional Plan Features
607 'analytics_insights' => [ 'plan' => 'professional' ],
608 'ai_topic_generator' => [ 'plan' => 'professional' ],
609 'ai_reply_generator' => [ 'plan' => 'professional' ],
610 'ai_bot_reply' => [ 'plan' => 'professional' ],
611 'auto_tag_generation' => [ 'plan' => 'professional' ],
612
613 // Business Plan Features
614 'ai_assistant_chatbot' => [ 'plan' => 'business' ],
615 'extended_knowledge_base' => [ 'plan' => 'business' ],
616 'wordpress_content_indexing' => [ 'plan' => 'business' ],
617 'custom_post_types_indexing' => [ 'plan' => 'business' ],
618 'woocommerce_products_indexing' => [ 'plan' => 'business' ],
619 'vector_db_cloud_storage' => [ 'plan' => 'business' ],
620
621 // Enterprise Plan Features
622 'developer_features' => [ 'plan' => 'enterprise' ],
623 'rest_api_access' => [ 'plan' => 'enterprise' ],
624 'custom_ai_models' => [ 'plan' => 'enterprise' ],
625 'custom_feature_development' => [ 'plan' => 'enterprise' ],
626 'premium_support' => [ 'plan' => 'enterprise' ],
627 'dedicated_account_manager' => [ 'plan' => 'enterprise' ],
628 'enterprise_capabilities' => [ 'plan' => 'enterprise' ],
629 ];
630 }
631
632 /**
633 * Clear cached tenant status
634 * Forces fresh fetch on next status request
635 */
636 public function clear_status_cache() {
637 delete_transient( 'wpforo_ai_tenant_status' );
638 }
639
640 /**
641 * Get indexed topic statistics by forum
642 *
643 * Returns indexed topic counts per forum for displaying in admin UI
644 *
645 * @return array|WP_Error Response data with forum_counts or error object
646 */
647 public function get_indexed_stats_by_forum() {
648 $response = $this->get( '/rag/indexed-stats/forums' );
649
650 if ( is_wp_error( $response ) ) {
651 $this->log_error( 'indexed_stats_fetch_failed', $response->get_error_message() );
652 return $response;
653 }
654
655 return $response;
656 }
657
658 /**
659 * Disconnect service (soft delete)
660 *
661 * @param string $reason Reason for disconnection
662 * @param bool $confirm Confirmation flag
663 * @return array|WP_Error Response data or error object
664 */
665 public function disconnect_tenant( $reason = '', $confirm = false, $purge_data = false ) {
666 $data = [
667 'reason' => sanitize_text_field( $reason ),
668 'confirm' => (bool) $confirm,
669 'purge_data' => (bool) $purge_data,
670 ];
671
672 $response = $this->delete( '/tenant/disconnect', $data );
673
674 if ( is_wp_error( $response ) ) {
675 $this->log_error( 'disconnection_failed', $response->get_error_message() );
676 return $response;
677 }
678
679 $this->log_info( 'tenant_disconnected', [ 'reason' => $reason ] );
680 $this->clear_status_cache();
681
682 // Clear indexed status for all topics (vectors are deleted on disconnect)
683 $this->clear_topics_indexed_status();
684
685 do_action( 'wpforo_ai_tenant_disconnected', $response );
686
687 return $response;
688 }
689
690 /**
691 * Check eligibility for bonus credits (large forum incentive)
692 *
693 * @return array Eligibility data with 'eligible' boolean and 'data' array
694 */
695 public function check_bonus_credits_eligibility() {
696 global $wpdb;
697
698 // Get wpforo table names
699 $topics_table = WPF()->tables->topics ?? $wpdb->prefix . 'wpforo_topics';
700 $posts_table = WPF()->tables->posts ?? $wpdb->prefix . 'wpforo_posts';
701 $profile_table = WPF()->tables->profiles ?? $wpdb->prefix . 'wpforo_profiles';
702
703 // 1. Count approved, non-private topics (status=0, private=0)
704 $topic_count = (int) $wpdb->get_var(
705 "SELECT COUNT(*) FROM {$topics_table} WHERE status = 0 AND private = 0"
706 );
707
708 // 2. Count approved posts (status=0)
709 $post_count = (int) $wpdb->get_var(
710 "SELECT COUNT(*) FROM {$posts_table} WHERE status = 0"
711 );
712
713 // 3. Get days between first and last topic
714 $date_range = $wpdb->get_row(
715 "SELECT
716 MIN(created) as first_topic,
717 MAX(created) as last_topic
718 FROM {$topics_table}
719 WHERE status = 0 AND private = 0"
720 );
721
722 $days_active = 0;
723 if ( $date_range && $date_range->first_topic && $date_range->last_topic ) {
724 $first_time = strtotime( $date_range->first_topic );
725 $last_time = strtotime( $date_range->last_topic );
726 $days_active = (int) floor( ( $last_time - $first_time ) / DAY_IN_SECONDS );
727 }
728
729 // 4. Count distinct topic authors who have login history (online_time > 0)
730 $active_authors = (int) $wpdb->get_var(
731 "SELECT COUNT(DISTINCT t.userid)
732 FROM {$topics_table} t
733 INNER JOIN {$profile_table} p ON t.userid = p.userid
734 WHERE t.status = 0 AND t.private = 0 AND t.userid > 0 AND p.online_time > 0"
735 );
736
737 // Determine eligibility
738 $eligible = (
739 $topic_count >= 501 &&
740 $post_count >= 511 &&
741 $days_active >= 30 &&
742 $active_authors >= 10
743 );
744
745 return [
746 'eligible' => $eligible,
747 'data' => [
748 'topic_count' => $topic_count,
749 'post_count' => $post_count,
750 'days_active' => $days_active,
751 'active_authors' => $active_authors,
752 ],
753 'requirements' => [
754 'min_topics' => 501,
755 'min_posts' => 511,
756 'min_days' => 30,
757 'min_active_authors' => 10,
758 ],
759 ];
760 }
761
762 /**
763 * Request bonus credits from API
764 *
765 * @param array $eligibility_data Data from check_bonus_credits_eligibility()
766 * @return array|WP_Error Response with credits_added or error
767 */
768 public function request_bonus_credits( $eligibility_data ) {
769 $response = $this->post( '/tenant/bonus-credits', [
770 'topic_count' => (int) $eligibility_data['topic_count'],
771 'post_count' => (int) $eligibility_data['post_count'],
772 'days_active' => (int) $eligibility_data['days_active'],
773 'active_authors' => (int) $eligibility_data['active_authors'],
774 ] );
775
776 if ( is_wp_error( $response ) ) {
777 $this->log_error( 'bonus_credits_failed', $response->get_error_message() );
778 return $response;
779 }
780
781 // Store bonus credits info locally
782 // Use isset() instead of !empty() for credits_added — empty(0) is true in PHP,
783 // which would skip saving when credits_added is 0 (e.g., due to cap enforcement)
784 if ( ! empty( $response['success'] ) && isset( $response['credits_added'] ) ) {
785 update_option( 'wpforo_ai_bonus_credits_claimed', true );
786 update_option( 'wpforo_ai_bonus_credits_amount', (int) $response['credits_added'] );
787 update_option( 'wpforo_ai_bonus_credits_claimed_at', current_time( 'mysql', true ) );
788
789 $this->log_info( 'bonus_credits_granted', [
790 'credits_added' => $response['credits_added'],
791 ] );
792
793 // Clear status cache to reflect new credits
794 $this->clear_status_cache();
795 }
796
797 return $response;
798 }
799
800 /**
801 * AJAX handler for requesting bonus credits
802 *
803 * @return void
804 */
805 public function ajax_request_bonus_credits() {
806 $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', '_wpnonce' );
807
808 // Check if already claimed locally
809 if ( get_option( 'wpforo_ai_bonus_credits_claimed', false ) ) {
810 $this->send_error(
811 wpforo_phrase( 'Bonus credits have already been claimed.', false ),
812 [ 'code' => 'already_claimed' ]
813 );
814 }
815
816 // Check eligibility
817 $eligibility = $this->check_bonus_credits_eligibility();
818
819 if ( ! $eligibility['eligible'] ) {
820 $this->send_error(
821 wpforo_phrase( 'Forum does not meet eligibility requirements for bonus credits.', false ),
822 [
823 'code' => 'not_eligible',
824 'requirements' => $eligibility['requirements'],
825 'current' => $eligibility['data'],
826 ]
827 );
828 }
829
830 // Request bonus credits from API
831 $response = $this->request_bonus_credits( $eligibility['data'] );
832
833 if ( is_wp_error( $response ) ) {
834 $this->send_error( $response->get_error_message() );
835 }
836
837 $this->send_success( [
838 'message' => $response['message'] ?? wpforo_phrase( 'Bonus credits added successfully!', false ),
839 'credits_added' => $response['credits_added'] ?? 0,
840 ] );
841 }
842
843 /**
844 * Check if bonus credits have been claimed
845 *
846 * @return bool|array False if not claimed, array with details if claimed
847 */
848 public function get_bonus_credits_status() {
849 $claimed = get_option( 'wpforo_ai_bonus_credits_claimed', false );
850
851 if ( ! $claimed ) {
852 return false;
853 }
854
855 return [
856 'claimed' => true,
857 'amount' => (int) get_option( 'wpforo_ai_bonus_credits_amount', 0 ),
858 'claimed_at' => get_option( 'wpforo_ai_bonus_credits_claimed_at', '' ),
859 ];
860 }
861
862 /**
863 * Get AI Content Indexing status
864 *
865 * Returns current status of AI Content Indexing including total indexed threads,
866 * progress, and whether indexing is currently active.
867 *
868 * @return array|WP_Error Status data or error object
869 */
870 public function get_rag_status( $boardid = 0 ) {
871 // Check cache first (30 second cache for frequent updates)
872 // Board-specific cache key
873 $cache_key = 'wpforo_ai_rag_status_' . intval( $boardid );
874 $cached = get_transient( $cache_key );
875
876 if ( false !== $cached && ! $this->is_debug_mode() ) {
877 return $cached;
878 }
879
880 // Include boardid in API request for future backend filtering
881 $endpoint = '/rag/status';
882 if ( $boardid > 0 ) {
883 $endpoint .= '?boardid=' . intval( $boardid );
884 }
885
886 $response = $this->get( $endpoint );
887
888 if ( is_wp_error( $response ) ) {
889 $this->log_error( 'rag_status_fetch_failed', $response->get_error_message() );
890 return $response;
891 }
892
893 // Cache the response for 30 seconds (short cache for indexing status)
894 set_transient( $cache_key, $response, 30 );
895
896 return $response;
897 }
898
899 /**
900 * AJAX handler for getting RAG status
901 *
902 * @return void
903 */
904 public function ajax_get_rag_status() {
905 $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', '_wpnonce' );
906
907 // Use VectorStorageManager to get stats (routes to local or cloud automatically)
908 $storage_manager = WPF()->vector_storage;
909 $status = $storage_manager->get_indexing_stats();
910
911 // Clear tenant status cache to get fresh credit info
912 $this->clear_status_cache();
913
914 // Add tenant status info for credits display (fresh fetch)
915 $tenant_status = $this->get_tenant_status();
916 if ( ! is_wp_error( $tenant_status ) ) {
917 $status['tenant_id'] = $tenant_status['tenant_id'] ?? '';
918 $status['credits'] = $tenant_status['credits'] ?? [];
919 $status['subscription_tier'] = $tenant_status['subscription_tier'] ?? '';
920 }
921
922 // Add pending cron jobs info
923 $pending_jobs = $this->get_pending_cron_jobs();
924 $status['pending_cron_jobs'] = $pending_jobs;
925
926 $this->send_success( $status );
927 }
928
929 /**
930 * Ask the backend to stop any in-flight cloud indexing for this tenant.
931 *
932 * Sends POST /v1/rag/cancel, which sets a cancellation flag on the
933 * tenant record with a ~10 minute TTL. The backend reads the flag
934 * at the start of every queued message and skips (without charging
935 * credits) anything still pending. Already in-flight processing
936 * calls are allowed to finish and are billed.
937 *
938 * @return array|WP_Error Backend response or error object
939 */
940 public function cancel_indexing() {
941 // Note: /rag/cancel was replaced by /rag/cleanup-jobs in backend
942 // Both set indexing_cancel_until flag, cleanup-jobs also finalizes stuck jobs
943 return $this->post( '/rag/cleanup-jobs', [], 30 );
944 }
945
946 /**
947 * Cleanup stuck cloud indexing jobs (backend).
948 *
949 * Calls POST /v1/rag/cleanup-jobs — scans the tenant's rag_jobs
950 * records, force-finalizes anything stuck (media_done < media_total,
951 * created >1h ago), refunds the unused portion of the image/document
952 * credit reservation, and sets indexing_cancel_until so any in-flight
953 * SQS messages drain without charging.
954 *
955 * Idempotent: backend uses a conditional update on
956 * media_credits_charged, so re-clicking is safe — no double refund.
957 * Safe in local storage mode: tenants with no rag_jobs get a zero-count
958 * response with no side effects.
959 *
960 * @return array|WP_Error Backend response: status, jobs_scanned,
961 * jobs_finalized, refund_images, refund_docs, ...
962 */
963 public function cleanup_jobs() {
964 return $this->post( '/rag/cleanup-jobs', [], 30 );
965 }
966
967 /**
968 * AJAX handler: stop in-flight cloud indexing.
969 *
970 * Mirrors the local-mode `clearLocalIndexingQueue` flow — no page
971 * reload, UI polling will pick up the drained state via the regular
972 * `/rag/status` poll.
973 *
974 * @return void
975 */
976 public function ajax_cancel_cloud_indexing() {
977 $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', '_wpnonce' );
978
979 // Clear any WordPress cron jobs scheduled for indexing (cloud/local/legacy
980 // hooks) and the per-board queue options. The legacy form-submit Stop
981 // flow did this via wpforo_ai_handle_stop_indexing(); the new AJAX path
982 // must do the same or orphaned crons continue firing after Stop.
983 $this->clear_pending_cron_jobs();
984
985 $result = $this->cancel_indexing();
986
987 if ( is_wp_error( $result ) ) {
988 $this->send_error(
989 wpforo_phrase( 'Failed to stop indexing. Please try again.', false ),
990 500
991 );
992 }
993
994 $this->send_success( [
995 'message' => wpforo_phrase( 'Indexing is being stopped. In-flight items may take a few minutes to drain.', false ),
996 ] );
997 }
998
999 /**
1000 * Cleanup stuck indexing session state.
1001 *
1002 * Resets all "in-progress" markers (queues, crons, locks, caches, backend
1003 * cancel flag) for either the forum-indexing pipeline or the WordPress
1004 * content indexing pipeline — without touching any successfully-indexed
1005 * data (vectors, wpforo_ai_local_vectors rows, topics.cloud/local/indexed
1006 * columns, credit counters).
1007 *
1008 * Covers BOTH local and cloud storage modes in a single call: forum
1009 * cleanup clears local queues, cloud queues, legacy queues, and tells the
1010 * backend worker to drop any queued items.
1011 *
1012 * @param string $scope 'forum' | 'wp' | 'all'
1013 * @return array Summary of what was cleared.
1014 */
1015 public function cleanup_indexing_session( $scope = 'forum' ) {
1016 $board_id = WPF()->board->get_current( 'boardid' ) ?: 0;
1017 $summary = [
1018 'scope' => $scope,
1019 'options_deleted' => 0,
1020 'transients_deleted' => 0,
1021 'crons_cleared' => 0,
1022 'topics_cleared' => 0,
1023 'backend_cancelled' => false,
1024 'backend_cleanup' => false,
1025 ];
1026
1027 if ( $scope === 'forum' || $scope === 'all' ) {
1028 // Reuse the existing helper — it clears local/cloud/legacy queue
1029 // options AND their cron hooks for the current board, including
1030 // the legacy wpforo_ai_process_batch pattern.
1031 $cron_result = $this->clear_pending_cron_jobs();
1032 $summary['topics_cleared'] += (int) ( $cron_result['cleared_topics'] ?? 0 );
1033 $summary['crons_cleared'] += (int) ( $cron_result['cleared_jobs'] ?? 0 );
1034
1035 // Session snapshot (chunk_size/overlap/batch_size/total/started_at)
1036 if ( delete_option( 'wpforo_ai_indexing_settings_' . $board_id ) ) {
1037 $summary['options_deleted']++;
1038 }
1039
1040 // Per-board batch locks (serialize AJAX + WP-Cron processing).
1041 // Clear all variants: legacy, local-mode, and cloud-mode locks.
1042 // If any are stale, new batches refuse to run until TTL expires.
1043 if ( delete_transient( 'wpforo_ai_indexing_lock_' . $board_id ) ) {
1044 $summary['transients_deleted']++;
1045 }
1046 if ( delete_transient( 'wpforo_ai_indexing_lock_local_' . $board_id ) ) {
1047 $summary['transients_deleted']++;
1048 }
1049 if ( delete_transient( 'wpforo_ai_indexing_lock_cloud_' . $board_id ) ) {
1050 $summary['transients_deleted']++;
1051 }
1052
1053 // Global 5-min "clearing in progress" semaphore. If set while
1054 // a clear operation crashed, it blocks the UI for up to 5 minutes.
1055 if ( delete_transient( 'wpforo_ai_clearing_in_progress' ) ) {
1056 $summary['transients_deleted']++;
1057 }
1058
1059 // Force-refresh the cached RAG status so the UI flips to Idle
1060 // immediately after cleanup.
1061 if ( delete_transient( 'wpforo_ai_rag_status' ) ) {
1062 $summary['transients_deleted']++;
1063 }
1064 if ( delete_transient( 'wpforo_ai_rag_status_' . $board_id ) ) {
1065 $summary['transients_deleted']++;
1066 }
1067
1068 // Tell the backend to drop any in-flight cloud image_worker items.
1069 // Safe in local mode: backend simply sets indexing_cancel_until on
1070 // the tenant record with no other side effects. Errors are
1071 // non-fatal — WP-side cleanup has already succeeded.
1072 $cancel = $this->cancel_indexing();
1073 $summary['backend_cancelled'] = ! is_wp_error( $cancel );
1074
1075 // Force-finalize any stuck rag_jobs server-side and refund the
1076 // unused portion of the image/document credit reservation.
1077 // Idempotent and tenant-wide; safe in local mode (no jobs to
1078 // finalize). Errors are non-fatal — WP-side cleanup has already
1079 // succeeded and the backend reaper cron runs hourly anyway.
1080 $cleanup = $this->cleanup_jobs();
1081 $summary['backend_cleanup'] = is_wp_error( $cleanup ) ? false : $cleanup;
1082 }
1083
1084 if ( $scope === 'wp' || $scope === 'all' ) {
1085 // WordPress post/page indexing queue (single global key, not
1086 // board-scoped — WP content is global).
1087 if ( delete_option( 'wpforo_ai_wp_indexing_queue' ) ) {
1088 $summary['options_deleted']++;
1089 }
1090
1091 // WP indexing status cache (5-min TTL) — deleting it forces the
1092 // UI to query fresh state.
1093 if ( delete_transient( 'wpforo_ai_wp_indexing_status' ) ) {
1094 $summary['transients_deleted']++;
1095 }
1096
1097 // WP indexing lock transient — must be cleared so new indexing
1098 // can start immediately after stop/clear. Without this, user
1099 // would have to wait up to 300s for the lock to auto-expire.
1100 if ( delete_transient( 'wpforo_ai_wp_indexing_lock' ) ) {
1101 $summary['transients_deleted']++;
1102 }
1103
1104 // WP post/page batch cron (single-event, no args, reschedules
1105 // itself). wp_clear_scheduled_hook removes all pending events.
1106 if ( wp_next_scheduled( 'wpforo_ai_process_wp_batch' ) ) {
1107 $summary['crons_cleared']++;
1108 }
1109 wp_clear_scheduled_hook( 'wpforo_ai_process_wp_batch' );
1110
1111 // For cloud mode: cancel in-flight backend jobs and cleanup stuck
1112 // rag_jobs. These APIs are tenant-wide (not scope-specific), so
1113 // calling them for WP scope ensures stuck cloud jobs are handled
1114 // even if user only uses WordPress indexing. Safe in local mode:
1115 // backend simply ignores the request with no side effects.
1116 if ( $scope === 'wp' ) {
1117 $cancel = $this->cancel_indexing();
1118 $summary['backend_cancelled'] = ! is_wp_error( $cancel );
1119
1120 $cleanup = $this->cleanup_jobs();
1121 $summary['backend_cleanup'] = is_wp_error( $cleanup ) ? false : $cleanup;
1122 }
1123 }
1124
1125 return $summary;
1126 }
1127
1128 /**
1129 * AJAX handler: cleanup stuck indexing session state.
1130 *
1131 * POST params:
1132 * scope — 'forum' | 'wp' | 'all' (default 'forum')
1133 * _wpnonce — wpforo_ai_features_nonce
1134 *
1135 * @return void
1136 */
1137 public function ajax_cleanup_indexing_session() {
1138 $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', '_wpnonce' );
1139
1140 $scope = $this->get_post_param( 'scope', 'forum' );
1141 if ( ! in_array( $scope, [ 'forum', 'wp', 'all' ], true ) ) {
1142 $scope = 'forum';
1143 }
1144
1145 $summary = $this->cleanup_indexing_session( $scope );
1146
1147 $this->send_success( [
1148 'summary' => $summary,
1149 'message' => wpforo_phrase( 'Indexing session cleaned up. Stuck jobs, queues and cached state have been cleared.', false ),
1150 ] );
1151 }
1152
1153 /**
1154 * AJAX handler for saving storage mode setting
1155 *
1156 * @return void
1157 */
1158 public function ajax_save_storage_mode() {
1159 $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', 'nonce' );
1160
1161 // Get and validate storage mode
1162 $storage_mode = $this->get_post_param( 'storage_mode', 'local' );
1163 if ( ! in_array( $storage_mode, [ 'local', 'cloud' ], true ) ) {
1164 $storage_mode = 'local';
1165 }
1166
1167 $board_id = $this->get_post_param( 'board_id', 0, 'int' );
1168
1169 // Save the setting
1170 $option_name = 'wpforo_ai_storage_mode_' . $board_id;
1171 $old_mode = get_option( $option_name, 'local' );
1172 update_option( $option_name, $storage_mode );
1173
1174 // If mode changed, sync the indexed status for the new mode
1175 $sync_result = null;
1176 if ( $old_mode !== $storage_mode ) {
1177 // Reset cached mode in VectorStorageManager
1178 WPF()->vector_storage->reset_storage_mode_cache();
1179
1180 // Sync indexed status based on new mode
1181 if ( $storage_mode === 'local' ) {
1182 $sync_result = WPF()->vector_storage->sync_local_indexed_status();
1183 } else {
1184 $sync_result = WPF()->vector_storage->sync_cloud_indexed_status();
1185 }
1186 }
1187
1188 // Log the change
1189 $this->log_info( 'storage_mode_changed', [
1190 'board_id' => $board_id,
1191 'old_mode' => $old_mode,
1192 'storage_mode' => $storage_mode,
1193 'sync_result' => is_wp_error( $sync_result ) ? $sync_result->get_error_message() : $sync_result
1194 ] );
1195
1196 $response_data = [
1197 'message' => wpforo_phrase( 'Storage mode saved successfully.', false ),
1198 'storage_mode' => $storage_mode,
1199 'board_id' => $board_id
1200 ];
1201
1202 if ( $sync_result && ! is_wp_error( $sync_result ) ) {
1203 $response_data['sync'] = $sync_result;
1204 }
1205
1206 $this->send_success( $response_data );
1207 }
1208
1209 /**
1210 * AJAX handler to save auto-indexing setting
1211 *
1212 * Saves the auto-indexing enabled/disabled state for a specific board.
1213 * When enabled, new and approved topics will be automatically queued for indexing.
1214 *
1215 * @return void Sends JSON response
1216 */
1217 public function ajax_save_auto_indexing() {
1218 $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', 'nonce' );
1219
1220 $enabled = $this->get_post_param( 'enabled', 0, 'bool' ) ? 1 : 0;
1221 $board_id = $this->get_post_param( 'board_id', 0, 'int' );
1222
1223 // Switch to the correct board context
1224 if ( $board_id > 0 ) {
1225 WPF()->change_board( $board_id );
1226 }
1227
1228 // Save the setting using wpforo options (board-specific)
1229 wpforo_update_option( 'ai_auto_indexing_enabled', $enabled );
1230
1231 // If enabling auto-indexing, schedule the cron jobs
1232 if ( $enabled ) {
1233 $this->schedule_pending_topics_indexing();
1234 } else {
1235 $this->unschedule_pending_topics_indexing();
1236 }
1237
1238 // Log the change
1239 $this->log_info( 'auto_indexing_changed', [
1240 'board_id' => $board_id,
1241 'enabled' => $enabled
1242 ] );
1243
1244 $this->send_success( [
1245 'message' => $enabled
1246 ? wpforo_phrase( 'Auto-indexing enabled successfully.', false )
1247 : wpforo_phrase( 'Auto-indexing disabled successfully.', false ),
1248 'enabled' => $enabled,
1249 'board_id' => $board_id
1250 ] );
1251 }
1252
1253 /**
1254 * AJAX handler to save image indexing setting
1255 *
1256 * Saves the image indexing enabled/disabled state for a specific board.
1257 * When enabled, posts with images will consume +1 additional credit for
1258 * multimodal processing (image → text description → embedding).
1259 *
1260 * Feature Requirements:
1261 * - Business or Enterprise plan required
1262 * - Maximum 10 images per post (enforced by API)
1263 * - +1 credit per post that has images (not per image)
1264 *
1265 * @return void Sends JSON response
1266 */
1267 public function ajax_save_image_indexing() {
1268 // Verify nonce
1269 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1270
1271 // Check user permissions
1272 if ( ! current_user_can( 'manage_options' ) ) {
1273 wp_send_json_error( [
1274 'message' => wpforo_phrase( 'Insufficient permissions', false )
1275 ], 403 );
1276 }
1277
1278 // Check plan eligibility (Business/Enterprise only)
1279 $status = $this->get_tenant_status();
1280 if ( is_wp_error( $status ) ) {
1281 wp_send_json_error( [
1282 'message' => wpforo_phrase( 'Could not verify subscription status', false )
1283 ], 400 );
1284 }
1285
1286 $plan = isset( $status['subscription']['plan'] ) ? strtolower( $status['subscription']['plan'] ) : '';
1287 if ( ! in_array( $plan, [ 'professional', 'business', 'enterprise' ], true ) ) {
1288 wp_send_json_error( [
1289 'message' => wpforo_phrase( 'Image indexing requires Professional plan or higher', false )
1290 ], 403 );
1291 }
1292
1293 // Get and validate enabled state
1294 $enabled = isset( $_POST['enabled'] ) ? (int) $_POST['enabled'] : 0;
1295 $enabled = $enabled ? 1 : 0;
1296
1297 // Get board ID
1298 $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0;
1299
1300 // Switch to the correct board context
1301 if ( $board_id > 0 ) {
1302 WPF()->change_board( $board_id );
1303 }
1304
1305 // Save the setting using wpforo options (board-specific)
1306 wpforo_update_option( 'ai_image_indexing_enabled', $enabled );
1307
1308 // Log the change
1309 $this->log_info( 'image_indexing_changed', [
1310 'board_id' => $board_id,
1311 'enabled' => $enabled
1312 ] );
1313
1314 wp_send_json_success( [
1315 'message' => $enabled
1316 ? wpforo_phrase( 'Image indexing enabled. Posts with images will consume +1 additional credit.', false )
1317 : wpforo_phrase( 'Image indexing disabled.', false ),
1318 'enabled' => $enabled,
1319 'board_id' => $board_id
1320 ] );
1321 }
1322
1323 /**
1324 * AJAX handler for saving document indexing setting
1325 *
1326 * Saves the board-specific document indexing enabled/disabled state.
1327 * Requires Professional+ plan.
1328 */
1329 public function ajax_save_document_indexing() {
1330 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1331
1332 if ( ! current_user_can( 'manage_options' ) ) {
1333 wp_send_json_error( [
1334 'message' => wpforo_phrase( 'Insufficient permissions', false )
1335 ], 403 );
1336 }
1337
1338 $status = $this->get_tenant_status();
1339 if ( is_wp_error( $status ) ) {
1340 wp_send_json_error( [
1341 'message' => wpforo_phrase( 'Could not verify subscription status', false )
1342 ], 400 );
1343 }
1344
1345 $plan = isset( $status['subscription']['plan'] ) ? strtolower( $status['subscription']['plan'] ) : '';
1346 if ( ! in_array( $plan, [ 'professional', 'business', 'enterprise' ], true ) ) {
1347 wp_send_json_error( [
1348 'message' => wpforo_phrase( 'Document indexing requires Professional plan or higher', false )
1349 ], 403 );
1350 }
1351
1352 $enabled = isset( $_POST['enabled'] ) ? (int) $_POST['enabled'] : 0;
1353 $enabled = $enabled ? 1 : 0;
1354
1355 $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0;
1356 if ( $board_id > 0 ) {
1357 WPF()->change_board( $board_id );
1358 }
1359
1360 wpforo_update_option( 'ai_document_indexing_enabled', $enabled );
1361
1362 $this->log_info( 'document_indexing_changed', [
1363 'board_id' => $board_id,
1364 'enabled' => $enabled
1365 ] );
1366
1367 wp_send_json_success( [
1368 'message' => $enabled
1369 ? wpforo_phrase( 'Document indexing enabled. Credit cost: 1 per page.', false )
1370 : wpforo_phrase( 'Document indexing disabled.', false ),
1371 'enabled' => $enabled,
1372 'board_id' => $board_id
1373 ] );
1374 }
1375
1376 /**
1377 * AJAX handler for saving WordPress indexing options
1378 *
1379 * WordPress content is global (not board-specific), so these settings
1380 * are saved globally using update_option() instead of wpforo_update_option().
1381 *
1382 * Supported options:
1383 * - ai_wp_auto_indexing_enabled: Auto-index new WordPress content
1384 * - ai_wp_image_indexing_enabled: Include images in WP content indexing
1385 */
1386 public function ajax_save_wp_indexing_option() {
1387 // Verify nonce
1388 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1389
1390 // Check user permissions
1391 if ( ! current_user_can( 'manage_options' ) ) {
1392 wp_send_json_error( [
1393 'message' => wpforo_phrase( 'Insufficient permissions', false )
1394 ], 403 );
1395 }
1396
1397 // Get option name and validate it's one of the allowed options
1398 $option_name = isset( $_POST['option_name'] ) ? sanitize_key( $_POST['option_name'] ) : '';
1399 $allowed_options = [ 'ai_wp_auto_indexing_enabled', 'ai_wp_image_indexing_enabled' ];
1400
1401 if ( ! in_array( $option_name, $allowed_options, true ) ) {
1402 wp_send_json_error( [
1403 'message' => wpforo_phrase( 'Invalid option name', false )
1404 ], 400 );
1405 }
1406
1407 // For image indexing, check plan eligibility (Professional/Business/Enterprise)
1408 if ( $option_name === 'ai_wp_image_indexing_enabled' ) {
1409 $status = $this->get_tenant_status();
1410 if ( is_wp_error( $status ) ) {
1411 wp_send_json_error( [
1412 'message' => wpforo_phrase( 'Could not verify subscription status', false )
1413 ], 400 );
1414 }
1415
1416 $plan = isset( $status['subscription']['plan'] ) ? strtolower( $status['subscription']['plan'] ) : '';
1417 if ( ! in_array( $plan, [ 'professional', 'business', 'enterprise' ], true ) ) {
1418 wp_send_json_error( [
1419 'message' => wpforo_phrase( 'Image indexing requires Professional, Business or Enterprise plan', false )
1420 ], 403 );
1421 }
1422 }
1423
1424 // Get and validate enabled state
1425 $enabled = isset( $_POST['enabled'] ) ? (int) $_POST['enabled'] : 0;
1426 $enabled = $enabled ? 1 : 0;
1427
1428 // Save globally using WordPress options (not board-specific)
1429 update_option( 'wpforo_' . $option_name, $enabled );
1430
1431 // Log the change
1432 $this->log_info( 'wp_indexing_option_changed', [
1433 'option' => $option_name,
1434 'enabled' => $enabled
1435 ] );
1436
1437 // Prepare success message based on option
1438 if ( $option_name === 'ai_wp_image_indexing_enabled' ) {
1439 $message = $enabled
1440 ? wpforo_phrase( 'WordPress image indexing enabled. Posts with images will consume +1 additional credit.', false )
1441 : wpforo_phrase( 'WordPress image indexing disabled.', false );
1442 } else {
1443 $message = $enabled
1444 ? wpforo_phrase( 'WordPress auto-indexing enabled. New content will be indexed automatically.', false )
1445 : wpforo_phrase( 'WordPress auto-indexing disabled.', false );
1446 }
1447
1448 wp_send_json_success( [
1449 'message' => $message,
1450 'enabled' => $enabled,
1451 'option' => $option_name
1452 ] );
1453 }
1454
1455 /**
1456 * AJAX handler for linking Freemius subscription to tenant
1457 *
1458 * Called after successful Freemius checkout to store subscription_id and user_id.
1459 * This enables webhook matching when emails don't match.
1460 *
1461 * @return void Sends JSON response
1462 */
1463 public function ajax_link_subscription() {
1464 // Verify nonce
1465 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1466
1467 // Check user permissions
1468 if ( ! current_user_can( 'manage_options' ) ) {
1469 wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 );
1470 }
1471
1472 $subscription_id = isset( $_POST['subscription_id'] ) ? sanitize_text_field( $_POST['subscription_id'] ) : '';
1473 $user_id = isset( $_POST['user_id'] ) ? sanitize_text_field( $_POST['user_id'] ) : '';
1474 $plan = isset( $_POST['plan'] ) ? sanitize_text_field( $_POST['plan'] ) : '';
1475
1476 if ( empty( $subscription_id ) ) {
1477 wp_send_json_error( [ 'message' => 'Missing subscription_id' ], 400 );
1478 }
1479
1480 // Call backend API to link subscription
1481 $response = $this->post( '/tenant/link-subscription', [
1482 'freemius_subscription_id' => $subscription_id,
1483 'freemius_user_id' => $user_id,
1484 'plan' => $plan,
1485 ] );
1486
1487 if ( is_wp_error( $response ) ) {
1488 $this->log_error( 'link_subscription_failed', $response->get_error_message() );
1489 wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 );
1490 }
1491
1492 $this->log_info( 'subscription_linked', [
1493 'subscription_id' => $subscription_id,
1494 'user_id' => $user_id,
1495 'plan' => $plan,
1496 ] );
1497
1498 wp_send_json_success( [ 'message' => 'Subscription linked successfully' ] );
1499 }
1500
1501 /**
1502 * AJAX handler for manual license activation
1503 *
1504 * Called when user enters a License ID to manually activate their plan.
1505 * The backend verifies with Freemius API and updates the subscription.
1506 *
1507 * Note: We only transmit the License ID (a numeric identifier like "1845944"),
1508 * NOT the License Key (sk_...). The License ID is safe to store as it's just
1509 * a reference number, not a secret.
1510 *
1511 * @return void Sends JSON response
1512 */
1513 public function ajax_activate_license() {
1514 // Verify nonce
1515 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1516
1517 // Check user permissions
1518 if ( ! current_user_can( 'manage_options' ) ) {
1519 wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 );
1520 }
1521
1522 $license_id = isset( $_POST['license_id'] ) ? sanitize_text_field( $_POST['license_id'] ) : '';
1523
1524 if ( empty( $license_id ) ) {
1525 wp_send_json_error( [ 'message' => 'Please enter your License ID' ], 400 );
1526 }
1527
1528 // Validate license_id format (should be numeric)
1529 if ( ! preg_match( '/^\d+$/', $license_id ) ) {
1530 wp_send_json_error( [ 'message' => 'Invalid License ID format. Please enter the numeric License ID from your purchase confirmation.' ], 400 );
1531 }
1532
1533 // Call backend API to verify and activate license
1534 $response = $this->post( '/tenant/activate-license', [
1535 'license_id' => $license_id,
1536 ] );
1537
1538 if ( is_wp_error( $response ) ) {
1539 $this->log_error( 'license_activation_failed', $response->get_error_message() );
1540 wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 );
1541 }
1542
1543 // Clear cached subscription data so it refreshes
1544 delete_option( 'wpforo_ai_subscription_plan' );
1545 delete_transient( 'wpforo_ai_subscription' );
1546 delete_transient( 'wpforo_ai_tenant_status' );
1547
1548 $this->log_info( 'license_activated', [
1549 'license_id' => $license_id,
1550 'plan' => $response['plan'] ?? '',
1551 ] );
1552
1553 wp_send_json_success( [
1554 'message' => $response['message'] ?? 'License activated successfully',
1555 'plan' => $response['plan'] ?? '',
1556 'credits_added' => $response['credits_added'] ?? 0,
1557 ] );
1558 }
1559
1560 /**
1561 * AJAX handler for activating a Paddle transaction manually.
1562 * Mirrors ajax_activate_license() but for Paddle Transaction IDs.
1563 */
1564 public function ajax_activate_paddle_transaction() {
1565 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1566
1567 if ( ! current_user_can( 'manage_options' ) ) {
1568 wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 );
1569 }
1570
1571 $transaction_id = isset( $_POST['transaction_id'] ) ? sanitize_text_field( $_POST['transaction_id'] ) : '';
1572
1573 if ( empty( $transaction_id ) ) {
1574 wp_send_json_error( [ 'message' => 'Please enter your Transaction ID' ], 400 );
1575 }
1576
1577 if ( strpos( $transaction_id, 'txn_' ) !== 0 ) {
1578 wp_send_json_error( [ 'message' => 'Invalid Transaction ID format. Must start with "txn_".' ], 400 );
1579 }
1580
1581 $response = $this->post( '/tenant/activate-paddle-transaction', [
1582 'transaction_id' => $transaction_id,
1583 ] );
1584
1585 if ( is_wp_error( $response ) ) {
1586 $this->log_error( 'paddle_transaction_activation_failed', $response->get_error_message() );
1587 wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 );
1588 }
1589
1590 // Clear cached subscription data so it refreshes
1591 delete_option( 'wpforo_ai_subscription_plan' );
1592 delete_transient( 'wpforo_ai_subscription' );
1593 delete_transient( 'wpforo_ai_tenant_status' );
1594
1595 $this->log_info( 'paddle_transaction_activated', [
1596 'transaction_id' => $transaction_id,
1597 'plan' => $response['plan'] ?? '',
1598 'transaction_type' => $response['transaction_type'] ?? '',
1599 ] );
1600
1601 wp_send_json_success( [
1602 'message' => $response['message'] ?? 'Transaction activated successfully',
1603 'plan' => $response['plan'] ?? '',
1604 'credits_added' => $response['credits_added'] ?? 0,
1605 'transaction_type' => $response['transaction_type'] ?? '',
1606 ] );
1607 }
1608
1609 /**
1610 * AJAX handler for creating a Paddle checkout
1611 *
1612 * Creates a server-side Paddle transaction via the backend.
1613 * Returns a checkout URL where Paddle.js is loaded and opens the
1614 * checkout overlay for the transaction.
1615 *
1616 * @return void Sends JSON response with checkout_url
1617 */
1618 public function ajax_paddle_checkout() {
1619 // Verify nonce
1620 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1621
1622 // Check user permissions
1623 if ( ! current_user_can( 'manage_options' ) ) {
1624 wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 );
1625 }
1626
1627 $price_id = isset( $_POST['price_id'] ) ? sanitize_text_field( $_POST['price_id'] ) : '';
1628 $plan = isset( $_POST['plan'] ) ? sanitize_text_field( $_POST['plan'] ) : '';
1629
1630 if ( empty( $price_id ) ) {
1631 wp_send_json_error( [ 'message' => 'Missing price_id' ], 400 );
1632 }
1633
1634 // Get tenant info
1635 $tenant_id = $this->get_tenant_id();
1636 $current_user = wp_get_current_user();
1637 $customer_email = ! empty( $current_user->user_email ) ? $current_user->user_email : get_option( 'admin_email' );
1638 $customer_name = trim( $current_user->first_name . ' ' . $current_user->last_name );
1639
1640 if ( empty( $tenant_id ) ) {
1641 wp_send_json_error( [ 'message' => 'Not connected. Please generate an API key first.' ], 400 );
1642 }
1643
1644 // Call backend Lambda to create Paddle checkout transaction
1645 $response = $this->post( '/paddle/create-checkout', [
1646 'tenant_id' => $tenant_id,
1647 'price_id' => $price_id,
1648 'customer_email' => $customer_email,
1649 'customer_name' => $customer_name ?: null,
1650 'site_url' => site_url(),
1651 ] );
1652
1653 if ( is_wp_error( $response ) ) {
1654 $this->log_error( 'paddle_checkout_failed', $response->get_error_message() );
1655 wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 );
1656 }
1657
1658 $checkout_url = $response['checkout_url'] ?? '';
1659 if ( empty( $checkout_url ) ) {
1660 wp_send_json_error( [ 'message' => 'No checkout URL returned. Please try again.' ], 500 );
1661 }
1662
1663 $this->log_info( 'paddle_checkout_created', [
1664 'price_id' => $price_id,
1665 'plan' => $plan,
1666 'transaction_id' => $response['transaction_id'] ?? '',
1667 ] );
1668
1669 wp_send_json_success( [
1670 'checkout_url' => $checkout_url,
1671 'transaction_id' => $response['transaction_id'] ?? '',
1672 ] );
1673 }
1674
1675 /**
1676 * AJAX handler for linking Paddle subscription to tenant
1677 *
1678 * Called after Paddle checkout to store paddle_subscription_id and paddle_customer_id.
1679 * This is a belt-and-suspenders approach — webhooks should already handle this via
1680 * custom_data.tenant_id, but calling this ensures the link is established immediately.
1681 *
1682 * @return void Sends JSON response
1683 */
1684 public function ajax_link_paddle_subscription() {
1685 // Verify nonce
1686 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1687
1688 // Check user permissions
1689 if ( ! current_user_can( 'manage_options' ) ) {
1690 wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 );
1691 }
1692
1693 $paddle_subscription_id = isset( $_POST['paddle_subscription_id'] ) ? sanitize_text_field( $_POST['paddle_subscription_id'] ) : '';
1694 $paddle_customer_id = isset( $_POST['paddle_customer_id'] ) ? sanitize_text_field( $_POST['paddle_customer_id'] ) : '';
1695 $plan = isset( $_POST['plan'] ) ? sanitize_text_field( $_POST['plan'] ) : '';
1696
1697 if ( empty( $paddle_subscription_id ) ) {
1698 wp_send_json_error( [ 'message' => 'Missing paddle_subscription_id' ], 400 );
1699 }
1700
1701 // Call backend API to link Paddle subscription
1702 $response = $this->post( '/tenant/link-paddle-subscription', [
1703 'paddle_subscription_id' => $paddle_subscription_id,
1704 'paddle_customer_id' => $paddle_customer_id,
1705 'plan' => $plan,
1706 ] );
1707
1708 if ( is_wp_error( $response ) ) {
1709 $this->log_error( 'link_paddle_subscription_failed', $response->get_error_message() );
1710 wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 );
1711 }
1712
1713 // Store payment provider locally for manage subscription URL routing
1714 update_option( 'wpforo_ai_payment_provider', 'paddle' );
1715
1716 $this->log_info( 'paddle_subscription_linked', [
1717 'paddle_subscription_id' => $paddle_subscription_id,
1718 'paddle_customer_id' => $paddle_customer_id,
1719 'plan' => $plan,
1720 ] );
1721
1722 wp_send_json_success( [ 'message' => 'Paddle subscription linked successfully' ] );
1723 }
1724
1725 /**
1726 * AJAX handler for searching bot users (for Bot Reply settings)
1727 *
1728 * Searches for activated WordPress users by login, display name, or email.
1729 * Only returns users with empty user_activation_key (active accounts).
1730 *
1731 * @return void Sends JSON response
1732 */
1733 public function ajax_search_bot_users() {
1734 // Verify nonce - use settings form nonce
1735 check_ajax_referer( 'wpforo_ai_bot_user_search', '_wpnonce' );
1736
1737 // Check user permissions
1738 if ( ! current_user_can( 'manage_options' ) ) {
1739 wp_send_json_error( [ 'message' => 'Permission denied' ] );
1740 }
1741
1742 $search = sanitize_text_field( $_POST['search'] ?? '' );
1743 $user_id = intval( $_POST['user_id'] ?? 0 );
1744
1745 global $wpdb;
1746
1747 // If user_id is provided, look up that specific user
1748 if ( $user_id > 0 ) {
1749 $user = get_userdata( $user_id );
1750 if ( $user ) {
1751 $role = ! empty( $user->roles ) ? ucfirst( $user->roles[0] ) : '';
1752 wp_send_json_success( [
1753 'users' => [
1754 [
1755 'id' => $user->ID,
1756 'user_login' => $user->user_login,
1757 'display_name' => $user->display_name,
1758 'role' => $role,
1759 'label' => sprintf(
1760 '%s (%s)%s',
1761 $user->display_name,
1762 $user->user_login,
1763 $role ? ' - ' . $role : ''
1764 ),
1765 ]
1766 ]
1767 ] );
1768 } else {
1769 wp_send_json_success( [ 'users' => [] ] );
1770 }
1771 return;
1772 }
1773
1774 // Otherwise, search by text
1775 if ( strlen( $search ) < 2 ) {
1776 wp_send_json_success( [ 'users' => [] ] );
1777 }
1778
1779 // Search for activated users (empty user_activation_key) by login, display name, or email
1780 $like = '%' . $wpdb->esc_like( $search ) . '%';
1781 $users = $wpdb->get_results(
1782 $wpdb->prepare(
1783 "SELECT ID, user_login, display_name, user_email
1784 FROM {$wpdb->users}
1785 WHERE user_activation_key = ''
1786 AND (user_login LIKE %s OR display_name LIKE %s OR user_email LIKE %s)
1787 ORDER BY display_name ASC
1788 LIMIT 50",
1789 $like,
1790 $like,
1791 $like
1792 )
1793 );
1794
1795 // Batch fetch user roles using single query
1796 $user_ids = wp_list_pluck( $users, 'ID' );
1797 $user_roles = [];
1798 if ( ! empty( $user_ids ) ) {
1799 $wp_users = get_users( [ 'include' => $user_ids, 'fields' => 'all_with_meta' ] );
1800 foreach ( $wp_users as $wp_user ) {
1801 $user_roles[ $wp_user->ID ] = ! empty( $wp_user->roles ) ? ucfirst( $wp_user->roles[0] ) : '';
1802 }
1803 }
1804
1805 $results = [];
1806 foreach ( $users as $user ) {
1807 $role = $user_roles[ $user->ID ] ?? '';
1808 $results[] = [
1809 'id' => $user->ID,
1810 'user_login' => $user->user_login,
1811 'display_name' => $user->display_name,
1812 'role' => $role,
1813 'label' => sprintf(
1814 '%s (%s)%s',
1815 $user->display_name,
1816 $user->user_login,
1817 $role ? ' - ' . $role : ''
1818 ),
1819 ];
1820 }
1821
1822 wp_send_json_success( [ 'users' => $results ] );
1823 }
1824
1825 /**
1826 * AJAX handler for getting analytics data
1827 *
1828 * Fetches AI usage analytics from backend API with local caching
1829 *
1830 * @return void Sends JSON response
1831 */
1832 public function ajax_get_analytics() {
1833 // Verify nonce
1834 check_ajax_referer( 'wpforo_ai_analytics_nonce', 'nonce' );
1835
1836 // Check user permissions
1837 if ( ! current_user_can( 'manage_options' ) ) {
1838 wp_send_json_error( [
1839 'message' => wpforo_phrase( 'Insufficient permissions', false )
1840 ], 403 );
1841 }
1842
1843 // Get parameters
1844 $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0;
1845 $start_time = isset( $_POST['start_time'] ) ? intval( $_POST['start_time'] ) : strtotime( '-7 days' );
1846 $end_time = isset( $_POST['end_time'] ) ? intval( $_POST['end_time'] ) : time();
1847
1848 // Check cache first
1849 $cache_key = 'analytics_usage_' . md5( $board_id . ':' . $start_time . ':' . $end_time );
1850 $cached_data = $this->get_analytics_cache( $cache_key );
1851
1852 if ( $cached_data !== false ) {
1853 wp_send_json_success( $cached_data );
1854 return;
1855 }
1856
1857 // Fetch from backend API
1858 $analytics_data = $this->fetch_analytics_from_api( $start_time, $end_time, $board_id );
1859
1860 if ( is_wp_error( $analytics_data ) ) {
1861 wp_send_json_error( [
1862 'message' => $analytics_data->get_error_message()
1863 ] );
1864 return;
1865 }
1866
1867 // Cache the result for 1 hour
1868 $this->set_analytics_cache( $cache_key, $analytics_data, 3600 );
1869
1870 wp_send_json_success( $analytics_data );
1871 }
1872
1873 /**
1874 * Fetch analytics data from backend API
1875 *
1876 * @param int $start_time Start timestamp
1877 * @param int $end_time End timestamp
1878 * @param int $board_id Board ID for filtering (0 for all boards)
1879 * @return array|WP_Error Analytics data or error
1880 */
1881 private function fetch_analytics_from_api( $start_time, $end_time, $board_id = 0 ) {
1882 // Build request data
1883 $data = [
1884 'start_time' => $start_time,
1885 'end_time' => $end_time,
1886 'granularity' => $this->determine_granularity( $start_time, $end_time ),
1887 'group_by' => 'request_type',
1888 ];
1889
1890 // Add board_id filter if specified (non-zero)
1891 if ( $board_id > 0 ) {
1892 $data['board_id'] = $board_id;
1893 }
1894
1895 // Make API request (longer timeout for large date ranges scanning CloudWatch logs)
1896 $response = $this->make_request( 'POST', '/logs/analytics', $data, [], 45 );
1897
1898 if ( is_wp_error( $response ) ) {
1899 return $response;
1900 }
1901
1902 // Process and structure the response
1903 return $this->process_analytics_response( $response, $start_time, $end_time );
1904 }
1905
1906 /**
1907 * Process analytics API response into structured format
1908 *
1909 * @param array $response Raw API response
1910 * @param int $start_time Start timestamp
1911 * @param int $end_time End timestamp
1912 * @return array Processed analytics data
1913 */
1914 private function process_analytics_response( $response, $start_time, $end_time ) {
1915 $data = wpfval( $response, 'data' ) ?: $response;
1916
1917 // Calculate days in range for average
1918 $days_in_range = max( 1, ceil( ( $end_time - $start_time ) / DAY_IN_SECONDS ) );
1919
1920 // Time series data
1921 $time_series = wpfval( $data, 'time_series' ) ?: [];
1922
1923 // Feature breakdown
1924 $by_feature = wpfval( $data, 'by_feature' ) ?: [];
1925
1926 // Moderation stats
1927 $moderation = wpfval( $data, 'moderation' ) ?: [
1928 'spam_blocked' => 0,
1929 'toxic_detected' => 0,
1930 'policy_violations' => 0,
1931 'clean_passed' => 0,
1932 ];
1933
1934 // Summary calculations - prefer by_feature, fallback to time_series
1935 $total_credits = 0;
1936 $total_requests = 0;
1937 $success_count = 0;
1938
1939 if ( ! empty( $by_feature ) ) {
1940 // Calculate from feature breakdown (more accurate)
1941 foreach ( $by_feature as $feature => $stats ) {
1942 $total_credits += (float) wpfval( $stats, 'credits' ) ?: 0;
1943 $total_requests += (int) wpfval( $stats, 'requests' ) ?: 0;
1944 $success_count += (int) wpfval( $stats, 'success_count' ) ?: wpfval( $stats, 'requests' ) ?: 0;
1945 }
1946 } else {
1947 // Fallback: calculate from time series data
1948 foreach ( $time_series as $point ) {
1949 $total_credits += (float) wpfval( $point, 'credits' ) ?: 0;
1950 $total_requests += (int) wpfval( $point, 'requests' ) ?: 0;
1951 }
1952 $success_count = $total_requests; // Assume all successful when no feature breakdown
1953 }
1954
1955 $success_rate = $total_requests > 0 ? ( $success_count / $total_requests ) * 100 : 100;
1956
1957 return [
1958 'time_series' => $time_series,
1959 'by_feature' => $by_feature,
1960 'moderation' => $moderation,
1961 'summary' => [
1962 'total_credits' => round( $total_credits, 2 ),
1963 'total_requests' => $total_requests,
1964 'success_rate' => round( $success_rate, 1 ),
1965 'avg_credits_per_day' => round( $total_credits / $days_in_range, 2 ),
1966 ],
1967 ];
1968 }
1969
1970 /**
1971 * Determine granularity based on time range
1972 *
1973 * @param int $start_time Start timestamp
1974 * @param int $end_time End timestamp
1975 * @return string Granularity (daily, weekly, monthly)
1976 */
1977 private function determine_granularity( $start_time, $end_time ) {
1978 $days = ( $end_time - $start_time ) / DAY_IN_SECONDS;
1979
1980 if ( $days <= 31 ) {
1981 return 'daily';
1982 } elseif ( $days <= 180 ) {
1983 return 'weekly';
1984 } else {
1985 return 'monthly';
1986 }
1987 }
1988
1989 /**
1990 * Get cached analytics data
1991 *
1992 * @param string $cache_key Cache key
1993 * @return mixed Cached data or false if not found/expired
1994 */
1995 private function get_analytics_cache( $cache_key ) {
1996 global $wpdb;
1997 $table = $wpdb->prefix . 'wpforo_ai_cache';
1998
1999 // Suppress errors and return false on any database issue
2000 // This prevents cache table issues from breaking analytics
2001 $wpdb->suppress_errors( true );
2002 $result = $wpdb->get_row( $wpdb->prepare(
2003 "SELECT response, expires_at FROM {$table}
2004 WHERE cache_key = %s AND type = 'analytics' AND expires_at > %d",
2005 $cache_key,
2006 time()
2007 ) );
2008 $wpdb->suppress_errors( false );
2009
2010 // Check for database errors (table doesn't exist, column issues, etc.)
2011 if ( $wpdb->last_error ) {
2012 return false;
2013 }
2014
2015 if ( $result && ! empty( $result->response ) ) {
2016 $data = json_decode( $result->response, true );
2017 if ( json_last_error() === JSON_ERROR_NONE ) {
2018 return $data;
2019 }
2020 }
2021
2022 return false;
2023 }
2024
2025 /**
2026 * Set analytics cache
2027 *
2028 * @param string $cache_key Cache key
2029 * @param array $data Data to cache
2030 * @param int $ttl Time to live in seconds
2031 * @return bool Success
2032 */
2033 private function set_analytics_cache( $cache_key, $data, $ttl = 3600 ) {
2034 global $wpdb;
2035 $table = $wpdb->prefix . 'wpforo_ai_cache';
2036
2037 $expires_at = time() + $ttl;
2038 $cache_value = wp_json_encode( $data );
2039
2040 // Suppress errors - caching failure shouldn't break analytics
2041 // This handles cases where the table doesn't exist or has schema issues
2042 $wpdb->suppress_errors( true );
2043 $result = $wpdb->replace( $table, [
2044 'cache_key' => $cache_key,
2045 'type' => 'analytics',
2046 'response' => $cache_value,
2047 'expires_at' => $expires_at,
2048 'postid' => 0,
2049 ], [ '%s', '%s', '%s', '%d', '%d' ] );
2050 $wpdb->suppress_errors( false );
2051
2052 return $result !== false && ! $wpdb->last_error;
2053 }
2054
2055 /**
2056 * AJAX handler for running AI insights analysis
2057 *
2058 * Sends forum content to AI for analysis (sentiment, trending, recommendations)
2059 * Uses credits and returns results with HTML rendering.
2060 *
2061 * @return void Sends JSON response
2062 */
2063 public function ajax_run_insight() {
2064 // Track start time for logging
2065 $start_time = microtime( true );
2066
2067 // Load analytics functions (needed for caching and rendering)
2068 require_once WPFORO_DIR . '/admin/pages/tabs/ai-features-tab-analytics.php';
2069 // Verify nonce
2070 check_ajax_referer( 'wpforo_ai_insights_nonce', 'nonce' );
2071
2072 // Check user permissions
2073 if ( ! current_user_can( 'manage_options' ) ) {
2074 wp_send_json_error( [
2075 'message' => wpforo_phrase( 'Insufficient permissions', false )
2076 ], 403 );
2077 }
2078
2079 // Get parameters
2080 $insight_type = isset( $_POST['insight_type'] ) ? sanitize_key( $_POST['insight_type'] ) : '';
2081 $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0;
2082
2083 // Validate insight type
2084 $valid_types = [ 'sentiment', 'trending', 'recommendations', 'deep_analysis', 'sentiment_trend' ];
2085 if ( ! in_array( $insight_type, $valid_types, true ) ) {
2086 wp_send_json_error( [
2087 'message' => wpforo_phrase( 'Invalid insight type', false )
2088 ] );
2089 return;
2090 }
2091
2092 // Get credit costs
2093 $credit_costs = [
2094 'sentiment' => 2,
2095 'trending' => 1,
2096 'recommendations' => 1,
2097 'deep_analysis' => 5,
2098 'sentiment_trend' => 4,
2099 ];
2100 $credit_cost = $credit_costs[ $insight_type ];
2101
2102 // Insight types with daily limits
2103 $daily_limit_types = [ 'recommendations' ];
2104
2105 // Check daily limit for restricted insight types
2106 if ( in_array( $insight_type, $daily_limit_types, true ) ) {
2107 $cached_insights = wpforo_ai_get_cached_insights( $board_id );
2108 if ( isset( $cached_insights[ $insight_type ] ) && ! empty( $cached_insights[ $insight_type ]['timestamp'] ) ) {
2109 $cached_date = date( 'Y-m-d', $cached_insights[ $insight_type ]['timestamp'] );
2110 $today_date = date( 'Y-m-d', current_time( 'timestamp' ) );
2111 if ( $cached_date === $today_date ) {
2112 wp_send_json_error( [
2113 'message' => wpforo_phrase( 'This analysis is limited to once per day. Please try again tomorrow.', false )
2114 ] );
2115 return;
2116 }
2117 }
2118 }
2119
2120 // Check if tenant has enough credits
2121 $status = $this->get_tenant_status();
2122 $credits_remaining = 0;
2123 if ( ! is_wp_error( $status ) && isset( $status['subscription']['credits_remaining'] ) ) {
2124 $credits_remaining = (int) $status['subscription']['credits_remaining'];
2125 }
2126
2127 if ( $credits_remaining < $credit_cost ) {
2128 wp_send_json_error( [
2129 'message' => wpforo_phrase( 'Insufficient credits for this analysis', false )
2130 ] );
2131 return;
2132 }
2133
2134 // Switch to the board if needed
2135 if ( $board_id > 0 ) {
2136 WPF()->change_board( $board_id );
2137 }
2138
2139 // Gather forum data for analysis
2140 $content_sample = $this->gather_insight_content( $insight_type );
2141
2142 if ( empty( $content_sample ) ) {
2143 wp_send_json_error( [
2144 'message' => wpforo_phrase( 'Not enough forum content for analysis', false )
2145 ] );
2146 return;
2147 }
2148
2149 // Send to backend for AI analysis
2150 $result = $this->run_ai_insight( $insight_type, $content_sample );
2151
2152 if ( is_wp_error( $result ) ) {
2153 // Log the error
2154 $duration_ms = (int) ( ( microtime( true ) - $start_time ) * 1000 );
2155 WPF()->ai_logs->log( [
2156 'action_type' => AILogs::ACTION_ANALYTICS_INSIGHTS,
2157 'credits_used' => 0,
2158 'status' => AILogs::STATUS_ERROR,
2159 'request_summary' => 'Insight type: ' . $insight_type,
2160 'error_message' => $result->get_error_message(),
2161 'duration_ms' => $duration_ms,
2162 'user_type' => 'admin',
2163 ] );
2164
2165 wp_send_json_error( [
2166 'message' => $result->get_error_message()
2167 ] );
2168 return;
2169 }
2170
2171 // For deep_analysis, merge database metrics with LLM results
2172 if ( $insight_type === 'deep_analysis' ) {
2173 $db_metrics = $this->get_deep_analysis_db_metrics();
2174 $result = array_merge( $db_metrics, $result );
2175 }
2176
2177 // Cache the result
2178 wpforo_ai_cache_insight( $board_id, $insight_type, $result );
2179
2180 // Log successful insight generation
2181 $duration_ms = (int) ( ( microtime( true ) - $start_time ) * 1000 );
2182 WPF()->ai_logs->log( [
2183 'action_type' => AILogs::ACTION_ANALYTICS_INSIGHTS,
2184 'credits_used' => $credit_cost,
2185 'status' => AILogs::STATUS_SUCCESS,
2186 'request_summary' => 'Insight type: ' . $insight_type,
2187 'response_summary' => 'Generated ' . $insight_type . ' analysis successfully',
2188 'duration_ms' => $duration_ms,
2189 'user_type' => 'admin',
2190 ] );
2191
2192 // Get updated credits (clear cache first to force refresh)
2193 $this->clear_status_cache();
2194 $new_status = $this->get_tenant_status();
2195 $new_credits = 0;
2196 if ( ! is_wp_error( $new_status ) && isset( $new_status['subscription']['credits_remaining'] ) ) {
2197 $new_credits = (int) $new_status['subscription']['credits_remaining'];
2198 }
2199
2200 // Render HTML for the results
2201 ob_start();
2202 wpforo_ai_render_insight_results( $insight_type, $result );
2203 $html = ob_get_clean();
2204
2205 wp_send_json_success( [
2206 'html' => $html,
2207 'data' => $result,
2208 'credits_remaining' => $new_credits,
2209 ] );
2210 }
2211
2212 /**
2213 * Gather forum content for AI insight analysis
2214 *
2215 * @param string $insight_type Type of insight
2216 * @return array Content sample for analysis
2217 */
2218 private function gather_insight_content( $insight_type ) {
2219 $content = [];
2220
2221 switch ( $insight_type ) {
2222 case 'sentiment':
2223 // Get recent posts for sentiment analysis
2224 $posts = WPF()->db->get_results(
2225 "SELECT p.body, p.created, t.title as topic_title
2226 FROM " . WPF()->tables->posts . " p
2227 LEFT JOIN " . WPF()->tables->topics . " t ON p.topicid = t.topicid
2228 WHERE p.status = 0
2229 ORDER BY p.created DESC
2230 LIMIT 200",
2231 ARRAY_A
2232 );
2233 foreach ( $posts as $post ) {
2234 $content[] = [
2235 'text' => wp_strip_all_tags( $post['body'] ),
2236 'topic' => $post['topic_title'],
2237 ];
2238 }
2239 break;
2240
2241 case 'trending':
2242 // Get recent topics with activity metrics using JOIN instead of correlated subquery
2243 $seven_days_ago = time() - ( 7 * DAY_IN_SECONDS );
2244 $topics = WPF()->db->get_results(
2245 WPF()->db->prepare(
2246 "SELECT t.title, t.posts, t.views, t.created, COALESCE(rp.recent_posts, 0) as recent_posts
2247 FROM " . WPF()->tables->topics . " t
2248 LEFT JOIN (
2249 SELECT topicid, COUNT(*) as recent_posts
2250 FROM " . WPF()->tables->posts . "
2251 WHERE created > %d
2252 GROUP BY topicid
2253 ) rp ON t.topicid = rp.topicid
2254 WHERE t.status = 0
2255 AND t.created > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)
2256 ORDER BY t.created DESC
2257 LIMIT 100",
2258 $seven_days_ago
2259 ),
2260 ARRAY_A
2261 );
2262 foreach ( $topics as $topic ) {
2263 $content[] = [
2264 'title' => $topic['title'],
2265 'posts' => (int) $topic['posts'],
2266 'views' => (int) $topic['views'],
2267 'recent_posts' => (int) $topic['recent_posts'],
2268 ];
2269 }
2270 break;
2271
2272 case 'recommendations':
2273 // Get forum statistics for recommendations
2274 $stats = [];
2275
2276 // Total topics and posts
2277 $stats['total_topics'] = (int) WPF()->db->get_var(
2278 "SELECT COUNT(*) FROM " . WPF()->tables->topics . " WHERE status = 0"
2279 );
2280 $stats['total_posts'] = (int) WPF()->db->get_var(
2281 "SELECT COUNT(*) FROM " . WPF()->tables->posts . " WHERE status = 0"
2282 );
2283
2284 // Unanswered topics
2285 $stats['unanswered_topics'] = (int) WPF()->db->get_var(
2286 "SELECT COUNT(*) FROM " . WPF()->tables->topics . " WHERE status = 0 AND posts = 1"
2287 );
2288
2289 // Active users this week
2290 $stats['active_users_week'] = (int) WPF()->db->get_var(
2291 "SELECT COUNT(DISTINCT userid) FROM " . WPF()->tables->posts . "
2292 WHERE created > UNIX_TIMESTAMP(NOW() - INTERVAL 7 DAY)"
2293 );
2294
2295 // Average response time (first reply)
2296 $stats['avg_response_hours'] = WPF()->db->get_var(
2297 "SELECT AVG(TIMESTAMPDIFF(HOUR, FROM_UNIXTIME(t.created), FROM_UNIXTIME(
2298 (SELECT MIN(p.created) FROM " . WPF()->tables->posts . " p
2299 WHERE p.topicid = t.topicid AND p.is_first_post = 0)
2300 )))
2301 FROM " . WPF()->tables->topics . " t
2302 WHERE t.posts > 1 AND t.created > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)"
2303 );
2304
2305 // Recent topic titles for context
2306 $recent_topics = WPF()->db->get_col(
2307 "SELECT title FROM " . WPF()->tables->topics . "
2308 WHERE status = 0
2309 ORDER BY created DESC LIMIT 50"
2310 );
2311
2312 $content = [
2313 'stats' => $stats,
2314 'recent_topics' => $recent_topics,
2315 ];
2316 break;
2317
2318 case 'deep_analysis':
2319 // Get comprehensive forum data for deep analysis
2320 $data = [];
2321
2322 // Get usergroup IDs that have 'aum' (admin user management) permission
2323 // These are admins/moderators who should be excluded from contributor analysis
2324 $admin_groupids = [];
2325 $all_groups = WPF()->db->get_results(
2326 "SELECT groupid, cans FROM " . WPF()->tables->usergroups,
2327 ARRAY_A
2328 );
2329 foreach ( $all_groups as $group ) {
2330 $cans = maybe_unserialize( $group['cans'] );
2331 if ( is_array( $cans ) && ! empty( $cans['aum'] ) ) {
2332 $admin_groupids[] = (int) $group['groupid'];
2333 }
2334 }
2335 // Fallback to default admin/mod groups if none found
2336 if ( empty( $admin_groupids ) ) {
2337 $admin_groupids = [ 1, 2 ];
2338 }
2339 $admin_groupids_str = implode( ',', $admin_groupids );
2340
2341 // User engagement data - exclude users with admin permissions (aum capability)
2342 $data['user_stats'] = WPF()->db->get_results(
2343 "SELECT p.userid, COUNT(*) as post_count
2344 FROM " . WPF()->tables->posts . " p
2345 INNER JOIN " . WPF()->tables->profiles . " pr ON p.userid = pr.userid
2346 WHERE p.status = 0
2347 AND p.created > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)
2348 AND pr.groupid NOT IN ({$admin_groupids_str})
2349 GROUP BY p.userid
2350 ORDER BY post_count DESC
2351 LIMIT 20",
2352 ARRAY_A
2353 );
2354
2355 // Topic and post length metrics
2356 $data['content_metrics'] = WPF()->db->get_row(
2357 "SELECT
2358 AVG(LENGTH(p.body)) as avg_post_length,
2359 AVG(CASE WHEN p.is_first_post = 1 THEN LENGTH(p.body) END) as avg_topic_length
2360 FROM " . WPF()->tables->posts . " p
2361 WHERE p.status = 0 AND p.created > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)",
2362 ARRAY_A
2363 );
2364
2365 // Recent posts with content for keyword/sentiment analysis
2366 // Exclude users with admin permissions (aum capability)
2367 $posts = WPF()->db->get_results(
2368 "SELECT p.body, p.created, p.userid, t.title as topic_title
2369 FROM " . WPF()->tables->posts . " p
2370 LEFT JOIN " . WPF()->tables->topics . " t ON p.topicid = t.topicid
2371 INNER JOIN " . WPF()->tables->profiles . " pr ON p.userid = pr.userid
2372 WHERE p.status = 0
2373 AND pr.groupid NOT IN ({$admin_groupids_str})
2374 ORDER BY p.created DESC
2375 LIMIT 150",
2376 ARRAY_A
2377 );
2378
2379 // Batch fetch all user display names for user_stats and posts
2380 $all_user_ids = array_merge(
2381 array_column( $data['user_stats'], 'userid' ),
2382 array_column( $posts, 'userid' )
2383 );
2384 $user_names = $this->batch_get_user_display_names( $all_user_ids );
2385 $guest_label = wpforo_phrase( 'Guest', false );
2386
2387 // Get usernames for top posters
2388 foreach ( $data['user_stats'] as &$user ) {
2389 $user['username'] = $user_names[ $user['userid'] ] ?? $guest_label;
2390 }
2391
2392 // Build posts array with usernames
2393 $data['posts'] = [];
2394 foreach ( $posts as $post ) {
2395 $timestamp = is_numeric( $post['created'] ) ? $post['created'] : strtotime( $post['created'] );
2396 $data['posts'][] = [
2397 'text' => wp_strip_all_tags( $post['body'] ),
2398 'topic' => $post['topic_title'],
2399 'username' => $user_names[ $post['userid'] ] ?? $guest_label,
2400 'date' => date( 'Y-m-d H:i', $timestamp ),
2401 ];
2402 }
2403
2404 // Reply frequency data
2405 $data['reply_stats'] = WPF()->db->get_row(
2406 "SELECT
2407 COUNT(*) as total_posts,
2408 SUM(CASE WHEN is_first_post = 0 THEN 1 ELSE 0 END) as total_replies,
2409 COUNT(DISTINCT userid) as unique_users
2410 FROM " . WPF()->tables->posts . "
2411 WHERE status = 0 AND created > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)",
2412 ARRAY_A
2413 );
2414
2415 $content = $data;
2416 break;
2417
2418 case 'sentiment_trend':
2419 // Get posts with timestamps for trend analysis
2420 $posts = WPF()->db->get_results(
2421 "SELECT p.body, p.created, t.title as topic_title, u.display_name
2422 FROM " . WPF()->tables->posts . " p
2423 LEFT JOIN " . WPF()->tables->topics . " t ON p.topicid = t.topicid
2424 LEFT JOIN " . WPF()->db->users . " u ON p.userid = u.ID
2425 WHERE p.status = 0 AND p.created > DATE_SUB(NOW(), INTERVAL 30 DAY)
2426 ORDER BY p.created ASC
2427 LIMIT 300",
2428 ARRAY_A
2429 );
2430
2431 foreach ( $posts as $post ) {
2432 $timestamp = is_numeric( $post['created'] ) ? $post['created'] : strtotime( $post['created'] );
2433 $content[] = [
2434 'text' => wp_strip_all_tags( $post['body'] ),
2435 'topic' => $post['topic_title'],
2436 'timestamp' => date( 'Y-m-d', $timestamp ),
2437 'author' => $post['display_name'] ?: 'Guest',
2438 ];
2439 }
2440 break;
2441 }
2442
2443 return $content;
2444 }
2445
2446 /**
2447 * Calculate deep analysis metrics from database
2448 *
2449 * These are factual metrics that should be computed from the database,
2450 * not generated by the LLM.
2451 *
2452 * @return array Database-calculated metrics
2453 */
2454 private function get_deep_analysis_db_metrics() {
2455 $metrics = [];
2456
2457 // Get total users and active users
2458 $total_members = (int) WPF()->db->get_var(
2459 "SELECT COUNT(*) FROM " . WPF()->tables->members
2460 );
2461
2462 $active_users = (int) WPF()->db->get_var(
2463 "SELECT COUNT(DISTINCT userid) FROM " . WPF()->tables->posts . "
2464 WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)"
2465 );
2466
2467 // Get reply stats
2468 $reply_stats = WPF()->db->get_row(
2469 "SELECT
2470 COUNT(*) as total_posts,
2471 SUM(CASE WHEN is_first_post = 0 THEN 1 ELSE 0 END) as total_replies,
2472 COUNT(DISTINCT userid) as unique_users
2473 FROM " . WPF()->tables->posts . "
2474 WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)",
2475 ARRAY_A
2476 );
2477
2478 $avg_replies_per_user = 0;
2479 if ( $reply_stats && $reply_stats['unique_users'] > 0 ) {
2480 $avg_replies_per_user = round( (int) $reply_stats['total_replies'] / (int) $reply_stats['unique_users'], 1 );
2481 }
2482
2483 $active_users_percent = 0;
2484 if ( $total_members > 0 ) {
2485 $active_users_percent = round( ( $active_users / $total_members ) * 100, 1 );
2486 }
2487
2488 // Get average response time (hours between topic creation and first reply)
2489 $avg_response_hours = WPF()->db->get_var(
2490 "SELECT AVG(response_time) FROM (
2491 SELECT TIMESTAMPDIFF(HOUR, t.created,
2492 (SELECT MIN(p.created) FROM " . WPF()->tables->posts . " p
2493 WHERE p.topicid = t.topicid AND p.is_first_post = 0)
2494 ) as response_time
2495 FROM " . WPF()->tables->topics . " t
2496 WHERE t.posts > 1 AND t.created > DATE_SUB(NOW(), INTERVAL 30 DAY)
2497 ) as response_times WHERE response_time IS NOT NULL"
2498 );
2499
2500 // Get top repliers with usernames
2501 $top_repliers_raw = WPF()->db->get_results(
2502 "SELECT userid, COUNT(*) as reply_count
2503 FROM " . WPF()->tables->posts . "
2504 WHERE status = 0 AND is_first_post = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)
2505 GROUP BY userid
2506 ORDER BY reply_count DESC
2507 LIMIT 5",
2508 ARRAY_A
2509 );
2510
2511 // Batch fetch user display names with proper fallback chain
2512 $replier_user_ids = array_column( $top_repliers_raw, 'userid' );
2513 $replier_names = $this->batch_get_user_display_names( $replier_user_ids );
2514 $guest_label = wpforo_phrase( 'Guest', false );
2515
2516 $top_repliers = [];
2517 foreach ( $top_repliers_raw as $replier ) {
2518 $top_repliers[] = [
2519 'username' => $replier_names[ $replier['userid'] ] ?? $guest_label,
2520 'reply_count' => (int) $replier['reply_count'],
2521 'sentiment' => 'neutral', // Will be filled by LLM if available
2522 ];
2523 }
2524
2525 // Get content metrics
2526 $content_stats = WPF()->db->get_row(
2527 "SELECT
2528 AVG(LENGTH(body) / 5) as avg_reply_words,
2529 AVG(CASE WHEN is_first_post = 1 THEN LENGTH(body) / 5 END) as avg_topic_words
2530 FROM " . WPF()->tables->posts . "
2531 WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)",
2532 ARRAY_A
2533 );
2534
2535 // Build user_engagement data
2536 $metrics['user_engagement'] = [
2537 'avg_replies_per_user' => $avg_replies_per_user,
2538 'active_users_percent' => $active_users_percent,
2539 'lurker_percent' => max( 0, 100 - $active_users_percent ),
2540 'avg_response_time_hours' => round( floatval( $avg_response_hours ) ?: 0, 1 ),
2541 'top_repliers' => $top_repliers,
2542 'summary' => '', // Will be filled by LLM
2543 ];
2544
2545 // Build content_metrics data
2546 $avg_topic_words = round( floatval( $content_stats['avg_topic_words'] ?? 0 ) );
2547 $avg_reply_words = round( floatval( $content_stats['avg_reply_words'] ?? 0 ) );
2548
2549 $detailed_percent = 0;
2550 if ( $avg_reply_words > 0 ) {
2551 // Consider replies > 100 words as "detailed"
2552 $detailed_count = (int) WPF()->db->get_var(
2553 "SELECT COUNT(*) FROM " . WPF()->tables->posts . "
2554 WHERE status = 0 AND is_first_post = 0 AND LENGTH(body) / 5 > 100
2555 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)"
2556 );
2557 $total_replies = (int) ( $reply_stats['total_replies'] ?? 1 );
2558 $detailed_percent = $total_replies > 0 ? round( ( $detailed_count / $total_replies ) * 100 ) : 0;
2559 }
2560
2561 $metrics['content_metrics'] = [
2562 'avg_topic_length_words' => $avg_topic_words,
2563 'avg_reply_length_words' => $avg_reply_words,
2564 'detailed_discussions_percent' => $detailed_percent,
2565 'quick_exchanges_percent' => max( 0, 100 - $detailed_percent ),
2566 'summary' => '', // Will be filled by LLM
2567 ];
2568
2569 // Get activity patterns from database
2570 $peak_hours_raw = WPF()->db->get_results(
2571 "SELECT HOUR(created) as hour, COUNT(*) as cnt
2572 FROM " . WPF()->tables->posts . "
2573 WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)
2574 GROUP BY HOUR(created)
2575 ORDER BY cnt DESC
2576 LIMIT 3",
2577 ARRAY_A
2578 );
2579
2580 $peak_hours = [];
2581 foreach ( $peak_hours_raw as $h ) {
2582 $peak_hours[] = sprintf( '%02d:00', $h['hour'] );
2583 }
2584
2585 $peak_days_raw = WPF()->db->get_results(
2586 "SELECT DAYNAME(created) as day_name, COUNT(*) as cnt
2587 FROM " . WPF()->tables->posts . "
2588 WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)
2589 GROUP BY DAYNAME(created)
2590 ORDER BY cnt DESC
2591 LIMIT 3",
2592 ARRAY_A
2593 );
2594
2595 $peak_days = [];
2596 foreach ( $peak_days_raw as $d ) {
2597 $peak_days[] = $d['day_name'];
2598 }
2599
2600 // Determine trend by comparing last 15 days to previous 15 days
2601 $recent_count = (int) WPF()->db->get_var(
2602 "SELECT COUNT(*) FROM " . WPF()->tables->posts . "
2603 WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 15 DAY)"
2604 );
2605 $previous_count = (int) WPF()->db->get_var(
2606 "SELECT COUNT(*) FROM " . WPF()->tables->posts . "
2607 WHERE status = 0
2608 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)
2609 AND created <= DATE_SUB(NOW(), INTERVAL 15 DAY)"
2610 );
2611
2612 $trend = 'stable';
2613 if ( $previous_count > 0 ) {
2614 $change = ( $recent_count - $previous_count ) / $previous_count;
2615 if ( $change > 0.1 ) {
2616 $trend = 'increasing';
2617 } elseif ( $change < -0.1 ) {
2618 $trend = 'decreasing';
2619 }
2620 }
2621
2622 $metrics['activity_patterns'] = [
2623 'peak_hours' => $peak_hours,
2624 'peak_days' => $peak_days,
2625 'trend' => $trend,
2626 'summary' => '', // Will be filled by LLM
2627 ];
2628
2629 return $metrics;
2630 }
2631
2632 /**
2633 * Run AI insight analysis via backend API
2634 *
2635 * @param string $insight_type Type of insight
2636 * @param array $content Content to analyze
2637 * @return array|WP_Error Analysis result or error
2638 */
2639 private function run_ai_insight( $insight_type, $content ) {
2640 $data = [
2641 'insight_type' => $insight_type,
2642 'content' => $content,
2643 ];
2644
2645 $response = $this->post( '/analytics/insights', $data );
2646
2647 if ( is_wp_error( $response ) ) {
2648 return $response;
2649 }
2650
2651 // Extract result from response
2652 $result = wpfval( $response, 'data' ) ?: $response;
2653
2654 // Ensure expected structure based on type
2655 switch ( $insight_type ) {
2656 case 'sentiment':
2657 // 7 emotion categories
2658 $result = [
2659 'happy' => (int) wpfval( $result, 'happy' ) ?: 0,
2660 'excited' => (int) wpfval( $result, 'excited' ) ?: 0,
2661 'neutral' => (int) wpfval( $result, 'neutral' ) ?: 0,
2662 'confused' => (int) wpfval( $result, 'confused' ) ?: 0,
2663 'frustrated' => (int) wpfval( $result, 'frustrated' ) ?: 0,
2664 'angry' => (int) wpfval( $result, 'angry' ) ?: 0,
2665 'sad' => (int) wpfval( $result, 'sad' ) ?: 0,
2666 'summary' => wpfval( $result, 'summary' ) ?: '',
2667 ];
2668 break;
2669
2670 case 'trending':
2671 $result = [
2672 'topics' => wpfval( $result, 'topics' ) ?: [],
2673 'summary' => wpfval( $result, 'summary' ) ?: '',
2674 ];
2675 break;
2676
2677 case 'recommendations':
2678 $result = [
2679 'recommendations' => wpfval( $result, 'recommendations' ) ?: [],
2680 'summary' => wpfval( $result, 'summary' ) ?: '',
2681 ];
2682 break;
2683 }
2684
2685 return $result;
2686 }
2687
2688 /**
2689 * Get forum IDs the current user can view (for search filtering)
2690 *
2691 * Uses cached WPF()->current_user_accesses (board-specific).
2692 * Returns null if user can access all forums (no filtering needed).
2693 *
2694 * @return array|null Array of accessible forum IDs, or null for full access
2695 */
2696 public function get_accessible_forumids() {
2697 // Admins see everything
2698 if ( current_user_can( 'administrator' ) ) {
2699 return null;
2700 }
2701
2702 // Get all forums for current board (cached by usergroup)
2703 $all_forums = WPF()->forum->get_forums( [ 'type' => 'forum' ] );
2704 if ( empty( $all_forums ) ) {
2705 return null;
2706 }
2707
2708 $accessible = [];
2709 $total_forums = 0;
2710
2711 foreach ( $all_forums as $forum ) {
2712 if ( empty( $forum['is_cat'] ) ) { // Skip categories
2713 $total_forums++;
2714 // 'vf' = can view forum
2715 if ( WPF()->perm->forum_can( 'vf', $forum['forumid'] ) ) {
2716 $accessible[] = (int) $forum['forumid'];
2717 }
2718 }
2719 }
2720
2721 // If user can access all forums, return null (no filtering needed)
2722 if ( count( $accessible ) === $total_forums ) {
2723 return null;
2724 }
2725
2726 return $accessible;
2727 }
2728
2729 /**
2730 * Perform semantic search query
2731 *
2732 * @param string $query Search query text
2733 * @param int $limit Maximum number of results to return
2734 * @param array $filters Optional filters
2735 * @return array|WP_Error Search results or error object
2736 */
2737 public function semantic_search( $query, $limit = 10, $filters = [] ) {
2738 if ( empty( $query ) ) {
2739 return new \WP_Error( 'empty_query', wpforo_phrase( 'Search query cannot be empty', false ) );
2740 }
2741
2742 // Get tenant ID from stored status
2743 $status = $this->get_tenant_status();
2744 if ( is_wp_error( $status ) ) {
2745 return $status;
2746 }
2747
2748 $tenant_id = wpfval( $status, 'tenant_id' );
2749 if ( empty( $tenant_id ) ) {
2750 return new \WP_Error( 'no_tenant_id', wpforo_phrase( 'Tenant ID not found', false ) );
2751 }
2752
2753 // Automatically add current board_id to filters (multi-board support)
2754 // Note: board_id is stored as string in vector metadata, so we send it as string
2755 $current_boardid = (string) WPF()->board->get_current( 'boardid' );
2756 if ( ! isset( $filters['board_id'] ) ) {
2757 $filters['board_id'] = $current_boardid;
2758 }
2759
2760 // Add forum access filtering (only forums current user can view)
2761 // Skip if already set by VectorStorageManager (avoids double-add)
2762 // Returns null for admins or users with full access (no filtering needed)
2763 if ( ! isset( $filters['accessible_forumids'] ) ) {
2764 $accessible_forumids = $this->get_accessible_forumids();
2765 if ( $accessible_forumids !== null ) {
2766 $filters['accessible_forumids'] = $accessible_forumids;
2767 }
2768 }
2769
2770 $data = [
2771 'tenant_id' => $tenant_id,
2772 'query' => sanitize_text_field( $query ),
2773 'limit' => min( (int) $limit, 100 ), // Cap at 100 results
2774 ];
2775
2776 if ( ! empty( $filters ) ) {
2777 $data['filters'] = $filters;
2778 }
2779
2780 // Add quality parameter from settings (for re-ranking model selection)
2781 $search_quality = wpfval( WPF()->settings->ai, 'search_quality' );
2782 if ( ! empty( $search_quality ) ) {
2783 $data['quality'] = sanitize_text_field( $search_quality );
2784 }
2785
2786 // Add minimum score threshold from settings (server-side filtering)
2787 $min_score_setting = (int) wpfval( WPF()->settings->ai, 'search_min_score' );
2788 if ( $min_score_setting > 0 ) {
2789 $data['min_score'] = $min_score_setting / 100; // Convert percentage to 0-1
2790 }
2791
2792 $response = $this->post( '/search/semantic', $data );
2793
2794 if ( is_wp_error( $response ) ) {
2795 $this->log_error( 'semantic_search_failed', $response->get_error_message() );
2796 return $response;
2797 }
2798
2799 $this->log_info( 'semantic_search_completed', [
2800 'query' => $query,
2801 'results_count' => wpfval( $response, 'total' ) ?: 0
2802 ] );
2803
2804 return $response;
2805 }
2806
2807 /**
2808 * Generate embedding vector for content
2809 *
2810 * Used for local storage mode - generates embeddings via cloud API
2811 * but stores them locally in WordPress database.
2812 *
2813 * Supports multimodal image indexing (Professional+ plans):
2814 * - Pass images array with URLs from site domain
2815 * - Images are processed by vision models
2816 * - Returns processed_content with image descriptions appended
2817 *
2818 * Supports document indexing (Professional+ plans):
2819 * - Pass documents array with URLs from site domain
2820 * - Documents are processed (text extraction, OCR, embedded images)
2821 * - Returns processed_content with document text appended
2822 *
2823 * @param string $content Content text to embed
2824 * @param array $images Optional. Array of image data: [['url' => '...', 'attach_id' => 123], ...]
2825 * @param string $topic_context Optional. Topic title for better image/document descriptions
2826 * @param array $documents Optional. Array of document data: [['url' => '...', 'attach_id' => 123], ...]
2827 * @return array|WP_Error Array with 'embedding' key or error object. Also includes
2828 * 'processed_content' if images/documents were processed.
2829 */
2830 public function generate_embedding( $content, $images = [], $topic_context = '', $documents = [] ) {
2831 if ( empty( $content ) ) {
2832 return new \WP_Error( 'empty_content', wpforo_phrase( 'Content cannot be empty', false ) );
2833 }
2834
2835 // Get tenant ID from stored status
2836 $status = $this->get_tenant_status();
2837 if ( is_wp_error( $status ) ) {
2838 return $status;
2839 }
2840
2841 $tenant_id = wpfval( $status, 'tenant_id' );
2842 if ( empty( $tenant_id ) ) {
2843 return new \WP_Error( 'no_tenant_id', wpforo_phrase( 'Tenant ID not found', false ) );
2844 }
2845
2846 $data = [
2847 'tenant_id' => $tenant_id,
2848 'content' => $content,
2849 ];
2850
2851 // Add image processing parameters if images provided
2852 if ( ! empty( $images ) && is_array( $images ) ) {
2853 // Get site domain (without protocol)
2854 $site_url = get_site_url();
2855 $parsed = wp_parse_url( $site_url );
2856 $domain = $parsed['host'] ?? '';
2857
2858 $data['images'] = $images;
2859 $data['site_domain'] = $domain;
2860
2861 if ( ! empty( $topic_context ) ) {
2862 $data['topic_context'] = $topic_context;
2863 }
2864 }
2865
2866 // Add document processing parameters if documents provided
2867 if ( ! empty( $documents ) && is_array( $documents ) ) {
2868 if ( empty( $data['site_domain'] ) ) {
2869 $site_url = get_site_url();
2870 $parsed = wp_parse_url( $site_url );
2871 $data['site_domain'] = $parsed['host'] ?? '';
2872 }
2873 $data['documents'] = $documents;
2874 if ( ! empty( $topic_context ) && empty( $data['topic_context'] ) ) {
2875 $data['topic_context'] = $topic_context;
2876 }
2877 }
2878
2879 $response = $this->post( '/search/embedding/generate', $data );
2880
2881 if ( is_wp_error( $response ) ) {
2882 $this->log_error( 'embedding_generation_failed', $response->get_error_message() );
2883 return $response;
2884 }
2885
2886 // Validate response
2887 if ( ! isset( $response['embedding'] ) || ! is_array( $response['embedding'] ) ) {
2888 return new \WP_Error( 'invalid_response', wpforo_phrase( 'Invalid embedding response from API', false ) );
2889 }
2890
2891 $log_data = [
2892 'dimensions' => count( $response['embedding'] ),
2893 'credits_used' => $response['credits_used'] ?? 1,
2894 ];
2895
2896 // Log image processing stats if present
2897 if ( ! empty( $response['image_processing'] ) ) {
2898 $log_data['images_processed'] = $response['image_processing']['images_processed'] ?? 0;
2899 $log_data['images_skipped'] = $response['image_processing']['images_skipped'] ?? 0;
2900 }
2901
2902 // Log document processing stats if present
2903 if ( ! empty( $response['document_processing'] ) ) {
2904 $log_data['documents_processed'] = $response['document_processing']['documents_processed'] ?? 0;
2905 $log_data['total_pages'] = $response['document_processing']['total_pages'] ?? 0;
2906 }
2907
2908 $this->log_info( 'embedding_generated', $log_data );
2909
2910 return $response;
2911 }
2912
2913 /**
2914 * Generate embedding vectors for multiple content items in a single request
2915 *
2916 * Used for efficient local storage indexing - generates all embeddings
2917 * in one API call, matching the cloud indexing pattern.
2918 *
2919 * @param array $items Array of items: [ ['id' => 'post_123', 'content' => '...'], ... ]
2920 * @return array|WP_Error Response with 'results' array containing embeddings, or error
2921 */
2922 public function generate_embeddings_batch( $items, $topic_count = null ) {
2923 if ( empty( $items ) || ! is_array( $items ) ) {
2924 return new \WP_Error( 'empty_items', wpforo_phrase( 'Items array cannot be empty', false ) );
2925 }
2926
2927 // Get tenant ID from stored status
2928 $status = $this->get_tenant_status();
2929 if ( is_wp_error( $status ) ) {
2930 return $status;
2931 }
2932
2933 $tenant_id = wpfval( $status, 'tenant_id' );
2934 if ( empty( $tenant_id ) ) {
2935 return new \WP_Error( 'no_tenant_id', wpforo_phrase( 'Tenant ID not found', false ) );
2936 }
2937
2938 // Format items for API
2939 $api_items = [];
2940 foreach ( $items as $item ) {
2941 if ( ! empty( $item['id'] ) && ! empty( $item['content'] ) ) {
2942 $api_items[] = [
2943 'id' => (string) $item['id'],
2944 'content' => $item['content'],
2945 ];
2946 }
2947 }
2948
2949 if ( empty( $api_items ) ) {
2950 return new \WP_Error( 'no_valid_items', wpforo_phrase( 'No valid items to process', false ) );
2951 }
2952
2953 $data = [
2954 'tenant_id' => $tenant_id,
2955 'items' => $api_items,
2956 ];
2957
2958 // Add topic_count for credit charging (charge per topic, not per post)
2959 // IMPORTANT: Always send topic_count when provided, including 0 for continuation chunks
2960 // If topic_count is not sent, API falls back to per-item charging (expensive!)
2961 if ( $topic_count !== null ) {
2962 $data['topic_count'] = (int) $topic_count;
2963 }
2964
2965 \wpforo_ai_log( 'debug', sprintf(
2966 'generate_embeddings_batch: topic_count=%s, items=%d, data_keys=%s',
2967 $topic_count !== null ? (string) $topic_count : 'NULL',
2968 count( $api_items ),
2969 implode( ',', array_keys( $data ) )
2970 ), 'Client' );
2971
2972 // API Gateway has 29-second hard limit, so use 25 seconds to fail fast
2973 // With smaller batches (5 topics for local mode), this should be sufficient
2974 $response = $this->api_post( '/search/embedding/generate-batch', $data, 25 );
2975
2976 if ( is_wp_error( $response ) ) {
2977 $this->log_error( 'batch_embedding_failed', $response->get_error_message() );
2978 return $response;
2979 }
2980
2981 // Validate response
2982 if ( ! isset( $response['results'] ) || ! is_array( $response['results'] ) ) {
2983 return new \WP_Error( 'invalid_response', wpforo_phrase( 'Invalid batch embedding response from API', false ) );
2984 }
2985
2986 $this->log_info( 'batch_embedding_completed', [
2987 'total_items' => $response['total_items'] ?? count( $api_items ),
2988 'successful_items' => $response['successful_items'] ?? 0,
2989 'failed_items' => $response['failed_items'] ?? 0,
2990 'credits_used' => $response['credits_used'] ?? 0,
2991 ] );
2992
2993 return $response;
2994 }
2995
2996 /**
2997 * Enhance search results with AI-generated summary and recommendations
2998 *
2999 * Credits are consumed based on quality tier selected.
3000 *
3001 * @param string $query The original search query
3002 * @param array $results Array of search results (title, excerpt, url, score)
3003 * @param string $user_language Language for AI response (default: English)
3004 * @return array|WP_Error Enhancement data or error object
3005 */
3006 public function enhance_search_results( $query, $results, $user_language = 'English' ) {
3007 // Check if AI Summary & Recommendations is enabled
3008 // Handle various stored formats: true, "1", 1, "on" = enabled; false, "0", 0, "" = disabled
3009 $enhance_setting = wpfval( WPF()->settings->ai, 'search_enhance' );
3010 // Consider enabled if value is truthy and not explicitly "0" or 0
3011 $enhance_enabled = ! empty( $enhance_setting ) && $enhance_setting !== '0' && $enhance_setting !== 0 && $enhance_setting !== 'false';
3012 if ( ! $enhance_enabled ) {
3013 $this->log_info( 'search_enhance_disabled', [
3014 'setting_value' => $enhance_setting,
3015 'setting_type' => gettype( $enhance_setting ),
3016 ] );
3017 return [
3018 'success' => false,
3019 'disabled' => true,
3020 'summary' => '',
3021 'quick_answer' => '',
3022 'recommendations' => [],
3023 ];
3024 }
3025
3026 if ( empty( $query ) || empty( $results ) ) {
3027 return new \WP_Error( 'invalid_params', wpforo_phrase( 'Query and results are required', false ) );
3028 }
3029
3030 // Get tenant ID from stored status
3031 $status = $this->get_tenant_status();
3032 if ( is_wp_error( $status ) ) {
3033 return $status;
3034 }
3035
3036 $tenant_id = wpfval( $status, 'tenant_id' );
3037 if ( empty( $tenant_id ) ) {
3038 return new \WP_Error( 'no_tenant_id', wpforo_phrase( 'Tenant ID not found', false ) );
3039 }
3040
3041 // Format results for the API (max 5 results)
3042 $formatted_results = [];
3043 $result_num = 1;
3044 foreach ( array_slice( $results, 0, 5 ) as $result ) {
3045 $formatted_results[] = [
3046 'result_number' => $result_num,
3047 'title' => wpfval( $result, 'title' ) ?: '',
3048 'excerpt' => wpfval( $result, 'content' ) ?: '',
3049 'url' => wpfval( $result, 'url' ) ?: '',
3050 'score' => ( wpfval( $result, 'score' ) ?: 0 ) / 100, // Convert from % back to 0-1
3051 ];
3052 $result_num++;
3053 }
3054
3055 $data = [
3056 'tenant_id' => $tenant_id,
3057 'query' => sanitize_text_field( $query ),
3058 'user_language' => sanitize_text_field( $user_language ),
3059 'results' => $formatted_results,
3060 ];
3061
3062 // Add quality parameter from settings (for AI summary/recommendations model selection)
3063 $enhance_quality = wpfval( WPF()->settings->ai, 'search_enhance_quality' );
3064 if ( ! empty( $enhance_quality ) ) {
3065 $data['quality'] = sanitize_text_field( $enhance_quality );
3066 }
3067
3068 $response = $this->post( '/search/enhance', $data );
3069
3070 if ( is_wp_error( $response ) ) {
3071 $this->log_error( 'search_enhance_failed', $response->get_error_message() );
3072 return $response;
3073 }
3074
3075 $this->log_info( 'search_enhance_completed', [
3076 'query' => $query,
3077 'results_count' => count( $formatted_results ),
3078 'processing_time' => wpfval( $response, 'processing_time_ms' ) ?: 0,
3079 ] );
3080
3081 return $response;
3082 }
3083
3084 /**
3085 * Get user's language for AI responses
3086 *
3087 * Priority order:
3088 * 1. Explicit language code parameter (from POST/request)
3089 * 2. User preference (from user_meta if logged in)
3090 * 3. Board AI settings (search_language)
3091 * 4. Board locale
3092 * 5. WordPress locale
3093 * 6. Default (English)
3094 *
3095 * @param string|null $language_code Explicit language code (e.g., 'en_US', 'de_DE')
3096 * @return string Language name (e.g., "English", "Spanish", "French")
3097 */
3098 public function get_user_language( $language_code = null, $setting_key = 'search_language' ) {
3099 // Build language map from master list (2-letter code => English name)
3100 $language_map = [];
3101 foreach ( wpforo_get_ai_languages() as $lang ) {
3102 if ( ! isset( $language_map[ $lang['code'] ] ) ) {
3103 $language_map[ $lang['code'] ] = $lang['name'];
3104 }
3105 }
3106
3107 $locale = null;
3108
3109 // 1. Use explicit language code if provided
3110 if ( ! empty( $language_code ) ) {
3111 $locale = $language_code;
3112 }
3113
3114 // 2. If no explicit code, try user preferences (logged in users only)
3115 if ( empty( $locale ) ) {
3116 $user_id = WPF()->current_userid;
3117 if ( $user_id > 0 ) {
3118 $saved_prefs = get_user_meta( $user_id, 'wpforo_ai_search', true );
3119 if ( is_array( $saved_prefs ) && ! empty( $saved_prefs['language'] ) ) {
3120 $locale = $saved_prefs['language'];
3121 }
3122 }
3123 }
3124
3125 // 3. If still no locale, try board AI settings
3126 if ( empty( $locale ) ) {
3127 $locale = wpforo_setting( 'ai', $setting_key );
3128 }
3129
3130 // 4. If still no locale, try board locale
3131 if ( empty( $locale ) ) {
3132 $locale = wpfval( WPF()->board, 'locale' );
3133 }
3134
3135 // 5. If still no locale, use WordPress locale
3136 if ( empty( $locale ) ) {
3137 $locale = get_locale();
3138 }
3139
3140 // Extract 2-letter language code from locale (e.g., 'en_US' -> 'en')
3141 $lang_code = substr( $locale, 0, 2 );
3142
3143 if ( isset( $language_map[ $lang_code ] ) ) {
3144 return $language_map[ $lang_code ];
3145 }
3146
3147 // Default to English
3148 return 'English';
3149 }
3150
3151 /**
3152 * Replace AI link markers with actual HTML links
3153 *
3154 * Converts [[#N]] and [[#N:Title]] markers to clickable links
3155 *
3156 * @param string $text Text containing link markers
3157 * @param array $url_map Map of result numbers to URLs (1-indexed)
3158 * @return string Text with markers replaced by HTML links
3159 */
3160 private function replace_ai_link_markers( $text, $url_map ) {
3161 if ( empty( $text ) ) {
3162 return '';
3163 }
3164
3165 // Replace [[#N:Title]] format - title with link
3166 $text = preg_replace_callback(
3167 '/\[\[#(\d+):([^\]]+)\]\]/',
3168 function ( $matches ) use ( $url_map ) {
3169 $num = (int) $matches[1];
3170 // Strip guillemet quotes «» from title (AI uses them as formatting markers)
3171 $title = trim( $matches[2], '«» ' );
3172 $title = esc_html( $title );
3173 $url = isset( $url_map[ $num ] ) ? esc_url( $url_map[ $num ] ) : '#';
3174 return '<a href="' . $url . '" class="wpf-ai-result-link" target="_blank" rel="noopener">' . $title . '</a>';
3175 },
3176 $text
3177 );
3178
3179 // Replace [[#N]] format - just number with link
3180 $text = preg_replace_callback(
3181 '/\[\[#(\d+)\]\]/',
3182 function ( $matches ) use ( $url_map ) {
3183 $num = (int) $matches[1];
3184 $url = isset( $url_map[ $num ] ) ? esc_url( $url_map[ $num ] ) : '#';
3185 return '<a href="' . $url . '" class="wpf-ai-result-link" target="_blank" rel="noopener">#' . $num . '</a>';
3186 },
3187 $text
3188 );
3189
3190 return $text;
3191 }
3192
3193 /**
3194 * Generate HTML for AI search recommendations section
3195 *
3196 * Builds the complete HTML for the recommendations section server-side
3197 * to avoid JavaScript having to handle HTML escaping issues.
3198 *
3199 * @param array $recommendations Array of recommendation objects with title, recommendation, url, result_number
3200 * @return string Complete HTML for the recommendations section
3201 */
3202 private function render_recommendations_html( $recommendations ) {
3203 if ( empty( $recommendations ) ) {
3204 return '';
3205 }
3206
3207 $html = '<div class="wpf-ai-recommendations-section">';
3208 $html .= '<div class="wpf-ai-section-header"><i class="fas fa-lightbulb"></i> ' . esc_html( wpforo_phrase( 'AI Recommendations', false ) ) . '</div>';
3209 $html .= '<div class="wpf-ai-recommendations-list">';
3210
3211 foreach ( $recommendations as $rec ) {
3212 $url = ! empty( $rec['url'] ) ? esc_url( $rec['url'] ) : '#';
3213 $result_number = isset( $rec['result_number'] ) ? (int) $rec['result_number'] : 0;
3214 // title and recommendation already contain safe HTML from replace_ai_link_markers()
3215 $title = $rec['title'] ?? '';
3216 $recommendation = $rec['recommendation'] ?? '';
3217
3218 $html .= '<div class="wpf-ai-recommendation-card">';
3219 $html .= '<div class="wpf-ai-recommendation-number">' . $result_number . '</div>';
3220 $html .= '<div class="wpf-ai-recommendation-content">';
3221 $html .= '<div class="wpf-ai-recommendation-title"><a href="' . $url . '" target="_blank" rel="noopener">' . $title . '</a></div>';
3222 $html .= '<div class="wpf-ai-recommendation-text">' . $recommendation . '</div>';
3223 $html .= '</div>';
3224 $html .= '</div>';
3225 }
3226
3227 $html .= '</div>';
3228 $html .= '</div>';
3229
3230 return $html;
3231 }
3232
3233 /**
3234 * Replace topic summary link markers with actual HTML links
3235 *
3236 * Converts [[#POST_ID]] and [[#POST_ID:Title]] markers to clickable links to specific posts.
3237 * The POST_ID is the actual database post ID, not a sequential number.
3238 *
3239 * @param string $text Text containing link markers
3240 * @param int $topicid Topic ID for fallback URL
3241 * @return string Text with markers replaced by HTML links
3242 */
3243 private function replace_summary_link_markers( $text, $topicid ) {
3244 if ( empty( $text ) ) {
3245 return '';
3246 }
3247
3248 // Replace [[#POST_ID:Title]] format - title with link to post
3249 $text = preg_replace_callback(
3250 '/\[\[#(\d+):([^\]]+)\]\]/',
3251 function ( $matches ) use ( $topicid ) {
3252 $postid = (int) $matches[1];
3253 $title = esc_html( $matches[2] );
3254 $url = WPF()->post->get_url( $postid );
3255 if ( empty( $url ) || $url === wpforo_home_url() ) {
3256 // Fallback to topic URL if post URL not found
3257 $url = WPF()->topic->get_url( $topicid );
3258 }
3259 return '<a href="' . esc_url( $url ) . '" class="wpf-ai-post-link">' . $title . '</a>';
3260 },
3261 $text
3262 );
3263
3264 // Replace [[#POST_ID]] format - just number with "Reply #N" link
3265 $text = preg_replace_callback(
3266 '/\[\[#(\d+)\]\]/',
3267 function ( $matches ) use ( $topicid ) {
3268 $postid = (int) $matches[1];
3269 $url = WPF()->post->get_url( $postid );
3270 if ( empty( $url ) || $url === wpforo_home_url() ) {
3271 // Fallback to topic URL if post URL not found
3272 $url = WPF()->topic->get_url( $topicid );
3273 }
3274 return '<a href="' . esc_url( $url ) . '" class="wpf-ai-post-link">#' . $postid . '</a>';
3275 },
3276 $text
3277 );
3278
3279 return $text;
3280 }
3281
3282 /**
3283 * AJAX handler for semantic search
3284 *
3285 * @return void
3286 */
3287 public function ajax_semantic_search() {
3288 // Verify nonce
3289 check_ajax_referer( 'wpforo_ai_features_nonce', '_wpnonce' );
3290
3291 // Check user permissions
3292 if ( ! current_user_can( 'manage_options' ) ) {
3293 wp_send_json_error( [
3294 'message' => wpforo_phrase( 'Insufficient permissions', false )
3295 ], 403 );
3296 }
3297
3298 // Get search parameters
3299 $query = sanitize_text_field( wpfval( $_POST, 'query' ) );
3300 $limit = isset( $_POST['limit'] ) ? min( (int) $_POST['limit'], 100 ) : 10;
3301
3302 if ( empty( $query ) ) {
3303 wp_send_json_error( [
3304 'message' => wpforo_phrase( 'Search query is required', false )
3305 ], 400 );
3306 }
3307
3308 // Perform search via VectorStorageManager (handles local vs cloud routing)
3309 $results = WPF()->vector_storage->semantic_search( $query, $limit );
3310
3311 if ( is_wp_error( $results ) ) {
3312 wp_send_json_error( [
3313 'message' => $results->get_error_message()
3314 ], 500 );
3315 }
3316
3317 // Clean content/excerpt for display (strip Lambda processing markers)
3318 if ( ! empty( $results['results'] ) ) {
3319 foreach ( $results['results'] as &$result ) {
3320 if ( ! empty( $result['excerpt'] ) ) {
3321 $result['excerpt'] = $this->clean_content_for_search_display( $result['excerpt'] );
3322 }
3323 if ( ! empty( $result['content'] ) ) {
3324 $result['content'] = $this->clean_content_for_search_display( $result['content'] );
3325 }
3326 }
3327 unset( $result );
3328 }
3329
3330 // Return success response
3331 wp_send_json_success( $results );
3332 }
3333
3334 /**
3335 * AJAX handler for public front-end semantic search
3336 *
3337 * Accessible to all users (logged-in and guests)
3338 * Returns enriched results with topic metadata
3339 *
3340 * @return void
3341 */
3342 public function ajax_public_semantic_search() {
3343 // Track start time for logging
3344 $_log_start_time = microtime( true );
3345
3346 // Verify nonce (action name matches nonce key in wpforo.nonces object)
3347 check_ajax_referer( 'wpforo_ai_public_search', '_wpnonce' );
3348
3349 // Note: Rate limit check moved after cache check
3350 // Cached search results should bypass rate limits since they don't use API resources
3351
3352 // Check if AI service is available
3353 if ( ! $this->is_service_available() ) {
3354 wp_send_json_error( [
3355 'message' => wpforo_phrase( 'AI service is not available', false )
3356 ], 403 );
3357 }
3358
3359 // Check if AI semantic search is enabled
3360 if ( ! wpforo_setting( 'ai', 'search' ) ) {
3361 wp_send_json_error( [
3362 'message' => wpforo_phrase( 'AI Semantic Search is disabled', false )
3363 ], 403 );
3364 }
3365
3366 // Check usergroup permission
3367 if ( ! WPF()->usergroup->can( 'ai_search' ) ) {
3368 wp_send_json_error( [
3369 'message' => wpforo_phrase( 'You do not have permission to use this feature', false )
3370 ], 403 );
3371 }
3372
3373 // Check if any content has been indexed
3374 if ( WPF()->vector_storage && ! WPF()->vector_storage->has_indexed_content() ) {
3375 $message = wpforo_phrase( 'No content has been indexed from this forum yet', false );
3376 if ( current_user_can( 'manage_options' ) ) {
3377 $message .= '. ' . wpforo_phrase( 'Please go to Dashboard > wpForo > AI Features > AI Content Indexing and start the content indexing', false );
3378 }
3379 wp_send_json_success( [
3380 'results' => [],
3381 'total' => 0,
3382 'has_more' => false,
3383 'no_indexed_content' => true,
3384 'message' => $message,
3385 ] );
3386 }
3387
3388 // Get search parameters
3389 $query = sanitize_text_field( wpfval( $_POST, 'query' ) );
3390 $limit = isset( $_POST['limit'] ) ? min( (int) $_POST['limit'], 20 ) : 5;
3391 $offset = isset( $_POST['offset'] ) ? (int) $_POST['offset'] : 0;
3392 $language_code = sanitize_text_field( wpfval( $_POST, 'language' ) );
3393
3394 if ( empty( $query ) ) {
3395 wp_send_json_error( [
3396 'message' => wpforo_phrase( 'Search query is required', false )
3397 ], 400 );
3398 }
3399
3400 $current_boardid = (string) WPF()->board->get_current( 'boardid' );
3401 $search_quality = wpfval( WPF()->settings->ai, 'search_quality' ) ?: 'balanced';
3402 $accessible_forumids = $this->get_accessible_forumids();
3403 $search_cache_key = $this->build_search_cache_key( $query, $limit, $offset, $current_boardid, $search_quality, $accessible_forumids );
3404 $search_from_cache = false;
3405
3406 $cached_results = $this->get_ai_cache( self::CACHE_TYPE_SEARCH, $search_cache_key );
3407
3408 if ( $cached_results ) {
3409 $results = $cached_results;
3410 $search_from_cache = true;
3411 } else {
3412 $this->check_rate_limit( 'search' );
3413
3414 $filters = [ 'accessible_forumids' => $accessible_forumids ];
3415 $results = WPF()->vector_storage->semantic_search( $query, $limit + $offset, $filters );
3416
3417 if ( is_wp_error( $results ) ) {
3418 wp_send_json_error( [
3419 'message' => $results->get_error_message()
3420 ], 500 );
3421 }
3422
3423 $this->set_ai_cache( self::CACHE_TYPE_SEARCH, $search_cache_key, $results );
3424 }
3425
3426 if ( is_wp_error( $results ) ) {
3427 wp_send_json_error( [
3428 'message' => $results->get_error_message()
3429 ], 500 );
3430 }
3431
3432 // Get results array
3433 $search_results = wpfval( $results, 'results' ) ?: [];
3434 $total = wpfval( $results, 'total' ) ?: 0;
3435
3436 // Apply offset and limit for pagination
3437 if ( $offset > 0 ) {
3438 $search_results = array_slice( $search_results, $offset, $limit );
3439 } else {
3440 $search_results = array_slice( $search_results, 0, $limit );
3441 }
3442
3443 // Get minimum score threshold from settings (default 30%)
3444 // Local cosine similarities are on a different scale (5-25%) than cloud scores (30-90%),
3445 // so apply 1/3 of the configured threshold for local mode.
3446 // Absolute minimum of 15% for local mode prevents garbage results (random vectors
3447 // have ~5-15% cosine similarity with any query).
3448 $is_local_mode = WPF()->vector_storage && WPF()->vector_storage->is_local_mode();
3449 $min_score_setting = (int) wpfval( WPF()->settings->ai, 'search_min_score' );
3450 if ( $is_local_mode ) {
3451 $local_threshold = round( $min_score_setting / 3 );
3452 $min_score_percent = max( 15, $local_threshold ); // Absolute minimum 15%
3453 } else {
3454 $min_score_percent = max( 0, min( 100, $min_score_setting ) );
3455 }
3456
3457 // Relevance label thresholds - different for local vs cloud modes
3458 // Local cosine scores: 5-25% range (raw similarity)
3459 // Cloud re-ranked scores: 30-90% range (LLM re-ranked)
3460 if ( $is_local_mode ) {
3461 $threshold_excellent = 25;
3462 $threshold_good = 20;
3463 $threshold_relevant = 15;
3464 } else {
3465 $threshold_excellent = 80;
3466 $threshold_good = 60;
3467 $threshold_relevant = 40;
3468 }
3469
3470 // Enrich results with wpForo/WordPress data
3471 // Cloud API returns: id, score, title, excerpt, url, content_source, metadata{...}
3472 // Local storage returns: topic_id, post_id, forum_id, title, content, score, url, post_url, created, user_id, content_type
3473 $enriched_results = [];
3474 foreach ( $search_results as $result ) {
3475 $content_source = wpfval( $result, 'content_source' ) ?: wpfval( $result, 'metadata', 'content_source' );
3476
3477 if ( $content_source === 'wordpress' ) {
3478 // ── WordPress CPT result ──
3479 $wp_post_id = wpfval( $result, 'metadata', 'post_id' );
3480 if ( empty( $wp_post_id ) ) continue;
3481
3482 $wp_post = get_post( (int) $wp_post_id );
3483 if ( ! $wp_post || $wp_post->post_status !== 'publish' ) continue;
3484
3485 // Use real WP post_type (post, page, product, etc.)
3486 $post_type_obj = get_post_type_object( $wp_post->post_type );
3487 $post_type_label = $post_type_obj ? $post_type_obj->labels->singular_name : ucfirst( $wp_post->post_type );
3488
3489 $url = get_permalink( $wp_post );
3490
3491 $author = get_user_by( 'id', $wp_post->post_author );
3492 $author_name = $author ? $author->display_name : '';
3493
3494 $content = wpfval( $result, 'content' ) ?: wpfval( $result, 'excerpt' ) ?: '';
3495 $content = $this->clean_content_for_search_display( $content );
3496
3497 $score = wpfval( $result, 'score' ) ?: 0;
3498 $score_percent = round( $score * 100 );
3499
3500 if ( $min_score_percent > 0 && $score_percent < $min_score_percent ) continue;
3501
3502 if ( $score_percent >= $threshold_excellent ) {
3503 $relevance_label = wpforo_phrase( 'Excellent match', false );
3504 } elseif ( $score_percent >= $threshold_good ) {
3505 $relevance_label = wpforo_phrase( 'Good match', false );
3506 } elseif ( $score_percent >= $threshold_relevant ) {
3507 $relevance_label = wpforo_phrase( 'Relevant', false );
3508 } else {
3509 $relevance_label = wpforo_phrase( 'Possibly relevant', false );
3510 }
3511
3512 $enriched_results[] = [
3513 'title' => wpfval( $result, 'title' ) ?: $wp_post->post_title,
3514 'url' => $url,
3515 'content' => $content,
3516 'score' => $score_percent,
3517 'relevance_label' => $relevance_label,
3518 'content_source' => 'wordpress',
3519 'post_type_label' => $post_type_label,
3520 'post_id' => (int) $wp_post_id,
3521 'forum_title' => '',
3522 'forum_url' => '',
3523 'author_name' => $author_name,
3524 'author_url' => '',
3525 'created' => $wp_post->post_date ? date( 'Y-m-d H:i', strtotime( $wp_post->post_date ) ) : '',
3526 'created_ago' => $wp_post->post_date ? human_time_diff( strtotime( $wp_post->post_date ) ) . ' ago' : '',
3527 ];
3528 } else {
3529 // ── Forum result ──
3530 $topic_id = wpfval( $result, 'topic_id' ) ?: wpfval( $result, 'metadata', 'thread_id' );
3531
3532 if ( empty( $topic_id ) ) {
3533 continue;
3534 }
3535
3536 $topic = wpforo_topic( $topic_id );
3537 if ( empty( $topic ) ) {
3538 continue;
3539 }
3540
3541 if ( ! WPF()->topic->view_access( $topic ) ) {
3542 continue;
3543 }
3544
3545 // Get URL - use /postid/<postid>/ format for direct post linking
3546 $chunk_post_id = wpfval( $result, 'post_id' ) ?: wpfval( $result, 'metadata', 'chunk_post_id' );
3547 if ( $chunk_post_id ) {
3548 $url = wpforo_home_url( wpforo_settings_get_slug( 'postid' ) . '/' . intval( $chunk_post_id ) . '/' );
3549 } else {
3550 $url = wpfval( $result, 'url' ) ?: wpforo_topic( $topic_id, 'url' );
3551 }
3552
3553 // Author: prefer chunk author (specific post), fallback to topic author
3554 $author_id = wpfval( $result, 'user_id' ) ?: wpfval( $result, 'metadata', 'chunk_author_id' ) ?: wpfval( $result, 'metadata', 'topic_author_id' ) ?: $topic['userid'];
3555 $author_name = wpfval( $result, 'metadata', 'chunk_display_name' ) ?: wpfval( $result, 'metadata', 'topic_display_name' );
3556 if ( ! $author_name ) {
3557 $author = wpforo_member( $author_id );
3558 $author_name = wpfval( $author, 'display_name' ) ?: wpfval( $author, 'user_login' );
3559 }
3560
3561 $content = wpfval( $result, 'content' ) ?: wpfval( $result, 'excerpt' ) ?: '';
3562 $content = $this->clean_content_for_search_display( $content );
3563
3564 $score = wpfval( $result, 'score' ) ?: 0;
3565 $score_percent = round( $score * 100 );
3566
3567 if ( $min_score_percent > 0 && $score_percent < $min_score_percent ) {
3568 continue;
3569 }
3570
3571 if ( $score_percent >= $threshold_excellent ) {
3572 $relevance_label = wpforo_phrase( 'Excellent match', false );
3573 } elseif ( $score_percent >= $threshold_good ) {
3574 $relevance_label = wpforo_phrase( 'Good match', false );
3575 } elseif ( $score_percent >= $threshold_relevant ) {
3576 $relevance_label = wpforo_phrase( 'Relevant', false );
3577 } else {
3578 $relevance_label = wpforo_phrase( 'Possibly relevant', false );
3579 }
3580
3581 $forum_title = wpfval( $result, 'metadata', 'forum_name' ) ?: wpforo_forum( $topic['forumid'], 'title' );
3582 $forum_url = wpforo_forum( $topic['forumid'], 'url' );
3583
3584 $title = wpfval( $result, 'title' ) ?: wpfval( $result, 'metadata', 'topic_title' ) ?: $topic['title'];
3585
3586 $enriched_results[] = [
3587 'title' => $title,
3588 'url' => $url,
3589 'content' => $content,
3590 'score' => $score_percent,
3591 'relevance_label' => $relevance_label,
3592 'content_source' => 'forum',
3593 'post_type_label' => '',
3594 'post_id' => $chunk_post_id ? (int) $chunk_post_id : 0,
3595 'forum_title' => $forum_title,
3596 'forum_url' => $forum_url,
3597 'author_name' => $author_name,
3598 'author_url' => wpforo_member( $author_id, 'profile_url' ),
3599 'created' => wpforo_date( $topic['created'], 'Y-m-d H:i', false ),
3600 'created_ago' => wpforo_date( $topic['created'], '', false ),
3601 ];
3602 }
3603 }
3604
3605 // Get AI enhancement (summary + recommendations) if we have 3+ results
3606 $ai_enhancement = null;
3607 $enhance_credits_used = 0;
3608 $enhance_from_cache = false;
3609 $_debug_enhance = [
3610 'results_count' => count( $enriched_results ),
3611 'min_required' => 3,
3612 'setting_value' => wpfval( WPF()->settings->ai, 'search_enhance' ),
3613 'setting_type' => gettype( wpfval( WPF()->settings->ai, 'search_enhance' ) ),
3614 'all_ai_settings' => WPF()->settings->ai,
3615 ];
3616 if ( count( $enriched_results ) >= 3 ) {
3617 $user_language = $this->get_user_language( $language_code );
3618
3619 // Build cache key based on query + results + language
3620 $cache_key = $this->build_enhance_cache_key( $query, $enriched_results, $user_language );
3621
3622 // Check cache first
3623 $cached_enhancement = $this->get_ai_cache( self::CACHE_TYPE_SEARCH_ENHANCE, $cache_key );
3624
3625 if ( $cached_enhancement ) {
3626 // Cache hit - use cached response, but reprocess recommendations for link markers
3627 // (older cached entries may have unprocessed [[#N:Title]] markers)
3628 $enhance_from_cache = true;
3629 $url_map = [];
3630 foreach ( $enriched_results as $index => $result ) {
3631 $url_map[ $index + 1 ] = wpfval( $result, 'url' ) ?: '#';
3632 }
3633 $cached_recs = wpfval( $cached_enhancement, 'recommendations' ) ?: [];
3634 foreach ( $cached_recs as &$rec ) {
3635 if ( ! empty( $rec['title'] ) ) {
3636 $rec['title'] = $this->replace_ai_link_markers( $rec['title'], $url_map );
3637 }
3638 if ( ! empty( $rec['description'] ) ) {
3639 $rec['description'] = $this->replace_ai_link_markers( $rec['description'], $url_map );
3640 }
3641 }
3642 unset( $rec );
3643 $cached_enhancement['recommendations'] = $cached_recs;
3644 // Generate recommendations HTML server-side
3645 $cached_enhancement['recommendations_html'] = $this->render_recommendations_html( $cached_recs );
3646 $ai_enhancement = $cached_enhancement;
3647 $_debug_enhance['source'] = 'cache';
3648 } else {
3649 // Cache miss - call enhance API
3650 $enhance_response = $this->enhance_search_results(
3651 $query,
3652 $enriched_results,
3653 $user_language
3654 );
3655 $_debug_enhance['source'] = 'api';
3656 $_debug_enhance['api_response'] = $enhance_response;
3657
3658 if ( ! is_wp_error( $enhance_response ) && wpfval( $enhance_response, 'success' ) ) {
3659 // Track credits used by AI enhancement
3660 $enhance_credits_used = wpfval( $enhance_response, 'credits_used' ) ?: 0;
3661
3662 // Build URL map for link replacement (1-indexed)
3663 $url_map = [];
3664 foreach ( $enriched_results as $index => $result ) {
3665 $url_map[ $index + 1 ] = wpfval( $result, 'url' ) ?: '#';
3666 }
3667
3668 // Process recommendations to add URL for each and convert link markers
3669 $recommendations = wpfval( $enhance_response, 'recommendations' ) ?: [];
3670 foreach ( $recommendations as &$rec ) {
3671 $result_num = wpfval( $rec, 'result_number' );
3672 $rec['url'] = isset( $url_map[ $result_num ] ) ? $url_map[ $result_num ] : '#';
3673 // Process link markers in title and recommendation
3674 if ( ! empty( $rec['title'] ) ) {
3675 $rec['title'] = $this->replace_ai_link_markers( $rec['title'], $url_map );
3676 }
3677 if ( ! empty( $rec['recommendation'] ) ) {
3678 $rec['recommendation'] = $this->replace_ai_link_markers( $rec['recommendation'], $url_map );
3679 }
3680 }
3681 unset( $rec ); // Break reference
3682
3683 $ai_enhancement = [
3684 'summary' => $this->replace_ai_link_markers( wpfval( $enhance_response, 'summary' ) ?: '', $url_map ),
3685 'quick_answer' => $this->replace_ai_link_markers( wpfval( $enhance_response, 'quick_answer' ) ?: '', $url_map ),
3686 'recommendations' => $recommendations,
3687 // Generate recommendations HTML server-side
3688 'recommendations_html' => $this->render_recommendations_html( $recommendations ),
3689 ];
3690
3691 // Store in cache for future requests
3692 $this->set_ai_cache( self::CACHE_TYPE_SEARCH_ENHANCE, $cache_key, $ai_enhancement );
3693 }
3694 }
3695 }
3696
3697 // Log the action
3698 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
3699 // Determine status: cached only if both search and enhance (if used) came from cache
3700 $all_from_cache = $search_from_cache && ( ! $ai_enhancement || $enhance_from_cache );
3701 $log_status = $all_from_cache ? AILogs::STATUS_CACHED : AILogs::STATUS_SUCCESS;
3702
3703 // Build response summary including enhancement info
3704 $response_parts = [ sprintf( '%d results', $total ) ];
3705 if ( $ai_enhancement ) {
3706 $response_parts[] = $enhance_from_cache ? 'AI enhanced (cached)' : 'AI enhanced';
3707 }
3708
3709 WPF()->ai_logs->log( [
3710 'action_type' => AILogs::ACTION_PUBLIC_SEARCH,
3711 'credits_used' => $enhance_credits_used,
3712 'status' => $log_status,
3713 'request_summary' => $query,
3714 'response_summary' => implode( ', ', $response_parts ),
3715 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
3716 ] );
3717 }
3718
3719 // Update total to reflect filtered results count
3720 $filtered_total = count( $enriched_results );
3721
3722 // Return enriched results with AI enhancement
3723 wp_send_json_success( [
3724 'results' => $enriched_results,
3725 'total' => $filtered_total,
3726 'has_more' => ( $offset + $limit ) < $total && $filtered_total >= $limit,
3727 'query' => $query,
3728 'ai_enhancement' => $ai_enhancement,
3729 'search_from_cache' => $search_from_cache,
3730 '_debug_enhance' => $_debug_enhance, // TEMP DEBUG - remove after fixing
3731 ] );
3732 }
3733
3734 /**
3735 * AJAX handler to save user AI search preferences
3736 *
3737 * Stores preferences in user_meta as serialized array
3738 * Only accessible to logged-in users
3739 *
3740 * @return void
3741 */
3742 public function ajax_save_ai_preferences() {
3743 // Verify nonce
3744 if ( ! wp_verify_nonce( wpfval( $_POST, 'nonce' ), 'wpforo_ai_preferences' ) ) {
3745 wp_send_json_error( wpforo_phrase( 'Security check failed', false ), 403 );
3746 }
3747
3748 // Must be logged in
3749 $user_id = WPF()->current_userid;
3750 if ( ! $user_id ) {
3751 wp_send_json_error( wpforo_phrase( 'You must be logged in to save preferences', false ), 401 );
3752 }
3753
3754 // Get and validate language
3755 $language = sanitize_text_field( wpfval( $_POST, 'language' ) );
3756 $valid_languages = array_merge( [ '' ], array_column( wpforo_get_ai_languages(), 'locale' ) );
3757 if ( ! in_array( $language, $valid_languages, true ) ) {
3758 $language = get_locale(); // Fallback to WP locale
3759 }
3760
3761 // Get and validate max results (backend validation - max 10)
3762 $max_results = isset( $_POST['max_results'] ) ? (int) $_POST['max_results'] : 5;
3763 $max_results = max( 1, min( 10, $max_results ) ); // Clamp between 1 and 10
3764
3765 // Build preferences array
3766 $preferences = [
3767 'language' => $language,
3768 'max_results' => $max_results,
3769 ];
3770
3771 // Save to user meta
3772 $updated = update_user_meta( $user_id, 'wpforo_ai_search', $preferences );
3773
3774 if ( false === $updated ) {
3775 // Check if it failed or just unchanged
3776 $existing = get_user_meta( $user_id, 'wpforo_ai_search', true );
3777 if ( $existing === $preferences ) {
3778 // No change needed, still success
3779 wp_send_json_success( [ 'message' => wpforo_phrase( 'Preferences saved', false ) ] );
3780 } else {
3781 wp_send_json_error( wpforo_phrase( 'Failed to save preferences', false ), 500 );
3782 }
3783 }
3784
3785 wp_send_json_success( [ 'message' => wpforo_phrase( 'Preferences saved', false ) ] );
3786 }
3787
3788 /**
3789 * Get user AI search preferences
3790 *
3791 * @param int|null $user_id User ID (defaults to current user)
3792 * @return array Preferences array with defaults applied
3793 */
3794 public function get_user_ai_preferences( $user_id = null ) {
3795 if ( null === $user_id ) {
3796 $user_id = WPF()->current_userid;
3797 }
3798
3799 $defaults = [
3800 'language' => get_locale(),
3801 'max_results' => 5,
3802 ];
3803
3804 if ( ! $user_id ) {
3805 return $defaults;
3806 }
3807
3808 $saved = get_user_meta( $user_id, 'wpforo_ai_search', true );
3809 if ( is_array( $saved ) ) {
3810 return wp_parse_args( $saved, $defaults );
3811 }
3812
3813 return $defaults;
3814 }
3815
3816 /**
3817 * AJAX handler for generic AI actions (refresh_status, etc.)
3818 *
3819 * @return void
3820 */
3821 public function ajax_generic_action() {
3822 // Verify nonce
3823 check_ajax_referer( 'wpforo_ai_features_nonce', '_wpnonce' );
3824
3825 // Check user permissions
3826 if ( ! current_user_can( 'manage_options' ) ) {
3827 wp_send_json_error( [
3828 'message' => wpforo_phrase( 'Insufficient permissions', false )
3829 ], 403 );
3830 }
3831
3832 // Get the specific action
3833 $wpforo_ai_action = sanitize_text_field( wpfval( $_POST, 'wpforo_ai_action' ) );
3834
3835 if ( empty( $wpforo_ai_action ) ) {
3836 wp_send_json_error( [
3837 'message' => wpforo_phrase( 'Action is required', false )
3838 ], 400 );
3839 }
3840
3841 // Handle different actions
3842 switch ( $wpforo_ai_action ) {
3843 case 'refresh_status':
3844 // Clear status cache and fetch fresh data
3845 $this->clear_status_cache();
3846 $status = $this->get_tenant_status();
3847
3848 if ( is_wp_error( $status ) ) {
3849 \wpforo_ai_log( 'error', 'Error getting status: ' . $status->get_error_message(), 'Client' );
3850 wp_send_json_error( [
3851 'message' => $status->get_error_message()
3852 ], 500 );
3853 }
3854
3855 wp_send_json_success( [
3856 'status' => $status,
3857 'message' => wpforo_phrase( 'Status refreshed successfully', false )
3858 ] );
3859 break;
3860
3861 case 'start_local_indexing':
3862 // Include helper file if not already loaded
3863 if ( ! function_exists( 'wpforo_ai_handle_start_local_indexing' ) ) {
3864 require_once WPFORO_DIR . '/admin/pages/tabs/ai-features-helpers.php';
3865 }
3866 $result = wpforo_ai_handle_start_local_indexing();
3867 if ( isset( $result['type'] ) && $result['type'] === 'success' ) {
3868 wp_send_json_success( $result );
3869 } else {
3870 wp_send_json_error( $result );
3871 }
3872 break;
3873
3874 case 'process_local_batch':
3875 try {
3876 // Include helper file if not already loaded
3877 if ( ! function_exists( 'wpforo_ai_handle_process_local_batch' ) ) {
3878 require_once WPFORO_DIR . '/admin/pages/tabs/ai-features-helpers.php';
3879 }
3880 $result = wpforo_ai_handle_process_local_batch();
3881 if ( isset( $result['type'] ) && $result['type'] === 'success' ) {
3882 wp_send_json_success( $result );
3883 } else {
3884 wp_send_json_error( $result );
3885 }
3886 } catch ( \Exception $e ) {
3887 \wpforo_ai_log( 'error', 'process_local_batch AJAX exception: ' . $e->getMessage(), 'Client' );
3888 wp_send_json_error( [ 'message' => 'Exception: ' . $e->getMessage() ] );
3889 } catch ( \Error $e ) {
3890 \wpforo_ai_log( 'error', 'process_local_batch AJAX error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine(), 'Client' );
3891 wp_send_json_error( [ 'message' => 'PHP Error: ' . $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine() ] );
3892 }
3893 break;
3894
3895 case 'get_indexing_progress':
3896 // Include helper file if not already loaded
3897 if ( ! function_exists( 'wpforo_ai_handle_get_indexing_progress' ) ) {
3898 require_once WPFORO_DIR . '/admin/pages/tabs/ai-features-helpers.php';
3899 }
3900 $result = wpforo_ai_handle_get_indexing_progress();
3901 if ( isset( $result['type'] ) && $result['type'] === 'success' ) {
3902 wp_send_json_success( $result );
3903 } else {
3904 wp_send_json_error( $result );
3905 }
3906 break;
3907
3908 case 'stop_local_indexing':
3909 // Include helper file if not already loaded
3910 if ( ! function_exists( 'wpforo_ai_handle_stop_local_indexing' ) ) {
3911 require_once WPFORO_DIR . '/admin/pages/tabs/ai-features-helpers.php';
3912 }
3913 $result = wpforo_ai_handle_stop_local_indexing();
3914 if ( isset( $result['type'] ) && $result['type'] === 'success' ) {
3915 wp_send_json_success( $result );
3916 } else {
3917 wp_send_json_error( $result );
3918 }
3919 break;
3920
3921 case 'clear_local_embeddings':
3922 // Include helper file if not already loaded
3923 if ( ! function_exists( 'wpforo_ai_handle_clear_local_embeddings' ) ) {
3924 require_once WPFORO_DIR . '/admin/pages/tabs/ai-features-helpers.php';
3925 }
3926 $result = wpforo_ai_handle_clear_local_embeddings();
3927 if ( isset( $result['type'] ) && $result['type'] === 'success' ) {
3928 wp_send_json_success( $result );
3929 } else {
3930 wp_send_json_error( $result );
3931 }
3932 break;
3933
3934 default:
3935 wp_send_json_error( [
3936 'message' => sprintf( wpforo_phrase( 'Unknown action: %s', false ), $wpforo_ai_action )
3937 ], 400 );
3938 }
3939 }
3940
3941 /**
3942 * WP Cron handler for processing batches in background
3943 *
3944 * @param array $args Arguments containing topic_ids, chunk_size, and overlap_percent
3945 * @return void
3946 */
3947 public function cron_process_batch( $args, $chunk_size_param = null, $overlap_percent_param = null ) {
3948 // Handle both old format (args spread as separate params) and new format (single associative array)
3949 // Old format: $args = topic_ids array, $chunk_size_param = chunk_size, $overlap_percent_param = overlap
3950 // New format: $args = ['topic_ids' => [...], 'chunk_size' => ..., 'overlap_percent' => ...]
3951 if ( is_array( $args ) && isset( $args['topic_ids'] ) ) {
3952 // New format: associative array with all params
3953 $topic_ids = $args['topic_ids'];
3954 // Use explicit checks because wpfval returns 0/null if that's the stored value
3955 $chunk_size = ! empty( $args['chunk_size'] ) ? (int) $args['chunk_size'] : 512;
3956 $overlap_percent = isset( $args['overlap_percent'] ) && $args['overlap_percent'] !== null ? (int) $args['overlap_percent'] : 20;
3957 } else {
3958 // Old format: args are spread as separate parameters
3959 $topic_ids = is_array( $args ) ? $args : [];
3960 $chunk_size = ! empty( $chunk_size_param ) ? (int) $chunk_size_param : 512;
3961 $overlap_percent = $overlap_percent_param !== null ? (int) $overlap_percent_param : 20;
3962 }
3963
3964 if ( empty( $topic_ids ) ) {
3965 $this->log_error( 'cron_batch_empty', 'No topic IDs in batch' );
3966 return;
3967 }
3968
3969 $this->log_info( 'cron_batch_started', [
3970 'topics_count' => count( $topic_ids ),
3971 'chunk_size' => $chunk_size,
3972 'overlap_percent' => $overlap_percent,
3973 ] );
3974
3975 // Use VectorStorageManager to route to appropriate storage backend
3976 $storage_manager = WPF()->vector_storage;
3977 $storage_mode = $storage_manager->get_storage_mode();
3978
3979 $this->log_info( 'cron_batch_storage_mode', [
3980 'storage_mode' => $storage_mode,
3981 ] );
3982
3983 if ( $storage_manager->is_local_mode() ) {
3984 // Local storage: use batch embedding API for efficiency
3985 // Check if batch API flag is set (new format) or fall back to old per-topic method
3986 $use_batch_api = isset( $args['use_batch_api'] ) && $args['use_batch_api'];
3987
3988 if ( $use_batch_api ) {
3989 // New efficient batch method: one API call for all topics
3990 $result = $storage_manager->index_topics_batch_local( $topic_ids, [
3991 'chunk_size' => $chunk_size,
3992 'overlap_percent' => $overlap_percent,
3993 ] );
3994
3995 if ( is_array( $result ) ) {
3996 $this->log_info( 'cron_batch_completed', [
3997 'topics_count' => count( $topic_ids ),
3998 'indexed_count' => $result['indexed_count'] ?? 0,
3999 'skipped_count' => $result['skipped_count'] ?? 0,
4000 'credits_used' => $result['credits_used'] ?? 0,
4001 'errors' => $result['errors'] ?? [],
4002 'storage_mode' => 'local_batch',
4003 ] );
4004 } else {
4005 $this->log_error( 'cron_batch_failed', [
4006 'error' => is_wp_error( $result ) ? $result->get_error_message() : 'Unknown error',
4007 'topics_count' => count( $topic_ids ),
4008 ] );
4009 }
4010 } else {
4011 // Legacy per-topic method (for backwards compatibility)
4012 $success_count = 0;
4013 $error_count = 0;
4014
4015 foreach ( $topic_ids as $topic_id ) {
4016 $result = $storage_manager->index_topic( $topic_id, [
4017 'chunk_size' => $chunk_size,
4018 'overlap_percent' => $overlap_percent,
4019 ] );
4020
4021 if ( is_wp_error( $result ) ) {
4022 $this->log_error( 'cron_topic_index_failed', [
4023 'topic_id' => $topic_id,
4024 'error' => $result->get_error_message(),
4025 ] );
4026 $error_count++;
4027 } else {
4028 $success_count += $result['indexed_count'] ?? 0;
4029 }
4030 }
4031
4032 $this->log_info( 'cron_batch_completed', [
4033 'topics_count' => count( $topic_ids ),
4034 'success_count' => $success_count,
4035 'error_count' => $error_count,
4036 'storage_mode' => 'local',
4037 ] );
4038 }
4039 } else {
4040 // Cloud storage: use existing batch ingestion
4041 $response = $this->ingest_topics( $topic_ids, $chunk_size, $overlap_percent );
4042
4043 if ( is_wp_error( $response ) ) {
4044 $this->log_error( 'cron_batch_failed', [
4045 'error' => $response->get_error_message(),
4046 'topics_count' => count( $topic_ids ),
4047 ] );
4048 } else {
4049 $this->log_info( 'cron_batch_completed', [
4050 'topics_count' => count( $topic_ids ),
4051 'response' => $response,
4052 'storage_mode' => 'cloud',
4053 ] );
4054 }
4055 }
4056
4057 // Clear status caches to update progress and credits
4058 $this->clear_rag_status_cache();
4059 $this->clear_status_cache(); // Also clear tenant status to refresh credits
4060 }
4061
4062 /**
4063 * WP Cron handler for processing local indexing queue
4064 *
4065 * This uses a self-rescheduling pattern to process large batches efficiently:
4066 * - Processes 50 topics per execution
4067 * - Reschedules itself if more topics remain in queue
4068 * - Only ONE cron job exists at a time (no job pile-up)
4069 *
4070 * @param int $board_id Board ID for multi-board support
4071 * @return void
4072 */
4073 public function cron_process_queue( $board_id = 0 ) {
4074 // Ensure WPF classes are initialized (may not be in cron context)
4075 if ( is_null( WPF()->vector_storage ) ) {
4076 WPF()->init();
4077 }
4078
4079 $storage_manager = WPF()->vector_storage;
4080 if ( ! $storage_manager ) {
4081 return; // Still null, can't proceed
4082 }
4083
4084 // Delegate to VectorStorageManager which handles the queue processing
4085 $storage_manager->cron_process_queue( $board_id );
4086
4087 // Clear status caches to update progress and credits
4088 $this->clear_rag_status_cache();
4089 $this->clear_status_cache();
4090 }
4091
4092 /**
4093 * Process the LOCAL auto-indexing queue (mode-specific cron handler)
4094 *
4095 * This ensures topics queued for local indexing are processed with local indexing,
4096 * even if the user has since switched to cloud mode.
4097 *
4098 * @param int $board_id Board ID
4099 */
4100 public function cron_process_queue_local( $board_id = 0 ) {
4101 // Ensure WPF classes are initialized (may not be in cron context)
4102 if ( is_null( WPF()->vector_storage ) ) {
4103 WPF()->init();
4104 }
4105
4106 $storage_manager = WPF()->vector_storage;
4107 if ( ! $storage_manager ) {
4108 return;
4109 }
4110
4111 // Process the local-specific queue
4112 $storage_manager->cron_process_queue_mode( $board_id, 'local' );
4113
4114 // Clear status caches
4115 $this->clear_rag_status_cache();
4116 $this->clear_status_cache();
4117 }
4118
4119 /**
4120 * Process the CLOUD auto-indexing queue (mode-specific cron handler)
4121 *
4122 * This ensures topics queued for cloud indexing are processed with cloud indexing,
4123 * even if the user has since switched to local mode.
4124 *
4125 * @param int $board_id Board ID
4126 */
4127 public function cron_process_queue_cloud( $board_id = 0 ) {
4128 // Ensure WPF classes are initialized (may not be in cron context)
4129 if ( is_null( WPF()->vector_storage ) ) {
4130 WPF()->init();
4131 }
4132
4133 $storage_manager = WPF()->vector_storage;
4134 if ( ! $storage_manager ) {
4135 return;
4136 }
4137
4138 // Process the cloud-specific queue
4139 $storage_manager->cron_process_queue_mode( $board_id, 'cloud' );
4140
4141 // Clear status caches
4142 $this->clear_rag_status_cache();
4143 $this->clear_status_cache();
4144 }
4145
4146 /**
4147 * Ingest specific topics by ID
4148 *
4149 * @param array $topic_ids Array of topic IDs to ingest
4150 * @return array|WP_Error Response data or error object
4151 */
4152 public function ingest_topics( $topic_ids, $chunk_size = 512, $overlap_percent = 20 ) {
4153 // Check if database clearing is in progress
4154 if ( $this->is_clearing_in_progress() ) {
4155 $remaining = $this->get_clearing_time_remaining();
4156 $minutes = ceil( $remaining / 60 );
4157 return new \WP_Error(
4158 'clearing_in_progress',
4159 sprintf(
4160 /* translators: %d: minutes remaining */
4161 wpforo_phrase( 'Database clearing is in progress. Please wait approximately %d minute(s) before starting new indexing.', false ),
4162 $minutes
4163 )
4164 );
4165 }
4166
4167 if ( empty( $topic_ids ) || ! is_array( $topic_ids ) ) {
4168 return new \WP_Error( 'invalid_topic_ids', wpforo_phrase( 'Please provide valid topic IDs', false ) );
4169 }
4170
4171 // Prepare topic data for indexing
4172 $threads = [];
4173
4174 foreach ( $topic_ids as $topic_id ) {
4175 // Bypass user permission check - this is admin-initiated backend indexing.
4176 // Private/unapproved topics are filtered below; forum-level permissions
4177 // don't apply because WP Cron runs without a logged-in user.
4178 $topic = WPF()->topic->get_topic( $topic_id, false );
4179
4180 if ( empty( $topic ) ) {
4181 $this->log_error( 'topic_not_found', "Topic ID {$topic_id} not found" );
4182 continue;
4183 }
4184
4185 // Skip private topics - they should never be indexed
4186 if ( ! empty( $topic['private'] ) ) {
4187 $this->log_info( 'skipping_private_topic', "Skipping private topic ID {$topic_id}" );
4188 continue;
4189 }
4190
4191 // Skip unapproved topics
4192 if ( isset( $topic['status'] ) && (int) $topic['status'] !== 0 ) {
4193 $this->log_info( 'skipping_unapproved_topic', "Skipping unapproved topic ID {$topic_id}" );
4194 continue;
4195 }
4196
4197 // Get topic posts/replies
4198 // Bypass user permission check - this is admin-initiated backend indexing,
4199 // not a user-facing request. Private/unapproved topics are already filtered
4200 // at the SQL level in forum_ingest handler and above in this function.
4201 $posts = WPF()->post->get_posts(
4202 [
4203 'topicid' => $topic_id,
4204 'orderby' => 'created',
4205 'order' => 'ASC',
4206 'check_private' => false,
4207 ]
4208 );
4209
4210 // Format thread data
4211 $thread = $this->format_thread_data( $topic, $posts );
4212
4213 if ( $thread ) {
4214 // Check if topic needs force re-indexing
4215 // When cloud = 0, it means the topic has changed (new post added)
4216 // and backend should delete old vectors before re-indexing
4217 $cloud_status = isset( $topic['cloud'] ) ? (int) $topic['cloud'] : 0;
4218 if ( $cloud_status === 0 ) {
4219 $thread['force_reindex'] = true;
4220 }
4221
4222 $threads[] = $thread;
4223 }
4224 }
4225
4226 if ( empty( $threads ) ) {
4227 return new \WP_Error( 'no_threads_to_ingest', wpforo_phrase( 'No valid threads found to ingest', false ) );
4228 }
4229
4230 // Get site domain for image/document URL validation on Lambda side
4231 $site_url = get_site_url();
4232 $parsed = wp_parse_url( $site_url );
4233 $domain = $parsed['host'] ?? '';
4234
4235 $data = [
4236 'threads' => $threads,
4237 'chunk_size' => (int) $chunk_size,
4238 'overlap_percent' => (int) $overlap_percent,
4239 'site_domain' => $domain,
4240 ];
4241
4242 // Bumped to 60s (from default 30s) as a safety margin. The backend
4243 // now processes images/documents asynchronously so /rag/ingest
4244 // typically returns in <5s, but large text-only batches can still
4245 // approach the old limit.
4246 $response = $this->post( '/rag/ingest', $data, 60 );
4247
4248 if ( is_wp_error( $response ) ) {
4249 $this->log_error( 'ingest_failed', $response->get_error_message() );
4250
4251 // Log error to AILogs database
4252 if ( isset( WPF()->ai_logs ) ) {
4253 $user_type = AILogs::USER_TYPE_USER;
4254 if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
4255 $user_type = AILogs::USER_TYPE_CRON;
4256 } elseif ( ! get_current_user_id() ) {
4257 $user_type = AILogs::USER_TYPE_SYSTEM;
4258 }
4259
4260 WPF()->ai_logs->log( [
4261 'action_type' => AILogs::ACTION_CONTENT_INDEXING,
4262 'user_type' => $user_type,
4263 'credits_used' => 0,
4264 'status' => AILogs::STATUS_ERROR,
4265 'content_type' => 'topic',
4266 'request_summary' => sprintf( 'Cloud indexing: %d topics', count( $topic_ids ) ),
4267 'error_message' => $response->get_error_message(),
4268 'extra_data' => wp_json_encode( [
4269 'storage_mode' => 'cloud',
4270 'topic_ids' => array_slice( $topic_ids, 0, 20 ),
4271 ] ),
4272 ] );
4273 }
4274
4275 return $response;
4276 }
4277
4278 // Update indexed hash in wpforo_topics table for each indexed topic
4279 // This enables summarization to use cloud-stored posts instead of sending from WordPress
4280 if ( ! empty( $response['indexed_hashes'] ) && is_array( $response['indexed_hashes'] ) ) {
4281 $this->update_topics_indexed_hash( $response['indexed_hashes'] );
4282 }
4283
4284 $this->log_info( 'topics_ingested', [ 'count' => count( $threads ) ] );
4285 $this->clear_rag_status_cache();
4286
4287 do_action( 'wpforo_ai_topics_ingested', $topic_ids, $response );
4288
4289 // Log to AILogs database for tracking
4290 $indexed_count = count( $response['indexed_hashes'] ?? [] );
4291 if ( $indexed_count > 0 && isset( WPF()->ai_logs ) ) {
4292 $user_type = AILogs::USER_TYPE_USER;
4293 if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
4294 $user_type = AILogs::USER_TYPE_CRON;
4295 } elseif ( ! get_current_user_id() ) {
4296 $user_type = AILogs::USER_TYPE_SYSTEM;
4297 }
4298
4299 // Build response summary with image/document stats
4300 $summary_parts = [ sprintf( 'Indexed %d topics to cloud storage', $indexed_count ) ];
4301 $extra = [
4302 'storage_mode' => 'cloud',
4303 'topic_ids' => array_slice( $topic_ids, 0, 20 ),
4304 'topics_indexed' => $indexed_count,
4305 'chunk_size' => $chunk_size,
4306 'overlap_percent' => $overlap_percent,
4307 ];
4308
4309 if ( ! empty( $response['image_processing'] ) ) {
4310 $img_count = $response['image_processing']['images_processed'] ?? 0;
4311 if ( $img_count > 0 ) {
4312 $summary_parts[] = sprintf( '%d images', $img_count );
4313 }
4314 $extra['image_processing'] = $response['image_processing'];
4315 }
4316
4317 if ( ! empty( $response['document_processing'] ) ) {
4318 $doc_count = $response['document_processing']['documents_processed'] ?? 0;
4319 $page_count = $response['document_processing']['total_pages'] ?? 0;
4320 if ( $doc_count > 0 ) {
4321 $summary_parts[] = sprintf( '%d documents (%d pages)', $doc_count, $page_count );
4322 }
4323 $extra['document_processing'] = $response['document_processing'];
4324 }
4325
4326 WPF()->ai_logs->log( [
4327 'action_type' => AILogs::ACTION_CONTENT_INDEXING,
4328 'user_type' => $user_type,
4329 'credits_used' => $response['credits_consumed'] ?? $response['credits_used'] ?? $indexed_count,
4330 'status' => AILogs::STATUS_SUCCESS,
4331 'content_type' => 'topic',
4332 'request_summary' => sprintf( 'Cloud indexing: %d topics', count( $topic_ids ) ),
4333 'response_summary' => implode( ', ', $summary_parts ),
4334 'extra_data' => wp_json_encode( $extra ),
4335 ] );
4336 }
4337
4338 return $response;
4339 }
4340
4341 /**
4342 * Update indexed hash for topics after successful indexing
4343 *
4344 * The indexed hash is an MD5 of "topicid_postcount" which changes when posts are added/removed.
4345 * This enables the summarization feature to use cloud-stored posts instead of requiring WordPress to send them.
4346 *
4347 * @param array $indexed_hashes Array of topicid => hash mappings
4348 * @return int Number of topics updated
4349 */
4350 private function update_topics_indexed_hash( $indexed_hashes ) {
4351 if ( empty( $indexed_hashes ) ) {
4352 return 0;
4353 }
4354
4355 global $wpdb;
4356
4357 // Build CASE statement for hash values (each topic has a specific hash from API)
4358 $case_parts = [];
4359 $topic_ids = [];
4360 foreach ( $indexed_hashes as $topic_id => $hash ) {
4361 $topic_id = (int) $topic_id;
4362 $hash = sanitize_text_field( $hash );
4363 $topic_ids[] = $topic_id;
4364 $case_parts[] = "WHEN {$topic_id} THEN '{$hash}'";
4365 }
4366
4367 if ( empty( $topic_ids ) ) {
4368 return 0;
4369 }
4370
4371 $ids_list = implode( ',', $topic_ids );
4372 $case_statement = implode( ' ', $case_parts );
4373
4374 // Update indexed hash and cloud column in a single query
4375 $updated = $wpdb->query(
4376 "UPDATE `" . WPF()->tables->topics . "`
4377 SET `indexed` = CASE topicid {$case_statement} END,
4378 `cloud` = 1
4379 WHERE topicid IN ({$ids_list})"
4380 );
4381
4382 if ( $updated > 0 ) {
4383 $this->log_info( 'topics_indexed_hash_updated', [ 'count' => $updated ] );
4384 // Clear topic cache to reflect indexed status
4385 wpforo_clean_cache( 'topic' );
4386 // Clear indexing stats cache for fresh counts in UI
4387 WPF()->vector_storage->clear_indexing_stats_cache();
4388 }
4389
4390 return (int) $updated;
4391 }
4392
4393 /**
4394 * Clear indexed status for all topics
4395 *
4396 * Called when:
4397 * - Clear Database is clicked (vectors deleted)
4398 * - Re-Index All is clicked (forcing re-indexing)
4399 * - Disconnect Service is clicked (vectors deleted)
4400 *
4401 * Clears both the `indexed` hash and the `cloud` column.
4402 *
4403 * @return int Number of topics updated
4404 */
4405 private function clear_topics_indexed_status() {
4406 $result = WPF()->db->query(
4407 "UPDATE `" . WPF()->tables->topics . "` SET `indexed` = NULL, `cloud` = 0 WHERE `indexed` IS NOT NULL OR `cloud` = 1"
4408 );
4409
4410 if ( $result !== false && $result > 0 ) {
4411 $this->log_info( 'topics_indexed_status_cleared', [ 'count' => $result ] );
4412 wpforo_clean_cache( 'topic' );
4413 // Clear indexing stats cache for fresh counts
4414 WPF()->vector_storage->clear_indexing_stats_cache();
4415 }
4416
4417 return (int) $result;
4418 }
4419
4420 /**
4421 * Re-index all topics
4422 *
4423 * Memory-efficient implementation using pagination to handle large forums (100,000+ topics)
4424 * Processes topics in batches without loading all topics into memory at once
4425 *
4426 * @param int $chunk_size Chunk size for text splitting in tokens (default: 512 tokens)
4427 * @param int $overlap_percent Overlap percentage for chunking (default: 20%)
4428 * @return array|WP_Error Response data or error object
4429 */
4430 public function reindex_all_topics( $chunk_size = 512, $overlap_percent = 20 ) {
4431 // Check if database clearing is in progress
4432 if ( $this->is_clearing_in_progress() ) {
4433 $remaining = $this->get_clearing_time_remaining();
4434 $minutes = ceil( $remaining / 60 );
4435 return new \WP_Error(
4436 'clearing_in_progress',
4437 sprintf(
4438 /* translators: %d: minutes remaining */
4439 wpforo_phrase( 'Database clearing is in progress. Please wait approximately %d minute(s) before starting new indexing.', false ),
4440 $minutes
4441 )
4442 );
4443 }
4444
4445 $storage_manager = WPF()->vector_storage;
4446
4447 // Get total topic count
4448 $total_topics = WPF()->topic->get_count();
4449
4450 if ( empty( $total_topics ) || $total_topics <= 0 ) {
4451 return new \WP_Error( 'no_topics', wpforo_phrase( 'No topics found in forum', false ) );
4452 }
4453
4454 // Check available credits before starting
4455 $status = $this->get_tenant_status( true ); // Force fresh status
4456 if ( is_wp_error( $status ) ) {
4457 return $status;
4458 }
4459
4460 $credits_available = isset( $status['subscription']['credits_remaining'] ) ? (int) $status['subscription']['credits_remaining'] : 0;
4461
4462 if ( $credits_available <= 0 ) {
4463 return new \WP_Error(
4464 'no_credits',
4465 wpforo_phrase( 'No credits available for indexing. Please wait for your monthly credit reset or upgrade your plan.', false )
4466 );
4467 }
4468
4469 // Get unindexed topics (cloud = 0)
4470 $unindexed_topic_ids = $storage_manager->get_unindexed_topic_ids();
4471 $unindexed_count = count( $unindexed_topic_ids );
4472
4473 // Determine if we're doing incremental indexing or full re-index
4474 $is_reindex_all = ( $unindexed_count === 0 );
4475
4476 if ( $is_reindex_all ) {
4477 // All topics are indexed - user wants to re-index everything
4478 // Clear indexed status to force re-indexing
4479 $this->clear_topics_indexed_status();
4480
4481 $this->log_info( 'reindex_mode', [
4482 'mode' => 'reindex_all',
4483 'total_topics' => $total_topics,
4484 ] );
4485
4486 // Get all topic IDs for re-indexing
4487 $all_topic_ids = $storage_manager->get_unindexed_topic_ids(); // Now all have cloud=0
4488 } else {
4489 // Some topics need indexing - only index those (don't clear status)
4490 $all_topic_ids = $unindexed_topic_ids;
4491
4492 $this->log_info( 'reindex_mode', [
4493 'mode' => 'incremental',
4494 'unindexed_count' => $unindexed_count,
4495 'total_topics' => $total_topics,
4496 ] );
4497 }
4498
4499 // Limit topics to available credits
4500 if ( count( $all_topic_ids ) > $credits_available ) {
4501 $all_topic_ids = array_slice( $all_topic_ids, 0, $credits_available );
4502 }
4503
4504 $this->log_info( 'reindex_credit_check', [
4505 'topics_to_index' => count( $all_topic_ids ),
4506 'credits_available' => $credits_available,
4507 'is_reindex_all' => $is_reindex_all,
4508 ] );
4509
4510 if ( empty( $all_topic_ids ) ) {
4511 return new \WP_Error( 'no_topics', wpforo_phrase( 'No topic IDs collected', false ) );
4512 }
4513
4514 // Configuration for memory-efficient processing
4515 $pagination_size = (int) wpforo_get_option( 'ai_pagination_size', 20 );
4516 $api_batch_size = $pagination_size;
4517
4518 $this->log_info( 'reindex_started', [
4519 'topics_to_index' => count( $all_topic_ids ),
4520 'api_batch_size' => $api_batch_size,
4521 'chunk_size' => $chunk_size,
4522 'overlap_percent' => $overlap_percent,
4523 ] );
4524
4525 // Check if we limited topics due to credits
4526 $original_count = $is_reindex_all ? $total_topics : $unindexed_count;
4527 $topics_limited = count( $all_topic_ids ) < $original_count;
4528 $skipped_topics = $topics_limited ? $original_count - count( $all_topic_ids ) : 0;
4529
4530 // Split topic IDs into batches for API calls
4531 $api_batches = array_chunk( $all_topic_ids, $api_batch_size );
4532 $total_batches = count( $api_batches );
4533
4534 $this->log_info( 'reindex_topic_ids_collected', [
4535 'topic_ids_count' => count( $all_topic_ids ),
4536 'api_batch_size' => $api_batch_size,
4537 'total_batches' => $total_batches,
4538 'batches_to_queue' => max( 0, $total_batches - 1 ), // -1 because first batch is processed immediately
4539 'topics_limited' => $topics_limited,
4540 'skipped_topics' => $skipped_topics,
4541 ] );
4542
4543 // Process first batch immediately (synchronous)
4544 $first_batch = array_shift( $api_batches );
4545 $response = $this->ingest_topics( $first_batch, $chunk_size, $overlap_percent );
4546
4547 if ( is_wp_error( $response ) ) {
4548 $this->log_error( 'reindex_first_batch_failed', $response->get_error_message() );
4549 return $response;
4550 }
4551
4552 // Queue remaining batches for background processing via WP Cron
4553 $scheduled_count = 0;
4554 $failed_schedules = [];
4555
4556 if ( ! empty( $api_batches ) ) {
4557 // Get plan-based interval (Enterprise: 5s, Business: 10s, Professional: 10s, Starter/Free: 20s)
4558 $batch_interval = $this->get_cron_interval_for_plan();
4559
4560 // Reset array keys after array_shift to ensure sequential indices starting from 0
4561 $api_batches = array_values( $api_batches );
4562
4563 foreach ( $api_batches as $batch_index => $batch ) {
4564 // Schedule with staggered timing based on subscription plan
4565 // Higher tier plans get faster indexing speeds
4566 // Note: Args must be wrapped in indexed array so the entire associative array
4567 // is passed as the first argument to the callback
4568 $scheduled_time = time() + ( ( $batch_index + 1 ) * $batch_interval );
4569 $cron_args = [
4570 [
4571 'topic_ids' => $batch,
4572 'chunk_size' => $chunk_size,
4573 'overlap_percent' => $overlap_percent,
4574 ]
4575 ];
4576
4577 $result = wp_schedule_single_event( $scheduled_time, 'wpforo_ai_process_batch', $cron_args );
4578
4579 if ( $result === false ) {
4580 $failed_schedules[] = [
4581 'batch_index' => $batch_index,
4582 'topics_count' => count( $batch ),
4583 'scheduled_time' => $scheduled_time,
4584 ];
4585 } else {
4586 $scheduled_count++;
4587 }
4588 }
4589
4590 $this->log_info( 'reindex_batches_scheduled', [
4591 'scheduled_batches' => $scheduled_count,
4592 'failed_schedules' => count( $failed_schedules ),
4593 'topics_per_batch' => $api_batch_size,
4594 'batch_interval_seconds' => $batch_interval,
4595 'failed_details' => $failed_schedules,
4596 ] );
4597 } else {
4598 $this->log_info( 'reindex_no_batches_to_schedule', [
4599 'reason' => 'All topics fit in first batch',
4600 'first_batch_size' => count( $first_batch ),
4601 ] );
4602 }
4603
4604 // Clear cached status to force refresh
4605 $this->clear_rag_status_cache();
4606
4607 // Trigger action hook for external integrations
4608 do_action( 'wpforo_ai_reindex_started', count( $all_topic_ids ) );
4609
4610 // Build message with credit limiting info if applicable
4611 if ( $topics_limited ) {
4612 $message = sprintf(
4613 wpforo_phrase( 'Re-indexing started: %1$d topics queued (limited by %2$d available credits). %3$d topics skipped.', false ),
4614 count( $all_topic_ids ),
4615 $credits_available,
4616 $skipped_topics
4617 );
4618 } else {
4619 $message = sprintf(
4620 wpforo_phrase( 'Re-indexing started: %d topics queued', false ),
4621 count( $all_topic_ids )
4622 );
4623 }
4624
4625 return [
4626 'success' => true,
4627 'message' => $message,
4628 'topics_queued' => count( $all_topic_ids ),
4629 'topics_limited' => $topics_limited,
4630 'skipped_topics' => $skipped_topics,
4631 'total_topics' => $total_topics,
4632 'total_batches' => $total_batches,
4633 'first_batch_sent' => count( $first_batch ),
4634 'scheduled_crons' => $scheduled_count,
4635 'failed_schedules' => count( $failed_schedules ),
4636 ];
4637 }
4638
4639 /**
4640 * Clear RAG database
4641 *
4642 * The actual deletion is queued and processed asynchronously (up to 5 minutes).
4643 * This returns immediately after queueing the operation.
4644 *
4645 * Local database is updated immediately to reflect the clearing state.
4646 *
4647 * @param int $board_id Board ID (reserved for future multi-board support)
4648 * @return array|WP_Error Response data or error object
4649 */
4650 public function clear_rag_database( $board_id = 0 ) {
4651 // API returns 202 Accepted immediately, actual deletion happens async
4652 $response = $this->delete( '/rag/clear', [], 30 );
4653
4654 if ( is_wp_error( $response ) ) {
4655 $this->log_error( 'rag_clear_failed', $response->get_error_message() );
4656 return $response;
4657 }
4658
4659 $this->log_info( 'rag_database_clear_queued', [
4660 'status' => $response['status'] ?? 'unknown',
4661 ] );
4662 $this->clear_rag_status_cache();
4663
4664 // Set clearing lock to prevent new indexing during async clear
4665 // Lock expires after 5 minutes (async clear should complete by then)
4666 $this->set_clearing_lock();
4667
4668 // Clear indexed status for all topics immediately
4669 // The cloud data will be deleted asynchronously, but we update local state now
4670 // so the UI shows correct counts right away
4671 $this->clear_topics_indexed_status();
4672
4673 do_action( 'wpforo_ai_rag_cleared', $response );
4674
4675 // Return response with note about async processing
4676 return [
4677 'success' => true,
4678 'message' => $response['message'] ?? wpforo_phrase( 'Database clear operation queued', false ),
4679 'status' => 'clearing',
4680 'note' => $response['note'] ?? wpforo_phrase( 'The database is being cleared in the background. This may take a few minutes.', false ),
4681 ];
4682 }
4683
4684 /**
4685 * Clear index for specific forums.
4686 *
4687 * @param array $forum_ids Forum IDs to clear
4688 * @param int $board_id Board ID
4689 * @return array|WP_Error Result or error
4690 */
4691 public function clear_forum_index( $forum_ids, $board_id = 0 ) {
4692 $response = $this->delete( '/rag/forums', [
4693 'forum_ids' => array_values( array_map( 'intval', (array) $forum_ids ) ),
4694 'board_id' => (int) $board_id,
4695 ] );
4696
4697 if ( is_wp_error( $response ) ) {
4698 $this->log_error( 'clear_forum_index_failed', $response->get_error_message(), [ 'forum_ids' => $forum_ids ] );
4699 return $response;
4700 }
4701
4702 $this->log_info( 'forum_index_clear_queued', [
4703 'forum_ids' => $forum_ids,
4704 'board_id' => $board_id,
4705 'forums_count' => $response['forums_count'] ?? count( $forum_ids ),
4706 ] );
4707
4708 $this->clear_rag_status_cache();
4709
4710 // Set clearing lock to prevent new indexing during async clear
4711 // Lock expires after 5 minutes (same as full clear)
4712 $this->set_clearing_lock();
4713
4714 // Return async response - actual deletion happens in background
4715 return [
4716 'success' => true,
4717 'status' => 'clearing',
4718 'message' => $response['message'] ?? wpforo_phrase( 'Forum index clear operation queued. Processing in background.', false ),
4719 'forums_count' => $response['forums_count'] ?? count( $forum_ids ),
4720 ];
4721 }
4722
4723 /**
4724 * Set a lock to prevent new indexing during async database clear
4725 *
4726 * The lock expires after 5 minutes (the maximum time for async clear to complete).
4727 */
4728 private function set_clearing_lock() {
4729 set_transient( 'wpforo_ai_clearing_in_progress', time(), 5 * MINUTE_IN_SECONDS );
4730 }
4731
4732 /**
4733 * Check if database clearing is in progress
4734 *
4735 * @return bool True if clearing is in progress, false otherwise
4736 */
4737 public function is_clearing_in_progress() {
4738 return (bool) get_transient( 'wpforo_ai_clearing_in_progress' );
4739 }
4740
4741 /**
4742 * Get remaining time until clearing lock expires
4743 *
4744 * @return int Seconds remaining, or 0 if not clearing
4745 */
4746 public function get_clearing_time_remaining() {
4747 $started = get_transient( 'wpforo_ai_clearing_in_progress' );
4748 if ( ! $started ) {
4749 return 0;
4750 }
4751 $elapsed = time() - (int) $started;
4752 $remaining = ( 5 * MINUTE_IN_SECONDS ) - $elapsed;
4753 return max( 0, $remaining );
4754 }
4755
4756 /**
4757 * Clear RAG status cache
4758 */
4759 public function clear_rag_status_cache() {
4760 delete_transient( 'wpforo_ai_rag_status' );
4761 }
4762
4763 /**
4764 * Queue a topic for cloud indexing
4765 *
4766 * This method is called by VectorStorageManager when in cloud mode.
4767 * It queues the topic for background processing via the cloud RAG pipeline.
4768 *
4769 * @param int $topicid Topic ID to queue
4770 * @param int $board_id Board ID (reserved for future multi-board support)
4771 * @return array|WP_Error Result or error
4772 */
4773 public function queue_topic_for_indexing( $topicid, $board_id = 0 ) {
4774 $response = $this->post( '/rag/queue', [
4775 'topic_ids' => [ $topicid ],
4776 'board_id' => $board_id,
4777 ] );
4778
4779 if ( is_wp_error( $response ) ) {
4780 $this->log_error( 'queue_topic_failed', $response->get_error_message(), [ 'topicid' => $topicid ] );
4781 return $response;
4782 }
4783
4784 $this->log_info( 'topic_queued_for_indexing', [
4785 'topicid' => $topicid,
4786 'board_id' => $board_id,
4787 ] );
4788
4789 return $response;
4790 }
4791
4792 /**
4793 * Delete a topic from the cloud index
4794 *
4795 * This method is called by VectorStorageManager when in cloud mode.
4796 * It removes all embeddings associated with the topic from the cloud storage.
4797 *
4798 * @param int $topicid Topic ID to delete
4799 * @param int $board_id Board ID (reserved for future multi-board support)
4800 * @return bool|WP_Error True on success, WP_Error on failure
4801 */
4802 public function delete_topic_from_index( $topicid, $board_id = 0 ) {
4803 $response = $this->delete( '/rag/topic/' . intval( $topicid ), [
4804 'board_id' => $board_id,
4805 ] );
4806
4807 if ( is_wp_error( $response ) ) {
4808 $this->log_error( 'delete_topic_failed', $response->get_error_message(), [ 'topicid' => $topicid ] );
4809 return $response;
4810 }
4811
4812 $this->log_info( 'topic_deleted_from_index', [
4813 'topicid' => $topicid,
4814 'board_id' => $board_id,
4815 ] );
4816
4817 return true;
4818 }
4819
4820 /**
4821 * Delete a post from the cloud index
4822 *
4823 * This method is called by VectorStorageManager when in cloud mode.
4824 * It removes the embedding for a specific post from the cloud storage.
4825 *
4826 * @param int $postid Post ID to delete
4827 * @param int $board_id Board ID (reserved for future multi-board support)
4828 * @return bool|WP_Error True on success, WP_Error on failure
4829 */
4830 public function delete_post_from_index( $postid, $board_id = 0 ) {
4831 $response = $this->delete( '/rag/post/' . intval( $postid ), [
4832 'board_id' => $board_id,
4833 ] );
4834
4835 if ( is_wp_error( $response ) ) {
4836 $this->log_error( 'delete_post_failed', $response->get_error_message(), [ 'postid' => $postid ] );
4837 return $response;
4838 }
4839
4840 $this->log_info( 'post_deleted_from_index', [
4841 'postid' => $postid,
4842 'board_id' => $board_id,
4843 ] );
4844
4845 return true;
4846 }
4847
4848 /**
4849 * Find similar topics via cloud API
4850 *
4851 * This method is called by VectorStorageManager when in cloud mode.
4852 * It uses the cloud semantic search to find topics similar to the given one.
4853 *
4854 * @param int $topic_id Topic ID to find similar topics for
4855 * @param int $limit Maximum number of results to return
4856 * @return array|WP_Error Array of similar topic IDs with scores, or WP_Error on failure
4857 */
4858 public function find_similar_topics( $topic_id, $limit = 5 ) {
4859 $response = $this->api_get( '/rag/similar', [
4860 'topic_id' => $topic_id,
4861 'limit' => $limit,
4862 ] );
4863
4864 if ( is_wp_error( $response ) ) {
4865 $this->log_error( 'find_similar_failed', $response->get_error_message(), [ 'topic_id' => $topic_id ] );
4866 return $response;
4867 }
4868
4869 return isset( $response['results'] ) ? $response['results'] : [];
4870 }
4871
4872 /**
4873 * Get cron interval (in seconds) based on subscription plan
4874 *
4875 * Higher tier plans get faster indexing speeds:
4876 * - Free trial & Starter: 20 seconds between batches
4877 * - Professional & Business: 10 seconds
4878 * - Enterprise: 5 seconds
4879 *
4880 * Note: Faster intervals mean topics get QUEUED faster, but actual processing
4881 * speed depends on API concurrency limits. SQS handles message accumulation.
4882 *
4883 * @return int Interval in seconds
4884 */
4885 public function get_cron_interval_for_plan() {
4886 // Use cached plan from database (no API calls during cron)
4887 $plan = strtolower( $this->get_subscription_plan() );
4888
4889 // Plan-based intervals (in seconds)
4890 // Faster intervals queue topics quicker; actual processing depends on API concurrency
4891 $intervals = [
4892 'enterprise' => 5, // Fastest queuing for enterprise
4893 'business' => 10,
4894 'professional' => 10,
4895 'starter' => 20,
4896 'free_trial' => 20,
4897 ];
4898
4899 // Return interval for plan, default to 20 seconds for unknown plans
4900 return isset( $intervals[ $plan ] ) ? $intervals[ $plan ] : 20;
4901 }
4902
4903 /**
4904 * Check if there are pending WP Cron jobs for background indexing
4905 *
4906 * @return array Status information about pending jobs
4907 */
4908 /**
4909 * admin_init wrapper: only nudge on real wpForo AI admin page loads.
4910 *
4911 * Checks `$_GET['page']` against the wpForo AI admin slugs and skips
4912 * everything else (other admin pages, AJAX requests, cron requests,
4913 * REST requests). This is the single entry point for the nudge — it
4914 * does NOT run on AJAX status polls.
4915 *
4916 * @return void
4917 */
4918 public function maybe_nudge_wp_cron_on_admin_page() {
4919 // Never run inside AJAX / cron / REST contexts — only on a real
4920 // admin page load (i.e. an admin hitting Refresh on the wpForo AI
4921 // admin page, or opening it from the menu).
4922 if ( wp_doing_ajax() || wp_doing_cron() ) {
4923 return;
4924 }
4925 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
4926 return;
4927 }
4928
4929 // Only on the wpForo AI admin pages.
4930 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
4931 if ( $page === '' || strpos( $page, 'wpforo-ai' ) !== 0 ) {
4932 return;
4933 }
4934
4935 $this->maybe_nudge_wp_cron();
4936 }
4937
4938 /**
4939 * Self-heal stalled WP-Cron for indexing background jobs.
4940 *
4941 * On some hosts (managed hosts where the loopback HTTP used by
4942 * `spawn_cron()` is blocked, sites with `DISABLE_WP_CRON`, sites with
4943 * plugins that suppress cron, or sites with no recent front-end traffic)
4944 * a scheduled `wpforo_ai_process_queue_*` event sits in the cron array
4945 * forever and the admin sees "Indexing..." with zero progress.
4946 *
4947 * Scope — this is intentionally narrow:
4948 * - Per-board forum queue hooks: wpforo_ai_process_queue_cloud,
4949 * wpforo_ai_process_queue_local, wpforo_ai_process_queue.
4950 * Only events whose first arg equals the CURRENT board id.
4951 * - WordPress content hook: wpforo_ai_process_wp_batch (global, no
4952 * board scope — WP posts/pages are site-wide, not per-board).
4953 * - Only events overdue by >= 2 minutes.
4954 * - No other plugin's cron jobs, ever.
4955 *
4956 * Previous attempts used `spawn_cron()` to kick off WP's normal cron
4957 * loopback. That fails on hosts that block or strip the loopback
4958 * request, which is exactly the environments we need to heal. This
4959 * version does what `leira-cron-jobs` does in its "Run now" action:
4960 * it fires the scheduled hooks INLINE in the current PHP request via
4961 * `do_action_ref_array()` — no loopback HTTP, no background request.
4962 *
4963 * Flow (per overdue hook):
4964 * 1. Find the scheduled event in `_get_cron_array()`.
4965 * 2. Reschedule recurring events for their next tick, or unschedule
4966 * single-run events so they don't fire twice.
4967 * 3. `do_action_ref_array( $hook, $args )` to run the handler inline.
4968 *
4969 * This helper only runs on wpForo AI admin page loads (refresh), NOT
4970 * on AJAX status polls. The admin hitting Refresh is effectively the
4971 * user asking "please run any stalled indexing work right now".
4972 *
4973 * Safety notes:
4974 * - The forum indexing processors have their own lock transient
4975 * (`wpforo_ai_indexing_lock_{mode}_{board_id}`, 300s TTL) so rapid
4976 * refreshes cannot cause duplicate batch processing.
4977 * - The WP content indexer has its own lock transient
4978 * (`wpforo_ai_wp_indexing_lock`, 300s TTL) to prevent duplicate
4979 * batch processing on rapid admin refreshes.
4980 * - We only fire hooks that are already OVERDUE (>= 2 min past due).
4981 * On a healthy site WP-Cron fires events on schedule, nothing is
4982 * overdue, and this is a pure no-op.
4983 * - Not called during AJAX polling → zero race window with Stop Indexing.
4984 * - Not called during REST / cron / non-wpforo-ai pages.
4985 * - No new settings, no new UI, no new cron hooks.
4986 *
4987 * Tradeoff: the page refresh will take as long as one batch of
4988 * indexing work takes (typically a few seconds). Admins refreshing
4989 * a stalled indexing dialog already expect work to happen.
4990 *
4991 * @return void
4992 */
4993 public function maybe_nudge_wp_cron() {
4994 if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( '_get_cron_array' ) ) {
4995 return;
4996 }
4997
4998 // Scope the nudge to the current board. Each board has its own
4999 // queue cron (args = [ $board_id ]) and admins view the AI page
5000 // one board at a time — we should only run indexing for the board
5001 // the admin is currently looking at, nothing else.
5002 if ( ! isset( WPF()->board ) || ! method_exists( WPF()->board, 'get_current' ) ) {
5003 return;
5004 }
5005 $current_board_id = WPF()->board->get_current( 'boardid' );
5006 if ( $current_board_id === null || $current_board_id === false ) {
5007 return;
5008 }
5009 $current_board_id = (int) $current_board_id;
5010
5011 $now = time();
5012 // Grace period so a normal brief WP-Cron tardiness on healthy sites
5013 // does not trigger the inline run.
5014 $overdue_threshold = 120;
5015
5016 // Per-board forum indexing hooks. These are scheduled by
5017 // VectorStorageManager with args = [ $board_id ] and protected by
5018 // the wpforo_ai_indexing_lock_{mode}_{board_id} transient.
5019 $forum_hooks = [
5020 'wpforo_ai_process_queue_cloud' => true,
5021 'wpforo_ai_process_queue_local' => true,
5022 'wpforo_ai_process_queue' => true,
5023 ];
5024
5025 // Global WordPress content indexing hook (no board scope).
5026 // Protected by wpforo_ai_wp_indexing_lock transient in
5027 // AIWordPressIndexer::process_batch_with_lock().
5028 $wp_content_hooks = [
5029 'wpforo_ai_process_wp_batch' => true,
5030 ];
5031
5032 $crons = _get_cron_array();
5033 if ( ! is_array( $crons ) || empty( $crons ) ) {
5034 return;
5035 }
5036
5037 // Collect overdue events.
5038 // Forum hooks: must match current board.
5039 // WP content hooks: global (no board scope), always include if overdue.
5040 $to_run = [];
5041 foreach ( $crons as $timestamp => $events ) {
5042 if ( ( $now - $timestamp ) < $overdue_threshold ) {
5043 continue;
5044 }
5045 if ( ! is_array( $events ) ) {
5046 continue;
5047 }
5048 foreach ( $events as $hook => $dings ) {
5049 $is_forum_hook = isset( $forum_hooks[ $hook ] );
5050 $is_wp_content_hook = isset( $wp_content_hooks[ $hook ] );
5051
5052 if ( ! $is_forum_hook && ! $is_wp_content_hook ) {
5053 continue;
5054 }
5055 if ( ! is_array( $dings ) ) {
5056 continue;
5057 }
5058
5059 foreach ( $dings as $sig => $event ) {
5060 $args = isset( $event['args'] ) ? (array) $event['args'] : [];
5061
5062 // Forum hooks: must be scoped to the board admin is viewing.
5063 if ( $is_forum_hook ) {
5064 $event_board_id = isset( $args[0] ) ? (int) $args[0] : 0;
5065 if ( $event_board_id !== $current_board_id ) {
5066 continue;
5067 }
5068 }
5069 // WP content hooks: global, no board scope check needed.
5070
5071 $to_run[] = [
5072 'timestamp' => $timestamp,
5073 'hook' => $hook,
5074 'args' => $args,
5075 'schedule' => isset( $event['schedule'] ) ? $event['schedule'] : false,
5076 ];
5077 }
5078 }
5079 }
5080
5081 if ( empty( $to_run ) ) {
5082 return;
5083 }
5084
5085 // Mark the current request as a cron context so the inline handlers
5086 // behave identically to a normal WP-Cron invocation.
5087 if ( ! defined( 'DOING_CRON' ) ) {
5088 define( 'DOING_CRON', true );
5089 }
5090
5091 foreach ( $to_run as $event ) {
5092 // Defensive: skip hooks with no registered handler.
5093 if ( ! has_action( $event['hook'] ) ) {
5094 continue;
5095 }
5096
5097 // Single-run events only in this whitelist — unschedule so
5098 // do_action below is the only run. (Guard against recurring
5099 // anyway in case a future caller schedules one recurring.)
5100 if ( false !== $event['schedule'] ) {
5101 wp_reschedule_event( $event['timestamp'], $event['schedule'], $event['hook'], $event['args'] );
5102 }
5103 wp_unschedule_event( $event['timestamp'], $event['hook'], $event['args'] );
5104
5105 // Fire the handler inline. Wrapped in try/catch so a fatal
5106 // in the indexing handler does not white-screen the admin
5107 // page — log and move on.
5108 try {
5109 do_action_ref_array( $event['hook'], $event['args'] );
5110 } catch ( \Throwable $e ) {
5111 if ( method_exists( $this, 'log_error' ) ) {
5112 $this->log_error(
5113 'inline_cron_run_failed',
5114 [
5115 'hook' => $event['hook'],
5116 'error' => $e->getMessage(),
5117 ]
5118 );
5119 }
5120 }
5121 }
5122 }
5123
5124 public function get_pending_cron_jobs() {
5125 $crons = _get_cron_array();
5126 $pending_jobs = 0;
5127 $pending_topics = 0;
5128 $is_actively_processing = false;
5129 $board_id = WPF()->board->get_current( 'boardid' ) ?: 0;
5130 $now = time();
5131
5132 // Check for queue-based processing (self-rescheduling pattern)
5133 // Mode-specific keys (current format): wpforo_ai_indexing_queue_{mode}_{board_id}
5134 // Legacy key (old format): wpforo_ai_indexing_queue_{board_id}
5135 foreach ( [ 'local', 'cloud', '' ] as $mode ) {
5136 $queue_key = 'wpforo_ai_indexing_queue_' . ( $mode ? $mode . '_' : '' ) . $board_id;
5137 $queue = get_option( $queue_key, [] );
5138 if ( ! empty( $queue ) ) {
5139 $pending_topics += count( $queue );
5140 $pending_jobs = max( $pending_jobs, 1 );
5141 }
5142 }
5143
5144 // Check if any queue processor cron is scheduled (mode-specific or legacy)
5145 // and whether it's due now (actively processing) or scheduled for the future (just queued)
5146 foreach ( [ 'wpforo_ai_process_queue_local', 'wpforo_ai_process_queue_cloud', 'wpforo_ai_process_queue' ] as $cron_hook ) {
5147 $next = wp_next_scheduled( $cron_hook, [ $board_id ] );
5148 if ( $next ) {
5149 $pending_jobs = max( $pending_jobs, 1 );
5150 // Cron is due or overdue — batch processing is imminent or in progress
5151 if ( $next <= $now ) {
5152 $is_actively_processing = true;
5153 }
5154 }
5155 }
5156
5157 // Also check if a processing lock is held (batch is running right now)
5158 if ( get_transient( 'wpforo_ai_indexing_lock_local_' . $board_id )
5159 || get_transient( 'wpforo_ai_indexing_lock_cloud_' . $board_id )
5160 || get_transient( 'wpforo_ai_indexing_lock_' . $board_id ) ) {
5161 $is_actively_processing = true;
5162 }
5163
5164 // Legacy: Search for wpforo_ai_process_batch scheduled events (old pattern)
5165 if ( ! empty( $crons ) ) {
5166 foreach ( $crons as $timestamp => $cron ) {
5167 if ( isset( $cron['wpforo_ai_process_batch'] ) ) {
5168 foreach ( $cron['wpforo_ai_process_batch'] as $key => $job ) {
5169 $pending_jobs++;
5170 if ( $timestamp <= $now ) {
5171 $is_actively_processing = true;
5172 }
5173 // Count topics in this batch
5174 $args = isset( $job['args'] ) ? $job['args'] : [];
5175 if ( isset( $args[0] ) && is_array( $args[0] ) ) {
5176 $args = $args[0];
5177 }
5178 $topic_ids = isset( $args['topic_ids'] ) ? $args['topic_ids'] : [];
5179 $pending_topics += count( $topic_ids );
5180 }
5181 }
5182 }
5183 }
5184
5185 return [
5186 'has_pending_jobs' => $pending_jobs > 0,
5187 'is_actively_processing' => $is_actively_processing,
5188 'pending_jobs' => $pending_jobs,
5189 'pending_topics' => $pending_topics,
5190 ];
5191 }
5192
5193 /**
5194 * Clear all pending WP Cron jobs for background indexing
5195 *
5196 * @return array Status information about cleared jobs
5197 */
5198 public function clear_pending_cron_jobs() {
5199 $crons = _get_cron_array();
5200 $cleared_jobs = 0;
5201 $cleared_topics = 0;
5202 $board_id = WPF()->board->get_current( 'boardid' ) ?: 0;
5203
5204 // Clear queue-based pattern: mode-specific keys (current) and legacy key
5205 foreach ( [ 'local', 'cloud', '' ] as $mode ) {
5206 $queue_key = 'wpforo_ai_indexing_queue_' . ( $mode ? $mode . '_' : '' ) . $board_id;
5207 $queue = get_option( $queue_key, [] );
5208 if ( ! empty( $queue ) ) {
5209 $cleared_topics += count( $queue );
5210 $cleared_jobs++;
5211 delete_option( $queue_key );
5212 }
5213 }
5214
5215 // Clear all queue processor crons (mode-specific and legacy)
5216 wp_clear_scheduled_hook( 'wpforo_ai_process_queue_local', [ $board_id ] );
5217 wp_clear_scheduled_hook( 'wpforo_ai_process_queue_cloud', [ $board_id ] );
5218 wp_clear_scheduled_hook( 'wpforo_ai_process_queue', [ $board_id ] );
5219
5220 // Clear forum indexing lock transients (legacy and mode-specific)
5221 // so new indexing can start immediately after stop.
5222 delete_transient( 'wpforo_ai_indexing_lock_' . $board_id );
5223 delete_transient( 'wpforo_ai_indexing_lock_local_' . $board_id );
5224 delete_transient( 'wpforo_ai_indexing_lock_cloud_' . $board_id );
5225
5226 // Legacy: Find and remove all wpforo_ai_process_batch scheduled events
5227 if ( ! empty( $crons ) ) {
5228 foreach ( $crons as $timestamp => $cron ) {
5229 if ( isset( $cron['wpforo_ai_process_batch'] ) ) {
5230 foreach ( $cron['wpforo_ai_process_batch'] as $key => $job ) {
5231 // Count topics in this batch before removing
5232 $args = isset( $job['args'] ) ? $job['args'] : [];
5233 if ( isset( $args[0] ) && is_array( $args[0] ) ) {
5234 $args = $args[0];
5235 }
5236 $topic_ids = isset( $args['topic_ids'] ) ? $args['topic_ids'] : [];
5237 $cleared_topics += count( $topic_ids );
5238 $cleared_jobs++;
5239
5240 // Unschedule this specific event
5241 wp_unschedule_event( $timestamp, 'wpforo_ai_process_batch', $job['args'] );
5242 }
5243 }
5244 }
5245 }
5246
5247 // Also clear any recurring hooks (just in case)
5248 wp_clear_scheduled_hook( 'wpforo_ai_process_batch' );
5249
5250 // Clear WordPress content indexing state (global, not board-scoped).
5251 // This ensures Stop Indexing works for WP content as well as forum.
5252 $wp_queue = get_option( 'wpforo_ai_wp_indexing_queue' );
5253 if ( ! empty( $wp_queue ) ) {
5254 $wp_posts_count = 0;
5255 if ( isset( $wp_queue['batches'] ) && is_array( $wp_queue['batches'] ) ) {
5256 foreach ( $wp_queue['batches'] as $batch ) {
5257 $wp_posts_count += is_array( $batch ) ? count( $batch ) : 0;
5258 }
5259 }
5260 $cleared_topics += $wp_posts_count;
5261 $cleared_jobs++;
5262 delete_option( 'wpforo_ai_wp_indexing_queue' );
5263 }
5264 wp_clear_scheduled_hook( 'wpforo_ai_process_wp_batch' );
5265 delete_transient( 'wpforo_ai_wp_indexing_status' );
5266 delete_transient( 'wpforo_ai_wp_indexing_lock' );
5267
5268 return [
5269 'cleared_jobs' => $cleared_jobs,
5270 'cleared_topics' => $cleared_topics,
5271 'success' => true,
5272 ];
5273 }
5274
5275 /**
5276 * Get user display name with proper fallbacks
5277 *
5278 * Delegates to AIUserTrait::get_single_user_display_name() for consistent
5279 * user name resolution with proper fallback chain.
5280 *
5281 * @param int $userid User ID (0 for guests)
5282 * @return string User display name
5283 */
5284 private function get_user_display_name( $userid ) {
5285 return $this->get_single_user_display_name( (int) $userid );
5286 }
5287
5288 /**
5289 * Format thread data for RAG Indexing
5290 *
5291 * @param array $topic Topic data
5292 * @param array $posts Array of posts/replies
5293 * @return array|null Formatted thread data or null
5294 */
5295 private function format_thread_data( $topic, $posts ) {
5296 if ( empty( $topic ) ) {
5297 return null;
5298 }
5299
5300 // Get topic URL
5301 $topic_url = wpforo_topic( $topic['topicid'], 'url' );
5302
5303 // Separate first post (topic) from replies
5304 $first_post = ! empty( $posts ) ? array_shift( $posts ) : null;
5305
5306 if ( ! $first_post ) {
5307 return null;
5308 }
5309
5310 // Get current board ID (for multi-board support)
5311 $boardid = (int) WPF()->board->get_current( 'boardid' );
5312
5313 // Get display names with proper fallbacks
5314 $topic_display_name = $this->get_user_display_name( $first_post['userid'] );
5315
5316 // Check if image/document indexing is enabled (Professional+ only)
5317 $include_images = $this->is_image_indexing_enabled();
5318 $include_documents = $this->is_document_indexing_enabled();
5319
5320 // Clean post content for indexing (strip quoted content, etc.)
5321 // This removes quoted replies to prevent duplicate content in the index
5322 $cleaned_first_post_body = $this->clean_content_for_indexing( $first_post['body'] );
5323
5324 // Format topic data
5325 $thread_data = [
5326 'thread_id' => (string) $topic['topicid'],
5327 'board_id' => $boardid,
5328 'topic' => [
5329 'title' => $topic['title'],
5330 'body' => $cleaned_first_post_body,
5331 'author' => wpforo_member( $first_post['userid'], 'user_login' ) ?: $topic_display_name,
5332 'author_id' => (string) $first_post['userid'],
5333 'display_name' => $topic_display_name,
5334 'post_id' => (string) $first_post['postid'],
5335 'created_at' => is_numeric( $first_post['created'] ) ? date( 'Y-m-d H:i:s', (int) $first_post['created'] ) : $first_post['created'],
5336 'url' => $topic_url,
5337 'forum_id' => (int) $topic['forumid'],
5338 'forum_name' => wpforo_forum( $topic['forumid'], 'title' ),
5339 // New fields for embedding strategy
5340 'is_solved' => ! empty( $topic['solved'] ),
5341 'likes_count' => (int) wpfval( $first_post, 'likes', 0 ),
5342 ],
5343 'replies' => [],
5344 ];
5345
5346 // Add images for first post if image indexing is enabled
5347 // Note: Extract images from cleaned content (quotes already removed)
5348 if ( $include_images ) {
5349 $topic_images = $this->extract_post_images( $cleaned_first_post_body );
5350 if ( ! empty( $topic_images ) ) {
5351 $thread_data['topic']['images'] = $topic_images;
5352 }
5353 }
5354
5355 // Add documents for first post if document indexing is enabled
5356 if ( $include_documents ) {
5357 $topic_documents = $this->extract_post_documents( $cleaned_first_post_body );
5358 if ( ! empty( $topic_documents ) ) {
5359 $thread_data['topic']['documents'] = $topic_documents;
5360 }
5361 }
5362
5363 // Format replies
5364 foreach ( $posts as $post ) {
5365 $reply_display_name = $this->get_user_display_name( $post['userid'] );
5366
5367 // Clean reply content for indexing (strip quoted content, etc.)
5368 $cleaned_reply_body = $this->clean_content_for_indexing( $post['body'] );
5369
5370 $reply_data = [
5371 'body' => $cleaned_reply_body,
5372 'author' => wpforo_member( $post['userid'], 'user_login' ) ?: $reply_display_name,
5373 'author_id' => (string) $post['userid'],
5374 'display_name' => $reply_display_name,
5375 'post_id' => (string) $post['postid'],
5376 'created_at' => is_numeric( $post['created'] ) ? date( 'Y-m-d H:i:s', (int) $post['created'] ) : $post['created'],
5377 // New fields for embedding strategy
5378 'is_best_answer' => ! empty( $post['is_answer'] ),
5379 'likes_count' => (int) wpfval( $post, 'likes', 0 ),
5380 ];
5381
5382 // Add images for reply if image indexing is enabled
5383 // Note: Extract images from cleaned content (quotes already removed)
5384 if ( $include_images ) {
5385 $reply_images = $this->extract_post_images( $cleaned_reply_body );
5386 if ( ! empty( $reply_images ) ) {
5387 $reply_data['images'] = $reply_images;
5388 }
5389 }
5390
5391 // Add documents for reply if document indexing is enabled
5392 if ( $include_documents ) {
5393 $reply_documents = $this->extract_post_documents( $cleaned_reply_body );
5394 if ( ! empty( $reply_documents ) ) {
5395 $reply_data['documents'] = $reply_documents;
5396 }
5397 }
5398
5399 $thread_data['replies'][] = $reply_data;
5400 }
5401
5402 return $thread_data;
5403 }
5404
5405 /**
5406 * Make GET request to API
5407 *
5408 * @param string $endpoint API endpoint (e.g., '/tenant/status')
5409 * @param array $query_params Query parameters
5410 * @return array|WP_Error Response data or error object
5411 */
5412 private function get( $endpoint, $query_params = [] ) {
5413 return $this->make_request( 'GET', $endpoint, [], $query_params );
5414 }
5415
5416 /**
5417 * Make POST request to API
5418 *
5419 * @param string $endpoint API endpoint
5420 * @param array $data Request body data
5421 * @param int $timeout Optional custom timeout in seconds (0 = use default)
5422 * @return array|WP_Error Response data or error object
5423 */
5424 private function post( $endpoint, $data = [], $timeout = 0 ) {
5425 return $this->make_request( 'POST', $endpoint, $data, [], $timeout );
5426 }
5427
5428 /**
5429 * Make public POST request to API
5430 *
5431 * This method is for external use by other classes (e.g., TaskManager)
5432 * that need to make API calls with custom timeouts.
5433 *
5434 * @param string $endpoint API endpoint
5435 * @param array $data Request body data
5436 * @param int $timeout Optional custom timeout in seconds (default: 30)
5437 * @return array|WP_Error Response data or error object
5438 */
5439 public function api_post( $endpoint, $data = [], $timeout = 30 ) {
5440 return $this->make_request( 'POST', $endpoint, $data, [], $timeout );
5441 }
5442
5443 /**
5444 * Make public GET request to API
5445 *
5446 * This method is for external use by other classes (e.g., VectorStorageManager)
5447 * that need to make API calls.
5448 *
5449 * @param string $endpoint API endpoint
5450 * @param array $query_params Query parameters
5451 * @param int $timeout Optional custom timeout in seconds (default: 30)
5452 * @return array|WP_Error Response data or error object
5453 */
5454 public function api_get( $endpoint, $query_params = [], $timeout = 30 ) {
5455 return $this->make_request( 'GET', $endpoint, [], $query_params, $timeout );
5456 }
5457
5458 /**
5459 * Make PUT request to API
5460 *
5461 * @param string $endpoint API endpoint
5462 * @param array $data Request body data
5463 * @return array|WP_Error Response data or error object
5464 */
5465 private function put( $endpoint, $data = [] ) {
5466 return $this->make_request( 'PUT', $endpoint, $data );
5467 }
5468
5469 /**
5470 * Make DELETE request to API
5471 *
5472 * @param string $endpoint API endpoint
5473 * @param array $data Request body data
5474 * @param int $timeout Optional custom timeout in seconds (default: 30)
5475 * @return array|WP_Error Response data or error object
5476 */
5477 private function delete( $endpoint, $data = [], $timeout = 30 ) {
5478 return $this->make_request( 'DELETE', $endpoint, $data, [], $timeout );
5479 }
5480
5481 /**
5482 * Central request method with automatic fallback domain support
5483 *
5484 * Tries the primary API domain first. If the request fails due to a connection
5485 * error (DNS failure, timeout, blocked domain), automatically retries on the
5486 * fallback domain.
5487 *
5488 * @param string $method HTTP method (GET, POST, PUT, DELETE)
5489 * @param string $endpoint API endpoint
5490 * @param array $data Request body data
5491 * @param array $query_params Query parameters (for GET requests)
5492 * @param int $timeout Optional custom timeout in seconds (0 = use default)
5493 * @return array|WP_Error Response data or error object
5494 */
5495 private function make_request( $method, $endpoint, $data = [], $query_params = [], $timeout = 0 ) {
5496 $args = $this->build_request_args( $method, $data, $endpoint );
5497 if ( $timeout > 0 ) {
5498 $args['timeout'] = $timeout;
5499 }
5500
5501 $this->log_request( $method, $endpoint, ! empty( $data ) ? $data : $query_params );
5502
5503 // Try primary domain
5504 $url = $this->build_url( $endpoint, $query_params );
5505 $response = $this->dispatch_request( $method, $url, $args );
5506
5507 // If connection failed and fallback domain is available, retry
5508 if ( is_wp_error( $response ) && $this->is_connection_error( $response ) && $this->fallback_api_url ) {
5509 $this->log_error( 'Primary API domain failed', $response->get_error_message() );
5510
5511 $fallback_url = $this->build_url( $endpoint, $query_params, $this->fallback_api_url );
5512 $response = $this->dispatch_request( $method, $fallback_url, $args );
5513
5514 if ( ! is_wp_error( $response ) ) {
5515 $this->log_error( 'Fallback domain succeeded', $this->fallback_api_url );
5516 }
5517 }
5518
5519 return $this->process_response( $response );
5520 }
5521
5522 /**
5523 * Dispatch an HTTP request using the appropriate WordPress function
5524 *
5525 * @param string $method HTTP method
5526 * @param string $url Full request URL
5527 * @param array $args Request arguments
5528 * @return array|WP_Error Raw response
5529 */
5530 private function dispatch_request( $method, $url, $args ) {
5531 switch ( $method ) {
5532 case 'GET':
5533 return wp_remote_get( $url, $args );
5534 case 'POST':
5535 return wp_remote_post( $url, $args );
5536 default:
5537 return wp_remote_request( $url, $args );
5538 }
5539 }
5540
5541 /**
5542 * Check if a WP_Error represents a connection-level failure
5543 *
5544 * These are errors where the request never reached the server:
5545 * DNS failures, connection refused, timeouts, SSL errors, blocked requests.
5546 *
5547 * @param \WP_Error $error The error to check
5548 * @return bool True if this is a connection error worth retrying on fallback
5549 */
5550 private function is_connection_error( $error ) {
5551 $code = $error->get_error_code();
5552 $message = strtolower( $error->get_error_message() );
5553
5554 // WordPress HTTP API error codes for connection failures
5555 $connection_codes = [
5556 'http_request_failed',
5557 'http_request_not_executed',
5558 ];
5559
5560 if ( in_array( $code, $connection_codes, true ) ) {
5561 return true;
5562 }
5563
5564 // Check error message for connection-related keywords
5565 $connection_keywords = [
5566 'could not resolve host',
5567 'connection refused',
5568 'connection timed out',
5569 'operation timed out',
5570 'name or service not known',
5571 'network is unreachable',
5572 'no route to host',
5573 'ssl',
5574 'curl error',
5575 ];
5576
5577 foreach ( $connection_keywords as $keyword ) {
5578 if ( strpos( $message, $keyword ) !== false ) {
5579 return true;
5580 }
5581 }
5582
5583 return false;
5584 }
5585
5586 /**
5587 * Build full API URL
5588 *
5589 * @param string $endpoint API endpoint
5590 * @param array $query_params Query parameters
5591 * @param string $base_url Optional base URL override (for fallback domain)
5592 * @return string Full URL
5593 */
5594 private function build_url( $endpoint, $query_params = [], $base_url = '' ) {
5595 $base = $base_url ?: $this->api_base_url;
5596 $url = rtrim( $base, '/' ) . '/' . ltrim( $endpoint, '/' );
5597
5598 if ( ! empty( $query_params ) ) {
5599 $url = add_query_arg( $query_params, $url );
5600 }
5601
5602 return $url;
5603 }
5604
5605 /**
5606 * Build request arguments
5607 *
5608 * @param string $method HTTP method
5609 * @param array $data Request body data
5610 * @return array Request arguments
5611 */
5612 private function build_request_args( $method = 'GET', $data = [], $endpoint = '' ) {
5613 $headers = [
5614 'Content-Type' => 'application/json',
5615 ];
5616
5617 // Add Origin header for cross-domain security validation
5618 // The backend validates that requests come from the registered site_url
5619 $site_url = get_site_url();
5620 if ( $site_url ) {
5621 $headers['Origin'] = $site_url;
5622 }
5623
5624 // Add development key header for localhost access
5625 // Define WPFORO_AI_DEV_KEY in wp-config.php to enable localhost API access
5626 if ( defined( 'WPFORO_AI_DEV_KEY' ) && WPFORO_AI_DEV_KEY ) {
5627 $headers['X-Dev-Key'] = WPFORO_AI_DEV_KEY;
5628 }
5629
5630 // Add API key header if available (for authenticated requests)
5631 // Skip API key for registration endpoint
5632 $api_key = $this->get_stored_api_key();
5633 if ( $api_key && strpos( $endpoint, '/register' ) === false ) {
5634 $headers['Authorization'] = 'Bearer ' . $api_key;
5635 }
5636
5637 $args = [
5638 'method' => $method,
5639 'timeout' => $this->timeout,
5640 'redirection' => 5,
5641 'httpversion' => '1.1',
5642 'blocking' => true,
5643 'headers' => $headers,
5644 'sslverify' => apply_filters( 'wpforo_ai_ssl_verify', true ),
5645 ];
5646
5647 // Add body for POST/DELETE requests
5648 if ( ! empty( $data ) && in_array( $method, [ 'POST', 'PUT', 'DELETE', 'PATCH' ] ) ) {
5649 $args['body'] = wp_json_encode( $data );
5650 }
5651
5652 return apply_filters( 'wpforo_ai_request_args', $args, $method, $data );
5653 }
5654
5655 /**
5656 * Process API response
5657 *
5658 * @param array|\WP_Error $response Raw response
5659 * @return array|\WP_Error Processed response data or error
5660 */
5661 private function process_response( $response ) {
5662 if ( is_wp_error( $response ) ) {
5663 $this->log_error( 'API Request Failed', $response->get_error_message() );
5664 return new \WP_Error(
5665 'api_request_failed',
5666 sprintf( wpforo_phrase( 'API request failed: %s', false ), $response->get_error_message() )
5667 );
5668 }
5669
5670 $response_code = wp_remote_retrieve_response_code( $response );
5671 $body = wp_remote_retrieve_body( $response );
5672 $data = json_decode( $body, true );
5673
5674 $this->log_response( $response_code, $data );
5675
5676 // Handle HTTP error codes
5677 if ( $response_code >= 400 ) {
5678 $error_message = $this->get_error_message( $response_code, $data );
5679 return new \WP_Error( 'api_error_' . $response_code, $error_message, $data );
5680 }
5681
5682 // Handle API-level errors
5683 if ( isset( $data['success'] ) && false === $data['success'] ) {
5684 $error_message = wpfval( $data, 'message' ) ?: wpforo_phrase( 'Unknown API error occurred', false );
5685 return new \WP_Error( 'api_error', $error_message, $data );
5686 }
5687
5688 return $data;
5689 }
5690
5691 /**
5692 * Get user-friendly error message based on response code
5693 *
5694 * @param int $code HTTP status code
5695 * @param array $data Response data
5696 * @return string Error message
5697 */
5698 private function get_error_message( $code, $data ) {
5699 // Check for FastAPI 'detail' field (common in 4xx/5xx errors)
5700 if ( isset( $data['detail'] ) ) {
5701 // If detail is an object/array with message or error fields
5702 if ( is_array( $data['detail'] ) ) {
5703 if ( isset( $data['detail']['message'] ) ) {
5704 return sanitize_text_field( $data['detail']['message'] );
5705 }
5706 if ( isset( $data['detail']['error'] ) ) {
5707 return sanitize_text_field( $data['detail']['error'] );
5708 }
5709 }
5710 // If detail is a string
5711 if ( is_string( $data['detail'] ) ) {
5712 return sanitize_text_field( $data['detail'] );
5713 }
5714 }
5715
5716 // Check for API-provided message first
5717 if ( isset( $data['message'] ) ) {
5718 $message = sanitize_text_field( $data['message'] );
5719 // In debug mode, append additional details
5720 if ( $this->is_debug_mode() && isset( $data['error'] ) ) {
5721 $message .= ' [' . sanitize_text_field( $data['error'] ) . ']';
5722 }
5723 return $message;
5724 }
5725
5726 // Check for error field
5727 if ( isset( $data['error'] ) ) {
5728 return sanitize_text_field( $data['error'] );
5729 }
5730
5731 // Fallback to standard HTTP status messages
5732 $messages = [
5733 400 => wpforo_phrase( 'Bad request. Please check your input and try again.', false ),
5734 401 => wpforo_phrase( 'Unauthorized. This may be due to localhost URL or missing authentication.', false ),
5735 402 => wpforo_phrase( 'Insufficient credits available for this search. Please upgrade your plan or purchase additional credits.', false ),
5736 403 => wpforo_phrase( 'Access forbidden. This feature is not available in your plan.', false ),
5737 404 => wpforo_phrase( 'Resource not found. The requested endpoint does not exist.', false ),
5738 429 => wpforo_phrase( 'Rate limit exceeded. Please try again later.', false ),
5739 500 => wpforo_phrase( 'Internal server error. Please try again later.', false ),
5740 503 => wpforo_phrase( 'Service temporarily unavailable. Please try again later.', false ),
5741 ];
5742
5743 return wpfval( $messages, $code ) ?: sprintf( wpforo_phrase( 'API error (HTTP %d)', false ), $code );
5744 }
5745
5746 /**
5747 * Get stored API key (decrypted)
5748 *
5749 * Uses global option (shared across all boards)
5750 *
5751 * @return string|null API key or null if not set
5752 */
5753 public function get_stored_api_key() {
5754 $encrypted = $this->get_api_key(); // Uses global option
5755
5756 if ( empty( $encrypted ) ) {
5757 return null;
5758 }
5759
5760 return $this->decrypt_api_key( $encrypted );
5761 }
5762
5763 /**
5764 * Encrypt API key for storage
5765 *
5766 * @param string $key Plain text API key
5767 * @return string Encrypted key
5768 */
5769 public function encrypt_api_key( $key ) {
5770 // Use base64 encoding as minimum obfuscation
5771 return base64_encode( $key );
5772 }
5773
5774 /**
5775 * Decrypt API key from storage
5776 *
5777 * @param string $encrypted Encrypted API key
5778 * @return string Plain text API key
5779 */
5780 public function decrypt_api_key( $encrypted ) {
5781 return base64_decode( $encrypted );
5782 }
5783
5784 /**
5785 * Get masked API key for display in UI
5786 *
5787 * @param string $key Full API key
5788 * @return string Masked key (e.g., "wp_abc***")
5789 */
5790 public function mask_api_key( $key ) {
5791 if ( empty( $key ) || strlen( $key ) < 10 ) {
5792 return '***';
5793 }
5794
5795 $prefix = substr( $key, 0, 6 ); // Show first 6 characters (e.g., "wp_abc")
5796 return $prefix . '***';
5797 }
5798
5799 /**
5800 * Check if debug mode is enabled
5801 *
5802 * @return bool
5803 */
5804 private function is_debug_mode() {
5805 return (bool) wpforo_setting( 'general', 'debug_mode' );
5806 }
5807
5808 /**
5809 * Log API request (only in debug mode)
5810 *
5811 * @param string $method HTTP method
5812 * @param string $endpoint API endpoint
5813 * @param array $data Request data
5814 */
5815 private function log_request( $method, $endpoint, $data = [] ) {
5816 if ( ! $this->is_debug_mode() ) {
5817 return;
5818 }
5819
5820 \wpforo_ai_log( 'debug', sprintf(
5821 '%s %s | Data: %s',
5822 $method,
5823 $endpoint,
5824 wp_json_encode( $this->sanitize_log_data( $data ) )
5825 ), 'Client' );
5826 }
5827
5828 /**
5829 * Log API response (only in debug mode)
5830 *
5831 * @param int $code Response code
5832 * @param array $data Response data
5833 */
5834 private function log_response( $code, $data ) {
5835 if ( ! $this->is_debug_mode() ) {
5836 return;
5837 }
5838
5839 \wpforo_ai_log( 'debug', sprintf(
5840 'Response %d | Data: %s',
5841 $code,
5842 wp_json_encode( $this->sanitize_log_data( $data ) )
5843 ), 'Client' );
5844 }
5845
5846 /**
5847 * Log error message
5848 *
5849 * @param string $context Error context
5850 * @param string $message Error message
5851 * @param array $data Optional additional data
5852 */
5853 private function log_error( $context, $message, $data = [] ) {
5854 $log_message = sprintf( '%s: %s', $context, $message );
5855 if ( ! empty( $data ) ) {
5856 $log_message .= ' ' . wp_json_encode( $data );
5857 }
5858 \wpforo_ai_log( 'error', $log_message, 'Client' );
5859 do_action( 'wpforo_ai_error', $context, $message, $data );
5860 }
5861
5862 /**
5863 * Log info message (only in debug mode)
5864 *
5865 * @param string $context Info context
5866 * @param array $data Additional data
5867 */
5868 private function log_info( $context, $data = [] ) {
5869 if ( ! $this->is_debug_mode() ) {
5870 return;
5871 }
5872
5873 \wpforo_ai_log( 'info', sprintf(
5874 '%s | Data: %s',
5875 $context,
5876 wp_json_encode( $this->sanitize_log_data( $data ) )
5877 ), 'Client' );
5878 }
5879
5880 /**
5881 * Sanitize data for logging (remove sensitive info)
5882 *
5883 * @param array $data Data to sanitize
5884 * @return array Sanitized data
5885 */
5886 private function sanitize_log_data( $data ) {
5887 if ( ! is_array( $data ) ) {
5888 return $data;
5889 }
5890
5891 $sensitive_keys = [ 'api_key', 'new_api_key', 'password', 'token', 'secret' ];
5892
5893 foreach ( $sensitive_keys as $key ) {
5894 if ( isset( $data[ $key ] ) ) {
5895 $data[ $key ] = '***REDACTED***';
5896 }
5897 }
5898
5899 // Recursively sanitize nested arrays
5900 foreach ( $data as $key => $value ) {
5901 if ( is_array( $value ) ) {
5902 $data[ $key ] = $this->sanitize_log_data( $value );
5903 }
5904 }
5905
5906 return $data;
5907 }
5908
5909 // =========================================================================
5910 // AI CACHE METHODS
5911 // =========================================================================
5912
5913 /**
5914 * Cache type constants
5915 */
5916 const CACHE_TYPE_SEARCH = 'search';
5917 const CACHE_TYPE_SEARCH_ENHANCE = 'search_enhance';
5918 const CACHE_TYPE_TRANSLATE = 'translate';
5919 const CACHE_TYPE_TOPIC_SUMMARY = 'topic_summary';
5920
5921 /**
5922 * Cache TTL in seconds (24 hours)
5923 */
5924 const CACHE_TTL = 86400;
5925
5926 /**
5927 * Build cache key for search enhancement
5928 *
5929 * Cache key is based on: query + result IDs + language
5930 * This ensures cache invalidates when results change (e.g., after reindexing)
5931 *
5932 * @param string $query Search query
5933 * @param array $results Search results array
5934 * @param string $language User language
5935 * @return string Raw MD5 hash (16 bytes binary)
5936 */
5937 private function build_enhance_cache_key( $query, $results, $language ) {
5938 // Extract result IDs to include in cache key
5939 // If results change (different topics returned), cache key changes
5940 $result_ids = [];
5941 foreach ( $results as $result ) {
5942 $result_ids[] = wpfval( $result, 'url' ) ?: ''; // Use URL as unique identifier
5943 }
5944
5945 $cache_string = strtolower( trim( $query ) ) . '|' . implode( ',', $result_ids ) . '|' . $language;
5946
5947 // Return raw binary MD5 (16 bytes) for BINARY(16) column
5948 return md5( $cache_string, true );
5949 }
5950
5951 /**
5952 * Build cache key for semantic search
5953 *
5954 * Cache key is based on: query + limit + offset + board_id + quality + accessible_forumids
5955 * Quality tier is included to prevent serving stale cached results
5956 * when the admin changes the search quality setting.
5957 * Accessible forumids ensure users with different permissions get different cache entries.
5958 *
5959 * @param string $query Search query
5960 * @param int $limit Result limit
5961 * @param int $offset Result offset
5962 * @param string $board_id Board ID for multi-board support
5963 * @param string $quality Search quality tier (fast, balanced, advanced, premium)
5964 * @param array|null $accessible_forumids Forums user can access (null = full access, [] = no access)
5965 * @return string Raw MD5 hash (16 bytes binary)
5966 */
5967 private function build_search_cache_key( $query, $limit, $offset, $board_id, $quality = 'fast', $accessible_forumids = null ) {
5968 if ( $accessible_forumids === null ) {
5969 $forums_hash = 'all';
5970 } elseif ( empty( $accessible_forumids ) ) {
5971 $forums_hash = 'none';
5972 } else {
5973 $sorted_forumids = $accessible_forumids;
5974 sort( $sorted_forumids ); // Ensure consistent ordering
5975 $forums_hash = md5( implode( ',', $sorted_forumids ) );
5976 }
5977
5978 $cache_string = 'search:' . strtolower( trim( $query ) ) . '|limit:' . intval( $limit ) . '|offset:' . intval( $offset ) . '|board:' . $board_id . '|quality:' . $quality . '|forums:' . $forums_hash;
5979
5980 // Return raw binary MD5 (16 bytes) for BINARY(16) column
5981 return md5( $cache_string, true );
5982 }
5983
5984 /**
5985 * Build cache key for translation
5986 *
5987 * Cache key is based on: postid + language
5988 *
5989 * @param int $postid Post ID
5990 * @param string $language Target language code
5991 * @return string Raw MD5 hash (16 bytes binary)
5992 */
5993 private function build_translate_cache_key( $postid, $language ) {
5994 $cache_string = 'post:' . intval( $postid ) . '|lang:' . strtolower( trim( $language ) );
5995 return md5( $cache_string, true );
5996 }
5997
5998 /**
5999 * Build cache key for topic summary
6000 *
6001 * Cache key is based on: topicid + reply_count + last_modified + quality + style + language
6002 * This ensures cache invalidates when:
6003 * - New replies are added (reply_count changes)
6004 * - Content is edited (last_modified changes)
6005 * - AI quality tier changes (quality changes)
6006 * - Summary style changes (style changes)
6007 * - Target language changes (language changes)
6008 *
6009 * @param int $topicid Topic ID
6010 * @param int $reply_count Number of replies in the topic
6011 * @param string $last_modified Last modified timestamp of the topic
6012 * @param string $quality AI quality tier (fast, balanced, advanced, premium)
6013 * @param string $style Summary style (compact, structured, conversational, detailed, minimal)
6014 * @param string $language Target language for summary (empty = auto-detect)
6015 * @return string Raw MD5 hash (16 bytes binary)
6016 */
6017 private function build_topic_summary_cache_key( $topicid, $reply_count, $last_modified = '', $quality = 'advanced', $style = 'detailed', $language = '' ) {
6018 $cache_string = 'topic:' . intval( $topicid ) . '|replies:' . intval( $reply_count ) . '|modified:' . $last_modified . '|quality:' . $quality . '|style:' . $style . '|lang:' . $language;
6019 return md5( $cache_string, true );
6020 }
6021
6022 /**
6023 * Get cached AI response
6024 *
6025 * Includes opportunistic cleanup: 1% chance to run garbage collection
6026 * on each cache read. This ensures expired entries are cleaned even if
6027 * WP Cron fails or is disabled.
6028 *
6029 * @param string $type Cache type (e.g., 'search_enhance')
6030 * @param string $cache_key Raw MD5 hash (16 bytes binary)
6031 * @return array|null Cached response or null if not found/expired
6032 */
6033 private function get_ai_cache( $type, $cache_key ) {
6034 global $wpdb;
6035
6036 $table = WPF()->tables->ai_cache;
6037 $now = time();
6038
6039 // Opportunistic cleanup: 1% chance to clean expired entries
6040 // This is a fallback if WP Cron doesn't run
6041 if ( wp_rand( 1, 100 ) === 1 ) {
6042 $this->cleanup_expired_cache();
6043 }
6044
6045 // Query for valid cache entry
6046 // Translation cache (expires_at = 0) never expires, other types check expiry time
6047 $response = $wpdb->get_var( $wpdb->prepare(
6048 "SELECT response FROM `{$table}` WHERE type = %s AND cache_key = %s AND (expires_at = 0 OR expires_at > %d)",
6049 $type,
6050 $cache_key,
6051 $now
6052 ) );
6053
6054 if ( $response ) {
6055 $decoded = json_decode( $response, true );
6056 if ( json_last_error() === JSON_ERROR_NONE ) {
6057 $this->log_info( 'ai_cache_hit', [ 'type' => $type ] );
6058 return $decoded;
6059 }
6060 }
6061
6062 return null;
6063 }
6064
6065 /**
6066 * Store AI response in cache
6067 *
6068 * @param string $type Cache type (e.g., 'search_enhance', 'translate')
6069 * @param string $cache_key Raw MD5 hash (16 bytes binary)
6070 * @param array $response Response data to cache
6071 * @param int $ttl Time-to-live in seconds (default: CACHE_TTL), 0 for no expiry
6072 * @param int $postid Post ID for translation cache (default: 0)
6073 * @return bool True on success, false on failure
6074 */
6075 private function set_ai_cache( $type, $cache_key, $response, $ttl = null, $postid = 0 ) {
6076 global $wpdb;
6077
6078 if ( $ttl === null ) {
6079 $ttl = self::CACHE_TTL;
6080 }
6081
6082 // Auto-schedule cleanup cron if not already scheduled
6083 $this->schedule_cache_cleanup();
6084
6085 $table = WPF()->tables->ai_cache;
6086 $json = wp_json_encode( $response );
6087
6088 // expires_at = 0 means never expires (for translation cache)
6089 $expires_at = ( $ttl === 0 ) ? 0 : time() + $ttl;
6090
6091 // Use REPLACE to insert or update existing cache entry
6092 $result = $wpdb->replace(
6093 $table,
6094 [
6095 'type' => $type,
6096 'cache_key' => $cache_key,
6097 'response' => $json,
6098 'expires_at' => $expires_at,
6099 'postid' => intval( $postid ),
6100 ],
6101 [ '%s', '%s', '%s', '%d', '%d' ]
6102 );
6103
6104 if ( $result !== false ) {
6105 $this->log_info( 'ai_cache_set', [ 'type' => $type, 'ttl' => $ttl, 'postid' => $postid ] );
6106 return true;
6107 }
6108
6109 $this->log_error( 'ai_cache_set_failed', $wpdb->last_error );
6110 return false;
6111 }
6112
6113 /**
6114 * Clean up expired cache entries
6115 *
6116 * Should be called periodically (e.g., via WP Cron)
6117 *
6118 * @return int Number of deleted rows
6119 */
6120 public function cleanup_expired_cache() {
6121 global $wpdb;
6122
6123 $table = WPF()->tables->ai_cache;
6124 $now = time();
6125
6126 $deleted = $wpdb->query( $wpdb->prepare(
6127 "DELETE FROM `{$table}` WHERE expires_at < %d",
6128 $now
6129 ) );
6130
6131 if ( $deleted > 0 ) {
6132 $this->log_info( 'ai_cache_cleanup', [ 'deleted' => $deleted ] );
6133 }
6134
6135 return (int) $deleted;
6136 }
6137
6138 /**
6139 * WP Cron handler for AI cache cleanup
6140 *
6141 * Runs daily to remove expired cache entries
6142 */
6143 public function cron_cache_cleanup() {
6144 $deleted = $this->cleanup_expired_cache();
6145 $this->log_info( 'cron_cache_cleanup_complete', [ 'deleted' => $deleted ] );
6146 }
6147
6148 /**
6149 * Schedule AI cache cleanup cron job
6150 *
6151 * Should be called on plugin activation
6152 */
6153 public function schedule_cache_cleanup() {
6154 if ( ! wp_next_scheduled( 'wpforo_ai_cache_cleanup' ) ) {
6155 wp_schedule_event( time(), 'daily', 'wpforo_ai_cache_cleanup' );
6156 }
6157 }
6158
6159 /**
6160 * Unschedule AI cache cleanup cron job
6161 *
6162 * Should be called on plugin deactivation
6163 */
6164 public function unschedule_cache_cleanup() {
6165 $timestamp = wp_next_scheduled( 'wpforo_ai_cache_cleanup' );
6166 if ( $timestamp ) {
6167 wp_unschedule_event( $timestamp, 'wpforo_ai_cache_cleanup' );
6168 }
6169 }
6170
6171 /**
6172 * Schedule daily pending topics indexing cron job
6173 *
6174 * Should be called on plugin activation or when AI is connected.
6175 * Runs once daily to find and queue topics that need indexing.
6176 */
6177 public function schedule_pending_topics_indexing() {
6178 if ( ! wp_next_scheduled( 'wpforo_ai_pending_topics_indexing' ) ) {
6179 // Schedule to run at 3 AM server time (off-peak hours)
6180 $next_run = strtotime( 'tomorrow 3:00am' );
6181 wp_schedule_event( $next_run, 'daily', 'wpforo_ai_pending_topics_indexing' );
6182 }
6183 }
6184
6185 /**
6186 * Unschedule daily pending topics indexing cron job
6187 *
6188 * Should be called on plugin deactivation.
6189 */
6190 public function unschedule_pending_topics_indexing() {
6191 $timestamp = wp_next_scheduled( 'wpforo_ai_pending_topics_indexing' );
6192 if ( $timestamp ) {
6193 wp_unschedule_event( $timestamp, 'wpforo_ai_pending_topics_indexing' );
6194 }
6195 }
6196
6197 /**
6198 * Schedule daily subscription sync cron job
6199 *
6200 * Syncs subscription status and plan from API once per day.
6201 * This ensures cached subscription info stays up-to-date without
6202 * making API calls on every page load.
6203 */
6204 public function schedule_daily_subscription_sync() {
6205 if ( ! wp_next_scheduled( 'wpforo_ai_daily_subscription_sync' ) ) {
6206 // Schedule to run at 4 AM server time (off-peak hours, after other daily jobs)
6207 $next_run = strtotime( 'tomorrow 4:00am' );
6208 wp_schedule_event( $next_run, 'daily', 'wpforo_ai_daily_subscription_sync' );
6209 }
6210 }
6211
6212 /**
6213 * Unschedule daily subscription sync cron job
6214 *
6215 * Should be called on plugin deactivation.
6216 */
6217 public function unschedule_daily_subscription_sync() {
6218 $timestamp = wp_next_scheduled( 'wpforo_ai_daily_subscription_sync' );
6219 if ( $timestamp ) {
6220 wp_unschedule_event( $timestamp, 'wpforo_ai_daily_subscription_sync' );
6221 }
6222 }
6223
6224 /**
6225 * Cron handler for daily subscription status sync
6226 *
6227 * Fetches fresh status from API and updates cached subscription info.
6228 * Only runs if tenant is connected.
6229 */
6230 public function cron_daily_subscription_sync() {
6231 // Only sync if connected
6232 if ( ! $this->is_connected() ) {
6233 return;
6234 }
6235
6236 // Force fresh API call to get latest subscription status
6237 $this->clear_status_cache();
6238 $status = $this->get_tenant_status( true );
6239
6240 if ( is_wp_error( $status ) ) {
6241 $this->log_error( 'daily_subscription_sync_failed', $status->get_error_message() );
6242 return;
6243 }
6244
6245 $this->log_info( 'daily_subscription_sync_completed', [
6246 'plan' => $status['subscription']['plan'] ?? 'unknown',
6247 'status' => $status['subscription']['status'] ?? 'unknown',
6248 ] );
6249 }
6250
6251 /**
6252 * Clear all AI cache entries of a specific type
6253 *
6254 * @param string $type Cache type to clear (or null for all types)
6255 * @return int Number of deleted rows
6256 */
6257 public function clear_ai_cache( $type = null ) {
6258 global $wpdb;
6259
6260 $table = WPF()->tables->ai_cache;
6261
6262 if ( $type ) {
6263 $deleted = $wpdb->query( $wpdb->prepare(
6264 "DELETE FROM `{$table}` WHERE type = %s",
6265 $type
6266 ) );
6267 } else {
6268 $deleted = $wpdb->query( "TRUNCATE TABLE `{$table}`" );
6269 }
6270
6271 $this->log_info( 'ai_cache_cleared', [ 'type' => $type ?: 'all', 'deleted' => $deleted ] );
6272
6273 return (int) $deleted;
6274 }
6275
6276 /**
6277 * Clear translation cache for a specific post
6278 *
6279 * Called when a post is updated or deleted to invalidate cached translations
6280 *
6281 * @param int $postid Post ID
6282 * @return int Number of deleted cache entries
6283 */
6284 public function clear_translation_cache_by_postid( $postid ) {
6285 global $wpdb;
6286
6287 $postid = intval( $postid );
6288 if ( ! $postid ) {
6289 return 0;
6290 }
6291
6292 $table = WPF()->tables->ai_cache;
6293
6294 $deleted = $wpdb->query( $wpdb->prepare(
6295 "DELETE FROM `{$table}` WHERE type = %s AND postid = %d",
6296 self::CACHE_TYPE_TRANSLATE,
6297 $postid
6298 ) );
6299
6300 if ( $deleted ) {
6301 $this->log_info( 'translation_cache_cleared', [ 'postid' => $postid, 'deleted' => $deleted ] );
6302 }
6303
6304 return (int) $deleted;
6305 }
6306
6307 /**
6308 * Clear topic summary cache for a specific topic
6309 *
6310 * Called when a post is added/edited/deleted to invalidate cached summaries.
6311 * Note: The summary cache key includes reply_count and last_modified, so deleting
6312 * by topicid clears all cached versions of the summary for this topic.
6313 *
6314 * @param int $topicid Topic ID
6315 * @return int Number of deleted cache entries
6316 */
6317 public function clear_topic_summary_cache( $topicid ) {
6318 global $wpdb;
6319
6320 $topicid = intval( $topicid );
6321 if ( ! $topicid ) {
6322 return 0;
6323 }
6324
6325 $table = WPF()->tables->ai_cache;
6326
6327 // Topic summary cache uses postid column to store topicid
6328 $deleted = $wpdb->query( $wpdb->prepare(
6329 "DELETE FROM `{$table}` WHERE type = %s AND postid = %d",
6330 self::CACHE_TYPE_TOPIC_SUMMARY,
6331 $topicid
6332 ) );
6333
6334 if ( $deleted ) {
6335 $this->log_info( 'topic_summary_cache_cleared', [ 'topicid' => $topicid, 'deleted' => $deleted ] );
6336 }
6337
6338 return (int) $deleted;
6339 }
6340
6341 /**
6342 * Callback for post edit action
6343 *
6344 * Clears translation cache when a post is edited
6345 *
6346 * @param array $post Post data
6347 * @param array $topic Topic data
6348 * @param array $forum Forum data
6349 * @param array $args Edit arguments
6350 */
6351 public function on_post_edit( $post, $topic, $forum, $args ) {
6352 $postid = wpfval( $post, 'postid' );
6353 if ( $postid ) {
6354 $this->clear_translation_cache_by_postid( $postid );
6355 }
6356
6357 // Update indexed hash for the topic (invalidates summarization cache)
6358 // Don't set cloud = 0 - per-reply deduplication handles content changes
6359 $topicid = wpfval( $post, 'topicid' );
6360 if ( $topicid ) {
6361 $this->update_topic_indexed_hash( $topicid );
6362 }
6363 }
6364
6365 /**
6366 * Callback for post add action
6367 *
6368 * Invalidates cloud status when a new post is added to an indexed topic.
6369 * This ensures the topic will be force re-indexed on next indexing run.
6370 *
6371 * @param array $post Post data
6372 * @param array $topic Topic data
6373 */
6374 public function on_post_add( $post, $topic ) {
6375 $topicid = wpfval( $post, 'topicid' );
6376 if ( $topicid ) {
6377 // Set local = 0 and cloud = 0 to trigger force re-indexing (new post = structural change)
6378 $this->invalidate_topic_indexed_status( $topicid, 'post_added' );
6379 // Also update indexed hash for cache invalidation
6380 $this->update_topic_indexed_hash( $topicid );
6381 }
6382 }
6383
6384 /**
6385 * Callback for post delete action
6386 *
6387 * When a post is deleted:
6388 * 1. Clear translation cache for the post
6389 * 2. Delete local embedding for the post
6390 * 3. Invalidate topic indexed status (triggers re-indexing)
6391 * 4. Update indexed hash (invalidates summarization cache)
6392 *
6393 * Note: Cloud vectors are cleaned up on next sync when the comparison
6394 * detects the missing post. This avoids blocking the delete operation
6395 * with an API call.
6396 *
6397 * @param array $post Post data
6398 */
6399 public function on_post_delete( $post ) {
6400 $postid = wpfval( $post, 'postid' );
6401 $topicid = wpfval( $post, 'topicid' );
6402
6403 if ( $postid ) {
6404 // 1. Clear translation cache for this post
6405 $this->clear_translation_cache_by_postid( $postid );
6406
6407 // 2. Delete local embedding for this post
6408 if ( WPF()->vector_storage ) {
6409 WPF()->vector_storage->delete_post_embedding( $postid );
6410 }
6411 }
6412
6413 if ( $topicid ) {
6414 // 3. Invalidate topic indexed status (local=0, cloud=0)
6415 // This ensures the topic gets re-indexed without the deleted post
6416 $this->invalidate_topic_indexed_status( $topicid, 'post_deleted' );
6417
6418 // 4. Update indexed hash (invalidates summarization cache)
6419 $this->update_topic_indexed_hash( $topicid );
6420
6421 // 5. Clear topic summary cache
6422 $this->clear_topic_summary_cache( $topicid );
6423
6424 // 6. Clear indexed counts transient
6425 delete_transient( 'wpforo_ai_indexed_counts' );
6426 }
6427 }
6428
6429 /**
6430 * Callback for post approve action
6431 *
6432 * When an unapproved post is approved, we need to:
6433 * 1. Update the indexed hash (content versioning)
6434 * 2. Set local = 0 and cloud = 0 to trigger re-indexing
6435 *
6436 * This ensures the approved post content gets indexed properly.
6437 *
6438 * @param array $post Post data
6439 */
6440 public function on_post_approve( $post ) {
6441 $topicid = wpfval( $post, 'topicid' );
6442 if ( $topicid ) {
6443 // Update indexed hash for cache invalidation (new content version)
6444 $this->update_topic_indexed_hash( $topicid );
6445 // Set local = 0 and cloud = 0 to trigger force re-indexing
6446 $this->invalidate_topic_indexed_status( $topicid, 'post_approved' );
6447 }
6448 }
6449
6450 /**
6451 * Callback for topic add action
6452 *
6453 * When a new approved topic is created, queue it for auto-indexing.
6454 * This ensures new content gets indexed without manual intervention.
6455 *
6456 * @param array $topic Topic data
6457 * @param array $forum Forum data
6458 */
6459 public function on_topic_add( $topic, $forum ) {
6460 $topicid = wpfval( $topic, 'topicid' );
6461 $status = intval( wpfval( $topic, 'status' ) );
6462
6463 // Only auto-index approved topics (status = 0)
6464 if ( $topicid && $status === 0 ) {
6465 // Queue for auto-indexing (background processing)
6466 WPF()->vector_storage->queue_topic_for_auto_indexing( $topicid );
6467
6468 $this->log_info( 'topic_queued_on_create', [
6469 'topicid' => $topicid,
6470 'status' => $status,
6471 ] );
6472 }
6473 }
6474
6475 /**
6476 * Callback for topic approve action
6477 *
6478 * When an unapproved topic is approved by admin, queue it for auto-indexing.
6479 * Also updates the indexed hash and invalidates any existing indexed status.
6480 *
6481 * @param array $topic Topic data
6482 */
6483 public function on_topic_approve( $topic ) {
6484 $topicid = wpfval( $topic, 'topicid' );
6485 if ( $topicid ) {
6486 // Update indexed hash for cache invalidation
6487 $this->update_topic_indexed_hash( $topicid );
6488
6489 // Set local = 0 and cloud = 0 to ensure fresh indexing
6490 $this->invalidate_topic_indexed_status( $topicid, 'topic_approved' );
6491
6492 // Queue for auto-indexing (background processing)
6493 WPF()->vector_storage->queue_topic_for_auto_indexing( $topicid );
6494
6495 $this->log_info( 'topic_queued_on_approve', [
6496 'topicid' => $topicid,
6497 ] );
6498 }
6499 }
6500
6501 /**
6502 * Callback for topic delete action
6503 *
6504 * When a topic is deleted:
6505 * 1. Delete all local embeddings for the topic
6506 * 2. Clear topic summary cache
6507 *
6508 * Note: Translation caches for individual posts are already cleared via
6509 * on_post_delete() which fires for each post before the topic is deleted.
6510 *
6511 * Note: Cloud vectors are cleaned up automatically - either during the next
6512 * sync (comparison detects missing topic) or when tenant is disconnected.
6513 * We don't need to reset topic indexed status since the topic row is deleted.
6514 *
6515 * @param array $topic Topic data
6516 */
6517 public function on_topic_delete( $topic ) {
6518 $topicid = wpfval( $topic, 'topicid' );
6519 if ( ! $topicid ) {
6520 return;
6521 }
6522
6523 // 1. Delete all local embeddings for this topic
6524 if ( WPF()->vector_storage ) {
6525 WPF()->vector_storage->delete_topic_embeddings( $topicid );
6526 }
6527
6528 // 2. Clear topic summary cache
6529 $this->clear_topic_summary_cache( $topicid );
6530
6531 // 3. Clear indexed counts transient
6532 delete_transient( 'wpforo_ai_indexed_counts' );
6533
6534 $this->log_info( 'topic_deleted_cleanup', [
6535 'topicid' => $topicid,
6536 ] );
6537 }
6538
6539 /**
6540 * Callback for topic private status change
6541 *
6542 * When a topic becomes private:
6543 * - Delete all embeddings (local and cloud)
6544 * - Mark topic as not indexed
6545 * - Clear caches
6546 *
6547 * When a topic becomes public again:
6548 * - Queue for auto-indexing (if enabled)
6549 *
6550 * @param int $topicid Topic ID
6551 * @param int $private New private status (1 = private, 0 = public)
6552 */
6553 public function on_topic_private_update( $topicid, $private ) {
6554 if ( ! $topicid ) {
6555 return;
6556 }
6557
6558 if ( $private ) {
6559 $topic = wpforo_topic( $topicid );
6560 if ( empty( $topic ) ) {
6561 return;
6562 }
6563
6564 $is_indexed = ! empty( $topic['local'] ) || ! empty( $topic['cloud'] );
6565 if ( ! $is_indexed ) {
6566 $this->log_info( 'topic_private_not_indexed', [ 'topicid' => $topicid ] );
6567 return;
6568 }
6569
6570 if ( WPF()->vector_storage ) {
6571 WPF()->vector_storage->delete_topic_embeddings( $topicid );
6572 WPF()->vector_storage->mark_topic_not_indexed( $topicid );
6573 }
6574 $this->clear_topic_summary_cache( $topicid );
6575 delete_transient( 'wpforo_ai_indexed_counts' );
6576
6577 $this->log_info( 'topic_removed_on_private', [ 'topicid' => $topicid ] );
6578 } else {
6579 if ( WPF()->vector_storage ) {
6580 WPF()->vector_storage->queue_topic_for_auto_indexing( $topicid );
6581 }
6582 $this->log_info( 'topic_queued_on_unprivate', [ 'topicid' => $topicid ] );
6583 }
6584 }
6585
6586 /**
6587 * Daily cron handler for pending topics indexing
6588 *
6589 * Runs once a day to find topics with local=0 or cloud=0
6590 * (based on current storage mode) and queue them for indexing.
6591 *
6592 * This catches:
6593 * - Topics that failed to index previously
6594 * - Topics added when cron was missed
6595 * - Topics that need re-indexing after content changes
6596 */
6597 public function cron_pending_topics_indexing() {
6598 if ( ! $this->is_service_available() ) {
6599 return;
6600 }
6601
6602 $result = WPF()->vector_storage->cron_process_pending_topics();
6603
6604 $this->log_info( 'daily_pending_topics_processed', $result );
6605 }
6606
6607 /**
6608 * Invalidate cloud indexed status for a topic
6609 *
6610 * Sets cloud = 0 for a topic that was previously indexed (cloud = 1).
6611 * This triggers force re-indexing when the topic is next indexed,
6612 * ensuring old vectors are deleted before re-embedding.
6613 *
6614 * Called when a new reply is added to the topic (structural change).
6615 * NOT called for edits/deletes - those use per-reply deduplication.
6616 *
6617 * @param int $topicid Topic ID
6618 */
6619 private function invalidate_topic_cloud_status( $topicid ) {
6620 global $wpdb;
6621
6622 // Only update if topic is currently indexed in cloud (cloud = 1)
6623 $updated = $wpdb->query(
6624 $wpdb->prepare(
6625 "UPDATE `" . WPF()->tables->topics . "`
6626 SET `cloud` = 0
6627 WHERE `topicid` = %d AND `cloud` = 1",
6628 $topicid
6629 )
6630 );
6631
6632 if ( $updated > 0 ) {
6633 $this->log_info( 'topic_cloud_status_invalidated', [
6634 'topicid' => $topicid,
6635 'reason' => 'post_added'
6636 ] );
6637 }
6638 }
6639
6640 /**
6641 * Invalidate both local and cloud indexed status for a topic
6642 *
6643 * Sets local = 0 and cloud = 0 for a topic.
6644 * This triggers force re-indexing for both local and cloud storage
6645 * when the topic is next indexed.
6646 *
6647 * Called when:
6648 * - A new approved post is added to the topic
6649 * - An unapproved post is approved
6650 *
6651 * @param int $topicid Topic ID
6652 * @param string $reason Reason for invalidation (for logging)
6653 */
6654 private function invalidate_topic_indexed_status( $topicid, $reason = 'unknown' ) {
6655 global $wpdb;
6656
6657 // Set both local and cloud to 0 to trigger re-indexing
6658 $updated = $wpdb->query(
6659 $wpdb->prepare(
6660 "UPDATE `" . WPF()->tables->topics . "`
6661 SET `local` = 0, `cloud` = 0
6662 WHERE `topicid` = %d AND (`local` = 1 OR `cloud` = 1)",
6663 $topicid
6664 )
6665 );
6666
6667 if ( $updated > 0 ) {
6668 $this->log_info( 'topic_indexed_status_invalidated', [
6669 'topicid' => $topicid,
6670 'reason' => $reason
6671 ] );
6672 }
6673 }
6674
6675 /**
6676 * Update indexed hash for a topic
6677 *
6678 * Recalculates the indexed hash (MD5 of topicid_postcount).
6679 * This invalidates the summarization cache when posts change.
6680 *
6681 * Called when:
6682 * - A new reply is added to the topic
6683 * - An existing post is edited
6684 * - A post is deleted
6685 *
6686 * @param int $topicid Topic ID
6687 */
6688 private function update_topic_indexed_hash( $topicid ) {
6689 global $wpdb;
6690
6691 // Update indexed hash based on current post count
6692 // Hash formula: MD5(topicid + '_' + posts)
6693 $updated = $wpdb->query(
6694 $wpdb->prepare(
6695 "UPDATE `" . WPF()->tables->topics . "`
6696 SET `indexed` = MD5(CONCAT(topicid, '_', posts))
6697 WHERE `topicid` = %d",
6698 $topicid
6699 )
6700 );
6701
6702 if ( $updated > 0 ) {
6703 $this->log_info( 'topic_indexed_hash_updated', [
6704 'topicid' => $topicid,
6705 ] );
6706 // Clear topic cache to reflect updated hash
6707 wpforo_clean_cache( 'topic', $topicid );
6708 }
6709 }
6710
6711 // =========================================================================
6712 // AI TRANSLATION METHODS
6713 // =========================================================================
6714
6715 /**
6716 * Render translation button in post content
6717 *
6718 * Displays a "Translate" dropdown button that allows users to translate
6719 * post content to their preferred language.
6720 *
6721 * @param array $post Post data
6722 * @return void
6723 */
6724 public function render_translation_button( $post ) {
6725 // Check if AI service is available and translation is available
6726 if ( ! $this->is_service_available() ) {
6727 return;
6728 }
6729
6730 // Check if translation is enabled in settings
6731 if ( ! wpfval( WPF()->settings->ai, 'translation' ) ) {
6732 return;
6733 }
6734
6735 // Check usergroup permission
6736 if ( ! WPF()->usergroup->can( 'ai_translation' ) ) {
6737 return;
6738 }
6739
6740 // Check if we have the post ID
6741 $post_id = wpfval( $post, 'postid' );
6742 if ( empty( $post_id ) ) {
6743 return;
6744 }
6745
6746 // Get available translation languages
6747 $languages = $this->get_available_translation_languages();
6748
6749 // Render the translation dropdown button
6750 ?>
6751 <div class="wpf-ai-translate-wrapper" data-postid="<?php echo esc_attr( $post_id ); ?>">
6752 <div class="wpf-ai-translate-btn" title="<?php echo esc_attr( wpforo_phrase( 'Translate this post', false ) ); ?>">
6753 <span class="wpf-ai-translate-label"><?php echo esc_html( wpforo_phrase( 'Translate', false ) ); ?></span>
6754 <span class="wpf-ai-translate-arrow">â–¼</span>
6755 </div>
6756 <div class="wpf-ai-translate-dropdown">
6757 <?php foreach ( $languages as $code => $name ) : ?>
6758 <div class="wpf-ai-translate-option" data-lang="<?php echo esc_attr( $code ); ?>">
6759 <?php echo esc_html( $name ); ?>
6760 </div>
6761 <?php endforeach; ?>
6762 </div>
6763 <div class="wpf-ai-translate-original" style="display:none;">
6764 <span class="wpf-ai-translate-label"><?php echo esc_html( wpforo_phrase( 'Show original', false ) ); ?></span>
6765 </div>
6766 <div class="wpf-ai-translate-loading" style="display:none;">
6767 <span class="wpf-ai-translate-spinner"></span>
6768 <span><?php echo esc_html( wpforo_phrase( 'Translating...', false ) ); ?></span>
6769 </div>
6770 </div>
6771 <?php
6772 }
6773
6774 /**
6775 * Get available translation languages
6776 *
6777 * @return array Associative array of language code => language name
6778 */
6779 public function get_available_translation_languages() {
6780 $languages = [
6781 'en' => wpforo_phrase( 'English', false ),
6782 'es' => wpforo_phrase( 'Spanish', false ),
6783 'fr' => wpforo_phrase( 'French', false ),
6784 'de' => wpforo_phrase( 'German', false ),
6785 'it' => wpforo_phrase( 'Italian', false ),
6786 'pt' => wpforo_phrase( 'Portuguese', false ),
6787 'ru' => wpforo_phrase( 'Russian', false ),
6788 'zh' => wpforo_phrase( 'Chinese', false ),
6789 'ja' => wpforo_phrase( 'Japanese', false ),
6790 'ko' => wpforo_phrase( 'Korean', false ),
6791 'ar' => wpforo_phrase( 'Arabic', false ),
6792 'hi' => wpforo_phrase( 'Hindi', false ),
6793 'nl' => wpforo_phrase( 'Dutch', false ),
6794 'pl' => wpforo_phrase( 'Polish', false ),
6795 'tr' => wpforo_phrase( 'Turkish', false ),
6796 'vi' => wpforo_phrase( 'Vietnamese', false ),
6797 'th' => wpforo_phrase( 'Thai', false ),
6798 'sv' => wpforo_phrase( 'Swedish', false ),
6799 'da' => wpforo_phrase( 'Danish', false ),
6800 'fi' => wpforo_phrase( 'Finnish', false ),
6801 'no' => wpforo_phrase( 'Norwegian', false ),
6802 'cs' => wpforo_phrase( 'Czech', false ),
6803 'hu' => wpforo_phrase( 'Hungarian', false ),
6804 'ro' => wpforo_phrase( 'Romanian', false ),
6805 'el' => wpforo_phrase( 'Greek', false ),
6806 'he' => wpforo_phrase( 'Hebrew', false ),
6807 'id' => wpforo_phrase( 'Indonesian', false ),
6808 'ms' => wpforo_phrase( 'Malay', false ),
6809 'uk' => wpforo_phrase( 'Ukrainian', false ),
6810 'bg' => wpforo_phrase( 'Bulgarian', false ),
6811 'hr' => wpforo_phrase( 'Croatian', false ),
6812 'sk' => wpforo_phrase( 'Slovak', false ),
6813 'sl' => wpforo_phrase( 'Slovenian', false ),
6814 'sr' => wpforo_phrase( 'Serbian', false ),
6815 'lt' => wpforo_phrase( 'Lithuanian', false ),
6816 'lv' => wpforo_phrase( 'Latvian', false ),
6817 'et' => wpforo_phrase( 'Estonian', false ),
6818 ];
6819
6820 return apply_filters( 'wpforo_ai_translation_languages', $languages );
6821 }
6822
6823 /**
6824 * Translate content via AI API
6825 *
6826 * @param string $content HTML content to translate
6827 * @param string $target_language Target language code (e.g., 'es', 'fr', 'de')
6828 * @return array|WP_Error Translated content or error
6829 */
6830 public function translate_content( $content, $target_language ) {
6831 if ( empty( $content ) ) {
6832 return new \WP_Error( 'empty_content', wpforo_phrase( 'Content cannot be empty', false ) );
6833 }
6834
6835 if ( empty( $target_language ) ) {
6836 return new \WP_Error( 'empty_language', wpforo_phrase( 'Target language is required', false ) );
6837 }
6838
6839 // Get language name for API
6840 $languages = $this->get_available_translation_languages();
6841 $language_name = isset( $languages[ $target_language ] ) ? $languages[ $target_language ] : $target_language;
6842
6843 $data = [
6844 'content' => $content,
6845 'target_language' => $language_name,
6846 ];
6847
6848 // Add quality parameter from settings (for translation model selection)
6849 $translation_quality = wpfval( WPF()->settings->ai, 'translation_quality' );
6850 if ( ! empty( $translation_quality ) ) {
6851 $data['quality'] = sanitize_text_field( $translation_quality );
6852 }
6853
6854 $response = $this->post( '/translate', $data );
6855
6856 if ( is_wp_error( $response ) ) {
6857 $this->log_error( 'translation_failed', $response->get_error_message() );
6858 return $response;
6859 }
6860
6861 $this->log_info( 'translation_completed', [
6862 'target_language' => $target_language,
6863 'content_length' => strlen( $content ),
6864 ] );
6865
6866 return $response;
6867 }
6868
6869 /**
6870 * AJAX handler for content translation
6871 *
6872 * @return void
6873 */
6874 public function ajax_translate_content() {
6875 // Track start time for logging
6876 $_log_start_time = microtime( true );
6877
6878 // Verify nonce
6879 if ( ! wp_verify_nonce( wpfval( $_POST, 'nonce' ), 'wpforo_ai_translate' ) ) {
6880 wp_send_json_error( [
6881 'message' => wpforo_phrase( 'Security check failed', false )
6882 ], 403 );
6883 }
6884
6885 // Note: Rate limit check moved after cache check
6886 // Cached content should bypass rate limits since it doesn't use API resources
6887
6888 // Check if AI service is available
6889 if ( ! $this->is_service_available() ) {
6890 wp_send_json_error( [
6891 'message' => wpforo_phrase( 'AI service is not available', false )
6892 ], 403 );
6893 }
6894
6895 // Check if translation is enabled in settings
6896 if ( ! wpfval( WPF()->settings->ai, 'translation' ) ) {
6897 wp_send_json_error( [
6898 'message' => wpforo_phrase( 'Translation feature is disabled', false )
6899 ], 403 );
6900 }
6901
6902 // Check usergroup permission
6903 if ( ! WPF()->usergroup->can( 'ai_translation' ) ) {
6904 wp_send_json_error( [
6905 'message' => wpforo_phrase( 'You do not have permission to use this feature', false )
6906 ], 403 );
6907 }
6908
6909 // Get post ID and validate
6910 $post_id = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0;
6911 if ( ! $post_id ) {
6912 wp_send_json_error( [
6913 'message' => wpforo_phrase( 'Invalid post ID', false )
6914 ], 400 );
6915 }
6916
6917 // Get target language
6918 $target_language = sanitize_text_field( wpfval( $_POST, 'language' ) );
6919 if ( empty( $target_language ) ) {
6920 wp_send_json_error( [
6921 'message' => wpforo_phrase( 'Target language is required', false )
6922 ], 400 );
6923 }
6924
6925 // Get post content from database
6926 $post = wpforo_post( $post_id );
6927 if ( empty( $post ) || empty( $post['body'] ) ) {
6928 wp_send_json_error( [
6929 'message' => wpforo_phrase( 'Post not found', false )
6930 ], 404 );
6931 }
6932
6933 // Get the rendered HTML content using output buffering
6934 // (wpforo_content echoes instead of returning)
6935 ob_start();
6936 wpforo_content( $post );
6937 $content = ob_get_clean();
6938
6939 if ( empty( trim( $content ) ) ) {
6940 // Fallback to raw body content
6941 $content = $post['body'];
6942 }
6943
6944 // Build cache key from postid + language
6945 $cache_key = $this->build_translate_cache_key( $post_id, $target_language );
6946
6947 // Check cache first
6948 $cached_result = $this->get_ai_cache( self::CACHE_TYPE_TRANSLATE, $cache_key );
6949 if ( $cached_result ) {
6950 // Log cached translation
6951 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
6952 WPF()->ai_logs->log( [
6953 'action_type' => AILogs::ACTION_TRANSLATION,
6954 'credits_used' => 0,
6955 'status' => AILogs::STATUS_CACHED,
6956 'content_type' => 'post',
6957 'content_id' => $post_id,
6958 'request_summary' => sprintf( 'Translate post #%d to %s', $post_id, $target_language ),
6959 'response_summary' => 'Cached translation',
6960 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
6961 ] );
6962 }
6963 // Return cached translation (no credits used)
6964 wp_send_json_success( [
6965 'translated_content' => wpfval( $cached_result, 'translated_content' ) ?: '',
6966 'source_language' => wpfval( $cached_result, 'source_language' ) ?: 'auto',
6967 'target_language' => $target_language,
6968 'credits_used' => 0,
6969 'cached' => true,
6970 ] );
6971 }
6972
6973 // Cache miss - check rate limit before making API call
6974 // Rate limit is only checked for non-cached requests that consume API resources
6975 $this->check_rate_limit( 'translation' );
6976
6977 // Translate the content (cache miss)
6978 $result = $this->translate_content( $content, $target_language );
6979
6980 if ( is_wp_error( $result ) ) {
6981 // Log error
6982 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
6983 WPF()->ai_logs->log( [
6984 'action_type' => AILogs::ACTION_TRANSLATION,
6985 'credits_used' => 0,
6986 'status' => AILogs::STATUS_ERROR,
6987 'content_type' => 'post',
6988 'content_id' => $post_id,
6989 'request_summary' => sprintf( 'Translate post #%d to %s', $post_id, $target_language ),
6990 'error_message' => $result->get_error_message(),
6991 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
6992 ] );
6993 }
6994 wp_send_json_error( [
6995 'message' => $result->get_error_message()
6996 ], 500 );
6997 }
6998
6999 $credits_used = wpfval( $result, 'credits_used' ) ?: 1;
7000
7001 // Log successful translation
7002 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
7003 WPF()->ai_logs->log( [
7004 'action_type' => AILogs::ACTION_TRANSLATION,
7005 'credits_used' => $credits_used,
7006 'status' => AILogs::STATUS_SUCCESS,
7007 'content_type' => 'post',
7008 'content_id' => $post_id,
7009 'request_summary' => sprintf( 'Translate post #%d to %s', $post_id, $target_language ),
7010 'response_summary' => sprintf( 'Translated from %s', wpfval( $result, 'source_language' ) ?: 'auto' ),
7011 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
7012 ] );
7013 }
7014
7015 // Cache only essential fields (exclude model_used, credits_remaining, credits_used)
7016 $cache_data = [
7017 'translated_content' => wpfval( $result, 'translated_content' ) ?: '',
7018 'source_language' => wpfval( $result, 'source_language' ) ?: 'auto',
7019 ];
7020 $this->set_ai_cache( self::CACHE_TYPE_TRANSLATE, $cache_key, $cache_data, 0, $post_id );
7021
7022 // Return translated content
7023 wp_send_json_success( [
7024 'translated_content' => wpfval( $result, 'translated_content' ) ?: '',
7025 'source_language' => wpfval( $result, 'source_language' ) ?: 'auto',
7026 'target_language' => $target_language,
7027 'credits_used' => $credits_used,
7028 'cached' => false,
7029 ] );
7030 }
7031
7032 // =========================================================================
7033 // TOPIC SUMMARIZATION
7034 // =========================================================================
7035
7036 /**
7037 * Summarize a forum topic using AI
7038 *
7039 * Sends topic content and replies to the AI summarization API.
7040 * If indexed_hash is provided, backend loads posts from cloud storage.
7041 * If posts are provided, they are sent directly to the API.
7042 *
7043 * @param int $topicid Topic ID to summarize
7044 * @param array|null $posts Array of post data (post_id, author, content, is_first_post) or null if using indexed_hash
7045 * @param string $style Summary style (compact, structured, conversational, detailed, minimal)
7046 * @param string $last_modified Topic last modified timestamp for cache key
7047 * @param string $indexed_hash MD5 hash of indexed topic (if posts are stored in cloud)
7048 * @return array|WP_Error Summary result or error
7049 */
7050 public function summarize_topic( $topicid, $posts = null, $style = 'detailed', $last_modified = '', $indexed_hash = '', $language = '' ) {
7051 if ( empty( $topicid ) ) {
7052 return new \WP_Error( 'empty_topicid', wpforo_phrase( 'Topic ID is required', false ) );
7053 }
7054
7055 // Must have either posts or indexed_hash
7056 if ( ( empty( $posts ) || ! is_array( $posts ) ) && empty( $indexed_hash ) ) {
7057 return new \WP_Error( 'empty_posts', wpforo_phrase( 'Topic posts or indexed_hash are required', false ) );
7058 }
7059
7060 // Get topic title
7061 $topic = wpforo_topic( $topicid );
7062 if ( empty( $topic ) || empty( $topic['title'] ) ) {
7063 return new \WP_Error( 'topic_not_found', wpforo_phrase( 'Topic not found', false ) );
7064 }
7065
7066 $data = [
7067 'topic_id' => (int) $topicid,
7068 'topic_title' => sanitize_text_field( $topic['title'] ),
7069 'style' => sanitize_text_field( $style ),
7070 ];
7071
7072 // If indexed_hash is provided, backend will load posts from cloud storage
7073 // Otherwise, send posts directly in the request
7074 if ( ! empty( $indexed_hash ) ) {
7075 $data['indexed_hash'] = sanitize_text_field( $indexed_hash );
7076 }
7077
7078 if ( ! empty( $posts ) && is_array( $posts ) ) {
7079 $data['posts'] = $posts;
7080 }
7081
7082 // Add quality parameter from settings
7083 $summary_quality = wpfval( WPF()->settings->ai, 'topic_summary_quality' );
7084 if ( ! empty( $summary_quality ) ) {
7085 $data['quality'] = sanitize_text_field( $summary_quality );
7086 }
7087
7088 // Add last_modified for cache key generation on server side
7089 if ( ! empty( $last_modified ) ) {
7090 $data['last_modified'] = sanitize_text_field( $last_modified );
7091 }
7092
7093 // Add language for summary generation
7094 if ( ! empty( $language ) ) {
7095 $data['language'] = sanitize_text_field( $language );
7096 }
7097
7098 $response = $this->post( '/summarize', $data );
7099
7100 if ( is_wp_error( $response ) ) {
7101 $this->log_error( 'topic_summary_failed', $response->get_error_message() );
7102 return $response;
7103 }
7104
7105 $this->log_info( 'topic_summary_completed', [
7106 'topic_id' => $topicid,
7107 'reply_count' => is_array( $posts ) ? count( $posts ) : 0,
7108 'style' => $style,
7109 'indexed_hash' => ! empty( $indexed_hash ),
7110 ] );
7111
7112 return $response;
7113 }
7114
7115 /**
7116 * Get available summary styles
7117 *
7118 * @return array Style ID => Style name mapping
7119 */
7120 public function get_available_summary_styles() {
7121 return [
7122 'compact' => wpforo_phrase( 'Compact with Key Points', false ),
7123 'structured' => wpforo_phrase( 'Structured with Sections', false ),
7124 'conversational' => wpforo_phrase( 'Conversational Flow', false ),
7125 'detailed' => wpforo_phrase( 'Short Summary + Details', false ),
7126 'minimal' => wpforo_phrase( 'Minimal and Clean', false ),
7127 ];
7128 }
7129
7130 /**
7131 * AJAX handler for topic summarization
7132 *
7133 * @return void
7134 */
7135 public function ajax_summarize_topic() {
7136 // Track start time for logging
7137 $_log_start_time = microtime( true );
7138
7139 // Verify nonce
7140 if ( ! wp_verify_nonce( wpfval( $_POST, 'nonce' ), 'wpforo_ai_summarize_topic' ) ) {
7141 wp_send_json_error( [
7142 'message' => wpforo_phrase( 'Security check failed', false )
7143 ], 403 );
7144 }
7145
7146 // Note: Rate limit check moved after cache check
7147 // Cached summaries should bypass rate limits since they don't use API resources
7148
7149 // Check if AI service is available
7150 if ( ! $this->is_service_available() ) {
7151 wp_send_json_error( [
7152 'message' => wpforo_phrase( 'AI service is not available', false )
7153 ], 403 );
7154 }
7155
7156 // Check if topic summarization is enabled in settings
7157 if ( ! wpfval( WPF()->settings->ai, 'topic_summary' ) ) {
7158 wp_send_json_error( [
7159 'message' => wpforo_phrase( 'Topic summarization feature is disabled', false )
7160 ], 403 );
7161 }
7162
7163 // Check usergroup permission
7164 if ( ! WPF()->usergroup->can( 'ai_summary' ) ) {
7165 wp_send_json_error( [
7166 'message' => wpforo_phrase( 'You do not have permission to use this feature', false )
7167 ], 403 );
7168 }
7169
7170 // Get topic ID and validate
7171 $topicid = isset( $_POST['topicid'] ) ? (int) $_POST['topicid'] : 0;
7172 if ( ! $topicid ) {
7173 wp_send_json_error( [
7174 'message' => wpforo_phrase( 'Invalid topic ID', false )
7175 ], 400 );
7176 }
7177
7178 // Get topic data
7179 $topic = wpforo_topic( $topicid );
7180 if ( empty( $topic ) ) {
7181 wp_send_json_error( [
7182 'message' => wpforo_phrase( 'Topic not found', false )
7183 ], 404 );
7184 }
7185
7186 // Get summary style from settings or request
7187 $style = sanitize_text_field( wpfval( $_POST, 'style' ) );
7188 if ( empty( $style ) ) {
7189 $style = wpfval( WPF()->settings->ai, 'topic_summary_style' ) ?: 'detailed';
7190 }
7191
7192 // Check if topic is indexed in CLOUD storage (posts stored in cloud)
7193 // Only use cloud loading when:
7194 // 1. We're in cloud storage mode
7195 // 2. The topic's `cloud` column is 1 (indexed in cloud)
7196 // 3. The indexed hash exists (for backend to identify the topic)
7197 $indexed_hash = $topic['indexed'] ?? '';
7198 $is_cloud_indexed = WPF()->vector_storage && WPF()->vector_storage->is_cloud_mode()
7199 && ! empty( $topic['cloud'] ) && (int) $topic['cloud'] === 1
7200 && ! empty( $indexed_hash );
7201
7202 $posts = null;
7203 $total_posts_count = 0;
7204 $posts_limited = false;
7205 $max_posts = 220; // first 20 + last 200
7206
7207 if ( $is_cloud_indexed ) {
7208 // Topic is indexed in cloud - backend will load posts from cloud storage
7209 // Use topic's posts count for display purposes
7210 $total_posts_count = (int) ( $topic['posts'] ?? 0 );
7211 $reply_count = $total_posts_count; // All posts available in cloud
7212 } else {
7213 // Clear indexed_hash if not using cloud - don't send to API
7214 $indexed_hash = '';
7215 // Topic not indexed - load posts from WordPress and send to API
7216 $items_count = 0;
7217 $posts_data = WPF()->post->get_posts( [ 'topicid' => $topicid ], $items_count );
7218 if ( empty( $posts_data ) ) {
7219 wp_send_json_error( [
7220 'message' => wpforo_phrase( 'No posts found in this topic', false )
7221 ], 404 );
7222 }
7223
7224 // Prepare posts array for API
7225 $posts = [];
7226 $first_post_id = $topic['first_postid'] ?? 0;
7227
7228 foreach ( $posts_data as $post ) {
7229 // Get rendered HTML content using output buffering
7230 ob_start();
7231 wpforo_content( $post );
7232 $content = ob_get_clean();
7233
7234 if ( empty( trim( $content ) ) ) {
7235 $content = $post['body'] ?? '';
7236 }
7237
7238 $posts[] = [
7239 'post_id' => (int) $post['postid'],
7240 'author' => $this->get_user_display_name( $post['userid'] ?? 0 ),
7241 'content' => $content,
7242 'created_at' => $post['created'] ?? '',
7243 'is_first_post' => ( (int) $post['postid'] === (int) $first_post_id ),
7244 ];
7245 }
7246
7247 // Store total count before limiting
7248 $total_posts_count = count( $posts );
7249
7250 // Limit posts for large topics: first 20 + last 200
7251 // This captures the original context and most recent discussion
7252 $first_posts_limit = 20;
7253 $last_posts_limit = 200;
7254
7255 if ( $total_posts_count > $max_posts ) {
7256 $first_posts = array_slice( $posts, 0, $first_posts_limit );
7257 $last_posts = array_slice( $posts, -$last_posts_limit );
7258 $posts = array_merge( $first_posts, $last_posts );
7259 $posts_limited = true;
7260 }
7261
7262 $reply_count = count( $posts );
7263 }
7264
7265 $last_modified = $topic['modified'] ?? $topic['created'] ?? '';
7266
7267 // Get quality and language for cache key
7268 $quality = wpfval( WPF()->settings->ai, 'topic_summary_quality' ) ?: 'advanced';
7269 $language_code = sanitize_text_field( wpfval( $_POST, 'language' ) ) ?: '';
7270 $language = $this->get_user_language( $language_code, 'topic_summary_language' );
7271
7272 // Build cache key from topicid + reply_count + last_modified + quality + style + language
7273 $cache_key = $this->build_topic_summary_cache_key( $topicid, $reply_count, $last_modified, $quality, $style, $language );
7274
7275 // Check cache first
7276 $cached_result = $this->get_ai_cache( self::CACHE_TYPE_TOPIC_SUMMARY, $cache_key );
7277 if ( $cached_result ) {
7278 // Log cached summary
7279 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
7280 WPF()->ai_logs->log( [
7281 'action_type' => AILogs::ACTION_TOPIC_SUMMARY,
7282 'credits_used' => 0,
7283 'status' => AILogs::STATUS_CACHED,
7284 'content_type' => 'topic',
7285 'content_id' => $topicid,
7286 'topicid' => $topicid,
7287 'forumid' => $topic['forumid'] ?? null,
7288 'request_summary' => sprintf( 'Summarize topic: %s', wp_trim_words( $topic['title'], 10 ) ),
7289 'response_summary' => 'Cached summary',
7290 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
7291 ] );
7292 }
7293
7294 // Return cached summary (no credits used)
7295 // Process link markers to convert [[#POST_ID]] to clickable links
7296 $cached_summary = wpfval( $cached_result, 'summary' ) ?: '';
7297 $cached_summary = $this->replace_summary_link_markers( $cached_summary, $topicid );
7298
7299 wp_send_json_success( [
7300 'summary' => $cached_summary,
7301 'style' => wpfval( $cached_result, 'style' ) ?: $style,
7302 'topic_id' => $topicid,
7303 'reply_count' => $reply_count,
7304 'total_posts_count' => $total_posts_count,
7305 'posts_limited' => $posts_limited,
7306 'credits_used' => 0,
7307 'cached' => true,
7308 'from_s3' => $is_cloud_indexed,
7309 ] );
7310 }
7311
7312 // Cache miss - check rate limit before making API call
7313 // Rate limit is only checked for non-cached requests that consume API resources
7314 $this->check_rate_limit( 'summarization' );
7315
7316 // Summarize the topic (cache miss) - pass indexed_hash if available
7317 $result = $this->summarize_topic( $topicid, $posts, $style, $last_modified, $indexed_hash, $language );
7318
7319 if ( is_wp_error( $result ) ) {
7320 // Log error
7321 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
7322 WPF()->ai_logs->log( [
7323 'action_type' => AILogs::ACTION_TOPIC_SUMMARY,
7324 'credits_used' => 0,
7325 'status' => AILogs::STATUS_ERROR,
7326 'content_type' => 'topic',
7327 'content_id' => $topicid,
7328 'topicid' => $topicid,
7329 'forumid' => $topic['forumid'] ?? null,
7330 'request_summary' => sprintf( 'Summarize topic: %s', wp_trim_words( $topic['title'], 10 ) ),
7331 'error_message' => $result->get_error_message(),
7332 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
7333 ] );
7334 }
7335 wp_send_json_error( [
7336 'message' => $result->get_error_message()
7337 ], 500 );
7338 }
7339
7340 // Get raw summary and store in cache (keep raw with link markers for re-processing)
7341 $raw_summary = wpfval( $result, 'summary' ) ?: '';
7342 // Strip markdown code fence wrappers (```html ... ```) that LLMs sometimes add around HTML output
7343 $raw_summary = preg_replace( '/^\s*```\w*\s*\n([\s\S]*?)\n\s*```\s*$/s', '$1', $raw_summary );
7344 $credits_used = wpfval( $result, 'credits_used' ) ?: 1;
7345
7346 // Log success
7347 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
7348 WPF()->ai_logs->log( [
7349 'action_type' => AILogs::ACTION_TOPIC_SUMMARY,
7350 'credits_used' => $credits_used,
7351 'status' => AILogs::STATUS_SUCCESS,
7352 'content_type' => 'topic',
7353 'content_id' => $topicid,
7354 'topicid' => $topicid,
7355 'forumid' => $topic['forumid'] ?? null,
7356 'request_summary' => sprintf( 'Summarize topic: %s (%d posts)', wp_trim_words( $topic['title'], 10 ), $reply_count ),
7357 'response_summary' => sprintf( 'Generated %s-style summary', $style ),
7358 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
7359 ] );
7360 }
7361
7362 // Cache only essential fields (store raw with link markers)
7363 // Note: Using postid parameter to store topicid for topic summary cache
7364 $cache_data = [
7365 'summary' => $raw_summary,
7366 'style' => wpfval( $result, 'style' ) ?: $style,
7367 ];
7368 $this->set_ai_cache( self::CACHE_TYPE_TOPIC_SUMMARY, $cache_key, $cache_data, self::CACHE_TTL, $topicid );
7369
7370 // Process link markers to convert [[#POST_ID]] to clickable links
7371 $processed_summary = $this->replace_summary_link_markers( $raw_summary, $topicid );
7372
7373 // Return summary with clickable links
7374 wp_send_json_success( [
7375 'summary' => $processed_summary,
7376 'style' => wpfval( $result, 'style' ) ?: $style,
7377 'topic_id' => $topicid,
7378 'reply_count' => $reply_count,
7379 'total_posts_count' => $total_posts_count,
7380 'posts_limited' => $posts_limited,
7381 'credits_used' => $credits_used,
7382 'cached' => false,
7383 'from_s3' => $is_cloud_indexed,
7384 ] );
7385 }
7386
7387 /**
7388 * Render topic summary button in the head-bar (next to Subscribe button)
7389 *
7390 * @param array $forum Forum data
7391 * @param array $topic Topic data
7392 * @param array $posts Posts data
7393 * @return void
7394 */
7395 public function render_topic_summary_button( $forum, $topic, $posts ) {
7396 // Check if topic summarization is enabled
7397 if ( ! wpfval( WPF()->settings->ai, 'topic_summary' ) ) {
7398 return;
7399 }
7400
7401 // Check if AI service is available (connected + active/trial subscription)
7402 if ( ! $this->is_service_available() ) {
7403 return;
7404 }
7405
7406 // Check usergroup permission
7407 if ( ! WPF()->usergroup->can( 'ai_summary' ) ) {
7408 return;
7409 }
7410
7411 $topicid = $topic['topicid'] ?? 0;
7412 if ( ! $topicid ) {
7413 return;
7414 }
7415
7416 // Get the number of replies (posts_count includes the original post, so replies = posts_count - 1)
7417 $posts_count = $topic['posts'] ?? count( $posts );
7418 $replies_count = max( 0, $posts_count - 1 );
7419
7420 // Check minimum replies setting (default 1 = at least one reply required)
7421 $min_replies = intval( wpfval( WPF()->settings->ai, 'topic_summary_min_replies' ) ?? 1 );
7422 if ( $replies_count < $min_replies ) {
7423 return;
7424 }
7425
7426 $nonce = wp_create_nonce( 'wpforo_ai_summarize_topic' );
7427 $button_text = wpforo_phrase( 'Summarize Topic', false );
7428
7429 ?>
7430 <span class="wpf-ai-summarize-btn wpf-button-outlined"
7431 data-topicid="<?php echo esc_attr( $topicid ); ?>"
7432 data-nonce="<?php echo esc_attr( $nonce ); ?>"
7433 title="<?php echo esc_attr( $button_text ); ?>">
7434 <i class="fa-solid fa-wand-magic-sparkles"></i>&nbsp; <?php echo esc_html( $button_text ); ?>
7435 </span>
7436 <?php
7437 }
7438
7439 /**
7440 * Standalone wrapper for rendering topic summary container
7441 * Called via wpforo_template_post_head_bar action hook
7442 *
7443 * @param array $forum Forum data
7444 * @param array $topic Topic data
7445 * @param array $posts Posts data
7446 * @return void
7447 */
7448 public function render_topic_summary_container_standalone( $forum, $topic, $posts ) {
7449 $topicid = $topic['topicid'] ?? 0;
7450 $this->render_topic_summary_container( $topicid );
7451 }
7452
7453 /**
7454 * Render the topic summary container (slide-down area under head-bar)
7455 *
7456 * @param int $topicid Topic ID
7457 * @return void
7458 */
7459 public function render_topic_summary_container( $topicid = 0 ) {
7460 if ( ! $topicid ) {
7461 $topic = WPF()->current_object['topic'] ?? [];
7462 $topicid = $topic['topicid'] ?? 0;
7463 }
7464
7465 if ( ! $topicid ) {
7466 return;
7467 }
7468
7469 // Check if topic summarization is enabled
7470 if ( ! wpfval( WPF()->settings->ai, 'topic_summary' ) ) {
7471 return;
7472 }
7473
7474 // Check if AI service is available (connected + active/trial subscription)
7475 if ( ! $this->is_service_available() ) {
7476 return;
7477 }
7478
7479 // Check usergroup permission
7480 if ( ! WPF()->usergroup->can( 'ai_summary' ) ) {
7481 return;
7482 }
7483
7484 ?>
7485 <div class="wpf-ai-summary-container" id="wpf-ai-summary-<?php echo esc_attr( $topicid ); ?>" data-topicid="<?php echo esc_attr( $topicid ); ?>" style="display: none;">
7486 <div class="wpf-ai-summary-loading" style="display: none;">
7487 <span class="wpf-ai-loading-stars">
7488 <span class="wpf-ai-star">&#10022;</span>
7489 <span class="wpf-ai-star">&#10022;</span>
7490 <span class="wpf-ai-star">&#10022;</span>
7491 </span>
7492 <span class="wpf-ai-loading-text"><?php wpforo_phrase( 'AI is analyzing the discussion...' ); ?></span>
7493 </div>
7494 <div class="wpf-ai-summary-content"></div>
7495 <div class="wpf-ai-summary-footer" style="display: none;">
7496 <span class="wpf-ai-summary-info">
7497 <span class="wpf-ai-summary-credits"></span>
7498 </span>
7499 <button type="button" class="wpf-ai-summary-close" title="<?php echo esc_attr( wpforo_phrase( 'Close', false ) ); ?>">
7500 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
7501 </button>
7502 </div>
7503 </div>
7504 <?php
7505 }
7506
7507 /**
7508 * AJAX handler for topic suggestions
7509 *
7510 * Returns similar topics, related topics, and quick AI answers based on topic title.
7511 * Used during topic creation to help users find existing discussions.
7512 *
7513 * @since 3.0.0
7514 * @return void
7515 */
7516 public function ajax_get_topic_suggestions() {
7517 // Verify nonce (action name matches the AJAX action)
7518 if ( ! wp_verify_nonce( wpfval( $_POST, 'nonce' ), 'wpforo_ai_get_topic_suggestions' ) ) {
7519 wp_send_json_error( [
7520 'message' => wpforo_phrase( 'Security check failed', false )
7521 ], 403 );
7522 }
7523
7524 // Check rate limit (guests and users have daily limits, moderators exempt)
7525 $this->check_rate_limit( 'suggestions' );
7526
7527 // Check if AI service is available
7528 if ( ! $this->is_service_available() ) {
7529 wp_send_json_error( [
7530 'message' => wpforo_phrase( 'AI service is not available', false )
7531 ], 403 );
7532 }
7533
7534 // Check if topic suggestions feature is enabled
7535 if ( ! wpfval( WPF()->settings->ai, 'topic_suggestions' ) ) {
7536 wp_send_json_error( [
7537 'message' => wpforo_phrase( 'Topic suggestions feature is disabled', false )
7538 ], 403 );
7539 }
7540
7541 // Check usergroup permission
7542 if ( ! WPF()->usergroup->can( 'ai_suggestion' ) ) {
7543 wp_send_json_error( [
7544 'message' => wpforo_phrase( 'You do not have permission to use this feature', false )
7545 ], 403 );
7546 }
7547
7548 // Check if user has API key configured
7549 $api_key = $this->get_api_key();
7550 if ( ! $api_key ) {
7551 wp_send_json_error( [
7552 'message' => wpforo_phrase( 'AI service not configured', false )
7553 ], 400 );
7554 }
7555
7556 // Get and validate title
7557 $title = sanitize_text_field( wpfval( $_POST, 'title' ) );
7558 if ( empty( $title ) ) {
7559 wp_send_json_error( [
7560 'message' => wpforo_phrase( 'Topic title is required', false )
7561 ], 400 );
7562 }
7563
7564 // Get forum ID (optional, for context)
7565 $forumid = isset( $_POST['forumid'] ) ? (int) $_POST['forumid'] : 0;
7566
7567 // Get settings for API request
7568 $quality = wpfval( WPF()->settings->ai, 'topic_suggestions_quality' ) ?: 'balanced';
7569 $show_related = wpfval( WPF()->settings->ai, 'topic_suggestions_show_related' );
7570 $show_answer = wpfval( WPF()->settings->ai, 'topic_suggestions_show_answer' );
7571 $max_similar = (int) ( wpfval( WPF()->settings->ai, 'topic_suggestions_max_similar' ) ?: 3 );
7572 $max_related = (int) ( wpfval( WPF()->settings->ai, 'topic_suggestions_max_related' ) ?: 3 );
7573 $similarity_threshold = (int) ( wpfval( WPF()->settings->ai, 'topic_suggestions_similarity' ) ?: 55 );
7574 $language = $this->get_user_language( null, 'topic_suggestions_language' );
7575
7576 // Check if using local storage mode
7577 $is_local_mode = WPF()->vector_storage && WPF()->vector_storage->is_local_mode();
7578
7579 // For local mode, use hybrid approach:
7580 // 1. Search local embeddings for similar topics
7581 // 2. Send results to cloud API for AI-generated related topics and quick answer
7582 if ( $is_local_mode ) {
7583 $result = $this->get_local_topic_suggestions(
7584 $title,
7585 $forumid,
7586 $max_similar,
7587 $similarity_threshold,
7588 $quality,
7589 (bool) $show_related,
7590 (bool) $show_answer,
7591 $max_related,
7592 $language
7593 );
7594 wp_send_json_success( $result );
7595 return;
7596 }
7597
7598 // Cloud mode: Use the suggestions API endpoint
7599 // Build API request payload
7600 // Note: include_similar is always true - similar topics are required for the feature to work
7601 $payload = [
7602 'title' => $title,
7603 'quality' => $quality,
7604 'include_similar' => true,
7605 'include_related' => (bool) $show_related,
7606 'include_answer' => (bool) $show_answer,
7607 'max_similar' => max( 1, $max_similar ), // Ensure at least 1 similar topic
7608 'max_related' => $max_related,
7609 'similarity_threshold' => $similarity_threshold / 100, // Convert percentage to decimal
7610 'language' => $language,
7611 ];
7612
7613 // Add forum context if available
7614 if ( $forumid ) {
7615 $forum = wpforo_forum( $forumid );
7616 if ( ! empty( $forum['title'] ) ) {
7617 $payload['forum_context'] = $forum['title'];
7618 }
7619 }
7620
7621 // Add forum access filtering (only show suggestions from forums user can access)
7622 // Returns null for admins or users with full access (no filtering needed)
7623 $accessible_forumids = $this->get_accessible_forumids();
7624 if ( $accessible_forumids !== null ) {
7625 $payload['accessible_forumids'] = $accessible_forumids;
7626 }
7627
7628 // Make API request to suggestions endpoint
7629 $response = $this->post( '/suggestions/suggest', $payload );
7630
7631 if ( is_wp_error( $response ) ) {
7632 wp_send_json_error( [
7633 'message' => $response->get_error_message()
7634 ], 500 );
7635 }
7636
7637 // Process and enrich results with topic URLs
7638 $result = [
7639 'similar_topics' => [],
7640 'related_topics' => [],
7641 'quick_answer' => null,
7642 'credits_used' => $response['credits_used'] ?? 0,
7643 ];
7644
7645 // Process similar topics
7646 if ( ! empty( $response['similar_topics'] ) && is_array( $response['similar_topics'] ) ) {
7647 foreach ( $response['similar_topics'] as $item ) {
7648 // Extract topic ID from content_id (format: topic_XX_post_YY or topic_XX_reply_YY)
7649 $content_id = $item['content_id'] ?? '';
7650 $topicid = 0;
7651 if ( preg_match( '/^topic_(\d+)_/', $content_id, $matches ) ) {
7652 $topicid = (int) $matches[1];
7653 }
7654
7655 if ( $topicid ) {
7656 $topic = wpforo_topic( $topicid );
7657 if ( ! empty( $topic ) ) {
7658 $result['similar_topics'][] = [
7659 'topicid' => $topicid,
7660 'title' => $topic['title'] ?? '',
7661 'url' => wpforo_topic( $topicid, 'url' ),
7662 'score' => round( ( $item['score'] ?? 0 ) * 100 ),
7663 'replies' => (int) ( $topic['posts'] ?? 0 ) - 1,
7664 'views' => (int) ( $topic['views'] ?? 0 ),
7665 'snippet' => $this->truncate_text( wp_strip_all_tags( $item['content'] ?? '' ), 150 ),
7666 'created' => $topic['created'] ?? '',
7667 ];
7668 }
7669 }
7670 }
7671 }
7672
7673 // Process related topics - these are selected from existing indexed content
7674 // They contain 'title', 'reason', and 'content_id' (format: topic_XX_post_YY or topic_XX_reply_YY)
7675 if ( ! empty( $response['related_topics'] ) && is_array( $response['related_topics'] ) ) {
7676 foreach ( $response['related_topics'] as $item ) {
7677 // Extract topic ID from content_id (same pattern as similar_topics)
7678 $content_id = $item['content_id'] ?? '';
7679 $topicid = 0;
7680 if ( preg_match( '/^topic_(\d+)_/', $content_id, $matches ) ) {
7681 $topicid = (int) $matches[1];
7682 }
7683
7684 $related_item = [
7685 'title' => $item['title'] ?? '',
7686 'reason' => $item['reason'] ?? '',
7687 ];
7688
7689 // Add URL if we have a valid topic ID
7690 if ( $topicid ) {
7691 $related_item['topicid'] = $topicid;
7692 $related_item['url'] = wpforo_topic( $topicid, 'url' );
7693 }
7694
7695 $result['related_topics'][] = $related_item;
7696 }
7697 }
7698
7699 // Process quick answer
7700 // Note: API returns 'ai_insight' as truncated string and 'ai_insight_full' as full text
7701 if ( ! empty( $response['ai_insight'] ) || ! empty( $response['ai_insight_full'] ) ) {
7702 // Use ai_insight_full if available, otherwise use ai_insight
7703 $answer_text = ! empty( $response['ai_insight_full'] )
7704 ? $response['ai_insight_full']
7705 : $response['ai_insight'];
7706
7707 $result['quick_answer'] = [
7708 'text' => wp_kses_post( $answer_text ),
7709 'sources' => [],
7710 'caveat' => wpforo_phrase( 'This is an AI-generated suggestion based on existing forum content. Please verify the information.', false ),
7711 ];
7712 }
7713
7714 // Deduplicate: Remove related topics that are already in similar topics
7715 if ( ! empty( $result['related_topics'] ) && ! empty( $result['similar_topics'] ) ) {
7716 $similar_topic_ids = array_column( $result['similar_topics'], 'topicid' );
7717 $result['related_topics'] = array_values( array_filter(
7718 $result['related_topics'],
7719 function( $related ) use ( $similar_topic_ids ) {
7720 return empty( $related['topicid'] ) || ! in_array( $related['topicid'], $similar_topic_ids, true );
7721 }
7722 ) );
7723 }
7724
7725 // Set has_suggestions flag - JS checks this to decide whether to show results or "no results" message
7726 $result['has_suggestions'] = ! empty( $result['similar_topics'] ) || ! empty( $result['related_topics'] ) || ! empty( $result['quick_answer'] );
7727
7728 // Return success response
7729 wp_send_json_success( $result );
7730 }
7731
7732 /**
7733 * Get topic suggestions using hybrid approach (local search + cloud AI)
7734 *
7735 * For local storage mode, we use hybrid approach:
7736 * 1. Search local embeddings for similar topics
7737 * 2. Send results to cloud API for AI-generated related topics and quick answer
7738 *
7739 * @param string $title Topic title to search for
7740 * @param int $forumid Optional forum ID filter
7741 * @param int $max_similar Maximum similar topics to return
7742 * @param int $similarity_threshold Minimum similarity percentage (0-100)
7743 * @param string $quality AI quality tier (fast, balanced, advanced, premium)
7744 * @param bool $show_related Include related topics from AI
7745 * @param bool $show_answer Include quick AI answer
7746 * @param int $max_related Maximum related topics to return
7747 * @return array Result with similar_topics, related_topics, quick_answer, credits_used
7748 */
7749 private function get_local_topic_suggestions(
7750 $title,
7751 $forumid = 0,
7752 $max_similar = 3,
7753 $similarity_threshold = 70,
7754 $quality = 'balanced',
7755 $show_related = true,
7756 $show_answer = true,
7757 $max_related = 3,
7758 $language = ''
7759 ) {
7760 $result = [
7761 'similar_topics' => [],
7762 'related_topics' => [],
7763 'quick_answer' => null,
7764 'credits_used' => 0,
7765 'storage_mode' => 'hybrid',
7766 ];
7767
7768 // Build filters
7769 $filters = [];
7770 if ( $forumid > 0 ) {
7771 $filters['forumid'] = $forumid;
7772 }
7773
7774 // Step 1: Search local embeddings
7775 // Fetch more results to have candidates for both similar and related topics
7776 $fetch_count = ( $max_similar + $max_related ) * 2;
7777 $search_results = WPF()->vector_storage->semantic_search( $title, $fetch_count, $filters );
7778
7779 if ( is_wp_error( $search_results ) ) {
7780 \wpforo_ai_log( 'error', 'Local topic suggestions error: ' . $search_results->get_error_message(), 'Client' );
7781 $result['has_suggestions'] = false;
7782 return $result;
7783 }
7784
7785 // Convert similarity threshold from percentage to decimal
7786 // Local cosine similarities are on a different scale (5-25%) than cloud scores (30-90%),
7787 // so apply 1/3 of the configured threshold for local mode with minimum of 15%
7788 $threshold_decimal = max( 0.15, ( $similarity_threshold / 100 ) / 3 );
7789 $related_threshold = max( 0.10, $threshold_decimal - 0.05 ); // 5% lower for related topics
7790
7791 // Group by topic (take best match per topic)
7792 $by_topic = [];
7793 $results_array = $search_results['results'] ?? [];
7794
7795 foreach ( $results_array as $item ) {
7796 $topicid = (int) ( $item['topic_id'] ?? 0 );
7797 if ( $topicid <= 0 ) {
7798 continue;
7799 }
7800
7801 $similarity = (float) ( $item['similarity'] ?? $item['score'] ?? 0 );
7802
7803 // Skip if below the lower threshold (for related topics)
7804 if ( $similarity < $related_threshold ) {
7805 continue;
7806 }
7807
7808 // Keep only best match per topic
7809 if ( ! isset( $by_topic[ $topicid ] ) || $similarity > $by_topic[ $topicid ]['similarity'] ) {
7810 $by_topic[ $topicid ] = [
7811 'topicid' => $topicid,
7812 'similarity' => $similarity,
7813 'snippet' => $item['snippet'] ?? $item['content_preview'] ?? $item['content'] ?? '',
7814 ];
7815 }
7816 }
7817
7818 // Sort by similarity
7819 $all_topics = array_values( $by_topic );
7820 usort( $all_topics, function( $a, $b ) {
7821 return $b['similarity'] <=> $a['similarity'];
7822 } );
7823
7824 // Enrich with full topic data and prepare for API
7825 $similar_topics_for_api = [];
7826 $similar_count = 0;
7827
7828 foreach ( $all_topics as $item ) {
7829 $topic = wpforo_topic( $item['topicid'] );
7830 if ( empty( $topic ) ) {
7831 continue;
7832 }
7833
7834 $topic_url = wpforo_topic( $item['topicid'], 'url' );
7835 $snippet = $this->truncate_text( wp_strip_all_tags( $item['snippet'] ), 500 );
7836
7837 // Build topic data for API (format matches SimilarTopicInput model)
7838 $topic_data = [
7839 'content_id' => 'topic_' . $item['topicid'],
7840 'title' => $topic['title'] ?? '',
7841 'url' => $topic_url,
7842 'content' => $snippet,
7843 'score' => $item['similarity'], // Keep as decimal for API
7844 ];
7845
7846 // Add first_post and replies for AI to generate meaningful insights
7847 // Only include this data for topics that will be used for AI features
7848 if ( $show_related || $show_answer ) {
7849 $topic_author_id = (int) ( $topic['userid'] ?? 0 );
7850
7851 // Get first post content
7852 $first_postid = $topic['first_postid'] ?? 0;
7853 if ( $first_postid ) {
7854 $first_post = wpforo_post( $first_postid );
7855 if ( $first_post ) {
7856 $first_post_content = $this->truncate_text( wp_strip_all_tags( $first_post['body'] ?? '' ), 500 );
7857 $topic_data['first_post'] = [
7858 'content' => $first_post_content,
7859 'author' => $this->get_user_display_name( $first_post['userid'] ?? 0 ),
7860 ];
7861 }
7862 }
7863
7864 // Get replies (exclude topic author, sort by is_answer + likes)
7865 $replies_args = [
7866 'topicid' => $item['topicid'],
7867 'is_first_post' => 0,
7868 'status' => 0, // Only approved
7869 'orderby' => 'is_answer DESC, likes DESC, created DESC',
7870 'row_count' => 15, // Fetch more to filter
7871 ];
7872 $topic_replies = WPF()->post->get_posts( $replies_args );
7873
7874 if ( ! empty( $topic_replies ) && is_array( $topic_replies ) ) {
7875 $formatted_replies = [];
7876 foreach ( $topic_replies as $reply ) {
7877 // Skip topic author's replies
7878 if ( (int) ( $reply['userid'] ?? 0 ) === $topic_author_id ) {
7879 continue;
7880 }
7881
7882 // Skip very short replies (less than 50 chars)
7883 $reply_content = wp_strip_all_tags( $reply['body'] ?? '' );
7884 if ( mb_strlen( $reply_content ) < 50 ) {
7885 continue;
7886 }
7887
7888 $formatted_replies[] = [
7889 'content' => $this->truncate_text( $reply_content, 500 ),
7890 'author' => $this->get_user_display_name( $reply['userid'] ?? 0 ),
7891 'is_answer' => ! empty( $reply['is_answer'] ),
7892 'likes' => (int) ( $reply['likes'] ?? 0 ),
7893 'votes' => 0, // wpForo doesn't have votes, only likes
7894 ];
7895
7896 // Limit to 10 best replies
7897 if ( count( $formatted_replies ) >= 10 ) {
7898 break;
7899 }
7900 }
7901
7902 if ( ! empty( $formatted_replies ) ) {
7903 $topic_data['replies'] = $formatted_replies;
7904 }
7905 }
7906 }
7907
7908 $similar_topics_for_api[] = $topic_data;
7909
7910 // Also build result format for similar topics (above threshold)
7911 if ( $item['similarity'] >= $threshold_decimal && $similar_count < $max_similar ) {
7912 $result['similar_topics'][] = [
7913 'topicid' => $item['topicid'],
7914 'title' => $topic['title'] ?? '',
7915 'url' => $topic_url,
7916 'score' => round( $item['similarity'] * 100 ),
7917 'replies' => (int) ( $topic['posts'] ?? 0 ) - 1,
7918 'views' => (int) ( $topic['views'] ?? 0 ),
7919 'snippet' => $this->truncate_text( $snippet, 150 ),
7920 'created' => $topic['created'] ?? '',
7921 ];
7922 $similar_count++;
7923 }
7924 }
7925
7926 // Step 2: If we have topics and need AI features, call cloud API
7927 if ( ! empty( $similar_topics_for_api ) && ( $show_related || $show_answer ) ) {
7928 // Collect similar topic IDs to exclude from related topics (prevents duplicates)
7929 $exclude_topic_ids = array_filter( array_column( $result['similar_topics'], 'topicid' ) );
7930
7931 // Build API request payload for hybrid mode
7932 $payload = [
7933 'title' => $title,
7934 'quality' => $quality,
7935 'include_similar' => false, // We already have similar topics from local search
7936 'include_related' => (bool) $show_related,
7937 'include_answer' => (bool) $show_answer,
7938 'max_similar' => $max_similar,
7939 'max_related' => $max_related,
7940 'similarity_threshold' => $similarity_threshold / 100, // Convert to decimal
7941 'similar_topics_input' => array_slice( $similar_topics_for_api, 0, 20 ), // Max 20 topics
7942 'exclude_topic_ids' => array_values( $exclude_topic_ids ), // Exclude similar topics from related
7943 'language' => $language,
7944 ];
7945
7946 // Make API request to suggestions endpoint
7947 $response = $this->post( '/suggestions/suggest', $payload );
7948
7949 if ( ! is_wp_error( $response ) ) {
7950 // Get related topics from cloud AI response
7951 if ( $show_related && ! empty( $response['related_topics'] ) ) {
7952 foreach ( $response['related_topics'] as $related ) {
7953 $result['related_topics'][] = [
7954 'title' => $related['title'] ?? '',
7955 'url' => $related['url'] ?? '',
7956 'reason' => $related['reason'] ?? '',
7957 ];
7958 }
7959 }
7960
7961 // Get quick answer from cloud AI response
7962 // Note: API returns 'ai_insight' as truncated string and 'ai_insight_full' as full text
7963 if ( $show_answer && ( ! empty( $response['ai_insight'] ) || ! empty( $response['ai_insight_full'] ) ) ) {
7964 // Use ai_insight_full if available, otherwise use ai_insight
7965 $answer_text = ! empty( $response['ai_insight_full'] )
7966 ? $response['ai_insight_full']
7967 : $response['ai_insight'];
7968
7969 $result['quick_answer'] = [
7970 'text' => wp_kses_post( $answer_text ),
7971 'sources' => [],
7972 'caveat' => wpforo_phrase( 'This is an AI-generated suggestion based on existing forum content. Please verify the information.', false ),
7973 ];
7974 }
7975
7976 // Add credits used by cloud AI
7977 $result['credits_used'] = $response['credits_used'] ?? 0;
7978 } else {
7979 // Log error but don't fail - we still have similar topics
7980 \wpforo_ai_log( 'error', 'Hybrid mode cloud API error: ' . $response->get_error_message(), 'Client' );
7981 }
7982 }
7983
7984 // Deduplicate: Remove related topics that are already in similar topics
7985 // In hybrid mode, related_topics only have title/url/reason, so we match by URL
7986 if ( ! empty( $result['related_topics'] ) && ! empty( $result['similar_topics'] ) ) {
7987 $similar_topic_urls = array_filter( array_column( $result['similar_topics'], 'url' ) );
7988
7989 $result['related_topics'] = array_values( array_filter(
7990 $result['related_topics'],
7991 function( $related ) use ( $similar_topic_urls ) {
7992 // Check by URL (the only common identifier in hybrid mode)
7993 return empty( $related['url'] ) || ! in_array( $related['url'], $similar_topic_urls, true );
7994 }
7995 ) );
7996 }
7997
7998 $result['has_suggestions'] = ! empty( $result['similar_topics'] ) ||
7999 ! empty( $result['related_topics'] ) ||
8000 ! empty( $result['quick_answer'] );
8001
8002 return $result;
8003 }
8004
8005 /**
8006 * Truncate text to specified length
8007 *
8008 * @param string $text Text to truncate
8009 * @param int $length Maximum length
8010 * @param string $suffix Suffix to append if truncated
8011 * @return string Truncated text
8012 */
8013 private function truncate_text( $text, $length = 100, $suffix = '...' ) {
8014 $text = trim( $text );
8015 if ( mb_strlen( $text ) <= $length ) {
8016 return $text;
8017 }
8018 return mb_substr( $text, 0, $length ) . $suffix;
8019 }
8020
8021 /**
8022 * Filter to disable built-in wpForo topic suggestions when AI Topic Suggestions is enabled
8023 *
8024 * When AI Topic Suggestions is enabled in settings, the built-in wpForo suggested topics
8025 * feature (which uses basic keyword matching) should be disabled to avoid duplication.
8026 *
8027 * @param bool $enabled Whether built-in suggestions are enabled
8028 * @return bool False if AI Topic Suggestions is enabled, otherwise unchanged
8029 */
8030 public function filter_built_in_suggestions( $enabled ) {
8031 // If AI Topic Suggestions is enabled, we have an API key, and user has permission, disable built-in suggestions
8032 if ( wpfval( WPF()->settings->ai, 'topic_suggestions' ) && $this->get_api_key() && WPF()->usergroup->can( 'ai_suggestion' ) ) {
8033 return false;
8034 }
8035 return $enabled;
8036 }
8037
8038 /**
8039 * Add AI suggestions panel as an HTML field right after the title field
8040 *
8041 * Uses the wpforo_form_fields filter to insert an HTML-type field containing
8042 * the AI suggestions panel directly after the topic title field.
8043 *
8044 * @param array $fields 3D array of form fields [row][col][field]
8045 * @return array Modified fields array
8046 */
8047 public function add_ai_suggestions_after_title( $fields ) {
8048 // Check if topic suggestions feature is enabled
8049 if ( ! wpfval( WPF()->settings->ai, 'topic_suggestions' ) ) {
8050 return $fields;
8051 }
8052
8053 // Check if AI service is available (connected + active/trial subscription)
8054 if ( ! $this->is_service_available() ) {
8055 return $fields;
8056 }
8057
8058 // Check usergroup permission
8059 if ( ! WPF()->usergroup->can( 'ai_suggestion' ) ) {
8060 return $fields;
8061 }
8062
8063 // Skip if editing existing topic (only show on new topic creation)
8064 // When editing, the title field has a pre-filled value from the existing topic
8065 foreach ( $fields as $row ) {
8066 foreach ( $row as $cols ) {
8067 foreach ( $cols as $field_key => $field ) {
8068 if ( $field_key === 'title' || ( is_array( $field ) && wpfval( $field, 'fieldKey' ) === 'title' ) ) {
8069 // If title field has a value, we're in edit mode
8070 if ( is_array( $field ) && ! empty( $field['value'] ) ) {
8071 return $fields;
8072 }
8073 }
8074 }
8075 }
8076 }
8077
8078 // Get the suggestions panel HTML
8079 $panel_html = $this->get_ai_suggestions_panel_html();
8080 if ( empty( $panel_html ) ) {
8081 return $fields;
8082 }
8083
8084 // Create the HTML field for the suggestions panel
8085 $suggestions_field = [
8086 'fieldKey' => 'ai_suggestions',
8087 'name' => 'ai_suggestions',
8088 'type' => 'html',
8089 'isDefault' => 1,
8090 'isRemovable' => 0,
8091 'isRequired' => 0,
8092 'label' => '',
8093 'html' => $panel_html,
8094 ];
8095
8096 // Find title field and insert suggestions field after it
8097 $new_fields = [];
8098 foreach ( $fields as $row_key => $row ) {
8099 $new_row = [];
8100 foreach ( $row as $col_key => $cols ) {
8101 $new_col = [];
8102 foreach ( $cols as $field_key => $field ) {
8103 $new_col[ $field_key ] = $field;
8104 // Insert suggestions field right after title field
8105 if ( $field_key === 'title' || ( is_array( $field ) && wpfval( $field, 'fieldKey' ) === 'title' ) ) {
8106 $new_col['ai_suggestions'] = $suggestions_field;
8107 }
8108 }
8109 $new_row[ $col_key ] = $new_col;
8110 }
8111 $new_fields[ $row_key ] = $new_row;
8112 }
8113
8114 return $new_fields;
8115 }
8116
8117 /**
8118 * Get the AI suggestions panel HTML
8119 *
8120 * Returns the HTML for the collapsible AI suggestions panel that displays
8121 * similar topics, related topics, and quick AI answers.
8122 *
8123 * @return string Panel HTML
8124 */
8125 private function get_ai_suggestions_panel_html() {
8126 // Get settings for suggestion config
8127 // Note: show_similar is not included - similar topics are always required for the feature
8128 $config = [
8129 'enabled' => true,
8130 'quality' => wpfval( WPF()->settings->ai, 'topic_suggestions_quality' ) ?: 'balanced',
8131 'min_words' => (int) ( wpfval( WPF()->settings->ai, 'topic_suggestions_min_words' ) ?: 3 ),
8132 'max_calls' => (int) ( wpfval( WPF()->settings->ai, 'topic_suggestions_max_calls' ) ?: 2 ),
8133 'show_related' => (bool) wpfval( WPF()->settings->ai, 'topic_suggestions_show_related' ),
8134 'show_answer' => (bool) wpfval( WPF()->settings->ai, 'topic_suggestions_show_answer' ),
8135 ];
8136
8137 ob_start();
8138 ?>
8139 <div class="wpf-ai-suggestions-panel" data-suggestion-config="<?php echo esc_attr( wp_json_encode( $config ) ); ?>">
8140 <div class="wpf-ai-suggestions-header">
8141 <div class="wpf-ai-suggestions-header-left">
8142 <span class="wpf-ai-suggestions-title"><?php wpforo_phrase( 'AI Topic Suggestions' ); ?></span>
8143 </div>
8144 <span class="wpf-ai-suggestions-close" title="<?php echo esc_attr( wpforo_phrase( 'Close', false ) ); ?>">&times;</span>
8145 </div>
8146 <div class="wpf-ai-suggestions-content">
8147 <!-- Content will be populated by JavaScript -->
8148 </div>
8149 </div>
8150 <?php
8151 return ob_get_clean();
8152 }
8153
8154 // =========================================================================
8155 // CONTENT CLEANING METHODS FOR INDEXING
8156 // =========================================================================
8157
8158 /**
8159 * Strip quoted content from post body before indexing
8160 *
8161 * Forum posts often contain quoted replies. When indexing, we should remove
8162 * quoted content because:
8163 * 1. The original content is already indexed from the original post
8164 * 2. Including quotes would create duplicate/redundant content in the index
8165 * 3. Search results would be skewed by over-representing quoted content
8166 *
8167 * Patterns removed:
8168 * - [quote data-userid="1" data-postid="488"]...content...[/quote]
8169 * - [quote ...]...content...[/quote] (any attributes)
8170 * - <blockquote class="..." data-...>...content...</blockquote> (with attributes)
8171 *
8172 * Patterns kept (user-written content, not quotes of other posts):
8173 * - <blockquote>...content...</blockquote> (clean tag without attributes)
8174 *
8175 * @param string $content Post content (HTML)
8176 * @return string Content with quoted sections removed
8177 */
8178 public function strip_quoted_content( $content ) {
8179 if ( empty( $content ) ) {
8180 return $content;
8181 }
8182
8183 // Pattern 1: Remove [quote ...attributes...] shortcodes
8184 // Matches [quote data-userid="1" data-postid="488"]...[/quote]
8185 // and [quote anything...]...[/quote]
8186 // Uses DOTALL flag (s) to match across newlines
8187 $content = preg_replace(
8188 '/\[quote\s+[^\]]+\].*?\[\/quote\]/is',
8189 '',
8190 $content
8191 );
8192
8193 // Pattern 2: Remove <blockquote> tags that have ANY attributes
8194 // This indicates a quoted post (wpForo adds data-* attributes)
8195 // Matches <blockquote class="..." data-userid="...">...</blockquote>
8196 // But NOT <blockquote>...</blockquote> (clean tag = user content)
8197 $content = preg_replace(
8198 '/<blockquote\s+[^>]+>.*?<\/blockquote>/is',
8199 '',
8200 $content
8201 );
8202
8203 // Clean up any resulting multiple blank lines
8204 $content = preg_replace( '/(\r?\n){3,}/', "\n\n", $content );
8205
8206 return trim( $content );
8207 }
8208
8209 /**
8210 * Clean post content for indexing
8211 *
8212 * Applies all content cleaning transformations:
8213 * - Strip quoted content (duplicates from other posts)
8214 * - Future: other content cleaning rules
8215 *
8216 * @param string $content Post content (HTML)
8217 * @return string Cleaned content ready for indexing
8218 */
8219 public function clean_content_for_indexing( $content ) {
8220 // Strip quoted content first
8221 $content = $this->strip_quoted_content( $content );
8222
8223 // Future: Add other cleaning rules here
8224
8225 return $content;
8226 }
8227
8228 /**
8229 * Clean content for search result display
8230 *
8231 * Strips HTML tags, shortcodes, and Lambda processing markers
8232 * (image descriptions, document content blocks) from search result excerpts.
8233 * These markers are useful for embeddings but should not be shown to users.
8234 *
8235 * @param string $content Raw content from search result (cloud excerpt or local preview)
8236 * @return string Cleaned content for display
8237 */
8238 public function clean_content_for_search_display( $content ) {
8239 // Strip HTML tags
8240 $content = wp_strip_all_tags( $content );
8241
8242 // Remove [TOPIC] or [TOPIC: title] prefix (cloud format)
8243 $content = preg_replace( '/^\[TOPIC[^\]]*\]\s*/i', '', $content );
8244 // Remove "Topic: Title\n\n" prefix (local format)
8245 $content = preg_replace( '/^Topic:\s*[^\n]*\n+/i', '', $content );
8246
8247 // Count image and document markers BEFORE stripping (for attachment summary)
8248 $image_count = preg_match_all( '/\[IMAGE:\s*[^\]]*\]/', $content );
8249 $doc_matches = [];
8250 preg_match_all( '/\[DOCUMENT:\s*([^\]]*)\]/', $content, $doc_matches );
8251 $doc_count = count( $doc_matches[0] );
8252 // Extract page counts from document markers like [DOCUMENT: filename.pdf (5 pages)]
8253 $total_pages = 0;
8254 if ( $doc_count > 0 ) {
8255 foreach ( $doc_matches[1] as $doc_info ) {
8256 if ( preg_match( '/\((\d+)\s+pages?\)/', $doc_info, $page_match ) ) {
8257 $total_pages += (int) $page_match[1];
8258 }
8259 }
8260 }
8261
8262 // Strip enrichment tags added for embedding quality: [FORUM: name], [SOLVED], [BEST ANSWER]
8263 $content = preg_replace( '/\[(?:FORUM|SOLVED|BEST ANSWER)[^\]]*\]/', '', $content );
8264 // Strip wpForo shortcodes: [attach]N[/attach], [attach]N,M[/attach]
8265 $content = preg_replace( '/\[attach\]\d+(?:,\d+)?\[\/attach\]/', '', $content );
8266 // Strip any remaining shortcode-like patterns: [something]...[/something] or [something]
8267 $content = preg_replace( '/\[(?:\/)?[a-zA-Z0-9_-]+(?:\s[^\]]*?)?\]/', '', $content );
8268
8269 // Strip Lambda image processing markers
8270 $content = preg_replace( '/---\s*Image\s+Content\s*---/', '', $content );
8271 $content = preg_replace( '/\[IMAGE:\s*[^\]]*\]/', '', $content );
8272
8273 // Strip Lambda document processing markers
8274 $content = preg_replace( '/---\s*Document\s+Content\s*---/', '', $content );
8275 $content = preg_replace( '/\[DOCUMENT:\s*[^\]]*\]/', '', $content );
8276 $content = preg_replace( '/\[\/DOCUMENT\]/', '', $content );
8277 $content = preg_replace( '/\[DOC_IMAGE:\s*[^\]]*\]/', '', $content );
8278
8279 // Normalize whitespace
8280 $content = preg_replace( '/\s+/', ' ', $content );
8281 $content = trim( $content );
8282
8283 // Append attachment summary (same format as local mode build_content_preview)
8284 $attachments = [];
8285 if ( $doc_count > 0 ) {
8286 if ( $total_pages > 0 ) {
8287 $attachments[] = sprintf( '%d %s, %d %s',
8288 $doc_count,
8289 $doc_count === 1 ? 'document' : 'documents',
8290 $total_pages,
8291 $total_pages === 1 ? 'page' : 'pages'
8292 );
8293 } else {
8294 $attachments[] = sprintf( '%d %s',
8295 $doc_count,
8296 $doc_count === 1 ? 'document' : 'documents'
8297 );
8298 }
8299 }
8300 if ( $image_count > 0 ) {
8301 $attachments[] = sprintf( '%d %s',
8302 $image_count,
8303 $image_count === 1 ? 'image' : 'images'
8304 );
8305 }
8306 if ( ! empty( $attachments ) ) {
8307 $content .= ' [+ ' . implode( ', ', $attachments ) . ']';
8308 }
8309
8310 return $content;
8311 }
8312
8313 // =========================================================================
8314 // AI BOT REPLY METHODS
8315 // =========================================================================
8316
8317 /**
8318 * Render Bot Reply button in post action buttons
8319 *
8320 * Displays an AI bot icon button before the quote button that allows
8321 * moderators to generate AI-powered replies to posts.
8322 *
8323 * @param array|string $button_html Current button HTML (may be array or string)
8324 * @param string $button Button type
8325 * @param array $forum Forum data
8326 * @param array $topic Topic data
8327 * @param array $post Post data
8328 * @return array Modified button HTML array
8329 */
8330 public function render_bot_reply_button( $button_html, $button, $forum, $topic, $post ) {
8331 // Ensure $button_html is an array
8332 if ( ! is_array( $button_html ) ) {
8333 $button_html = $button_html ? [ $button_html ] : [];
8334 }
8335
8336 // Check if Bot Reply feature is enabled in settings
8337 if ( ! wpfval( WPF()->settings->ai, 'bot_reply' ) ) {
8338 return $button_html;
8339 }
8340
8341 // Check if feature is available for this plan (wpforo AI specific)
8342 if ( ! $this->is_feature_available( 'ai_bot_reply' ) ) {
8343 return $button_html;
8344 }
8345
8346 // Get IDs
8347 $forumid = (int) ( wpfval( $forum, 'forumid' ) ?: wpfval( $topic, 'forumid' ) );
8348 $topicid = (int) wpfval( $topic, 'topicid' );
8349 $postid = (int) wpfval( $post, 'postid' );
8350 $is_closed = (int) wpfval( $topic, 'closed' );
8351 $is_approve = (int) wpfval( $post, 'status' );
8352
8353 // Skip if topic closed, post unapproved, or missing IDs (same as wpforo-aibot)
8354 if ( $is_closed || $is_approve || ! $postid ) {
8355 return $button_html;
8356 }
8357
8358 // Permission check: Can reply OR (is owner AND can reply to own)
8359 // Plus: Must have 'au' (approve/unapprove) permission (moderator/admin only)
8360 $can_reply = WPF()->perm->forum_can( 'cr', $forumid );
8361 $is_owner = wpforo_is_owner( wpforo_bigintval( wpfval( $topic, 'userid' ) ), (string) wpfval( $topic, 'email' ) );
8362 $can_own_reply = $is_owner && WPF()->perm->forum_can( 'ocr', $forumid );
8363
8364 if ( $can_reply || $can_own_reply ) {
8365 if ( WPF()->perm->forum_can( 'au', $forumid ) ) {
8366 $layout = WPF()->forum->get_layout( $forumid );
8367 $layout_class = 'wpforo_layout_' . $layout;
8368
8369 // Build the Bot Reply button HTML (matching wpforo-aibot structure exactly)
8370 $button_html[] = '<span id="parentpostid' . wpforo_bigintval( $postid ) . '" class="wpf-bot-reply wpf-ai-bot-reply wpf-action ' . $layout_class . '" title="' . esc_attr( wpforo_phrase( 'Ask AI Bot to reply', false ) ) . '" data-postid="' . wpforo_bigintval( $postid ) . '" data-topicid="' . wpforo_bigintval( $topicid ) . '"><i class="fas fa-robot"></i><span class="wpf-button-text">' . wpforo_phrase( 'Bot Reply', false ) . '</span></span>';
8371 }
8372 }
8373
8374 return $button_html;
8375 }
8376
8377 /**
8378 * Render Suggest Reply button in reply form
8379 *
8380 * Displays a "Suggest Reply" button before the "Add Reply" submit button
8381 * that loads AI-generated content into the TinyMCE editor.
8382 *
8383 * @param array $topic Topic data
8384 * @param array $values Form values (empty for new reply, populated for edit)
8385 * @param array $forum Forum data
8386 * @return void
8387 */
8388 public function render_suggest_reply_button( $topic, $values, $forum ) {
8389 // Skip if editing (values is not empty means edit mode)
8390 if ( ! empty( $values ) && wpfval( $values, 'postid' ) ) {
8391 return;
8392 }
8393
8394 // Check if Bot Reply feature is enabled
8395 if ( ! wpfval( WPF()->settings->ai, 'bot_reply' ) ) {
8396 return;
8397 }
8398
8399 // Check if feature is available for this plan
8400 if ( ! $this->is_feature_available( 'ai_bot_reply' ) ) {
8401 return;
8402 }
8403
8404 // Check if topic is closed
8405 if ( ! empty( $topic['closed'] ) ) {
8406 return;
8407 }
8408
8409 // Check if user has 'au' permission for this forum
8410 $forumid = wpfval( $forum, 'forumid' ) ?: wpfval( $topic, 'forumid' );
8411 if ( ! $forumid || ! WPF()->perm->forum_can( 'au', $forumid ) ) {
8412 return;
8413 }
8414
8415 $topic_id = (int) wpfval( $topic, 'topicid' );
8416 if ( ! $topic_id ) {
8417 return;
8418 }
8419
8420 // Render the Suggest Reply button
8421 ?>
8422 <button type="button"
8423 class="wpf-button wpf-button-secondary wpf-ai-suggest-reply"
8424 data-topicid="<?php echo esc_attr( $topic_id ); ?>"
8425 title="<?php echo esc_attr( wpforo_phrase( 'Generate AI reply suggestion', false ) ); ?>">
8426 <i class="fas fa-circle-notch fa-spin wpf-ai-spinner"></i>
8427 <svg class="wpf-ai-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.912 5.813a2 2 0 0 0 1.275 1.275L21 12l-5.813 1.912a2 2 0 0 0-1.275 1.275L12 21l-1.912-5.813a2 2 0 0 0-1.275-1.275L3 12l5.813-1.912a2 2 0 0 0 1.275-1.275L12 3z"/></svg>
8428 <span><?php echo esc_html( wpforo_phrase( 'Suggest Reply', false ) ); ?></span>
8429 </button>
8430 <?php
8431 }
8432
8433 /**
8434 * AJAX handler for Bot Reply
8435 *
8436 * Generates an AI reply and creates a new post from the bot user.
8437 *
8438 * @return void
8439 */
8440 public function ajax_bot_reply() {
8441 // Track start time for logging
8442 $_log_start_time = microtime( true );
8443
8444 // Verify nonce
8445 if ( ! wp_verify_nonce( sanitize_text_field( wpfval( $_POST, '_wpnonce' ) ), 'wpforo_ai_bot_reply' ) ) {
8446 wp_send_json_error( [ 'message' => wpforo_phrase( 'Security check failed', false ) ], 403 );
8447 }
8448
8449 // Check if feature is enabled and available
8450 if ( ! wpfval( WPF()->settings->ai, 'bot_reply' ) || ! $this->is_feature_available( 'ai_bot_reply' ) ) {
8451 wp_send_json_error( [ 'message' => wpforo_phrase( 'AI Bot Reply feature is not available', false ) ], 403 );
8452 }
8453
8454 // Get parameters
8455 $post_id = (int) wpfval( $_POST, 'post_id' );
8456 $topic_id = (int) wpfval( $_POST, 'topic_id' );
8457
8458 if ( ! $post_id || ! $topic_id ) {
8459 wp_send_json_error( [ 'message' => wpforo_phrase( 'Invalid request parameters', false ) ], 400 );
8460 }
8461
8462 // Get topic and post data
8463 $topic = WPF()->topic->get_topic( $topic_id );
8464 if ( ! $topic ) {
8465 wp_send_json_error( [ 'message' => wpforo_phrase( 'Topic not found', false ) ], 404 );
8466 }
8467
8468 // Check permission
8469 $forumid = (int) $topic['forumid'];
8470 if ( ! WPF()->perm->forum_can( 'au', $forumid ) ) {
8471 wp_send_json_error( [ 'message' => wpforo_phrase( 'Permission denied', false ) ], 403 );
8472 }
8473
8474 // Check if topic is closed
8475 if ( ! empty( $topic['closed'] ) ) {
8476 wp_send_json_error( [ 'message' => wpforo_phrase( 'Topic is closed', false ) ], 403 );
8477 }
8478
8479 // Check rate limits
8480 $limit_error = $this->check_bot_reply_limits( $topic_id );
8481 if ( $limit_error ) {
8482 wp_send_json_error( [ 'message' => $limit_error ], 429 );
8483 }
8484
8485 // Get the post being replied to
8486 $parent_post = WPF()->post->get_post( $post_id );
8487 if ( ! $parent_post ) {
8488 wp_send_json_error( [ 'message' => wpforo_phrase( 'Post not found', false ) ], 404 );
8489 }
8490
8491 // Get first post for topic context (if replying to a reply, not the first post)
8492 $first_post = null;
8493 if ( (int) $parent_post['is_first_post'] !== 1 ) {
8494 $first_post = WPF()->post->get_post( $topic['first_postid'] );
8495 }
8496
8497 // Generate AI reply
8498 $result = $this->generate_bot_reply( $topic, $parent_post, $first_post );
8499 if ( is_wp_error( $result ) ) {
8500 // Log error
8501 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
8502 WPF()->ai_logs->log( [
8503 'action_type' => AILogs::ACTION_BOT_REPLY,
8504 'credits_used' => 0,
8505 'status' => AILogs::STATUS_ERROR,
8506 'content_type' => 'topic',
8507 'content_id' => $topic_id,
8508 'topicid' => $topic_id,
8509 'forumid' => $forumid,
8510 'request_summary' => sprintf( 'Bot reply to topic: %s', wp_trim_words( $topic['title'], 10 ) ),
8511 'error_message' => $result->get_error_message(),
8512 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
8513 ] );
8514 }
8515 wp_send_json_error( [ 'message' => $result->get_error_message() ], 500 );
8516 }
8517
8518 // Create the bot reply post
8519 $new_post_id = $this->create_bot_reply_post( $topic, $parent_post, $result['reply'] );
8520 if ( is_wp_error( $new_post_id ) ) {
8521 // Log error
8522 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
8523 WPF()->ai_logs->log( [
8524 'action_type' => AILogs::ACTION_BOT_REPLY,
8525 'credits_used' => $result['credits_used'] ?? 0,
8526 'status' => AILogs::STATUS_ERROR,
8527 'content_type' => 'topic',
8528 'content_id' => $topic_id,
8529 'topicid' => $topic_id,
8530 'forumid' => $forumid,
8531 'request_summary' => sprintf( 'Bot reply to topic: %s', wp_trim_words( $topic['title'], 10 ) ),
8532 'error_message' => $new_post_id->get_error_message(),
8533 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
8534 ] );
8535 }
8536 wp_send_json_error( [ 'message' => $new_post_id->get_error_message() ], 500 );
8537 }
8538
8539 $credits_used = $result['credits_used'] ?? 0;
8540
8541 // Log success
8542 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
8543 WPF()->ai_logs->log( [
8544 'action_type' => AILogs::ACTION_BOT_REPLY,
8545 'credits_used' => $credits_used,
8546 'status' => AILogs::STATUS_SUCCESS,
8547 'content_type' => 'topic',
8548 'content_id' => $topic_id,
8549 'topicid' => $topic_id,
8550 'forumid' => $forumid,
8551 'request_summary' => sprintf( 'Bot reply to topic: %s', wp_trim_words( $topic['title'], 10 ) ),
8552 'response_summary' => sprintf( 'Created post #%d', $new_post_id ),
8553 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
8554 ] );
8555 }
8556
8557 wp_send_json_success( [
8558 'post_id' => $new_post_id,
8559 'credits_used' => $credits_used,
8560 'message' => wpforo_phrase( 'Bot reply created successfully', false ),
8561 ] );
8562 }
8563
8564 /**
8565 * AJAX handler for Suggest Reply
8566 *
8567 * Generates an AI reply suggestion and returns it for insertion into the editor.
8568 *
8569 * @return void
8570 */
8571 public function ajax_suggest_reply() {
8572 // Track start time for logging
8573 $_log_start_time = microtime( true );
8574
8575 // Verify nonce
8576 if ( ! wp_verify_nonce( sanitize_text_field( wpfval( $_POST, '_wpnonce' ) ), 'wpforo_ai_suggest_reply' ) ) {
8577 wp_send_json_error( [ 'message' => wpforo_phrase( 'Security check failed', false ) ], 403 );
8578 }
8579
8580 // Check if feature is enabled and available
8581 if ( ! wpfval( WPF()->settings->ai, 'bot_reply' ) || ! $this->is_feature_available( 'ai_bot_reply' ) ) {
8582 wp_send_json_error( [ 'message' => wpforo_phrase( 'AI Bot Reply feature is not available', false ) ], 403 );
8583 }
8584
8585 // Get parameters
8586 $topic_id = (int) wpfval( $_POST, 'topic_id' );
8587 $parent_id = (int) wpfval( $_POST, 'parent_id' ); // Optional: if replying to specific post
8588
8589 if ( ! $topic_id ) {
8590 wp_send_json_error( [ 'message' => wpforo_phrase( 'Invalid request parameters', false ) ], 400 );
8591 }
8592
8593 // Get topic data
8594 $topic = WPF()->topic->get_topic( $topic_id );
8595 if ( ! $topic ) {
8596 wp_send_json_error( [ 'message' => wpforo_phrase( 'Topic not found', false ) ], 404 );
8597 }
8598
8599 // Check permission
8600 $forumid = (int) $topic['forumid'];
8601 if ( ! WPF()->perm->forum_can( 'au', $forumid ) ) {
8602 wp_send_json_error( [ 'message' => wpforo_phrase( 'Permission denied', false ) ], 403 );
8603 }
8604
8605 // Get the post being replied to (default to first post if no parent specified)
8606 $parent_post = null;
8607 if ( $parent_id ) {
8608 $parent_post = WPF()->post->get_post( $parent_id );
8609 }
8610 if ( ! $parent_post ) {
8611 $parent_post = WPF()->post->get_post( $topic['first_postid'] );
8612 }
8613
8614 if ( ! $parent_post ) {
8615 wp_send_json_error( [ 'message' => wpforo_phrase( 'Post not found', false ) ], 404 );
8616 }
8617
8618 // Get first post for topic context (if replying to a reply)
8619 $first_post = null;
8620 if ( (int) $parent_post['is_first_post'] !== 1 ) {
8621 $first_post = WPF()->post->get_post( $topic['first_postid'] );
8622 }
8623
8624 // Generate AI reply
8625 $result = $this->generate_bot_reply( $topic, $parent_post, $first_post );
8626 if ( is_wp_error( $result ) ) {
8627 // Log error
8628 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
8629 WPF()->ai_logs->log( [
8630 'action_type' => AILogs::ACTION_SUGGEST_REPLY,
8631 'credits_used' => 0,
8632 'status' => AILogs::STATUS_ERROR,
8633 'content_type' => 'topic',
8634 'content_id' => $topic_id,
8635 'topicid' => $topic_id,
8636 'forumid' => $forumid,
8637 'request_summary' => sprintf( 'Suggest reply for topic: %s', wp_trim_words( $topic['title'], 10 ) ),
8638 'error_message' => $result->get_error_message(),
8639 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
8640 ] );
8641 }
8642 wp_send_json_error( [ 'message' => $result->get_error_message() ], 500 );
8643 }
8644
8645 $credits_used = $result['credits_used'] ?? 0;
8646
8647 // Log success
8648 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
8649 WPF()->ai_logs->log( [
8650 'action_type' => AILogs::ACTION_SUGGEST_REPLY,
8651 'credits_used' => $credits_used,
8652 'status' => AILogs::STATUS_SUCCESS,
8653 'content_type' => 'topic',
8654 'content_id' => $topic_id,
8655 'topicid' => $topic_id,
8656 'forumid' => $forumid,
8657 'request_summary' => sprintf( 'Suggest reply for topic: %s', wp_trim_words( $topic['title'], 10 ) ),
8658 'response_summary' => 'Generated reply suggestion',
8659 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
8660 ] );
8661 }
8662
8663 wp_send_json_success( [
8664 'content' => $result['reply'],
8665 'credits_used' => $credits_used,
8666 ] );
8667 }
8668
8669 /**
8670 * Generate bot reply using Tasks API
8671 *
8672 * @param array $topic Topic data
8673 * @param array $parent_post Post being replied to
8674 * @param array|null $first_post First post of topic (if replying to reply)
8675 * @return array|WP_Error Result with 'reply' and 'credits_used' or WP_Error
8676 */
8677 private function generate_bot_reply( $topic, $parent_post, $first_post = null ) {
8678 $api_key = $this->get_stored_api_key();
8679 if ( ! $api_key ) {
8680 return new \WP_Error( 'no_api_key', wpforo_phrase( 'API key not configured', false ) );
8681 }
8682
8683 // Get settings
8684 $settings = WPF()->settings->ai;
8685 $quality = wpfval( $settings, 'bot_reply_quality' ) ?: 'premium';
8686 $style = wpfval( $settings, 'bot_reply_style' ) ?: 'helpful_answer';
8687 $tone = wpfval( $settings, 'bot_reply_tone' ) ?: 'neutral';
8688 $length = wpfval( $settings, 'bot_reply_length' ) ?: 'medium';
8689 $knowledge_source = wpfval( $settings, 'bot_reply_knowledge_source' ) ?: 'forum_and_ai';
8690 $response_language = $this->get_user_language( null, 'bot_reply_language' );
8691
8692 // Build include options from checkbox array
8693 $include = [];
8694 $includes_setting = wpfval( $settings, 'bot_reply_includes' );
8695 if ( is_array( $includes_setting ) ) {
8696 // Map setting values to backend expected keys
8697 $include_map = [
8698 'code' => 'code_examples',
8699 'docs' => 'documentation_links',
8700 'steps' => 'step_by_step',
8701 'questions' => 'follow_up_questions',
8702 'youtube' => 'youtube_videos',
8703 'greeting' => 'personalized_greeting',
8704 ];
8705 foreach ( $includes_setting as $key ) {
8706 if ( isset( $include_map[ $key ] ) ) {
8707 $include[] = $include_map[ $key ];
8708 }
8709 }
8710 }
8711
8712 // Get forum info
8713 $forum = WPF()->forum->get_forum( (int) $topic['forumid'] );
8714
8715 // Get parent post author name for greeting (use @nicename format)
8716 $parent_author_name = '';
8717 if ( $parent_post['userid'] ) {
8718 $parent_author = WPF()->member->get_member( $parent_post['userid'] );
8719 $nicename = wpfval( $parent_author, 'user_nicename' ) ?: wpfval( $parent_author, 'display_name' );
8720 $parent_author_name = $nicename ? '@' . $nicename : '';
8721 } else {
8722 $parent_author_name = wpfval( $parent_post, 'name' ) ?: '';
8723 }
8724
8725 // Build posts array for the topic context
8726 $posts = [];
8727
8728 // Add first post if available
8729 if ( $first_post ) {
8730 $first_post_author = '';
8731 if ( $first_post['userid'] ) {
8732 $first_author = WPF()->member->get_member( $first_post['userid'] );
8733 $first_post_author = wpfval( $first_author, 'display_name' ) ?: '';
8734 } else {
8735 $first_post_author = wpfval( $first_post, 'name' ) ?: '';
8736 }
8737 $posts[] = [
8738 'postid' => (int) $first_post['postid'],
8739 'author' => $first_post_author,
8740 'content' => wp_strip_all_tags( $first_post['body'] ),
8741 ];
8742 }
8743
8744 // Add parent post (the one being replied to)
8745 $posts[] = [
8746 'postid' => (int) $parent_post['postid'],
8747 'author' => $parent_author_name,
8748 'content' => wp_strip_all_tags( $parent_post['body'] ),
8749 ];
8750
8751 // Build the request payload matching backend ReplyGeneratorRequest schema
8752 $request_body = [
8753 'task_type' => 'reply_generator',
8754 'topics' => [
8755 [
8756 'topic_id' => (int) $topic['topicid'],
8757 'title' => $topic['title'],
8758 'forum_id' => (int) $topic['forumid'],
8759 'posts' => $posts,
8760 'reply_strategy' => 'last_post',
8761 ],
8762 ],
8763 'replies_count' => 1,
8764 'quality' => $quality,
8765 'reply_style' => $style,
8766 'reply_tone' => $tone,
8767 'reply_strategy' => 'last_post',
8768 'reply_length' => $length,
8769 'include' => $include,
8770 'knowledge_source' => $knowledge_source,
8771 'response_language' => $response_language,
8772 ];
8773
8774 // Make API request to /tasks/generate endpoint
8775 $response = wp_remote_post( $this->api_base_url . '/tasks/generate', [
8776 'timeout' => 60,
8777 'headers' => [
8778 'Authorization' => 'Bearer ' . $api_key,
8779 'Content-Type' => 'application/json',
8780 ],
8781 'body' => wp_json_encode( $request_body ),
8782 ] );
8783
8784 if ( is_wp_error( $response ) ) {
8785 return new \WP_Error( 'api_error', $response->get_error_message() );
8786 }
8787
8788 $status_code = wp_remote_retrieve_response_code( $response );
8789 $body = json_decode( wp_remote_retrieve_body( $response ), true );
8790
8791 if ( $status_code >= 400 ) {
8792 $error_message = wpfval( $body, 'error' ) ?: wpfval( $body, 'detail' ) ?: 'API request failed';
8793 return new \WP_Error( 'api_error', $error_message );
8794 }
8795
8796 // Backend returns { success: bool, replies: [{topic_id, content}], credits_used }
8797 $replies = wpfval( $body, 'replies' );
8798 $reply_content = '';
8799 if ( is_array( $replies ) && ! empty( $replies ) ) {
8800 $reply_content = wpfval( $replies[0], 'content' ) ?: '';
8801 }
8802
8803 // Fallback to legacy response fields
8804 if ( empty( $reply_content ) ) {
8805 $reply_content = wpfval( $body, 'reply' ) ?: wpfval( $body, 'content' ) ?: '';
8806 }
8807
8808 if ( empty( $reply_content ) ) {
8809 return new \WP_Error( 'empty_reply', wpforo_phrase( 'AI generated an empty reply', false ) );
8810 }
8811
8812 return [
8813 'reply' => $reply_content,
8814 'credits_used' => wpfval( $body, 'credits_used' ) ?: 0,
8815 ];
8816 }
8817
8818 /**
8819 * Create bot reply post
8820 *
8821 * @param array $topic Topic data
8822 * @param array $parent_post Parent post data
8823 * @param string $content Reply content
8824 * @return int|WP_Error New post ID or WP_Error
8825 */
8826 private function create_bot_reply_post( $topic, $parent_post, $content ) {
8827 $settings = WPF()->settings->ai;
8828
8829 // Get bot user ID
8830 $bot_user_id = (int) wpfval( $settings, 'bot_reply_user_id' );
8831 if ( ! $bot_user_id ) {
8832 return new \WP_Error( 'no_bot_user', wpforo_phrase( 'Bot user not configured', false ) );
8833 }
8834
8835 // Check if bot user exists
8836 $bot_user = get_user_by( 'ID', $bot_user_id );
8837 if ( ! $bot_user ) {
8838 return new \WP_Error( 'invalid_bot_user', wpforo_phrase( 'Bot user does not exist', false ) );
8839 }
8840
8841 // Determine status (1 = unapproved, 0 = approved)
8842 $status = wpfval( $settings, 'bot_reply_unapproved' ) ? 1 : 0;
8843
8844 // Build post data
8845 $post_data = [
8846 'forumid' => (int) $topic['forumid'],
8847 'topicid' => (int) $topic['topicid'],
8848 'parentid' => (int) $parent_post['postid'],
8849 'userid' => $bot_user_id,
8850 'title' => wpforo_phrase( 'RE', false ) . ': ' . $topic['title'],
8851 'body' => $content,
8852 'status' => $status,
8853 'is_bot_reply' => true,
8854 ];
8855
8856 // Store current user to restore later
8857 $current_user_id = get_current_user_id();
8858
8859 // Temporarily switch to bot user
8860 wp_set_current_user( $bot_user_id );
8861 WPF()->current_userid = $bot_user_id;
8862
8863 // Create the post using wpForo's add method
8864 $new_post_id = WPF()->post->add( $post_data );
8865
8866 // Restore original user
8867 wp_set_current_user( $current_user_id );
8868 WPF()->current_userid = $current_user_id;
8869
8870 if ( ! $new_post_id ) {
8871 return new \WP_Error( 'post_creation_failed', wpforo_phrase( 'Failed to create bot reply', false ) );
8872 }
8873
8874 return $new_post_id;
8875 }
8876
8877 /**
8878 * Check bot reply rate limits
8879 *
8880 * @param int $topic_id Topic ID
8881 * @return string|null Error message if limit exceeded, null if OK
8882 */
8883 private function check_bot_reply_limits( $topic_id ) {
8884 global $wpdb;
8885 $settings = WPF()->settings->ai;
8886
8887 $bot_user_id = (int) wpfval( $settings, 'bot_reply_user_id' );
8888 if ( ! $bot_user_id ) {
8889 return wpforo_phrase( 'Bot user not configured', false );
8890 }
8891
8892 // Check max per topic
8893 $max_per_topic = (int) wpfval( $settings, 'bot_reply_max_per_topic' );
8894 if ( $max_per_topic > 0 ) {
8895 $topic_count = $wpdb->get_var(
8896 $wpdb->prepare(
8897 "SELECT COUNT(*) FROM `" . WPF()->tables->posts . "`
8898 WHERE `topicid` = %d AND `userid` = %d",
8899 $topic_id,
8900 $bot_user_id
8901 )
8902 );
8903
8904 if ( (int) $topic_count >= $max_per_topic ) {
8905 return sprintf(
8906 wpforo_phrase( 'Maximum bot replies per topic reached (%d)', false ),
8907 $max_per_topic
8908 );
8909 }
8910 }
8911
8912 // Check max per day
8913 $max_per_day = (int) wpfval( $settings, 'bot_reply_max_per_day' );
8914 if ( $max_per_day > 0 ) {
8915 $today_start = current_time( 'Y-m-d' ) . ' 00:00:00';
8916 $day_count = $wpdb->get_var(
8917 $wpdb->prepare(
8918 "SELECT COUNT(*) FROM `" . WPF()->tables->posts . "`
8919 WHERE `userid` = %d AND `created` >= %s",
8920 $bot_user_id,
8921 $today_start
8922 )
8923 );
8924
8925 if ( (int) $day_count >= $max_per_day ) {
8926 return sprintf(
8927 wpforo_phrase( 'Maximum bot replies per day reached (%d)', false ),
8928 $max_per_day
8929 );
8930 }
8931 }
8932
8933 return null;
8934 }
8935
8936 // =========================================================================
8937 // MULTIMODAL IMAGE EXTRACTION METHODS
8938 // =========================================================================
8939
8940 /**
8941 * Check if multimodal image indexing is enabled for the current board
8942 *
8943 * Image indexing requires:
8944 * 1. Professional, Business or Enterprise plan
8945 * 2. Board-specific setting enabled (ai_image_indexing_enabled)
8946 *
8947 * Credit Impact:
8948 * - When enabled, posts with images consume +1 additional credit
8949 * - Maximum 10 images per post (enforced by API)
8950 *
8951 * @return bool True if image indexing is enabled
8952 */
8953 public function is_image_indexing_enabled() {
8954 // Check board-specific setting first (fast check)
8955 $board_setting = (bool) wpforo_get_option( 'ai_image_indexing_enabled', 0 );
8956 if ( ! $board_setting ) {
8957 return false;
8958 }
8959
8960 // Check plan eligibility using cached plan (no API calls)
8961 $plan = strtolower( $this->get_subscription_plan() );
8962
8963 // Professional, Business and Enterprise plans have image indexing
8964 return in_array( $plan, [ 'professional', 'business', 'enterprise' ], true );
8965 }
8966
8967 /**
8968 * Check if URL points to an image file
8969 *
8970 * @param string $url URL to check
8971 * @return bool True if URL has an image extension
8972 */
8973 private function is_image_url( $url ) {
8974 $path = wp_parse_url( $url, PHP_URL_PATH );
8975 if ( ! $path ) {
8976 return false;
8977 }
8978 $ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
8979 return in_array( $ext, self::$image_extensions, true );
8980 }
8981
8982 /**
8983 * Check if document indexing is enabled for the current board
8984 *
8985 * Requires both:
8986 * 1. Professional+ subscription plan
8987 * 2. Board-specific setting enabled (ai_document_indexing_enabled)
8988 *
8989 * @return bool True if document indexing is enabled and eligible
8990 */
8991 public function is_document_indexing_enabled() {
8992 $board_setting = (bool) wpforo_get_option( 'ai_document_indexing_enabled', 0 );
8993 if ( ! $board_setting ) {
8994 return false;
8995 }
8996
8997 $plan = strtolower( $this->get_subscription_plan() );
8998
8999 return in_array( $plan, [ 'professional', 'business', 'enterprise' ], true );
9000 }
9001
9002 /**
9003 * Check if URL points to a document file
9004 *
9005 * @param string $url URL to check
9006 * @return bool True if URL has a document extension
9007 */
9008 private function is_document_url( $url ) {
9009 $path = wp_parse_url( $url, PHP_URL_PATH );
9010 if ( ! $path ) {
9011 return false;
9012 }
9013 $ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
9014 return in_array( $ext, self::$document_extensions, true );
9015 }
9016
9017 /**
9018 * Check if URL belongs to local site (not external domain)
9019 *
9020 * @param string $url URL to check
9021 * @param string $site_url Site URL for comparison (optional)
9022 * @return bool True if URL is local
9023 */
9024 private function is_local_url( $url, $site_url = null ) {
9025 if ( ! $site_url ) {
9026 $site_url = get_site_url();
9027 }
9028
9029 $site_host = wp_parse_url( $site_url, PHP_URL_HOST );
9030 $url_host = wp_parse_url( $url, PHP_URL_HOST );
9031
9032 // Relative URLs are local
9033 if ( ! $url_host ) {
9034 return true;
9035 }
9036
9037 // Exact match
9038 if ( $url_host === $site_host ) {
9039 return true;
9040 }
9041
9042 // Normalize both hosts (strip www prefix for comparison)
9043 $site_host_normalized = preg_replace( '/^www\./', '', $site_host );
9044 $url_host_normalized = preg_replace( '/^www\./', '', $url_host );
9045
9046 // Match after www normalization (example.com == www.example.com)
9047 if ( $url_host_normalized === $site_host_normalized ) {
9048 return true;
9049 }
9050
9051 // Check if URL host is a subdomain of site host (e.g., cdn.example.com for example.com)
9052 // Must end with .site_host to be a subdomain
9053 if ( substr( $url_host_normalized, -strlen( '.' . $site_host_normalized ) ) === '.' . $site_host_normalized ) {
9054 return true;
9055 }
9056
9057 return false;
9058 }
9059
9060 /**
9061 * Normalize URL to canonical form for deduplication
9062 *
9063 * Handles protocol-relative URLs, relative URLs, http→https normalization,
9064 * and query string/fragment removal.
9065 *
9066 * @param string $url URL to normalize
9067 * @param string $site_url Site URL for relative URL expansion (optional)
9068 * @return string Normalized URL, or empty string if invalid
9069 */
9070 private function normalize_url( $url, $site_url = null ) {
9071 if ( ! $site_url ) {
9072 $site_url = get_site_url();
9073 }
9074
9075 $url = trim( $url );
9076
9077 // Skip data URIs
9078 if ( strpos( $url, 'data:' ) === 0 ) {
9079 return '';
9080 }
9081
9082 // Expand relative URLs
9083 if ( strpos( $url, '/' ) === 0 && strpos( $url, '//' ) !== 0 ) {
9084 $url = rtrim( $site_url, '/' ) . $url;
9085 } elseif ( strpos( $url, '//' ) === 0 ) {
9086 // Protocol-relative URL
9087 $url = 'https:' . $url;
9088 }
9089
9090 // Normalize protocol to https
9091 $url = preg_replace( '#^http://#i', 'https://', $url );
9092
9093 // Remove query string and fragment for deduplication
9094 $url = strtok( $url, '?#' );
9095
9096 return $url;
9097 }
9098
9099 /**
9100 * Normalize image URL (delegates to normalize_url)
9101 *
9102 * @param string $url URL to normalize
9103 * @param string $site_url Site URL for relative URL expansion (optional)
9104 * @return string Normalized URL, or empty string if invalid
9105 */
9106 private function normalize_image_url( $url, $site_url = null ) {
9107 return $this->normalize_url( $url, $site_url );
9108 }
9109
9110 /**
9111 * Extract images from <img> tags in content
9112 *
9113 * @param string $content Post HTML content
9114 * @param string $site_url Site URL for validation
9115 * @return array Array of normalized image URLs
9116 */
9117 private function extract_img_tags( $content, $site_url = null ) {
9118 $images = [];
9119
9120 if ( preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $content, $matches ) ) {
9121 foreach ( $matches[1] as $src ) {
9122 $normalized = $this->normalize_image_url( $src, $site_url );
9123 if ( $normalized && $this->is_image_url( $normalized ) && $this->is_local_url( $normalized, $site_url ) ) {
9124 $images[] = $normalized;
9125 }
9126 }
9127 }
9128
9129 return $images;
9130 }
9131
9132 /**
9133 * Extract images from <a> tags in content (wpForo default attachments)
9134 *
9135 * @param string $content Post HTML content
9136 * @param string $site_url Site URL for validation
9137 * @return array Array of normalized image URLs
9138 */
9139 private function extract_anchor_images( $content, $site_url = null ) {
9140 $images = [];
9141
9142 if ( preg_match_all( '/<a[^>]+href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches ) ) {
9143 foreach ( $matches[1] as $href ) {
9144 $normalized = $this->normalize_image_url( $href, $site_url );
9145 if ( $normalized && $this->is_image_url( $normalized ) && $this->is_local_url( $normalized, $site_url ) ) {
9146 $images[] = $normalized;
9147 }
9148 }
9149 }
9150
9151 return $images;
9152 }
9153
9154 /**
9155 * Extract plain text image URLs from content
9156 *
9157 * @param string $content Post content
9158 * @param string $site_url Site URL for validation
9159 * @return array Array of normalized image URLs
9160 */
9161 private function extract_plain_urls( $content, $site_url = null ) {
9162 $images = [];
9163
9164 // Strip HTML tags first to find plain text URLs
9165 $text = wp_strip_all_tags( $content );
9166
9167 // Match URLs ending with image extensions
9168 $pattern = '#https?://[^\s<>"\']+\.(?:' . implode( '|', self::$image_extensions ) . ')#i';
9169
9170 if ( preg_match_all( $pattern, $text, $matches ) ) {
9171 foreach ( $matches[0] as $url ) {
9172 $normalized = $this->normalize_image_url( $url, $site_url );
9173 if ( $normalized && $this->is_local_url( $normalized, $site_url ) ) {
9174 $images[] = $normalized;
9175 }
9176 }
9177 }
9178
9179 return $images;
9180 }
9181
9182 /**
9183 * Extract attachment IDs from [attach] shortcodes
9184 *
9185 * @param string $content Post content with shortcodes
9186 * @return array Array of attachment IDs (integers)
9187 */
9188 private function extract_attach_ids( $content ) {
9189 $attach_ids = [];
9190
9191 // Match [attach...]ID[/attach] patterns
9192 if ( preg_match_all( '/\[attach[^\]]*\](\d+(?:,\s*\d+)*)\[\/attach\]/i', $content, $matches ) ) {
9193 foreach ( $matches[1] as $ids_string ) {
9194 $ids = array_map( 'intval', explode( ',', $ids_string ) );
9195 $attach_ids = array_merge( $attach_ids, $ids );
9196 }
9197 }
9198
9199 return array_unique( array_filter( $attach_ids ) );
9200 }
9201
9202 /**
9203 * Get image URLs from attachment IDs
9204 *
9205 * Handles missing Advanced Attachments addon gracefully.
9206 *
9207 * @param array $attach_ids Array of attachment IDs
9208 * @return array Array of image data with url and attach_id
9209 */
9210 private function get_attachment_urls( $attach_ids ) {
9211 if ( empty( $attach_ids ) ) {
9212 return [];
9213 }
9214
9215 // Check if wpForo is available
9216 if ( ! function_exists( 'WPF' ) ) {
9217 return [];
9218 }
9219
9220 // Check if Advanced Attachments addon exists
9221 if ( ! isset( WPF()->tables->attachments ) ) {
9222 return [];
9223 }
9224
9225 global $wpdb;
9226 $table = WPF()->tables->attachments;
9227
9228 // Check if table exists
9229 $table_exists = $wpdb->get_var( $wpdb->prepare(
9230 "SHOW TABLES LIKE %s",
9231 $table
9232 ) );
9233
9234 if ( ! $table_exists ) {
9235 return [];
9236 }
9237
9238 $placeholders = implode( ',', array_fill( 0, count( $attach_ids ), '%d' ) );
9239 $query = $wpdb->prepare(
9240 "SELECT attachid, fileurl, mime FROM {$table} WHERE attachid IN ({$placeholders})",
9241 $attach_ids
9242 );
9243
9244 $attachments = $wpdb->get_results( $query, ARRAY_A );
9245
9246 if ( ! $attachments ) {
9247 return [];
9248 }
9249
9250 $image_urls = [];
9251 foreach ( $attachments as $attach ) {
9252 // Only include image MIME types
9253 if ( isset( $attach['mime'] ) && strpos( $attach['mime'], 'image/' ) === 0 ) {
9254 $normalized = $this->normalize_image_url( $attach['fileurl'] );
9255 if ( $normalized ) {
9256 $image_urls[] = [
9257 'attach_id' => (int) $attach['attachid'],
9258 'url' => $normalized,
9259 ];
9260 }
9261 }
9262 }
9263
9264 return $image_urls;
9265 }
9266
9267 /**
9268 * Get document URLs from attachment IDs
9269 *
9270 * Filters attachments by document MIME types (application/*, text/*).
9271 * Validates that the file extension matches supported document formats.
9272 *
9273 * @param array $attach_ids Array of attachment IDs
9274 * @return array Array of document data with url and attach_id
9275 */
9276 private function get_attachment_document_urls( $attach_ids ) {
9277 if ( empty( $attach_ids ) ) {
9278 return [];
9279 }
9280
9281 if ( ! function_exists( 'WPF' ) ) {
9282 return [];
9283 }
9284
9285 if ( ! isset( WPF()->tables->attachments ) ) {
9286 return [];
9287 }
9288
9289 global $wpdb;
9290 $table = WPF()->tables->attachments;
9291
9292 $table_exists = $wpdb->get_var( $wpdb->prepare(
9293 "SHOW TABLES LIKE %s",
9294 $table
9295 ) );
9296
9297 if ( ! $table_exists ) {
9298 return [];
9299 }
9300
9301 $placeholders = implode( ',', array_fill( 0, count( $attach_ids ), '%d' ) );
9302 $query = $wpdb->prepare(
9303 "SELECT attachid, fileurl, mime FROM {$table} WHERE attachid IN ({$placeholders})",
9304 $attach_ids
9305 );
9306
9307 $attachments = $wpdb->get_results( $query, ARRAY_A );
9308
9309 if ( ! $attachments ) {
9310 return [];
9311 }
9312
9313 $doc_urls = [];
9314 foreach ( $attachments as $attach ) {
9315 $mime = $attach['mime'] ?? '';
9316 // Include application/* and text/* MIME types (PDFs, DOCX, TXT, etc.)
9317 if ( strpos( $mime, 'application/' ) === 0 || strpos( $mime, 'text/' ) === 0 ) {
9318 $normalized = $this->normalize_url( $attach['fileurl'] );
9319 if ( $normalized && $this->is_document_url( $normalized ) ) {
9320 $doc_urls[] = [
9321 'attach_id' => (int) $attach['attachid'],
9322 'url' => $normalized,
9323 ];
9324 }
9325 }
9326 }
9327
9328 return $doc_urls;
9329 }
9330
9331 /**
9332 * Extract ALL images from post content with deduplication
9333 *
9334 * Handles all 4 image source types:
9335 * 1. <img> tags
9336 * 2. <a> tags (wpForo default attachments)
9337 * 3. Plain text URLs
9338 * 4. [attach] shortcodes (Advanced Attachments addon)
9339 *
9340 * @param string $content Post body content
9341 * @return array Array of unique image data
9342 */
9343 public function extract_post_images( $content ) {
9344 if ( empty( $content ) ) {
9345 return [];
9346 }
9347
9348 $site_url = get_site_url();
9349
9350 // Track URLs for deduplication (normalized URL => image data)
9351 $url_map = [];
9352
9353 // 1. Extract from <img> tags
9354 foreach ( $this->extract_img_tags( $content, $site_url ) as $url ) {
9355 if ( ! isset( $url_map[ $url ] ) ) {
9356 $url_map[ $url ] = [
9357 'type' => 'img_tag',
9358 'url' => $url,
9359 'attach_id' => null,
9360 ];
9361 }
9362 }
9363
9364 // 2. Extract from <a> tags (default wpForo attachments)
9365 foreach ( $this->extract_anchor_images( $content, $site_url ) as $url ) {
9366 if ( ! isset( $url_map[ $url ] ) ) {
9367 $url_map[ $url ] = [
9368 'type' => 'anchor_link',
9369 'url' => $url,
9370 'attach_id' => null,
9371 ];
9372 }
9373 }
9374
9375 // 3. Extract plain text URLs
9376 foreach ( $this->extract_plain_urls( $content, $site_url ) as $url ) {
9377 if ( ! isset( $url_map[ $url ] ) ) {
9378 $url_map[ $url ] = [
9379 'type' => 'plain_url',
9380 'url' => $url,
9381 'attach_id' => null,
9382 ];
9383 }
9384 }
9385
9386 // 4. Extract [attach] shortcode images (if addon exists)
9387 $attach_ids = $this->extract_attach_ids( $content );
9388 if ( ! empty( $attach_ids ) ) {
9389 $attach_images = $this->get_attachment_urls( $attach_ids );
9390 foreach ( $attach_images as $attach ) {
9391 $url = $attach['url'];
9392 if ( ! isset( $url_map[ $url ] ) ) {
9393 $url_map[ $url ] = [
9394 'type' => 'shortcode',
9395 'url' => $url,
9396 'attach_id' => $attach['attach_id'],
9397 ];
9398 } else {
9399 // URL already exists from another source, add attach_id
9400 $url_map[ $url ]['attach_id'] = $attach['attach_id'];
9401 }
9402 }
9403 }
9404
9405 // Return deduplicated images as array
9406 return array_values( $url_map );
9407 }
9408
9409 /**
9410 * Extract ALL documents from post content with deduplication
9411 *
9412 * Handles 3 document source types:
9413 * 1. <a> tags with href pointing to document files (linked PDFs, DOCX, etc.)
9414 * 2. Plain text URLs ending in document extensions
9415 * 3. [attach] shortcodes resolving to document attachments
9416 *
9417 * @param string $content Post body content
9418 * @return array Array of unique document data: [['type' => '...', 'url' => '...', 'attach_id' => ...], ...]
9419 */
9420 public function extract_post_documents( $content ) {
9421 if ( empty( $content ) ) {
9422 return [];
9423 }
9424
9425 $site_url = get_site_url();
9426 $url_map = [];
9427
9428 // 1. Extract from <a> tags (most common - linked PDFs)
9429 if ( preg_match_all( '/<a[^>]+href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches ) ) {
9430 foreach ( $matches[1] as $href ) {
9431 $normalized = $this->normalize_url( $href, $site_url );
9432 if ( $normalized && $this->is_document_url( $normalized ) && $this->is_local_url( $normalized, $site_url ) ) {
9433 $url_map[ $normalized ] = [
9434 'type' => 'anchor_link',
9435 'url' => $normalized,
9436 'attach_id' => null,
9437 ];
9438 }
9439 }
9440 }
9441
9442 // 2. Extract plain text document URLs
9443 $doc_ext_pattern = implode( '|', self::$document_extensions );
9444 if ( preg_match_all( '#https?://[^\s<>"\']+\.(?:' . $doc_ext_pattern . ')#i', $content, $matches ) ) {
9445 foreach ( $matches[0] as $url ) {
9446 $normalized = $this->normalize_url( $url, $site_url );
9447 if ( $normalized && $this->is_local_url( $normalized, $site_url ) && ! isset( $url_map[ $normalized ] ) ) {
9448 $url_map[ $normalized ] = [
9449 'type' => 'plain_url',
9450 'url' => $normalized,
9451 'attach_id' => null,
9452 ];
9453 }
9454 }
9455 }
9456
9457 // 3. Extract [attach] shortcode documents (if addon exists)
9458 $attach_ids = $this->extract_attach_ids( $content );
9459 if ( ! empty( $attach_ids ) ) {
9460 $attach_docs = $this->get_attachment_document_urls( $attach_ids );
9461 foreach ( $attach_docs as $doc ) {
9462 $url = $doc['url'];
9463 if ( ! isset( $url_map[ $url ] ) ) {
9464 $url_map[ $url ] = [
9465 'type' => 'shortcode',
9466 'url' => $url,
9467 'attach_id' => $doc['attach_id'],
9468 ];
9469 } else {
9470 $url_map[ $url ]['attach_id'] = $doc['attach_id'];
9471 }
9472 }
9473 }
9474
9475 return array_values( $url_map );
9476 }
9477 }
9478