PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / trunk
AI Engine – The Chatbot, AI Framework & MCP for WordPress vtrunk
3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / classes / modules / discussions.php
ai-engine / classes / modules Last commit date
advisor.php 3 months ago chatbot.php 3 weeks ago discussions.php 23 hours ago editor-assistant.php 3 months ago files.php 3 months ago forms-manager.php 3 months ago gdpr.php 4 months ago search.php 3 months ago security.php 11 months ago tasks-examples.php 6 months ago tasks.php 1 month ago wand.php 3 months ago
discussions.php
927 lines
1 <?php
2
3 class Meow_MWAI_Modules_Discussions {
4 private $wpdb = null;
5 private $core = null;
6 public $table_chats = null;
7 private $db_check = false;
8 private $namespace_admin = 'mwai/v1';
9 private $namespace_ui = 'mwai-ui/v1';
10
11 public function __construct() {
12 global $wpdb;
13 $this->wpdb = $wpdb;
14 global $mwai_core;
15 $this->core = $mwai_core;
16 $this->table_chats = $wpdb->prefix . 'mwai_chats';
17
18 if ( $this->core->get_option( 'chatbot_discussions' ) ) {
19 add_filter( 'mwai_chatbot_reply', [ $this, 'chatbot_reply' ], 10, 4 );
20 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
21
22 // Register task handler
23 add_filter( 'mwai_task_cleanup_discussions', [ $this, 'handle_cleanup_task' ], 10, 2 );
24 }
25 }
26
27 public function rest_api_init() {
28 // Admin
29 register_rest_route( $this->namespace_admin, '/discussions/list', [
30 'methods' => 'POST',
31 'callback' => [ $this, 'rest_discussions_list' ],
32 'permission_callback' => [ $this->core, 'can_access_settings' ],
33 ] );
34 register_rest_route( $this->namespace_admin, '/discussions/delete', [
35 'methods' => 'POST',
36 'callback' => [ $this, 'rest_discussions_delete_admin' ],
37 'permission_callback' => [ $this->core, 'can_access_settings' ],
38 ] );
39 register_rest_route( $this->namespace_admin, '/discussions/stats', [
40 'methods' => 'GET',
41 'callback' => [ $this, 'rest_discussions_stats' ],
42 'permission_callback' => [ $this->core, 'can_access_settings' ],
43 ] );
44
45 // UI
46 register_rest_route( $this->namespace_ui, '/discussions/list', [
47 'methods' => 'POST',
48 'callback' => [ $this, 'rest_discussions_ui_list' ],
49 'permission_callback' => '__return_true'
50 ] );
51 register_rest_route( $this->namespace_ui, '/discussions/edit', [
52 'methods' => 'POST',
53 'callback' => [ $this, 'rest_discussions_ui_edit' ],
54 'permission_callback' => '__return_true'
55 ] );
56 register_rest_route( $this->namespace_ui, '/discussions/delete', [
57 'methods' => 'POST',
58 'callback' => [ $this, 'rest_discussions_delete' ],
59 'permission_callback' => [ $this, 'can_delete_discussion' ],
60 ] );
61 }
62
63 public function can_delete_discussion( $request ) {
64 $this->check_db();
65 $params = $request->get_json_params();
66 $chatIds = isset( $params['chatIds'] ) ? $params['chatIds'] : null;
67 $userId = get_current_user_id();
68 if ( !$userId ) {
69 return false;
70 }
71 foreach ( $chatIds as $chatId ) {
72 $chat = $this->wpdb->get_row(
73 $this->wpdb->prepare(
74 "SELECT * FROM $this->table_chats WHERE chatId = %s",
75 $chatId
76 )
77 );
78 if ( !$chat || (int) $chat->userId !== (int) $userId ) {
79 return false;
80 }
81 }
82 return true;
83 }
84
85 /**
86 * Helper method to create REST responses with automatic token refresh
87 *
88 * @param array $data The response data
89 * @param int $status HTTP status code
90 * @return WP_REST_Response
91 */
92 protected function create_rest_response( $data, $status = 200 ) {
93 // Always check if we need to provide a new nonce
94 $current_nonce = $this->core->get_nonce( true );
95 $request_nonce = isset( $_SERVER['HTTP_X_WP_NONCE'] ) ? $_SERVER['HTTP_X_WP_NONCE'] : null;
96
97 // Check if nonce is approaching expiration (WordPress nonces last 12-24 hours)
98 // We'll refresh if the nonce is older than 10 hours to be safe
99 $should_refresh = false;
100
101 if ( $request_nonce ) {
102 // Try to determine the age of the nonce
103 // WordPress uses a tick system where each tick is 12 hours
104 // If we're in the second half of the nonce's life, refresh it
105 $time = time();
106 $nonce_tick = wp_nonce_tick();
107
108 // Verify if the nonce is still valid but getting old
109 $verify = wp_verify_nonce( $request_nonce, 'wp_rest' );
110 if ( $verify === 2 ) {
111 // Nonce is valid but was generated 12-24 hours ago
112 $should_refresh = true;
113 // Log will be written when token is included in response
114 }
115 }
116
117 // If the nonce has changed or should be refreshed, include the new one
118 if ( $should_refresh || ( $request_nonce && $current_nonce !== $request_nonce ) ) {
119 $data['new_token'] = $current_nonce;
120
121 // Log if server debug mode is enabled
122 if ( $this->core->get_option( 'server_debug_mode' ) ) {
123 error_log( '[AI Engine] Token refresh: Nonce refreshed (12-24 hours old)' );
124 }
125 }
126
127 return new WP_REST_Response( $data, $status );
128 }
129
130 /**
131 * Generate or update the title for a specific discussion
132 * by calling the AI (if it meets the requirements).
133 *
134 * @param stdClass $discussion A row from the DB (object form).
135 * @return void
136 */
137 private function generate_title_for_discussion( $discussion ) {
138 // Check if there's already a title
139 if ( !empty( $discussion->title ) ) {
140 return; // Nothing to do if title is already set.
141 }
142
143 // Ensure it's not older than 10 days, or whatever logic you prefer
144 $ten_days_ago = strtotime( '-10 days' );
145 if ( strtotime( $discussion->updated ) < $ten_days_ago ) {
146 return; // Skip if older than 10 days
147 }
148
149 // We expect JSON in the messages
150 $messages = json_decode( $discussion->messages, true );
151 if ( !is_array( $messages ) ) {
152 return;
153 }
154
155 // Check for at least one user and one assistant message
156 $has_user_message = false;
157 $has_assistant_message = false;
158 foreach ( $messages as $message ) {
159 if ( isset( $message['role'] ) ) {
160 if ( $message['role'] === 'user' ) {
161 $has_user_message = true;
162 }
163 if ( $message['role'] === 'assistant' ) {
164 $has_assistant_message = true;
165 }
166 }
167 if ( $has_user_message && $has_assistant_message ) {
168 break;
169 }
170 }
171
172 if ( !( $has_user_message && $has_assistant_message ) ) {
173 return; // If doesn't have both, skip
174 }
175
176 // Prepare the conversation text for the prompt
177 $conversation_text = '';
178 foreach ( $messages as $message ) {
179 if ( isset( $message['role'] ) && isset( $message['content'] ) ) {
180 $role = ucfirst( $message['role'] );
181 $content = $message['content'];
182 $conversation_text .= "$role: $content\n";
183 }
184 }
185
186 $base_prompt = __( 'Based on the following conversation, generate a concise and specific title for the discussion, strictly less than 64 characters. Focus on the main topic, avoiding unnecessary words such as articles, pronouns, or adjectives. Do not include any punctuation at the end. Do not include anything else than the title itself, only one sentence, no line breaks, just the title.', 'ai-engine' ) . "\n\n" . __( 'Conversation:', 'ai-engine' ) . "\n$conversation_text\n";
187 $prompt = apply_filters( 'mwai_discussions_title_prompt', $base_prompt, $conversation_text, $discussion );
188
189 // Run the AI query using the fast environment
190 global $mwai;
191 $params = [ 'scope' => 'discussions' ];
192
193 // Use simpleFastTextQuery which handles Fast Model configuration
194 try {
195 $answer = $mwai->simpleFastTextQuery( $prompt, $params );
196
197 // Clean up the answer
198 $title = trim( $answer );
199 $title = rtrim( $title, '.!?:;,—–-–' ); // Remove trailing punctuation
200 $title = substr( $title, 0, 64 ); // Ensure less than 64 characters
201 if ( empty( $title ) ) {
202 $title = __( 'Untitled', 'ai-engine' );
203 }
204 }
205 catch ( Exception $e ) {
206 // Handle content filter or other API errors
207 $error_message = $e->getMessage();
208 if ( strpos( $error_message, 'content_filter' ) !== false ||
209 strpos( $error_message, 'ResponsibleAIPolicyViolation' ) !== false ) {
210 error_log( "AI Engine: Content filter blocked title generation for discussion ID {$discussion->id}. Using fallback title." );
211 $title = __( 'Discussion', 'ai-engine' ) . ' ' . date( 'Y-m-d H:i' );
212 }
213 else {
214 error_log( "AI Engine: Failed to generate title for discussion ID {$discussion->id}: " . $error_message );
215 $title = __( 'Untitled', 'ai-engine' );
216 }
217 }
218
219 // Update the discussion with the title
220 $updated = $this->wpdb->update(
221 $this->table_chats,
222 [ 'title' => $title ],
223 [ 'id' => $discussion->id ]
224 );
225 if ( $updated === false ) {
226 error_log( "Failed to update the title for discussion ID {$discussion->id}" );
227 }
228 }
229
230 /**
231 * Admin route for listing discussions. No forced logic here.
232 */
233 public function rest_discussions_list( $request ) {
234 try {
235 $params = $request->get_json_params();
236 $offset = $params['offset'];
237 $limit = $params['limit'];
238 $filters = $params['filters'];
239 $sort = $params['sort'];
240
241 // Retrieve the chats
242 $chats = $this->chats_query( [], $offset, $limit, $filters, $sort );
243
244 return $this->create_rest_response( [ 'success' => true, 'total' => $chats['total'], 'chats' => $chats['rows'] ], 200 );
245 }
246 catch ( Exception $e ) {
247 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
248 }
249 }
250
251 /**
252 * Admin route for discussion counts, used by the Dashboard usage widget.
253 * Discussions are hard-deleted, so these counts never include deleted ones.
254 */
255 public function rest_discussions_stats( $request ) {
256 try {
257 $this->check_db();
258 $days = (int) $request->get_param( 'days' );
259 if ( $days < 1 || $days > 365 ) {
260 $days = 7;
261 }
262 $since = date( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) );
263 $count = (int) $this->wpdb->get_var(
264 $this->wpdb->prepare( "SELECT COUNT(*) FROM {$this->table_chats} WHERE created >= %s", $since )
265 );
266 $total = (int) $this->wpdb->get_var( "SELECT COUNT(*) FROM {$this->table_chats}" );
267 return $this->create_rest_response( [ 'success' => true, 'count' => $count, 'total' => $total, 'days' => $days ], 200 );
268 }
269 catch ( Exception $e ) {
270 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
271 }
272 }
273
274 public function rest_discussions_ui_edit( $request ) {
275 try {
276 $params = $request->get_json_params();
277 $chatId = isset( $params['chatId'] ) ? sanitize_text_field( $params['chatId'] ) : null;
278 $title = isset( $params['title'] ) ? sanitize_text_field( $params['title'] ) : null;
279
280 if ( is_null( $chatId ) || is_null( $title ) ) {
281 return $this->create_rest_response( [ 'success' => false, 'message' => 'chatId and title are required.' ], 400 );
282 }
283
284 $userId = get_current_user_id();
285 if ( !$userId ) {
286 return $this->create_rest_response( [ 'success' => false, 'message' => 'You need to be logged in.' ], 401 );
287 }
288
289 // Update the discussion title for the current user
290 $updated = $this->wpdb->update(
291 $this->table_chats,
292 [ 'title' => $title ],
293 [ 'chatId' => $chatId, 'userId' => $userId ]
294 );
295 if ( $updated === false ) {
296 return $this->create_rest_response( [ 'success' => false, 'message' => 'Failed to update the discussion.' ], 500 );
297 }
298
299 return $this->create_rest_response( [ 'success' => true ], 200 );
300 }
301 catch ( Exception $e ) {
302 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
303 }
304 }
305
306 public function cron_discussions() {
307 // Track cron execution start
308 $this->core->track_cron_start( 'mwai_discussions' );
309
310 try {
311 $this->check_db();
312
313 // NEW CHECK: Only run if auto-titling is enabled
314 if ( !$this->core->get_option( 'chatbot_discussions_titling' ) ) {
315 $this->core->track_cron_end( 'mwai_discussions', 'success' );
316 return;
317 }
318 // END NEW CHECK
319
320 // Set the current user to the first admin to avoid guest limits
321 $admin_users = get_users( [ 'role' => 'administrator', 'number' => 1 ] );
322 if ( !empty( $admin_users ) ) {
323 $admin_user = $admin_users[0];
324 wp_set_current_user( $admin_user->ID );
325 }
326
327 $now = date( 'Y-m-d H:i:s' );
328 $ten_days_ago = date( 'Y-m-d H:i:s', strtotime( '-10 days' ) );
329
330 // Get 5 latest discussions, not older than 10 days, which have no 'title' yet
331 $query = $this->wpdb->prepare(
332 "SELECT * FROM {$this->table_chats}
333 WHERE title IS NULL AND updated >= %s
334 ORDER BY updated DESC LIMIT 5",
335 $ten_days_ago
336 );
337 $discussions = $this->wpdb->get_results( $query );
338 if ( empty( $discussions ) ) {
339 $this->core->track_cron_end( 'mwai_discussions', 'success' );
340 return;
341 }
342
343 foreach ( $discussions as $discussion ) {
344 $this->generate_title_for_discussion( $discussion );
345 }
346
347 $this->core->track_cron_end( 'mwai_discussions', 'success' );
348 }
349 catch ( Exception $e ) {
350 $this->core->track_cron_end( 'mwai_discussions', 'error', $e->getMessage() );
351 }
352 }
353
354 /**
355 * UI route for listing discussions.
356 * Here we add the "forced cron" logic for up to 5 discussions,
357 * but only if auto-titling is enabled.
358 */
359 public function rest_discussions_ui_list( $request ) {
360 try {
361 $params = $request->get_json_params();
362 $offset = isset( $params['offset'] ) ? $params['offset'] : 0;
363 // Get paging setting from options
364 $paging_option = $this->core->get_option( 'chatbot_discussions_paging' );
365 if ( $paging_option === 'None' ) {
366 $default_limit = 999; // Show all discussions
367 }
368 else {
369 $default_limit = is_numeric( $paging_option ) ? intval( $paging_option ) : 10; // Fallback to 10
370 }
371 $limit = isset( $params['limit'] ) ? $params['limit'] : $default_limit;
372 $botId = isset( $params['botId'] ) ? $params['botId'] : null;
373 $customId = isset( $params['customId'] ) ? $params['customId'] : null;
374
375 if ( !is_null( $customId ) ) {
376 $botId = $customId;
377 }
378 if ( is_null( $botId ) ) {
379 return $this->create_rest_response( [ 'success' => false, 'message' => 'Bot ID is required.' ], 200 );
380 }
381
382 $userId = get_current_user_id();
383 if ( !$userId ) {
384 return $this->create_rest_response( [ 'success' => false, 'message' => 'You need to be connected.' ], 200 );
385 }
386
387 $filters = [
388 [ 'accessor' => 'user', 'value' => $userId ],
389 [ 'accessor' => 'botId', 'value' => $botId ],
390 ];
391
392 // Retrieve the chats
393 $chats = $this->chats_query( [], $offset, $limit, $filters );
394
395 // NEW CHECK: only do forced titling if it's enabled
396 if ( $this->core->get_option( 'chatbot_discussions_titling' ) ) {
397 // "Forced cron" logic: check up to 5 that have no title
398 $counter = 0;
399 foreach ( $chats['rows'] as &$chatRow ) {
400 if ( $counter >= 5 ) {
401 break;
402 }
403 if ( empty( $chatRow['title'] ) && strtotime( $chatRow['updated'] ) >= strtotime( '-10 days' ) ) {
404 $discussionObj = (object) $chatRow;
405 $this->generate_title_for_discussion( $discussionObj );
406 $counter++;
407 }
408 }
409 // If you want the newly-updated titles to show up *immediately*:
410 $chats = $this->chats_query( [], $offset, $limit, $filters );
411 }
412 // END NEW CHECK
413
414 // Apply filters to discussion metadata
415 foreach ( $chats['rows'] as &$chatRow ) {
416 // Decode messages JSON to get the count
417 $messages = json_decode( $chatRow['messages'], true );
418 $message_count = is_array( $messages ) ? count( $messages ) : 0;
419
420 // Add formatted metadata that can be filtered
421 $chatRow['metadata_display'] = [
422 'start_date' => apply_filters( 'mwai_discussion_metadata_start_date', $this->core->format_discussion_date( $chatRow['created'] ), $chatRow ),
423 'last_update' => apply_filters( 'mwai_discussion_metadata_last_update', $this->core->format_discussion_date( $chatRow['updated'] ), $chatRow ),
424 'message_count' => apply_filters( 'mwai_discussion_metadata_message_count', $message_count, $chatRow )
425 ];
426 }
427
428 return $this->create_rest_response( [ 'success' => true, 'total' => $chats['total'], 'chats' => $chats['rows'] ], 200 );
429 }
430 catch ( Exception $e ) {
431 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
432 }
433 }
434
435 public function rest_discussions_delete_admin( $request ) {
436 try {
437 $this->check_db();
438 $params = $request->get_json_params();
439 $chatsIds = $params['chatIds'];
440 if ( is_array( $chatsIds ) ) {
441 if ( count( $chatsIds ) === 0 ) {
442 $this->wpdb->query( "TRUNCATE TABLE $this->table_chats" );
443 }
444 foreach ( $chatsIds as $chatId ) {
445 $this->wpdb->delete( $this->table_chats, [ 'chatId' => $chatId ] );
446 }
447 }
448 return $this->create_rest_response( [ 'success' => true ], 200 );
449 }
450 catch ( Exception $e ) {
451 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
452 }
453 }
454
455 public function rest_discussions_delete( $request ) {
456 try {
457 $this->check_db();
458 $params = $request->get_json_params();
459 $chatIds = isset( $params['chatIds'] ) ? $params['chatIds'] : null;
460
461 if ( !is_array( $chatIds ) || empty( $chatIds ) ) {
462 return $this->create_rest_response( [ 'success' => false, 'message' => 'chatIds is required.' ], 400 );
463 }
464
465 $userId = get_current_user_id();
466 if ( !$userId ) {
467 return $this->create_rest_response( [ 'success' => false, 'message' => 'You need to be logged in.' ], 401 );
468 }
469
470 foreach ( $chatIds as $chatId ) {
471 $this->wpdb->delete( $this->table_chats, [ 'chatId' => $chatId, 'userId' => $userId ] );
472 }
473
474 return $this->create_rest_response( [ 'success' => true ], 200 );
475 }
476 catch ( Exception $e ) {
477 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
478 }
479 }
480
481 // Get latest discussion for the given parameter
482 public function get_discussion( $botId, $chatId ) {
483 $this->check_db();
484 $chat = $this->wpdb->get_row(
485 $this->wpdb->prepare(
486 "SELECT * FROM $this->table_chats WHERE chatId = %s AND botId = %s",
487 $chatId,
488 $botId
489 ),
490 ARRAY_A
491 );
492 if ( $chat ) {
493 $chat['messages'] = json_decode( $chat['messages'] );
494 return $chat;
495 }
496 return null;
497 }
498
499 public function chats_query( $chats = [], $offset = 0, $limit = null, $filters = null, $sort = null ) {
500 $this->check_db();
501 $offset = !empty( $offset ) ? intval( $offset ) : 0;
502 $limit = !empty( $limit ) ? intval( $limit ) : 5;
503 $filters = !empty( $filters ) ? $filters : [];
504 $this->core->sanitize_sort( $sort, 'updated', 'DESC' );
505
506 $where_clauses = [];
507 $where_values = [];
508
509 if ( is_array( $filters ) ) {
510 foreach ( $filters as $filter ) {
511 $value = $filter['value'];
512 if ( is_null( $value ) || $value === '' ) {
513 continue;
514 }
515 switch ( $filter['accessor'] ) {
516 case 'user':
517 $isIP = filter_var( $value, FILTER_VALIDATE_IP );
518 if ( $isIP ) {
519 $where_clauses[] = 'ip = %s';
520 $where_values[] = $value;
521 }
522 else {
523 $where_clauses[] = 'userId = %d';
524 $where_values[] = intval( $value );
525 }
526 break;
527 case 'botId':
528 $where_clauses[] = 'botId = %s';
529 $where_values[] = $value;
530 break;
531 case 'preview':
532 $like = '%' . $this->wpdb->esc_like( $value ) . '%';
533 $where_clauses[] = 'messages LIKE %s';
534 $where_values[] = $like;
535 break;
536 // Add other cases as needed
537 }
538 }
539 }
540
541 $where_sql = '';
542 if ( !empty( $where_clauses ) ) {
543 $where_sql = 'WHERE ' . implode( ' AND ', $where_clauses );
544 }
545 $order_by = 'ORDER BY ' . esc_sql( $sort['accessor'] ) . ' ' . esc_sql( $sort['by'] );
546
547 $limit_sql = '';
548 if ( $limit > 0 ) {
549 $limit_sql = $this->wpdb->prepare( 'LIMIT %d, %d', $offset, $limit );
550 }
551
552 $query = "SELECT * FROM {$this->table_chats} {$where_sql} {$order_by} {$limit_sql}";
553 $chats['rows'] = $this->wpdb->get_results( $this->wpdb->prepare( $query, $where_values ), ARRAY_A );
554
555 // Get the total count
556 $count_query = "SELECT COUNT(*) FROM {$this->table_chats} {$where_sql}";
557 $chats['total'] = $this->wpdb->get_var( $this->wpdb->prepare( $count_query, $where_values ) );
558
559 return $chats;
560 }
561
562 public function chatbot_reply( $rawText, $reply, $params, $extra ) {
563 global $mwai_core;
564 $query = $reply->query;
565 $userIp = $mwai_core->get_ip_address();
566 $userId = $mwai_core->get_user_id();
567 $botId = isset( $params['botId'] ) ? $params['botId'] : null;
568 $chatId = $this->core->fix_chat_id( $query, $params );
569 $customId = isset( $params['customId'] ) ? $params['customId'] : null;
570 $threadId = $query instanceof Meow_MWAI_Query_Assistant ? $query->threadId : null;
571 $storeId = $query instanceof Meow_MWAI_Query_Assistant ? $query->storeId : null;
572 $now = date( 'Y-m-d H:i:s' );
573
574 if ( !empty( $customId ) ) {
575 $botId = $customId;
576 }
577 $newMessage = isset( $params['newMessage'] ) ? $params['newMessage'] : $query->get_message();
578 $shortcutName = isset( $params['shortcutName'] ) ? $params['shortcutName'] : null;
579
580 // If there are images, add them to the message for display purposes
581 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
582 foreach ( $attachments as $attachedFile ) {
583 if ( $attachedFile->is_image() && $attachedFile->get_type() === 'url' ) {
584 $newMessage = "![Uploaded Image]({$attachedFile->get_url()})\n" . $newMessage;
585 }
586 }
587
588 $this->check_db();
589 $chat = $this->wpdb->get_row(
590 $this->wpdb->prepare(
591 "SELECT * FROM $this->table_chats WHERE chatId = %s",
592 $chatId
593 )
594 );
595
596 // Ownership guard: never append to, or take over, a discussion owned by a
597 // different logged-in user. Without this, anyone holding a valid REST nonce
598 // could submit another user's chatId, have the row reassigned to them (the
599 // userId update below) and then read the victim's history through
600 // discussions/list. A guest-owned row (userId 0) stays claimable so a
601 // visitor can keep their conversation after logging in.
602 // Cross-user takeover reported by Shivamani Vastrala (via WPScan/Automattic),
603 // fixed in 3.5.5. Keep this on the write path; do not owner-scope the read
604 // path (get_discussion), which would drop legitimately shared history.
605 if ( $chat && (int) $chat->userId !== 0 && (int) $chat->userId !== (int) $userId ) {
606 return $rawText;
607 }
608
609 $messageExtra = [
610 'embeddings' => isset( $extra['embeddings'] ) ? $extra['embeddings'] : null
611 ];
612 $chatExtra = [
613 'session' => $query->session,
614 'model' => $query->model,
615 ];
616 if ( !empty( $query->temperature ) ) {
617 $chatExtra['temperature'] = $query->temperature;
618 }
619 if ( !empty( $query->context ) ) {
620 $chatExtra['context'] = $query->context;
621 }
622 if ( !empty( $params['parentBotId'] ) ) {
623 $chatExtra['parentBotId'] = $params['parentBotId'];
624 }
625 if ( $query instanceof Meow_MWAI_Query_Assistant ) {
626 $chatExtra['assistantId'] = $query->assistantId;
627 $chatExtra['threadId'] = $query->threadId;
628 $chatExtra['storeId'] = $query->storeId;
629 }
630
631 // Store response ID and date for Responses API
632 if ( !empty( $extra['responseId'] ) ) {
633 $chatExtra['previousResponseId'] = $extra['responseId'];
634 $chatExtra['previousResponseDate'] = $now;
635 }
636
637 if ( $chat ) {
638 $chat->messages = json_decode( $chat->messages );
639 $userMessage = [ 'role' => 'user', 'content' => $newMessage ];
640 if ( $shortcutName ) {
641 $userMessage['shortcutName'] = $shortcutName;
642 $userMessage['shortcutPrompt'] = $query->get_message();
643 }
644 $chat->messages[] = $userMessage;
645 $chat->messages[] = [ 'role' => 'assistant', 'content' => $rawText, 'extra' => $messageExtra ];
646 $chat->messages = json_encode( $chat->messages );
647
648 // Update or merge extra data
649 $existingExtra = json_decode( $chat->extra, true ) ?: [];
650 $mergedExtra = array_merge( $existingExtra, $chatExtra );
651
652 $this->wpdb->update(
653 $this->table_chats,
654 [
655 'userId' => $userId,
656 'messages' => $chat->messages,
657 'extra' => json_encode( $mergedExtra ),
658 'updated' => $now
659 ],
660 [ 'id' => $chat->id ]
661 );
662 }
663 else {
664 $startSentence = isset( $params['startSentence'] ) ? $params['startSentence'] : null;
665 $messages = [];
666 if ( !empty( $startSentence ) ) {
667 $messages[] = [ 'role' => 'assistant', 'content' => $startSentence ];
668 }
669 $userMessage = [ 'role' => 'user', 'content' => $newMessage ];
670 if ( $shortcutName ) {
671 $userMessage['shortcutName'] = $shortcutName;
672 $userMessage['shortcutPrompt'] = $query->get_message();
673 }
674 $messages[] = $userMessage;
675 $messages[] = [ 'role' => 'assistant', 'content' => $rawText, 'extra' => $messageExtra ];
676 $chat = [
677 'userId' => $userId,
678 'ip' => $userIp,
679 'messages' => json_encode( $messages ),
680 'extra' => json_encode( $chatExtra ),
681 'botId' => $botId,
682 'chatId' => $chatId,
683 'threadId' => $threadId,
684 'storeId' => $storeId,
685 'created' => $now,
686 'updated' => $now
687 ];
688 $this->wpdb->insert( $this->table_chats, $chat );
689 }
690 return $rawText;
691 }
692
693 public function format_messages( $json, $format = 'html' ) {
694 $html = '';
695 if ( $format === 'html' ) {
696 try {
697 $conversation = json_decode( $json, true );
698 if ( json_last_error() !== JSON_ERROR_NONE ) {
699 return 'Invalid JSON format';
700 }
701 foreach ( $conversation as $message ) {
702 $role = ucfirst( $message['role'] );
703 $html .= '<p><strong>' . htmlspecialchars( $role ) . ':</strong> ' . htmlspecialchars( $message['content'] ) . '</p>';
704 }
705 }
706 catch ( Exception $e ) {
707 error_log( $e->getMessage() );
708 return 'Error while formatting the message';
709 }
710 }
711 $html = apply_filters( 'mwai_discussion_format_messages', $html, $json, $format );
712 return $html;
713 }
714
715 /**
716 * Commits a discussion into the database (create or update if the same chatId is found).
717 *
718 * @param Meow_MWAI_Discussion $discussionObject
719 * @return bool True if success, false if error
720 */
721 public function commit_discussion( Meow_MWAI_Discussion $discussionObject ): bool {
722 $this->check_db();
723
724 // 1. Check if a discussion with the same chatId already exists
725 $chat = $this->wpdb->get_row(
726 $this->wpdb->prepare(
727 "SELECT * FROM {$this->table_chats} WHERE chatId = %s",
728 $discussionObject->chatId
729 ),
730 ARRAY_A
731 );
732
733 // 2. Prepare data for DB
734 $userIp = $this->core->get_ip_address();
735 $userId = $this->core->get_user_id();
736 $now = date( 'Y-m-d H:i:s' );
737
738 $data = [
739 'userId' => $userId,
740 'ip' => $userIp,
741 'botId' => $discussionObject->botId,
742 'chatId' => $discussionObject->chatId,
743 'messages' => !empty( $discussionObject->messages ) ? wp_json_encode( $discussionObject->messages ) : '[]',
744 'extra' => !empty( $discussionObject->extra ) ? wp_json_encode( $discussionObject->extra ) : '{}',
745 'updated' => $now,
746 ];
747
748 // 3. Update if found, otherwise insert a new row
749 if ( $chat ) {
750 $updateRes = $this->wpdb->update(
751 $this->table_chats,
752 $data,
753 [ 'id' => $chat['id'] ]
754 );
755 if ( $updateRes === false ) {
756 error_log( 'Error updating discussion: ' . $this->wpdb->last_error );
757 return false;
758 }
759 }
760 else {
761 // For insertion, also set "created"
762 $data['created'] = $now;
763 $insertRes = $this->wpdb->insert( $this->table_chats, $data );
764 if ( $insertRes === false ) {
765 error_log( 'Error inserting discussion: ' . $this->wpdb->last_error );
766 return false;
767 }
768 }
769
770 return true;
771 }
772
773 public function check_db() {
774 if ( $this->db_check ) {
775 return true;
776 }
777
778 // Per-module version check: skip SHOW TABLES if already verified for this version.
779 if ( get_option( 'mwai_db_version_discussions' ) === MWAI_VERSION ) {
780 $this->db_check = true;
781 return true;
782 }
783
784 $this->db_check = !(
785 strtolower( $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_chats'" ) )
786 != strtolower( $this->table_chats )
787 );
788 if ( !$this->db_check ) {
789 $this->create_db();
790 $this->db_check = !(
791 strtolower( $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_chats'" ) )
792 != strtolower( $this->table_chats )
793 );
794 }
795
796 if ( $this->db_check ) {
797 // TODO: Remove after 2026-10-15. Upgrade legacy TEXT (64KB) messages column to MEDIUMTEXT (16MB)
798 // so long conversations aren't truncated on insert.
799 $column_info = $this->wpdb->get_row( "SHOW COLUMNS FROM $this->table_chats WHERE Field = 'messages'" );
800 if ( $column_info && stripos( $column_info->Type, 'mediumtext' ) === false
801 && stripos( $column_info->Type, 'longtext' ) === false ) {
802 $this->wpdb->query( "ALTER TABLE $this->table_chats MODIFY COLUMN messages MEDIUMTEXT NOT NULL" );
803 }
804 update_option( 'mwai_db_version_discussions', MWAI_VERSION, true );
805 }
806
807 return $this->db_check;
808 }
809
810 public function create_db() {
811 $charset_collate = $this->wpdb->get_charset_collate();
812 $sqlLogs = "CREATE TABLE $this->table_chats (
813 id BIGINT(20) NOT NULL AUTO_INCREMENT,
814 userId BIGINT(20) NULL,
815 ip VARCHAR(64) NULL,
816 title VARCHAR(64) NULL,
817 messages MEDIUMTEXT NOT NULL NULL,
818 extra LONGTEXT NOT NULL NULL,
819 botId VARCHAR(64) NULL,
820 chatId VARCHAR(64) NOT NULL,
821 threadId VARCHAR(64) NULL,
822 storeId VARCHAR(64) NULL,
823 created DATETIME NOT NULL,
824 updated DATETIME NOT NULL,
825 PRIMARY KEY (id),
826 INDEX chatId (chatId)
827 ) $charset_collate;";
828 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
829 dbDelta( $sqlLogs );
830 }
831
832 /**
833 * Handle cleanup task for discussions
834 */
835 public function handle_cleanup_task( $result, $job ) {
836 $start = microtime( true );
837 $retention_option = $this->core->get_option( 'chatbot_discussions_retention_days' );
838 // "Never" (or 0 / negative) disables the cleanup entirely.
839 if ( $retention_option === 'Never' || (int) $retention_option <= 0 ) {
840 return [
841 'ok' => true,
842 'done' => true,
843 'message' => 'Discussions cleanup disabled (retention set to Never)',
844 ];
845 }
846 $retention_days = (int) apply_filters( 'mwai_discussions_retention_days', (int) $retention_option );
847 if ( $retention_days <= 0 ) {
848 return [
849 'ok' => true,
850 'done' => true,
851 'message' => 'Discussions cleanup disabled by filter',
852 ];
853 }
854 $cutoff = date( 'Y-m-d H:i:s', strtotime( "-{$retention_days} days" ) );
855
856 // Check if discussions table exists
857 $table_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '{$this->table_chats}'" );
858 if ( !$table_exists ) {
859 return [
860 'ok' => true,
861 'done' => true,
862 'message' => 'Discussions table does not exist yet',
863 ];
864 }
865
866 // Get current progress
867 $deleted_total = isset( $job['meta']['deleted_total'] ) ? (int) $job['meta']['deleted_total'] : 0;
868 $last_id = isset( $job['meta']['last_id'] ) ? (int) $job['meta']['last_id'] : 0;
869
870 // Delete in batches
871 $batch_size = 100;
872 $deleted_batch = 0;
873
874 $old_discussions = $this->wpdb->get_results( $this->wpdb->prepare(
875 "SELECT id FROM {$this->table_chats}
876 WHERE updated < %s AND id > %d
877 ORDER BY id ASC
878 LIMIT %d",
879 $cutoff,
880 $last_id,
881 $batch_size
882 ) );
883
884 if ( !empty( $old_discussions ) ) {
885 $ids = wp_list_pluck( $old_discussions, 'id' );
886 $ids_string = implode( ',', array_map( 'intval', $ids ) );
887
888 $deleted_batch = $this->wpdb->query(
889 "DELETE FROM {$this->table_chats} WHERE id IN ($ids_string)"
890 );
891
892 $deleted_total += $deleted_batch;
893 $last_id = end( $ids );
894 }
895
896 // Check if we have more to process or time is running out
897 $has_more = count( $old_discussions ) === $batch_size;
898 $time_elapsed = microtime( true ) - $start;
899
900 if ( $has_more && $time_elapsed < 8 ) {
901 // Continue processing
902 return [
903 'ok' => true,
904 'done' => false,
905 'message' => sprintf( 'Deleted %d discussions (total: %d)', $deleted_batch, $deleted_total ),
906 'meta' => [
907 'deleted_total' => $deleted_total,
908 'last_id' => $last_id,
909 ],
910 'step' => $job['step'] + 1,
911 'step_name' => 'batch_' . ( $job['step'] + 1 ),
912 ];
913 }
914
915 // Completed
916 return [
917 'ok' => true,
918 'done' => true,
919 'message' => sprintf( 'Cleanup complete. Deleted %d discussions older than %d days', $deleted_total, $retention_days ),
920 'meta' => [
921 'deleted_total' => 0,
922 'last_id' => 0,
923 ],
924 ];
925 }
926 }
927