PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.0
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
betterdocs / includes / Abilities / Status / GetStatus.php

GetStatus.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.0, at includes/Abilities/Status/GetStatus.php

613 lines 20.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Get status ability.
4 *
5 * @package BetterDocs
6 * @since 4.9.0
7 */
8
9 namespace WPDeveloper\BetterDocs\Abilities\Status;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit; // Exit if accessed directly.
13 }
14
15 use WPDeveloper\BetterDocs\Abilities\AbilityBase;
16 use WPDeveloper\BetterDocs\Mcp\MCPHealth;
17 use WPDeveloper\BetterDocs\Mcp\MCPTools;
18
19 /**
20 * Reports what BetterDocs looks like on this site.
21 *
22 * The first call an agent should make, and the reason every later refusal is
23 * explainable rather than mysterious: it answers "is Pro here", "is Multiple
24 * Knowledge Base on", "which capabilities do I hold", "what is there to work
25 * with" and "which tools exist" in one round trip. An agent that calls this
26 * first never has to discover a missing capability by failing a write.
27 *
28 * The report is assembled from {@see MCPHealth::report()} rather than
29 * duplicating it — that class is already the one place that knows how to answer
30 * these questions without side effects and without leaking a credential. What
31 * this ability adds is the content counts, the tool catalog, Pro's role
32 * settings and the human-readable `notes`.
33 *
34 * Read-only: it writes nothing and makes no outbound request.
35 *
36 * @since 4.9.0
37 */
38 class GetStatus extends AbilityBase {
39
40 /**
41 * Areas of BetterDocs deliberately left out of the MCP surface for now
42 * (ADR-020). Named in the response so an agent stops looking for a tool
43 * that was never built, instead of inferring one from the plugin's UI.
44 *
45 * @since 4.9.0
46 */
47 const DEFERRED_V2 = [
48 'ordering',
49 'access-control',
50 'import-export',
51 'search',
52 'layout',
53 'ai-chatbot'
54 ];
55
56 /**
57 * Taxonomy behind each `counts` key.
58 *
59 * @since 4.9.0
60 */
61 const COUNTED_TAXONOMIES = [
62 'doc_categories' => 'doc_category',
63 'doc_tags' => 'doc_tag',
64 'knowledge_bases' => 'knowledge_base',
65 'faq_groups' => 'betterdocs_faq_category',
66 'glossaries' => 'glossaries'
67 ];
68
69 /**
70 * Pro settings that decide who holds BetterDocs' capabilities (ADR-033).
71 *
72 * @since 4.9.0
73 */
74 const ROLE_SETTINGS = [ 'article_roles', 'settings_roles', 'analytics_roles', 'faq_roles' ];
75
76 /**
77 * Which of those four settings grants which capability, for the three Pro
78 * reconciles individually ({@see \WPDeveloper\BetterDocs\Core\Roles::PRO_RECONCILED_CAPS};
79 * Pro's own `get_selected_roles()` is the source). Everything else belongs
80 * to the `edit_docs` bundle and is granted by `article_roles`.
81 *
82 * A remedy that names the wrong setting is worse than no remedy: an agent
83 * told to add a role to `article_roles` to gain `read_docs_analytics` makes
84 * a write that cannot work.
85 *
86 * @since 4.9.0
87 */
88 const CAPABILITY_ROLE_SETTING = [
89 'edit_docs_settings' => 'settings_roles',
90 'read_docs_analytics' => 'analytics_roles',
91 'read_faq_builder' => 'faq_roles'
92 ];
93
94 /**
95 * @since 4.9.0
96 */
97 public function __construct() {
98 $this->id = 'betterdocs/get-status';
99 $this->label = __( 'Get status', 'betterdocs' );
100 $this->description = __( 'Get BetterDocs status: versions, Pro state, Multiple Knowledge Base state, content counts, your capabilities (held and missing by name), registered tools. Call this first.', 'betterdocs' );
101 $this->capability = 'edit_docs';
102 }
103
104 /**
105 * @since 4.9.0
106 *
107 * @return array
108 */
109 public function get_annotations() {
110 return [
111 'readonly' => true,
112 'destructive' => false,
113 'idempotent' => true,
114 'priority' => 1.0,
115 'openWorldHint' => false
116 ];
117 }
118
119 /**
120 * @since 4.9.0
121 *
122 * @return array
123 */
124 public function get_input_schema() {
125 return [
126 'type' => 'object',
127 'additionalProperties' => false,
128 'properties' => [],
129 // Required, not decorative: `WP_Ability::normalize_input()` turns a
130 // missing input into this default, and without it a client that
131 // simply calls the ability with no arguments — which is the only
132 // sensible way to call one that takes none — fails validation with
133 // "input is not of type object". Core's own no-argument abilities
134 // declare it for the same reason (ADR-030).
135 'default' => []
136 ];
137 }
138
139 /**
140 * The full contract.
141 *
142 * Declared in full because core validates the *output* against this schema
143 * too ({@see \WP_Ability::execute()}), so it is an executable promise rather
144 * than documentation: a key that stops being emitted, or changes type,
145 * fails the call instead of quietly changing what an agent reads.
146 *
147 * @since 4.9.0
148 *
149 * @return array
150 */
151 public function get_output_schema() {
152 $string_list = [
153 'type' => 'array',
154 'items' => [ 'type' => 'string' ]
155 ];
156
157 return [
158 'type' => 'object',
159 'required' => [
160 'free_version',
161 'pro',
162 'multiple_kb',
163 'enable_glossaries',
164 'enable_mcp',
165 'endpoints',
166 'counts',
167 'capabilities',
168 'abilities',
169 'tools',
170 'deferred_v2',
171 'notes'
172 ],
173 'properties' => [
174 'free_version' => [ 'type' => 'string' ],
175 'pro' => [
176 'type' => 'object',
177 'properties' => [
178 'installed' => [ 'type' => 'boolean' ],
179 'active' => [ 'type' => 'boolean' ],
180 'version' => [ 'type' => [ 'string', 'null' ] ],
181 'license_status' => [ 'type' => 'string' ],
182 'licensed' => [ 'type' => 'boolean' ],
183 'state' => [ 'type' => 'string' ]
184 ]
185 ],
186 'multiple_kb' => [ 'type' => 'boolean' ],
187 'enable_glossaries' => [ 'type' => 'boolean' ],
188 'enable_mcp' => [ 'type' => 'boolean' ],
189 'endpoints' => [
190 'type' => 'object',
191 'properties' => [
192 'mcp' => [ 'type' => 'string' ],
193 'mcp_rest' => [ 'type' => 'string' ],
194 'authorize' => [ 'type' => 'string' ]
195 ]
196 ],
197 'counts' => [
198 'type' => 'object',
199 'properties' => [
200 'docs' => [
201 'type' => 'object',
202 'properties' => [
203 'created' => [ 'type' => 'integer' ],
204 'published' => [ 'type' => 'integer' ]
205 ]
206 ],
207 'faqs' => [
208 'type' => 'object',
209 'properties' => [
210 'created' => [ 'type' => 'integer' ],
211 'published' => [ 'type' => 'integer' ]
212 ]
213 ],
214 'doc_categories' => [ 'type' => [ 'integer', 'null' ] ],
215 'doc_tags' => [ 'type' => [ 'integer', 'null' ] ],
216 // Null when Multiple Knowledge Base is off: the
217 // taxonomy is not registered, so there is nothing to
218 // count and 0 would be a lie.
219 'knowledge_bases' => [ 'type' => [ 'integer', 'null' ] ],
220 'faq_groups' => [ 'type' => [ 'integer', 'null' ] ],
221 // Null when enable_glossaries is off, for the same
222 // reason: the taxonomy is not registered.
223 'glossaries' => [ 'type' => [ 'integer', 'null' ] ]
224 ]
225 ],
226 'capabilities' => [
227 'type' => 'object',
228 'properties' => [
229 'required' => $string_list,
230 'held' => $string_list,
231 'missing' => $string_list,
232 // Pro's role settings when Pro is active (ADR-033),
233 // null on a Free-only site where the roles hold the
234 // capabilities directly.
235 'governed_by' => [ 'type' => [ 'object', 'null' ] ],
236 'user_id' => [ 'type' => 'integer' ],
237 'user_roles' => $string_list
238 ]
239 ],
240 'abilities' => [
241 'type' => 'object',
242 'properties' => [
243 'api_available' => [ 'type' => 'boolean' ],
244 'owner' => [ 'type' => 'object' ],
245 'foreign' => [ 'type' => 'boolean' ],
246 'total' => [ 'type' => 'integer' ],
247 'betterdocs' => [ 'type' => 'integer' ],
248 'replayed' => [ 'type' => 'boolean' ]
249 ]
250 ],
251 'tools' => [
252 'type' => 'object',
253 'properties' => [
254 'count' => [ 'type' => 'integer' ],
255 'names' => $string_list
256 ]
257 ],
258 'deferred_v2' => $string_list,
259 'notes' => $string_list
260 ]
261 ];
262 }
263
264 /**
265 * @since 4.9.0
266 *
267 * @param array $input Validated input.
268 * @return array
269 */
270 public function execute( $input ) {
271 $health = $this->health()->report();
272
273 $pro = isset( $health['plugin']['pro'] ) && is_array( $health['plugin']['pro'] ) ? $health['plugin']['pro'] : [];
274 $abilities = isset( $health['abilities'] ) && is_array( $health['abilities'] ) ? $health['abilities'] : [];
275 $mcp = isset( $health['mcp'] ) && is_array( $health['mcp'] ) ? $health['mcp'] : [];
276
277 // `owner` comes from the health report's `runtime` section rather than
278 // from `AbilitiesRegistrar::diagnostics()`, because that section is
279 // where the version is made meaningful: `Runtime::owner()` reports null
280 // when core owns the API, and the health report substitutes WordPress'
281 // own version there (ADR-024). The two must not disagree.
282 $owner = isset( $health['runtime']['abilities_api'] ) && is_array( $health['runtime']['abilities_api'] )
283 ? $health['runtime']['abilities_api']
284 : [];
285
286 $multiple_kb = ! empty( $pro['multiple_kb'] );
287 $glossaries = taxonomy_exists( 'glossaries' );
288 $counts = $this->counts();
289 $capabilities = $this->capabilities( $health, ! empty( $pro['active'] ) );
290 $tools = $this->tools();
291
292 return [
293 'free_version' => defined( 'BETTERDOCS_VERSION' ) ? (string) BETTERDOCS_VERSION : '',
294 'pro' => [
295 'installed' => ! empty( $pro['installed'] ),
296 'active' => ! empty( $pro['active'] ),
297 'version' => isset( $pro['version'] ) && null !== $pro['version'] ? (string) $pro['version'] : null,
298 'license_status' => isset( $pro['license_status'] ) ? (string) $pro['license_status'] : '',
299 'licensed' => ! empty( $pro['licensed'] ),
300 'state' => isset( $pro['state'] ) ? (string) $pro['state'] : ''
301 ],
302 'multiple_kb' => $multiple_kb,
303 // Reported from the registered taxonomy rather than the stored
304 // option: the setting only takes effect on the next request, and what
305 // a tool can do right now is what the taxonomy says.
306 'enable_glossaries' => $glossaries,
307 'enable_mcp' => ! empty( $mcp['enabled'] ),
308 'endpoints' => [
309 'mcp' => isset( $mcp['endpoint'] ) ? (string) $mcp['endpoint'] : '',
310 'mcp_rest' => isset( $mcp['endpoint_rest'] ) ? (string) $mcp['endpoint_rest'] : '',
311 'authorize' => isset( $mcp['authorize_url'] ) ? (string) $mcp['authorize_url'] : ''
312 ],
313 'counts' => $counts,
314 'capabilities' => $capabilities,
315 'abilities' => [
316 'api_available' => ! empty( $abilities['api_available'] ),
317 'owner' => $owner,
318 'foreign' => ! empty( $abilities['foreign'] ),
319 'total' => isset( $abilities['total'] ) ? (int) $abilities['total'] : 0,
320 'betterdocs' => isset( $abilities['betterdocs'] ) ? (int) $abilities['betterdocs'] : 0,
321 'replayed' => ! empty( $abilities['replayed'] )
322 ],
323 'tools' => $tools,
324 'deferred_v2' => self::DEFERRED_V2,
325 'notes' => $this->notes( $pro, $multiple_kb, $counts, $capabilities, $tools, $mcp, $glossaries )
326 ];
327 }
328
329 /**
330 * The health report this ability is a view onto.
331 *
332 * Constructed rather than resolved from the container: `MCPHealth` holds no
333 * state and takes no collaborators, and an ability that can be built by
334 * `new` in a test is worth more than a shared instance.
335 *
336 * @since 4.9.0
337 *
338 * @return MCPHealth
339 */
340 protected function health() {
341 return new MCPHealth();
342 }
343
344 /**
345 * How much content there is to work with.
346 *
347 * `wp_count_posts()` and `get_terms( fields => count )` are used rather than
348 * the `betterdocs/v1/docs-faq-count` route, which answers the same question
349 * with six `WP_Query`s at `posts_per_page => -1` (ADR-043): this ability is
350 * the one an agent is told to call first, so it must stay cheap on a site
351 * with fifty thousand docs. Two consequences, both deliberate:
352 *
353 * - `faqs` counts every `betterdocs_faq` post, where that route subtracts
354 * Product FAQs. On a site not using the WooCommerce tab the numbers are
355 * identical.
356 * - `created` follows the same privilege rule as that route (an agent
357 * without the "others'" capability sees published counts only), widened
358 * with `edit_others_docs` the way the FAQ Builder routes are — a
359 * docs-only role holds no core post capabilities at all.
360 *
361 * @since 4.9.0
362 *
363 * @return array
364 */
365 protected function counts() {
366 $counts = [
367 'docs' => $this->post_counts( 'docs' ),
368 'faqs' => $this->post_counts( 'betterdocs_faq' )
369 ];
370
371 foreach ( self::COUNTED_TAXONOMIES as $key => $taxonomy ) {
372 $counts[ $key ] = $this->term_count( $taxonomy );
373 }
374
375 return $counts;
376 }
377
378 /**
379 * `{created, published}` for one post type.
380 *
381 * @since 4.9.0
382 *
383 * @param string $post_type Post type name.
384 * @return array
385 */
386 protected function post_counts( $post_type ) {
387 if ( ! post_type_exists( $post_type ) ) {
388 return [
389 'created' => 0,
390 'published' => 0
391 ];
392 }
393
394 $counts = (array) wp_count_posts( $post_type );
395 $published = isset( $counts['publish'] ) ? (int) $counts['publish'] : 0;
396
397 if ( ! $this->sees_all_statuses() ) {
398 return [
399 'created' => $published,
400 'published' => $published
401 ];
402 }
403
404 // The same set `WP_Query`'s `post_status => 'any'` counts: everything
405 // except the statuses flagged `exclude_from_search` (trash, auto-draft).
406 $created = 0;
407
408 foreach ( get_post_stati( [ 'exclude_from_search' => false ] ) as $status ) {
409 $created += isset( $counts[ $status ] ) ? (int) $counts[ $status ] : 0;
410 }
411
412 return [
413 'created' => $created,
414 'published' => $published
415 ];
416 }
417
418 /**
419 * Number of terms in a taxonomy, or null when it is not registered.
420 *
421 * @since 4.9.0
422 *
423 * @param string $taxonomy Taxonomy name.
424 * @return int|null
425 */
426 protected function term_count( $taxonomy ) {
427 if ( ! taxonomy_exists( $taxonomy ) ) {
428 return null;
429 }
430
431 $count = get_terms(
432 [
433 'taxonomy' => $taxonomy,
434 'hide_empty' => false,
435 'fields' => 'count'
436 ]
437 );
438
439 return is_wp_error( $count ) ? null : (int) $count;
440 }
441
442 /**
443 * Whether this user may be told how much unpublished content exists.
444 *
445 * @since 4.9.0
446 *
447 * @return bool
448 */
449 protected function sees_all_statuses() {
450 return current_user_can( 'edit_others_docs' ) || current_user_can( 'edit_others_posts' );
451 }
452
453 /**
454 * Which capabilities this user holds, and what decides that.
455 *
456 * @since 4.9.0
457 *
458 * @param array $health The health report.
459 * @param bool $pro_active Whether Pro is active.
460 * @return array
461 */
462 protected function capabilities( array $health, $pro_active ) {
463 $caps = isset( $health['capabilities'] ) && is_array( $health['capabilities'] ) ? $health['capabilities'] : [];
464 $user = isset( $health['user'] ) && is_array( $health['user'] ) ? $health['user'] : [];
465
466 return [
467 'required' => isset( $caps['required'] ) ? array_values( (array) $caps['required'] ) : [],
468 'held' => isset( $caps['held'] ) ? array_values( (array) $caps['held'] ) : [],
469 'missing' => isset( $caps['missing'] ) ? array_values( (array) $caps['missing'] ) : [],
470 'governed_by' => $pro_active ? $this->role_settings() : null,
471 'user_id' => isset( $caps['user_id'] ) ? (int) $caps['user_id'] : 0,
472 'user_roles' => isset( $user['roles'] ) ? array_values( array_map( 'strval', (array) $user['roles'] ) ) : []
473 ];
474 }
475
476 /**
477 * Pro's four role settings, the thing that actually grants the capabilities
478 * on a Pro site (ADR-033).
479 *
480 * @since 4.9.0
481 *
482 * @return array
483 */
484 protected function role_settings() {
485 $settings = [];
486
487 foreach ( self::ROLE_SETTINGS as $key ) {
488 $value = betterdocs()->settings->get( $key );
489
490 $settings[ $key ] = is_array( $value ) ? array_values( array_map( 'strval', $value ) ) : [];
491 }
492
493 return $settings;
494 }
495
496 /**
497 * The tool catalog, from the same source `tools/list` uses.
498 *
499 * Same source on purpose: if a read-only credential is in play the catalog
500 * is the short one, and an agent reading this ability's answer sees exactly
501 * what it is allowed to call.
502 *
503 * @since 4.9.0
504 *
505 * @return array
506 */
507 protected function tools() {
508 $names = [];
509
510 foreach ( MCPTools::list() as $tool ) {
511 if ( isset( $tool['name'] ) ) {
512 $names[] = (string) $tool['name'];
513 }
514 }
515
516 return [
517 'count' => count( $names ),
518 'names' => $names
519 ];
520 }
521
522 /**
523 * The distinct Pro role settings that govern a list of capabilities, in the
524 * order {@see self::ROLE_SETTINGS} declares them.
525 *
526 * @since 4.9.0
527 *
528 * @param string[] $capabilities Capability names.
529 * @return string[]
530 */
531 protected function settings_governing( array $capabilities ) {
532 $settings = [];
533
534 foreach ( $capabilities as $capability ) {
535 $settings[] = isset( self::CAPABILITY_ROLE_SETTING[ $capability ] )
536 ? self::CAPABILITY_ROLE_SETTING[ $capability ]
537 : 'article_roles';
538 }
539
540 return array_values( array_intersect( self::ROLE_SETTINGS, $settings ) );
541 }
542
543 /**
544 * Plain-language hints an agent can act on.
545 *
546 * Every note names the tool call or the person that fixes what it describes;
547 * a note that only states a fact belongs in the structured fields above.
548 *
549 * @since 4.9.0
550 *
551 * @param array $pro Pro state.
552 * @param bool $multiple_kb Whether Multiple Knowledge Base is on.
553 * @param array $counts Content counts.
554 * @param array $capabilities Capability report.
555 * @param array $tools Tool catalog.
556 * @param array $mcp The health report's `mcp` section.
557 * @param bool $glossaries Whether the glossaries taxonomy is registered.
558 * @return string[]
559 */
560 protected function notes( array $pro, $multiple_kb, array $counts, array $capabilities, array $tools, array $mcp, $glossaries = true ) {
561 $notes = [];
562
563 if ( ! $glossaries ) {
564 $notes[] = __( 'Glossaries are off: the glossaries taxonomy is not registered, so the term tools and the doc tools refuse a glossary until bd-update-settings {enable_glossaries:true}. It takes effect from the next request.', 'betterdocs' );
565 }
566
567 if ( empty( $pro['active'] ) ) {
568 $notes[] = empty( $pro['installed'] )
569 ? __( 'BetterDocs Pro is not installed: the knowledge base and site-wide analytics tools are listed but will refuse.', 'betterdocs' )
570 : __( 'BetterDocs Pro is installed but not active: the knowledge base and site-wide analytics tools are listed but will refuse until an administrator activates it.', 'betterdocs' );
571 } elseif ( ! $multiple_kb ) {
572 $notes[] = __( 'Multiple Knowledge Base is off: knowledge base tools will refuse until bd-update-settings {multiple_kb:true}. It takes effect from the next request.', 'betterdocs' );
573 }
574
575 if ( ! empty( $capabilities['missing'] ) ) {
576 $notes[] = sprintf(
577 /* translators: %s: comma-separated list of capability names. */
578 __( 'You do not hold: %s. Tools gated on those will refuse with capability_missing.', 'betterdocs' ),
579 implode( ', ', $capabilities['missing'] )
580 );
581
582 if ( null === $capabilities['governed_by'] ) {
583 $notes[] = __( "An administrator can grant them to this user's role with do_action( 'betterdocs_grant_caps', 'the-role' ).", 'betterdocs' );
584 } else {
585 $notes[] = sprintf(
586 /* translators: 1: comma-separated setting names, 2: comma-separated role names. */
587 __( "On a Pro site those come from %1\$s: an administrator can add this user's role (%2\$s) to them with bd-update-settings — see capabilities.governed_by for what each holds today.", 'betterdocs' ),
588 implode( ', ', $this->settings_governing( $capabilities['missing'] ) ),
589 '' !== implode( ', ', $capabilities['user_roles'] ) ? implode( ', ', $capabilities['user_roles'] ) : __( 'none', 'betterdocs' )
590 );
591 }
592 }
593
594 if ( empty( $mcp['enabled'] ) ) {
595 $notes[] = __( 'The MCP server is switched off (BetterDocs → MCP): the abilities are registered but the MCP endpoint refuses every request.', 'betterdocs' );
596 }
597
598 if ( MCPTools::is_read_only() ) {
599 $notes[] = __( 'This credential is read-only: write tools are not listed and calling one is refused with read_only_credential.', 'betterdocs' );
600 }
601
602 if ( null === $counts['knowledge_bases'] ) {
603 $notes[] = __( 'The knowledge_base taxonomy is not registered on this site, so knowledge base counts and tools are unavailable.', 'betterdocs' );
604 }
605
606 if ( empty( $tools['names'] ) ) {
607 $notes[] = __( 'No BetterDocs tools are registered: the Abilities API may be owned by another plugin. See the abilities section.', 'betterdocs' );
608 }
609
610 return $notes;
611 }
612 }
613