PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.24
MxChat – AI Chatbot & Content Generation for WordPress v3.2.24
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
mxchat-basic / includes / class-mxchat-privacy.php

class-mxchat-privacy.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.24, at includes/class-mxchat-privacy.php

735 lines 31.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit; // Exit if accessed directly
4 }
5
6 /**
7 * GDPR / privacy integration (plan-mxchat-20260801-b81e42).
8 *
9 * Registers MxChat with WordPress's native privacy tooling so a data-subject
10 * request run from Tools → Export Personal Data / Erase Personal Data
11 * automatically includes chat data. Three surfaces:
12 *
13 * 1. Exporter — wp_privacy_personal_data_exporters
14 * 2. Eraser — wp_privacy_personal_data_erasers
15 * 3. Suggested privacy-policy text — wp_add_privacy_policy_content
16 *
17 * Identity matching: transcripts key logged-in visitors by username
18 * (user_identifier) and user_id, and store user_email when known — while the
19 * privacy tools hand us only an email address. So a subject's rows are matched
20 * three ways: user_email equals the address, and when the address resolves to
21 * a registered account, user_id / user_identifier equal that account's id and
22 * login. Matching only on email would silently miss logged-in conversations.
23 *
24 * Link-click rows (mxchat_url_clicks: user_ip + user_agent, plan 23c4a1) are
25 * covered on both surfaces via the subject's session ids, since the table has
26 * no email column of its own. Click rows whose session has no transcript left
27 * (transcripts are retention-pruned; clicks were not) can never be tied to a
28 * subject, so they are handled class-wide instead: user_ip is anonymized in
29 * place (wp_privacy_anonymize_ip) and user_agent cleared after
30 * CLICK_ANONYMIZE_DAYS, and whole rows are deleted after
31 * CLICK_RETENTION_DAYS. The sweep is cron-independent — it rides the click
32 * write path, time-gated and batched exactly like
33 * MxChat_Session_Store::maybe_trim() — with the daily transcripts-cleanup
34 * cron as a second chance on sites with no click traffic.
35 */
36 class MxChat_Privacy {
37
38 const EXPORT_SESSIONS_PER_PAGE = 10;
39 const ERASE_SESSIONS_PER_PAGE = 25;
40
41 /** Days before a click row's user_ip is anonymized + user_agent cleared. Filterable. */
42 const CLICK_ANONYMIZE_DAYS = 30;
43
44 /** Days before a click row is deleted outright. Filterable. */
45 const CLICK_RETENTION_DAYS = 365;
46
47 /** Rows per sweep pass — capped so it can never slow a chat request. */
48 const CLICK_SWEEP_BATCH = 200;
49
50 /** Minimum seconds between write-path sweeps. */
51 const CLICK_SWEEP_INTERVAL = HOUR_IN_SECONDS;
52
53 /** Timestamp of the last click sweep. Autoloaded (tiny, read on click writes). */
54 const CLICK_SWEEP_STAMP_OPTION = 'mxchat_url_clicks_last_sweep';
55
56 /**
57 * Anonymization bookmark: highest url_clicks id already anonymized. Ids
58 * are auto-increment and click_timestamp is monotonic with them, so the
59 * bookmark only ever moves forward — same idiom as the session-store
60 * migration bookmark (b64b77). Non-autoloaded.
61 */
62 const CLICK_ANON_BOOKMARK_OPTION = 'mxchat_url_clicks_anon_bookmark';
63
64 /**
65 * Per-session wp_options keys (prefix . session_id). Mirrors the cleanup
66 * set used by the transcripts delete paths, plus the session-owner key.
67 */
68 private static $session_option_prefixes = array(
69 'mxchat_history_',
70 'mxchat_lead_del_email_',
71 'mxchat_lead_del_name_',
72 'mxchat_lead_del_ts_',
73 'mxchat_lead_del_consent_',
74 'mxchat_lead_del_consent_label_',
75 'mxchat_lead_del_consent_at_',
76 // NOTE: mxchat_session_owner_ (b64b77) and mxchat_email_ /
77 // mxchat_name_ / mxchat_agent_name_ (5658f2) moved out of wp_options
78 // into the mxchat_sessions table. The eraser calls
79 // MxChat_Session_Store::delete_session(), which drops the row AND the
80 // legacy option keys, so they are no longer listed here.
81 );
82
83 /**
84 * Per-session transients (prefix . session_id) set by the PDF / Word
85 * upload chat flows. The *_url transients may point at an uploaded temp
86 * file on disk — the eraser removes that file too.
87 */
88 private static $session_transient_prefixes = array(
89 'mxchat_pdf_url_',
90 'mxchat_pdf_filename_',
91 'mxchat_pdf_embeddings_',
92 'mxchat_include_pdf_in_context_',
93 'mxchat_word_url_',
94 'mxchat_word_filename_',
95 'mxchat_word_embeddings_',
96 'mxchat_include_word_in_context_',
97 );
98
99 public static function init() {
100 add_filter('wp_privacy_personal_data_exporters', array(__CLASS__, 'register_exporter'));
101 add_filter('wp_privacy_personal_data_erasers', array(__CLASS__, 'register_eraser'));
102 add_action('admin_init', array(__CLASS__, 'add_privacy_policy_content'));
103
104 // Second chance for the click sweep on sites with no click traffic —
105 // rides the existing daily transcripts-cleanup event rather than
106 // scheduling its own. The write path remains the primary trigger
107 // because WP-Cron cannot be relied on (see class docblock).
108 add_action('mxchat_cleanup_old_transcripts', array(__CLASS__, 'run_url_click_maintenance'));
109 }
110
111 public static function register_exporter($exporters) {
112 $exporters['mxchat-chat-transcripts'] = array(
113 'exporter_friendly_name' => __('MxChat Chat Conversations', 'mxchat'),
114 'callback' => array(__CLASS__, 'export_personal_data'),
115 );
116 return $exporters;
117 }
118
119 public static function register_eraser($erasers) {
120 $erasers['mxchat-chat-transcripts'] = array(
121 'eraser_friendly_name' => __('MxChat Chat Conversations', 'mxchat'),
122 'callback' => array(__CLASS__, 'erase_personal_data'),
123 );
124 return $erasers;
125 }
126
127 /**
128 * Build the WHERE fragment + params matching every transcript row that
129 * belongs to the given email address.
130 */
131 private static function identity_where($email_address) {
132 $where = 'user_email = %s';
133 $params = array($email_address);
134
135 $user = get_user_by('email', $email_address);
136 if ($user) {
137 $where .= ' OR user_id = %d OR user_identifier = %s';
138 $params[] = (int) $user->ID;
139 $params[] = $user->user_login;
140 }
141
142 return array($where, $params);
143 }
144
145 /**
146 * Matching session ids in stable order (oldest conversation first).
147 * $offset supports the exporter's offset pagination; the eraser always
148 * passes 0 because deletion shrinks the matched set.
149 */
150 private static function get_session_ids($email_address, $limit, $offset) {
151 global $wpdb;
152 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
153
154 list($where, $params) = self::identity_where($email_address);
155 $params[] = (int) $limit;
156 $params[] = (int) $offset;
157
158 return $wpdb->get_col($wpdb->prepare(
159 "SELECT session_id FROM {$table} WHERE {$where}
160 GROUP BY session_id
161 ORDER BY MIN(timestamp) ASC, session_id ASC
162 LIMIT %d OFFSET %d",
163 $params
164 ));
165 }
166
167 /**
168 * Exporter callback. Called repeatedly by the privacy tool with an
169 * incrementing $page until we report done. One export item per
170 * conversation, with the messages as individual fields so the generated
171 * report reads as a transcript rather than a flat wall.
172 */
173 public static function export_personal_data($email_address, $page = 1) {
174 global $wpdb;
175 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
176
177 $page = max(1, (int) $page);
178 $offset = ($page - 1) * self::EXPORT_SESSIONS_PER_PAGE;
179
180 // Every session held for the subject: conversations first, then the
181 // pre-chat captures that never became one (fb8877). Computed whole on
182 // each call and sliced, so the pages stay stable.
183 $all_session_ids = self::get_export_session_ids($email_address);
184 $session_ids = array_slice($all_session_ids, $offset, self::EXPORT_SESSIONS_PER_PAGE);
185
186 $items = array();
187 foreach ($session_ids as $sid) {
188 $rows = $wpdb->get_results($wpdb->prepare(
189 "SELECT role, message, user_email, user_name, user_identifier,
190 originating_page_url, timestamp
191 FROM {$table} WHERE session_id = %s
192 ORDER BY timestamp ASC, id ASC",
193 $sid
194 ));
195 if (empty($rows)) {
196 // No conversation: a visitor who submitted the pre-chat form
197 // and never sent a message. The plugin still holds their
198 // email, name and consent record (shown on the Leads tab,
199 // removed by the eraser), so the export must disclose them.
200 $item = self::export_store_only_item($sid);
201 if ($item !== null) {
202 $items[] = $item;
203 }
204 continue;
205 }
206
207 $emails = array();
208 $names = array();
209 $identifiers = array();
210 $first_page = '';
211 foreach ($rows as $row) {
212 if (!empty($row->user_email)) {
213 $emails[$row->user_email] = true;
214 }
215 if (!empty($row->user_name)) {
216 $names[$row->user_name] = true;
217 }
218 if (!empty($row->user_identifier)) {
219 $identifiers[$row->user_identifier] = true;
220 }
221 if ($first_page === '' && !empty($row->originating_page_url)) {
222 $first_page = $row->originating_page_url;
223 }
224 }
225
226 $data = array(
227 array('name' => __('Session ID', 'mxchat'), 'value' => $sid),
228 array('name' => __('Conversation started', 'mxchat'), 'value' => $rows[0]->timestamp),
229 array('name' => __('Last message', 'mxchat'), 'value' => end($rows)->timestamp),
230 );
231 if ($first_page !== '') {
232 $data[] = array('name' => __('Started on page', 'mxchat'), 'value' => $first_page);
233 }
234 if (!empty($emails)) {
235 $data[] = array('name' => __('Email on record', 'mxchat'), 'value' => implode(', ', array_keys($emails)));
236 }
237 if (!empty($names)) {
238 $data[] = array('name' => __('Name on record', 'mxchat'), 'value' => implode(', ', array_keys($names)));
239 }
240 if (!empty($identifiers)) {
241 $data[] = array('name' => __('Visitor identifier', 'mxchat'), 'value' => implode(', ', array_keys($identifiers)));
242 }
243
244 // Lead-capture consent record (b062c4). If we hold a consent
245 // decision about the subject, the export must show it — same
246 // Article 15 parity rule as the click rows below.
247 $data = array_merge($data, self::export_consent_fields($sid));
248
249 foreach ($rows as $row) {
250 $data[] = array(
251 'name' => sprintf('[%s] %s', $row->timestamp, self::role_label($row->role)),
252 'value' => $row->message,
253 );
254 }
255
256 // Link-click rows for this session (Article 15 parity with the
257 // eraser — same subject, same session set, plan 23c4a1).
258 $data = array_merge($data, self::export_click_fields($sid));
259
260 $items[] = self::export_item($sid, $data);
261 }
262
263 return array(
264 'data' => $items,
265 'done' => ($offset + self::EXPORT_SESSIONS_PER_PAGE) >= count($all_session_ids),
266 );
267 }
268
269 /**
270 * The subject's full session set for the exporter: transcript sessions in
271 * their existing order (oldest conversation first), then sessions that
272 * exist only in the session store — a pre-chat capture with no messages —
273 * ordered by session id. The eraser already unions the store the same way
274 * (erase_lingering_options). The store-only set is bounded by lead count.
275 */
276 private static function get_export_session_ids($email_address) {
277 $session_ids = self::get_session_ids($email_address, PHP_INT_MAX, 0);
278 if (!is_array($session_ids)) {
279 $session_ids = array();
280 }
281
282 if (class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'find_by_email')) {
283 $store_only = array_diff(MxChat_Session_Store::find_by_email($email_address), $session_ids);
284 $store_only = array_values(array_unique(array_map('strval', $store_only)));
285 sort($store_only, SORT_STRING);
286 $session_ids = array_merge($session_ids, $store_only);
287 }
288
289 return $session_ids;
290 }
291
292 /**
293 * Export item for a session with no transcript rows. Only what is held:
294 * the identity the visitor typed, the consent record, and any link-click
295 * rows. No conversation dates (there is no conversation) and no capture
296 * time of its own — the store does not expose one, and the consent record
297 * carries its own timestamp. Null when the store holds nothing to show.
298 */
299 private static function export_store_only_item($sid) {
300 if (!class_exists('MxChat_Session_Store')) {
301 return null;
302 }
303
304 $email = MxChat_Session_Store::get($sid, 'email', '');
305 $name = MxChat_Session_Store::get($sid, 'name', '');
306
307 $data = array(
308 array('name' => __('Session ID', 'mxchat'), 'value' => $sid),
309 );
310 if (!empty($email)) {
311 $data[] = array('name' => __('Email on record', 'mxchat'), 'value' => (string) $email);
312 }
313 if (!empty($name)) {
314 $data[] = array('name' => __('Name on record', 'mxchat'), 'value' => (string) $name);
315 }
316
317 $data = array_merge($data, self::export_consent_fields($sid), self::export_click_fields($sid));
318
319 if (count($data) < 2) {
320 return null;
321 }
322
323 return self::export_item($sid, $data);
324 }
325
326 /** Lead-capture consent fields for one session (empty when none recorded). */
327 private static function export_consent_fields($sid) {
328 $fields = array();
329
330 if (class_exists('MxChat_Session_Store')) {
331 $consent_state = MxChat_Session_Store::get($sid, 'consent', '');
332 if ($consent_state !== '' && $consent_state !== false) {
333 $fields[] = array('name' => __('Lead-capture consent', 'mxchat'), 'value' => (string) $consent_state);
334 $consent_at = MxChat_Session_Store::get($sid, 'consent_at', '');
335 if (!empty($consent_at)) {
336 $fields[] = array('name' => __('Consent recorded', 'mxchat'), 'value' => (string) $consent_at);
337 }
338 $consent_label = MxChat_Session_Store::get($sid, 'consent_label', '');
339 if (!empty($consent_label)) {
340 $fields[] = array('name' => __('Consent text shown', 'mxchat'), 'value' => wp_strip_all_tags((string) $consent_label));
341 }
342 }
343 }
344
345 return $fields;
346 }
347
348 /** Link-click fields for one session (empty when none, or no clicks table). */
349 private static function export_click_fields($sid) {
350 global $wpdb;
351 $fields = array();
352
353 if (self::url_clicks_table_exists()) {
354 $clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
355 $clicks = $wpdb->get_results($wpdb->prepare(
356 "SELECT clicked_url, click_timestamp, user_ip, user_agent
357 FROM {$clicks_table} WHERE session_id = %s
358 ORDER BY click_timestamp ASC, id ASC",
359 $sid
360 ));
361 foreach ($clicks as $click) {
362 $details = $click->clicked_url;
363 $meta = array();
364 if (!empty($click->user_ip)) {
365 /* translators: %s: IP address recorded with a link click */
366 $meta[] = sprintf(__('IP address: %s', 'mxchat'), $click->user_ip);
367 }
368 if (!empty($click->user_agent)) {
369 /* translators: %s: browser user-agent recorded with a link click */
370 $meta[] = sprintf(__('Browser: %s', 'mxchat'), $click->user_agent);
371 }
372 if (!empty($meta)) {
373 $details .= ' (' . implode('; ', $meta) . ')';
374 }
375 $fields[] = array(
376 'name' => sprintf('[%s] %s', $click->click_timestamp, __('Link clicked', 'mxchat')),
377 'value' => $details,
378 );
379 }
380 }
381
382 return $fields;
383 }
384
385 private static function export_item($sid, array $data) {
386 return array(
387 'group_id' => 'mxchat-chat-transcripts',
388 'group_label' => __('Chat Conversations (MxChat)', 'mxchat'),
389 'group_description' => __('Chatbot conversations recorded by the MxChat plugin.', 'mxchat'),
390 'item_id' => 'mxchat-session-' . $sid,
391 'data' => $data,
392 );
393 }
394
395 private static function role_label($role) {
396 switch ($role) {
397 case 'user':
398 return __('Visitor', 'mxchat');
399 case 'bot':
400 case 'assistant':
401 return __('Chatbot', 'mxchat');
402 case 'agent':
403 return __('Agent', 'mxchat');
404 default:
405 return ucfirst((string) $role);
406 }
407 }
408
409 /**
410 * Eraser callback. Deletes the subject's conversations outright — a chat
411 * transcript has no ordinary legitimate-interest basis for retention after
412 * an erasure request, and half-anonymised free text routinely still
413 * contains identifying detail the person typed.
414 *
415 * Pagination note: we always query at offset 0 because deleting shrinks
416 * the matched set (offset paging would skip sessions). A site may retain a
417 * specific session via the mxchat_privacy_erase_session filter; retained
418 * sessions are reported via items_retained with a reason. If an entire
419 * batch is retained we report done rather than loop forever — with the
420 * side effect that a filter retaining 25+ consecutive sessions defers the
421 * rest to a re-run of the tool.
422 */
423 public static function erase_personal_data($email_address, $page = 1) {
424 global $wpdb;
425
426 $items_removed = 0;
427 $items_retained = 0;
428 $messages = array();
429
430 $session_ids = self::get_session_ids($email_address, self::ERASE_SESSIONS_PER_PAGE, 0);
431
432 $deleted_this_batch = 0;
433 foreach ($session_ids as $sid) {
434 /**
435 * Allow a site to retain a specific conversation (e.g. a legal
436 * hold). Default true = erase. Retained sessions are reported,
437 * never silently kept.
438 *
439 * @param bool $erase Whether to erase this conversation.
440 * @param string $sid The session id.
441 * @param string $email_address The data subject's email.
442 */
443 $erase = apply_filters('mxchat_privacy_erase_session', true, $sid, $email_address);
444 if (!$erase) {
445 $items_retained++;
446 $messages[] = sprintf(
447 /* translators: %s: chat session id */
448 __('Chat conversation %s was retained by a site-specific policy (mxchat_privacy_erase_session filter).', 'mxchat'),
449 $sid
450 );
451 continue;
452 }
453
454 self::erase_session($sid);
455 $items_removed++;
456 $deleted_this_batch++;
457 }
458
459 $done = (count($session_ids) < self::ERASE_SESSIONS_PER_PAGE) || ($deleted_this_batch === 0);
460
461 if ($done) {
462 $items_removed += self::erase_lingering_options($email_address);
463 wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions');
464 }
465
466 return array(
467 'items_removed' => $items_removed,
468 'items_retained' => $items_retained,
469 'messages' => $messages,
470 'done' => $done,
471 );
472 }
473
474 /**
475 * Remove one conversation and every piece of derived state: transcript
476 * rows, translation rows, per-session options (history, pre-chat capture,
477 * chat-deleted lead preservation, agent name, session owner), per-session
478 * upload transients, any uploaded PDF/Word temp file those transients
479 * still point at, and the session's row cache.
480 */
481 private static function erase_session($sid) {
482 global $wpdb;
483 static $has_translations = null;
484
485 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
486 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
487 if ($has_translations === null) {
488 $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
489 }
490
491 // Uploaded files first, while the *_url transients still map the
492 // session to its randomised temp filename. Only ever delete a real
493 // local file that follows the plugin's own mxchat_* naming — the PDF
494 // transient can also hold a remote URL, which file_exists rejects.
495 foreach (array('mxchat_pdf_url_', 'mxchat_word_url_') as $prefix) {
496 $path = get_transient($prefix . $sid);
497 if (is_string($path) && $path !== ''
498 && strpos(basename($path), 'mxchat_') === 0
499 && @file_exists($path) && @is_file($path)) {
500 wp_delete_file($path);
501 }
502 }
503
504 foreach (self::$session_transient_prefixes as $prefix) {
505 delete_transient($prefix . $sid);
506 }
507
508 $wpdb->delete($table, array('session_id' => $sid), array('%s'));
509 if ($has_translations) {
510 $wpdb->delete($translations_table, array('session_id' => $sid), array('%s'));
511 }
512
513 // Link-click rows carry user_ip + user_agent — same delete the REST
514 // session-cascade already runs, now reachable from an erasure request
515 // (plan 23c4a1).
516 if (self::url_clicks_table_exists()) {
517 $wpdb->delete($wpdb->prefix . 'mxchat_url_clicks', array('session_id' => $sid), array('%s'));
518 }
519
520 foreach (self::$session_option_prefixes as $prefix) {
521 delete_option($prefix . $sid);
522 }
523
524 // Per-session state (owner, originating page, mode, channel) — one row,
525 // plus the legacy option keys on installs still mid-migration (b64b77).
526 if (class_exists('MxChat_Session_Store')) {
527 MxChat_Session_Store::delete_session($sid);
528 }
529
530 wp_cache_delete('chat_session_' . $sid, 'mxchat_chat_sessions');
531 }
532
533 /**
534 * Orphan sweep: pre-chat captures and chat-deleted lead preservations
535 * whose stored value is the subject's email but whose transcript rows are
536 * already gone (e.g. removed earlier by retention). Mirrors the tail of
537 * MxChat_Admin::mxchat_wipe_leads_by_email(). Returns entries removed.
538 */
539 private static function erase_lingering_options($email_address) {
540 global $wpdb;
541
542 $removed = 0;
543 $email_lc = strtolower(trim($email_address));
544
545 // Table-side twin of the option scan below (5658f2): orphan pre-chat
546 // captures whose transcript rows are gone now live in the sessions
547 // table's identity columns.
548 if (class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'find_by_email')) {
549 foreach (MxChat_Session_Store::find_by_email($email_address) as $sid) {
550 MxChat_Session_Store::delete_session($sid);
551 $removed++;
552 }
553 }
554
555 $lingering = $wpdb->get_results(
556 "SELECT option_name, option_value FROM {$wpdb->options}
557 WHERE option_name LIKE 'mxchat\\_email\\_%' OR option_name LIKE 'mxchat\\_lead\\_del\\_email\\_%'"
558 );
559 foreach ($lingering as $opt) {
560 if (strtolower(trim((string) $opt->option_value)) !== $email_lc) {
561 continue;
562 }
563 if (strpos($opt->option_name, 'mxchat_lead_del_email_') === 0) {
564 $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
565 delete_option('mxchat_lead_del_email_' . $sid);
566 delete_option('mxchat_lead_del_name_' . $sid);
567 delete_option('mxchat_lead_del_ts_' . $sid);
568 delete_option('mxchat_lead_del_consent_' . $sid);
569 delete_option('mxchat_lead_del_consent_label_' . $sid);
570 delete_option('mxchat_lead_del_consent_at_' . $sid);
571 } else {
572 $sid = substr($opt->option_name, strlen('mxchat_email_'));
573 delete_option('mxchat_email_' . $sid);
574 delete_option('mxchat_name_' . $sid);
575 }
576 $removed++;
577 }
578
579 return $removed;
580 }
581
582 /* ---------------------------------------------------------------------
583 * Link-click retention (plan 23c4a1)
584 *
585 * mxchat_url_clicks rows whose session outlived its transcript can never
586 * be matched to a data subject, so the table needs its own lifecycle:
587 * identifiers are anonymized after CLICK_ANONYMIZE_DAYS (the click
588 * analytics — URL, time, session — stay useful) and rows are deleted
589 * after CLICK_RETENTION_DAYS. Fired from the click write path
590 * (time-gated, batched) plus the daily transcripts-cleanup cron.
591 * ------------------------------------------------------------------ */
592
593 private static function url_clicks_table_exists() {
594 global $wpdb;
595 static $exists = null;
596
597 if ($exists === null) {
598 $table = $wpdb->prefix . 'mxchat_url_clicks';
599 $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table;
600 }
601
602 return $exists;
603 }
604
605 /**
606 * Write-path entry point: at most one capped sweep per
607 * CLICK_SWEEP_INTERVAL regardless of click traffic. Mirrors
608 * MxChat_Session_Store::maybe_trim().
609 */
610 public static function maybe_sweep_url_clicks() {
611 $last = (int) get_option(self::CLICK_SWEEP_STAMP_OPTION, 0);
612 $now = time();
613
614 if ($last && ($now - $last) < self::CLICK_SWEEP_INTERVAL) {
615 return 0;
616 }
617
618 // Stamp first: a slow sweep must not let concurrent clicks pile up
619 // duplicate sweeps.
620 update_option(self::CLICK_SWEEP_STAMP_OPTION, $now, 'yes');
621
622 return self::sweep_url_clicks(self::CLICK_SWEEP_BATCH);
623 }
624
625 /** Daily cron: same sweep with a larger cap. */
626 public static function run_url_click_maintenance() {
627 return self::sweep_url_clicks(self::CLICK_SWEEP_BATCH * 10);
628 }
629
630 /**
631 * One sweep pass: delete rows past retention, then anonymize identifiers
632 * on rows past the anonymize window. Both capped at $limit rows.
633 *
634 * Anonymization progress is tracked by an id bookmark rather than by
635 * inspecting the stored values — an anonymized IP is still a non-empty
636 * string, so a value-based WHERE would re-match the same rows every pass
637 * and never converge. click_timestamp is monotonic with the
638 * auto-increment id (rows are only ever inserted at click time), so every
639 * id at or below the bookmark is already processed.
640 *
641 * @return int Rows deleted + rows anonymized this pass.
642 */
643 public static function sweep_url_clicks($limit = self::CLICK_SWEEP_BATCH) {
644 global $wpdb;
645
646 if (!self::url_clicks_table_exists()) {
647 return 0;
648 }
649
650 $table = $wpdb->prefix . 'mxchat_url_clicks';
651 $limit = max(1, (int) $limit);
652 $touched = 0;
653
654 // click_timestamp is written via current_time('mysql', 1) — GMT — so
655 // both cutoffs compare against GMT.
656
657 /**
658 * Days a link-click row is kept before deletion. 0 or negative
659 * disables the delete sweep (rows are still anonymized below).
660 *
661 * @param int $days Retention window in days.
662 */
663 $retention_days = (int) apply_filters('mxchat_url_click_retention_days', self::CLICK_RETENTION_DAYS);
664 if ($retention_days > 0) {
665 $cutoff = gmdate('Y-m-d H:i:s', time() - ($retention_days * DAY_IN_SECONDS));
666 $touched += (int) $wpdb->query($wpdb->prepare(
667 "DELETE FROM `$table` WHERE click_timestamp < %s LIMIT %d",
668 $cutoff,
669 $limit
670 ));
671 }
672
673 /**
674 * Days before a link-click row's user_ip is anonymized in place
675 * (wp_privacy_anonymize_ip) and its user_agent cleared. 0 or negative
676 * disables anonymization.
677 *
678 * @param int $days Anonymization window in days.
679 */
680 $anon_days = (int) apply_filters('mxchat_url_click_anonymize_days', self::CLICK_ANONYMIZE_DAYS);
681 if ($anon_days > 0) {
682 $cutoff = gmdate('Y-m-d H:i:s', time() - ($anon_days * DAY_IN_SECONDS));
683 $bookmark = (int) get_option(self::CLICK_ANON_BOOKMARK_OPTION, 0);
684
685 $rows = $wpdb->get_results($wpdb->prepare(
686 "SELECT id, user_ip FROM `$table`
687 WHERE id > %d AND click_timestamp < %s
688 ORDER BY id ASC LIMIT %d",
689 $bookmark,
690 $cutoff,
691 $limit
692 ));
693
694 foreach ($rows as $row) {
695 $anon_ip = '';
696 if (is_string($row->user_ip) && $row->user_ip !== '') {
697 $anon_ip = wp_privacy_anonymize_ip($row->user_ip);
698 }
699 $wpdb->update(
700 $table,
701 array('user_ip' => $anon_ip, 'user_agent' => ''),
702 array('id' => (int) $row->id),
703 array('%s', '%s'),
704 array('%d')
705 );
706 $bookmark = (int) $row->id;
707 $touched++;
708 }
709
710 if (!empty($rows)) {
711 update_option(self::CLICK_ANON_BOOKMARK_OPTION, $bookmark, 'no');
712 }
713 }
714
715 return $touched;
716 }
717
718 /**
719 * Suggested privacy-policy text, surfaced in the WP privacy-policy editor
720 * (Settings → Privacy → Policy Guide) for the site owner to adopt.
721 */
722 public static function add_privacy_policy_content() {
723 if (!function_exists('wp_add_privacy_policy_content')) {
724 return;
725 }
726
727 $content = '<p>' . __('This site uses MxChat to provide an AI chat assistant. When you use the chat, your messages and the assistant\'s replies are stored in this site\'s own database — they are not stored by a third-party chat service. If you provide your name or email address in the chat, those are stored with the conversation. Logged-in visitors are identified by their username; anonymous visitors are identified by their IP address. The page on which the conversation started is also recorded.', 'mxchat') . '</p>';
728 $content .= '<p>' . __('Chat conversations are kept according to the retention period configured by the site administrator, and may be deleted automatically after that period. Documents uploaded to the chat for analysis are processed on this server and their temporary copies expire automatically.', 'mxchat') . '</p>';
729 $content .= '<p>' . __('When you click a link suggested in the chat, the clicked address is recorded together with your IP address and browser information. The IP address and browser information are anonymized after a limited period, and click records are deleted entirely after at most one year.', 'mxchat') . '</p>';
730 $content .= '<p>' . __('Chat data is included in WordPress\'s personal data export and erasure tools: if you request a copy or deletion of your personal data from this site, your chat conversations are included in that request automatically.', 'mxchat') . '</p>';
731
732 wp_add_privacy_policy_content(__('MxChat', 'mxchat'), wp_kses_post($content));
733 }
734 }
735