PluginProbe
wpForo Forum / 3.1.6
wpForo Forum v3.1.6
3.1.6 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 All 138 releases
← All changes | classes/AIClient.php +670 -9 3.0.83.1.6 View file →
@@ -66,8 +66,9 @@
66 66
67 67 // Register admin-only AJAX handlers
68 68 if ( is_admin() ) {
69 69 add_action( 'wp_ajax_wpforo_ai_get_rag_status', [ $this, 'ajax_get_rag_status' ] );
70 + add_action( 'wp_ajax_wpforo_ai_get_indexing_breakdown', [ $this, 'ajax_get_indexing_breakdown' ] );
70 71 add_action( 'wp_ajax_wpforo_ai_cancel_cloud_indexing', [ $this, 'ajax_cancel_cloud_indexing' ] );
71 72 add_action( 'wp_ajax_wpforo_ai_cleanup_indexing_session', [ $this, 'ajax_cleanup_indexing_session' ] );
72 73 add_action( 'wp_ajax_wpforo_ai_action', [ $this, 'ajax_generic_action' ] );
73 74 add_action( 'wp_ajax_wpforo_ai_save_storage_mode', [ $this, 'ajax_save_storage_mode' ] );
@@ -84,8 +85,16 @@
84 85 add_action( 'wp_ajax_wpforo_ai_activate_paddle_transaction', [ $this, 'ajax_activate_paddle_transaction' ] );
85 86 add_action( 'wp_ajax_wpforo_ai_search_bot_users', [ $this, 'ajax_search_bot_users' ] );
86 87 add_action( 'wp_ajax_wpforo_ai_request_bonus_credits', [ $this, 'ajax_request_bonus_credits' ] );
87 88
89 + // Custom Knowledge AJAX handlers
90 + add_action( 'wp_ajax_wpforo_ai_add_knowledge', [ $this, 'ajax_add_knowledge' ] );
91 + add_action( 'wp_ajax_wpforo_ai_delete_knowledge', [ $this, 'ajax_delete_knowledge' ] );
92 + add_action( 'wp_ajax_wpforo_ai_save_knowledge_settings', [ $this, 'ajax_save_knowledge_priorities' ] );
93 + add_action( 'wp_ajax_wpforo_ai_get_knowledge_settings', [ $this, 'ajax_get_knowledge_settings' ] );
94 + add_action( 'wp_ajax_wpforo_ai_get_knowledge_files', [ $this, 'ajax_get_knowledge_files' ] );
95 + add_action( 'wp_ajax_wpforo_ai_get_job_status', [ $this, 'ajax_get_job_status' ] );
96 +
88 97 // Register privacy policy content for AI features
89 98 add_action( 'admin_init', [ $this, 'register_privacy_policy_content' ] );
90 99
91 100 // Self-heal stalled WP-Cron on wpForo AI admin page loads.
@@ -182,11 +191,88 @@
182 191 add_filter( 'wpforo_form_fields', [ $this, 'add_ai_suggestions_after_title' ] );
183 192
184 193 // Disable built-in wpForo topic suggestions when AI Topic Suggestions is enabled
185 194 add_filter( 'wpforo_topic_suggestion', [ $this, 'filter_built_in_suggestions' ] );
195 +
196 + // Cron lifecycle: schedule/unschedule the recurring AI maintenance crons
197 + // based on AI service connection state. Prevents stale events from
198 + // piling up in `wp_options.cron` on installs that never enabled AI.
199 + add_action( 'wpforo_ai_tenant_registered', [ $this, 'register_ai_crons' ] );
200 + add_action( 'wpforo_ai_tenant_disconnected', [ $this, 'unregister_ai_crons' ] );
186 201 }
187 202
188 203 /**
204 + * Schedule all recurring AI-related crons.
205 + *
206 + * Called on tenant connect (wpforo_ai_tenant_registered). Idempotent —
207 + * each helper skips if already scheduled. Safe to call from plugin
208 + * upgrade migrations via sync_cron_state().
209 + */
210 + public function register_ai_crons() {
211 + $this->schedule_cache_cleanup();
212 + $this->schedule_daily_subscription_sync();
213 +
214 + if ( isset( WPF()->ai_content_moderation ) && WPF()->ai_content_moderation ) {
215 + WPF()->ai_content_moderation->schedule_moderation_cleanup();
216 + }
217 + if ( isset( WPF()->vector_storage ) && WPF()->vector_storage ) {
218 + WPF()->vector_storage->schedule_cron_jobs();
219 + }
220 + if ( isset( WPF()->task_manager ) && WPF()->task_manager ) {
221 + WPF()->task_manager->schedule_cron_jobs();
222 + }
223 +
224 + $this->log_info( 'ai_crons_registered' );
225 + }
226 +
227 + /**
228 + * Unschedule every recurring AI-related cron.
229 + *
230 + * Called on tenant disconnect (wpforo_ai_tenant_disconnected) and on
231 + * plugin upgrade when not connected, so users who never enabled AI (or
232 + * who disconnected) do not see stale events accumulating in wp_cron.
233 + *
234 + * Single-event crons (wpforo_ai_execute_task[_for_topic], _process_batch,
235 + * _process_queue*, _process_wp_batch) are not blanket-cleared here — they
236 + * are managed per-task on the AI side and never get scheduled for users
237 + * who do not use AI features.
238 + */
239 + public function unregister_ai_crons() {
240 + $this->unschedule_cache_cleanup();
241 + $this->unschedule_daily_subscription_sync();
242 + $this->unschedule_pending_topics_indexing();
243 +
244 + if ( isset( WPF()->ai_content_moderation ) && WPF()->ai_content_moderation ) {
245 + WPF()->ai_content_moderation->unschedule_moderation_cleanup();
246 + }
247 + if ( isset( WPF()->vector_storage ) && WPF()->vector_storage ) {
248 + WPF()->vector_storage->unschedule_cron_jobs();
249 + }
250 + if ( isset( WPF()->task_manager ) && WPF()->task_manager ) {
251 + WPF()->task_manager->unschedule_cron_jobs();
252 + }
253 +
254 + // AILogs cleanup is scheduled lazily on first log insert; clear it
255 + // too so the wp_cron option stays clean for users who never reconnect.
256 + wp_clear_scheduled_hook( 'wpforo_ai_logs_cleanup' );
257 +
258 + $this->log_info( 'ai_crons_unregistered' );
259 + }
260 +
261 + /**
262 + * Idempotent reconciler: ensures the AI cron set matches the current
263 + * connection state. Called from plugin upgrade so existing installs that
264 + * accumulated AI crons without ever connecting get cleaned up.
265 + */
266 + public function sync_cron_state() {
267 + if ( $this->is_connected() ) {
268 + $this->register_ai_crons();
269 + } else {
270 + $this->unregister_ai_crons();
271 + }
272 + }
273 +
274 + /**
189 275 * Get API base URL
190 276 *
191 277 * @return string API base URL
192 278 */
@@ -484,9 +570,9 @@
484 570 update_option( 'wpforo_ai_payment_providers', array_map( 'sanitize_text_field', $subscription['payment_providers'] ) );
485 571 }
486 572
487 573 // Store last sync time for debugging
488 - $this->update_global_option( 'ai_subscription_synced_at', current_time( 'mysql' ) );
574 + $this->update_global_option( 'ai_subscription_synced_at', current_time( 'mysql', true ) );
489 575 }
490 576
491 577 /**
492 578 * Get cached subscription plan
@@ -616,8 +702,9 @@
616 702 'wordpress_content_indexing' => [ 'plan' => 'business' ],
617 703 'custom_post_types_indexing' => [ 'plan' => 'business' ],
618 704 'woocommerce_products_indexing' => [ 'plan' => 'business' ],
619 705 'vector_db_cloud_storage' => [ 'plan' => 'business' ],
706 + 'custom_knowledge' => [ 'plan' => 'business' ],
620 707
621 708 // Enterprise Plan Features
622 709 'developer_features' => [ 'plan' => 'enterprise' ],
623 710 'rest_api_access' => [ 'plan' => 'enterprise' ],
@@ -783,9 +870,9 @@
783 870 // which would skip saving when credits_added is 0 (e.g., due to cap enforcement)
784 871 if ( ! empty( $response['success'] ) && isset( $response['credits_added'] ) ) {
785 872 update_option( 'wpforo_ai_bonus_credits_claimed', true );
786 873 update_option( 'wpforo_ai_bonus_credits_amount', (int) $response['credits_added'] );
787 - update_option( 'wpforo_ai_bonus_credits_claimed_at', current_time( 'mysql' ) );
874 + update_option( 'wpforo_ai_bonus_credits_claimed_at', current_time( 'mysql', true ) );
788 875
789 876 $this->log_info( 'bonus_credits_granted', [
790 877 'credits_added' => $response['credits_added'],
791 878 ] );
@@ -926,8 +1013,25 @@
926 1013 $this->send_success( $status );
927 1014 }
928 1015
929 1016 /**
1017 + * AJAX handler for getting indexing status breakdown (private/unapproved counts)
1018 + *
1019 + * Returns cached breakdown data (1-day TTL) for displaying excluded topics info.
1020 + * Loaded asynchronously after page load to avoid slow initial page renders.
1021 + *
1022 + * @return void
1023 + */
1024 + public function ajax_get_indexing_breakdown() {
1025 + $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', 'nonce' );
1026 +
1027 + $storage_manager = WPF()->vector_storage;
1028 + $breakdown = $storage_manager->get_indexing_status_breakdown();
1029 +
1030 + $this->send_success( $breakdown );
1031 + }
1032 +
1033 + /**
930 1034 * Ask the backend to stop any in-flight cloud indexing for this tenant.
931 1035 *
932 1036 * Sends POST /v1/rag/cancel, which sets a cancellation flag on the
933 1037 * tenant record with a ~10 minute TTL. The backend reads the flag
@@ -937,9 +1041,11 @@
937 1041 *
938 1042 * @return array|WP_Error Backend response or error object
939 1043 */
940 1044 public function cancel_indexing() {
941 - return $this->post( '/rag/cancel', [], 30 );
1045 + // Note: /rag/cancel was replaced by /rag/cleanup-jobs in backend
1046 + // Both set indexing_cancel_until flag, cleanup-jobs also finalizes stuck jobs
1047 + return $this->post( '/rag/cleanup-jobs', [], 30 );
942 1048 }
943 1049
944 1050 /**
945 1051 * Cleanup stuck cloud indexing jobs (backend).
@@ -1062,8 +1168,11 @@
1062 1168 if ( delete_transient( 'wpforo_ai_rag_status_' . $board_id ) ) {
1063 1169 $summary['transients_deleted']++;
1064 1170 }
1065 1171
1172 + // Clear indexing breakdown cache so UI shows fresh counts
1173 + WPF()->vector_storage->clear_indexing_status_breakdown_cache();
1174 +
1066 1175 // Tell the backend to drop any in-flight cloud image_worker items.
1067 1176 // Safe in local mode: backend simply sets indexing_cancel_until on
1068 1177 // the tenant record with no other side effects. Errors are
1069 1178 // non-fatal — WP-side cleanup has already succeeded.
@@ -2786,8 +2895,16 @@
2786 2895 if ( $min_score_setting > 0 ) {
2787 2896 $data['min_score'] = $min_score_setting / 100; // Convert percentage to 0-1
2788 2897 }
2789 2898
2899 + // Add custom knowledge parameters (Business+ plans)
2900 + if ( $this->is_custom_knowledge_enabled() ) {
2901 + $data['include_custom_knowledge'] = true;
2902 + $data['knowledge_priority'] = [
2903 + 'search_priority' => $this->get_knowledge_priorities( 'search' ),
2904 + ];
2905 + }
2906 +
2790 2907 $response = $this->post( '/search/semantic', $data );
2791 2908
2792 2909 if ( is_wp_error( $response ) ) {
2793 2910 $this->log_error( 'semantic_search_failed', $response->get_error_message() );
@@ -3479,8 +3596,11 @@
3479 3596
3480 3597 $wp_post = get_post( (int) $wp_post_id );
3481 3598 if ( ! $wp_post || $wp_post->post_status !== 'publish' ) continue;
3482 3599
3600 + // Skip password-protected posts unless user has entered the password
3601 + if ( post_password_required( $wp_post ) ) continue;
3602 +
3483 3603 // Use real WP post_type (post, page, product, etc.)
3484 3604 $post_type_obj = get_post_type_object( $wp_post->post_type );
3485 3605 $post_type_label = $post_type_obj ? $post_type_obj->labels->singular_name : ucfirst( $wp_post->post_type );
3486 3606
@@ -3522,8 +3642,47 @@
3522 3642 'author_url' => '',
3523 3643 'created' => $wp_post->post_date ? date( 'Y-m-d H:i', strtotime( $wp_post->post_date ) ) : '',
3524 3644 'created_ago' => $wp_post->post_date ? human_time_diff( strtotime( $wp_post->post_date ) ) . ' ago' : '',
3525 3645 ];
3646 + } elseif ( $content_source === 'custom_knowledge' ) {
3647 + // ── Custom Knowledge result (Business+ cloud mode only) ──
3648 + $title = wpfval( $result, 'title' ) ?: wpfval( $result, 'metadata', 'title' ) ?: wpforo_phrase( 'Knowledge Base', false );
3649 + $content = wpfval( $result, 'excerpt' ) ?: wpfval( $result, 'metadata', 'content_preview' ) ?: '';
3650 + $content = $this->clean_content_for_search_display( $content );
3651 +
3652 + $score = wpfval( $result, 'score' ) ?: 0;
3653 + $score_percent = round( $score * 100 );
3654 +
3655 + if ( $min_score_percent > 0 && $score_percent < $min_score_percent ) {
3656 + continue;
3657 + }
3658 +
3659 + if ( $score_percent >= $threshold_excellent ) {
3660 + $relevance_label = wpforo_phrase( 'Excellent match', false );
3661 + } elseif ( $score_percent >= $threshold_good ) {
3662 + $relevance_label = wpforo_phrase( 'Good match', false );
3663 + } elseif ( $score_percent >= $threshold_relevant ) {
3664 + $relevance_label = wpforo_phrase( 'Relevant', false );
3665 + } else {
3666 + $relevance_label = wpforo_phrase( 'Possibly relevant', false );
3667 + }
3668 +
3669 + $enriched_results[] = [
3670 + 'title' => $title,
3671 + 'url' => '', // Custom knowledge has no URL
3672 + 'content' => $content,
3673 + 'score' => $score_percent,
3674 + 'relevance_label' => $relevance_label,
3675 + 'content_source' => 'custom_knowledge',
3676 + 'post_type_label' => wpforo_phrase( 'Knowledge Base', false ),
3677 + 'post_id' => 0,
3678 + 'forum_title' => '',
3679 + 'forum_url' => '',
3680 + 'author_name' => '',
3681 + 'author_url' => '',
3682 + 'created' => '',
3683 + 'created_ago' => '',
3684 + ];
3526 3685 } else {
3527 3686 // ── Forum result ──
3528 3687 $topic_id = wpfval( $result, 'topic_id' ) ?: wpfval( $result, 'metadata', 'thread_id' );
3529 3688
@@ -3716,8 +3875,21 @@
3716 3875
3717 3876 // Update total to reflect filtered results count
3718 3877 $filtered_total = count( $enriched_results );
3719 3878
3879 + // Sanitize AI enhancement output to prevent XSS
3880 + if ( $ai_enhancement ) {
3881 + if ( isset( $ai_enhancement['summary'] ) ) {
3882 + $ai_enhancement['summary'] = wpforo_kses( (string) $ai_enhancement['summary'] );
3883 + }
3884 + if ( isset( $ai_enhancement['quick_answer'] ) ) {
3885 + $ai_enhancement['quick_answer'] = wpforo_kses( (string) $ai_enhancement['quick_answer'] );
3886 + }
3887 + if ( isset( $ai_enhancement['recommendations_html'] ) ) {
3888 + $ai_enhancement['recommendations_html'] = wpforo_kses( (string) $ai_enhancement['recommendations_html'] );
3889 + }
3890 + }
3891 +
3720 3892 // Return enriched results with AI enhancement
3721 3893 wp_send_json_success( [
3722 3894 'results' => $enriched_results,
3723 3895 'total' => $filtered_total,
@@ -4380,8 +4552,10 @@
4380 4552 if ( $updated > 0 ) {
4381 4553 $this->log_info( 'topics_indexed_hash_updated', [ 'count' => $updated ] );
4382 4554 // Clear topic cache to reflect indexed status
4383 4555 wpforo_clean_cache( 'topic' );
4556 + // Clear indexing stats cache for fresh counts in UI
4557 + WPF()->vector_storage->clear_indexing_stats_cache();
4384 4558 }
4385 4559
4386 4560 return (int) $updated;
4387 4561 }
@@ -6925,8 +7099,15 @@
6925 7099 'message' => wpforo_phrase( 'Post not found', false )
6926 7100 ], 404 );
6927 7101 }
6928 7102
7103 + // SECURITY: Check if user can view this post before allowing translation
7104 + if ( ! WPF()->post->view_access( $post ) ) {
7105 + wp_send_json_error( [
7106 + 'message' => wpforo_phrase( 'You do not have permission to view this content', false )
7107 + ], 403 );
7108 + }
7109 +
6929 7110 // Get the rendered HTML content using output buffering
6930 7111 // (wpforo_content echoes instead of returning)
6931 7112 ob_start();
6932 7113 wpforo_content( $post );
@@ -6956,10 +7137,11 @@
6956 7137 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
6957 7138 ] );
6958 7139 }
6959 7140 // Return cached translation (no credits used)
7141 + // Sanitize AI output to prevent XSS - wpforo_kses allows all post-safe HTML tags
6960 7142 wp_send_json_success( [
6961 - 'translated_content' => wpfval( $cached_result, 'translated_content' ) ?: '',
7143 + 'translated_content' => wpforo_kses( (string) wpfval( $cached_result, 'translated_content' ) ),
6962 7144 'source_language' => wpfval( $cached_result, 'source_language' ) ?: 'auto',
6963 7145 'target_language' => $target_language,
6964 7146 'credits_used' => 0,
6965 7147 'cached' => true,
@@ -7015,10 +7197,11 @@
7015 7197 ];
7016 7198 $this->set_ai_cache( self::CACHE_TYPE_TRANSLATE, $cache_key, $cache_data, 0, $post_id );
7017 7199
7018 7200 // Return translated content
7201 + // Sanitize AI output to prevent XSS - wpforo_kses allows all post-safe HTML tags
7019 7202 wp_send_json_success( [
7020 - 'translated_content' => wpfval( $result, 'translated_content' ) ?: '',
7203 + 'translated_content' => wpforo_kses( (string) wpfval( $result, 'translated_content' ) ),
7021 7204 'source_language' => wpfval( $result, 'source_language' ) ?: 'auto',
7022 7205 'target_language' => $target_language,
7023 7206 'credits_used' => $credits_used,
7024 7207 'cached' => false,
@@ -7178,8 +7361,15 @@
7178 7361 'message' => wpforo_phrase( 'Topic not found', false )
7179 7362 ], 404 );
7180 7363 }
7181 7364
7365 + // SECURITY: Check if user can view this topic before allowing summarization
7366 + if ( ! WPF()->topic->view_access( $topic ) ) {
7367 + wp_send_json_error( [
7368 + 'message' => wpforo_phrase( 'You do not have permission to view this content', false )
7369 + ], 403 );
7370 + }
7371 +
7182 7372 // Get summary style from settings or request
7183 7373 $style = sanitize_text_field( wpfval( $_POST, 'style' ) );
7184 7374 if ( empty( $style ) ) {
7185 7375 $style = wpfval( WPF()->settings->ai, 'topic_summary_style' ) ?: 'detailed';
@@ -7288,13 +7478,14 @@
7288 7478 }
7289 7479
7290 7480 // Return cached summary (no credits used)
7291 7481 // Process link markers to convert [[#POST_ID]] to clickable links
7292 - $cached_summary = wpfval( $cached_result, 'summary' ) ?: '';
7482 + $cached_summary = (string) wpfval( $cached_result, 'summary' );
7293 7483 $cached_summary = $this->replace_summary_link_markers( $cached_summary, $topicid );
7294 7484
7485 + // Sanitize AI output to prevent XSS - wpforo_kses allows all post-safe HTML tags
7295 7486 wp_send_json_success( [
7296 - 'summary' => $cached_summary,
7487 + 'summary' => wpforo_kses( $cached_summary ),
7297 7488 'style' => wpfval( $cached_result, 'style' ) ?: $style,
7298 7489 'topic_id' => $topicid,
7299 7490 'reply_count' => $reply_count,
7300 7491 'total_posts_count' => $total_posts_count,
@@ -7333,9 +7524,9 @@
7333 7524 ], 500 );
7334 7525 }
7335 7526
7336 7527 // Get raw summary and store in cache (keep raw with link markers for re-processing)
7337 - $raw_summary = wpfval( $result, 'summary' ) ?: '';
7528 + $raw_summary = (string) wpfval( $result, 'summary' );
7338 7529 // Strip markdown code fence wrappers (```html ... ```) that LLMs sometimes add around HTML output
7339 7530 $raw_summary = preg_replace( '/^\s*```\w*\s*\n([\s\S]*?)\n\s*```\s*$/s', '$1', $raw_summary );
7340 7531 $credits_used = wpfval( $result, 'credits_used' ) ?: 1;
7341 7532
@@ -7366,10 +7557,11 @@
7366 7557 // Process link markers to convert [[#POST_ID]] to clickable links
7367 7558 $processed_summary = $this->replace_summary_link_markers( $raw_summary, $topicid );
7368 7559
7369 7560 // Return summary with clickable links
7561 + // Sanitize AI output to prevent XSS - wpforo_kses allows all post-safe HTML tags
7370 7562 wp_send_json_success( [
7371 - 'summary' => $processed_summary,
7563 + 'summary' => wpforo_kses( $processed_summary ),
7372 7564 'style' => wpfval( $result, 'style' ) ?: $style,
7373 7565 'topic_id' => $topicid,
7374 7566 'reply_count' => $reply_count,
7375 7567 'total_posts_count' => $total_posts_count,
@@ -7620,8 +7812,17 @@
7620 7812 if ( $accessible_forumids !== null ) {
7621 7813 $payload['accessible_forumids'] = $accessible_forumids;
7622 7814 }
7623 7815
7816 + // Add custom knowledge parameters (Business+ plans)
7817 + if ( $this->is_custom_knowledge_enabled() ) {
7818 + $payload['include_custom_knowledge'] = true;
7819 + $payload['knowledge_priority'] = [
7820 + 'search_priority' => $this->get_knowledge_priorities( 'search' ),
7821 + 'bot_reply_priority' => $this->get_knowledge_priorities( 'bot_reply' ),
7822 + ];
7823 + }
7824 +
7624 7825 // Make API request to suggestions endpoint
7625 7826 $response = $this->post( '/suggestions/suggest', $payload );
7626 7827
7627 7828 if ( is_wp_error( $response ) ) {
@@ -8766,8 +8967,16 @@
8766 8967 'knowledge_source' => $knowledge_source,
8767 8968 'response_language' => $response_language,
8768 8969 ];
8769 8970
8971 + // Add custom knowledge params if enabled (Business+ only, cloud storage only)
8972 + if ( $this->is_custom_knowledge_enabled() ) {
8973 + $request_body['include_custom_knowledge'] = true;
8974 + $request_body['knowledge_priority'] = [
8975 + 'bot_reply_priority' => $this->get_knowledge_priorities( 'bot_reply' ),
8976 + ];
8977 + }
8978 +
8770 8979 // Make API request to /tasks/generate endpoint
8771 8980 $response = wp_remote_post( $this->api_base_url . '/tasks/generate', [
8772 8981 'timeout' => 60,
8773 8982 'headers' => [
@@ -9468,6 +9677,458 @@
9468 9677 }
9469 9678 }
9470 9679
9471 9680 return array_values( $url_map );
9681 + }
9682 +
9683 + // =========================================================================
9684 + // CUSTOM KNOWLEDGE AJAX HANDLERS
9685 + // =========================================================================
9686 +
9687 + /**
9688 + * AJAX handler for adding custom knowledge
9689 + *
9690 + * Sends file URL to backend for processing and indexing.
9691 + * Endpoint: POST /v1/knowledge/ingest
9692 + */
9693 + public function ajax_add_knowledge() {
9694 + check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
9695 +
9696 + if ( ! current_user_can( 'manage_options' ) ) {
9697 + wp_send_json_error( [
9698 + 'message' => wpforo_phrase( 'Insufficient permissions', false )
9699 + ], 403 );
9700 + }
9701 +
9702 + if ( ! $this->is_feature_available( 'custom_knowledge' ) ) {
9703 + wp_send_json_error( [
9704 + 'message' => wpforo_phrase( 'Custom knowledge requires Business plan or higher', false )
9705 + ], 403 );
9706 + }
9707 +
9708 + $file_url = isset( $_POST['file_url'] ) ? esc_url_raw( trim( $_POST['file_url'] ) ) : '';
9709 + $file_type = isset( $_POST['file_type'] ) ? sanitize_key( $_POST['file_type'] ) : 'text';
9710 + $file_name = isset( $_POST['file_name'] ) ? sanitize_text_field( trim( $_POST['file_name'] ) ) : '';
9711 +
9712 + if ( empty( $file_url ) ) {
9713 + wp_send_json_error( [
9714 + 'message' => wpforo_phrase( 'File URL is required', false )
9715 + ], 400 );
9716 + }
9717 +
9718 + $valid_types = [ 'json', 'markdown', 'text', 'pdf' ];
9719 + if ( ! in_array( $file_type, $valid_types, true ) ) {
9720 + $file_type = 'text';
9721 + }
9722 +
9723 + // Build request body - backend expects 'name' not 'file_name'
9724 + $request_body = [
9725 + 'file_url' => $file_url,
9726 + 'file_type' => $file_type,
9727 + ];
9728 + if ( ! empty( $file_name ) ) {
9729 + $request_body['name'] = $file_name;
9730 + }
9731 +
9732 + $response = $this->post( '/knowledge/ingest', $request_body );
9733 +
9734 + if ( is_wp_error( $response ) ) {
9735 + wp_send_json_error( [
9736 + 'message' => $response->get_error_message()
9737 + ], 400 );
9738 + }
9739 +
9740 + $this->log_info( 'knowledge_added', [
9741 + 'file_url' => $file_url,
9742 + 'file_type' => $file_type,
9743 + 'name' => $file_name
9744 + ] );
9745 +
9746 + // Merge backend response fields into success response
9747 + // JS polling expects: async, file_id, name at top level
9748 + $success_data = [
9749 + 'message' => wpforo_phrase( 'Knowledge file added. Processing will begin shortly.', false ),
9750 + ];
9751 +
9752 + // Pass through key fields from backend response
9753 + if ( is_array( $response ) ) {
9754 + if ( ! empty( $response['file_id'] ) ) {
9755 + $success_data['file_id'] = $response['file_id'];
9756 + }
9757 + if ( ! empty( $response['async'] ) ) {
9758 + $success_data['async'] = true;
9759 + }
9760 + if ( ! empty( $response['name'] ) ) {
9761 + $success_data['name'] = $response['name'];
9762 + }
9763 + if ( isset( $response['credits_remaining'] ) ) {
9764 + $success_data['credits_remaining'] = $response['credits_remaining'];
9765 + }
9766 + }
9767 +
9768 + wp_send_json_success( $success_data );
9769 + }
9770 +
9771 + /**
9772 + * AJAX handler for deleting custom knowledge
9773 + *
9774 + * Endpoint: DELETE /v1/knowledge/files/{file_id}
9775 + */
9776 + public function ajax_delete_knowledge() {
9777 + check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
9778 +
9779 + if ( ! current_user_can( 'manage_options' ) ) {
9780 + wp_send_json_error( [
9781 + 'message' => wpforo_phrase( 'Insufficient permissions', false )
9782 + ], 403 );
9783 + }
9784 +
9785 + $file_id = isset( $_POST['file_id'] ) ? sanitize_text_field( $_POST['file_id'] ) : '';
9786 +
9787 + if ( empty( $file_id ) ) {
9788 + wp_send_json_error( [
9789 + 'message' => wpforo_phrase( 'File ID is required', false )
9790 + ], 400 );
9791 + }
9792 +
9793 + $response = $this->delete( '/knowledge/files/' . urlencode( $file_id ), [], 60 );
9794 +
9795 + if ( is_wp_error( $response ) ) {
9796 + wp_send_json_error( [
9797 + 'message' => $response->get_error_message()
9798 + ], 400 );
9799 + }
9800 +
9801 + $this->log_info( 'knowledge_deleted', [
9802 + 'file_id' => $file_id
9803 + ] );
9804 +
9805 + wp_send_json_success( [
9806 + 'message' => wpforo_phrase( 'Knowledge file deleted successfully.', false )
9807 + ] );
9808 + }
9809 +
9810 + /**
9811 + * AJAX handler for checking async job status
9812 + *
9813 + * Endpoint: GET /v1/knowledge/jobs/{file_id}
9814 + * Used by polling to check if async indexing is complete
9815 + */
9816 + public function ajax_get_job_status() {
9817 + check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
9818 +
9819 + if ( ! current_user_can( 'manage_options' ) ) {
9820 + wp_send_json_error( [
9821 + 'message' => wpforo_phrase( 'Insufficient permissions', false )
9822 + ], 403 );
9823 + }
9824 +
9825 + $file_id = isset( $_POST['file_id'] ) ? sanitize_text_field( $_POST['file_id'] ) : '';
9826 +
9827 + if ( empty( $file_id ) ) {
9828 + wp_send_json_error( [
9829 + 'message' => wpforo_phrase( 'File ID is required', false )
9830 + ], 400 );
9831 + }
9832 +
9833 + $response = $this->get( '/knowledge/jobs/' . urlencode( $file_id ) );
9834 +
9835 + if ( is_wp_error( $response ) ) {
9836 + wp_send_json_error( [
9837 + 'message' => $response->get_error_message()
9838 + ], 400 );
9839 + }
9840 +
9841 + // Log completion or failure (only once per file)
9842 + $status = isset( $response['status'] ) ? $response['status'] : '';
9843 + $logged_key = 'wpforo_knowledge_logged_' . $file_id;
9844 +
9845 + if ( in_array( $status, [ 'enabled', 'failed' ], true ) && ! get_transient( $logged_key ) ) {
9846 + $file_name = isset( $response['name'] ) ? $response['name'] : $file_id;
9847 + $credits_used = isset( $response['credits_used'] ) ? (int) $response['credits_used'] : 0;
9848 + $chunk_count = isset( $response['chunk_count'] ) ? (int) $response['chunk_count'] : 0;
9849 + $error_msg = isset( $response['error_message'] ) ? $response['error_message'] : '';
9850 +
9851 + if ( $status === 'enabled' ) {
9852 + WPF()->ai_logs->log( [
9853 + 'action_type' => AILogs::ACTION_KNOWLEDGE_INDEXING,
9854 + 'credits_used' => $credits_used,
9855 + 'status' => AILogs::STATUS_SUCCESS,
9856 + 'request_summary' => 'File: ' . $file_name,
9857 + 'response_summary' => sprintf( 'Indexed %d chunks, used %d credits', $chunk_count, $credits_used ),
9858 + 'user_type' => 'admin',
9859 + ] );
9860 + } else {
9861 + WPF()->ai_logs->log( [
9862 + 'action_type' => AILogs::ACTION_KNOWLEDGE_INDEXING,
9863 + 'credits_used' => 0,
9864 + 'status' => AILogs::STATUS_ERROR,
9865 + 'request_summary' => 'File: ' . $file_name,
9866 + 'error_message' => $error_msg ?: 'Indexing failed',
9867 + 'user_type' => 'admin',
9868 + ] );
9869 + }
9870 +
9871 + // Mark as logged (expires in 1 hour - enough to prevent duplicate logs)
9872 + set_transient( $logged_key, true, HOUR_IN_SECONDS );
9873 + }
9874 +
9875 + wp_send_json_success( $response );
9876 + }
9877 +
9878 + /**
9879 + * AJAX handler for saving knowledge settings (priorities + enabled state)
9880 + *
9881 + * Settings are stored per-board in WordPress options:
9882 + * - ai_knowledge_enabled (0/1)
9883 + * - ai_knowledge_priorities (array of priorities per feature)
9884 + *
9885 + * Priorities are arrays of content sources in order:
9886 + * - Position 0 = First priority (1.3x boost)
9887 + * - Position 1 = Second priority (1.15x boost)
9888 + * - Position 2 = Third priority (1.0x - no boost)
9889 + */
9890 + public function ajax_save_knowledge_priorities() {
9891 + check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
9892 +
9893 + if ( ! current_user_can( 'manage_options' ) ) {
9894 + wp_send_json_error( [
9895 + 'message' => wpforo_phrase( 'Insufficient permissions', false )
9896 + ], 403 );
9897 + }
9898 +
9899 + // Get board ID
9900 + $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0;
9901 +
9902 + // Switch to correct board context
9903 + if ( $board_id > 0 ) {
9904 + WPF()->change_board( $board_id );
9905 + }
9906 +
9907 + // Get enabled state
9908 + $enabled = isset( $_POST['enabled'] ) ? (int) (bool) $_POST['enabled'] : 0;
9909 +
9910 + // Valid content sources
9911 + $valid_sources = [ 'custom_knowledge', 'forum', 'wordpress' ];
9912 +
9913 + // Sanitize priority arrays from POST data
9914 + $priorities = [
9915 + 'search' => $this->sanitize_priority_array(
9916 + isset( $_POST['search_priority'] ) ? $_POST['search_priority'] : [],
9917 + $valid_sources,
9918 + [ 'forum', 'wordpress', 'custom_knowledge' ]
9919 + ),
9920 + 'chat' => $this->sanitize_priority_array(
9921 + isset( $_POST['chat_priority'] ) ? $_POST['chat_priority'] : [],
9922 + $valid_sources,
9923 + [ 'custom_knowledge', 'forum', 'wordpress' ]
9924 + ),
9925 + 'bot_reply' => $this->sanitize_priority_array(
9926 + isset( $_POST['bot_reply_priority'] ) ? $_POST['bot_reply_priority'] : [],
9927 + $valid_sources,
9928 + [ 'forum', 'custom_knowledge', 'wordpress' ]
9929 + ),
9930 + ];
9931 +
9932 + // Save to WordPress options (board-specific via wpforo_update_option)
9933 + wpforo_update_option( 'ai_knowledge_enabled', $enabled );
9934 + wpforo_update_option( 'ai_knowledge_priorities', $priorities );
9935 +
9936 + $this->log_info( 'knowledge_settings_saved', [
9937 + 'board_id' => $board_id,
9938 + 'enabled' => $enabled,
9939 + 'priorities' => $priorities
9940 + ] );
9941 +
9942 + wp_send_json_success( [
9943 + 'message' => wpforo_phrase( 'Settings saved successfully.', false )
9944 + ] );
9945 + }
9946 +
9947 + /**
9948 + * Sanitize and validate priority array
9949 + *
9950 + * @param mixed $input Raw input (may be array or string)
9951 + * @param array $valid_sources Valid content source values
9952 + * @param array $default Default priority order
9953 + * @return array Sanitized array with exactly 3 valid sources
9954 + */
9955 + private function sanitize_priority_array( $input, $valid_sources, $default = null ) {
9956 + if ( $default === null ) {
9957 + $default = [ 'forum', 'wordpress', 'custom_knowledge' ];
9958 + }
9959 +
9960 + if ( ! is_array( $input ) ) {
9961 + return $default;
9962 + }
9963 +
9964 + $sanitized = [];
9965 + foreach ( $input as $source ) {
9966 + $source = sanitize_key( $source );
9967 + if ( in_array( $source, $valid_sources, true ) && ! in_array( $source, $sanitized, true ) ) {
9968 + $sanitized[] = $source;
9969 + }
9970 + }
9971 +
9972 + // Ensure we have exactly 3 unique sources
9973 + if ( count( $sanitized ) !== 3 ) {
9974 + return $default;
9975 + }
9976 +
9977 + return $sanitized;
9978 + }
9979 +
9980 + /**
9981 + * AJAX handler for getting knowledge settings for a board
9982 + */
9983 + public function ajax_get_knowledge_settings() {
9984 + check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
9985 +
9986 + if ( ! current_user_can( 'manage_options' ) ) {
9987 + wp_send_json_error( [
9988 + 'message' => wpforo_phrase( 'Insufficient permissions', false )
9989 + ], 403 );
9990 + }
9991 +
9992 + // Get board ID
9993 + $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0;
9994 +
9995 + // Switch to correct board context
9996 + if ( $board_id > 0 ) {
9997 + WPF()->change_board( $board_id );
9998 + }
9999 +
10000 + // Get settings from WordPress options (board-specific)
10001 + $enabled = (int) wpforo_get_option( 'ai_knowledge_enabled', 0 );
10002 + $priorities = wpforo_get_option( 'ai_knowledge_priorities', [] );
10003 +
10004 + // Apply defaults if not set
10005 + $default_priorities = [
10006 + 'search' => [ 'forum', 'wordpress', 'custom_knowledge' ],
10007 + 'chat' => [ 'custom_knowledge', 'forum', 'wordpress' ],
10008 + 'bot_reply' => [ 'forum', 'custom_knowledge', 'wordpress' ],
10009 + ];
10010 +
10011 + if ( empty( $priorities ) || ! is_array( $priorities ) ) {
10012 + $priorities = $default_priorities;
10013 + } else {
10014 + foreach ( $default_priorities as $feature => $default ) {
10015 + if ( ! isset( $priorities[ $feature ] ) || ! is_array( $priorities[ $feature ] ) ) {
10016 + $priorities[ $feature ] = $default;
10017 + }
10018 + }
10019 + }
10020 +
10021 + wp_send_json_success( [
10022 + 'board_id' => $board_id,
10023 + 'enabled' => $enabled,
10024 + 'priorities' => $priorities
10025 + ] );
10026 + }
10027 +
10028 + /**
10029 + * AJAX handler for getting knowledge files list
10030 + *
10031 + * Makes one API call to backend:
10032 + * - GET /v1/knowledge/files - List of indexed files
10033 + *
10034 + * Settings (enabled, priorities) are stored in WordPress per-board
10035 + * and retrieved separately via ajax_get_knowledge_settings.
10036 + *
10037 + * Returns empty data gracefully if backend is not available yet.
10038 + */
10039 + public function ajax_get_knowledge_files() {
10040 + check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
10041 +
10042 + if ( ! current_user_can( 'manage_options' ) ) {
10043 + wp_send_json_error( [
10044 + 'message' => wpforo_phrase( 'Insufficient permissions', false )
10045 + ], 403 );
10046 + }
10047 +
10048 + // Get files list from backend - return empty if not ready
10049 + $files_response = $this->get( '/knowledge/files' );
10050 + if ( is_wp_error( $files_response ) ) {
10051 + // Backend not available - return empty state
10052 + wp_send_json_success( [
10053 + 'files' => [],
10054 + 'totals' => [
10055 + 'total_files' => 0,
10056 + 'total_chunks' => 0,
10057 + 'total_credits' => 0,
10058 + ]
10059 + ] );
10060 + return;
10061 + }
10062 +
10063 + // Normalize files data - map backend field names to UI field names
10064 + $files = [];
10065 + if ( isset( $files_response['files'] ) && is_array( $files_response['files'] ) ) {
10066 + foreach ( $files_response['files'] as $file ) {
10067 + $files[] = [
10068 + 'file_id' => isset( $file['file_id'] ) ? $file['file_id'] : '',
10069 + 'name' => isset( $file['name'] ) ? $file['name'] : '',
10070 + 'url' => isset( $file['source_url'] ) ? $file['source_url'] : '',
10071 + 'type' => isset( $file['file_type'] ) ? $file['file_type'] : 'text',
10072 + 'size_bytes' => isset( $file['file_size_bytes'] ) ? (int) $file['file_size_bytes'] : 0,
10073 + 'chunks' => isset( $file['chunk_count'] ) ? (int) $file['chunk_count'] : 0,
10074 + 'credits_used' => isset( $file['credits_used'] ) ? (int) $file['credits_used'] : 0,
10075 + 'status' => isset( $file['status'] ) ? $file['status'] : 'unknown',
10076 + 'enabled' => isset( $file['status'] ) && $file['status'] === 'enabled',
10077 + 'created_at' => isset( $file['created_at'] ) ? $file['created_at'] : '',
10078 + ];
10079 + }
10080 + }
10081 +
10082 + wp_send_json_success( [
10083 + 'files' => $files,
10084 + 'totals' => [
10085 + 'total_files' => isset( $files_response['total'] ) ? (int) $files_response['total'] : count( $files ),
10086 + 'total_chunks' => isset( $files_response['total_chunks'] ) ? (int) $files_response['total_chunks'] : 0,
10087 + 'total_credits' => isset( $files_response['total_credits_used'] ) ? (int) $files_response['total_credits_used'] : 0,
10088 + ]
10089 + ] );
10090 + }
10091 +
10092 + /**
10093 + * Check if custom knowledge is enabled for the current board
10094 + *
10095 + * @return bool True if enabled
10096 + */
10097 + public function is_custom_knowledge_enabled() {
10098 + // Must have Business+ plan
10099 + if ( ! $this->is_feature_available( 'custom_knowledge' ) ) {
10100 + return false;
10101 + }
10102 +
10103 + // Custom knowledge only works in cloud storage mode
10104 + // (vectors are stored in S3 Vectors, not local WordPress DB)
10105 + if ( WPF()->vector_storage->is_local_mode() ) {
10106 + return false;
10107 + }
10108 +
10109 + // Check board-specific setting
10110 + return (bool) wpforo_get_option( 'ai_knowledge_enabled', 0 );
10111 + }
10112 +
10113 + /**
10114 + * Get custom knowledge priorities for the current board
10115 + *
10116 + * @param string $feature Feature name: 'search', 'chat', or 'bot_reply'
10117 + * @return array Priority order array
10118 + */
10119 + public function get_knowledge_priorities( $feature = 'search' ) {
10120 + $defaults = [
10121 + 'search' => [ 'forum', 'wordpress', 'custom_knowledge' ],
10122 + 'chat' => [ 'custom_knowledge', 'forum', 'wordpress' ],
10123 + 'bot_reply' => [ 'forum', 'custom_knowledge', 'wordpress' ],
10124 + ];
10125 +
10126 + $priorities = wpforo_get_option( 'ai_knowledge_priorities', [] );
10127 +
10128 + if ( isset( $priorities[ $feature ] ) && is_array( $priorities[ $feature ] ) ) {
10129 + return $priorities[ $feature ];
10130 + }
10131 +
10132 + return isset( $defaults[ $feature ] ) ? $defaults[ $feature ] : $defaults['search'];
9472 10133 }
9473 10134 }