settings = $settings; } /** * Hook the bar onto the end of singular post content. */ public function register() { // Priority 30: after wpautop (10), shortcodes (11) and the usual // content-append plugins, so the bar lands at the true end of the body. add_filter( 'the_content', array( $this, 'append' ), 30 ); } /** * Append the Ask-AI bar to the main content of a singular post. * * The three-part guard (singular + in the loop + main query) keeps the bar * out of feeds, REST renders, excerpt derivations and widget loops — every * off-page place `the_content` also runs. * * @param string $content Rendered post content. * @return string Content, with the bar appended when this is the right surface. */ public function append( $content ) { if ( ! $this->settings->enabled( 'enable_ask_ai' ) ) { return $content; } if ( ! is_singular() || ! in_the_loop() || ! is_main_query() ) { return $content; } $post = get_post(); if ( ! $post || post_password_required( $post ) ) { return $content; } /** @var string[] $types Post types that carry the bar. */ $types = apply_filters( 'agentimus_ask_ai_post_types', array( 'post' ) ); if ( ! in_array( $post->post_type, (array) $types, true ) ) { return $content; } /** @var bool $enabled Per-post override, on top of the enable_ask_ai setting. */ $enabled = apply_filters( 'agentimus_ask_ai_enabled', true, $post ); if ( ! $enabled ) { return $content; } return $content . $this->bar( $post ); } /** * Build the bar's HTML (plus the style block, once per page). * * @param \WP_Post $post The post being rendered. * @return string */ private function bar( $post ) { $permalink = get_permalink( $post ); if ( ! $permalink ) { return ''; } /** * The pre-filled prompt. Deliberately a QUESTION about the URL rather * than pasted content: the assistant has to fetch the page to answer, * and that fetch is what the request log can show. * * @param string $prompt Default prompt. * @param \WP_Post $post The post being rendered. */ $prompt = apply_filters( 'agentimus_ask_ai_prompt', sprintf( 'Summarize the key points of this post: %s', $permalink ), $post ); $links = ''; foreach ( $this->networks() as $network ) { $url = sprintf( $network['url'], rawurlencode( $prompt ) ); $links .= sprintf( '%s', esc_url( $url ), esc_attr( $network['icon'] ), esc_html( $network['label'] ) ); } $style = ''; if ( ! $this->styled ) { $this->styled = true; $style = $this->style(); } return sprintf( '%s

%s

', $style, esc_html__( 'Ask AI about this post', 'agentimus' ), $links ); } /** * The assistants and their public prefill endpoints. `%s` receives the * rawurlencoded prompt. Each `icon` is the assistant's official monochrome * mark from Simple Icons (CC0; 24×24 viewBox path data), rendered with * `currentColor` so it follows the theme. Grok wears the X mark — Grok * lives inside X and has no icon of its own in the set. * * @return array[] Each: label + url (sprintf template) + icon (SVG path d). */ private function networks() { $networks = array( array( 'label' => 'ChatGPT', 'url' => 'https://chatgpt.com/?q=%s', 'agents' => array( 'ChatGPT-User' ), 'icon' => 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z', ), array( 'label' => 'Claude', 'url' => 'https://claude.ai/new?q=%s', 'agents' => array( 'Claude-User' ), 'icon' => 'm4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z', ), array( 'label' => 'Perplexity', 'url' => 'https://www.perplexity.ai/search?q=%s', 'agents' => array( 'Perplexity-User' ), 'icon' => 'M22.3977 7.0896h-2.3106V.0676l-7.5094 6.3542V.1577h-1.1554v6.1966L4.4904 0v7.0896H1.6023v10.3976h2.8882V24l6.932-6.3591v6.2005h1.1554v-6.0469l6.9318 6.1807v-6.4879h2.8882V7.0896zm-3.4657-4.531v4.531h-5.355l5.355-4.531zm-13.2862.0676 4.8691 4.4634H5.6458V2.6262zM2.7576 16.332V8.245h7.8476l-6.1149 6.1147v1.9723H2.7576zm2.8882 5.0404v-3.8852h.0001v-2.6488l5.7763-5.7764v7.0111l-5.7764 5.2993zm12.7086.0248-5.7766-5.1509V9.0618l5.7766 5.7766v6.5588zm2.8882-5.0652h-1.733v-1.9723L13.3948 8.245h7.8478v8.087z', ), array( 'label' => 'Google AI Mode', 'url' => 'https://www.google.com/search?udm=50&q=%s', // Google fused reading and training into ONE robots token: disallowing // Google-Extended (the anti-training default) also forbids Gemini/AI-Mode // from READING the page — so on a site that blocks it, this button can // only ever produce "page inaccessible". Watched live, 2026-07-27. 'agents' => array( 'Google-Extended' ), 'icon' => 'M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z', ), array( 'label' => 'Grok', 'url' => 'https://grok.com/?q=%s', 'agents' => array( 'Grok' ), 'icon' => 'M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z', ), ); /** * Adjust the assistant list — reorder, drop, or add one (a custom * entry needs `label` and a `%s`-templated `url`). * * @param array[] $networks */ $networks = apply_filters( 'agentimus_ask_ai_networks', $networks ); return array_values( array_filter( $networks, array( $this, 'permitted' ) ) ); } /** * Whether the site's OWN policy permits this assistant's button. * * Don't hand readers a button the site itself breaks: an entry is dropped * when one of its `agents` tokens — the agent that would do the READING — * is on the site's blocklists. Deliberately narrow: blocking a TRAINING * crawler (GPTBot, ClaudeBot) hides nothing, because OpenAI and Anthropic * fetch reader-initiated requests with separate agents (ChatGPT-User, * Claude-User) that such a block never touches. Google is the exception — * one token, Google-Extended, governs both training and AI-Mode reading — * which is why its entry lists it. The always-allow list wins, as it does * everywhere else in the plugin. * * @param array $network One networks() entry. * @return bool */ private function permitted( array $network ) { if ( empty( $network['agents'] ) ) { return true; // A filter-added custom entry states no policy tokens. } $blocked = array_merge( (array) $this->settings->get( 'blocked_trainers', array() ), (array) $this->settings->get( 'blocked_agents', array() ) ); $allowed = (array) $this->settings->get( 'allowed_agents', array() ); foreach ( (array) $network['agents'] as $token ) { foreach ( $allowed as $trust ) { if ( '' !== (string) $trust && false !== stripos( $token, (string) $trust ) ) { continue 2; // Trusted by name — a block elsewhere never beats an explicit allow. } } foreach ( $blocked as $deny ) { if ( '' !== (string) $deny && false !== stripos( $token, (string) $deny ) ) { return false; } } } return true; } /** * The assistant links for the setup wizard's proof screen — the same catalog * and policy gate as the front-end bar, carrying a SITE-level prompt instead * of a post's. Same closed loop: the prompt is a question about the address, * so the assistant has to fetch, and that fetch is what the wizard watches * the request log for. * * @param string $prompt The pre-filled question (plain text, un-encoded). * @return array[] Each: label + href (final URL) + icon (SVG path d) + agents (reader UA tokens). */ public function wizard_links( $prompt ) { $links = array(); foreach ( $this->networks() as $network ) { $links[] = array( 'label' => $network['label'], 'href' => sprintf( $network['url'], rawurlencode( (string) $prompt ) ), 'icon' => isset( $network['icon'] ) ? $network['icon'] : '', 'agents' => isset( $network['agents'] ) ? array_values( (array) $network['agents'] ) : array(), ); } return $links; } /** * Whether a host is one the public internet can reach. The wizard's proof * screen needs this truth up front: an assistant can never fetch * wpftest.test or 192.168.*, and watching the log for that visit would * wait forever — honesty beats a spinner that can't end. * * @param string $host Bare host name (no scheme, no port). * @return bool */ public static function host_is_public( $host ) { $host = strtolower( trim( (string) $host ) ); if ( '' === $host || 'localhost' === $host || false === strpos( $host, '.' ) ) { return false; } // Reserved and development-convention suffixes (RFC 2606/6761, mDNS, RFC 8375). if ( preg_match( '/\.(test|local|localhost|example|invalid|internal|home\.arpa)$/', $host ) ) { return false; } if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { // A literal IP is reachable only outside the private/reserved ranges. return (bool) filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } return true; } /** * Layout-only styles — deliberately no colors, borders or backgrounds, so * the links wear whatever the theme gives its anchors and the icons follow * via `currentColor`. Compact by design (Heera's call). * * @return string */ private function style() { return ''; } }