PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / trunk
ZIP AI – AI Website Builder & AI Agent (Beta) vtrunk
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / services / brain-client.php

brain-client.php in ZIP AI – AI Website Builder & AI Agent (Beta) trunk, at inc/services/brain-client.php

146 lines 5.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Brain Client. This is the ONE server-to-server seam from this plugin to the
4 * ZIP AI server. The abilities that an external AI client reaches over MCP use
5 * it. On those calls there is no browser to carry the Sanctum token.
6 *
7 * A remote POST has four failure classes. This class collapses them into one
8 * result shape. So callers branch on `code` instead of re-deriving them:
9 * 1. transport — DNS / TLS / timeout (`WP_Error` from wp_remote_post)
10 * 2. JSON error — non-2xx with the server's `{error_code, message}` body
11 * 3. non-JSON error — non-2xx with a host error page (Cloudflare, nginx 502)
12 * 4. broken 2xx — 200 with a body that isn't JSON
13 *
14 * Some callers are deliberately NOT migrated here. These are Helper's token
15 * exchange and wp-credentials bind, and Site_Scanner's scan push. They keep
16 * their own `wp_remote_post` blocks. New callers use this class.
17 *
18 * @since 0.0.8
19 * @package zip-ai
20 */
21
22 namespace ZipAI\MCP\Classes\Services;
23
24 use ZipAI\MCP\Classes\Core\Helper;
25
26 defined( 'ABSPATH' ) || exit;
27
28 /**
29 * Server-to-server POST client.
30 */
31 class Brain_Client {
32
33 /**
34 * Default request timeout. The server's import path converts and then commits
35 * page-by-page over MCP, which legitimately runs tens of seconds on a page
36 * with many images to sideload.
37 */
38 const DEFAULT_TIMEOUT = 120;
39
40 /**
41 * POST a JSON body to a server path with the site's Sanctum token.
42 *
43 * @param string $path Server path, leading slash (e.g. '/import').
44 * @param array<string,mixed> $body JSON body.
45 * @param int $timeout Seconds.
46 * @return array{ok: bool, status: int, code: string, message: string, data: array<string,mixed>}
47 */
48 public static function post( string $path, array $body, int $timeout = self::DEFAULT_TIMEOUT ) {
49 $token = Helper::get_decrypted_auth_token();
50 if ( '' === $token ) {
51 return self::result( false, 0, 'not_connected', 'This site is not connected to a ZIP AI account.' );
52 }
53
54 $encoded = wp_json_encode( $body );
55 if ( false === $encoded ) {
56 return self::result( false, 0, 'encode_failed', 'Request body could not be encoded.' );
57 }
58
59 $response = wp_remote_post(
60 untrailingslashit( ZIPAI_BRAIN_URL ) . $path,
61 array(
62 'timeout' => $timeout,
63 'sslverify' => Helper::should_verify_ssl(),
64 'headers' => array(
65 'Content-Type' => 'application/json',
66 'Authorization' => 'Bearer ' . $token,
67 ),
68 'body' => $encoded,
69 )
70 );
71
72 // 1. Transport — the request never reached the server, so nothing ran.
73 if ( is_wp_error( $response ) ) {
74 return self::result(
75 false,
76 0,
77 'brain_unreachable',
78 'Could not reach the ZIP AI service: ' . $response->get_error_message()
79 );
80 }
81
82 $status = (int) wp_remote_retrieve_response_code( $response );
83 $raw = wp_remote_retrieve_body( $response );
84 $parsed = json_decode( $raw, true );
85 // JSON object keys are strings by definition, but `json_decode` is typed
86 // loosely — normalise once here so callers read a known-shaped array
87 // instead of narrowing the key type at every access.
88 $data = array();
89 if ( is_array( $parsed ) ) {
90 foreach ( $parsed as $key => $value ) {
91 $data[ (string) $key ] = $value;
92 }
93 }
94
95 if ( $status >= 200 && $status < 300 ) {
96 // 4. A 2xx we cannot read is a failure, not a success — returning
97 // ok:true here would have the caller report an import that may not
98 // have happened.
99 if ( ! is_array( $parsed ) ) {
100 return self::result( false, $status, 'bad_response', 'The ZIP AI service returned an unreadable response.' );
101 }
102 return self::result( true, $status, '', '', $data );
103 }
104
105 // 2. Typed server error. `message` and `error` carry the same text (the
106 // server aliases them); prefer `message`.
107 $code = isset( $data['error_code'] ) && is_string( $data['error_code'] ) ? $data['error_code'] : '';
108 foreach ( array( 'message', 'error' ) as $key ) {
109 if ( isset( $data[ $key ] ) && is_string( $data[ $key ] ) && '' !== $data[ $key ] ) {
110 return self::result( false, $status, '' !== $code ? $code : 'http_' . $status, $data[ $key ], $data );
111 }
112 }
113
114 // 3. Non-JSON error body — a host error page, not the server speaking.
115 // The body is never surfaced: it is HTML, often multi-KB, and would be
116 // pasted straight into an AI client's context.
117 return self::result(
118 false,
119 $status,
120 '' !== $code ? $code : 'http_' . $status,
121 sprintf( 'The ZIP AI service returned HTTP %d.', $status ),
122 $data
123 );
124 }
125
126 /**
127 * Build the uniform result array.
128 *
129 * @param bool $ok Whether the call succeeded.
130 * @param int $status HTTP status (0 when no response).
131 * @param string $code Stable error code ('' on success).
132 * @param string $message Human/agent-readable text ('' on success).
133 * @param array<string,mixed> $data Decoded body.
134 * @return array{ok: bool, status: int, code: string, message: string, data: array<string,mixed>}
135 */
136 private static function result( bool $ok, int $status, string $code, string $message, array $data = array() ) {
137 return array(
138 'ok' => $ok,
139 'status' => $status,
140 'code' => $code,
141 'message' => $message,
142 'data' => $data,
143 );
144 }
145 }
146