PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
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 0.5.0 All 128 releases
extendify / app / Mcp / Handlers.php

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

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