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
← All changes | includes/class-mxchat-privacy.php +362 -13 3.2.17 → 3.2.24 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(
@@ -131,9 +176,13 @@
131 176
132 177 $page = max(1, (int) $page);
133 178 $offset = ($page - 1) * self::EXPORT_SESSIONS_PER_PAGE;
134 179
135 - $session_ids = self::get_session_ids($email_address, self::EXPORT_SESSIONS_PER_PAGE, $offset);
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);
136 185
137 186 $items = array();
138 187 foreach ($session_ids as $sid) {
139 188 $rows = $wpdb->get_results($wpdb->prepare(
@@ -143,8 +192,16 @@
143 192 ORDER BY timestamp ASC, id ASC",
144 193 $sid
145 194 ));
146 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 + }
147 204 continue;
148 205 }
149 206
150 207 $emails = array();
@@ -183,8 +240,13 @@
183 240 if (!empty($identifiers)) {
184 241 $data[] = array('name' => __('Visitor identifier', 'mxchat'), 'value' => implode(', ', array_keys($identifiers)));
185 242 }
186 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 +
187 249 foreach ($rows as $row) {
188 250 $data[] = array(
189 251 'name' => sprintf('[%s] %s', $row->timestamp, self::role_label($row->role)),
190 252 'value' => $row->message,
@@ -190,23 +252,147 @@
190 252 'value' => $row->message,
191 253 );
192 254 }
193 255
194 - $items[] = array(
195 - 'group_id' => 'mxchat-chat-transcripts',
196 - 'group_label' => __('Chat Conversations (MxChat)', 'mxchat'),
197 - 'group_description' => __('Chatbot conversations recorded by the MxChat plugin.', 'mxchat'),
198 - 'item_id' => 'mxchat-session-' . $sid,
199 - 'data' => $data,
200 - );
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);
201 261 }
202 262
203 263 return array(
204 264 'data' => $items,
205 - 'done' => count($session_ids) < self::EXPORT_SESSIONS_PER_PAGE,
265 + 'done' => ($offset + self::EXPORT_SESSIONS_PER_PAGE) >= count($all_session_ids),
206 266 );
207 267 }
208 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 +
209 395 private static function role_label($role) {
210 396 switch ($role) {
211 397 case 'user':
212 398 return __('Visitor', 'mxchat');
@@ -323,12 +509,25 @@
323 509 if ($has_translations) {
324 510 $wpdb->delete($translations_table, array('session_id' => $sid), array('%s'));
325 511 }
326 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 +
327 520 foreach (self::$session_option_prefixes as $prefix) {
328 521 delete_option($prefix . $sid);
329 522 }
330 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 +
331 530 wp_cache_delete('chat_session_' . $sid, 'mxchat_chat_sessions');
332 531 }
333 532
334 533 /**
@@ -342,8 +541,18 @@
342 541
343 542 $removed = 0;
344 543 $email_lc = strtolower(trim($email_address));
345 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 +
346 555 $lingering = $wpdb->get_results(
347 556 "SELECT option_name, option_value FROM {$wpdb->options}
348 557 WHERE option_name LIKE 'mxchat\\_email\\_%' OR option_name LIKE 'mxchat\\_lead\\_del\\_email\\_%'"
349 558 );
@@ -355,8 +564,11 @@
355 564 $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
356 565 delete_option('mxchat_lead_del_email_' . $sid);
357 566 delete_option('mxchat_lead_del_name_' . $sid);
358 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);
359 571 } else {
360 572 $sid = substr($opt->option_name, strlen('mxchat_email_'));
361 573 delete_option('mxchat_email_' . $sid);
362 574 delete_option('mxchat_name_' . $sid);
@@ -366,9 +578,145 @@
366 578
367 579 return $removed;
368 580 }
369 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 +
370 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 + /**
371 719 * Suggested privacy-policy text, surfaced in the WP privacy-policy editor
372 720 * (Settings → Privacy → Policy Guide) for the site owner to adopt.
373 721 */
374 722 public static function add_privacy_policy_content() {
@@ -377,8 +725,9 @@
377 725 }
378 726
379 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>';
380 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>';
381 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>';
382 731
383 732 wp_add_privacy_policy_content(__('MxChat', 'mxchat'), wp_kses_post($content));
384 733 }