| @@ -6,8 +6,10 @@ | ||
| 6 | 6 | * |
| 7 | 7 | * GET /wp-json/mxchat/v1/transcripts — read chat transcripts (filterable) |
| 8 | 8 | * DELETE /wp-json/mxchat/v1/transcripts — delete by session_ids (cascades) |
| 9 | 9 | * POST /wp-json/mxchat/v1/knowledge — push content into the knowledge base |
| 10 | + * DELETE /wp-json/mxchat/v1/knowledge — remove entries by source_url | |
| 11 | + * GET /wp-json/mxchat/v1/leads — read lead-capture submissions | |
| 10 | 12 | * GET /wp-json/mxchat/v1/health — connectivity + capability check |
| 11 | 13 | * |
| 12 | 14 | * These are general-purpose primitives. They power the official MxChat FAQ |
| 13 | 15 | * agent, but anyone running MxChat can use them for analytics exports, |
| @@ -175,8 +177,80 @@ | ||
| 175 | 177 | ), |
| 176 | 178 | ), |
| 177 | 179 | ), |
| 178 | 180 | )); |
| 181 | + | |
| 182 | + register_rest_route(self::REST_NAMESPACE, '/knowledge', array( | |
| 183 | + array( | |
| 184 | + 'methods' => WP_REST_Server::DELETABLE, | |
| 185 | + 'callback' => array($this, 'handle_delete_knowledge'), | |
| 186 | + 'permission_callback' => array($this, 'check_bearer_token'), | |
| 187 | + 'args' => array( | |
| 188 | + 'source_url' => array( | |
| 189 | + 'description' => __('The source_url of the entry to remove — the same value used when it was pushed. Accepts a single string or an array of up to 50 URLs so a sync job can batch. Removing a URL that is not in the knowledge base is not an error: the result reports deleted:false for it.', 'mxchat'), | |
| 190 | + 'required' => true, | |
| 191 | + ), | |
| 192 | + 'bot_id' => array( | |
| 193 | + 'description' => __('Bot ID (for multi-bot installs). Defaults to "default".', 'mxchat'), | |
| 194 | + 'type' => 'string', | |
| 195 | + 'required' => false, | |
| 196 | + 'default' => 'default', | |
| 197 | + 'sanitize_callback' => 'sanitize_key', | |
| 198 | + ), | |
| 199 | + ), | |
| 200 | + ), | |
| 201 | + )); | |
| 202 | + | |
| 203 | + register_rest_route(self::REST_NAMESPACE, '/leads', array( | |
| 204 | + array( | |
| 205 | + 'methods' => WP_REST_Server::READABLE, | |
| 206 | + 'callback' => array($this, 'handle_get_leads'), | |
| 207 | + 'permission_callback' => array($this, 'check_bearer_token'), | |
| 208 | + 'args' => array( | |
| 209 | + 'since' => array( | |
| 210 | + 'description' => __('Only return leads last seen at or after this value. Accepts ISO 8601 (2026-05-07T00:00:00Z) or any strtotime-compatible string. Orphan leads (captured but never chatted) have no timestamp and are omitted when this is set.', 'mxchat'), | |
| 211 | + 'type' => 'string', | |
| 212 | + 'required' => false, | |
| 213 | + 'sanitize_callback' => 'sanitize_text_field', | |
| 214 | + ), | |
| 215 | + 'search' => array( | |
| 216 | + 'description' => __('Substring match on email or name.', 'mxchat'), | |
| 217 | + 'type' => 'string', | |
| 218 | + 'required' => false, | |
| 219 | + 'sanitize_callback' => 'sanitize_text_field', | |
| 220 | + ), | |
| 221 | + 'status' => array( | |
| 222 | + 'description' => __('Filter by lead source. One of: all, orphan (captured but never chatted), chat_deleted (conversation removed by an admin). Default: all.', 'mxchat'), | |
| 223 | + 'type' => 'string', | |
| 224 | + 'required' => false, | |
| 225 | + 'enum' => array('all', 'orphan', 'chat_deleted'), | |
| 226 | + 'default' => 'all', | |
| 227 | + ), | |
| 228 | + 'page' => array( | |
| 229 | + 'description' => __('1-based page number.', 'mxchat'), | |
| 230 | + 'type' => 'integer', | |
| 231 | + 'required' => false, | |
| 232 | + 'default' => 1, | |
| 233 | + 'minimum' => 1, | |
| 234 | + ), | |
| 235 | + 'per_page' => array( | |
| 236 | + 'description' => __('Leads per page. 1-200, default 100.', 'mxchat'), | |
| 237 | + 'type' => 'integer', | |
| 238 | + 'required' => false, | |
| 239 | + 'default' => 100, | |
| 240 | + 'minimum' => 1, | |
| 241 | + 'maximum' => 200, | |
| 242 | + ), | |
| 243 | + 'order' => array( | |
| 244 | + 'description' => __('Sort order on last_seen. asc or desc (default: desc).', 'mxchat'), | |
| 245 | + 'type' => 'string', | |
| 246 | + 'required' => false, | |
| 247 | + 'enum' => array('asc', 'desc'), | |
| 248 | + 'default' => 'desc', | |
| 249 | + ), | |
| 250 | + ), | |
| 251 | + ), | |
| 252 | + )); | |
| 179 | 253 | } |
| 180 | 254 | |
| 181 | 255 | /** |
| 182 | 256 | * Permission callback: validate bearer token in constant time. |
| @@ -231,9 +305,12 @@ | ||
| 231 | 305 | public function handle_health($request) { |
| 232 | 306 | $token_set = (string) get_option(self::TOKEN_OPTION, '') !== ''; |
| 233 | 307 | |
| 234 | 308 | $options = get_option('mxchat_options', array()); |
| 235 | - $embedding_model = isset($options['embedding_model']) ? (string) $options['embedding_model'] : ''; | |
| 309 | + // The effective model: custom:<model> when Custom Provider embeddings are on (plan 418afd). | |
| 310 | + $embedding_model = class_exists('MxChat_Utils') | |
| 311 | + ? (string) MxChat_Utils::get_selected_embedding_model(is_array($options) ? $options : array()) | |
| 312 | + : (isset($options['embedding_model']) ? (string) $options['embedding_model'] : ''); | |
| 236 | 313 | |
| 237 | 314 | return rest_ensure_response(array( |
| 238 | 315 | 'ok' => true, |
| 239 | 316 | 'plugin_version' => defined('MXCHAT_VERSION') ? MXCHAT_VERSION : null, |
| @@ -527,26 +604,17 @@ | ||
| 527 | 604 | // Pull the embedding API key from MxChat options (same logic as the |
| 528 | 605 | // admin form handler in MxChat_Knowledge_Manager::mxchat_handle_content_submission). |
| 529 | 606 | $bot_options = $this->get_bot_options($bot_id); |
| 530 | 607 | $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', array()); |
| 531 | - $selected_model = isset($options['embedding_model']) ? (string) $options['embedding_model'] : 'text-embedding-ada-002'; | |
| 532 | - | |
| 533 | - if (strpos($selected_model, 'voyage') === 0) { | |
| 534 | - $api_key = isset($options['voyage_api_key']) ? (string) $options['voyage_api_key'] : ''; | |
| 535 | - } elseif (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 536 | - $api_key = isset($options['gemini_api_key']) ? (string) $options['gemini_api_key'] : ''; | |
| 537 | - } else { | |
| 538 | - $api_key = isset($options['api_key']) ? (string) $options['api_key'] : ''; | |
| 608 | + // The same decision every admin importer makes (plan 418afd): a site | |
| 609 | + // that embeds through a Custom Provider needs no OpenAI, Voyage or | |
| 610 | + // Gemini key, and the key check used to refuse it. | |
| 611 | + $preflight = MxChat_Utils::embedding_preflight(is_array($options) ? $options : array()); | |
| 612 | + if (!$preflight['ok']) { | |
| 613 | + return new WP_Error('mxchat_rest_no_api_key', $preflight['reason'], array('status' => 500)); | |
| 539 | 614 | } |
| 615 | + $api_key = $preflight['api_key']; | |
| 540 | 616 | |
| 541 | - if ($api_key === '') { | |
| 542 | - return new WP_Error( | |
| 543 | - 'mxchat_rest_no_api_key', | |
| 544 | - __('No embedding API key configured for the selected embedding model. Configure it in MxChat settings before pushing knowledge.', 'mxchat'), | |
| 545 | - array('status' => 500) | |
| 546 | - ); | |
| 547 | - } | |
| 548 | - | |
| 549 | 617 | // Embedding + chunking can take 10-60s for large documents. |
| 550 | 618 | if (function_exists('set_time_limit')) { |
| 551 | 619 | @set_time_limit(300); |
| 552 | 620 | } |
| @@ -568,8 +636,240 @@ | ||
| 568 | 636 | 'bot_id' => $bot_id, |
| 569 | 637 | 'content_type' => $content_type, |
| 570 | 638 | 'bytes' => strlen($content), |
| 571 | 639 | )); |
| 640 | + } | |
| 641 | + | |
| 642 | + /** | |
| 643 | + * DELETE /knowledge — remove entries from the KB by source_url. | |
| 644 | + * | |
| 645 | + * Wraps MxChat_Utils::delete_chunks_for_url(), which is the same entry | |
| 646 | + * point the admin's delete-by-URL action uses: it mirrors the removal to | |
| 647 | + * the OpenAI Vector Store and then clears whichever backend holds the | |
| 648 | + * content (Pinecone chunks, or the WordPress rows). There is deliberately | |
| 649 | + * no second delete path here. | |
| 650 | + * | |
| 651 | + * Idempotent by design: a URL that is not in the knowledge base comes back | |
| 652 | + * as deleted:false rather than an error, so a sync job can re-run cleanly. | |
| 653 | + */ | |
| 654 | + public function handle_delete_knowledge($request) { | |
| 655 | + if (!class_exists('MxChat_Utils')) { | |
| 656 | + return new WP_Error( | |
| 657 | + 'mxchat_rest_no_utils', | |
| 658 | + __('MxChat_Utils is not loaded.', 'mxchat'), | |
| 659 | + array('status' => 500) | |
| 660 | + ); | |
| 661 | + } | |
| 662 | + | |
| 663 | + // get_param() covers both a JSON body and a query string, so an | |
| 664 | + // automation can send either. | |
| 665 | + $raw = $request->get_param('source_url'); | |
| 666 | + if ($raw === null) { | |
| 667 | + $body = $request->get_json_params(); | |
| 668 | + if (is_array($body) && isset($body['source_url'])) { | |
| 669 | + $raw = $body['source_url']; | |
| 670 | + } | |
| 671 | + } | |
| 672 | + | |
| 673 | + $bot_id = $request->get_param('bot_id'); | |
| 674 | + $bot_id = is_string($bot_id) ? sanitize_key($bot_id) : ''; | |
| 675 | + if ($bot_id === '') { | |
| 676 | + $bot_id = 'default'; | |
| 677 | + } | |
| 678 | + | |
| 679 | + if (is_string($raw)) { | |
| 680 | + $raw = array($raw); | |
| 681 | + } | |
| 682 | + | |
| 683 | + if (!is_array($raw) || empty($raw)) { | |
| 684 | + return new WP_Error( | |
| 685 | + 'mxchat_rest_no_source_url', | |
| 686 | + __('source_url is required — a string, or a non-empty array of strings.', 'mxchat'), | |
| 687 | + array('status' => 400) | |
| 688 | + ); | |
| 689 | + } | |
| 690 | + | |
| 691 | + if (count($raw) > 50) { | |
| 692 | + return new WP_Error( | |
| 693 | + 'mxchat_rest_too_many', | |
| 694 | + __('Too many source_urls in a single request. Cap is 50; split into multiple calls.', 'mxchat'), | |
| 695 | + array('status' => 400) | |
| 696 | + ); | |
| 697 | + } | |
| 698 | + | |
| 699 | + // Normalise the same way the content was normalised on the way in, so | |
| 700 | + // a URL that POST accepted is the URL DELETE matches. Dedupe, keep order. | |
| 701 | + $urls = array(); | |
| 702 | + foreach ($raw as $candidate) { | |
| 703 | + if (!is_string($candidate)) { | |
| 704 | + continue; | |
| 705 | + } | |
| 706 | + $clean = $this->normalize_source_url($candidate); | |
| 707 | + if ($clean !== '' && !in_array($clean, $urls, true)) { | |
| 708 | + $urls[] = $clean; | |
| 709 | + } | |
| 710 | + } | |
| 711 | + | |
| 712 | + if (empty($urls)) { | |
| 713 | + return new WP_Error( | |
| 714 | + 'mxchat_rest_no_valid_source_url', | |
| 715 | + __('source_url must contain at least one valid non-empty URL.', 'mxchat'), | |
| 716 | + array('status' => 400) | |
| 717 | + ); | |
| 718 | + } | |
| 719 | + | |
| 720 | + // Pinecone listing + deletion is several round trips per URL. | |
| 721 | + if (function_exists('set_time_limit')) { | |
| 722 | + @set_time_limit(300); | |
| 723 | + } | |
| 724 | + @ignore_user_abort(true); | |
| 725 | + | |
| 726 | + $results = array(); | |
| 727 | + $urls_deleted = 0; | |
| 728 | + $entries_removed = 0; | |
| 729 | + | |
| 730 | + foreach ($urls as $url) { | |
| 731 | + // Counted before the delete, against the same backend the delete | |
| 732 | + // will act on, so the number reported is the number removed. | |
| 733 | + $before = (int) MxChat_Utils::count_chunks_for_url($url, $bot_id); | |
| 734 | + | |
| 735 | + // Called even at zero: the entry may still have a mirrored Vector | |
| 736 | + // Store file, and that must not outlive it. | |
| 737 | + $outcome = MxChat_Utils::delete_chunks_for_url($url, $bot_id); | |
| 738 | + | |
| 739 | + $row = array( | |
| 740 | + 'source_url' => $url, | |
| 741 | + 'deleted' => false, | |
| 742 | + 'entries_removed' => 0, | |
| 743 | + ); | |
| 744 | + | |
| 745 | + if (is_wp_error($outcome)) { | |
| 746 | + $row['error'] = $outcome->get_error_message(); | |
| 747 | + if (class_exists('MxChat_Admin')) { | |
| 748 | + MxChat_Admin::mxchat_log_debug( | |
| 749 | + 'rest_knowledge_delete', | |
| 750 | + 'DELETE /knowledge failed: ' . $outcome->get_error_message(), | |
| 751 | + array('source_url' => $url, 'bot_id' => $bot_id) | |
| 752 | + ); | |
| 753 | + } | |
| 754 | + } elseif ($before > 0) { | |
| 755 | + $row['deleted'] = true; | |
| 756 | + $row['entries_removed'] = $before; | |
| 757 | + $urls_deleted++; | |
| 758 | + $entries_removed += $before; | |
| 759 | + } | |
| 760 | + | |
| 761 | + $results[] = $row; | |
| 762 | + } | |
| 763 | + | |
| 764 | + if (class_exists('MxChat_Admin')) { | |
| 765 | + MxChat_Admin::mxchat_log_debug( | |
| 766 | + 'rest_knowledge_delete', | |
| 767 | + 'DELETE /knowledge removed ' . $entries_removed . ' entries across ' . $urls_deleted . ' of ' . count($urls) . ' URLs', | |
| 768 | + array('bot_id' => $bot_id) | |
| 769 | + ); | |
| 770 | + } | |
| 771 | + | |
| 772 | + return rest_ensure_response(array( | |
| 773 | + 'source_urls_requested' => count($urls), | |
| 774 | + 'urls_deleted' => $urls_deleted, | |
| 775 | + 'entries_removed' => $entries_removed, | |
| 776 | + 'bot_id' => $bot_id, | |
| 777 | + 'results' => $results, | |
| 778 | + )); | |
| 779 | + } | |
| 780 | + | |
| 781 | + /** | |
| 782 | + * GET /leads — lead-capture submissions, the same rows the Leads tab shows. | |
| 783 | + * | |
| 784 | + * Reads through MxChat_Admin::mxchat_collect_leads() rather than issuing | |
| 785 | + * its own query, so it inherits the address-less lead key (a name-only | |
| 786 | + * capture is keyed "session:<id>") instead of re-opening that blind spot. | |
| 787 | + */ | |
| 788 | + public function handle_get_leads($request) { | |
| 789 | + if (!class_exists('MxChat_Admin') || !method_exists('MxChat_Admin', 'mxchat_collect_leads')) { | |
| 790 | + return new WP_Error( | |
| 791 | + 'mxchat_rest_no_leads_source', | |
| 792 | + __('The leads reader is not available on this install.', 'mxchat'), | |
| 793 | + array('status' => 500) | |
| 794 | + ); | |
| 795 | + } | |
| 796 | + | |
| 797 | + $page = (int) $request->get_param('page'); | |
| 798 | + $per_page = (int) $request->get_param('per_page'); | |
| 799 | + $page = $page > 0 ? $page : 1; | |
| 800 | + $per_page = $per_page > 0 ? min(200, $per_page) : 100; | |
| 801 | + | |
| 802 | + $since_raw = (string) $request->get_param('since'); | |
| 803 | + $since_ts = $this->parse_datetime($since_raw); | |
| 804 | + if ($since_raw !== '' && $since_ts === null) { | |
| 805 | + return new WP_Error( | |
| 806 | + 'mxchat_rest_bad_since', | |
| 807 | + __('since could not be parsed as a date.', 'mxchat'), | |
| 808 | + array('status' => 400) | |
| 809 | + ); | |
| 810 | + } | |
| 811 | + | |
| 812 | + $status = (string) $request->get_param('status'); | |
| 813 | + if (!in_array($status, array('all', 'orphan', 'chat_deleted'), true)) { | |
| 814 | + $status = 'all'; | |
| 815 | + } | |
| 816 | + | |
| 817 | + $payload = MxChat_Admin::mxchat_collect_leads(array( | |
| 818 | + 'page' => $page, | |
| 819 | + 'per_page' => $per_page, | |
| 820 | + 'search' => (string) $request->get_param('search'), | |
| 821 | + // Timestamps in the transcripts table are UTC, as gmdate() writes | |
| 822 | + // them everywhere else the cutoff is built. | |
| 823 | + 'date_cutoff' => $since_ts === null ? '' : gmdate('Y-m-d H:i:s', $since_ts), | |
| 824 | + 'status' => $status, | |
| 825 | + 'sort' => 'last_seen', | |
| 826 | + 'sort_dir' => $request->get_param('order') === 'asc' ? 'asc' : 'desc', | |
| 827 | + )); | |
| 828 | + | |
| 829 | + $leads = array(); | |
| 830 | + foreach ((array) ($payload['leads'] ?? array()) as $lead) { | |
| 831 | + $leads[] = array( | |
| 832 | + 'lead_key' => (string) ($lead['lead_key'] ?? ''), | |
| 833 | + 'email' => (string) ($lead['email'] ?? ''), | |
| 834 | + 'name' => (string) ($lead['name'] ?? ''), | |
| 835 | + 'session_id' => (string) ($lead['latest_session_id'] ?? ''), | |
| 836 | + 'conversation_count' => (int) ($lead['conversation_count'] ?? 0), | |
| 837 | + 'first_seen' => (string) ($lead['first_seen'] ?? ''), | |
| 838 | + 'last_seen' => (string) ($lead['last_seen'] ?? ''), | |
| 839 | + 'consent' => (string) ($lead['consent'] ?? ''), | |
| 840 | + 'consent_at' => (string) ($lead['consent_at'] ?? ''), | |
| 841 | + 'consent_label' => (string) ($lead['consent_label'] ?? ''), | |
| 842 | + 'originating_page_url' => (string) ($lead['top_page_url'] ?? ''), | |
| 843 | + 'originating_page_title' => (string) ($lead['top_page_title'] ?? ''), | |
| 844 | + 'status' => (string) ($lead['status'] ?? 'active'), | |
| 845 | + 'is_orphan' => !empty($lead['is_orphan']), | |
| 846 | + ); | |
| 847 | + } | |
| 848 | + | |
| 849 | + return rest_ensure_response(array( | |
| 850 | + 'leads' => $leads, | |
| 851 | + 'page' => (int) ($payload['page'] ?? $page), | |
| 852 | + 'per_page' => (int) ($payload['per_page'] ?? $per_page), | |
| 853 | + 'total_count' => (int) ($payload['total_count'] ?? 0), | |
| 854 | + 'total_pages' => (int) ($payload['total_pages'] ?? 0), | |
| 855 | + )); | |
| 856 | + } | |
| 857 | + | |
| 858 | + /** | |
| 859 | + * Normalise a source_url the way MxChat_Utils::submit_content_to_db() does | |
| 860 | + * before it stores, so DELETE matches what POST created. The pseudo-scheme | |
| 861 | + * branch matters: esc_url_raw() EMPTIES mxchat:// and upload:// identities. | |
| 862 | + */ | |
| 863 | + private function normalize_source_url($raw) { | |
| 864 | + $raw = trim((string) $raw); | |
| 865 | + if ($raw === '') { | |
| 866 | + return ''; | |
| 867 | + } | |
| 868 | + if (preg_match('#^(mxchat|upload)://#i', $raw)) { | |
| 869 | + return sanitize_text_field($raw); | |
| 870 | + } | |
| 871 | + return esc_url_raw($raw); | |
| 572 | 872 | } |
| 573 | 873 | |
| 574 | 874 | /** |
| 575 | 875 | * Get bot-specific options if the multi-bot add-on is present, else return empty. |