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

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

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