PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.4.8
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.4.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 11 months ago engines 1 month ago exceptions 11 months ago modules 1 month ago query 1 month ago rest 3 months ago services 2 months ago admin.php 2 months ago api.php 1 month ago core.php 1 month ago discussion.php 11 months ago event.php 11 months ago init.php 7 months ago logging.php 11 months ago reply.php 2 months ago rest.php 2 months ago
core.php
1671 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 // Forms Manager (standalone Forms UI + shortcode renderer)
119 if ( $this->get_option( 'module_forms' ) ) {
120 new Meow_MWAI_Modules_Forms_Manager( $this );
121 }
122
123 // Advanced Core
124 if ( class_exists( 'MeowPro_MWAI_Core' ) ) {
125 new MeowPro_MWAI_Core( $this );
126 }
127
128 // Editor Assistant: Pro extends with tools and feedback; free version only provides recommendations.
129 // Must be after MeowPro_MWAI_Core so the Pro class is already instantiated.
130 if ( $this->get_option( 'module_assistant', true ) && ( is_admin() || $this->is_rest ) ) {
131 if ( !class_exists( 'MeowPro_MWAI_EditorAssistant', false ) ) {
132 new Meow_MWAI_Modules_Editor_Assistant( $this );
133 }
134 }
135
136 // Simple API
137 $mwai = new Meow_MWAI_API( $this->chatbot, $this->discussions ?? null );
138
139 // MCP
140 if ( $this->get_option( 'module_mcp' ) ) {
141 new Meow_MWAI_Labs_MCP( $this );
142
143 // Core - Core WordPress MCP tools
144 if ( $this->get_option( 'mcp_core' ) ) {
145 new Meow_MWAI_Labs_MCP_Core( $this );
146 }
147
148 // Dynamic REST - WordPress REST API MCP tools
149 if ( $this->get_option( 'mcp_dynamic_rest' ) ) {
150 require_once MWAI_PATH . '/labs/mcp-rest.php';
151 new Meow_MWAI_Labs_MCP_Rest();
152 }
153
154 // Themes - Pro theme management MCP tools
155 if ( $this->get_option( 'mcp_themes' ) && class_exists( 'MeowPro_MWAI_MCP_Theme' ) ) {
156 new MeowPro_MWAI_MCP_Theme( $this );
157 }
158
159 // Plugins - Pro plugin management MCP tools
160 if ( $this->get_option( 'mcp_plugins' ) && class_exists( 'MeowPro_MWAI_MCP_Plugin' ) ) {
161 new MeowPro_MWAI_MCP_Plugin( $this );
162 }
163
164 // Database - Pro database query MCP tools
165 if ( $this->get_option( 'mcp_database' ) && class_exists( 'MeowPro_MWAI_MCP_Database' ) ) {
166 new MeowPro_MWAI_MCP_Database( $this );
167 }
168
169 // Polylang - Pro multilingual MCP tools (only if Polylang is active)
170 if ( $this->get_option( 'mcp_polylang' ) && class_exists( 'MeowPro_MWAI_MCP_Polylang' ) && function_exists( 'pll_get_post_language' ) ) {
171 new MeowPro_MWAI_MCP_Polylang( $this );
172 }
173
174 // WooCommerce - Pro WooCommerce store management MCP tools (only if WooCommerce is active)
175 if ( $this->get_option( 'mcp_woocommerce' ) && class_exists( 'MeowPro_MWAI_MCP_WooCommerce' ) && class_exists( 'WooCommerce' ) ) {
176 new MeowPro_MWAI_MCP_WooCommerce( $this );
177 }
178 }
179
180 // WP 7 Connectors integration (self-gates on WP 7+).
181 if ( class_exists( 'WP_Connector_Registry' ) ) {
182 new Meow_MWAI_Labs_WPAI_Connectors( $this );
183 }
184
185 // WP 7 AiClient gateway: register AI Engine as a provider in the
186 // AiClient registry so the WP AI framework can dispatch through us.
187 if ( class_exists( '\\WordPress\\AiClient\\AiClient' ) ) {
188 new Meow_MWAI_Labs_WPAI_Gateway( $this );
189 }
190
191 }
192
193 public function register_scripts() {
194 // Register Highlight.js
195 wp_register_script( 'mwai_highlight', MWAI_URL . 'vendor/highlightjs/highlight.min.js', [], '11.7', false );
196 // Register CSS for the themes
197 $themes = $this->get_themes();
198 foreach ( $themes as $theme ) {
199 if ( $theme['type'] === 'internal' ) {
200 $themeId = $theme['themeId'];
201 $filename = $themeId . '.css';
202 $physical_file = trailingslashit( MWAI_PATH ) . 'themes/' . $filename;
203 $cache_buster = file_exists( $physical_file ) ? filemtime( $physical_file ) : MWAI_VERSION;
204 wp_register_style( 'mwai_chatbot_theme_' . $themeId, trailingslashit( MWAI_URL )
205 . 'themes/' . $filename, [], $cache_buster );
206 }
207 }
208 }
209
210 public function enqueue_theme( $themeId ) {
211 if ( empty( $themeId ) ) {
212 return;
213 }
214 wp_enqueue_style( "mwai_chatbot_theme_$themeId" );
215 }
216
217 public function enqueue_themes() {
218 $themes = $this->get_themes();
219 foreach ( $themes as $theme ) {
220 if ( $theme['type'] === 'internal' ) {
221 $this->enqueue_theme( $theme['themeId'] );
222 }
223 }
224 }
225
226 #endregion
227
228 #region Roles & Capabilities
229 public function can_start_session() {
230 return $this->sessionService->can_start_session();
231 }
232
233 public function can_access_settings() {
234 return apply_filters( 'mwai_allow_setup', current_user_can( 'manage_options' ) );
235 }
236
237 public function can_access_features() {
238 $editor_or_admin = current_user_can( 'editor' ) || current_user_can( 'administrator' );
239 return apply_filters( 'mwai_allow_usage', $editor_or_admin );
240 }
241
242 public function can_access_public_api( $feature, $extra ) {
243 $logged_in = is_user_logged_in();
244 return apply_filters( 'mwai_allow_public_api', $logged_in, $feature, $extra );
245 }
246 #endregion
247
248 #region AI-Related Helpers
249 public function run_query( $query, $streamCallback = null, $markdown = false ) {
250
251 // Allow to modify the query before it is sent.
252 // Different query types have specific filters for type-safe modifications.
253 if ( $query instanceof Meow_MWAI_Query_Embed ) {
254 $query = apply_filters( 'mwai_ai_embeddings_query', $query );
255 }
256 else if ( $query instanceof Meow_MWAI_Query_Feedback ) {
257 $query = apply_filters( 'mwai_ai_feedback_query', $query );
258 }
259 else {
260 $query = apply_filters( 'mwai_ai_query', $query );
261 }
262
263 // Ensure the query is still valid after filtering
264 if ( !$query || !is_object( $query ) ) {
265 throw new Exception( __( 'Invalid query object after filtering. The mwai_ai_query filter must return a valid query object.', 'ai-engine' ) );
266 }
267
268 // Validate that embeddings queries have a non-empty message
269 if ( $query instanceof Meow_MWAI_Query_Embed && empty( $query->get_message() ) ) {
270 throw new Exception( __( 'Embeddings query cannot have an empty message. Please check that the conversation context is properly extracted.', 'ai-engine' ) );
271 }
272
273 // Let's check the default environment and model.
274 $this->validate_env_model( $query );
275
276 // Create the engine based on the query's environment
277 $engine = Meow_MWAI_Engines_Factory::get( $this, $query->envId );
278
279 // Let's run the query.
280 $reply = $engine->run( $query, $streamCallback );
281
282 // Let's allow to modify the reply before it is sent.
283 if ( $markdown ) {
284 if ( $query instanceof Meow_MWAI_Query_Image || $query instanceof Meow_MWAI_Query_EditImage ) {
285 $reply->result = '';
286 foreach ( $reply->results as $result ) {
287 $reply->result .= "![Image]($result)\n";
288 }
289 }
290 }
291
292 return $reply;
293 }
294
295 public function validate_env_model( $query ) {
296 return $this->modelEnvironmentService->validate_env_model( $query );
297 }
298
299 #endregion
300
301 #region Text-Related Helpers
302
303 // Clean the text perfectly, resolve shortcodes, etc, etc.
304 public function clean_text( $rawText = '' ) {
305 $text = html_entity_decode( $rawText );
306 $text = wp_strip_all_tags( $text );
307 $text = preg_replace( '/[\r\n]+/', "\n", $text );
308 $text = preg_replace( '/\n+/', "\n", $text );
309 $text = preg_replace( '/\t+/', "\t", $text );
310 return $text . ' ';
311 }
312
313 // Make sure there are no duplicate sentences, and keep the length under a maximum length.
314 public function clean_sentences( $text, $maxLength = null ) {
315 // Step 1: Identify URLs and replace them with a placeholder.
316 $urlPattern = '/\bhttps?:\/\/[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|\/))/';
317 preg_match_all( $urlPattern, $text, $urls );
318 $urlPlaceholders = [];
319 foreach ( $urls[0] as $index => $url ) {
320 $placeholder = '{urlPlaceholder' . $index . '}';
321 $text = str_replace( $url, $placeholder, $text );
322 $urlPlaceholders[$placeholder] = $url;
323 }
324
325 $maxLength = (int) ( $maxLength ? $maxLength : $this->get_option( 'context_max_length', 4096 ) );
326 $sentences = preg_split( '/(?<=[.?!。.!?])\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY );
327 $hashes = [];
328 $uniqueSentences = [];
329 $total = 0;
330
331 foreach ( $sentences as $sentence ) {
332 $sentence = preg_replace( '/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $sentence );
333 $hash = md5( $sentence );
334 if ( !in_array( $hash, $hashes ) ) {
335 $length = mb_strlen( $sentence, 'UTF-8' );
336 if ( $total + $length > $maxLength ) {
337 continue;
338 }
339 $hashes[] = $hash;
340 $uniqueSentences[] = $sentence;
341 $total += $length;
342 }
343 }
344
345 $freshText = implode( ' ', $uniqueSentences );
346
347 // Step 3: Restore URLs in the final text.
348 foreach ( $urlPlaceholders as $placeholder => $url ) {
349 $freshText = str_replace( $placeholder, $url, $freshText );
350 }
351
352 $freshText = preg_replace( '/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $freshText );
353 return $freshText;
354 }
355
356 public function get_post_content( $postId ) {
357 // Ensure we get fresh post data by clearing cache
358 clean_post_cache( $postId );
359 $post = get_post( $postId );
360 if ( !$post ) {
361 return false;
362 }
363 $text = apply_filters( 'mwai_pre_post_content', $post->post_content, $postId );
364 $pattern = '/\[mwai_.*?\]/';
365 $text = preg_replace( $pattern, '', $text );
366 if ( $this->get_option( 'resolve_shortcodes' ) ) {
367 $text = apply_filters( 'the_content', $text );
368 }
369 else {
370 $pattern = "/\[[^\]]+\]/";
371 $text = preg_replace( $pattern, '', $text );
372 $pattern = "/<!--\s*\/?wp:[^\>]+-->/";
373 $text = preg_replace( $pattern, '', $text );
374 }
375 $text = $this->clean_text( $text );
376 $text = $this->clean_sentences( $text );
377 $text = apply_filters( 'mwai_post_content', $text, $postId );
378 return $text;
379 }
380
381 public function markdown_to_html( $content ) {
382 $Parsedown = new Parsedown();
383 $content = $Parsedown->text( $content );
384 return $content;
385 }
386
387 public function get_post_language( $postId ) {
388 $locale = get_locale();
389 $code = strtolower( substr( $locale, 0, 2 ) );
390 $humanLanguage = strtr( $code, MWAI_ALL_LANGUAGES );
391 $lang = apply_filters( 'wpml_post_language_details', null, $postId );
392 if ( !empty( $lang ) ) {
393 $locale = $lang['locale'];
394 $humanLanguage = $lang['display_name'];
395 }
396 return strtolower( "$locale ($humanLanguage)" );
397 }
398
399 public function do_placeholders( $text ) {
400 $defaultPlaceholders = [
401 // Date and time in a clear, AI-friendly format (e.g., "December 11, 2025 at 3:45 PM")
402 'DATE_TIME' => date_i18n( 'F j, Y \a\t g:i A' ),
403 ];
404 $dataPlaceholders = $this->get_user_data();
405 if ( !empty( $dataPlaceholders ) ) {
406 $defaultPlaceholders = array_merge( $defaultPlaceholders, $dataPlaceholders );
407 }
408 $placeholders = apply_filters( 'mwai_placeholders', $defaultPlaceholders );
409 foreach ( $placeholders as $key => $value ) {
410 $text = str_replace( '{' . $key . '}', $value ?? '', $text );
411 }
412 // Replace any remaining unmatched placeholders with a clear indicator
413 $text = preg_replace( '/\{[A-Z_]+\}/', '[N/A]', $text );
414 return $text;
415 }
416 #endregion
417
418 #region Security Helpers
419 /**
420 * Sanitize file path to prevent PHAR deserialization attacks.
421 * Strips dangerous stream wrappers that could trigger object injection.
422 *
423 * @param string $path The file path to sanitize.
424 * @return string The sanitized file path.
425 * @throws Exception If a dangerous stream wrapper is detected.
426 */
427 public static function sanitize_file_path( $path ) {
428 if ( empty( $path ) || !is_string( $path ) ) {
429 return $path;
430 }
431
432 // List of dangerous stream wrappers that could trigger deserialization
433 $dangerous_wrappers = [
434 'phar://',
435 'php://',
436 'zip://',
437 'zlib://',
438 'data://',
439 'glob://',
440 'rar://',
441 'ogg://',
442 'expect://'
443 ];
444
445 // Check if the path contains any dangerous wrappers
446 $lower_path = strtolower( $path );
447 foreach ( $dangerous_wrappers as $wrapper ) {
448 if ( strpos( $lower_path, $wrapper ) === 0 ) {
449 throw new Exception( 'Invalid file path: Stream wrappers are not allowed for security reasons.' );
450 }
451 }
452
453 return $path;
454 }
455 #endregion
456
457 #region Image-Related Helpers
458 public static function is_image( $file ) {
459 global $mwai_core;
460 if ( $mwai_core && $mwai_core->imageService ) {
461 return $mwai_core->imageService->is_image( $file );
462 }
463 // Fallback to original implementation if service not available
464 $mimeType = self::get_mime_type( $file );
465 if ( strpos( $mimeType, 'image' ) !== false ) {
466 return true;
467 }
468 return false;
469 }
470
471 public static function get_image_resolution( $url ) {
472 global $mwai_core;
473 if ( $mwai_core && $mwai_core->imageService ) {
474 return $mwai_core->imageService->get_image_resolution( $url );
475 }
476 // Fallback to original implementation if service not available
477 if ( empty( $url ) ) {
478 return null;
479 }
480 $headers = get_headers( $url, 1 );
481 if ( strpos( $headers[0], '200' ) === false ) {
482 return null;
483 }
484 $image_info = getimagesize( $url );
485 if ( $image_info === false ) {
486 return null;
487 }
488 return [
489 'width' => $image_info[0],
490 'height' => $image_info[1]
491 ];
492 }
493
494 public static function get_mime_type( $file ) {
495 global $mwai_core;
496 if ( $mwai_core && $mwai_core->imageService ) {
497 return $mwai_core->imageService->get_mime_type( $file );
498 }
499
500 // Fallback implementation - this should rarely be used as imageService is initialized early
501 Meow_MWAI_Logging::warn( 'get_mime_type called before imageService is available' );
502
503 // Basic extension-based detection only
504 $extension = pathinfo( $file, PATHINFO_EXTENSION );
505 $extension = strtolower( $extension );
506 $mimeTypes = [
507 'jpg' => 'image/jpeg',
508 'jpeg' => 'image/jpeg',
509 'png' => 'image/png',
510 'gif' => 'image/gif',
511 'webp' => 'image/webp',
512 'bmp' => 'image/bmp',
513 'tiff' => 'image/tiff',
514 'tif' => 'image/tiff',
515 'svg' => 'image/svg+xml',
516 'ico' => 'image/x-icon',
517 'pdf' => 'application/pdf',
518 ];
519 return isset( $mimeTypes[$extension] ) ? $mimeTypes[$extension] : null;
520 }
521
522 public function download_image( $url ) {
523 return $this->imageService->download_image( $url );
524 }
525
526 /**
527 * Add an image from a URL to the Media Library.
528 * @param string $url The URL of the image to be downloaded.
529 * @param string $filename The filename of the image, if not set, it will be the basename of the URL.
530 * @param string $title The title of the image.
531 * @param string $description The description of the image.
532 * @param string $caption The caption of the image.
533 * @param string $alt The alt text of the image.
534 * @return int The attachment ID of the image.
535 */
536 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 = [] ) {
537 return $this->imageService->add_image_from_url( $url, $filename, $title, $description, $caption, $alt, $attachedPost, $post_status, $post_type, $ai_metadata );
538 }
539 #endregion
540
541 #region Context-Related Helpers
542 public function retrieve_context( $params, $query, $streamCallback = null ) {
543 $contextMaxLength = $params['contextMaxLength'] ?? $this->get_option( 'context_max_length', 4096 );
544 $embeddingsEnvId = $params['embeddingsEnvId'] ?? null;
545
546 $context = apply_filters( 'mwai_context_search', [], $query, [
547 'embeddingsEnvId' => $embeddingsEnvId,
548 'streamCallback' => $streamCallback
549 ] );
550
551 // Emit embeddings event if streaming and context was found
552 if ( $streamCallback && !empty( $context ) ) {
553 $count = 0;
554 if ( isset( $context['embeddings'] ) && is_array( $context['embeddings'] ) ) {
555 $count = count( $context['embeddings'] );
556 }
557 else if ( isset( $context['content'] ) ) {
558 $count = 1;
559 }
560 if ( $count > 0 ) {
561 $event = Meow_MWAI_Event::embeddings( $count );
562 $streamCallback( $event );
563 }
564 }
565
566 if ( empty( $context ) ) {
567 return null;
568 }
569 else if ( !isset( $context['content'] ) ) {
570 Meow_MWAI_Logging::warn( 'A context without content was returned.' );
571 return null;
572 }
573 $context['content'] = $this->clean_sentences( $context['content'], $contextMaxLength );
574 $context['length'] = strlen( $context['content'] );
575 return $context;
576 }
577
578 /**
579 * Wrap context content with framing instructions for AI.
580 * This helps the AI understand that the context is background knowledge.
581 *
582 * @param string $context The raw context content.
583 * @return string The framed context with instructions.
584 */
585 public function frame_context( $context ) {
586 if ( empty( $context ) ) {
587 return $context;
588 }
589 $framing = 'The following is your knowledge about this topic. ' .
590 'Use it naturally when relevant - never mention or acknowledge that this information was provided to you. ' .
591 "If the user's message is unrelated (e.g., greetings, thanks), respond naturally without using it.";
592 $framing = apply_filters( 'mwai_context_framing', $framing, $context );
593 return $framing . "\n\n---\n" . $context . "\n---";
594 }
595 #endregion
596
597 #region Users/Sessions Helpers
598
599 public function get_nonce( $force = false ) {
600 return $this->sessionService->get_nonce( $force );
601 }
602
603 // This is a bit hacky, but chatId needs to be retrieved or generated.
604 // Maybe we can clean this up later.
605 public function fix_chat_id( $query, $params ) {
606 return $this->sessionService->fix_chat_id( $query, $params );
607 }
608
609 public function get_session_id() {
610 return $this->sessionService->get_session_id();
611 }
612
613 /**
614 * Get the Response ID Manager service
615 */
616 public function get_response_id_manager() {
617 return $this->responseIdManager;
618 }
619
620 /**
621 * Get the Message Builder service
622 */
623 public function get_message_builder() {
624 return $this->messageBuilder;
625 }
626
627 // Get the UserID from the data, or from the current user
628 public function get_user_id( $data = null ) {
629 return $this->sessionService->get_user_id( $data );
630 }
631
632 public function get_session_user_id() {
633 return $this->sessionService->get_session_user_id();
634 }
635
636 public function get_admin_user() {
637 return $this->sessionService->get_admin_user();
638 }
639
640 public function get_user_data() {
641 return $this->sessionService->get_user_data();
642 }
643
644 public function get_ip_address( $force = false ) {
645 return $this->sessionService->get_ip_address( $force );
646 }
647
648 #endregion
649
650 #region Sanitization
651 public function sanitize_sort(
652 &$sort,
653 $default_accessor = 'created',
654 $default_order = 'DESC',
655 $allowed_columns = [ 'created', 'updated', 'name', 'id', 'time', 'units', 'price' ]
656 ) {
657
658 // Ensure $sort is an array
659 if ( !is_array( $sort ) ) {
660 $sort = [ 'accessor' => $default_accessor, 'by' => $default_order ];
661 }
662 // Extract and sanitize the accessor
663 $sort_accessor = isset( $sort['accessor'] ) ? $sort['accessor'] : $default_accessor;
664 if ( !in_array( $sort_accessor, $allowed_columns ) ) {
665 Meow_MWAI_Logging::error( "This sort accessor is not allowed ($sort_accessor)." );
666 $sort_accessor = $default_accessor;
667 }
668 // Extract and sanitize the sort order
669 $sort_by = isset( $sort['by'] ) ? strtoupper( $sort['by'] ) : $default_order;
670 if ( $sort_by !== 'ASC' && $sort_by !== 'DESC' ) {
671 Meow_MWAI_Logging::error( "This sort order is not allowed ($sort_by)." );
672 $sort_by = $default_order;
673 }
674 // Update the sort array with sanitized values
675 $sort['accessor'] = $sort_accessor;
676 $sort['by'] = $sort_by;
677 }
678 #endregion
679
680 #region Other Helpers
681 public function safe_strlen( $string, $encoding = 'UTF-8' ) {
682 if ( function_exists( 'mb_strlen' ) ) {
683 return mb_strlen( $string, $encoding );
684 }
685 else {
686 // Fallback implementation for environments without mbstring extension
687 return preg_match_all( '/./u', $string, $matches );
688 }
689 }
690
691 public function check_rest_nonce( $request ) {
692 // REST NONCE VERIFICATION:
693 // Validates nonce from X-WP-Nonce header using WordPress nonce system.
694 // Returns: false (invalid), 1 (0-12 hours old), or 2 (12-24 hours old)
695 // WordPress REST permission callbacks accept any truthy value as success.
696 // The filter allows custom authorization logic if needed.
697 $nonce = $request->get_header( 'X-WP-Nonce' );
698 $rest_nonce = wp_verify_nonce( $nonce, 'wp_rest' );
699 return apply_filters( 'mwai_rest_authorized', $rest_nonce, $request );
700 }
701
702 public function get_random_id( $length = 8, $excludeIds = [] ) {
703 $characters = '0123456789abcdefghijklmnopqrstuvwxyz';
704 $charactersLength = strlen( $characters );
705 $randomId = '';
706 for ( $i = 0; $i < $length; $i++ ) {
707 $randomId .= $characters[ mt_rand( 0, $charactersLength - 1 ) ];
708 }
709 if ( in_array( $randomId, $excludeIds ) ) {
710 return $this->get_random_id( $length, $excludeIds );
711 }
712 return $randomId;
713 }
714
715 public function is_url( $url ) {
716 return strpos( $url, 'http' ) === 0 ? true : false;
717 }
718
719 public function get_post_types() {
720 $excluded = [ 'attachment', 'revision', 'nav_menu_item' ];
721 $post_types = [];
722 $types = get_post_types( [], 'objects' );
723
724 // Let's get the Post Types that are enabled for Embeddings Sync
725 $embeddingsSettings = $this->get_option( 'embeddings' );
726 $syncPostTypes = isset( $embeddingsSettings['syncPostTypes'] ) ? $embeddingsSettings['syncPostTypes'] : [];
727
728 foreach ( $types as $type ) {
729 $forced = in_array( $type->name, $syncPostTypes );
730 // Should not be excluded.
731 if ( !$forced && in_array( $type->name, $excluded ) ) {
732 continue;
733 }
734 // Should be public.
735 if ( !$forced && !$type->public ) {
736 continue;
737 }
738 $post_types[] = [
739 'name' => $type->labels->name,
740 'type' => $type->name,
741 ];
742 }
743
744 // Let's get the Post Types that are enabled for Embeddings Sync
745 $embeddingsSettings = $this->get_option( 'embeddings' );
746 $syncPostTypes = isset( $embeddingsSettings['syncPostTypes'] ) ? $embeddingsSettings['syncPostTypes'] : [];
747
748 return $post_types;
749 }
750
751 public function get_post( $post ) {
752 if ( is_numeric( $post ) ) {
753 // Force fresh retrieval to avoid cache issues
754 clean_post_cache( $post );
755 $post = get_post( $post );
756 }
757 if ( is_object( $post ) ) {
758 $post = (array) $post;
759 }
760 if ( !is_array( $post ) ) {
761 return null;
762 }
763 $language = $this->get_post_language( $post['ID'] );
764 $content = $this->get_post_content( $post['ID'] );
765 $title = $post['post_title'];
766 $excerpt = $post['post_excerpt'];
767 $url = get_permalink( $post['ID'] );
768 $checksum = wp_hash( $content . $title . $url );
769
770 return [
771 'postId' => (int) $post['ID'],
772 'title' => $title,
773 'content' => $content,
774 'excerpt' => $excerpt,
775 'url' => $url,
776 'language' => $language ?? 'english',
777 'checksum' => $checksum,
778 ];
779 }
780
781 /**
782 * Format a date/time string into a human-readable format
783 * @param string $date_string The date string to format
784 * @return string Formatted date (e.g., "Just now", "5m ago", "2h ago", "3d ago", "Jan 20th")
785 */
786 public function format_discussion_date( $date_string ) {
787 $date = strtotime( $date_string );
788 $now = time();
789 $diff = $now - $date;
790
791 // Less than a minute
792 if ( $diff < 60 ) {
793 return 'Just now';
794 }
795
796 // Less than an hour
797 if ( $diff < 3600 ) {
798 $minutes = floor( $diff / 60 );
799 return $minutes . 'm ago';
800 }
801
802 // Less than a day
803 if ( $diff < 86400 ) {
804 $hours = floor( $diff / 3600 );
805 return $hours . 'h ago';
806 }
807
808 // Less than a week
809 if ( $diff < 604800 ) {
810 $days = floor( $diff / 86400 );
811 return $days . 'd ago';
812 }
813
814 // Format as date
815 $is_current_year = date( 'Y', $date ) === date( 'Y', $now );
816 if ( $is_current_year ) {
817 return date( 'M jS', $date );
818 }
819 else {
820 return date( 'M jS, Y', $date );
821 }
822 }
823 #endregion
824
825 #region Usage & Costs
826
827 // Quick and dirty token estimation
828 // Let's keep this synchronized with Helpers in JS
829 public static function estimate_tokens( $text = '', $model = null ): int {
830 global $mwai_core;
831 if ( $mwai_core && $mwai_core->usageStatsService ) {
832 return $mwai_core->usageStatsService->estimate_tokens( $text, $model );
833 }
834 // Fallback to original implementation if service not available
835 if ( !is_string( $text ) ) {
836 $text = is_array( $text ) || is_object( $text ) ? json_encode( $text ) : (string) $text;
837 }
838 $averageTokenLength = 4;
839 $words = preg_split( '/\s+/', trim( $text ) );
840 $tokenCount = 0;
841 foreach ( $words as $word ) {
842 $tokenCount += ceil( strlen( $word ) / $averageTokenLength );
843 }
844 return apply_filters( 'mwai_estimate_tokens', $tokenCount, $text );
845 }
846
847 public function record_tokens_usage( $model, $in_tokens, $out_tokens = 0, $returned_price = null ) {
848 return $this->usageStatsService->record_tokens_usage( $model, $in_tokens, $out_tokens, $returned_price );
849 }
850
851 public function record_audio_usage( $model, $seconds ) {
852 return $this->usageStatsService->record_audio_usage( $model, $seconds );
853 }
854
855 public function record_images_usage( $model, $resolution, $images ) {
856 return $this->usageStatsService->record_images_usage( $model, $resolution, $images );
857 }
858
859 public function record_videos_usage( $model, $resolution, $seconds ) {
860 return $this->usageStatsService->record_videos_usage( $model, $resolution, $seconds );
861 }
862
863 #endregion
864
865 #region Streaming
866 public function stream_push( $data, $query = null ) {
867 try {
868 // Handle new Event objects
869 if ( is_object( $data ) && method_exists( $data, 'to_array' ) ) {
870 $data = $data->to_array();
871 }
872
873 $data = apply_filters( 'mwai_stream_push', $data, $query );
874 $out = 'data: ' . json_encode( $data );
875 echo $out;
876 echo "\n\n";
877 if ( ob_get_level() > 0 ) {
878 ob_end_flush();
879 }
880 flush();
881 }
882 catch ( Exception $e ) {
883 // Send error as proper SSE error event
884 $errorMessage = 'Oops! Something went wrong on the server. Please try again, and if you are the site developer, check the PHP Error Logs for details.';
885 error_log( '[AI Engine Stream Error] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
886
887 $errorData = [
888 'type' => 'error',
889 'data' => $errorMessage
890 ];
891 $out = 'data: ' . json_encode( $errorData );
892 echo $out;
893 echo "\n\n";
894 if ( ob_get_level() > 0 ) {
895 ob_end_flush();
896 }
897 flush();
898
899 // Stop execution after sending error
900 die();
901 }
902 }
903 #endregion
904
905 #region Options
906 public function get_themes() {
907 $themes = get_option( $this->themes_option_name, [] );
908 $themes = empty( $themes ) ? [] : $themes;
909
910 $internalThemes = [
911 'chatgpt' => [
912 'type' => 'internal', 'name' => 'ChatGPT', 'themeId' => 'chatgpt',
913 'settings' => [], 'style' => ''
914 ],
915 'messages' => [
916 'type' => 'internal', 'name' => 'Messages', 'themeId' => 'messages',
917 'settings' => [], 'style' => ''
918 ],
919 'timeless' => [
920 'type' => 'internal', 'name' => 'Timeless', 'themeId' => 'timeless',
921 'settings' => [], 'style' => ''
922 ],
923 'foundation' => [
924 'type' => 'internal', 'name' => 'Foundation', 'themeId' => 'foundation',
925 'settings' => [], 'style' => ''
926 ],
927 ];
928 $customThemes = [];
929 foreach ( $themes as $theme ) {
930 if ( isset( $internalThemes[$theme['themeId']] ) ) {
931 $internalThemes[$theme['themeId']] = $theme;
932 continue;
933 }
934 $customThemes[] = $theme;
935 }
936 return array_merge( array_values( $internalThemes ), $customThemes );
937 }
938
939 public function update_themes( $themes ) {
940 update_option( $this->themes_option_name, $themes );
941 return $themes;
942 }
943
944 public function get_chatbots() {
945 $chatbots = get_option( $this->chatbots_option_name, [] );
946 $hasChanges = false;
947 if ( empty( $chatbots ) ) {
948 $chatbots = [ array_merge( MWAI_CHATBOT_DEFAULT_PARAMS, ['name' => 'Default', 'botId' => 'default' ] ) ];
949 }
950 $hasDefault = false;
951 foreach ( $chatbots as &$chatbot ) {
952 if ( $chatbot['botId'] === 'default' ) {
953 $hasDefault = true;
954 }
955 foreach ( MWAI_CHATBOT_DEFAULT_PARAMS as $key => $value ) {
956 // Use default value if not set.
957 if ( !isset( $chatbot[$key] ) ) {
958 $chatbot[$key] = $value;
959 }
960 }
961
962 /*
963 This is the best section to rename fields.
964 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).
965 */
966
967 // Migrate old file upload params to new unified system
968 if ( !isset( $chatbot['fileUpload'] ) ) {
969 // Set fileUpload based on old params (imageUpload, fileUploads, or vision checkbox)
970 $chatbot['fileUpload'] = !empty( $chatbot['imageUpload'] ) || ( isset( $chatbot['fileUploads'] ) && $chatbot['fileUploads'] > 0 );
971 $hasChanges = true;
972 }
973
974 // Ensure maxUploads is set
975 if ( !isset( $chatbot['maxUploads'] ) ) {
976 if ( isset( $chatbot['fileUploads'] ) && $chatbot['fileUploads'] > 0 ) {
977 $chatbot['maxUploads'] = $chatbot['fileUploads'];
978 }
979 else {
980 $chatbot['maxUploads'] = 1; // Default to 1 file
981 }
982 $hasChanges = true;
983 }
984
985 // Sync fileUploads with fileUpload and maxUploads for consistency
986 if ( isset( $chatbot['fileUpload'] ) ) {
987 $chatbot['fileUploads'] = $chatbot['fileUpload'] ? $chatbot['maxUploads'] : 0;
988 $chatbot['multiUpload'] = $chatbot['fileUpload'] && $chatbot['maxUploads'] > 1;
989 $chatbot['imageUpload'] = $chatbot['fileUpload']; // Keep imageUpload in sync
990 }
991
992 // Migration: DALL-E was removed (deprecated by OpenAI). Move chatbots to gpt-image-1.5.
993 // TODO: Remove after 2027-04 (1 year after the shutdown on 2026-05-12).
994 if ( isset( $chatbot['model'] )
995 && in_array( $chatbot['model'], [ 'dall-e', 'dall-e-2', 'dall-e-3', 'dall-e-3-hd' ], true ) ) {
996 $chatbot['model'] = MWAI_FALLBACK_MODEL_IMAGES;
997 $hasChanges = true;
998 }
999
1000 // if ( isset( $chatbot['context'] ) ) {
1001 // $chatbot['instructions'] = $chatbot['context'];
1002 // unset( $chatbot['context'] );
1003 // $hasChanges = true;
1004 // }
1005 }
1006 if ( !$hasDefault ) {
1007 $defaultBot = array_merge( MWAI_CHATBOT_DEFAULT_PARAMS, ['name' => 'Default', 'botId' => 'default' ] );
1008 array_unshift( $chatbots, $defaultBot );
1009 $hasChanges = true;
1010 }
1011 if ( $hasChanges ) {
1012 update_option( $this->chatbots_option_name, $chatbots );
1013 }
1014 return $chatbots;
1015 }
1016
1017 public function get_chatbot( $botId ) {
1018 $chatbots = $this->get_chatbots();
1019 foreach ( $chatbots as $chatbot ) {
1020 if ( $chatbot['botId'] === (string) $botId ) {
1021 return $chatbot;
1022 }
1023 }
1024 return null;
1025 }
1026
1027 public function get_embeddings_env( $envId ) {
1028 return $this->modelEnvironmentService->get_embeddings_env( $envId );
1029 }
1030
1031 public function get_ai_env( $envId ) {
1032 return $this->modelEnvironmentService->get_ai_env( $envId );
1033 }
1034
1035 public function get_assistant( $envId, $assistantId ) {
1036 return $this->modelEnvironmentService->get_assistant( $envId, $assistantId );
1037 }
1038
1039 public function get_theme( $themeId ) {
1040 $themes = $this->get_themes();
1041 foreach ( $themes as $theme ) {
1042 if ( $theme['themeId'] === $themeId ) {
1043 // Append custom CSS to theme data for frontend rendering (check for non-empty trimmed string)
1044 if ( isset( $theme['settings']['customCSS'] ) && trim( $theme['settings']['customCSS'] ) !== '' ) {
1045 $customCSS = $theme['settings']['customCSS'];
1046
1047 // Add theme class prefix to all CSS rules for proper scoping
1048 $themeClass = '.mwai-' . $themeId . '-theme';
1049 $lines = explode( "\n", $customCSS );
1050 $processedCSS = '';
1051 $inRule = false;
1052
1053 foreach ( $lines as $line ) {
1054 $trimmedLine = trim( $line );
1055
1056 // Skip empty lines and comments
1057 if ( empty( $trimmedLine ) || strpos( $trimmedLine, '/*' ) === 0 ) {
1058 $processedCSS .= $line . "\n";
1059 continue;
1060 }
1061
1062 // If line contains a selector (has { but not })
1063 if ( strpos( $line, '{' ) !== false && strpos( $line, '}' ) === false ) {
1064 // Extract selector and the rest
1065 $parts = explode( '{', $line, 2 );
1066 $selector = trim( $parts[0] );
1067
1068 // Add theme class prefix if not already present
1069 if ( strpos( $selector, $themeClass ) !== 0 ) {
1070 // Handle multiple selectors separated by comma
1071 $selectors = explode( ',', $selector );
1072 $prefixedSelectors = array_map( function ( $sel ) use ( $themeClass ) {
1073 $sel = trim( $sel );
1074 // Don't prefix if it's a keyframe or similar
1075 if ( strpos( $sel, '@' ) === 0 || strpos( $sel, 'from' ) === 0 || strpos( $sel, 'to' ) === 0 || preg_match( '/^\d+%/', $sel ) ) {
1076 return $sel;
1077 }
1078 return $themeClass . ' ' . $sel;
1079 }, $selectors );
1080 $selector = implode( ', ', $prefixedSelectors );
1081 }
1082
1083 $processedCSS .= $selector . ' {' . ( isset( $parts[1] ) ? $parts[1] : '' ) . "\n";
1084 $inRule = true;
1085 }
1086 else {
1087 $processedCSS .= $line . "\n";
1088 }
1089 }
1090
1091 $customCSS = $processedCSS;
1092
1093 // For custom themes (type: 'css'), append to style property
1094 if ( $theme['type'] === 'css' ) {
1095 $theme['style'] = ( $theme['style'] ?? '' ) . "\n\n/* Custom CSS */\n" . $customCSS;
1096 }
1097 // For internal themes, add customCSS as a separate property
1098 else {
1099 $theme['customCSS'] = $customCSS;
1100 }
1101 }
1102
1103 // Add CSS URL for cross-site and dynamic loading support
1104 // Internal themes can use physical file, custom themes use REST endpoint
1105 $theme_type = $theme['type'] ?? 'internal';
1106 if ( $theme_type === 'internal' ) {
1107 $theme['cssUrl'] = MWAI_URL . 'themes/' . $themeId . '.css';
1108 }
1109 else {
1110 // Custom themes use REST endpoint (requires Cross-Site module to be enabled)
1111 $theme['cssUrl'] = get_rest_url( null, 'mwai-ui/v1/cross-site/theme-css' ) . '?themeId=' . $themeId;
1112 }
1113
1114 return $theme;
1115 }
1116 }
1117 return null;
1118 }
1119
1120 public function update_chatbots( $chatbots ) {
1121 $htmlFields = [ 'instructions', 'textCompliance', 'aiName', 'userName', 'startSentence' ];
1122 $keepLineReturnsFields = [ 'instructions' ];
1123 $whiteSpacedFields = [ 'context' ];
1124 // Boolean fields that need proper conversion
1125 $booleanFields = [ 'window', 'copyButton', 'pdfButton', 'fullscreen', 'localMemory', 'iconBubble', 'centerOpen',
1126 'imageUpload', 'fileUpload', 'multiUpload', 'fileSearch', 'contentAware', 'aiAvatar', 'userAvatar', 'guestAvatar' ];
1127 foreach ( $chatbots as &$chatbot ) {
1128 foreach ( $chatbot as $key => &$value ) {
1129 if ( in_array( $key, $htmlFields ) ) {
1130 $value = wp_kses_post( $value );
1131 }
1132 else if ( in_array( $key, $whiteSpacedFields ) ) {
1133 $value = sanitize_textarea_field( $value );
1134 }
1135 else if ( in_array( $key, $booleanFields ) ) {
1136 // Convert various representations to boolean
1137 if ( is_bool( $value ) ) {
1138 // Already boolean, keep as is
1139 }
1140 else if ( $value === 1 || $value === '1' || $value === true || $value === 'true' || $value === 'yes' ) {
1141 // These are true values
1142 $value = true;
1143 }
1144 else if ( $value === 0 || $value === '0' || $value === false || $value === 'false' || $value === 'no' || $value === '' || $value === null ) {
1145 // These are false values
1146 $value = false;
1147 }
1148 else {
1149 // Default to checking if not empty
1150 $value = !empty( $value );
1151 }
1152 }
1153 else if ( $key === 'functions' ) {
1154 $functions = [];
1155 foreach ( $value as $function ) {
1156 if ( isset( $function['id'] ) && isset( $function['type'] ) ) {
1157 $functions[] = [
1158 'id' => sanitize_text_field( $function['id'] ),
1159 'type' => sanitize_text_field( $function['type'] ),
1160 ];
1161 }
1162 }
1163 $value = $functions;
1164 }
1165 else if ( $key === 'mcpServers' ) {
1166 $mcpServers = [];
1167 foreach ( $value as $server ) {
1168 if ( isset( $server['id'] ) ) {
1169 $mcpServers[] = [
1170 'id' => sanitize_text_field( $server['id'] ),
1171 ];
1172 }
1173 }
1174 $value = $mcpServers;
1175 }
1176 else if ( $key === 'tools' ) {
1177 // Sanitize tools array (web_search, image_generation, thinking, etc)
1178 $tools = [];
1179 if ( is_array( $value ) ) {
1180 foreach ( $value as $tool ) {
1181 $sanitized_tool = sanitize_text_field( $tool );
1182 if ( in_array( $sanitized_tool, ['web_search', 'image_generation', 'thinking', 'code_interpreter'] ) ) {
1183 $tools[] = $sanitized_tool;
1184 }
1185 }
1186 }
1187 $value = $tools;
1188 }
1189 else if ( $key === 'crossSite' ) {
1190 // Handle crossSite object
1191 $crossSite = [
1192 'enabled' => isset( $value['enabled'] ) ? (bool) $value['enabled'] : false,
1193 'allowedDomains' => []
1194 ];
1195 if ( isset( $value['allowedDomains'] ) && is_array( $value['allowedDomains'] ) ) {
1196 foreach ( $value['allowedDomains'] as $domain ) {
1197 $sanitized_domain = sanitize_text_field( $domain );
1198 if ( !empty( $sanitized_domain ) ) {
1199 $crossSite['allowedDomains'][] = $sanitized_domain;
1200 }
1201 }
1202 }
1203 $value = $crossSite;
1204 }
1205 else {
1206 if ( in_array( $key, $keepLineReturnsFields ) ) {
1207 $value = preg_replace( '/\r\n/', '[==LINE_RETURN==]', $value );
1208 $value = preg_replace( '/\n/', '[==LINE_RETURN==]', $value );
1209 }
1210 $value = sanitize_text_field( $value );
1211 if ( in_array( $key, $keepLineReturnsFields ) ) {
1212 $value = preg_replace( '/\[==LINE_RETURN==\]/', "\n", $value );
1213 }
1214 }
1215 }
1216
1217 // Sync upload params to ensure consistency
1218 // fileUpload is the master control - respect its value
1219 $fileUploadEnabled = !empty( $chatbot['fileUpload'] ) || !empty( $chatbot['imageUpload'] );
1220 $maxFiles = isset( $chatbot['maxUploads'] ) && $chatbot['maxUploads'] > 0 ? (int) $chatbot['maxUploads'] : 1;
1221
1222 $chatbot['imageUpload'] = $fileUploadEnabled;
1223 $chatbot['fileUploads'] = $fileUploadEnabled ? $maxFiles : 0;
1224 $chatbot['multiUpload'] = $fileUploadEnabled && $maxFiles > 1;
1225 }
1226 if ( !update_option( $this->chatbots_option_name, $chatbots ) ) {
1227 Meow_MWAI_Logging::warn( 'Could not update chatbots.' );
1228 $chatbots = get_option( $this->chatbots_option_name, [] );
1229 return $chatbots;
1230 }
1231 return $chatbots;
1232 }
1233
1234 public function populate_dynamic_options( $options ) {
1235 static $populating = false;
1236
1237 // Prevent infinite recursion
1238 if ( $populating ) {
1239 return $options;
1240 }
1241
1242 $populating = true;
1243
1244 // Languages - use custom languages as the complete list
1245 $custom_languages = isset( $options['custom_languages'] ) && !empty( $options['custom_languages'] )
1246 ? $options['custom_languages']
1247 : [];
1248
1249 // If no custom languages defined, fall back to defaults
1250 if ( empty( $custom_languages ) ) {
1251 $options['languages'] = apply_filters( 'mwai_languages', MWAI_LANGUAGES );
1252 }
1253 else {
1254 // Process custom languages
1255 $processed_languages = [];
1256 foreach ( $custom_languages as $custom_lang ) {
1257 // Support formats like "Russian (ru)" or just "Russian"
1258 $custom_lang = trim( $custom_lang );
1259 if ( !empty( $custom_lang ) ) {
1260 // Check if language code is provided in parentheses
1261 if ( preg_match( '/^(.+)\s*\(([a-z]{2,3})\)$/i', $custom_lang, $matches ) ) {
1262 $lang_name = trim( $matches[1] );
1263 $lang_code = strtolower( trim( $matches[2] ) );
1264 $processed_languages[$lang_code] = $lang_name;
1265 }
1266 else {
1267 // No code provided, add as-is
1268 $processed_languages[] = $custom_lang;
1269 }
1270 }
1271 }
1272
1273 $options['languages'] = apply_filters( 'mwai_languages', $processed_languages );
1274 }
1275
1276 // Consolidate the Engines and their Models
1277 // PS: We should ABSOLUTELY AVOID to use ai_models directly (except for saving)
1278 // Engine Example: [ 'name' => 'Ollama', 'type' => 'ollama', inputs => ['apikey', 'endpoint'], models => [] ]
1279 $options['ai_engines'] = apply_filters( 'mwai_engines', MWAI_ENGINES );
1280 foreach ( $options['ai_engines'] as &$engine ) {
1281 if ( $engine['type'] === 'openai' ) {
1282 $engine['models'] = apply_filters(
1283 'mwai_openai_models',
1284 Meow_MWAI_Engines_OpenAI::get_models_static()
1285 );
1286 }
1287 else if ( $engine['type'] === 'anthropic' ) {
1288 $engine['models'] = apply_filters(
1289 'mwai_anthropic_models',
1290 Meow_MWAI_Engines_Anthropic::get_models_static()
1291 );
1292 }
1293 else if ( $engine['type'] === 'perplexity' ) {
1294 $engine['models'] = apply_filters(
1295 'mwai_perplexity_models',
1296 Meow_MWAI_Engines_Perplexity::get_models_static()
1297 );
1298 }
1299 else if ( $engine['type'] === 'mistral' ) {
1300 $engine['models'] = apply_filters(
1301 'mwai_mistral_models',
1302 Meow_MWAI_Engines_Mistral::get_models_static()
1303 );
1304 }
1305 else if ( $engine['type'] === 'xai' ) {
1306 // Static fallback covers the case where dynamic model fetch failed (e.g. no credits
1307 // on the xAI account). Dynamically fetched models override this list when available.
1308 $engine['models'] = apply_filters(
1309 'mwai_xai_models',
1310 Meow_MWAI_Engines_XAI::get_models_static()
1311 );
1312 }
1313 else {
1314 $engine['models'] = [];
1315 foreach ( $options['ai_models'] as $model ) {
1316 if ( $model['type'] === $engine['type'] ) {
1317 $engine['models'][] = $model;
1318 }
1319 }
1320 }
1321 }
1322
1323 // Functions via Code Engine (or custom code)
1324 $json = [];
1325 $functions = apply_filters( 'mwai_functions_list', [] );
1326 foreach ( $functions as $function ) {
1327 if ( $function->type === 'editor-assistant' ) {
1328 continue;
1329 }
1330 $json[] = Meow_MWAI_Query_Function::toJson( $function );
1331 }
1332 $options['functions'] = $json;
1333
1334 // Addons
1335 $options['addons'] = apply_filters( 'mwai_addons', [
1336 [
1337 'slug' => 'mwai-notifications',
1338 'name' => 'Notifications',
1339 'description' => 'Get real-time alerts for new discussions in your chatbot, so you never miss a chance to engage.',
1340 'install_url' => 'https://meowapps.com/products/mwai-notifications/',
1341 'settings_url' => null,
1342 'stars' => 4,
1343 'enabled' => false
1344 ],
1345 [
1346 'slug' => 'mwai-ollama',
1347 'name' => 'Ollama',
1348 'description' => 'Leverage local LLM integration through Ollama; refresh and use your own models for a flexible, cost-free approach.',
1349 'install_url' => 'https://meowapps.com/products/mwai-ollama/',
1350 'settings_url' => null,
1351 'stars' => 3,
1352 'enabled' => false
1353 ],
1354 [
1355 'slug' => 'mwai-deepseek',
1356 'name' => 'DeepSeek',
1357 'description' => 'Support for DeepSeek, a Chinese AI company that provides extremely powerful LLM models.',
1358 'install_url' => 'https://meowapps.com/products/deepseek/',
1359 'settings_url' => null,
1360 'stars' => 3,
1361 'enabled' => false
1362 ],
1363 [
1364 'slug' => 'mwai-websearch',
1365 'name' => 'Web Search',
1366 'description' => 'Enhance chatbot responses by pulling context from Google and Tavily, delivering more accurate answers.',
1367 'install_url' => 'https://meowapps.com/products/mwai-websearch/',
1368 'settings_url' => null,
1369 'stars' => 5,
1370 'enabled' => false
1371 ],
1372 [
1373 'slug' => 'mwai-better-links',
1374 'name' => 'Better Links',
1375 'description' => 'Validate internal and external links and map specific terms to custom URLs, ensuring smoother navigation and references.',
1376 'install_url' => 'https://meowapps.com/products/mwai-better-links/',
1377 'settings_url' => null,
1378 'stars' => 3,
1379 'enabled' => false
1380 ],
1381 [
1382 'slug' => 'mwai-woo-basics',
1383 'name' => 'Woo Basics',
1384 'description' => 'Access essential WooCommerce data so your chatbot can understand products, orders, and more for a richer shopping experience.',
1385 'install_url' => 'https://meowapps.com/products/mwai-woo-basics/',
1386 'settings_url' => null,
1387 'stars' => 2,
1388 'enabled' => false
1389 ],
1390 [
1391 'slug' => 'mwai-quick-actions',
1392 'name' => 'Quick Actions',
1393 'description' => 'Enable dynamic quick actions at chat start or during events, helping users find what they need faster.',
1394 'install_url' => 'https://meowapps.com/products/mwai-quick-actions/',
1395 'settings_url' => null,
1396 'stars' => 3,
1397 'enabled' => false
1398 ],
1399 [
1400 'slug' => 'mwai-content-parser',
1401 'name' => 'Content Parser',
1402 'description' => 'Parse complex website content, including ACF fields and page builders, for more precise embeddings and knowledge retrieval.',
1403 'install_url' => 'https://meowapps.com/products/mwai-content-parser/',
1404 'settings_url' => null,
1405 'stars' => 2,
1406 'enabled' => false
1407 ],
1408 [
1409 'slug' => 'mwai-visitor-form',
1410 'name' => 'Visitor Form',
1411 'description' => 'Add a customizable form triggered by specific events in your chatbot to collect key visitor information seamlessly.',
1412 'install_url' => 'https://meowapps.com/products/mwai-visitor-form/',
1413 'settings_url' => null,
1414 'stars' => 2,
1415 'enabled' => false
1416 ],
1417 [
1418 'slug' => 'mwai-dynamic-keys',
1419 'name' => 'Dynamic Keys',
1420 'description' => 'Rotate multiple API keys dynamically for any environment, balancing usage and ensuring smooth performance.',
1421 'install_url' => 'https://meowapps.com/products/mwai-dynamic-keys/',
1422 'settings_url' => null,
1423 'stars' => 1,
1424 'enabled' => false
1425 ],
1426 ] );
1427
1428 // Populate usage data from ai_usage to ai_models_usage for the frontend
1429 $ai_usage = $this->get_option( 'ai_usage', [] );
1430 $options['ai_models_usage'] = $ai_usage;
1431
1432 // Also include daily usage data
1433 $ai_usage_daily = $this->get_option( 'ai_usage_daily', [] );
1434 $options['ai_models_usage_daily'] = $ai_usage_daily;
1435
1436 $populating = false;
1437 return $options;
1438 }
1439
1440 public function get_all_options( $force = false, $sanitize = false ) {
1441 if ( $force || is_null( $this->options ) ) {
1442 $options = get_option( $this->option_name, [] );
1443 $init_mode = empty( $options );
1444 foreach ( MWAI_OPTIONS as $key => $value ) {
1445 if ( !isset( $options[$key] ) ) {
1446 $options[$key] = $value;
1447 }
1448 }
1449 $options['chatbot_defaults'] = MWAI_CHATBOT_DEFAULT_PARAMS;
1450 $options['default_limits'] = MWAI_LIMITS;
1451
1452 // Force sanitization if custom_languages is not set (migration)
1453 $needs_language_migration = !isset( $options['custom_languages'] ) || empty( $options['custom_languages'] );
1454
1455 if ( $sanitize || $init_mode || $needs_language_migration ) {
1456 $options = $this->sanitize_options( $options );
1457 }
1458 $this->options = $options;
1459 }
1460 $options = $this->populate_dynamic_options( $this->options );
1461 return $options;
1462 }
1463
1464 // Sanitize options when we update the plugin or perform some updates
1465 // if we change the structure of the options.
1466 public function sanitize_options( $options ) {
1467 $needs_update = false;
1468
1469 // Removing old options of options renaming should be done here, as it was done before.
1470 // Check version 2.6.8 for an example.
1471
1472 // Avoid the logs_path to be a PHP file.
1473 if ( isset( $options['logs_path'] ) ) {
1474 $logs_path = $options['logs_path'];
1475 if ( substr( $logs_path, -4 ) !== '.log' ) {
1476 $options['logs_path'] = '';
1477 $needs_update = true;
1478 }
1479 }
1480
1481 // The IDs for the embeddings environments are generated here.
1482 // TODO: We should handle this more gracefully via an option in the Embeddings Settings.
1483 $embeddings_default_exists = false;
1484 if ( isset( $options['embeddings_envs'] ) ) {
1485 foreach ( $options['embeddings_envs'] as &$env ) {
1486 if ( !isset( $env['id'] ) ) {
1487 $env['id'] = $this->get_random_id();
1488 $needs_update = true;
1489 }
1490 if ( $env['id'] === $options['embeddings_default_env'] ) {
1491 $embeddings_default_exists = true;
1492 }
1493 }
1494 }
1495 if ( !$embeddings_default_exists ) {
1496 $options['embeddings_default_env'] = $options['embeddings_envs'][0]['id'] ?? null;
1497 $needs_update = true;
1498 }
1499
1500 // The IDs for the AI environments are generated here.
1501 $allEnvIds = [];
1502 $ai_default_exists = false;
1503 // Allow empty string as valid "None" selection
1504 $ai_default_is_none = isset( $options['ai_default_env'] ) && $options['ai_default_env'] === '';
1505 if ( isset( $options['ai_envs'] ) ) {
1506 foreach ( $options['ai_envs'] as &$env ) {
1507 if ( !isset( $env['id'] ) ) {
1508 $env['id'] = $this->get_random_id();
1509 $needs_update = true;
1510 }
1511 if ( $env['id'] === $options['ai_default_env'] ) {
1512 $ai_default_exists = true;
1513 }
1514 $allEnvIds[] = $env['id'];
1515 }
1516 }
1517 if ( !$ai_default_exists && !$ai_default_is_none ) {
1518 $options['ai_default_env'] = $options['ai_envs'][0]['id'] ?? null;
1519 $needs_update = true;
1520 }
1521
1522 // The IDs for the MCP environments are generated here.
1523 if ( isset( $options['mcp_envs'] ) ) {
1524 foreach ( $options['mcp_envs'] as &$env ) {
1525 if ( !isset( $env['id'] ) ) {
1526 $env['id'] = $this->get_random_id();
1527 $needs_update = true;
1528 }
1529 }
1530 }
1531
1532 // Migration: DALL-E was removed (deprecated by OpenAI). Move users to gpt-image-1.5.
1533 // TODO: Remove after 2027-04 (1 year after the shutdown on 2026-05-12).
1534 if ( isset( $options['ai_images_default_model'] )
1535 && in_array( $options['ai_images_default_model'], [ 'dall-e', 'dall-e-2', 'dall-e-3', 'dall-e-3-hd' ], true ) ) {
1536 $options['ai_images_default_model'] = MWAI_FALLBACK_MODEL_IMAGES;
1537 $needs_update = true;
1538 }
1539
1540 // All the models with an envId that does not exist anymore are removed.
1541 if ( isset( $options['ai_models'] ) ) {
1542 $options['ai_models'] = array_values( array_filter(
1543 $options['ai_models'],
1544 function ( $model ) use ( $allEnvIds, &$needs_update ) {
1545 if ( isset( $model['envId'] ) && !in_array( $model['envId'], $allEnvIds ) ) {
1546 $needs_update = true;
1547 return false;
1548 }
1549 return true;
1550 }
1551 ) );
1552 }
1553
1554 // Migration: Populate custom_languages if empty for existing installations
1555 if ( !isset( $options['custom_languages'] ) || empty( $options['custom_languages'] ) ) {
1556 $options['custom_languages'] = [
1557 'English (en)',
1558 'German (de)',
1559 'French (fr)',
1560 'Spanish (es)',
1561 'Italian (it)',
1562 'Chinese (zh)',
1563 'Japanese (ja)',
1564 'Portuguese (pt)'
1565 ];
1566 $needs_update = true;
1567 }
1568
1569 if ( $needs_update ) {
1570 ksort( $options );
1571 update_option( $this->option_name, $options, false );
1572 }
1573
1574 return $options;
1575 }
1576
1577 public function update_options( $options ) {
1578 // update_option returns false both when update fails AND when value is unchanged
1579 // We just attempt the update and always return the current options
1580 // The frontend will see if the values actually changed
1581 update_option( $this->option_name, $options, false );
1582 $options = $this->get_all_options( true, true );
1583 return $options;
1584 }
1585
1586 public function update_option( $option, $value ) {
1587 $options = $this->get_all_options( true );
1588 $options[$option] = $value;
1589 return $this->update_options( $options );
1590 }
1591
1592 public function get_option( $option, $default = null ) {
1593 $options = $this->get_all_options();
1594 return $options[$option] ?? $default;
1595 }
1596
1597 public function update_ai_env( $env_id, $option, $value ) {
1598 $options = $this->get_all_options( true );
1599 foreach ( $options['ai_envs'] as &$env ) {
1600 if ( $env['id'] === $env_id ) {
1601 $env[$option] = $value;
1602 break;
1603 }
1604 }
1605 return $this->update_options( $options );
1606 }
1607
1608 public function get_engine_models( $engineType ) {
1609 // This method is called by engines with just a string type
1610 // We need to get the models differently
1611 $options = $this->get_all_options();
1612 $engines = $options['ai_envs'];
1613 $models = [];
1614
1615 // Find all models for this engine type
1616 foreach ( $engines as $engine ) {
1617 if ( $engine['type'] === $engineType ) {
1618 if ( isset( $engine['models'] ) ) {
1619 foreach ( $engine['models'] as $model ) {
1620 $models[] = $model;
1621 }
1622 }
1623 }
1624 }
1625
1626 // Also check custom models
1627 if ( isset( $options['ai_models'] ) ) {
1628 foreach ( $options['ai_models'] as $model ) {
1629 if ( $model['type'] === $engineType ) {
1630 $models[] = $model;
1631 }
1632 }
1633 }
1634
1635 return $models;
1636 }
1637
1638 public function reset_options() {
1639 delete_option( $this->themes_option_name );
1640 delete_option( $this->chatbots_option_name );
1641 delete_option( $this->option_name );
1642 return $this->get_all_options( true );
1643 }
1644 #endregion
1645
1646 #region Cron Tracking
1647 public function track_cron_start( $hook ) {
1648 // Set running transient (expires in 5 minutes as a safety measure)
1649 set_transient( 'mwai_cron_running_' . $hook, true, 300 );
1650 }
1651
1652 public function track_cron_end( $hook, $status = 'success', $error_message = '' ) {
1653 // Remove running transient
1654 delete_transient( 'mwai_cron_running_' . $hook );
1655
1656 // Get existing data
1657 $cron_data = get_transient( 'mwai_cron_last_run' ) ?: [];
1658
1659 // Update this cron's data - use time() for consistency
1660 $cron_data[$hook] = [
1661 'time' => time(),
1662 'status' => $status,
1663 'error' => $error_message
1664 ];
1665
1666 // Store for 7 days
1667 set_transient( 'mwai_cron_last_run', $cron_data, 7 * DAY_IN_SECONDS );
1668 }
1669 #endregion
1670 }
1671