PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.23
MxChat – AI Chatbot & Content Generation for WordPress v3.2.23
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
mxchat-basic / includes / class-rest-api.php

class-rest-api.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.23, at includes/class-rest-api.php

907 lines 39.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MxChat REST API
4 *
5 * Bearer-token-authenticated REST endpoints exposing core primitives:
6 *
7 * GET /wp-json/mxchat/v1/transcripts — read chat transcripts (filterable)
8 * DELETE /wp-json/mxchat/v1/transcripts — delete by session_ids (cascades)
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
12 * GET /wp-json/mxchat/v1/health — connectivity + capability check
13 *
14 * These are general-purpose primitives. They power the official MxChat FAQ
15 * agent, but anyone running MxChat can use them for analytics exports,
16 * external automations (n8n, Zapier, Make), data migrations, custom RAG
17 * pipelines, etc.
18 *
19 * Auth:
20 * Bearer token stored in wp_options under `mxchat_api_token`.
21 * - When the token is empty (default), all authenticated routes are
22 * locked. The endpoints simply refuse with 401, so the surface area
23 * is zero until the site owner explicitly enables it from the
24 * "MxChat → API Access" admin page (or via WP-CLI).
25 * - Comparison uses hash_equals() for constant-time safety.
26 *
27 * Privacy:
28 * The /transcripts endpoint returns user-submitted chat data. It is
29 * intentionally gated behind a token the site owner generates. No data
30 * leaves the site unsolicited.
31 *
32 * @package MxChat
33 * @since 3.2.5
34 */
35
36 if (!defined('ABSPATH')) {
37 exit;
38 }
39
40 class MxChat_Rest_Api {
41
42 const REST_NAMESPACE = 'mxchat/v1';
43 const TOKEN_OPTION = 'mxchat_api_token';
44
45 public function __construct() {
46 add_action('rest_api_init', array($this, 'register_routes'));
47 }
48
49 /**
50 * Register the REST routes.
51 */
52 public function register_routes() {
53 register_rest_route(self::REST_NAMESPACE, '/health', array(
54 array(
55 'methods' => WP_REST_Server::READABLE,
56 'callback' => array($this, 'handle_health'),
57 'permission_callback' => '__return_true',
58 ),
59 ));
60
61 register_rest_route(self::REST_NAMESPACE, '/transcripts', array(
62 array(
63 'methods' => WP_REST_Server::READABLE,
64 'callback' => array($this, 'handle_get_transcripts'),
65 'permission_callback' => array($this, 'check_bearer_token'),
66 'args' => array(
67 'since' => array(
68 'description' => __('Only return rows with timestamp >= this value. Accepts ISO 8601 (2026-05-07T00:00:00Z) or any strtotime-compatible string.', 'mxchat'),
69 'type' => 'string',
70 'required' => false,
71 'sanitize_callback' => 'sanitize_text_field',
72 ),
73 'until' => array(
74 'description' => __('Only return rows with timestamp <= this value. Same format as `since`.', 'mxchat'),
75 'type' => 'string',
76 'required' => false,
77 'sanitize_callback' => 'sanitize_text_field',
78 ),
79 'session_id' => array(
80 'description' => __('Filter to a single conversation.', 'mxchat'),
81 'type' => 'string',
82 'required' => false,
83 'sanitize_callback' => 'sanitize_text_field',
84 ),
85 'role' => array(
86 'description' => __('Filter by role. One of: user, assistant, all (default: all).', 'mxchat'),
87 'type' => 'string',
88 'required' => false,
89 'enum' => array('user', 'assistant', 'all'),
90 'default' => 'all',
91 ),
92 'has_rag_context' => array(
93 'description' => __('Filter by whether the row has retrieved RAG context. Useful for finding "no-knowledge-hit" answers. One of: yes, no, any (default: any).', 'mxchat'),
94 'type' => 'string',
95 'required' => false,
96 'enum' => array('yes', 'no', 'any'),
97 'default' => 'any',
98 ),
99 'limit' => array(
100 'description' => __('Max number of rows to return. 1-1000, default 100.', 'mxchat'),
101 'type' => 'integer',
102 'required' => false,
103 'default' => 100,
104 'minimum' => 1,
105 'maximum' => 1000,
106 ),
107 'offset' => array(
108 'description' => __('Skip this many rows (for pagination).', 'mxchat'),
109 'type' => 'integer',
110 'required' => false,
111 'default' => 0,
112 'minimum' => 0,
113 ),
114 'order' => array(
115 'description' => __('Sort order on timestamp. asc or desc (default: desc).', 'mxchat'),
116 'type' => 'string',
117 'required' => false,
118 'enum' => array('asc', 'desc'),
119 'default' => 'desc',
120 ),
121 ),
122 ),
123 ));
124
125 register_rest_route(self::REST_NAMESPACE, '/transcripts', array(
126 array(
127 'methods' => WP_REST_Server::DELETABLE,
128 'callback' => array($this, 'handle_delete_transcripts'),
129 'permission_callback' => array($this, 'check_bearer_token'),
130 'args' => array(
131 'session_ids' => array(
132 'description' => __('Array of session_ids to delete. Required and non-empty — there is intentionally no "delete all" shorthand.', 'mxchat'),
133 'type' => 'array',
134 'required' => true,
135 'items' => array('type' => 'string'),
136 ),
137 'cascade' => array(
138 'description' => __('If true (default), also deletes related rows in mxchat_transcript_translations and mxchat_url_clicks for each session_id.', 'mxchat'),
139 'type' => 'boolean',
140 'required' => false,
141 'default' => true,
142 ),
143 ),
144 ),
145 ));
146
147 register_rest_route(self::REST_NAMESPACE, '/knowledge', array(
148 array(
149 'methods' => WP_REST_Server::CREATABLE,
150 'callback' => array($this, 'handle_post_knowledge'),
151 'permission_callback' => array($this, 'check_bearer_token'),
152 'args' => array(
153 'content' => array(
154 'description' => __('The content body to embed and store.', 'mxchat'),
155 'type' => 'string',
156 'required' => true,
157 ),
158 'source_url' => array(
159 'description' => __('Canonical URL the content came from. Used as the dedupe key — submitting the same URL replaces the existing entry.', 'mxchat'),
160 'type' => 'string',
161 'required' => true,
162 'sanitize_callback' => 'esc_url_raw',
163 ),
164 'bot_id' => array(
165 'description' => __('Bot ID (for multi-bot installs). Defaults to "default".', 'mxchat'),
166 'type' => 'string',
167 'required' => false,
168 'default' => 'default',
169 'sanitize_callback' => 'sanitize_key',
170 ),
171 'content_type' => array(
172 'description' => __('Free-form content type label, e.g. content, manual, faq, page, post. Stored alongside the entry for filtering. Defaults to "manual".', 'mxchat'),
173 'type' => 'string',
174 'required' => false,
175 'default' => 'manual',
176 'sanitize_callback' => 'sanitize_key',
177 ),
178 ),
179 ),
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 ));
253 }
254
255 /**
256 * Permission callback: validate bearer token in constant time.
257 * Returns true on success, WP_Error on failure.
258 *
259 * @param WP_REST_Request $request
260 * @return bool|WP_Error
261 */
262 public function check_bearer_token($request) {
263 $stored = (string) get_option(self::TOKEN_OPTION, '');
264 if ($stored === '') {
265 return new WP_Error(
266 'mxchat_rest_disabled',
267 __('MxChat REST API is disabled. Generate a token from MxChat → API Access in the WordPress admin.', 'mxchat'),
268 array('status' => 401)
269 );
270 }
271
272 $auth = $request->get_header('authorization');
273 if (!is_string($auth) || $auth === '') {
274 return new WP_Error(
275 'mxchat_rest_no_auth',
276 __('Missing Authorization header. Expected: Authorization: Bearer <token>', 'mxchat'),
277 array('status' => 401)
278 );
279 }
280
281 if (!preg_match('/^Bearer\s+(.+)$/i', $auth, $matches)) {
282 return new WP_Error(
283 'mxchat_rest_bad_auth',
284 __('Malformed Authorization header. Expected: Authorization: Bearer <token>', 'mxchat'),
285 array('status' => 401)
286 );
287 }
288
289 $provided = trim($matches[1]);
290 if ($provided === '' || !hash_equals($stored, $provided)) {
291 return new WP_Error(
292 'mxchat_rest_bad_token',
293 __('Invalid API token.', 'mxchat'),
294 array('status' => 401)
295 );
296 }
297
298 return true;
299 }
300
301 /**
302 * GET /health — public, no auth.
303 * Reports whether the API is configured and which capabilities are present.
304 */
305 public function handle_health($request) {
306 $token_set = (string) get_option(self::TOKEN_OPTION, '') !== '';
307
308 $options = get_option('mxchat_options', array());
309 $embedding_model = isset($options['embedding_model']) ? (string) $options['embedding_model'] : '';
310
311 return rest_ensure_response(array(
312 'ok' => true,
313 'plugin_version' => defined('MXCHAT_VERSION') ? MXCHAT_VERSION : null,
314 'token_set' => $token_set,
315 'embedding_model' => $embedding_model,
316 'utils_loaded' => class_exists('MxChat_Utils'),
317 'namespace' => self::REST_NAMESPACE,
318 ));
319 }
320
321 /**
322 * GET /transcripts — return transcript rows with optional filters.
323 * Always uses prepared statements; user-supplied strings never concatenated into SQL.
324 */
325 public function handle_get_transcripts($request) {
326 global $wpdb;
327 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
328
329 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table)) !== $table) {
330 return new WP_Error(
331 'mxchat_rest_no_table',
332 __('Transcripts table does not exist on this site.', 'mxchat'),
333 array('status' => 500)
334 );
335 }
336
337 $since = (string) $request->get_param('since');
338 $until = (string) $request->get_param('until');
339 $session_id = (string) $request->get_param('session_id');
340 $role = (string) $request->get_param('role');
341 $has_rag_context = (string) $request->get_param('has_rag_context');
342 $limit = (int) $request->get_param('limit');
343 $offset = (int) $request->get_param('offset');
344 $order = strtolower((string) $request->get_param('order')) === 'asc' ? 'ASC' : 'DESC';
345
346 $where = array('1=1');
347 $params = array();
348
349 if ($since !== '') {
350 $ts = $this->parse_datetime($since);
351 if ($ts === null) {
352 return new WP_Error('mxchat_rest_bad_since', __('Could not parse `since` parameter as a date.', 'mxchat'), array('status' => 400));
353 }
354 $where[] = 'timestamp >= %s';
355 $params[] = gmdate('Y-m-d H:i:s', $ts);
356 }
357 if ($until !== '') {
358 $ts = $this->parse_datetime($until);
359 if ($ts === null) {
360 return new WP_Error('mxchat_rest_bad_until', __('Could not parse `until` parameter as a date.', 'mxchat'), array('status' => 400));
361 }
362 $where[] = 'timestamp <= %s';
363 $params[] = gmdate('Y-m-d H:i:s', $ts);
364 }
365 if ($session_id !== '') {
366 $where[] = 'session_id = %s';
367 $params[] = $session_id;
368 }
369 if ($role === 'user' || $role === 'assistant') {
370 $where[] = 'role = %s';
371 $params[] = $role;
372 }
373 if ($has_rag_context === 'yes') {
374 $where[] = "(rag_context IS NOT NULL AND rag_context <> '')";
375 } elseif ($has_rag_context === 'no') {
376 $where[] = "(rag_context IS NULL OR rag_context = '')";
377 }
378
379 $where_sql = implode(' AND ', $where);
380
381 // Fixed column list — never user-controlled.
382 $columns = 'id, user_id, session_id, role, message, user_email, user_name, user_identifier, originating_page_url, originating_page_title, rag_context, timestamp';
383
384 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
385 // $where_sql, $columns, $order, $table are all built from server-side
386 // whitelisted values; no user input is concatenated. All variable
387 // values flow through $wpdb->prepare() via $params + LIMIT/OFFSET.
388 $sql = "SELECT $columns FROM $table WHERE $where_sql ORDER BY timestamp $order LIMIT %d OFFSET %d";
389 $params[] = $limit;
390 $params[] = $offset;
391
392 $prepared = $wpdb->prepare($sql, $params);
393 $rows = $wpdb->get_results($prepared, ARRAY_A);
394 // phpcs:enable
395
396 // Total count (respecting filters, ignoring limit/offset) for pagination.
397 $count_sql = "SELECT COUNT(*) FROM $table WHERE $where_sql";
398 if (!empty(array_slice($params, 0, count($params) - 2))) {
399 $count_prepared = $wpdb->prepare($count_sql, array_slice($params, 0, count($params) - 2));
400 } else {
401 $count_prepared = $count_sql;
402 }
403 $total = (int) $wpdb->get_var($count_prepared);
404
405 $items = array();
406 if (is_array($rows)) {
407 foreach ($rows as $row) {
408 $items[] = array(
409 'id' => (int) $row['id'],
410 'user_id' => (int) $row['user_id'],
411 'session_id' => (string) $row['session_id'],
412 'role' => (string) $row['role'],
413 'message' => (string) $row['message'],
414 'user_email' => $row['user_email'] !== null ? (string) $row['user_email'] : null,
415 'user_name' => $row['user_name'] !== null ? (string) $row['user_name'] : null,
416 'user_identifier' => $row['user_identifier'] !== null ? (string) $row['user_identifier'] : null,
417 'originating_page_url' => $row['originating_page_url'] !== null ? (string) $row['originating_page_url'] : null,
418 'originating_page_title' => $row['originating_page_title'] !== null ? (string) $row['originating_page_title'] : null,
419 'has_rag_context' => !empty($row['rag_context']),
420 'rag_context' => $row['rag_context'] !== null ? (string) $row['rag_context'] : null,
421 'timestamp' => (string) $row['timestamp'],
422 );
423 }
424 }
425
426 return rest_ensure_response(array(
427 'total' => $total,
428 'count' => count($items),
429 'limit' => $limit,
430 'offset' => $offset,
431 'items' => $items,
432 ));
433 }
434
435 /**
436 * DELETE /transcripts — delete chat data for one or more session_ids.
437 *
438 * Body (JSON):
439 * {
440 * "session_ids": ["sid1", "sid2", ...], // required, non-empty
441 * "cascade": true // optional, default true
442 * }
443 *
444 * Cascading deletes (when cascade=true) also remove rows in:
445 * - wp_mxchat_transcript_translations (any saved translations)
446 * - wp_mxchat_url_clicks (link-click tracking rows)
447 *
448 * Hard caps for safety:
449 * - max 1000 session_ids per call
450 * - each session_id must be a non-empty string
451 *
452 * Response:
453 * {
454 * "session_ids_requested": N,
455 * "transcripts_deleted": N,
456 * "translations_deleted": N,
457 * "url_clicks_deleted": N,
458 * "cascade": bool
459 * }
460 */
461 public function handle_delete_transcripts($request) {
462 global $wpdb;
463
464 $body = $request->get_json_params();
465 if (!is_array($body)) {
466 $body = array();
467 }
468
469 $session_ids_raw = isset($body['session_ids']) ? $body['session_ids'] : null;
470 $cascade = isset($body['cascade']) ? (bool) $body['cascade'] : true;
471
472 if (!is_array($session_ids_raw) || empty($session_ids_raw)) {
473 return new WP_Error(
474 'mxchat_rest_no_session_ids',
475 __('session_ids is required and must be a non-empty array.', 'mxchat'),
476 array('status' => 400)
477 );
478 }
479
480 if (count($session_ids_raw) > 1000) {
481 return new WP_Error(
482 'mxchat_rest_too_many',
483 __('Too many session_ids in a single request. Cap is 1000; split into multiple calls.', 'mxchat'),
484 array('status' => 400)
485 );
486 }
487
488 // Sanitize: must be non-empty strings, dedupe, drop bad values.
489 $session_ids = array();
490 foreach ($session_ids_raw as $sid) {
491 if (is_string($sid) && trim($sid) !== '') {
492 $clean = sanitize_text_field($sid);
493 if ($clean !== '') {
494 $session_ids[] = $clean;
495 }
496 }
497 }
498 $session_ids = array_values(array_unique($session_ids));
499
500 if (empty($session_ids)) {
501 return new WP_Error(
502 'mxchat_rest_no_valid_session_ids',
503 __('session_ids must contain at least one valid non-empty string.', 'mxchat'),
504 array('status' => 400)
505 );
506 }
507
508 $transcripts_table = $wpdb->prefix . 'mxchat_chat_transcripts';
509 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
510 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
511
512 // Verify the main table exists; the others are best-effort.
513 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $transcripts_table)) !== $transcripts_table) {
514 return new WP_Error(
515 'mxchat_rest_no_table',
516 __('Transcripts table does not exist on this site.', 'mxchat'),
517 array('status' => 500)
518 );
519 }
520
521 $placeholders = implode(',', array_fill(0, count($session_ids), '%s'));
522
523 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
524 // $placeholders is a server-built list of literal "%s" tokens — never user-controlled.
525 // All values flow through $wpdb->prepare() via $session_ids.
526 $deleted_transcripts = (int) $wpdb->query(
527 $wpdb->prepare(
528 "DELETE FROM $transcripts_table WHERE session_id IN ($placeholders)",
529 $session_ids
530 )
531 );
532
533 $deleted_translations = 0;
534 $deleted_url_clicks = 0;
535
536 if ($cascade) {
537 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $translations_table)) === $translations_table) {
538 $deleted_translations = (int) $wpdb->query(
539 $wpdb->prepare(
540 "DELETE FROM $translations_table WHERE session_id IN ($placeholders)",
541 $session_ids
542 )
543 );
544 }
545 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $url_clicks_table)) === $url_clicks_table) {
546 $deleted_url_clicks = (int) $wpdb->query(
547 $wpdb->prepare(
548 "DELETE FROM $url_clicks_table WHERE session_id IN ($placeholders)",
549 $session_ids
550 )
551 );
552 }
553 }
554 // phpcs:enable
555
556 return rest_ensure_response(array(
557 'session_ids_requested' => count($session_ids),
558 'transcripts_deleted' => $deleted_transcripts,
559 'translations_deleted' => $deleted_translations,
560 'url_clicks_deleted' => $deleted_url_clicks,
561 'cascade' => $cascade,
562 ));
563 }
564
565 /**
566 * POST /knowledge — embed + store content in the KB.
567 * Wraps MxChat_Utils::submit_content_to_db() so external tools can push
568 * content the same way an admin would via the Knowledge UI.
569 */
570 public function handle_post_knowledge($request) {
571 if (!class_exists('MxChat_Utils')) {
572 return new WP_Error(
573 'mxchat_rest_no_utils',
574 __('MxChat_Utils is not loaded.', 'mxchat'),
575 array('status' => 500)
576 );
577 }
578
579 $body = $request->get_json_params();
580 if (!is_array($body)) {
581 $body = array();
582 }
583
584 $raw_content = isset($body['content']) ? (string) $body['content'] : '';
585 $content = wp_kses_post(wp_unslash($raw_content));
586
587 $source_url = isset($body['source_url']) ? esc_url_raw((string) $body['source_url']) : '';
588 $bot_id = isset($body['bot_id']) ? sanitize_key((string) $body['bot_id']) : 'default';
589 $content_type = isset($body['content_type']) ? sanitize_key((string) $body['content_type']) : 'manual';
590
591 if ($content === '') {
592 return new WP_Error('mxchat_rest_no_content', __('content is required.', 'mxchat'), array('status' => 400));
593 }
594 if ($source_url === '') {
595 return new WP_Error('mxchat_rest_no_url', __('source_url is required and must be a valid URL.', 'mxchat'), array('status' => 400));
596 }
597 if ($bot_id === '') {
598 $bot_id = 'default';
599 }
600
601 // Pull the embedding API key from MxChat options (same logic as the
602 // admin form handler in MxChat_Knowledge_Manager::mxchat_handle_content_submission).
603 $bot_options = $this->get_bot_options($bot_id);
604 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', array());
605 $selected_model = isset($options['embedding_model']) ? (string) $options['embedding_model'] : 'text-embedding-ada-002';
606
607 if (strpos($selected_model, 'voyage') === 0) {
608 $api_key = isset($options['voyage_api_key']) ? (string) $options['voyage_api_key'] : '';
609 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
610 $api_key = isset($options['gemini_api_key']) ? (string) $options['gemini_api_key'] : '';
611 } else {
612 $api_key = isset($options['api_key']) ? (string) $options['api_key'] : '';
613 }
614
615 if ($api_key === '') {
616 return new WP_Error(
617 'mxchat_rest_no_api_key',
618 __('No embedding API key configured for the selected embedding model. Configure it in MxChat settings before pushing knowledge.', 'mxchat'),
619 array('status' => 500)
620 );
621 }
622
623 // Embedding + chunking can take 10-60s for large documents.
624 if (function_exists('set_time_limit')) {
625 @set_time_limit(300);
626 }
627 @ignore_user_abort(true);
628
629 $result = MxChat_Utils::submit_content_to_db($content, $source_url, $api_key, null, $bot_id, $content_type);
630
631 if (is_wp_error($result)) {
632 return new WP_Error(
633 'mxchat_rest_kb_failed',
634 $result->get_error_message(),
635 array('status' => 500)
636 );
637 }
638
639 return rest_ensure_response(array(
640 'success' => true,
641 'source_url' => $source_url,
642 'bot_id' => $bot_id,
643 'content_type' => $content_type,
644 'bytes' => strlen($content),
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);
878 }
879
880 /**
881 * Get bot-specific options if the multi-bot add-on is present, else return empty.
882 */
883 private function get_bot_options($bot_id) {
884 if ($bot_id === '' || $bot_id === 'default') {
885 return array();
886 }
887 if (!class_exists('MxChat_Multi_Bot_Manager')) {
888 return array();
889 }
890 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
891 return is_array($bot_options) ? $bot_options : array();
892 }
893
894 /**
895 * Try a few formats before falling back to strtotime.
896 * Returns a Unix timestamp or null.
897 */
898 private function parse_datetime($value) {
899 $value = trim((string) $value);
900 if ($value === '') {
901 return null;
902 }
903 $ts = strtotime($value);
904 return ($ts === false || $ts <= 0) ? null : $ts;
905 }
906 }
907