PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.25
MxChat – AI Chatbot & Content Generation for WordPress v3.2.25
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
← All changes | includes/class-rest-api.php +317 -17 3.2.5 → 3.2.25 View file →
@@ -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.