PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.6.5
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.6.5
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 2 days ago exceptions 4 days ago modules 2 days ago query 4 days ago services 2 days ago admin.php 2 days ago api.php 3 weeks ago core.php 4 days ago discussion.php 1 year ago event.php 1 year ago init.php 4 days ago logging.php 1 year ago reply.php 4 days ago rest.php 2 days ago
rest.php
2994 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 $params = $request->get_json_params();
734 $message = $this->retrieve_message( $params );
735 $query = new Meow_MWAI_Query_Text( $message );
736 $query->inject_params( $params );
737
738 // Handle streaming
739 $stream = $params['stream'] ?? false;
740 $streamCallback = null;
741 if ( $stream ) {
742 $streamCallback = function ( $reply ) use ( $query ) {
743 //$raw = _wp_specialchars( $reply, ENT_NOQUOTES, 'UTF-8', true );
744 $raw = $reply;
745 $this->core->stream_push( [ 'type' => 'live', 'data' => $raw ], $query );
746 if ( ob_get_level() > 0 ) {
747 ob_flush();
748 }
749 flush();
750 };
751 if ( headers_sent( $filename, $linenum ) ) {
752 throw new Exception( "Headers already sent in $filename on line $linenum. Cannot start streaming." );
753 }
754 header( 'Cache-Control: no-cache' );
755 header( 'Content-Type: text/event-stream' );
756 header( 'X-Accel-Buffering: no' ); // This is useful to disable buffering in nginx through headers.
757 ob_implicit_flush( true );
758 if ( ob_get_level() > 0 ) {
759 ob_end_flush();
760 }
761 // Finish the request even if the client disconnects, so usage/credits are
762 // still recorded after the stream completes. Otherwise an abort mid-stream
763 // kills the script before accounting runs. See chat_submit for details.
764 ignore_user_abort( true );
765 }
766
767 // Process Reply
768 $reply = $this->core->run_query( $query, $streamCallback );
769 $restRes = [
770 'success' => true,
771 'data' => $reply->result,
772 'usage' => $reply->usage
773 ];
774 if ( $stream ) {
775 $this->core->stream_push( [ 'type' => 'end', 'data' => json_encode( $restRes ) ], $query );
776 die();
777 }
778 return $this->create_rest_response( $restRes, 200 );
779 }
780 catch ( Exception $e ) {
781 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
782 if ( $stream ) {
783 $this->core->stream_push( [ 'type' => 'error', 'data' => $message ], $query );
784 }
785 else {
786 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
787 }
788 }
789 }
790
791 public function rest_ai_images( $request ) {
792 try {
793 $params = $request->get_json_params();
794 $message = $this->retrieve_message( $params );
795 $query = new Meow_MWAI_Query_Image( $message );
796 $query->inject_params( $params );
797 $reply = $this->core->run_query( $query );
798 return $this->create_rest_response( [ 'success' => true, 'data' => $reply->results, 'usage' => $reply->usage ], 200 );
799 }
800 catch ( Exception $e ) {
801 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
802 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
803 }
804 }
805
806 public function rest_ai_image_edit( $request ) {
807 try {
808 // Check if this is a multipart request with files
809 $files = $request->get_file_params();
810 $params = null;
811
812 // Debug logging
813 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
814 error_log( '[AI Engine Queries] Image Edit Request - Method: ' . $request->get_method() );
815 $content_type = $request->get_content_type();
816 if ( is_array( $content_type ) ) {
817 error_log( '[AI Engine Queries] Image Edit Request - Content-Type: ' . $content_type['value'] );
818 }
819 else {
820 error_log( '[AI Engine Queries] Image Edit Request - Content-Type: ' . $content_type );
821 }
822 error_log( '[AI Engine Queries] Image Edit Request - Has files: ' . ( !empty( $files ) ? 'yes (' . count( $files ) . ')' : 'no' ) );
823 }
824
825 if ( !empty( $files ) ) {
826 // Handle multipart form data - get all params including POST data
827 $params = $request->get_params();
828 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
829 error_log( '[AI Engine Queries] Image Edit Request - Using form data params' );
830 }
831 }
832 else {
833 // Try to get body params first (for form data without files)
834 $body_params = $request->get_body_params();
835 if ( !empty( $body_params ) ) {
836 $params = $body_params;
837 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
838 error_log( '[AI Engine Queries] Image Edit Request - Using body params' );
839 }
840 }
841 else {
842 // Handle JSON request
843 $params = $request->get_json_params();
844 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
845 error_log( '[AI Engine Queries] Image Edit Request - Using JSON params' );
846 }
847 }
848 }
849
850 // Ensure params is always an array
851 if ( empty( $params ) ) {
852 $params = [];
853 }
854
855 // Debug logging
856 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
857 error_log( '[AI Engine Queries] Image Edit Request - Has files: ' . ( !empty( $files ) ? 'yes' : 'no' ) );
858 error_log( '[AI Engine Queries] Image Edit Request - Params: ' . json_encode( $params ) );
859 }
860
861 $message = $this->retrieve_message( $params );
862 $mediaId = isset( $params['mediaId'] ) ? intval( $params['mediaId'] ) : 0;
863 $query = new Meow_MWAI_Query_EditImage( $message );
864
865 // The inject_params method will handle setting the file from mediaId
866 $query->inject_params( $params );
867
868 // Handle mask file if provided
869 if ( !empty( $files['mask'] ) ) {
870 $mask_file = $files['mask'];
871 if ( $mask_file['error'] === UPLOAD_ERR_OK ) {
872 $mask_data = file_get_contents( $mask_file['tmp_name'] );
873 $query->set_mask( Meow_MWAI_Query_DroppedFile::from_data( $mask_data, 'analysis', $mask_file['type'] ) );
874 }
875 }
876
877 $reply = $this->core->run_query( $query );
878 return $this->create_rest_response( [ 'success' => true, 'data' => $reply->results, 'usage' => $reply->usage ], 200 );
879 }
880 catch ( Exception $e ) {
881 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
882 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
883 }
884 }
885
886 public function rest_ai_magic_wand( $request ) {
887 try {
888 $params = $request->get_json_params();
889 $action = isset( $params['action'] ) ? $params['action'] : null;
890 $data = isset( $params['data'] ) ? $params['data'] : null;
891 if ( empty( $data ) || empty( $action ) ) {
892 return $this->create_rest_response( [ 'success' => false, 'message' => 'An action and some data are required.' ], 500 );
893 }
894 $data = apply_filters( 'mwai_magic_wand_' . $action, '', $data );
895 return $this->create_rest_response( [ 'success' => true, 'data' => $data ], 200 );
896 }
897 catch ( Exception $e ) {
898 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
899 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
900 }
901 }
902
903 public function rest_ai_copilot( $request ) {
904 try {
905 $params = $request->get_json_params();
906 $action = sanitize_text_field( $params['action'] );
907 $message = $this->retrieve_message( $params, true );
908 $context = sanitize_text_field( $params['context'] );
909 $postId = !empty( $params['postId'] ) ? intval( $params['postId'] ) : null;
910 if ( empty( $action ) || empty( $message ) ) {
911 return $this->create_rest_response( [ 'success' => false, 'message' => 'Copilot needs an action and a prompt.' ], 500 );
912 }
913
914 global $mwai;
915 $result = null;
916 $params = [ 'scope' => 'copilot' ];
917
918 if ( $action === 'text' ) {
919 $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.";
920 if ( !empty( $model ) ) {
921 $params['model'] = $model;
922 }
923 $result = $mwai->simpleTextQuery( $prompt, $params );
924 }
925 else if ( $action === 'image' ) {
926 $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.";
927
928 // Create the image
929 $simplifiedPrompt = $mwai->simpleTextQuery( $prompt, $params );
930 $media = $mwai->imageQueryForMediaLibrary( $simplifiedPrompt, $params, $postId );
931 $result = [ 'media' => $media ];
932 }
933 return $this->create_rest_response( [ 'success' => true, 'data' => $result ], 200 );
934 }
935 catch ( Exception $e ) {
936 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
937 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
938 }
939 }
940
941 public function rest_helpers_update_title( $request ) {
942 try {
943 $params = $request->get_json_params();
944 $title = sanitize_text_field( $params['title'] );
945 $postId = intval( $params['postId'] );
946 $post = get_post( $postId );
947 if ( !$post ) {
948 throw new Exception( __( 'There is no post with this ID.', 'ai-engine' ) );
949 }
950 $post->post_title = $title;
951 //$post->post_name = sanitize_title( $title );
952 wp_update_post( $post );
953 return $this->create_rest_response( [ 'success' => true, 'message' => 'Title updated.' ], 200 );
954 }
955 catch ( Exception $e ) {
956 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
957 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
958 }
959 }
960
961 public function rest_helpers_update_excerpt( $request ) {
962 try {
963 $params = $request->get_json_params();
964 $excerpt = sanitize_text_field( $params['excerpt'] );
965 $postId = intval( $params['postId'] );
966 $post = get_post( $postId );
967 if ( !$post ) {
968 throw new Exception( __( 'There is no post with this ID.', 'ai-engine' ) );
969 }
970 $post->post_excerpt = $excerpt;
971 wp_update_post( $post );
972 return $this->create_rest_response( [ 'success' => true, 'message' => 'Excerpt updated.' ], 200 );
973 }
974 catch ( Exception $e ) {
975 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
976 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
977 }
978 }
979
980 public function rest_helpers_create_post( $request ) {
981 try {
982 $params = $request->get_json_params();
983 $title = sanitize_text_field( $params['title'] );
984 $content = sanitize_textarea_field( $params['content'] );
985 $excerpt = sanitize_text_field( $params['excerpt'] );
986 $postType = sanitize_text_field( $params['postType'] );
987 $post = new stdClass();
988 $post->post_title = $title;
989 $post->post_excerpt = $excerpt;
990 $post->post_content = $content;
991 $post->post_status = 'draft';
992 $post->post_type = isset( $postType ) ? $postType : 'post';
993 // TODO: Let's try to avoid using Markdown to create the Post
994 // Instead, we should create Gutenberg Blocks, or simple HTML.
995 // Then, we can get rid of the library for Markdown.
996 $post->post_content = $this->core->markdown_to_html( $post->post_content );
997 $postId = wp_insert_post( $post );
998 return $this->create_rest_response( [ 'success' => true, 'postId' => $postId ], 200 );
999 }
1000 catch ( Exception $e ) {
1001 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1002 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1003 }
1004 }
1005
1006 public function rest_helpers_create_images( $request ) {
1007 try {
1008 $params = $request->get_json_params();
1009 $title = sanitize_text_field( $params['title'] );
1010 $caption = sanitize_text_field( $params['caption'] );
1011 $alt = sanitize_text_field( $params['alt'] );
1012 $description = sanitize_text_field( $params['description'] );
1013 $url = $params['url'];
1014 $filename = sanitize_text_field( $params['filename'] );
1015
1016 // Prepare AI metadata
1017 $ai_metadata = [];
1018 if ( !empty( $params['model'] ) ) {
1019 $ai_metadata['model'] = $params['model'];
1020 }
1021 if ( !empty( $params['latency'] ) ) {
1022 $ai_metadata['latency'] = $params['latency'];
1023 }
1024 if ( !empty( $params['env_id'] ) ) {
1025 $ai_metadata['env_id'] = $params['env_id'];
1026 }
1027
1028 // Debug logging
1029 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1030 error_log( '[AI Engine] create_image metadata: ' . json_encode( $ai_metadata ) );
1031 }
1032
1033 // Create as mwai_image post type (draft image)
1034 $attachmentId = $this->core->add_image_from_url( $url, $filename, $title, $description, $caption, $alt, null, 'inherit', 'mwai_image', $ai_metadata );
1035
1036 // Add to user's draft media
1037 $user_id = get_current_user_id();
1038 $draft_media = get_user_meta( $user_id, 'mwai_draft_media', true );
1039 if ( !is_array( $draft_media ) ) {
1040 $draft_media = [];
1041 }
1042 $draft_media[] = [
1043 'attachment_id' => $attachmentId,
1044 'type' => 'image',
1045 'created_at' => time()
1046 ];
1047 update_user_meta( $user_id, 'mwai_draft_media', $draft_media );
1048
1049 return $this->create_rest_response( [ 'success' => true, 'attachmentId' => $attachmentId ], 200 );
1050 }
1051 catch ( Exception $e ) {
1052 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1053 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1054 }
1055 }
1056
1057 public function rest_helpers_generate_image_meta( $request ) {
1058 try {
1059 global $mwai;
1060 $params = $request->get_json_params();
1061 $attachment_id = isset( $params['attachmentId'] ) ? intval( $params['attachmentId'] ) : null;
1062
1063 if ( empty( $attachment_id ) ) {
1064 throw new Exception( __( 'The attachment ID is required.', 'ai-engine' ) );
1065 }
1066
1067 // Get the file path from the attachment ID
1068 $file_path = get_attached_file( $attachment_id );
1069 if ( empty( $file_path ) || !file_exists( $file_path ) ) {
1070 throw new Exception( __( 'Could not find the attachment file.', 'ai-engine' ) );
1071 }
1072
1073 $prompt = 'Describe this image and suggest a short title and description. '
1074 . 'Also suggest an SEO-friendly filename (lowercase, ASCII characters only, with hyphens instead of spaces). '
1075 . 'Return a JSON with the keys: title, description, filename.';
1076
1077 // Use file path instead of URL to avoid network issues
1078 $result = $mwai->simpleVisionQuery( $prompt, null, $file_path, [ 'scope' => 'admin-tools' ] );
1079 $result = preg_replace( '/^```json\s*/', '', $result );
1080 $result = preg_replace( '/\s*```$/', '', $result );
1081 if ( is_string( $result ) ) {
1082 $data = json_decode( $result, true );
1083 }
1084 else {
1085 $data = $result;
1086 }
1087 if ( !is_array( $data ) ) {
1088 $data = [];
1089 }
1090 $data = array_merge( [ 'title' => '', 'description' => '', 'filename' => '' ], $data );
1091 return $this->create_rest_response( [ 'success' => true, 'data' => $data ], 200 );
1092 }
1093 catch ( Exception $e ) {
1094 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1095 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1096 }
1097 }
1098
1099 public function rest_helpers_update_media_metadata( $request ) {
1100 try {
1101 $params = $request->get_json_params();
1102 $attachment_id = intval( $params['attachmentId'] );
1103 $title = sanitize_text_field( $params['title'] ?? '' );
1104 $description = sanitize_text_field( $params['description'] ?? '' );
1105 $caption = sanitize_text_field( $params['caption'] ?? '' );
1106 $alt = sanitize_text_field( $params['alt'] ?? '' );
1107 $filename = sanitize_file_name( $params['filename'] ?? '' );
1108
1109 if ( !$attachment_id ) {
1110 throw new Exception( __( 'Attachment ID is required.', 'ai-engine' ) );
1111 }
1112
1113 // Generate slug from filename (without extension)
1114 $slug = '';
1115 if ( !empty( $filename ) ) {
1116 $slug = pathinfo( $filename, PATHINFO_FILENAME );
1117 }
1118
1119 // Update post title, content (description), caption, and slug
1120 $update_data = [
1121 'ID' => $attachment_id,
1122 'post_title' => $title,
1123 'post_content' => $description,
1124 'post_excerpt' => $caption
1125 ];
1126
1127 if ( !empty( $slug ) ) {
1128 $update_data['post_name'] = $slug;
1129 }
1130
1131 wp_update_post( $update_data );
1132
1133 // Update alt text
1134 if ( !empty( $alt ) ) {
1135 update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt );
1136 }
1137
1138 // Update filename if provided
1139 $new_url = null;
1140 if ( !empty( $filename ) ) {
1141 $file_path = get_attached_file( $attachment_id );
1142 if ( $file_path ) {
1143 // Security: Validate file extension to prevent arbitrary file upload attacks
1144 $original_ext = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
1145 $new_ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
1146
1147 // Allowlist of safe media extensions (no executable types)
1148 $allowed_extensions = [
1149 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico', 'svg', 'avif',
1150 'mp4', 'webm', 'ogg', 'mov', 'avi', 'wmv', 'flv', 'm4v',
1151 'mp3', 'wav', 'flac', 'aac', 'm4a', 'wma',
1152 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'rtf'
1153 ];
1154
1155 // Extension must be in allowlist AND match original extension
1156 if ( !in_array( $new_ext, $allowed_extensions, true ) ) {
1157 throw new Exception( __( 'Invalid file extension. Only media file extensions are allowed.', 'ai-engine' ) );
1158 }
1159 if ( $new_ext !== $original_ext ) {
1160 throw new Exception( __( 'File extension must match the original file type.', 'ai-engine' ) );
1161 }
1162
1163 $path_parts = pathinfo( $file_path );
1164 $new_file_path = $path_parts['dirname'] . '/' . $filename;
1165 if ( rename( $file_path, $new_file_path ) ) {
1166 update_attached_file( $attachment_id, $new_file_path );
1167 // Build new URL from file path for custom post types
1168 $upload_dir = wp_upload_dir();
1169 $new_url = str_replace( $upload_dir['basedir'], $upload_dir['baseurl'], $new_file_path );
1170 }
1171 }
1172 }
1173
1174 $response = [ 'success' => true ];
1175 if ( $new_url ) {
1176 $response['url'] = $new_url;
1177 }
1178
1179 return $this->create_rest_response( $response, 200 );
1180 }
1181 catch ( Exception $e ) {
1182 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1183 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1184 }
1185 }
1186
1187 public function rest_openai_files_get() {
1188 try {
1189 $envId = isset( $_GET['envId'] ) ? $_GET['envId'] : null;
1190 $purposeFilter = isset( $_GET['purpose'] ) ? $_GET['purpose'] : null;
1191 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1192 $files = $openai->list_files( $purposeFilter );
1193 return $this->create_rest_response( [ 'success' => true, 'files' => $files ], 200 );
1194 }
1195 catch ( Exception $e ) {
1196 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1197 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1198 }
1199 }
1200
1201 // TODO: Remove all the rest_openai_*finetune* handlers below after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06).
1202 public function rest_openai_deleted_finetunes_get() {
1203 try {
1204 $envId = isset( $_GET['envId'] ) ? $_GET['envId'] : null;
1205 $legacy = isset( $_GET['legacy'] ) ? $_GET['legacy'] === 'true' : false;
1206 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1207 $finetunes = $openai->list_deleted_finetunes( $legacy );
1208 return $this->create_rest_response( [ 'success' => true, 'finetunes' => $finetunes ], 200 );
1209 }
1210 catch ( Exception $e ) {
1211 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1212 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1213 }
1214 }
1215
1216 public function rest_openai_finetunes_get() {
1217 try {
1218 $envId = isset( $_GET['envId'] ) ? $_GET['envId'] : null;
1219 $legacy = isset( $_GET['legacy'] ) ? $_GET['legacy'] === 'true' : false;
1220 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1221 $finetunes = $openai->list_finetunes( $legacy );
1222 return $this->create_rest_response( [ 'success' => true, 'finetunes' => $finetunes ], 200 );
1223 }
1224 catch ( Exception $e ) {
1225 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1226 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1227 }
1228 }
1229
1230 public function rest_openai_files_upload( $request ) {
1231 try {
1232 $params = $request->get_json_params();
1233 $envId = $params['envId'];
1234 ;
1235 $filename = sanitize_text_field( $params['filename'] );
1236 $data = $params['data'];
1237 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1238 $file = $openai->upload_file( $filename, $data );
1239 return $this->create_rest_response( [ 'success' => true, 'file' => $file ], 200 );
1240 }
1241 catch ( Exception $e ) {
1242 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1243 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1244 }
1245 }
1246
1247 public function rest_openai_files_delete( $request ) {
1248 try {
1249 $params = $request->get_json_params();
1250 $envId = $params['envId'];
1251 ;
1252 $fileId = $params['fileId'];
1253 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1254 $openai->delete_file( $fileId );
1255 return $this->create_rest_response( [ 'success' => true ], 200 );
1256 }
1257 catch ( Exception $e ) {
1258 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1259 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1260 }
1261 }
1262
1263 public function rest_openai_finetunes_cancel( $request ) {
1264 try {
1265 $params = $request->get_json_params();
1266 $envId = $params['envId'];
1267 ;
1268 $finetuneId = $params['finetuneId'];
1269 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1270 $openai->cancel_finetune( $finetuneId );
1271 return $this->create_rest_response( [ 'success' => true ], 200 );
1272 }
1273 catch ( Exception $e ) {
1274 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1275 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1276 }
1277 }
1278
1279 public function rest_openai_finetunes_delete( $request ) {
1280 try {
1281 $params = $request->get_json_params();
1282 $envId = $params['envId'];
1283 ;
1284 $modelId = $params['modelId'];
1285 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1286 $openai->delete_finetune( $modelId );
1287 return $this->create_rest_response( [ 'success' => true ], 200 );
1288 }
1289 catch ( Exception $e ) {
1290 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1291 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1292 }
1293 }
1294
1295 public function rest_openai_files_download( $request ) {
1296 try {
1297 $params = $request->get_json_params();
1298 $envId = $params['envId'];
1299 ;
1300 $fileId = $params['fileId'];
1301 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1302 $filename = $openai->download_file( $fileId );
1303 $data = file_get_contents( $filename );
1304 return $this->create_rest_response( [ 'success' => true, 'data' => $data ], 200 );
1305 }
1306 catch ( Exception $e ) {
1307 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1308 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1309 }
1310 }
1311
1312 public function rest_openai_files_finetune( $request ) {
1313 try {
1314 $params = $request->get_json_params();
1315 $envId = $params['envId'];
1316 ;
1317 $fileId = $params['fileId'];
1318 $model = $params['model'];
1319 $suffix = $params['suffix'];
1320 $hyperparams = [
1321 'nEpochs' => isset( $params['nEpochs'] ) ? $params['nEpochs'] : null,
1322 'batchSize' => isset( $params['batchSize'] ) ? $params['batchSize'] : null,
1323 ];
1324 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1325 $finetune = $openai->run_finetune( $fileId, $model, $suffix, $hyperparams );
1326 return $this->create_rest_response( [ 'success' => true, 'finetune' => $finetune ], 200 );
1327 }
1328 catch ( Exception $e ) {
1329 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1330 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1331 }
1332 }
1333
1334 /**
1335 * Term joins mirroring the Sync settings, shared by the Push All count and the
1336 * Push All id list so the two never disagree.
1337 *
1338 * Push All must select exactly what Sync would maintain. Otherwise it seeds the
1339 * index with posts the lifecycle hooks never update or remove: a site syncing
1340 * only 'fr' was pushing every language. Semantics match
1341 * MeowPro_MWAI_Embeddings: an empty list means no filtering, a non-empty one
1342 * requires at least one match. The joins go before the WHERE clause, so their
1343 * arguments come first in the prepare() call.
1344 */
1345 private function sync_term_joins( $params, &$joinArgs ) {
1346 global $wpdb;
1347 $joinArgs = [];
1348 $joins = '';
1349 $csv = function ( $key ) use ( $params ) {
1350 if ( empty( $params[$key] ) ) {
1351 return [];
1352 }
1353 return array_values( array_filter( array_map( 'trim', explode( ',', $params[$key] ) ) ) );
1354 };
1355 $termJoin = function ( $alias, $taxonomy, $slugs ) use ( $wpdb, &$joins, &$joinArgs ) {
1356 $placeholders = implode( ',', array_fill( 0, count( $slugs ), '%s' ) );
1357 $joins .= " INNER JOIN {$wpdb->term_relationships} tr_{$alias}"
1358 . " ON tr_{$alias}.object_id = p.ID"
1359 . " INNER JOIN {$wpdb->term_taxonomy} tt_{$alias}"
1360 . " ON tt_{$alias}.term_taxonomy_id = tr_{$alias}.term_taxonomy_id"
1361 . " AND tt_{$alias}.taxonomy = '{$taxonomy}'"
1362 . " INNER JOIN {$wpdb->terms} t_{$alias}"
1363 . " ON t_{$alias}.term_id = tt_{$alias}.term_id AND t_{$alias}.slug IN ({$placeholders})";
1364 $joinArgs = array_merge( $joinArgs, $slugs );
1365 };
1366 $postCategories = $csv( 'postCategories' );
1367 if ( !empty( $postCategories ) ) {
1368 $termJoin( 'cat', 'category', $postCategories );
1369 }
1370 // Polylang stores the language as a 'language' term whose slug is the code,
1371 // which is what pll_get_post_language() returns. Without Polylang there is
1372 // nothing to filter on, exactly like is_post_language_synced().
1373 $postLanguages = $csv( 'postLanguages' );
1374 if ( !empty( $postLanguages ) && taxonomy_exists( 'language' ) ) {
1375 $termJoin( 'lang', 'language', $postLanguages );
1376 }
1377 return $joins;
1378 }
1379
1380 public function rest_helpers_count_posts( $request ) {
1381 try {
1382 global $wpdb;
1383 $params = $request->get_query_params();
1384 $postType = $params['postType'];
1385 $postStatus = !empty( $params['postStatus'] ) ? explode( ',', $params['postStatus'] ) : [ 'publish' ];
1386 $joinArgs = [];
1387 $joins = $this->sync_term_joins( $params, $joinArgs );
1388 $statusPlaceholders = implode( ',', array_fill( 0, count( $postStatus ), '%s' ) );
1389 $ignored_ids = $wpdb->get_col(
1390 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_mwai_embedding_ignore'"
1391 );
1392 $exclude_sql = '';
1393 if ( !empty( $ignored_ids ) ) {
1394 $ignored_ids = array_map( 'intval', $ignored_ids );
1395 $exclude_sql = ' AND p.ID NOT IN (' . implode( ',', $ignored_ids ) . ')';
1396 }
1397 $mimeFilter = '';
1398 if ( $postType === 'attachment' ) {
1399 $mimeFilter = " AND p.post_mime_type LIKE 'image/%'";
1400 }
1401 // COUNT(DISTINCT) because a post matching several joined terms would repeat.
1402 $query = "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p" . $joins . "
1403 WHERE p.post_type = %s
1404 AND p.post_status IN ($statusPlaceholders)" . $exclude_sql . $mimeFilter;
1405 $prepareArgs = array_merge( $joinArgs, [ $postType ], $postStatus );
1406 $count = (int) $wpdb->get_var( $wpdb->prepare( $query, ...$prepareArgs ) );
1407 return $this->create_rest_response( [ 'success' => true, 'count' => $count ], 200 );
1408 }
1409 catch ( Exception $e ) {
1410 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1411 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1412 }
1413 }
1414
1415 public function rest_helpers_posts_ids( $request ) {
1416 try {
1417 global $wpdb;
1418 $params = $request->get_query_params();
1419 $postType = $params['postType'];
1420 $postStatus = !empty( $params['postStatus'] ) ? explode( ',', $params['postStatus'] ) : [ 'publish' ];
1421 $joinArgs = [];
1422 $joins = $this->sync_term_joins( $params, $joinArgs );
1423
1424 // Use direct SQL query instead of get_posts to avoid memory issues with large sites
1425 $statusPlaceholders = implode( ',', array_fill( 0, count( $postStatus ), '%s' ) );
1426 $ignored_ids = $wpdb->get_col(
1427 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_mwai_embedding_ignore'"
1428 );
1429 $exclude_sql = '';
1430 if ( !empty( $ignored_ids ) ) {
1431 $ignored_ids = array_map( 'intval', $ignored_ids );
1432 $exclude_sql = ' AND p.ID NOT IN (' . implode( ',', $ignored_ids ) . ')';
1433 }
1434 $mimeFilter = '';
1435 if ( $postType === 'attachment' ) {
1436 $mimeFilter = " AND p.post_mime_type LIKE 'image/%'";
1437 }
1438 // DISTINCT because a post matching several of the joined terms would repeat.
1439 $query = "SELECT DISTINCT p.ID FROM {$wpdb->posts} p" . $joins . "
1440 WHERE p.post_type = %s
1441 AND p.post_status IN ($statusPlaceholders)" . $exclude_sql . $mimeFilter . '
1442 ORDER BY p.ID ASC';
1443
1444 $prepareArgs = array_merge( $joinArgs, [ $postType ], $postStatus );
1445 $postIds = $wpdb->get_col( $wpdb->prepare( $query, ...$prepareArgs ) );
1446 $postIds = array_map( 'intval', $postIds );
1447
1448 return $this->create_rest_response( [ 'success' => true, 'postIds' => $postIds ], 200 );
1449 }
1450 catch ( Exception $e ) {
1451 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1452 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1453 }
1454 }
1455
1456 public function rest_helpers_post_content( $request ) {
1457 try {
1458 $params = $request->get_query_params();
1459 $offset = (int) $params['offset'];
1460 $postType = $params['postType'];
1461 $postStatus = isset( $params['postStatus'] ) ? explode( ',', $params['postStatus'] ) : [ 'publish' ];
1462 $postId = (int) $params['postId'];
1463
1464 $post = null;
1465 if ( !empty( $postId ) ) {
1466 $post = get_post( $postId );
1467 if ( $post->post_status !== 'publish' && $post->post_status !== 'future'
1468 && $post->post_status !== 'draft' && $post->post_status !== 'private' ) {
1469 $post = null;
1470 }
1471 }
1472 else {
1473 $posts = get_posts( [
1474 'posts_per_page' => 1,
1475 'post_type' => $postType,
1476 'offset' => $offset,
1477 'post_status' => $postStatus,
1478 ] );
1479 $post = count( $posts ) === 0 ? null : $posts[0];
1480 }
1481 if ( !$post ) {
1482 return $this->create_rest_response( [ 'success' => false, 'message' => 'Post not found' ], 404 );
1483 }
1484 $cleanPost = $this->core->get_post( $post );
1485 return $this->create_rest_response( [ 'success' => true, 'content' => $cleanPost['content'],
1486 'checksum' => $cleanPost['checksum'], 'language' => $cleanPost['language'], 'excerpt' => $cleanPost['excerpt'],
1487 'postId' => $cleanPost['postId'], 'title' => $cleanPost['title'], 'url' => $cleanPost['url'] ], 200 );
1488 }
1489 catch ( Exception $e ) {
1490 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1491 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1492 }
1493 }
1494
1495 // Batch check which posts have content (for Push All optimization)
1496 public function rest_helpers_check_posts_content( $request ) {
1497 try {
1498 $params = $request->get_json_params();
1499 $postIds = isset( $params['postIds'] ) ? $params['postIds'] : [];
1500
1501 if ( empty( $postIds ) || !is_array( $postIds ) ) {
1502 return $this->create_rest_response( [
1503 'success' => false,
1504 'message' => 'postIds array is required'
1505 ], 400 );
1506 }
1507
1508 // Sanitize post IDs
1509 $postIds = array_map( 'intval', $postIds );
1510
1511 // Check content using the mwai_pre_post_content filter to support page builders,
1512 // ACF, and other plugins that store content outside of post_content
1513 $postsWithContent = [];
1514
1515 foreach ( $postIds as $postId ) {
1516 $post = get_post( $postId );
1517 if ( !$post ) {
1518 continue;
1519 }
1520 // Apply the same filter used by get_post_content() in core.php
1521 $content = apply_filters( 'mwai_pre_post_content', $post->post_content, $postId );
1522 $content = trim( strip_tags( $content ) );
1523 if ( !empty( $content ) ) {
1524 $postsWithContent[] = $postId;
1525 }
1526 }
1527
1528 return $this->create_rest_response( [
1529 'success' => true,
1530 'postsWithContent' => $postsWithContent
1531 ], 200 );
1532 }
1533 catch ( Exception $e ) {
1534 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1535 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1536 }
1537 }
1538
1539 public function rest_helpers_run_tasks( $request ) {
1540 try {
1541 // Prevent concurrent execution with a transient lock
1542 $lock_key = 'mwai_rest_run_tasks_lock';
1543 if ( get_transient( $lock_key ) ) {
1544 // Log excessive calls for debugging
1545 if ( $this->core->get_option( 'dev_mode' ) ) {
1546 error_log( '[AI Engine] WARNING: rest_helpers_run_tasks called while already running' );
1547 }
1548 return $this->create_rest_response( [
1549 'success' => false,
1550 'message' => 'Tasks are already running. Please wait.'
1551 ], 429 ); // 429 Too Many Requests
1552 }
1553
1554 // Set lock for 30 seconds
1555 set_transient( $lock_key, true, 30 );
1556
1557 // Log task execution start
1558 if ( $this->core->get_option( 'dev_mode' ) ) {
1559 error_log( '[AI Engine] rest_helpers_run_tasks triggered via REST API' );
1560 }
1561
1562 try {
1563 do_action( 'mwai_tasks_run' );
1564 delete_transient( $lock_key );
1565 return $this->create_rest_response( [ 'success' => true ], 200 );
1566 }
1567 catch ( Exception $e ) {
1568 delete_transient( $lock_key );
1569 throw $e;
1570 }
1571 }
1572 catch ( Exception $e ) {
1573 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1574 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1575 }
1576 }
1577
1578 public function rest_helpers_optimize_database( $request ) {
1579 try {
1580 global $wpdb;
1581 $results = [];
1582
1583 // Add indexes to optimize query performance
1584 $indexes = [
1585 // mwai_logs indexes
1586 [ 'table' => 'mwai_logs', 'name' => 'idx_mwai_logs_time', 'columns' => 'time' ],
1587 [ 'table' => 'mwai_logs', 'name' => 'idx_mwai_logs_userId', 'columns' => 'userId' ],
1588 [ 'table' => 'mwai_logs', 'name' => 'idx_mwai_logs_envId', 'columns' => 'envId' ],
1589 [ 'table' => 'mwai_logs', 'name' => 'idx_mwai_logs_refId', 'columns' => 'refId' ],
1590 [ 'table' => 'mwai_logs', 'name' => 'idx_mwai_logs_time_model', 'columns' => 'time, model' ],
1591
1592 // mwai_logmeta indexes
1593 [ 'table' => 'mwai_logmeta', 'name' => 'idx_mwai_logmeta_log_id', 'columns' => 'log_id' ],
1594
1595 // mwai_vectors indexes
1596 [ 'table' => 'mwai_vectors', 'name' => 'idx_mwai_vectors_envId_status_dbId', 'columns' => 'envId, status, dbId' ],
1597 [ 'table' => 'mwai_vectors', 'name' => 'idx_mwai_vectors_refId', 'columns' => 'refId' ],
1598 [ 'table' => 'mwai_vectors', 'name' => 'idx_mwai_vectors_status', 'columns' => 'status' ],
1599 [ 'table' => 'mwai_vectors', 'name' => 'idx_mwai_vectors_updated', 'columns' => 'updated' ],
1600
1601 // mwai_files indexes
1602 [ 'table' => 'mwai_files', 'name' => 'idx_mwai_files_expires', 'columns' => 'expires' ],
1603 [ 'table' => 'mwai_files', 'name' => 'idx_mwai_files_userId', 'columns' => 'userId' ],
1604 [ 'table' => 'mwai_files', 'name' => 'idx_mwai_files_purpose', 'columns' => 'purpose' ],
1605
1606 // mwai_filemeta indexes
1607 [ 'table' => 'mwai_filemeta', 'name' => 'idx_mwai_filemeta_file_id', 'columns' => 'file_id' ],
1608
1609 // mwai_chats indexes
1610 [ 'table' => 'mwai_chats', 'name' => 'idx_mwai_chats_chatId_botId', 'columns' => 'chatId, botId' ],
1611 [ 'table' => 'mwai_chats', 'name' => 'idx_mwai_chats_chatId_userId', 'columns' => 'chatId, userId' ],
1612 [ 'table' => 'mwai_chats', 'name' => 'idx_mwai_chats_updated', 'columns' => 'updated' ],
1613 ];
1614
1615 // Add indexes
1616 foreach ( $indexes as $index ) {
1617 $table = $wpdb->prefix . $index['table'];
1618 $index_name = $index['name'];
1619 $columns = $index['columns'];
1620
1621 // Check if index already exists
1622 $existing = $wpdb->get_var( $wpdb->prepare(
1623 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
1624 WHERE table_schema = %s AND table_name = %s AND index_name = %s',
1625 DB_NAME,
1626 $table,
1627 $index_name
1628 ) );
1629
1630 if ( !$existing ) {
1631 $wpdb->query( "ALTER TABLE `$table` ADD INDEX `$index_name` ($columns)" );
1632 $results[] = "Added index $index_name on $table";
1633 }
1634 }
1635
1636 // Clean up old logs (older than 3 months)
1637 $three_months_ago = date( 'Y-m-d H:i:s', strtotime( '-3 months' ) );
1638
1639 // Delete old logs
1640 $deleted_logs = $wpdb->query( $wpdb->prepare(
1641 "DELETE FROM {$wpdb->prefix}mwai_logs WHERE time < %s",
1642 $three_months_ago
1643 ) );
1644 $results[] = "Deleted $deleted_logs old log entries";
1645
1646 // Delete orphaned logmeta
1647 $deleted_logmeta = $wpdb->query(
1648 "DELETE lm FROM {$wpdb->prefix}mwai_logmeta lm
1649 LEFT JOIN {$wpdb->prefix}mwai_logs l ON lm.log_id = l.id
1650 WHERE l.id IS NULL"
1651 );
1652 $results[] = "Deleted $deleted_logmeta orphaned logmeta entries";
1653
1654 // Delete old chats (older than 3 months)
1655 $deleted_chats = $wpdb->query( $wpdb->prepare(
1656 "DELETE FROM {$wpdb->prefix}mwai_chats WHERE updated < %s",
1657 $three_months_ago
1658 ) );
1659 $results[] = "Deleted $deleted_chats old chat discussions";
1660
1661 // Optimize tables
1662 $tables = [ 'mwai_logs', 'mwai_logmeta', 'mwai_vectors', 'mwai_files', 'mwai_filemeta', 'mwai_chats' ];
1663 foreach ( $tables as $table ) {
1664 $wpdb->query( "OPTIMIZE TABLE {$wpdb->prefix}$table" );
1665 }
1666 $results[] = 'Optimized all AI Engine tables';
1667
1668 $message = implode( "\n", $results );
1669 return $this->create_rest_response( [ 'success' => true, 'message' => $message ], 200 );
1670 }
1671 catch ( Exception $e ) {
1672 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1673 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1674 }
1675 }
1676
1677 public function rest_system_templates_get( $request ) {
1678 try {
1679 $params = $request->get_query_params();
1680 $category = $params['category'];
1681 $templates = [];
1682 $templates_option = get_option( 'mwai_templates', [] );
1683 if ( !is_array( $templates_option ) ) {
1684 update_option( 'mwai_templates', [] );
1685 $templates_option = [];
1686 }
1687
1688 // Migration: DALL-E was removed (deprecated by OpenAI). Move templates to gpt-image-1.5.
1689 // TODO: Remove after 2027-04 (1 year after the shutdown on 2026-05-12).
1690 $deprecated = [ 'dall-e', 'dall-e-2', 'dall-e-3', 'dall-e-3-hd' ];
1691 $migrated = false;
1692 foreach ( $templates_option as &$group ) {
1693 if ( !empty( $group['templates'] ) && is_array( $group['templates'] ) ) {
1694 foreach ( $group['templates'] as &$template ) {
1695 if ( isset( $template['model'] ) && in_array( $template['model'], $deprecated, true ) ) {
1696 $template['model'] = MWAI_FALLBACK_MODEL_IMAGES;
1697 $migrated = true;
1698 }
1699 }
1700 }
1701 }
1702 unset( $group, $template );
1703 if ( $migrated ) {
1704 update_option( 'mwai_templates', $templates_option );
1705 }
1706
1707 $categories = array_column( $templates_option, 'category' );
1708 $index = array_search( $category, $categories );
1709 $templates = [];
1710 if ( $index !== false ) {
1711 $templates = $templates_option[$index]['templates'];
1712 }
1713 return $this->create_rest_response( [ 'success' => true, 'templates' => $templates ], 200 );
1714 }
1715 catch ( Exception $e ) {
1716 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1717 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1718 }
1719 }
1720
1721 public function rest_system_templates_save( $request ) {
1722 try {
1723 $params = $request->get_json_params();
1724 $category = $params['category'];
1725 $templates = $params['templates'];
1726 $templates_option = get_option( 'mwai_templates', [] );
1727 $categories = array_column( $templates_option, 'category' );
1728 $index = array_search( $category, $categories );
1729 if ( $index !== false && $index >= 0 ) {
1730 $templates_option[$index]['templates'] = $templates;
1731 }
1732 else {
1733 $group = [ 'category' => $category, 'templates' => $templates ];
1734 $templates_option[] = $group;
1735 }
1736
1737 update_option( 'mwai_templates', $templates_option );
1738 return $this->create_rest_response( [ 'success' => true ], 200 );
1739 }
1740 catch ( Exception $e ) {
1741 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1742 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1743 }
1744 }
1745
1746 public function rest_system_logs_list( $request ) {
1747 try {
1748 $params = $request->get_json_params();
1749 $offset = $params['offset'];
1750 $limit = $params['limit'];
1751 $filters = $params['filters'];
1752 $sort = isset( $params['sort'] ) ? $params['sort'] : null;
1753 $logs = apply_filters( 'mwai_stats_logs_list', [], $offset, $limit, $filters, $sort );
1754 return $this->create_rest_response( [ 'success' => true, 'total' => $logs['total'], 'logs' => $logs['rows'] ], 200 );
1755 }
1756 catch ( Exception $e ) {
1757 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1758 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1759 }
1760 }
1761
1762 public function rest_system_logs_delete( $request ) {
1763 try {
1764 $params = $request->get_json_params();
1765 $logIds = $params['logIds'];
1766 $success = apply_filters( 'mwai_stats_logs_delete', true, $logIds );
1767 return $this->create_rest_response( [ 'success' => $success ], 200 );
1768 }
1769 catch ( Exception $e ) {
1770 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1771 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1772 }
1773 }
1774
1775 public function rest_system_logs_meta_get( $request ) {
1776 try {
1777 $params = $request->get_json_params();
1778 $logId = $params['logId'];
1779 $metaKeys = $params['metaKeys'];
1780 $data = apply_filters( 'mwai_stats_logs_meta', [], $logId, $metaKeys );
1781 return $this->create_rest_response( [ 'success' => true, 'data' => $data ], 200 );
1782 }
1783 catch ( Exception $e ) {
1784 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1785 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1786 }
1787 }
1788
1789 public function rest_system_logs_activity( $request ) {
1790 try {
1791 $params = $request->get_json_params();
1792 $hours = isset( $params['hours'] ) ? intval( $params['hours'] ) : 24;
1793 $data = apply_filters( 'mwai_stats_logs_activity', [], $hours );
1794 return $this->create_rest_response( [ 'success' => true, 'data' => $data ], 200 );
1795 }
1796 catch ( Exception $e ) {
1797 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1798 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1799 }
1800 }
1801
1802 public function rest_system_logs_activity_daily( $request ) {
1803 try {
1804 $params = $request->get_json_params();
1805 $days = isset( $params['days'] ) ? intval( $params['days'] ) : 31;
1806 $byModel = isset( $params['byModel'] ) ? (bool) $params['byModel'] : false;
1807 $feature = isset( $params['feature'] ) ? sanitize_text_field( $params['feature'] ) : null;
1808
1809 if ( $byModel ) {
1810 $data = apply_filters( 'mwai_stats_logs_activity_daily_by_model', [], $days );
1811 }
1812 else {
1813 $data = apply_filters( 'mwai_stats_logs_activity_daily', [], $days, $feature );
1814 }
1815
1816 return $this->create_rest_response( [ 'success' => true, 'data' => $data ], 200 );
1817 }
1818 catch ( Exception $e ) {
1819 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1820 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1821 }
1822 }
1823
1824 public function rest_ai_moderate( $request ) {
1825 try {
1826 $params = $request->get_json_params();
1827 $envId = $params['envId'];
1828 $text = $params['text'];
1829 if ( !$text ) {
1830 return $this->create_rest_response( [ 'success' => false, 'message' => 'Text not found.' ], 404 );
1831 }
1832 $openai = Meow_MWAI_Engines_Factory::get_openai( $this->core, $envId );
1833 $results = $openai->moderate( $text );
1834 return $this->create_rest_response( [ 'success' => true, 'results' => $results ], 200 );
1835 }
1836 catch ( Exception $e ) {
1837 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1838 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1839 }
1840 }
1841
1842 public function rest_ai_transcribe_audio( $request ) {
1843 try {
1844 global $mwai;
1845 $params = $request->get_json_params();
1846 $url = !empty( $params['url'] ) ? $params['url'] : null;
1847 $mediaId = isset( $params['mediaId'] ) ? intval( $params['mediaId'] ) : 0;
1848 // A client-supplied path is deliberately ignored. It used to reach
1849 // file_get_contents() with only a stream-wrapper blocklist in front of it, so an
1850 // absolute path or ../ went straight through and the bytes were forwarded to the
1851 // configured transcription endpoint. On multisite that let a subsite administrator
1852 // read the network wp-config.php and exfiltrate the auth salts off-host. This is
1853 // the same class as CVE-2024-38791, which the image handler below already dropped
1854 // its path for. mediaId stays: it resolves through get_attached_file() on a real
1855 // attachment instead of an arbitrary string.
1856 $path = null;
1857
1858 // If mediaId is provided, get the file path
1859 if ( !$path && $mediaId > 0 ) {
1860 $path = get_attached_file( $mediaId );
1861 if ( empty( $path ) ) {
1862 throw new Exception( __( 'The media file cannot be found.', 'ai-engine' ) );
1863 }
1864 }
1865
1866 // Set the scope for admin tools
1867 if ( !isset( $params['scope'] ) ) {
1868 $params['scope'] = 'admin-tools';
1869 }
1870
1871 $result = $mwai->simpleTranscribeAudio( $url, $path, $params );
1872 return $this->create_rest_response( [ 'success' => true, 'data' => $result ], 200 );
1873 }
1874 catch ( Exception $e ) {
1875 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1876 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1877 }
1878 }
1879
1880 public function rest_ai_transcribe_image( $request ) {
1881 try {
1882 global $mwai;
1883 $params = $request->get_json_params();
1884 $message = $this->retrieve_message( $params );
1885 $url = !empty( $params['url'] ) ? $params['url'] : null;
1886 // This could lead to a security issue, so let's avoid using path directly.
1887 //$path = !empty( $params['path'] ) ? $params['path'] : null;
1888 $result = $mwai->simpleVisionQuery( $message, $url );
1889 return $this->create_rest_response( [ 'success' => true, 'data' => $result ], 200 );
1890 }
1891 catch ( Exception $e ) {
1892 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1893 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1894 }
1895 }
1896
1897 public function rest_ai_json( $request ) {
1898 try {
1899 global $mwai;
1900 $params = $request->get_json_params();
1901 $message = $this->retrieve_message( $params );
1902 $result = $mwai->simpleJsonQuery( $message );
1903 return $this->create_rest_response( [ 'success' => true, 'data' => $result ], 200 );
1904 }
1905 catch ( Exception $e ) {
1906 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1907 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1908 }
1909 }
1910
1911 public function rest_mcp_functions( $request ) {
1912 try {
1913 // Get all registered MCP tools. Handlers key entries by tool name, so the
1914 // array is associative; array_values() forces a JSON array (not an object)
1915 // for the client, which groups them with functions.reduce().
1916 $tools = array_values( apply_filters( 'mwai_mcp_tools', [] ) );
1917
1918 // Format the response
1919 $response = [
1920 'success' => true,
1921 'count' => count( $tools ),
1922 'functions' => $tools
1923 ];
1924
1925 return $this->create_rest_response( $response, 200 );
1926 }
1927 catch ( Exception $e ) {
1928 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
1929 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
1930 }
1931 }
1932
1933 /**
1934 * Loopback test that mimics the way Anthropic's claude.ai connector reaches
1935 * the site, so admins can detect a hosting-layer block without waiting until
1936 * they try to connect from claude.ai. Two stages, because WAFs filter them
1937 * independently and real tickets showed both patterns:
1938 *
1939 * 1. GET on the OAuth discovery path with the python-httpx User-Agent
1940 * (WP Engine's WAF blocks this one).
1941 * 2. POST on /wp-json/mcp/v1/http, python-httpx vs neutral User-Agent
1942 * (Bluehost's mod_security lets discovery GETs through but 403s the
1943 * POSTs, so OAuth client registration and every MCP call fail while
1944 * stage 1 looks fine). Both POSTs are unauthenticated, so WordPress
1945 * itself answers them identically: any python-only difference in
1946 * status or content type is the firewall talking.
1947 */
1948 public function rest_mcp_self_test( $request ) {
1949 try {
1950 $resource_url = rest_url( 'mcp/v1/http' );
1951 $probe_url = home_url( '/.well-known/oauth-protected-resource' . wp_parse_url( $resource_url, PHP_URL_PATH ) );
1952 $reference_url = rest_url( 'mcp/v1/.well-known/oauth-protected-resource' );
1953
1954 $args = [
1955 'timeout' => 10,
1956 'redirection' => 3,
1957 'sslverify' => apply_filters( 'mwai_mcp_self_test_sslverify', true ),
1958 'user-agent' => 'python-httpx/0.28.1',
1959 'headers' => [
1960 'Accept' => 'application/json',
1961 ],
1962 ];
1963
1964 $probe_response = wp_remote_get( $probe_url, $args );
1965 $reference_args = $args;
1966 $reference_args['user-agent'] = 'AI-Engine-Self-Test/1.0';
1967 $reference_response = wp_remote_get( $reference_url, $reference_args );
1968
1969 $build = function ( $url, $response ) {
1970 if ( is_wp_error( $response ) ) {
1971 return [
1972 'url' => $url,
1973 'reachable' => false,
1974 'error' => $response->get_error_message(),
1975 'status' => null,
1976 'content_type' => null,
1977 ];
1978 }
1979 return [
1980 'url' => $url,
1981 'reachable' => true,
1982 'status' => (int) wp_remote_retrieve_response_code( $response ),
1983 'content_type' => wp_remote_retrieve_header( $response, 'content-type' ),
1984 ];
1985 };
1986
1987 $probe = $build( $probe_url, $probe_response );
1988 $reference = $build( $reference_url, $reference_response );
1989
1990 // Stage 2: the POST pair. An unauthenticated initialize is harmless and
1991 // never reaches a tool; we only care whether the firewall lets it in.
1992 $post_body = wp_json_encode( [
1993 'jsonrpc' => '2.0',
1994 'id' => 1,
1995 'method' => 'initialize',
1996 'params' => [
1997 'protocolVersion' => '2025-03-26',
1998 'capabilities' => new stdClass(),
1999 'clientInfo' => [ 'name' => 'ai-engine-self-test', 'version' => '1.0' ],
2000 ],
2001 ] );
2002 $post_args = [
2003 'timeout' => 10,
2004 'redirection' => 3,
2005 'sslverify' => apply_filters( 'mwai_mcp_self_test_sslverify', true ),
2006 'user-agent' => 'python-httpx/0.28.1',
2007 'headers' => [
2008 'Accept' => 'application/json, text/event-stream',
2009 'Content-Type' => 'application/json',
2010 ],
2011 'body' => $post_body,
2012 ];
2013 $post_probe = $build( $resource_url, wp_remote_post( $resource_url, $post_args ) );
2014 $post_reference_args = $post_args;
2015 $post_reference_args['user-agent'] = 'AI-Engine-Self-Test/1.0';
2016 $post_reference = $build( $resource_url, wp_remote_post( $resource_url, $post_reference_args ) );
2017
2018 $is_json = function ( $probe ) {
2019 return $probe['content_type'] && strpos( $probe['content_type'], 'json' ) !== false;
2020 };
2021 // The firewall reveals itself by treating the python UA differently from
2022 // the neutral one: different status, or a block page instead of the JSON
2023 // that WordPress returns to both unauthenticated POSTs.
2024 $post_blocked = ( $post_reference['reachable'] && (
2025 ( !$post_probe['reachable'] )
2026 || ( $post_probe['status'] !== $post_reference['status'] && in_array( $post_probe['status'], [ 403, 406, 418, 429 ], true ) )
2027 || ( $is_json( $post_reference ) && !$is_json( $post_probe ) )
2028 ) );
2029
2030 $verdict = 'unknown';
2031 $message = '';
2032 if ( $probe['reachable'] && $probe['status'] === 403 ) {
2033 $verdict = 'waf_blocks_python_ua';
2034 $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.';
2035 }
2036 else if ( $probe['reachable'] && $probe['status'] === 404 ) {
2037 $verdict = 'wellknown_blocked';
2038 $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.';
2039 }
2040 else if ( !$probe['reachable'] ) {
2041 $verdict = 'unreachable';
2042 $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.';
2043 }
2044 else if ( $probe['status'] === 200 && $post_blocked ) {
2045 $verdict = 'waf_blocks_python_post';
2046 $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.';
2047 }
2048 else if ( $probe['status'] === 200 ) {
2049 $verdict = 'ok';
2050 $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.';
2051 }
2052 else {
2053 $verdict = 'unexpected_status';
2054 $message = 'Got HTTP ' . $probe['status'] . ' from the loopback probe. Expected 200. Investigate the response in your CDN/origin logs.';
2055 }
2056
2057 return $this->create_rest_response( [
2058 'success' => true,
2059 'verdict' => $verdict,
2060 'message' => $message,
2061 'probe' => $probe,
2062 'reference' => $reference,
2063 'post_probe' => $post_probe,
2064 'post_reference' => $post_reference,
2065 ], 200 );
2066 }
2067 catch ( Exception $e ) {
2068 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
2069 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
2070 }
2071 }
2072
2073 /**
2074 * Top MCP tools by call count over the last N days. Powers the
2075 * "Top Tools" widget in the MCP Logs view of the Insights screen.
2076 * Returns rows shaped as { tool, count, success_count, error_count }.
2077 */
2078 public function rest_mcp_top_tools( $request ) {
2079 try {
2080 $params = $request->get_json_params();
2081 $days = isset( $params['days'] ) ? max( 1, intval( $params['days'] ) ) : 7;
2082 $limit = isset( $params['limit'] ) ? max( 1, min( 50, intval( $params['limit'] ) ) ) : 10;
2083
2084 global $wpdb;
2085 $table = $wpdb->prefix . 'mwai_logs';
2086 $rows = $wpdb->get_results(
2087 $wpdb->prepare(
2088 "SELECT scope AS tool,
2089 COUNT(*) AS count,
2090 SUM(CASE WHEN stats LIKE %s THEN 1 ELSE 0 END) AS success_count,
2091 SUM(CASE WHEN stats LIKE %s THEN 1 ELSE 0 END) AS error_count
2092 FROM $table
2093 WHERE feature = 'mcp_tool'
2094 AND time >= DATE_SUB(NOW(), INTERVAL %d DAY)
2095 GROUP BY scope
2096 ORDER BY count DESC
2097 LIMIT %d",
2098 '%"status":"success"%',
2099 '%"status":"error"%',
2100 $days,
2101 $limit
2102 ),
2103 ARRAY_A
2104 );
2105
2106 foreach ( $rows as &$row ) {
2107 $row['count'] = (int) $row['count'];
2108 $row['success_count'] = (int) $row['success_count'];
2109 $row['error_count'] = (int) $row['error_count'];
2110 }
2111 unset( $row );
2112
2113 return $this->create_rest_response( [ 'success' => true, 'tools' => $rows ?: [] ], 200 );
2114 }
2115 catch ( Exception $e ) {
2116 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
2117 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
2118 }
2119 }
2120
2121 public function rest_helpers_post_types() {
2122 try {
2123 $postTypes = $this->core->get_post_types();
2124 return $this->create_rest_response( [ 'success' => true, 'postTypes' => $postTypes ], 200 );
2125 }
2126 catch ( Exception $e ) {
2127 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
2128 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
2129 }
2130 }
2131
2132 public function rest_settings_themes( $request ) {
2133 try {
2134 $method = $request->get_method();
2135 if ( $method === 'GET' ) {
2136 $themes = $this->core->get_themes();
2137 return $this->create_rest_response( [ 'success' => true, 'themes' => $themes ], 200 );
2138 }
2139 else if ( $method === 'POST' ) {
2140 $params = $request->get_json_params();
2141 $themes = $params['themes'];
2142 $themes = $this->core->update_themes( $themes );
2143 return $this->create_rest_response( [ 'success' => true, 'themes' => $themes ], 200 );
2144 }
2145 }
2146 catch ( Exception $e ) {
2147 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
2148 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
2149 }
2150 }
2151
2152 public function rest_settings_chatbots( $request ) {
2153 try {
2154 $method = $request->get_method();
2155 if ( $method === 'GET' ) {
2156 $chatbots = $this->core->get_chatbots();
2157 return $this->create_rest_response( [ 'success' => true, 'chatbots' => $chatbots ], 200 );
2158 }
2159 else if ( $method === 'POST' ) {
2160 $params = $request->get_json_params();
2161 $chatbots = $params['chatbots'];
2162 $chatbots = $this->core->update_chatbots( $chatbots );
2163 return $this->create_rest_response( [ 'success' => true, 'chatbots' => $chatbots ], 200 );
2164 }
2165 return $this->create_rest_response( [ 'success' => false, 'message' => 'Method not allowed' ], 405 );
2166 }
2167 catch ( Exception $e ) {
2168 $message = apply_filters( 'mwai_ai_exception', $e->getMessage() );
2169 return $this->create_rest_response( [ 'success' => false, 'message' => $message ], 500 );
2170 }
2171 }
2172
2173 #region Logs
2174
2175 public function rest_get_logs() {
2176 $logs = Meow_MWAI_Logging::get();
2177 return $this->create_rest_response( [ 'success' => true, 'data' => $logs ], 200 );
2178 }
2179
2180 public function rest_clear_logs() {
2181 Meow_MWAI_Logging::clear();
2182 return $this->create_rest_response( [ 'success' => true ], 200 );
2183 }
2184
2185 #endregion
2186
2187 #region Forms
2188
2189 public function rest_forms_list( $request ) {
2190 try {
2191 $args = [
2192 'post_type' => 'mwai_form',
2193 'posts_per_page' => 100,
2194 'post_status' => 'any',
2195 'orderby' => 'date',
2196 'order' => 'DESC'
2197 ];
2198
2199 $posts = get_posts( $args );
2200 $forms = array_map( function ( $post ) {
2201 return [
2202 'id' => $post->ID,
2203 'title' => $post->post_title,
2204 'status' => $post->post_status
2205 ];
2206 }, $posts );
2207
2208 return $this->create_rest_response( [ 'success' => true, 'forms' => $forms ], 200 );
2209 }
2210 catch ( Exception $e ) {
2211 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2212 }
2213 }
2214
2215 public function rest_forms_get( $request ) {
2216 try {
2217 $id = intval( $request->get_param( 'id' ) );
2218 if ( !$id ) {
2219 return $this->create_rest_response( [ 'success' => false, 'message' => 'Invalid form ID' ], 400 );
2220 }
2221
2222 $post = get_post( $id );
2223 if ( !$post || $post->post_type !== 'mwai_form' ) {
2224 return $this->create_rest_response( [ 'success' => false, 'message' => 'Form not found' ], 404 );
2225 }
2226
2227 $form = [
2228 'id' => $post->ID,
2229 'title' => [
2230 'raw' => $post->post_title,
2231 'rendered' => $post->post_title
2232 ],
2233 'content' => [
2234 'raw' => $post->post_content,
2235 'rendered' => $post->post_content
2236 ],
2237 'status' => $post->post_status
2238 ];
2239
2240 return $this->create_rest_response( [ 'success' => true, 'form' => $form ], 200 );
2241 }
2242 catch ( Exception $e ) {
2243 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2244 }
2245 }
2246
2247 public function rest_forms_create( $request ) {
2248 try {
2249 $params = $request->get_json_params();
2250 $title = isset( $params['title'] ) ? $params['title'] : 'Untitled Form';
2251
2252 // wp_insert_post expects slashed data - it calls wp_unslash() internally, which would
2253 // otherwise strip backslashes from block-comment JSON escapes (e.g. \n → n) and corrupt
2254 // the stored blocks.
2255 $post_data = wp_slash( [
2256 'post_title' => $title,
2257 'post_content' => '',
2258 'post_status' => 'draft',
2259 'post_type' => 'mwai_form'
2260 ] );
2261
2262 $post_id = wp_insert_post( $post_data );
2263
2264 if ( is_wp_error( $post_id ) ) {
2265 return $this->create_rest_response( [ 'success' => false, 'message' => $post_id->get_error_message() ], 500 );
2266 }
2267
2268 $post = get_post( $post_id );
2269 $form = [
2270 'id' => $post->ID,
2271 'title' => [
2272 'raw' => $post->post_title,
2273 'rendered' => $post->post_title
2274 ],
2275 'status' => $post->post_status
2276 ];
2277
2278 return $this->create_rest_response( [ 'success' => true, 'form' => $form ], 200 );
2279 }
2280 catch ( Exception $e ) {
2281 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2282 }
2283 }
2284
2285 public function rest_forms_update( $request ) {
2286 try {
2287 $params = $request->get_json_params();
2288 $id = isset( $params['id'] ) ? intval( $params['id'] ) : 0;
2289
2290 if ( !$id ) {
2291 return $this->create_rest_response( [ 'success' => false, 'message' => 'Invalid form ID' ], 400 );
2292 }
2293
2294 $post = get_post( $id );
2295 if ( !$post || $post->post_type !== 'mwai_form' ) {
2296 return $this->create_rest_response( [ 'success' => false, 'message' => 'Form not found' ], 404 );
2297 }
2298
2299 $post_data = [ 'ID' => $id ];
2300
2301 if ( isset( $params['title'] ) ) {
2302 $post_data['post_title'] = $params['title'];
2303 }
2304
2305 if ( isset( $params['content'] ) ) {
2306 $post_data['post_content'] = $params['content'];
2307 }
2308
2309 if ( isset( $params['status'] ) ) {
2310 $post_data['post_status'] = $params['status'];
2311 }
2312
2313 // wp_update_post expects slashed data - it calls wp_unslash() internally, which would
2314 // otherwise strip backslashes from block-comment JSON escapes (e.g. \n → n) and break
2315 // Gutenberg blocks on reload.
2316 $result = wp_update_post( wp_slash( $post_data ) );
2317
2318 if ( is_wp_error( $result ) ) {
2319 return $this->create_rest_response( [ 'success' => false, 'message' => $result->get_error_message() ], 500 );
2320 }
2321
2322 $post = get_post( $id );
2323 $form = [
2324 'id' => $post->ID,
2325 'title' => [
2326 'raw' => $post->post_title,
2327 'rendered' => $post->post_title
2328 ],
2329 'content' => [
2330 'raw' => $post->post_content,
2331 'rendered' => $post->post_content
2332 ],
2333 'status' => $post->post_status
2334 ];
2335
2336 return $this->create_rest_response( [ 'success' => true, 'form' => $form ], 200 );
2337 }
2338 catch ( Exception $e ) {
2339 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2340 }
2341 }
2342
2343 public function rest_forms_delete( $request ) {
2344 try {
2345 $params = $request->get_json_params();
2346 $id = isset( $params['id'] ) ? intval( $params['id'] ) : 0;
2347
2348 if ( !$id ) {
2349 return $this->create_rest_response( [ 'success' => false, 'message' => 'Invalid form ID' ], 400 );
2350 }
2351
2352 $post = get_post( $id );
2353 if ( !$post || $post->post_type !== 'mwai_form' ) {
2354 return $this->create_rest_response( [ 'success' => false, 'message' => 'Form not found' ], 404 );
2355 }
2356
2357 $result = wp_delete_post( $id, true );
2358
2359 if ( !$result ) {
2360 return $this->create_rest_response( [ 'success' => false, 'message' => 'Failed to delete form' ], 500 );
2361 }
2362
2363 return $this->create_rest_response( [ 'success' => true ], 200 );
2364 }
2365 catch ( Exception $e ) {
2366 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2367 }
2368 }
2369
2370 #endregion
2371
2372 #region Video Generation Helpers
2373
2374 public function rest_helpers_create_video( $request ) {
2375 try {
2376 $params = $request->get_json_params();
2377 $prompt = sanitize_text_field( $params['prompt'] );
2378 $model = sanitize_text_field( $params['model'] ?? 'sora-2' );
2379 $size = sanitize_text_field( $params['size'] ?? '720x1280' );
2380 $seconds = absint( $params['seconds'] ?? 4 );
2381 $envId = sanitize_text_field( $params['envId'] ?? '' );
2382
2383 // Check if envId is provided (defaults not supported for videos yet)
2384 if ( empty( $envId ) ) {
2385 throw new Exception( 'Please select a specific environment and model in the Video Generator. Default environments are not yet supported for video generation.' );
2386 }
2387
2388 // Get API key from environment
2389 $env = $this->core->get_ai_env( $envId );
2390 $api_key = $env['apikey'] ?? '';
2391
2392 if ( empty( $api_key ) ) {
2393 throw new Exception( 'OpenAI API key not found.' );
2394 }
2395
2396 // Prepare multipart boundary
2397 $boundary = wp_generate_password( 24, false );
2398 $body = '';
2399
2400 // Add model
2401 $body .= "--{$boundary}\r\n";
2402 $body .= "Content-Disposition: form-data; name=\"model\"\r\n\r\n";
2403 $body .= "{$model}\r\n";
2404
2405 // Add prompt
2406 $body .= "--{$boundary}\r\n";
2407 $body .= "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n";
2408 $body .= "{$prompt}\r\n";
2409
2410 // Add size
2411 $body .= "--{$boundary}\r\n";
2412 $body .= "Content-Disposition: form-data; name=\"size\"\r\n\r\n";
2413 $body .= "{$size}\r\n";
2414
2415 // Add seconds
2416 $body .= "--{$boundary}\r\n";
2417 $body .= "Content-Disposition: form-data; name=\"seconds\"\r\n\r\n";
2418 $body .= "{$seconds}\r\n";
2419
2420 $body .= "--{$boundary}--\r\n";
2421
2422 // Call OpenAI API to create video
2423 $response = wp_remote_post( 'https://api.openai.com/v1/videos', [
2424 'headers' => [
2425 'Authorization' => 'Bearer ' . $api_key,
2426 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
2427 ],
2428 'body' => $body,
2429 'timeout' => 30
2430 ] );
2431
2432 if ( is_wp_error( $response ) ) {
2433 throw new Exception( $response->get_error_message() );
2434 }
2435
2436 $response_body = json_decode( wp_remote_retrieve_body( $response ), true );
2437
2438 if ( isset( $response_body['error'] ) ) {
2439 throw new Exception( $response_body['error']['message'] ?? 'Unknown error' );
2440 }
2441
2442 // Record usage (price is calculated per second)
2443 $usage = $this->core->record_videos_usage( $model, $size, $seconds );
2444
2445 // Log to Query Logs (Statistics)
2446 try {
2447 if ( class_exists( 'MeowPro_MWAI_Stats' ) && class_exists( 'MeowPro_MWAI_Statistics' ) ) {
2448 $statsObject = new MeowPro_MWAI_Stats();
2449 $statsObject->session = $params['session'] ?? null;
2450 $statsObject->scope = 'admin-tools';
2451 $statsObject->feature = 'video-generator';
2452 $statsObject->model = $model;
2453 $statsObject->envId = $envId;
2454 $statsObject->units = $seconds;
2455 $statsObject->type = 'seconds';
2456 $statsObject->price = $usage['price'] ?? 0;
2457 $statsObject->accuracy = $usage['accuracy'] ?? 'full';
2458
2459 $statistics = new MeowPro_MWAI_Statistics();
2460 $statistics->commit_stats( $statsObject );
2461 }
2462 }
2463 catch ( Exception $statsError ) {
2464 // Log the error but don't fail the video creation
2465 error_log( '[AI Engine Video] Failed to log statistics: ' . $statsError->getMessage() );
2466 }
2467
2468 // Store metadata for later retrieval when video completes
2469 if ( isset( $response_body['id'] ) ) {
2470 set_transient( 'mwai_video_metadata_' . $response_body['id'], [
2471 'model' => $model,
2472 'env_id' => $envId,
2473 'created_at' => time()
2474 ], 7 * DAY_IN_SECONDS );
2475 }
2476
2477 return $this->create_rest_response( [ 'success' => true, 'video' => $response_body, 'usage' => $usage ], 200 );
2478 }
2479 catch ( Exception $e ) {
2480 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2481 }
2482 }
2483
2484 public function rest_helpers_video_status( $request ) {
2485 try {
2486 $params = $request->get_json_params();
2487 $video_ids = $params['videoIds'] ?? [];
2488 $envId = sanitize_text_field( $params['envId'] ?? '' );
2489
2490 if ( empty( $video_ids ) ) {
2491 return $this->create_rest_response( [ 'success' => true, 'videos' => [] ], 200 );
2492 }
2493
2494 // Get API key from environment
2495 $env = $this->core->get_ai_env( $envId );
2496 $api_key = $env['apikey'] ?? '';
2497
2498 if ( empty( $api_key ) ) {
2499 throw new Exception( 'OpenAI API key not found.' );
2500 }
2501
2502 $videos = [];
2503 foreach ( $video_ids as $video_id ) {
2504 $response = wp_remote_get( 'https://api.openai.com/v1/videos/' . $video_id, [
2505 'headers' => [
2506 'Authorization' => 'Bearer ' . $api_key
2507 ],
2508 'timeout' => 15
2509 ] );
2510
2511 if ( !is_wp_error( $response ) ) {
2512 $body = json_decode( wp_remote_retrieve_body( $response ), true );
2513 if ( !isset( $body['error'] ) ) {
2514 // If video is completed and we haven't saved it yet, download and save to media library
2515 if ( $body['status'] === 'completed' && empty( get_transient( 'mwai_video_saved_' . $video_id ) ) ) {
2516 // Retrieve metadata that was stored when video was created
2517 $metadata = get_transient( 'mwai_video_metadata_' . $video_id );
2518 $ai_metadata = [];
2519 if ( $metadata ) {
2520 $ai_metadata = [
2521 'model' => $metadata['model'] ?? null,
2522 'env_id' => $metadata['env_id'] ?? null,
2523 'latency' => isset( $metadata['created_at'] ) ? ( time() - $metadata['created_at'] ) : null
2524 ];
2525 }
2526
2527 $attachment_id = $this->download_and_save_video( $video_id, $api_key, '', '', $ai_metadata );
2528 if ( $attachment_id ) {
2529 $body['attachment_id'] = $attachment_id;
2530 // Build URL from file path for custom post types
2531 $file_path = get_attached_file( $attachment_id );
2532 $upload_dir = wp_upload_dir();
2533 $body['url'] = str_replace( $upload_dir['basedir'], $upload_dir['baseurl'], $file_path );
2534 // Mark as saved so we don't download again
2535 set_transient( 'mwai_video_saved_' . $video_id, $attachment_id, DAY_IN_SECONDS );
2536 // Clean up metadata transient
2537 delete_transient( 'mwai_video_metadata_' . $video_id );
2538 }
2539 }
2540 // Check if we already have this video saved
2541 else if ( $body['status'] === 'completed' ) {
2542 $attachment_id = get_transient( 'mwai_video_saved_' . $video_id );
2543 if ( $attachment_id ) {
2544 $body['attachment_id'] = $attachment_id;
2545 // Build URL from file path for custom post types
2546 $file_path = get_attached_file( $attachment_id );
2547 $upload_dir = wp_upload_dir();
2548 $body['url'] = str_replace( $upload_dir['basedir'], $upload_dir['baseurl'], $file_path );
2549 }
2550 }
2551 $videos[] = $body;
2552 }
2553 else {
2554 // Include error information in the response
2555 error_log( 'AI Engine: Video generation failed for ID ' . $video_id . ': ' . json_encode( $body['error'] ) );
2556 $videos[] = [
2557 'id' => $video_id,
2558 'status' => 'failed',
2559 'error' => $body['error']
2560 ];
2561 }
2562 }
2563 else {
2564 // WP HTTP error
2565 error_log( 'AI Engine: Failed to check video status for ID ' . $video_id . ': ' . $response->get_error_message() );
2566 $videos[] = [
2567 'id' => $video_id,
2568 'status' => 'failed',
2569 'error' => [ 'message' => $response->get_error_message() ]
2570 ];
2571 }
2572 }
2573
2574 return $this->create_rest_response( [ 'success' => true, 'videos' => $videos ], 200 );
2575 }
2576 catch ( Exception $e ) {
2577 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2578 }
2579 }
2580
2581 public function rest_helpers_download_video( $request ) {
2582 try {
2583 $params = $request->get_json_params();
2584 $video_id = sanitize_text_field( $params['videoId'] );
2585 $envId = sanitize_text_field( $params['envId'] ?? '' );
2586
2587 // Get API key from environment
2588 $env = $this->core->get_ai_env( $envId );
2589 $api_key = $env['apikey'] ?? '';
2590
2591 if ( empty( $api_key ) ) {
2592 throw new Exception( 'OpenAI API key not found.' );
2593 }
2594
2595 $temp_file = wp_tempnam( $video_id . '.mp4' );
2596
2597 $response = wp_remote_get( 'https://api.openai.com/v1/videos/' . $video_id . '/content', [
2598 'headers' => [
2599 'Authorization' => 'Bearer ' . $api_key
2600 ],
2601 'timeout' => 120,
2602 'stream' => true,
2603 'filename' => $temp_file
2604 ] );
2605
2606 if ( is_wp_error( $response ) ) {
2607 throw new Exception( $response->get_error_message() );
2608 }
2609
2610 $file_data = file_get_contents( $temp_file );
2611 $base64 = base64_encode( $file_data );
2612
2613 unlink( $temp_file );
2614
2615 return $this->create_rest_response( [
2616 'success' => true,
2617 'data' => $base64,
2618 'mimeType' => 'video/mp4'
2619 ], 200 );
2620 }
2621 catch ( Exception $e ) {
2622 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2623 }
2624 }
2625
2626 public function rest_helpers_delete_video( $request ) {
2627 try {
2628 $params = $request->get_json_params();
2629 $video_id = sanitize_text_field( $params['videoId'] );
2630 $envId = sanitize_text_field( $params['envId'] ?? '' );
2631
2632 // Get API key from environment
2633 $env = $this->core->get_ai_env( $envId );
2634 $api_key = $env['apikey'] ?? '';
2635
2636 if ( empty( $api_key ) ) {
2637 throw new Exception( 'OpenAI API key not found.' );
2638 }
2639
2640 $response = wp_remote_request( 'https://api.openai.com/v1/videos/' . $video_id, [
2641 'method' => 'DELETE',
2642 'headers' => [
2643 'Authorization' => 'Bearer ' . $api_key
2644 ],
2645 'timeout' => 15
2646 ] );
2647
2648 if ( is_wp_error( $response ) ) {
2649 throw new Exception( $response->get_error_message() );
2650 }
2651
2652 return $this->create_rest_response( [ 'success' => true ], 200 );
2653 }
2654 catch ( Exception $e ) {
2655 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2656 }
2657 }
2658
2659 private function download_and_save_video( $video_id, $api_key, $title = '', $description = '', $ai_metadata = [] ) {
2660 try {
2661 // Download video content
2662 $response = wp_remote_get( 'https://api.openai.com/v1/videos/' . $video_id . '/content', [
2663 'headers' => [
2664 'Authorization' => 'Bearer ' . $api_key
2665 ],
2666 'timeout' => 120
2667 ] );
2668
2669 if ( is_wp_error( $response ) ) {
2670 error_log( 'Error downloading video: ' . $response->get_error_message() );
2671 return false;
2672 }
2673
2674 $video_data = wp_remote_retrieve_body( $response );
2675 if ( empty( $video_data ) ) {
2676 error_log( 'Empty video data received' );
2677 return false;
2678 }
2679
2680 // Generate filename
2681 $filename = $video_id . '.mp4';
2682 $upload_dir = wp_upload_dir();
2683 $file_path = $upload_dir['path'] . '/' . $filename;
2684
2685 // Save to file
2686 file_put_contents( $file_path, $video_data );
2687
2688 // Prepare attachment data - use mwai_video post type (draft video)
2689 $attachment = [
2690 'post_mime_type' => 'video/mp4',
2691 'post_title' => !empty( $title ) ? $title : 'AI Generated Video',
2692 'post_content' => $description,
2693 'post_status' => 'inherit',
2694 'post_type' => 'mwai_video'
2695 ];
2696
2697 // Use wp_insert_post instead of wp_insert_attachment to allow custom post types
2698 $attachment_id = wp_insert_post( $attachment );
2699
2700 // Set the attached file manually since we're not using wp_insert_attachment
2701 update_attached_file( $attachment_id, $file_path );
2702
2703 if ( is_wp_error( $attachment_id ) ) {
2704 error_log( 'Error creating attachment: ' . $attachment_id->get_error_message() );
2705 return false;
2706 }
2707
2708 // Generate attachment metadata
2709 require_once ABSPATH . 'wp-admin/includes/image.php';
2710 $attach_data = wp_generate_attachment_metadata( $attachment_id, $file_path );
2711 wp_update_attachment_metadata( $attachment_id, $attach_data );
2712
2713 // Store AI-related metadata
2714 if ( !empty( $ai_metadata['model'] ) ) {
2715 update_post_meta( $attachment_id, 'mwai_model', sanitize_text_field( $ai_metadata['model'] ) );
2716 }
2717 if ( !empty( $ai_metadata['latency'] ) ) {
2718 update_post_meta( $attachment_id, 'mwai_latency', floatval( $ai_metadata['latency'] ) );
2719 }
2720 if ( !empty( $ai_metadata['env_id'] ) ) {
2721 update_post_meta( $attachment_id, 'mwai_env_id', sanitize_text_field( $ai_metadata['env_id'] ) );
2722 }
2723
2724 // Add to user's draft media
2725 $user_id = get_current_user_id();
2726 $draft_media = get_user_meta( $user_id, 'mwai_draft_media', true );
2727 if ( !is_array( $draft_media ) ) {
2728 $draft_media = [];
2729 }
2730 $draft_media[] = [
2731 'attachment_id' => $attachment_id,
2732 'type' => 'video',
2733 'openai_id' => $video_id,
2734 'created_at' => time()
2735 ];
2736 update_user_meta( $user_id, 'mwai_draft_media', $draft_media );
2737
2738 return $attachment_id;
2739 }
2740 catch ( Exception $e ) {
2741 error_log( 'Exception in download_and_save_video: ' . $e->getMessage() );
2742 return false;
2743 }
2744 }
2745
2746 public function rest_helpers_save_video_to_library( $request ) {
2747 try {
2748 $params = $request->get_json_params();
2749 $video_id = sanitize_text_field( $params['videoId'] );
2750 $title = sanitize_text_field( $params['title'] );
2751 $description = sanitize_text_field( $params['description'] );
2752 $filename = sanitize_file_name( $params['filename'] );
2753 $envId = sanitize_text_field( $params['envId'] ?? '' );
2754
2755 // Ensure filename has .mp4 extension
2756 if ( !preg_match( '/\.mp4$/i', $filename ) ) {
2757 $filename .= '.mp4';
2758 }
2759
2760 // Get API key from environment
2761 $env = $this->core->get_ai_env( $envId );
2762 $api_key = $env['apikey'] ?? '';
2763
2764 if ( empty( $api_key ) ) {
2765 throw new Exception( 'OpenAI API key not found.' );
2766 }
2767
2768 // Download video content
2769 $response = wp_remote_get( 'https://api.openai.com/v1/videos/' . $video_id . '/content', [
2770 'headers' => [
2771 'Authorization' => 'Bearer ' . $api_key
2772 ],
2773 'timeout' => 120
2774 ] );
2775
2776 if ( is_wp_error( $response ) ) {
2777 throw new Exception( $response->get_error_message() );
2778 }
2779
2780 $video_data = wp_remote_retrieve_body( $response );
2781
2782 // Upload to WordPress media library
2783 $upload_dir = wp_upload_dir();
2784 $file_path = $upload_dir['path'] . '/' . $filename;
2785
2786 file_put_contents( $file_path, $video_data );
2787
2788 $attachment = [
2789 'post_mime_type' => 'video/mp4',
2790 'post_title' => $title,
2791 'post_content' => $description,
2792 'post_status' => 'inherit'
2793 ];
2794
2795 $attach_id = wp_insert_attachment( $attachment, $file_path );
2796
2797 require_once( ABSPATH . 'wp-admin/includes/image.php' );
2798 $attach_data = wp_generate_attachment_metadata( $attach_id, $file_path );
2799 wp_update_attachment_metadata( $attach_id, $attach_data );
2800
2801 return $this->create_rest_response( [
2802 'success' => true,
2803 'attachmentId' => $attach_id
2804 ], 200 );
2805 }
2806 catch ( Exception $e ) {
2807 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2808 }
2809 }
2810
2811 public function rest_helpers_delete_video_from_library( $request ) {
2812 try {
2813 $params = $request->get_json_params();
2814 $attachment_id = absint( $params['attachmentId'] );
2815
2816 if ( empty( $attachment_id ) ) {
2817 throw new Exception( 'Attachment ID is required.' );
2818 }
2819
2820 $deleted = wp_delete_attachment( $attachment_id, true );
2821
2822 if ( !$deleted ) {
2823 throw new Exception( 'Failed to delete attachment.' );
2824 }
2825
2826 return $this->create_rest_response( [ 'success' => true ], 200 );
2827 }
2828 catch ( Exception $e ) {
2829 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2830 }
2831 }
2832
2833 public function rest_helpers_list_draft_media( $request ) {
2834 try {
2835 $type = $request->get_param( 'type' ); // 'image', 'video', or null for all
2836 $user_id = get_current_user_id();
2837 $draft_media = get_user_meta( $user_id, 'mwai_draft_media', true );
2838
2839 if ( !is_array( $draft_media ) ) {
2840 return $this->create_rest_response( [ 'success' => true, 'media' => [] ], 200 );
2841 }
2842
2843 $media_items = [];
2844 foreach ( $draft_media as $item ) {
2845 // Filter by type if specified
2846 if ( $type && $item['type'] !== $type ) {
2847 continue;
2848 }
2849
2850 $attachment_id = $item['attachment_id'];
2851 $attachment = get_post( $attachment_id );
2852
2853 if ( $attachment ) {
2854 // For custom post types (mwai_image, mwai_video), build URL from file path
2855 $file_path = get_attached_file( $attachment_id );
2856 $upload_dir = wp_upload_dir();
2857 $url = str_replace( $upload_dir['basedir'], $upload_dir['baseurl'], $file_path );
2858
2859 $model = get_post_meta( $attachment_id, 'mwai_model', true );
2860 $generation_time = get_post_meta( $attachment_id, 'mwai_latency', true );
2861 $env_id = get_post_meta( $attachment_id, 'mwai_env_id', true );
2862
2863 // Debug logging
2864 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
2865 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 ) );
2866 }
2867
2868 $media_items[] = [
2869 'attachment_id' => $attachment_id,
2870 'type' => $item['type'],
2871 'openai_id' => $item['openai_id'] ?? null,
2872 'url' => $url,
2873 'title' => $attachment->post_title,
2874 'description' => $attachment->post_content,
2875 'filename' => basename( $file_path ),
2876 'created_at' => $item['created_at'],
2877 'model' => $model,
2878 'generation_time' => $generation_time,
2879 'env_id' => $env_id
2880 ];
2881 }
2882 }
2883
2884 return $this->create_rest_response( [ 'success' => true, 'media' => $media_items ], 200 );
2885 }
2886 catch ( Exception $e ) {
2887 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2888 }
2889 }
2890
2891 public function rest_helpers_approve_media( $request ) {
2892 try {
2893 $params = $request->get_json_params();
2894 $attachment_id = absint( $params['attachmentId'] );
2895 $openai_id = sanitize_text_field( $params['openaiId'] ?? '' );
2896 $envId = sanitize_text_field( $params['envId'] ?? '' );
2897
2898 if ( empty( $attachment_id ) ) {
2899 throw new Exception( 'Attachment ID is required.' );
2900 }
2901
2902 // Convert from mwai_image/mwai_video to attachment post type
2903 wp_update_post( [
2904 'ID' => $attachment_id,
2905 'post_type' => 'attachment',
2906 'post_status' => 'inherit'
2907 ] );
2908
2909 // Remove from draft media list
2910 $user_id = get_current_user_id();
2911 $draft_media = get_user_meta( $user_id, 'mwai_draft_media', true );
2912 if ( is_array( $draft_media ) ) {
2913 $draft_media = array_filter( $draft_media, function ( $item ) use ( $attachment_id ) {
2914 return $item['attachment_id'] !== $attachment_id;
2915 } );
2916 update_user_meta( $user_id, 'mwai_draft_media', array_values( $draft_media ) );
2917 }
2918
2919 // Delete video from OpenAI if applicable
2920 if ( !empty( $openai_id ) ) {
2921 $env = $this->core->get_ai_env( $envId );
2922 $api_key = $env['apikey'] ?? '';
2923
2924 if ( !empty( $api_key ) ) {
2925 wp_remote_request( 'https://api.openai.com/v1/videos/' . $openai_id, [
2926 'method' => 'DELETE',
2927 'headers' => [ 'Authorization' => 'Bearer ' . $api_key ],
2928 'timeout' => 15
2929 ] );
2930 }
2931 }
2932
2933 return $this->create_rest_response( [ 'success' => true ], 200 );
2934 }
2935 catch ( Exception $e ) {
2936 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2937 }
2938 }
2939
2940 public function rest_helpers_reject_media( $request ) {
2941 try {
2942 $params = $request->get_json_params();
2943 $attachment_id = absint( $params['attachmentId'] );
2944 $openai_id = sanitize_text_field( $params['openaiId'] ?? '' );
2945 $envId = sanitize_text_field( $params['envId'] ?? '' );
2946
2947 if ( empty( $attachment_id ) ) {
2948 throw new Exception( 'Attachment ID is required.' );
2949 }
2950
2951 // Convert from mwai_image/mwai_video to attachment post type first
2952 // This ensures wp_delete_attachment properly deletes the physical file
2953 wp_update_post( [
2954 'ID' => $attachment_id,
2955 'post_type' => 'attachment'
2956 ] );
2957
2958 // Delete attachment from WordPress (now that it's a proper attachment, files will be deleted)
2959 wp_delete_attachment( $attachment_id, true );
2960
2961 // Remove from draft media list
2962 $user_id = get_current_user_id();
2963 $draft_media = get_user_meta( $user_id, 'mwai_draft_media', true );
2964 if ( is_array( $draft_media ) ) {
2965 $draft_media = array_filter( $draft_media, function ( $item ) use ( $attachment_id ) {
2966 return $item['attachment_id'] !== $attachment_id;
2967 } );
2968 update_user_meta( $user_id, 'mwai_draft_media', array_values( $draft_media ) );
2969 }
2970
2971 // Delete video from OpenAI if applicable
2972 if ( !empty( $openai_id ) ) {
2973 $env = $this->core->get_ai_env( $envId );
2974 $api_key = $env['apikey'] ?? '';
2975
2976 if ( !empty( $api_key ) ) {
2977 wp_remote_request( 'https://api.openai.com/v1/videos/' . $openai_id, [
2978 'method' => 'DELETE',
2979 'headers' => [ 'Authorization' => 'Bearer ' . $api_key ],
2980 'timeout' => 15
2981 ] );
2982 }
2983 }
2984
2985 return $this->create_rest_response( [ 'success' => true ], 200 );
2986 }
2987 catch ( Exception $e ) {
2988 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
2989 }
2990 }
2991
2992 #endregion
2993 }
2994