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
mxchat-basic / includes / class-rest-api.php

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

901 lines 39.1 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 // 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'] : '');
313
314 return rest_ensure_response(array(
315 'ok' => true,
316 'plugin_version' => defined('MXCHAT_VERSION') ? MXCHAT_VERSION : null,
317 'token_set' => $token_set,
318 'embedding_model' => $embedding_model,
319 'utils_loaded' => class_exists('MxChat_Utils'),
320 'namespace' => self::REST_NAMESPACE,
321 ));
322 }
323
324 /**
325 * GET /transcripts — return transcript rows with optional filters.
326 * Always uses prepared statements; user-supplied strings never concatenated into SQL.
327 */
328 public function handle_get_transcripts($request) {
329 global $wpdb;
330 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
331
332 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table)) !== $table) {
333 return new WP_Error(
334 'mxchat_rest_no_table',
335 __('Transcripts table does not exist on this site.', 'mxchat'),
336 array('status' => 500)
337 );
338 }
339
340 $since = (string) $request->get_param('since');
341 $until = (string) $request->get_param('until');
342 $session_id = (string) $request->get_param('session_id');
343 $role = (string) $request->get_param('role');
344 $has_rag_context = (string) $request->get_param('has_rag_context');
345 $limit = (int) $request->get_param('limit');
346 $offset = (int) $request->get_param('offset');
347 $order = strtolower((string) $request->get_param('order')) === 'asc' ? 'ASC' : 'DESC';
348
349 $where = array('1=1');
350 $params = array();
351
352 if ($since !== '') {
353 $ts = $this->parse_datetime($since);
354 if ($ts === null) {
355 return new WP_Error('mxchat_rest_bad_since', __('Could not parse `since` parameter as a date.', 'mxchat'), array('status' => 400));
356 }
357 $where[] = 'timestamp >= %s';
358 $params[] = gmdate('Y-m-d H:i:s', $ts);
359 }
360 if ($until !== '') {
361 $ts = $this->parse_datetime($until);
362 if ($ts === null) {
363 return new WP_Error('mxchat_rest_bad_until', __('Could not parse `until` parameter as a date.', 'mxchat'), array('status' => 400));
364 }
365 $where[] = 'timestamp <= %s';
366 $params[] = gmdate('Y-m-d H:i:s', $ts);
367 }
368 if ($session_id !== '') {
369 $where[] = 'session_id = %s';
370 $params[] = $session_id;
371 }
372 if ($role === 'user' || $role === 'assistant') {
373 $where[] = 'role = %s';
374 $params[] = $role;
375 }
376 if ($has_rag_context === 'yes') {
377 $where[] = "(rag_context IS NOT NULL AND rag_context <> '')";
378 } elseif ($has_rag_context === 'no') {
379 $where[] = "(rag_context IS NULL OR rag_context = '')";
380 }
381
382 $where_sql = implode(' AND ', $where);
383
384 // Fixed column list — never user-controlled.
385 $columns = 'id, user_id, session_id, role, message, user_email, user_name, user_identifier, originating_page_url, originating_page_title, rag_context, timestamp';
386
387 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
388 // $where_sql, $columns, $order, $table are all built from server-side
389 // whitelisted values; no user input is concatenated. All variable
390 // values flow through $wpdb->prepare() via $params + LIMIT/OFFSET.
391 $sql = "SELECT $columns FROM $table WHERE $where_sql ORDER BY timestamp $order LIMIT %d OFFSET %d";
392 $params[] = $limit;
393 $params[] = $offset;
394
395 $prepared = $wpdb->prepare($sql, $params);
396 $rows = $wpdb->get_results($prepared, ARRAY_A);
397 // phpcs:enable
398
399 // Total count (respecting filters, ignoring limit/offset) for pagination.
400 $count_sql = "SELECT COUNT(*) FROM $table WHERE $where_sql";
401 if (!empty(array_slice($params, 0, count($params) - 2))) {
402 $count_prepared = $wpdb->prepare($count_sql, array_slice($params, 0, count($params) - 2));
403 } else {
404 $count_prepared = $count_sql;
405 }
406 $total = (int) $wpdb->get_var($count_prepared);
407
408 $items = array();
409 if (is_array($rows)) {
410 foreach ($rows as $row) {
411 $items[] = array(
412 'id' => (int) $row['id'],
413 'user_id' => (int) $row['user_id'],
414 'session_id' => (string) $row['session_id'],
415 'role' => (string) $row['role'],
416 'message' => (string) $row['message'],
417 'user_email' => $row['user_email'] !== null ? (string) $row['user_email'] : null,
418 'user_name' => $row['user_name'] !== null ? (string) $row['user_name'] : null,
419 'user_identifier' => $row['user_identifier'] !== null ? (string) $row['user_identifier'] : null,
420 'originating_page_url' => $row['originating_page_url'] !== null ? (string) $row['originating_page_url'] : null,
421 'originating_page_title' => $row['originating_page_title'] !== null ? (string) $row['originating_page_title'] : null,
422 'has_rag_context' => !empty($row['rag_context']),
423 'rag_context' => $row['rag_context'] !== null ? (string) $row['rag_context'] : null,
424 'timestamp' => (string) $row['timestamp'],
425 );
426 }
427 }
428
429 return rest_ensure_response(array(
430 'total' => $total,
431 'count' => count($items),
432 'limit' => $limit,
433 'offset' => $offset,
434 'items' => $items,
435 ));
436 }
437
438 /**
439 * DELETE /transcripts — delete chat data for one or more session_ids.
440 *
441 * Body (JSON):
442 * {
443 * "session_ids": ["sid1", "sid2", ...], // required, non-empty
444 * "cascade": true // optional, default true
445 * }
446 *
447 * Cascading deletes (when cascade=true) also remove rows in:
448 * - wp_mxchat_transcript_translations (any saved translations)
449 * - wp_mxchat_url_clicks (link-click tracking rows)
450 *
451 * Hard caps for safety:
452 * - max 1000 session_ids per call
453 * - each session_id must be a non-empty string
454 *
455 * Response:
456 * {
457 * "session_ids_requested": N,
458 * "transcripts_deleted": N,
459 * "translations_deleted": N,
460 * "url_clicks_deleted": N,
461 * "cascade": bool
462 * }
463 */
464 public function handle_delete_transcripts($request) {
465 global $wpdb;
466
467 $body = $request->get_json_params();
468 if (!is_array($body)) {
469 $body = array();
470 }
471
472 $session_ids_raw = isset($body['session_ids']) ? $body['session_ids'] : null;
473 $cascade = isset($body['cascade']) ? (bool) $body['cascade'] : true;
474
475 if (!is_array($session_ids_raw) || empty($session_ids_raw)) {
476 return new WP_Error(
477 'mxchat_rest_no_session_ids',
478 __('session_ids is required and must be a non-empty array.', 'mxchat'),
479 array('status' => 400)
480 );
481 }
482
483 if (count($session_ids_raw) > 1000) {
484 return new WP_Error(
485 'mxchat_rest_too_many',
486 __('Too many session_ids in a single request. Cap is 1000; split into multiple calls.', 'mxchat'),
487 array('status' => 400)
488 );
489 }
490
491 // Sanitize: must be non-empty strings, dedupe, drop bad values.
492 $session_ids = array();
493 foreach ($session_ids_raw as $sid) {
494 if (is_string($sid) && trim($sid) !== '') {
495 $clean = sanitize_text_field($sid);
496 if ($clean !== '') {
497 $session_ids[] = $clean;
498 }
499 }
500 }
501 $session_ids = array_values(array_unique($session_ids));
502
503 if (empty($session_ids)) {
504 return new WP_Error(
505 'mxchat_rest_no_valid_session_ids',
506 __('session_ids must contain at least one valid non-empty string.', 'mxchat'),
507 array('status' => 400)
508 );
509 }
510
511 $transcripts_table = $wpdb->prefix . 'mxchat_chat_transcripts';
512 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
513 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
514
515 // Verify the main table exists; the others are best-effort.
516 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $transcripts_table)) !== $transcripts_table) {
517 return new WP_Error(
518 'mxchat_rest_no_table',
519 __('Transcripts table does not exist on this site.', 'mxchat'),
520 array('status' => 500)
521 );
522 }
523
524 $placeholders = implode(',', array_fill(0, count($session_ids), '%s'));
525
526 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
527 // $placeholders is a server-built list of literal "%s" tokens — never user-controlled.
528 // All values flow through $wpdb->prepare() via $session_ids.
529 $deleted_transcripts = (int) $wpdb->query(
530 $wpdb->prepare(
531 "DELETE FROM $transcripts_table WHERE session_id IN ($placeholders)",
532 $session_ids
533 )
534 );
535
536 $deleted_translations = 0;
537 $deleted_url_clicks = 0;
538
539 if ($cascade) {
540 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $translations_table)) === $translations_table) {
541 $deleted_translations = (int) $wpdb->query(
542 $wpdb->prepare(
543 "DELETE FROM $translations_table WHERE session_id IN ($placeholders)",
544 $session_ids
545 )
546 );
547 }
548 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $url_clicks_table)) === $url_clicks_table) {
549 $deleted_url_clicks = (int) $wpdb->query(
550 $wpdb->prepare(
551 "DELETE FROM $url_clicks_table WHERE session_id IN ($placeholders)",
552 $session_ids
553 )
554 );
555 }
556 }
557 // phpcs:enable
558
559 return rest_ensure_response(array(
560 'session_ids_requested' => count($session_ids),
561 'transcripts_deleted' => $deleted_transcripts,
562 'translations_deleted' => $deleted_translations,
563 'url_clicks_deleted' => $deleted_url_clicks,
564 'cascade' => $cascade,
565 ));
566 }
567
568 /**
569 * POST /knowledge — embed + store content in the KB.
570 * Wraps MxChat_Utils::submit_content_to_db() so external tools can push
571 * content the same way an admin would via the Knowledge UI.
572 */
573 public function handle_post_knowledge($request) {
574 if (!class_exists('MxChat_Utils')) {
575 return new WP_Error(
576 'mxchat_rest_no_utils',
577 __('MxChat_Utils is not loaded.', 'mxchat'),
578 array('status' => 500)
579 );
580 }
581
582 $body = $request->get_json_params();
583 if (!is_array($body)) {
584 $body = array();
585 }
586
587 $raw_content = isset($body['content']) ? (string) $body['content'] : '';
588 $content = wp_kses_post(wp_unslash($raw_content));
589
590 $source_url = isset($body['source_url']) ? esc_url_raw((string) $body['source_url']) : '';
591 $bot_id = isset($body['bot_id']) ? sanitize_key((string) $body['bot_id']) : 'default';
592 $content_type = isset($body['content_type']) ? sanitize_key((string) $body['content_type']) : 'manual';
593
594 if ($content === '') {
595 return new WP_Error('mxchat_rest_no_content', __('content is required.', 'mxchat'), array('status' => 400));
596 }
597 if ($source_url === '') {
598 return new WP_Error('mxchat_rest_no_url', __('source_url is required and must be a valid URL.', 'mxchat'), array('status' => 400));
599 }
600 if ($bot_id === '') {
601 $bot_id = 'default';
602 }
603
604 // Pull the embedding API key from MxChat options (same logic as the
605 // admin form handler in MxChat_Knowledge_Manager::mxchat_handle_content_submission).
606 $bot_options = $this->get_bot_options($bot_id);
607 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', array());
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));
614 }
615 $api_key = $preflight['api_key'];
616
617 // Embedding + chunking can take 10-60s for large documents.
618 if (function_exists('set_time_limit')) {
619 @set_time_limit(300);
620 }
621 @ignore_user_abort(true);
622
623 $result = MxChat_Utils::submit_content_to_db($content, $source_url, $api_key, null, $bot_id, $content_type);
624
625 if (is_wp_error($result)) {
626 return new WP_Error(
627 'mxchat_rest_kb_failed',
628 $result->get_error_message(),
629 array('status' => 500)
630 );
631 }
632
633 return rest_ensure_response(array(
634 'success' => true,
635 'source_url' => $source_url,
636 'bot_id' => $bot_id,
637 'content_type' => $content_type,
638 'bytes' => strlen($content),
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);
872 }
873
874 /**
875 * Get bot-specific options if the multi-bot add-on is present, else return empty.
876 */
877 private function get_bot_options($bot_id) {
878 if ($bot_id === '' || $bot_id === 'default') {
879 return array();
880 }
881 if (!class_exists('MxChat_Multi_Bot_Manager')) {
882 return array();
883 }
884 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
885 return is_array($bot_options) ? $bot_options : array();
886 }
887
888 /**
889 * Try a few formats before falling back to strtotime.
890 * Returns a Unix timestamp or null.
891 */
892 private function parse_datetime($value) {
893 $value = trim((string) $value);
894 if ($value === '') {
895 return null;
896 }
897 $ts = strtotime($value);
898 return ($ts === false || $ts <= 0) ? null : $ts;
899 }
900 }
901