PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / trunk
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More vtrunk
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 / API / AIAPI.php

AIAPI.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More trunk, at includes/API/AIAPI.php

370 lines 14.3 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\API;
4
5 use Better_Payment\Lite\AI\AIManager;
6 use Better_Payment\Lite\AI\ProviderRegistry;
7 use Better_Payment\Lite\AI\Operations\OperationRegistry;
8 use Better_Payment\Lite\AI\Services\BriefWriter;
9 use Better_Payment\Lite\AI\Services\CampaignAnalyzer;
10 use Better_Payment\Lite\AI\Services\CampaignEditor;
11 use Better_Payment\Lite\AI\Services\CampaignGenerator;
12 use Better_Payment\Lite\AI\Services\ConversationManager;
13 use Better_Payment\Lite\AI\Services\ImageGenerator;
14 use Better_Payment\Lite\AI\Services\UserFieldGuard;
15 use WP_REST_Controller;
16 use WP_REST_Request;
17 use WP_REST_Response;
18 use WP_REST_Server;
19
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit;
22 }
23
24 /**
25 * REST API routes for the AI Campaign Assistant.
26 *
27 * Namespace: better-payment/v1. Mirrors CampaignAPI: admin-only
28 * (`manage_options`) with X-WP-Nonce verification on writes. The builder already
29 * configures apiFetch with the wp_rest nonce, so no new client plumbing.
30 *
31 * @see \Better_Payment\Lite\AI\Services\AIService Turn orchestration.
32 */
33 class AIAPI extends WP_REST_Controller {
34
35 protected $namespace = 'better-payment/v1';
36
37 public function __construct() {
38 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
39 }
40
41 public function register_routes() {
42 register_rest_route( $this->namespace, '/ai/chat', [
43 [
44 'methods' => WP_REST_Server::CREATABLE,
45 'callback' => [ $this, 'chat' ],
46 'permission_callback' => [ $this, 'check_admin_permissions' ],
47 ],
48 ] );
49
50 register_rest_route( $this->namespace, '/ai/generate', [
51 [
52 'methods' => WP_REST_Server::CREATABLE,
53 'callback' => [ $this, 'generate' ],
54 'permission_callback' => [ $this, 'check_admin_permissions' ],
55 ],
56 ] );
57
58 register_rest_route( $this->namespace, '/ai/analyze', [
59 [
60 'methods' => WP_REST_Server::CREATABLE,
61 'callback' => [ $this, 'analyze' ],
62 'permission_callback' => [ $this, 'check_admin_permissions' ],
63 ],
64 ] );
65
66 register_rest_route( $this->namespace, '/ai/brief', [
67 [
68 'methods' => WP_REST_Server::CREATABLE,
69 'callback' => [ $this, 'refine_brief' ],
70 'permission_callback' => [ $this, 'check_admin_permissions' ],
71 ],
72 ] );
73
74 register_rest_route( $this->namespace, '/ai/image', [
75 [
76 'methods' => WP_REST_Server::CREATABLE,
77 'callback' => [ $this, 'image' ],
78 'permission_callback' => [ $this, 'check_admin_permissions' ],
79 ],
80 ] );
81
82 register_rest_route( $this->namespace, '/ai/conversations/(?P<id>[\d]+)', [
83 [
84 'methods' => WP_REST_Server::READABLE,
85 'callback' => [ $this, 'get_conversation' ],
86 'permission_callback' => [ $this, 'check_admin_permissions' ],
87 'args' => [ 'id' => [ 'type' => 'integer' ] ],
88 ],
89 [
90 'methods' => WP_REST_Server::CREATABLE,
91 'callback' => [ $this, 'save_conversation' ],
92 'permission_callback' => [ $this, 'check_admin_permissions' ],
93 'args' => [ 'id' => [ 'type' => 'integer' ] ],
94 ],
95 [
96 'methods' => WP_REST_Server::DELETABLE,
97 'callback' => [ $this, 'clear_conversation' ],
98 'permission_callback' => [ $this, 'check_admin_permissions' ],
99 'args' => [ 'id' => [ 'type' => 'integer' ] ],
100 ],
101 ] );
102
103 register_rest_route( $this->namespace, '/ai/config', [
104 [
105 'methods' => WP_REST_Server::READABLE,
106 'callback' => [ $this, 'get_config' ],
107 'permission_callback' => [ $this, 'check_admin_permissions' ],
108 ],
109 ] );
110 }
111
112 // ------------------------------------------------------------------ handlers
113
114 public function chat( WP_REST_Request $request ): WP_REST_Response {
115 $nonce = $this->nonce_guard( $request );
116 if ( null !== $nonce ) {
117 return $nonce;
118 }
119 $disabled = $this->enabled_guard();
120 if ( null !== $disabled ) {
121 return $disabled;
122 }
123
124 $campaign_id = (int) $request->get_param( 'campaign_id' );
125 $message = trim( (string) $request->get_param( 'message' ) );
126 if ( '' === $message ) {
127 return new WP_REST_Response( [ 'message' => __( 'Message is required.', 'better-payment' ) ], 400 );
128 }
129
130 $context = $this->read_context( $request );
131 $history = ConversationManager::sanitize( (array) $request->get_param( 'history' ) );
132
133 // Sent by the builder's per-element "AI quick edit" buttons. Only the id
134 // travels — CampaignEditor reads the type and current settings back out of
135 // the trusted context layout.
136 $target_element_id = sanitize_text_field( (string) $request->get_param( 'target_element_id' ) );
137
138 $result = CampaignEditor::edit( $message, $context, $history, $target_element_id );
139 if ( is_wp_error( $result ) ) {
140 return $this->error_response( $result );
141 }
142
143 // Persist the turn when we have a saved campaign to attach it to.
144 if ( $campaign_id > 0 ) {
145 ConversationManager::append( $campaign_id, $message, $result['assistant_message'] );
146 }
147
148 return new WP_REST_Response( $result, 200 );
149 }
150
151 public function generate( WP_REST_Request $request ): WP_REST_Response {
152 $nonce = $this->nonce_guard( $request );
153 if ( null !== $nonce ) {
154 return $nonce;
155 }
156 $disabled = $this->enabled_guard();
157 if ( null !== $disabled ) {
158 return $disabled;
159 }
160
161 $brief = trim( (string) $request->get_param( 'message' ) );
162 if ( '' === $brief ) {
163 return new WP_REST_Response( [ 'message' => __( 'A campaign brief is required.', 'better-payment' ) ], 400 );
164 }
165
166 // The category the user picked in the Smart Prompt Wizard, if any. Not
167 // validated against the registry here — CampaignGenerator falls back to
168 // inferring from the brief for anything it doesn't recognise, so an
169 // unknown slug degrades to the same path as no slug at all.
170 $category = sanitize_key( (string) $request->get_param( 'category' ) );
171
172 // The campaign fields the user filled in themselves. A key present but
173 // empty means they were asked and left it blank — the model may not fill
174 // that gap in for them. Absent entirely (free-form prompt) enforces
175 // nothing. See UserFieldGuard.
176 $fields = UserFieldGuard::sanitize( $request->get_param( 'fields' ) );
177
178 $result = CampaignGenerator::generate( $brief, $this->read_context( $request ), $category, $fields );
179 if ( is_wp_error( $result ) ) {
180 return $this->error_response( $result );
181 }
182 return new WP_REST_Response( $result, 200 );
183 }
184
185 public function analyze( WP_REST_Request $request ): WP_REST_Response {
186 $nonce = $this->nonce_guard( $request );
187 if ( null !== $nonce ) {
188 return $nonce;
189 }
190 $disabled = $this->enabled_guard();
191 if ( null !== $disabled ) {
192 return $disabled;
193 }
194
195 $result = CampaignAnalyzer::analyze( $this->read_context( $request ) );
196 if ( is_wp_error( $result ) ) {
197 return $this->error_response( $result );
198 }
199 return new WP_REST_Response( $result, 200 );
200 }
201
202 /**
203 * Refine the Smart Prompt Wizard's brief before generation (text in, text
204 * out). Applies nothing — the improved brief is returned for the user to
205 * review and edit, then hand to /ai/generate. Same guards as the other AI
206 * routes: valid nonce, feature enabled, manage_options.
207 */
208 public function refine_brief( WP_REST_Request $request ): WP_REST_Response {
209 $nonce = $this->nonce_guard( $request );
210 if ( null !== $nonce ) {
211 return $nonce;
212 }
213 $disabled = $this->enabled_guard();
214 if ( null !== $disabled ) {
215 return $disabled;
216 }
217
218 $brief = trim( (string) $request->get_param( 'brief' ) );
219 if ( '' === $brief ) {
220 return new WP_REST_Response( [ 'message' => __( 'A brief is required.', 'better-payment' ) ], 400 );
221 }
222
223 $instruction = trim( (string) $request->get_param( 'instruction' ) );
224
225 $result = BriefWriter::write( $brief, $instruction );
226 if ( is_wp_error( $result ) ) {
227 return $this->error_response( $result );
228 }
229 return new WP_REST_Response( $result, 200 );
230 }
231
232 public function image( WP_REST_Request $request ): WP_REST_Response {
233 $nonce = $this->nonce_guard( $request );
234 if ( null !== $nonce ) {
235 return $nonce;
236 }
237 $disabled = $this->enabled_guard();
238 if ( null !== $disabled ) {
239 return $disabled;
240 }
241
242 $prompt = trim( (string) $request->get_param( 'prompt' ) );
243 if ( '' === $prompt ) {
244 return new WP_REST_Response( [ 'message' => __( 'An image prompt is required.', 'better-payment' ) ], 400 );
245 }
246
247 $result = ImageGenerator::generate( $prompt, (int) $request->get_param( 'campaign_id' ) );
248 if ( is_wp_error( $result ) ) {
249 return $this->error_response( $result );
250 }
251 return new WP_REST_Response( $result, 200 );
252 }
253
254 public function get_conversation( WP_REST_Request $request ): WP_REST_Response {
255 $id = (int) $request->get_param( 'id' );
256 return new WP_REST_Response( [ 'messages' => ConversationManager::get( $id ) ], 200 );
257 }
258
259 /**
260 * Persist the full conversation for a campaign (the client is the source of
261 * truth for the message list). This is how the very first "create campaign"
262 * turn — which happens while the campaign is still unsaved (id 0), so it
263 * cannot be appended server-side during /ai/generate — gets written once the
264 * campaign has been saved and has an id. Replaces rather than appends, so a
265 * re-sync is idempotent. Nonce-guarded (write route) but NOT enabled-guarded:
266 * saving history the user already produced must keep working even if an admin
267 * later turns the AI Assistant off.
268 */
269 public function save_conversation( WP_REST_Request $request ): WP_REST_Response {
270 $nonce = $this->nonce_guard( $request );
271 if ( null !== $nonce ) {
272 return $nonce;
273 }
274 $id = (int) $request->get_param( 'id' );
275 $messages = ConversationManager::replace( $id, (array) $request->get_param( 'messages' ) );
276 return new WP_REST_Response( [ 'messages' => $messages ], 200 );
277 }
278
279 public function clear_conversation( WP_REST_Request $request ): WP_REST_Response {
280 $nonce = $this->nonce_guard( $request );
281 if ( null !== $nonce ) {
282 return $nonce;
283 }
284 ConversationManager::clear( (int) $request->get_param( 'id' ) );
285 return new WP_REST_Response( [ 'ok' => true ], 200 );
286 }
287
288 public function get_config( WP_REST_Request $request ): WP_REST_Response {
289 $provider = AIManager::active_provider();
290 return new WP_REST_Response( [
291 'enabled' => AIManager::is_enabled(),
292 'active' => AIManager::active_provider_id(),
293 'configured' => null !== $provider && $provider->is_configured(),
294 'providers' => ProviderRegistry::descriptors(),
295 'operations' => OperationRegistry::names(),
296 ], 200 );
297 }
298
299 // ------------------------------------------------------------------ helpers
300
301 /**
302 * Read the trusted { layout, meta } context from the request body.
303 *
304 * @return array{ layout: array, meta: array }
305 */
306 private function read_context( WP_REST_Request $request ): array {
307 $context = $request->get_param( 'context' );
308 $context = is_array( $context ) ? $context : [];
309 return [
310 'layout' => is_array( $context['layout'] ?? null ) ? $context['layout'] : [],
311 'meta' => is_array( $context['meta'] ?? null ) ? $context['meta'] : [],
312 ];
313 }
314
315 /**
316 * Nonce guard for write routes. Returns a 403 response when invalid, else null.
317 *
318 * @return WP_REST_Response|null
319 */
320 private function nonce_guard( WP_REST_Request $request ) {
321 if ( ! $this->valid_nonce( $request ) ) {
322 return new WP_REST_Response( [ 'message' => __( 'Invalid nonce', 'better-payment' ) ], 403 );
323 }
324 return null;
325 }
326
327 private function valid_nonce( WP_REST_Request $request ): bool {
328 $nonce = $request->get_header( 'x_wp_nonce' );
329 return (bool) wp_verify_nonce( $nonce, 'wp_rest' );
330 }
331
332 /**
333 * Guard for routes that call out to an AI provider. Returns a 403 response
334 * when the AI Assistant is disabled in settings, else null.
335 *
336 * @return WP_REST_Response|null
337 */
338 private function enabled_guard() {
339 if ( ! AIManager::is_enabled() ) {
340 return new WP_REST_Response( [
341 'code' => 'ai_disabled',
342 'message' => __( 'The AI Assistant is disabled. Enable it under Settings → AI Assistant.', 'better-payment' ),
343 ], 403 );
344 }
345 return null;
346 }
347
348 /**
349 * Convert a WP_Error to a REST response using its status.
350 */
351 private function error_response( $error ): WP_REST_Response {
352 $status = 500;
353 $data = $error->get_error_data();
354 if ( is_array( $data ) && isset( $data['status'] ) ) {
355 $status = (int) $data['status'];
356 }
357 return new WP_REST_Response( [
358 'code' => $error->get_error_code(),
359 'message' => $error->get_error_message(),
360 ], $status );
361 }
362
363 public function check_admin_permissions() {
364 if ( ! current_user_can( 'manage_options' ) ) {
365 return new \WP_Error( 'unauthorized', 'Unauthorized', [ 'status' => 401 ] );
366 }
367 return true;
368 }
369 }
370