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 / AbilityBase.php

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

611 lines 17.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Ability base class.
4 *
5 * @package BetterDocs
6 * @since 4.9.0
7 */
8
9 namespace WPDeveloper\BetterDocs\Abilities;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit; // Exit if accessed directly.
13 }
14
15 /**
16 * Base implementation every BetterDocs ability extends.
17 *
18 * An ability declares what it is (id, label, description), who may call it
19 * (one capability), what it takes and returns (JSON Schema), and what it does
20 * (`execute()`). The Abilities API registers it; the MCP server reads the same
21 * registry as its tool catalog, so one definition serves both.
22 *
23 * Most abilities reuse BetterDocs' own REST controllers through
24 * {@see self::dispatch()} rather than reimplementing doc/term/FAQ logic. That
25 * keeps validation, sanitisation and side effects in one place, and means REST
26 * fixes reach MCP for free.
27 *
28 * @since 4.9.0
29 */
30 abstract class AbilityBase {
31
32 /**
33 * Default REST namespace {@see self::dispatch()} targets.
34 *
35 * @since 4.9.0
36 */
37 protected const NS = 'betterdocs/v1';
38
39 // The six constants below belong to `Traits\ShapesTerms` and
40 // `Traits\ShapesFAQs` by subject, and live here because **PHP 7.4 traits
41 // cannot carry constants** — that is PHP 8.2 syntax and this plugin's floor is
42 // 7.4. Every `self::` use of them sits in a class that extends this one, so
43 // each reference still resolves, through inheritance instead of the trait.
44
45 /**
46 * The taxonomies the Terms abilities address. Knowledge bases are
47 * deliberately not among them: they are the Pro family's own four tools
48 * (ADR-003). `glossaries` is here rather than in a tool family of its own
49 * because it is an ordinary hierarchical taxonomy on `docs` carrying the
50 * same four capabilities as the other two (ADR-061); it exists only while
51 * `enable_glossaries` is on, which {@see Traits\ResolvesTerms::taxonomy_available()}
52 * answers for.
53 *
54 * @since 4.9.0
55 */
56 protected const TERM_TAXONOMIES = [ 'doc_category', 'doc_tag', 'glossaries' ];
57
58 /**
59 * The setting that registers the glossary taxonomy. A Free setting, so its
60 * absence is a `setting_disabled` refusal and never a Pro state (ADR-061).
61 *
62 * @since 4.9.0
63 */
64 protected const GLOSSARY_SETTING = 'enable_glossaries';
65
66 /**
67 * The glossary taxonomy's own description meta.
68 *
69 * BetterDocs drains the term's native `description` into this key and blanks
70 * the column (`Core\Glossaries::update_glossary_term()`), and both the admin
71 * screen and the A–Z front end read this first, so for `glossaries` the
72 * tools' `description` field is this meta (ADR-061).
73 *
74 * @since 4.9.0
75 */
76 protected const GLOSSARY_DESCRIPTION_META = 'glossary_term_description';
77
78 /**
79 * The doc-category term meta recording knowledge-base membership, registered
80 * for REST.
81 *
82 * @since 4.9.0
83 */
84 protected const KB_META_KEY = 'doc_category_knowledge_base';
85
86 /**
87 * The FAQ group taxonomy the FAQ abilities address. The WooCommerce Product
88 * FAQ groups (`betterdocs_product_faq_category`) share the post type but are a
89 * separate taxonomy and a separate feature; they are not exposed here.
90 *
91 * @since 4.9.0
92 */
93 protected const GROUP_TAXONOMY = 'betterdocs_faq_category';
94
95 /**
96 * The FAQ post type.
97 *
98 * @since 4.9.0
99 */
100 protected const FAQ_POST_TYPE = 'betterdocs_faq';
101
102 /**
103 * REST namespace of the FAQ Builder routes. `betterdocs`, with no version
104 * segment — `FAQBuilder::$namespace` has been that since 1.0.
105 *
106 * @since 4.9.0
107 */
108 protected const FAQ_NS = 'betterdocs';
109
110 /**
111 * Post statuses an FAQ may be written with.
112 *
113 * @since 4.9.0
114 */
115 protected const FAQ_STATUSES = [ 'publish', 'draft', 'pending', 'private' ];
116
117 /**
118 * Unique ability identifier, e.g. `betterdocs/create-doc`.
119 *
120 * @since 4.9.0
121 *
122 * @var string
123 */
124 protected $id = '';
125
126 /**
127 * Human-readable label. Becomes the MCP tool `title`.
128 *
129 * @since 4.9.0
130 *
131 * @var string
132 */
133 protected $label = '';
134
135 /**
136 * Static description. {@see self::describe()} may vary it per Pro state.
137 *
138 * @since 4.9.0
139 *
140 * @var string
141 */
142 protected $description = '';
143
144 /**
145 * Ability category slug.
146 *
147 * @since 4.9.0
148 *
149 * @var string
150 */
151 protected $category = AbilitiesRegistrar::CATEGORY;
152
153 /**
154 * Capability that gates this ability. **Subclasses must set one** — there is
155 * deliberately no default, so an ability cannot ship ungated by omission.
156 *
157 * There is deliberately no blanket `manage_options` gate.
158 * Each ability declares the capability that already governs its feature
159 * (`edit_docs`, `manage_doc_terms`, `edit_docs_settings`, …) so an editor or
160 * author reaches exactly what the WordPress admin already lets them reach.
161 *
162 * @since 4.9.0
163 *
164 * @var string
165 */
166 protected $capability = '';
167
168 /**
169 * Whether the feature behind this ability needs BetterDocs Pro.
170 *
171 * @since 4.9.0
172 *
173 * @var bool
174 */
175 protected $requires_pro = false;
176
177 /**
178 * JSON Schema for the ability's input.
179 *
180 * @since 4.9.0
181 *
182 * @return array
183 */
184 abstract public function get_input_schema();
185
186 /**
187 * JSON Schema for the ability's output.
188 *
189 * Note that the Abilities API validates the returned value against this, so
190 * a schema narrower than what {@see self::execute()} actually returns turns
191 * a successful call into a validation error.
192 *
193 * @since 4.9.0
194 *
195 * @return array
196 */
197 abstract public function get_output_schema();
198
199 /**
200 * Do the work.
201 *
202 * @since 4.9.0
203 *
204 * @param array $input Validated input.
205 * @return array|\WP_Error Plain data on success — never a `{success, data}` envelope.
206 */
207 abstract public function execute( $input );
208
209 /**
210 * Developer kill switch for the whole ability surface.
211 *
212 * @since 4.9.0
213 *
214 * @return bool
215 */
216 public static function abilities_enabled() {
217 /**
218 * Filters whether BetterDocs registers any abilities at all.
219 *
220 * @since 4.9.0
221 *
222 * @param bool $enabled Default true.
223 */
224 return (bool) apply_filters( 'betterdocs_abilities_api_enabled', true );
225 }
226
227 /**
228 * Whether this particular ability may be registered and executed.
229 *
230 * @since 4.9.0
231 *
232 * @return bool
233 */
234 public function is_enabled() {
235 /**
236 * Filters whether one ability is enabled.
237 *
238 * @since 4.9.0
239 *
240 * @param bool $enabled Whether the ability surface is on.
241 * @param string $id Ability id.
242 * @param AbilityBase $ability The ability instance.
243 */
244 return (bool) apply_filters( 'betterdocs_ability_enabled', self::abilities_enabled(), $this->id, $this );
245 }
246
247 /**
248 * Permission callback handed to the Abilities API.
249 *
250 * Returns a plain bool on purpose. The Abilities API discards a `WP_Error`
251 * from this callback and answers with its own generic
252 * `ability_invalid_permissions` — and calls `_doing_it_wrong()` on the way,
253 * so returning a typed error here would only add log noise. The typed
254 * `capability_missing` lives in {@see self::execute_wrapper()}, which is the
255 * path the MCP server takes.
256 *
257 * @since 4.9.0
258 *
259 * @return bool
260 */
261 public function permission_callback() {
262 if ( ! $this->is_enabled() ) {
263 return false;
264 }
265
266 return current_user_can( $this->capability );
267 }
268
269 /**
270 * Safety net against an ability that declares no gate at all.
271 *
272 * This only asserts that *some* capability was named; the capability itself is
273 * per-ability (see {@see self::$capability}).
274 *
275 * @since 4.9.0
276 *
277 * @return bool
278 */
279 public function meets_capability_policy() {
280 return is_string( $this->capability ) && '' !== $this->capability;
281 }
282
283 /**
284 * Description for the current Pro state.
285 *
286 * Defaults to the static description. Stubs and the Pro knowledge-base
287 * abilities override this to say what is actually true on *this* site — that
288 * Pro is missing, inactive, or that a setting is off — so the tool list an
289 * agent reads is never misleading.
290 *
291 * @since 4.9.0
292 *
293 * @param array $pro_state Result of `ProState::get()`.
294 * @return string
295 */
296 // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- the state is the whole point of the override; the default just ignores it.
297 public function describe( array $pro_state ) {
298 return $this->description;
299 }
300
301 /**
302 * Whether the feature behind this ability needs Pro.
303 *
304 * @since 4.9.0
305 *
306 * @return bool
307 */
308 public function requires_pro() {
309 return (bool) $this->requires_pro;
310 }
311
312 /**
313 * MCP-compatible annotations. Subclasses override.
314 *
315 * Key names are the Abilities API's; `MCPTools` maps them onto the
316 * MCP spelling (`readOnlyHint`, `destructiveHint`, `idempotentHint`) when it
317 * builds `tools/list`.
318 *
319 * @since 4.9.0
320 *
321 * @return array
322 */
323 public function get_annotations() {
324 return [
325 'readonly' => false,
326 'destructive' => false,
327 'idempotent' => false,
328 'priority' => 2.0,
329 'openWorldHint' => false
330 ];
331 }
332
333 /**
334 * Run the ability with the surrounding contract: capability gate, action
335 * hooks, and a `\Throwable` net.
336 *
337 * Registered as the ability's `execute_callback`, so on the Abilities API
338 * path the capability check here is a second opinion the runtime has already
339 * formed. It matters on the MCP path, which calls this directly and needs
340 * the typed `capability_missing` rather than a bare false.
341 *
342 * `\Throwable`, not `\Exception`: a `TypeError` from a controller must come
343 * back as a typed `upstream_error` naming the real reason, never as a fatal
344 * that takes the JSON-RPC response with it.
345 *
346 * @since 4.9.0
347 *
348 * @param array $input Validated input.
349 * @return array|\WP_Error
350 */
351 public function execute_wrapper( $input ) {
352 if ( ! $this->is_enabled() || ! current_user_can( $this->capability ) ) {
353 return AbilityError::capability_missing( $this->capability, $this->permission_phrase() );
354 }
355
356 /**
357 * Fires before an ability executes.
358 *
359 * @since 4.9.0
360 *
361 * @param string $id Ability id.
362 * @param array $input Validated input.
363 */
364 do_action( 'betterdocs_before_ability_execute', $this->id, $input );
365
366 try {
367 $output = $this->execute( $input );
368 } catch ( \Throwable $e ) {
369 $output = AbilityError::upstream( $e->getMessage(), [ 'ability' => $this->id ] );
370 }
371
372 /**
373 * Fires after an ability executes, whatever the outcome.
374 *
375 * @since 4.9.0
376 *
377 * @param string $id Ability id.
378 * @param array $input Validated input.
379 * @param array|\WP_Error $output What the ability returned.
380 */
381 do_action( 'betterdocs_after_ability_execute', $this->id, $input, $output );
382
383 return $output;
384 }
385
386 /**
387 * Call one of BetterDocs' own REST routes in-process and return its data.
388 *
389 * `rest_do_request()` still runs the route's `permission_callback`, but skips
390 * the cookie-nonce check that only applies to real HTTP requests — correct
391 * here, because the MCP server has already set the current user to whoever
392 * granted the credential.
393 *
394 * Returns the **unwrapped** payload: an ability answers with plain data, not a
395 * `{success, data}` envelope.
396 *
397 * @since 4.9.0
398 *
399 * @param string $method HTTP verb.
400 * @param string $route Route beneath the namespace, e.g. `/docs`.
401 * @param array $params Query params for GET, body params otherwise.
402 * @param string $rest_namespace REST namespace. Defaults to `betterdocs/v1`; the FAQ
403 * routes live under the bare `betterdocs` namespace.
404 * @return mixed|\WP_Error Response data, or the error the route produced.
405 */
406 protected function dispatch( $method, $route, array $params = [], $rest_namespace = self::NS ) {
407 $method = strtoupper( $method );
408 $request = new \WP_REST_Request( $method, '/' . trim( $rest_namespace, '/' ) . $route );
409
410 $request->set_header( 'Content-Type', 'application/json' );
411
412 if ( 'GET' === $method ) {
413 $request->set_query_params( $params );
414 } else {
415 $request->set_body( wp_json_encode( $params ) );
416 $request->set_body_params( $params );
417 }
418
419 $response = rest_do_request( $request );
420
421 if ( $response->is_error() ) {
422 return $response->as_error();
423 }
424
425 return $response->get_data();
426 }
427
428 /**
429 * The same call as {@see self::dispatch()}, but handing back the whole
430 * response instead of its data.
431 *
432 * Listing tools need it: `X-WP-Total` and `X-WP-TotalPages` live on the
433 * response, and a page that reports the size of the page it returned rather
434 * than the number of matches makes a client stop paging early.
435 *
436 * @since 4.9.0
437 *
438 * @param string $method HTTP verb.
439 * @param string $route Route beneath the namespace, e.g. `/docs`.
440 * @param array $params Query params for GET, body params otherwise.
441 * @param string $rest_namespace REST namespace.
442 * @return \WP_REST_Response
443 */
444 protected function dispatch_response( $method, $route, array $params = [], $rest_namespace = self::NS ) {
445 $method = strtoupper( $method );
446 $request = new \WP_REST_Request( $method, '/' . trim( $rest_namespace, '/' ) . $route );
447
448 $request->set_header( 'Content-Type', 'application/json' );
449
450 if ( 'GET' === $method ) {
451 $request->set_query_params( $params );
452 } else {
453 $request->set_body( wp_json_encode( $params ) );
454 $request->set_body_params( $params );
455 }
456
457 return rest_do_request( $request );
458 }
459
460 /**
461 * Whether a controller refused a page because it is past the last one.
462 *
463 * `WP_REST_Posts_Controller::get_items()` answers `rest_post_invalid_page_number`
464 * the moment `page` exceeds the available pages and there is at least one
465 * match; the terms controller does not (it pages by offset and returns an
466 * empty page), so only the post-backed list tools ever meet this. It is not
467 * a "not found": the collection exists, the page is merely empty. The terms
468 * code is recognised too, for any controller that later adopts the same rule.
469 *
470 * @since 4.9.0
471 *
472 * @param \WP_Error $error The controller error.
473 * @return bool
474 */
475 protected function is_page_out_of_range( \WP_Error $error ) {
476 return in_array(
477 $error->get_error_code(),
478 [ 'rest_post_invalid_page_number', 'rest_term_invalid_page_number' ],
479 true
480 );
481 }
482
483 /**
484 * An empty page carrying the listing's real totals.
485 *
486 * The out-of-range error carries no counts, so the same query is re-run at
487 * page 1 to read `X-WP-Total` / `X-WP-TotalPages` from the headers. The
488 * result keeps paging honest: no items, the real `total` and `total_pages`,
489 * and the `page` / `per_page` the caller asked for. A page past the end is a
490 * normal empty answer, not an error (ADR-059, finding C).
491 *
492 * @since 4.9.0
493 *
494 * @param string $route Route passed to dispatch_response (e.g. '/docs').
495 * @param array $params Query params of the failed request.
496 * @param int $page The requested (out-of-range) page.
497 * @param int $per_page Requested per_page.
498 * @param string $rest_namespace REST namespace.
499 * @return array
500 */
501 protected function empty_page( $route, array $params, $page, $per_page, $rest_namespace = self::NS ) {
502 $params['page'] = 1;
503 $probe = $this->dispatch_response( 'GET', $route, $params, $rest_namespace );
504 $headers = $probe->is_error() ? [] : $probe->get_headers();
505
506 return [
507 'items' => [],
508 'total' => isset( $headers['X-WP-Total'] ) ? (int) $headers['X-WP-Total'] : 0,
509 'total_pages' => isset( $headers['X-WP-TotalPages'] ) ? (int) $headers['X-WP-TotalPages'] : 1,
510 'page' => (int) $page,
511 'per_page' => (int) $per_page
512 ];
513 }
514
515 /**
516 * Register the ability with the WordPress Abilities API.
517 *
518 * The `function_exists()` guard is inside the callback, never at hook
519 * registration time: which copy of the API owns the global functions is
520 * decided by load order, so a copy that lands late must still find us hooked
521 * (see `AbilitiesRegistrar`).
522 *
523 * @since 4.9.0
524 *
525 * @return void
526 */
527 public function register() {
528 if ( ! function_exists( 'wp_register_ability' ) ) {
529 return;
530 }
531
532 wp_register_ability(
533 $this->id,
534 [
535 'label' => $this->label,
536 'description' => $this->description,
537 'category' => $this->category,
538 'input_schema' => $this->get_input_schema(),
539 'output_schema' => $this->get_output_schema(),
540 'permission_callback' => [ $this, 'permission_callback' ],
541 'execute_callback' => [ $this, 'execute_wrapper' ],
542 'meta' => [
543 'show_in_rest' => true,
544 'annotations' => $this->get_annotations(),
545 'mcp' => [
546 'public' => false
547 ],
548 'requires_pro' => $this->requires_pro()
549 ]
550 ]
551 );
552 }
553
554 /**
555 * Ability id.
556 *
557 * @since 4.9.0
558 *
559 * @return string
560 */
561 public function get_id() {
562 return $this->id;
563 }
564
565 /**
566 * Human-readable label.
567 *
568 * @since 4.9.0
569 *
570 * @return string
571 */
572 public function get_label() {
573 return $this->label;
574 }
575
576 /**
577 * Static description.
578 *
579 * @since 4.9.0
580 *
581 * @return string
582 */
583 public function get_description() {
584 return $this->description;
585 }
586
587 /**
588 * Capability this ability is gated on.
589 *
590 * @since 4.9.0
591 *
592 * @return string
593 */
594 public function get_capability() {
595 return $this->capability;
596 }
597
598 /**
599 * The phrase `capability_missing` uses to say what was being attempted.
600 * Defaults to the label, lowercased; subclasses may override for a better
601 * sentence.
602 *
603 * @since 4.9.0
604 *
605 * @return string
606 */
607 protected function permission_phrase() {
608 return '' !== $this->label ? lcfirst( $this->label ) : $this->id;
609 }
610 }
611