Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
89.80% |
132 / 147 |
|
50.00% |
3 / 6 |
CRAP | |
0.00% |
0 / 1 |
| LogoGen | |
89.80% |
132 / 147 |
|
50.00% |
3 / 6 |
25.66 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| register_abilities | |
100.00% |
66 / 66 |
|
100.00% |
1 / 1 |
1 | |||
| regenerate | |
100.00% |
49 / 49 |
|
100.00% |
1 / 1 |
13 | |||
| set_from_image | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
| sideload_and_set_logo | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
3.00 | |||
| is_allowed_url | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| 1 | <?php |
| 2 | |
| 3 | declare( strict_types=1 ); |
| 4 | |
| 5 | namespace BLU\Abilities; |
| 6 | |
| 7 | /** |
| 8 | * Logo generation ability using the AI platform service. |
| 9 | */ |
| 10 | class LogoGen { |
| 11 | |
| 12 | /** |
| 13 | * Constructor - registers logo generation ability. |
| 14 | */ |
| 15 | public function __construct() { |
| 16 | $this->register_abilities(); |
| 17 | } |
| 18 | |
| 19 | /** |
| 20 | * Register logo generation abilities. |
| 21 | */ |
| 22 | private function register_abilities(): void { |
| 23 | blu_register_ability( |
| 24 | 'blu/set-logo-from-image', |
| 25 | array( |
| 26 | 'label' => 'Set Logo From Image', |
| 27 | 'description' => 'Set an existing image URL as the site logo. Use this after optionally calling blu/edit-image to clean the image (e.g. remove background with background:transparent). Sideloads the image into the media library and sets it as the active site logo.', |
| 28 | 'category' => 'blu-mcp', |
| 29 | 'input_schema' => array( |
| 30 | 'type' => 'object', |
| 31 | 'properties' => array( |
| 32 | 'source_url' => array( |
| 33 | 'type' => 'string', |
| 34 | 'description' => 'URL of the image to use as the site logo. Can be a temp upload URL or a CDN URL returned by blu/edit-image.', |
| 35 | ), |
| 36 | ), |
| 37 | 'required' => array( 'source_url' ), |
| 38 | ), |
| 39 | 'execute_callback' => array( $this, 'set_from_image' ), |
| 40 | 'permission_callback' => fn() => current_user_can( 'manage_options' ), |
| 41 | 'meta' => array( |
| 42 | 'annotations' => array( |
| 43 | 'readonly' => false, |
| 44 | 'destructive' => false, |
| 45 | 'idempotent' => false, |
| 46 | ), |
| 47 | ), |
| 48 | ) |
| 49 | ); |
| 50 | |
| 51 | blu_register_ability( |
| 52 | 'blu/regenerate-logo', |
| 53 | array( |
| 54 | 'label' => 'Regenerate Logo', |
| 55 | 'description' => 'Generate or replace the site logo using AI. Use this for ANY logo-related request: "regenerate my logo", "generate a new logo", "change the logo", "update my logo", "create a logo", or similar. IMPORTANT: there is no media library UI available — you cannot open a file picker or ask the user to upload an image. This ability is the ONLY way to change the site logo. Compose the prompt from the site/brand name and any style or color preferences the user mentions. Required parameter: prompt (describe the logo: brand name, style, colors). Optional: subject_name (brand or site name), style (auto|lettermark|wordmark|combination|emblem|pictorial). Generates a new logo image, saves it to the media library, and sets it as the active site logo.', |
| 56 | 'category' => 'blu-mcp', |
| 57 | 'input_schema' => array( |
| 58 | 'type' => 'object', |
| 59 | 'properties' => array( |
| 60 | 'prompt' => array( |
| 61 | 'type' => 'string', |
| 62 | 'description' => 'A detailed description of the logo to generate. Max 1000 characters.', |
| 63 | 'maxLength' => 1000, |
| 64 | ), |
| 65 | 'subject_name' => array( |
| 66 | 'type' => 'string', |
| 67 | 'description' => 'The name of the brand, company, or entity the logo is for. May come as an explicit brand name or a website title.', |
| 68 | 'maxLength' => 100, |
| 69 | ), |
| 70 | 'style' => array( |
| 71 | 'type' => 'string', |
| 72 | 'description' => 'Logo style', |
| 73 | 'enum' => array( 'auto', 'lettermark', 'wordmark', 'combination', 'emblem', 'pictorial' ), |
| 74 | 'default' => 'auto', |
| 75 | ), |
| 76 | ), |
| 77 | 'required' => array( 'prompt' ), |
| 78 | ), |
| 79 | 'execute_callback' => array( $this, 'regenerate' ), |
| 80 | 'permission_callback' => fn() => current_user_can( 'manage_options' ), |
| 81 | 'meta' => array( |
| 82 | 'annotations' => array( |
| 83 | 'readonly' => false, |
| 84 | 'destructive' => false, |
| 85 | 'idempotent' => false, |
| 86 | ), |
| 87 | ), |
| 88 | ) |
| 89 | ); |
| 90 | } |
| 91 | |
| 92 | /** |
| 93 | * Request a logo from the AI platform, sideload it, and set as site_logo. |
| 94 | * |
| 95 | * @param array $input Tool input parameters. |
| 96 | * @return array Standardized ability response. |
| 97 | */ |
| 98 | public function regenerate( array $input ): array { |
| 99 | // Extend PHP execution time for this long-running request. |
| 100 | set_time_limit( 120 ); |
| 101 | |
| 102 | $api_url = defined( 'NFD_AI_PLATFORM_URL' ) ? NFD_AI_PLATFORM_URL : 'https://ai-platform.hiive.cloud'; |
| 103 | |
| 104 | // Get Hiive auth token — required by the ai-platform middleware. |
| 105 | $hiive_token = ''; |
| 106 | if ( class_exists( '\NewfoldLabs\WP\Module\Data\HiiveConnection' ) ) { |
| 107 | $hiive_token = \NewfoldLabs\WP\Module\Data\HiiveConnection::get_auth_token(); |
| 108 | } |
| 109 | |
| 110 | if ( empty( $hiive_token ) ) { |
| 111 | return blu_prepare_ability_response( 401, __( 'Unable to retrieve Hiive authentication token for logo generation.', 'wp-module-mcp' ) ); |
| 112 | } |
| 113 | |
| 114 | $body = array( |
| 115 | 'prompt' => substr( $input['prompt'], 0, 1000 ), |
| 116 | ); |
| 117 | |
| 118 | if ( ! empty( $input['subject_name'] ) ) { |
| 119 | $body['subject_name'] = $input['subject_name']; |
| 120 | } |
| 121 | |
| 122 | $body['style'] = ! empty( $input['style'] ) ? $input['style'] : 'auto'; |
| 123 | |
| 124 | $response = wp_remote_post( |
| 125 | trailingslashit( $api_url ) . 'api/v1/imagegen/logo', |
| 126 | array( |
| 127 | 'headers' => array( |
| 128 | 'Content-Type' => 'application/json', |
| 129 | 'Authorization' => 'Bearer ' . $hiive_token, |
| 130 | ), |
| 131 | 'body' => wp_json_encode( $body ), |
| 132 | 'timeout' => 90, |
| 133 | ) |
| 134 | ); |
| 135 | |
| 136 | if ( is_wp_error( $response ) ) { |
| 137 | $message = $response->get_error_message(); |
| 138 | if ( false !== strpos( $message, 'timed out' ) || false !== strpos( $message, 'cURL error 28' ) ) { |
| 139 | return blu_prepare_ability_response( 504, __( 'Logo generation timed out.', 'wp-module-mcp' ) ); |
| 140 | } |
| 141 | return blu_prepare_ability_response( |
| 142 | 502, |
| 143 | /* translators: %s: error message */ |
| 144 | sprintf( __( 'Logo generation service unavailable: %s', 'wp-module-mcp' ), $message ) |
| 145 | ); |
| 146 | } |
| 147 | |
| 148 | $status_code = wp_remote_retrieve_response_code( $response ); |
| 149 | if ( $status_code < 200 || $status_code >= 300 ) { |
| 150 | return blu_prepare_ability_response( |
| 151 | $status_code, |
| 152 | /* translators: %d: HTTP status code */ |
| 153 | sprintf( __( 'Logo generation failed with status %d.', 'wp-module-mcp' ), $status_code ) |
| 154 | ); |
| 155 | } |
| 156 | |
| 157 | $data = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 158 | if ( empty( $data['url'] ) ) { |
| 159 | return blu_prepare_ability_response( 500, __( 'No image URL in response.', 'wp-module-mcp' ) ); |
| 160 | } |
| 161 | |
| 162 | $cdn_url = $data['url']; |
| 163 | |
| 164 | $desc = __( 'Site logo (AI generated)', 'wp-module-mcp' ); |
| 165 | if ( ! empty( $input['subject_name'] ) ) { |
| 166 | $desc = sprintf( |
| 167 | /* translators: %s: brand or site name for the logo attachment title/description */ |
| 168 | __( 'Site logo (AI generated) — %s', 'wp-module-mcp' ), |
| 169 | substr( (string) $input['subject_name'], 0, 100 ) |
| 170 | ); |
| 171 | } |
| 172 | |
| 173 | return $this->sideload_and_set_logo( $cdn_url, $desc ); |
| 174 | } |
| 175 | |
| 176 | /** |
| 177 | * Set an existing image URL as the site logo. |
| 178 | * |
| 179 | * @param array $input Tool input parameters. |
| 180 | * @return array Standardized ability response. |
| 181 | */ |
| 182 | public function set_from_image( array $input ): array { |
| 183 | set_time_limit( 120 ); |
| 184 | |
| 185 | $raw_url = (string) ( $input['source_url'] ?? '' ); |
| 186 | $url = esc_url_raw( $raw_url ); |
| 187 | |
| 188 | if ( empty( $url ) || ! filter_var( $raw_url, FILTER_VALIDATE_URL ) ) { |
| 189 | return blu_prepare_ability_response( 400, __( 'A valid source_url is required.', 'wp-module-mcp' ) ); |
| 190 | } |
| 191 | |
| 192 | if ( ! $this->is_allowed_url( $url ) ) { |
| 193 | return blu_prepare_ability_response( 400, __( 'source_url is not allowed.', 'wp-module-mcp' ) ); |
| 194 | } |
| 195 | |
| 196 | $desc = __( 'Site logo (uploaded)', 'wp-module-mcp' ); |
| 197 | |
| 198 | return $this->sideload_and_set_logo( $url, $desc ); |
| 199 | } |
| 200 | |
| 201 | /** |
| 202 | * Sideload an image URL into the media library and set it as the site logo. |
| 203 | * |
| 204 | * @param string $url Image URL to sideload. |
| 205 | * @param string $desc Attachment description/title. |
| 206 | * @return array Standardized ability response. |
| 207 | */ |
| 208 | private function sideload_and_set_logo( string $url, string $desc ): array { |
| 209 | require_once ABSPATH . 'wp-admin/includes/media.php'; |
| 210 | require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 211 | require_once ABSPATH . 'wp-admin/includes/image.php'; |
| 212 | |
| 213 | $attachment_id = media_sideload_image( $url, 0, $desc, 'id' ); |
| 214 | |
| 215 | if ( is_wp_error( $attachment_id ) ) { |
| 216 | return blu_prepare_ability_response( 500, $attachment_id->get_error_message() ); |
| 217 | } |
| 218 | |
| 219 | $attachment_id = (int) $attachment_id; |
| 220 | update_option( 'site_logo', $attachment_id ); |
| 221 | |
| 222 | $local_url = wp_get_attachment_url( $attachment_id ); |
| 223 | |
| 224 | return blu_prepare_ability_response( |
| 225 | 200, |
| 226 | array( |
| 227 | 'message' => __( 'Site logo updated.', 'wp-module-mcp' ), |
| 228 | 'attachment_id' => $attachment_id, |
| 229 | 'url' => $local_url ? $local_url : $url, |
| 230 | ) |
| 231 | ); |
| 232 | } |
| 233 | |
| 234 | /** |
| 235 | * SSRF guard — allow local site, hiive.cloud CDN, and unsplash only. |
| 236 | * |
| 237 | * @param string $url URL to check. |
| 238 | * @return bool |
| 239 | */ |
| 240 | private function is_allowed_url( string $url ): bool { |
| 241 | $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); |
| 242 | $site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); |
| 243 | |
| 244 | return $host === $site_host |
| 245 | || preg_match( '/(^|\.)hiive\.cloud$/', $host ) |
| 246 | || preg_match( '/(^|\.)unsplash\.com$/', $host ); |
| 247 | } |
| 248 | } |