PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.7.4
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.7.4
3.7.5 3.7.4 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 2 weeks ago exceptions 1 month ago modules 2 days ago query 1 week ago services 1 month ago admin.php 2 weeks ago api.php 1 month ago core.php 1 week 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 1 week ago
core.php
2093 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 * Resolve "." and ".." segments in a path WITHOUT touching the filesystem.
495 *
496 * Deliberately lexical, not realpath(): realpath() also resolves symlinks, and plenty
497 * of real installs symlink a folder inside uploads (shared media between sites, local
498 * dev, NFS mounts). Comparing a symlink-resolved file against a non-resolved base would
499 * reject those perfectly legitimate setups. An attacker can only inject "../" into a
500 * string, never create a symlink under uploads, so resolving the segments is exactly
501 * the check that is needed and nothing more.
502 *
503 * @param string $path
504 * @return string
505 */
506 public static function normalize_path_lexically( $path ) {
507 $path = str_replace( '\\', '/', (string) $path );
508 $isAbsolute = strpos( $path, '/' ) === 0;
509 $resolved = [];
510 foreach ( explode( '/', $path ) as $segment ) {
511 if ( $segment === '' || $segment === '.' ) {
512 continue;
513 }
514 if ( $segment === '..' ) {
515 array_pop( $resolved );
516 continue;
517 }
518 $resolved[] = $segment;
519 }
520 return ( $isAbsolute ? '/' : '' ) . implode( '/', $resolved );
521 }
522
523 /**
524 * Whether a path sits inside a base directory, once "../" segments are resolved.
525 *
526 * @param string $path
527 * @param string $base
528 * @return bool
529 */
530 public static function is_path_within( $path, $base ) {
531 $path = self::normalize_path_lexically( $path );
532 $base = rtrim( self::normalize_path_lexically( $base ), '/' );
533 if ( $base === '' || $path === '' ) {
534 return false;
535 }
536 return $path === $base || strpos( $path, $base . '/' ) === 0;
537 }
538
539 /**
540 * Strip the parameters a client must never control out of a REST payload.
541 *
542 * Nulling a local $path inside a route handler is not enough: the raw params array is
543 * still forwarded to the query, inject_params() repopulates $query->path from it, and
544 * the engine then hands that straight to file_get_contents(). That is how the 3.6.4
545 * transcription fix was bypassed. Sanitizing the array itself covers every caller that
546 * forwards params, present and future.
547 *
548 * Keys are normalized before matching because Meow_MWAI_Query_Base::convert_keys()
549 * camel-cases them later: "path_" becomes "path" and "api_key" becomes "apiKey", so an
550 * exact-match blocklist would let both through.
551 *
552 * @param array $params The client-supplied parameters.
553 * @return array The parameters without the blocked keys.
554 */
555 public static function sanitize_rest_params( $params ) {
556 if ( !is_array( $params ) ) {
557 return [];
558 }
559 $blocked = [
560 'apiKey', // Overrides the environment's provider key.
561 'path', // Reaches file_get_contents() via inject_params(): arbitrary file read.
562 ];
563 $blocked = apply_filters( 'mwai_blocked_rest_params', $blocked, $params );
564 $normalize = function ( $key ) {
565 return strtolower( str_replace( '_', '', (string) $key ) );
566 };
567 $blocked = array_map( $normalize, (array) $blocked );
568 foreach ( array_keys( $params ) as $key ) {
569 if ( in_array( $normalize( $key ), $blocked, true ) ) {
570 unset( $params[$key] );
571 }
572 }
573 return $params;
574 }
575 #endregion
576
577 #region Image-Related Helpers
578 public static function is_image( $file ) {
579 global $mwai_core;
580 if ( $mwai_core && $mwai_core->imageService ) {
581 return $mwai_core->imageService->is_image( $file );
582 }
583 // Fallback to original implementation if service not available
584 $mimeType = self::get_mime_type( $file );
585 if ( strpos( $mimeType, 'image' ) !== false ) {
586 return true;
587 }
588 return false;
589 }
590
591 public static function get_image_resolution( $url ) {
592 global $mwai_core;
593 if ( $mwai_core && $mwai_core->imageService ) {
594 return $mwai_core->imageService->get_image_resolution( $url );
595 }
596 // Fallback to original implementation if service not available
597 if ( empty( $url ) ) {
598 return null;
599 }
600 $headers = get_headers( $url, 1 );
601 if ( strpos( $headers[0], '200' ) === false ) {
602 return null;
603 }
604 $image_info = getimagesize( $url );
605 if ( $image_info === false ) {
606 return null;
607 }
608 return [
609 'width' => $image_info[0],
610 'height' => $image_info[1]
611 ];
612 }
613
614 public static function get_mime_type( $file ) {
615 global $mwai_core;
616 if ( $mwai_core && $mwai_core->imageService ) {
617 return $mwai_core->imageService->get_mime_type( $file );
618 }
619
620 // Fallback implementation - this should rarely be used as imageService is initialized early
621 Meow_MWAI_Logging::warn( 'get_mime_type called before imageService is available' );
622
623 // Basic extension-based detection only
624 $extension = pathinfo( $file, PATHINFO_EXTENSION );
625 $extension = strtolower( $extension );
626 $mimeTypes = [
627 'jpg' => 'image/jpeg',
628 'jpeg' => 'image/jpeg',
629 'png' => 'image/png',
630 'gif' => 'image/gif',
631 'webp' => 'image/webp',
632 'bmp' => 'image/bmp',
633 'tiff' => 'image/tiff',
634 'tif' => 'image/tiff',
635 'svg' => 'image/svg+xml',
636 'ico' => 'image/x-icon',
637 'pdf' => 'application/pdf',
638 ];
639 return isset( $mimeTypes[$extension] ) ? $mimeTypes[$extension] : null;
640 }
641
642 public function download_image( $url ) {
643 return $this->imageService->download_image( $url );
644 }
645
646 /**
647 * Add an image from a URL to the Media Library.
648 * @param string $url The URL of the image to be downloaded.
649 * @param string $filename The filename of the image, if not set, it will be the basename of the URL.
650 * @param string $title The title of the image.
651 * @param string $description The description of the image.
652 * @param string $caption The caption of the image.
653 * @param string $alt The alt text of the image.
654 * @return int The attachment ID of the image.
655 */
656 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 = [] ) {
657 return $this->imageService->add_image_from_url( $url, $filename, $title, $description, $caption, $alt, $attachedPost, $post_status, $post_type, $ai_metadata );
658 }
659 #endregion
660
661 #region Context-Related Helpers
662 public function retrieve_context( $params, $query, $streamCallback = null ) {
663 $contextMaxLength = $params['contextMaxLength'] ?? $this->get_option( 'context_max_length', 4096 );
664 $embeddingsEnvId = $params['embeddingsEnvId'] ?? null;
665
666 $context = apply_filters( 'mwai_context_search', [], $query, [
667 'embeddingsEnvId' => $embeddingsEnvId,
668 'streamCallback' => $streamCallback
669 ] );
670
671 // Emit embeddings event if streaming and context was found
672 if ( $streamCallback && !empty( $context ) ) {
673 $count = 0;
674 if ( isset( $context['embeddings'] ) && is_array( $context['embeddings'] ) ) {
675 $count = count( $context['embeddings'] );
676 }
677 else if ( isset( $context['content'] ) ) {
678 $count = 1;
679 }
680 if ( $count > 0 ) {
681 $event = Meow_MWAI_Event::embeddings( $count );
682 $streamCallback( $event );
683 }
684 }
685
686 if ( empty( $context ) ) {
687 return null;
688 }
689 else if ( !isset( $context['content'] ) ) {
690 Meow_MWAI_Logging::warn( 'A context without content was returned.' );
691 return null;
692 }
693 $context['content'] = $this->clean_sentences( $context['content'], $contextMaxLength );
694 $context['length'] = strlen( $context['content'] );
695 return $context;
696 }
697
698 /**
699 * Wrap context content with framing instructions for AI.
700 * This helps the AI understand that the context is background knowledge.
701 *
702 * @param string $context The raw context content.
703 * @return string The framed context with instructions.
704 */
705 public function frame_context( $context ) {
706 if ( empty( $context ) ) {
707 return $context;
708 }
709 $framing = 'The following is your knowledge about this topic. ' .
710 'Use it naturally when relevant - never mention or acknowledge that this information was provided to you. ' .
711 "If the user's message is unrelated (e.g., greetings, thanks), respond naturally without using it.";
712 $framing = apply_filters( 'mwai_context_framing', $framing, $context );
713 return $framing . "\n\n---\n" . $context . "\n---";
714 }
715 #endregion
716
717 #region Users/Sessions Helpers
718
719 public function get_nonce( $force = false ) {
720 return $this->sessionService->get_nonce( $force );
721 }
722
723 // This is a bit hacky, but chatId needs to be retrieved or generated.
724 // Maybe we can clean this up later.
725 public function fix_chat_id( $query, $params ) {
726 return $this->sessionService->fix_chat_id( $query, $params );
727 }
728
729 public function get_session_id() {
730 return $this->sessionService->get_session_id();
731 }
732
733 /**
734 * Get the Response ID Manager service
735 */
736 public function get_response_id_manager() {
737 return $this->responseIdManager;
738 }
739
740 /**
741 * Get the Message Builder service
742 */
743 public function get_message_builder() {
744 return $this->messageBuilder;
745 }
746
747 // Get the UserID from the data, or from the current user
748 public function get_user_id( $data = null ) {
749 return $this->sessionService->get_user_id( $data );
750 }
751
752 public function get_session_user_id() {
753 return $this->sessionService->get_session_user_id();
754 }
755
756 public function get_admin_user() {
757 return $this->sessionService->get_admin_user();
758 }
759
760 public function get_user_data() {
761 return $this->sessionService->get_user_data();
762 }
763
764 public function get_ip_address( $force = false ) {
765 return $this->sessionService->get_ip_address( $force );
766 }
767
768 #endregion
769
770 #region Sanitization
771 public function sanitize_sort(
772 &$sort,
773 $default_accessor = 'created',
774 $default_order = 'DESC',
775 $allowed_columns = [ 'created', 'updated', 'name', 'id', 'time', 'units', 'price' ]
776 ) {
777
778 // Ensure $sort is an array
779 if ( !is_array( $sort ) ) {
780 $sort = [ 'accessor' => $default_accessor, 'by' => $default_order ];
781 }
782 // Extract and sanitize the accessor
783 $sort_accessor = isset( $sort['accessor'] ) ? $sort['accessor'] : $default_accessor;
784 if ( !in_array( $sort_accessor, $allowed_columns ) ) {
785 Meow_MWAI_Logging::error( "This sort accessor is not allowed ($sort_accessor)." );
786 $sort_accessor = $default_accessor;
787 }
788 // Extract and sanitize the sort order
789 $sort_by = isset( $sort['by'] ) ? strtoupper( $sort['by'] ) : $default_order;
790 if ( $sort_by !== 'ASC' && $sort_by !== 'DESC' ) {
791 Meow_MWAI_Logging::error( "This sort order is not allowed ($sort_by)." );
792 $sort_by = $default_order;
793 }
794 // Update the sort array with sanitized values
795 $sort['accessor'] = $sort_accessor;
796 $sort['by'] = $sort_by;
797 }
798 #endregion
799
800 #region Other Helpers
801 public function safe_strlen( $string, $encoding = 'UTF-8' ) {
802 if ( function_exists( 'mb_strlen' ) ) {
803 return mb_strlen( $string, $encoding );
804 }
805 else {
806 // Fallback implementation for environments without mbstring extension
807 return preg_match_all( '/./u', $string, $matches );
808 }
809 }
810
811 public function check_rest_nonce( $request ) {
812 // REST NONCE VERIFICATION:
813 // Validates nonce from X-WP-Nonce header using WordPress nonce system.
814 // Returns: false (invalid), 1 (0-12 hours old), or 2 (12-24 hours old)
815 // WordPress REST permission callbacks accept any truthy value as success.
816 // The filter allows custom authorization logic if needed.
817 $nonce = $request->get_header( 'X-WP-Nonce' );
818 $rest_nonce = wp_verify_nonce( $nonce, 'wp_rest' );
819 return apply_filters( 'mwai_rest_authorized', $rest_nonce, $request );
820 }
821
822 public function get_random_id( $length = 8, $excludeIds = [] ) {
823 $characters = '0123456789abcdefghijklmnopqrstuvwxyz';
824 $charactersLength = strlen( $characters );
825 $randomId = '';
826 for ( $i = 0; $i < $length; $i++ ) {
827 $randomId .= $characters[ mt_rand( 0, $charactersLength - 1 ) ];
828 }
829 if ( in_array( $randomId, $excludeIds ) ) {
830 return $this->get_random_id( $length, $excludeIds );
831 }
832 return $randomId;
833 }
834
835 public function is_url( $url ) {
836 return strpos( $url, 'http' ) === 0 ? true : false;
837 }
838
839 public function get_post_types() {
840 $excluded = [ 'attachment', 'revision', 'nav_menu_item' ];
841 $post_types = [];
842 $types = get_post_types( [], 'objects' );
843
844 // Let's get the Post Types that are enabled for Embeddings Sync
845 $embeddingsSettings = $this->get_option( 'embeddings' );
846 $syncPostTypes = isset( $embeddingsSettings['syncPostTypes'] ) ? $embeddingsSettings['syncPostTypes'] : [];
847
848 foreach ( $types as $type ) {
849 $forced = in_array( $type->name, $syncPostTypes );
850 // Should not be excluded.
851 if ( !$forced && in_array( $type->name, $excluded ) ) {
852 continue;
853 }
854 // Should be public.
855 if ( !$forced && !$type->public ) {
856 continue;
857 }
858 $post_types[] = [
859 'name' => $type->labels->name,
860 'type' => $type->name,
861 ];
862 }
863
864 // Let's get the Post Types that are enabled for Embeddings Sync
865 $embeddingsSettings = $this->get_option( 'embeddings' );
866 $syncPostTypes = isset( $embeddingsSettings['syncPostTypes'] ) ? $embeddingsSettings['syncPostTypes'] : [];
867
868 return $post_types;
869 }
870
871 public function get_post( $post ) {
872 if ( is_numeric( $post ) ) {
873 // Force fresh retrieval to avoid cache issues
874 clean_post_cache( $post );
875 $post = get_post( $post );
876 }
877 if ( is_object( $post ) ) {
878 $post = (array) $post;
879 }
880 if ( !is_array( $post ) ) {
881 return null;
882 }
883 $language = $this->get_post_language( $post['ID'] );
884 $content = $this->get_post_content( $post['ID'] );
885 $title = $post['post_title'];
886 $excerpt = $post['post_excerpt'];
887 $url = get_permalink( $post['ID'] );
888 $checksum = wp_hash( $content . $title . $url );
889
890 return [
891 'postId' => (int) $post['ID'],
892 'title' => $title,
893 'content' => $content,
894 'excerpt' => $excerpt,
895 'url' => $url,
896 'language' => $language ?? 'english',
897 'checksum' => $checksum,
898 ];
899 }
900
901 /**
902 * Format a date/time string into a human-readable format
903 * @param string $date_string The date string to format
904 * @return string Formatted date (e.g., "Just now", "5m ago", "2h ago", "3d ago", "Jan 20th")
905 */
906 public function format_discussion_date( $date_string ) {
907 $date = strtotime( $date_string );
908 $now = time();
909 $diff = $now - $date;
910
911 // Less than a minute
912 if ( $diff < 60 ) {
913 return 'Just now';
914 }
915
916 // Less than an hour
917 if ( $diff < 3600 ) {
918 $minutes = floor( $diff / 60 );
919 return $minutes . 'm ago';
920 }
921
922 // Less than a day
923 if ( $diff < 86400 ) {
924 $hours = floor( $diff / 3600 );
925 return $hours . 'h ago';
926 }
927
928 // Less than a week
929 if ( $diff < 604800 ) {
930 $days = floor( $diff / 86400 );
931 return $days . 'd ago';
932 }
933
934 // Format as date, in the site's configured timezone (wp_date), not UTC.
935 $is_current_year = wp_date( 'Y', $date ) === wp_date( 'Y', $now );
936 if ( $is_current_year ) {
937 return wp_date( 'M jS', $date );
938 }
939 else {
940 return wp_date( 'M jS, Y', $date );
941 }
942 }
943 #endregion
944
945 #region Usage & Costs
946
947 // Quick and dirty token estimation
948 // Let's keep this synchronized with Helpers in JS
949 public static function estimate_tokens( $text = '', $model = null ): int {
950 global $mwai_core;
951 if ( $mwai_core && $mwai_core->usageStatsService ) {
952 return $mwai_core->usageStatsService->estimate_tokens( $text, $model );
953 }
954 // Fallback to original implementation if service not available
955 if ( !is_string( $text ) ) {
956 $text = is_array( $text ) || is_object( $text ) ? json_encode( $text ) : (string) $text;
957 }
958 $averageTokenLength = 4;
959 $words = preg_split( '/\s+/', trim( $text ) );
960 $tokenCount = 0;
961 foreach ( $words as $word ) {
962 $tokenCount += ceil( strlen( $word ) / $averageTokenLength );
963 }
964 return apply_filters( 'mwai_estimate_tokens', $tokenCount, $text );
965 }
966
967 public function record_tokens_usage( $model, $in_tokens, $out_tokens = 0, $returned_price = null ) {
968 return $this->usageStatsService->record_tokens_usage( $model, $in_tokens, $out_tokens, $returned_price );
969 }
970
971 public function record_audio_usage( $model, $seconds ) {
972 return $this->usageStatsService->record_audio_usage( $model, $seconds );
973 }
974
975 public function record_images_usage( $model, $resolution, $images ) {
976 return $this->usageStatsService->record_images_usage( $model, $resolution, $images );
977 }
978
979 public function record_videos_usage( $model, $resolution, $seconds ) {
980 return $this->usageStatsService->record_videos_usage( $model, $resolution, $seconds );
981 }
982
983 #endregion
984
985 #region Errors
986
987 // The message visitors get when something breaks internally (a provider failure, a
988 // function-call issue, a stream error). The real error is always logged, and admins
989 // still see it; visitors only ever get this. Filterable so a site can match its own
990 // tone and so a raw provider message (billing, quota, model errors) never leaks.
991 public static function get_public_error_message( $exception = null ) {
992 $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.';
993 return apply_filters( 'mwai_public_error_message', $message, $exception );
994 }
995
996 #endregion
997
998 #region Streaming
999 public function stream_push( $data, $query = null ) {
1000 try {
1001 // Handle new Event objects
1002 if ( is_object( $data ) && method_exists( $data, 'to_array' ) ) {
1003 $data = $data->to_array();
1004 }
1005
1006 $data = apply_filters( 'mwai_stream_push', $data, $query );
1007 $out = 'data: ' . json_encode( $data );
1008 echo $out;
1009 echo "\n\n";
1010 if ( ob_get_level() > 0 ) {
1011 ob_end_flush();
1012 }
1013 flush();
1014 }
1015 catch ( Exception $e ) {
1016 // Send error as proper SSE error event
1017 $errorMessage = self::get_public_error_message( $e );
1018 error_log( '[AI Engine Stream Error] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
1019
1020 $errorData = [
1021 'type' => 'error',
1022 'data' => $errorMessage
1023 ];
1024 $out = 'data: ' . json_encode( $errorData );
1025 echo $out;
1026 echo "\n\n";
1027 if ( ob_get_level() > 0 ) {
1028 ob_end_flush();
1029 }
1030 flush();
1031
1032 // Stop execution after sending error
1033 die();
1034 }
1035 }
1036 #endregion
1037
1038 #region Options
1039 public function get_themes() {
1040 $themes = get_option( $this->themes_option_name, [] );
1041 $themes = empty( $themes ) ? [] : $themes;
1042
1043 $internalThemes = [
1044 'chatgpt' => [
1045 'type' => 'internal', 'name' => 'ChatGPT', 'themeId' => 'chatgpt',
1046 'settings' => [], 'style' => ''
1047 ],
1048 'messages' => [
1049 'type' => 'internal', 'name' => 'Messages', 'themeId' => 'messages',
1050 'settings' => [], 'style' => ''
1051 ],
1052 'timeless' => [
1053 'type' => 'internal', 'name' => 'Timeless', 'themeId' => 'timeless',
1054 'settings' => [], 'style' => ''
1055 ],
1056 'foundation' => [
1057 'type' => 'internal', 'name' => 'Foundation', 'themeId' => 'foundation',
1058 'settings' => [], 'style' => ''
1059 ],
1060 ];
1061 $customThemes = [];
1062 foreach ( $themes as $theme ) {
1063 if ( isset( $internalThemes[$theme['themeId']] ) ) {
1064 $internalThemes[$theme['themeId']] = $theme;
1065 continue;
1066 }
1067 $customThemes[] = $theme;
1068 }
1069 return array_merge( array_values( $internalThemes ), $customThemes );
1070 }
1071
1072 public function update_themes( $themes ) {
1073 update_option( $this->themes_option_name, $themes );
1074 return $themes;
1075 }
1076
1077 /**
1078 * Returns the registered chatbots. Pass a $filters array to narrow the list,
1079 * e.g. get_chatbots( [ 'functions' => true ] ) to only keep chatbots whose
1080 * model supports function calling. No filters returns every chatbot, as before.
1081 */
1082 public function get_chatbots( $filters = [] ) {
1083 $chatbots = get_option( $this->chatbots_option_name, [] );
1084 $hasChanges = false;
1085 if ( empty( $chatbots ) ) {
1086 $chatbots = [ array_merge( MWAI_CHATBOT_DEFAULT_PARAMS, ['name' => 'Default', 'botId' => 'default' ] ) ];
1087 }
1088 $hasDefault = false;
1089 foreach ( $chatbots as &$chatbot ) {
1090 if ( $chatbot['botId'] === 'default' ) {
1091 $hasDefault = true;
1092 }
1093 foreach ( MWAI_CHATBOT_DEFAULT_PARAMS as $key => $value ) {
1094 // Use default value if not set.
1095 if ( !isset( $chatbot[$key] ) ) {
1096 $chatbot[$key] = $value;
1097 }
1098 }
1099
1100 /*
1101 This is the best section to rename fields.
1102 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).
1103 */
1104
1105 // Migrate old file upload params to new unified system
1106 if ( !isset( $chatbot['fileUpload'] ) ) {
1107 // Set fileUpload based on old params (imageUpload, fileUploads, or vision checkbox)
1108 $chatbot['fileUpload'] = !empty( $chatbot['imageUpload'] ) || ( isset( $chatbot['fileUploads'] ) && $chatbot['fileUploads'] > 0 );
1109 $hasChanges = true;
1110 }
1111
1112 // Ensure maxUploads is set
1113 if ( !isset( $chatbot['maxUploads'] ) ) {
1114 if ( isset( $chatbot['fileUploads'] ) && $chatbot['fileUploads'] > 0 ) {
1115 $chatbot['maxUploads'] = $chatbot['fileUploads'];
1116 }
1117 else {
1118 $chatbot['maxUploads'] = 1; // Default to 1 file
1119 }
1120 $hasChanges = true;
1121 }
1122
1123 // Sync fileUploads with fileUpload and maxUploads for consistency
1124 if ( isset( $chatbot['fileUpload'] ) ) {
1125 $chatbot['fileUploads'] = $chatbot['fileUpload'] ? $chatbot['maxUploads'] : 0;
1126 $chatbot['multiUpload'] = $chatbot['fileUpload'] && $chatbot['maxUploads'] > 1;
1127 $chatbot['imageUpload'] = $chatbot['fileUpload']; // Keep imageUpload in sync
1128 }
1129
1130 // Migration: DALL-E was removed (deprecated by OpenAI). Move chatbots to the image fallback.
1131 // TODO: Remove after 2027-04 (1 year after the shutdown on 2026-05-12).
1132 if ( isset( $chatbot['model'] )
1133 && in_array( $chatbot['model'], [ 'dall-e', 'dall-e-2', 'dall-e-3', 'dall-e-3-hd' ], true ) ) {
1134 $chatbot['model'] = MWAI_FALLBACK_MODEL_IMAGES;
1135 $hasChanges = true;
1136 }
1137
1138 // if ( isset( $chatbot['context'] ) ) {
1139 // $chatbot['instructions'] = $chatbot['context'];
1140 // unset( $chatbot['context'] );
1141 // $hasChanges = true;
1142 // }
1143 }
1144 if ( !$hasDefault ) {
1145 $defaultBot = array_merge( MWAI_CHATBOT_DEFAULT_PARAMS, ['name' => 'Default', 'botId' => 'default' ] );
1146 array_unshift( $chatbots, $defaultBot );
1147 $hasChanges = true;
1148 }
1149 if ( $hasChanges ) {
1150 update_option( $this->chatbots_option_name, $chatbots );
1151 }
1152
1153 // Optional filtering by capability (e.g. only function-calling chatbots).
1154 if ( !empty( $filters['functions'] ) ) {
1155 $chatbots = array_values( array_filter( $chatbots, function ( $chatbot ) {
1156 return $this->chatbot_supports_functions( $chatbot );
1157 } ) );
1158 }
1159
1160 return $chatbots;
1161 }
1162
1163 /**
1164 * Whether a chatbot's model supports function calling. The 'functions' tag is
1165 * the canonical signal across all engines; dynamic providers (OpenRouter, etc.)
1166 * also expose it as a feature, so we accept both.
1167 */
1168 public function chatbot_supports_functions( $chatbot ) {
1169 list( $envId, $modelId ) = $this->resolve_chatbot_env_model( $chatbot );
1170 if ( empty( $modelId ) ) {
1171 return false;
1172 }
1173 $model = $this->find_model_data( $modelId, $envId );
1174 if ( empty( $model ) ) {
1175 return false;
1176 }
1177 $tags = $model['tags'] ?? [];
1178 $features = $model['features'] ?? [];
1179 return in_array( 'functions', $tags, true ) || in_array( 'functions', $features, true );
1180 }
1181
1182 /**
1183 * Resolves the environment and model a chatbot will actually run with. A chatbot
1184 * left on "Default" stores no envId/model, so we have to apply the same fallbacks
1185 * as Meow_MWAI_Services_ModelEnvironment::validate_env_model(), otherwise such a
1186 * chatbot looks like it has no model at all (it was dropped by the 'functions'
1187 * filter of get_chatbots(), for instance).
1188 * Returns [ $envId, $modelId ], either of which can be null.
1189 */
1190 public function resolve_chatbot_env_model( $chatbot ) {
1191 $envId = $chatbot['envId'] ?? ( $chatbot['environment'] ?? null );
1192 $modelId = $chatbot['model'] ?? null;
1193
1194 if ( empty( $envId ) && empty( $modelId ) ) {
1195 return [ $this->get_option( 'ai_default_env' ), $this->get_option( 'ai_default_model' ) ];
1196 }
1197
1198 // Model without an environment: find_model_data() already searches the engine
1199 // defaults and the custom models, so there is nothing else to resolve.
1200 if ( empty( $envId ) ) {
1201 return [ null, $modelId ];
1202 }
1203
1204 // Environment without a model: the first model of that environment is used.
1205 if ( empty( $modelId ) ) {
1206 $env = $this->get_ai_env( $envId );
1207 if ( !empty( $env['models'] ) && is_array( $env['models'] ) ) {
1208 $firstModel = reset( $env['models'] );
1209 if ( !empty( $firstModel['model'] ) ) {
1210 return [ $envId, $firstModel['model'] ];
1211 }
1212 }
1213 return [ $envId, $this->get_option( 'ai_default_model' ) ];
1214 }
1215
1216 return [ $envId, $modelId ];
1217 }
1218
1219 /**
1220 * Resolve a model id to its metadata (tags, features, etc.). Static providers
1221 * (OpenAI, Anthropic) keep their models in 'ai_engines' by type; dynamic ones
1222 * (OpenRouter, Google) store them per environment in 'ai_envs'. We check the
1223 * environment first, then fall back to the engine defaults and custom models.
1224 */
1225 public function find_model_data( $modelId, $envId = null ) {
1226 if ( empty( $modelId ) ) {
1227 return null;
1228 }
1229 $options = $this->get_all_options();
1230
1231 // 1. The chatbot's own environment (covers dynamically-fetched models).
1232 $envType = null;
1233 if ( !empty( $envId ) && !empty( $options['ai_envs'] ) ) {
1234 foreach ( $options['ai_envs'] as $env ) {
1235 if ( ( $env['id'] ?? null ) !== $envId ) {
1236 continue;
1237 }
1238 $envType = $env['type'] ?? null;
1239 foreach ( $env['models'] ?? [] as $model ) {
1240 if ( ( $model['model'] ?? null ) === $modelId ) {
1241 return $model;
1242 }
1243 }
1244 }
1245 }
1246
1247 // 2. The engine defaults (constants), scoped to the env type when known.
1248 foreach ( $options['ai_engines'] ?? [] as $engine ) {
1249 if ( $envType !== null && ( $engine['type'] ?? null ) !== $envType ) {
1250 continue;
1251 }
1252 foreach ( $engine['models'] ?? [] as $model ) {
1253 if ( ( $model['model'] ?? null ) === $modelId ) {
1254 return $model;
1255 }
1256 }
1257 }
1258
1259 // 3. Custom models.
1260 foreach ( $options['ai_models'] ?? [] as $model ) {
1261 if ( ( $model['model'] ?? null ) === $modelId ) {
1262 return $model;
1263 }
1264 }
1265
1266 return null;
1267 }
1268
1269 public function get_chatbot( $botId ) {
1270 $chatbots = $this->get_chatbots();
1271 foreach ( $chatbots as $chatbot ) {
1272 if ( $chatbot['botId'] === (string) $botId ) {
1273 return $chatbot;
1274 }
1275 }
1276 return null;
1277 }
1278
1279 public function get_embeddings_env( $envId ) {
1280 return $this->modelEnvironmentService->get_embeddings_env( $envId );
1281 }
1282
1283 public function get_ai_env( $envId ) {
1284 return $this->modelEnvironmentService->get_ai_env( $envId );
1285 }
1286
1287 public function get_assistant( $envId, $assistantId ) {
1288 return $this->modelEnvironmentService->get_assistant( $envId, $assistantId );
1289 }
1290
1291 public function get_theme( $themeId ) {
1292 $themes = $this->get_themes();
1293 foreach ( $themes as $theme ) {
1294 if ( $theme['themeId'] === $themeId ) {
1295 // Append custom CSS to theme data for frontend rendering (check for non-empty trimmed string)
1296 if ( isset( $theme['settings']['customCSS'] ) && trim( $theme['settings']['customCSS'] ) !== '' ) {
1297 $customCSS = $theme['settings']['customCSS'];
1298
1299 // Add theme class prefix to all CSS rules for proper scoping
1300 $themeClass = '.mwai-' . $themeId . '-theme';
1301 $lines = explode( "\n", $customCSS );
1302 $processedCSS = '';
1303 $inRule = false;
1304
1305 foreach ( $lines as $line ) {
1306 $trimmedLine = trim( $line );
1307
1308 // Skip empty lines and comments
1309 if ( empty( $trimmedLine ) || strpos( $trimmedLine, '/*' ) === 0 ) {
1310 $processedCSS .= $line . "\n";
1311 continue;
1312 }
1313
1314 // If line contains a selector (has { but not })
1315 if ( strpos( $line, '{' ) !== false && strpos( $line, '}' ) === false ) {
1316 // Extract selector and the rest
1317 $parts = explode( '{', $line, 2 );
1318 $selector = trim( $parts[0] );
1319
1320 // Add theme class prefix if not already present
1321 if ( strpos( $selector, $themeClass ) !== 0 ) {
1322 // Handle multiple selectors separated by comma
1323 $selectors = explode( ',', $selector );
1324 $prefixedSelectors = array_map( function ( $sel ) use ( $themeClass ) {
1325 $sel = trim( $sel );
1326 // Don't prefix if it's a keyframe or similar
1327 if ( strpos( $sel, '@' ) === 0 || strpos( $sel, 'from' ) === 0 || strpos( $sel, 'to' ) === 0 || preg_match( '/^\d+%/', $sel ) ) {
1328 return $sel;
1329 }
1330 return $themeClass . ' ' . $sel;
1331 }, $selectors );
1332 $selector = implode( ', ', $prefixedSelectors );
1333 }
1334
1335 $processedCSS .= $selector . ' {' . ( isset( $parts[1] ) ? $parts[1] : '' ) . "\n";
1336 $inRule = true;
1337 }
1338 else {
1339 $processedCSS .= $line . "\n";
1340 }
1341 }
1342
1343 $customCSS = $processedCSS;
1344
1345 // For custom themes (type: 'css'), append to style property
1346 if ( $theme['type'] === 'css' ) {
1347 $theme['style'] = ( $theme['style'] ?? '' ) . "\n\n/* Custom CSS */\n" . $customCSS;
1348 }
1349 // For internal themes, add customCSS as a separate property
1350 else {
1351 $theme['customCSS'] = $customCSS;
1352 }
1353 }
1354
1355 // Add CSS URL for cross-site and dynamic loading support
1356 // Internal themes can use physical file, custom themes use REST endpoint
1357 $theme_type = $theme['type'] ?? 'internal';
1358 if ( $theme_type === 'internal' ) {
1359 $theme['cssUrl'] = MWAI_URL . 'themes/' . $themeId . '.css';
1360 }
1361 else {
1362 // Custom themes use REST endpoint (requires Cross-Site module to be enabled)
1363 $theme['cssUrl'] = get_rest_url( null, 'mwai-ui/v1/cross-site/theme-css' ) . '?themeId=' . $themeId;
1364 }
1365
1366 return $theme;
1367 }
1368 }
1369 return null;
1370 }
1371
1372 public function update_chatbots( $chatbots ) {
1373 $htmlFields = [ 'instructions', 'textCompliance', 'aiName', 'userName', 'startSentence' ];
1374 $keepLineReturnsFields = [ 'instructions' ];
1375 $whiteSpacedFields = [ 'context' ];
1376 // Boolean fields that need proper conversion
1377 $booleanFields = [ 'window', 'copyButton', 'pdfButton', 'fullscreen', 'localMemory', 'iconBubble', 'centerOpen',
1378 'imageUpload', 'fileUpload', 'multiUpload', 'fileSearch', 'contentAware', 'aiAvatar', 'userAvatar', 'guestAvatar' ];
1379 foreach ( $chatbots as &$chatbot ) {
1380 foreach ( $chatbot as $key => &$value ) {
1381 if ( in_array( $key, $htmlFields ) ) {
1382 $value = wp_kses_post( $value );
1383 }
1384 else if ( in_array( $key, $whiteSpacedFields ) ) {
1385 $value = sanitize_textarea_field( $value );
1386 }
1387 else if ( in_array( $key, $booleanFields ) ) {
1388 // Convert various representations to boolean
1389 if ( is_bool( $value ) ) {
1390 // Already boolean, keep as is
1391 }
1392 else if ( $value === 1 || $value === '1' || $value === true || $value === 'true' || $value === 'yes' ) {
1393 // These are true values
1394 $value = true;
1395 }
1396 else if ( $value === 0 || $value === '0' || $value === false || $value === 'false' || $value === 'no' || $value === '' || $value === null ) {
1397 // These are false values
1398 $value = false;
1399 }
1400 else {
1401 // Default to checking if not empty
1402 $value = !empty( $value );
1403 }
1404 }
1405 else if ( $key === 'functions' ) {
1406 $functions = [];
1407 foreach ( $value as $function ) {
1408 if ( isset( $function['id'] ) && isset( $function['type'] ) ) {
1409 $functions[] = [
1410 'id' => sanitize_text_field( $function['id'] ),
1411 'type' => sanitize_text_field( $function['type'] ),
1412 ];
1413 }
1414 }
1415 $value = $functions;
1416 }
1417 else if ( $key === 'mcpServers' ) {
1418 $mcpServers = [];
1419 foreach ( $value as $server ) {
1420 if ( isset( $server['id'] ) ) {
1421 $mcpServers[] = [
1422 'id' => sanitize_text_field( $server['id'] ),
1423 ];
1424 }
1425 }
1426 $value = $mcpServers;
1427 }
1428 else if ( $key === 'tools' ) {
1429 // Sanitize tools array (web_search, image_generation, thinking, etc)
1430 $tools = [];
1431 if ( is_array( $value ) ) {
1432 foreach ( $value as $tool ) {
1433 $sanitized_tool = sanitize_text_field( $tool );
1434 if ( in_array( $sanitized_tool, ['web_search', 'image_generation', 'thinking', 'code_interpreter', 'google_maps'] ) ) {
1435 $tools[] = $sanitized_tool;
1436 }
1437 }
1438 }
1439 $value = $tools;
1440 }
1441 else if ( $key === 'crossSite' ) {
1442 // Handle crossSite object
1443 $crossSite = [
1444 'enabled' => isset( $value['enabled'] ) ? (bool) $value['enabled'] : false,
1445 'allowedDomains' => []
1446 ];
1447 if ( isset( $value['allowedDomains'] ) && is_array( $value['allowedDomains'] ) ) {
1448 foreach ( $value['allowedDomains'] as $domain ) {
1449 $sanitized_domain = sanitize_text_field( $domain );
1450 if ( !empty( $sanitized_domain ) ) {
1451 $crossSite['allowedDomains'][] = $sanitized_domain;
1452 }
1453 }
1454 }
1455 $value = $crossSite;
1456 }
1457 else {
1458 if ( in_array( $key, $keepLineReturnsFields ) ) {
1459 $value = preg_replace( '/\r\n/', '[==LINE_RETURN==]', $value );
1460 $value = preg_replace( '/\n/', '[==LINE_RETURN==]', $value );
1461 }
1462 $value = sanitize_text_field( $value );
1463 if ( in_array( $key, $keepLineReturnsFields ) ) {
1464 $value = preg_replace( '/\[==LINE_RETURN==\]/', "\n", $value );
1465 }
1466 }
1467 }
1468
1469 // Sync upload params to ensure consistency
1470 // fileUpload is the master control - respect its value
1471 $fileUploadEnabled = !empty( $chatbot['fileUpload'] ) || !empty( $chatbot['imageUpload'] );
1472 $maxFiles = isset( $chatbot['maxUploads'] ) && $chatbot['maxUploads'] > 0 ? (int) $chatbot['maxUploads'] : 1;
1473
1474 $chatbot['imageUpload'] = $fileUploadEnabled;
1475 $chatbot['fileUploads'] = $fileUploadEnabled ? $maxFiles : 0;
1476 $chatbot['multiUpload'] = $fileUploadEnabled && $maxFiles > 1;
1477 }
1478 if ( !update_option( $this->chatbots_option_name, $chatbots ) ) {
1479 Meow_MWAI_Logging::warn( 'Could not update chatbots.' );
1480 $chatbots = get_option( $this->chatbots_option_name, [] );
1481 return $chatbots;
1482 }
1483 return $chatbots;
1484 }
1485
1486 public function populate_dynamic_options( $options ) {
1487 static $populating = false;
1488
1489 // Prevent infinite recursion
1490 if ( $populating ) {
1491 return $options;
1492 }
1493
1494 $populating = true;
1495
1496 // Languages - use custom languages as the complete list
1497 $custom_languages = isset( $options['custom_languages'] ) && !empty( $options['custom_languages'] )
1498 ? $options['custom_languages']
1499 : [];
1500
1501 // If no custom languages defined, fall back to defaults
1502 if ( empty( $custom_languages ) ) {
1503 $options['languages'] = apply_filters( 'mwai_languages', MWAI_LANGUAGES );
1504 }
1505 else {
1506 // Process custom languages
1507 $processed_languages = [];
1508 foreach ( $custom_languages as $custom_lang ) {
1509 // Support formats like "Russian (ru)" or just "Russian"
1510 $custom_lang = trim( $custom_lang );
1511 if ( !empty( $custom_lang ) ) {
1512 // Check if language code is provided in parentheses
1513 if ( preg_match( '/^(.+)\s*\(([a-z]{2,3})\)$/i', $custom_lang, $matches ) ) {
1514 $lang_name = trim( $matches[1] );
1515 $lang_code = strtolower( trim( $matches[2] ) );
1516 $processed_languages[$lang_code] = $lang_name;
1517 }
1518 else {
1519 // No code provided, add as-is
1520 $processed_languages[] = $custom_lang;
1521 }
1522 }
1523 }
1524
1525 $options['languages'] = apply_filters( 'mwai_languages', $processed_languages );
1526 }
1527
1528 // Consolidate the Engines and their Models
1529 // PS: We should ABSOLUTELY AVOID to use ai_models directly (except for saving)
1530 // Engine Example: [ 'name' => 'Ollama', 'type' => 'ollama', inputs => ['apikey', 'endpoint'], models => [] ]
1531 $engines = MWAI_ENGINES;
1532
1533 // OVHcloud is integrated but not yet promoted publicly (pending the OVHcloud
1534 // partnership). It only appears as a selectable provider when Dev Mode is on,
1535 // so production sites never see it until we decide to ship it.
1536 if ( $this->get_option( 'dev_mode' ) ) {
1537 $engines[] = [
1538 'name' => 'OVHcloud',
1539 'type' => 'ovh',
1540 'inputs' => [ 'apikey', 'dynamicModels' ],
1541 'internal' => true,
1542 'models' => [],
1543 ];
1544 }
1545
1546 $options['ai_engines'] = apply_filters( 'mwai_engines', $engines );
1547 foreach ( $options['ai_engines'] as &$engine ) {
1548 if ( $engine['type'] === 'openai' ) {
1549 $engine['models'] = apply_filters(
1550 'mwai_openai_models',
1551 Meow_MWAI_Engines_OpenAI::get_models_static()
1552 );
1553 }
1554 else if ( $engine['type'] === 'anthropic' ) {
1555 $engine['models'] = apply_filters(
1556 'mwai_anthropic_models',
1557 Meow_MWAI_Engines_Anthropic::get_models_static()
1558 );
1559 }
1560 else if ( $engine['type'] === 'perplexity' ) {
1561 $engine['models'] = apply_filters(
1562 'mwai_perplexity_models',
1563 Meow_MWAI_Engines_Perplexity::get_models_static()
1564 );
1565 }
1566 else if ( $engine['type'] === 'mistral' ) {
1567 $engine['models'] = apply_filters(
1568 'mwai_mistral_models',
1569 Meow_MWAI_Engines_Mistral::get_models_static()
1570 );
1571 }
1572 else if ( $engine['type'] === 'xai' ) {
1573 // Static fallback covers the case where dynamic model fetch failed (e.g. no credits
1574 // on the xAI account). Dynamically fetched models override this list when available.
1575 $engine['models'] = apply_filters(
1576 'mwai_xai_models',
1577 Meow_MWAI_Engines_XAI::get_models_static()
1578 );
1579 }
1580 else {
1581 $engine['models'] = [];
1582 foreach ( $options['ai_models'] as $model ) {
1583 if ( $model['type'] === $engine['type'] ) {
1584 $engine['models'][] = $model;
1585 }
1586 }
1587 }
1588 }
1589
1590 // Functions via Code Engine (or custom code)
1591 $json = [];
1592 $functions = apply_filters( 'mwai_functions_list', [] );
1593 foreach ( $functions as $function ) {
1594 if ( $function->type === 'editor-assistant' ) {
1595 continue;
1596 }
1597 $json[] = Meow_MWAI_Query_Function::toJson( $function );
1598 }
1599 $options['functions'] = $json;
1600
1601 // Addons
1602 $options['addons'] = apply_filters( 'mwai_addons', [
1603 [
1604 'slug' => 'mwai-notifications',
1605 'name' => 'Notifications',
1606 'description' => 'Get real-time alerts for new discussions in your chatbot, so you never miss a chance to engage.',
1607 'install_url' => 'https://meowapps.com/products/mwai-notifications/',
1608 'settings_url' => null,
1609 'stars' => 4,
1610 'enabled' => false
1611 ],
1612 [
1613 'slug' => 'mwai-ollama',
1614 'name' => 'Ollama',
1615 'description' => 'Leverage local LLM integration through Ollama; refresh and use your own models for a flexible, cost-free approach.',
1616 'install_url' => 'https://meowapps.com/products/mwai-ollama/',
1617 'settings_url' => null,
1618 'stars' => 3,
1619 'enabled' => false
1620 ],
1621 [
1622 'slug' => 'mwai-deepseek',
1623 'name' => 'DeepSeek',
1624 'description' => 'Support for DeepSeek, a Chinese AI company that provides extremely powerful LLM models.',
1625 'install_url' => 'https://meowapps.com/products/deepseek/',
1626 'settings_url' => null,
1627 'stars' => 3,
1628 'enabled' => false
1629 ],
1630 [
1631 'slug' => 'mwai-websearch',
1632 'name' => 'Web Search',
1633 'description' => 'Enhance chatbot responses by pulling context from Google and Tavily, delivering more accurate answers.',
1634 'install_url' => 'https://meowapps.com/products/mwai-websearch/',
1635 'settings_url' => null,
1636 'stars' => 5,
1637 'enabled' => false
1638 ],
1639 [
1640 'slug' => 'mwai-better-links',
1641 'name' => 'Better Links',
1642 'description' => 'Validate internal and external links and map specific terms to custom URLs, ensuring smoother navigation and references.',
1643 'install_url' => 'https://meowapps.com/products/mwai-better-links/',
1644 'settings_url' => null,
1645 'stars' => 3,
1646 'enabled' => false
1647 ],
1648 [
1649 'slug' => 'mwai-woo-basics',
1650 'name' => 'Woo Basics',
1651 'description' => 'Access essential WooCommerce data so your chatbot can understand products, orders, and more for a richer shopping experience.',
1652 'install_url' => 'https://meowapps.com/products/mwai-woo-basics/',
1653 'settings_url' => null,
1654 'stars' => 2,
1655 'enabled' => false
1656 ],
1657 [
1658 'slug' => 'mwai-quick-actions',
1659 'name' => 'Quick Actions',
1660 'description' => 'Enable dynamic quick actions at chat start or during events, helping users find what they need faster.',
1661 'install_url' => 'https://meowapps.com/products/mwai-quick-actions/',
1662 'settings_url' => null,
1663 'stars' => 3,
1664 'enabled' => false
1665 ],
1666 [
1667 'slug' => 'mwai-content-parser',
1668 'name' => 'Content Parser',
1669 'description' => 'Parse complex website content, including ACF fields and page builders, for more precise embeddings and knowledge retrieval.',
1670 'install_url' => 'https://meowapps.com/products/mwai-content-parser/',
1671 'settings_url' => null,
1672 'stars' => 2,
1673 'enabled' => false
1674 ],
1675 [
1676 'slug' => 'mwai-visitor-form',
1677 'name' => 'Visitor Form',
1678 'description' => 'Add a customizable form triggered by specific events in your chatbot to collect key visitor information seamlessly.',
1679 'install_url' => 'https://meowapps.com/products/mwai-visitor-form/',
1680 'settings_url' => null,
1681 'stars' => 2,
1682 'enabled' => false
1683 ],
1684 [
1685 'slug' => 'mwai-dynamic-keys',
1686 'name' => 'Dynamic Keys',
1687 'description' => 'Rotate multiple API keys dynamically for any environment, balancing usage and ensuring smooth performance.',
1688 'install_url' => 'https://meowapps.com/products/mwai-dynamic-keys/',
1689 'settings_url' => null,
1690 'stars' => 1,
1691 'enabled' => false
1692 ],
1693 [
1694 'slug' => 'mwai-user-memory',
1695 'name' => 'User Memory',
1696 'description' => 'Let your chatbot remember details about logged-in users across conversations, for more personal and context-aware replies.',
1697 'install_url' => 'https://meowapps.com/products/mwai-user-memory/',
1698 'settings_url' => null,
1699 'stars' => 3,
1700 'enabled' => false
1701 ],
1702 ] );
1703
1704 // Add-ons mark themselves enabled through the `mwai_addons` filter, but they
1705 // only register that hook inside is_admin(). Over REST (the settings/options
1706 // endpoint, or an options auto-save) is_admin() is false, so none of them would
1707 // report as enabled and the admin UI would show no enabled add-ons. Flag any
1708 // add-on whose plugin is active as a context-independent fallback.
1709 if ( !function_exists( 'is_plugin_active' ) ) {
1710 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1711 }
1712 foreach ( $options['addons'] as &$addon ) {
1713 if ( empty( $addon['enabled'] ) && is_plugin_active( $addon['slug'] . '/' . $addon['slug'] . '.php' ) ) {
1714 $addon['enabled'] = true;
1715 }
1716 }
1717 unset( $addon );
1718
1719 // SEO stats. Filled by SEO Engine when it is active; null otherwise. AI Engine knows
1720 // nothing about SEO Engine's internals, exactly like the addons list above.
1721 // Admin-only: this feeds the Modules tab, and the provider filter may run SQL, so it
1722 // must never fire on guest-facing requests (get_option() rebuilds this payload often).
1723 // Guard on wp_get_current_user, not current_user_can: the core is constructed while
1724 // plugins load, before pluggable.php exists, and current_user_can calls into it.
1725 $can_admin = function_exists( 'wp_get_current_user' ) && current_user_can( 'manage_options' );
1726 $options['seo_stats'] = $can_admin ? apply_filters( 'mwai_seo_stats', null ) : null;
1727 $options['seo_robots'] = $can_admin ? $this->get_ai_crawler_access() : null;
1728
1729 // Populate usage data from ai_usage to ai_models_usage for the frontend
1730 $ai_usage = $this->get_option( 'ai_usage', [] );
1731 $options['ai_models_usage'] = $ai_usage;
1732
1733 // Also include daily usage data
1734 $ai_usage_daily = $this->get_option( 'ai_usage_daily', [] );
1735 $options['ai_models_usage_daily'] = $ai_usage_daily;
1736
1737 $populating = false;
1738 return $options;
1739 }
1740
1741 // Reports whether the well-known AI crawlers are allowed by robots.txt. Local reads only:
1742 // fetching home_url( '/robots.txt' ) over HTTP would be slow on an admin screen and fails
1743 // on hosts that block loopback requests.
1744 public function get_ai_crawler_access() {
1745 $public = get_option( 'blog_public' );
1746 $discouraged = (string) $public === '0';
1747 $has_file = file_exists( ABSPATH . 'robots.txt' );
1748 $mtime = $has_file ? ( filemtime( ABSPATH . 'robots.txt' ) ?: null ) : null;
1749
1750 // The cache is only trusted while its cheap fingerprints still match, so editing
1751 // robots.txt (SEO plugin, FTP) or toggling "Discourage search engines" shows up
1752 // immediately instead of after the transient expires.
1753 $cached = get_transient( 'mwai_ai_crawler_access' );
1754 if ( is_array( $cached ) && array_key_exists( 'mtime', $cached )
1755 && $cached['mtime'] === $mtime && $cached['discouraged'] === $discouraged ) {
1756 return $cached;
1757 }
1758
1759 $bots = [ 'GPTBot', 'ClaudeBot', 'PerplexityBot', 'Google-Extended', 'CCBot',
1760 'Applebot-Extended', 'meta-externalagent', 'Bytespider' ];
1761 $source = 'virtual';
1762 $content = '';
1763
1764 if ( $has_file ) {
1765 $source = 'file';
1766 $content = (string) file_get_contents( ABSPATH . 'robots.txt' );
1767 }
1768 else {
1769 // WordPress only serves a virtual robots.txt when no physical file exists. Rebuild
1770 // what it actually serves (do_robots() echoes, so replicate its default) and run the
1771 // robots_txt filter so SEO plugins hooking it are reflected.
1772 $site_url = wp_parse_url( site_url() );
1773 $path = !empty( $site_url['path'] ) ? $site_url['path'] : '';
1774 $default = "User-agent: *\n";
1775 $default .= "Disallow: $path/wp-admin/\n";
1776 $default .= "Allow: $path/wp-admin/admin-ajax.php\n";
1777 $content = apply_filters( 'robots_txt', $default, $public );
1778 }
1779
1780 $access = [
1781 'discouraged' => $discouraged,
1782 'source' => $source,
1783 'mtime' => $mtime,
1784 'bots' => $this->parse_robots_for_bots( $content, $bots, $discouraged ),
1785 ];
1786
1787 set_transient( 'mwai_ai_crawler_access', $access, HOUR_IN_SECONDS );
1788 return $access;
1789 }
1790
1791 private function parse_robots_for_bots( $content, $bots, $discouraged ) {
1792 $access = [];
1793
1794 // Settings > Reading > "Discourage search engines" overrides everything.
1795 if ( $discouraged ) {
1796 foreach ( $bots as $bot ) {
1797 $access[$bot] = 'blocked';
1798 }
1799 return $access;
1800 }
1801
1802 // Group the rules: consecutive User-agent lines share the record that follows.
1803 $groups = [];
1804 $agents = [];
1805 $collecting = true;
1806 $lines = preg_split( '/\r\n|\r|\n/', $content );
1807 foreach ( $lines as $line ) {
1808 $line = trim( preg_replace( '/#.*$/', '', $line ) );
1809 if ( $line === '' ) {
1810 continue;
1811 }
1812 if ( preg_match( '/^user-agent\s*:\s*(.+)$/i', $line, $m ) ) {
1813 if ( !$collecting ) {
1814 $agents = [];
1815 $collecting = true;
1816 }
1817 $agent = strtolower( trim( $m[1] ) );
1818 $agents[] = $agent;
1819 // Register the group even if it only ever gets Allow lines, so a bot with its
1820 // own (permissive) group never falls back to a blocking * group.
1821 if ( !isset( $groups[$agent] ) ) {
1822 $groups[$agent] = [];
1823 }
1824 }
1825 else if ( preg_match( '/^disallow\s*:\s*(.*)$/i', $line, $m ) ) {
1826 $collecting = false;
1827 $rule = trim( $m[1] );
1828 foreach ( $agents as $agent ) {
1829 $groups[$agent][] = $rule;
1830 }
1831 }
1832 else {
1833 // Allow, Sitemap, Crawl-delay… end the User-agent run but keep the group.
1834 $collecting = false;
1835 }
1836 }
1837
1838 foreach ( $bots as $bot ) {
1839 $key = strtolower( $bot );
1840 $rules = isset( $groups[$key] ) ? $groups[$key] :
1841 ( isset( $groups['*'] ) ? $groups['*'] : [] );
1842 // Only a full "Disallow: /" counts as blocked; narrower paths leave the site readable.
1843 $access[$bot] = in_array( '/', $rules, true ) ? 'blocked' : 'allowed';
1844 }
1845 return $access;
1846 }
1847
1848 public function get_all_options( $force = false, $sanitize = false ) {
1849 if ( $force || is_null( $this->options ) ) {
1850 $options = get_option( $this->option_name, [] );
1851 $init_mode = empty( $options );
1852 foreach ( MWAI_OPTIONS as $key => $value ) {
1853 if ( !isset( $options[$key] ) ) {
1854 $options[$key] = $value;
1855 }
1856 }
1857 $options['chatbot_defaults'] = MWAI_CHATBOT_DEFAULT_PARAMS;
1858 $options['default_limits'] = MWAI_LIMITS;
1859
1860 // Force sanitization if custom_languages is not set (migration)
1861 $needs_language_migration = !isset( $options['custom_languages'] ) || empty( $options['custom_languages'] );
1862
1863 if ( $sanitize || $init_mode || $needs_language_migration ) {
1864 $options = $this->sanitize_options( $options );
1865 }
1866 $this->options = $options;
1867 }
1868 $options = $this->populate_dynamic_options( $this->options );
1869 return $options;
1870 }
1871
1872 // Sanitize options when we update the plugin or perform some updates
1873 // if we change the structure of the options.
1874 public function sanitize_options( $options ) {
1875 $needs_update = false;
1876
1877 // Removing old options of options renaming should be done here, as it was done before.
1878 // Check version 2.6.8 for an example.
1879
1880 // Avoid the logs_path to be a PHP file.
1881 if ( isset( $options['logs_path'] ) ) {
1882 $logs_path = $options['logs_path'];
1883 if ( substr( $logs_path, -4 ) !== '.log' ) {
1884 $options['logs_path'] = '';
1885 $needs_update = true;
1886 }
1887 }
1888
1889 // The IDs for the embeddings environments are generated here.
1890 // TODO: We should handle this more gracefully via an option in the Embeddings Settings.
1891 $embeddings_default_exists = false;
1892 if ( isset( $options['embeddings_envs'] ) ) {
1893 foreach ( $options['embeddings_envs'] as &$env ) {
1894 if ( !isset( $env['id'] ) ) {
1895 $env['id'] = $this->get_random_id();
1896 $needs_update = true;
1897 }
1898 if ( $env['id'] === $options['embeddings_default_env'] ) {
1899 $embeddings_default_exists = true;
1900 }
1901 }
1902 }
1903 if ( !$embeddings_default_exists ) {
1904 $options['embeddings_default_env'] = $options['embeddings_envs'][0]['id'] ?? null;
1905 $needs_update = true;
1906 }
1907
1908 // The IDs for the AI environments are generated here.
1909 $allEnvIds = [];
1910 $ai_default_exists = false;
1911 // Allow empty string as valid "None" selection
1912 $ai_default_is_none = isset( $options['ai_default_env'] ) && $options['ai_default_env'] === '';
1913 if ( isset( $options['ai_envs'] ) ) {
1914 foreach ( $options['ai_envs'] as &$env ) {
1915 if ( !isset( $env['id'] ) ) {
1916 $env['id'] = $this->get_random_id();
1917 $needs_update = true;
1918 }
1919 if ( $env['id'] === $options['ai_default_env'] ) {
1920 $ai_default_exists = true;
1921 }
1922 $allEnvIds[] = $env['id'];
1923 }
1924 }
1925 if ( !$ai_default_exists && !$ai_default_is_none ) {
1926 $options['ai_default_env'] = $options['ai_envs'][0]['id'] ?? null;
1927 $needs_update = true;
1928 }
1929
1930 // The IDs for the MCP environments are generated here.
1931 if ( isset( $options['mcp_envs'] ) ) {
1932 foreach ( $options['mcp_envs'] as &$env ) {
1933 if ( !isset( $env['id'] ) ) {
1934 $env['id'] = $this->get_random_id();
1935 $needs_update = true;
1936 }
1937 }
1938 }
1939
1940 // Migration: DALL-E was removed (deprecated by OpenAI). Move users to the image fallback.
1941 // TODO: Remove after 2027-04 (1 year after the shutdown on 2026-05-12).
1942 if ( isset( $options['ai_images_default_model'] )
1943 && in_array( $options['ai_images_default_model'], [ 'dall-e', 'dall-e-2', 'dall-e-3', 'dall-e-3-hd' ], true ) ) {
1944 $options['ai_images_default_model'] = MWAI_FALLBACK_MODEL_IMAGES;
1945 $needs_update = true;
1946 }
1947
1948 // All the models with an envId that does not exist anymore are removed.
1949 if ( isset( $options['ai_models'] ) ) {
1950 $options['ai_models'] = array_values( array_filter(
1951 $options['ai_models'],
1952 function ( $model ) use ( $allEnvIds, &$needs_update ) {
1953 if ( isset( $model['envId'] ) && !in_array( $model['envId'], $allEnvIds ) ) {
1954 $needs_update = true;
1955 return false;
1956 }
1957 return true;
1958 }
1959 ) );
1960 }
1961
1962 // Migration: limits.creditType 'units' → 'tokens'. The read path in
1963 // premium/statistics.php still accepts both, so this is purely cosmetic
1964 // alignment with the new "Tokens" labels. One-shot per install.
1965 // TODO: Remove after 2026-11.
1966 if ( isset( $options['limits'] ) && is_array( $options['limits'] ) ) {
1967 foreach ( [ 'system', 'users', 'guests' ] as $bucket ) {
1968 if ( isset( $options['limits'][$bucket]['creditType'] )
1969 && $options['limits'][$bucket]['creditType'] === 'units' ) {
1970 $options['limits'][$bucket]['creditType'] = 'tokens';
1971 $needs_update = true;
1972 }
1973 }
1974 }
1975
1976 // Migration: Populate custom_languages if empty for existing installations
1977 if ( !isset( $options['custom_languages'] ) || empty( $options['custom_languages'] ) ) {
1978 $options['custom_languages'] = [
1979 'English (en)',
1980 'German (de)',
1981 'French (fr)',
1982 'Spanish (es)',
1983 'Italian (it)',
1984 'Chinese (zh)',
1985 'Japanese (ja)',
1986 'Portuguese (pt)'
1987 ];
1988 $needs_update = true;
1989 }
1990
1991 if ( $needs_update ) {
1992 ksort( $options );
1993 update_option( $this->option_name, $options, false );
1994 }
1995
1996 return $options;
1997 }
1998
1999 public function update_options( $options ) {
2000 // update_option returns false both when update fails AND when value is unchanged
2001 // We just attempt the update and always return the current options
2002 // The frontend will see if the values actually changed
2003 update_option( $this->option_name, $options, false );
2004 $options = $this->get_all_options( true, true );
2005 return $options;
2006 }
2007
2008 public function update_option( $option, $value ) {
2009 $options = $this->get_all_options( true );
2010 $options[$option] = $value;
2011 return $this->update_options( $options );
2012 }
2013
2014 public function get_option( $option, $default = null ) {
2015 $options = $this->get_all_options();
2016 return $options[$option] ?? $default;
2017 }
2018
2019 public function update_ai_env( $env_id, $option, $value ) {
2020 $options = $this->get_all_options( true );
2021 foreach ( $options['ai_envs'] as &$env ) {
2022 if ( $env['id'] === $env_id ) {
2023 $env[$option] = $value;
2024 break;
2025 }
2026 }
2027 return $this->update_options( $options );
2028 }
2029
2030 public function get_engine_models( $engineType ) {
2031 // This method is called by engines with just a string type
2032 // We need to get the models differently
2033 $options = $this->get_all_options();
2034 $engines = $options['ai_envs'];
2035 $models = [];
2036
2037 // Find all models for this engine type
2038 foreach ( $engines as $engine ) {
2039 if ( $engine['type'] === $engineType ) {
2040 if ( isset( $engine['models'] ) ) {
2041 foreach ( $engine['models'] as $model ) {
2042 $models[] = $model;
2043 }
2044 }
2045 }
2046 }
2047
2048 // Also check custom models
2049 if ( isset( $options['ai_models'] ) ) {
2050 foreach ( $options['ai_models'] as $model ) {
2051 if ( $model['type'] === $engineType ) {
2052 $models[] = $model;
2053 }
2054 }
2055 }
2056
2057 return $models;
2058 }
2059
2060 public function reset_options() {
2061 delete_option( $this->themes_option_name );
2062 delete_option( $this->chatbots_option_name );
2063 delete_option( $this->option_name );
2064 return $this->get_all_options( true );
2065 }
2066 #endregion
2067
2068 #region Cron Tracking
2069 public function track_cron_start( $hook ) {
2070 // Set running transient (expires in 5 minutes as a safety measure)
2071 set_transient( 'mwai_cron_running_' . $hook, true, 300 );
2072 }
2073
2074 public function track_cron_end( $hook, $status = 'success', $error_message = '' ) {
2075 // Remove running transient
2076 delete_transient( 'mwai_cron_running_' . $hook );
2077
2078 // Get existing data
2079 $cron_data = get_transient( 'mwai_cron_last_run' ) ?: [];
2080
2081 // Update this cron's data - use time() for consistency
2082 $cron_data[$hook] = [
2083 'time' => time(),
2084 'status' => $status,
2085 'error' => $error_message
2086 ];
2087
2088 // Store for 7 days
2089 set_transient( 'mwai_cron_last_run', $cron_data, 7 * DAY_IN_SECONDS );
2090 }
2091 #endregion
2092 }
2093