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