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