PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.6.6
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.6.6
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 1 week ago exceptions 1 week ago modules 1 week ago query 1 week ago services 1 week ago admin.php 1 week ago api.php 1 week ago core.php 1 week ago discussion.php 1 year ago event.php 1 year ago init.php 1 week ago logging.php 1 year ago reply.php 1 week ago rest.php 1 week ago
rest.php
3004 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 gpt-image-1.5.
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 $is_json = function ( $probe ) {
2029 return $probe['content_type'] && strpos( $probe['content_type'], 'json' ) !== false;
2030 };
2031 // The firewall reveals itself by treating the python UA differently from
2032 // the neutral one: different status, or a block page instead of the JSON
2033 // that WordPress returns to both unauthenticated POSTs.
2034 $post_blocked = ( $post_reference['reachable'] && (
2035 ( !$post_probe['reachable'] )
2036 || ( $post_probe['status'] !== $post_reference['status'] && in_array( $post_probe['status'], [ 403, 406, 418, 429 ], true ) )
2037 || ( $is_json( $post_reference ) && !$is_json( $post_probe ) )
2038 ) );
2039
2040 $verdict = 'unknown';
2041 $message = '';
2042 if ( $probe['reachable'] && $probe['status'] === 403 ) {
2043 $verdict = 'waf_blocks_python_ua';
2044 $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.';
2045 }
2046 else if ( $probe['reachable'] && $probe['status'] === 404 ) {
2047 $verdict = 'wellknown_blocked';
2048 $message = 'Your host returned 404 for the host-root /.well-known/oauth-protected-resource path. 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.';
2049 }
2050 else if ( !$probe['reachable'] ) {
2051 $verdict = 'unreachable';
2052 $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.';
2053 }
2054 else if ( $probe['status'] === 200 && $post_blocked ) {
2055 $verdict = 'waf_blocks_python_post';
2056 $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.';
2057 }
2058 else if ( $probe['status'] === 200 ) {
2059 $verdict = 'ok';
2060 $message = 'Your site accepts the python-httpx User-Agent on both the OAuth discovery path (GET) and the MCP endpoint (POST). Claude.ai\'s connector should be able to reach it. If connecting still fails, run the same probes from an external machine: some servers reach themselves without going through the edge firewall.';
2061 }
2062 else {
2063 $verdict = 'unexpected_status';
2064 $message = 'Got HTTP ' . $probe['status'] . ' from the loopback probe. Expected 200. Investigate the response in your CDN/origin logs.';
2065 }
2066
2067 return $this->create_rest_response( [
2068 'success' => true,
2069 'verdict' => $verdict,
2070 'message' => $message,
2071 'probe' => $probe,
2072 'reference' => $reference,
2073 'post_probe' => $post_probe,
2074 'post_reference' => $post_reference,
2075 ], 200 );
2076 }
2077 catch ( Exception $e ) {
2078 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
2079 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
2080 }
2081 }
2082
2083 /**
2084 * Top MCP tools by call count over the last N days. Powers the
2085 * "Top Tools" widget in the MCP Logs view of the Insights screen.
2086 * Returns rows shaped as { tool, count, success_count, error_count }.
2087 */
2088 public function rest_mcp_top_tools( $request ) {
2089 try {
2090 $params = $request->get_json_params();
2091 $days = isset( $params['days'] ) ? max( 1, intval( $params['days'] ) ) : 7;
2092 $limit = isset( $params['limit'] ) ? max( 1, min( 50, intval( $params['limit'] ) ) ) : 10;
2093
2094 global $wpdb;
2095 $table = $wpdb->prefix . 'mwai_logs';
2096 $rows = $wpdb->get_results(
2097 $wpdb->prepare(
2098 "SELECT scope AS tool,
2099 COUNT(*) AS count,
2100 SUM(CASE WHEN stats LIKE %s THEN 1 ELSE 0 END) AS success_count,
2101 SUM(CASE WHEN stats LIKE %s THEN 1 ELSE 0 END) AS error_count
2102 FROM $table
2103 WHERE feature = 'mcp_tool'
2104 AND time >= DATE_SUB(NOW(), INTERVAL %d DAY)
2105 GROUP BY scope
2106 ORDER BY count DESC
2107 LIMIT %d",
2108 '%"status":"success"%',
2109 '%"status":"error"%',
2110 $days,
2111 $limit
2112 ),
2113 ARRAY_A
2114 );
2115
2116 foreach ( $rows as &$row ) {
2117 $row['count'] = (int) $row['count'];
2118 $row['success_count'] = (int) $row['success_count'];
2119 $row['error_count'] = (int) $row['error_count'];
2120 }
2121 unset( $row );
2122
2123 return $this->create_rest_response( [ 'success' => true, 'tools' => $rows ?: [] ], 200 );
2124 }
2125 catch ( Exception $e ) {
2126 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
2127 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
2128 }
2129 }
2130
2131 public function rest_helpers_post_types() {
2132 try {
2133 $postTypes = $this->core->get_post_types();
2134 return $this->create_rest_response( [ 'success' => true, 'postTypes' => $postTypes ], 200 );
2135 }
2136 catch ( Exception $e ) {
2137 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
2138 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
2139 }
2140 }
2141
2142 public function rest_settings_themes( $request ) {
2143 try {
2144 $method = $request->get_method();
2145 if ( $method === 'GET' ) {
2146 $themes = $this->core->get_themes();
2147 return $this->create_rest_response( [ 'success' => true, 'themes' => $themes ], 200 );
2148 }
2149 else if ( $method === 'POST' ) {
2150 $params = $request->get_json_params();
2151 $themes = $params['themes'];
2152 $themes = $this->core->update_themes( $themes );
2153 return $this->create_rest_response( [ 'success' => true, 'themes' => $themes ], 200 );
2154 }
2155 }
2156 catch ( Exception $e ) {
2157 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
2158 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
2159 }
2160 }
2161
2162 public function rest_settings_chatbots( $request ) {
2163 try {
2164 $method = $request->get_method();
2165 if ( $method === 'GET' ) {
2166 $chatbots = $this->core->get_chatbots();
2167 return $this->create_rest_response( [ 'success' => true, 'chatbots' => $chatbots ], 200 );
2168 }
2169 else if ( $method === 'POST' ) {
2170 $params = $request->get_json_params();
2171 $chatbots = $params['chatbots'];
2172 $chatbots = $this->core->update_chatbots( $chatbots );
2173 return $this->create_rest_response( [ 'success' => true, 'chatbots' => $chatbots ], 200 );
2174 }
2175 return $this->create_rest_response( [ 'success' => false, 'message' => 'Method not allowed' ], 405 );
2176 }
2177 catch ( Exception $e ) {
2178 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
2179 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
2180 }
2181 }
2182
2183 #region Logs
2184
2185 public function rest_get_logs() {
2186 $logs = Meow_MWAI_Logging::get();
2187 return $this->create_rest_response( [ 'success' => true, 'data' => $logs ], 200 );
2188 }
2189
2190 public function rest_clear_logs() {
2191 Meow_MWAI_Logging::clear();
2192 return $this->create_rest_response( [ 'success' => true ], 200 );
2193 }
2194
2195 #endregion
2196
2197 #region Forms
2198
2199 public function rest_forms_list( $request ) {
2200 try {
2201 $args = [
2202 'post_type' => 'mwai_form',
2203 'posts_per_page' => 100,
2204 'post_status' => 'any',
2205 'orderby' => 'date',
2206 'order' => 'DESC'
2207 ];
2208
2209 $posts = get_posts( $args );
2210 $forms = array_map( function ( $post ) {
2211 return [
2212 'id' => $post->ID,
2213 'title' => $post->post_title,
2214 'status' => $post->post_status
2215 ];
2216 }, $posts );
2217
2218 return $this->create_rest_response( [ 'success' => true, 'forms' => $forms ], 200 );
2219 }
2220 catch ( Exception $e ) {
2221 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2222 }
2223 }
2224
2225 public function rest_forms_get( $request ) {
2226 try {
2227 $id = intval( $request->get_param( 'id' ) );
2228 if ( !$id ) {
2229 return $this->create_rest_response( [ 'success' => false, 'message' => 'Invalid form ID' ], 400 );
2230 }
2231
2232 $post = get_post( $id );
2233 if ( !$post || $post->post_type !== 'mwai_form' ) {
2234 return $this->create_rest_response( [ 'success' => false, 'message' => 'Form not found' ], 404 );
2235 }
2236
2237 $form = [
2238 'id' => $post->ID,
2239 'title' => [
2240 'raw' => $post->post_title,
2241 'rendered' => $post->post_title
2242 ],
2243 'content' => [
2244 'raw' => $post->post_content,
2245 'rendered' => $post->post_content
2246 ],
2247 'status' => $post->post_status
2248 ];
2249
2250 return $this->create_rest_response( [ 'success' => true, 'form' => $form ], 200 );
2251 }
2252 catch ( Exception $e ) {
2253 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2254 }
2255 }
2256
2257 public function rest_forms_create( $request ) {
2258 try {
2259 $params = $request->get_json_params();
2260 $title = isset( $params['title'] ) ? $params['title'] : 'Untitled Form';
2261
2262 // wp_insert_post expects slashed data - it calls wp_unslash() internally, which would
2263 // otherwise strip backslashes from block-comment JSON escapes (e.g. \n → n) and corrupt
2264 // the stored blocks.
2265 $post_data = wp_slash( [
2266 'post_title' => $title,
2267 'post_content' => '',
2268 'post_status' => 'draft',
2269 'post_type' => 'mwai_form'
2270 ] );
2271
2272 $post_id = wp_insert_post( $post_data );
2273
2274 if ( is_wp_error( $post_id ) ) {
2275 return $this->create_rest_response( [ 'success' => false, 'message' => $post_id->get_error_message() ], 500 );
2276 }
2277
2278 $post = get_post( $post_id );
2279 $form = [
2280 'id' => $post->ID,
2281 'title' => [
2282 'raw' => $post->post_title,
2283 'rendered' => $post->post_title
2284 ],
2285 'status' => $post->post_status
2286 ];
2287
2288 return $this->create_rest_response( [ 'success' => true, 'form' => $form ], 200 );
2289 }
2290 catch ( Exception $e ) {
2291 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2292 }
2293 }
2294
2295 public function rest_forms_update( $request ) {
2296 try {
2297 $params = $request->get_json_params();
2298 $id = isset( $params['id'] ) ? intval( $params['id'] ) : 0;
2299
2300 if ( !$id ) {
2301 return $this->create_rest_response( [ 'success' => false, 'message' => 'Invalid form ID' ], 400 );
2302 }
2303
2304 $post = get_post( $id );
2305 if ( !$post || $post->post_type !== 'mwai_form' ) {
2306 return $this->create_rest_response( [ 'success' => false, 'message' => 'Form not found' ], 404 );
2307 }
2308
2309 $post_data = [ 'ID' => $id ];
2310
2311 if ( isset( $params['title'] ) ) {
2312 $post_data['post_title'] = $params['title'];
2313 }
2314
2315 if ( isset( $params['content'] ) ) {
2316 $post_data['post_content'] = $params['content'];
2317 }
2318
2319 if ( isset( $params['status'] ) ) {
2320 $post_data['post_status'] = $params['status'];
2321 }
2322
2323 // wp_update_post expects slashed data - it calls wp_unslash() internally, which would
2324 // otherwise strip backslashes from block-comment JSON escapes (e.g. \n → n) and break
2325 // Gutenberg blocks on reload.
2326 $result = wp_update_post( wp_slash( $post_data ) );
2327
2328 if ( is_wp_error( $result ) ) {
2329 return $this->create_rest_response( [ 'success' => false, 'message' => $result->get_error_message() ], 500 );
2330 }
2331
2332 $post = get_post( $id );
2333 $form = [
2334 'id' => $post->ID,
2335 'title' => [
2336 'raw' => $post->post_title,
2337 'rendered' => $post->post_title
2338 ],
2339 'content' => [
2340 'raw' => $post->post_content,
2341 'rendered' => $post->post_content
2342 ],
2343 'status' => $post->post_status
2344 ];
2345
2346 return $this->create_rest_response( [ 'success' => true, 'form' => $form ], 200 );
2347 }
2348 catch ( Exception $e ) {
2349 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2350 }
2351 }
2352
2353 public function rest_forms_delete( $request ) {
2354 try {
2355 $params = $request->get_json_params();
2356 $id = isset( $params['id'] ) ? intval( $params['id'] ) : 0;
2357
2358 if ( !$id ) {
2359 return $this->create_rest_response( [ 'success' => false, 'message' => 'Invalid form ID' ], 400 );
2360 }
2361
2362 $post = get_post( $id );
2363 if ( !$post || $post->post_type !== 'mwai_form' ) {
2364 return $this->create_rest_response( [ 'success' => false, 'message' => 'Form not found' ], 404 );
2365 }
2366
2367 $result = wp_delete_post( $id, true );
2368
2369 if ( !$result ) {
2370 return $this->create_rest_response( [ 'success' => false, 'message' => 'Failed to delete form' ], 500 );
2371 }
2372
2373 return $this->create_rest_response( [ 'success' => true ], 200 );
2374 }
2375 catch ( Exception $e ) {
2376 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2377 }
2378 }
2379
2380 #endregion
2381
2382 #region Video Generation Helpers
2383
2384 public function rest_helpers_create_video( $request ) {
2385 try {
2386 $params = $request->get_json_params();
2387 $prompt = sanitize_text_field( $params['prompt'] );
2388 $model = sanitize_text_field( $params['model'] ?? 'sora-2' );
2389 $size = sanitize_text_field( $params['size'] ?? '720x1280' );
2390 $seconds = absint( $params['seconds'] ?? 4 );
2391 $envId = sanitize_text_field( $params['envId'] ?? '' );
2392
2393 // Check if envId is provided (defaults not supported for videos yet)
2394 if ( empty( $envId ) ) {
2395 throw new Exception( 'Please select a specific environment and model in the Video Generator. Default environments are not yet supported for video generation.' );
2396 }
2397
2398 // Get API key from environment
2399 $env = $this->core->get_ai_env( $envId );
2400 $api_key = $env['apikey'] ?? '';
2401
2402 if ( empty( $api_key ) ) {
2403 throw new Exception( 'OpenAI API key not found.' );
2404 }
2405
2406 // Prepare multipart boundary
2407 $boundary = wp_generate_password( 24, false );
2408 $body = '';
2409
2410 // Add model
2411 $body .= "--{$boundary}\r\n";
2412 $body .= "Content-Disposition: form-data; name=\"model\"\r\n\r\n";
2413 $body .= "{$model}\r\n";
2414
2415 // Add prompt
2416 $body .= "--{$boundary}\r\n";
2417 $body .= "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n";
2418 $body .= "{$prompt}\r\n";
2419
2420 // Add size
2421 $body .= "--{$boundary}\r\n";
2422 $body .= "Content-Disposition: form-data; name=\"size\"\r\n\r\n";
2423 $body .= "{$size}\r\n";
2424
2425 // Add seconds
2426 $body .= "--{$boundary}\r\n";
2427 $body .= "Content-Disposition: form-data; name=\"seconds\"\r\n\r\n";
2428 $body .= "{$seconds}\r\n";
2429
2430 $body .= "--{$boundary}--\r\n";
2431
2432 // Call OpenAI API to create video
2433 $response = wp_remote_post( 'https://api.openai.com/v1/videos', [
2434 'headers' => [
2435 'Authorization' => 'Bearer ' . $api_key,
2436 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
2437 ],
2438 'body' => $body,
2439 'timeout' => 30
2440 ] );
2441
2442 if ( is_wp_error( $response ) ) {
2443 throw new Exception( $response->get_error_message() );
2444 }
2445
2446 $response_body = json_decode( wp_remote_retrieve_body( $response ), true );
2447
2448 if ( isset( $response_body['error'] ) ) {
2449 throw new Exception( $response_body['error']['message'] ?? 'Unknown error' );
2450 }
2451
2452 // Record usage (price is calculated per second)
2453 $usage = $this->core->record_videos_usage( $model, $size, $seconds );
2454
2455 // Log to Query Logs (Statistics)
2456 try {
2457 if ( class_exists( 'MeowPro_MWAI_Stats' ) && class_exists( 'MeowPro_MWAI_Statistics' ) ) {
2458 $statsObject = new MeowPro_MWAI_Stats();
2459 $statsObject->session = $params['session'] ?? null;
2460 $statsObject->scope = 'admin-tools';
2461 $statsObject->feature = 'video-generator';
2462 $statsObject->model = $model;
2463 $statsObject->envId = $envId;
2464 $statsObject->units = $seconds;
2465 $statsObject->type = 'seconds';
2466 $statsObject->price = $usage['price'] ?? 0;
2467 $statsObject->accuracy = $usage['accuracy'] ?? 'full';
2468
2469 $statistics = new MeowPro_MWAI_Statistics();
2470 $statistics->commit_stats( $statsObject );
2471 }
2472 }
2473 catch ( Exception $statsError ) {
2474 // Log the error but don't fail the video creation
2475 error_log( '[AI Engine Video] Failed to log statistics: ' . $statsError->getMessage() );
2476 }
2477
2478 // Store metadata for later retrieval when video completes
2479 if ( isset( $response_body['id'] ) ) {
2480 set_transient( 'mwai_video_metadata_' . $response_body['id'], [
2481 'model' => $model,
2482 'env_id' => $envId,
2483 'created_at' => time()
2484 ], 7 * DAY_IN_SECONDS );
2485 }
2486
2487 return $this->create_rest_response( [ 'success' => true, 'video' => $response_body, 'usage' => $usage ], 200 );
2488 }
2489 catch ( Exception $e ) {
2490 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2491 }
2492 }
2493
2494 public function rest_helpers_video_status( $request ) {
2495 try {
2496 $params = $request->get_json_params();
2497 $video_ids = $params['videoIds'] ?? [];
2498 $envId = sanitize_text_field( $params['envId'] ?? '' );
2499
2500 if ( empty( $video_ids ) ) {
2501 return $this->create_rest_response( [ 'success' => true, 'videos' => [] ], 200 );
2502 }
2503
2504 // Get API key from environment
2505 $env = $this->core->get_ai_env( $envId );
2506 $api_key = $env['apikey'] ?? '';
2507
2508 if ( empty( $api_key ) ) {
2509 throw new Exception( 'OpenAI API key not found.' );
2510 }
2511
2512 $videos = [];
2513 foreach ( $video_ids as $video_id ) {
2514 $response = wp_remote_get( 'https://api.openai.com/v1/videos/' . $video_id, [
2515 'headers' => [
2516 'Authorization' => 'Bearer ' . $api_key
2517 ],
2518 'timeout' => 15
2519 ] );
2520
2521 if ( !is_wp_error( $response ) ) {
2522 $body = json_decode( wp_remote_retrieve_body( $response ), true );
2523 if ( !isset( $body['error'] ) ) {
2524 // If video is completed and we haven't saved it yet, download and save to media library
2525 if ( $body['status'] === 'completed' && empty( get_transient( 'mwai_video_saved_' . $video_id ) ) ) {
2526 // Retrieve metadata that was stored when video was created
2527 $metadata = get_transient( 'mwai_video_metadata_' . $video_id );
2528 $ai_metadata = [];
2529 if ( $metadata ) {
2530 $ai_metadata = [
2531 'model' => $metadata['model'] ?? null,
2532 'env_id' => $metadata['env_id'] ?? null,
2533 'latency' => isset( $metadata['created_at'] ) ? ( time() - $metadata['created_at'] ) : null
2534 ];
2535 }
2536
2537 $attachment_id = $this->download_and_save_video( $video_id, $api_key, '', '', $ai_metadata );
2538 if ( $attachment_id ) {
2539 $body['attachment_id'] = $attachment_id;
2540 // Build URL from file path for custom post types
2541 $file_path = get_attached_file( $attachment_id );
2542 $upload_dir = wp_upload_dir();
2543 $body['url'] = str_replace( $upload_dir['basedir'], $upload_dir['baseurl'], $file_path );
2544 // Mark as saved so we don't download again
2545 set_transient( 'mwai_video_saved_' . $video_id, $attachment_id, DAY_IN_SECONDS );
2546 // Clean up metadata transient
2547 delete_transient( 'mwai_video_metadata_' . $video_id );
2548 }
2549 }
2550 // Check if we already have this video saved
2551 else if ( $body['status'] === 'completed' ) {
2552 $attachment_id = get_transient( 'mwai_video_saved_' . $video_id );
2553 if ( $attachment_id ) {
2554 $body['attachment_id'] = $attachment_id;
2555 // Build URL from file path for custom post types
2556 $file_path = get_attached_file( $attachment_id );
2557 $upload_dir = wp_upload_dir();
2558 $body['url'] = str_replace( $upload_dir['basedir'], $upload_dir['baseurl'], $file_path );
2559 }
2560 }
2561 $videos[] = $body;
2562 }
2563 else {
2564 // Include error information in the response
2565 error_log( 'AI Engine: Video generation failed for ID ' . $video_id . ': ' . json_encode( $body['error'] ) );
2566 $videos[] = [
2567 'id' => $video_id,
2568 'status' => 'failed',
2569 'error' => $body['error']
2570 ];
2571 }
2572 }
2573 else {
2574 // WP HTTP error
2575 error_log( 'AI Engine: Failed to check video status for ID ' . $video_id . ': ' . $response->get_error_message() );
2576 $videos[] = [
2577 'id' => $video_id,
2578 'status' => 'failed',
2579 'error' => [ 'message' => $response->get_error_message() ]
2580 ];
2581 }
2582 }
2583
2584 return $this->create_rest_response( [ 'success' => true, 'videos' => $videos ], 200 );
2585 }
2586 catch ( Exception $e ) {
2587 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2588 }
2589 }
2590
2591 public function rest_helpers_download_video( $request ) {
2592 try {
2593 $params = $request->get_json_params();
2594 $video_id = sanitize_text_field( $params['videoId'] );
2595 $envId = sanitize_text_field( $params['envId'] ?? '' );
2596
2597 // Get API key from environment
2598 $env = $this->core->get_ai_env( $envId );
2599 $api_key = $env['apikey'] ?? '';
2600
2601 if ( empty( $api_key ) ) {
2602 throw new Exception( 'OpenAI API key not found.' );
2603 }
2604
2605 $temp_file = wp_tempnam( $video_id . '.mp4' );
2606
2607 $response = wp_remote_get( 'https://api.openai.com/v1/videos/' . $video_id . '/content', [
2608 'headers' => [
2609 'Authorization' => 'Bearer ' . $api_key
2610 ],
2611 'timeout' => 120,
2612 'stream' => true,
2613 'filename' => $temp_file
2614 ] );
2615
2616 if ( is_wp_error( $response ) ) {
2617 throw new Exception( $response->get_error_message() );
2618 }
2619
2620 $file_data = file_get_contents( $temp_file );
2621 $base64 = base64_encode( $file_data );
2622
2623 unlink( $temp_file );
2624
2625 return $this->create_rest_response( [
2626 'success' => true,
2627 'data' => $base64,
2628 'mimeType' => 'video/mp4'
2629 ], 200 );
2630 }
2631 catch ( Exception $e ) {
2632 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2633 }
2634 }
2635
2636 public function rest_helpers_delete_video( $request ) {
2637 try {
2638 $params = $request->get_json_params();
2639 $video_id = sanitize_text_field( $params['videoId'] );
2640 $envId = sanitize_text_field( $params['envId'] ?? '' );
2641
2642 // Get API key from environment
2643 $env = $this->core->get_ai_env( $envId );
2644 $api_key = $env['apikey'] ?? '';
2645
2646 if ( empty( $api_key ) ) {
2647 throw new Exception( 'OpenAI API key not found.' );
2648 }
2649
2650 $response = wp_remote_request( 'https://api.openai.com/v1/videos/' . $video_id, [
2651 'method' => 'DELETE',
2652 'headers' => [
2653 'Authorization' => 'Bearer ' . $api_key
2654 ],
2655 'timeout' => 15
2656 ] );
2657
2658 if ( is_wp_error( $response ) ) {
2659 throw new Exception( $response->get_error_message() );
2660 }
2661
2662 return $this->create_rest_response( [ 'success' => true ], 200 );
2663 }
2664 catch ( Exception $e ) {
2665 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2666 }
2667 }
2668
2669 private function download_and_save_video( $video_id, $api_key, $title = '', $description = '', $ai_metadata = [] ) {
2670 try {
2671 // Download video content
2672 $response = wp_remote_get( 'https://api.openai.com/v1/videos/' . $video_id . '/content', [
2673 'headers' => [
2674 'Authorization' => 'Bearer ' . $api_key
2675 ],
2676 'timeout' => 120
2677 ] );
2678
2679 if ( is_wp_error( $response ) ) {
2680 error_log( 'Error downloading video: ' . $response->get_error_message() );
2681 return false;
2682 }
2683
2684 $video_data = wp_remote_retrieve_body( $response );
2685 if ( empty( $video_data ) ) {
2686 error_log( 'Empty video data received' );
2687 return false;
2688 }
2689
2690 // Generate filename
2691 $filename = $video_id . '.mp4';
2692 $upload_dir = wp_upload_dir();
2693 $file_path = $upload_dir['path'] . '/' . $filename;
2694
2695 // Save to file
2696 file_put_contents( $file_path, $video_data );
2697
2698 // Prepare attachment data - use mwai_video post type (draft video)
2699 $attachment = [
2700 'post_mime_type' => 'video/mp4',
2701 'post_title' => !empty( $title ) ? $title : 'AI Generated Video',
2702 'post_content' => $description,
2703 'post_status' => 'inherit',
2704 'post_type' => 'mwai_video'
2705 ];
2706
2707 // Use wp_insert_post instead of wp_insert_attachment to allow custom post types
2708 $attachment_id = wp_insert_post( $attachment );
2709
2710 // Set the attached file manually since we're not using wp_insert_attachment
2711 update_attached_file( $attachment_id, $file_path );
2712
2713 if ( is_wp_error( $attachment_id ) ) {
2714 error_log( 'Error creating attachment: ' . $attachment_id->get_error_message() );
2715 return false;
2716 }
2717
2718 // Generate attachment metadata
2719 require_once ABSPATH . 'wp-admin/includes/image.php';
2720 $attach_data = wp_generate_attachment_metadata( $attachment_id, $file_path );
2721 wp_update_attachment_metadata( $attachment_id, $attach_data );
2722
2723 // Store AI-related metadata
2724 if ( !empty( $ai_metadata['model'] ) ) {
2725 update_post_meta( $attachment_id, 'mwai_model', sanitize_text_field( $ai_metadata['model'] ) );
2726 }
2727 if ( !empty( $ai_metadata['latency'] ) ) {
2728 update_post_meta( $attachment_id, 'mwai_latency', floatval( $ai_metadata['latency'] ) );
2729 }
2730 if ( !empty( $ai_metadata['env_id'] ) ) {
2731 update_post_meta( $attachment_id, 'mwai_env_id', sanitize_text_field( $ai_metadata['env_id'] ) );
2732 }
2733
2734 // Add to user's draft media
2735 $user_id = get_current_user_id();
2736 $draft_media = get_user_meta( $user_id, 'mwai_draft_media', true );
2737 if ( !is_array( $draft_media ) ) {
2738 $draft_media = [];
2739 }
2740 $draft_media[] = [
2741 'attachment_id' => $attachment_id,
2742 'type' => 'video',
2743 'openai_id' => $video_id,
2744 'created_at' => time()
2745 ];
2746 update_user_meta( $user_id, 'mwai_draft_media', $draft_media );
2747
2748 return $attachment_id;
2749 }
2750 catch ( Exception $e ) {
2751 error_log( 'Exception in download_and_save_video: ' . $e->getMessage() );
2752 return false;
2753 }
2754 }
2755
2756 public function rest_helpers_save_video_to_library( $request ) {
2757 try {
2758 $params = $request->get_json_params();
2759 $video_id = sanitize_text_field( $params['videoId'] );
2760 $title = sanitize_text_field( $params['title'] );
2761 $description = sanitize_text_field( $params['description'] );
2762 $filename = sanitize_file_name( $params['filename'] );
2763 $envId = sanitize_text_field( $params['envId'] ?? '' );
2764
2765 // Ensure filename has .mp4 extension
2766 if ( !preg_match( '/\.mp4$/i', $filename ) ) {
2767 $filename .= '.mp4';
2768 }
2769
2770 // Get API key from environment
2771 $env = $this->core->get_ai_env( $envId );
2772 $api_key = $env['apikey'] ?? '';
2773
2774 if ( empty( $api_key ) ) {
2775 throw new Exception( 'OpenAI API key not found.' );
2776 }
2777
2778 // Download video content
2779 $response = wp_remote_get( 'https://api.openai.com/v1/videos/' . $video_id . '/content', [
2780 'headers' => [
2781 'Authorization' => 'Bearer ' . $api_key
2782 ],
2783 'timeout' => 120
2784 ] );
2785
2786 if ( is_wp_error( $response ) ) {
2787 throw new Exception( $response->get_error_message() );
2788 }
2789
2790 $video_data = wp_remote_retrieve_body( $response );
2791
2792 // Upload to WordPress media library
2793 $upload_dir = wp_upload_dir();
2794 $file_path = $upload_dir['path'] . '/' . $filename;
2795
2796 file_put_contents( $file_path, $video_data );
2797
2798 $attachment = [
2799 'post_mime_type' => 'video/mp4',
2800 'post_title' => $title,
2801 'post_content' => $description,
2802 'post_status' => 'inherit'
2803 ];
2804
2805 $attach_id = wp_insert_attachment( $attachment, $file_path );
2806
2807 require_once( ABSPATH . 'wp-admin/includes/image.php' );
2808 $attach_data = wp_generate_attachment_metadata( $attach_id, $file_path );
2809 wp_update_attachment_metadata( $attach_id, $attach_data );
2810
2811 return $this->create_rest_response( [
2812 'success' => true,
2813 'attachmentId' => $attach_id
2814 ], 200 );
2815 }
2816 catch ( Exception $e ) {
2817 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2818 }
2819 }
2820
2821 public function rest_helpers_delete_video_from_library( $request ) {
2822 try {
2823 $params = $request->get_json_params();
2824 $attachment_id = absint( $params['attachmentId'] );
2825
2826 if ( empty( $attachment_id ) ) {
2827 throw new Exception( 'Attachment ID is required.' );
2828 }
2829
2830 $deleted = wp_delete_attachment( $attachment_id, true );
2831
2832 if ( !$deleted ) {
2833 throw new Exception( 'Failed to delete attachment.' );
2834 }
2835
2836 return $this->create_rest_response( [ 'success' => true ], 200 );
2837 }
2838 catch ( Exception $e ) {
2839 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2840 }
2841 }
2842
2843 public function rest_helpers_list_draft_media( $request ) {
2844 try {
2845 $type = $request->get_param( 'type' ); // 'image', 'video', or null for all
2846 $user_id = get_current_user_id();
2847 $draft_media = get_user_meta( $user_id, 'mwai_draft_media', true );
2848
2849 if ( !is_array( $draft_media ) ) {
2850 return $this->create_rest_response( [ 'success' => true, 'media' => [] ], 200 );
2851 }
2852
2853 $media_items = [];
2854 foreach ( $draft_media as $item ) {
2855 // Filter by type if specified
2856 if ( $type && $item['type'] !== $type ) {
2857 continue;
2858 }
2859
2860 $attachment_id = $item['attachment_id'];
2861 $attachment = get_post( $attachment_id );
2862
2863 if ( $attachment ) {
2864 // For custom post types (mwai_image, mwai_video), build URL from file path
2865 $file_path = get_attached_file( $attachment_id );
2866 $upload_dir = wp_upload_dir();
2867 $url = str_replace( $upload_dir['basedir'], $upload_dir['baseurl'], $file_path );
2868
2869 $model = get_post_meta( $attachment_id, 'mwai_model', true );
2870 $generation_time = get_post_meta( $attachment_id, 'mwai_latency', true );
2871 $env_id = get_post_meta( $attachment_id, 'mwai_env_id', true );
2872
2873 // Debug logging
2874 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
2875 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 ) );
2876 }
2877
2878 $media_items[] = [
2879 'attachment_id' => $attachment_id,
2880 'type' => $item['type'],
2881 'openai_id' => $item['openai_id'] ?? null,
2882 'url' => $url,
2883 'title' => $attachment->post_title,
2884 'description' => $attachment->post_content,
2885 'filename' => basename( $file_path ),
2886 'created_at' => $item['created_at'],
2887 'model' => $model,
2888 'generation_time' => $generation_time,
2889 'env_id' => $env_id
2890 ];
2891 }
2892 }
2893
2894 return $this->create_rest_response( [ 'success' => true, 'media' => $media_items ], 200 );
2895 }
2896 catch ( Exception $e ) {
2897 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2898 }
2899 }
2900
2901 public function rest_helpers_approve_media( $request ) {
2902 try {
2903 $params = $request->get_json_params();
2904 $attachment_id = absint( $params['attachmentId'] );
2905 $openai_id = sanitize_text_field( $params['openaiId'] ?? '' );
2906 $envId = sanitize_text_field( $params['envId'] ?? '' );
2907
2908 if ( empty( $attachment_id ) ) {
2909 throw new Exception( 'Attachment ID is required.' );
2910 }
2911
2912 // Convert from mwai_image/mwai_video to attachment post type
2913 wp_update_post( [
2914 'ID' => $attachment_id,
2915 'post_type' => 'attachment',
2916 'post_status' => 'inherit'
2917 ] );
2918
2919 // Remove from draft media list
2920 $user_id = get_current_user_id();
2921 $draft_media = get_user_meta( $user_id, 'mwai_draft_media', true );
2922 if ( is_array( $draft_media ) ) {
2923 $draft_media = array_filter( $draft_media, function ( $item ) use ( $attachment_id ) {
2924 return $item['attachment_id'] !== $attachment_id;
2925 } );
2926 update_user_meta( $user_id, 'mwai_draft_media', array_values( $draft_media ) );
2927 }
2928
2929 // Delete video from OpenAI if applicable
2930 if ( !empty( $openai_id ) ) {
2931 $env = $this->core->get_ai_env( $envId );
2932 $api_key = $env['apikey'] ?? '';
2933
2934 if ( !empty( $api_key ) ) {
2935 wp_remote_request( 'https://api.openai.com/v1/videos/' . $openai_id, [
2936 'method' => 'DELETE',
2937 'headers' => [ 'Authorization' => 'Bearer ' . $api_key ],
2938 'timeout' => 15
2939 ] );
2940 }
2941 }
2942
2943 return $this->create_rest_response( [ 'success' => true ], 200 );
2944 }
2945 catch ( Exception $e ) {
2946 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2947 }
2948 }
2949
2950 public function rest_helpers_reject_media( $request ) {
2951 try {
2952 $params = $request->get_json_params();
2953 $attachment_id = absint( $params['attachmentId'] );
2954 $openai_id = sanitize_text_field( $params['openaiId'] ?? '' );
2955 $envId = sanitize_text_field( $params['envId'] ?? '' );
2956
2957 if ( empty( $attachment_id ) ) {
2958 throw new Exception( 'Attachment ID is required.' );
2959 }
2960
2961 // Convert from mwai_image/mwai_video to attachment post type first
2962 // This ensures wp_delete_attachment properly deletes the physical file
2963 wp_update_post( [
2964 'ID' => $attachment_id,
2965 'post_type' => 'attachment'
2966 ] );
2967
2968 // Delete attachment from WordPress (now that it's a proper attachment, files will be deleted)
2969 wp_delete_attachment( $attachment_id, true );
2970
2971 // Remove from draft media list
2972 $user_id = get_current_user_id();
2973 $draft_media = get_user_meta( $user_id, 'mwai_draft_media', true );
2974 if ( is_array( $draft_media ) ) {
2975 $draft_media = array_filter( $draft_media, function ( $item ) use ( $attachment_id ) {
2976 return $item['attachment_id'] !== $attachment_id;
2977 } );
2978 update_user_meta( $user_id, 'mwai_draft_media', array_values( $draft_media ) );
2979 }
2980
2981 // Delete video from OpenAI if applicable
2982 if ( !empty( $openai_id ) ) {
2983 $env = $this->core->get_ai_env( $envId );
2984 $api_key = $env['apikey'] ?? '';
2985
2986 if ( !empty( $api_key ) ) {
2987 wp_remote_request( 'https://api.openai.com/v1/videos/' . $openai_id, [
2988 'method' => 'DELETE',
2989 'headers' => [ 'Authorization' => 'Bearer ' . $api_key ],
2990 'timeout' => 15
2991 ] );
2992 }
2993 }
2994
2995 return $this->create_rest_response( [ 'success' => true ], 200 );
2996 }
2997 catch ( Exception $e ) {
2998 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2999 }
3000 }
3001
3002 #endregion
3003 }
3004