| Server IP : 108.167.142.43 / Your IP : 216.73.217.58 Web Server : Apache System : Linux gator4174.hostgator.com 5.14.0-687.39.1.el9_8.x86_64 #1 SMP PREEMPT_DYNAMIC Tue Aug 18 06:09:16 EDT 2026 x86_64 User : delarice ( 2406) PHP Version : 8.3.36 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : ON Directory : /home2/delarice/public_html/wp-content/plugins/templately/modules/ai-fsi/REST/ |
Upload File : |
<?php
/**
* Templately AI Content Importer
*
* @package Templately
* @since 1.0.0
*/
namespace Templately\Modules\AiFsi\REST;
use Error;
use Exception;
use Templately\API\API;
use Templately\Utils\Helper;
use Templately\Utils\Response\ErrorCode;
use WP_REST_Request;
use WP_Error;
use Templately\Modules\FullSiteImport\Utils\Utils;
use Templately\Modules\FullSiteImport\Utils\AIUtils;
use Templately\Modules\FullSiteImport\Utils\SessionData;
use Templately\Utils\Database;
use Templately\Modules\FullSiteImport\Utils\SignatureVerifier;
use Templately\Modules\FullSiteImport\Parsers\WXR_Parser;
class AIContent extends API {
private $endpoint = 'ai-content';
private $dev_mode = false;
/**
* Short-lived cache of the `v2/chatbot/generated/{chat}` bundle.
*
* That payload is large (every generated page's block JSON, served off GCP)
* and the direct-import handoff pulls it twice within seconds — once to read
* the customization, once to write the pages. Only a COMPLETE bundle is ever
* reused (an incomplete one has to be re-pulled to pick up new pages), and
* the TTL is deliberately short so the `can_import` / already-imported gate
* cannot go meaningfully stale.
*/
const GENERATED_CACHE_KEY = 'chatbot_generated_';
const GENERATED_CACHE_TTL = 60;
/**
* AIContent constructor.
*
* @param string $file File path.
* @param array $settings Settings.
*/
public function __construct() {
parent::__construct();
}
public function _permission_check(WP_REST_Request $request) {
$this->request = $request;
$this->api_key = $this->utils('options')->get( 'api_key' );
$process_id = $this->get_param('process_id');
$_route = $request->get_route();
if ('/templately/v1/ai-content/ai-update' === $_route || '/templately/v1/ai-content/ai-update-preview' === $_route) {
// Redact the API-key header before logging — a credential must NEVER
// reach the log, even under WP_DEBUG_LOG (Helper::log's guard). The body
// (AI request params, not a credential) stays inside that dev guard.
$safe_headers = $request->get_headers();
foreach ( [ 'x_templately_apikey', 'x-templately-apikey' ] as $redact_key ) {
if ( isset( $safe_headers[ $redact_key ] ) ) {
$safe_headers[ $redact_key ] = [ '[redacted]' ];
}
}
Helper::log( [
'headers' => $safe_headers,
'body' => $request->get_params(),
], 'ai_update_request' );
if (empty($process_id)) {
return $this->error('invalid_id', __('Invalid ID.', 'templately'), 'calculate_credit', 400);
}
$header_api_key = sanitize_text_field($request->get_header('x_templately_apikey'));
if (empty($header_api_key)) {
$header_api_key = sanitize_text_field($request->get_header('X-Templately-Apikey'));
}
// Validate API key from header against database
if (empty($header_api_key)) {
return $this->error('missing_api_key', __('Missing API key in header.', 'templately'), 'ai-content/permission', 403);
}
$is_valid_key = $this->validate_api_key_in_db($header_api_key);
if (!$is_valid_key) {
return $this->error('invalid_api_key', __('Invalid API key provided in header.', 'templately'), 'ai-content/permission', 403);
}
// Verify the callback HMAC signature (034 FR-001) — log-only by default;
// rejects only in enforce mode, once cloud-signed traffic is confirmed.
$verified = SignatureVerifier::verify_request($request, $header_api_key, 'ai-content/permission');
if (is_wp_error($verified)) {
return $verified;
}
// Check the AI process exists (single per-process row read; 026 seam).
if (AIUtils::read_process($process_id) !== null) {
return true;
}
return (bool) AIUtils::get_matched_session_data($process_id);
}
// // Allow access to attachments endpoint
// if ('/templately/v1/ai-content/attachments' === $_route) {
// return true;
// }
return parent::_permission_check($request);
}
public function register_routes() {
$this->post($this->endpoint . '/modify-content', [$this, 'modify_content']);
$this->post($this->endpoint . '/ai-update', [$this, 'ai_update']);
$this->post($this->endpoint . '/ai-update-preview', [$this, 'ai_update_preview']);
$this->post($this->endpoint . '/generate-tagline', [$this, 'generate_tagline']);
$this->post($this->endpoint . '/generate-brand-kit', [$this, 'generate_brand_kit']);
$this->get($this->endpoint . '/chatbot-conversation', [$this, 'get_chatbot_conversation'], [
'chat' => [
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function($param, $request, $key) {
return is_string($param) && strlen($param) > 0 && strlen($param) <= 128 && preg_match('/^[A-Za-z0-9\-_]+$/', $param);
},
],
]);
$this->get($this->endpoint . '/chatbot-conversations', [$this, 'get_chatbot_conversations'], [
'page' => [
'default' => 1,
'required' => false,
'sanitize_callback' => 'absint',
],
'per_page' => [
'default' => 10,
'required' => false,
'sanitize_callback' => 'absint',
],
]);
$this->get($this->endpoint . '/chatbot-generated', [$this, 'get_chatbot_generated'], [
'chat' => [
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function($param, $request, $key) {
return is_string($param) && strlen($param) > 0 && strlen($param) <= 128 && preg_match('/^[A-Za-z0-9\-_]+$/', $param);
},
],
]);
$this->post($this->endpoint . '/chatbot-detected-info', [$this, 'update_chatbot_detected_info'], [
'chat' => [
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function($param, $request, $key) {
return is_string($param) && strlen($param) > 0 && strlen($param) <= 128 && preg_match('/^[A-Za-z0-9\-_]+$/', $param);
},
],
]);
$this->post($this->endpoint . '/chatbot-import-prepare', [$this, 'chatbot_import_prepare']);
$this->post($this->endpoint . '/chatbot-mark-imported', [$this, 'mark_chatbot_imported'], [
'chat' => [
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function($param, $request, $key) {
return is_string($param) && strlen($param) > 0 && strlen($param) <= 128 && preg_match('/^[A-Za-z0-9\-_]+$/', $param);
},
],
]);
$this->get($this->endpoint . '/attachments', [$this, 'get_attachments'], [
'type' => [
'default' => 'pack',
'required' => false,
'sanitize_callback' => 'sanitize_text_field',
],
'id' => [
'required' => false,
'sanitize_callback' => 'sanitize_text_field',
],
'pack_id' => [
'required' => false,
'sanitize_callback' => 'sanitize_text_field',
],
]);
$this->post($this->endpoint . '/customization', [$this, 'save_customization'], [
'process_id' => [
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
],
// A free-form design blob (title, tagline, colours, typography, logo +
// composition). `null` because the default per-element `sanitize_text_field`
// would flatten the nested structure and corrupt the base64 logo; validated
// structurally in the handler instead.
'customization_data' => [
'required' => true,
'sanitize_callback' => null,
],
// Whether to also create/update the CLOUD conversation. False during the
// import, true at the finalizer — see the handler.
'sync_cloud' => [
'required' => false,
'default' => false,
'sanitize_callback' => null,
],
]);
// /images (search_images) moved to modules/image-replace/REST/ImageSearch.php (spec 023).
// die(rest_url( 'templately/v1/ai-content/ai-update' ));
}
/**
* Extract + sanitize + validate the modify-content request params (033 US4 / T026).
*
* The DTO half of modify_content's former 181-line body, pulled out verbatim so the
* endpoint reads as parse → call → build-row. Returns the request fields as an assoc
* array, or a WP_Error (the validation/sanitize early-returns) the caller propagates.
* Behaviour-identical: same params, same sanitize, same validation order.
*
* @return array|\WP_Error
*/
private function parse_modify_content_request() {
$session_id = $this->get_param('session_id');
// Security: Sanitize session_id if provided
if (!empty($session_id)) {
$session_id = AIUtils::sanitize_path_component($session_id, 'session_id');
if (is_wp_error($session_id)) {
return $session_id;
}
}
$req = [
'pack_id' => $this->get_param('pack_id'),
'isBusinessNichesNew' => $this->get_param('isBusinessNichesNew', false),
'ai_page_ids' => $this->get_param('ai_page_ids', [], null),
'content_ids' => $this->get_param('content_ids', [], null),
'session_id' => $session_id,
'preview_pages' => $this->get_param('preview_pages', [], null),
'image_replace' => $this->get_param('imageReplace', [], null),
'platform' => $this->get_param('platform'),
'language' => $this->get_param('language', null),
// ai content fields
'name' => $this->get_param('name'),
'category' => $this->get_param('category'),
'description' => $this->get_param('description'),
'email' => $this->get_param('email'),
'contactNumber' => $this->get_param('contactNumber'),
'businessAddress' => $this->get_param('businessAddress'),
'openingHour' => $this->get_param('openingHour'),
'requested_platform' => Helper::get_requested_platform(),
];
if (empty($req['pack_id'])) {
return $this->error('invalid_id', __('Invalid ID.', 'templately'), 'modify_content', 400);
}
if (empty($req['category'])) {
return $this->error('invalid_prompt', __('Invalid prompt.', 'templately'), 'modify_content', 400);
}
if (empty($req['content_ids']) && empty($req['preview_pages'])) {
return $this->error('invalid_content_ids', __('Invalid content ids.', 'templately'), 'modify_content', 400);
}
if (empty($req['platform'])) {
return $this->error('invalid_platform', __('Invalid platform.', 'templately'), 'modify_content', 400);
}
return $req;
}
public function modify_content() {
add_filter('wp_redirect', '__return_false', 999);
set_time_limit(3 * MINUTE_IN_SECONDS);
ini_set('max_execution_time', 3 * MINUTE_IN_SECONDS);
$req = $this->parse_modify_content_request();
if (is_wp_error($req)) {
return $req;
}
// Re-establish the locals the rest of this method uses (keyed list destructuring,
// PHP 7.1+) so the body below stays byte-identical to the pre-T026 implementation.
[
'pack_id' => $pack_id,
'isBusinessNichesNew' => $isBusinessNichesNew,
'ai_page_ids' => $ai_page_ids,
'content_ids' => $content_ids,
'session_id' => $session_id,
'preview_pages' => $preview_pages,
'image_replace' => $image_replace,
'platform' => $platform,
'language' => $language,
'name' => $name,
'category' => $category,
'description' => $description,
'email' => $email,
'contactNumber' => $contactNumber,
'businessAddress' => $businessAddress,
'openingHour' => $openingHour,
'requested_platform' => $requested_platform,
] = $req;
// $response = get_transient( '__templately_ai_process_id' );
// if(empty($response)) {
$extra_headers = [
'Accept' => 'application/json',
'x-templately-session-id' => $session_id,
'x-templately-requested-platform' => $requested_platform,
];
$body_data = [
'business_name' => $name,
'business_niches' => $category,
'prompt' => $description,
'email' => $email,
'phone' => $contactNumber,
'address' => $businessAddress,
'openingHour' => $openingHour,
'pack_id' => $pack_id,
'content_ids' => $content_ids,
'platform' => $platform,
'preview_pages' => $preview_pages,
'language' => $language,
'callback' => defined('TEMPLATELY_CALLBACK') ? TEMPLATELY_CALLBACK . '/wp-json/templately/v1/ai-content/ai-update' : rest_url('templately/v1/ai-content/ai-update'),
];
// The `templately_ai_modify_content_body_data` filter was removed 2026-07-30 along
// with the developer AI Settings tab that was its only consumer. It existed to stamp
// an `ai_model` parameter onto this request; the cloud no longer supports that
// parameter, so the seam had nothing legitimate left to add and keeping it invited
// re-introducing a field the API rejects.
// `unwrap => false`: the handler below reads `status`/`process_id` from the
// TOP level, and treats a body-level `status:'error'` as a result to pass
// through rather than a hard failure — so the envelope must stay intact.
// The REST client logs nothing (unlike `Http`, which logs URL/QUERY/RESPONSE for
// every GraphQL call), so an AI generation used to leave NO trace at all — a failed
// run was indistinguishable from one that never started. Mark the attempt with the
// identifiers needed to correlate it with the session log and the process row. The
// business copy and the API key are deliberately NOT logged.
Helper::log(
sprintf(
'modify_content → v2/ai/modify-content/pack (pack_id=%s, platform=%s, language=%s, session=%s, content_ids=%d, preview_pages=%d, local=%s)',
$pack_id,
$platform,
'' === $language ? '(default)' : $language,
$session_id,
is_array($content_ids) ? count($content_ids) : 0,
is_array($preview_pages) ? count($preview_pages) : 0,
Helper::is_local_site() ? 'yes' : 'no'
),
'modify_content',
'info'
);
$normalized = Helper::api_post('v2/ai/modify-content/pack', $body_data, $extra_headers, 15 * MINUTE_IN_SECONDS, ['unwrap' => false]);
// set_transient( '__templately_ai_process_id', $response, 60 * 60 * 24 * 30 );
// }
// The business-niche value is destructured as $category (sent as
// body_data['business_niches'] above). The previous $business_niches local was
// never defined — so this "remember my niche" persistence silently never ran and
// emitted a PHP 8 undefined-variable warning on every AI generation. Use $category.
$bk_ai_business_niches = get_option('templately_ai_business_niches', []);
if (!empty($category) && $isBusinessNichesNew && ! in_array($category, $bk_ai_business_niches)) {
$bk_ai_business_niches[] = $category;
update_option('templately_ai_business_niches', $bk_ai_business_niches, false);
}
// A transport/protocol failure is terminal. A body-level `status:'error'` is
// NOT — it is a legitimate outcome this endpoint forwards to the client, so it
// is deliberately allowed through to the handling below.
if ($normalized->is_error() && ErrorCode::INVALID_REQUEST !== $normalized->error()->code()) {
$error = $normalized->error();
Helper::log("modify_content failed: {$error->code()} — {$error->message()}", 'modify_content_error', 'error');
return $this->error($error->code(), $error->message(), 'modify_content', $error->status());
}
$data = $normalized->is_error()
? ['status' => 'error', 'message' => $normalized->error()->message()]
: $normalized->payload();
if (! is_array($data) || ! isset($data['status'])) {
return $this->error(ErrorCode::SERVER_ERROR, __('Invalid response.', 'templately'), 'modify_content', 500);
}
// "{"status":"success","message":"The content is being generated in the queue","process_id":"01JRQQD39GNWTNF18EWF8YH0BG-271838-pack-408"}"
if (isset($data['status']) && $data['status'] === 'success' && isset($data['process_id'])) {
$process_id = $data['process_id'];
// // Save templates to files if available using the common function
// if (!empty($data['templates']) && is_array($data['templates'])) {
// foreach ($data['templates'] as $content_id => $template_data) {
// // Decode template if it's base64 encoded
// if (! empty($template_data) && base64_decode($template_data, true) !== false) {
// $data['templates'][$content_id] = base64_decode($template_data);
// }
// if (!empty($template_data)) {
// AIUtils::save_template_to_file(
// $process_id,
// $content_id,
// $template_data,
// $ai_page_ids,
// true, // Always use preview mode for AI content workflow
// isset($template_data['isSkipped']) ? $template_data['isSkipped'] : false
// );
// }
// }
// }
$user = $this->utils('options')->get('user');
// FR-004: is_local_site is decided by the backend, not supplied by the
// client or trusted from the remote pass-through.
$is_local_site = Helper::is_local_site();
$ai_process_data[$process_id] = [
'name' => $name,
'category' => $category,
'description' => $description,
'email' => $email,
'contactNumber' => $contactNumber,
'businessAddress' => $businessAddress,
'openingHour' => $openingHour,
'process_id' => $process_id,
'pack_id' => $pack_id,
'ai_page_ids' => $ai_page_ids,
'ai_preview_ids' => $preview_pages,
'content_ids' => $content_ids,
'platform' => $platform,
'api_key' => $this->api_key,
'user_id' => isset($user['id']) ? $user['id'] : null,
'session_id' => $session_id,
'is_local_site' => $is_local_site,
'imageReplace' => $image_replace,
'language' => $language,
'requested_platform' => $requested_platform,
];
// Persist the process record (per-process non-autoloaded row, 026 seam).
AIUtils::update_ai_process_data($ai_process_data);
// FR-005: link the generation process to its import session server-side
// so the client never re-submits these identifiers. Mirror is_local_site
// onto the session for the import pipeline.
if (!empty($session_id)) {
AIUtils::link_session($process_id, $session_id);
SessionData::set($session_id, 'isLocalSite', $is_local_site);
}
// FR-003: additive {success,terminal,code,message} envelope; the
// generation request itself succeeded (the client now polls for content).
return self::ai_envelope('ok', __('The content is being generated in the queue', 'templately'), [
'status' => 'success',
'process_id' => $process_id,
'templates' => !empty($data['templates']) ? $data['templates'] : null,
// FR-004: server-decided, authoritative.
'is_local_site' => $is_local_site,
]);
}
// Everything above returns early on success. Reaching here means the cloud
// answered with a non-success status, or with success but no `process_id` — and
// this pass-through used to return it to the client WITHOUT a word in the log,
// which is why a "failed to generate content" in the UI had nothing behind it to
// read. It is not an error from this endpoint's point of view (the call itself
// worked), so it is still passed through; it is now merely audible.
Helper::log(
sprintf(
'modify_content: cloud declined to start generation (pack_id=%s, platform=%s, status=%s, process_id=%s) — %s',
$pack_id,
$platform,
isset($data['status']) ? (string) $data['status'] : '(none)',
isset($data['process_id']) ? (string) $data['process_id'] : '(none)',
isset($data['message']) ? (string) $data['message'] : '(no message)'
),
'modify_content',
'error'
);
return $data;
}
public function ai_update() {
add_filter('wp_redirect', '__return_false', 999);
$template = $this->get_param('template');
$process_id = $this->get_param('process_id');
$template_id = $this->get_param('template_id');
$content_id = $this->get_param('content_id');
$type = $this->get_param('type');
$isSkipped = $this->get_param('isSkipped', false);
$credit_cost = $this->request->get_param('credit_cost');
Helper::log('process_id: ' . $process_id, 'ai_update', 'debug'); // gated by WP_DEBUG_LOG
// Handle credit cost updates separately
if ($this->request->has_param('credit_cost')) {
AIUtils::merge_process_row($process_id, ['credit_cost' => $credit_cost]);
return self::ai_envelope('ok', '', [
'status' => 'success',
'data' => [
'process_id' => $process_id,
'credit_cost' => $credit_cost,
],
]);
}
// Always use preview mode for AI content workflow
// Validate and get process data using centralized method
$process_data = AIUtils::validate_and_get_process_data($process_id);
if (is_wp_error($process_data)) {
return $process_data;
}
$session_id = $process_data['session_id'];
$ai_page_ids = $process_data['ai_page_ids'];
// Use the common helper function to save the template
$result = AIUtils::save_template_to_file(
$process_id,
$session_id,
$content_id,
$template,
$ai_page_ids,
$isSkipped
);
if(is_wp_error($result)){
return $result;
}
// Return the result from the helper function (FR-003 additive envelope).
if (isset($result['status']) && $result['status'] === 'success') {
return self::ai_envelope('ok', '', $result);
}
// Return error if the helper function failed
return $result;
}
public function ai_update_preview() {
add_filter('wp_redirect', '__return_false', 999);
$template = $this->get_param('templates'); // Now expects an array with content_id as keys
$process_id = $this->get_param('process_id');
$isSkipped = $this->get_param('isSkipped', false);
$error = $this->get_param('error', null);
Helper::log('process_id: ' . $process_id, 'ai_update', 'debug'); // gated by WP_DEBUG_LOG
if (!empty($isSkipped) || !empty($error)) {
// The cloud reporting a generation failure is THE answer to "why did this
// fail", and it was recorded only on the process row — a serialized option
// nobody reads while debugging. Put it in the log too.
Helper::log(
sprintf(
'ai_update_preview: remote reported failure for %s (isSkipped=%s) — %s',
$process_id,
!empty($isSkipped) ? 'yes' : 'no',
!empty($error) ? (string) $error : '(no message)'
),
'ai_update_preview',
'error'
);
// Persist the preview error onto the process row (026 seam).
if (AIUtils::read_process($process_id) !== null) {
AIUtils::merge_process_row($process_id, ['preview_error' => $error]);
}
// This is a REST route (registered via $this->post()), so it must RETURN.
// `wp_send_json_error()` die()s mid-request and emits an AJAX-shaped body,
// bypassing the REST envelope, the HTTP status mapping and every filter
// after dispatch — the one place in the codebase where a REST handler
// answered in AJAX. Every other exit in this method already returns.
return $this->error(
ErrorCode::AI_REMOTE_FAILED,
! empty( $error ) ? $error : __( 'AI preview generation did not complete.', 'templately' ),
'ai-content/ai-update-preview',
502
);
}
// Validate template parameter is an array
if (!is_array($template) || empty($template)) {
return $this->error('invalid_template', __('Template must be a non-empty array with content_id as keys.', 'templately'), 'ai-content/ai-update-preview', 400);
}
// Always use preview mode for AI content workflow
// Validate and get process data using centralized method
$process_data = AIUtils::validate_and_get_process_data($process_id);
if (is_wp_error($process_data)) {
return $process_data;
}
$session_id = $process_data['session_id'];
$ai_page_ids = $process_data['ai_page_ids'];
$results = [];
$success_count = 0;
$error_count = 0;
// Process each content_id/template pair
foreach ($template as $content_id => $template_data) {
// Use the common helper function to save the template (always preview mode)
$result = AIUtils::save_template_to_file(
$process_id,
$session_id,
$content_id,
$template_data,
$ai_page_ids,
$isSkipped
);
$results[$content_id] = $result;
// Track success/error counts
if (isset($result['status']) && $result['status'] === 'success') {
$success_count++;
} else {
$error_count++;
}
}
// Return consolidated response
$overall_status = $error_count === 0 ? 'success' : ($success_count === 0 ? 'error' : 'partial_success');
// Note: No cleanup needed with API key-based storage and count-based management
// FR-003 additive envelope: a total failure is terminal (remote_failed);
// full or partial success is 'ok' (the per-page results carry the detail).
$code = $overall_status === 'error' ? 'remote_failed' : 'ok';
return self::ai_envelope($code, sprintf(
__('Processed %d templates: %d successful, %d failed.', 'templately'),
count($template),
$success_count,
$error_count
), [
'status' => $overall_status,
]);
}
/**
* Get attachments from API endpoint
*
* @return array|\WP_Error
*/
public function get_attachments() {
// Get parameters from request. The route declares BOTH `id` and `pack_id` and the
// error copy says "Pack ID or ID" — but only `pack_id` was read, so a caller
// passing `?id=` (the documented alternative, matching the get-xml-attachment/{type}/{id}
// URL shape) failed with missing_id. Fall back to `id` when `pack_id` is absent.
$type = $this->get_param('type', 'pack');
$id = $this->get_param('pack_id');
if (empty($id)) {
$id = $this->get_param('id');
}
$requested_platform = Helper::get_requested_platform();
// Require ID parameter - return error if not provided
if (empty($id)) {
return $this->error('missing_id', __('Pack ID or ID parameter is required.', 'templately'), 'get_attachments', 400);
}
try {
// Construct API endpoint URL
$api_endpoint = "get-xml-attachment/{$type}/{$id}";
// Make API call
$extra_headers = [
'Accept' => 'application/xml, text/xml',
'x-templately-requested-platform' => $requested_platform,
];
// XML, not JSON — FR-012: the body passes through the normalizer untouched
// and is only CLASSIFIED. An error body on this endpoint is JSON even
// though success is XML, so it is re-checked below.
$normalized = Helper::api_get("v2/$api_endpoint", [], $extra_headers, 30, ['raw' => true]);
if ($normalized->is_error()) {
$error = $normalized->error();
return $this->error($error->code(), $error->message(), 'get_attachments', $error->status());
}
$xml_content = $normalized->payload();
// Validate we have XML content
if (empty($xml_content)) {
return $this->error('no_xml_content', __('No XML content found in API response.', 'templately'), 'get_attachments', 404);
}
// Parse the XML content from API response
$parsed_data = $this->parse_xml_content($xml_content);
if (is_wp_error($parsed_data)) {
return $this->error('xml_parse_error', __('Failed to parse XML content.', 'templately'), 'get_attachments', 500, ['error_detail' => $parsed_data->get_error_message()]);
}
// Extract attachments from parsed data
$attachments = $this->extract_attachments_from_parsed_data($parsed_data);
return [
'status' => 'success',
'data' => $attachments,
'message' => sprintf(__('Found %d attachments.', 'templately'), count($attachments)),
];
} catch (Exception $e) {
return $this->error('exception', __('An unexpected error occurred while fetching attachments.', 'templately'), 'get_attachments', 500, ['error_detail' => $e->getMessage()]);
}
}
/**
* Parse XML content string using WXR Parser
*
* @param string $xml_content XML content string
* @return array|\WP_Error Parsed data or error
*/
private function parse_xml_content($xml_content) {
// Ensure WordPress filesystem functions are available
if (!function_exists('wp_tempnam')) {
require_once(ABSPATH . 'wp-admin/includes/file.php');
}
// Create a temporary file to store XML content
$temp_file = wp_tempnam('templately_attachments');
if (!$temp_file) {
return new WP_Error('temp_file_failed', __('Failed to create temporary file.', 'templately'));
}
// Write XML content to temporary file
$bytes_written = file_put_contents($temp_file, $xml_content);
if ($bytes_written === false) {
unlink($temp_file);
return new WP_Error('write_failed', __('Failed to write XML content to temporary file.', 'templately'));
}
try {
// Initialize WXR Parser
$parser = new WXR_Parser();
// Parse the temporary XML file
$parsed_data = $parser->parse($temp_file);
// Clean up temporary file
unlink($temp_file);
return $parsed_data;
} catch (Exception $e) {
// Clean up temporary file on exception
if (file_exists($temp_file)) {
unlink($temp_file);
}
return new WP_Error('parse_exception', $e->getMessage());
}
}
/**
* Extract attachments from parsed WXR data
*
* @param array $parsed_data Parsed WXR data
* @return array Array of attachment data
*/
private function extract_attachments_from_parsed_data($parsed_data) {
$attachments = [];
if (isset($parsed_data['posts']) && is_array($parsed_data['posts'])) {
foreach ($parsed_data['posts'] as $post) {
// Check if this is an attachment
if (isset($post['post_type']) && $post['post_type'] === 'attachment') {
$attachment = [
'id' => isset($post['post_id']) ? (int) $post['post_id'] : 0,
'url' => isset($post['attachment_url']) ? (string) $post['attachment_url'] : '',
'title' => isset($post['post_title']) ? (string) $post['post_title'] : '',
'type' => isset($post['attachment_type']) ? (string) $post['attachment_type'] : '',
];
// Extract metadata including dimensions and medium URL
$metadata = $this->extract_medium_size_url($post, $attachment['url']);
// Filter out small images (width or height <= 150px) to ignore small icons
if ($metadata && isset($metadata['width']) && isset($metadata['height'])) {
if ($metadata['width'] < 150 || $metadata['height'] < 150) {
continue; // Skip small images/icons
}
// Add dimensions to attachment data
$attachment['width'] = $metadata['width'];
$attachment['height'] = $metadata['height'];
// Add medium URL if available
if (isset($metadata['medium_url'])) {
$attachment['medium_url'] = $metadata['medium_url'];
}
} else {
// Skip attachments without metadata or dimensions
continue;
}
// Only add if we have the required data
if ($attachment['id'] && $attachment['url'] && $attachment['title']) {
$attachments[] = $attachment;
}
}
}
}
return $attachments;
}
/**
* Extract medium size URL from attachment metadata and get image dimensions
*
* @param array $post Post data from WXR parser
* @param string $original_url Original attachment URL
* @return array|null Array with medium_url and dimensions if found, null otherwise
*/
private function extract_medium_size_url($post, $original_url) {
if (!isset($post['postmeta']) || !is_array($post['postmeta'])) {
return null;
}
foreach ($post['postmeta'] as $meta) {
if (!isset($meta['key']) || !isset($meta['value'])) {
continue;
}
// Only check _wp_attachment_metadata
if ($meta['key'] === '_wp_attachment_metadata') {
// Pack-supplied meta: parse without object hydration, and without @
// (a malformed value just fails the is_array check below).
$attachment_metadata = is_serialized($meta['value'])
? unserialize($meta['value'], ['allowed_classes' => false])
: null;
if (is_array($attachment_metadata)) {
$result = [];
// Get original image dimensions
$width = isset($attachment_metadata['width']) ? (int) $attachment_metadata['width'] : 0;
$height = isset($attachment_metadata['height']) ? (int) $attachment_metadata['height'] : 0;
$result['width'] = $width;
$result['height'] = $height;
// Check if medium size exists
if (isset($attachment_metadata['sizes']['medium']['file'])) {
// Construct medium URL from original URL and medium filename
$medium_filename = $attachment_metadata['sizes']['medium']['file'];
$original_path = dirname(parse_url($original_url, PHP_URL_PATH));
$base_url = str_replace(parse_url($original_url, PHP_URL_PATH), '', $original_url);
$result['medium_url'] = $base_url . $original_path . '/' . $medium_filename;
}
return $result;
}
}
}
return null;
}
// search_images() moved to modules/image-replace/REST/ImageSearch.php (spec 023).
/**
* Generate tagline using AI
*
* @return array|WP_Error
*/
public function generate_tagline() {
// Get parameters
$prompt = $this->get_param('prompt');
$requested_platform = Helper::get_requested_platform();
// Validate required parameters
if (empty($prompt)) {
return $this->error(
'missing_prompt',
__('Prompt is required for tagline generation.', 'templately'),
'generate_tagline',
400
);
}
// Prepare request body
$body_data = [
'prompt' => $prompt,
];
// Make API request
$extra_headers = [
'Content-Type' => 'application/json',
'x-templately-requested-platform' => $requested_platform,
];
$response = Helper::make_api_post_request('v2/generate-tagline', $body_data, $extra_headers, 30);
// Handle API response errors
if (is_wp_error($response)) {
return $this->error(
'api_request_failed',
__('Failed to generate tagline.', 'templately'),
'generate_tagline',
500,
['error_detail' => $response->get_error_message()]
);
}
$response_code = wp_remote_retrieve_response_code($response);
$response_body = wp_remote_retrieve_body($response);
if ($response_code !== 200) {
// Try to parse the response body as JSON to get specific error details
$data = json_decode($response_body, true);
// If valid JSON, extract error message and return with proper status code
if (json_last_error() === JSON_ERROR_NONE && is_array($data)) {
$error_message = isset($data['message']) ? $data['message'] : __('Something went wrong. Please try again or contact support.', 'templately');
return $this->error(
'api_response_error',
$error_message,
'generate_tagline',
$response_code
);
}
// Otherwise, return generic error
return $this->error(
'api_response_error',
__('Something went wrong. Please try again or contact support.', 'templately'),
'generate_tagline',
$response_code
);
}
// Parse and validate response
$data = json_decode($response_body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return $this->error(
'invalid_response',
__('Invalid response from API.', 'templately'),
'generate_tagline',
500
);
}
// Check if the response has the expected structure
if (!isset($data['status'])) {
return $this->error(
'api_response_error',
__('API returned an unexpected response.', 'templately'),
'generate_tagline',
500
);
}
// Same rule as {@see chatbot_request()}: a body-level `status:'error'` on an
// HTTP 200 is a failure, not a payload. Inline rather than routed through
// that helper because this is `v2/generate-tagline`, not a chatbot route —
// folding it in would widen a chatbot-specific seam to mean "any upstream
// call", which is how such helpers stop being safe to reason about.
//
// Defence in depth here specifically: `useTaglineGeneration` already checks
// `response.status === 'error'` itself. This is the one of the four sites
// where the frontend had compensated — but leaving the backend inconsistent
// is how the next caller inherits the bug.
if ('error' === $data['status']) {
$message = !empty($data['message'])
? $data['message']
: __('Failed to generate tagline.', 'templately');
return $this->error('tagline_generation_failed', $message, 'generate_tagline', 400);
}
// Return the response as-is
return $data;
}
/**
* Ask the cloud for a tagline AND typography-logo parameters in one call.
*
* A thin pass-through to `v2/generate-brand-kit`, same shape as {@see generate_tagline()}.
* The font and icon vocabularies travel in the request because only the CLIENT knows what
* its logo editor can render; sending them is what stops the model naming a font we cannot
* load or an icon that exists nowhere.
*
* Nothing is validated against those vocabularies here. That check belongs where the
* renderer is — the client re-checks every returned value and falls back to its own
* deterministic composition — and duplicating it in PHP would mean this endpoint had to
* track the icon registry's contents.
*
* @return array|\WP_Error Pass-through of the external response { status, data } or WP_Error.
*/
/**
* Persist the customizer draft for a generation — locally always, in the cloud on request.
*
* **Three cadences, and this is the slow two.** The moment-to-moment draft is a keystroke
* and lives in the browser's localStorage; a database write per keystroke is the wrong
* shape at any scale. This runs at two MILESTONES instead: when the import starts, and
* when it finishes.
*
* `sync_cloud` is what separates them. The import-start write is local only — fast, and
* an import that never completes should not leave a cloud record claiming a site exists.
* The finalizer's write carries `sync_cloud`, and THAT is where a site generated in this
* plugin becomes a conversation in "My AI Sites", re-importable anywhere.
*
* **Create-if-absent lives here, not in the client.** The conversation uuid is stored on
* the process row, so this endpoint knows whether one already exists: a web-generated site
* has one and gets an update, a plugin-generated site has none and gets a create whose
* minted uuid is written back. A client deciding that would have to be trusted with the
* uuid, and a second import would mint a duplicate.
*
* A cloud failure is reported but does NOT fail the call: the local write already
* succeeded, and the import it belongs to has finished. Losing the listing entry is worth
* far less than surfacing an error on a site that imported correctly.
*
* @return array|\WP_Error { status, data: { saved, uuid, cloud } } or WP_Error.
*/
public function save_customization() {
$process_id = $this->get_param('process_id');
$customization = $this->get_param('customization_data', [], null);
if (empty($process_id)) {
return $this->error(
'missing_process_id',
__('A generation id is required to save customization.', 'templately'),
'ai-content/customization',
400
);
}
if (!is_array($customization)) {
return $this->error(
'invalid_customization',
__('Customization data must be an object.', 'templately'),
'ai-content/customization',
400
);
}
$row = AIUtils::read_process($process_id);
if (!is_array($row)) {
// Nothing to attach it to. Not an error the user can act on — the draft still
// lives in their browser — but the caller should know the durable write did not
// happen rather than believing it did.
return $this->error(
'unknown_process',
__('That generation is no longer available on this site.', 'templately'),
'ai-content/customization',
404
);
}
// `processed_pages` is a MERGE-ON-READ view assembled from the per-page rows, not a
// stored field. Writing it back would bake a snapshot into the record and make the
// live per-page rows unreachable.
unset($row['processed_pages']);
$row['customization_data'] = $customization;
AIUtils::write_process_row($process_id, $row);
$result = [
'saved' => true,
'uuid' => isset($row['conversation_uuid']) ? $row['conversation_uuid'] : null,
'cloud' => 'skipped',
];
if (!$this->get_param('sync_cloud', false, null)) {
return ['status' => 'success', 'data' => $result];
}
$body = ['customization_data' => $customization];
if (!empty($row['conversation_uuid'])) {
$body['uuid'] = $row['conversation_uuid'];
} else {
// Only on the CREATE. Without these the row appears in "My AI Sites" with no pack
// name, no thumbnail and — the one that does damage — a null platform, which is
// what a later re-import hands back to pick a builder with.
//
// The cloud fills name/slug/thumbnail from the live pack; it cannot know the
// PLATFORM this generation actually ran for, so that travels from here.
$body['selected_pack_id'] = isset($row['pack_id']) ? (int) $row['pack_id'] : null;
$body['platform'] = isset($row['platform']) ? $row['platform'] : null;
$body['detected_info'] = array_filter([
'business_name' => isset($row['name']) ? $row['name'] : null,
'business_type' => isset($row['category']) ? $row['category'] : null,
'business_description' => isset($row['description']) ? $row['description'] : null,
]);
}
$response = Helper::make_api_post_request(
'v2/chatbot/conversation/customization',
$body,
['Content-Type' => 'application/json'],
20
);
if (is_wp_error($response)) {
$result['cloud'] = 'failed';
return ['status' => 'success', 'data' => $result];
}
$data = json_decode(wp_remote_retrieve_body($response), true);
$uuid = isset($data['data']['uuid']) ? $data['data']['uuid'] : null;
if ($uuid) {
$result['uuid'] = $uuid;
$result['cloud'] = 'synced';
// Written back so the NEXT save updates that conversation instead of minting a
// second one. This is what makes the whole thing create-once.
if (empty($row['conversation_uuid'])) {
$row['conversation_uuid'] = $uuid;
AIUtils::write_process_row($process_id, $row);
}
} else {
$result['cloud'] = 'failed';
}
return ['status' => 'success', 'data' => $result];
}
public function generate_brand_kit() {
$business_name = $this->get_param('business_name');
if (empty($business_name)) {
return $this->error(
'missing_business_name',
__('Business name is required for brand kit generation.', 'templately'),
'generate_brand_kit',
400
);
}
// Arrays of plain identifiers (font families, icon LIBRARY names, hex colours).
// `sanitize_text_field` is applied per element by `get_param`, which is right for all
// three; the cloud caps their length and the client validates their contents.
$body_data = [
'business_name' => $business_name,
'business_type' => $this->get_param('business_type', ''),
'business_description' => $this->get_param('business_description', ''),
// The rest of what the conversation collected. `language` is the one that was
// missing and was a live defect: without it the cloud writes the tagline in the
// language of its own instruction, so a French or Bengali site opened with an
// English tagline. `sanitize_email` for the address — `sanitize_text_field`
// would pass a malformed one straight through.
'email' => sanitize_email((string) $this->get_param('email', '', null)),
'contact_number' => $this->get_param('contact_number', ''),
'business_address' => $this->get_param('business_address', ''),
'opening_hour' => $this->get_param('opening_hour', ''),
'language' => $this->get_param('language', ''),
'fonts' => (array) $this->get_param('fonts', []),
'icon_libraries' => (array) $this->get_param('icon_libraries', []),
'colors' => (array) $this->get_param('colors', []),
];
$extra_headers = [
'x-templately-requested-platform' => Helper::get_requested_platform(),
];
// GET, not POST — and not a style choice: `v2/generate-brand-kit` answers
// `Allow: GET,HEAD`, so a POST is rejected with 405 before the cloud ever reads the
// body, and every brand kit failed. (Its sibling `v2/generate-tagline` DOES allow
// POST, which is why the two look inconsistent here; they are, on the server.)
// The fields travel as query params — `make_api_get_request` encodes them, arrays
// included — and none of them is a secret: the credential rides the Authorization
// header, as on every other call.
$response = Helper::make_api_get_request('v2/generate-brand-kit', $body_data, $extra_headers, 30);
if (is_wp_error($response)) {
return $this->error(
'api_request_failed',
__('Failed to generate brand kit.', 'templately'),
'generate_brand_kit',
500,
['error_detail' => $response->get_error_message()]
);
}
$response_code = wp_remote_retrieve_response_code($response);
$data = json_decode(wp_remote_retrieve_body($response), true);
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($data)) {
return $this->error(
'invalid_response',
__('Invalid response from API.', 'templately'),
'generate_brand_kit',
500
);
}
if ($response_code !== 200) {
$message = ! empty($data['message'])
? $data['message']
: __('Something went wrong. Please try again or contact support.', 'templately');
return $this->error('api_response_error', $message, 'generate_brand_kit', $response_code);
}
// Same rule as {@see generate_tagline()}: a body-level `status:'error'` on an HTTP 200
// is a failure, not a payload. Insufficient credit arrives this way.
if (isset($data['status']) && 'error' === $data['status']) {
$message = ! empty($data['message'])
? $data['message']
: __('Failed to generate brand kit.', 'templately');
return $this->error('brand_kit_generation_failed', $message, 'generate_brand_kit', 400);
}
return $data;
}
/**
* Fetch a chatbot conversation by ID from the external Templately chatbot API.
*
* Used to resume a conversation that began on templately.dev when the user
* is redirected into the plugin with ?process=ai&chat={uuid}.
*
* @return array|\WP_Error Pass-through of the external response { status, data } or WP_Error.
*/
public function get_chatbot_conversation() {
$chat = $this->get_param('chat');
if (empty($chat)) {
return $this->error('invalid_chat_id', __('Invalid conversation ID.', 'templately'), 'ai-content/chatbot-conversation', 400);
}
return $this->chatbot_request('GET', "v2/chatbot/conversation/{$chat}", 'ai-content/chatbot-conversation', [
'transport_message' => __('Failed to fetch conversation.', 'templately'),
'error_code' => 'conversation_unavailable',
'error_message' => __('Could not retrieve the conversation.', 'templately'),
]);
}
/**
* The account's AI-generated sites, paginated — the list behind "My AI Sites".
*
* Plural sibling of {@see get_chatbot_conversation()}: that one fetches a
* single conversation by id, this one enumerates them so a user can come back
* to a site they generated earlier and import it again.
*
* The upstream page size is clamped rather than trusted. `per_page` reaches
* this from a query string, and an unbounded value turns one list render into
* an arbitrarily large upstream fetch — the pagination exists precisely
* because an account can accumulate a lot of these.
*
* @return array|\WP_Error Pass-through of the external response { status, data } or WP_Error.
*/
public function get_chatbot_conversations() {
$page = max(1, (int) $this->get_param('page', 1, 'absint'));
$per_page = min(100, max(1, (int) $this->get_param('per_page', 10, 'absint')));
return $this->chatbot_request('GET', 'v2/chatbot/conversations', 'ai-content/chatbot-conversations', [
'body' => [
'page' => $page,
'per_page' => $per_page,
],
'transport_message' => __('Failed to fetch your AI sites.', 'templately'),
'error_code' => 'conversations_unavailable',
'error_message' => __('Could not retrieve your AI sites.', 'templately'),
]);
}
/**
* THE single path to the chatbot API. Every call goes through here.
*
* This exists because the validation could not be left to call sites. Four
* handlers each hand-rolled the same twenty lines — transport check, HTTP
* status check, decode, envelope check — and every one of them stopped at
* "does `status` EXIST", then returned the body. The chatbot API signals
* application-level failure INSIDE an HTTP 200 as `{status:'error', message}`,
* so all four forwarded failures that the 043 envelope then stamped
* `success: true`. Downstream, the caller reads a field the error body does
* not carry, gets nothing, and renders an empty state with no error and no
* retry — the "an error is never an empty array" failure includes/CLAUDE.md
* warns about.
*
* Guarding each site individually fixed those four and left the NEXT handler
* free to inherit the bug by simply not knowing about the guard. Owning the
* whole sequence here makes that impossible: there is no way to call the
* chatbot API without the check, because there is no other way to call it.
*
* The disconnected pre-flight lives here for the same reason. Only
* {@see get_chatbot_conversation()} carried it inline, so the other four
* handlers made the doomed upstream call anyway and answered with a generic
* auth error that named no remedy. A disconnected site cannot authenticate
* upstream, full stop — so the check belongs to the seam, not to whichever
* handler remembered it.
*
* Inside this seam an error body is ALWAYS a failure — there is no opt-out.
* The callers that legitimately render an error body as a partial outcome
* ({@see modify_content()} via `Helper::api_post(['unwrap' => false])`,
* {@see generate_tagline()} with its inline guard) are different route
* families and sit outside this seam by design.
*
* @param string $method GET|POST.
* @param string $path Upstream path, e.g. `v2/chatbot/generated/{id}`.
* @param string $endpoint Endpoint label carried on error payloads.
* @param array $opts body, timeout, with_api_key, transport_message,
* error_code, error_message.
* @return array|\WP_Error Decoded body on success; WP_Error on ANY failure.
*/
private function chatbot_request(string $method, string $path, string $endpoint, array $opts = []) {
$opts = wp_parse_args($opts, [
'body' => [],
'timeout' => 30,
'with_api_key' => false,
'transport_message' => __('The request could not be completed.', 'templately'),
'error_code' => 'upstream_error',
'error_message' => __('The request could not be completed.', 'templately'),
]);
// Pre-flight the connection, mirroring the FSI handlers
// (Concerns\HandlesSession::import_settings, Concerns\RunsImport).
$user = $this->options()->get('user');
if (!empty($user['is_disconnected'])) {
return $this->error(
ErrorCode::SITE_DISCONNECTED,
__('Your site connection is disconnected. Please migrate your connection first.', 'templately'),
$endpoint,
403
);
}
$headers = ['Accept' => 'application/json'];
// Some chatbot routes are gated on the key header rather than the bearer.
if ($opts['with_api_key']) {
$headers['X-Templately-Apikey'] = $this->api_key;
}
$response = ('POST' === strtoupper($method))
? Helper::make_api_post_request($path, $opts['body'], $headers, $opts['timeout'])
: Helper::make_api_get_request($path, $opts['body'], $headers, $opts['timeout']);
if (is_wp_error($response)) {
return $this->error(
'request_failed',
$opts['transport_message'],
$endpoint,
500,
['error_detail' => $response->get_error_message()]
);
}
$code = wp_remote_retrieve_response_code($response);
$data = json_decode(wp_remote_retrieve_body($response), true);
if (200 !== $code) {
$message = (is_array($data) && !empty($data['message']))
? $data['message']
: sprintf(
/* translators: %d: HTTP status code returned by the API. */
__('API returned HTTP %d error.', 'templately'),
$code
);
return $this->error('api_http_error', $message, $endpoint, $code ?: 500);
}
if (!is_array($data) || !isset($data['status'])) {
return $this->error('invalid_response', __('Invalid response.', 'templately'), $endpoint, 500);
}
if ('error' === $data['status']) {
$message = !empty($data['message']) ? $data['message'] : $opts['error_message'];
return $this->error($opts['error_code'], $message, $endpoint, 400);
}
return $data;
}
/**
* Fetch the server-side generated content for a chatbot conversation (Phase 2).
*
* In Phase 2 the AI content is generated on the backend. This proxy mirrors
* {@see get_chatbot_conversation()} and returns the already-generated page
* content, customization data and signed logo URL so the plugin can run a
* thin import without triggering generation or the customizer locally.
*
* @return array|\WP_Error Pass-through of the external response { status, data } or WP_Error.
*/
public function get_chatbot_generated() {
$chat = $this->get_param('chat');
if (empty($chat)) {
return $this->error('invalid_chat_id', __('Invalid conversation ID.', 'templately'), 'ai-content/chatbot-generated', 400);
}
// Validation happens inside chatbot_request(), i.e. BEFORE the transient
// write below — an error body must never be cached. Caching one would
// replay the failure to chatbot-import-prepare for the whole TTL, so a
// TRANSIENT upstream error would look like a persistent one and the thin
// import would keep failing after the cause had cleared.
$data = $this->chatbot_request('GET', "v2/chatbot/generated/{$chat}", 'ai-content/chatbot-generated', [
'transport_message' => __('Failed to fetch generated content.', 'templately'),
'error_code' => 'generated_unavailable',
'error_message' => __('Could not retrieve the generated content.', 'templately'),
]);
if (is_wp_error($data)) {
return $data;
}
// The direct-import handoff reads this endpoint and then immediately calls
// chatbot-import-prepare, which pulls the very same (large) bundle off GCP
// seconds later. Park it so prepare can reuse it instead of paying for a
// second identical transfer.
Database::set_transient(self::GENERATED_CACHE_KEY . $chat, $data, self::GENERATED_CACHE_TTL);
return $data;
}
/**
* Does a `v2/chatbot/generated` bundle already carry every expected page?
*
* A page counts as present when it is in `templates` (string or int key, the
* upstream is inconsistent) or listed in `skipped_pages` — a skipped page is
* never coming, so waiting on it would hang the poll until it timed out.
*
* @param array $data Decoded `{ status, data }` bundle.
* @param array $expected_ids Flattened page ids, as strings.
* @return bool
*/
private function is_generated_bundle_complete($data, $expected_ids) {
$generated = isset($data['data']) && is_array($data['data']) ? $data['data'] : [];
// Never reuse a bundle the user is no longer allowed to import.
if (isset($generated['can_import']) && ! $generated['can_import']) {
return false;
}
$templates = isset($generated['templates']) && is_array($generated['templates']) ? $generated['templates'] : [];
$skipped = isset($generated['skipped_pages']) && is_array($generated['skipped_pages']) ? array_map('strval', $generated['skipped_pages']) : [];
if (empty($templates) && empty($skipped)) {
return false;
}
foreach ($expected_ids as $id) {
if (array_key_exists($id, $templates) || array_key_exists((int) $id, $templates) || in_array($id, $skipped, true)) {
continue;
}
return false;
}
return true;
}
/**
* Persist edited detected-info back to the chatbot conversation (Phase 2).
*
* Mirrors {@see get_chatbot_conversation()} / {@see get_chatbot_generated()}
* but forwards a POST. When the user edits the detected-info card in the
* sidebar, the plugin proxies the corrected values to the backend so a later
* replay reflects them. The backend route is gated by the X-Templately-Apikey
* header, so it is supplied explicitly here.
*
* Expected JSON body: { chat, detected_info: { ...fields } }
*
* @return array|\WP_Error Pass-through of the external response { status, data } or WP_Error.
*/
public function update_chatbot_detected_info() {
$chat = $this->get_param('chat');
$detected_info = $this->get_param('detected_info', [], null);
if (empty($chat)) {
return $this->error('invalid_chat_id', __('Invalid conversation ID.', 'templately'), 'ai-content/chatbot-detected-info', 400);
}
// detected_info may arrive as a JSON string when sent via FormData.
if (is_string($detected_info)) {
$decoded = json_decode($detected_info, true);
$detected_info = is_array($decoded) ? $decoded : [];
}
if (!is_array($detected_info)) {
$detected_info = [];
}
// The caller uses templatelyApiOrThrow, which throws on `error` but NOT on a
// body-level `status:'error'` — so an unguarded failure meant the user's
// edit silently did not persist and a later replay used the stale values.
return $this->chatbot_request('POST', "v2/chatbot/conversation/{$chat}/detected-info", 'ai-content/chatbot-detected-info', [
'body' => ['detected_info' => $detected_info],
'with_api_key' => true,
'transport_message' => __('Failed to update detected info.', 'templately'),
'error_code' => 'detected_info_not_saved',
'error_message' => __('Could not save your changes.', 'templately'),
]);
}
/**
* Report a completed import back to templately.dev (Phase 2 import-once gate).
*
* Once the plugin finishes importing the generated content for a conversation,
* it calls this so the backend flips the conversation status to `imported`.
* Subsequent pulls then return `already_imported = true`, and the web/plugin
* UIs refuse a second import.
*
* @return array|\WP_Error
*/
public function mark_chatbot_imported() {
$chat = $this->get_param('chat');
if (empty($chat)) {
return $this->error('invalid_chat_id', __('Invalid conversation ID.', 'templately'), 'ai-content/chatbot-mark-imported', 400);
}
// A GATE, not a read: this flips the conversation to `imported` so a second
// import is refused. Forwarding an upstream failure as success made
// markChatbotImported() return true when nothing had been recorded — the
// import-once gate failing OPEN, the one direction it must not fail. The
// caller already branches on `error`, so it now correctly returns false.
return $this->chatbot_request('POST', "v2/chatbot/conversation/{$chat}/imported", 'ai-content/chatbot-mark-imported', [
'with_api_key' => true,
'transport_message' => __('Failed to record import.', 'templately'),
'error_code' => 'mark_imported_failed',
'error_message' => __('Could not record the import.', 'templately'),
]);
}
/**
* Resolve the conversation answers to store on a chat-driven process.
*
* Prefers what the client sent (the sidebar holds the detected info the user
* may have just edited), then what was already stored for this process (so a
* repeat call costs nothing), and only then pulls the conversation from the
* cloud — which is the path the `?process=import` landing takes, since it
* never opens the sidebar and so has no answers to send.
*
* A failure here must never break the import: it returns an empty array and
* the process is stored exactly as before.
*
* @param string $chat Conversation uuid.
* @param mixed $detected_info Raw `detected_info` sent by the client.
* @param array $ai_process_data All stored process data.
* @param string $process_id This process id.
* @return array<string,string> Map of conversation step key => value.
*/
private function resolve_chat_conversation_fields($chat, $detected_info, $ai_process_data, $process_id) {
$mapped = AIUtils::map_chat_detected_info($detected_info);
if (!empty($mapped)) {
return $mapped;
}
// Already resolved on an earlier call for this process — reuse it.
if (isset($ai_process_data[$process_id]) && AIUtils::has_conversation_data($ai_process_data[$process_id])) {
return AIUtils::map_chat_detected_info(
array_intersect_key($ai_process_data[$process_id], array_flip(AIUtils::CONVERSATION_FIELDS))
);
}
$response = Helper::make_api_get_request("v2/chatbot/conversation/{$chat}", [], ['Accept' => 'application/json'], 30);
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
Helper::log(sprintf('chatbot_import_prepare[%s] conversation fetch failed — process stored without answers', $chat), 'ai-import', 'warning');
return [];
}
$data = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($data) || empty($data['data']['detected_info'])) {
return [];
}
return AIUtils::map_chat_detected_info($data['data']['detected_info']);
}
/**
* Thin importer prepare step (Phase 2).
*
* Given a chat uuid and a session that has already been created and had its
* pack downloaded (via the existing templately_pack_create_session_and_download
* AJAX flow), this:
* 1. Registers AI process data (including `chat_id`) so ai_get_json()/
* validation keep working AND so the Finalizer's ChatAIContentProvider
* can claim this process.
* 2. Fetches the backend-generated page content for the conversation.
* 3. Writes whatever pages are ALREADY generated to the same .ai.json
* location the legacy flow uses (via AIUtils::save_template_to_file).
* 4. Downloads the signed logo URL into the WP media library (Utils::upload_logo).
*
* This endpoint NEVER waits for generation to complete. Pages still being
* generated are reported in `missing` and are pulled on demand — and waited
* for — by the Finalizer via AIContentResolver. Previously this held the
* client in a 3s poll loop until every page was ready, which delayed the
* start of the import by minutes for no benefit.
*
* It returns the resolved customization data, logo attachment and the
* process_id so the React app can build the settings FormData and run the
* existing import. No generation or local customizer is involved.
*
* Expected JSON body: { chat, session_id, ai_page_ids: { 'content/page': [...], templates: [...] },
* pack_id?, platform?, detected_info? }
*
* @return array|\WP_Error
*/
public function chatbot_import_prepare() {
add_filter('wp_redirect', '__return_false', 999);
set_time_limit(3 * MINUTE_IN_SECONDS);
$handler_started = microtime(true);
$chat = $this->get_param('chat');
$session_id = $this->get_param('session_id');
$ai_page_ids = $this->get_param('ai_page_ids', [], null);
// Conversation context, so reopening "Build with AI" later can resume this
// app-end session instead of starting from scratch. `detected_info` is
// sanitized field-by-field in AIUtils::map_chat_detected_info().
$pack_id = $this->get_param('pack_id', 0, 'absint');
$platform = $this->get_param('platform');
$detected_info = $this->get_param('detected_info', [], null);
if (empty($chat)) {
return $this->error('invalid_chat_id', __('Invalid conversation ID.', 'templately'), 'ai-content/chatbot-import-prepare', 400);
}
if (empty($session_id)) {
return $this->error('invalid_session_id', __('Invalid session ID.', 'templately'), 'ai-content/chatbot-import-prepare', 400);
}
// Security: sanitize the session id before it is used to build file paths.
$session_id = AIUtils::sanitize_path_component($session_id, 'session_id');
if (is_wp_error($session_id)) {
return $this->error('invalid_session_id', $session_id->get_error_message(), 'ai-content/chatbot-import-prepare', 400);
}
// ai_page_ids may arrive as a JSON string when sent via FormData, and with
// scalar / comma-separated group values — normalize to the canonical
// `type/sub_type => ['id',...]` shape before anything indexes into it.
$ai_page_ids = AIUtils::normalize_ai_page_ids($ai_page_ids);
if (empty($ai_page_ids)) {
return $this->error('invalid_ai_page_ids', __('Invalid AI page IDs.', 'templately'), 'ai-content/chatbot-import-prepare', 400);
}
// Expected page ids (flattened) — the client may redirect to customization
// as soon as the home/header/footer are ready, so by import time some pages
// can still be generating.
$expected_ids = AIUtils::flatten_ai_page_ids($ai_page_ids);
$cache_key = self::GENERATED_CACHE_KEY . $chat;
// This endpoint NEVER waits for completeness. The import starts as soon as
// the session exists; whatever pages are already generated are written
// here as a warm start, and any page still generating is pulled on demand
// by the Finalizer (FullSiteImport\Utils\AIContentResolver +
// Providers\ChatAIContentProvider).
//
// Reuse the bundle chatbot-generated just parked, but ONLY when it already
// holds every expected page — a complete bundle cannot become less
// complete, whereas an incomplete one has to be re-pulled to pick up the
// pages that have since finished. On the common handoff (generation
// finished long before the user landed here) this removes an entire
// duplicate transfer of every page's block JSON.
$pull_started = microtime(true);
$pull_duration = 0;
$data = Database::get_transient($cache_key);
$from_cache = is_array($data) && $this->is_generated_bundle_complete($data, $expected_ids);
if (! $from_cache) {
// Single pull — no server-side sleep/retry. Routed through the shared
// seam so this endpoint cannot drift from the others on what counts as
// a failure; it was the last hand-rolled copy of that sequence.
$data = $this->chatbot_request('GET', "v2/chatbot/generated/{$chat}", 'ai-content/chatbot-import-prepare', [
'timeout' => 2 * MINUTE_IN_SECONDS,
'transport_message' => __('Failed to fetch generated content.', 'templately'),
'error_code' => 'generated_unavailable',
'error_message' => __('Could not retrieve the generated content.', 'templately'),
]);
$pull_duration = microtime(true) - $pull_started;
// A FAILED pull is not an empty one. Before this, an upstream
// `status:'error'` on an HTTP 200 passed the shape check (`status` was
// present), left `$generated` empty, and fell through the whole handler
// to return `status:'success'` with saved=0 and every page "missing" —
// so a hard upstream failure was indistinguishable from the legitimate
// "nothing generated yet, the Finalizer will pull on demand" case, and
// the import began against a process with no content behind it.
if (is_wp_error($data)) {
Helper::log(
sprintf('chatbot_import_prepare[%s] pull failed after %.2fs: %s', $chat, $pull_duration, $data->get_error_message()),
'ai-import',
'error'
);
return $data;
}
// Park a complete bundle for the credits re-read on the success screen.
if ($this->is_generated_bundle_complete($data, $expected_ids)) {
Database::set_transient($cache_key, $data, self::GENERATED_CACHE_TTL);
}
} else {
Helper::log(sprintf('chatbot_import_prepare[%s] reused cached bundle (no upstream pull)', $chat), 'ai-import', 'info');
}
$generated = isset($data['data']) && is_array($data['data']) ? $data['data'] : [];
// Access gate: the backend blocks a free user past their 7-day window.
if (isset($generated['can_import']) && ! $generated['can_import']) {
return $this->error('access_expired', __('Your free access to this generated site has ended. Upgrade your plan or purchase this template to import it.', 'templately'), 'ai-content/chatbot-import-prepare', 403);
}
$templates = isset($generated['templates']) && is_array($generated['templates']) ? $generated['templates'] : [];
// Pages the backend skipped (empty source JSON) or failed to generate.
// These will never appear in `templates`, so they must not be treated
// as "still generating" — without this, one skipped page keeps the poll
// pending until it times out.
$skipped_pages = isset($generated['skipped_pages']) && is_array($generated['skipped_pages']) ? array_map('strval', $generated['skipped_pages']) : [];
$skipped_expected = array_values(array_intersect($expected_ids, $skipped_pages));
// Which expected pages are still missing from the bundle?
$missing = array_values(array_filter($expected_ids, function ($id) use ($templates, $skipped_pages) {
return !array_key_exists($id, $templates) && !array_key_exists((int) $id, $templates) && !in_array($id, $skipped_pages, true);
}));
$ready = array_values(array_diff($expected_ids, $missing));
Helper::log(sprintf('chatbot_import_prepare[%s] warm start: ready=%d/%d missing=%d skipped=%d pull=%.2fs', $chat, count($ready), count($expected_ids), count($missing), count($skipped_expected), $pull_duration), 'ai-import', 'info');
// Derive a process_id for this chat-driven import and register process data
// so the existing validation/ai_get_json paths keep functioning.
$process_id = 'chat-' . $session_id;
$user = $this->utils('options')->get('user');
$ai_process_data = AIUtils::get_ai_process_data();
$record = [
'process_id' => $process_id,
'session_id' => $session_id,
'ai_page_ids' => $ai_page_ids,
'api_key' => $this->api_key,
'user_id' => isset($user['id']) ? $user['id'] : null,
'chat_id' => $chat,
];
if (!empty($pack_id)) {
$record['pack_id'] = $pack_id;
}
if (!empty($platform)) {
$record['platform'] = $platform;
}
// Persist the app-end answers exactly as an in-plugin conversation stores
// them, so reopening "Build with AI" resumes this session instead of
// starting over. Without this the record holds no answers at all and the
// sidebar has nothing to restore.
$conversation = AIUtils::build_conversation_fields(
$this->resolve_chat_conversation_fields($chat, $detected_info, $ai_process_data, $process_id)
);
if (!empty($conversation)) {
$record = array_merge($record, $conversation);
}
$ai_process_data[$process_id] = $record;
AIUtils::update_ai_process_data($ai_process_data);
// Persist each generated page to its .ai.json location for the import runners.
//
// Every page present in THIS pull is written immediately, even when others
// are still generating. Holding the writes back until the whole set was
// ready meant a single slow page threw away the full bundle on every poll
// — dozens of multi-hundred-KB pulls (all of `templates`, straight off GCP)
// discarded to save nothing. Writing as we go also lets the Finalizer
// runner finalize the pages that ARE ready instead of blocking on all of
// them. Already-written pages are skipped, so a re-poll is cheap.
$save_started = microtime(true);
$saved_count = 0;
$errors = [];
$processed_pages = get_option('templately_ai_processed_pages', []);
$already_saved = isset($processed_pages[$process_id]['pages']) ? $processed_pages[$process_id]['pages'] : [];
foreach ($templates as $content_id => $template) {
if (empty($template)) {
continue;
}
if (array_key_exists((string) $content_id, $already_saved)) {
$saved_count++;
continue;
}
// The runners read JSON strings; normalize arrays/objects to a string.
$template_payload = is_string($template) ? $template : wp_json_encode($template);
$result = AIUtils::save_template_to_file(
$process_id,
$session_id,
$content_id,
$template_payload,
$ai_page_ids,
false
);
if (is_wp_error($result)) {
$errors[$content_id] = $result->get_error_message();
continue;
}
if (isset($result['status']) && $result['status'] === 'success') {
$saved_count++;
} else {
$errors[$content_id] = isset($result['message']) ? $result['message'] : 'unknown';
}
}
// Write each backend-skipped page as an explicit `{"isSkipped": true}`
// marker (same shape the legacy per-page callback wrote) so the import
// runners fall back to the pack's default content instead of treating
// the page as missing.
$skipped_saved = [];
foreach ($skipped_expected as $skipped_id) {
if (array_key_exists($skipped_id, $templates) || array_key_exists((int) $skipped_id, $templates)) {
continue;
}
if (array_key_exists((string) $skipped_id, $already_saved)) {
$skipped_saved[] = $skipped_id;
continue;
}
$result = AIUtils::save_template_to_file(
$process_id,
$session_id,
$skipped_id,
'',
$ai_page_ids,
true
);
if (is_wp_error($result)) {
$errors[$skipped_id] = $result->get_error_message();
continue;
}
if (isset($result['status']) && $result['status'] === 'success') {
$skipped_saved[] = $skipped_id;
} else {
$errors[$skipped_id] = isset($result['message']) ? $result['message'] : 'unknown';
}
}
$save_duration = microtime(true) - $save_started;
Helper::log(sprintf('chatbot_import_prepare[%s] saved %d/%d pages in %.2fs (skipped=%d, errors=%d)', $chat, $saved_count, count($templates), $save_duration, count($skipped_saved), count($errors)), 'ai-import', 'info');
// NOTE: there is deliberately NO `pending` return here any more. Pages that
// are still generating come back in `missing` and are pulled on demand —
// and waited for — by the Finalizer. Returning `pending` made the client
// poll for minutes before the import could even start.
//
// NOT an error when nothing was saved: with the wait deferred to the
// Finalizer it is legitimate for zero pages to be ready at import start.
// Only a genuine write failure (something was ready but every save
// errored) is fatal.
if ($saved_count === 0 && empty($skipped_saved) && !empty($errors)) {
return $this->error('save_failed', __('Failed to save generated content.', 'templately'), 'ai-content/chatbot-import-prepare', 500, ['errors' => $errors]);
}
Helper::log(sprintf('chatbot_import_prepare[%s] returning: pages=%d missing=%d pull=%.2fs', $chat, count($templates), count($missing), $pull_duration), 'ai-import', 'info');
// Import the logo into the media library and map it into the customization.
$customization = isset($generated['customization_data']) && is_array($generated['customization_data']) ? $generated['customization_data'] : [];
$logo = null;
$logo_url = !empty($generated['logo_url']) ? esc_url_raw($generated['logo_url']) : '';
if (!empty($logo_url)) {
$logo_started = microtime(true);
$uploaded = Utils::upload_logo($logo_url, $session_id);
Helper::log(sprintf('chatbot_import_prepare[%s] logo upload in %.2fs', $chat, microtime(true) - $logo_started), 'ai-import', 'info');
if (!empty($uploaded['id'])) {
$logo = [
'id' => (int) $uploaded['id'],
'url' => $uploaded['url'],
];
} elseif (!empty($uploaded['error'])) {
// Logo is non-fatal: log and continue without it.
Helper::log('chatbot_import_prepare logo upload failed: ' . $uploaded['error']);
}
}
// Reflect the imported logo back into the customization payload so React
// can build the settings FormData from a single source.
if (!empty($logo)) {
$customization['logo'] = $logo;
}
Helper::log(sprintf('chatbot_import_prepare[%s] done in %.2fs total', $chat, microtime(true) - $handler_started), 'ai-import', 'info');
return [
'status' => 'success',
'data' => [
'session_id' => $session_id,
'process_id' => $process_id,
'ai_page_ids' => $ai_page_ids,
'saved' => $saved_count,
// Pages the backend explicitly skipped — imported with the
// pack's default content instead.
'skipped' => $skipped_expected,
// Readiness snapshot at import start. `missing` pages are NOT a
// failure: the Finalizer pulls each on demand and waits for it.
'expected' => $expected_ids,
'ready' => $ready,
'missing' => $missing,
'platform' => isset($customization['platform']) ? $customization['platform'] : null,
'customization_data' => $customization,
'logo' => $logo,
'errors' => $errors,
],
];
}
/**
* Validate API key against database
* Checks if the provided API key exists for any user on the current site
* Handles both single-site and multisite WordPress installations
*
* @param string $api_key The API key to validate
* @return bool True if valid, false otherwise
*/
private function validate_api_key_in_db($api_key) {
global $wpdb;
$api_key = sanitize_text_field($api_key);
if (empty($api_key)) {
return false;
}
$meta_key = '_templately_api_key';
// Handle multisite: key will have site prefix in multisite
if (is_multisite()) {
// get_user_option() uses the format: {$wpdb->base_prefix}{$blog_id}_{$meta_key}
// For current blog, we need to check with the current blog prefix
$blog_id = get_current_blog_id();
$meta_key = $wpdb->get_blog_prefix($blog_id) . $meta_key;
}
// Query to check if this API key exists for any user
$query = $wpdb->prepare(
"SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1",
$meta_key,
$api_key
);
$user_id = $wpdb->get_var($query);
return !empty($user_id);
}
}