'', 'site_key' => '', 'agent_id' => '', 'published' => false, 'last_sync' => '', 'last_error' => '', 'credit' => null, // Team chat: the people who log in to this site messaging each other. 'erp_app_key' => '', 'erp_secret' => '', 'team_chat' => false, 'team_roles' => '', // WordPress does not allow a plugin to put a link on the public site unless the person // running that site asks for it, so this stays off until they tick the box. 'show_credit' => false, ]; $saved = get_option(CHATNORY_OPTION, []); return array_merge($defaults, is_array($saved) ? $saved : []); } function chatnory_update_options(array $changes) { update_option(CHATNORY_OPTION, array_merge(chatnory_options(), $changes), false); } /** * Collects published pages/posts (and products) and pushes them to the ChatBot AI server. * The server uses them for page-visibility rules and to auto-train the chatbot. */ function chatnory_sync() { $options = chatnory_options(); if (empty($options['server_url']) || empty($options['site_key'])) { return new WP_Error('chatnory_not_configured', __('Enter the server URL and site key first.', 'chatnory')); } $post_types = ['page', 'post']; if (class_exists('WooCommerce')) { $post_types[] = 'product'; } /** Add your own post types, e.g. docs or FAQs: add_filter('chatnory_post_types', ...). */ $post_types = apply_filters('chatnory_post_types', $post_types); /** How many of the most recently changed items are sent in one sync. */ $limit = (int) apply_filters('chatnory_sync_limit', 300); $posts = get_posts([ 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => max(1, $limit), 'orderby' => 'modified', 'order' => 'DESC', 'suppress_filters' => false, 'has_password' => false, ]); $pages = []; $contents = []; foreach ($posts as $post) { $url = get_permalink($post); if ($post->post_type === 'page') { $pages[] = [ 'id' => (string) $post->ID, 'title' => get_the_title($post), 'url' => $url, ]; } $text = wp_strip_all_tags(strip_shortcodes($post->post_content)); if ($post->post_excerpt !== '') { $text = wp_strip_all_tags($post->post_excerpt) . ' ' . $text; } // Categories and tags help the chatbot answer "what do you have for ..." questions. if ($post->post_type === 'post') { $terms = wp_get_post_terms($post->ID, ['category', 'post_tag'], ['fields' => 'names']); if (!is_wp_error($terms) && $terms) { /* translators: %s: comma separated list of category and tag names. */ $text = sprintf(__('Topics: %s', 'chatnory'), implode(', ', $terms)) . '. ' . $text; } } $text = trim(preg_replace('/\s+/u', ' ', html_entity_decode($text, ENT_QUOTES, 'UTF-8'))); // Price and stock so the chatbot can answer product questions even without WooCommerce API keys. if ($post->post_type === 'product' && function_exists('wc_get_product')) { $product = wc_get_product($post->ID); if ($product) { $facts = []; $price = wp_strip_all_tags($product->get_price_html()); if ($price !== '') { /* translators: %s: the product price, already formatted by WooCommerce. */ $facts[] = sprintf(__('Price: %s', 'chatnory'), $price); } $facts[] = $product->is_in_stock() ? __('In stock', 'chatnory') : __('Out of stock', 'chatnory'); $categories = wp_get_post_terms($post->ID, 'product_cat', ['fields' => 'names']); if (!is_wp_error($categories) && $categories) { /* translators: %s: comma separated list of product category names. */ $facts[] = sprintf(__('Category: %s', 'chatnory'), implode(', ', $categories)); } if ($product->get_sku() !== '') { /* translators: %s: the product SKU. */ $facts[] = sprintf(__('SKU: %s', 'chatnory'), $product->get_sku()); } $text = implode('. ', $facts) . '. ' . $text; } } if ($text !== '') { $contents[] = [ 'id' => (string) $post->ID, 'title' => get_the_title($post), 'url' => $url, 'content' => mb_substr($text, 0, 60000), ]; } } $response = wp_remote_post(trailingslashit($options['server_url']) . 'api/public/wp/sync', [ 'timeout' => 30, 'headers' => [ 'Content-Type' => 'application/json', 'X-Site-Key' => $options['site_key'], ], 'body' => wp_json_encode([ 'siteUrl' => home_url('/'), 'pages' => $pages, 'contents' => $contents, 'woocommerce' => class_exists('WooCommerce'), 'permalink' => get_option('permalink_structure') ? 'Custom' : 'Plain', ]), ]); if (is_wp_error($response)) { chatnory_update_options(['last_error' => $response->get_error_message()]); return $response; } $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($code !== 200 || !is_array($body)) { /* translators: %d: HTTP status code returned by the ChatNory server. */ $message = is_array($body) && !empty($body['message']) ? $body['message'] : sprintf(__('Server responded with HTTP %d', 'chatnory'), $code); chatnory_update_options(['last_error' => $message]); return new WP_Error('chatnory_sync_failed', $message); } $credit = null; if (!empty($body['credit']) && is_array($body['credit']) && !empty($body['credit']['text'])) { $credit = [ 'text' => sanitize_text_field($body['credit']['text']), 'url' => isset($body['credit']['url']) ? esc_url_raw($body['credit']['url']) : '', 'rel' => (isset($body['credit']['rel']) && $body['credit']['rel'] === 'nofollow') ? 'nofollow' : '', ]; } chatnory_update_options([ 'agent_id' => isset($body['agentId']) ? sanitize_text_field($body['agentId']) : '', 'published' => !empty($body['published']), 'last_sync' => current_time('mysql'), 'last_error' => '', 'credit' => $credit, ]); return true; } // ---------------------------------------------------------------- front end /** * The data-* attributes for each script we enqueue, kept by handle. * * The chat widget is configured entirely through attributes on its own tag, and WordPress has no * API for arbitrary attributes on an enqueued script. So the script is enqueued the ordinary way — * which is what lets caching and optimisation plugins see it at all — and its attributes are added * to the tag on the way out. */ function chatnory_script_attributes($handle, $attributes = null) { static $map = []; if ($attributes !== null) { $map[$handle] = $attributes; } return isset($map[$handle]) ? $map[$handle] : []; } function chatnory_filter_script_tag($tag, $handle) { $attributes = chatnory_script_attributes($handle); if (empty($attributes)) { return $tag; } $extra = ''; foreach ($attributes as $name => $value) { $extra .= ' ' . esc_attr($name) . '="' . esc_attr($value) . '"'; } return str_replace(' src=', $extra . ' src=', $tag); } add_filter('script_loader_tag', 'chatnory_filter_script_tag', 10, 2); /** * Adds the widget. Visibility (device, page rules, published state) is enforced by the widget itself. */ function chatnory_enqueue_widget() { if (is_404()) { return; } $options = chatnory_options(); if (empty($options['server_url']) || empty($options['agent_id'])) { return; } $handle = 'chatnory-widget'; // The plugin's own version, so an update reaches browsers that cached the old widget. wp_enqueue_script($handle, trailingslashit($options['server_url']) . 'embed.js', [], CHATNORY_VERSION, true); chatnory_script_attributes($handle, [ 'data-agent' => $options['agent_id'], 'data-page-id' => is_singular() ? (string) get_queried_object_id() : '', 'data-is-single' => is_single() ? '1' : '0', 'data-is-category' => is_category() ? '1' : '0', 'async' => 'async', ]); } add_action('wp_enqueue_scripts', 'chatnory_enqueue_widget'); /** * Signs the logged-in WordPress user the way the ChatNory server expects: * base64url(JSON) . "." . base64url(HMAC-SHA256(secret, that JSON)). * The secret stays on this server, so nobody can chat as someone else. */ function chatnory_sign_user($secret, array $payload) { $body = rtrim(strtr(base64_encode(wp_json_encode($payload)), '+/', '-_'), '='); $mac = rtrim(strtr(base64_encode(hash_hmac('sha256', $body, $secret, true)), '+/', '-_'), '='); return $body . '.' . $mac; } /** Whether this user is allowed team chat (all logged-in users unless roles are listed). */ function chatnory_team_chat_allowed($user, $roles) { $allowed = array_filter(array_map('trim', explode(',', (string) $roles))); if (empty($allowed)) { return true; } return (bool) array_intersect($allowed, (array) $user->roles); } /** * Team chat for the people who log in to this site. The chatbot itself is added by the * snippet above; this adds messaging between your own users. */ function chatnory_render_team_chat() { if (!is_user_logged_in()) { return; } $options = chatnory_options(); if (empty($options['team_chat']) || empty($options['server_url']) || empty($options['erp_app_key']) || empty($options['erp_secret'])) { return; } $user = wp_get_current_user(); if (!chatnory_team_chat_allowed($user, $options['team_roles'])) { return; } $token = chatnory_sign_user($options['erp_secret'], [ 'id' => (string) $user->ID, 'name' => $user->display_name ? $user->display_name : $user->user_login, 'email' => $user->user_email, 'role' => !empty($user->roles) ? (string) reset($user->roles) : '', 'avatar' => get_avatar_url($user->ID, ['size' => 96]), // Short-lived on purpose: the page signs a fresh one on every load. 'exp' => time() + 600, ]); $handle = 'chatnory-team-chat'; wp_enqueue_script($handle, trailingslashit($options['server_url']) . 'erp-chat.js', [], CHATNORY_VERSION, true); chatnory_script_attributes($handle, [ 'data-app-key' => $options['erp_app_key'], 'data-token' => $token, 'async' => 'async', ]); } add_action('wp_enqueue_scripts', 'chatnory_render_team_chat'); /** * A small "Chatbot by …" credit in the site footer, shown only when the site owner has ticked the * box for it. The server also withholds the credit entirely on plans with "Remove branding", so * both sides have to agree before anything appears. */ function chatnory_render_credit() { if (is_admin() || wp_doing_ajax()) { return; } $options = chatnory_options(); $credit = $options['credit']; if (empty($options['show_credit']) || empty($options['published']) || empty($credit['text']) || empty($credit['url'])) { return; } printf( '
%s %s
' . "\n", esc_html__('Chatbot by', 'chatnory'), esc_url($credit['url']), $credit['rel'] === 'nofollow' ? ' rel="nofollow"' : '', esc_html($credit['text']) ); } add_action('wp_footer', 'chatnory_render_credit', 20); /** * [chatnory] places the chatbot inside a page or post instead of the floating corner button. * Attributes: height (default 560) and width (default 100%). */ function chatnory_shortcode($atts) { $options = chatnory_options(); if (empty($options['server_url']) || empty($options['agent_id'])) { return ''; } $atts = shortcode_atts(['height' => '560', 'width' => '100%'], $atts, 'chatnory'); static $count = 0; $count += 1; $id = 'chatnory-inline-' . $count; $height = preg_match('/^\d+(px|%|vh)?$/', $atts['height']) ? $atts['height'] : '560'; $width = preg_match('/^\d+(px|%)?$/', $atts['width']) ? $atts['width'] : '100%'; if (is_numeric($height)) { $height .= 'px'; } if (is_numeric($width)) { $width .= 'px'; } // One enqueued script per shortcode on the page, each pointed at its own box. The shortcode is // rendered with the content, which is still before the footer, so a footer script arrives in time. $handle = 'chatnory-inline-' . $count; wp_enqueue_script($handle, trailingslashit($options['server_url']) . 'embed.js', [], CHATNORY_VERSION, true); chatnory_script_attributes($handle, [ 'data-agent' => $options['agent_id'], 'data-root' => $id, 'async' => 'async', ]); return sprintf( '', esc_attr($id), esc_attr($width), esc_attr($height) ); } add_shortcode('chatnory', 'chatnory_shortcode'); // ---------------------------------------------------------------- keep content in sync function chatnory_schedule_sync() { if (!wp_next_scheduled('chatnory_sync_event')) { wp_schedule_single_event(time() + 60, 'chatnory_sync_event'); } } function chatnory_on_save_post($post_id, $post) { if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) { return; } if (in_array($post->post_type, ['page', 'post', 'product'], true)) { chatnory_schedule_sync(); } } add_action('save_post', 'chatnory_on_save_post', 10, 2); add_action('deleted_post', 'chatnory_schedule_sync'); add_action('chatnory_sync_event', 'chatnory_sync'); add_action('chatnory_daily_sync', 'chatnory_sync'); register_activation_hook(__FILE__, function () { if (!wp_next_scheduled('chatnory_daily_sync')) { wp_schedule_event(time() + HOUR_IN_SECONDS, 'daily', 'chatnory_daily_sync'); } }); register_deactivation_hook(__FILE__, function () { wp_clear_scheduled_hook('chatnory_daily_sync'); wp_clear_scheduled_hook('chatnory_sync_event'); }); // ---------------------------------------------------------------- admin function chatnory_admin_menu() { add_options_page( __('ChatNory', 'chatnory'), __('ChatNory', 'chatnory'), 'manage_options', 'chatnory', 'chatnory_settings_page' ); } add_action('admin_menu', 'chatnory_admin_menu'); function chatnory_handle_post() { if (!current_user_can('manage_options') || empty($_POST['chatnory_action'])) { return null; } check_admin_referer('chatnory_settings'); $action = sanitize_key(wp_unslash($_POST['chatnory_action'])); if ($action === 'save') { $server_url = isset($_POST['server_url']) ? esc_url_raw(untrailingslashit(trim(sanitize_text_field(wp_unslash($_POST['server_url']))))) : ''; $site_key = isset($_POST['site_key']) ? sanitize_text_field(wp_unslash($_POST['site_key'])) : ''; chatnory_update_options([ 'server_url' => $server_url, 'site_key' => $site_key, 'agent_id' => '', 'show_credit' => !empty($_POST['show_credit']), ]); } if ($action === 'save_team_chat') { $secret = isset($_POST['erp_secret']) ? sanitize_text_field(wp_unslash($_POST['erp_secret'])) : ''; $current = chatnory_options(); chatnory_update_options([ 'erp_app_key' => isset($_POST['erp_app_key']) ? sanitize_text_field(wp_unslash($_POST['erp_app_key'])) : '', // An empty box keeps the secret that is already saved. 'erp_secret' => $secret !== '' ? $secret : $current['erp_secret'], 'team_chat' => !empty($_POST['team_chat']), 'team_roles' => isset($_POST['team_roles']) ? sanitize_text_field(wp_unslash($_POST['team_roles'])) : '', ]); return ['success', __('Team chat settings saved.', 'chatnory')]; } if ($action === 'disconnect') { delete_option(CHATNORY_OPTION); return ['success', __('Disconnected. The chatbot has been removed from your site.', 'chatnory')]; } $result = chatnory_sync(); if (is_wp_error($result)) { return ['error', $result->get_error_message()]; } return ['success', __('Connected and synced. Your chatbot has learned from your latest content.', 'chatnory')]; } function chatnory_settings_page() { if (!current_user_can('manage_options')) { return; } $notice = chatnory_handle_post(); $options = chatnory_options(); $connected = !empty($options['agent_id']); ?>