PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.14
MxChat – AI Chatbot & Content Generation for WordPress v3.2.14
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 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / includes / class-rest-api.php

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

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