File "Chat.php"

Full Path: /home/buyiwexj/public_html/wp-content/plugins/wpforms-lite/src/Integrations/AI/Admin/Chat/Chat.php
File size: 14.52 KB
MIME-type: text/x-php
Charset: utf-8

<?php

namespace WPForms\Integrations\AI\Admin\Chat;

use WPForms\Integrations\AI\Admin\Ajax\Chat\Chat as ChatAjax;
use WPForms\Integrations\AI\Helpers;

/**
 * WPForms AI Chat — admin page handler / loader.
 *
 * Triggers the two registries' singleton init (they own their defaults and
 * dispatch registration filters for extension), then — when the current admin
 * screen matches an enabled surface — enqueues the chat element + chat.js,
 * renders the FAB / modal templates, and emits the two localized JS objects.
 *
 * Loaded by `\WPForms\Integrations\AI\AI::load()`.
 *
 * @since 2.0.0
 */
class Chat {

	/**
	 * Localized JS object name. Must match the chat.js consumer expectation.
	 *
	 * @since 2.0.0
	 */
	private const LOCALIZE_OBJECT = 'wpforms_ai_chat';

	/**
	 * Chat element handle — shared with the form editor.
	 *
	 * @since 2.0.0
	 */
	private const CHAT_ELEMENT_HANDLE = 'wpforms-ai-chat-element';

	/**
	 * Admin chat asset handle (JS + CSS share the handle).
	 *
	 * @since 2.0.0
	 */
	private const CHAT_HANDLE = 'wpforms-ai-chat';

	/**
	 * The active surface config (with `slug` injected by SurfaceRegistry), or null
	 * when the current admin screen has no matching enabled surface.
	 *
	 * @since 2.0.0
	 *
	 * @var array|null
	 */
	private $surface;

	/**
	 * Initialize.
	 *
	 * Boots the chat registries and scope/surface classes via `wpforms()->register_bulk()`,
	 * then captures the active surface (if any) and attaches page-render hooks.
	 *
	 * @since 2.0.0
	 */
	public function init(): void {

		$this->loader();

		$surface_registry = wpforms()->obj( 'ai_chat_surface_registry' );

		$this->surface = $surface_registry instanceof SurfaceRegistry
			? $surface_registry->get_active_surface()
			: null;

		if ( $this->surface !== null ) {
			$this->hooks();
		}
	}

	/**
	 * Register the AI Chat registries, scopes, and surfaces through `wpforms()->register_bulk()`.
	 *
	 * The Loader's Pro/Lite namespace switch resolves each class to its Pro variant when present,
	 * falling back to the Lite class. Pure-Pro classes (Analytics, AnalyticsPage) silently no-op
	 * on Lite because no class is found at either resolved path.
	 *
	 * @since 2.0.0
	 */
	private function loader(): void {

		$classes = [
			// Registries first — `hook => false` runs the callback immediately
			// so the registries are available before scopes/surfaces self-register.
			[
				'name' => 'Integrations\AI\Admin\Chat\ScopeRegistry',
				'id'   => 'ai_chat_scope_registry',
				'hook' => false,
			],
			[
				'name' => 'Integrations\AI\Admin\Chat\SurfaceRegistry',
				'id'   => 'ai_chat_surface_registry',
				'hook' => false,
			],

			// Scopes — each init() self-registers into the scope registry.
			[
				'name' => 'Integrations\AI\Admin\Chat\Scope\WPFormsGeneral',
				'hook' => false,
			],
			[
				'name' => 'Integrations\AI\Admin\Chat\Scope\FormsInventory\FormsInventory',
				'hook' => false,
			],
			[
				'name' => 'Integrations\AI\Admin\Chat\Scope\Analytics\Analytics',
				'hook' => false,
			],

			// Surfaces.
			[
				'name' => 'Integrations\AI\Admin\Chat\Surface\AnalyticsPage',
				'hook' => false,
			],
		];

		wpforms()->register_bulk( $classes );
	}

	/**
	 * Register page-render hooks for the active surface.
	 *
	 * @since 2.0.0
	 */
	private function hooks(): void {

		add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] );
		add_action( 'admin_footer', [ $this, 'render_templates' ] );
		add_filter( 'admin_body_class', [ $this, 'filter_admin_body_class' ] );
		add_filter( 'wpforms_integrations_ai_admin_chat_chat_get_localize_chat_data', [ $this, 'get_chat_mode_strings' ] );
	}

	/**
	 * Append the surface's body class.
	 *
	 * @since 2.0.0
	 *
	 * @param string|mixed $classes Existing body classes.
	 *
	 * @return string
	 */
	public function filter_admin_body_class( $classes ): string {

		$classes    = (string) $classes;
		$body_class = (string) ( $this->surface['body_class'] ?? '' );

		if ( $body_class === '' ) {
			return $classes;
		}

		if ( ! $this->user_can_view() ) {
			return $classes;
		}

		return trim( $classes . ' ' . $body_class );
	}

	/**
	 * Enqueue the chat element + chat.js + per-surface CSS, and emit both localized objects.
	 *
	 *   - `wpforms_ai_chat_element` — chat-element strings + modules + nonce + actions.
	 *   - `wpforms_ai_chat`         — admin chat surface config consumed by chat.js.
	 *
	 * `chat-helpers-admin.js` is loaded as a dynamic ES module via the chat
	 * element's `modules` array, not as a classic script.
	 *
	 * @since 2.0.0
	 */
	public function enqueue_assets(): void {

		if ( ! $this->user_can_view() ) {
			return;
		}

		$min = wpforms_get_min_suffix();

		// DOMPurify — required by chat-helpers-admin's sanitizeHtmlAnswer().
		wp_enqueue_script(
			'dom-purify',
			WPFORMS_PLUGIN_URL . 'assets/lib/purify.min.js',
			[],
			'3.4.1',
			false
		);

		// Chat element (CSS + JS). `wp-i18n` is a dep so dynamically-imported modules can call wp.i18n.__().
		wp_enqueue_style(
			self::CHAT_ELEMENT_HANDLE,
			WPFORMS_PLUGIN_URL . "assets/css/integrations/ai/chat-element$min.css",
			[],
			WPFORMS_VERSION
		);

		wp_enqueue_script(
			self::CHAT_ELEMENT_HANDLE,
			WPFORMS_PLUGIN_URL . "assets/js/integrations/ai/chat-element/wpforms-ai-chat-element$min.js",
			[ 'dom-purify', 'wp-i18n' ],
			WPFORMS_VERSION,
			false
		);

		// Admin chat CSS.
		wp_enqueue_style(
			self::CHAT_HANDLE,
			WPFORMS_PLUGIN_URL . "assets/css/integrations/ai/wpforms-ai-chat$min.css",
			[],
			WPFORMS_VERSION
		);

		// Shared modal drag/resize utility (WPForms.Admin.AIChatModal) — reused by chat.js and the form editor.
		wp_enqueue_script(
			'wpforms-admin-chat-modal',
			WPFORMS_PLUGIN_URL . "assets/js/admin/share/chat-modal$min.js",
			[],
			WPFORMS_VERSION,
			true
		);

		// Admin chat JS — chat.js IIFE that wires FAB, modal, drag, resize, chat-element.
		// Note: chat-helpers-admin is not listed as a dep — it is dynamically imported by the
		// chat element via the wpforms_ai_chat_element.modules array.
		wp_enqueue_script(
			self::CHAT_HANDLE,
			WPFORMS_PLUGIN_URL . "assets/js/integrations/ai/chat/chat$min.js",
			[
				'jquery',
				'jquery-ui-draggable',
				'jquery-ui-resizable',
				'wp-util',
				'wp-hooks',
				'wpforms-admin-chat-modal',
				self::CHAT_ELEMENT_HANDLE,
			],
			WPFORMS_VERSION,
			true
		);

		wp_localize_script(
			self::CHAT_ELEMENT_HANDLE,
			'wpforms_ai_chat_element',
			$this->get_localize_chat_data()
		);

		// Localize the admin chat surface config — chat.js and chat-helpers-admin read this.
		wp_localize_script(
			self::CHAT_HANDLE,
			self::LOCALIZE_OBJECT,
			$this->get_localize_data()
		);
	}

	/**
	 * Render the FAB + modal templates.
	 *
	 * @since 2.0.0
	 */
	public function render_templates(): void {

		if ( ! $this->user_can_view() ) {
			return;
		}

		// Templates are passive `<script type="text/html">` blocks; chat.js wp.template()s them.
		require WPFORMS_PLUGIN_DIR . 'templates/integrations/ai/chat.php';
	}

	/**
	 * Get the localize data for the chat element on admin chat surfaces.
	 *
	 * Independent of the form-editor's `\WPForms\Integrations\AI\Admin\Builder\Enqueues::get_localize_chat_data`
	 * to avoid coupling admin-area surfaces to the builder. Some strings are intentionally
	 * duplicated to keep the two surfaces decoupled.
	 *
	 * Modules array contains only:
	 *   - `api` — required infrastructure (chat element extracts it as `WPFormsAi.api`).
	 *   - `admin` — the admin chat mode helper.
	 *
	 * @since 2.0.0
	 *
	 * @return array
	 */
	private function get_localize_chat_data(): array {

		$min = wpforms_get_min_suffix();

		// The chat element's api.js is used only for the rate endpoint in admin mode
		// (prompts route through dispatch.js with the wpforms_ai_chat nonce). The rate
		// handler ( Ajax\Base::rate_response() ) validates the shared 'wpforms-ai-nonce'
		// action, so the chat element must carry that nonce — matching the builder surfaces.
		$strings = Helpers::get_chat_element_strings(
			'wpforms-ai-nonce',
			[ 'admin' => ChatAjax::AJAX_ACTION ]
		);

		$strings['modules'] = [
			[
				'name' => 'api',
				'path' => "./modules/api$min.js",
			],
			[
				'name' => 'admin',
				'path' => "../chat/modules/chat-helpers-admin$min.js",
			],
			[
				'name' => 'adminBlocks',
				'path' => "../chat/modules/blocks$min.js",
			],
			[
				'name' => 'adminDispatch',
				'path' => "../chat/modules/dispatch$min.js",
			],
			[
				'name' => 'adminPageState',
				'path' => "../chat/modules/page-state$min.js",
			],
			[
				'name' => 'adminRenderer',
				'path' => "../chat/modules/renderer$min.js",
			],
			[
				'name' => 'adminUi',
				'path' => "../chat/modules/ui$min.js",
			],
		];

		// Always-present fallback surface module. The chat element uses it
		// when the active surface does not register its own JS module
		// (third-party admin page, Pro-only surface inactive, etc.).
		$strings['modules'][] = [
			'name' => 'surfaceAdmin',
			'path' => "../chat/modules/surface-admin$min.js",
		];

		// Active surface's JS module (if it registers one). Lazy loaded:
		// only the active surface's module ships per page, keeping the
		// admin page payload minimal.
		$active_surface = $this->resolve_active_surface();

		if ( $active_surface !== null ) {
			$js_module = $active_surface->get_js_module();

			if ( is_string( $js_module ) && $js_module !== '' ) {
				$strings['modules'][]     = [
					'name' => $active_surface->get_js_helper_name(),
					'path' => $js_module,
				];
				$strings['activeSurface'] = $active_surface->get_js_helper_name();
			}
		}

		// Cache-bust the dynamically imported ES modules. The chat element loads
		// them via `import( path )` with bare relative paths, so without a version
		// query the browser caches each module by URL indefinitely and never picks
		// up plugin updates. Append the plugin version so each release invalidates it.
		foreach ( $strings['modules'] as $index => $module ) {
			$separator = strpos( $module['path'], '?' ) === false ? '?' : '&';

			$strings['modules'][ $index ]['path'] = $module['path'] . $separator . 'ver=' . WPFORMS_VERSION;
		}

		/**
		 * Filters the admin AI chat element localize data.
		 *
		 * Mode-specific strings (e.g. the `admin` mode block) are added through this
		 * filter so the registration pattern mirrors the form-editor surface.
		 *
		 * @since 2.0.0
		 *
		 * @param array $strings Localize data.
		 */
		return (array) apply_filters( 'wpforms_integrations_ai_admin_chat_chat_get_localize_chat_data', $strings );
	}

	/**
	 * Add chat mode strings for the `admin` mode.
	 *
	 * Hooked into the admin chat-data filter so the admin chat surface follows
	 * the same registration pattern as the form-editor mode (see
	 * `\WPForms\Integrations\AI\Admin\Builder\FormEditor::add_chat_mode_strings`).
	 *
	 * The chat element reads `wpforms_ai_chat_element.admin.*` via `this.modeStrings.*`;
	 * without these the welcome screen renders "undefined" labels and the input
	 * crashes on the placeholder string. Sample-prompt defaults are JS-side and
	 * extended via `wp.hooks` from `chat-helpers-admin.js`.
	 *
	 * @since 2.0.0
	 *
	 * @param array|mixed $strings Localize strings.
	 *
	 * @return array
	 */
	public function get_chat_mode_strings( $strings ): array {

		$strings = (array) $strings;

		$strings['admin'] = [
			'placeholder'        => esc_html__( 'Ask WPForms AI…', 'wpforms-lite' ),
			'title'              => esc_html__( 'WPForms AI', 'wpforms-lite' ),
			'description'        => esc_html__( 'Ask a question about your form\'s performance. Get insights on trends, drop-off points, and ways to improve conversions.', 'wpforms-lite' ),
			'samplePromptsTitle' => esc_html__( 'Example Questions:', 'wpforms-lite' ),
			'responseButtons'    => [
				'like'    => esc_html__( 'Helpful', 'wpforms-lite' ),
				'dislike' => esc_html__( 'Not helpful', 'wpforms-lite' ),
				'retry'   => esc_html__( 'Regenerate', 'wpforms-lite' ),
				'clear'   => esc_html__( 'Clear chat', 'wpforms-lite' ),
			],
		];

		return $strings;
	}

	/**
	 * Assemble the wpforms_ai_chat localize payload.
	 *
	 * @since 2.0.0
	 *
	 * @return array
	 */
	private function get_localize_data(): array {

		$scopes = (array) ( $this->surface['default_scopes'] ?? [] );

		return [
			'ajaxUrl'     => admin_url( 'admin-ajax.php' ),
			'action'      => ChatAjax::AJAX_ACTION,
			'nonce'       => wp_create_nonce( ChatAjax::AJAX_ACTION ),
			'surface'     => (string) ( $this->surface['slug'] ?? '' ),
			'bodyClass'   => (string) ( $this->surface['body_class'] ?? '' ),
			'colorScheme' => (string) ( $this->surface['color_scheme'] ?? 'light' ),
			'mode'        => 'admin',
			'scopes'      => $this->filter_scopes_for_current_user( $scopes ),
			'strings'     => [
				'errorGeneric' => esc_html__( 'Something went wrong. Please try again.', 'wpforms-lite' ),
				'errorNonce'   => esc_html__( 'Session expired. Refresh the page and try again.', 'wpforms-lite' ),
			],
		];
	}

	/**
	 * Reduce a scope-slug list to those the current user has capability for.
	 *
	 * @since 2.0.0
	 *
	 * @param array $scopes Scope slugs.
	 *
	 * @return array Re-indexed list of accessible scope slugs.
	 */
	private function filter_scopes_for_current_user( array $scopes ): array {

		$registry = wpforms()->obj( 'ai_chat_scope_registry' );

		if ( ! $registry instanceof ScopeRegistry ) {
			return [];
		}

		$allowed = [];

		foreach ( $scopes as $scope_slug ) {
			$scope = $registry->get( (string) $scope_slug );

			if ( ! $scope instanceof ScopeBase ) {
				continue;
			}

			if ( ! wpforms_current_user_can( $scope->get_capability() ) ) {
				continue;
			}

			$allowed[] = $scope_slug;
		}

		return $allowed;
	}

	/**
	 * Resolve the currently active surface instance.
	 *
	 * Reuses the slug already cached in `$this->surface` (set by `init()` from
	 * `SurfaceRegistry::get_active_surface()`) and fetches the corresponding
	 * instance from the registry. Returns null when the surface is inactive
	 * or the registry is unavailable — in which case the chat element falls
	 * back to the `surface-admin` no-op module.
	 *
	 * @since 2.0.0
	 *
	 * @return SurfaceBase|null
	 */
	private function resolve_active_surface(): ?SurfaceBase {

		$slug = (string) ( $this->surface['slug'] ?? '' );

		if ( $slug === '' ) {
			return null;
		}

		$registry = wpforms()->obj( 'ai_chat_surface_registry' );

		if ( ! $registry instanceof SurfaceRegistry ) {
			return null;
		}

		return $registry->get( $slug );
	}

	/**
	 * Whether the current user can view the chat on this surface.
	 *
	 * @since 2.0.0
	 *
	 * @return bool
	 */
	private function user_can_view(): bool {

		$capability = (string) ( $this->surface['capability'] ?? 'manage_options' );

		return wpforms_current_user_can( $capability );
	}
}