PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.3.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.3.1
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 All 66 releases
better-payment / includes / AI / Schema / CampaignSchema.php

CampaignSchema.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.3.1, at includes/AI/Schema/CampaignSchema.php

556 lines 23.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Better_Payment\Lite\AI\Schema;
4
5 use Better_Payment\Lite\Campaign\Elements\ElementRegistry;
6
7 if ( ! defined( 'ABSPATH' ) ) {
8 exit;
9 }
10
11 /**
12 * Canonical, machine-readable description of a Better Payment campaign.
13 *
14 * This is the single source of truth the AI layer uses to (a) tell a model what
15 * it is allowed to produce and (b) validate what a model returns. It never
16 * hardcodes the element list — everything is derived live from
17 * {@see ElementRegistry::get_all()} so the AI schema can never drift from the
18 * builder's real capabilities.
19 *
20 * A campaign is structured JSON:
21 * {
22 * "layout": "1-column" | "2-column" | "3-column",
23 * "columns": [
24 * { "id": string, "label": string, "width": "NN%",
25 * "elements": [ { "id": string, "type": <element type>, "settings": { ... } } ] }
26 * ]
27 * }
28 * plus flat campaign meta (goal amount, colours, donation amounts, ...).
29 *
30 * @see \Better_Payment\Lite\AI\Operations\OperationValidator Uses these allowlists.
31 * @see \Better_Payment\Lite\AI\Prompt\PromptBuilder Serialises this for the model.
32 */
33 class CampaignSchema {
34
35 /**
36 * Layout presets the builder understands.
37 *
38 * @return array<int, string>
39 */
40 public static function layout_presets(): array {
41 return [ '1-column', '2-column', '3-column' ];
42 }
43
44 /**
45 * Every registered element type key (e.g. campaign_title, photo, ...).
46 *
47 * @return array<int, string>
48 */
49 public static function element_types(): array {
50 return array_keys( ElementRegistry::get_all() );
51 }
52
53 /**
54 * Whether a given element type is registered.
55 *
56 * Deliberately entitlement-BLIND. A campaign built while Pro was active may
57 * still hold a `donors_wall`; when the licence lapses that element must
58 * survive an AI edit turn untouched, exactly as
59 * {@see \Better_Payment\Lite\Campaign\MetaBox::enforce_pro_entitlement()}
60 * restores its settings rather than deleting it. Making this method
61 * entitlement-aware would make an "edit the title" turn — whose `set_layout`
62 * echoes the whole page back — silently delete the user's Pro elements.
63 *
64 * Entitlement decides what we *offer* ({@see self::offerable_element_types()}),
65 * never what we recognise.
66 */
67 public static function is_element_type( string $type ): bool {
68 return in_array( $type, self::element_types(), true );
69 }
70
71 /**
72 * Whether Pro is active on this install, read live per request.
73 *
74 * Never inferred from stored campaign data — same contract the builder and
75 * MetaBox follow.
76 */
77 public static function pro_enabled(): bool {
78 return (bool) apply_filters( 'better_payment/pro_enabled', false );
79 }
80
81 /**
82 * Whether an element type is flagged Pro in the registry.
83 *
84 * The `pro` flag is present whether or not Pro is installed — Lite owns all
85 * three schemas — so this asks "is this a Pro widget", not "is it locked".
86 */
87 public static function is_pro_element_type( string $type ): bool {
88 $schema = ElementRegistry::get( $type );
89
90 return null !== $schema && ! empty( $schema['pro'] );
91 }
92
93 /**
94 * The element types this install may be offered: everything registered, minus
95 * the Pro ones when Pro is inactive.
96 *
97 * A free install used to be handed `donors_wall`, `faq` and `video` in the
98 * prompt under a rule reading "Only use the element types listed above" — so
99 * the model reasonably used them, and they reached a page that has no
100 * renderer for them and drops them to nothing. This is the list that decides
101 * what the model is told exists.
102 *
103 * @return array<int, string>
104 */
105 public static function offerable_element_types(): array {
106 $types = self::element_types();
107
108 if ( self::pro_enabled() ) {
109 return $types;
110 }
111
112 return array_values( array_filter(
113 $types,
114 static function ( $type ) {
115 return ! self::is_pro_element_type( $type );
116 }
117 ) );
118 }
119
120 /**
121 * Whether a type may be offered on this install (fed to the model in the
122 * prompt schema).
123 *
124 * NOTE this is no longer the gate for *inserting* an element. A free user who
125 * explicitly asks the AI for a Pro widget gets that exact widget dropped onto
126 * the canvas as a locked preview — see
127 * {@see \Better_Payment\Lite\AI\Operations\OperationValidator::validate_insert_block()}
128 * and {@see self::locked_pro_types_in_operations()}. Offering (proactive) and
129 * inserting (on explicit request) are now different questions.
130 */
131 public static function is_offerable_element_type( string $type ): bool {
132 return in_array( $type, self::offerable_element_types(), true );
133 }
134
135 /**
136 * The Pro element types this batch of operations puts onto the canvas that
137 * the install is NOT entitled to — the widgets that will render as a locked
138 * preview and stay inactive on the live page.
139 *
140 * This exists so the assistant can tell the user plainly, whatever the model
141 * wrote in its own reply, that a widget they asked for is a Pro-only feature.
142 * Reused by {@see \Better_Payment\Lite\AI\Services\AIService::run()}.
143 *
144 * Detection is deliberately "newly introduced", not merely "present". A
145 * lapsed-Pro user's routine edit ("shorten the title") comes back as a
146 * `set_layout` that echoes the whole page — leftover Pro widgets and all — and
147 * announcing "FAQ is a Pro widget" on every such turn would be noise about
148 * something the user did not just do. A type therefore counts only when an
149 * `insert_block` creates it, or a `set_layout` adds one the current layout did
150 * not already contain. Returns `[]` when Pro is active (nothing is locked).
151 *
152 * @param array $operations Validated operations (canonical `op` + type keys).
153 * @param array $context Trusted current state: [ 'layout' => [...] ].
154 * @return array<int, string> Distinct Pro type keys, in first-seen order.
155 */
156 public static function locked_pro_types_in_operations( array $operations, array $context = [] ): array {
157 if ( self::pro_enabled() ) {
158 return [];
159 }
160
161 $existing = self::pro_types_in_columns( $context['layout']['columns'] ?? [] );
162 $found = [];
163
164 foreach ( $operations as $operation ) {
165 if ( ! is_array( $operation ) ) {
166 continue;
167 }
168 $name = $operation['op'] ?? ( $operation['operation'] ?? '' );
169
170 if ( 'insert_block' === $name ) {
171 $type = self::resolve_element_type( is_string( $operation['type'] ?? null ) ? $operation['type'] : '' );
172 if ( '' !== $type && self::is_pro_element_type( $type ) && ! in_array( $type, $found, true ) ) {
173 $found[] = $type;
174 }
175 continue;
176 }
177
178 if ( 'set_layout' === $name ) {
179 foreach ( self::pro_types_in_columns( $operation['columns'] ?? [] ) as $type ) {
180 if ( ! in_array( $type, $existing, true ) && ! in_array( $type, $found, true ) ) {
181 $found[] = $type;
182 }
183 }
184 }
185 }
186
187 return $found;
188 }
189
190 /**
191 * Distinct Pro element type keys present in a set of layout columns.
192 *
193 * @param mixed $columns
194 * @return array<int, string>
195 */
196 private static function pro_types_in_columns( $columns ): array {
197 $types = [];
198 if ( ! is_array( $columns ) ) {
199 return $types;
200 }
201 foreach ( $columns as $column ) {
202 if ( ! is_array( $column ) || empty( $column['elements'] ) || ! is_array( $column['elements'] ) ) {
203 continue;
204 }
205 foreach ( $column['elements'] as $element ) {
206 if ( ! is_array( $element ) ) {
207 continue;
208 }
209 $type = self::resolve_element_type( is_string( $element['type'] ?? null ) ? $element['type'] : '' );
210 if ( '' !== $type && self::is_pro_element_type( $type ) && ! in_array( $type, $types, true ) ) {
211 $types[] = $type;
212 }
213 }
214 }
215 return $types;
216 }
217
218 /**
219 * Pro element type keys registered on this install, regardless of entitlement.
220 *
221 * Used by the prompt builder to name the locked Pro widgets to a free install
222 * (they are absent from {@see self::offerable_element_types()}), so the model
223 * can honour an explicit request for one instead of substituting a free widget.
224 *
225 * @return array<int, string>
226 */
227 public static function pro_element_types(): array {
228 return array_values( array_filter(
229 self::element_types(),
230 [ self::class, 'is_pro_element_type' ]
231 ) );
232 }
233
234 /**
235 * Common alias → canonical element type. Models frequently emit generic names
236 * (heading, paragraph, image, button …) instead of the registry keys; mapping
237 * them recovers the element instead of silently dropping it.
238 *
239 * @return array<string, string>
240 */
241 private static function type_aliases(): array {
242 return apply_filters( 'better_payment/ai/element_type_aliases', [
243 'heading' => 'campaign_title', 'title' => 'campaign_title', 'header' => 'campaign_title', 'headline' => 'campaign_title', 'campaigntitle' => 'campaign_title',
244 'paragraph' => 'campaign_description', 'text' => 'campaign_description', 'story' => 'campaign_description', 'description' => 'campaign_description', 'content' => 'campaign_description', 'body' => 'campaign_description', 'richtext' => 'campaign_description', 'rich_text' => 'campaign_description',
245 'image' => 'photo', 'img' => 'photo', 'picture' => 'photo', 'hero_image' => 'photo', 'heroimage' => 'photo', 'gallery' => 'photo',
246 'button' => 'donation_form', 'cta' => 'donation_form', 'donate_button' => 'donation_form', 'donatebutton' => 'donation_form', 'donate' => 'donation_form', 'donatenow' => 'donation_form', 'donate_now' => 'donation_form',
247 'progress' => 'progress_bar', 'progressbar' => 'progress_bar',
248 'donation' => 'donate_amount', 'amounts' => 'donate_amount', 'donation_amounts' => 'donate_amount', 'suggested_amounts' => 'donate_amount', 'donation_amount' => 'donate_amount', 'tiers' => 'donate_amount',
249 'summary' => 'campaign_summary', 'stats' => 'campaign_summary', 'statistics' => 'campaign_summary', 'campaignsummary' => 'campaign_summary',
250 'share' => 'social_sharing', 'sharing' => 'social_sharing', 'social' => 'social_sharing', 'socialshare' => 'social_sharing', 'social_share' => 'social_sharing',
251 'links' => 'social_links', 'sociallinks' => 'social_links', 'social_link' => 'social_links',
252 'author' => 'organizer', 'creator' => 'organizer', 'organizer_card' => 'organizer', 'host' => 'organizer',
253 // Pro elements. Aliased unconditionally — resolution is not permission,
254 // and a free install simply never sees these types offered.
255 'donors' => 'donors_wall', 'donorswall' => 'donors_wall', 'donor_list' => 'donors_wall', 'donorlist' => 'donors_wall', 'supporters' => 'donors_wall', 'recent_donors' => 'donors_wall', 'contributors' => 'donors_wall',
256 'faqs' => 'faq', 'questions' => 'faq', 'accordion' => 'faq', 'q_and_a' => 'faq', 'qa' => 'faq', 'frequently_asked_questions' => 'faq',
257 'youtube' => 'video', 'vimeo' => 'video', 'embed' => 'video', 'video_embed' => 'video', 'media' => 'video',
258 ] );
259 }
260
261 /**
262 * Resolve a possibly-aliased element type to a registered type, or '' when it
263 * cannot be mapped.
264 */
265 public static function resolve_element_type( string $type ): string {
266 $t = strtolower( trim( $type ) );
267 if ( self::is_element_type( $t ) ) {
268 return $t;
269 }
270 $norm = preg_replace( '/[\s\-]+/', '_', $t );
271 if ( self::is_element_type( $norm ) ) {
272 return $norm;
273 }
274 $aliases = self::type_aliases();
275 if ( isset( $aliases[ $t ] ) ) {
276 return $aliases[ $t ];
277 }
278 if ( isset( $aliases[ $norm ] ) ) {
279 return $aliases[ $norm ];
280 }
281 return '';
282 }
283
284 /**
285 * Per-type alias → canonical setting key, so a mis-named content key
286 * (e.g. `text` on a title) still lands in the right place.
287 *
288 * @return array<string, array<string, string>>
289 */
290 private static function setting_key_aliases(): array {
291 return apply_filters( 'better_payment/ai/setting_key_aliases', [
292 'campaign_title' => [ 'text' => 'title', 'heading' => 'title', 'label' => 'title', 'headline' => 'title', 'name' => 'title' ],
293 'campaign_description' => [ 'text' => 'content', 'body' => 'content', 'paragraph' => 'content', 'description' => 'content', 'story' => 'content', 'heading' => 'headline', 'title' => 'headline' ],
294 'donation_form' => [ 'text' => 'button_label', 'label' => 'button_label', 'cta' => 'button_label', 'button_text' => 'button_label', 'title' => 'button_label' ],
295 'photo' => [ 'url' => 'src', 'image' => 'src', 'source' => 'src', 'href' => 'src' ],
296 'progress_bar' => [ 'title' => 'headline', 'text' => 'headline' ],
297 'campaign_summary' => [ 'title' => 'headline', 'text' => 'headline' ],
298 'donate_amount' => [ 'title' => 'headline', 'text' => 'headline' ],
299 'organizer' => [ 'text' => 'description', 'bio' => 'description', 'name' => 'role_title', 'title' => 'role_title' ],
300 'social_sharing' => [ 'title' => 'headline', 'text' => 'headline' ],
301 'social_links' => [ 'title' => 'headline', 'text' => 'headline' ],
302 'donors_wall' => [ 'title' => 'headline', 'text' => 'headline', 'heading' => 'headline', 'limit' => 'number_to_show', 'count' => 'number_to_show' ],
303 // `faq` names its heading `heading`, not `headline` — the one element
304 // that breaks the pattern, so the reverse alias matters here.
305 'faq' => [ 'title' => 'heading', 'text' => 'heading', 'headline' => 'heading', 'questions' => 'items', 'faqs' => 'items', 'list' => 'items' ],
306 'video' => [ 'src' => 'url', 'link' => 'url', 'video_url' => 'url', 'embed_url' => 'url' ],
307 ] );
308 }
309
310 /**
311 * Resolve a possibly-aliased setting key for a type. Returns the canonical key
312 * when it is (or maps to) an allowed key, otherwise the original (which the
313 * allowlist will then drop).
314 */
315 public static function resolve_setting_key( string $type, string $key ): string {
316 if ( self::is_allowed_settings_key( $type, $key ) ) {
317 return $key;
318 }
319 $map = self::setting_key_aliases();
320 $k = strtolower( trim( $key ) );
321 if ( isset( $map[ $type ][ $k ] ) ) {
322 return $map[ $type ][ $k ];
323 }
324 return $key;
325 }
326
327 /**
328 * Campaign-level meta keys the AI is allowed to write.
329 *
330 * Deliberately a subset of {@see \Better_Payment\Lite\Campaign\MetaBox} keys:
331 * layout (bpc_fields_layout), the form page id and the template key are
332 * managed structurally, not by free-form AI meta writes.
333 *
334 * @return array<int, string>
335 */
336 public static function writable_meta_keys(): array {
337 $keys = [
338 'title',
339 'bpc_goal_amount',
340 'bpc_end_date',
341 'bpc_status',
342 'bpc_allow_custom_amount',
343 'bpc_minimum_amount',
344 'bpc_color_primary',
345 'bpc_color_background',
346 'bpc_css_class',
347 'bpc_suggested_amounts',
348 ];
349
350 /**
351 * Filter the campaign meta keys the AI layer may write.
352 *
353 * @param array<int, string> $keys
354 */
355 return apply_filters( 'better_payment/ai/writable_meta_keys', $keys );
356 }
357
358 public static function is_writable_meta_key( string $key ): bool {
359 return in_array( $key, self::writable_meta_keys(), true );
360 }
361
362 /**
363 * The full set of setting keys a given element type accepts.
364 *
365 * Built from the element's defaultSettings keys plus every control key in
366 * its settingsSchema (recursing into collapsible `section` children). Synthetic
367 * container keys (sections, notes — always prefixed with `_`) are excluded.
368 *
369 * @return array<int, string>
370 */
371 public static function allowed_settings_keys( string $type ): array {
372 $schema = ElementRegistry::get( $type );
373 if ( null === $schema ) {
374 return [];
375 }
376
377 $keys = array_keys( (array) ( $schema['defaultSettings'] ?? [] ) );
378
379 $collect = function ( $controls, &$out ) use ( &$collect ) {
380 foreach ( (array) $controls as $control ) {
381 if ( ! is_array( $control ) ) {
382 continue;
383 }
384 $ctype = $control['type'] ?? '';
385 if ( in_array( $ctype, [ 'section', 'section_label', 'note' ], true ) ) {
386 if ( ! empty( $control['children'] ) ) {
387 $collect( $control['children'], $out );
388 }
389 continue;
390 }
391 if ( ! empty( $control['key'] ) && 0 !== strpos( (string) $control['key'], '_' ) ) {
392 $out[] = (string) $control['key'];
393 }
394 if ( ! empty( $control['children'] ) ) {
395 $collect( $control['children'], $out );
396 }
397 }
398 };
399
400 $collect( $schema['settingsSchema'] ?? [], $keys );
401
402 return array_values( array_unique( $keys ) );
403 }
404
405 public static function is_allowed_settings_key( string $type, string $key ): bool {
406 return in_array( $key, self::allowed_settings_keys( $type ), true );
407 }
408
409 /**
410 * Enumerated values for a select/align control, or null when the key is free-form.
411 *
412 * @return array<int, string>|null
413 */
414 public static function enum_values( string $type, string $key ) {
415 // `align` controls carry no options array but accept a fixed set.
416 $control = self::find_control( $type, $key );
417 if ( null === $control ) {
418 return null;
419 }
420 if ( 'align' === ( $control['type'] ?? '' ) ) {
421 return [ 'left', 'center', 'right' ];
422 }
423 if ( ! empty( $control['options'] ) && is_array( $control['options'] ) ) {
424 $values = [];
425 foreach ( $control['options'] as $option ) {
426 if ( is_array( $option ) && array_key_exists( 'value', $option ) ) {
427 $values[] = (string) $option['value'];
428 }
429 }
430 return $values;
431 }
432 return null;
433 }
434
435 /**
436 * Locate a single control definition (recursing sections) for an element key.
437 *
438 * @return array|null
439 */
440 public static function find_control( string $type, string $key ) {
441 $schema = ElementRegistry::get( $type );
442 if ( null === $schema ) {
443 return null;
444 }
445
446 $search = function ( $controls ) use ( &$search, $key ) {
447 foreach ( (array) $controls as $control ) {
448 if ( ! is_array( $control ) ) {
449 continue;
450 }
451 if ( ( $control['key'] ?? null ) === $key ) {
452 return $control;
453 }
454 if ( ! empty( $control['children'] ) ) {
455 $found = $search( $control['children'] );
456 if ( null !== $found ) {
457 return $found;
458 }
459 }
460 }
461 return null;
462 };
463
464 return $search( $schema['settingsSchema'] ?? [] );
465 }
466
467 /**
468 * A compact, prompt-friendly description of the whole builder capability set.
469 *
470 * Shape:
471 * [
472 * 'layout_presets' => [...],
473 * 'meta_keys' => [...],
474 * 'elements' => [
475 * [ 'type' => 'campaign_title', 'label' => '...', 'guide' => '...',
476 * 'settings' => [ 'title' => [ 'type' => 'text' ],
477 * 'align' => [ 'type' => 'align', 'enum' => ['left','center','right'] ] ] ],
478 * ...
479 * ],
480 * ]
481 *
482 * Only settable, non-container keys are described, keeping the prompt small.
483 *
484 * Two things shape this list beyond the raw registry:
485 * - Pro elements are omitted unless Pro is active. Describing a widget the
486 * install cannot render is not a harmless extra option: the rules tell the
487 * model these types are the ones it may use.
488 * - Each element carries a `guide` — what its content is actually *for*.
489 * A key list alone is not self-describing, and three elements sharing a
490 * key named `headline` otherwise get three copies of the same sentence.
491 *
492 * @return array<string, mixed>
493 */
494 public static function for_prompt(): array {
495 $elements = [];
496
497 foreach ( self::offerable_element_types() as $type ) {
498 $elements[] = self::describe_element( $type );
499 }
500
501 return [
502 'layout_presets' => self::layout_presets(),
503 'meta_keys' => self::writable_meta_keys(),
504 'elements' => $elements,
505 ];
506 }
507
508 /**
509 * Describe ONE element type: its label, its content guide, and every settable
510 * key with that key's control type and enum.
511 *
512 * Extracted from {@see self::for_prompt()} so the whole-page schema and the
513 * single-widget "target element" prompt section are built from the same code.
514 * Two builders would drift, and a drift here is not cosmetic — the targeted
515 * section exists precisely to tell the model which fields the widget in front
516 * of the user actually has, so a stale copy would describe a widget that is
517 * not the one being edited.
518 *
519 * Deliberately **entitlement-blind**, unlike {@see self::for_prompt()}. This
520 * describes an element that already exists on the page, and the caller has
521 * already decided it may be edited; filtering here would leave a lapsed
522 * subscriber's Pro widget selectable but undescribable, so the model would be
523 * asked to edit a widget it had been told nothing about. Entitlement decides
524 * what is *offered* (`offerable_element_types()`) and what may be newly
525 * *inserted* (`validate_insert_block()`) — never what is recognised.
526 *
527 * @param string $type Element type key.
528 * @return array{type: string, label: string, guide: string, settings: array<string, array>}
529 */
530 public static function describe_element( string $type ): array {
531 $schema = ElementRegistry::get( $type );
532 $schema = is_array( $schema ) ? $schema : [];
533
534 $settings = [];
535 foreach ( self::allowed_settings_keys( $type ) as $key ) {
536 $control = self::find_control( $type, $key );
537 $entry = [ 'type' => $control['type'] ?? 'text' ];
538 $enum = self::enum_values( $type, $key );
539 if ( null !== $enum && ! empty( $enum ) ) {
540 $entry['enum'] = $enum;
541 }
542 if ( ! empty( $control['label'] ) ) {
543 $entry['label'] = (string) $control['label'];
544 }
545 $settings[ $key ] = $entry;
546 }
547
548 return [
549 'type' => $type,
550 'label' => (string) ( $schema['label'] ?? $type ),
551 'guide' => ElementContentGuide::for_type( $type ),
552 'settings' => $settings,
553 ];
554 }
555 }
556