get_launcher_settings(); // Localize script with settings wp_localize_script('answerrails-chat-widget', 'answerrailsChat', array( 'restUrl' => rest_url('answerrails/v1/'), // #258 D1: no wp_rest nonce is localized. The public routes do not // require one, and a nonce baked into cacheable HTML outlives its // own validity — which is the defect this removed. 'settings' => array( 'title' => get_option('answerrails_chat_title', 'How can we help you today?'), 'welcomeMessage' => get_option('answerrails_welcome_message', 'Hi! I\'m here to help with any questions about our products and services.'), 'businessName' => get_option('answerrails_business_name', 'Your Business'), 'requireConsent' => AnswerRails_REST_Auth::consent_required(), // #120 single authority // #81 item E: the widget's storage adapter consults this — // resolved server-side from the STORED setting, as saved // (#174, decision 25 D4). 'privacyMode' => $this->get_effective_privacy_mode(), // Gates visitor-console telemetry logging (trackEvent) 'debug' => (bool) (defined('WP_DEBUG') && WP_DEBUG), 'avatarUrl' => $this->get_avatar_url(), 'launcher' => $launcher_settings, // reCAPTCHA v3 (#51): site key only — the secret never // reaches the browser. api.js is lazy-loaded by the widget // on first open (compliance: no Google script for visitors // who never engage the chat). #206 D4: the KEY is now lazy // in the same sense — see recaptcha_settings(). 'recaptcha' => $this->recaptcha_settings(), // Session idle TTL (#74): seconds of inactivity before the // widget drops its session client-side. Filterable; 0 // disables expiry entirely. 'sessionIdleTtl' => (int) apply_filters('answerrails_session_idle_ttl', 7200), // #262 D1: the wrapper class the widget strips from the // history it REPLAYS on the wire. Sourced from the class // that WRITES the block, never re-typed here or in JS — // the #144 format-contract rule (`Response_Service` owns // both sides; `REST_Chat`'s ingest strip reads the same // constant). A third copy, in another language, is exactly // what that rule exists to prevent. 'citationsClass' => AnswerRails_Response_Service::RENDERED_CITATIONS_CLASS, // #262 D2: the ceiling, in BYTES, on the whole serialised // chat request body — the number an intermediary counts, not // the length of the messages array. Default 6,144: the // 8,192-byte cliff measured on prod (AWS WAF // SizeRestrictions_BODY) with ~2 KB of headroom for the // session fields and a reCAPTCHA token. Filterable in either // direction; 0 disables the bound entirely, the // sessionIdleTtl convention above. 'historyByteBudget' => (int) apply_filters('answerrails_history_byte_budget', 6144), // #262 D6: the ceiling, in BYTES, on ONE visitor message. // Sits under the body budget above with room for the session // fields and a reCAPTCHA token, so a message at the ceiling // still sends — it simply crowds the history out, which is // honest: a very long question buys less memory. ~4,000 // Latin characters is 600-800 words. Filterable; 0 disables. // // ⚠ BYTES, not characters, and the CLIENT is the remedy. The // whole failure happens BEFORE the origin — a refused request // never reaches WordPress, so the server companion (REST_Chat) // never gets the chance to fire for a widget visitor. It // exists for non-widget callers, never as the guard. 'messageByteLimit' => AnswerRails_REST_Auth::message_byte_limit(), ), 'strings' => array( 'send' => __('Send', 'answerrails'), 'typing' => __('AI is typing...', 'answerrails'), 'error' => __('Sorry, there was an error. Please try again.', 'answerrails'), // #117: the friendly fallback for a failure the widget cannot // describe more precisely. Three situations reach it: a // non-2xx whose body IS JSON but is not our {error:{…}} // envelope (WordPress core's or a third party's top-level // {code, message, data} shape, or an `error.message` that is // empty); a 2xx whose body does not parse as JSON at all; and // a failure carrying no HTTP body to read — a fetch // rejection, an exception thrown in the render chain, or a // rejected self-heal consent call. // // ⚠ NOT the "no reply of ours came back" case: a non-2xx // whose body is NOT JSON gets `upstreamRefused` below, whose // copy asserts nothing about where the request stopped (#262 // D3/D4). And never raw browser exception text — // friendlyErrorText() in chat-widget.js is the single place // this string is chosen. 'serverError' => __('Sorry — something went wrong on our side. Please try again.', 'answerrails'), // #262 D3/D4: shown when a non-2xx carries a body that did // not parse as JSON. Every error our REST stack produces is a // JSON envelope, so the shape is the evidence that no reply // of ours came back; the status is not (403 is CloudFront, // 413 is nginx, 406 is mod_security, and a status list would // fix this for our own edge and nobody else's). // // ⚠ The copy claims only what the branch can see, which is // LESS than "an intermediary refused this" (Codex P2, PR // #268). A non-JSON body proves the reply was not one of ours // — it does not prove where the request stopped, because a // WordPress critical-error page is HTML from our own origin. // Naming an intermediary, or asserting the request never // arrived, would be the unevidenced-claim class #263 was // about, smuggled into the remedy for a misattribution. So // the sentence describes the observation, keeps the cause // that is true of this branch in practice, and D4 ends it on // the control that resolves it — honest copy with no route // out is a smaller lie, not an honest product. Not // owner-configurable, for the #263 reason: a truth claim an // owner can edit is one they can edit into a lie. 'upstreamRefused' => __('This message could not be sent — nothing came back that we could read. A long conversation is the usual cause. Use the new-conversation button to start again.', 'answerrails'), // #262 D6: shown beside the composer, and announced through // #ar-announcer (#122), while the typed message is over the // byte ceiling. The text STAYS in the composer to be edited // down — never truncated, never lost (#117). Not // owner-configurable, for the same reason as the string // above. 'messageTooLong' => __('This message is too long to send. Please shorten it.', 'answerrails'), 'consent' => __('I agree to the privacy policy', 'answerrails'), 'minimize' => __('Minimize', 'answerrails'), 'maximize' => __('Open chat', 'answerrails'), 'support' => __('Support', 'answerrails'), // #122: status-region announcement when a session is ended // by content policy (#119) — communicates the disabled-input // STATE; the server's canned message carries the reason. 'sessionEnded' => __('This conversation has ended. Use the new-conversation button to start again.', 'answerrails'), // #205 D2: the withdrawal confirmation, rendered in the // transcript and announced. Claims CLIENT-side facts only — // logging stopped, no restore on reload — never server-side // deletion (what is already stored is governed by the // retention policy; a claim about the ledger belongs to the // ledger. COMPLIANCE COPY IS NOT CUSTOMER COPY). 'consentWithdrawn' => __('You have withdrawn your consent. Logging has stopped, and this conversation will not be restored if you reload the page. To continue chatting, please accept the disclaimer again.', 'answerrails'), // #261 D2: shown when the withdrawal's own record could not // be written after the retries. Every clause is a CLIENT-SIDE // fact — withdrawn here (the box, the gate, the rotation), // not recorded (the observed result), nothing more stored // (the composer is disabled and the id is gone). It claims // nothing about what the server holds, and in particular // makes no deletion claim (COMPLIANCE COPY IS NOT CUSTOMER // COPY — the #205 D2 rule). 'consentRevokeFailed' => __('Your consent has been withdrawn here, but your withdrawal could not be recorded on the site. Nothing more from this conversation will be stored.', 'answerrails'), // Honest copy with no route out is a smaller lie, not an // honest product (#262 D4). One more attempt on the same // retained credentials; no cap on manual tries. 'consentRevokeRetry' => __('Try again', 'answerrails'), 'consentRevokeRecorded' => __('Your withdrawal has now been recorded.', 'answerrails'), ) )); // Privacy controls frontend (storage policy + auto-clear TTL), // promoted at #174 (decision 25 D4) and enqueued here so this // plugin never enqueues an asset it does not ship. The script, // its style, the handle and the localised object share one name. wp_enqueue_script( 'answerrails-privacy-controls', ANSWERRAILS_PLUGIN_URL . 'assets/js/privacy-controls.js', array('answerrails-chat-widget'), $version, true ); wp_enqueue_style( 'answerrails-privacy-controls', ANSWERRAILS_PLUGIN_URL . 'assets/css/privacy-controls.css', array('answerrails-chat-widget'), $version ); wp_localize_script('answerrails-privacy-controls', 'answerrailsPrivacy', array( 'settings' => array( 'privacyMode' => get_option('answerrails_privacy_mode', 'session'), 'privacyTTL' => intval(get_option('answerrails_privacy_ttl', 0)), ), )); } /** * Effective privacy mode for the widget's storage adapter (#81 E2). * The stored option is honoured as-is (#174, advanced_privacy * promoted): * * 'local' — conversation in localStorage (the pre-adapter behaviour) * 'session' — conversation in sessionStorage (the default) * 'none' — nothing persisted client-side */ private function get_effective_privacy_mode() { $mode = get_option('answerrails_privacy_mode', 'session'); return in_array($mode, array('none', 'session', 'local'), true) ? $mode : 'session'; } /** * The widget's reCAPTCHA config (#206 D4, F13). * * A site key is public by design, so publishing one is not a * vulnerability — but publishing it while the feature is switched OFF * is config for something that cannot happen, on every page, for every * visitor. The loader this key feeds is already lazy * (`ensureRecaptcha()` runs from `openChat()`, never from `init()`, so * Google's script never touches a visitor who does not chat); emitting * the key eagerly broke the very rule the loader was written to follow. * * The shape is therefore minimal-until-needed: `enabled => false` and * nothing else when the feature is off. Both widget guards read * `enabled` before `siteKey` (`chat-widget.js` :1455, :1476), so the * absent key changes no behaviour. * * @return array{enabled:bool,siteKey?:string} */ private function recaptcha_settings() { if (!(bool) get_option('answerrails_recaptcha_enabled', false)) { return array('enabled' => false); } return array( 'enabled' => true, 'siteKey' => (string) get_option('answerrails_recaptcha_site_key', ''), ); } /** * Retrieve sanitized launcher configuration. */ private function get_launcher_settings() { // Launcher inherits the widget theme by default (Phase B, closes // #61 by construction): unset options fall back to the accent + // its contrast text. A site that saved explicit launcher colours // keeps them — the options are overrides, not renamed. $theme_accent = AnswerRails_Theme::settings()['accent']; $defaults = array( 'tagline' => __('Chat with us', 'answerrails'), 'position' => 'bottom-right', 'size' => 'comfortable', 'bgColor' => $theme_accent, 'textColor' => AnswerRails_Theme::text_on($theme_accent), 'visual' => 'bubble', ); $settings = array( 'tagline' => get_option('answerrails_launcher_tagline', $defaults['tagline']), 'position' => get_option('answerrails_launcher_position', $defaults['position']), 'size' => get_option('answerrails_launcher_size', $defaults['size']), 'bgColor' => get_option('answerrails_launcher_bg_color', $defaults['bgColor']), 'textColor' => get_option('answerrails_launcher_text_color', $defaults['textColor']), 'visual' => get_option('answerrails_launcher_visual', $defaults['visual']), ); $settings['tagline'] = sanitize_text_field($settings['tagline']); $allowed_positions = array('bottom-right', 'bottom-left', 'bottom-center'); if (!in_array($settings['position'], $allowed_positions, true)) { $settings['position'] = $defaults['position']; } $allowed_sizes = array('compact', 'comfortable', 'spacious'); if (!in_array($settings['size'], $allowed_sizes, true)) { $settings['size'] = $defaults['size']; } $allowed_visuals = array('bubble', 'avatar'); if (!in_array($settings['visual'], $allowed_visuals, true)) { $settings['visual'] = $defaults['visual']; } $settings['bgColor'] = sanitize_hex_color($settings['bgColor']); if (!$settings['bgColor']) { $settings['bgColor'] = $defaults['bgColor']; } $settings['textColor'] = sanitize_hex_color($settings['textColor']); if (!$settings['textColor']) { $settings['textColor'] = $defaults['textColor']; } return $settings; } /** * Render chat widget in footer */ public function render_chat_widget() { // Only render if we should display the widget if (!$this->should_render_widget()) { return; } echo $this->get_chat_widget_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- method contract: returns pre-escaped HTML (every value escaped at construction; disclaimer ends in disclaimer_kses() — explicit allowlist a[href|target|rel], br, em, strong, #178 D1) } /** * Check if widget should render on current page */ private function should_render_widget() { // Don't render in admin if (is_admin()) { return false; } // Don't render if chat is disabled if (!get_option('answerrails_chat_enabled', true)) { return false; } // Check if shortcode was used on this page if (get_query_var('answerrails_shortcode_used')) { return false; } // Get display mode $display_mode = get_option('answerrails_display_mode', 'home_only'); switch ($display_mode) { case 'disabled': return false; case 'home_only': return is_front_page(); case 'all_pages': return true; case 'custom_pages': return $this->is_selected_custom_page(); case 'shortcode_only': return false; default: return is_front_page(); // Fallback to home only } } /** * Check if current page is selected for custom pages mode */ private function is_selected_custom_page() { $selected_pages = get_option('answerrails_custom_pages', array()); if (empty($selected_pages)) { return false; } // Check various page types if (is_front_page() && in_array('front_page', $selected_pages)) { return true; } if (is_home() && in_array('blog_page', $selected_pages)) { return true; } if (is_page()) { $page_id = get_the_ID(); return in_array($page_id, $selected_pages); } if (is_single()) { $post_id = get_the_ID(); return in_array($post_id, $selected_pages); } // Check for specific post types if (is_singular()) { $post_type = get_post_type(); return in_array($post_type . '_pages', $selected_pages); } return false; } /** * Get chat widget HTML */ private function get_chat_widget_html($atts = array()) { $business_name = get_option('answerrails_business_name', 'Your Business'); $chat_title = get_option('answerrails_chat_title', 'How can we help you today?'); $avatar_url = $this->get_avatar_url(); $launcher_settings = $this->get_launcher_settings(); // Window preset (Phase C): drives the geometry tokens via a root // class. 'standard' has no CSS override (token defaults); visitor // drag/resize overrides the preset locally via inline styles. $window_preset = get_option('answerrails_window_preset', 'standard'); if (!in_array($window_preset, array('compact', 'standard', 'tall'), true)) { $window_preset = 'standard'; } $widget_classes = array( 'answerrails-chat-minimized', 'answerrails-launcher-pos-' . $launcher_settings['position'], 'answerrails-window-preset-' . $window_preset, ); $launcher_classes = array( 'answerrails-chat-button', 'answerrails-launcher', 'answerrails-launcher--size-' . $launcher_settings['size'], 'answerrails-launcher--' . $launcher_settings['visual'], ); if (!empty($launcher_settings['tagline'])) { $launcher_classes[] = 'answerrails-launcher--has-tagline'; } $widget_class_attr = implode(' ', array_map('sanitize_html_class', array_filter($widget_classes))); $launcher_class_attr = implode(' ', array_map('sanitize_html_class', array_filter($launcher_classes))); $launcher_style_attr = sprintf('--ar-launcher-bg:%1$s; --ar-launcher-text:%2$s;', $launcher_settings['bgColor'], $launcher_settings['textColor']); /* translators: %s: the configured business name */ $launcher_label_parts = array(sprintf(__('Open chat with %s', 'answerrails'), $business_name)); if (!empty($launcher_settings['tagline'])) { $launcher_label_parts[] = $launcher_settings['tagline']; } $launcher_aria_label = implode(' – ', $launcher_label_parts); // Allow shortcode attributes to override settings if (!empty($atts['title'])) { $chat_title = sanitize_text_field($atts['title']); } if (!empty($atts['business_name'])) { $business_name = sanitize_text_field($atts['business_name']); } // Theme (Phase B): server-derived, contrast-checked CSS custom // properties on the widget root; the stylesheet consumes tokens // only. data-ar-mode keys the dark-mode token overrides. $theme_style = AnswerRails_Theme::inline_style(); $theme_mode = AnswerRails_Theme::mode(); ob_start(); ?>
'', 'business_name' => '', ), $atts, 'answerrails_chat'); return $this->get_chat_widget_html($atts); } /** * Get avatar URL */ private function get_avatar_url() { $attachment_id = (int) get_option('answerrails_avatar_image_id', 0); if ($attachment_id > 0) { $image_url = wp_get_attachment_image_url($attachment_id, 'thumbnail'); if (!$image_url) { $image_url = wp_get_attachment_url($attachment_id); } if (!empty($image_url)) { return $image_url; } } $avatar = get_option('answerrails_avatar_url', ''); if (!empty($avatar)) { return $avatar; } // Default avatar - simple AI assistant icon return ANSWERRAILS_PLUGIN_URL . 'assets/images/default-avatar.svg'; } /** * Allowed HTML for the consent disclaimer (#178 D1/D2): the ONE * allowlist authority, consumed at output by * get_processed_disclaimer_text() and at save by the * answerrails_disclaimer_text sanitize_callback (wired in #176). * Intent is "a sentence with a link and emphasis" — wp_kses_post * admitted images, headings, tables and forms-adjacent markup far * beyond it. Widening this list widens BOTH paths: only with a * design decision. */ const DISCLAIMER_ALLOWED_HTML = array( 'a' => array( 'href' => true, 'target' => true, 'rel' => true, ), 'br' => array(), 'em' => array(), 'strong' => array(), ); /** * Filter disclaimer HTML through the shared allowlist (#178 D2). * * Public static so #176 can wire it unchanged as the * answerrails_disclaimer_text sanitize_callback. * * @param string $text Raw disclaimer HTML. * @return string Filtered HTML containing only allowlisted markup. */ public static function disclaimer_kses($text) { return wp_kses((string) $text, self::DISCLAIMER_ALLOWED_HTML); } /** * The SHIPPED consent disclaimer — the one default (#244). * * Logging-aware AT CALL TIME, because conversation logging is off on a * fresh install: a default asserting "this conversation may be logged" * would be a false statement to every visitor until the owner turns * logging on. AnswerRails_Logger::logging_mode() is the single * authority for that state; this method does not read the option. * * Carries no markup and no link — it sits inside DISCLAIMER_ALLOWED_HTML * without using any of it, and the owner supplies their own privacy-policy * link. {retention_days} stays a literal token; substitute_disclaimer_tokens() * resolves it. * * @return string */ public static function default_disclaimer_text(): string { if ('off' !== AnswerRails_Logger::logging_mode()) { return __('This chat gives informal guidance only and does not create a contract, quote or promise of delivery. By continuing, you agree that this conversation may be logged for {retention_days} days. For a binding quote or order, please contact us directly.', 'answerrails'); } return __('This chat gives informal guidance only and does not create a contract, quote or promise of delivery. This conversation is not stored. For a binding quote or order, please contact us directly.', 'answerrails'); } /** * The disclaimer this site actually shows: the stored text when the * owner has written one, the shipped default otherwise (#244). * * BLANK MEANS DEFAULT. Installs activated before #244 hold a seeded * empty string, so "option is set" cannot mean "owner chose this" — * emptiness is the signal, which is why no migration is needed. * Returns RAW text, before token substitution and before kses. * * @return string */ public static function effective_disclaimer_text(): string { $stored = (string) get_option('answerrails_disclaimer_text', ''); return '' === trim($stored) ? self::default_disclaimer_text() : $stored; } /** * Resolve the {retention_days} token (#244). * * ONE substitution, two callers: the widget's rendered notice and the * consent receipt's hash. Duplicating the str_replace is how the hash * and the text the visitor agreed to drift apart. * * @param string $text Raw disclaimer text. * @return string */ public static function substitute_disclaimer_tokens(string $text): string { if (false === strpos($text, '{retention_days}')) { return $text; } $retention_days = (int) get_option('answerrails_privacy_retention_days', 180); if ($retention_days <= 0) { $retention_days = 180; } return str_replace('{retention_days}', (string) $retention_days, $text); } /** * Process disclaimer text for display in the consent notice. */ private function get_processed_disclaimer_text() { $disclaimer_text = self::substitute_disclaimer_tokens(self::effective_disclaimer_text()); // make_clickable() output is anchors — inside the allowlist. $disclaimer_text = make_clickable($disclaimer_text); return self::disclaimer_kses($disclaimer_text); } /** * Get client IP address (utility function for compatibility) */ public function get_client_ip() { // Single trusted resolver (#52) — see AnswerRails_Utils::get_client_ip(). return AnswerRails_Utils::get_client_ip(); } }