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