PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.23
MxChat – AI Chatbot & Content Generation for WordPress v3.2.23
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.23, at includes/class-mxchat-privacy.php

643 lines 27.9 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 $session_ids = self::get_session_ids($email_address, self::EXPORT_SESSIONS_PER_PAGE, $offset);
181
182 $items = array();
183 foreach ($session_ids as $sid) {
184 $rows = $wpdb->get_results($wpdb->prepare(
185 "SELECT role, message, user_email, user_name, user_identifier,
186 originating_page_url, timestamp
187 FROM {$table} WHERE session_id = %s
188 ORDER BY timestamp ASC, id ASC",
189 $sid
190 ));
191 if (empty($rows)) {
192 continue;
193 }
194
195 $emails = array();
196 $names = array();
197 $identifiers = array();
198 $first_page = '';
199 foreach ($rows as $row) {
200 if (!empty($row->user_email)) {
201 $emails[$row->user_email] = true;
202 }
203 if (!empty($row->user_name)) {
204 $names[$row->user_name] = true;
205 }
206 if (!empty($row->user_identifier)) {
207 $identifiers[$row->user_identifier] = true;
208 }
209 if ($first_page === '' && !empty($row->originating_page_url)) {
210 $first_page = $row->originating_page_url;
211 }
212 }
213
214 $data = array(
215 array('name' => __('Session ID', 'mxchat'), 'value' => $sid),
216 array('name' => __('Conversation started', 'mxchat'), 'value' => $rows[0]->timestamp),
217 array('name' => __('Last message', 'mxchat'), 'value' => end($rows)->timestamp),
218 );
219 if ($first_page !== '') {
220 $data[] = array('name' => __('Started on page', 'mxchat'), 'value' => $first_page);
221 }
222 if (!empty($emails)) {
223 $data[] = array('name' => __('Email on record', 'mxchat'), 'value' => implode(', ', array_keys($emails)));
224 }
225 if (!empty($names)) {
226 $data[] = array('name' => __('Name on record', 'mxchat'), 'value' => implode(', ', array_keys($names)));
227 }
228 if (!empty($identifiers)) {
229 $data[] = array('name' => __('Visitor identifier', 'mxchat'), 'value' => implode(', ', array_keys($identifiers)));
230 }
231
232 // Lead-capture consent record (b062c4). If we hold a consent
233 // decision about the subject, the export must show it — same
234 // Article 15 parity rule as the click rows below.
235 if (class_exists('MxChat_Session_Store')) {
236 $consent_state = MxChat_Session_Store::get($sid, 'consent', '');
237 if ($consent_state !== '' && $consent_state !== false) {
238 $data[] = array('name' => __('Lead-capture consent', 'mxchat'), 'value' => (string) $consent_state);
239 $consent_at = MxChat_Session_Store::get($sid, 'consent_at', '');
240 if (!empty($consent_at)) {
241 $data[] = array('name' => __('Consent recorded', 'mxchat'), 'value' => (string) $consent_at);
242 }
243 $consent_label = MxChat_Session_Store::get($sid, 'consent_label', '');
244 if (!empty($consent_label)) {
245 $data[] = array('name' => __('Consent text shown', 'mxchat'), 'value' => wp_strip_all_tags((string) $consent_label));
246 }
247 }
248 }
249
250 foreach ($rows as $row) {
251 $data[] = array(
252 'name' => sprintf('[%s] %s', $row->timestamp, self::role_label($row->role)),
253 'value' => $row->message,
254 );
255 }
256
257 // Link-click rows for this session (Article 15 parity with the
258 // eraser — same subject, same session set, plan 23c4a1).
259 if (self::url_clicks_table_exists()) {
260 $clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
261 $clicks = $wpdb->get_results($wpdb->prepare(
262 "SELECT clicked_url, click_timestamp, user_ip, user_agent
263 FROM {$clicks_table} WHERE session_id = %s
264 ORDER BY click_timestamp ASC, id ASC",
265 $sid
266 ));
267 foreach ($clicks as $click) {
268 $details = $click->clicked_url;
269 $meta = array();
270 if (!empty($click->user_ip)) {
271 /* translators: %s: IP address recorded with a link click */
272 $meta[] = sprintf(__('IP address: %s', 'mxchat'), $click->user_ip);
273 }
274 if (!empty($click->user_agent)) {
275 /* translators: %s: browser user-agent recorded with a link click */
276 $meta[] = sprintf(__('Browser: %s', 'mxchat'), $click->user_agent);
277 }
278 if (!empty($meta)) {
279 $details .= ' (' . implode('; ', $meta) . ')';
280 }
281 $data[] = array(
282 'name' => sprintf('[%s] %s', $click->click_timestamp, __('Link clicked', 'mxchat')),
283 'value' => $details,
284 );
285 }
286 }
287
288 $items[] = array(
289 'group_id' => 'mxchat-chat-transcripts',
290 'group_label' => __('Chat Conversations (MxChat)', 'mxchat'),
291 'group_description' => __('Chatbot conversations recorded by the MxChat plugin.', 'mxchat'),
292 'item_id' => 'mxchat-session-' . $sid,
293 'data' => $data,
294 );
295 }
296
297 return array(
298 'data' => $items,
299 'done' => count($session_ids) < self::EXPORT_SESSIONS_PER_PAGE,
300 );
301 }
302
303 private static function role_label($role) {
304 switch ($role) {
305 case 'user':
306 return __('Visitor', 'mxchat');
307 case 'bot':
308 case 'assistant':
309 return __('Chatbot', 'mxchat');
310 case 'agent':
311 return __('Agent', 'mxchat');
312 default:
313 return ucfirst((string) $role);
314 }
315 }
316
317 /**
318 * Eraser callback. Deletes the subject's conversations outright — a chat
319 * transcript has no ordinary legitimate-interest basis for retention after
320 * an erasure request, and half-anonymised free text routinely still
321 * contains identifying detail the person typed.
322 *
323 * Pagination note: we always query at offset 0 because deleting shrinks
324 * the matched set (offset paging would skip sessions). A site may retain a
325 * specific session via the mxchat_privacy_erase_session filter; retained
326 * sessions are reported via items_retained with a reason. If an entire
327 * batch is retained we report done rather than loop forever — with the
328 * side effect that a filter retaining 25+ consecutive sessions defers the
329 * rest to a re-run of the tool.
330 */
331 public static function erase_personal_data($email_address, $page = 1) {
332 global $wpdb;
333
334 $items_removed = 0;
335 $items_retained = 0;
336 $messages = array();
337
338 $session_ids = self::get_session_ids($email_address, self::ERASE_SESSIONS_PER_PAGE, 0);
339
340 $deleted_this_batch = 0;
341 foreach ($session_ids as $sid) {
342 /**
343 * Allow a site to retain a specific conversation (e.g. a legal
344 * hold). Default true = erase. Retained sessions are reported,
345 * never silently kept.
346 *
347 * @param bool $erase Whether to erase this conversation.
348 * @param string $sid The session id.
349 * @param string $email_address The data subject's email.
350 */
351 $erase = apply_filters('mxchat_privacy_erase_session', true, $sid, $email_address);
352 if (!$erase) {
353 $items_retained++;
354 $messages[] = sprintf(
355 /* translators: %s: chat session id */
356 __('Chat conversation %s was retained by a site-specific policy (mxchat_privacy_erase_session filter).', 'mxchat'),
357 $sid
358 );
359 continue;
360 }
361
362 self::erase_session($sid);
363 $items_removed++;
364 $deleted_this_batch++;
365 }
366
367 $done = (count($session_ids) < self::ERASE_SESSIONS_PER_PAGE) || ($deleted_this_batch === 0);
368
369 if ($done) {
370 $items_removed += self::erase_lingering_options($email_address);
371 wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions');
372 }
373
374 return array(
375 'items_removed' => $items_removed,
376 'items_retained' => $items_retained,
377 'messages' => $messages,
378 'done' => $done,
379 );
380 }
381
382 /**
383 * Remove one conversation and every piece of derived state: transcript
384 * rows, translation rows, per-session options (history, pre-chat capture,
385 * chat-deleted lead preservation, agent name, session owner), per-session
386 * upload transients, any uploaded PDF/Word temp file those transients
387 * still point at, and the session's row cache.
388 */
389 private static function erase_session($sid) {
390 global $wpdb;
391 static $has_translations = null;
392
393 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
394 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
395 if ($has_translations === null) {
396 $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
397 }
398
399 // Uploaded files first, while the *_url transients still map the
400 // session to its randomised temp filename. Only ever delete a real
401 // local file that follows the plugin's own mxchat_* naming — the PDF
402 // transient can also hold a remote URL, which file_exists rejects.
403 foreach (array('mxchat_pdf_url_', 'mxchat_word_url_') as $prefix) {
404 $path = get_transient($prefix . $sid);
405 if (is_string($path) && $path !== ''
406 && strpos(basename($path), 'mxchat_') === 0
407 && @file_exists($path) && @is_file($path)) {
408 wp_delete_file($path);
409 }
410 }
411
412 foreach (self::$session_transient_prefixes as $prefix) {
413 delete_transient($prefix . $sid);
414 }
415
416 $wpdb->delete($table, array('session_id' => $sid), array('%s'));
417 if ($has_translations) {
418 $wpdb->delete($translations_table, array('session_id' => $sid), array('%s'));
419 }
420
421 // Link-click rows carry user_ip + user_agent — same delete the REST
422 // session-cascade already runs, now reachable from an erasure request
423 // (plan 23c4a1).
424 if (self::url_clicks_table_exists()) {
425 $wpdb->delete($wpdb->prefix . 'mxchat_url_clicks', array('session_id' => $sid), array('%s'));
426 }
427
428 foreach (self::$session_option_prefixes as $prefix) {
429 delete_option($prefix . $sid);
430 }
431
432 // Per-session state (owner, originating page, mode, channel) — one row,
433 // plus the legacy option keys on installs still mid-migration (b64b77).
434 if (class_exists('MxChat_Session_Store')) {
435 MxChat_Session_Store::delete_session($sid);
436 }
437
438 wp_cache_delete('chat_session_' . $sid, 'mxchat_chat_sessions');
439 }
440
441 /**
442 * Orphan sweep: pre-chat captures and chat-deleted lead preservations
443 * whose stored value is the subject's email but whose transcript rows are
444 * already gone (e.g. removed earlier by retention). Mirrors the tail of
445 * MxChat_Admin::mxchat_wipe_leads_by_email(). Returns entries removed.
446 */
447 private static function erase_lingering_options($email_address) {
448 global $wpdb;
449
450 $removed = 0;
451 $email_lc = strtolower(trim($email_address));
452
453 // Table-side twin of the option scan below (5658f2): orphan pre-chat
454 // captures whose transcript rows are gone now live in the sessions
455 // table's identity columns.
456 if (class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'find_by_email')) {
457 foreach (MxChat_Session_Store::find_by_email($email_address) as $sid) {
458 MxChat_Session_Store::delete_session($sid);
459 $removed++;
460 }
461 }
462
463 $lingering = $wpdb->get_results(
464 "SELECT option_name, option_value FROM {$wpdb->options}
465 WHERE option_name LIKE 'mxchat\\_email\\_%' OR option_name LIKE 'mxchat\\_lead\\_del\\_email\\_%'"
466 );
467 foreach ($lingering as $opt) {
468 if (strtolower(trim((string) $opt->option_value)) !== $email_lc) {
469 continue;
470 }
471 if (strpos($opt->option_name, 'mxchat_lead_del_email_') === 0) {
472 $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
473 delete_option('mxchat_lead_del_email_' . $sid);
474 delete_option('mxchat_lead_del_name_' . $sid);
475 delete_option('mxchat_lead_del_ts_' . $sid);
476 delete_option('mxchat_lead_del_consent_' . $sid);
477 delete_option('mxchat_lead_del_consent_label_' . $sid);
478 delete_option('mxchat_lead_del_consent_at_' . $sid);
479 } else {
480 $sid = substr($opt->option_name, strlen('mxchat_email_'));
481 delete_option('mxchat_email_' . $sid);
482 delete_option('mxchat_name_' . $sid);
483 }
484 $removed++;
485 }
486
487 return $removed;
488 }
489
490 /* ---------------------------------------------------------------------
491 * Link-click retention (plan 23c4a1)
492 *
493 * mxchat_url_clicks rows whose session outlived its transcript can never
494 * be matched to a data subject, so the table needs its own lifecycle:
495 * identifiers are anonymized after CLICK_ANONYMIZE_DAYS (the click
496 * analytics — URL, time, session — stay useful) and rows are deleted
497 * after CLICK_RETENTION_DAYS. Fired from the click write path
498 * (time-gated, batched) plus the daily transcripts-cleanup cron.
499 * ------------------------------------------------------------------ */
500
501 private static function url_clicks_table_exists() {
502 global $wpdb;
503 static $exists = null;
504
505 if ($exists === null) {
506 $table = $wpdb->prefix . 'mxchat_url_clicks';
507 $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table;
508 }
509
510 return $exists;
511 }
512
513 /**
514 * Write-path entry point: at most one capped sweep per
515 * CLICK_SWEEP_INTERVAL regardless of click traffic. Mirrors
516 * MxChat_Session_Store::maybe_trim().
517 */
518 public static function maybe_sweep_url_clicks() {
519 $last = (int) get_option(self::CLICK_SWEEP_STAMP_OPTION, 0);
520 $now = time();
521
522 if ($last && ($now - $last) < self::CLICK_SWEEP_INTERVAL) {
523 return 0;
524 }
525
526 // Stamp first: a slow sweep must not let concurrent clicks pile up
527 // duplicate sweeps.
528 update_option(self::CLICK_SWEEP_STAMP_OPTION, $now, 'yes');
529
530 return self::sweep_url_clicks(self::CLICK_SWEEP_BATCH);
531 }
532
533 /** Daily cron: same sweep with a larger cap. */
534 public static function run_url_click_maintenance() {
535 return self::sweep_url_clicks(self::CLICK_SWEEP_BATCH * 10);
536 }
537
538 /**
539 * One sweep pass: delete rows past retention, then anonymize identifiers
540 * on rows past the anonymize window. Both capped at $limit rows.
541 *
542 * Anonymization progress is tracked by an id bookmark rather than by
543 * inspecting the stored values — an anonymized IP is still a non-empty
544 * string, so a value-based WHERE would re-match the same rows every pass
545 * and never converge. click_timestamp is monotonic with the
546 * auto-increment id (rows are only ever inserted at click time), so every
547 * id at or below the bookmark is already processed.
548 *
549 * @return int Rows deleted + rows anonymized this pass.
550 */
551 public static function sweep_url_clicks($limit = self::CLICK_SWEEP_BATCH) {
552 global $wpdb;
553
554 if (!self::url_clicks_table_exists()) {
555 return 0;
556 }
557
558 $table = $wpdb->prefix . 'mxchat_url_clicks';
559 $limit = max(1, (int) $limit);
560 $touched = 0;
561
562 // click_timestamp is written via current_time('mysql', 1) — GMT — so
563 // both cutoffs compare against GMT.
564
565 /**
566 * Days a link-click row is kept before deletion. 0 or negative
567 * disables the delete sweep (rows are still anonymized below).
568 *
569 * @param int $days Retention window in days.
570 */
571 $retention_days = (int) apply_filters('mxchat_url_click_retention_days', self::CLICK_RETENTION_DAYS);
572 if ($retention_days > 0) {
573 $cutoff = gmdate('Y-m-d H:i:s', time() - ($retention_days * DAY_IN_SECONDS));
574 $touched += (int) $wpdb->query($wpdb->prepare(
575 "DELETE FROM `$table` WHERE click_timestamp < %s LIMIT %d",
576 $cutoff,
577 $limit
578 ));
579 }
580
581 /**
582 * Days before a link-click row's user_ip is anonymized in place
583 * (wp_privacy_anonymize_ip) and its user_agent cleared. 0 or negative
584 * disables anonymization.
585 *
586 * @param int $days Anonymization window in days.
587 */
588 $anon_days = (int) apply_filters('mxchat_url_click_anonymize_days', self::CLICK_ANONYMIZE_DAYS);
589 if ($anon_days > 0) {
590 $cutoff = gmdate('Y-m-d H:i:s', time() - ($anon_days * DAY_IN_SECONDS));
591 $bookmark = (int) get_option(self::CLICK_ANON_BOOKMARK_OPTION, 0);
592
593 $rows = $wpdb->get_results($wpdb->prepare(
594 "SELECT id, user_ip FROM `$table`
595 WHERE id > %d AND click_timestamp < %s
596 ORDER BY id ASC LIMIT %d",
597 $bookmark,
598 $cutoff,
599 $limit
600 ));
601
602 foreach ($rows as $row) {
603 $anon_ip = '';
604 if (is_string($row->user_ip) && $row->user_ip !== '') {
605 $anon_ip = wp_privacy_anonymize_ip($row->user_ip);
606 }
607 $wpdb->update(
608 $table,
609 array('user_ip' => $anon_ip, 'user_agent' => ''),
610 array('id' => (int) $row->id),
611 array('%s', '%s'),
612 array('%d')
613 );
614 $bookmark = (int) $row->id;
615 $touched++;
616 }
617
618 if (!empty($rows)) {
619 update_option(self::CLICK_ANON_BOOKMARK_OPTION, $bookmark, 'no');
620 }
621 }
622
623 return $touched;
624 }
625
626 /**
627 * Suggested privacy-policy text, surfaced in the WP privacy-policy editor
628 * (Settings → Privacy → Policy Guide) for the site owner to adopt.
629 */
630 public static function add_privacy_policy_content() {
631 if (!function_exists('wp_add_privacy_policy_content')) {
632 return;
633 }
634
635 $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>';
636 $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>';
637 $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>';
638 $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>';
639
640 wp_add_privacy_policy_content(__('MxChat', 'mxchat'), wp_kses_post($content));
641 }
642 }
643