PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / trunk
AI Engine – The Chatbot, AI Framework & MCP for WordPress vtrunk
3.7.3 3.7.2 3.7.1 3.7.0 3.6.9 3.6.8 3.6.7 3.6.6 3.6.4 3.6.5 3.6.3 3.6.2 3.6.1 3.6.0 3.5.9 3.5.8 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 / rest.php
ai-engine / classes Last commit date
data 1 year ago engines 6 days ago exceptions 4 weeks ago modules 6 days ago query 13 hours ago services 3 weeks ago admin.php 1 week ago api.php 3 weeks ago core.php 13 hours ago discussion.php 1 year ago event.php 1 year ago init.php 1 week ago logging.php 1 year ago reply.php 4 weeks ago rest.php 13 hours ago
rest.php
3107 lines
1 <?php
2
3 class Meow_MWAI_Rest {
4 private $core = null;
5 private $namespace = 'mwai/v1';
6
7 public function __construct( $core ) {
8 $this->core = $core;
9 add_action( 'rest_api_init', [ $this, 'rest_init' ] );
10 }
11
12 /**
13 * Retrieve the message from the parameters and optionally sanitize it.
14 *
15 * @param array &$params The parameters array, passed by reference.
16 * @param bool $sanitize Whether to sanitize the message using sanitize_text_field.
17 * @return string The retrieved (and optionally sanitized) message.
18 */
19 public function retrieve_message( &$params, $sanitize = false ): string {
20 $message = $params['message'] ?? '';
21
22 if ( $sanitize ) {
23 $message = sanitize_text_field( $message );
24 }
25
26 return $message;
27 }
28
29 /**
30 * Helper method to create REST responses with automatic token refresh
31 *
32 * @param array $data The response data
33 * @param int $status HTTP status code
34 * @return WP_REST_Response
35 */
36 protected function create_rest_response( $data, $status = 200 ) {
37 // Always check if we need to provide a new nonce
38 $current_nonce = $this->core->get_nonce( true );
39 $request_nonce = isset( $_SERVER['HTTP_X_WP_NONCE'] ) ? $_SERVER['HTTP_X_WP_NONCE'] : null;
40
41 // Check if nonce is approaching expiration (WordPress nonces last 12-24 hours)
42 // We'll refresh if the nonce is older than 10 hours to be safe
43 $should_refresh = false;
44
45 if ( $request_nonce ) {
46 // Try to determine the age of the nonce
47 // WordPress uses a tick system where each tick is 12 hours
48 // If we're in the second half of the nonce's life, refresh it
49 $time = time();
50 $nonce_tick = wp_nonce_tick();
51
52 // Verify if the nonce is still valid but getting old
53 $verify = wp_verify_nonce( $request_nonce, 'wp_rest' );
54 if ( $verify === 2 ) {
55 // Nonce is valid but was generated 12-24 hours ago
56 $should_refresh = true;
57 // Log will be written when token is included in response
58 }
59 }
60
61 // If the nonce has changed or should be refreshed, include the new one
62 if ( $should_refresh || ( $request_nonce && $current_nonce !== $request_nonce ) ) {
63 $data['new_token'] = $current_nonce;
64
65 // Log if server debug mode is enabled
66 if ( $this->core->get_option( 'server_debug_mode' ) ) {
67 error_log( '[AI Engine] Token refresh: Nonce refreshed (12-24 hours old)' );
68 }
69 }
70
71 return new WP_REST_Response( $data, $status );
72 }
73
74 public function rest_init() {
75 try {
76 // Session Endpoint
77 register_rest_route( $this->namespace, '/start_session', [
78 'methods' => 'POST',
79 'permission_callback' => '__return_true', // Public endpoint for guest users
80 'callback' => [ $this, 'rest_start_session' ],
81 ] );
82
83 // Settings Endpoints
84 register_rest_route( $this->namespace, '/settings/update', [
85 'methods' => 'POST',
86 'permission_callback' => [ $this->core, 'can_access_settings' ],
87 'callback' => [ $this, 'rest_settings_update' ],
88 ] );
89 register_rest_route( $this->namespace, '/settings/options', [
90 'methods' => 'GET',
91 'permission_callback' => [ $this->core, 'can_access_settings' ],
92 'callback' => [ $this, 'rest_settings_list' ],
93 ] );
94 register_rest_route( $this->namespace, '/settings/reset', [
95 'methods' => 'POST',
96 'permission_callback' => [ $this->core, 'can_access_settings' ],
97 'callback' => [ $this, 'rest_settings_reset' ],
98 ] );
99 register_rest_route( $this->namespace, '/settings/chatbots', [
100 'methods' => ['GET', 'POST'],
101 'permission_callback' => [ $this->core, 'can_access_settings' ],
102 'callback' => [ $this, 'rest_settings_chatbots' ],
103 ] );
104 register_rest_route( $this->namespace, '/settings/themes', [
105 'methods' => ['GET', 'POST'],
106 'permission_callback' => [ $this->core, 'can_access_settings' ],
107 'callback' => [ $this, 'rest_settings_themes' ],
108 ] );
109
110 // System Endpoints
111 register_rest_route( $this->namespace, '/system/logs/list', [
112 'methods' => 'POST',
113 'permission_callback' => [ $this->core, 'can_access_settings' ],
114 'callback' => [ $this, 'rest_system_logs_list' ],
115 ] );
116 register_rest_route( $this->namespace, '/system/logs/delete', [
117 'methods' => 'POST',
118 'permission_callback' => [ $this->core, 'can_access_settings' ],
119 'callback' => [ $this, 'rest_system_logs_delete' ],
120 ] );
121 register_rest_route( $this->namespace, '/system/logs/meta', [
122 'methods' => 'POST',
123 'permission_callback' => [ $this->core, 'can_access_settings' ],
124 'callback' => [ $this, 'rest_system_logs_meta_get' ],
125 ] );
126 register_rest_route( $this->namespace, '/system/logs/activity', [
127 'methods' => 'POST',
128 'permission_callback' => [ $this->core, 'can_access_settings' ],
129 'callback' => [ $this, 'rest_system_logs_activity' ],
130 ] );
131 register_rest_route( $this->namespace, '/system/logs/activity_daily', [
132 'methods' => 'POST',
133 'permission_callback' => [ $this->core, 'can_access_settings' ],
134 'callback' => [ $this, 'rest_system_logs_activity_daily' ],
135 ] );
136 register_rest_route( $this->namespace, '/system/templates', [
137 'methods' => 'POST',
138 'permission_callback' => [ $this->core, 'can_access_features' ],
139 'callback' => [ $this, 'rest_system_templates_save' ],
140 ] );
141 register_rest_route( $this->namespace, '/system/templates', [
142 'methods' => 'GET',
143 'permission_callback' => [ $this->core, 'can_access_features' ],
144 'callback' => [ $this, 'rest_system_templates_get' ],
145 ] );
146
147 // AI Endpoints
148 register_rest_route( $this->namespace, '/ai/models', [
149 'methods' => 'POST',
150 'permission_callback' => [ $this->core, 'can_access_features' ],
151 'callback' => [ $this, 'rest_ai_models' ],
152 ] );
153 register_rest_route( $this->namespace, '/ai/test_connection', [
154 'methods' => 'POST',
155 'permission_callback' => [ $this->core, 'can_access_settings' ],
156 'callback' => [ $this, 'rest_ai_test_connection' ],
157 ] );
158 register_rest_route( $this->namespace, '/ai/completions', [
159 'methods' => 'POST',
160 'permission_callback' => [ $this->core, 'can_access_features' ],
161 'callback' => [ $this, 'rest_ai_completions' ],
162 ] );
163 register_rest_route( $this->namespace, '/ai/images', [
164 'methods' => 'POST',
165 'permission_callback' => [ $this->core, 'can_access_features' ],
166 'callback' => [ $this, 'rest_ai_images' ],
167 ] );
168 register_rest_route( $this->namespace, '/ai/image_edit', [
169 'methods' => 'POST',
170 'permission_callback' => [ $this->core, 'can_access_features' ],
171 'callback' => [ $this, 'rest_ai_image_edit' ],
172 ] );
173 register_rest_route( $this->namespace, '/ai/copilot', [
174 'methods' => 'POST',
175 'permission_callback' => [ $this->core, 'can_access_features' ],
176 'callback' => [ $this, 'rest_ai_copilot' ],
177 ] );
178
179 register_rest_route( $this->namespace, '/ai/magic_wand', [
180 'methods' => 'POST',
181 'callback' => [ $this, 'rest_ai_magic_wand' ],
182 'permission_callback' => [ $this->core, 'can_access_features' ],
183 ] );
184 register_rest_route( $this->namespace, '/ai/moderate', [
185 'methods' => 'POST',
186 'permission_callback' => [ $this->core, 'can_access_settings' ],
187 'callback' => [ $this, 'rest_ai_moderate' ],
188 ] );
189 register_rest_route( $this->namespace, '/ai/transcribe_audio', [
190 'methods' => 'POST',
191 'permission_callback' => [ $this->core, 'can_access_settings' ],
192 'callback' => [ $this, 'rest_ai_transcribe_audio' ],
193 ] );
194 register_rest_route( $this->namespace, '/ai/transcribe_image', [
195 'methods' => 'POST',
196 'permission_callback' => [ $this->core, 'can_access_settings' ],
197 'callback' => [ $this, 'rest_ai_transcribe_image' ],
198 ] );
199 register_rest_route( $this->namespace, '/ai/json', [
200 'methods' => 'POST',
201 'permission_callback' => [ $this->core, 'can_access_settings' ],
202 'callback' => [ $this, 'rest_ai_json' ],
203 ] );
204
205 // MCP Endpoints
206 register_rest_route( $this->namespace, '/mcp/functions', [
207 'methods' => 'GET',
208 'permission_callback' => [ $this->core, 'can_access_settings' ],
209 'callback' => [ $this, 'rest_mcp_functions' ],
210 ] );
211 register_rest_route( $this->namespace, '/mcp/self_test', [
212 'methods' => 'POST',
213 'permission_callback' => [ $this->core, 'can_access_settings' ],
214 'callback' => [ $this, 'rest_mcp_self_test' ],
215 ] );
216 register_rest_route( $this->namespace, '/system/mcp_logs/top_tools', [
217 'methods' => 'POST',
218 'permission_callback' => [ $this->core, 'can_access_settings' ],
219 'callback' => [ $this, 'rest_mcp_top_tools' ],
220 ] );
221
222 // Helpers Endpoints
223 register_rest_route( $this->namespace, '/helpers/update_post_title', [
224 'methods' => 'POST',
225 'permission_callback' => [ $this->core, 'can_access_features' ],
226 'callback' => [ $this, 'rest_helpers_update_title' ],
227 ] );
228 register_rest_route( $this->namespace, '/helpers/update_post_excerpt', [
229 'methods' => 'POST',
230 'permission_callback' => [ $this->core, 'can_access_features' ],
231 'callback' => [ $this, 'rest_helpers_update_excerpt' ],
232 ] );
233 register_rest_route( $this->namespace, '/helpers/create_post', [
234 'methods' => 'POST',
235 'permission_callback' => [ $this->core, 'can_access_features' ],
236 'callback' => [ $this, 'rest_helpers_create_post' ],
237 ] );
238 register_rest_route( $this->namespace, '/helpers/create_image', [
239 'methods' => 'POST',
240 'permission_callback' => [ $this->core, 'can_access_features' ],
241 'callback' => [ $this, 'rest_helpers_create_images' ],
242 ] );
243 register_rest_route( $this->namespace, '/helpers/generate_image_meta', [
244 'methods' => 'POST',
245 'permission_callback' => [ $this->core, 'can_access_features' ],
246 'callback' => [ $this, 'rest_helpers_generate_image_meta' ],
247 ] );
248 register_rest_route( $this->namespace, '/helpers/update_media_metadata', [
249 'methods' => 'POST',
250 'permission_callback' => [ $this->core, 'can_access_features' ],
251 'callback' => [ $this, 'rest_helpers_update_media_metadata' ],
252 ] );
253 register_rest_route( $this->namespace, '/helpers/create_video', [
254 'methods' => 'POST',
255 'permission_callback' => [ $this->core, 'can_access_features' ],
256 'callback' => [ $this, 'rest_helpers_create_video' ],
257 ] );
258 register_rest_route( $this->namespace, '/helpers/video_status', [
259 'methods' => 'POST',
260 'permission_callback' => [ $this->core, 'can_access_features' ],
261 'callback' => [ $this, 'rest_helpers_video_status' ],
262 ] );
263 register_rest_route( $this->namespace, '/helpers/download_video', [
264 'methods' => 'POST',
265 'permission_callback' => [ $this->core, 'can_access_features' ],
266 'callback' => [ $this, 'rest_helpers_download_video' ],
267 ] );
268 register_rest_route( $this->namespace, '/helpers/delete_video', [
269 'methods' => 'POST',
270 'permission_callback' => [ $this->core, 'can_access_features' ],
271 'callback' => [ $this, 'rest_helpers_delete_video' ],
272 ] );
273 register_rest_route( $this->namespace, '/helpers/save_video_to_library', [
274 'methods' => 'POST',
275 'permission_callback' => [ $this->core, 'can_access_features' ],
276 'callback' => [ $this, 'rest_helpers_save_video_to_library' ],
277 ] );
278 register_rest_route( $this->namespace, '/helpers/delete_video_from_library', [
279 'methods' => 'POST',
280 'permission_callback' => [ $this->core, 'can_access_features' ],
281 'callback' => [ $this, 'rest_helpers_delete_video_from_library' ],
282 ] );
283 register_rest_route( $this->namespace, '/helpers/list_draft_media', [
284 'methods' => 'GET',
285 'permission_callback' => [ $this->core, 'can_access_features' ],
286 'callback' => [ $this, 'rest_helpers_list_draft_media' ],
287 ] );
288 register_rest_route( $this->namespace, '/helpers/approve_media', [
289 'methods' => 'POST',
290 'permission_callback' => [ $this->core, 'can_access_features' ],
291 'callback' => [ $this, 'rest_helpers_approve_media' ],
292 ] );
293 register_rest_route( $this->namespace, '/helpers/reject_media', [
294 'methods' => 'POST',
295 'permission_callback' => [ $this->core, 'can_access_features' ],
296 'callback' => [ $this, 'rest_helpers_reject_media' ],
297 ] );
298 register_rest_route( $this->namespace, '/helpers/count_posts', [
299 'methods' => 'GET',
300 'permission_callback' => [ $this->core, 'can_access_features' ],
301 'callback' => [ $this, 'rest_helpers_count_posts' ],
302 ] );
303 register_rest_route( $this->namespace, '/helpers/posts_ids', [
304 'methods' => 'GET',
305 'permission_callback' => [ $this->core, 'can_access_features' ],
306 'callback' => [ $this, 'rest_helpers_posts_ids' ],
307 ] );
308 register_rest_route( $this->namespace, '/helpers/post_types', [
309 'methods' => 'GET',
310 'permission_callback' => [ $this->core, 'can_access_features' ],
311 'callback' => [ $this, 'rest_helpers_post_types' ],
312 ] );
313 register_rest_route( $this->namespace, '/helpers/post_content', [
314 'methods' => 'GET',
315 'permission_callback' => [ $this->core, 'can_access_features' ],
316 'callback' => [ $this, 'rest_helpers_post_content' ],
317 ] );
318 register_rest_route( $this->namespace, '/helpers/check_posts_content', [
319 'methods' => 'POST',
320 'permission_callback' => [ $this->core, 'can_access_features' ],
321 'callback' => [ $this, 'rest_helpers_check_posts_content' ],
322 ] );
323 register_rest_route( $this->namespace, '/helpers/run_tasks', [
324 'methods' => 'POST',
325 'permission_callback' => [ $this->core, 'can_access_features' ],
326 'callback' => [ $this, 'rest_helpers_run_tasks' ],
327 ] );
328 register_rest_route( $this->namespace, '/helpers/optimize_database', [
329 'methods' => 'POST',
330 'permission_callback' => [ $this->core, 'can_access_settings' ],
331 'callback' => [ $this, 'rest_helpers_optimize_database' ],
332 ] );
333 register_rest_route( $this->namespace, '/helpers/cron_events', [
334 'methods' => 'GET',
335 'permission_callback' => [ $this->core, 'can_access_features' ],
336 'callback' => [ $this, 'rest_helpers_cron_events' ],
337 ] );
338 register_rest_route( $this->namespace, '/helpers/run_cron', [
339 'methods' => 'POST',
340 'permission_callback' => [ $this->core, 'can_access_features' ],
341 'callback' => [ $this, 'rest_helpers_run_cron' ],
342 ] );
343
344 // OpenAI Endpoints
345 register_rest_route( $this->namespace, '/openai/files/list', [
346 'methods' => 'GET',
347 'permission_callback' => [ $this->core, 'can_access_settings' ],
348 'callback' => [ $this, 'rest_openai_files_get' ],
349 ] );
350 register_rest_route( $this->namespace, '/openai/files/upload', [
351 'methods' => 'POST',
352 'permission_callback' => [ $this->core, 'can_access_settings' ],
353 'callback' => [ $this, 'rest_openai_files_upload' ],
354 ] );
355 register_rest_route( $this->namespace, '/openai/files/delete', [
356 'methods' => 'POST',
357 'permission_callback' => [ $this->core, 'can_access_settings' ],
358 'callback' => [ $this, 'rest_openai_files_delete' ],
359 ] );
360 register_rest_route( $this->namespace, '/openai/files/download', [
361 'methods' => 'POST',
362 'permission_callback' => [ $this->core, 'can_access_settings' ],
363 'callback' => [ $this, 'rest_openai_files_download' ],
364 ] );
365 // TODO: Remove all the /openai/finetunes/* and /openai/files/finetune routes after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06).
366 register_rest_route( $this->namespace, '/openai/files/finetune', [
367 'methods' => 'POST',
368 'permission_callback' => [ $this->core, 'can_access_settings' ],
369 'callback' => [ $this, 'rest_openai_files_finetune' ],
370 ] );
371 register_rest_route( $this->namespace, '/openai/finetunes/list_deleted', [
372 'methods' => 'GET',
373 'permission_callback' => [ $this->core, 'can_access_settings' ],
374 'callback' => [ $this, 'rest_openai_deleted_finetunes_get' ],
375 ] );
376
377 // register_rest_route( $this->namespace, '/openai/models', array(
378 // 'methods' => 'GET',
379 // 'permission_callback' => [ $this->core, 'can_access_settings' ],
380 // 'callback' => [ $this, 'rest_openai_models_get' ],
381 // ) );
382
383 register_rest_route( $this->namespace, '/openai/finetunes/list', [
384 'methods' => 'GET',
385 'permission_callback' => [ $this->core, 'can_access_settings' ],
386 'callback' => [ $this, 'rest_openai_finetunes_get' ],
387 ] );
388 register_rest_route( $this->namespace, '/openai/finetunes/delete', [
389 'methods' => 'POST',
390 'permission_callback' => [ $this->core, 'can_access_settings' ],
391 'callback' => [ $this, 'rest_openai_finetunes_delete' ],
392 ] );
393 register_rest_route( $this->namespace, '/openai/finetunes/cancel', [
394 'methods' => 'POST',
395 'permission_callback' => [ $this->core, 'can_access_settings' ],
396 'callback' => [ $this, 'rest_openai_finetunes_cancel' ],
397 ] );
398
399 // Logging Endpoints
400 register_rest_route( $this->namespace, '/get_logs', [
401 'methods' => 'GET',
402 'permission_callback' => [ $this->core, 'can_access_features' ],
403 'callback' => [ $this, 'rest_get_logs' ]
404 ] );
405 register_rest_route( $this->namespace, '/clear_logs', [
406 'methods' => 'GET',
407 'permission_callback' => [ $this->core, 'can_access_features' ],
408 'callback' => [ $this, 'rest_clear_logs' ]
409 ] );
410
411 // Forms Endpoints
412 register_rest_route( $this->namespace, '/forms/list', [
413 'methods' => 'GET',
414 'permission_callback' => [ $this->core, 'can_access_settings' ],
415 'callback' => [ $this, 'rest_forms_list' ]
416 ] );
417 register_rest_route( $this->namespace, '/forms/get', [
418 'methods' => 'GET',
419 'permission_callback' => [ $this->core, 'can_access_settings' ],
420 'callback' => [ $this, 'rest_forms_get' ]
421 ] );
422 register_rest_route( $this->namespace, '/forms/create', [
423 'methods' => 'POST',
424 'permission_callback' => [ $this->core, 'can_access_settings' ],
425 'callback' => [ $this, 'rest_forms_create' ]
426 ] );
427 register_rest_route( $this->namespace, '/forms/update', [
428 'methods' => 'POST',
429 'permission_callback' => [ $this->core, 'can_access_settings' ],
430 'callback' => [ $this, 'rest_forms_update' ]
431 ] );
432 register_rest_route( $this->namespace, '/forms/delete', [
433 'methods' => 'POST',
434 'permission_callback' => [ $this->core, 'can_access_settings' ],
435 'callback' => [ $this, 'rest_forms_delete' ]
436 ] );
437 }
438 catch ( Exception $e ) {
439 Meow_MWAI_Logging::error( 'REST API initialization failed: ' . $e->getMessage() );
440 }
441 }
442
443 public function rest_start_session() {
444 try {
445 $sessionId = $this->core->get_session_id();
446 $restNonce = $this->core->get_nonce( true );
447
448 $response = [
449 'success' => true,
450 'sessionId' => $sessionId,
451 'restNonce' => $restNonce
452 ];
453
454 // If in test mode and we have a new token, it will be added by create_rest_response
455 // But we also want to ensure the restNonce matches the test token if available
456 if ( get_option( 'mwai_token_test_mode' ) ) {
457 $token_data = get_option( 'mwai_test_token_data' );
458 if ( $token_data && isset( $token_data['token'] ) ) {
459 $response['restNonce'] = $token_data['token'];
460 }
461 }
462
463 return $this->create_rest_response( $response, 200 );
464 }
465 catch ( Exception $e ) {
466 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
467 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
468 }
469 }
470
471 public function rest_settings_list() {
472 return $this->create_rest_response( [
473 'success' => true,
474 'options' => $this->core->get_all_options()
475 ], 200 );
476 }
477
478 public function rest_helpers_cron_events( $request ) {
479 try {
480 // Only show AI Engine cron events (those starting with mwai_)
481 $cron_events = [];
482 $crons = _get_cron_array();
483
484 // Get transient data for last run status (we'll store this when crons run)
485 $last_run_data = get_transient( 'mwai_cron_last_run' ) ?: [];
486
487 // Get all scheduled events and filter for AI Engine ones
488 foreach ( $crons as $timestamp => $cron ) {
489 foreach ( $cron as $hook => $details ) {
490 // Only process AI Engine hooks (starting with mwai_)
491 if ( strpos( $hook, 'mwai_' ) !== 0 ) {
492 continue;
493 }
494
495 $schedule_key = array_keys( $details )[0];
496 $schedule_info = $details[$schedule_key];
497
498 // Get schedule display name
499 $schedule = $schedule_info['schedule'];
500 $schedules = wp_get_schedules();
501 $schedule_display = isset( $schedules[$schedule]['display'] ) ?
502 $schedules[$schedule]['display'] : $schedule;
503
504 $event_info = [
505 'hook' => $hook,
506 'name' => $this->get_cron_display_name( $hook ),
507 'description' => $this->get_cron_description( $hook ),
508 'next_run' => $timestamp,
509 'next_run_human' => '',
510 'last_run' => isset( $last_run_data[$hook]['time'] ) ? $last_run_data[$hook]['time'] : null,
511 'last_run_human' => isset( $last_run_data[$hook]['time'] ) ?
512 human_time_diff( $last_run_data[$hook]['time'], time() ) . ' ago' :
513 'Never',
514 'last_status' => isset( $last_run_data[$hook]['status'] ) ? $last_run_data[$hook]['status'] : 'unknown',
515 'schedule' => $schedule_display,
516 'is_running' => false,
517 'is_scheduled' => true
518 ];
519
520 // Calculate next run time properly
521 // If we have a last run time and schedule interval, calculate the actual next run
522 if ( isset( $last_run_data[$hook]['time'] ) && isset( $schedules[$schedule]['interval'] ) ) {
523 $interval = $schedules[$schedule]['interval'];
524 $last_run = $last_run_data[$hook]['time'];
525 $expected_next_run = $last_run + $interval;
526
527 // If the scheduled timestamp is in the past but we ran recently,
528 // the next run should be based on the last actual run
529 if ( $timestamp < time() &&
530 $last_run > ( time() - $interval ) ) {
531 // Cron ran recently, calculate next run from last run time
532 $event_info['next_run'] = $expected_next_run;
533 $event_info['next_run_human'] = 'In ' . human_time_diff( time(), $expected_next_run );
534 }
535 else if ( $timestamp < time() ) {
536 // Genuinely overdue
537 $event_info['next_run_human'] = 'Overdue by ' . human_time_diff( time(), $timestamp );
538 }
539 else {
540 // Future scheduled time
541 $event_info['next_run_human'] = 'In ' . human_time_diff( time(), $timestamp );
542 }
543 }
544 else {
545 // No last run data, use the scheduled timestamp but be conservative about "overdue"
546 if ( $timestamp < time() ) {
547 // Only show as overdue if it's significantly past due (more than the schedule interval)
548 // to avoid false positives for crons that might be running but not tracked
549 $time_past_due = time() - $timestamp;
550 $interval = isset( $schedules[$schedule]['interval'] ) ? $schedules[$schedule]['interval'] : 3600; // Default 1 hour
551
552 if ( $time_past_due > $interval ) {
553 $event_info['next_run_human'] = 'Overdue by ' . human_time_diff( time(), $timestamp );
554 }
555 else {
556 $event_info['next_run_human'] = 'Due to run';
557 }
558 }
559 else {
560 $event_info['next_run_human'] = 'In ' . human_time_diff( time(), $timestamp );
561 }
562 }
563
564 // Check if currently running (via transient)
565 $running_transient = get_transient( 'mwai_cron_running_' . $hook );
566 if ( $running_transient ) {
567 $event_info['is_running'] = true;
568 }
569
570 $cron_events[] = $event_info;
571 }
572 }
573
574 return $this->create_rest_response( [ 'success' => true, 'events' => $cron_events ], 200 );
575 }
576 catch ( Exception $e ) {
577 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
578 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
579 }
580 }
581
582 public function rest_helpers_run_cron( $request ) {
583 try {
584 $params = $request->get_json_params();
585 $hook = isset( $params['hook'] ) ? $params['hook'] : null;
586
587 if ( empty( $hook ) ) {
588 return $this->create_rest_response( [ 'success' => false, 'message' => 'No cron hook provided' ], 400 );
589 }
590
591 // Only allow running AI Engine crons (starting with mwai_)
592 if ( strpos( $hook, 'mwai_' ) !== 0 ) {
593 return $this->create_rest_response( [ 'success' => false, 'message' => 'Invalid cron hook' ], 400 );
594 }
595
596 // Prevent running the Tasks Runner hooks directly - they should only run via cron
597 if ( $hook === 'mwai_tasks_internal_run' || $hook === 'mwai_tasks_internal_dev_run' ) {
598 return $this->create_rest_response( [
599 'success' => false,
600 'message' => 'The Tasks Runner cannot be triggered manually. It runs automatically based on its schedule.'
601 ], 403 );
602 }
603
604 // Check if the hook exists
605 if ( !has_action( $hook ) ) {
606 return $this->create_rest_response( [ 'success' => false, 'message' => 'Cron hook not found' ], 404 );
607 }
608
609 // Run the cron action
610 do_action( $hook );
611
612 return $this->create_rest_response( [
613 'success' => true,
614 'message' => 'Cron executed successfully',
615 'hook' => $hook
616 ], 200 );
617 }
618 catch ( Exception $e ) {
619 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
620 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
621 }
622 }
623
624 private function get_cron_display_name( $hook ) {
625 $names = [
626 'mwai_tasks_internal_run' => 'Tasks Runner',
627 'mwai_tasks_internal_dev_run' => 'Tasks Runner (Dev)',
628 'mwai_cleanup_oauth' => 'OAuth Cleanup',
629 'mwai_files_cleanup' => 'Files Cleanup',
630 'mwai_discussions' => 'Discussions Cleanup'
631 ];
632 return isset( $names[$hook] ) ? $names[$hook] : $hook;
633 }
634
635 private function get_cron_description( $hook ) {
636 $descriptions = [
637 'mwai_tasks_internal_run' => 'Processes background tasks and queued operations.',
638 'mwai_tasks_internal_dev_run' => 'Processes tasks in development mode (every 5 seconds).',
639 'mwai_cleanup_oauth' => 'Cleans up expired OAuth tokens and sessions.',
640 'mwai_files_cleanup' => 'Removes expired files based on expiration dates.',
641 'mwai_discussions' => 'Maintains chat discussions database and removes old entries.'
642 ];
643 return isset( $descriptions[$hook] ) ? $descriptions[$hook] : '';
644 }
645
646 public function rest_settings_update( $request ) {
647 try {
648 $params = $request->get_json_params();
649 $value = $params['options'];
650 $options = $this->core->update_options( $value );
651 $success = !!$options;
652 $message = __( $success ? 'OK' : 'Could not update options.', 'ai-engine' );
653 return $this->create_rest_response( [ 'success' => $success, 'message' => $message, 'options' => $options ], 200 );
654 }
655 catch ( Exception $e ) {
656 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
657 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
658 }
659 }
660
661 public function rest_settings_reset() {
662 try {
663 $options = $this->core->reset_options();
664 $success = !!$options;
665 $message = __( $success ? 'OK' : 'Could not reset options.', 'ai-engine' );
666 return $this->create_rest_response( [ 'success' => $success, 'message' => $message, 'options' => $options ], 200 );
667 }
668 catch ( Exception $e ) {
669 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
670 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
671 }
672 }
673
674 public function rest_ai_models( $request ) {
675 try {
676 $params = $request->get_json_params();
677 $envId = $params['envId'];
678 $engine = Meow_MWAI_Engines_Factory::get( $this->core, $envId );
679 $models = $engine->retrieve_models();
680 return $this->create_rest_response( [ 'success' => true, 'models' => $models ], 200 );
681 }
682 catch ( Exception $e ) {
683 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
684 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
685 }
686 }
687
688 public function rest_ai_test_connection( $request ) {
689 try {
690 $params = $request->get_json_params();
691 $envId = $params['env_id'];
692
693 // Get the environment details
694 $env = null;
695 $envs = $this->core->get_option( 'ai_envs' );
696 foreach ( $envs as $e ) {
697 if ( $e['id'] === $envId ) {
698 $env = $e;
699 break;
700 }
701 }
702
703 if ( !$env ) {
704 throw new Exception( __( 'Environment not found.', 'ai-engine' ) );
705 }
706
707 // Get the engine and test connection
708 $engine = Meow_MWAI_Engines_Factory::get( $this->core, $envId );
709 $result = $engine->connection_check();
710
711 // Format the response based on provider
712 $response = [
713 'success' => true,
714 'provider' => $env['type'],
715 'name' => $env['name'],
716 'data' => $result
717 ];
718
719 return $this->create_rest_response( $response, 200 );
720 }
721 catch ( Exception $e ) {
722 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
723 return $this->create_rest_response( [
724 'success' => false,
725 'error' => $message,
726 'provider' => isset( $env ) ? $env['type'] : 'unknown'
727 ], 200 ); // Return 200 even on error for consistent modal display
728 }
729 }
730
731 public function rest_ai_completions( $request ) {
732 try {
733 // can_access_features is Editor-and-up (and filterable), so the payload is not
734 // trusted: inject_params() honours a client-supplied apiKey for every query type,
735 // which would run the site's completions against a key of the caller's choosing.
736 $params = Meow_MWAI_Core::sanitize_rest_params( $request->get_json_params() );
737 $message = $this->retrieve_message( $params );
738 $query = new Meow_MWAI_Query_Text( $message );
739 $query->inject_params( $params );
740
741 // Handle streaming
742 $stream = $params['stream'] ?? false;
743 $streamCallback = null;
744 if ( $stream ) {
745 $streamCallback = function ( $reply ) use ( $query ) {
746 //$raw = _wp_specialchars( $reply, ENT_NOQUOTES, 'UTF-8', true );
747 $raw = $reply;
748 $this->core->stream_push( [ 'type' => 'live', 'data' => $raw ], $query );
749 if ( ob_get_level() > 0 ) {
750 ob_flush();
751 }
752 flush();
753 };
754 if ( headers_sent( $filename, $linenum ) ) {
755 throw new Exception( "Headers already sent in $filename on line $linenum. Cannot start streaming." );
756 }
757 header( 'Cache-Control: no-cache' );
758 header( 'Content-Type: text/event-stream' );
759 header( 'X-Accel-Buffering: no' ); // This is useful to disable buffering in nginx through headers.
760 ob_implicit_flush( true );
761 if ( ob_get_level() > 0 ) {
762 ob_end_flush();
763 }
764 // Finish the request even if the client disconnects, so usage/credits are
765 // still recorded after the stream completes. Otherwise an abort mid-stream
766 // kills the script before accounting runs. See chat_submit for details.
767 ignore_user_abort( true );
768 }
769
770 // Process Reply
771 $reply = $this->core->run_query( $query, $streamCallback );
772 $restRes = [
773 'success' => true,
774 'data' => $reply->result,
775 'usage' => $reply->usage
776 ];
777 if ( $stream ) {
778 $this->core->stream_push( [ 'type' => 'end', 'data' => json_encode( $restRes ) ], $query );
779 die();
780 }
781 return $this->create_rest_response( $restRes, 200 );
782 }
783 catch ( Exception $e ) {
784 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
785 if ( $stream ) {
786 $this->core->stream_push( [ 'type' => 'error', 'data' => $message ], $query );
787 }
788 else {
789 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
790 }
791 }
792 }
793
794 public function rest_ai_images( $request ) {
795 try {
796 // Same reasoning as rest_ai_completions(): Editor-reachable route, so the
797 // client-controlled apiKey override has to leave the array.
798 $params = Meow_MWAI_Core::sanitize_rest_params( $request->get_json_params() );
799 $message = $this->retrieve_message( $params );
800 $query = new Meow_MWAI_Query_Image( $message );
801 $query->inject_params( $params );
802 $reply = $this->core->run_query( $query );
803 return $this->create_rest_response( [ 'success' => true, 'data' => $reply->results, 'usage' => $reply->usage ], 200 );
804 }
805 catch ( Exception $e ) {
806 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
807 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
808 }
809 }
810
811 public function rest_ai_image_edit( $request ) {
812 try {
813 // Check if this is a multipart request with files
814 $files = $request->get_file_params();
815 $params = null;
816
817 // Debug logging
818 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
819 error_log( '[AI Engine Queries] Image Edit Request - Method: ' . $request->get_method() );
820 $content_type = $request->get_content_type();
821 if ( is_array( $content_type ) ) {
822 error_log( '[AI Engine Queries] Image Edit Request - Content-Type: ' . $content_type['value'] );
823 }
824 else {
825 error_log( '[AI Engine Queries] Image Edit Request - Content-Type: ' . $content_type );
826 }
827 error_log( '[AI Engine Queries] Image Edit Request - Has files: ' . ( !empty( $files ) ? 'yes (' . count( $files ) . ')' : 'no' ) );
828 }
829
830 if ( !empty( $files ) ) {
831 // Handle multipart form data - get all params including POST data
832 $params = $request->get_params();
833 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
834 error_log( '[AI Engine Queries] Image Edit Request - Using form data params' );
835 }
836 }
837 else {
838 // Try to get body params first (for form data without files)
839 $body_params = $request->get_body_params();
840 if ( !empty( $body_params ) ) {
841 $params = $body_params;
842 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
843 error_log( '[AI Engine Queries] Image Edit Request - Using body params' );
844 }
845 }
846 else {
847 // Handle JSON request
848 $params = $request->get_json_params();
849 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
850 error_log( '[AI Engine Queries] Image Edit Request - Using JSON params' );
851 }
852 }
853 }
854
855 // Ensure params is always an array, and strip the client-controlled keys. Done
856 // here rather than at each read above so both the multipart and the JSON branch
857 // are covered, and before the debug log so a blocked key is never written to it.
858 $params = Meow_MWAI_Core::sanitize_rest_params( $params );
859
860 // Debug logging
861 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
862 error_log( '[AI Engine Queries] Image Edit Request - Has files: ' . ( !empty( $files ) ? 'yes' : 'no' ) );
863 error_log( '[AI Engine Queries] Image Edit Request - Params: ' . json_encode( $params ) );
864 }
865
866 $message = $this->retrieve_message( $params );
867 $mediaId = isset( $params['mediaId'] ) ? intval( $params['mediaId'] ) : 0;
868 $query = new Meow_MWAI_Query_EditImage( $message );
869
870 // The inject_params method will handle setting the file from mediaId
871 $query->inject_params( $params );
872
873 // Handle mask file if provided
874 if ( !empty( $files['mask'] ) ) {
875 $mask_file = $files['mask'];
876 if ( $mask_file['error'] === UPLOAD_ERR_OK ) {
877 $mask_data = file_get_contents( $mask_file['tmp_name'] );
878 $query->set_mask( Meow_MWAI_Query_DroppedFile::from_data( $mask_data, 'analysis', $mask_file['type'] ) );
879 }
880 }
881
882 $reply = $this->core->run_query( $query );
883 return $this->create_rest_response( [ 'success' => true, 'data' => $reply->results, 'usage' => $reply->usage ], 200 );
884 }
885 catch ( Exception $e ) {
886 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
887 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
888 }
889 }
890
891 public function rest_ai_magic_wand( $request ) {
892 try {
893 $params = $request->get_json_params();
894 $action = isset( $params['action'] ) ? $params['action'] : null;
895 $data = isset( $params['data'] ) ? $params['data'] : null;
896 if ( empty( $data ) || empty( $action ) ) {
897 return $this->create_rest_response( [ 'success' => false, 'message' => 'An action and some data are required.' ], 500 );
898 }
899 $data = apply_filters( 'mwai_magic_wand_' . $action, '', $data );
900 return $this->create_rest_response( [ 'success' => true, 'data' => $data ], 200 );
901 }
902 catch ( Exception $e ) {
903 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
904 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
905 }
906 }
907
908 public function rest_ai_copilot( $request ) {
909 try {
910 $params = $request->get_json_params();
911 $action = sanitize_text_field( $params['action'] );
912 $message = $this->retrieve_message( $params, true );
913 $context = sanitize_text_field( $params['context'] );
914 $postId = !empty( $params['postId'] ) ? intval( $params['postId'] ) : null;
915 if ( empty( $action ) || empty( $message ) ) {
916 return $this->create_rest_response( [ 'success' => false, 'message' => 'Copilot needs an action and a prompt.' ], 500 );
917 }
918
919 global $mwai;
920 $result = null;
921 $params = [ 'scope' => 'copilot' ];
922
923 if ( $action === 'text' ) {
924 $prompt = "Here is the current article: \n\n===\n\n" . $context . "\n\n===\n\nIn this article, instead of the [== CURRENT BLOCK ==] placeholder, the author needs additional content. This new content should use the same tone, style, context, it should naturally flow in the article. The author shared additional information for this request:\n\n===\n\n" . $message . "\n\n===\n\nPlease provide the additional content. Only output the additional content, not the entire article, no need for extra information, and no need for the placeholders. Only output the content that should be added.";
925 if ( !empty( $model ) ) {
926 $params['model'] = $model;
927 }
928 $result = $mwai->simpleTextQuery( $prompt, $params );
929 }
930 else if ( $action === 'image' ) {
931 $prompt = "Here is the current article: \n\n===\n\n" . $context . "\n\n===\n\nIn this article, instead of the [== CURRENT BLOCK ==] placeholder, the author needs an image. Please write a detailed description (prompt) for that image that would fit this context. The image should be relevant to the article. The author shared additional information for this request:\n\n===\n\n" . $message . "\n\n===\n\nPlease only output the description for the image, not the entire article, no need for extra information, and no need for the placeholders. Only output the description.";
932
933 // Create the image
934 $simplifiedPrompt = $mwai->simpleTextQuery( $prompt, $params );
935 $media = $mwai->imageQueryForMediaLibrary( $simplifiedPrompt, $params, $postId );
936 $result = [ 'media' => $media ];
937 }
938 return $this->create_rest_response( [ 'success' => true, 'data' => $result ], 200 );
939 }
940 catch ( Exception $e ) {
941 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
942 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
943 }
944 }
945
946 public function rest_helpers_update_title( $request ) {
947 try {
948 $params = $request->get_json_params();
949 $title = sanitize_text_field( $params['title'] );
950 $postId = intval( $params['postId'] );
951 $post = get_post( $postId );
952 if ( !$post ) {
953 throw new Exception( __( 'There is no post with this ID.', 'ai-engine' ) );
954 }
955 $post->post_title = $title;
956 //$post->post_name = sanitize_title( $title );
957 wp_update_post( $post );
958 return $this->create_rest_response( [ 'success' => true, 'message' => 'Title updated.' ], 200 );
959 }
960 catch ( Exception $e ) {
961 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
962 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
963 }
964 }
965
966 public function rest_helpers_update_excerpt( $request ) {
967 try {
968 $params = $request->get_json_params();
969 $excerpt = sanitize_text_field( $params['excerpt'] );
970 $postId = intval( $params['postId'] );
971 $post = get_post( $postId );
972 if ( !$post ) {
973 throw new Exception( __( 'There is no post with this ID.', 'ai-engine' ) );
974 }
975 $post->post_excerpt = $excerpt;
976 wp_update_post( $post );
977 return $this->create_rest_response( [ 'success' => true, 'message' => 'Excerpt updated.' ], 200 );
978 }
979 catch ( Exception $e ) {
980 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
981 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
982 }
983 }
984
985 public function rest_helpers_create_post( $request ) {
986 try {
987 $params = $request->get_json_params();
988 $title = sanitize_text_field( $params['title'] );
989 $content = sanitize_textarea_field( $params['content'] );
990 $excerpt = sanitize_text_field( $params['excerpt'] );
991 $postType = sanitize_text_field( $params['postType'] );
992 $post = new stdClass();
993 $post->post_title = $title;
994 $post->post_excerpt = $excerpt;
995 $post->post_content = $content;
996 $post->post_status = 'draft';
997 $post->post_type = isset( $postType ) ? $postType : 'post';
998 // TODO: Let's try to avoid using Markdown to create the Post
999 // Instead, we should create Gutenberg Blocks, or simple HTML.
1000 // Then, we can get rid of the library for Markdown.
1001 $post->post_content = $this->core->markdown_to_html( $post->post_content );
1002 $postId = wp_insert_post( $post );
1003 return $this->create_rest_response( [ 'success' => true, 'postId' => $postId ], 200 );
1004 }
1005 catch ( Exception $e ) {
1006 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1007 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1008 }
1009 }
1010
1011 public function rest_helpers_create_images( $request ) {
1012 try {
1013 $params = $request->get_json_params();
1014 $title = sanitize_text_field( $params['title'] );
1015 $caption = sanitize_text_field( $params['caption'] );
1016 $alt = sanitize_text_field( $params['alt'] );
1017 $description = sanitize_text_field( $params['description'] );
1018 $url = $params['url'];
1019 $filename = sanitize_text_field( $params['filename'] );
1020
1021 // Prepare AI metadata
1022 $ai_metadata = [];
1023 if ( !empty( $params['model'] ) ) {
1024 $ai_metadata['model'] = $params['model'];
1025 }
1026 if ( !empty( $params['latency'] ) ) {
1027 $ai_metadata['latency'] = $params['latency'];
1028 }
1029 if ( !empty( $params['env_id'] ) ) {
1030 $ai_metadata['env_id'] = $params['env_id'];
1031 }
1032
1033 // Debug logging
1034 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1035 error_log( '[AI Engine] create_image metadata: ' . json_encode( $ai_metadata ) );
1036 }
1037
1038 // Create as mwai_image post type (draft image)
1039 $attachmentId = $this->core->add_image_from_url( $url, $filename, $title, $description, $caption, $alt, null, 'inherit', 'mwai_image', $ai_metadata );
1040
1041 // Add to user's draft media
1042 $user_id = get_current_user_id();
1043 $draft_media = get_user_meta( $user_id, 'mwai_draft_media', true );
1044 if ( !is_array( $draft_media ) ) {
1045 $draft_media = [];
1046 }
1047 $draft_media[] = [
1048 'attachment_id' => $attachmentId,
1049 'type' => 'image',
1050 'created_at' => time()
1051 ];
1052 update_user_meta( $user_id, 'mwai_draft_media', $draft_media );
1053
1054 return $this->create_rest_response( [ 'success' => true, 'attachmentId' => $attachmentId ], 200 );
1055 }
1056 catch ( Exception $e ) {
1057 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1058 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1059 }
1060 }
1061
1062 public function rest_helpers_generate_image_meta( $request ) {
1063 try {
1064 global $mwai;
1065 $params = $request->get_json_params();
1066 $attachment_id = isset( $params['attachmentId'] ) ? intval( $params['attachmentId'] ) : null;
1067
1068 if ( empty( $attachment_id ) ) {
1069 throw new Exception( __( 'The attachment ID is required.', 'ai-engine' ) );
1070 }
1071
1072 // Get the file path from the attachment ID
1073 $file_path = get_attached_file( $attachment_id );
1074 if ( empty( $file_path ) || !file_exists( $file_path ) ) {
1075 throw new Exception( __( 'Could not find the attachment file.', 'ai-engine' ) );
1076 }
1077
1078 $prompt = 'Describe this image and suggest a short title and description. '
1079 . 'Also suggest an SEO-friendly filename (lowercase, ASCII characters only, with hyphens instead of spaces). '
1080 . 'Return a JSON with the keys: title, description, filename.';
1081
1082 // Use file path instead of URL to avoid network issues
1083 $result = $mwai->simpleVisionQuery( $prompt, null, $file_path, [ 'scope' => 'admin-tools' ] );
1084 $result = preg_replace( '/^```json\s*/', '', $result );
1085 $result = preg_replace( '/\s*```$/', '', $result );
1086 if ( is_string( $result ) ) {
1087 $data = json_decode( $result, true );
1088 }
1089 else {
1090 $data = $result;
1091 }
1092 if ( !is_array( $data ) ) {
1093 $data = [];
1094 }
1095 $data = array_merge( [ 'title' => '', 'description' => '', 'filename' => '' ], $data );
1096 return $this->create_rest_response( [ 'success' => true, 'data' => $data ], 200 );
1097 }
1098 catch ( Exception $e ) {
1099 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1100 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1101 }
1102 }
1103
1104 public function rest_helpers_update_media_metadata( $request ) {
1105 try {
1106 $params = $request->get_json_params();
1107 $attachment_id = intval( $params['attachmentId'] );
1108 $title = sanitize_text_field( $params['title'] ?? '' );
1109 $description = sanitize_text_field( $params['description'] ?? '' );
1110 $caption = sanitize_text_field( $params['caption'] ?? '' );
1111 $alt = sanitize_text_field( $params['alt'] ?? '' );
1112 $filename = sanitize_file_name( $params['filename'] ?? '' );
1113
1114 if ( !$attachment_id ) {
1115 throw new Exception( __( 'Attachment ID is required.', 'ai-engine' ) );
1116 }
1117
1118 // Generate slug from filename (without extension)
1119 $slug = '';
1120 if ( !empty( $filename ) ) {
1121 $slug = pathinfo( $filename, PATHINFO_FILENAME );
1122 }
1123
1124 // Update post title, content (description), caption, and slug
1125 $update_data = [
1126 'ID' => $attachment_id,
1127 'post_title' => $title,
1128 'post_content' => $description,
1129 'post_excerpt' => $caption
1130 ];
1131
1132 if ( !empty( $slug ) ) {
1133 $update_data['post_name'] = $slug;
1134 }
1135
1136 wp_update_post( $update_data );
1137
1138 // Update alt text
1139 if ( !empty( $alt ) ) {
1140 update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt );
1141 }
1142
1143 // Update filename if provided
1144 $new_url = null;
1145 if ( !empty( $filename ) ) {
1146 $file_path = get_attached_file( $attachment_id );
1147 if ( $file_path ) {
1148 // Security: Validate file extension to prevent arbitrary file upload attacks
1149 $original_ext = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
1150 $new_ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
1151
1152 // Allowlist of safe media extensions (no executable types)
1153 $allowed_extensions = [
1154 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico', 'svg', 'avif',
1155 'mp4', 'webm', 'ogg', 'mov', 'avi', 'wmv', 'flv', 'm4v',
1156 'mp3', 'wav', 'flac', 'aac', 'm4a', 'wma',
1157 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'rtf'
1158 ];
1159
1160 // Extension must be in allowlist AND match original extension
1161 if ( !in_array( $new_ext, $allowed_extensions, true ) ) {
1162 throw new Exception( __( 'Invalid file extension. Only media file extensions are allowed.', 'ai-engine' ) );
1163 }
1164 if ( $new_ext !== $original_ext ) {
1165 throw new Exception( __( 'File extension must match the original file type.', 'ai-engine' ) );
1166 }
1167
1168 $path_parts = pathinfo( $file_path );
1169 $new_file_path = $path_parts['dirname'] . '/' . $filename;
1170 if ( rename( $file_path, $new_file_path ) ) {
1171 update_attached_file( $attachment_id, $new_file_path );
1172 // Build new URL from file path for custom post types
1173 $upload_dir = wp_upload_dir();
1174 $new_url = str_replace( $upload_dir['basedir'], $upload_dir['baseurl'], $new_file_path );
1175 }
1176 }
1177 }
1178
1179 $response = [ 'success' => true ];
1180 if ( $new_url ) {
1181 $response['url'] = $new_url;
1182 }
1183
1184 return $this->create_rest_response( $response, 200 );
1185 }
1186 catch ( Exception $e ) {
1187 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1188 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1189 }
1190 }
1191
1192 public function rest_openai_files_get() {
1193 try {
1194 $envId = isset( $_GET['envId'] ) ? $_GET['envId'] : null;
1195 $purposeFilter = isset( $_GET['purpose'] ) ? $_GET['purpose'] : null;
1196 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1197 $files = $openai->list_files( $purposeFilter );
1198 return $this->create_rest_response( [ 'success' => true, 'files' => $files ], 200 );
1199 }
1200 catch ( Exception $e ) {
1201 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1202 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1203 }
1204 }
1205
1206 // TODO: Remove all the rest_openai_*finetune* handlers below after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06).
1207 public function rest_openai_deleted_finetunes_get() {
1208 try {
1209 $envId = isset( $_GET['envId'] ) ? $_GET['envId'] : null;
1210 $legacy = isset( $_GET['legacy'] ) ? $_GET['legacy'] === 'true' : false;
1211 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1212 $finetunes = $openai->list_deleted_finetunes( $legacy );
1213 return $this->create_rest_response( [ 'success' => true, 'finetunes' => $finetunes ], 200 );
1214 }
1215 catch ( Exception $e ) {
1216 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1217 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1218 }
1219 }
1220
1221 public function rest_openai_finetunes_get() {
1222 try {
1223 $envId = isset( $_GET['envId'] ) ? $_GET['envId'] : null;
1224 $legacy = isset( $_GET['legacy'] ) ? $_GET['legacy'] === 'true' : false;
1225 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1226 $finetunes = $openai->list_finetunes( $legacy );
1227 return $this->create_rest_response( [ 'success' => true, 'finetunes' => $finetunes ], 200 );
1228 }
1229 catch ( Exception $e ) {
1230 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1231 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1232 }
1233 }
1234
1235 public function rest_openai_files_upload( $request ) {
1236 try {
1237 $params = $request->get_json_params();
1238 $envId = $params['envId'];
1239 ;
1240 $filename = sanitize_text_field( $params['filename'] );
1241 $data = $params['data'];
1242 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1243 $file = $openai->upload_file( $filename, $data );
1244 return $this->create_rest_response( [ 'success' => true, 'file' => $file ], 200 );
1245 }
1246 catch ( Exception $e ) {
1247 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1248 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1249 }
1250 }
1251
1252 public function rest_openai_files_delete( $request ) {
1253 try {
1254 $params = $request->get_json_params();
1255 $envId = $params['envId'];
1256 ;
1257 $fileId = $params['fileId'];
1258 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1259 $openai->delete_file( $fileId );
1260 return $this->create_rest_response( [ 'success' => true ], 200 );
1261 }
1262 catch ( Exception $e ) {
1263 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1264 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1265 }
1266 }
1267
1268 public function rest_openai_finetunes_cancel( $request ) {
1269 try {
1270 $params = $request->get_json_params();
1271 $envId = $params['envId'];
1272 ;
1273 $finetuneId = $params['finetuneId'];
1274 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1275 $openai->cancel_finetune( $finetuneId );
1276 return $this->create_rest_response( [ 'success' => true ], 200 );
1277 }
1278 catch ( Exception $e ) {
1279 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1280 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1281 }
1282 }
1283
1284 public function rest_openai_finetunes_delete( $request ) {
1285 try {
1286 $params = $request->get_json_params();
1287 $envId = $params['envId'];
1288 ;
1289 $modelId = $params['modelId'];
1290 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1291 $openai->delete_finetune( $modelId );
1292 return $this->create_rest_response( [ 'success' => true ], 200 );
1293 }
1294 catch ( Exception $e ) {
1295 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1296 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1297 }
1298 }
1299
1300 public function rest_openai_files_download( $request ) {
1301 try {
1302 $params = $request->get_json_params();
1303 $envId = $params['envId'];
1304 ;
1305 $fileId = $params['fileId'];
1306 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1307 $filename = $openai->download_file( $fileId );
1308 $data = file_get_contents( $filename );
1309 return $this->create_rest_response( [ 'success' => true, 'data' => $data ], 200 );
1310 }
1311 catch ( Exception $e ) {
1312 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1313 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1314 }
1315 }
1316
1317 public function rest_openai_files_finetune( $request ) {
1318 try {
1319 $params = $request->get_json_params();
1320 $envId = $params['envId'];
1321 ;
1322 $fileId = $params['fileId'];
1323 $model = $params['model'];
1324 $suffix = $params['suffix'];
1325 $hyperparams = [
1326 'nEpochs' => isset( $params['nEpochs'] ) ? $params['nEpochs'] : null,
1327 'batchSize' => isset( $params['batchSize'] ) ? $params['batchSize'] : null,
1328 ];
1329 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1330 $finetune = $openai->run_finetune( $fileId, $model, $suffix, $hyperparams );
1331 return $this->create_rest_response( [ 'success' => true, 'finetune' => $finetune ], 200 );
1332 }
1333 catch ( Exception $e ) {
1334 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1335 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1336 }
1337 }
1338
1339 /**
1340 * Term joins mirroring the Sync settings, shared by the Push All count and the
1341 * Push All id list so the two never disagree.
1342 *
1343 * Push All must select exactly what Sync would maintain. Otherwise it seeds the
1344 * index with posts the lifecycle hooks never update or remove: a site syncing
1345 * only 'fr' was pushing every language. Semantics match
1346 * MeowPro_MWAI_Embeddings: an empty list means no filtering, a non-empty one
1347 * requires at least one match. The joins go before the WHERE clause, so their
1348 * arguments come first in the prepare() call.
1349 */
1350 private function sync_term_joins( $params, &$joinArgs ) {
1351 global $wpdb;
1352 $joinArgs = [];
1353 $joins = '';
1354 $csv = function ( $key ) use ( $params ) {
1355 if ( empty( $params[$key] ) ) {
1356 return [];
1357 }
1358 return array_values( array_filter( array_map( 'trim', explode( ',', $params[$key] ) ) ) );
1359 };
1360 $termJoin = function ( $alias, $taxonomy, $slugs ) use ( $wpdb, &$joins, &$joinArgs ) {
1361 $placeholders = implode( ',', array_fill( 0, count( $slugs ), '%s' ) );
1362 $joins .= " INNER JOIN {$wpdb->term_relationships} tr_{$alias}"
1363 . " ON tr_{$alias}.object_id = p.ID"
1364 . " INNER JOIN {$wpdb->term_taxonomy} tt_{$alias}"
1365 . " ON tt_{$alias}.term_taxonomy_id = tr_{$alias}.term_taxonomy_id"
1366 . " AND tt_{$alias}.taxonomy = '{$taxonomy}'"
1367 . " INNER JOIN {$wpdb->terms} t_{$alias}"
1368 . " ON t_{$alias}.term_id = tt_{$alias}.term_id AND t_{$alias}.slug IN ({$placeholders})";
1369 $joinArgs = array_merge( $joinArgs, $slugs );
1370 };
1371 $postCategories = $csv( 'postCategories' );
1372 if ( !empty( $postCategories ) ) {
1373 $termJoin( 'cat', 'category', $postCategories );
1374 }
1375 // Polylang stores the language as a 'language' term whose slug is the code,
1376 // which is what pll_get_post_language() returns. Without Polylang there is
1377 // nothing to filter on, exactly like is_post_language_synced().
1378 $postLanguages = $csv( 'postLanguages' );
1379 if ( !empty( $postLanguages ) && taxonomy_exists( 'language' ) ) {
1380 $termJoin( 'lang', 'language', $postLanguages );
1381 }
1382 return $joins;
1383 }
1384
1385 public function rest_helpers_count_posts( $request ) {
1386 try {
1387 global $wpdb;
1388 $params = $request->get_query_params();
1389 $postType = $params['postType'];
1390 $postStatus = !empty( $params['postStatus'] ) ? explode( ',', $params['postStatus'] ) : [ 'publish' ];
1391 $joinArgs = [];
1392 $joins = $this->sync_term_joins( $params, $joinArgs );
1393 $statusPlaceholders = implode( ',', array_fill( 0, count( $postStatus ), '%s' ) );
1394 $ignored_ids = $wpdb->get_col(
1395 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_mwai_embedding_ignore'"
1396 );
1397 $exclude_sql = '';
1398 if ( !empty( $ignored_ids ) ) {
1399 $ignored_ids = array_map( 'intval', $ignored_ids );
1400 $exclude_sql = ' AND p.ID NOT IN (' . implode( ',', $ignored_ids ) . ')';
1401 }
1402 $mimeFilter = '';
1403 if ( $postType === 'attachment' ) {
1404 $mimeFilter = " AND p.post_mime_type LIKE 'image/%'";
1405 }
1406 // COUNT(DISTINCT) because a post matching several joined terms would repeat.
1407 $query = "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p" . $joins . "
1408 WHERE p.post_type = %s
1409 AND p.post_status IN ($statusPlaceholders)" . $exclude_sql . $mimeFilter;
1410 $prepareArgs = array_merge( $joinArgs, [ $postType ], $postStatus );
1411 $count = (int) $wpdb->get_var( $wpdb->prepare( $query, ...$prepareArgs ) );
1412 return $this->create_rest_response( [ 'success' => true, 'count' => $count ], 200 );
1413 }
1414 catch ( Exception $e ) {
1415 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1416 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1417 }
1418 }
1419
1420 public function rest_helpers_posts_ids( $request ) {
1421 try {
1422 global $wpdb;
1423 $params = $request->get_query_params();
1424 $postType = $params['postType'];
1425 $postStatus = !empty( $params['postStatus'] ) ? explode( ',', $params['postStatus'] ) : [ 'publish' ];
1426 $joinArgs = [];
1427 $joins = $this->sync_term_joins( $params, $joinArgs );
1428
1429 // Use direct SQL query instead of get_posts to avoid memory issues with large sites
1430 $statusPlaceholders = implode( ',', array_fill( 0, count( $postStatus ), '%s' ) );
1431 $ignored_ids = $wpdb->get_col(
1432 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_mwai_embedding_ignore'"
1433 );
1434 $exclude_sql = '';
1435 if ( !empty( $ignored_ids ) ) {
1436 $ignored_ids = array_map( 'intval', $ignored_ids );
1437 $exclude_sql = ' AND p.ID NOT IN (' . implode( ',', $ignored_ids ) . ')';
1438 }
1439 $mimeFilter = '';
1440 if ( $postType === 'attachment' ) {
1441 $mimeFilter = " AND p.post_mime_type LIKE 'image/%'";
1442 }
1443 // DISTINCT because a post matching several of the joined terms would repeat.
1444 $query = "SELECT DISTINCT p.ID FROM {$wpdb->posts} p" . $joins . "
1445 WHERE p.post_type = %s
1446 AND p.post_status IN ($statusPlaceholders)" . $exclude_sql . $mimeFilter . '
1447 ORDER BY p.ID ASC';
1448
1449 $prepareArgs = array_merge( $joinArgs, [ $postType ], $postStatus );
1450 $postIds = $wpdb->get_col( $wpdb->prepare( $query, ...$prepareArgs ) );
1451 $postIds = array_map( 'intval', $postIds );
1452
1453 return $this->create_rest_response( [ 'success' => true, 'postIds' => $postIds ], 200 );
1454 }
1455 catch ( Exception $e ) {
1456 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1457 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1458 }
1459 }
1460
1461 public function rest_helpers_post_content( $request ) {
1462 try {
1463 $params = $request->get_query_params();
1464 $offset = (int) $params['offset'];
1465 $postType = $params['postType'];
1466 $postStatus = isset( $params['postStatus'] ) ? explode( ',', $params['postStatus'] ) : [ 'publish' ];
1467 $postId = (int) $params['postId'];
1468
1469 $post = null;
1470 if ( !empty( $postId ) ) {
1471 $post = get_post( $postId );
1472 if ( $post->post_status !== 'publish' && $post->post_status !== 'future'
1473 && $post->post_status !== 'draft' && $post->post_status !== 'private' ) {
1474 $post = null;
1475 }
1476 }
1477 else {
1478 $posts = get_posts( [
1479 'posts_per_page' => 1,
1480 'post_type' => $postType,
1481 'offset' => $offset,
1482 'post_status' => $postStatus,
1483 ] );
1484 $post = count( $posts ) === 0 ? null : $posts[0];
1485 }
1486 if ( !$post ) {
1487 return $this->create_rest_response( [ 'success' => false, 'message' => 'Post not found' ], 404 );
1488 }
1489 $cleanPost = $this->core->get_post( $post );
1490 return $this->create_rest_response( [ 'success' => true, 'content' => $cleanPost['content'],
1491 'checksum' => $cleanPost['checksum'], 'language' => $cleanPost['language'], 'excerpt' => $cleanPost['excerpt'],
1492 'postId' => $cleanPost['postId'], 'title' => $cleanPost['title'], 'url' => $cleanPost['url'] ], 200 );
1493 }
1494 catch ( Exception $e ) {
1495 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1496 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1497 }
1498 }
1499
1500 // Batch check which posts have content (for Push All optimization)
1501 public function rest_helpers_check_posts_content( $request ) {
1502 try {
1503 $params = $request->get_json_params();
1504 $postIds = isset( $params['postIds'] ) ? $params['postIds'] : [];
1505
1506 if ( empty( $postIds ) || !is_array( $postIds ) ) {
1507 return $this->create_rest_response( [
1508 'success' => false,
1509 'message' => 'postIds array is required'
1510 ], 400 );
1511 }
1512
1513 // Sanitize post IDs
1514 $postIds = array_map( 'intval', $postIds );
1515
1516 // Check content using the mwai_pre_post_content filter to support page builders,
1517 // ACF, and other plugins that store content outside of post_content
1518 $postsWithContent = [];
1519
1520 foreach ( $postIds as $postId ) {
1521 $post = get_post( $postId );
1522 if ( !$post ) {
1523 continue;
1524 }
1525 // Apply the same filter used by get_post_content() in core.php
1526 $content = apply_filters( 'mwai_pre_post_content', $post->post_content, $postId );
1527 $content = trim( strip_tags( $content ) );
1528 if ( !empty( $content ) ) {
1529 $postsWithContent[] = $postId;
1530 }
1531 }
1532
1533 return $this->create_rest_response( [
1534 'success' => true,
1535 'postsWithContent' => $postsWithContent
1536 ], 200 );
1537 }
1538 catch ( Exception $e ) {
1539 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1540 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1541 }
1542 }
1543
1544 public function rest_helpers_run_tasks( $request ) {
1545 try {
1546 // Prevent concurrent execution with a transient lock
1547 $lock_key = 'mwai_rest_run_tasks_lock';
1548 if ( get_transient( $lock_key ) ) {
1549 // Log excessive calls for debugging
1550 if ( $this->core->get_option( 'dev_mode' ) ) {
1551 error_log( '[AI Engine] WARNING: rest_helpers_run_tasks called while already running' );
1552 }
1553 return $this->create_rest_response( [
1554 'success' => false,
1555 'message' => 'Tasks are already running. Please wait.'
1556 ], 429 ); // 429 Too Many Requests
1557 }
1558
1559 // Set lock for 30 seconds
1560 set_transient( $lock_key, true, 30 );
1561
1562 // Log task execution start
1563 if ( $this->core->get_option( 'dev_mode' ) ) {
1564 error_log( '[AI Engine] rest_helpers_run_tasks triggered via REST API' );
1565 }
1566
1567 try {
1568 do_action( 'mwai_tasks_run' );
1569 delete_transient( $lock_key );
1570 return $this->create_rest_response( [ 'success' => true ], 200 );
1571 }
1572 catch ( Exception $e ) {
1573 delete_transient( $lock_key );
1574 throw $e;
1575 }
1576 }
1577 catch ( Exception $e ) {
1578 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1579 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1580 }
1581 }
1582
1583 public function rest_helpers_optimize_database( $request ) {
1584 try {
1585 global $wpdb;
1586 $results = [];
1587
1588 // Add indexes to optimize query performance
1589 $indexes = [
1590 // mwai_logs indexes
1591 [ 'table' => 'mwai_logs', 'name' => 'idx_mwai_logs_time', 'columns' => 'time' ],
1592 [ 'table' => 'mwai_logs', 'name' => 'idx_mwai_logs_userId', 'columns' => 'userId' ],
1593 [ 'table' => 'mwai_logs', 'name' => 'idx_mwai_logs_envId', 'columns' => 'envId' ],
1594 [ 'table' => 'mwai_logs', 'name' => 'idx_mwai_logs_refId', 'columns' => 'refId' ],
1595 [ 'table' => 'mwai_logs', 'name' => 'idx_mwai_logs_time_model', 'columns' => 'time, model' ],
1596
1597 // mwai_logmeta indexes
1598 [ 'table' => 'mwai_logmeta', 'name' => 'idx_mwai_logmeta_log_id', 'columns' => 'log_id' ],
1599
1600 // mwai_vectors indexes
1601 [ 'table' => 'mwai_vectors', 'name' => 'idx_mwai_vectors_envId_status_dbId', 'columns' => 'envId, status, dbId' ],
1602 [ 'table' => 'mwai_vectors', 'name' => 'idx_mwai_vectors_refId', 'columns' => 'refId' ],
1603 [ 'table' => 'mwai_vectors', 'name' => 'idx_mwai_vectors_status', 'columns' => 'status' ],
1604 [ 'table' => 'mwai_vectors', 'name' => 'idx_mwai_vectors_updated', 'columns' => 'updated' ],
1605
1606 // mwai_files indexes
1607 [ 'table' => 'mwai_files', 'name' => 'idx_mwai_files_expires', 'columns' => 'expires' ],
1608 [ 'table' => 'mwai_files', 'name' => 'idx_mwai_files_userId', 'columns' => 'userId' ],
1609 [ 'table' => 'mwai_files', 'name' => 'idx_mwai_files_purpose', 'columns' => 'purpose' ],
1610
1611 // mwai_filemeta indexes
1612 [ 'table' => 'mwai_filemeta', 'name' => 'idx_mwai_filemeta_file_id', 'columns' => 'file_id' ],
1613
1614 // mwai_chats indexes
1615 [ 'table' => 'mwai_chats', 'name' => 'idx_mwai_chats_chatId_botId', 'columns' => 'chatId, botId' ],
1616 [ 'table' => 'mwai_chats', 'name' => 'idx_mwai_chats_chatId_userId', 'columns' => 'chatId, userId' ],
1617 [ 'table' => 'mwai_chats', 'name' => 'idx_mwai_chats_updated', 'columns' => 'updated' ],
1618 ];
1619
1620 // Add indexes
1621 foreach ( $indexes as $index ) {
1622 $table = $wpdb->prefix . $index['table'];
1623 $index_name = $index['name'];
1624 $columns = $index['columns'];
1625
1626 // Check if index already exists
1627 $existing = $wpdb->get_var( $wpdb->prepare(
1628 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
1629 WHERE table_schema = %s AND table_name = %s AND index_name = %s',
1630 DB_NAME,
1631 $table,
1632 $index_name
1633 ) );
1634
1635 if ( !$existing ) {
1636 $wpdb->query( "ALTER TABLE `$table` ADD INDEX `$index_name` ($columns)" );
1637 $results[] = "Added index $index_name on $table";
1638 }
1639 }
1640
1641 // Clean up old logs (older than 3 months)
1642 $three_months_ago = date( 'Y-m-d H:i:s', strtotime( '-3 months' ) );
1643
1644 // Delete old logs
1645 $deleted_logs = $wpdb->query( $wpdb->prepare(
1646 "DELETE FROM {$wpdb->prefix}mwai_logs WHERE time < %s",
1647 $three_months_ago
1648 ) );
1649 $results[] = "Deleted $deleted_logs old log entries";
1650
1651 // Delete orphaned logmeta
1652 $deleted_logmeta = $wpdb->query(
1653 "DELETE lm FROM {$wpdb->prefix}mwai_logmeta lm
1654 LEFT JOIN {$wpdb->prefix}mwai_logs l ON lm.log_id = l.id
1655 WHERE l.id IS NULL"
1656 );
1657 $results[] = "Deleted $deleted_logmeta orphaned logmeta entries";
1658
1659 // Delete old chats (older than 3 months)
1660 $deleted_chats = $wpdb->query( $wpdb->prepare(
1661 "DELETE FROM {$wpdb->prefix}mwai_chats WHERE updated < %s",
1662 $three_months_ago
1663 ) );
1664 $results[] = "Deleted $deleted_chats old chat discussions";
1665
1666 // Optimize tables
1667 $tables = [ 'mwai_logs', 'mwai_logmeta', 'mwai_vectors', 'mwai_files', 'mwai_filemeta', 'mwai_chats' ];
1668 foreach ( $tables as $table ) {
1669 $wpdb->query( "OPTIMIZE TABLE {$wpdb->prefix}$table" );
1670 }
1671 $results[] = 'Optimized all AI Engine tables';
1672
1673 $message = implode( "\n", $results );
1674 return $this->create_rest_response( [ 'success' => true, 'message' => $message ], 200 );
1675 }
1676 catch ( Exception $e ) {
1677 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1678 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1679 }
1680 }
1681
1682 public function rest_system_templates_get( $request ) {
1683 try {
1684 $params = $request->get_query_params();
1685 $category = $params['category'];
1686 $templates = [];
1687 $templates_option = get_option( 'mwai_templates', [] );
1688 if ( !is_array( $templates_option ) ) {
1689 update_option( 'mwai_templates', [] );
1690 $templates_option = [];
1691 }
1692
1693 // Migration: DALL-E was removed (deprecated by OpenAI). Move templates to the image fallback.
1694 // TODO: Remove after 2027-04 (1 year after the shutdown on 2026-05-12).
1695 $deprecated = [ 'dall-e', 'dall-e-2', 'dall-e-3', 'dall-e-3-hd' ];
1696 $migrated = false;
1697 foreach ( $templates_option as &$group ) {
1698 if ( !empty( $group['templates'] ) && is_array( $group['templates'] ) ) {
1699 foreach ( $group['templates'] as &$template ) {
1700 if ( isset( $template['model'] ) && in_array( $template['model'], $deprecated, true ) ) {
1701 $template['model'] = MWAI_FALLBACK_MODEL_IMAGES;
1702 $migrated = true;
1703 }
1704 }
1705 }
1706 }
1707 unset( $group, $template );
1708 if ( $migrated ) {
1709 update_option( 'mwai_templates', $templates_option );
1710 }
1711
1712 $categories = array_column( $templates_option, 'category' );
1713 $index = array_search( $category, $categories );
1714 $templates = [];
1715 if ( $index !== false ) {
1716 $templates = $templates_option[$index]['templates'];
1717 }
1718 return $this->create_rest_response( [ 'success' => true, 'templates' => $templates ], 200 );
1719 }
1720 catch ( Exception $e ) {
1721 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1722 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1723 }
1724 }
1725
1726 public function rest_system_templates_save( $request ) {
1727 try {
1728 $params = $request->get_json_params();
1729 $category = $params['category'];
1730 $templates = $params['templates'];
1731 $templates_option = get_option( 'mwai_templates', [] );
1732 $categories = array_column( $templates_option, 'category' );
1733 $index = array_search( $category, $categories );
1734 if ( $index !== false && $index >= 0 ) {
1735 $templates_option[$index]['templates'] = $templates;
1736 }
1737 else {
1738 $group = [ 'category' => $category, 'templates' => $templates ];
1739 $templates_option[] = $group;
1740 }
1741
1742 update_option( 'mwai_templates', $templates_option );
1743 return $this->create_rest_response( [ 'success' => true ], 200 );
1744 }
1745 catch ( Exception $e ) {
1746 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1747 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1748 }
1749 }
1750
1751 public function rest_system_logs_list( $request ) {
1752 try {
1753 $params = $request->get_json_params();
1754 $offset = $params['offset'];
1755 $limit = $params['limit'];
1756 $filters = $params['filters'];
1757 $sort = isset( $params['sort'] ) ? $params['sort'] : null;
1758 $logs = apply_filters( 'mwai_stats_logs_list', [], $offset, $limit, $filters, $sort );
1759 return $this->create_rest_response( [ 'success' => true, 'total' => $logs['total'], 'logs' => $logs['rows'] ], 200 );
1760 }
1761 catch ( Exception $e ) {
1762 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1763 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1764 }
1765 }
1766
1767 public function rest_system_logs_delete( $request ) {
1768 try {
1769 $params = $request->get_json_params();
1770 $logIds = $params['logIds'];
1771 $success = apply_filters( 'mwai_stats_logs_delete', true, $logIds );
1772 return $this->create_rest_response( [ 'success' => $success ], 200 );
1773 }
1774 catch ( Exception $e ) {
1775 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1776 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1777 }
1778 }
1779
1780 public function rest_system_logs_meta_get( $request ) {
1781 try {
1782 $params = $request->get_json_params();
1783 $logId = $params['logId'];
1784 $metaKeys = $params['metaKeys'];
1785 $data = apply_filters( 'mwai_stats_logs_meta', [], $logId, $metaKeys );
1786 return $this->create_rest_response( [ 'success' => true, 'data' => $data ], 200 );
1787 }
1788 catch ( Exception $e ) {
1789 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1790 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1791 }
1792 }
1793
1794 public function rest_system_logs_activity( $request ) {
1795 try {
1796 $params = $request->get_json_params();
1797 $hours = isset( $params['hours'] ) ? intval( $params['hours'] ) : 24;
1798 $data = apply_filters( 'mwai_stats_logs_activity', [], $hours );
1799 return $this->create_rest_response( [ 'success' => true, 'data' => $data ], 200 );
1800 }
1801 catch ( Exception $e ) {
1802 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1803 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1804 }
1805 }
1806
1807 public function rest_system_logs_activity_daily( $request ) {
1808 try {
1809 $params = $request->get_json_params();
1810 $days = isset( $params['days'] ) ? intval( $params['days'] ) : 31;
1811 $byModel = isset( $params['byModel'] ) ? (bool) $params['byModel'] : false;
1812 $feature = isset( $params['feature'] ) ? sanitize_text_field( $params['feature'] ) : null;
1813
1814 if ( $byModel ) {
1815 $data = apply_filters( 'mwai_stats_logs_activity_daily_by_model', [], $days );
1816 }
1817 else {
1818 $data = apply_filters( 'mwai_stats_logs_activity_daily', [], $days, $feature );
1819 }
1820
1821 return $this->create_rest_response( [ 'success' => true, 'data' => $data ], 200 );
1822 }
1823 catch ( Exception $e ) {
1824 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1825 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1826 }
1827 }
1828
1829 public function rest_ai_moderate( $request ) {
1830 try {
1831 $params = $request->get_json_params();
1832 $envId = $params['envId'];
1833 $text = $params['text'];
1834 if ( !$text ) {
1835 return $this->create_rest_response( [ 'success' => false, 'message' => 'Text not found.' ], 404 );
1836 }
1837 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1838 $results = $openai->moderate( $text );
1839 return $this->create_rest_response( [ 'success' => true, 'results' => $results ], 200 );
1840 }
1841 catch ( Exception $e ) {
1842 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1843 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1844 }
1845 }
1846
1847 public function rest_ai_transcribe_audio( $request ) {
1848 try {
1849 global $mwai;
1850 // A client-supplied path is deliberately ignored. It used to reach
1851 // file_get_contents() with only a stream-wrapper blocklist in front of it, so an
1852 // absolute path or ../ went straight through and the bytes were forwarded to the
1853 // configured transcription endpoint. On multisite that let a subsite administrator
1854 // read the network wp-config.php and exfiltrate the auth salts off-host. This is
1855 // the same class as CVE-2024-38791, which the image handler below already dropped
1856 // its path for. mediaId stays: it resolves through get_attached_file() on a real
1857 // attachment instead of an arbitrary string.
1858 //
1859 // Dropping the local $path alone was not enough (the 3.6.4 fix, bypassed): $params
1860 // is forwarded to simpleTranscribeAudio(), whose inject_params() runs before the
1861 // "URL or path required" guard and repopulates $query->path from $params['path'],
1862 // which the engine then reads. The key has to leave the array itself.
1863 $params = Meow_MWAI_Core::sanitize_rest_params( $request->get_json_params() );
1864 $url = !empty( $params['url'] ) ? $params['url'] : null;
1865 $mediaId = isset( $params['mediaId'] ) ? intval( $params['mediaId'] ) : 0;
1866 $path = null;
1867
1868 // If mediaId is provided, get the file path
1869 if ( !$path && $mediaId > 0 ) {
1870 $path = get_attached_file( $mediaId );
1871 if ( empty( $path ) ) {
1872 throw new Exception( __( 'The media file cannot be found.', 'ai-engine' ) );
1873 }
1874 }
1875
1876 // Set the scope for admin tools
1877 if ( !isset( $params['scope'] ) ) {
1878 $params['scope'] = 'admin-tools';
1879 }
1880
1881 $result = $mwai->simpleTranscribeAudio( $url, $path, $params );
1882 return $this->create_rest_response( [ 'success' => true, 'data' => $result ], 200 );
1883 }
1884 catch ( Exception $e ) {
1885 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1886 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1887 }
1888 }
1889
1890 public function rest_ai_transcribe_image( $request ) {
1891 try {
1892 global $mwai;
1893 $params = $request->get_json_params();
1894 $message = $this->retrieve_message( $params );
1895 $url = !empty( $params['url'] ) ? $params['url'] : null;
1896 // This could lead to a security issue, so let's avoid using path directly.
1897 //$path = !empty( $params['path'] ) ? $params['path'] : null;
1898 $result = $mwai->simpleVisionQuery( $message, $url );
1899 return $this->create_rest_response( [ 'success' => true, 'data' => $result ], 200 );
1900 }
1901 catch ( Exception $e ) {
1902 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1903 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1904 }
1905 }
1906
1907 public function rest_ai_json( $request ) {
1908 try {
1909 global $mwai;
1910 $params = $request->get_json_params();
1911 $message = $this->retrieve_message( $params );
1912 $result = $mwai->simpleJsonQuery( $message );
1913 return $this->create_rest_response( [ 'success' => true, 'data' => $result ], 200 );
1914 }
1915 catch ( Exception $e ) {
1916 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1917 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1918 }
1919 }
1920
1921 public function rest_mcp_functions( $request ) {
1922 try {
1923 // Get all registered MCP tools. Handlers key entries by tool name, so the
1924 // array is associative; array_values() forces a JSON array (not an object)
1925 // for the client, which groups them with functions.reduce().
1926 $tools = array_values( apply_filters( 'mwai_mcp_tools', [] ) );
1927
1928 // Format the response
1929 $response = [
1930 'success' => true,
1931 'count' => count( $tools ),
1932 'functions' => $tools
1933 ];
1934
1935 return $this->create_rest_response( $response, 200 );
1936 }
1937 catch ( Exception $e ) {
1938 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1939 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1940 }
1941 }
1942
1943 /**
1944 * Loopback test that mimics the way Anthropic's claude.ai connector reaches
1945 * the site, so admins can detect a hosting-layer block without waiting until
1946 * they try to connect from claude.ai. Two stages, because WAFs filter them
1947 * independently and real tickets showed both patterns:
1948 *
1949 * 1. GET on the OAuth discovery path with the python-httpx User-Agent
1950 * (WP Engine's WAF blocks this one).
1951 * 2. POST on /wp-json/mcp/v1/http, python-httpx vs neutral User-Agent
1952 * (Bluehost's mod_security lets discovery GETs through but 403s the
1953 * POSTs, so OAuth client registration and every MCP call fail while
1954 * stage 1 looks fine). Both POSTs are unauthenticated, so WordPress
1955 * itself answers them identically: any python-only difference in
1956 * status or content type is the firewall talking.
1957 */
1958 public function rest_mcp_self_test( $request ) {
1959 try {
1960 $resource_url = rest_url( 'mcp/v1/http' );
1961 $probe_url = home_url( '/.well-known/oauth-protected-resource' . wp_parse_url( $resource_url, PHP_URL_PATH ) );
1962 $reference_url = rest_url( 'mcp/v1/.well-known/oauth-protected-resource' );
1963
1964 $args = [
1965 'timeout' => 10,
1966 'redirection' => 3,
1967 'sslverify' => apply_filters( 'mwai_mcp_self_test_sslverify', true ),
1968 'user-agent' => 'python-httpx/0.28.1',
1969 'headers' => [
1970 'Accept' => 'application/json',
1971 ],
1972 ];
1973
1974 $probe_response = wp_remote_get( $probe_url, $args );
1975 $reference_args = $args;
1976 $reference_args['user-agent'] = 'AI-Engine-Self-Test/1.0';
1977 $reference_response = wp_remote_get( $reference_url, $reference_args );
1978
1979 $build = function ( $url, $response ) {
1980 if ( is_wp_error( $response ) ) {
1981 return [
1982 'url' => $url,
1983 'reachable' => false,
1984 'error' => $response->get_error_message(),
1985 'status' => null,
1986 'content_type' => null,
1987 ];
1988 }
1989 return [
1990 'url' => $url,
1991 'reachable' => true,
1992 'status' => (int) wp_remote_retrieve_response_code( $response ),
1993 'content_type' => wp_remote_retrieve_header( $response, 'content-type' ),
1994 ];
1995 };
1996
1997 $probe = $build( $probe_url, $probe_response );
1998 $reference = $build( $reference_url, $reference_response );
1999
2000 // Stage 2: the POST pair. An unauthenticated initialize is harmless and
2001 // never reaches a tool; we only care whether the firewall lets it in.
2002 $post_body = wp_json_encode( [
2003 'jsonrpc' => '2.0',
2004 'id' => 1,
2005 'method' => 'initialize',
2006 'params' => [
2007 'protocolVersion' => '2025-03-26',
2008 'capabilities' => new stdClass(),
2009 'clientInfo' => [ 'name' => 'ai-engine-self-test', 'version' => '1.0' ],
2010 ],
2011 ] );
2012 $post_args = [
2013 'timeout' => 10,
2014 'redirection' => 3,
2015 'sslverify' => apply_filters( 'mwai_mcp_self_test_sslverify', true ),
2016 'user-agent' => 'python-httpx/0.28.1',
2017 'headers' => [
2018 'Accept' => 'application/json, text/event-stream',
2019 'Content-Type' => 'application/json',
2020 ],
2021 'body' => $post_body,
2022 ];
2023 $post_probe = $build( $resource_url, wp_remote_post( $resource_url, $post_args ) );
2024 $post_reference_args = $post_args;
2025 $post_reference_args['user-agent'] = 'AI-Engine-Self-Test/1.0';
2026 $post_reference = $build( $resource_url, wp_remote_post( $resource_url, $post_reference_args ) );
2027
2028 // Stage 3: the name the real connector sends. Claude.ai identifies itself as
2029 // Claude-User, and Cloudflare's AI bot blocking (Security > Bots) matches that
2030 // name alongside ClaudeBot and GPTBot. A site can pass every python-httpx probe
2031 // above and still 403 the actual connector, so probe the crawler name too,
2032 // against the URL the reference has already proven answers fine.
2033 $ai_args = $reference_args;
2034 $ai_args['user-agent'] = 'Claude-User/1.0';
2035 $ai_probe = $build( $reference_url, wp_remote_get( $reference_url, $ai_args ) );
2036 $ai_blocked = $reference['reachable'] && (
2037 !$ai_probe['reachable']
2038 || ( $ai_probe['status'] !== $reference['status']
2039 && in_array( $ai_probe['status'], [ 403, 406, 418, 429 ], true ) )
2040 );
2041
2042 // We run on the server, so we can answer directly what an HTTP probe can only infer:
2043 // is there a second .htaccess inside .well-known? Apache does not apply the rewrite
2044 // rules of the root file to a directory carrying its own .htaccess, so a perfectly
2045 // correct rule in the main file sits there doing nothing, and the user has no way to
2046 // see why. Hosts create that folder for SSL certificate renewals.
2047 $nested_htaccess = false;
2048 $wellknown_htaccess = ABSPATH . '.well-known/.htaccess';
2049 if ( @is_dir( ABSPATH . '.well-known' ) && @is_file( $wellknown_htaccess ) ) {
2050 $nested_htaccess = $wellknown_htaccess;
2051 }
2052
2053 $is_json = function ( $probe ) {
2054 return $probe['content_type'] && strpos( $probe['content_type'], 'json' ) !== false;
2055 };
2056 // The firewall reveals itself by treating the python UA differently from
2057 // the neutral one: different status, or a block page instead of the JSON
2058 // that WordPress returns to both unauthenticated POSTs.
2059 $post_blocked = ( $post_reference['reachable'] && (
2060 ( !$post_probe['reachable'] )
2061 || ( $post_probe['status'] !== $post_reference['status'] && in_array( $post_probe['status'], [ 403, 406, 418, 429 ], true ) )
2062 || ( $is_json( $post_reference ) && !$is_json( $post_probe ) )
2063 ) );
2064
2065 // A 404 on discovery has two very different causes, and they need opposite fixes.
2066 // Either the hosting layer never lets the path reach WordPress, or a page cache
2067 // stored a 404 from a moment when it legitimately was one (the plugin inactive,
2068 // mid-update) and now serves that hit forever. Repeating the request with a
2069 // throwaway query string separates them: same path, different cache key. If the
2070 // busted request answers properly, WordPress was always fine and the cache is the
2071 // problem. Seen on LiteSpeed, which returns x-litespeed-cache: hit on the stale
2072 // 404 while honouring our no-cache headers on the fresh response.
2073 $cache_probe = null;
2074 if ( $probe['reachable'] && $probe['status'] === 404 ) {
2075 $busted_url = add_query_arg( 'mwai_cb', (string) time(), $probe_url );
2076 $cache_probe = $build( $busted_url, wp_remote_get( $busted_url, $args ) );
2077 }
2078 $stale_404_cached = $cache_probe && $cache_probe['reachable'] && $cache_probe['status'] === 200;
2079
2080 $verdict = 'unknown';
2081 $message = '';
2082 if ( $stale_404_cached ) {
2083 $verdict = 'wellknown_cached_404';
2084 $message = 'Your OAuth discovery path returns 404, but the same URL with a query string added returns the correct response. That means WordPress is answering fine and a cache is serving an old 404 in front of it, which is why connecting works sometimes and not others. Fix: purge your page cache and your CDN, then exclude /.well-known/ from caching. In LiteSpeed Cache that is Cache > Excludes > Do Not Cache URIs. Without the exclusion it will come back the next time a 404 gets cached.';
2085 }
2086 else if ( $probe['reachable'] && $probe['status'] === 403 ) {
2087 $verdict = 'waf_blocks_python_ua';
2088 $message = 'Your host returned 403 to a User-Agent containing "python" on the OAuth discovery path. Claude.ai uses python-httpx as its outbound HTTP client, so its connector will fail with "Couldn\'t reach the MCP server". This is a common default on WP Engine. Fix: add a Cloudflare Transform Rule that rewrites the User-Agent for /.well-known/oauth-* and /wp-json/mcp/v1/* paths before the request reaches your origin. See https://meowapps.com/fix-mcp-wordpress-connection for the full recipe.';
2089 }
2090 else if ( $probe['reachable'] && $probe['status'] === 404 ) {
2091 $verdict = 'wellknown_blocked';
2092 $verdict = $nested_htaccess ? 'wellknown_blocked_nested_htaccess' : 'wellknown_blocked';
2093 $message = 'Your host returned 404 for the host-root /.well-known/oauth-protected-resource path, so the request never reaches WordPress. ';
2094 if ( $nested_htaccess ) {
2095 $message .= 'We found why: there is a second .htaccess inside your .well-known folder, at ' . esc_html( $nested_htaccess ) . '. '
2096 . 'Apache stops applying the rules from your main .htaccess to a folder that has its own, so any rewrite you add to the main file is ignored for these paths, however correct it is. '
2097 . 'Your host created that folder for SSL certificate renewals. Fix: rename that file to htaccess-old. Renewals keep working, because those are real files that nothing rewrites.';
2098 }
2099 else {
2100 $message .= 'This usually means your hosting layer (.htaccess, nginx config, or a security plugin) intercepts /.well-known/* paths before WordPress sees them. Adjust rewrites so the path reaches index.php. '
2101 . 'We checked and there is no .htaccess inside your .well-known folder, so the interception is happening in your server or CDN configuration rather than in a file you can edit. Your host can fix it with one sentence: let /.well-known/* fall through to WordPress.';
2102 }
2103 }
2104 else if ( !$probe['reachable'] ) {
2105 $verdict = 'unreachable';
2106 $message = 'The loopback request could not reach the site at all (' . esc_html( $probe['error'] ) . '). Check that the site is publicly resolvable and that the server can reach itself over HTTPS.';
2107 }
2108 else if ( $probe['status'] === 200 && $post_blocked ) {
2109 $verdict = 'waf_blocks_python_post';
2110 $message = 'OAuth discovery works, but your host blocks POST requests from the python-httpx User-Agent on /wp-json/mcp/v1/*. Claude.ai\'s connector will pass discovery and then fail at client registration ("Couldn\'t register with your sign-in service") or on the first MCP call. This pattern is common with mod_security on shared hosts (seen on Bluehost). Fix: ask your host to whitelist POST requests on the /wp-json/mcp/v1/ path, or add a Cloudflare Transform Rule that rewrites the User-Agent for /.well-known/oauth-* and /wp-json/mcp/v1/* before the request reaches your origin. See https://meowapps.com/fix-mcp-wordpress-connection for the full recipe.';
2111 }
2112 else if ( $probe['status'] === 200 ) {
2113 $verdict = 'ok';
2114 $message = 'Your site accepts the python-httpx and Claude-User User-Agents on both the OAuth discovery path (GET) and the MCP endpoint (POST). Claude.ai\'s connector should be able to reach it. '
2115 . 'One caveat: these checks run from your server to itself, and many hosts resolve their own domain straight to the origin, so a block that lives at your CDN can pass here and still refuse the real connector. '
2116 . 'If connecting still fails, run the same requests from an external machine, and check whether your CDN blocks AI crawlers by name (in Cloudflare: Security > Bots). Blocking AI crawlers also blocks your own connector.';
2117 }
2118 else {
2119 $verdict = 'unexpected_status';
2120 $message = 'Got HTTP ' . $probe['status'] . ' from the loopback probe. Expected 200. Investigate the response in your CDN/origin logs.';
2121 }
2122
2123 // A site that answers on both apex and www advertises only one of them in its OAuth
2124 // metadata, because everything there is built from home_url(). A user who types the
2125 // other spelling gets told the resource lives on a different origin than the one they
2126 // entered, which RFC 9728 requires the client to check, and strict clients stop there
2127 // with no error the user can see. Cheap to detect, impossible to guess from outside.
2128 $canonical_host = wp_parse_url( home_url(), PHP_URL_HOST );
2129 $other_host = $canonical_host && strpos( $canonical_host, 'www.' ) === 0
2130 ? substr( $canonical_host, 4 ) : 'www.' . $canonical_host;
2131 $other_probe = null;
2132 if ( $canonical_host ) {
2133 $other_url = str_replace( '//' . $canonical_host, '//' . $other_host, $reference_url );
2134 // Do NOT follow redirects here. A site that correctly sends www to its canonical
2135 // host would otherwise answer 200 at the end of the redirect and look exactly like
2136 // a site serving both, which is the opposite of the problem we are looking for.
2137 // Only a direct 200 on the other spelling means both hosts really serve the site.
2138 $other_args = $reference_args;
2139 $other_args['redirection'] = 0;
2140 $other_probe = $build( $other_url, wp_remote_get( $other_url, $other_args ) );
2141 }
2142 if ( $other_probe && $other_probe['reachable'] && $other_probe['status'] === 200 ) {
2143 $message .= ' Also worth knowing: your site answers on both ' . esc_html( $canonical_host )
2144 . ' and ' . esc_html( $other_host ) . ', but its OAuth metadata only ever advertises '
2145 . esc_html( $canonical_host ) . ', because that is your WordPress address. '
2146 . 'Connect your client using exactly that spelling, otherwise it is told the server lives somewhere else and may refuse without explaining why.';
2147 }
2148
2149 // Reported on top of whatever else is wrong, because it is a separate blocker with a
2150 // separate fix: a site can have perfect discovery and still refuse the connector.
2151 if ( $ai_blocked ) {
2152 $ai_message = 'Your site answers the OAuth discovery URL normally to a neutral User-Agent but returns HTTP '
2153 . ( $ai_probe['reachable'] ? $ai_probe['status'] : 'no response' )
2154 . ' to "Claude-User". That is the name Claude.ai\'s connector sends, so it will be refused even once everything else works. '
2155 . 'The usual cause is Cloudflare\'s AI bot blocking (Security > Bots), which matches Claude-User, ClaudeBot and GPTBot. '
2156 . 'Fix: turn that off, or add a skip rule covering /.well-known/oauth-* and /wp-json/mcp/v1/*. '
2157 . 'Note that blocking AI crawlers also blocks your own connector.';
2158 if ( $verdict === 'ok' ) {
2159 $verdict = 'waf_blocks_ai_ua';
2160 $message = $ai_message;
2161 }
2162 else {
2163 $message .= ' ' . $ai_message;
2164 }
2165 }
2166
2167 return $this->create_rest_response( [
2168 'success' => true,
2169 'verdict' => $verdict,
2170 'message' => $message,
2171 'probe' => $probe,
2172 'reference' => $reference,
2173 'post_probe' => $post_probe,
2174 'post_reference' => $post_reference,
2175 'ai_probe' => $ai_probe,
2176 'nested_htaccess' => $nested_htaccess,
2177 'cache_probe' => $cache_probe,
2178 ], 200 );
2179 }
2180 catch ( Exception $e ) {
2181 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
2182 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
2183 }
2184 }
2185
2186 /**
2187 * Top MCP tools by call count over the last N days. Powers the
2188 * "Top Tools" widget in the MCP Logs view of the Insights screen.
2189 * Returns rows shaped as { tool, count, success_count, error_count }.
2190 */
2191 public function rest_mcp_top_tools( $request ) {
2192 try {
2193 $params = $request->get_json_params();
2194 $days = isset( $params['days'] ) ? max( 1, intval( $params['days'] ) ) : 7;
2195 $limit = isset( $params['limit'] ) ? max( 1, min( 50, intval( $params['limit'] ) ) ) : 10;
2196
2197 global $wpdb;
2198 $table = $wpdb->prefix . 'mwai_logs';
2199 $rows = $wpdb->get_results(
2200 $wpdb->prepare(
2201 "SELECT scope AS tool,
2202 COUNT(*) AS count,
2203 SUM(CASE WHEN stats LIKE %s THEN 1 ELSE 0 END) AS success_count,
2204 SUM(CASE WHEN stats LIKE %s THEN 1 ELSE 0 END) AS error_count
2205 FROM $table
2206 WHERE feature = 'mcp_tool'
2207 AND time >= DATE_SUB(NOW(), INTERVAL %d DAY)
2208 GROUP BY scope
2209 ORDER BY count DESC
2210 LIMIT %d",
2211 '%"status":"success"%',
2212 '%"status":"error"%',
2213 $days,
2214 $limit
2215 ),
2216 ARRAY_A
2217 );
2218
2219 foreach ( $rows as &$row ) {
2220 $row['count'] = (int) $row['count'];
2221 $row['success_count'] = (int) $row['success_count'];
2222 $row['error_count'] = (int) $row['error_count'];
2223 }
2224 unset( $row );
2225
2226 return $this->create_rest_response( [ 'success' => true, 'tools' => $rows ?: [] ], 200 );
2227 }
2228 catch ( Exception $e ) {
2229 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
2230 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
2231 }
2232 }
2233
2234 public function rest_helpers_post_types() {
2235 try {
2236 $postTypes = $this->core->get_post_types();
2237 return $this->create_rest_response( [ 'success' => true, 'postTypes' => $postTypes ], 200 );
2238 }
2239 catch ( Exception $e ) {
2240 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
2241 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
2242 }
2243 }
2244
2245 public function rest_settings_themes( $request ) {
2246 try {
2247 $method = $request->get_method();
2248 if ( $method === 'GET' ) {
2249 $themes = $this->core->get_themes();
2250 return $this->create_rest_response( [ 'success' => true, 'themes' => $themes ], 200 );
2251 }
2252 else if ( $method === 'POST' ) {
2253 $params = $request->get_json_params();
2254 $themes = $params['themes'];
2255 $themes = $this->core->update_themes( $themes );
2256 return $this->create_rest_response( [ 'success' => true, 'themes' => $themes ], 200 );
2257 }
2258 }
2259 catch ( Exception $e ) {
2260 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
2261 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
2262 }
2263 }
2264
2265 public function rest_settings_chatbots( $request ) {
2266 try {
2267 $method = $request->get_method();
2268 if ( $method === 'GET' ) {
2269 $chatbots = $this->core->get_chatbots();
2270 return $this->create_rest_response( [ 'success' => true, 'chatbots' => $chatbots ], 200 );
2271 }
2272 else if ( $method === 'POST' ) {
2273 $params = $request->get_json_params();
2274 $chatbots = $params['chatbots'];
2275 $chatbots = $this->core->update_chatbots( $chatbots );
2276 return $this->create_rest_response( [ 'success' => true, 'chatbots' => $chatbots ], 200 );
2277 }
2278 return $this->create_rest_response( [ 'success' => false, 'message' => 'Method not allowed' ], 405 );
2279 }
2280 catch ( Exception $e ) {
2281 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
2282 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
2283 }
2284 }
2285
2286 #region Logs
2287
2288 public function rest_get_logs() {
2289 $logs = Meow_MWAI_Logging::get();
2290 return $this->create_rest_response( [ 'success' => true, 'data' => $logs ], 200 );
2291 }
2292
2293 public function rest_clear_logs() {
2294 Meow_MWAI_Logging::clear();
2295 return $this->create_rest_response( [ 'success' => true ], 200 );
2296 }
2297
2298 #endregion
2299
2300 #region Forms
2301
2302 public function rest_forms_list( $request ) {
2303 try {
2304 $args = [
2305 'post_type' => 'mwai_form',
2306 'posts_per_page' => 100,
2307 'post_status' => 'any',
2308 'orderby' => 'date',
2309 'order' => 'DESC'
2310 ];
2311
2312 $posts = get_posts( $args );
2313 $forms = array_map( function ( $post ) {
2314 return [
2315 'id' => $post->ID,
2316 'title' => $post->post_title,
2317 'status' => $post->post_status
2318 ];
2319 }, $posts );
2320
2321 return $this->create_rest_response( [ 'success' => true, 'forms' => $forms ], 200 );
2322 }
2323 catch ( Exception $e ) {
2324 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2325 }
2326 }
2327
2328 public function rest_forms_get( $request ) {
2329 try {
2330 $id = intval( $request->get_param( 'id' ) );
2331 if ( !$id ) {
2332 return $this->create_rest_response( [ 'success' => false, 'message' => 'Invalid form ID' ], 400 );
2333 }
2334
2335 $post = get_post( $id );
2336 if ( !$post || $post->post_type !== 'mwai_form' ) {
2337 return $this->create_rest_response( [ 'success' => false, 'message' => 'Form not found' ], 404 );
2338 }
2339
2340 $form = [
2341 'id' => $post->ID,
2342 'title' => [
2343 'raw' => $post->post_title,
2344 'rendered' => $post->post_title
2345 ],
2346 'content' => [
2347 'raw' => $post->post_content,
2348 'rendered' => $post->post_content
2349 ],
2350 'status' => $post->post_status
2351 ];
2352
2353 return $this->create_rest_response( [ 'success' => true, 'form' => $form ], 200 );
2354 }
2355 catch ( Exception $e ) {
2356 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2357 }
2358 }
2359
2360 public function rest_forms_create( $request ) {
2361 try {
2362 $params = $request->get_json_params();
2363 $title = isset( $params['title'] ) ? $params['title'] : 'Untitled Form';
2364
2365 // wp_insert_post expects slashed data - it calls wp_unslash() internally, which would
2366 // otherwise strip backslashes from block-comment JSON escapes (e.g. \n → n) and corrupt
2367 // the stored blocks.
2368 $post_data = wp_slash( [
2369 'post_title' => $title,
2370 'post_content' => '',
2371 'post_status' => 'draft',
2372 'post_type' => 'mwai_form'
2373 ] );
2374
2375 $post_id = wp_insert_post( $post_data );
2376
2377 if ( is_wp_error( $post_id ) ) {
2378 return $this->create_rest_response( [ 'success' => false, 'message' => $post_id->get_error_message() ], 500 );
2379 }
2380
2381 $post = get_post( $post_id );
2382 $form = [
2383 'id' => $post->ID,
2384 'title' => [
2385 'raw' => $post->post_title,
2386 'rendered' => $post->post_title
2387 ],
2388 'status' => $post->post_status
2389 ];
2390
2391 return $this->create_rest_response( [ 'success' => true, 'form' => $form ], 200 );
2392 }
2393 catch ( Exception $e ) {
2394 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2395 }
2396 }
2397
2398 public function rest_forms_update( $request ) {
2399 try {
2400 $params = $request->get_json_params();
2401 $id = isset( $params['id'] ) ? intval( $params['id'] ) : 0;
2402
2403 if ( !$id ) {
2404 return $this->create_rest_response( [ 'success' => false, 'message' => 'Invalid form ID' ], 400 );
2405 }
2406
2407 $post = get_post( $id );
2408 if ( !$post || $post->post_type !== 'mwai_form' ) {
2409 return $this->create_rest_response( [ 'success' => false, 'message' => 'Form not found' ], 404 );
2410 }
2411
2412 $post_data = [ 'ID' => $id ];
2413
2414 if ( isset( $params['title'] ) ) {
2415 $post_data['post_title'] = $params['title'];
2416 }
2417
2418 if ( isset( $params['content'] ) ) {
2419 $post_data['post_content'] = $params['content'];
2420 }
2421
2422 if ( isset( $params['status'] ) ) {
2423 $post_data['post_status'] = $params['status'];
2424 }
2425
2426 // wp_update_post expects slashed data - it calls wp_unslash() internally, which would
2427 // otherwise strip backslashes from block-comment JSON escapes (e.g. \n → n) and break
2428 // Gutenberg blocks on reload.
2429 $result = wp_update_post( wp_slash( $post_data ) );
2430
2431 if ( is_wp_error( $result ) ) {
2432 return $this->create_rest_response( [ 'success' => false, 'message' => $result->get_error_message() ], 500 );
2433 }
2434
2435 $post = get_post( $id );
2436 $form = [
2437 'id' => $post->ID,
2438 'title' => [
2439 'raw' => $post->post_title,
2440 'rendered' => $post->post_title
2441 ],
2442 'content' => [
2443 'raw' => $post->post_content,
2444 'rendered' => $post->post_content
2445 ],
2446 'status' => $post->post_status
2447 ];
2448
2449 return $this->create_rest_response( [ 'success' => true, 'form' => $form ], 200 );
2450 }
2451 catch ( Exception $e ) {
2452 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2453 }
2454 }
2455
2456 public function rest_forms_delete( $request ) {
2457 try {
2458 $params = $request->get_json_params();
2459 $id = isset( $params['id'] ) ? intval( $params['id'] ) : 0;
2460
2461 if ( !$id ) {
2462 return $this->create_rest_response( [ 'success' => false, 'message' => 'Invalid form ID' ], 400 );
2463 }
2464
2465 $post = get_post( $id );
2466 if ( !$post || $post->post_type !== 'mwai_form' ) {
2467 return $this->create_rest_response( [ 'success' => false, 'message' => 'Form not found' ], 404 );
2468 }
2469
2470 $result = wp_delete_post( $id, true );
2471
2472 if ( !$result ) {
2473 return $this->create_rest_response( [ 'success' => false, 'message' => 'Failed to delete form' ], 500 );
2474 }
2475
2476 return $this->create_rest_response( [ 'success' => true ], 200 );
2477 }
2478 catch ( Exception $e ) {
2479 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2480 }
2481 }
2482
2483 #endregion
2484
2485 #region Video Generation Helpers
2486
2487 public function rest_helpers_create_video( $request ) {
2488 try {
2489 $params = $request->get_json_params();
2490 $prompt = sanitize_text_field( $params['prompt'] );
2491 $model = sanitize_text_field( $params['model'] ?? 'sora-2' );
2492 $size = sanitize_text_field( $params['size'] ?? '720x1280' );
2493 $seconds = absint( $params['seconds'] ?? 4 );
2494 $envId = sanitize_text_field( $params['envId'] ?? '' );
2495
2496 // Check if envId is provided (defaults not supported for videos yet)
2497 if ( empty( $envId ) ) {
2498 throw new Exception( 'Please select a specific environment and model in the Video Generator. Default environments are not yet supported for video generation.' );
2499 }
2500
2501 // Get API key from environment
2502 $env = $this->core->get_ai_env( $envId );
2503 $api_key = $env['apikey'] ?? '';
2504
2505 if ( empty( $api_key ) ) {
2506 throw new Exception( 'OpenAI API key not found.' );
2507 }
2508
2509 // Prepare multipart boundary
2510 $boundary = wp_generate_password( 24, false );
2511 $body = '';
2512
2513 // Add model
2514 $body .= "--{$boundary}\r\n";
2515 $body .= "Content-Disposition: form-data; name=\"model\"\r\n\r\n";
2516 $body .= "{$model}\r\n";
2517
2518 // Add prompt
2519 $body .= "--{$boundary}\r\n";
2520 $body .= "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n";
2521 $body .= "{$prompt}\r\n";
2522
2523 // Add size
2524 $body .= "--{$boundary}\r\n";
2525 $body .= "Content-Disposition: form-data; name=\"size\"\r\n\r\n";
2526 $body .= "{$size}\r\n";
2527
2528 // Add seconds
2529 $body .= "--{$boundary}\r\n";
2530 $body .= "Content-Disposition: form-data; name=\"seconds\"\r\n\r\n";
2531 $body .= "{$seconds}\r\n";
2532
2533 $body .= "--{$boundary}--\r\n";
2534
2535 // Call OpenAI API to create video
2536 $response = wp_remote_post( 'https://api.openai.com/v1/videos', [
2537 'headers' => [
2538 'Authorization' => 'Bearer ' . $api_key,
2539 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
2540 ],
2541 'body' => $body,
2542 'timeout' => 30
2543 ] );
2544
2545 if ( is_wp_error( $response ) ) {
2546 throw new Exception( $response->get_error_message() );
2547 }
2548
2549 $response_body = json_decode( wp_remote_retrieve_body( $response ), true );
2550
2551 if ( isset( $response_body['error'] ) ) {
2552 throw new Exception( $response_body['error']['message'] ?? 'Unknown error' );
2553 }
2554
2555 // Record usage (price is calculated per second)
2556 $usage = $this->core->record_videos_usage( $model, $size, $seconds );
2557
2558 // Log to Query Logs (Statistics)
2559 try {
2560 if ( class_exists( 'MeowPro_MWAI_Stats' ) && class_exists( 'MeowPro_MWAI_Statistics' ) ) {
2561 $statsObject = new MeowPro_MWAI_Stats();
2562 $statsObject->session = $params['session'] ?? null;
2563 $statsObject->scope = 'admin-tools';
2564 $statsObject->feature = 'video-generator';
2565 $statsObject->model = $model;
2566 $statsObject->envId = $envId;
2567 $statsObject->units = $seconds;
2568 $statsObject->type = 'seconds';
2569 $statsObject->price = $usage['price'] ?? 0;
2570 $statsObject->accuracy = $usage['accuracy'] ?? 'full';
2571
2572 $statistics = new MeowPro_MWAI_Statistics();
2573 $statistics->commit_stats( $statsObject );
2574 }
2575 }
2576 catch ( Exception $statsError ) {
2577 // Log the error but don't fail the video creation
2578 error_log( '[AI Engine Video] Failed to log statistics: ' . $statsError->getMessage() );
2579 }
2580
2581 // Store metadata for later retrieval when video completes
2582 if ( isset( $response_body['id'] ) ) {
2583 set_transient( 'mwai_video_metadata_' . $response_body['id'], [
2584 'model' => $model,
2585 'env_id' => $envId,
2586 'created_at' => time()
2587 ], 7 * DAY_IN_SECONDS );
2588 }
2589
2590 return $this->create_rest_response( [ 'success' => true, 'video' => $response_body, 'usage' => $usage ], 200 );
2591 }
2592 catch ( Exception $e ) {
2593 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2594 }
2595 }
2596
2597 public function rest_helpers_video_status( $request ) {
2598 try {
2599 $params = $request->get_json_params();
2600 $video_ids = $params['videoIds'] ?? [];
2601 $envId = sanitize_text_field( $params['envId'] ?? '' );
2602
2603 if ( empty( $video_ids ) ) {
2604 return $this->create_rest_response( [ 'success' => true, 'videos' => [] ], 200 );
2605 }
2606
2607 // Get API key from environment
2608 $env = $this->core->get_ai_env( $envId );
2609 $api_key = $env['apikey'] ?? '';
2610
2611 if ( empty( $api_key ) ) {
2612 throw new Exception( 'OpenAI API key not found.' );
2613 }
2614
2615 $videos = [];
2616 foreach ( $video_ids as $video_id ) {
2617 $response = wp_remote_get( 'https://api.openai.com/v1/videos/' . $video_id, [
2618 'headers' => [
2619 'Authorization' => 'Bearer ' . $api_key
2620 ],
2621 'timeout' => 15
2622 ] );
2623
2624 if ( !is_wp_error( $response ) ) {
2625 $body = json_decode( wp_remote_retrieve_body( $response ), true );
2626 if ( !isset( $body['error'] ) ) {
2627 // If video is completed and we haven't saved it yet, download and save to media library
2628 if ( $body['status'] === 'completed' && empty( get_transient( 'mwai_video_saved_' . $video_id ) ) ) {
2629 // Retrieve metadata that was stored when video was created
2630 $metadata = get_transient( 'mwai_video_metadata_' . $video_id );
2631 $ai_metadata = [];
2632 if ( $metadata ) {
2633 $ai_metadata = [
2634 'model' => $metadata['model'] ?? null,
2635 'env_id' => $metadata['env_id'] ?? null,
2636 'latency' => isset( $metadata['created_at'] ) ? ( time() - $metadata['created_at'] ) : null
2637 ];
2638 }
2639
2640 $attachment_id = $this->download_and_save_video( $video_id, $api_key, '', '', $ai_metadata );
2641 if ( $attachment_id ) {
2642 $body['attachment_id'] = $attachment_id;
2643 // Build URL from file path for custom post types
2644 $file_path = get_attached_file( $attachment_id );
2645 $upload_dir = wp_upload_dir();
2646 $body['url'] = str_replace( $upload_dir['basedir'], $upload_dir['baseurl'], $file_path );
2647 // Mark as saved so we don't download again
2648 set_transient( 'mwai_video_saved_' . $video_id, $attachment_id, DAY_IN_SECONDS );
2649 // Clean up metadata transient
2650 delete_transient( 'mwai_video_metadata_' . $video_id );
2651 }
2652 }
2653 // Check if we already have this video saved
2654 else if ( $body['status'] === 'completed' ) {
2655 $attachment_id = get_transient( 'mwai_video_saved_' . $video_id );
2656 if ( $attachment_id ) {
2657 $body['attachment_id'] = $attachment_id;
2658 // Build URL from file path for custom post types
2659 $file_path = get_attached_file( $attachment_id );
2660 $upload_dir = wp_upload_dir();
2661 $body['url'] = str_replace( $upload_dir['basedir'], $upload_dir['baseurl'], $file_path );
2662 }
2663 }
2664 $videos[] = $body;
2665 }
2666 else {
2667 // Include error information in the response
2668 error_log( 'AI Engine: Video generation failed for ID ' . $video_id . ': ' . json_encode( $body['error'] ) );
2669 $videos[] = [
2670 'id' => $video_id,
2671 'status' => 'failed',
2672 'error' => $body['error']
2673 ];
2674 }
2675 }
2676 else {
2677 // WP HTTP error
2678 error_log( 'AI Engine: Failed to check video status for ID ' . $video_id . ': ' . $response->get_error_message() );
2679 $videos[] = [
2680 'id' => $video_id,
2681 'status' => 'failed',
2682 'error' => [ 'message' => $response->get_error_message() ]
2683 ];
2684 }
2685 }
2686
2687 return $this->create_rest_response( [ 'success' => true, 'videos' => $videos ], 200 );
2688 }
2689 catch ( Exception $e ) {
2690 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2691 }
2692 }
2693
2694 public function rest_helpers_download_video( $request ) {
2695 try {
2696 $params = $request->get_json_params();
2697 $video_id = sanitize_text_field( $params['videoId'] );
2698 $envId = sanitize_text_field( $params['envId'] ?? '' );
2699
2700 // Get API key from environment
2701 $env = $this->core->get_ai_env( $envId );
2702 $api_key = $env['apikey'] ?? '';
2703
2704 if ( empty( $api_key ) ) {
2705 throw new Exception( 'OpenAI API key not found.' );
2706 }
2707
2708 $temp_file = wp_tempnam( $video_id . '.mp4' );
2709
2710 $response = wp_remote_get( 'https://api.openai.com/v1/videos/' . $video_id . '/content', [
2711 'headers' => [
2712 'Authorization' => 'Bearer ' . $api_key
2713 ],
2714 'timeout' => 120,
2715 'stream' => true,
2716 'filename' => $temp_file
2717 ] );
2718
2719 if ( is_wp_error( $response ) ) {
2720 throw new Exception( $response->get_error_message() );
2721 }
2722
2723 $file_data = file_get_contents( $temp_file );
2724 $base64 = base64_encode( $file_data );
2725
2726 unlink( $temp_file );
2727
2728 return $this->create_rest_response( [
2729 'success' => true,
2730 'data' => $base64,
2731 'mimeType' => 'video/mp4'
2732 ], 200 );
2733 }
2734 catch ( Exception $e ) {
2735 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2736 }
2737 }
2738
2739 public function rest_helpers_delete_video( $request ) {
2740 try {
2741 $params = $request->get_json_params();
2742 $video_id = sanitize_text_field( $params['videoId'] );
2743 $envId = sanitize_text_field( $params['envId'] ?? '' );
2744
2745 // Get API key from environment
2746 $env = $this->core->get_ai_env( $envId );
2747 $api_key = $env['apikey'] ?? '';
2748
2749 if ( empty( $api_key ) ) {
2750 throw new Exception( 'OpenAI API key not found.' );
2751 }
2752
2753 $response = wp_remote_request( 'https://api.openai.com/v1/videos/' . $video_id, [
2754 'method' => 'DELETE',
2755 'headers' => [
2756 'Authorization' => 'Bearer ' . $api_key
2757 ],
2758 'timeout' => 15
2759 ] );
2760
2761 if ( is_wp_error( $response ) ) {
2762 throw new Exception( $response->get_error_message() );
2763 }
2764
2765 return $this->create_rest_response( [ 'success' => true ], 200 );
2766 }
2767 catch ( Exception $e ) {
2768 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2769 }
2770 }
2771
2772 private function download_and_save_video( $video_id, $api_key, $title = '', $description = '', $ai_metadata = [] ) {
2773 try {
2774 // Download video content
2775 $response = wp_remote_get( 'https://api.openai.com/v1/videos/' . $video_id . '/content', [
2776 'headers' => [
2777 'Authorization' => 'Bearer ' . $api_key
2778 ],
2779 'timeout' => 120
2780 ] );
2781
2782 if ( is_wp_error( $response ) ) {
2783 error_log( 'Error downloading video: ' . $response->get_error_message() );
2784 return false;
2785 }
2786
2787 $video_data = wp_remote_retrieve_body( $response );
2788 if ( empty( $video_data ) ) {
2789 error_log( 'Empty video data received' );
2790 return false;
2791 }
2792
2793 // Generate filename
2794 $filename = $video_id . '.mp4';
2795 $upload_dir = wp_upload_dir();
2796 $file_path = $upload_dir['path'] . '/' . $filename;
2797
2798 // Save to file
2799 file_put_contents( $file_path, $video_data );
2800
2801 // Prepare attachment data - use mwai_video post type (draft video)
2802 $attachment = [
2803 'post_mime_type' => 'video/mp4',
2804 'post_title' => !empty( $title ) ? $title : 'AI Generated Video',
2805 'post_content' => $description,
2806 'post_status' => 'inherit',
2807 'post_type' => 'mwai_video'
2808 ];
2809
2810 // Use wp_insert_post instead of wp_insert_attachment to allow custom post types
2811 $attachment_id = wp_insert_post( $attachment );
2812
2813 // Set the attached file manually since we're not using wp_insert_attachment
2814 update_attached_file( $attachment_id, $file_path );
2815
2816 if ( is_wp_error( $attachment_id ) ) {
2817 error_log( 'Error creating attachment: ' . $attachment_id->get_error_message() );
2818 return false;
2819 }
2820
2821 // Generate attachment metadata
2822 require_once ABSPATH . 'wp-admin/includes/image.php';
2823 $attach_data = wp_generate_attachment_metadata( $attachment_id, $file_path );
2824 wp_update_attachment_metadata( $attachment_id, $attach_data );
2825
2826 // Store AI-related metadata
2827 if ( !empty( $ai_metadata['model'] ) ) {
2828 update_post_meta( $attachment_id, 'mwai_model', sanitize_text_field( $ai_metadata['model'] ) );
2829 }
2830 if ( !empty( $ai_metadata['latency'] ) ) {
2831 update_post_meta( $attachment_id, 'mwai_latency', floatval( $ai_metadata['latency'] ) );
2832 }
2833 if ( !empty( $ai_metadata['env_id'] ) ) {
2834 update_post_meta( $attachment_id, 'mwai_env_id', sanitize_text_field( $ai_metadata['env_id'] ) );
2835 }
2836
2837 // Add to user's draft media
2838 $user_id = get_current_user_id();
2839 $draft_media = get_user_meta( $user_id, 'mwai_draft_media', true );
2840 if ( !is_array( $draft_media ) ) {
2841 $draft_media = [];
2842 }
2843 $draft_media[] = [
2844 'attachment_id' => $attachment_id,
2845 'type' => 'video',
2846 'openai_id' => $video_id,
2847 'created_at' => time()
2848 ];
2849 update_user_meta( $user_id, 'mwai_draft_media', $draft_media );
2850
2851 return $attachment_id;
2852 }
2853 catch ( Exception $e ) {
2854 error_log( 'Exception in download_and_save_video: ' . $e->getMessage() );
2855 return false;
2856 }
2857 }
2858
2859 public function rest_helpers_save_video_to_library( $request ) {
2860 try {
2861 $params = $request->get_json_params();
2862 $video_id = sanitize_text_field( $params['videoId'] );
2863 $title = sanitize_text_field( $params['title'] );
2864 $description = sanitize_text_field( $params['description'] );
2865 $filename = sanitize_file_name( $params['filename'] );
2866 $envId = sanitize_text_field( $params['envId'] ?? '' );
2867
2868 // Ensure filename has .mp4 extension
2869 if ( !preg_match( '/\.mp4$/i', $filename ) ) {
2870 $filename .= '.mp4';
2871 }
2872
2873 // Get API key from environment
2874 $env = $this->core->get_ai_env( $envId );
2875 $api_key = $env['apikey'] ?? '';
2876
2877 if ( empty( $api_key ) ) {
2878 throw new Exception( 'OpenAI API key not found.' );
2879 }
2880
2881 // Download video content
2882 $response = wp_remote_get( 'https://api.openai.com/v1/videos/' . $video_id . '/content', [
2883 'headers' => [
2884 'Authorization' => 'Bearer ' . $api_key
2885 ],
2886 'timeout' => 120
2887 ] );
2888
2889 if ( is_wp_error( $response ) ) {
2890 throw new Exception( $response->get_error_message() );
2891 }
2892
2893 $video_data = wp_remote_retrieve_body( $response );
2894
2895 // Upload to WordPress media library
2896 $upload_dir = wp_upload_dir();
2897 $file_path = $upload_dir['path'] . '/' . $filename;
2898
2899 file_put_contents( $file_path, $video_data );
2900
2901 $attachment = [
2902 'post_mime_type' => 'video/mp4',
2903 'post_title' => $title,
2904 'post_content' => $description,
2905 'post_status' => 'inherit'
2906 ];
2907
2908 $attach_id = wp_insert_attachment( $attachment, $file_path );
2909
2910 require_once( ABSPATH . 'wp-admin/includes/image.php' );
2911 $attach_data = wp_generate_attachment_metadata( $attach_id, $file_path );
2912 wp_update_attachment_metadata( $attach_id, $attach_data );
2913
2914 return $this->create_rest_response( [
2915 'success' => true,
2916 'attachmentId' => $attach_id
2917 ], 200 );
2918 }
2919 catch ( Exception $e ) {
2920 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2921 }
2922 }
2923
2924 public function rest_helpers_delete_video_from_library( $request ) {
2925 try {
2926 $params = $request->get_json_params();
2927 $attachment_id = absint( $params['attachmentId'] );
2928
2929 if ( empty( $attachment_id ) ) {
2930 throw new Exception( 'Attachment ID is required.' );
2931 }
2932
2933 $deleted = wp_delete_attachment( $attachment_id, true );
2934
2935 if ( !$deleted ) {
2936 throw new Exception( 'Failed to delete attachment.' );
2937 }
2938
2939 return $this->create_rest_response( [ 'success' => true ], 200 );
2940 }
2941 catch ( Exception $e ) {
2942 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2943 }
2944 }
2945
2946 public function rest_helpers_list_draft_media( $request ) {
2947 try {
2948 $type = $request->get_param( 'type' ); // 'image', 'video', or null for all
2949 $user_id = get_current_user_id();
2950 $draft_media = get_user_meta( $user_id, 'mwai_draft_media', true );
2951
2952 if ( !is_array( $draft_media ) ) {
2953 return $this->create_rest_response( [ 'success' => true, 'media' => [] ], 200 );
2954 }
2955
2956 $media_items = [];
2957 foreach ( $draft_media as $item ) {
2958 // Filter by type if specified
2959 if ( $type && $item['type'] !== $type ) {
2960 continue;
2961 }
2962
2963 $attachment_id = $item['attachment_id'];
2964 $attachment = get_post( $attachment_id );
2965
2966 if ( $attachment ) {
2967 // For custom post types (mwai_image, mwai_video), build URL from file path
2968 $file_path = get_attached_file( $attachment_id );
2969 $upload_dir = wp_upload_dir();
2970 $url = str_replace( $upload_dir['basedir'], $upload_dir['baseurl'], $file_path );
2971
2972 $model = get_post_meta( $attachment_id, 'mwai_model', true );
2973 $generation_time = get_post_meta( $attachment_id, 'mwai_latency', true );
2974 $env_id = get_post_meta( $attachment_id, 'mwai_env_id', true );
2975
2976 // Debug logging
2977 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
2978 error_log( '[AI Engine] list_draft_media - attachment_id: ' . $attachment_id . ' model: ' . var_export( $model, true ) . ' generation_time: ' . var_export( $generation_time, true ) . ' env_id: ' . var_export( $env_id, true ) );
2979 }
2980
2981 $media_items[] = [
2982 'attachment_id' => $attachment_id,
2983 'type' => $item['type'],
2984 'openai_id' => $item['openai_id'] ?? null,
2985 'url' => $url,
2986 'title' => $attachment->post_title,
2987 'description' => $attachment->post_content,
2988 'filename' => basename( $file_path ),
2989 'created_at' => $item['created_at'],
2990 'model' => $model,
2991 'generation_time' => $generation_time,
2992 'env_id' => $env_id
2993 ];
2994 }
2995 }
2996
2997 return $this->create_rest_response( [ 'success' => true, 'media' => $media_items ], 200 );
2998 }
2999 catch ( Exception $e ) {
3000 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
3001 }
3002 }
3003
3004 public function rest_helpers_approve_media( $request ) {
3005 try {
3006 $params = $request->get_json_params();
3007 $attachment_id = absint( $params['attachmentId'] );
3008 $openai_id = sanitize_text_field( $params['openaiId'] ?? '' );
3009 $envId = sanitize_text_field( $params['envId'] ?? '' );
3010
3011 if ( empty( $attachment_id ) ) {
3012 throw new Exception( 'Attachment ID is required.' );
3013 }
3014
3015 // Convert from mwai_image/mwai_video to attachment post type
3016 wp_update_post( [
3017 'ID' => $attachment_id,
3018 'post_type' => 'attachment',
3019 'post_status' => 'inherit'
3020 ] );
3021
3022 // Remove from draft media list
3023 $user_id = get_current_user_id();
3024 $draft_media = get_user_meta( $user_id, 'mwai_draft_media', true );
3025 if ( is_array( $draft_media ) ) {
3026 $draft_media = array_filter( $draft_media, function ( $item ) use ( $attachment_id ) {
3027 return $item['attachment_id'] !== $attachment_id;
3028 } );
3029 update_user_meta( $user_id, 'mwai_draft_media', array_values( $draft_media ) );
3030 }
3031
3032 // Delete video from OpenAI if applicable
3033 if ( !empty( $openai_id ) ) {
3034 $env = $this->core->get_ai_env( $envId );
3035 $api_key = $env['apikey'] ?? '';
3036
3037 if ( !empty( $api_key ) ) {
3038 wp_remote_request( 'https://api.openai.com/v1/videos/' . $openai_id, [
3039 'method' => 'DELETE',
3040 'headers' => [ 'Authorization' => 'Bearer ' . $api_key ],
3041 'timeout' => 15
3042 ] );
3043 }
3044 }
3045
3046 return $this->create_rest_response( [ 'success' => true ], 200 );
3047 }
3048 catch ( Exception $e ) {
3049 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
3050 }
3051 }
3052
3053 public function rest_helpers_reject_media( $request ) {
3054 try {
3055 $params = $request->get_json_params();
3056 $attachment_id = absint( $params['attachmentId'] );
3057 $openai_id = sanitize_text_field( $params['openaiId'] ?? '' );
3058 $envId = sanitize_text_field( $params['envId'] ?? '' );
3059
3060 if ( empty( $attachment_id ) ) {
3061 throw new Exception( 'Attachment ID is required.' );
3062 }
3063
3064 // Convert from mwai_image/mwai_video to attachment post type first
3065 // This ensures wp_delete_attachment properly deletes the physical file
3066 wp_update_post( [
3067 'ID' => $attachment_id,
3068 'post_type' => 'attachment'
3069 ] );
3070
3071 // Delete attachment from WordPress (now that it's a proper attachment, files will be deleted)
3072 wp_delete_attachment( $attachment_id, true );
3073
3074 // Remove from draft media list
3075 $user_id = get_current_user_id();
3076 $draft_media = get_user_meta( $user_id, 'mwai_draft_media', true );
3077 if ( is_array( $draft_media ) ) {
3078 $draft_media = array_filter( $draft_media, function ( $item ) use ( $attachment_id ) {
3079 return $item['attachment_id'] !== $attachment_id;
3080 } );
3081 update_user_meta( $user_id, 'mwai_draft_media', array_values( $draft_media ) );
3082 }
3083
3084 // Delete video from OpenAI if applicable
3085 if ( !empty( $openai_id ) ) {
3086 $env = $this->core->get_ai_env( $envId );
3087 $api_key = $env['apikey'] ?? '';
3088
3089 if ( !empty( $api_key ) ) {
3090 wp_remote_request( 'https://api.openai.com/v1/videos/' . $openai_id, [
3091 'method' => 'DELETE',
3092 'headers' => [ 'Authorization' => 'Bearer ' . $api_key ],
3093 'timeout' => 15
3094 ] );
3095 }
3096 }
3097
3098 return $this->create_rest_response( [ 'success' => true ], 200 );
3099 }
3100 catch ( Exception $e ) {
3101 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
3102 }
3103 }
3104
3105 #endregion
3106 }
3107