PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.4
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.4
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / agents / rest.php

rest.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.4, at includes/agents/rest.php

620 lines 18.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Agents: REST surface at /desktop-mode/v1/agents.
4 *
5 * One CRUD surface over the two layers (user row + definition meta) so
6 * the bundle never coordinates `/wp/v2/users` and raw meta from JS.
7 *
8 * Routes:
9 *
10 * GET /desktop-mode/v1/agents list
11 * POST /desktop-mode/v1/agents create
12 * GET /desktop-mode/v1/agents/(?P<id>\d+) get
13 * POST /desktop-mode/v1/agents/(?P<id>\d+) patch
14 * DELETE /desktop-mode/v1/agents/(?P<id>\d+) delete
15 * POST /desktop-mode/v1/agents/(?P<id>\d+)/invoke run (chat trigger)
16 * GET /desktop-mode/v1/agents/abilities abilities catalogue
17 * GET /desktop-mode/v1/agents/trigger-kinds trigger kinds catalogue
18 * GET /desktop-mode/v1/agents/hooks-catalogue hook autocomplete
19 * GET /desktop-mode/v1/agents/roles assignable roles (writers only)
20 *
21 * Permissions: reads and invokes default to `edit_posts` (the same
22 * audience as the WP Explorer window hosting the UI); writes require
23 * `edit_users` (agents are real users — managing them is user
24 * management). All three are filterable.
25 *
26 * @package OpenStation
27 */
28
29 defined( 'ABSPATH' ) || exit;
30
31 /**
32 * Register REST routes on rest_api_init.
33 *
34 * @return void
35 */
36 function openstation_agents_register_rest_routes() {
37 $namespace = 'desktop-mode/v1';
38
39 register_rest_route(
40 $namespace,
41 '/agents',
42 array(
43 array(
44 'methods' => WP_REST_Server::READABLE,
45 'permission_callback' => 'openstation_agents_rest_read_permission',
46 'callback' => 'openstation_agents_rest_list',
47 ),
48 array(
49 'methods' => WP_REST_Server::CREATABLE,
50 'permission_callback' => 'openstation_agents_rest_write_permission',
51 'callback' => 'openstation_agents_rest_create',
52 'args' => array(
53 'name' => array(
54 'type' => 'string',
55 'required' => true,
56 'sanitize_callback' => 'sanitize_text_field',
57 ),
58 'role' => array(
59 'type' => 'string',
60 'required' => true,
61 'sanitize_callback' => 'sanitize_key',
62 ),
63 'description' => array(
64 'type' => 'string',
65 'default' => '',
66 'sanitize_callback' => 'sanitize_text_field',
67 ),
68 'instructions' => array(
69 'type' => 'string',
70 'default' => '',
71 ),
72 'abilities' => array(
73 'type' => 'array',
74 'default' => array(),
75 'items' => array( 'type' => 'string' ),
76 ),
77 // Like `face`, deliberately schema-light. Each row
78 // is validated against the live trigger-kind
79 // catalogue by openstation_agent_sanitize_triggers(),
80 // which drops rows it does not recognise rather
81 // than rejecting the whole create.
82 'triggers' => array(
83 'type' => 'array',
84 'default' => array(),
85 ),
86 'vibes' => array(
87 'type' => 'string',
88 'default' => '',
89 ),
90 // `face` carries no schema beyond "object" and no
91 // sanitize_callback on purpose. The real validator is
92 // openstation_agent_sanitize_face_json(), which clamps
93 // every number; a partial JSON Schema here would only
94 // suggest the route had checked it.
95 'face' => array(
96 'type' => 'object',
97 'default' => null,
98 ),
99 'faceSeed' => array(
100 'type' => 'integer',
101 'default' => 0,
102 'sanitize_callback' => 'absint',
103 ),
104 ),
105 ),
106 )
107 );
108
109 register_rest_route(
110 $namespace,
111 '/agents/abilities',
112 array(
113 'methods' => WP_REST_Server::READABLE,
114 'permission_callback' => 'openstation_agents_rest_read_permission',
115 'callback' => 'openstation_agents_rest_abilities_catalogue',
116 )
117 );
118
119 register_rest_route(
120 $namespace,
121 '/agents/draft',
122 array(
123 'methods' => WP_REST_Server::CREATABLE,
124 'permission_callback' => 'openstation_agents_rest_write_permission',
125 'callback' => 'openstation_agents_rest_draft',
126 'args' => array(
127 'brief' => array(
128 'type' => 'string',
129 'required' => true,
130 'sanitize_callback' => 'sanitize_textarea_field',
131 'validate_callback' => 'openstation_agents_rest_validate_brief',
132 ),
133 ),
134 )
135 );
136
137 register_rest_route(
138 $namespace,
139 '/agents/trigger-kinds',
140 array(
141 'methods' => WP_REST_Server::READABLE,
142 'permission_callback' => 'openstation_agents_rest_read_permission',
143 'callback' => 'openstation_agents_rest_trigger_kinds',
144 )
145 );
146
147 register_rest_route(
148 $namespace,
149 '/agents/hooks-catalogue',
150 array(
151 'methods' => WP_REST_Server::READABLE,
152 'permission_callback' => 'openstation_agents_rest_read_permission',
153 'callback' => 'openstation_agents_rest_hooks_catalogue',
154 )
155 );
156
157 register_rest_route(
158 $namespace,
159 '/agents/roles',
160 array(
161 'methods' => WP_REST_Server::READABLE,
162 'permission_callback' => 'openstation_agents_rest_write_permission',
163 'callback' => 'openstation_agents_rest_roles',
164 )
165 );
166
167 register_rest_route(
168 $namespace,
169 '/agents/(?P<id>\d+)',
170 array(
171 array(
172 'methods' => WP_REST_Server::READABLE,
173 'permission_callback' => 'openstation_agents_rest_read_permission',
174 'callback' => 'openstation_agents_rest_get',
175 ),
176 array(
177 'methods' => WP_REST_Server::CREATABLE,
178 'permission_callback' => 'openstation_agents_rest_write_permission',
179 'callback' => 'openstation_agents_rest_patch',
180 ),
181 array(
182 'methods' => WP_REST_Server::DELETABLE,
183 'permission_callback' => 'openstation_agents_rest_write_permission',
184 'callback' => 'openstation_agents_rest_delete',
185 ),
186 )
187 );
188
189 register_rest_route(
190 $namespace,
191 '/agents/(?P<id>\d+)/invoke',
192 array(
193 'methods' => WP_REST_Server::CREATABLE,
194 'permission_callback' => 'openstation_agents_rest_invoke_permission',
195 'callback' => 'openstation_agents_rest_invoke',
196 'args' => array(
197 'message' => array(
198 'type' => 'string',
199 'required' => true,
200 'sanitize_callback' => 'sanitize_textarea_field',
201 ),
202 'source' => array(
203 'type' => 'string',
204 'default' => 'chat',
205 'enum' => array( 'chat', 'drag', 'send-to' ),
206 'sanitize_callback' => 'sanitize_key',
207 ),
208 // Prior conversation turns, oldest first. Without these
209 // every message is a contextless run — a follow-up like
210 // "yes, do it" would be resolved against nothing and the
211 // agent could act on the wrong entity entirely.
212 'history' => array(
213 'type' => 'array',
214 'default' => array(),
215 'items' => array(
216 'type' => 'object',
217 'properties' => array(
218 'role' => array(
219 'type' => 'string',
220 'enum' => array( 'user', 'agent' ),
221 ),
222 'text' => array( 'type' => 'string' ),
223 ),
224 ),
225 ),
226 ),
227 )
228 );
229 }
230 add_action( 'rest_api_init', 'openstation_agents_register_rest_routes' );
231
232 // ---------------------------------------------------------------------------
233 // Permissions
234 //
235 // The three capability gates themselves (`openstation_agents_user_can_read`
236 // / `_manage` / `_invoke`) live in bootstrap.php: the WP Explorer
237 // integration loads while the feature flag is off, and this file does
238 // not.
239 // ---------------------------------------------------------------------------
240
241 /**
242 * Read-route permission callback.
243 *
244 * @return bool|WP_Error
245 */
246 function openstation_agents_rest_read_permission() {
247 if ( ! is_user_logged_in() || ! openstation_agents_user_can_read() ) {
248 return new WP_Error(
249 'openstation_agents_forbidden',
250 __( 'You do not have permission to read OpenStation agents.', 'desktop-mode' ),
251 array( 'status' => rest_authorization_required_code() )
252 );
253 }
254 return true;
255 }
256
257 /**
258 * Write-route permission callback.
259 *
260 * @return bool|WP_Error
261 */
262 function openstation_agents_rest_write_permission() {
263 if ( ! is_user_logged_in() || ! openstation_agents_user_can_manage() ) {
264 return new WP_Error(
265 'openstation_agents_forbidden',
266 __( 'You do not have permission to manage OpenStation agents.', 'desktop-mode' ),
267 array( 'status' => rest_authorization_required_code() )
268 );
269 }
270 return true;
271 }
272
273 /**
274 * Invoke-route permission callback.
275 *
276 * @return bool|WP_Error
277 */
278 function openstation_agents_rest_invoke_permission() {
279 if ( ! is_user_logged_in() || ! openstation_agents_user_can_invoke() ) {
280 return new WP_Error(
281 'openstation_agents_forbidden',
282 __( 'You do not have permission to invoke OpenStation agents.', 'desktop-mode' ),
283 array( 'status' => rest_authorization_required_code() )
284 );
285 }
286 return true;
287 }
288
289 // ---------------------------------------------------------------------------
290 // Handlers
291 // ---------------------------------------------------------------------------
292
293 /**
294 * GET /agents — list every agent on the site.
295 *
296 * @return WP_REST_Response
297 */
298 function openstation_agents_rest_list() {
299 $out = array();
300 foreach ( openstation_agent_get_agents() as $user ) {
301 $shape = openstation_agents_rest_shape_user( $user );
302 if ( $shape ) {
303 $out[] = $shape;
304 }
305 }
306 $response = rest_ensure_response( $out );
307 // Standard collection headers — WP Explorer's root grid derives
308 // its folder counts from `X-WP-Total`.
309 $response->header( 'X-WP-Total', (string) count( $out ) );
310 $response->header( 'X-WP-TotalPages', '1' );
311 return $response;
312 }
313
314 /**
315 * GET /agents/:id — fetch a single agent.
316 *
317 * @param WP_REST_Request $request REST request.
318 * @return WP_REST_Response|WP_Error
319 */
320 function openstation_agents_rest_get( WP_REST_Request $request ) {
321 $user = get_userdata( (int) $request['id'] );
322 if ( ! $user || ! openstation_agent_is_agent( $user ) ) {
323 return new WP_Error(
324 'openstation_agents_not_found',
325 __( 'Agent not found.', 'desktop-mode' ),
326 array( 'status' => 404 )
327 );
328 }
329 return rest_ensure_response( openstation_agents_rest_shape_user( $user ) );
330 }
331
332 /**
333 * POST /agents — create.
334 *
335 * @param WP_REST_Request $request REST request.
336 * @return WP_REST_Response|WP_Error
337 */
338 function openstation_agents_rest_create( WP_REST_Request $request ) {
339 // Every field the route declares is forwarded. `vibes`, `face` and
340 // `faceSeed` are the character half of an agent, and a create that
341 // took the name and dropped the portrait is how an agent ends up
342 // wearing the fallback glyph seconds after someone picked a face
343 // for it. `openstation_agent_create()` sanitizes each one.
344 $user = openstation_agent_create(
345 array(
346 'name' => (string) $request['name'],
347 'role' => (string) $request['role'],
348 'description' => (string) $request['description'],
349 'instructions' => (string) $request['instructions'],
350 'abilities' => (array) $request['abilities'],
351 'triggers' => (array) $request['triggers'],
352 'vibes' => (string) $request['vibes'],
353 'face' => $request['face'],
354 'faceSeed' => (int) $request['faceSeed'],
355 )
356 );
357 if ( is_wp_error( $user ) ) {
358 $data = $user->get_error_data();
359 if ( ! is_array( $data ) || ! isset( $data['status'] ) ) {
360 $user->add_data( array( 'status' => 400 ) );
361 }
362 return $user;
363 }
364
365 $response = rest_ensure_response( openstation_agents_rest_shape_user( $user ) );
366 $response->set_status( 201 );
367 return $response;
368 }
369
370 /**
371 * POST /agents/:id — patch any subset of the definition fields.
372 *
373 * @param WP_REST_Request $request REST request.
374 * @return WP_REST_Response|WP_Error
375 */
376 function openstation_agents_rest_patch( WP_REST_Request $request ) {
377 $user = get_userdata( (int) $request['id'] );
378 if ( ! $user || ! openstation_agent_is_agent( $user ) ) {
379 return new WP_Error(
380 'openstation_agents_not_found',
381 __( 'Agent not found.', 'desktop-mode' ),
382 array( 'status' => 404 )
383 );
384 }
385
386 $body = $request->get_json_params();
387 if ( ! is_array( $body ) ) {
388 $body = $request->get_body_params();
389 }
390 if ( ! is_array( $body ) ) {
391 $body = array();
392 }
393
394 $fields = array();
395 $allowed = array(
396 'name',
397 'role',
398 'description',
399 'instructions',
400 'abilities',
401 'triggers',
402 'model',
403 'rateLimit',
404 'vibes',
405 'face',
406 'faceSeed',
407 );
408 foreach ( $allowed as $field ) {
409 if ( array_key_exists( $field, $body ) ) {
410 $fields[ $field ] = $body[ $field ];
411 }
412 }
413
414 $updated = openstation_agent_update( (int) $user->ID, $fields );
415 if ( is_wp_error( $updated ) ) {
416 $updated->add_data( array( 'status' => 400 ) );
417 return $updated;
418 }
419
420 return rest_ensure_response(
421 openstation_agents_rest_shape_user( get_userdata( (int) $user->ID ) )
422 );
423 }
424
425 /**
426 * DELETE /agents/:id.
427 *
428 * @param WP_REST_Request $request REST request.
429 * @return WP_REST_Response|WP_Error
430 */
431 function openstation_agents_rest_delete( WP_REST_Request $request ) {
432 $user_id = (int) $request['id'];
433 $user = get_userdata( $user_id );
434 if ( ! $user || ! openstation_agent_is_agent( $user ) ) {
435 return new WP_Error(
436 'openstation_agents_not_found',
437 __( 'Agent not found.', 'desktop-mode' ),
438 array( 'status' => 404 )
439 );
440 }
441
442 $result = openstation_agent_delete( $user_id );
443 if ( is_wp_error( $result ) ) {
444 $result->add_data( array( 'status' => 500 ) );
445 return $result;
446 }
447
448 return rest_ensure_response(
449 array(
450 'deleted' => true,
451 'id' => $user_id,
452 )
453 );
454 }
455
456 /**
457 * POST /agents/:id/invoke — run the agent with the supplied message.
458 *
459 * @param WP_REST_Request $request REST request.
460 * @return WP_REST_Response|WP_Error
461 */
462 function openstation_agents_rest_invoke( WP_REST_Request $request ) {
463 $user = get_userdata( (int) $request['id'] );
464 if ( ! $user || ! openstation_agent_is_agent( $user ) ) {
465 return new WP_Error(
466 'openstation_agents_not_found',
467 __( 'Agent not found.', 'desktop-mode' ),
468 array( 'status' => 404 )
469 );
470 }
471
472 $source = (string) $request['source'];
473
474 // Per-agent gate. The route's `permission_callback` cannot run this
475 // one: it has no access to the resolved agent, and the capability an
476 // agent requires is a property of that agent's trigger config.
477 if ( ! openstation_agent_user_can_invoke_agent( (int) $user->ID, $source ) ) {
478 return new WP_Error(
479 'openstation_agents_forbidden',
480 __( 'You do not have permission to invoke this agent.', 'desktop-mode' ),
481 array( 'status' => rest_authorization_required_code() )
482 );
483 }
484
485 $result = openstation_agent_invoke(
486 (int) $user->ID,
487 (string) $request['message'],
488 array(
489 'source' => $source,
490 'invoker' => get_current_user_id(),
491 'history' => (array) $request['history'],
492 )
493 );
494 if ( is_wp_error( $result ) ) {
495 $data = $result->get_error_data();
496 if ( ! is_array( $data ) || ! isset( $data['status'] ) ) {
497 $result->add_data( array( 'status' => 500 ) );
498 }
499 return $result;
500 }
501 return rest_ensure_response( $result );
502 }
503
504 /**
505 * GET /agents/abilities — the abilities catalogue for the picker.
506 *
507 * @return WP_REST_Response
508 */
509 function openstation_agents_rest_abilities_catalogue() {
510 return rest_ensure_response( openstation_agents_abilities_catalogue() );
511 }
512
513 /**
514 * `brief` must carry words and fit the drafting cap.
515 *
516 * @param mixed $value Raw param.
517 * @return bool
518 */
519 function openstation_agents_rest_validate_brief( $value ) {
520 return is_string( $value )
521 && '' !== trim( $value )
522 && mb_strlen( $value ) <= OPENSTATION_AGENT_DRAFT_BRIEF_MAX;
523 }
524
525 /**
526 * POST /agents/draft — draft a definition from a brief.
527 *
528 * Nothing is created: the wizard shows the draft for review and the
529 * create route is still the only way an agent comes to exist.
530 *
531 * @param WP_REST_Request $request Request.
532 * @return WP_REST_Response|WP_Error
533 */
534 function openstation_agents_rest_draft( WP_REST_Request $request ) {
535 $draft = openstation_agent_draft( (string) $request['brief'], get_current_user_id() );
536 if ( is_wp_error( $draft ) ) {
537 return $draft;
538 }
539 return rest_ensure_response( $draft );
540 }
541
542 /**
543 * GET /agents/trigger-kinds — the trigger-kinds catalogue.
544 *
545 * @return WP_REST_Response
546 */
547 function openstation_agents_rest_trigger_kinds() {
548 return rest_ensure_response( openstation_agent_trigger_kinds() );
549 }
550
551 /**
552 * GET /agents/hooks-catalogue — the curated WP hooks catalogue.
553 *
554 * @return WP_REST_Response
555 */
556 function openstation_agents_rest_hooks_catalogue() {
557 return rest_ensure_response( openstation_agent_hooks_catalogue() );
558 }
559
560 /**
561 * GET /agents/roles — roles the current user may assign to an agent.
562 *
563 * @return WP_REST_Response
564 */
565 function openstation_agents_rest_roles() {
566 $names = wp_roles()->get_names();
567 $out = array();
568 foreach ( openstation_agent_allowed_roles() as $slug ) {
569 $out[] = array(
570 'slug' => $slug,
571 'label' => isset( $names[ $slug ] ) ? translate_user_role( $names[ $slug ] ) : $slug,
572 );
573 }
574 return rest_ensure_response( $out );
575 }
576
577 /**
578 * Build the canonical REST shape for one agent.
579 *
580 * @param WP_User|null $user Agent user.
581 * @return array|null Null when the user is not an agent.
582 */
583 function openstation_agents_rest_shape_user( $user ) {
584 if ( ! $user instanceof WP_User || ! openstation_agent_is_agent( $user ) ) {
585 return null;
586 }
587
588 $slug = (string) $user->user_login;
589 if ( 0 === strpos( $slug, 'agent-' ) ) {
590 $slug = substr( $slug, strlen( 'agent-' ) );
591 }
592
593 $role = '';
594 if ( is_array( $user->roles ) && ! empty( $user->roles ) ) {
595 $role = (string) reset( $user->roles );
596 }
597
598 $avatar = get_avatar_url( $user->ID, array( 'size' => 96 ) );
599 if ( ! is_string( $avatar ) || '' === $avatar ) {
600 $avatar = openstation_agent_avatar_url( (int) $user->ID );
601 }
602
603 return array(
604 'id' => (int) $user->ID,
605 'slug' => $slug,
606 'name' => (string) $user->display_name,
607 'description' => openstation_agent_get_description( (int) $user->ID ),
608 'instructions' => openstation_agent_get_instructions( (int) $user->ID ),
609 'role' => $role,
610 'abilities' => openstation_agent_get_abilities( (int) $user->ID ),
611 'triggers' => openstation_agent_get_triggers( (int) $user->ID ),
612 'model' => openstation_agent_get_model( (int) $user->ID ),
613 'rateLimit' => openstation_agent_get_rate_limit( (int) $user->ID ),
614 'vibes' => openstation_agent_get_vibes( (int) $user->ID ),
615 'face' => openstation_agent_get_face( (int) $user->ID ),
616 'faceSeed' => openstation_agent_get_face_seed( (int) $user->ID ),
617 'avatarUrl' => $avatar,
618 );
619 }
620