[ 'label' => 'Midnight', 'base' => '#0f1117', 'accent' => '#6366f1' ], 'monoDark' => [ 'label' => 'Mono Dark', 'base' => '#09090b', 'accent' => '#fafafa' ], 'forest' => [ 'label' => 'Forest', 'base' => '#1b362c', 'accent' => '#22c55e' ], 'cherry' => [ 'label' => 'Cherry', 'base' => '#ffffff', 'accent' => '#ef4444' ], 'cherryDark' => [ 'label' => 'Cherry Dark', 'base' => '#440b19', 'accent' => '#ef4444' ], 'slate' => [ 'label' => 'Slate', 'base' => '#334155', 'accent' => '#38bdf8' ], 'amber' => [ 'label' => 'Amber', 'base' => '#3d2c15', 'accent' => '#f59e0b' ], 'sunrise' => [ 'label' => 'Sunrise', 'base' => '#ffffff', 'accent' => '#f97316' ], 'ivory' => [ 'label' => 'Ivory', 'base' => '#f1ede5', 'accent' => '#e08229' ], 'sky' => [ 'label' => 'Sky', 'base' => '#ffffff', 'accent' => '#5ba1da' ], 'lavender' => [ 'label' => 'Lavender', 'base' => '#faf5ff', 'accent' => '#8b5cf6' ], 'nice' => [ 'label' => 'Nice', 'base' => '#ffffff', 'accent' => '#3f7e83' ], 'navy' => [ 'label' => 'Navy', 'base' => '#ebf3f2', 'accent' => '#bed6dd' ], 'graphite' => [ 'label' => 'Graphite', 'base' => '#313949', 'accent' => '#4185eb' ], 'stone' => [ 'label' => 'Stone', 'base' => '#44403c', 'accent' => '#fbbf24' ], 'olive' => [ 'label' => 'Olive', 'base' => '#2e3220', 'accent' => '#a3b14b' ], 'mint' => [ 'label' => 'Mint', 'base' => '#f4fbf7', 'accent' => '#0f9d63' ], 'rose' => [ 'label' => 'Rose', 'base' => '#fff7f9', 'accent' => '#db2777' ], 'linen' => [ 'label' => 'Linen', 'base' => '#fcfbf8', 'accent' => '#d6e6e0' ], 'mono' => [ 'label' => 'Mono', 'base' => '#ffffff', 'accent' => '#18181b' ], ]; } // ─── Defaults ──────────────────────────────────────────────────────────────── function elia_assistant_defaults(): array { return [ 'widget_id' => '', 'enabled' => '1', 'show_floating' => '1', 'language_override_enabled' => '0', 'language_override' => '', 'is_test' => '0', ]; } // ─── Sanitize ───────────────────────────────────────────────────────────────── function elia_assistant_sanitize( $raw ): array { $out = []; // options.php has already verified the settings nonce before it calls this sanitize callback. // phpcs:ignore WordPress.Security.NonceVerification.Missing $disconnect = ! empty( $_POST['ec_disconnect'] ); $widget_id = strtolower( trim( sanitize_text_field( $raw['widget_id'] ?? '' ) ) ); if ( $disconnect ) { $widget_id = ''; } elseif ( $widget_id !== '' && ! elia_assistant_is_widget_id( $widget_id ) ) { add_settings_error( ELIA_ASSISTANT_OPTION, 'elia_assistant_widget_id', __( 'This is not a widget ID. Copy it again from the WordPress card in the ELIA Assistant dashboard.', 'elia-assistant' ) ); // Keep the ID that worked rather than replacing it with a typo. $widget_id = elia_assistant_widget_id( (array) get_option( ELIA_ASSISTANT_OPTION, [] ) ); } $out['widget_id'] = $widget_id; $out['enabled'] = empty( $raw['enabled'] ) ? '0' : '1'; $out['show_floating'] = empty( $raw['show_floating'] ) ? '0' : '1'; $language = sanitize_key( $raw['language_override'] ?? '' ); $out['language_override'] = array_key_exists( $language, elia_assistant_languages() ) ? $language : ''; $out['language_override_enabled'] = ! empty( $raw['language_override_enabled'] ) && $out['language_override'] !== '' ? '1' : '0'; $out['is_test'] = empty( $raw['is_test'] ) ? '0' : '1'; return $out; } // ─── Widget id ──────────────────────────────────────────────────────────────── /** * The widget id is the id of the agent whose chat widget the site shows, copied from the WordPress * card in the dashboard. It is public: the dashboard's embed snippet carries the same value. */ function elia_assistant_is_widget_id( string $value ): bool { return (bool) preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value ); } function elia_assistant_widget_id( array $opts ): string { $widget_id = isset( $opts['widget_id'] ) && is_string( $opts['widget_id'] ) ? $opts['widget_id'] : ''; return elia_assistant_is_widget_id( $widget_id ) ? $widget_id : ''; } // ─── Language detection ─────────────────────────────────────────────────────── /** * Returns the 2-letter code of the language the current page is in. * Asks the language plugin first: WPML (whose wpml_current_language filter Polylang answers too), * Polylang, then Weglot, which translates the page without switching the WordPress locale. Then the * locale, which TranslatePress and the others switch per page. Codes like pt-br or zh_CN are cut * to their language part. A site with another language plugin can say which language the page is * in through the elia_assistant_language filter. */ function elia_assistant_current_language(): string { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's own filter, read here, not defined by this plugin. $candidates = [ apply_filters( 'wpml_current_language', null ) ]; if ( function_exists( 'pll_current_language' ) ) { $candidates[] = pll_current_language( 'slug' ); } if ( function_exists( 'weglot_get_current_language' ) ) { $candidates[] = weglot_get_current_language(); } $candidates[] = get_locale(); $lang = 'en'; foreach ( $candidates as $candidate ) { // WPML answers "all" in the admin when every language is listed; that is not a language. $code = is_string( $candidate ) ? strtolower( (string) preg_split( '/[-_]/', $candidate )[0] ) : ''; if ( preg_match( '/^[a-z]{2}$/', $code ) ) { $lang = $code; break; } } $filtered = apply_filters( 'elia_assistant_language', $lang ); return is_string( $filtered ) && preg_match( '/^[a-z]{2}$/', $filtered ) ? $filtered : $lang; } function elia_assistant_resolve_language( array $opts ): string { if ( ( $opts['language_override_enabled'] ?? '0' ) === '1' && ! empty( $opts['language_override'] ) ) { return $opts['language_override']; } return elia_assistant_current_language(); } // ─── WooCommerce detection and read-only key ───────────────────────────────── /** * Returns WooCommerce store info when the plugin is active, or null. * class_exists() is enough - by the time admin_menu runs, an active WooCommerce * has already loaded. */ function elia_assistant_woocommerce_info(): ?array { if ( ! class_exists( 'WooCommerce' ) ) { return null; } return [ 'version' => defined( 'WC_VERSION' ) ? WC_VERSION : '', // The value to enter as "Store URL" in ELIA: the MCP server appends // /wp-json/wc/v3/, which is where WordPress mounts REST routes. 'store_url' => home_url(), ]; } function elia_assistant_wc_table(): string { global $wpdb; return $wpdb->prefix . 'woocommerce_api_keys'; } function elia_assistant_wc_transient_key(): string { return 'elia_assistant_wc_new_key_' . get_current_user_id(); } /** * Returns the stored key metadata, but only if the key still exists in WooCommerce. * Someone may have deleted it from WooCommerce → Settings → Advanced → REST API. */ function elia_assistant_wc_stored_key(): ?array { $stored = get_option( ELIA_ASSISTANT_WC_OPTION ); if ( ! is_array( $stored ) || empty( $stored['key_id'] ) ) { return null; } global $wpdb; $table = elia_assistant_wc_table(); // The table name is $wpdb->prefix plus a constant, so it is safe to interpolate. The lookup must // hit the live table (the key may have been deleted in WooCommerce), so it is not cached. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $row = $wpdb->get_row( $wpdb->prepare( "SELECT truncated_key, permissions FROM {$table} WHERE key_id = %d", (int) $stored['key_id'] ), ARRAY_A ); // phpcs:enable return $row ? array_merge( $stored, $row ) : null; } function elia_assistant_wc_redirect( string $notice ): void { // Back to the WooCommerce block, not the top of the page: the new key or the notice is there. wp_safe_redirect( add_query_arg( [ 'page' => 'elia-assistant', 'ec_wc' => $notice ], admin_url( 'options-general.php' ) ) . '#elia-wc-title' ); exit; } /** * Creates a read-only WooCommerce REST key, exactly as the WooCommerce admin screen does. * WooCommerce exposes no public API for this, hence the direct insert. * The consumer key is stored one-way hashed, so the full value only exists here - it is * handed to the transient for a single display and never persisted by this plugin. */ function elia_assistant_wc_generate(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'elia-assistant' ) ); } check_admin_referer( 'elia_assistant_wc_generate' ); if ( ! class_exists( 'WooCommerce' ) || ! function_exists( 'wc_rand_hash' ) || ! function_exists( 'wc_api_hash' ) ) { elia_assistant_wc_redirect( 'unavailable' ); } global $wpdb; $table = elia_assistant_wc_table(); // Regenerating replaces the old key rather than leaving it live. $previous = get_option( ELIA_ASSISTANT_WC_OPTION ); // WooCommerce has no public API for creating or deleting REST keys, so this mirrors what its admin screen does. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( is_array( $previous ) && ! empty( $previous['key_id'] ) ) { $wpdb->delete( $table, [ 'key_id' => (int) $previous['key_id'] ], [ '%d' ] ); } $consumer_key = 'ck_' . wc_rand_hash(); $consumer_secret = 'cs_' . wc_rand_hash(); $inserted = $wpdb->insert( $table, [ 'user_id' => get_current_user_id(), 'description' => 'ELIA Assistant', 'permissions' => 'read', 'consumer_key' => wc_api_hash( $consumer_key ), 'consumer_secret' => $consumer_secret, 'truncated_key' => substr( $consumer_key, -7 ), ], [ '%d', '%s', '%s', '%s', '%s', '%s' ] ); // phpcs:enable if ( ! $inserted ) { delete_option( ELIA_ASSISTANT_WC_OPTION ); elia_assistant_wc_redirect( 'failed' ); } update_option( ELIA_ASSISTANT_WC_OPTION, [ 'key_id' => (int) $wpdb->insert_id, 'truncated' => substr( $consumer_key, -7 ), 'created' => time(), ], false ); set_transient( elia_assistant_wc_transient_key(), [ 'consumer_key' => $consumer_key, 'consumer_secret' => $consumer_secret, ], 120 ); elia_assistant_wc_redirect( 'generated' ); } function elia_assistant_wc_revoke(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'elia-assistant' ) ); } check_admin_referer( 'elia_assistant_wc_revoke' ); $stored = get_option( ELIA_ASSISTANT_WC_OPTION ); if ( is_array( $stored ) && ! empty( $stored['key_id'] ) ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->delete( elia_assistant_wc_table(), [ 'key_id' => (int) $stored['key_id'] ], [ '%d' ] ); } delete_option( ELIA_ASSISTANT_WC_OPTION ); delete_transient( elia_assistant_wc_transient_key() ); elia_assistant_wc_redirect( 'revoked' ); } add_action( 'admin_post_elia_assistant_wc_generate', 'elia_assistant_wc_generate' ); add_action( 'admin_post_elia_assistant_wc_revoke', 'elia_assistant_wc_revoke' ); // ─── Remote config fetch ───────────────────────────────────────────────────── /** * Fetch the widget's saved config from the ELIA API, for the preview on the settings page. The site * does not need it: widget.js loads the config by widget id in the visitor's browser. * Returns the decoded response array on success, an array with '_status'=>'not_found' on HTTP 404 * (no widget with this id, or it was never saved), or null on error / no widget id. * Not cached: it runs once each time the settings page opens, so the preview is always current. */ function elia_assistant_fetch_remote_config(): ?array { $opts = array_merge( elia_assistant_defaults(), (array) get_option( ELIA_ASSISTANT_OPTION, [] ) ); $widget_id = elia_assistant_widget_id( $opts ); if ( $widget_id === '' ) { return null; } $api_url = untrailingslashit( ELIA_ASSISTANT_API_URL ) . '/public/chat-widget/' . rawurlencode( $widget_id ); $response = wp_remote_get( $api_url, [ 'timeout' => 10 ] ); if ( is_wp_error( $response ) ) { return null; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code === 404 ) { return [ '_status' => 'not_found' ]; } if ( $code !== 200 ) { return null; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $body ) || ! isset( $body['config'] ) || ! is_array( $body['config'] ) ) { return null; } return $body; } // ─── Admin: helpers ─────────────────────────────────────────────────────────── /** * Languages the chat widget ships translations for, with their own names. */ function elia_assistant_languages(): array { return [ 'bg' => 'Български', 'cs' => 'Čeština', 'da' => 'Dansk', 'de' => 'Deutsch', 'el' => 'Ελληνικά', 'en' => 'English', 'es' => 'Español', 'et' => 'Eesti', 'fi' => 'Suomi', 'fr' => 'Français', 'hr' => 'Hrvatski', 'hu' => 'Magyar', 'it' => 'Italiano', 'lt' => 'Lietuvių', 'lv' => 'Latviešu', 'nl' => 'Nederlands', 'pl' => 'Polski', 'pt' => 'Português', 'ro' => 'Română', 'ru' => 'Русский', 'sk' => 'Slovenčina', 'sl' => 'Slovenščina', 'sv' => 'Svenska', 'uk' => 'Українська', ]; } /** * Reads a text the dashboard may store per language (config.i18n[lang][key]) or globally. */ function elia_assistant_config_text( array $config, string $key, string $lang ): string { $i18n = isset( $config['i18n'] ) && is_array( $config['i18n'] ) ? $config['i18n'] : []; foreach ( [ $lang, 'en' ] as $code ) { if ( isset( $i18n[ $code ][ $key ] ) && is_string( $i18n[ $code ][ $key ] ) && trim( $i18n[ $code ][ $key ] ) !== '' ) { return trim( $i18n[ $code ][ $key ] ); } } if ( isset( $config[ $key ] ) && is_string( $config[ $key ] ) ) { return trim( $config[ $key ] ); } return ''; } /** * Picks dark or light text for a background colour, so the preview bubble stays readable on every theme. */ function elia_assistant_text_on( string $hex ): string { $hex = ltrim( $hex, '#' ); if ( strlen( $hex ) !== 6 ) { return '#1d2327'; } [ $r, $g, $b ] = array_map( 'hexdec', str_split( $hex, 2 ) ); return ( 0.299 * $r + 0.587 * $g + 0.114 * $b ) > 150 ? '#1d2327' : '#ffffff'; } function elia_assistant_mode_label( string $mode ): string { switch ( $mode ) { case 'fullscreen': return __( 'opens full screen', 'elia-assistant' ); case 'mixed': return __( 'chat window that can expand to full screen', 'elia-assistant' ); default: return __( 'chat window in the corner', 'elia-assistant' ); } } /** * The ELIA mark (frontend/public/favicon.svg) with its transforms flattened, drawn inline so nothing * is loaded from outside. */ function elia_assistant_mark(): string { return ''; } /** * Save and Discard for a block whose changes are saved only when the admin is happy with them * (data-elia-manual). admin.js shows it once something in the block differs from what is saved; * without JS it stays hidden and the form's own Save changes button is shown instead. */ function elia_assistant_block_save_bar(): void { ?>
%1$s %2$s %4$s