PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.6.5
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.6.5
3.6.9 3.6.8 3.6.7 3.6.6 3.6.4 3.6.5 3.6.3 3.6.2 3.6.1 3.6.0 3.5.9 3.5.8 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 4 months ago chatbot.php 5 days ago discussions.php 5 days ago editor-assistant.php 4 months ago files.php 5 days ago forms-manager.php 4 months ago gdpr.php 5 months ago search.php 4 months ago security.php 1 year ago tasks-examples.php 7 months ago tasks.php 4 days ago wand.php 4 months ago workspace.php 5 days ago
discussions.php
1074 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 // Truncate a discussion to a client-provided (shorter) message set. Used by
62 // edit-and-branch: when a user edits an earlier message, everything after
63 // the edit point is dropped and the Responses API chain is reset so the
64 // model continues from the fork, not the stale tail.
65 register_rest_route( $this->namespace_ui, '/discussions/truncate', [
66 'methods' => 'POST',
67 'callback' => [ $this, 'rest_discussions_ui_truncate' ],
68 'permission_callback' => '__return_true'
69 ] );
70 }
71
72 public function can_delete_discussion( $request ) {
73 $this->check_db();
74 $params = $request->get_json_params();
75 $chatIds = isset( $params['chatIds'] ) ? $params['chatIds'] : null;
76 $userId = get_current_user_id();
77 if ( !$userId ) {
78 return false;
79 }
80 foreach ( $chatIds as $chatId ) {
81 $chat = $this->wpdb->get_row(
82 $this->wpdb->prepare(
83 "SELECT * FROM $this->table_chats WHERE chatId = %s",
84 $chatId
85 )
86 );
87 if ( !$chat || (int) $chat->userId !== (int) $userId ) {
88 return false;
89 }
90 }
91 return true;
92 }
93
94 /**
95 * Helper method to create REST responses with automatic token refresh
96 *
97 * @param array $data The response data
98 * @param int $status HTTP status code
99 * @return WP_REST_Response
100 */
101 protected function create_rest_response( $data, $status = 200 ) {
102 // Always check if we need to provide a new nonce
103 $current_nonce = $this->core->get_nonce( true );
104 $request_nonce = isset( $_SERVER['HTTP_X_WP_NONCE'] ) ? $_SERVER['HTTP_X_WP_NONCE'] : null;
105
106 // Check if nonce is approaching expiration (WordPress nonces last 12-24 hours)
107 // We'll refresh if the nonce is older than 10 hours to be safe
108 $should_refresh = false;
109
110 if ( $request_nonce ) {
111 // Try to determine the age of the nonce
112 // WordPress uses a tick system where each tick is 12 hours
113 // If we're in the second half of the nonce's life, refresh it
114 $time = time();
115 $nonce_tick = wp_nonce_tick();
116
117 // Verify if the nonce is still valid but getting old
118 $verify = wp_verify_nonce( $request_nonce, 'wp_rest' );
119 if ( $verify === 2 ) {
120 // Nonce is valid but was generated 12-24 hours ago
121 $should_refresh = true;
122 // Log will be written when token is included in response
123 }
124 }
125
126 // If the nonce has changed or should be refreshed, include the new one
127 if ( $should_refresh || ( $request_nonce && $current_nonce !== $request_nonce ) ) {
128 $data['new_token'] = $current_nonce;
129
130 // Log if server debug mode is enabled
131 if ( $this->core->get_option( 'server_debug_mode' ) ) {
132 error_log( '[AI Engine] Token refresh: Nonce refreshed (12-24 hours old)' );
133 }
134 }
135
136 return new WP_REST_Response( $data, $status );
137 }
138
139 /**
140 * Generate or update the title for a specific discussion
141 * by calling the AI (if it meets the requirements).
142 *
143 * @param stdClass $discussion A row from the DB (object form).
144 * @return void
145 */
146 private function generate_title_for_discussion( $discussion ) {
147 // Check if there's already a title
148 if ( !empty( $discussion->title ) ) {
149 return; // Nothing to do if title is already set.
150 }
151
152 // Ensure it's not older than 10 days, or whatever logic you prefer
153 $ten_days_ago = strtotime( '-10 days' );
154 if ( strtotime( $discussion->updated ) < $ten_days_ago ) {
155 return; // Skip if older than 10 days
156 }
157
158 // We expect JSON in the messages
159 $messages = json_decode( $discussion->messages, true );
160 if ( !is_array( $messages ) ) {
161 return;
162 }
163
164 // Check for at least one user and one assistant message
165 $has_user_message = false;
166 $has_assistant_message = false;
167 foreach ( $messages as $message ) {
168 if ( isset( $message['role'] ) ) {
169 if ( $message['role'] === 'user' ) {
170 $has_user_message = true;
171 }
172 if ( $message['role'] === 'assistant' ) {
173 $has_assistant_message = true;
174 }
175 }
176 if ( $has_user_message && $has_assistant_message ) {
177 break;
178 }
179 }
180
181 if ( !( $has_user_message && $has_assistant_message ) ) {
182 return; // If doesn't have both, skip
183 }
184
185 // Prepare the conversation text for the prompt
186 $conversation_text = '';
187 foreach ( $messages as $message ) {
188 if ( isset( $message['role'] ) && isset( $message['content'] ) ) {
189 $role = ucfirst( $message['role'] );
190 $content = $message['content'];
191 $conversation_text .= "$role: $content\n";
192 }
193 }
194
195 $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";
196 $prompt = apply_filters( 'mwai_discussions_title_prompt', $base_prompt, $conversation_text, $discussion );
197
198 // Run the AI query using the fast environment
199 global $mwai;
200 $params = [ 'scope' => 'discussions' ];
201
202 // Use simpleFastTextQuery which handles Fast Model configuration
203 try {
204 $answer = $mwai->simpleFastTextQuery( $prompt, $params );
205
206 // Clean up the answer
207 $title = trim( $answer );
208 $title = rtrim( $title, '.!?:;,—–-–' ); // Remove trailing punctuation
209 $title = substr( $title, 0, 64 ); // Ensure less than 64 characters
210 if ( empty( $title ) ) {
211 $title = __( 'Untitled', 'ai-engine' );
212 }
213 }
214 catch ( Exception $e ) {
215 // Handle content filter or other API errors
216 $error_message = $e->getMessage();
217 if ( strpos( $error_message, 'content_filter' ) !== false ||
218 strpos( $error_message, 'ResponsibleAIPolicyViolation' ) !== false ) {
219 error_log( "AI Engine: Content filter blocked title generation for discussion ID {$discussion->id}. Using fallback title." );
220 $title = __( 'Discussion', 'ai-engine' ) . ' ' . date( 'Y-m-d H:i' );
221 }
222 else {
223 error_log( "AI Engine: Failed to generate title for discussion ID {$discussion->id}: " . $error_message );
224 $title = __( 'Untitled', 'ai-engine' );
225 }
226 }
227
228 // Update the discussion with the title
229 $updated = $this->wpdb->update(
230 $this->table_chats,
231 [ 'title' => $title ],
232 [ 'id' => $discussion->id ]
233 );
234 if ( $updated === false ) {
235 error_log( "Failed to update the title for discussion ID {$discussion->id}" );
236 }
237 }
238
239 /**
240 * Admin route for listing discussions. No forced logic here.
241 */
242 public function rest_discussions_list( $request ) {
243 try {
244 $params = $request->get_json_params();
245 $offset = $params['offset'];
246 $limit = $params['limit'];
247 $filters = $params['filters'];
248 $sort = $params['sort'];
249
250 // Retrieve the chats
251 $chats = $this->chats_query( [], $offset, $limit, $filters, $sort );
252
253 return $this->create_rest_response( [ 'success' => true, 'total' => $chats['total'], 'chats' => $chats['rows'] ], 200 );
254 }
255 catch ( Exception $e ) {
256 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
257 }
258 }
259
260 /**
261 * Admin route for discussion counts, used by the Dashboard usage widget.
262 * Discussions are hard-deleted, so these counts never include deleted ones.
263 */
264 public function rest_discussions_stats( $request ) {
265 try {
266 $this->check_db();
267 $days = (int) $request->get_param( 'days' );
268 if ( $days < 1 || $days > 365 ) {
269 $days = 7;
270 }
271 $since = date( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) );
272 $count = (int) $this->wpdb->get_var(
273 $this->wpdb->prepare( "SELECT COUNT(*) FROM {$this->table_chats} WHERE created >= %s", $since )
274 );
275 $total = (int) $this->wpdb->get_var( "SELECT COUNT(*) FROM {$this->table_chats}" );
276 return $this->create_rest_response( [ 'success' => true, 'count' => $count, 'total' => $total, 'days' => $days ], 200 );
277 }
278 catch ( Exception $e ) {
279 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
280 }
281 }
282
283 public function rest_discussions_ui_edit( $request ) {
284 try {
285 $params = $request->get_json_params();
286 $chatId = isset( $params['chatId'] ) ? sanitize_text_field( $params['chatId'] ) : null;
287 $title = isset( $params['title'] ) ? sanitize_text_field( $params['title'] ) : null;
288
289 if ( is_null( $chatId ) || is_null( $title ) ) {
290 return $this->create_rest_response( [ 'success' => false, 'message' => 'chatId and title are required.' ], 400 );
291 }
292
293 $userId = get_current_user_id();
294 if ( !$userId ) {
295 return $this->create_rest_response( [ 'success' => false, 'message' => 'You need to be logged in.' ], 401 );
296 }
297
298 // Update the discussion title for the current user
299 $updated = $this->wpdb->update(
300 $this->table_chats,
301 [ 'title' => $title ],
302 [ 'chatId' => $chatId, 'userId' => $userId ]
303 );
304 if ( $updated === false ) {
305 return $this->create_rest_response( [ 'success' => false, 'message' => 'Failed to update the discussion.' ], 500 );
306 }
307
308 return $this->create_rest_response( [ 'success' => true ], 200 );
309 }
310 catch ( Exception $e ) {
311 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
312 }
313 }
314
315 /**
316 * Overwrite a discussion's stored messages with a client-provided (shorter)
317 * set, and clear the stored Responses API id so the next turn forks cleanly.
318 * Ownership-guarded like the rest of the write path.
319 */
320 public function rest_discussions_ui_truncate( $request ) {
321 try {
322 $this->check_db();
323 $params = $request->get_json_params();
324 $chatId = isset( $params['chatId'] ) ? sanitize_text_field( $params['chatId'] ) : null;
325 $messages = isset( $params['messages'] ) && is_array( $params['messages'] ) ? $params['messages'] : null;
326
327 if ( is_null( $chatId ) || is_null( $messages ) ) {
328 return $this->create_rest_response( [ 'success' => false, 'message' => 'chatId and messages are required.' ], 400 );
329 }
330
331 $userId = get_current_user_id();
332 if ( !$userId ) {
333 return $this->create_rest_response( [ 'success' => false, 'message' => 'You need to be logged in.' ], 401 );
334 }
335
336 $chat = $this->wpdb->get_row(
337 $this->wpdb->prepare( "SELECT * FROM $this->table_chats WHERE chatId = %s", $chatId )
338 );
339 // Same ownership guard as chatbot_reply: never touch another user's row.
340 if ( $chat && (int) $chat->userId !== 0 && (int) $chat->userId !== (int) $userId ) {
341 return $this->create_rest_response( [ 'success' => false, 'message' => 'Not allowed.' ], 403 );
342 }
343
344 // Keep only known fields so a client can't inject arbitrary data.
345 // stopped and extra.model matter for stopped turns and model attribution.
346 $clean = [];
347 foreach ( $messages as $m ) {
348 if ( !isset( $m['role'] ) ) {
349 continue;
350 }
351 $entry = [ 'role' => sanitize_text_field( $m['role'] ), 'content' => $m['content'] ?? '' ];
352 if ( isset( $m['who'] ) ) {
353 $entry['who'] = sanitize_text_field( $m['who'] );
354 }
355 if ( !empty( $m['stopped'] ) ) {
356 $entry['stopped'] = true;
357 }
358 if ( !empty( $m['timestamp'] ) && is_numeric( $m['timestamp'] ) ) {
359 $entry['timestamp'] = (int) $m['timestamp'];
360 }
361 if ( isset( $m['extra'] ) && is_array( $m['extra'] ) ) {
362 $extraEntry = [];
363 if ( !empty( $m['extra']['model'] ) ) {
364 $extraEntry['model'] = sanitize_text_field( $m['extra']['model'] );
365 }
366 // Embeddings and tool calls used to be dropped here, so truncating or
367 // branching a discussion silently lost them on every surviving message.
368 if ( !empty( $m['extra']['embeddings'] ) && is_array( $m['extra']['embeddings'] ) ) {
369 $extraEntry['embeddings'] = $this->sanitize_message_extra( $m['extra']['embeddings'] );
370 }
371 if ( !empty( $m['extra']['toolCalls'] ) && is_array( $m['extra']['toolCalls'] ) ) {
372 $extraEntry['toolCalls'] = $this->sanitize_message_extra( $m['extra']['toolCalls'] );
373 }
374 if ( !empty( $extraEntry ) ) {
375 $entry['extra'] = $extraEntry;
376 }
377 }
378 $clean[] = $entry;
379 }
380
381 // No row yet (e.g. the very first turn was stopped before completing):
382 // create it, so the conversation survives a reload.
383 if ( !$chat ) {
384 $botId = isset( $params['botId'] ) ? sanitize_text_field( $params['botId'] ) : null;
385 if ( !$botId || empty( $clean ) ) {
386 return $this->create_rest_response( [ 'success' => true ], 200 );
387 }
388 $now = date( 'Y-m-d H:i:s' );
389 $this->wpdb->insert( $this->table_chats, [
390 'userId' => $userId,
391 'ip' => $this->core->get_ip_address(),
392 'messages' => json_encode( $clean ),
393 'extra' => json_encode( [] ),
394 'botId' => $botId,
395 'chatId' => $chatId,
396 'created' => $now,
397 'updated' => $now,
398 ] );
399 return $this->create_rest_response( [ 'success' => true ], 200 );
400 }
401
402 $extra = json_decode( $chat->extra, true );
403 $extra = is_array( $extra ) ? $extra : [];
404 unset( $extra['responseId'], $extra['previousResponseId'], $extra['responseDate'], $extra['previousResponseDate'] );
405
406 $this->wpdb->update(
407 $this->table_chats,
408 [ 'messages' => json_encode( $clean ), 'extra' => json_encode( $extra ), 'updated' => date( 'Y-m-d H:i:s' ) ],
409 [ 'chatId' => $chatId ]
410 );
411 return $this->create_rest_response( [ 'success' => true ], 200 );
412 }
413 catch ( Exception $e ) {
414 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
415 }
416 }
417
418 // Sanitize a client-supplied structure of unknown shape (embeddings list, tool call
419 // arguments) before it goes back into the messages column. Keys and scalar leaves are
420 // sanitized, with a depth cap so a crafted payload cannot recurse without end.
421 private function sanitize_message_extra( $value, $depth = 0 ) {
422 if ( $depth > 6 ) {
423 return null;
424 }
425 if ( is_array( $value ) ) {
426 $out = [];
427 foreach ( $value as $key => $item ) {
428 $out[sanitize_text_field( (string) $key )] = $this->sanitize_message_extra( $item, $depth + 1 );
429 }
430 return $out;
431 }
432 if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) || is_null( $value ) ) {
433 return $value;
434 }
435 return sanitize_text_field( (string) $value );
436 }
437
438 public function cron_discussions() {
439 // Track cron execution start
440 $this->core->track_cron_start( 'mwai_discussions' );
441
442 try {
443 $this->check_db();
444
445 // NEW CHECK: Only run if auto-titling is enabled
446 if ( !$this->core->get_option( 'chatbot_discussions_titling' ) ) {
447 $this->core->track_cron_end( 'mwai_discussions', 'success' );
448 return;
449 }
450 // END NEW CHECK
451
452 // Set the current user to the first admin to avoid guest limits
453 $admin_users = get_users( [ 'role' => 'administrator', 'number' => 1 ] );
454 if ( !empty( $admin_users ) ) {
455 $admin_user = $admin_users[0];
456 wp_set_current_user( $admin_user->ID );
457 }
458
459 $now = date( 'Y-m-d H:i:s' );
460 $ten_days_ago = date( 'Y-m-d H:i:s', strtotime( '-10 days' ) );
461
462 // Get 5 latest discussions, not older than 10 days, which have no 'title' yet
463 $query = $this->wpdb->prepare(
464 "SELECT * FROM {$this->table_chats}
465 WHERE title IS NULL AND updated >= %s
466 ORDER BY updated DESC LIMIT 5",
467 $ten_days_ago
468 );
469 $discussions = $this->wpdb->get_results( $query );
470 if ( empty( $discussions ) ) {
471 $this->core->track_cron_end( 'mwai_discussions', 'success' );
472 return;
473 }
474
475 foreach ( $discussions as $discussion ) {
476 $this->generate_title_for_discussion( $discussion );
477 }
478
479 $this->core->track_cron_end( 'mwai_discussions', 'success' );
480 }
481 catch ( Exception $e ) {
482 $this->core->track_cron_end( 'mwai_discussions', 'error', $e->getMessage() );
483 }
484 }
485
486 /**
487 * UI route for listing discussions.
488 * Here we add the "forced cron" logic for up to 5 discussions,
489 * but only if auto-titling is enabled.
490 */
491 public function rest_discussions_ui_list( $request ) {
492 try {
493 $params = $request->get_json_params();
494 $offset = isset( $params['offset'] ) ? $params['offset'] : 0;
495 // Get paging setting from options
496 $paging_option = $this->core->get_option( 'chatbot_discussions_paging' );
497 if ( $paging_option === 'None' ) {
498 $default_limit = 999; // Show all discussions
499 }
500 else {
501 $default_limit = is_numeric( $paging_option ) ? intval( $paging_option ) : 10; // Fallback to 10
502 }
503 $limit = isset( $params['limit'] ) ? $params['limit'] : $default_limit;
504 $botId = isset( $params['botId'] ) ? $params['botId'] : null;
505 $customId = isset( $params['customId'] ) ? $params['customId'] : null;
506
507 if ( !is_null( $customId ) ) {
508 $botId = $customId;
509 }
510 if ( is_null( $botId ) ) {
511 return $this->create_rest_response( [ 'success' => false, 'message' => 'Bot ID is required.' ], 200 );
512 }
513
514 $userId = get_current_user_id();
515 if ( !$userId ) {
516 return $this->create_rest_response( [ 'success' => false, 'message' => 'You need to be connected.' ], 200 );
517 }
518
519 $filters = [
520 [ 'accessor' => 'user', 'value' => $userId ],
521 [ 'accessor' => 'botId', 'value' => $botId ],
522 ];
523
524 // Optional server-side search (content + title), used by the Workspace
525 // sidebar and available to the chatbot discussions UI.
526 $search = isset( $params['search'] ) ? sanitize_text_field( $params['search'] ) : '';
527 if ( $search !== '' ) {
528 $filters[] = [ 'accessor' => 'preview', 'value' => $search ];
529 }
530
531 // Retrieve the chats
532 $chats = $this->chats_query( [], $offset, $limit, $filters );
533
534 // NEW CHECK: only do forced titling if it's enabled
535 if ( $this->core->get_option( 'chatbot_discussions_titling' ) ) {
536 // "Forced cron" logic: check up to 5 that have no title
537 $counter = 0;
538 foreach ( $chats['rows'] as &$chatRow ) {
539 if ( $counter >= 5 ) {
540 break;
541 }
542 if ( empty( $chatRow['title'] ) && strtotime( $chatRow['updated'] ) >= strtotime( '-10 days' ) ) {
543 $discussionObj = (object) $chatRow;
544 $this->generate_title_for_discussion( $discussionObj );
545 $counter++;
546 }
547 }
548 // If you want the newly-updated titles to show up *immediately*:
549 $chats = $this->chats_query( [], $offset, $limit, $filters );
550 }
551 // END NEW CHECK
552
553 // Apply filters to discussion metadata
554 foreach ( $chats['rows'] as &$chatRow ) {
555 // Decode messages JSON to get the count
556 $messages = json_decode( $chatRow['messages'], true );
557 $message_count = is_array( $messages ) ? count( $messages ) : 0;
558
559 // Add formatted metadata that can be filtered
560 $chatRow['metadata_display'] = [
561 'start_date' => apply_filters( 'mwai_discussion_metadata_start_date', $this->core->format_discussion_date( $chatRow['created'] ), $chatRow ),
562 'last_update' => apply_filters( 'mwai_discussion_metadata_last_update', $this->core->format_discussion_date( $chatRow['updated'] ), $chatRow ),
563 'message_count' => apply_filters( 'mwai_discussion_metadata_message_count', $message_count, $chatRow )
564 ];
565 }
566
567 return $this->create_rest_response( [ 'success' => true, 'total' => $chats['total'], 'chats' => $chats['rows'] ], 200 );
568 }
569 catch ( Exception $e ) {
570 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
571 }
572 }
573
574 public function rest_discussions_delete_admin( $request ) {
575 try {
576 $this->check_db();
577 $params = $request->get_json_params();
578 $chatsIds = $params['chatIds'];
579 if ( is_array( $chatsIds ) ) {
580 if ( count( $chatsIds ) === 0 ) {
581 $this->wpdb->query( "TRUNCATE TABLE $this->table_chats" );
582 }
583 foreach ( $chatsIds as $chatId ) {
584 $this->wpdb->delete( $this->table_chats, [ 'chatId' => $chatId ] );
585 }
586 }
587 return $this->create_rest_response( [ 'success' => true ], 200 );
588 }
589 catch ( Exception $e ) {
590 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
591 }
592 }
593
594 public function rest_discussions_delete( $request ) {
595 try {
596 $this->check_db();
597 $params = $request->get_json_params();
598 $chatIds = isset( $params['chatIds'] ) ? $params['chatIds'] : null;
599
600 if ( !is_array( $chatIds ) || empty( $chatIds ) ) {
601 return $this->create_rest_response( [ 'success' => false, 'message' => 'chatIds is required.' ], 400 );
602 }
603
604 $userId = get_current_user_id();
605 if ( !$userId ) {
606 return $this->create_rest_response( [ 'success' => false, 'message' => 'You need to be logged in.' ], 401 );
607 }
608
609 foreach ( $chatIds as $chatId ) {
610 $this->wpdb->delete( $this->table_chats, [ 'chatId' => $chatId, 'userId' => $userId ] );
611 }
612
613 return $this->create_rest_response( [ 'success' => true ], 200 );
614 }
615 catch ( Exception $e ) {
616 return $this->create_rest_response( [ 'success' => false, 'message' => $e->getMessage() ], 500 );
617 }
618 }
619
620 // Get latest discussion for the given parameter
621 public function get_discussion( $botId, $chatId ) {
622 $this->check_db();
623 $chat = $this->wpdb->get_row(
624 $this->wpdb->prepare(
625 "SELECT * FROM $this->table_chats WHERE chatId = %s AND botId = %s",
626 $chatId,
627 $botId
628 ),
629 ARRAY_A
630 );
631 if ( $chat ) {
632 $chat['messages'] = json_decode( $chat['messages'] );
633 return $chat;
634 }
635 return null;
636 }
637
638 public function chats_query( $chats = [], $offset = 0, $limit = null, $filters = null, $sort = null ) {
639 $this->check_db();
640 $offset = !empty( $offset ) ? intval( $offset ) : 0;
641 $limit = !empty( $limit ) ? intval( $limit ) : 5;
642 $filters = !empty( $filters ) ? $filters : [];
643 $this->core->sanitize_sort( $sort, 'updated', 'DESC' );
644
645 $where_clauses = [];
646 $where_values = [];
647
648 if ( is_array( $filters ) ) {
649 foreach ( $filters as $filter ) {
650 $value = $filter['value'];
651 if ( is_null( $value ) || $value === '' ) {
652 continue;
653 }
654 switch ( $filter['accessor'] ) {
655 case 'user':
656 $isIP = filter_var( $value, FILTER_VALIDATE_IP );
657 if ( $isIP ) {
658 $where_clauses[] = 'ip = %s';
659 $where_values[] = $value;
660 }
661 else {
662 $where_clauses[] = 'userId = %d';
663 $where_values[] = intval( $value );
664 }
665 break;
666 case 'botId':
667 $where_clauses[] = 'botId = %s';
668 $where_values[] = $value;
669 break;
670 case 'preview':
671 $like = '%' . $this->wpdb->esc_like( $value ) . '%';
672 // Match the message content AND the (AI or user) title.
673 $where_clauses[] = '(messages LIKE %s OR title LIKE %s)';
674 $where_values[] = $like;
675 $where_values[] = $like;
676 break;
677 // Add other cases as needed
678 }
679 }
680 }
681
682 $where_sql = '';
683 if ( !empty( $where_clauses ) ) {
684 $where_sql = 'WHERE ' . implode( ' AND ', $where_clauses );
685 }
686 $order_by = 'ORDER BY ' . esc_sql( $sort['accessor'] ) . ' ' . esc_sql( $sort['by'] );
687
688 $limit_sql = '';
689 if ( $limit > 0 ) {
690 $limit_sql = $this->wpdb->prepare( 'LIMIT %d, %d', $offset, $limit );
691 }
692
693 $query = "SELECT * FROM {$this->table_chats} {$where_sql} {$order_by} {$limit_sql}";
694 $chats['rows'] = $this->wpdb->get_results( $this->wpdb->prepare( $query, $where_values ), ARRAY_A );
695
696 // Get the total count
697 $count_query = "SELECT COUNT(*) FROM {$this->table_chats} {$where_sql}";
698 $chats['total'] = $this->wpdb->get_var( $this->wpdb->prepare( $count_query, $where_values ) );
699
700 return $chats;
701 }
702
703 public function chatbot_reply( $rawText, $reply, $params, $extra ) {
704 global $mwai_core;
705 $query = $reply->query;
706 $userIp = $mwai_core->get_ip_address();
707 $userId = $mwai_core->get_user_id();
708 $botId = isset( $params['botId'] ) ? $params['botId'] : null;
709 $chatId = $this->core->fix_chat_id( $query, $params );
710 $customId = isset( $params['customId'] ) ? $params['customId'] : null;
711 $threadId = $query instanceof Meow_MWAI_Query_Assistant ? $query->threadId : null;
712 $storeId = $query instanceof Meow_MWAI_Query_Assistant ? $query->storeId : null;
713 $now = date( 'Y-m-d H:i:s' );
714
715 if ( !empty( $customId ) ) {
716 $botId = $customId;
717 }
718 $newMessage = isset( $params['newMessage'] ) ? $params['newMessage'] : $query->get_message();
719 $shortcutName = isset( $params['shortcutName'] ) ? $params['shortcutName'] : null;
720
721 // If there are images, add them to the message for display purposes
722 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
723 foreach ( $attachments as $attachedFile ) {
724 if ( $attachedFile->is_image() && $attachedFile->get_type() === 'url' ) {
725 $newMessage = "![Uploaded Image]({$attachedFile->get_url()})\n" . $newMessage;
726 }
727 }
728
729 $this->check_db();
730 $chat = $this->wpdb->get_row(
731 $this->wpdb->prepare(
732 "SELECT * FROM $this->table_chats WHERE chatId = %s",
733 $chatId
734 )
735 );
736
737 // Ownership guard: never append to, or take over, a discussion owned by a
738 // different logged-in user. Without this, anyone holding a valid REST nonce
739 // could submit another user's chatId, have the row reassigned to them (the
740 // userId update below) and then read the victim's history through
741 // discussions/list. A guest-owned row (userId 0) stays claimable so a
742 // visitor can keep their conversation after logging in.
743 // Cross-user takeover reported by Shivamani Vastrala (via WPScan/Automattic),
744 // fixed in 3.5.5. Keep this on the write path; do not owner-scope the read
745 // path (get_discussion), which would drop legitimately shared history.
746 if ( $chat && (int) $chat->userId !== 0 && (int) $chat->userId !== (int) $userId ) {
747 return $rawText;
748 }
749
750 $messageExtra = [
751 'embeddings' => isset( $extra['embeddings'] ) ? $extra['embeddings'] : null,
752 // Tools executed to produce this reply (name, arguments, truncated result).
753 'toolCalls' => isset( $extra['toolCalls'] ) ? $extra['toolCalls'] : null,
754 // The model that produced this reply, so UIs can attribute each message
755 // even after the user switches models mid-conversation.
756 'model' => $query->model ?? null,
757 ];
758 $chatExtra = [
759 'session' => $query->session,
760 'model' => $query->model,
761 ];
762 if ( !empty( $query->temperature ) ) {
763 $chatExtra['temperature'] = $query->temperature;
764 }
765 if ( !empty( $query->context ) ) {
766 $chatExtra['context'] = $query->context;
767 }
768 if ( !empty( $params['parentBotId'] ) ) {
769 $chatExtra['parentBotId'] = $params['parentBotId'];
770 }
771 if ( $query instanceof Meow_MWAI_Query_Assistant ) {
772 $chatExtra['assistantId'] = $query->assistantId;
773 $chatExtra['threadId'] = $query->threadId;
774 $chatExtra['storeId'] = $query->storeId;
775 }
776
777 // Store response ID and date for Responses API
778 if ( !empty( $extra['responseId'] ) ) {
779 $chatExtra['previousResponseId'] = $extra['responseId'];
780 $chatExtra['previousResponseDate'] = $now;
781 }
782
783 $nowMs = (int) round( microtime( true ) * 1000 );
784 if ( $chat ) {
785 $chat->messages = json_decode( $chat->messages );
786 $userMessage = [ 'role' => 'user', 'content' => $newMessage, 'timestamp' => $nowMs ];
787 if ( $shortcutName ) {
788 $userMessage['shortcutName'] = $shortcutName;
789 $userMessage['shortcutPrompt'] = $query->get_message();
790 }
791 $chat->messages[] = $userMessage;
792 $chat->messages[] = [ 'role' => 'assistant', 'content' => $rawText, 'extra' => $messageExtra, 'timestamp' => $nowMs ];
793 $chat->messages = json_encode( $chat->messages );
794
795 // Update or merge extra data
796 $existingExtra = json_decode( $chat->extra, true ) ?: [];
797 $mergedExtra = array_merge( $existingExtra, $chatExtra );
798
799 $this->wpdb->update(
800 $this->table_chats,
801 [
802 'userId' => $userId,
803 'messages' => $chat->messages,
804 'extra' => json_encode( $mergedExtra ),
805 'updated' => $now
806 ],
807 [ 'id' => $chat->id ]
808 );
809 }
810 else {
811 $startSentence = isset( $params['startSentence'] ) ? $params['startSentence'] : null;
812 $messages = [];
813 if ( !empty( $startSentence ) ) {
814 $messages[] = [ 'role' => 'assistant', 'content' => $startSentence ];
815 }
816 $userMessage = [ 'role' => 'user', 'content' => $newMessage, 'timestamp' => $nowMs ];
817 if ( $shortcutName ) {
818 $userMessage['shortcutName'] = $shortcutName;
819 $userMessage['shortcutPrompt'] = $query->get_message();
820 }
821 $messages[] = $userMessage;
822 $messages[] = [ 'role' => 'assistant', 'content' => $rawText, 'extra' => $messageExtra, 'timestamp' => $nowMs ];
823 $chat = [
824 'userId' => $userId,
825 'ip' => $userIp,
826 'messages' => json_encode( $messages ),
827 'extra' => json_encode( $chatExtra ),
828 'botId' => $botId,
829 'chatId' => $chatId,
830 'threadId' => $threadId,
831 'storeId' => $storeId,
832 'created' => $now,
833 'updated' => $now
834 ];
835 $this->wpdb->insert( $this->table_chats, $chat );
836 }
837 return $rawText;
838 }
839
840 public function format_messages( $json, $format = 'html' ) {
841 $html = '';
842 if ( $format === 'html' ) {
843 try {
844 $conversation = json_decode( $json, true );
845 if ( json_last_error() !== JSON_ERROR_NONE ) {
846 return 'Invalid JSON format';
847 }
848 foreach ( $conversation as $message ) {
849 $role = ucfirst( $message['role'] );
850 $html .= '<p><strong>' . htmlspecialchars( $role ) . ':</strong> ' . htmlspecialchars( $message['content'] ) . '</p>';
851 }
852 }
853 catch ( Exception $e ) {
854 error_log( $e->getMessage() );
855 return 'Error while formatting the message';
856 }
857 }
858 $html = apply_filters( 'mwai_discussion_format_messages', $html, $json, $format );
859 return $html;
860 }
861
862 /**
863 * Commits a discussion into the database (create or update if the same chatId is found).
864 *
865 * @param Meow_MWAI_Discussion $discussionObject
866 * @return bool True if success, false if error
867 */
868 public function commit_discussion( Meow_MWAI_Discussion $discussionObject ): bool {
869 $this->check_db();
870
871 // 1. Check if a discussion with the same chatId already exists
872 $chat = $this->wpdb->get_row(
873 $this->wpdb->prepare(
874 "SELECT * FROM {$this->table_chats} WHERE chatId = %s",
875 $discussionObject->chatId
876 ),
877 ARRAY_A
878 );
879
880 // 2. Prepare data for DB
881 $userIp = $this->core->get_ip_address();
882 $userId = $this->core->get_user_id();
883 $now = date( 'Y-m-d H:i:s' );
884
885 $data = [
886 'userId' => $userId,
887 'ip' => $userIp,
888 'botId' => $discussionObject->botId,
889 'chatId' => $discussionObject->chatId,
890 'messages' => !empty( $discussionObject->messages ) ? wp_json_encode( $discussionObject->messages ) : '[]',
891 'extra' => !empty( $discussionObject->extra ) ? wp_json_encode( $discussionObject->extra ) : '{}',
892 'updated' => $now,
893 ];
894
895 // 3. Update if found, otherwise insert a new row
896 if ( $chat ) {
897 $updateRes = $this->wpdb->update(
898 $this->table_chats,
899 $data,
900 [ 'id' => $chat['id'] ]
901 );
902 if ( $updateRes === false ) {
903 error_log( 'Error updating discussion: ' . $this->wpdb->last_error );
904 return false;
905 }
906 }
907 else {
908 // For insertion, also set "created"
909 $data['created'] = $now;
910 $insertRes = $this->wpdb->insert( $this->table_chats, $data );
911 if ( $insertRes === false ) {
912 error_log( 'Error inserting discussion: ' . $this->wpdb->last_error );
913 return false;
914 }
915 }
916
917 return true;
918 }
919
920 public function check_db() {
921 if ( $this->db_check ) {
922 return true;
923 }
924
925 // Per-module version check: skip SHOW TABLES if already verified for this version.
926 if ( get_option( 'mwai_db_version_discussions' ) === MWAI_VERSION ) {
927 $this->db_check = true;
928 return true;
929 }
930
931 $this->db_check = !(
932 strtolower( $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_chats'" ) )
933 != strtolower( $this->table_chats )
934 );
935 if ( !$this->db_check ) {
936 $this->create_db();
937 $this->db_check = !(
938 strtolower( $this->wpdb->get_var( "SHOW TABLES LIKE '$this->table_chats'" ) )
939 != strtolower( $this->table_chats )
940 );
941 }
942
943 if ( $this->db_check ) {
944 // TODO: Remove after 2026-10-15. Upgrade legacy TEXT (64KB) messages column to MEDIUMTEXT (16MB)
945 // so long conversations aren't truncated on insert.
946 $column_info = $this->wpdb->get_row( "SHOW COLUMNS FROM $this->table_chats WHERE Field = 'messages'" );
947 if ( $column_info && stripos( $column_info->Type, 'mediumtext' ) === false
948 && stripos( $column_info->Type, 'longtext' ) === false ) {
949 $this->wpdb->query( "ALTER TABLE $this->table_chats MODIFY COLUMN messages MEDIUMTEXT NOT NULL" );
950 }
951 update_option( 'mwai_db_version_discussions', MWAI_VERSION, true );
952 }
953
954 return $this->db_check;
955 }
956
957 public function create_db() {
958 $charset_collate = $this->wpdb->get_charset_collate();
959 $sqlLogs = "CREATE TABLE $this->table_chats (
960 id BIGINT(20) NOT NULL AUTO_INCREMENT,
961 userId BIGINT(20) NULL,
962 ip VARCHAR(64) NULL,
963 title VARCHAR(64) NULL,
964 messages MEDIUMTEXT NOT NULL NULL,
965 extra LONGTEXT NOT NULL NULL,
966 botId VARCHAR(64) NULL,
967 chatId VARCHAR(64) NOT NULL,
968 threadId VARCHAR(64) NULL,
969 storeId VARCHAR(64) NULL,
970 created DATETIME NOT NULL,
971 updated DATETIME NOT NULL,
972 PRIMARY KEY (id),
973 INDEX chatId (chatId)
974 ) $charset_collate;";
975 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
976 dbDelta( $sqlLogs );
977 }
978
979 /**
980 * Handle cleanup task for discussions
981 */
982 public function handle_cleanup_task( $result, $job ) {
983 $start = microtime( true );
984 $retention_option = $this->core->get_option( 'chatbot_discussions_retention_days' );
985 // "Never" (or 0 / negative) disables the cleanup entirely.
986 if ( $retention_option === 'Never' || (int) $retention_option <= 0 ) {
987 return [
988 'ok' => true,
989 'done' => true,
990 'message' => 'Discussions cleanup disabled (retention set to Never)',
991 ];
992 }
993 $retention_days = (int) apply_filters( 'mwai_discussions_retention_days', (int) $retention_option );
994 if ( $retention_days <= 0 ) {
995 return [
996 'ok' => true,
997 'done' => true,
998 'message' => 'Discussions cleanup disabled by filter',
999 ];
1000 }
1001 $cutoff = date( 'Y-m-d H:i:s', strtotime( "-{$retention_days} days" ) );
1002
1003 // Check if discussions table exists
1004 $table_exists = $this->wpdb->get_var( "SHOW TABLES LIKE '{$this->table_chats}'" );
1005 if ( !$table_exists ) {
1006 return [
1007 'ok' => true,
1008 'done' => true,
1009 'message' => 'Discussions table does not exist yet',
1010 ];
1011 }
1012
1013 // Get current progress
1014 $deleted_total = isset( $job['meta']['deleted_total'] ) ? (int) $job['meta']['deleted_total'] : 0;
1015 $last_id = isset( $job['meta']['last_id'] ) ? (int) $job['meta']['last_id'] : 0;
1016
1017 // Delete in batches
1018 $batch_size = 100;
1019 $deleted_batch = 0;
1020
1021 $old_discussions = $this->wpdb->get_results( $this->wpdb->prepare(
1022 "SELECT id FROM {$this->table_chats}
1023 WHERE updated < %s AND id > %d
1024 ORDER BY id ASC
1025 LIMIT %d",
1026 $cutoff,
1027 $last_id,
1028 $batch_size
1029 ) );
1030
1031 if ( !empty( $old_discussions ) ) {
1032 $ids = wp_list_pluck( $old_discussions, 'id' );
1033 $ids_string = implode( ',', array_map( 'intval', $ids ) );
1034
1035 $deleted_batch = $this->wpdb->query(
1036 "DELETE FROM {$this->table_chats} WHERE id IN ($ids_string)"
1037 );
1038
1039 $deleted_total += $deleted_batch;
1040 $last_id = end( $ids );
1041 }
1042
1043 // Check if we have more to process or time is running out
1044 $has_more = count( $old_discussions ) === $batch_size;
1045 $time_elapsed = microtime( true ) - $start;
1046
1047 if ( $has_more && $time_elapsed < 8 ) {
1048 // Continue processing
1049 return [
1050 'ok' => true,
1051 'done' => false,
1052 'message' => sprintf( 'Deleted %d discussions (total: %d)', $deleted_batch, $deleted_total ),
1053 'meta' => [
1054 'deleted_total' => $deleted_total,
1055 'last_id' => $last_id,
1056 ],
1057 'step' => $job['step'] + 1,
1058 'step_name' => 'batch_' . ( $job['step'] + 1 ),
1059 ];
1060 }
1061
1062 // Completed
1063 return [
1064 'ok' => true,
1065 'done' => true,
1066 'message' => sprintf( 'Cleanup complete. Deleted %d discussions older than %d days', $deleted_total, $retention_days ),
1067 'meta' => [
1068 'deleted_total' => 0,
1069 'last_id' => 0,
1070 ],
1071 ];
1072 }
1073 }
1074