PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.5.6
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.5.6
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 1 week 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 1 year ago tasks-examples.php 6 months ago tasks.php 1 month ago wand.php 3 months ago
discussions.php
924 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 if ( $chat && (int) $chat->userId !== 0 && (int) $chat->userId !== (int) $userId ) {
603 return $rawText;
604 }
605
606 $messageExtra = [
607 'embeddings' => isset( $extra['embeddings'] ) ? $extra['embeddings'] : null
608 ];
609 $chatExtra = [
610 'session' => $query->session,
611 'model' => $query->model,
612 ];
613 if ( !empty( $query->temperature ) ) {
614 $chatExtra['temperature'] = $query->temperature;
615 }
616 if ( !empty( $query->context ) ) {
617 $chatExtra['context'] = $query->context;
618 }
619 if ( !empty( $params['parentBotId'] ) ) {
620 $chatExtra['parentBotId'] = $params['parentBotId'];
621 }
622 if ( $query instanceof Meow_MWAI_Query_Assistant ) {
623 $chatExtra['assistantId'] = $query->assistantId;
624 $chatExtra['threadId'] = $query->threadId;
625 $chatExtra['storeId'] = $query->storeId;
626 }
627
628 // Store response ID and date for Responses API
629 if ( !empty( $extra['responseId'] ) ) {
630 $chatExtra['previousResponseId'] = $extra['responseId'];
631 $chatExtra['previousResponseDate'] = $now;
632 }
633
634 if ( $chat ) {
635 $chat->messages = json_decode( $chat->messages );
636 $userMessage = [ 'role' => 'user', 'content' => $newMessage ];
637 if ( $shortcutName ) {
638 $userMessage['shortcutName'] = $shortcutName;
639 $userMessage['shortcutPrompt'] = $query->get_message();
640 }
641 $chat->messages[] = $userMessage;
642 $chat->messages[] = [ 'role' => 'assistant', 'content' => $rawText, 'extra' => $messageExtra ];
643 $chat->messages = json_encode( $chat->messages );
644
645 // Update or merge extra data
646 $existingExtra = json_decode( $chat->extra, true ) ?: [];
647 $mergedExtra = array_merge( $existingExtra, $chatExtra );
648
649 $this->wpdb->update(
650 $this->table_chats,
651 [
652 'userId' => $userId,
653 'messages' => $chat->messages,
654 'extra' => json_encode( $mergedExtra ),
655 'updated' => $now
656 ],
657 [ 'id' => $chat->id ]
658 );
659 }
660 else {
661 $startSentence = isset( $params['startSentence'] ) ? $params['startSentence'] : null;
662 $messages = [];
663 if ( !empty( $startSentence ) ) {
664 $messages[] = [ 'role' => 'assistant', 'content' => $startSentence ];
665 }
666 $userMessage = [ 'role' => 'user', 'content' => $newMessage ];
667 if ( $shortcutName ) {
668 $userMessage['shortcutName'] = $shortcutName;
669 $userMessage['shortcutPrompt'] = $query->get_message();
670 }
671 $messages[] = $userMessage;
672 $messages[] = [ 'role' => 'assistant', 'content' => $rawText, 'extra' => $messageExtra ];
673 $chat = [
674 'userId' => $userId,
675 'ip' => $userIp,
676 'messages' => json_encode( $messages ),
677 'extra' => json_encode( $chatExtra ),
678 'botId' => $botId,
679 'chatId' => $chatId,
680 'threadId' => $threadId,
681 'storeId' => $storeId,
682 'created' => $now,
683 'updated' => $now
684 ];
685 $this->wpdb->insert( $this->table_chats, $chat );
686 }
687 return $rawText;
688 }
689
690 public function format_messages( $json, $format = 'html' ) {
691 $html = '';
692 if ( $format === 'html' ) {
693 try {
694 $conversation = json_decode( $json, true );
695 if ( json_last_error() !== JSON_ERROR_NONE ) {
696 return 'Invalid JSON format';
697 }
698 foreach ( $conversation as $message ) {
699 $role = ucfirst( $message['role'] );
700 $html .= '<p><strong>' . htmlspecialchars( $role ) . ':</strong> ' . htmlspecialchars( $message['content'] ) . '</p>';
701 }
702 }
703 catch ( Exception $e ) {
704 error_log( $e->getMessage() );
705 return 'Error while formatting the message';
706 }
707 }
708 $html = apply_filters( 'mwai_discussion_format_messages', $html, $json, $format );
709 return $html;
710 }
711
712 /**
713 * Commits a discussion into the database (create or update if the same chatId is found).
714 *
715 * @param Meow_MWAI_Discussion $discussionObject
716 * @return bool True if success, false if error
717 */
718 public function commit_discussion( Meow_MWAI_Discussion $discussionObject ): bool {
719 $this->check_db();
720
721 // 1. Check if a discussion with the same chatId already exists
722 $chat = $this->wpdb->get_row(
723 $this->wpdb->prepare(
724 "SELECT * FROM {$this->table_chats} WHERE chatId = %s",
725 $discussionObject->chatId
726 ),
727 ARRAY_A
728 );
729
730 // 2. Prepare data for DB
731 $userIp = $this->core->get_ip_address();
732 $userId = $this->core->get_user_id();
733 $now = date( 'Y-m-d H:i:s' );
734
735 $data = [
736 'userId' => $userId,
737 'ip' => $userIp,
738 'botId' => $discussionObject->botId,
739 'chatId' => $discussionObject->chatId,
740 'messages' => !empty( $discussionObject->messages ) ? wp_json_encode( $discussionObject->messages ) : '[]',
741 'extra' => !empty( $discussionObject->extra ) ? wp_json_encode( $discussionObject->extra ) : '{}',
742 'updated' => $now,
743 ];
744
745 // 3. Update if found, otherwise insert a new row
746 if ( $chat ) {
747 $updateRes = $this->wpdb->update(
748 $this->table_chats,
749 $data,
750 [ 'id' => $chat['id'] ]
751 );
752 if ( $updateRes === false ) {
753 error_log( 'Error updating discussion: ' . $this->wpdb->last_error );
754 return false;
755 }
756 }
757 else {
758 // For insertion, also set "created"
759 $data['created'] = $now;
760 $insertRes = $this->wpdb->insert( $this->table_chats, $data );
761 if ( $insertRes === false ) {
762 error_log( 'Error inserting discussion: ' . $this->wpdb->last_error );
763 return false;
764 }
765 }
766
767 return true;
768 }
769
770 public function check_db() {
771 if ( $this->db_check ) {
772 return true;
773 }
774
775 // Per-module version check: skip SHOW TABLES if already verified for this version.
776 if ( get_option( 'mwai_db_version_discussions' ) === MWAI_VERSION ) {
777 $this->db_check = true;
778 return true;
779 }
780
781 $this->db_check = !(
782 strtolower( $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_chats'" ) )
783 != strtolower( $this->table_chats )
784 );
785 if ( !$this->db_check ) {
786 $this->create_db();
787 $this->db_check = !(
788 strtolower( $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_chats'" ) )
789 != strtolower( $this->table_chats )
790 );
791 }
792
793 if ( $this->db_check ) {
794 // TODO: Remove after 2026-10-15. Upgrade legacy TEXT (64KB) messages column to MEDIUMTEXT (16MB)
795 // so long conversations aren't truncated on insert.
796 $column_info = $this->wpdb->get_row( "SHOW COLUMNS FROM $this->table_chats WHERE Field = 'messages'" );
797 if ( $column_info && stripos( $column_info->Type, 'mediumtext' ) === false
798 && stripos( $column_info->Type, 'longtext' ) === false ) {
799 $this->wpdb->query( "ALTER TABLE $this->table_chats MODIFY COLUMN messages MEDIUMTEXT NOT NULL" );
800 }
801 update_option( 'mwai_db_version_discussions', MWAI_VERSION, true );
802 }
803
804 return $this->db_check;
805 }
806
807 public function create_db() {
808 $charset_collate = $this->wpdb->get_charset_collate();
809 $sqlLogs = "CREATE TABLE $this->table_chats (
810 id BIGINT(20) NOT NULL AUTO_INCREMENT,
811 userId BIGINT(20) NULL,
812 ip VARCHAR(64) NULL,
813 title VARCHAR(64) NULL,
814 messages MEDIUMTEXT NOT NULL NULL,
815 extra LONGTEXT NOT NULL NULL,
816 botId VARCHAR(64) NULL,
817 chatId VARCHAR(64) NOT NULL,
818 threadId VARCHAR(64) NULL,
819 storeId VARCHAR(64) NULL,
820 created DATETIME NOT NULL,
821 updated DATETIME NOT NULL,
822 PRIMARY KEY (id),
823 INDEX chatId (chatId)
824 ) $charset_collate;";
825 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
826 dbDelta( $sqlLogs );
827 }
828
829 /**
830 * Handle cleanup task for discussions
831 */
832 public function handle_cleanup_task( $result, $job ) {
833 $start = microtime( true );
834 $retention_option = $this->core->get_option( 'chatbot_discussions_retention_days' );
835 // "Never" (or 0 / negative) disables the cleanup entirely.
836 if ( $retention_option === 'Never' || (int) $retention_option <= 0 ) {
837 return [
838 'ok' => true,
839 'done' => true,
840 'message' => 'Discussions cleanup disabled (retention set to Never)',
841 ];
842 }
843 $retention_days = (int) apply_filters( 'mwai_discussions_retention_days', (int) $retention_option );
844 if ( $retention_days <= 0 ) {
845 return [
846 'ok' => true,
847 'done' => true,
848 'message' => 'Discussions cleanup disabled by filter',
849 ];
850 }
851 $cutoff = date( 'Y-m-d H:i:s', strtotime( "-{$retention_days} days" ) );
852
853 // Check if discussions table exists
854 $table_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '{$this->table_chats}'" );
855 if ( !$table_exists ) {
856 return [
857 'ok' => true,
858 'done' => true,
859 'message' => 'Discussions table does not exist yet',
860 ];
861 }
862
863 // Get current progress
864 $deleted_total = isset( $job['meta']['deleted_total'] ) ? (int) $job['meta']['deleted_total'] : 0;
865 $last_id = isset( $job['meta']['last_id'] ) ? (int) $job['meta']['last_id'] : 0;
866
867 // Delete in batches
868 $batch_size = 100;
869 $deleted_batch = 0;
870
871 $old_discussions = $this->wpdb->get_results( $this->wpdb->prepare(
872 "SELECT id FROM {$this->table_chats}
873 WHERE updated < %s AND id > %d
874 ORDER BY id ASC
875 LIMIT %d",
876 $cutoff,
877 $last_id,
878 $batch_size
879 ) );
880
881 if ( !empty( $old_discussions ) ) {
882 $ids = wp_list_pluck( $old_discussions, 'id' );
883 $ids_string = implode( ',', array_map( 'intval', $ids ) );
884
885 $deleted_batch = $this->wpdb->query(
886 "DELETE FROM {$this->table_chats} WHERE id IN ($ids_string)"
887 );
888
889 $deleted_total += $deleted_batch;
890 $last_id = end( $ids );
891 }
892
893 // Check if we have more to process or time is running out
894 $has_more = count( $old_discussions ) === $batch_size;
895 $time_elapsed = microtime( true ) - $start;
896
897 if ( $has_more && $time_elapsed < 8 ) {
898 // Continue processing
899 return [
900 'ok' => true,
901 'done' => false,
902 'message' => sprintf( 'Deleted %d discussions (total: %d)', $deleted_batch, $deleted_total ),
903 'meta' => [
904 'deleted_total' => $deleted_total,
905 'last_id' => $last_id,
906 ],
907 'step' => $job['step'] + 1,
908 'step_name' => 'batch_' . ( $job['step'] + 1 ),
909 ];
910 }
911
912 // Completed
913 return [
914 'ok' => true,
915 'done' => true,
916 'message' => sprintf( 'Cleanup complete. Deleted %d discussions older than %d days', $deleted_total, $retention_days ),
917 'meta' => [
918 'deleted_total' => 0,
919 'last_id' => 0,
920 ],
921 ];
922 }
923 }
924