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