PluginProbe
Extendify / 3.2.3
Extendify v3.2.3
3.2.3 3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 All 129 releases
extendify / app / Mcp / Handlers.php

Handlers.php in Extendify 3.2.3, at app/Mcp/Handlers.php

1,684 lines 57.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * What each hand-written tool does when a connection calls it.
5 */
6
7 namespace Extendify\Mcp;
8
9 defined('ABSPATH') || die('No direct access.');
10
11 use Extendify\Constants;
12
13 /**
14 * Every write goes back through the REST server, so the route's own
15 * permission_callback decides it as the connection's owner. A raw REST body
16 * would carry fields nobody asked for, so the answer is picked, not passed on.
17 */
18 class Handlers
19 {
20 // phpcs:disable PSR12.Properties.ConstantVisibility.NotFound
21 const DELETE_BATCH = 100;
22
23 const FEATURE_REQUESTS = 'extendify_mcp_feature_requests';
24
25 const FEATURE_REQUESTS_A_DAY = 10;
26
27 const FEATURE_REQUEST_GAP = 60;
28
29 /**
30 * An unlisted field reaching the route would change more than the tool says it does.
31 */
32 const POST_FIELDS = ['title', 'status', 'excerpt', 'slug', 'date', 'meta'];
33
34 /**
35 * Core renders these into the post itself, and this tool never changes a post's body.
36 */
37 const BODY_META = ['footnotes'];
38
39 /**
40 * Each setting a tool may change, against the name the settings route knows it by.
41 */
42 const SITE_SETTINGS = [
43 'title' => 'title',
44 'tagline' => 'description',
45 'language' => 'language',
46 'timezone' => 'timezone',
47 'date_format' => 'date_format',
48 'time_format' => 'time_format',
49 'start_of_week' => 'start_of_week',
50 ];
51
52 /**
53 * The options those settings land in, and the only ones this tool's call may write.
54 */
55 const SITE_OPTIONS = [
56 'blogname',
57 'blogdescription',
58 'WPLANG',
59 'timezone_string',
60 'gmt_offset',
61 'date_format',
62 'time_format',
63 'start_of_week',
64 ];
65
66 /**
67 * The word core's comment write takes for each state a tool names.
68 */
69 const COMMENT_STATES = [
70 'approved' => 'approved',
71 'pending' => 'hold',
72 'spam' => 'spam',
73 'trash' => 'trash',
74 ];
75 // phpcs:enable PSR12.Properties.ConstantVisibility.NotFound
76
77 /**
78 * @param array $arguments - The validated tool arguments.
79 * @return array|\WP_Error
80 */
81 public static function listPosts(array $arguments)
82 {
83 $route = self::typeRoute($arguments['type']);
84 $response = self::request('GET', $route, [
85 'search' => $arguments['search'] ?? null,
86 'status' => $arguments['status'],
87 'author' => $arguments['author'] ?? null,
88 'after' => $arguments['after'] ?? null,
89 'before' => $arguments['before'] ?? null,
90 'per_page' => $arguments['per_page'],
91 'page' => $arguments['page'],
92 '_fields' => 'id,title,status,type,slug,date,modified,author,link',
93 ]);
94
95 return \is_wp_error($response) ? $response : self::listed($response, [self::class, 'shapePost']);
96 }
97
98 /**
99 * @param array $arguments - The validated tool arguments.
100 * @return array|\WP_Error
101 */
102 public static function getPost(array $arguments)
103 {
104 $route = self::typeRoute($arguments['type']);
105 // Only edit context carries the raw block markup the description promises.
106 $response = self::request('GET', $route . '/' . (int) $arguments['id'], ['context' => 'edit']);
107 if (\is_wp_error($response)) {
108 return $response;
109 }
110
111 $item = (array) $response->get_data();
112
113 return array_merge(self::shapePost($item), [
114 'parent' => (int) ($item['parent'] ?? 0),
115 'featured_media' => (int) ($item['featured_media'] ?? 0),
116 'excerpt' => self::raw($item['excerpt'] ?? ''),
117 'content' => self::raw($item['content'] ?? ''),
118 ]);
119 }
120
121 /**
122 * @param array $arguments - The validated tool arguments.
123 * @return array|\WP_Error
124 */
125 public static function updatePostsMetadata(array $arguments)
126 {
127 $updated = [];
128 $failed = [];
129 foreach ((array) $arguments['items'] as $item) {
130 $item = (array) $item;
131 $id = (int) ($item['id'] ?? 0);
132 $body = array_intersect_key($item, array_flip(self::POST_FIELDS));
133 if (!$body) {
134 $failed[] = ['id' => $id, 'error' => 'No field to change was given for this post.'];
135 continue;
136 }
137
138 $type = (string) ($item['type'] ?? 'post');
139 $metaKeys = array_keys((array) ($body['meta'] ?? []));
140 $inBody = array_intersect($metaKeys, self::BODY_META);
141 if ($inBody) {
142 $failed[] = ['id' => $id, 'error' => sprintf(
143 '%s is part of the post content, which this tool does not change.',
144 implode(', ', $inBody)
145 )];
146 continue;
147 }
148
149 $unregistered = self::unregistered($metaKeys, $type);
150 if ($unregistered) {
151 $failed[] = ['id' => $id, 'error' => sprintf(
152 'This site has not registered %s for its API, so it cannot be set here.',
153 implode(', ', $unregistered)
154 )];
155 continue;
156 }
157
158 $route = self::typeRoute($type) . '/' . $id;
159 // The REST update refuses trash as a status; only its DELETE moves a post there.
160 $trashing = ($body['status'] ?? '') === 'trash';
161 if ($trashing) {
162 unset($body['status']);
163 }
164
165 $response = $body ? self::request('POST', $route, $body) : null;
166 if ($trashing && !\is_wp_error($response)) {
167 $response = self::request('DELETE', $route);
168 }
169
170 if (\is_wp_error($response)) {
171 $failed[] = ['id' => $id, 'error' => $response->get_error_message()];
172 continue;
173 }
174
175 $updated[] = self::shapePost((array) $response->get_data());
176 }
177
178 return ['updated' => $updated, 'failed' => $failed];
179 }
180
181 /**
182 * @param array $arguments - The validated tool arguments.
183 * @return array|\WP_Error
184 */
185 public static function searchSite(array $arguments)
186 {
187 $response = self::request('GET', 'wp/v2/search', [
188 'search' => $arguments['query'],
189 'per_page' => $arguments['per_page'],
190 'page' => $arguments['page'],
191 ]);
192 if (\is_wp_error($response)) {
193 return $response;
194 }
195
196 return self::listed($response, function (array $item) {
197 return [
198 'id' => (int) ($item['id'] ?? 0),
199 'title' => self::title($item['title'] ?? ''),
200 'url' => (string) ($item['url'] ?? ''),
201 'type' => (string) ($item['type'] ?? ''),
202 'subtype' => (string) ($item['subtype'] ?? ''),
203 ];
204 });
205 }
206
207 /**
208 * @param array $arguments - The validated tool arguments.
209 * @return array|\WP_Error
210 */
211 public static function listComments(array $arguments)
212 {
213 $response = self::request('GET', 'wp/v2/comments', [
214 'context' => 'edit',
215 'status' => self::commentQuery($arguments['status']),
216 'post' => $arguments['post'] ?? null,
217 'search' => $arguments['search'] ?? null,
218 'after' => $arguments['after'] ?? null,
219 'before' => $arguments['before'] ?? null,
220 'per_page' => $arguments['per_page'],
221 'page' => $arguments['page'],
222 ]);
223
224 return \is_wp_error($response) ? $response : self::listed($response, [self::class, 'shapeComment']);
225 }
226
227 /**
228 * @param array $arguments - The validated tool arguments.
229 * @return array|\WP_Error
230 */
231 public static function setCommentStatus(array $arguments)
232 {
233 $status = self::COMMENT_STATES[(string) $arguments['status']];
234 $changed = [];
235 $failed = [];
236 foreach (array_unique(array_map('intval', (array) $arguments['ids'])) as $id) {
237 $response = self::request('POST', 'wp/v2/comments/' . $id, ['status' => $status]);
238 if (\is_wp_error($response)) {
239 $failed[] = ['id' => $id, 'error' => $response->get_error_message()];
240 continue;
241 }
242
243 $changed[] = ['id' => $id, 'status' => (string) (((array) $response->get_data())['status'] ?? '')];
244 }
245
246 return ['changed' => $changed, 'failed' => $failed];
247 }
248
249 /**
250 * @param array $arguments - The validated tool arguments.
251 * @return array|\WP_Error
252 */
253 public static function replyToComment(array $arguments)
254 {
255 $parent = \get_comment((int) $arguments['comment']);
256 if (!$parent) {
257 return new \WP_Error('extendify_mcp_no_comment', 'No comment has that id. Call list_comments first.');
258 }
259
260 $response = self::request('POST', 'wp/v2/comments', [
261 'post' => (int) $parent->comment_post_ID,
262 'parent' => (int) $parent->comment_ID,
263 'author' => \get_current_user_id(),
264 'content' => (string) $arguments['content'],
265 'status' => 'approved',
266 ]);
267
268 return \is_wp_error($response) ? $response : self::shapeComment((array) $response->get_data());
269 }
270
271 /**
272 * @param array $arguments - The validated tool arguments.
273 * @return array|\WP_Error
274 */
275 public static function listTerms(array $arguments)
276 {
277 $taxonomy = (string) $arguments['taxonomy'];
278 $response = self::request('GET', self::taxonomyRoute($taxonomy), [
279 'search' => $arguments['search'] ?? null,
280 'post' => $arguments['post'] ?? null,
281 'per_page' => $arguments['per_page'],
282 'page' => $arguments['page'],
283 'orderby' => 'count',
284 'order' => 'desc',
285 ]);
286
287 if (\is_wp_error($response)) {
288 return $response;
289 }
290
291 return self::listed($response, function (array $item) use ($taxonomy) {
292 return self::shapeTerm($item, $taxonomy);
293 });
294 }
295
296 /**
297 * @param array $arguments - The validated tool arguments.
298 * @return array|\WP_Error
299 */
300 public static function createTerm(array $arguments)
301 {
302 $taxonomy = (string) $arguments['taxonomy'];
303 $response = self::request('POST', self::taxonomyRoute($taxonomy), [
304 'name' => (string) $arguments['name'],
305 'parent' => $arguments['parent'] ?? null,
306 'description' => $arguments['description'] ?? null,
307 ]);
308
309 return \is_wp_error($response)
310 ? $response
311 : self::shapeTerm((array) $response->get_data(), $taxonomy);
312 }
313
314 /**
315 * @param array $arguments - The validated tool arguments.
316 * @return array|\WP_Error
317 */
318 public static function setPostTerms(array $arguments)
319 {
320 $taxonomy = (string) $arguments['taxonomy'];
321 $object = \get_taxonomy($taxonomy);
322 $field = empty($object->rest_base) ? $taxonomy : $object->rest_base;
323 if (!in_array((string) $arguments['type'], (array) $object->object_type, true)) {
324 return new \WP_Error('extendify_mcp_refused', sprintf(
325 'The %s taxonomy does not apply to %s content.',
326 $taxonomy,
327 $arguments['type']
328 ));
329 }
330
331 $resolved = self::terms((array) $arguments['terms'], $taxonomy);
332 if ($resolved['unknown']) {
333 return new \WP_Error('extendify_mcp_no_term', sprintf(
334 'No term in %s is named %s. Call list_terms, or create_term first.',
335 $taxonomy,
336 implode(', ', $resolved['unknown'])
337 ));
338 }
339
340 $ids = $resolved['ids'];
341 if ($arguments['mode'] === 'add') {
342 $ids = array_values(array_unique(array_merge(
343 \wp_get_object_terms((int) $arguments['id'], $taxonomy, ['fields' => 'ids']),
344 $ids
345 )));
346 }
347
348 $route = self::typeRoute((string) $arguments['type']) . '/' . (int) $arguments['id'];
349 $response = self::request('POST', $route, [$field => array_map('intval', $ids)]);
350 if (\is_wp_error($response)) {
351 return $response;
352 }
353
354 return [
355 'id' => (int) $arguments['id'],
356 'taxonomy' => $taxonomy,
357 'terms' => array_map(function ($id) use ($taxonomy) {
358 $term = \get_term((int) $id, $taxonomy);
359
360 return ['id' => (int) $id, 'name' => $term ? self::title($term->name) : ''];
361 }, (array) (((array) $response->get_data())[$field] ?? [])),
362 ];
363 }
364
365 /**
366 * @param array $arguments - The validated tool arguments.
367 * @return array|\WP_Error
368 */
369 public static function listMedia(array $arguments)
370 {
371 $bare = empty($arguments['missing_alt_text']) ? null : self::bareAltText();
372 if ($bare) {
373 \add_filter('rest_attachment_query', $bare);
374 }
375
376 try {
377 $response = self::request('GET', 'wp/v2/media', array_merge([
378 'search' => $arguments['search'] ?? null,
379 'per_page' => $arguments['per_page'],
380 'page' => $arguments['page'],
381 ], self::mime($arguments['mime_type'] ?? null)));
382 } finally {
383 if ($bare) {
384 \remove_filter('rest_attachment_query', $bare);
385 }
386 }
387
388 if (\is_wp_error($response)) {
389 return $response;
390 }
391
392 return self::listed($response, function (array $item) {
393 $details = (array) ($item['media_details'] ?? []);
394 $url = (string) ($item['source_url'] ?? '');
395
396 return [
397 'id' => (int) ($item['id'] ?? 0),
398 'title' => self::title($item['title'] ?? ''),
399 'filename' => \wp_basename($url),
400 'mime_type' => (string) ($item['mime_type'] ?? ''),
401 'alt_text' => (string) ($item['alt_text'] ?? ''),
402 'width' => (int) ($details['width'] ?? 0),
403 'height' => (int) ($details['height'] ?? 0),
404 'url' => $url,
405 'date' => (string) ($item['date'] ?? ''),
406 ];
407 });
408 }
409
410 /**
411 * @param array $arguments - The validated tool arguments.
412 * @return array|\WP_Error
413 */
414 public static function addMediaFromUrl(array $arguments)
415 {
416 // Blocks a connection whose own user may not upload from writing files to the server.
417 if (!\current_user_can('upload_files')) {
418 return new \WP_Error('extendify_mcp_refused', 'This user may not add to the media library.');
419 }
420
421 foreach (['file', 'media', 'image'] as $include) {
422 require_once ABSPATH . 'wp-admin/includes/' . $include . '.php';
423 }
424
425 $url = (string) $arguments['url'];
426 // download_url() fetches through wp_safe_remote_get, which refuses loopback and private addresses.
427 $temporary = \download_url($url);
428 if (\is_wp_error($temporary)) {
429 return new \WP_Error('extendify_mcp_refused', sprintf(
430 'That address could not be read: %s',
431 $temporary->get_error_message()
432 ));
433 }
434
435 $upload = [
436 'name' => (string) ($arguments['filename'] ?? '') ?: basename((string) parse_url($url, PHP_URL_PATH)),
437 'tmp_name' => $temporary,
438 ];
439 $data = isset($arguments['title']) ? ['post_title' => (string) $arguments['title']] : [];
440 $id = \media_handle_sideload($upload, (int) ($arguments['post'] ?? 0), null, $data);
441 if (\is_wp_error($id)) {
442 if (file_exists($temporary)) {
443 unlink($temporary);
444 }
445
446 return new \WP_Error('extendify_mcp_refused', $id->get_error_message());
447 }
448
449 if (isset($arguments['alt_text'])) {
450 \update_post_meta($id, '_wp_attachment_image_alt', \sanitize_text_field($arguments['alt_text']));
451 }
452
453 return [
454 'id' => (int) $id,
455 'title' => self::title(\get_the_title($id)),
456 'mime_type' => (string) \get_post_mime_type($id),
457 'url' => (string) \wp_get_attachment_url($id),
458 'attached_to' => (int) ($arguments['post'] ?? 0),
459 'alt_text' => (string) \get_post_meta($id, '_wp_attachment_image_alt', true),
460 ];
461 }
462
463 /**
464 * @param array $arguments - The validated tool arguments.
465 * @return array|\WP_Error
466 */
467 public static function updateAltTexts(array $arguments)
468 {
469 $updated = [];
470 $skipped = [];
471 foreach ($arguments['items'] as $item) {
472 $id = (int) $item['id'];
473 if (empty($arguments['overwrite']) && \get_post_meta($id, '_wp_attachment_image_alt', true) !== '') {
474 $skipped[] = ['id' => $id, 'reason' => 'Already has alt text. Pass overwrite to replace it.'];
475 continue;
476 }
477
478 $response = self::request('POST', 'wp/v2/media/' . $id, ['alt_text' => $item['alt_text']]);
479 if (\is_wp_error($response)) {
480 $skipped[] = ['id' => $id, 'reason' => $response->get_error_message()];
481 continue;
482 }
483
484 $updated[] = ['id' => $id, 'alt_text' => (string) (((array) $response->get_data())['alt_text'] ?? '')];
485 }
486
487 return ['updated' => $updated, 'skipped' => $skipped];
488 }
489
490 /**
491 * @param array $arguments - The validated tool arguments.
492 * @return array|\WP_Error
493 */
494 public static function listPlugins(array $arguments)
495 {
496 $response = self::request('GET', 'wp/v2/plugins', self::status($arguments['status']));
497 if (\is_wp_error($response)) {
498 return $response;
499 }
500
501 $waiting = self::waiting('update_plugins');
502 $auto = (array) \get_site_option('auto_update_plugins', []);
503
504 $items = [];
505 foreach ((array) $response->get_data() as $item) {
506 // The controller answers with the plugin file, minus the extension every list stores.
507 $file = $item['plugin'] . '.php';
508 $update = $waiting[$file] ?? null;
509 if (!empty($arguments['has_update']) && $update === null) {
510 continue;
511 }
512
513 $items[] = [
514 'slug' => (string) $item['plugin'],
515 'name' => self::title($item['name'] ?? ''),
516 'version' => (string) ($item['version'] ?? ''),
517 'status' => (string) ($item['status'] ?? ''),
518 'update_available' => $update !== null,
519 'new_version' => self::newVersion($update),
520 'auto_update' => in_array($file, $auto, true),
521 ];
522 }
523
524 return ['items' => $items, 'total' => count($items)];
525 }
526
527 /**
528 * @param array $arguments - The validated tool arguments.
529 * @return array|\WP_Error
530 */
531 public static function deleteInactivePlugins(array $arguments)
532 {
533 $items = self::inactivePlugins(array_map('strval', (array) ($arguments['exclude'] ?? [])));
534 if (!empty($arguments['preview'])) {
535 return self::preview('delete_inactive_plugins', $items, 'slug');
536 }
537
538 $refused = self::unconfirmed('delete_inactive_plugins', array_column($items, 'slug'), $arguments);
539 if ($refused) {
540 return $refused;
541 }
542
543 $deleted = [];
544 $failed = [];
545 foreach ($items as $item) {
546 $response = self::request('DELETE', 'wp/v2/plugins/' . $item['slug']);
547 if (\is_wp_error($response)) {
548 $failed[] = ['slug' => $item['slug'], 'error' => $response->get_error_message()];
549 continue;
550 }
551
552 $deleted[] = $item['slug'];
553 }
554
555 return ['deleted' => $deleted, 'failed' => $failed];
556 }
557
558 /**
559 * @param array $arguments - The validated tool arguments.
560 * @return array|\WP_Error
561 */
562 public static function installPlugin(array $arguments)
563 {
564 $slug = (string) $arguments['slug'];
565 $status = !empty($arguments['activate']) ? 'active' : 'inactive';
566 $response = self::unguarded('POST', 'wp/v2/plugins', ['slug' => $slug, 'status' => $status]);
567 if (\is_wp_error($response)) {
568 return $response;
569 }
570
571 $item = (array) $response->get_data();
572
573 return [
574 'slug' => (string) ($item['plugin'] ?? $slug),
575 'name' => self::title($item['name'] ?? ''),
576 'version' => (string) ($item['version'] ?? ''),
577 'status' => (string) ($item['status'] ?? ''),
578 ];
579 }
580
581 /**
582 * @param array $arguments - The validated tool arguments.
583 * @return array|\WP_Error
584 */
585 public static function setPluginStatus(array $arguments)
586 {
587 $status = !empty($arguments['active']) ? 'active' : 'inactive';
588 $changed = [];
589 $failed = [];
590 foreach (array_unique(array_map('strval', (array) $arguments['plugins'])) as $slug) {
591 $response = self::unguarded('POST', 'wp/v2/plugins/' . $slug, ['status' => $status]);
592 if (\is_wp_error($response)) {
593 $failed[] = ['slug' => $slug, 'error' => $response->get_error_message()];
594 continue;
595 }
596
597 $changed[] = ['slug' => $slug, 'status' => (string) (((array) $response->get_data())['status'] ?? '')];
598 }
599
600 return ['changed' => $changed, 'failed' => $failed];
601 }
602
603 /**
604 * @param array $arguments - The validated tool arguments.
605 * @return array|\WP_Error
606 */
607 public static function listThemes(array $arguments)
608 {
609 $response = self::request('GET', 'wp/v2/themes', self::status($arguments['status']));
610 if (\is_wp_error($response)) {
611 return $response;
612 }
613
614 $waiting = self::waiting('update_themes');
615 $auto = (array) \get_site_option('auto_update_themes', []);
616 $parent = \get_template();
617 $items = array_map(function (array $item) use ($waiting, $auto, $parent) {
618 $stylesheet = (string) ($item['stylesheet'] ?? '');
619 $update = $waiting[$stylesheet] ?? null;
620
621 return [
622 'stylesheet' => $stylesheet,
623 'name' => self::title($item['name'] ?? ''),
624 'version' => (string) ($item['version'] ?? ''),
625 'active' => ($item['status'] ?? '') === 'active',
626 'parent_of_active' => $stylesheet === $parent && $parent !== \get_stylesheet(),
627 'update_available' => $update !== null,
628 'new_version' => self::newVersion($update),
629 'auto_update' => in_array($stylesheet, $auto, true),
630 ];
631 }, (array) $response->get_data());
632
633 return ['items' => $items, 'total' => count($items)];
634 }
635
636 /**
637 * @param array $arguments - The validated tool arguments.
638 * @return array|\WP_Error
639 */
640 public static function listUsers(array $arguments)
641 {
642 $registered = self::registeredBetween($arguments);
643 if ($registered) {
644 \add_filter('rest_user_query', $registered);
645 }
646
647 try {
648 $response = self::request('GET', 'wp/v2/users', [
649 'context' => 'edit',
650 'roles' => $arguments['role'] ?? null,
651 'search' => $arguments['search'] ?? null,
652 'exclude' => isset($arguments['max_posts']) ? self::wroteMoreThan($arguments['max_posts']) : null,
653 'per_page' => $arguments['per_page'],
654 'page' => $arguments['page'],
655 ]);
656 } finally {
657 if ($registered) {
658 \remove_filter('rest_user_query', $registered);
659 }
660 }
661
662 if (\is_wp_error($response)) {
663 return $response;
664 }
665
666 $counts = \count_many_users_posts(array_column((array) $response->get_data(), 'id'), Tools::types());
667
668 return self::listed($response, function (array $item) use ($counts) {
669 return [
670 'id' => (int) $item['id'],
671 'name' => (string) ($item['name'] ?? ''),
672 'username' => (string) ($item['username'] ?? ''),
673 'roles' => array_values((array) ($item['roles'] ?? [])),
674 'registered' => (string) ($item['registered_date'] ?? ''),
675 'post_count' => (int) ($counts[$item['id']] ?? 0),
676 ];
677 });
678 }
679
680 /**
681 * @param array $arguments - The validated tool arguments.
682 * @return array|\WP_Error
683 */
684 public static function createUser(array $arguments)
685 {
686 if (\is_multisite()) {
687 return new \WP_Error(
688 'extendify_mcp_refused',
689 'Creating an account is not available on a multisite network.'
690 );
691 }
692
693 $response = Guard::registering(function () use ($arguments) {
694 return self::request('POST', 'wp/v2/users', [
695 'username' => (string) $arguments['username'],
696 'email' => (string) $arguments['email'],
697 // Nobody is told this: the account is reached by a reset link, never a shared password.
698 'password' => \wp_generate_password(24, true, true),
699 'roles' => [(string) $arguments['role']],
700 'name' => $arguments['name'] ?? null,
701 ]);
702 });
703
704 if (\is_wp_error($response)) {
705 return $response;
706 }
707
708 $item = (array) $response->get_data();
709 $id = (int) ($item['id'] ?? 0);
710 if (!empty($arguments['send_email'])) {
711 \wp_new_user_notification($id, null, 'user');
712 }
713
714 return [
715 'id' => $id,
716 'username' => (string) ($item['username'] ?? ''),
717 'name' => self::title($item['name'] ?? ''),
718 'roles' => array_values((array) ($item['roles'] ?? [])),
719 'emailed' => !empty($arguments['send_email']),
720 ];
721 }
722
723 /**
724 * @param array $arguments - The validated tool arguments.
725 * @return array|\WP_Error
726 */
727 public static function deleteUsers(array $arguments)
728 {
729 if (\is_multisite()) {
730 return new \WP_Error('extendify_mcp_refused', 'Deleting users is not available on a multisite network.');
731 }
732
733 $reassign = (int) $arguments['reassign_to'];
734 if (!\get_userdata($reassign)) {
735 return new \WP_Error('extendify_mcp_refused', 'reassign_to must be the id of a user this site has.');
736 }
737
738 $admins = self::adminRoles();
739 if (in_array($arguments['role'] ?? '', $admins, true)) {
740 return new \WP_Error('extendify_mcp_refused', 'Administrators are never deleted by this tool.');
741 }
742
743 $query = new \WP_User_Query(self::deletable($arguments, $reassign, $admins));
744 $users = $query->get_results();
745 $counts = \count_many_users_posts(\wp_list_pluck($users, 'ID'), Tools::types());
746 $items = array_map(function (\WP_User $user) use ($counts) {
747 return self::shapeUser($user, $counts);
748 }, $users);
749 if (!empty($arguments['preview'])) {
750 return self::preview('delete_users', $items, 'id', (int) $query->get_total());
751 }
752
753 $refused = self::unconfirmed('delete_users', array_column($items, 'id'), $arguments);
754 if ($refused) {
755 return $refused;
756 }
757
758 $deleted = [];
759 $failed = [];
760 foreach ($items as $item) {
761 $response = self::request('DELETE', 'wp/v2/users/' . $item['id'], [
762 'force' => true,
763 'reassign' => $reassign,
764 ]);
765 if (\is_wp_error($response)) {
766 $failed[] = ['id' => $item['id'], 'error' => $response->get_error_message()];
767 continue;
768 }
769
770 $deleted[] = $item['id'];
771 }
772
773 return [
774 'deleted' => $deleted,
775 'failed' => $failed,
776 'reassigned_to' => $reassign,
777 'remaining' => max(0, (int) $query->get_total() - count($items)),
778 ];
779 }
780
781 /**
782 * @param array $arguments - The validated tool arguments.
783 * @return array|\WP_Error
784 */
785 public static function updatePlugins(array $arguments)
786 {
787 $items = Maintenance::pluginUpdates(array_map('strval', (array) ($arguments['plugins'] ?? [])));
788 if (!empty($arguments['preview'])) {
789 $answer = self::preview('update_plugins', $items, 'slug');
790 if (!$items) {
791 $answer['note'] = 'Every plugin is up to date.';
792 }
793
794 return $answer;
795 }
796
797 if (!$items) {
798 return ['started' => false, 'note' => 'Every plugin is up to date.'];
799 }
800
801 $refused = self::unconfirmed('update_plugins', array_column($items, 'slug'), $arguments);
802 if ($refused) {
803 return $refused;
804 }
805
806 if (!\current_user_can('update_plugins')) {
807 return new \WP_Error('extendify_mcp_refused', 'This user may not update plugins.');
808 }
809
810 $files = array_map(function ($slug) {
811 return $slug . '.php';
812 }, array_column($items, 'slug'));
813
814 return Jobs::start('update_plugins', ['plugins' => $files], count($files));
815 }
816
817 /**
818 * @param array $arguments - The validated tool arguments.
819 * @return array|\WP_Error
820 */
821 public static function updateThemes(array $arguments)
822 {
823 $items = Maintenance::themeUpdates(array_map('strval', (array) ($arguments['themes'] ?? [])));
824 if (!empty($arguments['preview'])) {
825 $answer = self::preview('update_themes', $items, 'stylesheet');
826 if (!$items) {
827 $answer['note'] = 'Every theme is up to date.';
828 }
829
830 return $answer;
831 }
832
833 if (!$items) {
834 return ['started' => false, 'note' => 'Every theme is up to date.'];
835 }
836
837 $refused = self::unconfirmed('update_themes', array_column($items, 'stylesheet'), $arguments);
838 if ($refused) {
839 return $refused;
840 }
841
842 if (!\current_user_can('update_themes')) {
843 return new \WP_Error('extendify_mcp_refused', 'This user may not update themes.');
844 }
845
846 $stylesheets = array_column($items, 'stylesheet');
847
848 return Jobs::start('update_themes', ['themes' => $stylesheets], count($stylesheets));
849 }
850
851 /**
852 * @param array $arguments - The validated tool arguments.
853 * @return array|\WP_Error
854 */
855 public static function updateCore(array $arguments)
856 {
857 $update = Maintenance::coreUpdate();
858 $waiting = count(Maintenance::pluginUpdates());
859 if (!empty($arguments['preview'])) {
860 $answer = [
861 'preview' => true,
862 'current_version' => $GLOBALS['wp_version'],
863 'update_available' => $update !== null,
864 'new_version' => $update ? $update['to'] : null,
865 'plugins_waiting' => $waiting,
866 ];
867 if ($update) {
868 $answer['confirm_token'] = Confirmation::issue('update_core', [$update['to']]);
869 }
870
871 return $answer;
872 }
873
874 if (!$update) {
875 return new \WP_Error('extendify_mcp_refused', 'WordPress is already at the latest version.');
876 }
877
878 if ($waiting) {
879 return new \WP_Error('extendify_mcp_refused', sprintf(
880 '%d plugin(s) have updates waiting. Run update_plugins first; plugin authors ship compatibility'
881 . ' fixes ahead of core releases.',
882 $waiting
883 ));
884 }
885
886 $refused = self::unconfirmed('update_core', [$update['to']], $arguments);
887 if ($refused) {
888 return $refused;
889 }
890
891 if (!\current_user_can('update_core')) {
892 return new \WP_Error('extendify_mcp_refused', 'This user may not update WordPress.');
893 }
894
895 return Jobs::start('update_core', ['version' => $update['to'], 'locale' => $update['locale']], 1);
896 }
897
898 /**
899 * @param array $arguments - The validated tool arguments.
900 * @return array|\WP_Error
901 */
902 public static function setAutoUpdates(array $arguments)
903 {
904 return Maintenance::setAutoUpdates(
905 !empty($arguments['enabled']),
906 array_map('strval', (array) ($arguments['plugins'] ?? [])),
907 array_map('strval', (array) ($arguments['themes'] ?? [])),
908 !empty($arguments['all'])
909 );
910 }
911
912 /**
913 * @param array $arguments - The validated tool arguments.
914 * @return array|\WP_Error
915 */
916 public static function deleteInactiveThemes(array $arguments)
917 {
918 if (\is_multisite()) {
919 return new \WP_Error('extendify_mcp_refused', 'Deleting themes is not available on a multisite network.');
920 }
921
922 $items = Maintenance::inactiveThemes(
923 array_map('strval', (array) ($arguments['exclude'] ?? [])),
924 !empty($arguments['keep_default'])
925 );
926 if (!empty($arguments['preview'])) {
927 return self::preview('delete_inactive_themes', $items, 'stylesheet');
928 }
929
930 $refused = self::unconfirmed('delete_inactive_themes', array_column($items, 'stylesheet'), $arguments);
931 if ($refused) {
932 return $refused;
933 }
934
935 $deleted = [];
936 $failed = [];
937 foreach ($items as $item) {
938 $reason = Maintenance::deleteTheme($item['stylesheet']);
939 if ($reason !== null) {
940 $failed[] = ['stylesheet' => $item['stylesheet'], 'error' => $reason];
941 continue;
942 }
943
944 $deleted[] = $item['stylesheet'];
945 }
946
947 return ['deleted' => $deleted, 'failed' => $failed];
948 }
949
950 /**
951 * @param array $arguments - The validated tool arguments.
952 * @return array|\WP_Error
953 */
954 public static function regenerateThumbnails(array $arguments)
955 {
956 $found = Maintenance::imageIds(isset($arguments['ids']) ? (array) $arguments['ids'] : null);
957 if (!$found['ids']) {
958 return ['total' => 0, 'skipped' => $found['skipped'], 'note' => 'No images to process.'];
959 }
960
961 if (!\current_user_can('upload_files')) {
962 return new \WP_Error('extendify_mcp_refused', 'This user may not work on the media library.');
963 }
964
965 $payload = ['ids' => $found['ids'], 'only_missing' => !empty($arguments['only_missing'])];
966 $answer = Jobs::start('regenerate_thumbnails', $payload, count($found['ids']));
967 if ($found['skipped']) {
968 $answer['skipped'] = $found['skipped'];
969 }
970
971 return $answer;
972 }
973
974 /**
975 * @param array $arguments - The validated tool arguments.
976 * @return array|\WP_Error
977 */
978 public static function updateSiteSettings(array $arguments)
979 {
980 $given = array_intersect_key($arguments, self::SITE_SETTINGS);
981 if (!$given) {
982 return new \WP_Error('extendify_mcp_refused', 'No setting to change was given.');
983 }
984
985 if (isset($given['language'])) {
986 $refusal = self::translated((string) $given['language']);
987 if ($refusal) {
988 return $refusal;
989 }
990 }
991
992 $fields = [];
993 foreach ($given as $name => $value) {
994 $fields[self::SITE_SETTINGS[$name]] = $value;
995 }
996
997 $response = Guard::permitting(self::SITE_OPTIONS, function () use ($fields) {
998 return self::request('POST', 'wp/v2/settings', $fields);
999 });
1000
1001 if (\is_wp_error($response)) {
1002 return $response;
1003 }
1004
1005 $settings = (array) $response->get_data();
1006 $answer = [];
1007 foreach (self::SITE_SETTINGS as $name => $field) {
1008 $answer[$name] = $settings[$field] ?? null;
1009 }
1010
1011 // Core stores the name and tagline escaped.
1012 $answer['title'] = self::title($answer['title']);
1013 $answer['tagline'] = self::title($answer['tagline']);
1014
1015 return $answer;
1016 }
1017
1018 /**
1019 * @param array $arguments - The validated tool arguments.
1020 * @return array|\WP_Error
1021 */
1022 public static function getSiteHealth(array $arguments)
1023 {
1024 return Maintenance::health();
1025 }
1026
1027 /**
1028 * @param array $arguments - The validated tool arguments.
1029 * @return array|\WP_Error
1030 */
1031 public static function getTaskStatus(array $arguments)
1032 {
1033 $status = Jobs::status((string) $arguments['job_id']);
1034
1035 return $status ?: new \WP_Error(
1036 'extendify_mcp_no_job',
1037 'No such job for this connection. Job ids come from update_plugins, update_themes, update_core and'
1038 . ' regenerate_thumbnails.'
1039 );
1040 }
1041
1042 /**
1043 * @param array $arguments - The validated tool arguments.
1044 * @return array|\WP_Error
1045 */
1046 public static function getSiteInfo(array $arguments)
1047 {
1048 $settings = self::request('GET', 'wp/v2/settings');
1049 if (\is_wp_error($settings)) {
1050 return $settings;
1051 }
1052
1053 $themes = self::request('GET', 'wp/v2/themes', ['status' => 'active']);
1054 if (\is_wp_error($themes)) {
1055 return $themes;
1056 }
1057
1058 $settings = (array) $settings->get_data();
1059 $active = (array) (((array) $themes->get_data())[0] ?? []);
1060
1061 return [
1062 'name' => self::title($settings['title'] ?? ''),
1063 'tagline' => self::title($settings['description'] ?? ''),
1064 'url' => (string) ($settings['url'] ?? ''),
1065 'language' => (string) ($settings['language'] ?? ''),
1066 'timezone' => (string) ($settings['timezone'] ?? ''),
1067 'wordpress_version' => \get_bloginfo('version'),
1068 'active_theme' => [
1069 'name' => self::title($active['name'] ?? ''),
1070 'stylesheet' => (string) ($active['stylesheet'] ?? ''),
1071 'version' => (string) ($active['version'] ?? ''),
1072 ],
1073 'content_types' => self::contentTypes(),
1074 'plugins' => self::pluginCounts(),
1075 ];
1076 }
1077
1078 /**
1079 * @param string $method - GET, POST or DELETE.
1080 * @param string $path - The REST route to call.
1081 * @param array $params - The query on a GET, the body otherwise; null and empty values are dropped.
1082 * @return \WP_REST_Response|\WP_Error
1083 */
1084 private static function request($method, $path, array $params = [])
1085 {
1086 $request = new \WP_REST_Request($method, '/' . ltrim($path, '/'));
1087 $params = array_filter($params, function ($value) {
1088 return $value !== null && $value !== [];
1089 });
1090 if ($method === 'GET') {
1091 $request->set_query_params($params);
1092 } else {
1093 $request->set_body_params($params);
1094 }
1095
1096 $response = \rest_do_request($request);
1097
1098 return $response->is_error() ? $response->as_error() : $response;
1099 }
1100
1101 /**
1102 * A plugin's own install and activation routines write options; refusing those leaves it half-installed.
1103 *
1104 * @param string $method - The REST method.
1105 * @param string $path - The REST route to call.
1106 * @param array $params - The body to send.
1107 * @return \WP_REST_Response|\WP_Error
1108 */
1109 private static function unguarded($method, $path, array $params)
1110 {
1111 Guard::lift();
1112
1113 try {
1114 return self::request($method, $path, $params);
1115 } finally {
1116 Guard::hold();
1117 }
1118 }
1119
1120 /**
1121 * @param string $tool - The tool the preview ran for.
1122 * @param array $items - What it matched, each carrying $by.
1123 * @param string $by - The field the token is issued over.
1124 * @param integer|null $total - How many matched in all, when the items are one batch of them.
1125 * @return array
1126 */
1127 private static function preview($tool, array $items, $by, $total = null)
1128 {
1129 $answer = ['preview' => true, 'total' => $total ?? count($items), 'items' => $items];
1130 $set = array_column($items, $by);
1131 if ($set) {
1132 $answer['confirm_token'] = Confirmation::issue($tool, $set);
1133 }
1134
1135 if ($answer['total'] > count($items)) {
1136 $answer['remaining'] = $answer['total'] - count($items);
1137 }
1138
1139 return $answer;
1140 }
1141
1142 /**
1143 * @param string $tool - The tool about to execute.
1144 * @param array $set - The ids or slugs it matched now.
1145 * @param array $arguments - The validated tool arguments.
1146 * @return \WP_Error|null - Why it may not go ahead, or null when it may.
1147 */
1148 private static function unconfirmed($tool, array $set, array $arguments)
1149 {
1150 if (!$set) {
1151 return null;
1152 }
1153
1154 $refusal = Confirmation::refusal($tool, $set, $arguments['confirm_token'] ?? null);
1155
1156 return $refusal === null ? null : new \WP_Error('extendify_mcp_unconfirmed', $refusal);
1157 }
1158
1159 /**
1160 * @param \WP_REST_Response $response - What the REST server answered.
1161 * @param callable $shape - Given one item, returns the fields to keep.
1162 * @return array
1163 */
1164 private static function listed($response, $shape)
1165 {
1166 $items = array_map($shape, (array) $response->get_data());
1167 $headers = $response->get_headers();
1168
1169 return [
1170 'items' => array_values($items),
1171 'total' => (int) ($headers['X-WP-Total'] ?? count($items)),
1172 'pages' => (int) ($headers['X-WP-TotalPages'] ?? 1),
1173 ];
1174 }
1175
1176 /**
1177 * A locale with no translation installed leaves the site in English, saying nothing.
1178 *
1179 * @param string $locale - The locale a setting write named.
1180 * @return \WP_Error|null - Why it may not be set, or null when it may.
1181 */
1182 private static function translated($locale)
1183 {
1184 if ($locale === 'en_US' || in_array($locale, \get_available_languages(), true)) {
1185 return null;
1186 }
1187
1188 require_once ABSPATH . 'wp-admin/includes/translation-install.php';
1189 if (\wp_download_language_pack($locale)) {
1190 return null;
1191 }
1192
1193 return new \WP_Error('extendify_mcp_refused', sprintf(
1194 'The %s translation is not installed and could not be downloaded, so the language is unchanged.',
1195 $locale
1196 ));
1197 }
1198
1199 /**
1200 * Core's meta write skips an unknown key, so a model would be told it landed.
1201 *
1202 * @param array $keys - The custom field names a call means to set.
1203 * @param string $type - The post type they would be written on.
1204 * @return array - The names this site does not expose.
1205 */
1206 private static function unregistered(array $keys, $type)
1207 {
1208 $registered = array_merge(
1209 \get_registered_meta_keys('post', ''),
1210 \get_registered_meta_keys('post', $type)
1211 );
1212
1213 return array_values(array_filter($keys, function ($key) use ($registered) {
1214 return empty($registered[$key]['show_in_rest']);
1215 }));
1216 }
1217
1218 /**
1219 * @param array $item - One item from a terms controller.
1220 * @param string $taxonomy - The taxonomy it belongs to.
1221 * @return array
1222 */
1223 private static function shapeTerm(array $item, $taxonomy)
1224 {
1225 return [
1226 'id' => (int) ($item['id'] ?? 0),
1227 'name' => self::title($item['name'] ?? ''),
1228 'slug' => (string) ($item['slug'] ?? ''),
1229 'taxonomy' => $taxonomy,
1230 'parent' => (int) ($item['parent'] ?? 0),
1231 'count' => (int) ($item['count'] ?? 0),
1232 'link' => (string) ($item['link'] ?? ''),
1233 ];
1234 }
1235
1236 /**
1237 * @param string $taxonomy - The taxonomy being reached.
1238 * @return string
1239 */
1240 private static function taxonomyRoute($taxonomy)
1241 {
1242 $object = \get_taxonomy($taxonomy);
1243 $namespace = empty($object->rest_namespace) ? 'wp/v2' : $object->rest_namespace;
1244
1245 return $namespace . '/' . (empty($object->rest_base) ? $taxonomy : $object->rest_base);
1246 }
1247
1248 /**
1249 * @param array $terms - Ids or names as the tool was given them.
1250 * @param string $taxonomy - The taxonomy they belong to.
1251 * @return array - The ids resolved, and the names nothing matched.
1252 */
1253 private static function terms(array $terms, $taxonomy)
1254 {
1255 $ids = [];
1256 $unknown = [];
1257 foreach ($terms as $term) {
1258 if (is_int($term) || preg_match('/^[0-9]+$/', (string) $term)) {
1259 $found = \get_term((int) $term, $taxonomy);
1260 \is_wp_error($found) || !$found ? $unknown[] = (string) $term : $ids[] = (int) $found->term_id;
1261 continue;
1262 }
1263
1264 $found = \get_term_by('name', (string) $term, $taxonomy) ?: \get_term_by('slug', (string) $term, $taxonomy);
1265 $found ? $ids[] = (int) $found->term_id : $unknown[] = (string) $term;
1266 }
1267
1268 return ['ids' => array_values(array_unique($ids)), 'unknown' => $unknown];
1269 }
1270
1271 /**
1272 * A commenter's email, IP and user agent stay out of every answer.
1273 *
1274 * @param array $item - One item from the comments controller.
1275 * @return array
1276 */
1277 private static function shapeComment(array $item)
1278 {
1279 $post = (int) ($item['post'] ?? 0);
1280
1281 return [
1282 'id' => (int) ($item['id'] ?? 0),
1283 'post' => $post,
1284 'post_title' => self::title(\get_the_title($post)),
1285 'author' => self::title($item['author_name'] ?? ''),
1286 'date' => (string) ($item['date_gmt'] ?? ''),
1287 'status' => (string) ($item['status'] ?? ''),
1288 'parent' => (int) ($item['parent'] ?? 0),
1289 'content' => trim(\wp_strip_all_tags(self::raw($item['content'] ?? ''))),
1290 'link' => (string) ($item['link'] ?? ''),
1291 ];
1292 }
1293
1294 /**
1295 * The query says 'approve' where a write says 'approved', and 'all' excludes spam and trash.
1296 *
1297 * @param string $status - The state the tool was asked for.
1298 * @return string
1299 */
1300 private static function commentQuery($status)
1301 {
1302 $query = [
1303 'any' => 'all',
1304 'approved' => 'approve',
1305 'pending' => 'hold',
1306 'spam' => 'spam',
1307 'trash' => 'trash',
1308 ];
1309
1310 return $query[$status];
1311 }
1312
1313 /**
1314 * @param array $item - One item from a posts controller.
1315 * @return array
1316 */
1317 private static function shapePost(array $item)
1318 {
1319 return [
1320 'id' => (int) ($item['id'] ?? 0),
1321 'title' => self::title($item['title'] ?? ''),
1322 'status' => (string) ($item['status'] ?? ''),
1323 'type' => (string) ($item['type'] ?? ''),
1324 'slug' => (string) ($item['slug'] ?? ''),
1325 'date' => (string) ($item['date'] ?? ''),
1326 'modified' => (string) ($item['modified'] ?? ''),
1327 'author' => (int) ($item['author'] ?? 0),
1328 'link' => (string) ($item['link'] ?? ''),
1329 ];
1330 }
1331
1332 /**
1333 * @param string $type - The content type asked for, already held to Tools::types() by the schema.
1334 * @return string
1335 */
1336 private static function typeRoute($type)
1337 {
1338 $object = \get_post_type_object($type);
1339 $namespace = empty($object->rest_namespace) ? 'wp/v2' : $object->rest_namespace;
1340
1341 return $namespace . '/' . (empty($object->rest_base) ? $object->name : $object->rest_base);
1342 }
1343
1344 /**
1345 * @param mixed $field - A field a controller may spell as raw and rendered.
1346 * @return string
1347 */
1348 private static function raw($field)
1349 {
1350 if (!is_array($field)) {
1351 return (string) $field;
1352 }
1353
1354 return (string) ($field['raw'] ?? $field['rendered'] ?? '');
1355 }
1356
1357 /**
1358 * @param mixed $field - A title a controller may spell as raw and rendered.
1359 * @return string
1360 */
1361 private static function title($field)
1362 {
1363 if (is_array($field) && isset($field['raw'])) {
1364 return (string) $field['raw'];
1365 }
1366
1367 // A rendered title is entity-encoded, and &amp; is noise to a model.
1368 return \wp_specialchars_decode(self::raw($field), ENT_QUOTES);
1369 }
1370
1371 /**
1372 * @param string $status - active, inactive, or any.
1373 * @return array
1374 */
1375 private static function status($status)
1376 {
1377 return $status === 'any' ? [] : ['status' => $status];
1378 }
1379
1380 /**
1381 * @param string $transient - update_plugins or update_themes.
1382 * @return array
1383 */
1384 private static function waiting($transient)
1385 {
1386 $updates = \get_site_transient($transient);
1387
1388 return isset($updates->response) ? (array) $updates->response : [];
1389 }
1390
1391 /**
1392 * Plugin updates arrive as objects and theme updates as arrays.
1393 *
1394 * @param mixed $update - What the update transient held, if anything.
1395 * @return string|null
1396 */
1397 private static function newVersion($update)
1398 {
1399 if ($update === null) {
1400 return null;
1401 }
1402
1403 return (string) (((array) $update)['new_version'] ?? '');
1404 }
1405
1406 /**
1407 * @param string|null $value - A MIME type, or the prefix standing for one.
1408 * @return array
1409 */
1410 private static function mime($value)
1411 {
1412 if (!$value) {
1413 return [];
1414 }
1415
1416 return strpos($value, '/') === false ? ['media_type' => $value] : ['mime_type' => $value];
1417 }
1418
1419 /**
1420 * @return callable
1421 */
1422 private static function bareAltText()
1423 {
1424 return function (array $args) {
1425 $args['meta_query'] = [
1426 'relation' => 'OR',
1427 ['key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'],
1428 ['key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='],
1429 ];
1430
1431 return $args;
1432 };
1433 }
1434
1435 /**
1436 * @param array $arguments - The validated tool arguments.
1437 * @return array|null - A date_query over user_registered, or null when neither bound was given.
1438 */
1439 private static function registered(array $arguments)
1440 {
1441 $bounds = array_filter([
1442 'after' => $arguments['registered_after'] ?? null,
1443 'before' => $arguments['registered_before'] ?? null,
1444 ]);
1445
1446 return $bounds ? [array_merge(['column' => 'user_registered'], $bounds)] : null;
1447 }
1448
1449 /**
1450 * @param array $arguments - The validated tool arguments.
1451 * @return callable|null
1452 */
1453 private static function registeredBetween(array $arguments)
1454 {
1455 $registered = self::registered($arguments);
1456 if (!$registered) {
1457 return null;
1458 }
1459
1460 return function (array $args) use ($registered) {
1461 $args['date_query'] = $registered;
1462
1463 return $args;
1464 };
1465 }
1466
1467 /**
1468 * @param array $arguments - The validated tool arguments.
1469 * @param integer $reassign - The user inheriting the content.
1470 * @param array $admins - The roles that may manage the site.
1471 * @return array
1472 */
1473 private static function deletable(array $arguments, $reassign, array $admins)
1474 {
1475 $kept = array_merge([\get_current_user_id(), $reassign], self::wroteMoreThan((int) $arguments['max_posts']));
1476 $args = [
1477 'role__not_in' => $admins,
1478 'exclude' => $kept,
1479 'number' => self::DELETE_BATCH,
1480 'orderby' => 'ID',
1481 'order' => 'ASC',
1482 'count_total' => true,
1483 ];
1484 if (!empty($arguments['role'])) {
1485 $args['role'] = $arguments['role'];
1486 }
1487
1488 $registered = self::registered($arguments);
1489 if ($registered) {
1490 $args['date_query'] = $registered;
1491 }
1492
1493 return $args;
1494 }
1495
1496 /**
1497 * @return array
1498 */
1499 private static function adminRoles()
1500 {
1501 $roles = [];
1502 foreach (\wp_roles()->role_objects as $name => $role) {
1503 if ($role->has_cap('manage_options')) {
1504 $roles[] = $name;
1505 }
1506 }
1507
1508 return $roles;
1509 }
1510
1511 /**
1512 * @param \WP_User $user - A user the query found.
1513 * @param array $counts - Post counts by user id.
1514 * @return array
1515 */
1516 private static function shapeUser(\WP_User $user, array $counts)
1517 {
1518 return [
1519 'id' => (int) $user->ID,
1520 'name' => (string) $user->display_name,
1521 'username' => (string) $user->user_login,
1522 'roles' => array_values((array) $user->roles),
1523 'registered' => gmdate('c', strtotime($user->user_registered)),
1524 'post_count' => (int) ($counts[$user->ID] ?? 0),
1525 ];
1526 }
1527
1528 /**
1529 * @param array $exclude - Slugs, or plugin files, to keep.
1530 * @return array
1531 */
1532 private static function inactivePlugins(array $exclude)
1533 {
1534 if (!function_exists('get_plugins')) {
1535 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1536 }
1537
1538 $items = [];
1539 foreach (\get_plugins() as $file => $plugin) {
1540 $slug = preg_replace('/\.php$/', '', $file);
1541 if (\is_plugin_active($file) || in_array($slug, $exclude, true) || in_array($file, $exclude, true)) {
1542 continue;
1543 }
1544
1545 $items[] = [
1546 'slug' => $slug,
1547 'name' => self::title($plugin['Name'] ?? ''),
1548 'version' => (string) ($plugin['Version'] ?? ''),
1549 ];
1550 }
1551
1552 return $items;
1553 }
1554
1555 /**
1556 * Filtering the page we got back would leave its total and count lying.
1557 *
1558 * @param integer $most - The most posts a user may have written.
1559 * @return array
1560 */
1561 private static function wroteMoreThan($most)
1562 {
1563 $wpdb = $GLOBALS['wpdb'];
1564 // Core builds the same WHERE clause count_many_users_posts() counts through.
1565 $where = \get_posts_by_author_sql(Tools::types(), true, null, false);
1566
1567 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery
1568 $authors = $wpdb->get_col($wpdb->prepare(
1569 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1570 "SELECT post_author FROM {$wpdb->posts} {$where} GROUP BY post_author HAVING COUNT(*) > %d",
1571 (int) $most
1572 ));
1573
1574 return array_map('intval', $authors);
1575 }
1576
1577 /**
1578 * Counts what went out, not what landed, so a dead service cannot be looped on.
1579 *
1580 * @param array $arguments - The tool arguments.
1581 * @return array|\WP_Error
1582 */
1583 public static function requestFeature(array $arguments)
1584 {
1585 $sent = array_values(array_filter((array) \get_transient(self::FEATURE_REQUESTS), function ($at) {
1586 return (int) $at > (time() - DAY_IN_SECONDS);
1587 }));
1588
1589 if ($sent && max($sent) > (time() - self::FEATURE_REQUEST_GAP)) {
1590 return new \WP_Error(
1591 'extendify_mcp_feature_request_recent',
1592 'A request from this site went out moments ago. Tell the user, and do not send another.'
1593 );
1594 }
1595
1596 if (count($sent) >= self::FEATURE_REQUESTS_A_DAY) {
1597 return new \WP_Error('extendify_mcp_feature_requests_today', sprintf(
1598 'This site has sent %d feature requests today, which is the limit. Try again tomorrow.',
1599 self::FEATURE_REQUESTS_A_DAY
1600 ));
1601 }
1602
1603 $sent[] = time();
1604 \set_transient(self::FEATURE_REQUESTS, $sent, DAY_IN_SECONDS);
1605
1606 return self::passOnRequest($arguments);
1607 }
1608
1609 /**
1610 * @param array $arguments - The tool arguments.
1611 * @return array|\WP_Error
1612 */
1613 private static function passOnRequest(array $arguments)
1614 {
1615 $response = \wp_remote_post(Constants::INSIGHTS_HOST . '/api/v1/mcp-feature-request', [
1616 'timeout' => 5,
1617 'headers' => [
1618 'Content-Type' => 'application/json',
1619 'Accept' => 'application/json',
1620 'X-Extendify-Site-Id' => \get_option('extendify_site_id', ''),
1621 ],
1622 'body' => \wp_json_encode([
1623 'partner' => (string) constant('EXTENDIFY_PARTNER_ID'),
1624 'tool' => $arguments['tool'],
1625 'justification' => $arguments['justification'],
1626 'context' => (string) ($arguments['context'] ?? ''),
1627 ]),
1628 ]);
1629
1630 $code = \is_wp_error($response) ? 0 : (int) \wp_remote_retrieve_response_code($response);
1631 if ($code === 200) {
1632 return [
1633 'sent' => true,
1634 'note' => 'Passed on. Tell the user it was sent, and do not send this request again.',
1635 ];
1636 }
1637
1638 $answered = json_decode(\wp_remote_retrieve_body($response), true);
1639 $told = is_array($answered) ? (string) ($answered['error'] ?? '') : '';
1640 // A 404 is the route not deployed yet, which the model can do nothing with.
1641 if ($told !== '' && $code >= 400 && $code < 500 && $code !== 404) {
1642 return new \WP_Error('extendify_mcp_feature_request_turned_down', $told);
1643 }
1644
1645 return new \WP_Error(
1646 'extendify_mcp_feature_request_failed',
1647 'The request could not be sent. Nothing on this site is wrong; tell the user it did not go out.'
1648 );
1649 }
1650
1651 /**
1652 * @return array
1653 */
1654 private static function contentTypes()
1655 {
1656 return array_map(function ($type) {
1657 $object = \get_post_type_object($type);
1658
1659 return [
1660 'slug' => $type,
1661 'label' => (string) $object->labels->name,
1662 'hierarchical' => (bool) $object->hierarchical,
1663 ];
1664 }, Tools::types());
1665 }
1666
1667 /**
1668 * @return array
1669 */
1670 private static function pluginCounts()
1671 {
1672 if (!function_exists('get_plugins')) {
1673 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1674 }
1675
1676 $installed = array_keys(\get_plugins());
1677
1678 return [
1679 'installed' => count($installed),
1680 'active' => count(array_filter($installed, 'is_plugin_active')),
1681 ];
1682 }
1683 }
1684