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
← All changes | includes/class-mxchat-privacy.php +261 -4 3.2.16 → 3.2.23 View file →
@@ -19,8 +19,20 @@
19 19 * privacy tools hand us only an email address. So a subject's rows are matched
20 20 * three ways: user_email equals the address, and when the address resolves to
21 21 * a registered account, user_id / user_identifier equal that account's id and
22 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.
23 35 */
24 36 class MxChat_Privacy {
25 37
26 38 const EXPORT_SESSIONS_PER_PAGE = 10;
@@ -25,21 +37,48 @@
25 37
26 38 const EXPORT_SESSIONS_PER_PAGE = 10;
27 39 const ERASE_SESSIONS_PER_PAGE = 25;
28 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 +
29 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 + /**
30 65 * Per-session wp_options keys (prefix . session_id). Mirrors the cleanup
31 66 * set used by the transcripts delete paths, plus the session-owner key.
32 67 */
33 68 private static $session_option_prefixes = array(
34 69 'mxchat_history_',
35 - 'mxchat_email_',
36 - 'mxchat_name_',
37 - 'mxchat_agent_name_',
38 70 'mxchat_lead_del_email_',
39 71 'mxchat_lead_del_name_',
40 72 'mxchat_lead_del_ts_',
41 - 'mxchat_session_owner_',
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.
42 81 );
43 82
44 83 /**
45 84 * Per-session transients (prefix . session_id) set by the PDF / Word
@@ -60,8 +99,14 @@
60 99 public static function init() {
61 100 add_filter('wp_privacy_personal_data_exporters', array(__CLASS__, 'register_exporter'));
62 101 add_filter('wp_privacy_personal_data_erasers', array(__CLASS__, 'register_eraser'));
63 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'));
64 109 }
65 110
66 111 public static function register_exporter($exporters) {
67 112 $exporters['mxchat-chat-transcripts'] = array(
@@ -183,8 +228,26 @@
183 228 if (!empty($identifiers)) {
184 229 $data[] = array('name' => __('Visitor identifier', 'mxchat'), 'value' => implode(', ', array_keys($identifiers)));
185 230 }
186 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 +
187 250 foreach ($rows as $row) {
188 251 $data[] = array(
189 252 'name' => sprintf('[%s] %s', $row->timestamp, self::role_label($row->role)),
190 253 'value' => $row->message,
@@ -190,8 +253,39 @@
190 253 'value' => $row->message,
191 254 );
192 255 }
193 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 +
194 288 $items[] = array(
195 289 'group_id' => 'mxchat-chat-transcripts',
196 290 'group_label' => __('Chat Conversations (MxChat)', 'mxchat'),
197 291 'group_description' => __('Chatbot conversations recorded by the MxChat plugin.', 'mxchat'),
@@ -323,12 +417,25 @@
323 417 if ($has_translations) {
324 418 $wpdb->delete($translations_table, array('session_id' => $sid), array('%s'));
325 419 }
326 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 +
327 428 foreach (self::$session_option_prefixes as $prefix) {
328 429 delete_option($prefix . $sid);
329 430 }
330 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 +
331 438 wp_cache_delete('chat_session_' . $sid, 'mxchat_chat_sessions');
332 439 }
333 440
334 441 /**
@@ -342,8 +449,18 @@
342 449
343 450 $removed = 0;
344 451 $email_lc = strtolower(trim($email_address));
345 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 +
346 463 $lingering = $wpdb->get_results(
347 464 "SELECT option_name, option_value FROM {$wpdb->options}
348 465 WHERE option_name LIKE 'mxchat\\_email\\_%' OR option_name LIKE 'mxchat\\_lead\\_del\\_email\\_%'"
349 466 );
@@ -355,8 +472,11 @@
355 472 $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
356 473 delete_option('mxchat_lead_del_email_' . $sid);
357 474 delete_option('mxchat_lead_del_name_' . $sid);
358 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);
359 479 } else {
360 480 $sid = substr($opt->option_name, strlen('mxchat_email_'));
361 481 delete_option('mxchat_email_' . $sid);
362 482 delete_option('mxchat_name_' . $sid);
@@ -366,9 +486,145 @@
366 486
367 487 return $removed;
368 488 }
369 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 +
370 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 + /**
371 627 * Suggested privacy-policy text, surfaced in the WP privacy-policy editor
372 628 * (Settings → Privacy → Policy Guide) for the site owner to adopt.
373 629 */
374 630 public static function add_privacy_policy_content() {
@@ -377,8 +633,9 @@
377 633 }
378 634
379 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>';
380 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>';
381 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>';
382 639
383 640 wp_add_privacy_policy_content(__('MxChat', 'mxchat'), wp_kses_post($content));
384 641 }