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