Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.80% covered (warning)
89.80%
132 / 147
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
LogoGen
89.80% covered (warning)
89.80%
132 / 147
50.00% covered (danger)
50.00%
3 / 6
25.66
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_abilities
100.00% covered (success)
100.00%
66 / 66
100.00% covered (success)
100.00%
1 / 1
1
 regenerate
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
13
 set_from_image
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 sideload_and_set_logo
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
3.00
 is_allowed_url
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3declare( strict_types=1 );
4
5namespace BLU\Abilities;
6
7/**
8 * Logo generation ability using the AI platform service.
9 */
10class 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}