PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.7.1
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.7.1
3.7.3 3.7.2 3.7.1 3.7.0 3.6.9 3.6.8 3.6.7 3.6.6 3.6.4 3.6.5 3.6.3 3.6.2 3.6.1 3.6.0 3.5.9 3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / classes / core.php
ai-engine / classes Last commit date
data 1 year ago engines 4 weeks ago exceptions 1 month ago modules 2 weeks ago query 1 month ago services 1 month ago admin.php 2 weeks ago api.php 4 weeks ago core.php 2 weeks ago discussion.php 1 year ago event.php 1 year ago init.php 2 weeks ago logging.php 1 year ago reply.php 1 month ago rest.php 2 weeks ago
core.php
2011 lines
1 <?php
2
3 require_once( MWAI_PATH . '/vendor/autoload.php' );
4 require_once( MWAI_PATH . '/constants/init.php' );
5
6 define( 'MWAI_IMG_WAND', MWAI_URL . '/images/wand.png' );
7 define( 'MWAI_IMG_WAND_HTML', "<img style='height: 22px; margin-bottom: -5px; margin-right: 8px;'
8 src='" . MWAI_IMG_WAND . "' alt='AI Wand' />" );
9 define( 'MWAI_IMG_WAND_HTML_XS', "<img style='height: 16px; margin-bottom: -2px;'
10 src='" . MWAI_IMG_WAND . "' alt='AI Wand' />" );
11
12 class Meow_MWAI_Core {
13 public $admin = null;
14 public $is_rest = false;
15 public $is_cli = false;
16 public $site_url = null;
17 public $files = null;
18 public $tasks = null;
19 public $magicWand = null;
20 private $options = null;
21 private $option_name = 'mwai_options';
22 private $themes_option_name = 'mwai_themes';
23 private $chatbots_option_name = 'mwai_chatbots';
24 private $nonce = null;
25
26 public $chatbot = null;
27 public $discussions = null;
28 public $search = null;
29 public $advisor = null;
30
31 // Service instances for improved architecture
32 public $responseIdManager = null;
33 public $messageBuilder = null;
34 public $sessionService = null;
35 public $imageService = null;
36 public $usageStatsService = null;
37 public $modelEnvironmentService = null;
38
39 public function __construct() {
40 Meow_MWAI_Logging::init( 'mwai_options', 'AI Engine' );
41 $this->site_url = get_site_url();
42 $this->is_rest = MeowKit_MWAI_Helpers::is_rest();
43 $this->is_cli = defined( 'WP_CLI' );
44 $this->files = new Meow_MWAI_Modules_Files( $this );
45 $this->tasks = new Meow_MWAI_Modules_Tasks( $this );
46 $this->advisor = new Meow_MWAI_Modules_Advisor( $this );
47
48 // Load task examples in Dev Mode
49 if ( $this->get_option( 'dev_mode' ) ) {
50 new Meow_MWAI_Modules_Tasks_Examples( $this );
51 }
52
53 add_action( 'plugins_loaded', [ $this, 'init' ] );
54 add_action( 'wp_register_script', [ $this, 'register_scripts' ] );
55 add_action( 'wp_enqueue_scripts', [ $this, 'register_scripts' ] );
56 add_action( 'admin_enqueue_scripts', [ $this, 'register_scripts' ] );
57 }
58
59 #region Init & Scripts
60 public function init() {
61 global $mwai;
62
63 // Language
64 load_plugin_textdomain( MWAI_DOMAIN, false, basename( MWAI_PATH ) . '/languages' );
65
66 $this->chatbot = null;
67 $this->discussions = null;
68
69 // Initialize services here after autoloader is ready
70 $this->responseIdManager = new Meow_MWAI_Services_ResponseIdManager( $this );
71 $this->messageBuilder = new Meow_MWAI_Services_MessageBuilder( $this );
72 $this->sessionService = new Meow_MWAI_Services_Session( $this );
73 $this->imageService = new Meow_MWAI_Services_Image( $this );
74 $this->usageStatsService = new Meow_MWAI_Services_UsageStats( $this );
75 $this->modelEnvironmentService = new Meow_MWAI_Services_ModelEnvironment( $this );
76
77 // Start session early if needed for REST requests
78 if ( $this->is_rest && $this->sessionService->can_start_session() ) {
79 session_start();
80 }
81
82 new Meow_MWAI_Modules_Security( $this );
83
84 // REST API
85 if ( $this->is_rest ) {
86 new Meow_MWAI_Rest( $this );
87 }
88
89 // WP Admin
90 if ( is_admin() ) {
91 new Meow_MWAI_Admin( $this );
92 }
93
94 // GDPR Module
95 if ( $this->get_option( 'chatbot_gdpr_consent' ) ) {
96 new Meow_MWAI_Modules_GDPR( $this );
97 }
98
99 // Suggestions Module
100 if ( $this->get_option( 'module_suggestions' ) && ( is_admin() || $this->is_rest ) ) {
101 $this->magicWand = new Meow_MWAI_Modules_Wand( $this );
102 }
103
104 // Chatbots & Discussions
105 if ( $this->get_option( 'module_chatbots' ) ) {
106 $this->chatbot = new Meow_MWAI_Modules_Chatbot();
107 // Only instantiate discussions if the feature is enabled
108 if ( $this->get_option( 'chatbot_discussions' ) ) {
109 $this->discussions = new Meow_MWAI_Modules_Discussions();
110 }
111 }
112
113 // Search
114 if ( $this->get_option( 'module_search' ) ) {
115 $this->search = new Meow_MWAI_Modules_Search( $this );
116 }
117
118 // Workspace (full-screen chat surface in wp-admin; admins only for now).
119 // Free shell; Knowledge, MCP Servers and Functions inside it are Pro.
120 if ( $this->get_option( 'module_workspace' ) && ( is_admin() || $this->is_rest ) ) {
121 new Meow_MWAI_Modules_Workspace( $this );
122 }
123
124 // Forms Manager (standalone Forms UI + shortcode renderer)
125 if ( $this->get_option( 'module_forms' ) ) {
126 new Meow_MWAI_Modules_Forms_Manager( $this );
127 }
128
129 // Advanced Core
130 if ( class_exists( 'MeowPro_MWAI_Core' ) ) {
131 new MeowPro_MWAI_Core( $this );
132 }
133
134 // Editor Assistant: Pro extends with tools and feedback; free version only provides recommendations.
135 // Must be after MeowPro_MWAI_Core so the Pro class is already instantiated.
136 if ( $this->get_option( 'module_assistant', true ) && ( is_admin() || $this->is_rest ) ) {
137 if ( !class_exists( 'MeowPro_MWAI_EditorAssistant', false ) ) {
138 new Meow_MWAI_Modules_Editor_Assistant( $this );
139 }
140 }
141
142 // Simple API
143 $mwai = new Meow_MWAI_API( $this->chatbot, $this->discussions ?? null );
144
145 // MCP
146 if ( $this->get_option( 'module_mcp' ) ) {
147 new Meow_MWAI_Labs_MCP( $this );
148
149 // Core - Core WordPress MCP tools
150 if ( $this->get_option( 'mcp_core' ) ) {
151 new Meow_MWAI_Labs_MCP_Core( $this );
152 }
153
154 // Dynamic REST - WordPress REST API MCP tools
155 if ( $this->get_option( 'mcp_dynamic_rest' ) ) {
156 require_once MWAI_PATH . '/labs/mcp-rest.php';
157 new Meow_MWAI_Labs_MCP_Rest();
158 }
159
160 // Themes - Pro theme management MCP tools
161 if ( $this->get_option( 'mcp_themes' ) && class_exists( 'MeowPro_MWAI_MCP_Theme' ) ) {
162 new MeowPro_MWAI_MCP_Theme( $this );
163 }
164
165 // Plugins - Pro plugin management MCP tools
166 if ( $this->get_option( 'mcp_plugins' ) && class_exists( 'MeowPro_MWAI_MCP_Plugin' ) ) {
167 new MeowPro_MWAI_MCP_Plugin( $this );
168 }
169
170 // Database - Pro database query MCP tools
171 if ( $this->get_option( 'mcp_database' ) && class_exists( 'MeowPro_MWAI_MCP_Database' ) ) {
172 new MeowPro_MWAI_MCP_Database( $this );
173 }
174
175 // Polylang - Pro multilingual MCP tools (only if Polylang is active)
176 if ( $this->get_option( 'mcp_polylang' ) && class_exists( 'MeowPro_MWAI_MCP_Polylang' ) && function_exists( 'pll_get_post_language' ) ) {
177 new MeowPro_MWAI_MCP_Polylang( $this );
178 }
179
180 // WPML - Pro multilingual MCP tools (only if WPML is active)
181 if ( $this->get_option( 'mcp_wpml' ) && class_exists( 'MeowPro_MWAI_MCP_Wpml' ) && defined( 'ICL_SITEPRESS_VERSION' ) ) {
182 new MeowPro_MWAI_MCP_Wpml( $this );
183 }
184
185 // WooCommerce - Pro WooCommerce store management MCP tools (only if WooCommerce is active)
186 if ( $this->get_option( 'mcp_woocommerce' ) && class_exists( 'MeowPro_MWAI_MCP_WooCommerce' ) && class_exists( 'WooCommerce' ) ) {
187 new MeowPro_MWAI_MCP_WooCommerce( $this );
188 }
189 }
190
191 // WP 7 Connectors integration (self-gates on WP 7+).
192 if ( class_exists( 'WP_Connector_Registry' ) ) {
193 new Meow_MWAI_Labs_WPAI_Connectors( $this );
194 }
195
196 // WP 7 AiClient gateway: register AI Engine as a provider in the
197 // AiClient registry so the WP AI framework can dispatch through us.
198 if ( class_exists( '\\WordPress\\AiClient\\AiClient' ) ) {
199 new Meow_MWAI_Labs_WPAI_Gateway( $this );
200 }
201
202 }
203
204 public function register_scripts() {
205 // Register Highlight.js
206 wp_register_script( 'mwai_highlight', MWAI_URL . 'vendor/highlightjs/highlight.min.js', [], '11.7', false );
207 // Register CSS for the themes
208 $themes = $this->get_themes();
209 foreach ( $themes as $theme ) {
210 if ( $theme['type'] === 'internal' ) {
211 $themeId = $theme['themeId'];
212 $filename = $themeId . '.css';
213 $physical_file = trailingslashit( MWAI_PATH ) . 'themes/' . $filename;
214 $cache_buster = file_exists( $physical_file ) ? filemtime( $physical_file ) : MWAI_VERSION;
215 wp_register_style( 'mwai_chatbot_theme_' . $themeId, trailingslashit( MWAI_URL )
216 . 'themes/' . $filename, [], $cache_buster );
217 }
218 }
219 }
220
221 public function enqueue_theme( $themeId ) {
222 if ( empty( $themeId ) ) {
223 return;
224 }
225 wp_enqueue_style( "mwai_chatbot_theme_$themeId" );
226 }
227
228 public function enqueue_themes() {
229 $themes = $this->get_themes();
230 foreach ( $themes as $theme ) {
231 if ( $theme['type'] === 'internal' ) {
232 $this->enqueue_theme( $theme['themeId'] );
233 }
234 }
235 }
236
237 #endregion
238
239 #region Roles & Capabilities
240 public function can_start_session() {
241 return $this->sessionService->can_start_session();
242 }
243
244 public function can_access_settings() {
245 return apply_filters( 'mwai_allow_setup', current_user_can( 'manage_options' ) );
246 }
247
248 public function can_access_features() {
249 $editor_or_admin = current_user_can( 'editor' ) || current_user_can( 'administrator' );
250 return apply_filters( 'mwai_allow_usage', $editor_or_admin );
251 }
252
253 public function can_access_public_api( $feature, $extra ) {
254 $logged_in = is_user_logged_in();
255 return apply_filters( 'mwai_allow_public_api', $logged_in, $feature, $extra );
256 }
257 #endregion
258
259 #region AI-Related Helpers
260 public function run_query( $query, $streamCallback = null, $markdown = false ) {
261
262 // Allow to modify the query before it is sent.
263 // Different query types have specific filters for type-safe modifications.
264 if ( $query instanceof Meow_MWAI_Query_Embed ) {
265 $query = apply_filters( 'mwai_ai_embeddings_query', $query );
266 }
267 else if ( $query instanceof Meow_MWAI_Query_Feedback ) {
268 $query = apply_filters( 'mwai_ai_feedback_query', $query );
269 }
270 else {
271 $query = apply_filters( 'mwai_ai_query', $query );
272 }
273
274 // Ensure the query is still valid after filtering
275 if ( !$query || !is_object( $query ) ) {
276 throw new Exception( __( 'Invalid query object after filtering. The mwai_ai_query filter must return a valid query object.', 'ai-engine' ) );
277 }
278
279 // Validate that embeddings queries have a non-empty message
280 if ( $query instanceof Meow_MWAI_Query_Embed && empty( $query->get_message() ) ) {
281 throw new Exception( __( 'Embeddings query cannot have an empty message. Please check that the conversation context is properly extracted.', 'ai-engine' ) );
282 }
283
284 // Let's check the default environment and model.
285 $this->validate_env_model( $query );
286
287 // Create the engine based on the query's environment
288 $engine = Meow_MWAI_Engines_Factory::get( $this, $query->envId );
289
290 // Let's run the query.
291 $reply = $engine->run( $query, $streamCallback );
292
293 // Let's allow to modify the reply before it is sent.
294 if ( $markdown ) {
295 if ( $query instanceof Meow_MWAI_Query_Image || $query instanceof Meow_MWAI_Query_EditImage ) {
296 $reply->result = '';
297 foreach ( $reply->results as $result ) {
298 $reply->result .= "![Image]($result)\n";
299 }
300 }
301 }
302
303 return $reply;
304 }
305
306 public function validate_env_model( $query ) {
307 return $this->modelEnvironmentService->validate_env_model( $query );
308 }
309
310 #endregion
311
312 #region Text-Related Helpers
313
314 // Clean the text perfectly, resolve shortcodes, etc, etc.
315 public function clean_text( $rawText = '' ) {
316 $text = html_entity_decode( $rawText );
317 $text = wp_strip_all_tags( $text );
318 $text = preg_replace( '/[\r\n]+/', "\n", $text );
319 $text = preg_replace( '/\n+/', "\n", $text );
320 $text = preg_replace( '/\t+/', "\t", $text );
321 return $text . ' ';
322 }
323
324 // Make sure there are no duplicate sentences, and keep the length under a maximum length.
325 public function clean_sentences( $text, $maxLength = null ) {
326 // Step 1: Identify URLs and replace them with a placeholder.
327 $urlPattern = '/\bhttps?:\/\/[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|\/))/';
328 preg_match_all( $urlPattern, $text, $urls );
329 $urlPlaceholders = [];
330 foreach ( $urls[0] as $index => $url ) {
331 $placeholder = '{urlPlaceholder' . $index . '}';
332 $text = str_replace( $url, $placeholder, $text );
333 $urlPlaceholders[$placeholder] = $url;
334 }
335
336 $maxLength = (int) ( $maxLength ? $maxLength : $this->get_option( 'context_max_length', 4096 ) );
337 // A zero or negative limit is a misconfiguration, never a request for no content. It used
338 // to return an empty string for any input, which silently emptied post content: embeddings
339 // stayed forever PENDING with only a log line, and chatbot context came back blank. Treat
340 // it as unset and use the default instead.
341 if ( $maxLength <= 0 ) {
342 $maxLength = 4096;
343 }
344 // Japanese, Chinese and friends do not put a space after 。 so requiring trailing
345 // whitespace made a whole CJK article count as one single sentence, which the loop
346 // below then skipped for being over maxLength: the content came back empty and the
347 // post stayed forever stale. Split on CJK punctuation with no whitespace needed, and
348 // keep requiring it after Latin punctuation so "3.14" and "e.g." stay in one piece.
349 $sentences = preg_split( '/(?<=[。.!?])|(?<=[.?!])\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY );
350 $hashes = [];
351 $uniqueSentences = [];
352 $total = 0;
353
354 foreach ( $sentences as $sentence ) {
355 $sentence = preg_replace( '/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $sentence );
356 $hash = md5( $sentence );
357 if ( !in_array( $hash, $hashes ) ) {
358 $length = mb_strlen( $sentence, 'UTF-8' );
359 if ( $total + $length > $maxLength ) {
360 continue;
361 }
362 $hashes[] = $hash;
363 $uniqueSentences[] = $sentence;
364 $total += $length;
365 }
366 }
367
368 // A single sentence longer than maxLength is skipped by the loop above, so text with
369 // no sentence punctuation at all (Thai, a wall of text) would still come back empty.
370 // Trim it instead of dropping it: an over-long input must never produce no input.
371 if ( empty( $uniqueSentences ) ) {
372 $trimmed = preg_replace( '/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $text );
373 if ( $trimmed !== '' ) {
374 $uniqueSentences = [ mb_substr( $trimmed, 0, $maxLength, 'UTF-8' ) ];
375 }
376 }
377
378 $freshText = implode( ' ', $uniqueSentences );
379
380 // Step 3: Restore URLs in the final text.
381 foreach ( $urlPlaceholders as $placeholder => $url ) {
382 $freshText = str_replace( $placeholder, $url, $freshText );
383 }
384
385 $freshText = preg_replace( '/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $freshText );
386 return $freshText;
387 }
388
389 public function get_post_content( $postId ) {
390 // Ensure we get fresh post data by clearing cache
391 clean_post_cache( $postId );
392 $post = get_post( $postId );
393 if ( !$post ) {
394 return false;
395 }
396 $text = apply_filters( 'mwai_pre_post_content', $post->post_content, $postId );
397 $pattern = '/\[mwai_.*?\]/';
398 $text = preg_replace( $pattern, '', $text );
399 if ( $this->get_option( 'resolve_shortcodes' ) ) {
400 $text = apply_filters( 'the_content', $text );
401 }
402 else {
403 $pattern = "/\[[^\]]+\]/";
404 $text = preg_replace( $pattern, '', $text );
405 $pattern = "/<!--\s*\/?wp:[^\>]+-->/";
406 $text = preg_replace( $pattern, '', $text );
407 }
408 $text = $this->clean_text( $text );
409 $text = $this->clean_sentences( $text );
410 $text = apply_filters( 'mwai_post_content', $text, $postId );
411 return $text;
412 }
413
414 public function markdown_to_html( $content ) {
415 $Parsedown = new Parsedown();
416 $content = $Parsedown->text( $content );
417 return $content;
418 }
419
420 public function get_post_language( $postId ) {
421 $locale = get_locale();
422 $code = strtolower( substr( $locale, 0, 2 ) );
423 $humanLanguage = strtr( $code, MWAI_ALL_LANGUAGES );
424 $lang = apply_filters( 'wpml_post_language_details', null, $postId );
425 if ( !empty( $lang ) ) {
426 $locale = $lang['locale'];
427 $humanLanguage = $lang['display_name'];
428 }
429 return strtolower( "$locale ($humanLanguage)" );
430 }
431
432 public function do_placeholders( $text ) {
433 $defaultPlaceholders = [
434 // Date and time in a clear, AI-friendly format (e.g., "December 11, 2025 at 3:45 PM")
435 'DATE_TIME' => date_i18n( 'F j, Y \a\t g:i A' ),
436 ];
437 $dataPlaceholders = $this->get_user_data();
438 if ( !empty( $dataPlaceholders ) ) {
439 $defaultPlaceholders = array_merge( $defaultPlaceholders, $dataPlaceholders );
440 }
441 $placeholders = apply_filters( 'mwai_placeholders', $defaultPlaceholders );
442 foreach ( $placeholders as $key => $value ) {
443 $text = str_replace( '{' . $key . '}', $value ?? '', $text );
444 }
445 // Unmatched {ALL_CAPS} tokens are left as-is on purpose. This runs after
446 // content-aware has already injected {CONTENT}/{TITLE} page text into the
447 // instructions, so a blanket "{FOO} -> [N/A]" catch-all would corrupt the
448 // page's own content (e.g. an API doc mentioning {API_KEY}) and any literal
449 // template text the admin wants the model to see. Known placeholders are
450 // resolved by the loop above; everything else is real text, so pass it through.
451 return $text;
452 }
453 #endregion
454
455 #region Security Helpers
456 /**
457 * Sanitize file path to prevent PHAR deserialization attacks.
458 * Strips dangerous stream wrappers that could trigger object injection.
459 *
460 * @param string $path The file path to sanitize.
461 * @return string The sanitized file path.
462 * @throws Exception If a dangerous stream wrapper is detected.
463 */
464 public static function sanitize_file_path( $path ) {
465 if ( empty( $path ) || !is_string( $path ) ) {
466 return $path;
467 }
468
469 // List of dangerous stream wrappers that could trigger deserialization
470 $dangerous_wrappers = [
471 'phar://',
472 'php://',
473 'zip://',
474 'zlib://',
475 'data://',
476 'glob://',
477 'rar://',
478 'ogg://',
479 'expect://'
480 ];
481
482 // Check if the path contains any dangerous wrappers
483 $lower_path = strtolower( $path );
484 foreach ( $dangerous_wrappers as $wrapper ) {
485 if ( strpos( $lower_path, $wrapper ) === 0 ) {
486 throw new Exception( 'Invalid file path: Stream wrappers are not allowed for security reasons.' );
487 }
488 }
489
490 return $path;
491 }
492
493 /**
494 * Strip the parameters a client must never control out of a REST payload.
495 *
496 * Nulling a local $path inside a route handler is not enough: the raw params array is
497 * still forwarded to the query, inject_params() repopulates $query->path from it, and
498 * the engine then hands that straight to file_get_contents(). That is how the 3.6.4
499 * transcription fix was bypassed. Sanitizing the array itself covers every caller that
500 * forwards params, present and future.
501 *
502 * Keys are normalized before matching because Meow_MWAI_Query_Base::convert_keys()
503 * camel-cases them later: "path_" becomes "path" and "api_key" becomes "apiKey", so an
504 * exact-match blocklist would let both through.
505 *
506 * @param array $params The client-supplied parameters.
507 * @return array The parameters without the blocked keys.
508 */
509 public static function sanitize_rest_params( $params ) {
510 if ( !is_array( $params ) ) {
511 return [];
512 }
513 $blocked = [
514 'apiKey', // Overrides the environment's provider key.
515 'path', // Reaches file_get_contents() via inject_params(): arbitrary file read.
516 ];
517 $blocked = apply_filters( 'mwai_blocked_rest_params', $blocked, $params );
518 $normalize = function ( $key ) {
519 return strtolower( str_replace( '_', '', (string) $key ) );
520 };
521 $blocked = array_map( $normalize, (array) $blocked );
522 foreach ( array_keys( $params ) as $key ) {
523 if ( in_array( $normalize( $key ), $blocked, true ) ) {
524 unset( $params[$key] );
525 }
526 }
527 return $params;
528 }
529 #endregion
530
531 #region Image-Related Helpers
532 public static function is_image( $file ) {
533 global $mwai_core;
534 if ( $mwai_core && $mwai_core->imageService ) {
535 return $mwai_core->imageService->is_image( $file );
536 }
537 // Fallback to original implementation if service not available
538 $mimeType = self::get_mime_type( $file );
539 if ( strpos( $mimeType, 'image' ) !== false ) {
540 return true;
541 }
542 return false;
543 }
544
545 public static function get_image_resolution( $url ) {
546 global $mwai_core;
547 if ( $mwai_core && $mwai_core->imageService ) {
548 return $mwai_core->imageService->get_image_resolution( $url );
549 }
550 // Fallback to original implementation if service not available
551 if ( empty( $url ) ) {
552 return null;
553 }
554 $headers = get_headers( $url, 1 );
555 if ( strpos( $headers[0], '200' ) === false ) {
556 return null;
557 }
558 $image_info = getimagesize( $url );
559 if ( $image_info === false ) {
560 return null;
561 }
562 return [
563 'width' => $image_info[0],
564 'height' => $image_info[1]
565 ];
566 }
567
568 public static function get_mime_type( $file ) {
569 global $mwai_core;
570 if ( $mwai_core && $mwai_core->imageService ) {
571 return $mwai_core->imageService->get_mime_type( $file );
572 }
573
574 // Fallback implementation - this should rarely be used as imageService is initialized early
575 Meow_MWAI_Logging::warn( 'get_mime_type called before imageService is available' );
576
577 // Basic extension-based detection only
578 $extension = pathinfo( $file, PATHINFO_EXTENSION );
579 $extension = strtolower( $extension );
580 $mimeTypes = [
581 'jpg' => 'image/jpeg',
582 'jpeg' => 'image/jpeg',
583 'png' => 'image/png',
584 'gif' => 'image/gif',
585 'webp' => 'image/webp',
586 'bmp' => 'image/bmp',
587 'tiff' => 'image/tiff',
588 'tif' => 'image/tiff',
589 'svg' => 'image/svg+xml',
590 'ico' => 'image/x-icon',
591 'pdf' => 'application/pdf',
592 ];
593 return isset( $mimeTypes[$extension] ) ? $mimeTypes[$extension] : null;
594 }
595
596 public function download_image( $url ) {
597 return $this->imageService->download_image( $url );
598 }
599
600 /**
601 * Add an image from a URL to the Media Library.
602 * @param string $url The URL of the image to be downloaded.
603 * @param string $filename The filename of the image, if not set, it will be the basename of the URL.
604 * @param string $title The title of the image.
605 * @param string $description The description of the image.
606 * @param string $caption The caption of the image.
607 * @param string $alt The alt text of the image.
608 * @return int The attachment ID of the image.
609 */
610 public function add_image_from_url( $url, $filename = null, $title = null, $description = null, $caption = null, $alt = null, $attachedPost = null, $post_status = 'inherit', $post_type = 'attachment', $ai_metadata = [] ) {
611 return $this->imageService->add_image_from_url( $url, $filename, $title, $description, $caption, $alt, $attachedPost, $post_status, $post_type, $ai_metadata );
612 }
613 #endregion
614
615 #region Context-Related Helpers
616 public function retrieve_context( $params, $query, $streamCallback = null ) {
617 $contextMaxLength = $params['contextMaxLength'] ?? $this->get_option( 'context_max_length', 4096 );
618 $embeddingsEnvId = $params['embeddingsEnvId'] ?? null;
619
620 $context = apply_filters( 'mwai_context_search', [], $query, [
621 'embeddingsEnvId' => $embeddingsEnvId,
622 'streamCallback' => $streamCallback
623 ] );
624
625 // Emit embeddings event if streaming and context was found
626 if ( $streamCallback && !empty( $context ) ) {
627 $count = 0;
628 if ( isset( $context['embeddings'] ) && is_array( $context['embeddings'] ) ) {
629 $count = count( $context['embeddings'] );
630 }
631 else if ( isset( $context['content'] ) ) {
632 $count = 1;
633 }
634 if ( $count > 0 ) {
635 $event = Meow_MWAI_Event::embeddings( $count );
636 $streamCallback( $event );
637 }
638 }
639
640 if ( empty( $context ) ) {
641 return null;
642 }
643 else if ( !isset( $context['content'] ) ) {
644 Meow_MWAI_Logging::warn( 'A context without content was returned.' );
645 return null;
646 }
647 $context['content'] = $this->clean_sentences( $context['content'], $contextMaxLength );
648 $context['length'] = strlen( $context['content'] );
649 return $context;
650 }
651
652 /**
653 * Wrap context content with framing instructions for AI.
654 * This helps the AI understand that the context is background knowledge.
655 *
656 * @param string $context The raw context content.
657 * @return string The framed context with instructions.
658 */
659 public function frame_context( $context ) {
660 if ( empty( $context ) ) {
661 return $context;
662 }
663 $framing = 'The following is your knowledge about this topic. ' .
664 'Use it naturally when relevant - never mention or acknowledge that this information was provided to you. ' .
665 "If the user's message is unrelated (e.g., greetings, thanks), respond naturally without using it.";
666 $framing = apply_filters( 'mwai_context_framing', $framing, $context );
667 return $framing . "\n\n---\n" . $context . "\n---";
668 }
669 #endregion
670
671 #region Users/Sessions Helpers
672
673 public function get_nonce( $force = false ) {
674 return $this->sessionService->get_nonce( $force );
675 }
676
677 // This is a bit hacky, but chatId needs to be retrieved or generated.
678 // Maybe we can clean this up later.
679 public function fix_chat_id( $query, $params ) {
680 return $this->sessionService->fix_chat_id( $query, $params );
681 }
682
683 public function get_session_id() {
684 return $this->sessionService->get_session_id();
685 }
686
687 /**
688 * Get the Response ID Manager service
689 */
690 public function get_response_id_manager() {
691 return $this->responseIdManager;
692 }
693
694 /**
695 * Get the Message Builder service
696 */
697 public function get_message_builder() {
698 return $this->messageBuilder;
699 }
700
701 // Get the UserID from the data, or from the current user
702 public function get_user_id( $data = null ) {
703 return $this->sessionService->get_user_id( $data );
704 }
705
706 public function get_session_user_id() {
707 return $this->sessionService->get_session_user_id();
708 }
709
710 public function get_admin_user() {
711 return $this->sessionService->get_admin_user();
712 }
713
714 public function get_user_data() {
715 return $this->sessionService->get_user_data();
716 }
717
718 public function get_ip_address( $force = false ) {
719 return $this->sessionService->get_ip_address( $force );
720 }
721
722 #endregion
723
724 #region Sanitization
725 public function sanitize_sort(
726 &$sort,
727 $default_accessor = 'created',
728 $default_order = 'DESC',
729 $allowed_columns = [ 'created', 'updated', 'name', 'id', 'time', 'units', 'price' ]
730 ) {
731
732 // Ensure $sort is an array
733 if ( !is_array( $sort ) ) {
734 $sort = [ 'accessor' => $default_accessor, 'by' => $default_order ];
735 }
736 // Extract and sanitize the accessor
737 $sort_accessor = isset( $sort['accessor'] ) ? $sort['accessor'] : $default_accessor;
738 if ( !in_array( $sort_accessor, $allowed_columns ) ) {
739 Meow_MWAI_Logging::error( "This sort accessor is not allowed ($sort_accessor)." );
740 $sort_accessor = $default_accessor;
741 }
742 // Extract and sanitize the sort order
743 $sort_by = isset( $sort['by'] ) ? strtoupper( $sort['by'] ) : $default_order;
744 if ( $sort_by !== 'ASC' && $sort_by !== 'DESC' ) {
745 Meow_MWAI_Logging::error( "This sort order is not allowed ($sort_by)." );
746 $sort_by = $default_order;
747 }
748 // Update the sort array with sanitized values
749 $sort['accessor'] = $sort_accessor;
750 $sort['by'] = $sort_by;
751 }
752 #endregion
753
754 #region Other Helpers
755 public function safe_strlen( $string, $encoding = 'UTF-8' ) {
756 if ( function_exists( 'mb_strlen' ) ) {
757 return mb_strlen( $string, $encoding );
758 }
759 else {
760 // Fallback implementation for environments without mbstring extension
761 return preg_match_all( '/./u', $string, $matches );
762 }
763 }
764
765 public function check_rest_nonce( $request ) {
766 // REST NONCE VERIFICATION:
767 // Validates nonce from X-WP-Nonce header using WordPress nonce system.
768 // Returns: false (invalid), 1 (0-12 hours old), or 2 (12-24 hours old)
769 // WordPress REST permission callbacks accept any truthy value as success.
770 // The filter allows custom authorization logic if needed.
771 $nonce = $request->get_header( 'X-WP-Nonce' );
772 $rest_nonce = wp_verify_nonce( $nonce, 'wp_rest' );
773 return apply_filters( 'mwai_rest_authorized', $rest_nonce, $request );
774 }
775
776 public function get_random_id( $length = 8, $excludeIds = [] ) {
777 $characters = '0123456789abcdefghijklmnopqrstuvwxyz';
778 $charactersLength = strlen( $characters );
779 $randomId = '';
780 for ( $i = 0; $i < $length; $i++ ) {
781 $randomId .= $characters[ mt_rand( 0, $charactersLength - 1 ) ];
782 }
783 if ( in_array( $randomId, $excludeIds ) ) {
784 return $this->get_random_id( $length, $excludeIds );
785 }
786 return $randomId;
787 }
788
789 public function is_url( $url ) {
790 return strpos( $url, 'http' ) === 0 ? true : false;
791 }
792
793 public function get_post_types() {
794 $excluded = [ 'attachment', 'revision', 'nav_menu_item' ];
795 $post_types = [];
796 $types = get_post_types( [], 'objects' );
797
798 // Let's get the Post Types that are enabled for Embeddings Sync
799 $embeddingsSettings = $this->get_option( 'embeddings' );
800 $syncPostTypes = isset( $embeddingsSettings['syncPostTypes'] ) ? $embeddingsSettings['syncPostTypes'] : [];
801
802 foreach ( $types as $type ) {
803 $forced = in_array( $type->name, $syncPostTypes );
804 // Should not be excluded.
805 if ( !$forced && in_array( $type->name, $excluded ) ) {
806 continue;
807 }
808 // Should be public.
809 if ( !$forced && !$type->public ) {
810 continue;
811 }
812 $post_types[] = [
813 'name' => $type->labels->name,
814 'type' => $type->name,
815 ];
816 }
817
818 // Let's get the Post Types that are enabled for Embeddings Sync
819 $embeddingsSettings = $this->get_option( 'embeddings' );
820 $syncPostTypes = isset( $embeddingsSettings['syncPostTypes'] ) ? $embeddingsSettings['syncPostTypes'] : [];
821
822 return $post_types;
823 }
824
825 public function get_post( $post ) {
826 if ( is_numeric( $post ) ) {
827 // Force fresh retrieval to avoid cache issues
828 clean_post_cache( $post );
829 $post = get_post( $post );
830 }
831 if ( is_object( $post ) ) {
832 $post = (array) $post;
833 }
834 if ( !is_array( $post ) ) {
835 return null;
836 }
837 $language = $this->get_post_language( $post['ID'] );
838 $content = $this->get_post_content( $post['ID'] );
839 $title = $post['post_title'];
840 $excerpt = $post['post_excerpt'];
841 $url = get_permalink( $post['ID'] );
842 $checksum = wp_hash( $content . $title . $url );
843
844 return [
845 'postId' => (int) $post['ID'],
846 'title' => $title,
847 'content' => $content,
848 'excerpt' => $excerpt,
849 'url' => $url,
850 'language' => $language ?? 'english',
851 'checksum' => $checksum,
852 ];
853 }
854
855 /**
856 * Format a date/time string into a human-readable format
857 * @param string $date_string The date string to format
858 * @return string Formatted date (e.g., "Just now", "5m ago", "2h ago", "3d ago", "Jan 20th")
859 */
860 public function format_discussion_date( $date_string ) {
861 $date = strtotime( $date_string );
862 $now = time();
863 $diff = $now - $date;
864
865 // Less than a minute
866 if ( $diff < 60 ) {
867 return 'Just now';
868 }
869
870 // Less than an hour
871 if ( $diff < 3600 ) {
872 $minutes = floor( $diff / 60 );
873 return $minutes . 'm ago';
874 }
875
876 // Less than a day
877 if ( $diff < 86400 ) {
878 $hours = floor( $diff / 3600 );
879 return $hours . 'h ago';
880 }
881
882 // Less than a week
883 if ( $diff < 604800 ) {
884 $days = floor( $diff / 86400 );
885 return $days . 'd ago';
886 }
887
888 // Format as date, in the site's configured timezone (wp_date), not UTC.
889 $is_current_year = wp_date( 'Y', $date ) === wp_date( 'Y', $now );
890 if ( $is_current_year ) {
891 return wp_date( 'M jS', $date );
892 }
893 else {
894 return wp_date( 'M jS, Y', $date );
895 }
896 }
897 #endregion
898
899 #region Usage & Costs
900
901 // Quick and dirty token estimation
902 // Let's keep this synchronized with Helpers in JS
903 public static function estimate_tokens( $text = '', $model = null ): int {
904 global $mwai_core;
905 if ( $mwai_core && $mwai_core->usageStatsService ) {
906 return $mwai_core->usageStatsService->estimate_tokens( $text, $model );
907 }
908 // Fallback to original implementation if service not available
909 if ( !is_string( $text ) ) {
910 $text = is_array( $text ) || is_object( $text ) ? json_encode( $text ) : (string) $text;
911 }
912 $averageTokenLength = 4;
913 $words = preg_split( '/\s+/', trim( $text ) );
914 $tokenCount = 0;
915 foreach ( $words as $word ) {
916 $tokenCount += ceil( strlen( $word ) / $averageTokenLength );
917 }
918 return apply_filters( 'mwai_estimate_tokens', $tokenCount, $text );
919 }
920
921 public function record_tokens_usage( $model, $in_tokens, $out_tokens = 0, $returned_price = null ) {
922 return $this->usageStatsService->record_tokens_usage( $model, $in_tokens, $out_tokens, $returned_price );
923 }
924
925 public function record_audio_usage( $model, $seconds ) {
926 return $this->usageStatsService->record_audio_usage( $model, $seconds );
927 }
928
929 public function record_images_usage( $model, $resolution, $images ) {
930 return $this->usageStatsService->record_images_usage( $model, $resolution, $images );
931 }
932
933 public function record_videos_usage( $model, $resolution, $seconds ) {
934 return $this->usageStatsService->record_videos_usage( $model, $resolution, $seconds );
935 }
936
937 #endregion
938
939 #region Errors
940
941 // The message visitors get when something breaks internally (a provider failure, a
942 // function-call issue, a stream error). The real error is always logged, and admins
943 // still see it; visitors only ever get this. Filterable so a site can match its own
944 // tone and so a raw provider message (billing, quota, model errors) never leaks.
945 public static function get_public_error_message( $exception = null ) {
946 $message = 'Oops! Something went wrong on the server. Please try again, and if you are the site developer, check the PHP Error Logs for details.';
947 return apply_filters( 'mwai_public_error_message', $message, $exception );
948 }
949
950 #endregion
951
952 #region Streaming
953 public function stream_push( $data, $query = null ) {
954 try {
955 // Handle new Event objects
956 if ( is_object( $data ) && method_exists( $data, 'to_array' ) ) {
957 $data = $data->to_array();
958 }
959
960 $data = apply_filters( 'mwai_stream_push', $data, $query );
961 $out = 'data: ' . json_encode( $data );
962 echo $out;
963 echo "\n\n";
964 if ( ob_get_level() > 0 ) {
965 ob_end_flush();
966 }
967 flush();
968 }
969 catch ( Exception $e ) {
970 // Send error as proper SSE error event
971 $errorMessage = self::get_public_error_message( $e );
972 error_log( '[AI Engine Stream Error] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
973
974 $errorData = [
975 'type' => 'error',
976 'data' => $errorMessage
977 ];
978 $out = 'data: ' . json_encode( $errorData );
979 echo $out;
980 echo "\n\n";
981 if ( ob_get_level() > 0 ) {
982 ob_end_flush();
983 }
984 flush();
985
986 // Stop execution after sending error
987 die();
988 }
989 }
990 #endregion
991
992 #region Options
993 public function get_themes() {
994 $themes = get_option( $this->themes_option_name, [] );
995 $themes = empty( $themes ) ? [] : $themes;
996
997 $internalThemes = [
998 'chatgpt' => [
999 'type' => 'internal', 'name' => 'ChatGPT', 'themeId' => 'chatgpt',
1000 'settings' => [], 'style' => ''
1001 ],
1002 'messages' => [
1003 'type' => 'internal', 'name' => 'Messages', 'themeId' => 'messages',
1004 'settings' => [], 'style' => ''
1005 ],
1006 'timeless' => [
1007 'type' => 'internal', 'name' => 'Timeless', 'themeId' => 'timeless',
1008 'settings' => [], 'style' => ''
1009 ],
1010 'foundation' => [
1011 'type' => 'internal', 'name' => 'Foundation', 'themeId' => 'foundation',
1012 'settings' => [], 'style' => ''
1013 ],
1014 ];
1015 $customThemes = [];
1016 foreach ( $themes as $theme ) {
1017 if ( isset( $internalThemes[$theme['themeId']] ) ) {
1018 $internalThemes[$theme['themeId']] = $theme;
1019 continue;
1020 }
1021 $customThemes[] = $theme;
1022 }
1023 return array_merge( array_values( $internalThemes ), $customThemes );
1024 }
1025
1026 public function update_themes( $themes ) {
1027 update_option( $this->themes_option_name, $themes );
1028 return $themes;
1029 }
1030
1031 /**
1032 * Returns the registered chatbots. Pass a $filters array to narrow the list,
1033 * e.g. get_chatbots( [ 'functions' => true ] ) to only keep chatbots whose
1034 * model supports function calling. No filters returns every chatbot, as before.
1035 */
1036 public function get_chatbots( $filters = [] ) {
1037 $chatbots = get_option( $this->chatbots_option_name, [] );
1038 $hasChanges = false;
1039 if ( empty( $chatbots ) ) {
1040 $chatbots = [ array_merge( MWAI_CHATBOT_DEFAULT_PARAMS, ['name' => 'Default', 'botId' => 'default' ] ) ];
1041 }
1042 $hasDefault = false;
1043 foreach ( $chatbots as &$chatbot ) {
1044 if ( $chatbot['botId'] === 'default' ) {
1045 $hasDefault = true;
1046 }
1047 foreach ( MWAI_CHATBOT_DEFAULT_PARAMS as $key => $value ) {
1048 // Use default value if not set.
1049 if ( !isset( $chatbot[$key] ) ) {
1050 $chatbot[$key] = $value;
1051 }
1052 }
1053
1054 /*
1055 This is the best section to rename fields.
1056 We did this in 2024 for context to instructions, and fileUpload to fileSearch. fileSearch is for assistant file search, and fileUpload is now for chatbot file upload (similar to vision, but for files instead of images).
1057 */
1058
1059 // Migrate old file upload params to new unified system
1060 if ( !isset( $chatbot['fileUpload'] ) ) {
1061 // Set fileUpload based on old params (imageUpload, fileUploads, or vision checkbox)
1062 $chatbot['fileUpload'] = !empty( $chatbot['imageUpload'] ) || ( isset( $chatbot['fileUploads'] ) && $chatbot['fileUploads'] > 0 );
1063 $hasChanges = true;
1064 }
1065
1066 // Ensure maxUploads is set
1067 if ( !isset( $chatbot['maxUploads'] ) ) {
1068 if ( isset( $chatbot['fileUploads'] ) && $chatbot['fileUploads'] > 0 ) {
1069 $chatbot['maxUploads'] = $chatbot['fileUploads'];
1070 }
1071 else {
1072 $chatbot['maxUploads'] = 1; // Default to 1 file
1073 }
1074 $hasChanges = true;
1075 }
1076
1077 // Sync fileUploads with fileUpload and maxUploads for consistency
1078 if ( isset( $chatbot['fileUpload'] ) ) {
1079 $chatbot['fileUploads'] = $chatbot['fileUpload'] ? $chatbot['maxUploads'] : 0;
1080 $chatbot['multiUpload'] = $chatbot['fileUpload'] && $chatbot['maxUploads'] > 1;
1081 $chatbot['imageUpload'] = $chatbot['fileUpload']; // Keep imageUpload in sync
1082 }
1083
1084 // Migration: DALL-E was removed (deprecated by OpenAI). Move chatbots to gpt-image-1.5.
1085 // TODO: Remove after 2027-04 (1 year after the shutdown on 2026-05-12).
1086 if ( isset( $chatbot['model'] )
1087 && in_array( $chatbot['model'], [ 'dall-e', 'dall-e-2', 'dall-e-3', 'dall-e-3-hd' ], true ) ) {
1088 $chatbot['model'] = MWAI_FALLBACK_MODEL_IMAGES;
1089 $hasChanges = true;
1090 }
1091
1092 // if ( isset( $chatbot['context'] ) ) {
1093 // $chatbot['instructions'] = $chatbot['context'];
1094 // unset( $chatbot['context'] );
1095 // $hasChanges = true;
1096 // }
1097 }
1098 if ( !$hasDefault ) {
1099 $defaultBot = array_merge( MWAI_CHATBOT_DEFAULT_PARAMS, ['name' => 'Default', 'botId' => 'default' ] );
1100 array_unshift( $chatbots, $defaultBot );
1101 $hasChanges = true;
1102 }
1103 if ( $hasChanges ) {
1104 update_option( $this->chatbots_option_name, $chatbots );
1105 }
1106
1107 // Optional filtering by capability (e.g. only function-calling chatbots).
1108 if ( !empty( $filters['functions'] ) ) {
1109 $chatbots = array_values( array_filter( $chatbots, function ( $chatbot ) {
1110 return $this->chatbot_supports_functions( $chatbot );
1111 } ) );
1112 }
1113
1114 return $chatbots;
1115 }
1116
1117 /**
1118 * Whether a chatbot's model supports function calling. The 'functions' tag is
1119 * the canonical signal across all engines; dynamic providers (OpenRouter, etc.)
1120 * also expose it as a feature, so we accept both.
1121 */
1122 public function chatbot_supports_functions( $chatbot ) {
1123 $modelId = $chatbot['model'] ?? null;
1124 if ( empty( $modelId ) ) {
1125 return false;
1126 }
1127 $envId = $chatbot['envId'] ?? ( $chatbot['environment'] ?? null );
1128 $model = $this->find_model_data( $modelId, $envId );
1129 if ( empty( $model ) ) {
1130 return false;
1131 }
1132 $tags = $model['tags'] ?? [];
1133 $features = $model['features'] ?? [];
1134 return in_array( 'functions', $tags, true ) || in_array( 'functions', $features, true );
1135 }
1136
1137 /**
1138 * Resolve a model id to its metadata (tags, features, etc.). Static providers
1139 * (OpenAI, Anthropic) keep their models in 'ai_engines' by type; dynamic ones
1140 * (OpenRouter, Google) store them per environment in 'ai_envs'. We check the
1141 * environment first, then fall back to the engine defaults and custom models.
1142 */
1143 public function find_model_data( $modelId, $envId = null ) {
1144 if ( empty( $modelId ) ) {
1145 return null;
1146 }
1147 $options = $this->get_all_options();
1148
1149 // 1. The chatbot's own environment (covers dynamically-fetched models).
1150 $envType = null;
1151 if ( !empty( $envId ) && !empty( $options['ai_envs'] ) ) {
1152 foreach ( $options['ai_envs'] as $env ) {
1153 if ( ( $env['id'] ?? null ) !== $envId ) {
1154 continue;
1155 }
1156 $envType = $env['type'] ?? null;
1157 foreach ( $env['models'] ?? [] as $model ) {
1158 if ( ( $model['model'] ?? null ) === $modelId ) {
1159 return $model;
1160 }
1161 }
1162 }
1163 }
1164
1165 // 2. The engine defaults (constants), scoped to the env type when known.
1166 foreach ( $options['ai_engines'] ?? [] as $engine ) {
1167 if ( $envType !== null && ( $engine['type'] ?? null ) !== $envType ) {
1168 continue;
1169 }
1170 foreach ( $engine['models'] ?? [] as $model ) {
1171 if ( ( $model['model'] ?? null ) === $modelId ) {
1172 return $model;
1173 }
1174 }
1175 }
1176
1177 // 3. Custom models.
1178 foreach ( $options['ai_models'] ?? [] as $model ) {
1179 if ( ( $model['model'] ?? null ) === $modelId ) {
1180 return $model;
1181 }
1182 }
1183
1184 return null;
1185 }
1186
1187 public function get_chatbot( $botId ) {
1188 $chatbots = $this->get_chatbots();
1189 foreach ( $chatbots as $chatbot ) {
1190 if ( $chatbot['botId'] === (string) $botId ) {
1191 return $chatbot;
1192 }
1193 }
1194 return null;
1195 }
1196
1197 public function get_embeddings_env( $envId ) {
1198 return $this->modelEnvironmentService->get_embeddings_env( $envId );
1199 }
1200
1201 public function get_ai_env( $envId ) {
1202 return $this->modelEnvironmentService->get_ai_env( $envId );
1203 }
1204
1205 public function get_assistant( $envId, $assistantId ) {
1206 return $this->modelEnvironmentService->get_assistant( $envId, $assistantId );
1207 }
1208
1209 public function get_theme( $themeId ) {
1210 $themes = $this->get_themes();
1211 foreach ( $themes as $theme ) {
1212 if ( $theme['themeId'] === $themeId ) {
1213 // Append custom CSS to theme data for frontend rendering (check for non-empty trimmed string)
1214 if ( isset( $theme['settings']['customCSS'] ) && trim( $theme['settings']['customCSS'] ) !== '' ) {
1215 $customCSS = $theme['settings']['customCSS'];
1216
1217 // Add theme class prefix to all CSS rules for proper scoping
1218 $themeClass = '.mwai-' . $themeId . '-theme';
1219 $lines = explode( "\n", $customCSS );
1220 $processedCSS = '';
1221 $inRule = false;
1222
1223 foreach ( $lines as $line ) {
1224 $trimmedLine = trim( $line );
1225
1226 // Skip empty lines and comments
1227 if ( empty( $trimmedLine ) || strpos( $trimmedLine, '/*' ) === 0 ) {
1228 $processedCSS .= $line . "\n";
1229 continue;
1230 }
1231
1232 // If line contains a selector (has { but not })
1233 if ( strpos( $line, '{' ) !== false && strpos( $line, '}' ) === false ) {
1234 // Extract selector and the rest
1235 $parts = explode( '{', $line, 2 );
1236 $selector = trim( $parts[0] );
1237
1238 // Add theme class prefix if not already present
1239 if ( strpos( $selector, $themeClass ) !== 0 ) {
1240 // Handle multiple selectors separated by comma
1241 $selectors = explode( ',', $selector );
1242 $prefixedSelectors = array_map( function ( $sel ) use ( $themeClass ) {
1243 $sel = trim( $sel );
1244 // Don't prefix if it's a keyframe or similar
1245 if ( strpos( $sel, '@' ) === 0 || strpos( $sel, 'from' ) === 0 || strpos( $sel, 'to' ) === 0 || preg_match( '/^\d+%/', $sel ) ) {
1246 return $sel;
1247 }
1248 return $themeClass . ' ' . $sel;
1249 }, $selectors );
1250 $selector = implode( ', ', $prefixedSelectors );
1251 }
1252
1253 $processedCSS .= $selector . ' {' . ( isset( $parts[1] ) ? $parts[1] : '' ) . "\n";
1254 $inRule = true;
1255 }
1256 else {
1257 $processedCSS .= $line . "\n";
1258 }
1259 }
1260
1261 $customCSS = $processedCSS;
1262
1263 // For custom themes (type: 'css'), append to style property
1264 if ( $theme['type'] === 'css' ) {
1265 $theme['style'] = ( $theme['style'] ?? '' ) . "\n\n/* Custom CSS */\n" . $customCSS;
1266 }
1267 // For internal themes, add customCSS as a separate property
1268 else {
1269 $theme['customCSS'] = $customCSS;
1270 }
1271 }
1272
1273 // Add CSS URL for cross-site and dynamic loading support
1274 // Internal themes can use physical file, custom themes use REST endpoint
1275 $theme_type = $theme['type'] ?? 'internal';
1276 if ( $theme_type === 'internal' ) {
1277 $theme['cssUrl'] = MWAI_URL . 'themes/' . $themeId . '.css';
1278 }
1279 else {
1280 // Custom themes use REST endpoint (requires Cross-Site module to be enabled)
1281 $theme['cssUrl'] = get_rest_url( null, 'mwai-ui/v1/cross-site/theme-css' ) . '?themeId=' . $themeId;
1282 }
1283
1284 return $theme;
1285 }
1286 }
1287 return null;
1288 }
1289
1290 public function update_chatbots( $chatbots ) {
1291 $htmlFields = [ 'instructions', 'textCompliance', 'aiName', 'userName', 'startSentence' ];
1292 $keepLineReturnsFields = [ 'instructions' ];
1293 $whiteSpacedFields = [ 'context' ];
1294 // Boolean fields that need proper conversion
1295 $booleanFields = [ 'window', 'copyButton', 'pdfButton', 'fullscreen', 'localMemory', 'iconBubble', 'centerOpen',
1296 'imageUpload', 'fileUpload', 'multiUpload', 'fileSearch', 'contentAware', 'aiAvatar', 'userAvatar', 'guestAvatar' ];
1297 foreach ( $chatbots as &$chatbot ) {
1298 foreach ( $chatbot as $key => &$value ) {
1299 if ( in_array( $key, $htmlFields ) ) {
1300 $value = wp_kses_post( $value );
1301 }
1302 else if ( in_array( $key, $whiteSpacedFields ) ) {
1303 $value = sanitize_textarea_field( $value );
1304 }
1305 else if ( in_array( $key, $booleanFields ) ) {
1306 // Convert various representations to boolean
1307 if ( is_bool( $value ) ) {
1308 // Already boolean, keep as is
1309 }
1310 else if ( $value === 1 || $value === '1' || $value === true || $value === 'true' || $value === 'yes' ) {
1311 // These are true values
1312 $value = true;
1313 }
1314 else if ( $value === 0 || $value === '0' || $value === false || $value === 'false' || $value === 'no' || $value === '' || $value === null ) {
1315 // These are false values
1316 $value = false;
1317 }
1318 else {
1319 // Default to checking if not empty
1320 $value = !empty( $value );
1321 }
1322 }
1323 else if ( $key === 'functions' ) {
1324 $functions = [];
1325 foreach ( $value as $function ) {
1326 if ( isset( $function['id'] ) && isset( $function['type'] ) ) {
1327 $functions[] = [
1328 'id' => sanitize_text_field( $function['id'] ),
1329 'type' => sanitize_text_field( $function['type'] ),
1330 ];
1331 }
1332 }
1333 $value = $functions;
1334 }
1335 else if ( $key === 'mcpServers' ) {
1336 $mcpServers = [];
1337 foreach ( $value as $server ) {
1338 if ( isset( $server['id'] ) ) {
1339 $mcpServers[] = [
1340 'id' => sanitize_text_field( $server['id'] ),
1341 ];
1342 }
1343 }
1344 $value = $mcpServers;
1345 }
1346 else if ( $key === 'tools' ) {
1347 // Sanitize tools array (web_search, image_generation, thinking, etc)
1348 $tools = [];
1349 if ( is_array( $value ) ) {
1350 foreach ( $value as $tool ) {
1351 $sanitized_tool = sanitize_text_field( $tool );
1352 if ( in_array( $sanitized_tool, ['web_search', 'image_generation', 'thinking', 'code_interpreter', 'google_maps'] ) ) {
1353 $tools[] = $sanitized_tool;
1354 }
1355 }
1356 }
1357 $value = $tools;
1358 }
1359 else if ( $key === 'crossSite' ) {
1360 // Handle crossSite object
1361 $crossSite = [
1362 'enabled' => isset( $value['enabled'] ) ? (bool) $value['enabled'] : false,
1363 'allowedDomains' => []
1364 ];
1365 if ( isset( $value['allowedDomains'] ) && is_array( $value['allowedDomains'] ) ) {
1366 foreach ( $value['allowedDomains'] as $domain ) {
1367 $sanitized_domain = sanitize_text_field( $domain );
1368 if ( !empty( $sanitized_domain ) ) {
1369 $crossSite['allowedDomains'][] = $sanitized_domain;
1370 }
1371 }
1372 }
1373 $value = $crossSite;
1374 }
1375 else {
1376 if ( in_array( $key, $keepLineReturnsFields ) ) {
1377 $value = preg_replace( '/\r\n/', '[==LINE_RETURN==]', $value );
1378 $value = preg_replace( '/\n/', '[==LINE_RETURN==]', $value );
1379 }
1380 $value = sanitize_text_field( $value );
1381 if ( in_array( $key, $keepLineReturnsFields ) ) {
1382 $value = preg_replace( '/\[==LINE_RETURN==\]/', "\n", $value );
1383 }
1384 }
1385 }
1386
1387 // Sync upload params to ensure consistency
1388 // fileUpload is the master control - respect its value
1389 $fileUploadEnabled = !empty( $chatbot['fileUpload'] ) || !empty( $chatbot['imageUpload'] );
1390 $maxFiles = isset( $chatbot['maxUploads'] ) && $chatbot['maxUploads'] > 0 ? (int) $chatbot['maxUploads'] : 1;
1391
1392 $chatbot['imageUpload'] = $fileUploadEnabled;
1393 $chatbot['fileUploads'] = $fileUploadEnabled ? $maxFiles : 0;
1394 $chatbot['multiUpload'] = $fileUploadEnabled && $maxFiles > 1;
1395 }
1396 if ( !update_option( $this->chatbots_option_name, $chatbots ) ) {
1397 Meow_MWAI_Logging::warn( 'Could not update chatbots.' );
1398 $chatbots = get_option( $this->chatbots_option_name, [] );
1399 return $chatbots;
1400 }
1401 return $chatbots;
1402 }
1403
1404 public function populate_dynamic_options( $options ) {
1405 static $populating = false;
1406
1407 // Prevent infinite recursion
1408 if ( $populating ) {
1409 return $options;
1410 }
1411
1412 $populating = true;
1413
1414 // Languages - use custom languages as the complete list
1415 $custom_languages = isset( $options['custom_languages'] ) && !empty( $options['custom_languages'] )
1416 ? $options['custom_languages']
1417 : [];
1418
1419 // If no custom languages defined, fall back to defaults
1420 if ( empty( $custom_languages ) ) {
1421 $options['languages'] = apply_filters( 'mwai_languages', MWAI_LANGUAGES );
1422 }
1423 else {
1424 // Process custom languages
1425 $processed_languages = [];
1426 foreach ( $custom_languages as $custom_lang ) {
1427 // Support formats like "Russian (ru)" or just "Russian"
1428 $custom_lang = trim( $custom_lang );
1429 if ( !empty( $custom_lang ) ) {
1430 // Check if language code is provided in parentheses
1431 if ( preg_match( '/^(.+)\s*\(([a-z]{2,3})\)$/i', $custom_lang, $matches ) ) {
1432 $lang_name = trim( $matches[1] );
1433 $lang_code = strtolower( trim( $matches[2] ) );
1434 $processed_languages[$lang_code] = $lang_name;
1435 }
1436 else {
1437 // No code provided, add as-is
1438 $processed_languages[] = $custom_lang;
1439 }
1440 }
1441 }
1442
1443 $options['languages'] = apply_filters( 'mwai_languages', $processed_languages );
1444 }
1445
1446 // Consolidate the Engines and their Models
1447 // PS: We should ABSOLUTELY AVOID to use ai_models directly (except for saving)
1448 // Engine Example: [ 'name' => 'Ollama', 'type' => 'ollama', inputs => ['apikey', 'endpoint'], models => [] ]
1449 $engines = MWAI_ENGINES;
1450
1451 // OVHcloud is integrated but not yet promoted publicly (pending the OVHcloud
1452 // partnership). It only appears as a selectable provider when Dev Mode is on,
1453 // so production sites never see it until we decide to ship it.
1454 if ( $this->get_option( 'dev_mode' ) ) {
1455 $engines[] = [
1456 'name' => 'OVHcloud',
1457 'type' => 'ovh',
1458 'inputs' => [ 'apikey', 'dynamicModels' ],
1459 'internal' => true,
1460 'models' => [],
1461 ];
1462 }
1463
1464 $options['ai_engines'] = apply_filters( 'mwai_engines', $engines );
1465 foreach ( $options['ai_engines'] as &$engine ) {
1466 if ( $engine['type'] === 'openai' ) {
1467 $engine['models'] = apply_filters(
1468 'mwai_openai_models',
1469 Meow_MWAI_Engines_OpenAI::get_models_static()
1470 );
1471 }
1472 else if ( $engine['type'] === 'anthropic' ) {
1473 $engine['models'] = apply_filters(
1474 'mwai_anthropic_models',
1475 Meow_MWAI_Engines_Anthropic::get_models_static()
1476 );
1477 }
1478 else if ( $engine['type'] === 'perplexity' ) {
1479 $engine['models'] = apply_filters(
1480 'mwai_perplexity_models',
1481 Meow_MWAI_Engines_Perplexity::get_models_static()
1482 );
1483 }
1484 else if ( $engine['type'] === 'mistral' ) {
1485 $engine['models'] = apply_filters(
1486 'mwai_mistral_models',
1487 Meow_MWAI_Engines_Mistral::get_models_static()
1488 );
1489 }
1490 else if ( $engine['type'] === 'xai' ) {
1491 // Static fallback covers the case where dynamic model fetch failed (e.g. no credits
1492 // on the xAI account). Dynamically fetched models override this list when available.
1493 $engine['models'] = apply_filters(
1494 'mwai_xai_models',
1495 Meow_MWAI_Engines_XAI::get_models_static()
1496 );
1497 }
1498 else {
1499 $engine['models'] = [];
1500 foreach ( $options['ai_models'] as $model ) {
1501 if ( $model['type'] === $engine['type'] ) {
1502 $engine['models'][] = $model;
1503 }
1504 }
1505 }
1506 }
1507
1508 // Functions via Code Engine (or custom code)
1509 $json = [];
1510 $functions = apply_filters( 'mwai_functions_list', [] );
1511 foreach ( $functions as $function ) {
1512 if ( $function->type === 'editor-assistant' ) {
1513 continue;
1514 }
1515 $json[] = Meow_MWAI_Query_Function::toJson( $function );
1516 }
1517 $options['functions'] = $json;
1518
1519 // Addons
1520 $options['addons'] = apply_filters( 'mwai_addons', [
1521 [
1522 'slug' => 'mwai-notifications',
1523 'name' => 'Notifications',
1524 'description' => 'Get real-time alerts for new discussions in your chatbot, so you never miss a chance to engage.',
1525 'install_url' => 'https://meowapps.com/products/mwai-notifications/',
1526 'settings_url' => null,
1527 'stars' => 4,
1528 'enabled' => false
1529 ],
1530 [
1531 'slug' => 'mwai-ollama',
1532 'name' => 'Ollama',
1533 'description' => 'Leverage local LLM integration through Ollama; refresh and use your own models for a flexible, cost-free approach.',
1534 'install_url' => 'https://meowapps.com/products/mwai-ollama/',
1535 'settings_url' => null,
1536 'stars' => 3,
1537 'enabled' => false
1538 ],
1539 [
1540 'slug' => 'mwai-deepseek',
1541 'name' => 'DeepSeek',
1542 'description' => 'Support for DeepSeek, a Chinese AI company that provides extremely powerful LLM models.',
1543 'install_url' => 'https://meowapps.com/products/deepseek/',
1544 'settings_url' => null,
1545 'stars' => 3,
1546 'enabled' => false
1547 ],
1548 [
1549 'slug' => 'mwai-websearch',
1550 'name' => 'Web Search',
1551 'description' => 'Enhance chatbot responses by pulling context from Google and Tavily, delivering more accurate answers.',
1552 'install_url' => 'https://meowapps.com/products/mwai-websearch/',
1553 'settings_url' => null,
1554 'stars' => 5,
1555 'enabled' => false
1556 ],
1557 [
1558 'slug' => 'mwai-better-links',
1559 'name' => 'Better Links',
1560 'description' => 'Validate internal and external links and map specific terms to custom URLs, ensuring smoother navigation and references.',
1561 'install_url' => 'https://meowapps.com/products/mwai-better-links/',
1562 'settings_url' => null,
1563 'stars' => 3,
1564 'enabled' => false
1565 ],
1566 [
1567 'slug' => 'mwai-woo-basics',
1568 'name' => 'Woo Basics',
1569 'description' => 'Access essential WooCommerce data so your chatbot can understand products, orders, and more for a richer shopping experience.',
1570 'install_url' => 'https://meowapps.com/products/mwai-woo-basics/',
1571 'settings_url' => null,
1572 'stars' => 2,
1573 'enabled' => false
1574 ],
1575 [
1576 'slug' => 'mwai-quick-actions',
1577 'name' => 'Quick Actions',
1578 'description' => 'Enable dynamic quick actions at chat start or during events, helping users find what they need faster.',
1579 'install_url' => 'https://meowapps.com/products/mwai-quick-actions/',
1580 'settings_url' => null,
1581 'stars' => 3,
1582 'enabled' => false
1583 ],
1584 [
1585 'slug' => 'mwai-content-parser',
1586 'name' => 'Content Parser',
1587 'description' => 'Parse complex website content, including ACF fields and page builders, for more precise embeddings and knowledge retrieval.',
1588 'install_url' => 'https://meowapps.com/products/mwai-content-parser/',
1589 'settings_url' => null,
1590 'stars' => 2,
1591 'enabled' => false
1592 ],
1593 [
1594 'slug' => 'mwai-visitor-form',
1595 'name' => 'Visitor Form',
1596 'description' => 'Add a customizable form triggered by specific events in your chatbot to collect key visitor information seamlessly.',
1597 'install_url' => 'https://meowapps.com/products/mwai-visitor-form/',
1598 'settings_url' => null,
1599 'stars' => 2,
1600 'enabled' => false
1601 ],
1602 [
1603 'slug' => 'mwai-dynamic-keys',
1604 'name' => 'Dynamic Keys',
1605 'description' => 'Rotate multiple API keys dynamically for any environment, balancing usage and ensuring smooth performance.',
1606 'install_url' => 'https://meowapps.com/products/mwai-dynamic-keys/',
1607 'settings_url' => null,
1608 'stars' => 1,
1609 'enabled' => false
1610 ],
1611 [
1612 'slug' => 'mwai-user-memory',
1613 'name' => 'User Memory',
1614 'description' => 'Let your chatbot remember details about logged-in users across conversations, for more personal and context-aware replies.',
1615 'install_url' => 'https://meowapps.com/products/mwai-user-memory/',
1616 'settings_url' => null,
1617 'stars' => 3,
1618 'enabled' => false
1619 ],
1620 ] );
1621
1622 // Add-ons mark themselves enabled through the `mwai_addons` filter, but they
1623 // only register that hook inside is_admin(). Over REST (the settings/options
1624 // endpoint, or an options auto-save) is_admin() is false, so none of them would
1625 // report as enabled and the admin UI would show no enabled add-ons. Flag any
1626 // add-on whose plugin is active as a context-independent fallback.
1627 if ( !function_exists( 'is_plugin_active' ) ) {
1628 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1629 }
1630 foreach ( $options['addons'] as &$addon ) {
1631 if ( empty( $addon['enabled'] ) && is_plugin_active( $addon['slug'] . '/' . $addon['slug'] . '.php' ) ) {
1632 $addon['enabled'] = true;
1633 }
1634 }
1635 unset( $addon );
1636
1637 // SEO stats. Filled by SEO Engine when it is active; null otherwise. AI Engine knows
1638 // nothing about SEO Engine's internals, exactly like the addons list above.
1639 // Admin-only: this feeds the Modules tab, and the provider filter may run SQL, so it
1640 // must never fire on guest-facing requests (get_option() rebuilds this payload often).
1641 // Guard on wp_get_current_user, not current_user_can: the core is constructed while
1642 // plugins load, before pluggable.php exists, and current_user_can calls into it.
1643 $can_admin = function_exists( 'wp_get_current_user' ) && current_user_can( 'manage_options' );
1644 $options['seo_stats'] = $can_admin ? apply_filters( 'mwai_seo_stats', null ) : null;
1645 $options['seo_robots'] = $can_admin ? $this->get_ai_crawler_access() : null;
1646
1647 // Populate usage data from ai_usage to ai_models_usage for the frontend
1648 $ai_usage = $this->get_option( 'ai_usage', [] );
1649 $options['ai_models_usage'] = $ai_usage;
1650
1651 // Also include daily usage data
1652 $ai_usage_daily = $this->get_option( 'ai_usage_daily', [] );
1653 $options['ai_models_usage_daily'] = $ai_usage_daily;
1654
1655 $populating = false;
1656 return $options;
1657 }
1658
1659 // Reports whether the well-known AI crawlers are allowed by robots.txt. Local reads only:
1660 // fetching home_url( '/robots.txt' ) over HTTP would be slow on an admin screen and fails
1661 // on hosts that block loopback requests.
1662 public function get_ai_crawler_access() {
1663 $public = get_option( 'blog_public' );
1664 $discouraged = (string) $public === '0';
1665 $has_file = file_exists( ABSPATH . 'robots.txt' );
1666 $mtime = $has_file ? ( filemtime( ABSPATH . 'robots.txt' ) ?: null ) : null;
1667
1668 // The cache is only trusted while its cheap fingerprints still match, so editing
1669 // robots.txt (SEO plugin, FTP) or toggling "Discourage search engines" shows up
1670 // immediately instead of after the transient expires.
1671 $cached = get_transient( 'mwai_ai_crawler_access' );
1672 if ( is_array( $cached ) && array_key_exists( 'mtime', $cached )
1673 && $cached['mtime'] === $mtime && $cached['discouraged'] === $discouraged ) {
1674 return $cached;
1675 }
1676
1677 $bots = [ 'GPTBot', 'ClaudeBot', 'PerplexityBot', 'Google-Extended', 'CCBot',
1678 'Applebot-Extended', 'meta-externalagent', 'Bytespider' ];
1679 $source = 'virtual';
1680 $content = '';
1681
1682 if ( $has_file ) {
1683 $source = 'file';
1684 $content = (string) file_get_contents( ABSPATH . 'robots.txt' );
1685 }
1686 else {
1687 // WordPress only serves a virtual robots.txt when no physical file exists. Rebuild
1688 // what it actually serves (do_robots() echoes, so replicate its default) and run the
1689 // robots_txt filter so SEO plugins hooking it are reflected.
1690 $site_url = wp_parse_url( site_url() );
1691 $path = !empty( $site_url['path'] ) ? $site_url['path'] : '';
1692 $default = "User-agent: *\n";
1693 $default .= "Disallow: $path/wp-admin/\n";
1694 $default .= "Allow: $path/wp-admin/admin-ajax.php\n";
1695 $content = apply_filters( 'robots_txt', $default, $public );
1696 }
1697
1698 $access = [
1699 'discouraged' => $discouraged,
1700 'source' => $source,
1701 'mtime' => $mtime,
1702 'bots' => $this->parse_robots_for_bots( $content, $bots, $discouraged ),
1703 ];
1704
1705 set_transient( 'mwai_ai_crawler_access', $access, HOUR_IN_SECONDS );
1706 return $access;
1707 }
1708
1709 private function parse_robots_for_bots( $content, $bots, $discouraged ) {
1710 $access = [];
1711
1712 // Settings > Reading > "Discourage search engines" overrides everything.
1713 if ( $discouraged ) {
1714 foreach ( $bots as $bot ) {
1715 $access[$bot] = 'blocked';
1716 }
1717 return $access;
1718 }
1719
1720 // Group the rules: consecutive User-agent lines share the record that follows.
1721 $groups = [];
1722 $agents = [];
1723 $collecting = true;
1724 $lines = preg_split( '/\r\n|\r|\n/', $content );
1725 foreach ( $lines as $line ) {
1726 $line = trim( preg_replace( '/#.*$/', '', $line ) );
1727 if ( $line === '' ) {
1728 continue;
1729 }
1730 if ( preg_match( '/^user-agent\s*:\s*(.+)$/i', $line, $m ) ) {
1731 if ( !$collecting ) {
1732 $agents = [];
1733 $collecting = true;
1734 }
1735 $agent = strtolower( trim( $m[1] ) );
1736 $agents[] = $agent;
1737 // Register the group even if it only ever gets Allow lines, so a bot with its
1738 // own (permissive) group never falls back to a blocking * group.
1739 if ( !isset( $groups[$agent] ) ) {
1740 $groups[$agent] = [];
1741 }
1742 }
1743 else if ( preg_match( '/^disallow\s*:\s*(.*)$/i', $line, $m ) ) {
1744 $collecting = false;
1745 $rule = trim( $m[1] );
1746 foreach ( $agents as $agent ) {
1747 $groups[$agent][] = $rule;
1748 }
1749 }
1750 else {
1751 // Allow, Sitemap, Crawl-delay… end the User-agent run but keep the group.
1752 $collecting = false;
1753 }
1754 }
1755
1756 foreach ( $bots as $bot ) {
1757 $key = strtolower( $bot );
1758 $rules = isset( $groups[$key] ) ? $groups[$key] :
1759 ( isset( $groups['*'] ) ? $groups['*'] : [] );
1760 // Only a full "Disallow: /" counts as blocked; narrower paths leave the site readable.
1761 $access[$bot] = in_array( '/', $rules, true ) ? 'blocked' : 'allowed';
1762 }
1763 return $access;
1764 }
1765
1766 public function get_all_options( $force = false, $sanitize = false ) {
1767 if ( $force || is_null( $this->options ) ) {
1768 $options = get_option( $this->option_name, [] );
1769 $init_mode = empty( $options );
1770 foreach ( MWAI_OPTIONS as $key => $value ) {
1771 if ( !isset( $options[$key] ) ) {
1772 $options[$key] = $value;
1773 }
1774 }
1775 $options['chatbot_defaults'] = MWAI_CHATBOT_DEFAULT_PARAMS;
1776 $options['default_limits'] = MWAI_LIMITS;
1777
1778 // Force sanitization if custom_languages is not set (migration)
1779 $needs_language_migration = !isset( $options['custom_languages'] ) || empty( $options['custom_languages'] );
1780
1781 if ( $sanitize || $init_mode || $needs_language_migration ) {
1782 $options = $this->sanitize_options( $options );
1783 }
1784 $this->options = $options;
1785 }
1786 $options = $this->populate_dynamic_options( $this->options );
1787 return $options;
1788 }
1789
1790 // Sanitize options when we update the plugin or perform some updates
1791 // if we change the structure of the options.
1792 public function sanitize_options( $options ) {
1793 $needs_update = false;
1794
1795 // Removing old options of options renaming should be done here, as it was done before.
1796 // Check version 2.6.8 for an example.
1797
1798 // Avoid the logs_path to be a PHP file.
1799 if ( isset( $options['logs_path'] ) ) {
1800 $logs_path = $options['logs_path'];
1801 if ( substr( $logs_path, -4 ) !== '.log' ) {
1802 $options['logs_path'] = '';
1803 $needs_update = true;
1804 }
1805 }
1806
1807 // The IDs for the embeddings environments are generated here.
1808 // TODO: We should handle this more gracefully via an option in the Embeddings Settings.
1809 $embeddings_default_exists = false;
1810 if ( isset( $options['embeddings_envs'] ) ) {
1811 foreach ( $options['embeddings_envs'] as &$env ) {
1812 if ( !isset( $env['id'] ) ) {
1813 $env['id'] = $this->get_random_id();
1814 $needs_update = true;
1815 }
1816 if ( $env['id'] === $options['embeddings_default_env'] ) {
1817 $embeddings_default_exists = true;
1818 }
1819 }
1820 }
1821 if ( !$embeddings_default_exists ) {
1822 $options['embeddings_default_env'] = $options['embeddings_envs'][0]['id'] ?? null;
1823 $needs_update = true;
1824 }
1825
1826 // The IDs for the AI environments are generated here.
1827 $allEnvIds = [];
1828 $ai_default_exists = false;
1829 // Allow empty string as valid "None" selection
1830 $ai_default_is_none = isset( $options['ai_default_env'] ) && $options['ai_default_env'] === '';
1831 if ( isset( $options['ai_envs'] ) ) {
1832 foreach ( $options['ai_envs'] as &$env ) {
1833 if ( !isset( $env['id'] ) ) {
1834 $env['id'] = $this->get_random_id();
1835 $needs_update = true;
1836 }
1837 if ( $env['id'] === $options['ai_default_env'] ) {
1838 $ai_default_exists = true;
1839 }
1840 $allEnvIds[] = $env['id'];
1841 }
1842 }
1843 if ( !$ai_default_exists && !$ai_default_is_none ) {
1844 $options['ai_default_env'] = $options['ai_envs'][0]['id'] ?? null;
1845 $needs_update = true;
1846 }
1847
1848 // The IDs for the MCP environments are generated here.
1849 if ( isset( $options['mcp_envs'] ) ) {
1850 foreach ( $options['mcp_envs'] as &$env ) {
1851 if ( !isset( $env['id'] ) ) {
1852 $env['id'] = $this->get_random_id();
1853 $needs_update = true;
1854 }
1855 }
1856 }
1857
1858 // Migration: DALL-E was removed (deprecated by OpenAI). Move users to gpt-image-1.5.
1859 // TODO: Remove after 2027-04 (1 year after the shutdown on 2026-05-12).
1860 if ( isset( $options['ai_images_default_model'] )
1861 && in_array( $options['ai_images_default_model'], [ 'dall-e', 'dall-e-2', 'dall-e-3', 'dall-e-3-hd' ], true ) ) {
1862 $options['ai_images_default_model'] = MWAI_FALLBACK_MODEL_IMAGES;
1863 $needs_update = true;
1864 }
1865
1866 // All the models with an envId that does not exist anymore are removed.
1867 if ( isset( $options['ai_models'] ) ) {
1868 $options['ai_models'] = array_values( array_filter(
1869 $options['ai_models'],
1870 function ( $model ) use ( $allEnvIds, &$needs_update ) {
1871 if ( isset( $model['envId'] ) && !in_array( $model['envId'], $allEnvIds ) ) {
1872 $needs_update = true;
1873 return false;
1874 }
1875 return true;
1876 }
1877 ) );
1878 }
1879
1880 // Migration: limits.creditType 'units' → 'tokens'. The read path in
1881 // premium/statistics.php still accepts both, so this is purely cosmetic
1882 // alignment with the new "Tokens" labels. One-shot per install.
1883 // TODO: Remove after 2026-11.
1884 if ( isset( $options['limits'] ) && is_array( $options['limits'] ) ) {
1885 foreach ( [ 'system', 'users', 'guests' ] as $bucket ) {
1886 if ( isset( $options['limits'][$bucket]['creditType'] )
1887 && $options['limits'][$bucket]['creditType'] === 'units' ) {
1888 $options['limits'][$bucket]['creditType'] = 'tokens';
1889 $needs_update = true;
1890 }
1891 }
1892 }
1893
1894 // Migration: Populate custom_languages if empty for existing installations
1895 if ( !isset( $options['custom_languages'] ) || empty( $options['custom_languages'] ) ) {
1896 $options['custom_languages'] = [
1897 'English (en)',
1898 'German (de)',
1899 'French (fr)',
1900 'Spanish (es)',
1901 'Italian (it)',
1902 'Chinese (zh)',
1903 'Japanese (ja)',
1904 'Portuguese (pt)'
1905 ];
1906 $needs_update = true;
1907 }
1908
1909 if ( $needs_update ) {
1910 ksort( $options );
1911 update_option( $this->option_name, $options, false );
1912 }
1913
1914 return $options;
1915 }
1916
1917 public function update_options( $options ) {
1918 // update_option returns false both when update fails AND when value is unchanged
1919 // We just attempt the update and always return the current options
1920 // The frontend will see if the values actually changed
1921 update_option( $this->option_name, $options, false );
1922 $options = $this->get_all_options( true, true );
1923 return $options;
1924 }
1925
1926 public function update_option( $option, $value ) {
1927 $options = $this->get_all_options( true );
1928 $options[$option] = $value;
1929 return $this->update_options( $options );
1930 }
1931
1932 public function get_option( $option, $default = null ) {
1933 $options = $this->get_all_options();
1934 return $options[$option] ?? $default;
1935 }
1936
1937 public function update_ai_env( $env_id, $option, $value ) {
1938 $options = $this->get_all_options( true );
1939 foreach ( $options['ai_envs'] as &$env ) {
1940 if ( $env['id'] === $env_id ) {
1941 $env[$option] = $value;
1942 break;
1943 }
1944 }
1945 return $this->update_options( $options );
1946 }
1947
1948 public function get_engine_models( $engineType ) {
1949 // This method is called by engines with just a string type
1950 // We need to get the models differently
1951 $options = $this->get_all_options();
1952 $engines = $options['ai_envs'];
1953 $models = [];
1954
1955 // Find all models for this engine type
1956 foreach ( $engines as $engine ) {
1957 if ( $engine['type'] === $engineType ) {
1958 if ( isset( $engine['models'] ) ) {
1959 foreach ( $engine['models'] as $model ) {
1960 $models[] = $model;
1961 }
1962 }
1963 }
1964 }
1965
1966 // Also check custom models
1967 if ( isset( $options['ai_models'] ) ) {
1968 foreach ( $options['ai_models'] as $model ) {
1969 if ( $model['type'] === $engineType ) {
1970 $models[] = $model;
1971 }
1972 }
1973 }
1974
1975 return $models;
1976 }
1977
1978 public function reset_options() {
1979 delete_option( $this->themes_option_name );
1980 delete_option( $this->chatbots_option_name );
1981 delete_option( $this->option_name );
1982 return $this->get_all_options( true );
1983 }
1984 #endregion
1985
1986 #region Cron Tracking
1987 public function track_cron_start( $hook ) {
1988 // Set running transient (expires in 5 minutes as a safety measure)
1989 set_transient( 'mwai_cron_running_' . $hook, true, 300 );
1990 }
1991
1992 public function track_cron_end( $hook, $status = 'success', $error_message = '' ) {
1993 // Remove running transient
1994 delete_transient( 'mwai_cron_running_' . $hook );
1995
1996 // Get existing data
1997 $cron_data = get_transient( 'mwai_cron_last_run' ) ?: [];
1998
1999 // Update this cron's data - use time() for consistency
2000 $cron_data[$hook] = [
2001 'time' => time(),
2002 'status' => $status,
2003 'error' => $error_message
2004 ];
2005
2006 // Store for 7 days
2007 set_transient( 'mwai_cron_last_run', $cron_data, 7 * DAY_IN_SECONDS );
2008 }
2009 #endregion
2010 }
2011