api_base_url = WPFORO_AI_API; } // Allow API URLs to be filtered for different environments $this->api_base_url = apply_filters( 'wpforo_ai_api_base_url', $this->api_base_url ); $this->fallback_api_url = apply_filters( 'wpforo_ai_fallback_api_url', $this->fallback_api_url ); // Register admin-only AJAX handlers if ( is_admin() ) { add_action( 'wp_ajax_wpforo_ai_get_rag_status', [ $this, 'ajax_get_rag_status' ] ); add_action( 'wp_ajax_wpforo_ai_get_indexing_breakdown', [ $this, 'ajax_get_indexing_breakdown' ] ); add_action( 'wp_ajax_wpforo_ai_cancel_cloud_indexing', [ $this, 'ajax_cancel_cloud_indexing' ] ); add_action( 'wp_ajax_wpforo_ai_cleanup_indexing_session', [ $this, 'ajax_cleanup_indexing_session' ] ); add_action( 'wp_ajax_wpforo_ai_action', [ $this, 'ajax_generic_action' ] ); add_action( 'wp_ajax_wpforo_ai_save_storage_mode', [ $this, 'ajax_save_storage_mode' ] ); add_action( 'wp_ajax_wpforo_ai_save_auto_indexing', [ $this, 'ajax_save_auto_indexing' ] ); add_action( 'wp_ajax_wpforo_ai_save_image_indexing', [ $this, 'ajax_save_image_indexing' ] ); add_action( 'wp_ajax_wpforo_ai_save_document_indexing', [ $this, 'ajax_save_document_indexing' ] ); add_action( 'wp_ajax_wpforo_ai_save_wp_indexing_option', [ $this, 'ajax_save_wp_indexing_option' ] ); add_action( 'wp_ajax_wpforo_ai_get_analytics', [ $this, 'ajax_get_analytics' ] ); add_action( 'wp_ajax_wpforo_ai_run_insight', [ $this, 'ajax_run_insight' ] ); add_action( 'wp_ajax_wpforo_ai_link_subscription', [ $this, 'ajax_link_subscription' ] ); add_action( 'wp_ajax_wpforo_ai_activate_license', [ $this, 'ajax_activate_license' ] ); add_action( 'wp_ajax_wpforo_ai_paddle_checkout', [ $this, 'ajax_paddle_checkout' ] ); add_action( 'wp_ajax_wpforo_ai_link_paddle_subscription', [ $this, 'ajax_link_paddle_subscription' ] ); add_action( 'wp_ajax_wpforo_ai_activate_paddle_transaction', [ $this, 'ajax_activate_paddle_transaction' ] ); add_action( 'wp_ajax_wpforo_ai_search_bot_users', [ $this, 'ajax_search_bot_users' ] ); add_action( 'wp_ajax_wpforo_ai_request_bonus_credits', [ $this, 'ajax_request_bonus_credits' ] ); // Custom Knowledge AJAX handlers add_action( 'wp_ajax_wpforo_ai_add_knowledge', [ $this, 'ajax_add_knowledge' ] ); add_action( 'wp_ajax_wpforo_ai_delete_knowledge', [ $this, 'ajax_delete_knowledge' ] ); add_action( 'wp_ajax_wpforo_ai_save_knowledge_settings', [ $this, 'ajax_save_knowledge_priorities' ] ); add_action( 'wp_ajax_wpforo_ai_get_knowledge_settings', [ $this, 'ajax_get_knowledge_settings' ] ); add_action( 'wp_ajax_wpforo_ai_get_knowledge_files', [ $this, 'ajax_get_knowledge_files' ] ); add_action( 'wp_ajax_wpforo_ai_get_job_status', [ $this, 'ajax_get_job_status' ] ); // Register privacy policy content for AI features add_action( 'admin_init', [ $this, 'register_privacy_policy_content' ] ); // Self-heal stalled WP-Cron on wpForo AI admin page loads. // Runs once per page refresh (not on AJAX polls) to avoid any // interference with the Stop Indexing flow. Hosts where AJAX // responses are unreliable (proxies stripping bodies, etc.) // still get the nudge whenever an admin reloads the page. add_action( 'admin_init', [ $this, 'maybe_nudge_wp_cron_on_admin_page' ] ); } // Register front-end and admin AJAX handlers (for semantic search, antispam, etc.) add_action( 'wp_ajax_wpforo_ai_semantic_search', [ $this, 'ajax_semantic_search' ] ); add_action( 'wp_ajax_nopriv_wpforo_ai_semantic_search', [ $this, 'ajax_semantic_search' ] ); // Register public front-end semantic search (no admin permissions required) add_action( 'wp_ajax_wpforo_ai_public_search', [ $this, 'ajax_public_semantic_search' ] ); add_action( 'wp_ajax_nopriv_wpforo_ai_public_search', [ $this, 'ajax_public_semantic_search' ] ); // Register translation AJAX handlers (for logged-in and guest users) add_action( 'wp_ajax_wpforo_ai_translate', [ $this, 'ajax_translate_content' ] ); add_action( 'wp_ajax_nopriv_wpforo_ai_translate', [ $this, 'ajax_translate_content' ] ); // Register topic summarization AJAX handlers (for logged-in and guest users) add_action( 'wp_ajax_wpforo_ai_summarize_topic', [ $this, 'ajax_summarize_topic' ] ); add_action( 'wp_ajax_nopriv_wpforo_ai_summarize_topic', [ $this, 'ajax_summarize_topic' ] ); // Register topic suggestions AJAX handlers (for logged-in and guest users) add_action( 'wp_ajax_wpforo_ai_get_topic_suggestions', [ $this, 'ajax_get_topic_suggestions' ] ); add_action( 'wp_ajax_nopriv_wpforo_ai_get_topic_suggestions', [ $this, 'ajax_get_topic_suggestions' ] ); // Register translation button hook for post content add_action( 'wpforo_post_content_top_left', [ $this, 'render_translation_button' ] ); // Register AI Bot Reply button hook for post action buttons add_filter( 'wpforo_template_buttons_bottom', [ $this, 'render_bot_reply_button' ], 8, 5 ); // Register Suggest Reply button hook for reply form add_action( 'wpforo_editor_post_submit_button_before', [ $this, 'render_suggest_reply_button' ], 10, 3 ); // Register Bot Reply AJAX handlers (logged-in users only) add_action( 'wp_ajax_wpforo_ai_bot_reply', [ $this, 'ajax_bot_reply' ] ); add_action( 'wp_ajax_wpforo_ai_suggest_reply', [ $this, 'ajax_suggest_reply' ] ); // Register topic summarization button hook (in head-bar with subscribe button) add_action( 'wpforo_template_post_head_bar_action_links', [ $this, 'render_topic_summary_button' ], 11, 3 ); // Register topic summary container hook (after head-bar, for slide-down area) add_action( 'wpforo_template_post_head_bar', [ $this, 'render_topic_summary_container_standalone' ], 10, 3 ); // Register user AI preferences handler (logged-in users only) add_action( 'wp_ajax_wpforo_save_ai_preferences', [ $this, 'ajax_save_ai_preferences' ] ); // Register WP Cron handler for background batch processing // IMPORTANT: Must be registered unconditionally (not only in admin context) // because WP Cron runs in a separate request where is_admin() returns FALSE // Accept 3 args for backwards compatibility with old cron format add_action( 'wpforo_ai_process_batch', [ $this, 'cron_process_batch' ], 10, 3 ); // Register WP Cron handler for local indexing queue (self-rescheduling pattern) // This processes batches from the queue and reschedules itself until queue is empty add_action( 'wpforo_ai_process_queue', [ $this, 'cron_process_queue' ], 10, 1 ); // Register mode-specific WP Cron handlers for auto-indexing queues // These ensure local topics are processed with local indexing and cloud topics with cloud indexing add_action( 'wpforo_ai_process_queue_local', [ $this, 'cron_process_queue_local' ], 10, 1 ); add_action( 'wpforo_ai_process_queue_cloud', [ $this, 'cron_process_queue_cloud' ], 10, 1 ); // Register WP Cron handler for AI cache cleanup (daily) add_action( 'wpforo_ai_cache_cleanup', [ $this, 'cron_cache_cleanup' ] ); // Register WP Cron handler for daily pending topics indexing add_action( 'wpforo_ai_pending_topics_indexing', [ $this, 'cron_pending_topics_indexing' ] ); // Register WP Cron handler for daily subscription status sync add_action( 'wpforo_ai_daily_subscription_sync', [ $this, 'cron_daily_subscription_sync' ] ); // Clear translation cache and invalidate indexed status when posts change add_action( 'wpforo_after_add_post', [ $this, 'on_post_add' ], 10, 2 ); add_action( 'wpforo_after_edit_post', [ $this, 'on_post_edit' ], 10, 4 ); add_action( 'wpforo_after_delete_post', [ $this, 'on_post_delete' ], 10, 1 ); add_action( 'wpforo_post_approve', [ $this, 'on_post_approve' ], 10, 1 ); // Auto-index new approved topics and topics that get approved add_action( 'wpforo_after_add_topic', [ $this, 'on_topic_add' ], 10, 2 ); add_action( 'wpforo_topic_approve', [ $this, 'on_topic_approve' ], 10, 1 ); // Clean up embeddings when topics are deleted add_action( 'wpforo_after_delete_topic', [ $this, 'on_topic_delete' ], 10, 1 ); // Clean up embeddings when topics become private (priority 15 to run after Forums/PostMeta hooks) add_action( 'wpforo_topic_private_update', [ $this, 'on_topic_private_update' ], 15, 2 ); // Add AI suggestions panel right after title field using form fields filter add_filter( 'wpforo_form_fields', [ $this, 'add_ai_suggestions_after_title' ] ); // Disable built-in wpForo topic suggestions when AI Topic Suggestions is enabled add_filter( 'wpforo_topic_suggestion', [ $this, 'filter_built_in_suggestions' ] ); // Cron lifecycle: schedule/unschedule the recurring AI maintenance crons // based on AI service connection state. Prevents stale events from // piling up in `wp_options.cron` on installs that never enabled AI. add_action( 'wpforo_ai_tenant_registered', [ $this, 'register_ai_crons' ] ); add_action( 'wpforo_ai_tenant_disconnected', [ $this, 'unregister_ai_crons' ] ); } /** * Schedule all recurring AI-related crons. * * Called on tenant connect (wpforo_ai_tenant_registered). Idempotent — * each helper skips if already scheduled. Safe to call from plugin * upgrade migrations via sync_cron_state(). */ public function register_ai_crons() { $this->schedule_cache_cleanup(); $this->schedule_daily_subscription_sync(); if ( isset( WPF()->ai_content_moderation ) && WPF()->ai_content_moderation ) { WPF()->ai_content_moderation->schedule_moderation_cleanup(); } if ( isset( WPF()->vector_storage ) && WPF()->vector_storage ) { WPF()->vector_storage->schedule_cron_jobs(); } if ( isset( WPF()->task_manager ) && WPF()->task_manager ) { WPF()->task_manager->schedule_cron_jobs(); } $this->log_info( 'ai_crons_registered' ); } /** * Unschedule every recurring AI-related cron. * * Called on tenant disconnect (wpforo_ai_tenant_disconnected) and on * plugin upgrade when not connected, so users who never enabled AI (or * who disconnected) do not see stale events accumulating in wp_cron. * * Single-event crons (wpforo_ai_execute_task[_for_topic], _process_batch, * _process_queue*, _process_wp_batch) are not blanket-cleared here — they * are managed per-task on the AI side and never get scheduled for users * who do not use AI features. */ public function unregister_ai_crons() { $this->unschedule_cache_cleanup(); $this->unschedule_daily_subscription_sync(); $this->unschedule_pending_topics_indexing(); if ( isset( WPF()->ai_content_moderation ) && WPF()->ai_content_moderation ) { WPF()->ai_content_moderation->unschedule_moderation_cleanup(); } if ( isset( WPF()->vector_storage ) && WPF()->vector_storage ) { WPF()->vector_storage->unschedule_cron_jobs(); } if ( isset( WPF()->task_manager ) && WPF()->task_manager ) { WPF()->task_manager->unschedule_cron_jobs(); } // AILogs cleanup is scheduled lazily on first log insert; clear it // too so the wp_cron option stays clean for users who never reconnect. wp_clear_scheduled_hook( 'wpforo_ai_logs_cleanup' ); $this->log_info( 'ai_crons_unregistered' ); } /** * Idempotent reconciler: ensures the AI cron set matches the current * connection state. Called from plugin upgrade so existing installs that * accumulated AI crons without ever connecting get cleaned up. */ public function sync_cron_state() { if ( $this->is_connected() ) { $this->register_ai_crons(); } else { $this->unregister_ai_crons(); } } /** * Get API base URL * * @return string API base URL */ public function get_api_base_url() { return $this->api_base_url; } /** * Register suggested privacy policy content for AI features * * Adds a suggestion to Settings > Privacy so site admins can include * AI data processing disclosure in their site's privacy policy. */ public function register_privacy_policy_content() { $content = '
' . __( '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' ) . '
' . '' . __( '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' ) . '
' . '' . sprintf( __( 'For more information, see the gVectors %1$sTerms of Service%2$s and %3$sPrivacy Policy%4$s.', 'wpforo' ), '', '', '', '' ) . '
'; wp_add_privacy_policy_content( 'wpForo Forum', $content ); } // ========================================================================= // GLOBAL AI OPTIONS // ========================================================================= // These options are shared across ALL boards (use base prefix 'wpforo_') // Unlike board-specific options, these don't use the board prefix. // // Global options: // - ai_api_key : API key for authentication (shared) // - ai_tenant_id : Tenant identifier (shared) // // Board-specific options (use wpforo_get_option/wpforo_update_option): // - ai_chunk_size : Chunking size per board // - ai_overlap_percent : Overlap percentage per board // - ai_pagination_size : Pagination size per board // ========================================================================= /** * Get a global AI option (shared across all boards) * * Uses base prefix 'wpforo_' regardless of current board context. * This ensures API key and tenant ID are always found. * * @param string $option Option name without prefix (e.g., 'ai_api_key') * @param mixed $default Default value if option not found * @return mixed Option value */ public function get_global_option( $option, $default = '' ) { return get_option( 'wpforo_' . $option, $default ); } /** * Update a global AI option (shared across all boards) * * Uses base prefix 'wpforo_' regardless of current board context. * * @param string $option Option name without prefix (e.g., 'ai_api_key') * @param mixed $value Value to save * @return bool True on success */ public function update_global_option( $option, $value ) { $result = update_option( 'wpforo_' . $option, $value ); wpforo_clean_cache( 'option' ); return $result; } /** * Delete a global AI option (shared across all boards) * * Uses base prefix 'wpforo_' regardless of current board context. * * @param string $option Option name without prefix (e.g., 'ai_api_key') * @return bool True on success */ public function delete_global_option( $option ) { $result = delete_option( 'wpforo_' . $option ); wpforo_clean_cache( 'option' ); return $result; } /** * Get the API key (global option) * * @return string Encrypted API key or empty string */ public function get_api_key() { return $this->get_global_option( 'ai_api_key', '' ); } /** * Get the tenant ID (global option) * * @return string Tenant ID or empty string */ public function get_tenant_id() { return $this->get_global_option( 'ai_tenant_id', '' ); } /** * Check if tenant is connected to AI service * * @return bool True if API key and tenant ID are configured */ public function is_connected() { $api_key = $this->get_api_key(); $tenant_id = $this->get_tenant_id(); return ! empty( $api_key ) && ! empty( $tenant_id ); } /** * Check if AI service is available for use * * This checks both connection AND subscription status. * AI features should only work when: * 1. Tenant is connected (has API key and tenant ID) * 2. Subscription status is active or trial * * Returns false for: inactive, pending_approval, disconnected, expired * * @return bool True if AI features can be used */ public function is_service_available() { // First check connection if ( ! $this->is_connected() ) { return false; } // Use cached subscription status from database options (no API calls) // Status is synced when: // 1. AI Features > Overview tab is loaded // 2. User clicks refresh button // 3. On first connection/registration $sub_status = $this->get_subscription_status(); // Only allow active and trial statuses return in_array( $sub_status, [ 'active', 'trial' ], true ); } /** * Check API health status * * @return array|WP_Error Health status or error object */ public function health_check() { $response = $this->get( '/tenant/health' ); if ( is_wp_error( $response ) ) { $this->log_error( 'health_check_failed', $response->get_error_message() ); return $response; } return $response; } /** * Register new tenant and generate API key * * Creates a new tenant account with free trial (500 credits, 30 days) * * @return array|WP_Error Response data or error object */ public function register_tenant() { $site_url = get_site_url(); // For localhost development, allow URL override via filter or constant if ( strpos( $site_url, 'localhost' ) !== false || strpos( $site_url, '127.0.0.1' ) !== false ) { // Check for development mode override if ( defined( 'WPFORO_AI_DEV_URL' ) && WPFORO_AI_DEV_URL ) { $site_url = WPFORO_AI_DEV_URL; } } $data = [ 'site_url' => $site_url, 'admin_email' => get_option( 'admin_email' ), 'wordpress_version' => get_bloginfo( 'version' ), 'wpforo_version' => defined( 'WPFORO_VERSION' ) ? WPFORO_VERSION : 'unknown', 'site_name' => get_bloginfo( 'name' ), 'language' => get_bloginfo( 'language' ), 'timezone' => wp_timezone_string(), ]; // Allow filtering registration data $data = apply_filters( 'wpforo_ai_registration_data', $data ); // Log the registration attempt $this->log_info( 'attempting_tenant_registration', [ 'site_url' => $data['site_url'], 'admin_email' => $data['admin_email'], ] ); $response = $this->post( '/tenant/register', $data ); if ( is_wp_error( $response ) ) { $this->log_error( 'registration_failed', $response->get_error_message() ); return $response; } // Log successful registration $this->log_info( 'tenant_registered', [ 'tenant_id' => wpfval( $response, 'tenant_id' ), 'plan' => wpfval( $response, 'subscription', 'plan' ), ] ); do_action( 'wpforo_ai_tenant_registered', $response ); return $response; } /** * Get current tenant status and subscription info * * @param bool $force_fresh Whether to bypass the cache and fetch fresh data * @return array|WP_Error Status data or error object */ public function get_tenant_status( $force_fresh = false ) { // Check cache first (5 minute cache) $cache_key = 'wpforo_ai_tenant_status'; $cached = get_transient( $cache_key ); if ( false !== $cached && ! $force_fresh && ! $this->is_debug_mode() ) { return $cached; } // Validate credentials exist before making API call (use global options) $api_key = $this->get_stored_api_key(); $tenant_id = $this->get_tenant_id(); if ( empty( $api_key ) || empty( $tenant_id ) ) { return new \WP_Error( 'no_credentials', wpforo_phrase( 'No credentials found. Please connect to the service first.', false ) ); } $response = $this->get( '/tenant/status' ); if ( is_wp_error( $response ) ) { $this->log_error( 'status_fetch_failed', $response->get_error_message() ); return $response; } // Cache the response for 5 minutes set_transient( $cache_key, $response, 5 * MINUTE_IN_SECONDS ); // Also update persistent subscription info for frontend feature gating // This allows checking plan without API calls on every page load $this->update_cached_subscription_info( $response ); return $response; } /** * Update cached subscription info from status response * * Stores plan and features in WordPress options for quick access * without making API calls on every page load. * * @param array $status_response Response from /tenant/status API */ private function update_cached_subscription_info( $status_response ) { if ( ! is_array( $status_response ) ) { return; } $subscription = isset( $status_response['subscription'] ) ? $status_response['subscription'] : []; $features_enabled = isset( $status_response['features_enabled'] ) ? $status_response['features_enabled'] : []; // Store subscription status (e.g., 'active', 'trial', 'inactive', 'pending_approval') $sub_status = isset( $subscription['status'] ) ? sanitize_text_field( $subscription['status'] ) : ''; $this->update_global_option( 'ai_subscription_status', $sub_status ); // Store plan (e.g., 'free_trial', 'starter', 'professional', 'business', 'enterprise') $plan = isset( $subscription['plan'] ) ? sanitize_text_field( $subscription['plan'] ) : 'free_trial'; $this->update_global_option( 'ai_subscription_plan', $plan ); // Store features enabled (array of feature IDs) $this->update_global_option( 'ai_features_enabled', array_map( 'sanitize_text_field', $features_enabled ) ); // Store payment provider (freemius, paddle, or empty for free trial) $payment_provider = isset( $subscription['payment_provider'] ) ? sanitize_text_field( $subscription['payment_provider'] ) : ''; if ( $payment_provider ) { update_option( 'wpforo_ai_payment_provider', $payment_provider ); } // Store all payment providers list (for tenants with both Freemius and Paddle) if ( isset( $subscription['payment_providers'] ) && is_array( $subscription['payment_providers'] ) ) { update_option( 'wpforo_ai_payment_providers', array_map( 'sanitize_text_field', $subscription['payment_providers'] ) ); } // Store last sync time for debugging $this->update_global_option( 'ai_subscription_synced_at', current_time( 'mysql', true ) ); } /** * Get cached subscription plan * * Returns the plan stored in WordPress options. * This doesn't make API calls - use get_tenant_status() to refresh. * * @return string Plan name (free_trial, starter, professional, business, enterprise) */ public function get_subscription_plan() { return $this->get_global_option( 'ai_subscription_plan', 'free_trial' ); } /** * Get cached subscription status * * Returns the subscription status stored in WordPress options. * This doesn't make API calls - use get_tenant_status() to refresh. * * @return string Status (active, trial, inactive, pending_approval, etc.) */ public function get_subscription_status() { return $this->get_global_option( 'ai_subscription_status', '' ); } /** * Get cached features enabled list * * Returns the features_enabled array from last status sync. * * @return array List of enabled feature IDs */ public function get_features_enabled() { $features = $this->get_global_option( 'ai_features_enabled', [] ); return is_array( $features ) ? $features : []; } /** * Check if a specific feature is available based on subscription plan * * This method checks locally cached plan data to avoid API calls. * Use this for frontend feature gating (showing/hiding UI elements). * * Note: Backend APIs still verify plan independently for security. * * @param string $feature_id Feature identifier (e.g., 'ai_assistant_chatbot', 'multi_language_translation') * @return bool True if feature is available for current plan */ public function is_feature_available( $feature_id ) { // Service must be available (connected + active subscription) if ( ! $this->is_service_available() ) { return false; } // Get current plan from cache $current_plan = $this->get_subscription_plan(); // Get feature definitions to find required plan $all_features = $this->get_feature_definitions(); $feature = isset( $all_features[ $feature_id ] ) ? $all_features[ $feature_id ] : null; // Unknown feature - deny by default if ( ! $feature ) { return false; } $required_plan = isset( $feature['plan'] ) ? $feature['plan'] : 'enterprise'; // Check if current plan meets requirement return $this->plan_meets_requirement( $current_plan, $required_plan ); } /** * Check if current plan meets or exceeds required plan level * * @param string $current_plan Current subscription plan * @param string $required_plan Required plan for feature * @return bool True if current plan is sufficient */ private function plan_meets_requirement( $current_plan, $required_plan ) { // Plan hierarchy (lower to higher) $plan_hierarchy = [ 'free_trial' => 0, 'starter' => 0, // Starter and free_trial are same level 'professional' => 1, 'business' => 2, 'enterprise' => 3, ]; $current_level = isset( $plan_hierarchy[ $current_plan ] ) ? $plan_hierarchy[ $current_plan ] : 0; $required_level = isset( $plan_hierarchy[ $required_plan ] ) ? $plan_hierarchy[ $required_plan ] : 0; return $current_level >= $required_level; } /** * Get feature definitions with plan requirements * * Returns a simplified version of feature definitions for plan checking. * This is a subset of what wpforo_ai_get_all_features() returns. * * @return array Feature ID => ['plan' => required_plan] */ private function get_feature_definitions() { return [ // Starter Plan Features (also available on free_trial) 'semantic_search' => [ 'plan' => 'starter' ], 'search_enhance' => [ 'plan' => 'starter' ], 'content_indexing' => [ 'plan' => 'starter' ], 'multi_language_translation' => [ 'plan' => 'starter' ], 'topic_summary' => [ 'plan' => 'starter' ], 'smart_topic_suggestions' => [ 'plan' => 'starter' ], 'ai_spam_detection' => [ 'plan' => 'starter' ], 'ai_toxicity_detection' => [ 'plan' => 'starter' ], 'ai_rule_compliance' => [ 'plan' => 'starter' ], // Professional Plan Features 'analytics_insights' => [ 'plan' => 'professional' ], 'ai_topic_generator' => [ 'plan' => 'professional' ], 'ai_reply_generator' => [ 'plan' => 'professional' ], 'ai_bot_reply' => [ 'plan' => 'professional' ], 'auto_tag_generation' => [ 'plan' => 'professional' ], // Business Plan Features 'ai_assistant_chatbot' => [ 'plan' => 'business' ], 'extended_knowledge_base' => [ 'plan' => 'business' ], 'wordpress_content_indexing' => [ 'plan' => 'business' ], 'custom_post_types_indexing' => [ 'plan' => 'business' ], 'woocommerce_products_indexing' => [ 'plan' => 'business' ], 'vector_db_cloud_storage' => [ 'plan' => 'business' ], 'custom_knowledge' => [ 'plan' => 'business' ], // Enterprise Plan Features 'developer_features' => [ 'plan' => 'enterprise' ], 'rest_api_access' => [ 'plan' => 'enterprise' ], 'custom_ai_models' => [ 'plan' => 'enterprise' ], 'custom_feature_development' => [ 'plan' => 'enterprise' ], 'premium_support' => [ 'plan' => 'enterprise' ], 'dedicated_account_manager' => [ 'plan' => 'enterprise' ], 'enterprise_capabilities' => [ 'plan' => 'enterprise' ], ]; } /** * Clear cached tenant status * Forces fresh fetch on next status request */ public function clear_status_cache() { delete_transient( 'wpforo_ai_tenant_status' ); } /** * Get indexed topic statistics by forum * * Returns indexed topic counts per forum for displaying in admin UI * * @return array|WP_Error Response data with forum_counts or error object */ public function get_indexed_stats_by_forum() { $response = $this->get( '/rag/indexed-stats/forums' ); if ( is_wp_error( $response ) ) { $this->log_error( 'indexed_stats_fetch_failed', $response->get_error_message() ); return $response; } return $response; } /** * Disconnect service (soft delete) * * @param string $reason Reason for disconnection * @param bool $confirm Confirmation flag * @return array|WP_Error Response data or error object */ public function disconnect_tenant( $reason = '', $confirm = false, $purge_data = false ) { $data = [ 'reason' => sanitize_text_field( $reason ), 'confirm' => (bool) $confirm, 'purge_data' => (bool) $purge_data, ]; $response = $this->delete( '/tenant/disconnect', $data ); if ( is_wp_error( $response ) ) { $this->log_error( 'disconnection_failed', $response->get_error_message() ); return $response; } $this->log_info( 'tenant_disconnected', [ 'reason' => $reason ] ); $this->clear_status_cache(); // Clear indexed status for all topics (vectors are deleted on disconnect) $this->clear_topics_indexed_status(); do_action( 'wpforo_ai_tenant_disconnected', $response ); return $response; } /** * Check eligibility for bonus credits (large forum incentive) * * @return array Eligibility data with 'eligible' boolean and 'data' array */ public function check_bonus_credits_eligibility() { global $wpdb; // Get wpforo table names $topics_table = WPF()->tables->topics ?? $wpdb->prefix . 'wpforo_topics'; $posts_table = WPF()->tables->posts ?? $wpdb->prefix . 'wpforo_posts'; $profile_table = WPF()->tables->profiles ?? $wpdb->prefix . 'wpforo_profiles'; // 1. Count approved, non-private topics (status=0, private=0) $topic_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$topics_table} WHERE status = 0 AND private = 0" ); // 2. Count approved posts (status=0) $post_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$posts_table} WHERE status = 0" ); // 3. Get days between first and last topic $date_range = $wpdb->get_row( "SELECT MIN(created) as first_topic, MAX(created) as last_topic FROM {$topics_table} WHERE status = 0 AND private = 0" ); $days_active = 0; if ( $date_range && $date_range->first_topic && $date_range->last_topic ) { $first_time = strtotime( $date_range->first_topic ); $last_time = strtotime( $date_range->last_topic ); $days_active = (int) floor( ( $last_time - $first_time ) / DAY_IN_SECONDS ); } // 4. Count distinct topic authors who have login history (online_time > 0) $active_authors = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT t.userid) FROM {$topics_table} t INNER JOIN {$profile_table} p ON t.userid = p.userid WHERE t.status = 0 AND t.private = 0 AND t.userid > 0 AND p.online_time > 0" ); // Determine eligibility $eligible = ( $topic_count >= 501 && $post_count >= 511 && $days_active >= 30 && $active_authors >= 10 ); return [ 'eligible' => $eligible, 'data' => [ 'topic_count' => $topic_count, 'post_count' => $post_count, 'days_active' => $days_active, 'active_authors' => $active_authors, ], 'requirements' => [ 'min_topics' => 501, 'min_posts' => 511, 'min_days' => 30, 'min_active_authors' => 10, ], ]; } /** * Request bonus credits from API * * @param array $eligibility_data Data from check_bonus_credits_eligibility() * @return array|WP_Error Response with credits_added or error */ public function request_bonus_credits( $eligibility_data ) { $response = $this->post( '/tenant/bonus-credits', [ 'topic_count' => (int) $eligibility_data['topic_count'], 'post_count' => (int) $eligibility_data['post_count'], 'days_active' => (int) $eligibility_data['days_active'], 'active_authors' => (int) $eligibility_data['active_authors'], ] ); if ( is_wp_error( $response ) ) { $this->log_error( 'bonus_credits_failed', $response->get_error_message() ); return $response; } // Store bonus credits info locally // Use isset() instead of !empty() for credits_added — empty(0) is true in PHP, // which would skip saving when credits_added is 0 (e.g., due to cap enforcement) if ( ! empty( $response['success'] ) && isset( $response['credits_added'] ) ) { update_option( 'wpforo_ai_bonus_credits_claimed', true ); update_option( 'wpforo_ai_bonus_credits_amount', (int) $response['credits_added'] ); update_option( 'wpforo_ai_bonus_credits_claimed_at', current_time( 'mysql', true ) ); $this->log_info( 'bonus_credits_granted', [ 'credits_added' => $response['credits_added'], ] ); // Clear status cache to reflect new credits $this->clear_status_cache(); } return $response; } /** * AJAX handler for requesting bonus credits * * @return void */ public function ajax_request_bonus_credits() { $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', '_wpnonce' ); // Check if already claimed locally if ( get_option( 'wpforo_ai_bonus_credits_claimed', false ) ) { $this->send_error( wpforo_phrase( 'Bonus credits have already been claimed.', false ), [ 'code' => 'already_claimed' ] ); } // Check eligibility $eligibility = $this->check_bonus_credits_eligibility(); if ( ! $eligibility['eligible'] ) { $this->send_error( wpforo_phrase( 'Forum does not meet eligibility requirements for bonus credits.', false ), [ 'code' => 'not_eligible', 'requirements' => $eligibility['requirements'], 'current' => $eligibility['data'], ] ); } // Request bonus credits from API $response = $this->request_bonus_credits( $eligibility['data'] ); if ( is_wp_error( $response ) ) { $this->send_error( $response->get_error_message() ); } $this->send_success( [ 'message' => $response['message'] ?? wpforo_phrase( 'Bonus credits added successfully!', false ), 'credits_added' => $response['credits_added'] ?? 0, ] ); } /** * Check if bonus credits have been claimed * * @return bool|array False if not claimed, array with details if claimed */ public function get_bonus_credits_status() { $claimed = get_option( 'wpforo_ai_bonus_credits_claimed', false ); if ( ! $claimed ) { return false; } return [ 'claimed' => true, 'amount' => (int) get_option( 'wpforo_ai_bonus_credits_amount', 0 ), 'claimed_at' => get_option( 'wpforo_ai_bonus_credits_claimed_at', '' ), ]; } /** * Get AI Content Indexing status * * Returns current status of AI Content Indexing including total indexed threads, * progress, and whether indexing is currently active. * * @return array|WP_Error Status data or error object */ public function get_rag_status( $boardid = 0 ) { // Check cache first (30 second cache for frequent updates) // Board-specific cache key $cache_key = 'wpforo_ai_rag_status_' . intval( $boardid ); $cached = get_transient( $cache_key ); if ( false !== $cached && ! $this->is_debug_mode() ) { return $cached; } // Include boardid in API request for future backend filtering $endpoint = '/rag/status'; if ( $boardid > 0 ) { $endpoint .= '?boardid=' . intval( $boardid ); } $response = $this->get( $endpoint ); if ( is_wp_error( $response ) ) { $this->log_error( 'rag_status_fetch_failed', $response->get_error_message() ); return $response; } // Cache the response for 30 seconds (short cache for indexing status) set_transient( $cache_key, $response, 30 ); return $response; } /** * AJAX handler for getting RAG status * * @return void */ public function ajax_get_rag_status() { $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', '_wpnonce' ); // Use VectorStorageManager to get stats (routes to local or cloud automatically) $storage_manager = WPF()->vector_storage; $status = $storage_manager->get_indexing_stats(); // Clear tenant status cache to get fresh credit info $this->clear_status_cache(); // Add tenant status info for credits display (fresh fetch) $tenant_status = $this->get_tenant_status(); if ( ! is_wp_error( $tenant_status ) ) { $status['tenant_id'] = $tenant_status['tenant_id'] ?? ''; $status['credits'] = $tenant_status['credits'] ?? []; $status['subscription_tier'] = $tenant_status['subscription_tier'] ?? ''; } // Add pending cron jobs info $pending_jobs = $this->get_pending_cron_jobs(); $status['pending_cron_jobs'] = $pending_jobs; $this->send_success( $status ); } /** * AJAX handler for getting indexing status breakdown (private/unapproved counts) * * Returns cached breakdown data (1-day TTL) for displaying excluded topics info. * Loaded asynchronously after page load to avoid slow initial page renders. * * @return void */ public function ajax_get_indexing_breakdown() { $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', 'nonce' ); $storage_manager = WPF()->vector_storage; $breakdown = $storage_manager->get_indexing_status_breakdown(); $this->send_success( $breakdown ); } /** * Ask the backend to stop any in-flight cloud indexing for this tenant. * * Sends POST /v1/rag/cancel, which sets a cancellation flag on the * tenant record with a ~10 minute TTL. The backend reads the flag * at the start of every queued message and skips (without charging * credits) anything still pending. Already in-flight processing * calls are allowed to finish and are billed. * * @return array|WP_Error Backend response or error object */ public function cancel_indexing() { // Note: /rag/cancel was replaced by /rag/cleanup-jobs in backend // Both set indexing_cancel_until flag, cleanup-jobs also finalizes stuck jobs return $this->post( '/rag/cleanup-jobs', [], 30 ); } /** * Cleanup stuck cloud indexing jobs (backend). * * Calls POST /v1/rag/cleanup-jobs — scans the tenant's rag_jobs * records, force-finalizes anything stuck (media_done < media_total, * created >1h ago), refunds the unused portion of the image/document * credit reservation, and sets indexing_cancel_until so any in-flight * SQS messages drain without charging. * * Idempotent: backend uses a conditional update on * media_credits_charged, so re-clicking is safe — no double refund. * Safe in local storage mode: tenants with no rag_jobs get a zero-count * response with no side effects. * * @return array|WP_Error Backend response: status, jobs_scanned, * jobs_finalized, refund_images, refund_docs, ... */ public function cleanup_jobs() { return $this->post( '/rag/cleanup-jobs', [], 30 ); } /** * AJAX handler: stop in-flight cloud indexing. * * Mirrors the local-mode `clearLocalIndexingQueue` flow — no page * reload, UI polling will pick up the drained state via the regular * `/rag/status` poll. * * @return void */ public function ajax_cancel_cloud_indexing() { $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', '_wpnonce' ); // Clear any WordPress cron jobs scheduled for indexing (cloud/local/legacy // hooks) and the per-board queue options. The legacy form-submit Stop // flow did this via wpforo_ai_handle_stop_indexing(); the new AJAX path // must do the same or orphaned crons continue firing after Stop. $this->clear_pending_cron_jobs(); $result = $this->cancel_indexing(); if ( is_wp_error( $result ) ) { $this->send_error( wpforo_phrase( 'Failed to stop indexing. Please try again.', false ), 500 ); } $this->send_success( [ 'message' => wpforo_phrase( 'Indexing is being stopped. In-flight items may take a few minutes to drain.', false ), ] ); } /** * Cleanup stuck indexing session state. * * Resets all "in-progress" markers (queues, crons, locks, caches, backend * cancel flag) for either the forum-indexing pipeline or the WordPress * content indexing pipeline — without touching any successfully-indexed * data (vectors, wpforo_ai_local_vectors rows, topics.cloud/local/indexed * columns, credit counters). * * Covers BOTH local and cloud storage modes in a single call: forum * cleanup clears local queues, cloud queues, legacy queues, and tells the * backend worker to drop any queued items. * * @param string $scope 'forum' | 'wp' | 'all' * @return array Summary of what was cleared. */ public function cleanup_indexing_session( $scope = 'forum' ) { $board_id = WPF()->board->get_current( 'boardid' ) ?: 0; $summary = [ 'scope' => $scope, 'options_deleted' => 0, 'transients_deleted' => 0, 'crons_cleared' => 0, 'topics_cleared' => 0, 'backend_cancelled' => false, 'backend_cleanup' => false, ]; if ( $scope === 'forum' || $scope === 'all' ) { // Reuse the existing helper — it clears local/cloud/legacy queue // options AND their cron hooks for the current board, including // the legacy wpforo_ai_process_batch pattern. $cron_result = $this->clear_pending_cron_jobs(); $summary['topics_cleared'] += (int) ( $cron_result['cleared_topics'] ?? 0 ); $summary['crons_cleared'] += (int) ( $cron_result['cleared_jobs'] ?? 0 ); // Session snapshot (chunk_size/overlap/batch_size/total/started_at) if ( delete_option( 'wpforo_ai_indexing_settings_' . $board_id ) ) { $summary['options_deleted']++; } // Per-board batch locks (serialize AJAX + WP-Cron processing). // Clear all variants: legacy, local-mode, and cloud-mode locks. // If any are stale, new batches refuse to run until TTL expires. if ( delete_transient( 'wpforo_ai_indexing_lock_' . $board_id ) ) { $summary['transients_deleted']++; } if ( delete_transient( 'wpforo_ai_indexing_lock_local_' . $board_id ) ) { $summary['transients_deleted']++; } if ( delete_transient( 'wpforo_ai_indexing_lock_cloud_' . $board_id ) ) { $summary['transients_deleted']++; } // Global 5-min "clearing in progress" semaphore. If set while // a clear operation crashed, it blocks the UI for up to 5 minutes. if ( delete_transient( 'wpforo_ai_clearing_in_progress' ) ) { $summary['transients_deleted']++; } // Force-refresh the cached RAG status so the UI flips to Idle // immediately after cleanup. if ( delete_transient( 'wpforo_ai_rag_status' ) ) { $summary['transients_deleted']++; } if ( delete_transient( 'wpforo_ai_rag_status_' . $board_id ) ) { $summary['transients_deleted']++; } // Clear indexing breakdown cache so UI shows fresh counts WPF()->vector_storage->clear_indexing_status_breakdown_cache(); // Tell the backend to drop any in-flight cloud image_worker items. // Safe in local mode: backend simply sets indexing_cancel_until on // the tenant record with no other side effects. Errors are // non-fatal — WP-side cleanup has already succeeded. $cancel = $this->cancel_indexing(); $summary['backend_cancelled'] = ! is_wp_error( $cancel ); // Force-finalize any stuck rag_jobs server-side and refund the // unused portion of the image/document credit reservation. // Idempotent and tenant-wide; safe in local mode (no jobs to // finalize). Errors are non-fatal — WP-side cleanup has already // succeeded and the backend reaper cron runs hourly anyway. $cleanup = $this->cleanup_jobs(); $summary['backend_cleanup'] = is_wp_error( $cleanup ) ? false : $cleanup; } if ( $scope === 'wp' || $scope === 'all' ) { // WordPress post/page indexing queue (single global key, not // board-scoped — WP content is global). if ( delete_option( 'wpforo_ai_wp_indexing_queue' ) ) { $summary['options_deleted']++; } // WP indexing status cache (5-min TTL) — deleting it forces the // UI to query fresh state. if ( delete_transient( 'wpforo_ai_wp_indexing_status' ) ) { $summary['transients_deleted']++; } // WP indexing lock transient — must be cleared so new indexing // can start immediately after stop/clear. Without this, user // would have to wait up to 300s for the lock to auto-expire. if ( delete_transient( 'wpforo_ai_wp_indexing_lock' ) ) { $summary['transients_deleted']++; } // WP post/page batch cron (single-event, no args, reschedules // itself). wp_clear_scheduled_hook removes all pending events. if ( wp_next_scheduled( 'wpforo_ai_process_wp_batch' ) ) { $summary['crons_cleared']++; } wp_clear_scheduled_hook( 'wpforo_ai_process_wp_batch' ); // For cloud mode: cancel in-flight backend jobs and cleanup stuck // rag_jobs. These APIs are tenant-wide (not scope-specific), so // calling them for WP scope ensures stuck cloud jobs are handled // even if user only uses WordPress indexing. Safe in local mode: // backend simply ignores the request with no side effects. if ( $scope === 'wp' ) { $cancel = $this->cancel_indexing(); $summary['backend_cancelled'] = ! is_wp_error( $cancel ); $cleanup = $this->cleanup_jobs(); $summary['backend_cleanup'] = is_wp_error( $cleanup ) ? false : $cleanup; } } return $summary; } /** * AJAX handler: cleanup stuck indexing session state. * * POST params: * scope — 'forum' | 'wp' | 'all' (default 'forum') * _wpnonce — wpforo_ai_features_nonce * * @return void */ public function ajax_cleanup_indexing_session() { $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', '_wpnonce' ); $scope = $this->get_post_param( 'scope', 'forum' ); if ( ! in_array( $scope, [ 'forum', 'wp', 'all' ], true ) ) { $scope = 'forum'; } $summary = $this->cleanup_indexing_session( $scope ); $this->send_success( [ 'summary' => $summary, 'message' => wpforo_phrase( 'Indexing session cleaned up. Stuck jobs, queues and cached state have been cleared.', false ), ] ); } /** * AJAX handler for saving storage mode setting * * @return void */ public function ajax_save_storage_mode() { $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', 'nonce' ); // Get and validate storage mode $storage_mode = $this->get_post_param( 'storage_mode', 'local' ); if ( ! in_array( $storage_mode, [ 'local', 'cloud' ], true ) ) { $storage_mode = 'local'; } $board_id = $this->get_post_param( 'board_id', 0, 'int' ); // Save the setting $option_name = 'wpforo_ai_storage_mode_' . $board_id; $old_mode = get_option( $option_name, 'local' ); update_option( $option_name, $storage_mode ); // If mode changed, sync the indexed status for the new mode $sync_result = null; if ( $old_mode !== $storage_mode ) { // Reset cached mode in VectorStorageManager WPF()->vector_storage->reset_storage_mode_cache(); // Sync indexed status based on new mode if ( $storage_mode === 'local' ) { $sync_result = WPF()->vector_storage->sync_local_indexed_status(); } else { $sync_result = WPF()->vector_storage->sync_cloud_indexed_status(); } } // Log the change $this->log_info( 'storage_mode_changed', [ 'board_id' => $board_id, 'old_mode' => $old_mode, 'storage_mode' => $storage_mode, 'sync_result' => is_wp_error( $sync_result ) ? $sync_result->get_error_message() : $sync_result ] ); $response_data = [ 'message' => wpforo_phrase( 'Storage mode saved successfully.', false ), 'storage_mode' => $storage_mode, 'board_id' => $board_id ]; if ( $sync_result && ! is_wp_error( $sync_result ) ) { $response_data['sync'] = $sync_result; } $this->send_success( $response_data ); } /** * AJAX handler to save auto-indexing setting * * Saves the auto-indexing enabled/disabled state for a specific board. * When enabled, new and approved topics will be automatically queued for indexing. * * @return void Sends JSON response */ public function ajax_save_auto_indexing() { $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', 'nonce' ); $enabled = $this->get_post_param( 'enabled', 0, 'bool' ) ? 1 : 0; $board_id = $this->get_post_param( 'board_id', 0, 'int' ); // Switch to the correct board context if ( $board_id > 0 ) { WPF()->change_board( $board_id ); } // Save the setting using wpforo options (board-specific) wpforo_update_option( 'ai_auto_indexing_enabled', $enabled ); // If enabling auto-indexing, schedule the cron jobs if ( $enabled ) { $this->schedule_pending_topics_indexing(); } else { $this->unschedule_pending_topics_indexing(); } // Log the change $this->log_info( 'auto_indexing_changed', [ 'board_id' => $board_id, 'enabled' => $enabled ] ); $this->send_success( [ 'message' => $enabled ? wpforo_phrase( 'Auto-indexing enabled successfully.', false ) : wpforo_phrase( 'Auto-indexing disabled successfully.', false ), 'enabled' => $enabled, 'board_id' => $board_id ] ); } /** * AJAX handler to save image indexing setting * * Saves the image indexing enabled/disabled state for a specific board. * When enabled, posts with images will consume +1 additional credit for * multimodal processing (image → text description → embedding). * * Feature Requirements: * - Business or Enterprise plan required * - Maximum 10 images per post (enforced by API) * - +1 credit per post that has images (not per image) * * @return void Sends JSON response */ public function ajax_save_image_indexing() { // Verify nonce check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' ); // Check user permissions if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Insufficient permissions', false ) ], 403 ); } // Check plan eligibility (Business/Enterprise only) $status = $this->get_tenant_status(); if ( is_wp_error( $status ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Could not verify subscription status', false ) ], 400 ); } $plan = isset( $status['subscription']['plan'] ) ? strtolower( $status['subscription']['plan'] ) : ''; if ( ! in_array( $plan, [ 'professional', 'business', 'enterprise' ], true ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Image indexing requires Professional plan or higher', false ) ], 403 ); } // Get and validate enabled state $enabled = isset( $_POST['enabled'] ) ? (int) $_POST['enabled'] : 0; $enabled = $enabled ? 1 : 0; // Get board ID $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0; // Switch to the correct board context if ( $board_id > 0 ) { WPF()->change_board( $board_id ); } // Save the setting using wpforo options (board-specific) wpforo_update_option( 'ai_image_indexing_enabled', $enabled ); // Log the change $this->log_info( 'image_indexing_changed', [ 'board_id' => $board_id, 'enabled' => $enabled ] ); wp_send_json_success( [ 'message' => $enabled ? wpforo_phrase( 'Image indexing enabled. Posts with images will consume +1 additional credit.', false ) : wpforo_phrase( 'Image indexing disabled.', false ), 'enabled' => $enabled, 'board_id' => $board_id ] ); } /** * AJAX handler for saving document indexing setting * * Saves the board-specific document indexing enabled/disabled state. * Requires Professional+ plan. */ public function ajax_save_document_indexing() { check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Insufficient permissions', false ) ], 403 ); } $status = $this->get_tenant_status(); if ( is_wp_error( $status ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Could not verify subscription status', false ) ], 400 ); } $plan = isset( $status['subscription']['plan'] ) ? strtolower( $status['subscription']['plan'] ) : ''; if ( ! in_array( $plan, [ 'professional', 'business', 'enterprise' ], true ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Document indexing requires Professional plan or higher', false ) ], 403 ); } $enabled = isset( $_POST['enabled'] ) ? (int) $_POST['enabled'] : 0; $enabled = $enabled ? 1 : 0; $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0; if ( $board_id > 0 ) { WPF()->change_board( $board_id ); } wpforo_update_option( 'ai_document_indexing_enabled', $enabled ); $this->log_info( 'document_indexing_changed', [ 'board_id' => $board_id, 'enabled' => $enabled ] ); wp_send_json_success( [ 'message' => $enabled ? wpforo_phrase( 'Document indexing enabled. Credit cost: 1 per page.', false ) : wpforo_phrase( 'Document indexing disabled.', false ), 'enabled' => $enabled, 'board_id' => $board_id ] ); } /** * AJAX handler for saving WordPress indexing options * * WordPress content is global (not board-specific), so these settings * are saved globally using update_option() instead of wpforo_update_option(). * * Supported options: * - ai_wp_auto_indexing_enabled: Auto-index new WordPress content * - ai_wp_image_indexing_enabled: Include images in WP content indexing */ public function ajax_save_wp_indexing_option() { // Verify nonce check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' ); // Check user permissions if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Insufficient permissions', false ) ], 403 ); } // Get option name and validate it's one of the allowed options $option_name = isset( $_POST['option_name'] ) ? sanitize_key( $_POST['option_name'] ) : ''; $allowed_options = [ 'ai_wp_auto_indexing_enabled', 'ai_wp_image_indexing_enabled' ]; if ( ! in_array( $option_name, $allowed_options, true ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Invalid option name', false ) ], 400 ); } // For image indexing, check plan eligibility (Professional/Business/Enterprise) if ( $option_name === 'ai_wp_image_indexing_enabled' ) { $status = $this->get_tenant_status(); if ( is_wp_error( $status ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Could not verify subscription status', false ) ], 400 ); } $plan = isset( $status['subscription']['plan'] ) ? strtolower( $status['subscription']['plan'] ) : ''; if ( ! in_array( $plan, [ 'professional', 'business', 'enterprise' ], true ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Image indexing requires Professional, Business or Enterprise plan', false ) ], 403 ); } } // Get and validate enabled state $enabled = isset( $_POST['enabled'] ) ? (int) $_POST['enabled'] : 0; $enabled = $enabled ? 1 : 0; // Save globally using WordPress options (not board-specific) update_option( 'wpforo_' . $option_name, $enabled ); // Log the change $this->log_info( 'wp_indexing_option_changed', [ 'option' => $option_name, 'enabled' => $enabled ] ); // Prepare success message based on option if ( $option_name === 'ai_wp_image_indexing_enabled' ) { $message = $enabled ? wpforo_phrase( 'WordPress image indexing enabled. Posts with images will consume +1 additional credit.', false ) : wpforo_phrase( 'WordPress image indexing disabled.', false ); } else { $message = $enabled ? wpforo_phrase( 'WordPress auto-indexing enabled. New content will be indexed automatically.', false ) : wpforo_phrase( 'WordPress auto-indexing disabled.', false ); } wp_send_json_success( [ 'message' => $message, 'enabled' => $enabled, 'option' => $option_name ] ); } /** * AJAX handler for linking Freemius subscription to tenant * * Called after successful Freemius checkout to store subscription_id and user_id. * This enables webhook matching when emails don't match. * * @return void Sends JSON response */ public function ajax_link_subscription() { // Verify nonce check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' ); // Check user permissions if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 ); } $subscription_id = isset( $_POST['subscription_id'] ) ? sanitize_text_field( $_POST['subscription_id'] ) : ''; $user_id = isset( $_POST['user_id'] ) ? sanitize_text_field( $_POST['user_id'] ) : ''; $plan = isset( $_POST['plan'] ) ? sanitize_text_field( $_POST['plan'] ) : ''; if ( empty( $subscription_id ) ) { wp_send_json_error( [ 'message' => 'Missing subscription_id' ], 400 ); } // Call backend API to link subscription $response = $this->post( '/tenant/link-subscription', [ 'freemius_subscription_id' => $subscription_id, 'freemius_user_id' => $user_id, 'plan' => $plan, ] ); if ( is_wp_error( $response ) ) { $this->log_error( 'link_subscription_failed', $response->get_error_message() ); wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 ); } $this->log_info( 'subscription_linked', [ 'subscription_id' => $subscription_id, 'user_id' => $user_id, 'plan' => $plan, ] ); wp_send_json_success( [ 'message' => 'Subscription linked successfully' ] ); } /** * AJAX handler for manual license activation * * Called when user enters a License ID to manually activate their plan. * The backend verifies with Freemius API and updates the subscription. * * Note: We only transmit the License ID (a numeric identifier like "1845944"), * NOT the License Key (sk_...). The License ID is safe to store as it's just * a reference number, not a secret. * * @return void Sends JSON response */ public function ajax_activate_license() { // Verify nonce check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' ); // Check user permissions if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 ); } $license_id = isset( $_POST['license_id'] ) ? sanitize_text_field( $_POST['license_id'] ) : ''; if ( empty( $license_id ) ) { wp_send_json_error( [ 'message' => 'Please enter your License ID' ], 400 ); } // Validate license_id format (should be numeric) if ( ! preg_match( '/^\d+$/', $license_id ) ) { wp_send_json_error( [ 'message' => 'Invalid License ID format. Please enter the numeric License ID from your purchase confirmation.' ], 400 ); } // Call backend API to verify and activate license $response = $this->post( '/tenant/activate-license', [ 'license_id' => $license_id, ] ); if ( is_wp_error( $response ) ) { $this->log_error( 'license_activation_failed', $response->get_error_message() ); wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 ); } // Clear cached subscription data so it refreshes delete_option( 'wpforo_ai_subscription_plan' ); delete_transient( 'wpforo_ai_subscription' ); delete_transient( 'wpforo_ai_tenant_status' ); $this->log_info( 'license_activated', [ 'license_id' => $license_id, 'plan' => $response['plan'] ?? '', ] ); wp_send_json_success( [ 'message' => $response['message'] ?? 'License activated successfully', 'plan' => $response['plan'] ?? '', 'credits_added' => $response['credits_added'] ?? 0, ] ); } /** * AJAX handler for activating a Paddle transaction manually. * Mirrors ajax_activate_license() but for Paddle Transaction IDs. */ public function ajax_activate_paddle_transaction() { check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 ); } $transaction_id = isset( $_POST['transaction_id'] ) ? sanitize_text_field( $_POST['transaction_id'] ) : ''; if ( empty( $transaction_id ) ) { wp_send_json_error( [ 'message' => 'Please enter your Transaction ID' ], 400 ); } if ( strpos( $transaction_id, 'txn_' ) !== 0 ) { wp_send_json_error( [ 'message' => 'Invalid Transaction ID format. Must start with "txn_".' ], 400 ); } $response = $this->post( '/tenant/activate-paddle-transaction', [ 'transaction_id' => $transaction_id, ] ); if ( is_wp_error( $response ) ) { $this->log_error( 'paddle_transaction_activation_failed', $response->get_error_message() ); wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 ); } // Clear cached subscription data so it refreshes delete_option( 'wpforo_ai_subscription_plan' ); delete_transient( 'wpforo_ai_subscription' ); delete_transient( 'wpforo_ai_tenant_status' ); $this->log_info( 'paddle_transaction_activated', [ 'transaction_id' => $transaction_id, 'plan' => $response['plan'] ?? '', 'transaction_type' => $response['transaction_type'] ?? '', ] ); wp_send_json_success( [ 'message' => $response['message'] ?? 'Transaction activated successfully', 'plan' => $response['plan'] ?? '', 'credits_added' => $response['credits_added'] ?? 0, 'transaction_type' => $response['transaction_type'] ?? '', ] ); } /** * AJAX handler for creating a Paddle checkout * * Creates a server-side Paddle transaction via the backend. * Returns a checkout URL where Paddle.js is loaded and opens the * checkout overlay for the transaction. * * @return void Sends JSON response with checkout_url */ public function ajax_paddle_checkout() { // Verify nonce check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' ); // Check user permissions if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 ); } $price_id = isset( $_POST['price_id'] ) ? sanitize_text_field( $_POST['price_id'] ) : ''; $plan = isset( $_POST['plan'] ) ? sanitize_text_field( $_POST['plan'] ) : ''; if ( empty( $price_id ) ) { wp_send_json_error( [ 'message' => 'Missing price_id' ], 400 ); } // Get tenant info $tenant_id = $this->get_tenant_id(); $current_user = wp_get_current_user(); $customer_email = ! empty( $current_user->user_email ) ? $current_user->user_email : get_option( 'admin_email' ); $customer_name = trim( $current_user->first_name . ' ' . $current_user->last_name ); if ( empty( $tenant_id ) ) { wp_send_json_error( [ 'message' => 'Not connected. Please generate an API key first.' ], 400 ); } // Call backend Lambda to create Paddle checkout transaction $response = $this->post( '/paddle/create-checkout', [ 'tenant_id' => $tenant_id, 'price_id' => $price_id, 'customer_email' => $customer_email, 'customer_name' => $customer_name ?: null, 'site_url' => site_url(), ] ); if ( is_wp_error( $response ) ) { $this->log_error( 'paddle_checkout_failed', $response->get_error_message() ); wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 ); } $checkout_url = $response['checkout_url'] ?? ''; if ( empty( $checkout_url ) ) { wp_send_json_error( [ 'message' => 'No checkout URL returned. Please try again.' ], 500 ); } $this->log_info( 'paddle_checkout_created', [ 'price_id' => $price_id, 'plan' => $plan, 'transaction_id' => $response['transaction_id'] ?? '', ] ); wp_send_json_success( [ 'checkout_url' => $checkout_url, 'transaction_id' => $response['transaction_id'] ?? '', ] ); } /** * AJAX handler for linking Paddle subscription to tenant * * Called after Paddle checkout to store paddle_subscription_id and paddle_customer_id. * This is a belt-and-suspenders approach — webhooks should already handle this via * custom_data.tenant_id, but calling this ensures the link is established immediately. * * @return void Sends JSON response */ public function ajax_link_paddle_subscription() { // Verify nonce check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' ); // Check user permissions if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 ); } $paddle_subscription_id = isset( $_POST['paddle_subscription_id'] ) ? sanitize_text_field( $_POST['paddle_subscription_id'] ) : ''; $paddle_customer_id = isset( $_POST['paddle_customer_id'] ) ? sanitize_text_field( $_POST['paddle_customer_id'] ) : ''; $plan = isset( $_POST['plan'] ) ? sanitize_text_field( $_POST['plan'] ) : ''; if ( empty( $paddle_subscription_id ) ) { wp_send_json_error( [ 'message' => 'Missing paddle_subscription_id' ], 400 ); } // Call backend API to link Paddle subscription $response = $this->post( '/tenant/link-paddle-subscription', [ 'paddle_subscription_id' => $paddle_subscription_id, 'paddle_customer_id' => $paddle_customer_id, 'plan' => $plan, ] ); if ( is_wp_error( $response ) ) { $this->log_error( 'link_paddle_subscription_failed', $response->get_error_message() ); wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 ); } // Store payment provider locally for manage subscription URL routing update_option( 'wpforo_ai_payment_provider', 'paddle' ); $this->log_info( 'paddle_subscription_linked', [ 'paddle_subscription_id' => $paddle_subscription_id, 'paddle_customer_id' => $paddle_customer_id, 'plan' => $plan, ] ); wp_send_json_success( [ 'message' => 'Paddle subscription linked successfully' ] ); } /** * AJAX handler for searching bot users (for Bot Reply settings) * * Searches for activated WordPress users by login, display name, or email. * Only returns users with empty user_activation_key (active accounts). * * @return void Sends JSON response */ public function ajax_search_bot_users() { // Verify nonce - use settings form nonce check_ajax_referer( 'wpforo_ai_bot_user_search', '_wpnonce' ); // Check user permissions if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => 'Permission denied' ] ); } $search = sanitize_text_field( $_POST['search'] ?? '' ); $user_id = intval( $_POST['user_id'] ?? 0 ); global $wpdb; // If user_id is provided, look up that specific user if ( $user_id > 0 ) { $user = get_userdata( $user_id ); if ( $user ) { $role = ! empty( $user->roles ) ? ucfirst( $user->roles[0] ) : ''; wp_send_json_success( [ 'users' => [ [ 'id' => $user->ID, 'user_login' => $user->user_login, 'display_name' => $user->display_name, 'role' => $role, 'label' => sprintf( '%s (%s)%s', $user->display_name, $user->user_login, $role ? ' - ' . $role : '' ), ] ] ] ); } else { wp_send_json_success( [ 'users' => [] ] ); } return; } // Otherwise, search by text if ( strlen( $search ) < 2 ) { wp_send_json_success( [ 'users' => [] ] ); } // Search for activated users (empty user_activation_key) by login, display name, or email $like = '%' . $wpdb->esc_like( $search ) . '%'; $users = $wpdb->get_results( $wpdb->prepare( "SELECT ID, user_login, display_name, user_email FROM {$wpdb->users} WHERE user_activation_key = '' AND (user_login LIKE %s OR display_name LIKE %s OR user_email LIKE %s) ORDER BY display_name ASC LIMIT 50", $like, $like, $like ) ); // Batch fetch user roles using single query $user_ids = wp_list_pluck( $users, 'ID' ); $user_roles = []; if ( ! empty( $user_ids ) ) { $wp_users = get_users( [ 'include' => $user_ids, 'fields' => 'all_with_meta' ] ); foreach ( $wp_users as $wp_user ) { $user_roles[ $wp_user->ID ] = ! empty( $wp_user->roles ) ? ucfirst( $wp_user->roles[0] ) : ''; } } $results = []; foreach ( $users as $user ) { $role = $user_roles[ $user->ID ] ?? ''; $results[] = [ 'id' => $user->ID, 'user_login' => $user->user_login, 'display_name' => $user->display_name, 'role' => $role, 'label' => sprintf( '%s (%s)%s', $user->display_name, $user->user_login, $role ? ' - ' . $role : '' ), ]; } wp_send_json_success( [ 'users' => $results ] ); } /** * AJAX handler for getting analytics data * * Fetches AI usage analytics from backend API with local caching * * @return void Sends JSON response */ public function ajax_get_analytics() { // Verify nonce check_ajax_referer( 'wpforo_ai_analytics_nonce', 'nonce' ); // Check user permissions if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Insufficient permissions', false ) ], 403 ); } // Get parameters $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0; $start_time = isset( $_POST['start_time'] ) ? intval( $_POST['start_time'] ) : strtotime( '-7 days' ); $end_time = isset( $_POST['end_time'] ) ? intval( $_POST['end_time'] ) : time(); // Check cache first $cache_key = 'analytics_usage_' . md5( $board_id . ':' . $start_time . ':' . $end_time ); $cached_data = $this->get_analytics_cache( $cache_key ); if ( $cached_data !== false ) { wp_send_json_success( $cached_data ); return; } // Fetch from backend API $analytics_data = $this->fetch_analytics_from_api( $start_time, $end_time, $board_id ); if ( is_wp_error( $analytics_data ) ) { wp_send_json_error( [ 'message' => $analytics_data->get_error_message() ] ); return; } // Cache the result for 1 hour $this->set_analytics_cache( $cache_key, $analytics_data, 3600 ); wp_send_json_success( $analytics_data ); } /** * Fetch analytics data from backend API * * @param int $start_time Start timestamp * @param int $end_time End timestamp * @param int $board_id Board ID for filtering (0 for all boards) * @return array|WP_Error Analytics data or error */ private function fetch_analytics_from_api( $start_time, $end_time, $board_id = 0 ) { // Build request data $data = [ 'start_time' => $start_time, 'end_time' => $end_time, 'granularity' => $this->determine_granularity( $start_time, $end_time ), 'group_by' => 'request_type', ]; // Add board_id filter if specified (non-zero) if ( $board_id > 0 ) { $data['board_id'] = $board_id; } // Make API request (longer timeout for large date ranges scanning CloudWatch logs) $response = $this->make_request( 'POST', '/logs/analytics', $data, [], 45 ); if ( is_wp_error( $response ) ) { return $response; } // Process and structure the response return $this->process_analytics_response( $response, $start_time, $end_time ); } /** * Process analytics API response into structured format * * @param array $response Raw API response * @param int $start_time Start timestamp * @param int $end_time End timestamp * @return array Processed analytics data */ private function process_analytics_response( $response, $start_time, $end_time ) { $data = wpfval( $response, 'data' ) ?: $response; // Calculate days in range for average $days_in_range = max( 1, ceil( ( $end_time - $start_time ) / DAY_IN_SECONDS ) ); // Time series data $time_series = wpfval( $data, 'time_series' ) ?: []; // Feature breakdown $by_feature = wpfval( $data, 'by_feature' ) ?: []; // Moderation stats $moderation = wpfval( $data, 'moderation' ) ?: [ 'spam_blocked' => 0, 'toxic_detected' => 0, 'policy_violations' => 0, 'clean_passed' => 0, ]; // Summary calculations - prefer by_feature, fallback to time_series $total_credits = 0; $total_requests = 0; $success_count = 0; if ( ! empty( $by_feature ) ) { // Calculate from feature breakdown (more accurate) foreach ( $by_feature as $feature => $stats ) { $total_credits += (float) wpfval( $stats, 'credits' ) ?: 0; $total_requests += (int) wpfval( $stats, 'requests' ) ?: 0; $success_count += (int) wpfval( $stats, 'success_count' ) ?: wpfval( $stats, 'requests' ) ?: 0; } } else { // Fallback: calculate from time series data foreach ( $time_series as $point ) { $total_credits += (float) wpfval( $point, 'credits' ) ?: 0; $total_requests += (int) wpfval( $point, 'requests' ) ?: 0; } $success_count = $total_requests; // Assume all successful when no feature breakdown } $success_rate = $total_requests > 0 ? ( $success_count / $total_requests ) * 100 : 100; return [ 'time_series' => $time_series, 'by_feature' => $by_feature, 'moderation' => $moderation, 'summary' => [ 'total_credits' => round( $total_credits, 2 ), 'total_requests' => $total_requests, 'success_rate' => round( $success_rate, 1 ), 'avg_credits_per_day' => round( $total_credits / $days_in_range, 2 ), ], ]; } /** * Determine granularity based on time range * * @param int $start_time Start timestamp * @param int $end_time End timestamp * @return string Granularity (daily, weekly, monthly) */ private function determine_granularity( $start_time, $end_time ) { $days = ( $end_time - $start_time ) / DAY_IN_SECONDS; if ( $days <= 31 ) { return 'daily'; } elseif ( $days <= 180 ) { return 'weekly'; } else { return 'monthly'; } } /** * Get cached analytics data * * @param string $cache_key Cache key * @return mixed Cached data or false if not found/expired */ private function get_analytics_cache( $cache_key ) { global $wpdb; $table = $wpdb->prefix . 'wpforo_ai_cache'; // Suppress errors and return false on any database issue // This prevents cache table issues from breaking analytics $wpdb->suppress_errors( true ); $result = $wpdb->get_row( $wpdb->prepare( "SELECT response, expires_at FROM {$table} WHERE cache_key = %s AND type = 'analytics' AND expires_at > %d", $cache_key, time() ) ); $wpdb->suppress_errors( false ); // Check for database errors (table doesn't exist, column issues, etc.) if ( $wpdb->last_error ) { return false; } if ( $result && ! empty( $result->response ) ) { $data = json_decode( $result->response, true ); if ( json_last_error() === JSON_ERROR_NONE ) { return $data; } } return false; } /** * Set analytics cache * * @param string $cache_key Cache key * @param array $data Data to cache * @param int $ttl Time to live in seconds * @return bool Success */ private function set_analytics_cache( $cache_key, $data, $ttl = 3600 ) { global $wpdb; $table = $wpdb->prefix . 'wpforo_ai_cache'; $expires_at = time() + $ttl; $cache_value = wp_json_encode( $data ); // Suppress errors - caching failure shouldn't break analytics // This handles cases where the table doesn't exist or has schema issues $wpdb->suppress_errors( true ); $result = $wpdb->replace( $table, [ 'cache_key' => $cache_key, 'type' => 'analytics', 'response' => $cache_value, 'expires_at' => $expires_at, 'postid' => 0, ], [ '%s', '%s', '%s', '%d', '%d' ] ); $wpdb->suppress_errors( false ); return $result !== false && ! $wpdb->last_error; } /** * AJAX handler for running AI insights analysis * * Sends forum content to AI for analysis (sentiment, trending, recommendations) * Uses credits and returns results with HTML rendering. * * @return void Sends JSON response */ public function ajax_run_insight() { // Track start time for logging $start_time = microtime( true ); // Load analytics functions (needed for caching and rendering) require_once WPFORO_DIR . '/admin/pages/tabs/ai-features-tab-analytics.php'; // Verify nonce check_ajax_referer( 'wpforo_ai_insights_nonce', 'nonce' ); // Check user permissions if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Insufficient permissions', false ) ], 403 ); } // Get parameters $insight_type = isset( $_POST['insight_type'] ) ? sanitize_key( $_POST['insight_type'] ) : ''; $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0; // Validate insight type $valid_types = [ 'sentiment', 'trending', 'recommendations', 'deep_analysis', 'sentiment_trend' ]; if ( ! in_array( $insight_type, $valid_types, true ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Invalid insight type', false ) ] ); return; } // Get credit costs $credit_costs = [ 'sentiment' => 2, 'trending' => 1, 'recommendations' => 1, 'deep_analysis' => 5, 'sentiment_trend' => 4, ]; $credit_cost = $credit_costs[ $insight_type ]; // Insight types with daily limits $daily_limit_types = [ 'recommendations' ]; // Check daily limit for restricted insight types if ( in_array( $insight_type, $daily_limit_types, true ) ) { $cached_insights = wpforo_ai_get_cached_insights( $board_id ); if ( isset( $cached_insights[ $insight_type ] ) && ! empty( $cached_insights[ $insight_type ]['timestamp'] ) ) { $cached_date = date( 'Y-m-d', $cached_insights[ $insight_type ]['timestamp'] ); $today_date = date( 'Y-m-d', current_time( 'timestamp' ) ); if ( $cached_date === $today_date ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'This analysis is limited to once per day. Please try again tomorrow.', false ) ] ); return; } } } // Check if tenant has enough credits $status = $this->get_tenant_status(); $credits_remaining = 0; if ( ! is_wp_error( $status ) && isset( $status['subscription']['credits_remaining'] ) ) { $credits_remaining = (int) $status['subscription']['credits_remaining']; } if ( $credits_remaining < $credit_cost ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Insufficient credits for this analysis', false ) ] ); return; } // Switch to the board if needed if ( $board_id > 0 ) { WPF()->change_board( $board_id ); } // Gather forum data for analysis $content_sample = $this->gather_insight_content( $insight_type ); if ( empty( $content_sample ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Not enough forum content for analysis', false ) ] ); return; } // Send to backend for AI analysis $result = $this->run_ai_insight( $insight_type, $content_sample ); if ( is_wp_error( $result ) ) { // Log the error $duration_ms = (int) ( ( microtime( true ) - $start_time ) * 1000 ); WPF()->ai_logs->log( [ 'action_type' => AILogs::ACTION_ANALYTICS_INSIGHTS, 'credits_used' => 0, 'status' => AILogs::STATUS_ERROR, 'request_summary' => 'Insight type: ' . $insight_type, 'error_message' => $result->get_error_message(), 'duration_ms' => $duration_ms, 'user_type' => 'admin', ] ); wp_send_json_error( [ 'message' => $result->get_error_message() ] ); return; } // For deep_analysis, merge database metrics with LLM results if ( $insight_type === 'deep_analysis' ) { $db_metrics = $this->get_deep_analysis_db_metrics(); $result = array_merge( $db_metrics, $result ); } // Cache the result wpforo_ai_cache_insight( $board_id, $insight_type, $result ); // Log successful insight generation $duration_ms = (int) ( ( microtime( true ) - $start_time ) * 1000 ); WPF()->ai_logs->log( [ 'action_type' => AILogs::ACTION_ANALYTICS_INSIGHTS, 'credits_used' => $credit_cost, 'status' => AILogs::STATUS_SUCCESS, 'request_summary' => 'Insight type: ' . $insight_type, 'response_summary' => 'Generated ' . $insight_type . ' analysis successfully', 'duration_ms' => $duration_ms, 'user_type' => 'admin', ] ); // Get updated credits (clear cache first to force refresh) $this->clear_status_cache(); $new_status = $this->get_tenant_status(); $new_credits = 0; if ( ! is_wp_error( $new_status ) && isset( $new_status['subscription']['credits_remaining'] ) ) { $new_credits = (int) $new_status['subscription']['credits_remaining']; } // Render HTML for the results ob_start(); wpforo_ai_render_insight_results( $insight_type, $result ); $html = ob_get_clean(); wp_send_json_success( [ 'html' => $html, 'data' => $result, 'credits_remaining' => $new_credits, ] ); } /** * Gather forum content for AI insight analysis * * @param string $insight_type Type of insight * @return array Content sample for analysis */ private function gather_insight_content( $insight_type ) { $content = []; switch ( $insight_type ) { case 'sentiment': // Get recent posts for sentiment analysis $posts = WPF()->db->get_results( "SELECT p.body, p.created, t.title as topic_title FROM " . WPF()->tables->posts . " p LEFT JOIN " . WPF()->tables->topics . " t ON p.topicid = t.topicid WHERE p.status = 0 ORDER BY p.created DESC LIMIT 200", ARRAY_A ); foreach ( $posts as $post ) { $content[] = [ 'text' => wp_strip_all_tags( $post['body'] ), 'topic' => $post['topic_title'], ]; } break; case 'trending': // Get recent topics with activity metrics using JOIN instead of correlated subquery $seven_days_ago = time() - ( 7 * DAY_IN_SECONDS ); $topics = WPF()->db->get_results( WPF()->db->prepare( "SELECT t.title, t.posts, t.views, t.created, COALESCE(rp.recent_posts, 0) as recent_posts FROM " . WPF()->tables->topics . " t LEFT JOIN ( SELECT topicid, COUNT(*) as recent_posts FROM " . WPF()->tables->posts . " WHERE created > %d GROUP BY topicid ) rp ON t.topicid = rp.topicid WHERE t.status = 0 AND t.created > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY) ORDER BY t.created DESC LIMIT 100", $seven_days_ago ), ARRAY_A ); foreach ( $topics as $topic ) { $content[] = [ 'title' => $topic['title'], 'posts' => (int) $topic['posts'], 'views' => (int) $topic['views'], 'recent_posts' => (int) $topic['recent_posts'], ]; } break; case 'recommendations': // Get forum statistics for recommendations $stats = []; // Total topics and posts $stats['total_topics'] = (int) WPF()->db->get_var( "SELECT COUNT(*) FROM " . WPF()->tables->topics . " WHERE status = 0" ); $stats['total_posts'] = (int) WPF()->db->get_var( "SELECT COUNT(*) FROM " . WPF()->tables->posts . " WHERE status = 0" ); // Unanswered topics $stats['unanswered_topics'] = (int) WPF()->db->get_var( "SELECT COUNT(*) FROM " . WPF()->tables->topics . " WHERE status = 0 AND posts = 1" ); // Active users this week $stats['active_users_week'] = (int) WPF()->db->get_var( "SELECT COUNT(DISTINCT userid) FROM " . WPF()->tables->posts . " WHERE created > UNIX_TIMESTAMP(NOW() - INTERVAL 7 DAY)" ); // Average response time (first reply) $stats['avg_response_hours'] = WPF()->db->get_var( "SELECT AVG(TIMESTAMPDIFF(HOUR, FROM_UNIXTIME(t.created), FROM_UNIXTIME( (SELECT MIN(p.created) FROM " . WPF()->tables->posts . " p WHERE p.topicid = t.topicid AND p.is_first_post = 0) ))) FROM " . WPF()->tables->topics . " t WHERE t.posts > 1 AND t.created > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)" ); // Recent topic titles for context $recent_topics = WPF()->db->get_col( "SELECT title FROM " . WPF()->tables->topics . " WHERE status = 0 ORDER BY created DESC LIMIT 50" ); $content = [ 'stats' => $stats, 'recent_topics' => $recent_topics, ]; break; case 'deep_analysis': // Get comprehensive forum data for deep analysis $data = []; // Get usergroup IDs that have 'aum' (admin user management) permission // These are admins/moderators who should be excluded from contributor analysis $admin_groupids = []; $all_groups = WPF()->db->get_results( "SELECT groupid, cans FROM " . WPF()->tables->usergroups, ARRAY_A ); foreach ( $all_groups as $group ) { $cans = maybe_unserialize( $group['cans'] ); if ( is_array( $cans ) && ! empty( $cans['aum'] ) ) { $admin_groupids[] = (int) $group['groupid']; } } // Fallback to default admin/mod groups if none found if ( empty( $admin_groupids ) ) { $admin_groupids = [ 1, 2 ]; } $admin_groupids_str = implode( ',', $admin_groupids ); // User engagement data - exclude users with admin permissions (aum capability) $data['user_stats'] = WPF()->db->get_results( "SELECT p.userid, COUNT(*) as post_count FROM " . WPF()->tables->posts . " p INNER JOIN " . WPF()->tables->profiles . " pr ON p.userid = pr.userid WHERE p.status = 0 AND p.created > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY) AND pr.groupid NOT IN ({$admin_groupids_str}) GROUP BY p.userid ORDER BY post_count DESC LIMIT 20", ARRAY_A ); // Topic and post length metrics $data['content_metrics'] = WPF()->db->get_row( "SELECT AVG(LENGTH(p.body)) as avg_post_length, AVG(CASE WHEN p.is_first_post = 1 THEN LENGTH(p.body) END) as avg_topic_length FROM " . WPF()->tables->posts . " p WHERE p.status = 0 AND p.created > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)", ARRAY_A ); // Recent posts with content for keyword/sentiment analysis // Exclude users with admin permissions (aum capability) $posts = WPF()->db->get_results( "SELECT p.body, p.created, p.userid, t.title as topic_title FROM " . WPF()->tables->posts . " p LEFT JOIN " . WPF()->tables->topics . " t ON p.topicid = t.topicid INNER JOIN " . WPF()->tables->profiles . " pr ON p.userid = pr.userid WHERE p.status = 0 AND pr.groupid NOT IN ({$admin_groupids_str}) ORDER BY p.created DESC LIMIT 150", ARRAY_A ); // Batch fetch all user display names for user_stats and posts $all_user_ids = array_merge( array_column( $data['user_stats'], 'userid' ), array_column( $posts, 'userid' ) ); $user_names = $this->batch_get_user_display_names( $all_user_ids ); $guest_label = wpforo_phrase( 'Guest', false ); // Get usernames for top posters foreach ( $data['user_stats'] as &$user ) { $user['username'] = $user_names[ $user['userid'] ] ?? $guest_label; } // Build posts array with usernames $data['posts'] = []; foreach ( $posts as $post ) { $timestamp = is_numeric( $post['created'] ) ? $post['created'] : strtotime( $post['created'] ); $data['posts'][] = [ 'text' => wp_strip_all_tags( $post['body'] ), 'topic' => $post['topic_title'], 'username' => $user_names[ $post['userid'] ] ?? $guest_label, 'date' => date( 'Y-m-d H:i', $timestamp ), ]; } // Reply frequency data $data['reply_stats'] = WPF()->db->get_row( "SELECT COUNT(*) as total_posts, SUM(CASE WHEN is_first_post = 0 THEN 1 ELSE 0 END) as total_replies, COUNT(DISTINCT userid) as unique_users FROM " . WPF()->tables->posts . " WHERE status = 0 AND created > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)", ARRAY_A ); $content = $data; break; case 'sentiment_trend': // Get posts with timestamps for trend analysis $posts = WPF()->db->get_results( "SELECT p.body, p.created, t.title as topic_title, u.display_name FROM " . WPF()->tables->posts . " p LEFT JOIN " . WPF()->tables->topics . " t ON p.topicid = t.topicid LEFT JOIN " . WPF()->db->users . " u ON p.userid = u.ID WHERE p.status = 0 AND p.created > DATE_SUB(NOW(), INTERVAL 30 DAY) ORDER BY p.created ASC LIMIT 300", ARRAY_A ); foreach ( $posts as $post ) { $timestamp = is_numeric( $post['created'] ) ? $post['created'] : strtotime( $post['created'] ); $content[] = [ 'text' => wp_strip_all_tags( $post['body'] ), 'topic' => $post['topic_title'], 'timestamp' => date( 'Y-m-d', $timestamp ), 'author' => $post['display_name'] ?: 'Guest', ]; } break; } return $content; } /** * Calculate deep analysis metrics from database * * These are factual metrics that should be computed from the database, * not generated by the LLM. * * @return array Database-calculated metrics */ private function get_deep_analysis_db_metrics() { $metrics = []; // Get total users and active users $total_members = (int) WPF()->db->get_var( "SELECT COUNT(*) FROM " . WPF()->tables->members ); $active_users = (int) WPF()->db->get_var( "SELECT COUNT(DISTINCT userid) FROM " . WPF()->tables->posts . " WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)" ); // Get reply stats $reply_stats = WPF()->db->get_row( "SELECT COUNT(*) as total_posts, SUM(CASE WHEN is_first_post = 0 THEN 1 ELSE 0 END) as total_replies, COUNT(DISTINCT userid) as unique_users FROM " . WPF()->tables->posts . " WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)", ARRAY_A ); $avg_replies_per_user = 0; if ( $reply_stats && $reply_stats['unique_users'] > 0 ) { $avg_replies_per_user = round( (int) $reply_stats['total_replies'] / (int) $reply_stats['unique_users'], 1 ); } $active_users_percent = 0; if ( $total_members > 0 ) { $active_users_percent = round( ( $active_users / $total_members ) * 100, 1 ); } // Get average response time (hours between topic creation and first reply) $avg_response_hours = WPF()->db->get_var( "SELECT AVG(response_time) FROM ( SELECT TIMESTAMPDIFF(HOUR, t.created, (SELECT MIN(p.created) FROM " . WPF()->tables->posts . " p WHERE p.topicid = t.topicid AND p.is_first_post = 0) ) as response_time FROM " . WPF()->tables->topics . " t WHERE t.posts > 1 AND t.created > DATE_SUB(NOW(), INTERVAL 30 DAY) ) as response_times WHERE response_time IS NOT NULL" ); // Get top repliers with usernames $top_repliers_raw = WPF()->db->get_results( "SELECT userid, COUNT(*) as reply_count FROM " . WPF()->tables->posts . " WHERE status = 0 AND is_first_post = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY userid ORDER BY reply_count DESC LIMIT 5", ARRAY_A ); // Batch fetch user display names with proper fallback chain $replier_user_ids = array_column( $top_repliers_raw, 'userid' ); $replier_names = $this->batch_get_user_display_names( $replier_user_ids ); $guest_label = wpforo_phrase( 'Guest', false ); $top_repliers = []; foreach ( $top_repliers_raw as $replier ) { $top_repliers[] = [ 'username' => $replier_names[ $replier['userid'] ] ?? $guest_label, 'reply_count' => (int) $replier['reply_count'], 'sentiment' => 'neutral', // Will be filled by LLM if available ]; } // Get content metrics $content_stats = WPF()->db->get_row( "SELECT AVG(LENGTH(body) / 5) as avg_reply_words, AVG(CASE WHEN is_first_post = 1 THEN LENGTH(body) / 5 END) as avg_topic_words FROM " . WPF()->tables->posts . " WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)", ARRAY_A ); // Build user_engagement data $metrics['user_engagement'] = [ 'avg_replies_per_user' => $avg_replies_per_user, 'active_users_percent' => $active_users_percent, 'lurker_percent' => max( 0, 100 - $active_users_percent ), 'avg_response_time_hours' => round( floatval( $avg_response_hours ) ?: 0, 1 ), 'top_repliers' => $top_repliers, 'summary' => '', // Will be filled by LLM ]; // Build content_metrics data $avg_topic_words = round( floatval( $content_stats['avg_topic_words'] ?? 0 ) ); $avg_reply_words = round( floatval( $content_stats['avg_reply_words'] ?? 0 ) ); $detailed_percent = 0; if ( $avg_reply_words > 0 ) { // Consider replies > 100 words as "detailed" $detailed_count = (int) WPF()->db->get_var( "SELECT COUNT(*) FROM " . WPF()->tables->posts . " WHERE status = 0 AND is_first_post = 0 AND LENGTH(body) / 5 > 100 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)" ); $total_replies = (int) ( $reply_stats['total_replies'] ?? 1 ); $detailed_percent = $total_replies > 0 ? round( ( $detailed_count / $total_replies ) * 100 ) : 0; } $metrics['content_metrics'] = [ 'avg_topic_length_words' => $avg_topic_words, 'avg_reply_length_words' => $avg_reply_words, 'detailed_discussions_percent' => $detailed_percent, 'quick_exchanges_percent' => max( 0, 100 - $detailed_percent ), 'summary' => '', // Will be filled by LLM ]; // Get activity patterns from database $peak_hours_raw = WPF()->db->get_results( "SELECT HOUR(created) as hour, COUNT(*) as cnt FROM " . WPF()->tables->posts . " WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY HOUR(created) ORDER BY cnt DESC LIMIT 3", ARRAY_A ); $peak_hours = []; foreach ( $peak_hours_raw as $h ) { $peak_hours[] = sprintf( '%02d:00', $h['hour'] ); } $peak_days_raw = WPF()->db->get_results( "SELECT DAYNAME(created) as day_name, COUNT(*) as cnt FROM " . WPF()->tables->posts . " WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY DAYNAME(created) ORDER BY cnt DESC LIMIT 3", ARRAY_A ); $peak_days = []; foreach ( $peak_days_raw as $d ) { $peak_days[] = $d['day_name']; } // Determine trend by comparing last 15 days to previous 15 days $recent_count = (int) WPF()->db->get_var( "SELECT COUNT(*) FROM " . WPF()->tables->posts . " WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 15 DAY)" ); $previous_count = (int) WPF()->db->get_var( "SELECT COUNT(*) FROM " . WPF()->tables->posts . " WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY) AND created <= DATE_SUB(NOW(), INTERVAL 15 DAY)" ); $trend = 'stable'; if ( $previous_count > 0 ) { $change = ( $recent_count - $previous_count ) / $previous_count; if ( $change > 0.1 ) { $trend = 'increasing'; } elseif ( $change < -0.1 ) { $trend = 'decreasing'; } } $metrics['activity_patterns'] = [ 'peak_hours' => $peak_hours, 'peak_days' => $peak_days, 'trend' => $trend, 'summary' => '', // Will be filled by LLM ]; return $metrics; } /** * Run AI insight analysis via backend API * * @param string $insight_type Type of insight * @param array $content Content to analyze * @return array|WP_Error Analysis result or error */ private function run_ai_insight( $insight_type, $content ) { $data = [ 'insight_type' => $insight_type, 'content' => $content, ]; $response = $this->post( '/analytics/insights', $data ); if ( is_wp_error( $response ) ) { return $response; } // Extract result from response $result = wpfval( $response, 'data' ) ?: $response; // Ensure expected structure based on type switch ( $insight_type ) { case 'sentiment': // 7 emotion categories $result = [ 'happy' => (int) wpfval( $result, 'happy' ) ?: 0, 'excited' => (int) wpfval( $result, 'excited' ) ?: 0, 'neutral' => (int) wpfval( $result, 'neutral' ) ?: 0, 'confused' => (int) wpfval( $result, 'confused' ) ?: 0, 'frustrated' => (int) wpfval( $result, 'frustrated' ) ?: 0, 'angry' => (int) wpfval( $result, 'angry' ) ?: 0, 'sad' => (int) wpfval( $result, 'sad' ) ?: 0, 'summary' => wpfval( $result, 'summary' ) ?: '', ]; break; case 'trending': $result = [ 'topics' => wpfval( $result, 'topics' ) ?: [], 'summary' => wpfval( $result, 'summary' ) ?: '', ]; break; case 'recommendations': $result = [ 'recommendations' => wpfval( $result, 'recommendations' ) ?: [], 'summary' => wpfval( $result, 'summary' ) ?: '', ]; break; } return $result; } /** * Get forum IDs the current user can view (for search filtering) * * Uses cached WPF()->current_user_accesses (board-specific). * Returns null if user can access all forums (no filtering needed). * * @return array|null Array of accessible forum IDs, or null for full access */ public function get_accessible_forumids() { // Admins see everything if ( current_user_can( 'administrator' ) ) { return null; } // Get all forums for current board (cached by usergroup) $all_forums = WPF()->forum->get_forums( [ 'type' => 'forum' ] ); if ( empty( $all_forums ) ) { return null; } $accessible = []; $total_forums = 0; foreach ( $all_forums as $forum ) { if ( empty( $forum['is_cat'] ) ) { // Skip categories $total_forums++; // 'vf' = can view forum if ( WPF()->perm->forum_can( 'vf', $forum['forumid'] ) ) { $accessible[] = (int) $forum['forumid']; } } } // If user can access all forums, return null (no filtering needed) if ( count( $accessible ) === $total_forums ) { return null; } return $accessible; } /** * Perform semantic search query * * @param string $query Search query text * @param int $limit Maximum number of results to return * @param array $filters Optional filters * @return array|WP_Error Search results or error object */ public function semantic_search( $query, $limit = 10, $filters = [] ) { if ( empty( $query ) ) { return new \WP_Error( 'empty_query', wpforo_phrase( 'Search query cannot be empty', false ) ); } // Get tenant ID from stored status $status = $this->get_tenant_status(); if ( is_wp_error( $status ) ) { return $status; } $tenant_id = wpfval( $status, 'tenant_id' ); if ( empty( $tenant_id ) ) { return new \WP_Error( 'no_tenant_id', wpforo_phrase( 'Tenant ID not found', false ) ); } // Automatically add current board_id to filters (multi-board support) // Note: board_id is stored as string in vector metadata, so we send it as string $current_boardid = (string) WPF()->board->get_current( 'boardid' ); if ( ! isset( $filters['board_id'] ) ) { $filters['board_id'] = $current_boardid; } // Add forum access filtering (only forums current user can view) // Skip if already set by VectorStorageManager (avoids double-add) // Returns null for admins or users with full access (no filtering needed) if ( ! isset( $filters['accessible_forumids'] ) ) { $accessible_forumids = $this->get_accessible_forumids(); if ( $accessible_forumids !== null ) { $filters['accessible_forumids'] = $accessible_forumids; } } $data = [ 'tenant_id' => $tenant_id, 'query' => sanitize_text_field( $query ), 'limit' => min( (int) $limit, 100 ), // Cap at 100 results ]; if ( ! empty( $filters ) ) { $data['filters'] = $filters; } // Add quality parameter from settings (for re-ranking model selection) $search_quality = wpfval( WPF()->settings->ai, 'search_quality' ); if ( ! empty( $search_quality ) ) { $data['quality'] = sanitize_text_field( $search_quality ); } // Add minimum score threshold from settings (server-side filtering) $min_score_setting = (int) wpfval( WPF()->settings->ai, 'search_min_score' ); if ( $min_score_setting > 0 ) { $data['min_score'] = $min_score_setting / 100; // Convert percentage to 0-1 } // Add custom knowledge parameters (Business+ plans) if ( $this->is_custom_knowledge_enabled() ) { $data['include_custom_knowledge'] = true; $data['knowledge_priority'] = [ 'search_priority' => $this->get_knowledge_priorities( 'search' ), ]; } $response = $this->post( '/search/semantic', $data ); if ( is_wp_error( $response ) ) { $this->log_error( 'semantic_search_failed', $response->get_error_message() ); return $response; } $this->log_info( 'semantic_search_completed', [ 'query' => $query, 'results_count' => wpfval( $response, 'total' ) ?: 0 ] ); return $response; } /** * Generate embedding vector for content * * Used for local storage mode - generates embeddings via cloud API * but stores them locally in WordPress database. * * Supports multimodal image indexing (Professional+ plans): * - Pass images array with URLs from site domain * - Images are processed by vision models * - Returns processed_content with image descriptions appended * * Supports document indexing (Professional+ plans): * - Pass documents array with URLs from site domain * - Documents are processed (text extraction, OCR, embedded images) * - Returns processed_content with document text appended * * @param string $content Content text to embed * @param array $images Optional. Array of image data: [['url' => '...', 'attach_id' => 123], ...] * @param string $topic_context Optional. Topic title for better image/document descriptions * @param array $documents Optional. Array of document data: [['url' => '...', 'attach_id' => 123], ...] * @return array|WP_Error Array with 'embedding' key or error object. Also includes * 'processed_content' if images/documents were processed. */ public function generate_embedding( $content, $images = [], $topic_context = '', $documents = [] ) { if ( empty( $content ) ) { return new \WP_Error( 'empty_content', wpforo_phrase( 'Content cannot be empty', false ) ); } // Get tenant ID from stored status $status = $this->get_tenant_status(); if ( is_wp_error( $status ) ) { return $status; } $tenant_id = wpfval( $status, 'tenant_id' ); if ( empty( $tenant_id ) ) { return new \WP_Error( 'no_tenant_id', wpforo_phrase( 'Tenant ID not found', false ) ); } $data = [ 'tenant_id' => $tenant_id, 'content' => $content, ]; // Add image processing parameters if images provided if ( ! empty( $images ) && is_array( $images ) ) { // Get site domain (without protocol) $site_url = get_site_url(); $parsed = wp_parse_url( $site_url ); $domain = $parsed['host'] ?? ''; $data['images'] = $images; $data['site_domain'] = $domain; if ( ! empty( $topic_context ) ) { $data['topic_context'] = $topic_context; } } // Add document processing parameters if documents provided if ( ! empty( $documents ) && is_array( $documents ) ) { if ( empty( $data['site_domain'] ) ) { $site_url = get_site_url(); $parsed = wp_parse_url( $site_url ); $data['site_domain'] = $parsed['host'] ?? ''; } $data['documents'] = $documents; if ( ! empty( $topic_context ) && empty( $data['topic_context'] ) ) { $data['topic_context'] = $topic_context; } } $response = $this->post( '/search/embedding/generate', $data ); if ( is_wp_error( $response ) ) { $this->log_error( 'embedding_generation_failed', $response->get_error_message() ); return $response; } // Validate response if ( ! isset( $response['embedding'] ) || ! is_array( $response['embedding'] ) ) { return new \WP_Error( 'invalid_response', wpforo_phrase( 'Invalid embedding response from API', false ) ); } $log_data = [ 'dimensions' => count( $response['embedding'] ), 'credits_used' => $response['credits_used'] ?? 1, ]; // Log image processing stats if present if ( ! empty( $response['image_processing'] ) ) { $log_data['images_processed'] = $response['image_processing']['images_processed'] ?? 0; $log_data['images_skipped'] = $response['image_processing']['images_skipped'] ?? 0; } // Log document processing stats if present if ( ! empty( $response['document_processing'] ) ) { $log_data['documents_processed'] = $response['document_processing']['documents_processed'] ?? 0; $log_data['total_pages'] = $response['document_processing']['total_pages'] ?? 0; } $this->log_info( 'embedding_generated', $log_data ); return $response; } /** * Generate embedding vectors for multiple content items in a single request * * Used for efficient local storage indexing - generates all embeddings * in one API call, matching the cloud indexing pattern. * * @param array $items Array of items: [ ['id' => 'post_123', 'content' => '...'], ... ] * @return array|WP_Error Response with 'results' array containing embeddings, or error */ public function generate_embeddings_batch( $items, $topic_count = null ) { if ( empty( $items ) || ! is_array( $items ) ) { return new \WP_Error( 'empty_items', wpforo_phrase( 'Items array cannot be empty', false ) ); } // Get tenant ID from stored status $status = $this->get_tenant_status(); if ( is_wp_error( $status ) ) { return $status; } $tenant_id = wpfval( $status, 'tenant_id' ); if ( empty( $tenant_id ) ) { return new \WP_Error( 'no_tenant_id', wpforo_phrase( 'Tenant ID not found', false ) ); } // Format items for API $api_items = []; foreach ( $items as $item ) { if ( ! empty( $item['id'] ) && ! empty( $item['content'] ) ) { $api_items[] = [ 'id' => (string) $item['id'], 'content' => $item['content'], ]; } } if ( empty( $api_items ) ) { return new \WP_Error( 'no_valid_items', wpforo_phrase( 'No valid items to process', false ) ); } $data = [ 'tenant_id' => $tenant_id, 'items' => $api_items, ]; // Add topic_count for credit charging (charge per topic, not per post) // IMPORTANT: Always send topic_count when provided, including 0 for continuation chunks // If topic_count is not sent, API falls back to per-item charging (expensive!) if ( $topic_count !== null ) { $data['topic_count'] = (int) $topic_count; } \wpforo_ai_log( 'debug', sprintf( 'generate_embeddings_batch: topic_count=%s, items=%d, data_keys=%s', $topic_count !== null ? (string) $topic_count : 'NULL', count( $api_items ), implode( ',', array_keys( $data ) ) ), 'Client' ); // API Gateway has 29-second hard limit, so use 25 seconds to fail fast // With smaller batches (5 topics for local mode), this should be sufficient $response = $this->api_post( '/search/embedding/generate-batch', $data, 25 ); if ( is_wp_error( $response ) ) { $this->log_error( 'batch_embedding_failed', $response->get_error_message() ); return $response; } // Validate response if ( ! isset( $response['results'] ) || ! is_array( $response['results'] ) ) { return new \WP_Error( 'invalid_response', wpforo_phrase( 'Invalid batch embedding response from API', false ) ); } $this->log_info( 'batch_embedding_completed', [ 'total_items' => $response['total_items'] ?? count( $api_items ), 'successful_items' => $response['successful_items'] ?? 0, 'failed_items' => $response['failed_items'] ?? 0, 'credits_used' => $response['credits_used'] ?? 0, ] ); return $response; } /** * Enhance search results with AI-generated summary and recommendations * * Credits are consumed based on quality tier selected. * * @param string $query The original search query * @param array $results Array of search results (title, excerpt, url, score) * @param string $user_language Language for AI response (default: English) * @return array|WP_Error Enhancement data or error object */ public function enhance_search_results( $query, $results, $user_language = 'English' ) { // Check if AI Summary & Recommendations is enabled // Handle various stored formats: true, "1", 1, "on" = enabled; false, "0", 0, "" = disabled $enhance_setting = wpfval( WPF()->settings->ai, 'search_enhance' ); // Consider enabled if value is truthy and not explicitly "0" or 0 $enhance_enabled = ! empty( $enhance_setting ) && $enhance_setting !== '0' && $enhance_setting !== 0 && $enhance_setting !== 'false'; if ( ! $enhance_enabled ) { $this->log_info( 'search_enhance_disabled', [ 'setting_value' => $enhance_setting, 'setting_type' => gettype( $enhance_setting ), ] ); return [ 'success' => false, 'disabled' => true, 'summary' => '', 'quick_answer' => '', 'recommendations' => [], ]; } if ( empty( $query ) || empty( $results ) ) { return new \WP_Error( 'invalid_params', wpforo_phrase( 'Query and results are required', false ) ); } // Get tenant ID from stored status $status = $this->get_tenant_status(); if ( is_wp_error( $status ) ) { return $status; } $tenant_id = wpfval( $status, 'tenant_id' ); if ( empty( $tenant_id ) ) { return new \WP_Error( 'no_tenant_id', wpforo_phrase( 'Tenant ID not found', false ) ); } // Format results for the API (max 5 results) $formatted_results = []; $result_num = 1; foreach ( array_slice( $results, 0, 5 ) as $result ) { $formatted_results[] = [ 'result_number' => $result_num, 'title' => wpfval( $result, 'title' ) ?: '', 'excerpt' => wpfval( $result, 'content' ) ?: '', 'url' => wpfval( $result, 'url' ) ?: '', 'score' => ( wpfval( $result, 'score' ) ?: 0 ) / 100, // Convert from % back to 0-1 ]; $result_num++; } $data = [ 'tenant_id' => $tenant_id, 'query' => sanitize_text_field( $query ), 'user_language' => sanitize_text_field( $user_language ), 'results' => $formatted_results, ]; // Add quality parameter from settings (for AI summary/recommendations model selection) $enhance_quality = wpfval( WPF()->settings->ai, 'search_enhance_quality' ); if ( ! empty( $enhance_quality ) ) { $data['quality'] = sanitize_text_field( $enhance_quality ); } $response = $this->post( '/search/enhance', $data ); if ( is_wp_error( $response ) ) { $this->log_error( 'search_enhance_failed', $response->get_error_message() ); return $response; } $this->log_info( 'search_enhance_completed', [ 'query' => $query, 'results_count' => count( $formatted_results ), 'processing_time' => wpfval( $response, 'processing_time_ms' ) ?: 0, ] ); return $response; } /** * Get user's language for AI responses * * Priority order: * 1. Explicit language code parameter (from POST/request) * 2. User preference (from user_meta if logged in) * 3. Board AI settings (search_language) * 4. Board locale * 5. WordPress locale * 6. Default (English) * * @param string|null $language_code Explicit language code (e.g., 'en_US', 'de_DE') * @return string Language name (e.g., "English", "Spanish", "French") */ public function get_user_language( $language_code = null, $setting_key = 'search_language' ) { // Build language map from master list (2-letter code => English name) $language_map = []; foreach ( wpforo_get_ai_languages() as $lang ) { if ( ! isset( $language_map[ $lang['code'] ] ) ) { $language_map[ $lang['code'] ] = $lang['name']; } } $locale = null; // 1. Use explicit language code if provided if ( ! empty( $language_code ) ) { $locale = $language_code; } // 2. If no explicit code, try user preferences (logged in users only) if ( empty( $locale ) ) { $user_id = WPF()->current_userid; if ( $user_id > 0 ) { $saved_prefs = get_user_meta( $user_id, 'wpforo_ai_search', true ); if ( is_array( $saved_prefs ) && ! empty( $saved_prefs['language'] ) ) { $locale = $saved_prefs['language']; } } } // 3. If still no locale, try board AI settings if ( empty( $locale ) ) { $locale = wpforo_setting( 'ai', $setting_key ); } // 4. If still no locale, try board locale if ( empty( $locale ) ) { $locale = wpfval( WPF()->board, 'locale' ); } // 5. If still no locale, use WordPress locale if ( empty( $locale ) ) { $locale = get_locale(); } // Extract 2-letter language code from locale (e.g., 'en_US' -> 'en') $lang_code = substr( $locale, 0, 2 ); if ( isset( $language_map[ $lang_code ] ) ) { return $language_map[ $lang_code ]; } // Default to English return 'English'; } /** * Replace AI link markers with actual HTML links * * Converts [[#N]] and [[#N:Title]] markers to clickable links * * @param string $text Text containing link markers * @param array $url_map Map of result numbers to URLs (1-indexed) * @return string Text with markers replaced by HTML links */ private function replace_ai_link_markers( $text, $url_map ) { if ( empty( $text ) ) { return ''; } // Replace [[#N:Title]] format - title with link $text = preg_replace_callback( '/\[\[#(\d+):([^\]]+)\]\]/', function ( $matches ) use ( $url_map ) { $num = (int) $matches[1]; // Strip guillemet quotes «» from title (AI uses them as formatting markers) $title = trim( $matches[2], '«» ' ); $title = esc_html( $title ); $url = isset( $url_map[ $num ] ) ? esc_url( $url_map[ $num ] ) : '#'; return '' . $title . ''; }, $text ); // Replace [[#N]] format - just number with link $text = preg_replace_callback( '/\[\[#(\d+)\]\]/', function ( $matches ) use ( $url_map ) { $num = (int) $matches[1]; $url = isset( $url_map[ $num ] ) ? esc_url( $url_map[ $num ] ) : '#'; return '#' . $num . ''; }, $text ); return $text; } /** * Generate HTML for AI search recommendations section * * Builds the complete HTML for the recommendations section server-side * to avoid JavaScript having to handle HTML escaping issues. * * @param array $recommendations Array of recommendation objects with title, recommendation, url, result_number * @return string Complete HTML for the recommendations section */ private function render_recommendations_html( $recommendations ) { if ( empty( $recommendations ) ) { return ''; } $html = '...content...(clean tag without attributes) * * @param string $content Post content (HTML) * @return string Content with quoted sections removed */ public function strip_quoted_content( $content ) { if ( empty( $content ) ) { return $content; } // Pattern 1: Remove [quote ...attributes...] shortcodes // Matches [quote data-userid="1" data-postid="488"]...[/quote] // and [quote anything...]...[/quote] // Uses DOTALL flag (s) to match across newlines $content = preg_replace( '/\[quote\s+[^\]]+\].*?\[\/quote\]/is', '', $content ); // Pattern 2: Remove
tags that have ANY attributes // This indicates a quoted post (wpForo adds data-* attributes) // Matches...// But NOT...(clean tag = user content) $content = preg_replace( '/]+>.*?<\/blockquote>/is', '', $content ); // Clean up any resulting multiple blank lines $content = preg_replace( '/(\r?\n){3,}/', "\n\n", $content ); return trim( $content ); } /** * Clean post content for indexing * * Applies all content cleaning transformations: * - Strip quoted content (duplicates from other posts) * - Future: other content cleaning rules * * @param string $content Post content (HTML) * @return string Cleaned content ready for indexing */ public function clean_content_for_indexing( $content ) { // Strip quoted content first $content = $this->strip_quoted_content( $content ); // Future: Add other cleaning rules here return $content; } /** * Clean content for search result display * * Strips HTML tags, shortcodes, and Lambda processing markers * (image descriptions, document content blocks) from search result excerpts. * These markers are useful for embeddings but should not be shown to users. * * @param string $content Raw content from search result (cloud excerpt or local preview) * @return string Cleaned content for display */ public function clean_content_for_search_display( $content ) { // Strip HTML tags $content = wp_strip_all_tags( $content ); // Remove [TOPIC] or [TOPIC: title] prefix (cloud format) $content = preg_replace( '/^\[TOPIC[^\]]*\]\s*/i', '', $content ); // Remove "Topic: Title\n\n" prefix (local format) $content = preg_replace( '/^Topic:\s*[^\n]*\n+/i', '', $content ); // Count image and document markers BEFORE stripping (for attachment summary) $image_count = preg_match_all( '/\[IMAGE:\s*[^\]]*\]/', $content ); $doc_matches = []; preg_match_all( '/\[DOCUMENT:\s*([^\]]*)\]/', $content, $doc_matches ); $doc_count = count( $doc_matches[0] ); // Extract page counts from document markers like [DOCUMENT: filename.pdf (5 pages)] $total_pages = 0; if ( $doc_count > 0 ) { foreach ( $doc_matches[1] as $doc_info ) { if ( preg_match( '/\((\d+)\s+pages?\)/', $doc_info, $page_match ) ) { $total_pages += (int) $page_match[1]; } } } // Strip enrichment tags added for embedding quality: [FORUM: name], [SOLVED], [BEST ANSWER] $content = preg_replace( '/\[(?:FORUM|SOLVED|BEST ANSWER)[^\]]*\]/', '', $content ); // Strip wpForo shortcodes: [attach]N[/attach], [attach]N,M[/attach] $content = preg_replace( '/\[attach\]\d+(?:,\d+)?\[\/attach\]/', '', $content ); // Strip any remaining shortcode-like patterns: [something]...[/something] or [something] $content = preg_replace( '/\[(?:\/)?[a-zA-Z0-9_-]+(?:\s[^\]]*?)?\]/', '', $content ); // Strip Lambda image processing markers $content = preg_replace( '/---\s*Image\s+Content\s*---/', '', $content ); $content = preg_replace( '/\[IMAGE:\s*[^\]]*\]/', '', $content ); // Strip Lambda document processing markers $content = preg_replace( '/---\s*Document\s+Content\s*---/', '', $content ); $content = preg_replace( '/\[DOCUMENT:\s*[^\]]*\]/', '', $content ); $content = preg_replace( '/\[\/DOCUMENT\]/', '', $content ); $content = preg_replace( '/\[DOC_IMAGE:\s*[^\]]*\]/', '', $content ); // Normalize whitespace $content = preg_replace( '/\s+/', ' ', $content ); $content = trim( $content ); // Append attachment summary (same format as local mode build_content_preview) $attachments = []; if ( $doc_count > 0 ) { if ( $total_pages > 0 ) { $attachments[] = sprintf( '%d %s, %d %s', $doc_count, $doc_count === 1 ? 'document' : 'documents', $total_pages, $total_pages === 1 ? 'page' : 'pages' ); } else { $attachments[] = sprintf( '%d %s', $doc_count, $doc_count === 1 ? 'document' : 'documents' ); } } if ( $image_count > 0 ) { $attachments[] = sprintf( '%d %s', $image_count, $image_count === 1 ? 'image' : 'images' ); } if ( ! empty( $attachments ) ) { $content .= ' [+ ' . implode( ', ', $attachments ) . ']'; } return $content; } // ========================================================================= // AI BOT REPLY METHODS // ========================================================================= /** * Render Bot Reply button in post action buttons * * Displays an AI bot icon button before the quote button that allows * moderators to generate AI-powered replies to posts. * * @param array|string $button_html Current button HTML (may be array or string) * @param string $button Button type * @param array $forum Forum data * @param array $topic Topic data * @param array $post Post data * @return array Modified button HTML array */ public function render_bot_reply_button( $button_html, $button, $forum, $topic, $post ) { // Ensure $button_html is an array if ( ! is_array( $button_html ) ) { $button_html = $button_html ? [ $button_html ] : []; } // Check if Bot Reply feature is enabled in settings if ( ! wpfval( WPF()->settings->ai, 'bot_reply' ) ) { return $button_html; } // Check if feature is available for this plan (wpforo AI specific) if ( ! $this->is_feature_available( 'ai_bot_reply' ) ) { return $button_html; } // Get IDs $forumid = (int) ( wpfval( $forum, 'forumid' ) ?: wpfval( $topic, 'forumid' ) ); $topicid = (int) wpfval( $topic, 'topicid' ); $postid = (int) wpfval( $post, 'postid' ); $is_closed = (int) wpfval( $topic, 'closed' ); $is_approve = (int) wpfval( $post, 'status' ); // Skip if topic closed, post unapproved, or missing IDs (same as wpforo-aibot) if ( $is_closed || $is_approve || ! $postid ) { return $button_html; } // Permission check: Can reply OR (is owner AND can reply to own) // Plus: Must have 'au' (approve/unapprove) permission (moderator/admin only) $can_reply = WPF()->perm->forum_can( 'cr', $forumid ); $is_owner = wpforo_is_owner( wpforo_bigintval( wpfval( $topic, 'userid' ) ), (string) wpfval( $topic, 'email' ) ); $can_own_reply = $is_owner && WPF()->perm->forum_can( 'ocr', $forumid ); if ( $can_reply || $can_own_reply ) { if ( WPF()->perm->forum_can( 'au', $forumid ) ) { $layout = WPF()->forum->get_layout( $forumid ); $layout_class = 'wpforo_layout_' . $layout; // Build the Bot Reply button HTML (matching wpforo-aibot structure exactly) $button_html[] = ''; } } return $button_html; } /** * Render Suggest Reply button in reply form * * Displays a "Suggest Reply" button before the "Add Reply" submit button * that loads AI-generated content into the TinyMCE editor. * * @param array $topic Topic data * @param array $values Form values (empty for new reply, populated for edit) * @param array $forum Forum data * @return void */ public function render_suggest_reply_button( $topic, $values, $forum ) { // Skip if editing (values is not empty means edit mode) if ( ! empty( $values ) && wpfval( $values, 'postid' ) ) { return; } // Check if Bot Reply feature is enabled if ( ! wpfval( WPF()->settings->ai, 'bot_reply' ) ) { return; } // Check if feature is available for this plan if ( ! $this->is_feature_available( 'ai_bot_reply' ) ) { return; } // Check if topic is closed if ( ! empty( $topic['closed'] ) ) { return; } // Check if user has 'au' permission for this forum $forumid = wpfval( $forum, 'forumid' ) ?: wpfval( $topic, 'forumid' ); if ( ! $forumid || ! WPF()->perm->forum_can( 'au', $forumid ) ) { return; } $topic_id = (int) wpfval( $topic, 'topicid' ); if ( ! $topic_id ) { return; } // Render the Suggest Reply button ?> wpforo_phrase( 'Security check failed', false ) ], 403 ); } // Check if feature is enabled and available if ( ! wpfval( WPF()->settings->ai, 'bot_reply' ) || ! $this->is_feature_available( 'ai_bot_reply' ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'AI Bot Reply feature is not available', false ) ], 403 ); } // Get parameters $post_id = (int) wpfval( $_POST, 'post_id' ); $topic_id = (int) wpfval( $_POST, 'topic_id' ); if ( ! $post_id || ! $topic_id ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Invalid request parameters', false ) ], 400 ); } // Get topic and post data $topic = WPF()->topic->get_topic( $topic_id ); if ( ! $topic ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Topic not found', false ) ], 404 ); } // Check permission $forumid = (int) $topic['forumid']; if ( ! WPF()->perm->forum_can( 'au', $forumid ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Permission denied', false ) ], 403 ); } // Check if topic is closed if ( ! empty( $topic['closed'] ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Topic is closed', false ) ], 403 ); } // Check rate limits $limit_error = $this->check_bot_reply_limits( $topic_id ); if ( $limit_error ) { wp_send_json_error( [ 'message' => $limit_error ], 429 ); } // Get the post being replied to $parent_post = WPF()->post->get_post( $post_id ); if ( ! $parent_post ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Post not found', false ) ], 404 ); } // Get first post for topic context (if replying to a reply, not the first post) $first_post = null; if ( (int) $parent_post['is_first_post'] !== 1 ) { $first_post = WPF()->post->get_post( $topic['first_postid'] ); } // Generate AI reply $result = $this->generate_bot_reply( $topic, $parent_post, $first_post ); if ( is_wp_error( $result ) ) { // Log error if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) { WPF()->ai_logs->log( [ 'action_type' => AILogs::ACTION_BOT_REPLY, 'credits_used' => 0, 'status' => AILogs::STATUS_ERROR, 'content_type' => 'topic', 'content_id' => $topic_id, 'topicid' => $topic_id, 'forumid' => $forumid, 'request_summary' => sprintf( 'Bot reply to topic: %s', wp_trim_words( $topic['title'], 10 ) ), 'error_message' => $result->get_error_message(), 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ), ] ); } wp_send_json_error( [ 'message' => $result->get_error_message() ], 500 ); } // Create the bot reply post $new_post_id = $this->create_bot_reply_post( $topic, $parent_post, $result['reply'] ); if ( is_wp_error( $new_post_id ) ) { // Log error if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) { WPF()->ai_logs->log( [ 'action_type' => AILogs::ACTION_BOT_REPLY, 'credits_used' => $result['credits_used'] ?? 0, 'status' => AILogs::STATUS_ERROR, 'content_type' => 'topic', 'content_id' => $topic_id, 'topicid' => $topic_id, 'forumid' => $forumid, 'request_summary' => sprintf( 'Bot reply to topic: %s', wp_trim_words( $topic['title'], 10 ) ), 'error_message' => $new_post_id->get_error_message(), 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ), ] ); } wp_send_json_error( [ 'message' => $new_post_id->get_error_message() ], 500 ); } $credits_used = $result['credits_used'] ?? 0; // Log success if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) { WPF()->ai_logs->log( [ 'action_type' => AILogs::ACTION_BOT_REPLY, 'credits_used' => $credits_used, 'status' => AILogs::STATUS_SUCCESS, 'content_type' => 'topic', 'content_id' => $topic_id, 'topicid' => $topic_id, 'forumid' => $forumid, 'request_summary' => sprintf( 'Bot reply to topic: %s', wp_trim_words( $topic['title'], 10 ) ), 'response_summary' => sprintf( 'Created post #%d', $new_post_id ), 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ), ] ); } wp_send_json_success( [ 'post_id' => $new_post_id, 'credits_used' => $credits_used, 'message' => wpforo_phrase( 'Bot reply created successfully', false ), ] ); } /** * AJAX handler for Suggest Reply * * Generates an AI reply suggestion and returns it for insertion into the editor. * * @return void */ public function ajax_suggest_reply() { // Track start time for logging $_log_start_time = microtime( true ); // Verify nonce if ( ! wp_verify_nonce( sanitize_text_field( wpfval( $_POST, '_wpnonce' ) ), 'wpforo_ai_suggest_reply' ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Security check failed', false ) ], 403 ); } // Check if feature is enabled and available if ( ! wpfval( WPF()->settings->ai, 'bot_reply' ) || ! $this->is_feature_available( 'ai_bot_reply' ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'AI Bot Reply feature is not available', false ) ], 403 ); } // Get parameters $topic_id = (int) wpfval( $_POST, 'topic_id' ); $parent_id = (int) wpfval( $_POST, 'parent_id' ); // Optional: if replying to specific post if ( ! $topic_id ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Invalid request parameters', false ) ], 400 ); } // Get topic data $topic = WPF()->topic->get_topic( $topic_id ); if ( ! $topic ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Topic not found', false ) ], 404 ); } // Check permission $forumid = (int) $topic['forumid']; if ( ! WPF()->perm->forum_can( 'au', $forumid ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Permission denied', false ) ], 403 ); } // Get the post being replied to (default to first post if no parent specified) $parent_post = null; if ( $parent_id ) { $parent_post = WPF()->post->get_post( $parent_id ); } if ( ! $parent_post ) { $parent_post = WPF()->post->get_post( $topic['first_postid'] ); } if ( ! $parent_post ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Post not found', false ) ], 404 ); } // Get first post for topic context (if replying to a reply) $first_post = null; if ( (int) $parent_post['is_first_post'] !== 1 ) { $first_post = WPF()->post->get_post( $topic['first_postid'] ); } // Generate AI reply $result = $this->generate_bot_reply( $topic, $parent_post, $first_post ); if ( is_wp_error( $result ) ) { // Log error if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) { WPF()->ai_logs->log( [ 'action_type' => AILogs::ACTION_SUGGEST_REPLY, 'credits_used' => 0, 'status' => AILogs::STATUS_ERROR, 'content_type' => 'topic', 'content_id' => $topic_id, 'topicid' => $topic_id, 'forumid' => $forumid, 'request_summary' => sprintf( 'Suggest reply for topic: %s', wp_trim_words( $topic['title'], 10 ) ), 'error_message' => $result->get_error_message(), 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ), ] ); } wp_send_json_error( [ 'message' => $result->get_error_message() ], 500 ); } $credits_used = $result['credits_used'] ?? 0; // Log success if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) { WPF()->ai_logs->log( [ 'action_type' => AILogs::ACTION_SUGGEST_REPLY, 'credits_used' => $credits_used, 'status' => AILogs::STATUS_SUCCESS, 'content_type' => 'topic', 'content_id' => $topic_id, 'topicid' => $topic_id, 'forumid' => $forumid, 'request_summary' => sprintf( 'Suggest reply for topic: %s', wp_trim_words( $topic['title'], 10 ) ), 'response_summary' => 'Generated reply suggestion', 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ), ] ); } wp_send_json_success( [ 'content' => $result['reply'], 'credits_used' => $credits_used, ] ); } /** * Generate bot reply using Tasks API * * @param array $topic Topic data * @param array $parent_post Post being replied to * @param array|null $first_post First post of topic (if replying to reply) * @return array|WP_Error Result with 'reply' and 'credits_used' or WP_Error */ private function generate_bot_reply( $topic, $parent_post, $first_post = null ) { $api_key = $this->get_stored_api_key(); if ( ! $api_key ) { return new \WP_Error( 'no_api_key', wpforo_phrase( 'API key not configured', false ) ); } // Get settings $settings = WPF()->settings->ai; $quality = wpfval( $settings, 'bot_reply_quality' ) ?: 'premium'; $style = wpfval( $settings, 'bot_reply_style' ) ?: 'helpful_answer'; $tone = wpfval( $settings, 'bot_reply_tone' ) ?: 'neutral'; $length = wpfval( $settings, 'bot_reply_length' ) ?: 'medium'; $knowledge_source = wpfval( $settings, 'bot_reply_knowledge_source' ) ?: 'forum_and_ai'; $response_language = $this->get_user_language( null, 'bot_reply_language' ); // Build include options from checkbox array $include = []; $includes_setting = wpfval( $settings, 'bot_reply_includes' ); if ( is_array( $includes_setting ) ) { // Map setting values to backend expected keys $include_map = [ 'code' => 'code_examples', 'docs' => 'documentation_links', 'steps' => 'step_by_step', 'questions' => 'follow_up_questions', 'youtube' => 'youtube_videos', 'greeting' => 'personalized_greeting', ]; foreach ( $includes_setting as $key ) { if ( isset( $include_map[ $key ] ) ) { $include[] = $include_map[ $key ]; } } } // Get forum info $forum = WPF()->forum->get_forum( (int) $topic['forumid'] ); // Get parent post author name for greeting (use @nicename format) $parent_author_name = ''; if ( $parent_post['userid'] ) { $parent_author = WPF()->member->get_member( $parent_post['userid'] ); $nicename = wpfval( $parent_author, 'user_nicename' ) ?: wpfval( $parent_author, 'display_name' ); $parent_author_name = $nicename ? '@' . $nicename : ''; } else { $parent_author_name = wpfval( $parent_post, 'name' ) ?: ''; } // Build posts array for the topic context $posts = []; // Add first post if available if ( $first_post ) { $first_post_author = ''; if ( $first_post['userid'] ) { $first_author = WPF()->member->get_member( $first_post['userid'] ); $first_post_author = wpfval( $first_author, 'display_name' ) ?: ''; } else { $first_post_author = wpfval( $first_post, 'name' ) ?: ''; } $posts[] = [ 'postid' => (int) $first_post['postid'], 'author' => $first_post_author, 'content' => wp_strip_all_tags( $first_post['body'] ), ]; } // Add parent post (the one being replied to) $posts[] = [ 'postid' => (int) $parent_post['postid'], 'author' => $parent_author_name, 'content' => wp_strip_all_tags( $parent_post['body'] ), ]; // Build the request payload matching backend ReplyGeneratorRequest schema $request_body = [ 'task_type' => 'reply_generator', 'topics' => [ [ 'topic_id' => (int) $topic['topicid'], 'title' => $topic['title'], 'forum_id' => (int) $topic['forumid'], 'posts' => $posts, 'reply_strategy' => 'last_post', ], ], 'replies_count' => 1, 'quality' => $quality, 'reply_style' => $style, 'reply_tone' => $tone, 'reply_strategy' => 'last_post', 'reply_length' => $length, 'include' => $include, 'knowledge_source' => $knowledge_source, 'response_language' => $response_language, ]; // Add custom knowledge params if enabled (Business+ only, cloud storage only) if ( $this->is_custom_knowledge_enabled() ) { $request_body['include_custom_knowledge'] = true; $request_body['knowledge_priority'] = [ 'bot_reply_priority' => $this->get_knowledge_priorities( 'bot_reply' ), ]; } // Make API request to /tasks/generate endpoint $response = wp_remote_post( $this->api_base_url . '/tasks/generate', [ 'timeout' => 60, 'headers' => [ 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( $request_body ), ] ); if ( is_wp_error( $response ) ) { return new \WP_Error( 'api_error', $response->get_error_message() ); } $status_code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $status_code >= 400 ) { $error_message = wpfval( $body, 'error' ) ?: wpfval( $body, 'detail' ) ?: 'API request failed'; return new \WP_Error( 'api_error', $error_message ); } // Backend returns { success: bool, replies: [{topic_id, content}], credits_used } $replies = wpfval( $body, 'replies' ); $reply_content = ''; if ( is_array( $replies ) && ! empty( $replies ) ) { $reply_content = wpfval( $replies[0], 'content' ) ?: ''; } // Fallback to legacy response fields if ( empty( $reply_content ) ) { $reply_content = wpfval( $body, 'reply' ) ?: wpfval( $body, 'content' ) ?: ''; } if ( empty( $reply_content ) ) { return new \WP_Error( 'empty_reply', wpforo_phrase( 'AI generated an empty reply', false ) ); } return [ 'reply' => $reply_content, 'credits_used' => wpfval( $body, 'credits_used' ) ?: 0, ]; } /** * Create bot reply post * * @param array $topic Topic data * @param array $parent_post Parent post data * @param string $content Reply content * @return int|WP_Error New post ID or WP_Error */ private function create_bot_reply_post( $topic, $parent_post, $content ) { $settings = WPF()->settings->ai; // Get bot user ID $bot_user_id = (int) wpfval( $settings, 'bot_reply_user_id' ); if ( ! $bot_user_id ) { return new \WP_Error( 'no_bot_user', wpforo_phrase( 'Bot user not configured', false ) ); } // Check if bot user exists $bot_user = get_user_by( 'ID', $bot_user_id ); if ( ! $bot_user ) { return new \WP_Error( 'invalid_bot_user', wpforo_phrase( 'Bot user does not exist', false ) ); } // Determine status (1 = unapproved, 0 = approved) $status = wpfval( $settings, 'bot_reply_unapproved' ) ? 1 : 0; // Build post data $post_data = [ 'forumid' => (int) $topic['forumid'], 'topicid' => (int) $topic['topicid'], 'parentid' => (int) $parent_post['postid'], 'userid' => $bot_user_id, 'title' => wpforo_phrase( 'RE', false ) . ': ' . $topic['title'], 'body' => $content, 'status' => $status, 'is_bot_reply' => true, ]; // Store current user to restore later $current_user_id = get_current_user_id(); // Temporarily switch to bot user wp_set_current_user( $bot_user_id ); WPF()->current_userid = $bot_user_id; // Create the post using wpForo's add method $new_post_id = WPF()->post->add( $post_data ); // Restore original user wp_set_current_user( $current_user_id ); WPF()->current_userid = $current_user_id; if ( ! $new_post_id ) { return new \WP_Error( 'post_creation_failed', wpforo_phrase( 'Failed to create bot reply', false ) ); } return $new_post_id; } /** * Check bot reply rate limits * * @param int $topic_id Topic ID * @return string|null Error message if limit exceeded, null if OK */ private function check_bot_reply_limits( $topic_id ) { global $wpdb; $settings = WPF()->settings->ai; $bot_user_id = (int) wpfval( $settings, 'bot_reply_user_id' ); if ( ! $bot_user_id ) { return wpforo_phrase( 'Bot user not configured', false ); } // Check max per topic $max_per_topic = (int) wpfval( $settings, 'bot_reply_max_per_topic' ); if ( $max_per_topic > 0 ) { $topic_count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `" . WPF()->tables->posts . "` WHERE `topicid` = %d AND `userid` = %d", $topic_id, $bot_user_id ) ); if ( (int) $topic_count >= $max_per_topic ) { return sprintf( wpforo_phrase( 'Maximum bot replies per topic reached (%d)', false ), $max_per_topic ); } } // Check max per day $max_per_day = (int) wpfval( $settings, 'bot_reply_max_per_day' ); if ( $max_per_day > 0 ) { $today_start = current_time( 'Y-m-d' ) . ' 00:00:00'; $day_count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `" . WPF()->tables->posts . "` WHERE `userid` = %d AND `created` >= %s", $bot_user_id, $today_start ) ); if ( (int) $day_count >= $max_per_day ) { return sprintf( wpforo_phrase( 'Maximum bot replies per day reached (%d)', false ), $max_per_day ); } } return null; } // ========================================================================= // MULTIMODAL IMAGE EXTRACTION METHODS // ========================================================================= /** * Check if multimodal image indexing is enabled for the current board * * Image indexing requires: * 1. Professional, Business or Enterprise plan * 2. Board-specific setting enabled (ai_image_indexing_enabled) * * Credit Impact: * - When enabled, posts with images consume +1 additional credit * - Maximum 10 images per post (enforced by API) * * @return bool True if image indexing is enabled */ public function is_image_indexing_enabled() { // Check board-specific setting first (fast check) $board_setting = (bool) wpforo_get_option( 'ai_image_indexing_enabled', 0 ); if ( ! $board_setting ) { return false; } // Check plan eligibility using cached plan (no API calls) $plan = strtolower( $this->get_subscription_plan() ); // Professional, Business and Enterprise plans have image indexing return in_array( $plan, [ 'professional', 'business', 'enterprise' ], true ); } /** * Check if URL points to an image file * * @param string $url URL to check * @return bool True if URL has an image extension */ private function is_image_url( $url ) { $path = wp_parse_url( $url, PHP_URL_PATH ); if ( ! $path ) { return false; } $ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); return in_array( $ext, self::$image_extensions, true ); } /** * Check if document indexing is enabled for the current board * * Requires both: * 1. Professional+ subscription plan * 2. Board-specific setting enabled (ai_document_indexing_enabled) * * @return bool True if document indexing is enabled and eligible */ public function is_document_indexing_enabled() { $board_setting = (bool) wpforo_get_option( 'ai_document_indexing_enabled', 0 ); if ( ! $board_setting ) { return false; } $plan = strtolower( $this->get_subscription_plan() ); return in_array( $plan, [ 'professional', 'business', 'enterprise' ], true ); } /** * Check if URL points to a document file * * @param string $url URL to check * @return bool True if URL has a document extension */ private function is_document_url( $url ) { $path = wp_parse_url( $url, PHP_URL_PATH ); if ( ! $path ) { return false; } $ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); return in_array( $ext, self::$document_extensions, true ); } /** * Check if URL belongs to local site (not external domain) * * @param string $url URL to check * @param string $site_url Site URL for comparison (optional) * @return bool True if URL is local */ private function is_local_url( $url, $site_url = null ) { if ( ! $site_url ) { $site_url = get_site_url(); } $site_host = wp_parse_url( $site_url, PHP_URL_HOST ); $url_host = wp_parse_url( $url, PHP_URL_HOST ); // Relative URLs are local if ( ! $url_host ) { return true; } // Exact match if ( $url_host === $site_host ) { return true; } // Normalize both hosts (strip www prefix for comparison) $site_host_normalized = preg_replace( '/^www\./', '', $site_host ); $url_host_normalized = preg_replace( '/^www\./', '', $url_host ); // Match after www normalization (example.com == www.example.com) if ( $url_host_normalized === $site_host_normalized ) { return true; } // Check if URL host is a subdomain of site host (e.g., cdn.example.com for example.com) // Must end with .site_host to be a subdomain if ( substr( $url_host_normalized, -strlen( '.' . $site_host_normalized ) ) === '.' . $site_host_normalized ) { return true; } return false; } /** * Normalize URL to canonical form for deduplication * * Handles protocol-relative URLs, relative URLs, http→https normalization, * and query string/fragment removal. * * @param string $url URL to normalize * @param string $site_url Site URL for relative URL expansion (optional) * @return string Normalized URL, or empty string if invalid */ private function normalize_url( $url, $site_url = null ) { if ( ! $site_url ) { $site_url = get_site_url(); } $url = trim( $url ); // Skip data URIs if ( strpos( $url, 'data:' ) === 0 ) { return ''; } // Expand relative URLs if ( strpos( $url, '/' ) === 0 && strpos( $url, '//' ) !== 0 ) { $url = rtrim( $site_url, '/' ) . $url; } elseif ( strpos( $url, '//' ) === 0 ) { // Protocol-relative URL $url = 'https:' . $url; } // Normalize protocol to https $url = preg_replace( '#^http://#i', 'https://', $url ); // Remove query string and fragment for deduplication $url = strtok( $url, '?#' ); return $url; } /** * Normalize image URL (delegates to normalize_url) * * @param string $url URL to normalize * @param string $site_url Site URL for relative URL expansion (optional) * @return string Normalized URL, or empty string if invalid */ private function normalize_image_url( $url, $site_url = null ) { return $this->normalize_url( $url, $site_url ); } /** * Extract images fromtags in content * * @param string $content Post HTML content * @param string $site_url Site URL for validation * @return array Array of normalized image URLs */ private function extract_img_tags( $content, $site_url = null ) { $images = []; if ( preg_match_all( '/
]+src=["\']([^"\']+)["\'][^>]*>/i', $content, $matches ) ) { foreach ( $matches[1] as $src ) { $normalized = $this->normalize_image_url( $src, $site_url ); if ( $normalized && $this->is_image_url( $normalized ) && $this->is_local_url( $normalized, $site_url ) ) { $images[] = $normalized; } } } return $images; } /** * Extract images from tags in content (wpForo default attachments) * * @param string $content Post HTML content * @param string $site_url Site URL for validation * @return array Array of normalized image URLs */ private function extract_anchor_images( $content, $site_url = null ) { $images = []; if ( preg_match_all( '/]+href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches ) ) { foreach ( $matches[1] as $href ) { $normalized = $this->normalize_image_url( $href, $site_url ); if ( $normalized && $this->is_image_url( $normalized ) && $this->is_local_url( $normalized, $site_url ) ) { $images[] = $normalized; } } } return $images; } /** * Extract plain text image URLs from content * * @param string $content Post content * @param string $site_url Site URL for validation * @return array Array of normalized image URLs */ private function extract_plain_urls( $content, $site_url = null ) { $images = []; // Strip HTML tags first to find plain text URLs $text = wp_strip_all_tags( $content ); // Match URLs ending with image extensions $pattern = '#https?://[^\s<>"\']+\.(?:' . implode( '|', self::$image_extensions ) . ')#i'; if ( preg_match_all( $pattern, $text, $matches ) ) { foreach ( $matches[0] as $url ) { $normalized = $this->normalize_image_url( $url, $site_url ); if ( $normalized && $this->is_local_url( $normalized, $site_url ) ) { $images[] = $normalized; } } } return $images; } /** * Extract attachment IDs from [attach] shortcodes * * @param string $content Post content with shortcodes * @return array Array of attachment IDs (integers) */ private function extract_attach_ids( $content ) { $attach_ids = []; // Match [attach...]ID[/attach] patterns if ( preg_match_all( '/\[attach[^\]]*\](\d+(?:,\s*\d+)*)\[\/attach\]/i', $content, $matches ) ) { foreach ( $matches[1] as $ids_string ) { $ids = array_map( 'intval', explode( ',', $ids_string ) ); $attach_ids = array_merge( $attach_ids, $ids ); } } return array_unique( array_filter( $attach_ids ) ); } /** * Get image URLs from attachment IDs * * Handles missing Advanced Attachments addon gracefully. * * @param array $attach_ids Array of attachment IDs * @return array Array of image data with url and attach_id */ private function get_attachment_urls( $attach_ids ) { if ( empty( $attach_ids ) ) { return []; } // Check if wpForo is available if ( ! function_exists( 'WPF' ) ) { return []; } // Check if Advanced Attachments addon exists if ( ! isset( WPF()->tables->attachments ) ) { return []; } global $wpdb; $table = WPF()->tables->attachments; // Check if table exists $table_exists = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $table ) ); if ( ! $table_exists ) { return []; } $placeholders = implode( ',', array_fill( 0, count( $attach_ids ), '%d' ) ); $query = $wpdb->prepare( "SELECT attachid, fileurl, mime FROM {$table} WHERE attachid IN ({$placeholders})", $attach_ids ); $attachments = $wpdb->get_results( $query, ARRAY_A ); if ( ! $attachments ) { return []; } $image_urls = []; foreach ( $attachments as $attach ) { // Only include image MIME types if ( isset( $attach['mime'] ) && strpos( $attach['mime'], 'image/' ) === 0 ) { $normalized = $this->normalize_image_url( $attach['fileurl'] ); if ( $normalized ) { $image_urls[] = [ 'attach_id' => (int) $attach['attachid'], 'url' => $normalized, ]; } } } return $image_urls; } /** * Get document URLs from attachment IDs * * Filters attachments by document MIME types (application/*, text/*). * Validates that the file extension matches supported document formats. * * @param array $attach_ids Array of attachment IDs * @return array Array of document data with url and attach_id */ private function get_attachment_document_urls( $attach_ids ) { if ( empty( $attach_ids ) ) { return []; } if ( ! function_exists( 'WPF' ) ) { return []; } if ( ! isset( WPF()->tables->attachments ) ) { return []; } global $wpdb; $table = WPF()->tables->attachments; $table_exists = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $table ) ); if ( ! $table_exists ) { return []; } $placeholders = implode( ',', array_fill( 0, count( $attach_ids ), '%d' ) ); $query = $wpdb->prepare( "SELECT attachid, fileurl, mime FROM {$table} WHERE attachid IN ({$placeholders})", $attach_ids ); $attachments = $wpdb->get_results( $query, ARRAY_A ); if ( ! $attachments ) { return []; } $doc_urls = []; foreach ( $attachments as $attach ) { $mime = $attach['mime'] ?? ''; // Include application/* and text/* MIME types (PDFs, DOCX, TXT, etc.) if ( strpos( $mime, 'application/' ) === 0 || strpos( $mime, 'text/' ) === 0 ) { $normalized = $this->normalize_url( $attach['fileurl'] ); if ( $normalized && $this->is_document_url( $normalized ) ) { $doc_urls[] = [ 'attach_id' => (int) $attach['attachid'], 'url' => $normalized, ]; } } } return $doc_urls; } /** * Extract ALL images from post content with deduplication * * Handles all 4 image source types: * 1.
tags * 2. tags (wpForo default attachments) * 3. Plain text URLs * 4. [attach] shortcodes (Advanced Attachments addon) * * @param string $content Post body content * @return array Array of unique image data */ public function extract_post_images( $content ) { if ( empty( $content ) ) { return []; } $site_url = get_site_url(); // Track URLs for deduplication (normalized URL => image data) $url_map = []; // 1. Extract from
tags foreach ( $this->extract_img_tags( $content, $site_url ) as $url ) { if ( ! isset( $url_map[ $url ] ) ) { $url_map[ $url ] = [ 'type' => 'img_tag', 'url' => $url, 'attach_id' => null, ]; } } // 2. Extract from tags (default wpForo attachments) foreach ( $this->extract_anchor_images( $content, $site_url ) as $url ) { if ( ! isset( $url_map[ $url ] ) ) { $url_map[ $url ] = [ 'type' => 'anchor_link', 'url' => $url, 'attach_id' => null, ]; } } // 3. Extract plain text URLs foreach ( $this->extract_plain_urls( $content, $site_url ) as $url ) { if ( ! isset( $url_map[ $url ] ) ) { $url_map[ $url ] = [ 'type' => 'plain_url', 'url' => $url, 'attach_id' => null, ]; } } // 4. Extract [attach] shortcode images (if addon exists) $attach_ids = $this->extract_attach_ids( $content ); if ( ! empty( $attach_ids ) ) { $attach_images = $this->get_attachment_urls( $attach_ids ); foreach ( $attach_images as $attach ) { $url = $attach['url']; if ( ! isset( $url_map[ $url ] ) ) { $url_map[ $url ] = [ 'type' => 'shortcode', 'url' => $url, 'attach_id' => $attach['attach_id'], ]; } else { // URL already exists from another source, add attach_id $url_map[ $url ]['attach_id'] = $attach['attach_id']; } } } // Return deduplicated images as array return array_values( $url_map ); } /** * Extract ALL documents from post content with deduplication * * Handles 3 document source types: * 1. tags with href pointing to document files (linked PDFs, DOCX, etc.) * 2. Plain text URLs ending in document extensions * 3. [attach] shortcodes resolving to document attachments * * @param string $content Post body content * @return array Array of unique document data: [['type' => '...', 'url' => '...', 'attach_id' => ...], ...] */ public function extract_post_documents( $content ) { if ( empty( $content ) ) { return []; } $site_url = get_site_url(); $url_map = []; // 1. Extract from tags (most common - linked PDFs) if ( preg_match_all( '/]+href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches ) ) { foreach ( $matches[1] as $href ) { $normalized = $this->normalize_url( $href, $site_url ); if ( $normalized && $this->is_document_url( $normalized ) && $this->is_local_url( $normalized, $site_url ) ) { $url_map[ $normalized ] = [ 'type' => 'anchor_link', 'url' => $normalized, 'attach_id' => null, ]; } } } // 2. Extract plain text document URLs $doc_ext_pattern = implode( '|', self::$document_extensions ); if ( preg_match_all( '#https?://[^\s<>"\']+\.(?:' . $doc_ext_pattern . ')#i', $content, $matches ) ) { foreach ( $matches[0] as $url ) { $normalized = $this->normalize_url( $url, $site_url ); if ( $normalized && $this->is_local_url( $normalized, $site_url ) && ! isset( $url_map[ $normalized ] ) ) { $url_map[ $normalized ] = [ 'type' => 'plain_url', 'url' => $normalized, 'attach_id' => null, ]; } } } // 3. Extract [attach] shortcode documents (if addon exists) $attach_ids = $this->extract_attach_ids( $content ); if ( ! empty( $attach_ids ) ) { $attach_docs = $this->get_attachment_document_urls( $attach_ids ); foreach ( $attach_docs as $doc ) { $url = $doc['url']; if ( ! isset( $url_map[ $url ] ) ) { $url_map[ $url ] = [ 'type' => 'shortcode', 'url' => $url, 'attach_id' => $doc['attach_id'], ]; } else { $url_map[ $url ]['attach_id'] = $doc['attach_id']; } } } return array_values( $url_map ); } // ========================================================================= // CUSTOM KNOWLEDGE AJAX HANDLERS // ========================================================================= /** * AJAX handler for adding custom knowledge * * Sends file URL to backend for processing and indexing. * Endpoint: POST /v1/knowledge/ingest */ public function ajax_add_knowledge() { check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Insufficient permissions', false ) ], 403 ); } if ( ! $this->is_feature_available( 'custom_knowledge' ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Custom knowledge requires Business plan or higher', false ) ], 403 ); } $file_url = isset( $_POST['file_url'] ) ? esc_url_raw( trim( $_POST['file_url'] ) ) : ''; $file_type = isset( $_POST['file_type'] ) ? sanitize_key( $_POST['file_type'] ) : 'text'; $file_name = isset( $_POST['file_name'] ) ? sanitize_text_field( trim( $_POST['file_name'] ) ) : ''; if ( empty( $file_url ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'File URL is required', false ) ], 400 ); } $valid_types = [ 'json', 'markdown', 'text', 'pdf' ]; if ( ! in_array( $file_type, $valid_types, true ) ) { $file_type = 'text'; } // Build request body - backend expects 'name' not 'file_name' $request_body = [ 'file_url' => $file_url, 'file_type' => $file_type, ]; if ( ! empty( $file_name ) ) { $request_body['name'] = $file_name; } $response = $this->post( '/knowledge/ingest', $request_body ); if ( is_wp_error( $response ) ) { wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 ); } $this->log_info( 'knowledge_added', [ 'file_url' => $file_url, 'file_type' => $file_type, 'name' => $file_name ] ); // Merge backend response fields into success response // JS polling expects: async, file_id, name at top level $success_data = [ 'message' => wpforo_phrase( 'Knowledge file added. Processing will begin shortly.', false ), ]; // Pass through key fields from backend response if ( is_array( $response ) ) { if ( ! empty( $response['file_id'] ) ) { $success_data['file_id'] = $response['file_id']; } if ( ! empty( $response['async'] ) ) { $success_data['async'] = true; } if ( ! empty( $response['name'] ) ) { $success_data['name'] = $response['name']; } if ( isset( $response['credits_remaining'] ) ) { $success_data['credits_remaining'] = $response['credits_remaining']; } } wp_send_json_success( $success_data ); } /** * AJAX handler for deleting custom knowledge * * Endpoint: DELETE /v1/knowledge/files/{file_id} */ public function ajax_delete_knowledge() { check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Insufficient permissions', false ) ], 403 ); } $file_id = isset( $_POST['file_id'] ) ? sanitize_text_field( $_POST['file_id'] ) : ''; if ( empty( $file_id ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'File ID is required', false ) ], 400 ); } $response = $this->delete( '/knowledge/files/' . urlencode( $file_id ), [], 60 ); if ( is_wp_error( $response ) ) { wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 ); } $this->log_info( 'knowledge_deleted', [ 'file_id' => $file_id ] ); wp_send_json_success( [ 'message' => wpforo_phrase( 'Knowledge file deleted successfully.', false ) ] ); } /** * AJAX handler for checking async job status * * Endpoint: GET /v1/knowledge/jobs/{file_id} * Used by polling to check if async indexing is complete */ public function ajax_get_job_status() { check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Insufficient permissions', false ) ], 403 ); } $file_id = isset( $_POST['file_id'] ) ? sanitize_text_field( $_POST['file_id'] ) : ''; if ( empty( $file_id ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'File ID is required', false ) ], 400 ); } $response = $this->get( '/knowledge/jobs/' . urlencode( $file_id ) ); if ( is_wp_error( $response ) ) { wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 ); } // Log completion or failure (only once per file) $status = isset( $response['status'] ) ? $response['status'] : ''; $logged_key = 'wpforo_knowledge_logged_' . $file_id; if ( in_array( $status, [ 'enabled', 'failed' ], true ) && ! get_transient( $logged_key ) ) { $file_name = isset( $response['name'] ) ? $response['name'] : $file_id; $credits_used = isset( $response['credits_used'] ) ? (int) $response['credits_used'] : 0; $chunk_count = isset( $response['chunk_count'] ) ? (int) $response['chunk_count'] : 0; $error_msg = isset( $response['error_message'] ) ? $response['error_message'] : ''; if ( $status === 'enabled' ) { WPF()->ai_logs->log( [ 'action_type' => AILogs::ACTION_KNOWLEDGE_INDEXING, 'credits_used' => $credits_used, 'status' => AILogs::STATUS_SUCCESS, 'request_summary' => 'File: ' . $file_name, 'response_summary' => sprintf( 'Indexed %d chunks, used %d credits', $chunk_count, $credits_used ), 'user_type' => 'admin', ] ); } else { WPF()->ai_logs->log( [ 'action_type' => AILogs::ACTION_KNOWLEDGE_INDEXING, 'credits_used' => 0, 'status' => AILogs::STATUS_ERROR, 'request_summary' => 'File: ' . $file_name, 'error_message' => $error_msg ?: 'Indexing failed', 'user_type' => 'admin', ] ); } // Mark as logged (expires in 1 hour - enough to prevent duplicate logs) set_transient( $logged_key, true, HOUR_IN_SECONDS ); } wp_send_json_success( $response ); } /** * AJAX handler for saving knowledge settings (priorities + enabled state) * * Settings are stored per-board in WordPress options: * - ai_knowledge_enabled (0/1) * - ai_knowledge_priorities (array of priorities per feature) * * Priorities are arrays of content sources in order: * - Position 0 = First priority (1.3x boost) * - Position 1 = Second priority (1.15x boost) * - Position 2 = Third priority (1.0x - no boost) */ public function ajax_save_knowledge_priorities() { check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Insufficient permissions', false ) ], 403 ); } // Get board ID $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0; // Switch to correct board context if ( $board_id > 0 ) { WPF()->change_board( $board_id ); } // Get enabled state $enabled = isset( $_POST['enabled'] ) ? (int) (bool) $_POST['enabled'] : 0; // Valid content sources $valid_sources = [ 'custom_knowledge', 'forum', 'wordpress' ]; // Sanitize priority arrays from POST data $priorities = [ 'search' => $this->sanitize_priority_array( isset( $_POST['search_priority'] ) ? $_POST['search_priority'] : [], $valid_sources, [ 'forum', 'wordpress', 'custom_knowledge' ] ), 'chat' => $this->sanitize_priority_array( isset( $_POST['chat_priority'] ) ? $_POST['chat_priority'] : [], $valid_sources, [ 'custom_knowledge', 'forum', 'wordpress' ] ), 'bot_reply' => $this->sanitize_priority_array( isset( $_POST['bot_reply_priority'] ) ? $_POST['bot_reply_priority'] : [], $valid_sources, [ 'forum', 'custom_knowledge', 'wordpress' ] ), ]; // Save to WordPress options (board-specific via wpforo_update_option) wpforo_update_option( 'ai_knowledge_enabled', $enabled ); wpforo_update_option( 'ai_knowledge_priorities', $priorities ); $this->log_info( 'knowledge_settings_saved', [ 'board_id' => $board_id, 'enabled' => $enabled, 'priorities' => $priorities ] ); wp_send_json_success( [ 'message' => wpforo_phrase( 'Settings saved successfully.', false ) ] ); } /** * Sanitize and validate priority array * * @param mixed $input Raw input (may be array or string) * @param array $valid_sources Valid content source values * @param array $default Default priority order * @return array Sanitized array with exactly 3 valid sources */ private function sanitize_priority_array( $input, $valid_sources, $default = null ) { if ( $default === null ) { $default = [ 'forum', 'wordpress', 'custom_knowledge' ]; } if ( ! is_array( $input ) ) { return $default; } $sanitized = []; foreach ( $input as $source ) { $source = sanitize_key( $source ); if ( in_array( $source, $valid_sources, true ) && ! in_array( $source, $sanitized, true ) ) { $sanitized[] = $source; } } // Ensure we have exactly 3 unique sources if ( count( $sanitized ) !== 3 ) { return $default; } return $sanitized; } /** * AJAX handler for getting knowledge settings for a board */ public function ajax_get_knowledge_settings() { check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Insufficient permissions', false ) ], 403 ); } // Get board ID $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0; // Switch to correct board context if ( $board_id > 0 ) { WPF()->change_board( $board_id ); } // Get settings from WordPress options (board-specific) $enabled = (int) wpforo_get_option( 'ai_knowledge_enabled', 0 ); $priorities = wpforo_get_option( 'ai_knowledge_priorities', [] ); // Apply defaults if not set $default_priorities = [ 'search' => [ 'forum', 'wordpress', 'custom_knowledge' ], 'chat' => [ 'custom_knowledge', 'forum', 'wordpress' ], 'bot_reply' => [ 'forum', 'custom_knowledge', 'wordpress' ], ]; if ( empty( $priorities ) || ! is_array( $priorities ) ) { $priorities = $default_priorities; } else { foreach ( $default_priorities as $feature => $default ) { if ( ! isset( $priorities[ $feature ] ) || ! is_array( $priorities[ $feature ] ) ) { $priorities[ $feature ] = $default; } } } wp_send_json_success( [ 'board_id' => $board_id, 'enabled' => $enabled, 'priorities' => $priorities ] ); } /** * AJAX handler for getting knowledge files list * * Makes one API call to backend: * - GET /v1/knowledge/files - List of indexed files * * Settings (enabled, priorities) are stored in WordPress per-board * and retrieved separately via ajax_get_knowledge_settings. * * Returns empty data gracefully if backend is not available yet. */ public function ajax_get_knowledge_files() { check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => wpforo_phrase( 'Insufficient permissions', false ) ], 403 ); } // Get files list from backend - return empty if not ready $files_response = $this->get( '/knowledge/files' ); if ( is_wp_error( $files_response ) ) { // Backend not available - return empty state wp_send_json_success( [ 'files' => [], 'totals' => [ 'total_files' => 0, 'total_chunks' => 0, 'total_credits' => 0, ] ] ); return; } // Normalize files data - map backend field names to UI field names $files = []; if ( isset( $files_response['files'] ) && is_array( $files_response['files'] ) ) { foreach ( $files_response['files'] as $file ) { $files[] = [ 'file_id' => isset( $file['file_id'] ) ? $file['file_id'] : '', 'name' => isset( $file['name'] ) ? $file['name'] : '', 'url' => isset( $file['source_url'] ) ? $file['source_url'] : '', 'type' => isset( $file['file_type'] ) ? $file['file_type'] : 'text', 'size_bytes' => isset( $file['file_size_bytes'] ) ? (int) $file['file_size_bytes'] : 0, 'chunks' => isset( $file['chunk_count'] ) ? (int) $file['chunk_count'] : 0, 'credits_used' => isset( $file['credits_used'] ) ? (int) $file['credits_used'] : 0, 'status' => isset( $file['status'] ) ? $file['status'] : 'unknown', 'enabled' => isset( $file['status'] ) && $file['status'] === 'enabled', 'created_at' => isset( $file['created_at'] ) ? $file['created_at'] : '', ]; } } wp_send_json_success( [ 'files' => $files, 'totals' => [ 'total_files' => isset( $files_response['total'] ) ? (int) $files_response['total'] : count( $files ), 'total_chunks' => isset( $files_response['total_chunks'] ) ? (int) $files_response['total_chunks'] : 0, 'total_credits' => isset( $files_response['total_credits_used'] ) ? (int) $files_response['total_credits_used'] : 0, ] ] ); } /** * Check if custom knowledge is enabled for the current board * * @return bool True if enabled */ public function is_custom_knowledge_enabled() { // Must have Business+ plan if ( ! $this->is_feature_available( 'custom_knowledge' ) ) { return false; } // Custom knowledge only works in cloud storage mode // (vectors are stored in S3 Vectors, not local WordPress DB) if ( WPF()->vector_storage->is_local_mode() ) { return false; } // Check board-specific setting return (bool) wpforo_get_option( 'ai_knowledge_enabled', 0 ); } /** * Get custom knowledge priorities for the current board * * @param string $feature Feature name: 'search', 'chat', or 'bot_reply' * @return array Priority order array */ public function get_knowledge_priorities( $feature = 'search' ) { $defaults = [ 'search' => [ 'forum', 'wordpress', 'custom_knowledge' ], 'chat' => [ 'custom_knowledge', 'forum', 'wordpress' ], 'bot_reply' => [ 'forum', 'custom_knowledge', 'wordpress' ], ]; $priorities = wpforo_get_option( 'ai_knowledge_priorities', [] ); if ( isset( $priorities[ $feature ] ) && is_array( $priorities[ $feature ] ) ) { return $priorities[ $feature ]; } return isset( $defaults[ $feature ] ) ? $defaults[ $feature ] : $defaults['search']; } }