array( 'Trợ lý ảo' ), 'welcome' => array( 'Xin chào! Tôi có thể giúp gì cho bạn?' ), 'placeholder' => array( 'Nhập câu hỏi của bạn...' ), 'system_prompt' => array( 'Bạn là nhân viên hỗ trợ khách hàng. Trả lời ngắn gọn, chính xác, bằng tiếng Việt.', 'You are a customer support agent. Answer concisely and accurately.', ), 'fallback_message' => array( 'Xin lỗi, hệ thống đang bận. Bạn vui lòng thử lại sau ít phút.' ), ); /** * The default system prompt — the guideline, in one paragraph (1.18.0). * * WHY IT CHANGED, AND WHAT IT FIXES * * The old default was one sentence: *"You are a customer support agent. Answer concisely and accurately."* * It said nothing about the three things the owner hit on a real site: * * 1. **politeness was answered as a question.** "thanks for your support" came back as *"this question is * outside the information this website holds"* — the lock refusing a thank-you. The lock now knows * politeness (`MYBOT_Languages::COURTESY`), and the prompt tells the model what to do with it; * 2. **"concise" was not a length.** A model asked to be concise writes a page; asked for **two or three * sentences, never more than five**, it writes two or three sentences; * 3. **nothing said what to do when it does not know.** The RAG note and the lock say *answer only from * the website*; this says what to do INSTEAD — say so, offer a human, and never invent a price, a * date, a policy or a phone number. A made-up price is worse than "I do not know". * * The full guideline is `docs/17-huong-dan-tra-loi-cua-tro-ly.md`; this is the executable summary, and the * owner can replace it in Settings → AI tuning → System prompt. * * @var string */ const SYSTEM_PROMPT = 'You are the customer support assistant of this website. Be brief and warm: answer in two or three sentences, never more than five. If the visitor greets you or thanks you, answer the greeting or the thanks and offer to help — never treat it as a question. If you do not know something, say so plainly and offer to pass the question to a member of staff; never guess, and never invent a price, a date, a policy or a phone number. Answer in the language the visitor used in their last message.'; /** * Default language used before 1.4.2. * * The widget is the first thing a visitor sees, and Vietnamese was the wrong guess for a * site whose audience is unknown, so the default moved to English. This constant exists so * an install that never picked a language can be switched once, while a language the site * owner chose deliberately is left alone. * * @var string */ const LEGACY_DEFAULT_LANGUAGE = 'vi'; /** * Minimum score that 1.5.3 replaced. * * 0.72 filtered out every chunk on a real site, so the knowledge base was searched and * then ignored: the answer came from the model and the widget showed no source at all. * A stored value nobody chose is upgraded once; a value the administrator picked is kept. */ const LEGACY_MIN_SCORE = 0.72; /** * Decrypted record, cached in memory for the entire request. * * @var array|null */ protected $runtime = null; /** * Default values. * * @return array */ public static function defaults() { return array( // Appearance. The content defaults below are plain strings on purpose: see CONTENT_FIELDS. 'enabled' => 1, 'bot_name' => 'AI Assistant', 'welcome' => 'Hello! How can I help you today?', 'placeholder' => 'Type your question...', 'primary_color' => '#2563eb', 'position' => 'bottom-right', /* * The floating button, and the bubble that invites a click (docs/08 muc 6.47). * * The two texts live with the content fields, because they are words in the visitor's * language and not markup: `launcher_label` sits on the button itself, `teaser_text` is * the bubble. Both default to EMPTY on purpose — a widget that adds a label and a popup * to somebody's site unasked is not a feature, it is an imposition. Empty means "draw * nothing", so the button looks exactly as it did before this existed. * * `teaser_delay` is seconds before the bubble appears, and 0 turns it off. */ 'launcher_label_style' => 'below', 'teaser_delay' => 0, // Chatbot languages. 'languages' => array( 'vi', 'en' ), 'default_language' => 'en', 'show_language_switch' => 1, /* * Language of the plugin's OWN admin screens (not of the chat). Empty string means * "follow whatever WordPress is set to", which is what every plugin does by default and * what most owners want; the setting exists for the owner whose WordPress is in one * language but who would rather read AI House in another (docs/08 muc 3.46). */ 'admin_language' => '', 'lang_strings' => array( 'vi' => array( 'bot_name' => 'Trợ lý ảo', 'welcome' => 'Xin chào! Tôi có thể giúp gì cho bạn?', 'placeholder' => 'Nhập câu hỏi của bạn...', 'system_prompt' => 'Bạn là nhân viên hỗ trợ khách hàng. Trả lời ngắn gọn, chính xác, bằng tiếng Việt.', 'instruction' => '', 'fallback_message' => 'Xin lỗi, hệ thống đang bận. Vui lòng thử lại sau ít phút.', ), 'en' => array( 'bot_name' => 'AI Assistant', 'welcome' => 'Hello! How can I help you today?', 'placeholder' => 'Type your question...', 'system_prompt' => self::SYSTEM_PROMPT, 'instruction' => '', 'fallback_message' => 'Sorry, the system is busy.', ), ), // Branch 2a — direct LLM call. 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o-mini', 'llm_api_key' => '', 'provider_keys' => array(), 'llm_timeout' => 8, 'llm_max_tokens' => 500, 'llm_temperature' => 0.3, 'system_prompt' => self::SYSTEM_PROMPT, // Branch 2b — RAG. 'rag_enabled' => 1, 'rag_mode' => 'auto', 'embedding_provider' => 'openai', 'embedding_model' => 'text-embedding-3-small', 'embedding_api_key' => '', 'vector_store' => 'none', 'vector_url' => '', 'vector_key' => '', 'vector_namespace' => 'mybot', 'rag_top_k' => 4, 'rag_min_score' => 0.35, 'rag_max_context' => 6000, 'chunk_size' => 1200, 'chunk_overlap' => 200, 'index_post_types' => array( 'post', 'page' ), 'index_batch_size' => 15, /* * Optional second copy of the conversation log, written to Supabase beside the * vectors. Off by default: it sends visitor messages to a service outside * WordPress, so it must be a deliberate choice on every site. */ 'supabase_logging' => 0, 'supabase_logging_attempts' => 0, 'supabase_logging_error' => '', 'supabase_logging_ok_at' => 0, /* * Hand a question to a human when the chatbot cannot answer it. Off by default: it * asks visitors for an email address and creates work for somebody, so it should be * a decision rather than a surprise. */ 'support_enabled' => 0, 'support_notify_email' => 0, 'support_notify_to' => '', 'support_webhook_url' => '', // Resource protection. 'rate_limit' => 20, 'rate_window' => 60, 'cache_ttl' => 3600, 'async_logging' => 1, 'log_retention_days' => 30, 'circuit_failures' => 5, 'circuit_cooldown' => 120, 'fallback_message' => 'Sorry, the system is busy. Please try again in a few minutes.', /* * The add-on key, and the record of the last time somebody checked it (1.11.0). * * The key is encrypted at rest exactly like a provider API key, and an empty field means * "keep the stored one" — the same rule the provider fields follow, so a masked field * cannot erase a key by being saved untouched. * * `addon_key_status` and `addon_key_problem` hold CODES, not sentences: a sentence stored * in the database keeps whichever language the administrator was reading on the day they * pressed the button, and the screen would then print two languages side by side. * * All four start empty because nothing has been checked yet — which is the truth about a * new install, and about every install until the licence server exists (`docs/13` M4). */ 'addon_key' => '', 'addon_key_status' => '', 'addon_key_problem' => '', 'addon_key_checked_at' => 0, ); } /** * Replaces saved content values that still hold a legacy Vietnamese default. * * Up to 1.3.0 the content defaults were wrapped in __(), so a Vietnamese admin saved the * Vietnamese translation of the default into the option. Those rows now look like * hand-written content, which is why the settings fields kept showing Vietnamese. * * A value is replaced only when it is exactly a shipped legacy default, so anything the * administrator actually typed is preserved. Runs once per plugin version. * * @return int Number of fields upgraded. */ public static function upgrade_content_defaults() { if ( MYBOT_VERSION === get_option( MYBOT_CONTENT_DEFAULTS_OPTION, '' ) ) { return 0; } $saved = get_option( MYBOT_OPTION_KEY, array() ); $count = 0; if ( is_array( $saved ) ) { $defaults = self::defaults(); foreach ( self::CONTENT_FIELDS as $key ) { if ( ! isset( $saved[ $key ] ) || ! is_string( $saved[ $key ] ) ) { continue; } $legacy = isset( self::LEGACY_CONTENT_DEFAULTS[ $key ] ) ? self::LEGACY_CONTENT_DEFAULTS[ $key ] : array(); $candidate = self::normalize_text( $saved[ $key ] ); $untouched = false; foreach ( $legacy as $legacy_value ) { if ( self::normalize_text( $legacy_value ) === $candidate ) { $untouched = true; break; } } if ( ! $untouched ) { continue; } $saved[ $key ] = $defaults[ $key ]; ++$count; } /* * The default language moved from Vietnamese to English in 1.4.2. A stored value * that still equals the old default was never chosen by anyone, so it is switched * once — the same rule the content fields above follow. English has to be enabled, * otherwise the new default would be a language the site turned off. */ $saved_default = isset( $saved['default_language'] ) ? sanitize_key( (string) $saved['default_language'] ) : ''; $saved_langs = ( isset( $saved['languages'] ) && is_array( $saved['languages'] ) ) ? array_map( 'sanitize_key', $saved['languages'] ) : array(); if ( self::LEGACY_DEFAULT_LANGUAGE === $saved_default ) { // An install with no language list at all falls back to the defaults, which // include English, so the missing list must not block the switch. if ( empty( $saved_langs ) || in_array( $defaults['default_language'], $saved_langs, true ) ) { $saved['default_language'] = $defaults['default_language']; ++$count; } } /* * The minimum score default moved from 0.72 to 0.35 in 1.5.3: at 0.72 no chunk ever * cleared the threshold, so RAG ran, found nothing, and every answer lost its source. */ if ( isset( $saved['rag_min_score'] ) && self::LEGACY_MIN_SCORE === (float) $saved['rag_min_score'] ) { $saved['rag_min_score'] = $defaults['rag_min_score']; ++$count; } if ( $count > 0 ) { update_option( MYBOT_OPTION_KEY, $saved ); } } update_option( MYBOT_CONTENT_DEFAULTS_OPTION, MYBOT_VERSION, false ); return $count; } /** * Normalizes a value before comparison: trimmed, inner whitespace collapsed. * * @param string $value Raw value. * @return string */ protected static function normalize_text( $value ) { return trim( preg_replace( '/\s+/u', ' ', (string) $value ) ); } /** * All options. * * @return array */ public function all() { if ( null === $this->runtime ) { $saved = get_option( MYBOT_OPTION_KEY, array() ); if ( ! is_array( $saved ) ) { $saved = array(); } $defaults = self::defaults(); $merged = wp_parse_args( $saved, $defaults ); // wp_parse_args only merges shallowly, so lang_strings must be merged per language // to avoid losing the definitions of languages that have not been saved yet. $merged['lang_strings'] = $this->merge_lang_strings( $saved, $defaults ); $this->runtime = $merged; } return $this->runtime; } /** * Merges content strings per language. * * @param array $saved Saved data. * @param array $defaults Default values. * @return array */ protected function merge_lang_strings( $saved, $defaults ) { $saved_map = ( isset( $saved['lang_strings'] ) && is_array( $saved['lang_strings'] ) ) ? $saved['lang_strings'] : array(); $merged = array(); foreach ( $defaults['lang_strings'] as $code => $fields ) { $stored = ( isset( $saved_map[ $code ] ) && is_array( $saved_map[ $code ] ) ) ? $saved_map[ $code ] : array(); $merged[ $code ] = wp_parse_args( $stored, $fields ); } // Keep languages configured by the user that are missing from the defaults. foreach ( $saved_map as $code => $fields ) { if ( ! isset( $merged[ $code ] ) && is_array( $fields ) ) { $merged[ $code ] = $fields; } } return $merged; } /** * A single option. * * @param string $key Field name. * @param mixed $fallback Fallback value. * @return mixed */ public function get( $key, $fallback = null ) { $all = $this->all(); return array_key_exists( $key, $all ) ? $all[ $key ] : $fallback; } /** * Decrypted secret value. * * Prefers a constant in wp-config.php so the user does not have to store the key in the database. * * @param string $key Field name. * @return string */ public function secret( $key ) { $constants = array( 'llm_api_key' => 'MYBOT_LLM_KEY', 'embedding_api_key' => 'MYBOT_EMBEDDING_KEY', 'vector_key' => 'MYBOT_VECTOR_KEY', ); if ( isset( $constants[ $key ] ) ) { foreach ( array( $constants[ $key ], 'MYBOT_' . strtoupper( $key ) ) as $constant ) { if ( defined( $constant ) && '' !== (string) constant( $constant ) ) { return (string) constant( $constant ); } } } $stored = (string) $this->get( $key, '' ); if ( '' === $stored ) { return ''; } $plain = MYBOT_Crypto::decrypt( $stored ); return ( MYBOT_Crypto::UNDECRYPTABLE === $plain ) ? '' : $plain; } /** * Decrypted API key for a provider. * * Prefers a constant in wp-config.php, then the stored key, and finally the legacy * `llm_api_key` for compatibility with configurations from before per-provider keys were introduced. * * @param string $provider Provider slug. * @return string */ public function provider_key( $provider ) { $provider = sanitize_key( $provider ); $constant = MYBOT_LLM_Factory::constant_key( $provider ); if ( '' !== $constant ) { return $constant; } $keys = $this->get( 'provider_keys', array() ); $keys = is_array( $keys ) ? $keys : array(); if ( ! empty( $keys[ $provider ] ) ) { $plain = MYBOT_Crypto::decrypt( $keys[ $provider ] ); if ( MYBOT_Crypto::UNDECRYPTABLE !== $plain ) { return $plain; } } // Legacy compatibility: llm_api_key applies to the currently selected provider. $selected = sanitize_key( (string) $this->get( 'llm_provider', 'openai' ) ); if ( $provider === $selected ) { return $this->secret( 'llm_api_key' ); } return ''; } /** * Key used for embeddings; the provider key is reused when this is left empty. * * @return string */ public function embedding_key() { $key = $this->secret( 'embedding_api_key' ); if ( '' !== $key ) { return $key; } return $this->provider_key( (string) $this->get( 'embedding_provider', 'openai' ) ); } /** * Embedding model to send to the configured embedding provider. * * OpenAI and Gemini use flat model names. OpenRouter namespaces its catalogue * ("openai/text-embedding-3-small"), so a flat name that belongs to one of OpenAI's * embedding models is qualified before it is sent — sending it as-is would fail with an * unknown-model error while the settings screen still looked correct. Anything else is * passed through untouched, so a full ID the admin pasted is never rewritten. * * @return string */ public function embedding_model() { $model = trim( (string) $this->get( 'embedding_model', '' ) ); if ( '' === $model ) { return ''; } $provider = sanitize_key( (string) $this->get( 'embedding_provider', 'openai' ) ); if ( 'openrouter' !== $provider || false !== strpos( $model, '/' ) ) { return $model; } // Flat names that OpenRouter serves under its "openai/" namespace. $namespaced = array( 'text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002' ); return in_array( $model, $namespaced, true ) ? 'openai/' . $model : $model; } /** * Whether the chatbot is enabled. * * @return bool */ public function is_enabled() { return ! empty( $this->all()['enabled'] ); } /** * Whether the RAG branch is available. * * @return bool */ public function rag_available() { $all = $this->all(); if ( empty( $all['rag_enabled'] ) || 'none' === $all['vector_store'] ) { return false; } return '' !== $this->secret( 'vector_key' ) && '' !== $this->embedding_key(); } /** * Saves options from the admin form. * * An empty secret means "keep as is", which avoids overwriting the stored value when the form is rendered masked. * * @param array $input Raw data from the form. * @return array */ public function save( $input ) { $input = is_array( $input ) ? $input : array(); $defaults = self::defaults(); $current = $this->all(); $clean = array(); // Strings and numbers. $clean['bot_name'] = $this->clean_text( $input, 'bot_name', $defaults['bot_name'] ); $clean['welcome'] = $this->clean_textarea( $input, 'welcome', $defaults['welcome'] ); $clean['placeholder'] = $this->clean_text( $input, 'placeholder', $defaults['placeholder'] ); $clean['system_prompt'] = $this->clean_textarea( $input, 'system_prompt', $defaults['system_prompt'] ); $clean['fallback_message'] = $this->clean_textarea( $input, 'fallback_message', $defaults['fallback_message'] ); $clean['llm_model'] = $this->clean_text( $input, 'llm_model', $defaults['llm_model'] ); $clean['embedding_model'] = $this->clean_text( $input, 'embedding_model', $defaults['embedding_model'] ); $clean['vector_namespace'] = $this->clean_text( $input, 'vector_namespace', $defaults['vector_namespace'] ); $clean['vector_url'] = isset( $input['vector_url'] ) ? esc_url_raw( trim( (string) $input['vector_url'] ) ) : ''; // Bounded integers. $clean['llm_timeout'] = $this->clean_int( $input, 'llm_timeout', 3, 20, $defaults['llm_timeout'] ); $clean['llm_max_tokens'] = $this->clean_int( $input, 'llm_max_tokens', 64, 4096, $defaults['llm_max_tokens'] ); $clean['rag_top_k'] = $this->clean_int( $input, 'rag_top_k', 1, 12, $defaults['rag_top_k'] ); $clean['rag_max_context'] = $this->clean_int( $input, 'rag_max_context', 1000, 24000, $defaults['rag_max_context'] ); $clean['chunk_size'] = $this->clean_int( $input, 'chunk_size', 400, 4000, $defaults['chunk_size'] ); $clean['chunk_overlap'] = $this->clean_int( $input, 'chunk_overlap', 0, 800, $defaults['chunk_overlap'] ); $clean['index_batch_size'] = $this->clean_int( $input, 'index_batch_size', 5, 50, $defaults['index_batch_size'] ); $clean['rate_limit'] = $this->clean_int( $input, 'rate_limit', 1, 600, $defaults['rate_limit'] ); $clean['rate_window'] = $this->clean_int( $input, 'rate_window', 10, 3600, $defaults['rate_window'] ); $clean['cache_ttl'] = $this->clean_int( $input, 'cache_ttl', 0, 86400, $defaults['cache_ttl'] ); $clean['log_retention_days'] = $this->clean_int( $input, 'log_retention_days', 1, 365, $defaults['log_retention_days'] ); $clean['circuit_failures'] = $this->clean_int( $input, 'circuit_failures', 1, 50, $defaults['circuit_failures'] ); $clean['circuit_cooldown'] = $this->clean_int( $input, 'circuit_cooldown', 30, 3600, $defaults['circuit_cooldown'] ); // Floating point numbers. $temperature = isset( $input['llm_temperature'] ) ? (float) $input['llm_temperature'] : $defaults['llm_temperature']; $clean['llm_temperature'] = max( 0.0, min( 1.5, $temperature ) ); $score = isset( $input['rag_min_score'] ) ? (float) $input['rag_min_score'] : $defaults['rag_min_score']; $clean['rag_min_score'] = max( 0.0, min( 1.0, $score ) ); // Toggle flags. foreach ( array( 'enabled', 'rag_enabled', 'async_logging', 'show_language_switch', 'supabase_logging', 'support_enabled', 'support_notify_email' ) as $flag ) { $clean[ $flag ] = empty( $input[ $flag ] ) ? 0 : 1; } $clean['support_webhook_url'] = isset( $input['support_webhook_url'] ) ? esc_url_raw( trim( (string) $input['support_webhook_url'] ) ) : ''; // A comma, semicolon or space separated list of addresses, cleaned one by one. $recipients = array(); if ( isset( $input['support_notify_to'] ) ) { foreach ( preg_split( '/[,;\s]+/', (string) $input['support_notify_to'] ) as $candidate ) { $candidate = sanitize_email( $candidate ); if ( '' !== $candidate && is_email( $candidate ) ) { $recipients[] = $candidate; } } } $clean['support_notify_to'] = implode( ', ', array_unique( $recipients ) ); /* * Saving the form resets the state of the Supabase log mirror. The mirror switches itself * off after repeated failures, and the only way to switch it back on is through this form — * so a fresh Save has to clear the counter, or ticking the box again would appear to do * nothing. */ $clean['supabase_logging_attempts'] = 0; $clean['supabase_logging_error'] = ''; /* * Not a form field, and `save()` rebuilds the whole option from the form, so it has to be * carried over by hand: it is the only record of when the mirror last worked, and losing * it on every Save would make "has this ever copied anything?" unanswerable. */ $clean['supabase_logging_ok_at'] = isset( $current['supabase_logging_ok_at'] ) ? (int) $current['supabase_logging_ok_at'] : 0; // Chatbot languages. $supported = array_keys( MYBOT_Languages::supported() ); $languages = ( isset( $input['languages'] ) && is_array( $input['languages'] ) ) ? array_map( 'sanitize_key', $input['languages'] ) : array(); $languages = array_values( array_intersect( $languages, $supported ) ); if ( empty( $languages ) ) { $languages = $defaults['languages']; } $clean['languages'] = $languages; $requested_default = isset( $input['default_language'] ) ? sanitize_key( $input['default_language'] ) : ''; $clean['default_language'] = in_array( $requested_default, $languages, true ) ? $requested_default : $languages[0]; /* * The interface language is accepted only when a matching translation actually ships. * `admin_locale_options()` reads the .mo files on disk, so this cannot drift from reality: a * locale with no file is refused here rather than stored and silently ignored at render time * — which would look like a broken setting (docs/08 muc 6.31, the "number nobody checks"). */ $requested_admin_language = isset( $input['admin_language'] ) ? (string) $input['admin_language'] : ''; $admin_locales = MYBOT_Languages::admin_locale_options(); $clean['admin_language'] = isset( $admin_locales[ $requested_admin_language ] ) ? $requested_admin_language : ''; $clean['lang_strings'] = $this->sanitize_lang_strings( $input, $current, $supported ); // Allow lists. $clean['position'] = $this->clean_enum( $input, 'position', array( 'bottom-right', 'bottom-left', 'inline' ), $defaults['position'] ); $clean['vector_store'] = $this->clean_enum( $input, 'vector_store', array( 'none', 'supabase', 'pinecone', 'qdrant' ), $defaults['vector_store'] ); $clean['rag_mode'] = $this->clean_enum( $input, 'rag_mode', array( 'auto', 'always', 'never' ), $defaults['rag_mode'] ); /* * Both provider allow-lists come from the factory, which is also what builds the two * dropdowns in the admin form. Hard-coding them here is what broke OpenRouter: its * entry was missing from the chat list and present in the embedding list, so saving * silently reverted the choice to the default. Deriving both sides from one source * means a provider can never be offered and then rejected on save. */ $clean['llm_provider'] = $this->clean_enum( $input, 'llm_provider', array_keys( MYBOT_LLM_Factory::options() ), $defaults['llm_provider'] ); $clean['embedding_provider'] = $this->clean_enum( $input, 'embedding_provider', array_keys( MYBOT_LLM_Factory::embedding_options() ), $defaults['embedding_provider'] ); // Color. $color = isset( $input['primary_color'] ) ? sanitize_hex_color( $input['primary_color'] ) : ''; $clean['primary_color'] = $color ? $color : $defaults['primary_color']; /* * The floating button's label style and its invitation delay (1.9.4). * * The style is an allow-list rather than a free string, because it ends up as a CLASS NAME on * the launcher: a value the CSS does not know would remove the label from the page with * nothing to show for it (docs/08 muc 6.40 is the same shape). The delay is capped at two * minutes — long enough for any invitation, short enough that a typo cannot become "never". */ $clean['launcher_label_style'] = $this->clean_enum( $input, 'launcher_label_style', array( 'below', 'beside' ), $defaults['launcher_label_style'] ); $clean['teaser_delay'] = $this->clean_int( $input, 'teaser_delay', 0, 120, $defaults['teaser_delay'] ); // Post types to be indexed. $post_types = isset( $input['index_post_types'] ) && is_array( $input['index_post_types'] ) ? $input['index_post_types'] : array(); $clean['index_post_types'] = array_values( array_intersect( array_map( 'sanitize_key', $post_types ), array_keys( get_post_types( array(), 'names' ) ) ) ); if ( empty( $clean['index_post_types'] ) ) { $clean['index_post_types'] = $defaults['index_post_types']; } // Secrets: an empty value means keep the existing value. foreach ( self::SECRET_FIELDS as $field ) { $submitted = isset( $input[ $field ] ) ? trim( (string) $input[ $field ] ) : ''; if ( '' === $submitted ) { $clean[ $field ] = isset( $current[ $field ] ) ? $current[ $field ] : ''; continue; } $clean[ $field ] = MYBOT_Crypto::encrypt( $submitted ); } /* * The add-on key, and the verdict of the last check (1.11.0). * * Deliberately NOT in SECRET_FIELDS: those fields are cleaned with `trim()`, and a key copied * out of a web page carries non-breaking spaces and zero-width characters that `trim()` leaves * alone. `clean_api_key()` exists for exactly that, and the key has to be cleaned the same way * whether it is being STORED or CHECKED — two cleanings that differ is how a key ends up * stored one way and judged another, which is the shape this project keeps recording (`6.31`). */ $submitted_key = isset( $input['addon_key'] ) ? self::clean_api_key( $input['addon_key'] ) : ''; $clean['addon_key'] = ( '' === $submitted_key ) ? ( isset( $current['addon_key'] ) ? (string) $current['addon_key'] : '' ) : MYBOT_Crypto::encrypt( $submitted_key ); // Carry the last verdict forward: a Save that did not ask for a check must not erase one. $clean['addon_key_status'] = isset( $current['addon_key_status'] ) ? (string) $current['addon_key_status'] : ''; $clean['addon_key_problem'] = isset( $current['addon_key_problem'] ) ? (string) $current['addon_key_problem'] : ''; $clean['addon_key_checked_at'] = isset( $current['addon_key_checked_at'] ) ? (int) $current['addon_key_checked_at'] : 0; /* * The check runs only when the button was pressed. * * That button posts `check_addon_key` INSIDE the option array, so it arrives with the rest of * the form: no `admin-post.php` handler, no second entry point, and `$_POST` is never read * directly by this class. WordPress already hands this method exactly two things — what was * submitted and what is stored — which is everything the check needs. * * With the field left empty the check judges the STORED key, because that is the key the site * would use. Judging the empty string instead would answer "there was nothing to check" about * a key the owner can see masked on the same screen. */ if ( ! empty( $input['check_addon_key'] ) ) { $key_to_check = ( '' === $submitted_key ) ? $this->secret( 'addon_key' ) : $submitted_key; $problem = self::addon_key_problem( $key_to_check ); if ( '' === $key_to_check ) { $clean['addon_key_status'] = 'empty'; } elseif ( '' === $problem ) { $clean['addon_key_status'] = 'shape-ok'; } else { $clean['addon_key_status'] = 'shape-bad'; } $clean['addon_key_problem'] = $problem; $clean['addon_key_checked_at'] = time(); } // Separate API key for each LLM provider. $clean['provider_keys'] = $this->sanitize_provider_keys( $input, $current ); $this->runtime = null; return $clean; } /** * What is wrong with an add-on key's SHAPE — and nothing else. * * ⚠️ This is not verification, and it cannot be: there is no licence server to ask (`docs/13` * milestone M4), so a key that passes this check is a key of the right SHAPE and nothing more. * The screen says so in words, and that sentence is the point of the whole control — a check that * answered "activated" here would be reporting a fact nobody established, which is the most * expensive kind of defect this project records (`6.31`, `6.42`). * * The rule is aimed at a pasted key's realistic failure: a whole sentence, a wrapped line, half a * key. Letters, digits and dashes are accepted; anything else is refused, because whatever it is * would end up inside a request header later. * * One copy of the rule, in PHP, used by the button and by nothing else — `check-settings-tabs.mjs` * R9 fails if JavaScript ever grows a second copy. * * @param string $key Already cleaned by clean_api_key(). * @return string '' when the shape is acceptable, or a code: too-short, too-long, bad-characters. */ public static function addon_key_problem( $key ) { $key = (string) $key; if ( '' === $key ) { return ''; } if ( strlen( $key ) < self::ADDON_KEY_MIN ) { return 'too-short'; } if ( strlen( $key ) > self::ADDON_KEY_MAX ) { return 'too-long'; } return preg_match( '/^[A-Za-z0-9-]+$/', $key ) ? '' : 'bad-characters'; } /** * Clears the in-memory cache; used after saving. * * @return void */ public function flush() { $this->runtime = null; } /** * Removes everything from a pasted API key that is not part of the key itself. * * trim() is not enough: a key copied out of a web page often carries a non-breaking space, * a zero-width space or a byte-order mark, none of which trim() removes and all of which * end up inside the `Authorization` header. OpenRouter then answers "Missing Authentication * header" — a message that gives no clue that the key merely has an invisible character * stuck to it. * * @param string $api_key Raw value from the form or the REST request. * @return string */ public static function clean_api_key( $api_key ) { $key = (string) $api_key; // Control characters, including the newlines a wrapped copy can introduce. $clean = preg_replace( '/[\x00-\x1F\x7F]/u', '', $key ); if ( is_string( $clean ) ) { $key = $clean; } // Invisible spacing: non-breaking space, zero-width space/joiner, byte-order mark. $clean = preg_replace( '/[\x{00A0}\x{200B}-\x{200D}\x{FEFF}]/u', '', $key ); if ( is_string( $clean ) ) { $key = $clean; } return trim( $key ); } /** * Stores one provider API key and makes that provider the active one. * * Deliberately NOT routed through save(): save() rebuilds the whole option array from the * defaults plus whatever the submitted form contained, so calling it with a partial array * would reset every other setting to its default. This method patches exactly two keys — * the key of one provider, and which provider is active — and leaves the rest of the * stored configuration untouched. * * @param string $provider Provider ID. * @param string $api_key Plain text API key. * @return true|WP_Error */ public function store_provider_credential( $provider, $api_key ) { $provider = sanitize_key( $provider ); $api_key = self::clean_api_key( $api_key ); if ( ! in_array( $provider, array_keys( MYBOT_LLM_Factory::options() ), true ) ) { return new WP_Error( 'mybot_unknown_provider', /* translators: %s: provider slug */ sprintf( __( 'Unsupported provider: %s', 'aihouse' ), $provider ) ); } if ( '' === $api_key ) { return new WP_Error( 'mybot_empty_key', __( 'The API key is empty.', 'aihouse' ) ); } $current = $this->all(); $keys = ( isset( $current['provider_keys'] ) && is_array( $current['provider_keys'] ) ) ? $current['provider_keys'] : array(); $keys[ $provider ] = MYBOT_Crypto::encrypt( $api_key ); $next = $current; $next['provider_keys'] = $keys; $next['llm_provider'] = $provider; update_option( MYBOT_OPTION_KEY, array_merge( self::defaults(), $next ) ); $this->flush(); return true; } /** * Sanitizes the API keys for each provider. * * An empty field means keep the existing key, so the user does not have to paste it again on every save. * * @param array $input Data from the form. * @param array $current Current configuration. * @return array */ protected function sanitize_provider_keys( $input, $current ) { $existing = ( isset( $current['provider_keys'] ) && is_array( $current['provider_keys'] ) ) ? $current['provider_keys'] : array(); $keys = $existing; if ( ! isset( $input['provider_keys'] ) || ! is_array( $input['provider_keys'] ) ) { return $keys; } foreach ( array_keys( MYBOT_LLM_Factory::options() ) as $provider ) { $submitted = isset( $input['provider_keys'][ $provider ] ) ? self::clean_api_key( $input['provider_keys'][ $provider ] ) : ''; if ( '' === $submitted ) { continue; } $keys[ $provider ] = MYBOT_Crypto::encrypt( $submitted ); } return $keys; } /** * Sanitizes content strings per language. * * Languages that are not submitted (currently disabled) are kept as is, so that re-enabling them * does not require re-entering the content. * * @param array $input Data from the form. * @param array $current Current configuration. * @param array $supported List of valid language codes. * @return array */ protected function sanitize_lang_strings( $input, $current, $supported ) { $existing = ( isset( $current['lang_strings'] ) && is_array( $current['lang_strings'] ) ) ? $current['lang_strings'] : array(); $strings = $existing; if ( ! isset( $input['lang_strings'] ) || ! is_array( $input['lang_strings'] ) ) { return $strings; } foreach ( $input['lang_strings'] as $code => $fields ) { $code = sanitize_key( $code ); if ( ! in_array( $code, $supported, true ) || ! is_array( $fields ) ) { continue; } // Empty values are allowed on purpose: empty means "use the content of the default language". $strings[ $code ] = array( 'bot_name' => isset( $fields['bot_name'] ) ? sanitize_text_field( $fields['bot_name'] ) : '', 'welcome' => isset( $fields['welcome'] ) ? sanitize_textarea_field( $fields['welcome'] ) : '', 'placeholder' => isset( $fields['placeholder'] ) ? sanitize_text_field( $fields['placeholder'] ) : '', 'system_prompt' => isset( $fields['system_prompt'] ) ? sanitize_textarea_field( $fields['system_prompt'] ) : '', 'instruction' => isset( $fields['instruction'] ) ? sanitize_textarea_field( $fields['instruction'] ) : '', /* * Saved since 1.7.0. The list above is assigned as a whole, so a key that is missing * from it is not "left alone" — it is deleted on every save. The per-language fallback * would then survive only through the read-time merge with the defaults, and a value * the site owner had typed would be lost without a word. */ 'fallback_message' => isset( $fields['fallback_message'] ) ? sanitize_textarea_field( $fields['fallback_message'] ) : '', /* * Saved since 1.9.4, and listed here for exactly the reason spelled out above: this * array is assigned as a whole, so a key missing from it is not "left alone" — it is * deleted on every save. Leaving them out would wipe the owner's button label and * invitation text the first time they pressed Save. * tools/check-launcher.mjs R6 keeps this list and CONTENT_FIELDS in step. */ 'launcher_label' => isset( $fields['launcher_label'] ) ? sanitize_text_field( $fields['launcher_label'] ) : '', 'teaser_text' => isset( $fields['teaser_text'] ) ? sanitize_textarea_field( $fields['teaser_text'] ) : '', ); } return $strings; } /** * Sanitizes a text field. * * @param array $input Input data. * @param string $key Field name. * @param string $fallback Fallback value. * @return string */ protected function clean_text( $input, $key, $fallback ) { $value = isset( $input[ $key ] ) ? trim( (string) $input[ $key ] ) : ''; return ( '' === $value ) ? $fallback : sanitize_text_field( $value ); } /** * Sanitizes a textarea field. * * @param array $input Input data. * @param string $key Field name. * @param string $fallback Fallback value. * @return string */ protected function clean_textarea( $input, $key, $fallback ) { $value = isset( $input[ $key ] ) ? trim( (string) $input[ $key ] ) : ''; return ( '' === $value ) ? $fallback : sanitize_textarea_field( $value ); } /** * Sanitizes a bounded integer. * * @param array $input Input data. * @param string $key Field name. * @param int $min Minimum value. * @param int $max Maximum value. * @param int $fallback Fallback value. * @return int */ protected function clean_int( $input, $key, $min, $max, $fallback ) { if ( ! isset( $input[ $key ] ) || '' === $input[ $key ] ) { return (int) $fallback; } return max( $min, min( $max, absint( $input[ $key ] ) ) ); } /** * Sanitizes a value against an allow list. * * @param array $input Input data. * @param string $key Field name. * @param array $allowed Allowed values. * @param string $fallback Fallback value. * @return string */ protected function clean_enum( $input, $key, $allowed, $fallback ) { $value = isset( $input[ $key ] ) ? sanitize_key( $input[ $key ] ) : ''; return in_array( $value, $allowed, true ) ? $value : $fallback; } }