| @@ -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. |
| @@ -568,8 +642,240 @@ | ||
| 568 | 642 | 'bot_id' => $bot_id, |
| 569 | 643 | 'content_type' => $content_type, |
| 570 | 644 | 'bytes' => strlen($content), |
| 571 | 645 | )); |
| 646 | + } | |
| 647 | + | |
| 648 | + /** | |
| 649 | + * DELETE /knowledge — remove entries from the KB by source_url. | |
| 650 | + * | |
| 651 | + * Wraps MxChat_Utils::delete_chunks_for_url(), which is the same entry | |
| 652 | + * point the admin's delete-by-URL action uses: it mirrors the removal to | |
| 653 | + * the OpenAI Vector Store and then clears whichever backend holds the | |
| 654 | + * content (Pinecone chunks, or the WordPress rows). There is deliberately | |
| 655 | + * no second delete path here. | |
| 656 | + * | |
| 657 | + * Idempotent by design: a URL that is not in the knowledge base comes back | |
| 658 | + * as deleted:false rather than an error, so a sync job can re-run cleanly. | |
| 659 | + */ | |
| 660 | + public function handle_delete_knowledge($request) { | |
| 661 | + if (!class_exists('MxChat_Utils')) { | |
| 662 | + return new WP_Error( | |
| 663 | + 'mxchat_rest_no_utils', | |
| 664 | + __('MxChat_Utils is not loaded.', 'mxchat'), | |
| 665 | + array('status' => 500) | |
| 666 | + ); | |
| 667 | + } | |
| 668 | + | |
| 669 | + // get_param() covers both a JSON body and a query string, so an | |
| 670 | + // automation can send either. | |
| 671 | + $raw = $request->get_param('source_url'); | |
| 672 | + if ($raw === null) { | |
| 673 | + $body = $request->get_json_params(); | |
| 674 | + if (is_array($body) && isset($body['source_url'])) { | |
| 675 | + $raw = $body['source_url']; | |
| 676 | + } | |
| 677 | + } | |
| 678 | + | |
| 679 | + $bot_id = $request->get_param('bot_id'); | |
| 680 | + $bot_id = is_string($bot_id) ? sanitize_key($bot_id) : ''; | |
| 681 | + if ($bot_id === '') { | |
| 682 | + $bot_id = 'default'; | |
| 683 | + } | |
| 684 | + | |
| 685 | + if (is_string($raw)) { | |
| 686 | + $raw = array($raw); | |
| 687 | + } | |
| 688 | + | |
| 689 | + if (!is_array($raw) || empty($raw)) { | |
| 690 | + return new WP_Error( | |
| 691 | + 'mxchat_rest_no_source_url', | |
| 692 | + __('source_url is required — a string, or a non-empty array of strings.', 'mxchat'), | |
| 693 | + array('status' => 400) | |
| 694 | + ); | |
| 695 | + } | |
| 696 | + | |
| 697 | + if (count($raw) > 50) { | |
| 698 | + return new WP_Error( | |
| 699 | + 'mxchat_rest_too_many', | |
| 700 | + __('Too many source_urls in a single request. Cap is 50; split into multiple calls.', 'mxchat'), | |
| 701 | + array('status' => 400) | |
| 702 | + ); | |
| 703 | + } | |
| 704 | + | |
| 705 | + // Normalise the same way the content was normalised on the way in, so | |
| 706 | + // a URL that POST accepted is the URL DELETE matches. Dedupe, keep order. | |
| 707 | + $urls = array(); | |
| 708 | + foreach ($raw as $candidate) { | |
| 709 | + if (!is_string($candidate)) { | |
| 710 | + continue; | |
| 711 | + } | |
| 712 | + $clean = $this->normalize_source_url($candidate); | |
| 713 | + if ($clean !== '' && !in_array($clean, $urls, true)) { | |
| 714 | + $urls[] = $clean; | |
| 715 | + } | |
| 716 | + } | |
| 717 | + | |
| 718 | + if (empty($urls)) { | |
| 719 | + return new WP_Error( | |
| 720 | + 'mxchat_rest_no_valid_source_url', | |
| 721 | + __('source_url must contain at least one valid non-empty URL.', 'mxchat'), | |
| 722 | + array('status' => 400) | |
| 723 | + ); | |
| 724 | + } | |
| 725 | + | |
| 726 | + // Pinecone listing + deletion is several round trips per URL. | |
| 727 | + if (function_exists('set_time_limit')) { | |
| 728 | + @set_time_limit(300); | |
| 729 | + } | |
| 730 | + @ignore_user_abort(true); | |
| 731 | + | |
| 732 | + $results = array(); | |
| 733 | + $urls_deleted = 0; | |
| 734 | + $entries_removed = 0; | |
| 735 | + | |
| 736 | + foreach ($urls as $url) { | |
| 737 | + // Counted before the delete, against the same backend the delete | |
| 738 | + // will act on, so the number reported is the number removed. | |
| 739 | + $before = (int) MxChat_Utils::count_chunks_for_url($url, $bot_id); | |
| 740 | + | |
| 741 | + // Called even at zero: the entry may still have a mirrored Vector | |
| 742 | + // Store file, and that must not outlive it. | |
| 743 | + $outcome = MxChat_Utils::delete_chunks_for_url($url, $bot_id); | |
| 744 | + | |
| 745 | + $row = array( | |
| 746 | + 'source_url' => $url, | |
| 747 | + 'deleted' => false, | |
| 748 | + 'entries_removed' => 0, | |
| 749 | + ); | |
| 750 | + | |
| 751 | + if (is_wp_error($outcome)) { | |
| 752 | + $row['error'] = $outcome->get_error_message(); | |
| 753 | + if (class_exists('MxChat_Admin')) { | |
| 754 | + MxChat_Admin::mxchat_log_debug( | |
| 755 | + 'rest_knowledge_delete', | |
| 756 | + 'DELETE /knowledge failed: ' . $outcome->get_error_message(), | |
| 757 | + array('source_url' => $url, 'bot_id' => $bot_id) | |
| 758 | + ); | |
| 759 | + } | |
| 760 | + } elseif ($before > 0) { | |
| 761 | + $row['deleted'] = true; | |
| 762 | + $row['entries_removed'] = $before; | |
| 763 | + $urls_deleted++; | |
| 764 | + $entries_removed += $before; | |
| 765 | + } | |
| 766 | + | |
| 767 | + $results[] = $row; | |
| 768 | + } | |
| 769 | + | |
| 770 | + if (class_exists('MxChat_Admin')) { | |
| 771 | + MxChat_Admin::mxchat_log_debug( | |
| 772 | + 'rest_knowledge_delete', | |
| 773 | + 'DELETE /knowledge removed ' . $entries_removed . ' entries across ' . $urls_deleted . ' of ' . count($urls) . ' URLs', | |
| 774 | + array('bot_id' => $bot_id) | |
| 775 | + ); | |
| 776 | + } | |
| 777 | + | |
| 778 | + return rest_ensure_response(array( | |
| 779 | + 'source_urls_requested' => count($urls), | |
| 780 | + 'urls_deleted' => $urls_deleted, | |
| 781 | + 'entries_removed' => $entries_removed, | |
| 782 | + 'bot_id' => $bot_id, | |
| 783 | + 'results' => $results, | |
| 784 | + )); | |
| 785 | + } | |
| 786 | + | |
| 787 | + /** | |
| 788 | + * GET /leads — lead-capture submissions, the same rows the Leads tab shows. | |
| 789 | + * | |
| 790 | + * Reads through MxChat_Admin::mxchat_collect_leads() rather than issuing | |
| 791 | + * its own query, so it inherits the address-less lead key (a name-only | |
| 792 | + * capture is keyed "session:<id>") instead of re-opening that blind spot. | |
| 793 | + */ | |
| 794 | + public function handle_get_leads($request) { | |
| 795 | + if (!class_exists('MxChat_Admin') || !method_exists('MxChat_Admin', 'mxchat_collect_leads')) { | |
| 796 | + return new WP_Error( | |
| 797 | + 'mxchat_rest_no_leads_source', | |
| 798 | + __('The leads reader is not available on this install.', 'mxchat'), | |
| 799 | + array('status' => 500) | |
| 800 | + ); | |
| 801 | + } | |
| 802 | + | |
| 803 | + $page = (int) $request->get_param('page'); | |
| 804 | + $per_page = (int) $request->get_param('per_page'); | |
| 805 | + $page = $page > 0 ? $page : 1; | |
| 806 | + $per_page = $per_page > 0 ? min(200, $per_page) : 100; | |
| 807 | + | |
| 808 | + $since_raw = (string) $request->get_param('since'); | |
| 809 | + $since_ts = $this->parse_datetime($since_raw); | |
| 810 | + if ($since_raw !== '' && $since_ts === null) { | |
| 811 | + return new WP_Error( | |
| 812 | + 'mxchat_rest_bad_since', | |
| 813 | + __('since could not be parsed as a date.', 'mxchat'), | |
| 814 | + array('status' => 400) | |
| 815 | + ); | |
| 816 | + } | |
| 817 | + | |
| 818 | + $status = (string) $request->get_param('status'); | |
| 819 | + if (!in_array($status, array('all', 'orphan', 'chat_deleted'), true)) { | |
| 820 | + $status = 'all'; | |
| 821 | + } | |
| 822 | + | |
| 823 | + $payload = MxChat_Admin::mxchat_collect_leads(array( | |
| 824 | + 'page' => $page, | |
| 825 | + 'per_page' => $per_page, | |
| 826 | + 'search' => (string) $request->get_param('search'), | |
| 827 | + // Timestamps in the transcripts table are UTC, as gmdate() writes | |
| 828 | + // them everywhere else the cutoff is built. | |
| 829 | + 'date_cutoff' => $since_ts === null ? '' : gmdate('Y-m-d H:i:s', $since_ts), | |
| 830 | + 'status' => $status, | |
| 831 | + 'sort' => 'last_seen', | |
| 832 | + 'sort_dir' => $request->get_param('order') === 'asc' ? 'asc' : 'desc', | |
| 833 | + )); | |
| 834 | + | |
| 835 | + $leads = array(); | |
| 836 | + foreach ((array) ($payload['leads'] ?? array()) as $lead) { | |
| 837 | + $leads[] = array( | |
| 838 | + 'lead_key' => (string) ($lead['lead_key'] ?? ''), | |
| 839 | + 'email' => (string) ($lead['email'] ?? ''), | |
| 840 | + 'name' => (string) ($lead['name'] ?? ''), | |
| 841 | + 'session_id' => (string) ($lead['latest_session_id'] ?? ''), | |
| 842 | + 'conversation_count' => (int) ($lead['conversation_count'] ?? 0), | |
| 843 | + 'first_seen' => (string) ($lead['first_seen'] ?? ''), | |
| 844 | + 'last_seen' => (string) ($lead['last_seen'] ?? ''), | |
| 845 | + 'consent' => (string) ($lead['consent'] ?? ''), | |
| 846 | + 'consent_at' => (string) ($lead['consent_at'] ?? ''), | |
| 847 | + 'consent_label' => (string) ($lead['consent_label'] ?? ''), | |
| 848 | + 'originating_page_url' => (string) ($lead['top_page_url'] ?? ''), | |
| 849 | + 'originating_page_title' => (string) ($lead['top_page_title'] ?? ''), | |
| 850 | + 'status' => (string) ($lead['status'] ?? 'active'), | |
| 851 | + 'is_orphan' => !empty($lead['is_orphan']), | |
| 852 | + ); | |
| 853 | + } | |
| 854 | + | |
| 855 | + return rest_ensure_response(array( | |
| 856 | + 'leads' => $leads, | |
| 857 | + 'page' => (int) ($payload['page'] ?? $page), | |
| 858 | + 'per_page' => (int) ($payload['per_page'] ?? $per_page), | |
| 859 | + 'total_count' => (int) ($payload['total_count'] ?? 0), | |
| 860 | + 'total_pages' => (int) ($payload['total_pages'] ?? 0), | |
| 861 | + )); | |
| 862 | + } | |
| 863 | + | |
| 864 | + /** | |
| 865 | + * Normalise a source_url the way MxChat_Utils::submit_content_to_db() does | |
| 866 | + * before it stores, so DELETE matches what POST created. The pseudo-scheme | |
| 867 | + * branch matters: esc_url_raw() EMPTIES mxchat:// and upload:// identities. | |
| 868 | + */ | |
| 869 | + private function normalize_source_url($raw) { | |
| 870 | + $raw = trim((string) $raw); | |
| 871 | + if ($raw === '') { | |
| 872 | + return ''; | |
| 873 | + } | |
| 874 | + if (preg_match('#^(mxchat|upload)://#i', $raw)) { | |
| 875 | + return sanitize_text_field($raw); | |
| 876 | + } | |
| 877 | + return esc_url_raw($raw); | |
| 572 | 878 | } |
| 573 | 879 | |
| 574 | 880 | /** |
| 575 | 881 | * Get bot-specific options if the multi-bot add-on is present, else return empty. |