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