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-session-store.php

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

948 lines 34.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Per-session chat state storage.
4 *
5 * Historically every piece of per-session state was written to wp_options
6 * under a session-suffixed key (mxchat_session_owner_<sid>, mxchat_mode_<sid>,
7 * ...). Those rows have no expiry and no index, so the table grew linearly
8 * with sessions forever — on a real install MxChat owned 65% of wp_options
9 * (6,858 of 10,538 rows), and several hundred of them were autoloaded because
10 * the writes omitted the autoload argument. wp_options is the one table
11 * WordPress reads in full on every request, including anonymous front-end
12 * views.
13 *
14 * This class replaces those rows with one indexed row per session in a
15 * dedicated table, plus a retention sweep that does not depend on WP-Cron
16 * (WP-Cron is disabled on some installs; see plan-mxchat-20260805-b64b77).
17 *
18 * Fields owned by this store: owner, originating_page, mode, channel, plus
19 * (since schema v2 / plan 5658f2) the visitor identity keys name, email and
20 * agent_name — moved atomically with the mxchat-forms and mxchat-embed
21 * writers so no plugin half writes options while another reads the table.
22 * Chat history is deliberately NOT here either: plan 839c4c deduplicated the
23 * mxchat_history_ options into the transcripts table (their content was a
24 * second copy of it all along) — MxChat_Utils::get_session_history() is the
25 * accessor.
26 *
27 * @package MxChat
28 */
29
30 if (!defined('ABSPATH')) {
31 exit;
32 }
33
34 class MxChat_Session_Store {
35
36 /** Table name without the wpdb prefix. */
37 const TABLE_SUFFIX = 'mxchat_sessions';
38
39 /**
40 * Bump when create_table()'s schema changes. ensure_table() compares the
41 * READY option against this, so existing installs re-run dbDelta once and
42 * pick up new columns ('1' = b64b77 original, '2' = 5658f2 identity
43 * columns, '3' = b062c4 consent record).
44 */
45 const SCHEMA_VERSION = '3';
46
47 /**
48 * Bump when $legacy_prefixes gains entries. A stale version in the stored
49 * migration state resets the bookmark so the new prefixes get a full
50 * re-scan (the migration upsert is a no-op on already-moved rows).
51 */
52 const MIGRATION_VERSION = 2;
53
54 /** Batched-migration bookmark. Non-autoloaded. */
55 const MIGRATION_OPTION = 'mxchat_session_store_migration';
56
57 /** Timestamp of the last retention sweep. Autoloaded (tiny, read on writes). */
58 const TRIM_STAMP_OPTION = 'mxchat_session_store_last_trim';
59
60 /** Set once the table is known to exist, so the hot path costs no SHOW TABLES. */
61 const READY_OPTION = 'mxchat_session_store_ready';
62
63 /** Daily maintenance event. Belt to the opportunistic braces. */
64 const CRON_HOOK = 'mxchat_session_store_maintenance';
65
66 /** Default retention for untouched sessions, in days. Filterable. */
67 const DEFAULT_RETENTION_DAYS = 30;
68
69 /** Rows migrated per batch. */
70 const MIGRATION_BATCH = 500;
71
72 /** Rows deleted per opportunistic sweep. */
73 const TRIM_BATCH = 200;
74
75 /** Minimum seconds between opportunistic sweeps. */
76 const TRIM_INTERVAL = HOUR_IN_SECONDS;
77
78 /**
79 * Logical field => column name. Also the allowlist: anything not in here
80 * is rejected, so the column name is never attacker-influenced.
81 */
82 private static $columns = array(
83 'owner' => 'owner',
84 'originating_page' => 'originating_page',
85 'mode' => 'mode',
86 'channel' => 'channel',
87 'name' => 'visitor_name',
88 'email' => 'visitor_email',
89 'agent_name' => 'agent_name',
90 // Consent record (b062c4). Never lived in wp_options, so no legacy
91 // prefixes: legacy_get() correctly falls through to the default.
92 'consent' => 'consent_given',
93 'consent_label' => 'consent_label',
94 'consent_at' => 'consent_recorded_at',
95 );
96
97 /** Logical field => the wp_options prefix it used to live under. */
98 private static $legacy_prefixes = array(
99 'owner' => 'mxchat_session_owner_',
100 'originating_page' => 'mxchat_originating_page_',
101 'mode' => 'mxchat_mode_',
102 'channel' => 'mxchat_channel_',
103 'name' => 'mxchat_name_',
104 'email' => 'mxchat_email_',
105 'agent_name' => 'mxchat_agent_name_',
106 );
107
108 /** Fields stored as JSON rather than a scalar. */
109 private static $json_fields = array('originating_page');
110
111 /** Request-scoped row cache. Mirrors get_option()'s per-request caching. */
112 private static $cache = array();
113
114 /** Set once per request after the table has been confirmed/created. */
115 private static $table_ready = false;
116
117 public static function init() {
118 add_action(self::CRON_HOOK, array(__CLASS__, 'run_maintenance'));
119
120 if (!wp_next_scheduled(self::CRON_HOOK)) {
121 wp_schedule_event(time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK);
122 }
123
124 // Cron-independent progress: one migration batch per admin PAGE load
125 // until the backlog is drained. Installs with WP-Cron disabled still
126 // converge, just more slowly.
127 //
128 // wp_doing_ajax() guard is load-bearing, not defensive: admin-ajax.php
129 // fires admin_init too, and the chat widget's own message endpoint is
130 // an admin-ajax action. Without this, an anonymous visitor sending a
131 // chat message would pay for a 500-row migration batch inside their
132 // request. Measured on the test site: the front end alone never
133 // advanced the bookmark, one admin-ajax hit drained the whole backlog.
134 if (!wp_doing_ajax()) {
135 add_action('admin_init', array(__CLASS__, 'maybe_migrate'), 20);
136 }
137 }
138
139 public static function table() {
140 global $wpdb;
141 return $wpdb->prefix . self::TABLE_SUFFIX;
142 }
143
144 /* ---------------------------------------------------------------------
145 * Schema
146 * ------------------------------------------------------------------ */
147
148 public static function create_table() {
149 global $wpdb;
150
151 $table = self::table();
152 $charset_collate = $wpdb->get_charset_collate();
153
154 // session_id is the primary key so set() can use a single upsert.
155 // 191 chars keeps the key inside the utf8mb4 index limit; session ids
156 // are 'mxchat_chat_' + 32 hex, and sanitize_session_id() caps at 128.
157 $sql = "CREATE TABLE $table (
158 session_id varchar(191) NOT NULL,
159 owner varchar(191) DEFAULT NULL,
160 originating_page longtext DEFAULT NULL,
161 mode varchar(32) DEFAULT NULL,
162 channel varchar(191) DEFAULT NULL,
163 visitor_name varchar(191) DEFAULT NULL,
164 visitor_email varchar(191) DEFAULT NULL,
165 agent_name varchar(191) DEFAULT NULL,
166 consent_given varchar(8) DEFAULT NULL,
167 consent_label text DEFAULT NULL,
168 consent_recorded_at datetime DEFAULT NULL,
169 created_at datetime DEFAULT NULL,
170 updated_at datetime DEFAULT NULL,
171 PRIMARY KEY (session_id),
172 KEY updated_at (updated_at)
173 ) $charset_collate;";
174
175 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
176 dbDelta($sql);
177
178 $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table;
179 if ($exists) {
180 update_option(self::READY_OPTION, self::SCHEMA_VERSION, 'yes');
181 self::$table_ready = true;
182 }
183
184 return $exists;
185 }
186
187 /**
188 * Cheap guard so a read/write never fires at a missing table. Steady state
189 * is one autoloaded option read (free — it is already in alloptions).
190 */
191 private static function ensure_table() {
192 if (self::$table_ready) {
193 return true;
194 }
195
196 // The stored value is the schema version the table was last built at.
197 // Anything else (including b64b77's '1') re-runs create_table() once —
198 // dbDelta adds the missing columns to existing installs.
199 if (get_option(self::READY_OPTION) === self::SCHEMA_VERSION) {
200 self::$table_ready = true;
201 return true;
202 }
203
204 return self::create_table();
205 }
206
207 /**
208 * Feature probe for add-ons: does this core's store handle $field?
209 * mxchat-forms / mxchat-embed call this before routing identity writes to
210 * the table, falling back to the legacy option write against an older
211 * core whose allowlist would silently reject the field.
212 */
213 public static function supports($field) {
214 return isset(self::$columns[$field]);
215 }
216
217 /* ---------------------------------------------------------------------
218 * Accessors
219 * ------------------------------------------------------------------ */
220
221 /**
222 * @param string $session_id
223 * @param string $field One of the keys in self::$columns.
224 * @param mixed $default Returned when the session or the field is unset —
225 * matching get_option()'s default semantics.
226 * @return mixed
227 */
228 public static function get($session_id, $field, $default = false) {
229 $session_id = self::sanitize($session_id);
230 if ($session_id === '' || !isset(self::$columns[$field])) {
231 return $default;
232 }
233
234 $row = self::row($session_id);
235 if (!is_array($row)) {
236 return self::legacy_get($session_id, $field, $default);
237 }
238
239 $column = self::$columns[$field];
240 if (!array_key_exists($column, $row) || $row[$column] === null) {
241 return self::legacy_get($session_id, $field, $default);
242 }
243
244 if (in_array($field, self::$json_fields, true)) {
245 $decoded = json_decode($row[$column], true);
246 return is_array($decoded) ? $decoded : $default;
247 }
248
249 return $row[$column];
250 }
251
252 /**
253 * Mid-migration read-through. A session created before the update may
254 * still live only in wp_options until its batch migrates; without this,
255 * an in-flight live-agent session read mode='ai' (dropping the visitor
256 * out of agent mode) and a handed-off session read channel='' (minting a
257 * duplicate Slack channel on re-handover) — plan 71e4b6. Gated on
258 * migration state so steady-state reads never pay for it: once the
259 * migration has marked itself done this is a single cached option read.
260 *
261 * get_option() returns the legacy value in its historical shape (options
262 * were stored unserialized), which is exactly what pre-b64b77 callers got.
263 */
264 private static function legacy_get($session_id, $field, $default) {
265 if (!isset(self::$legacy_prefixes[$field]) || self::is_migrated()) {
266 return $default;
267 }
268
269 return get_option(self::$legacy_prefixes[$field] . $session_id, $default);
270 }
271
272 /**
273 * Upsert one field. Touches updated_at, which is what the retention sweep
274 * ages against.
275 *
276 * @return bool True when the write was attempted against a live table.
277 */
278 public static function set($session_id, $field, $value) {
279 global $wpdb;
280
281 $session_id = self::sanitize($session_id);
282 if ($session_id === '' || !isset(self::$columns[$field]) || !self::ensure_table()) {
283 return false;
284 }
285
286 if (in_array($field, self::$json_fields, true)) {
287 $stored = is_null($value) ? null : wp_json_encode($value);
288 } else {
289 $stored = is_scalar($value) ? (string) $value : wp_json_encode($value);
290 }
291
292 $table = self::table();
293 $column = self::$columns[$field]; // allowlisted above, never user input
294 $now = current_time('mysql');
295
296 $wpdb->query(
297 $wpdb->prepare(
298 "INSERT INTO `$table` (session_id, `$column`, created_at, updated_at)
299 VALUES (%s, %s, %s, %s)
300 ON DUPLICATE KEY UPDATE `$column` = VALUES(`$column`), updated_at = VALUES(updated_at)",
301 $session_id,
302 $stored,
303 $now,
304 $now
305 )
306 );
307
308 unset(self::$cache[$session_id]);
309 self::maybe_trim();
310
311 return true;
312 }
313
314 /** Clear one field, leaving the rest of the session row intact. */
315 public static function delete($session_id, $field) {
316 global $wpdb;
317
318 $session_id = self::sanitize($session_id);
319 if ($session_id === '' || !isset(self::$columns[$field]) || !self::ensure_table()) {
320 return false;
321 }
322
323 $table = self::table();
324 $column = self::$columns[$field];
325
326 $wpdb->query(
327 $wpdb->prepare(
328 "UPDATE `$table` SET `$column` = NULL, updated_at = %s WHERE session_id = %s",
329 current_time('mysql'),
330 $session_id
331 )
332 );
333
334 // Mirror delete_session(): an unmigrated legacy row left behind would
335 // be resurrected by a later migration batch — e.g. an archived Slack
336 // channel id coming back from the dead (plan 71e4b6). The migration
337 // upsert COALESCEs, so a NULLed column is exactly the fillable case.
338 if (isset(self::$legacy_prefixes[$field])) {
339 delete_option(self::$legacy_prefixes[$field] . $session_id);
340 }
341
342 unset(self::$cache[$session_id]);
343
344 return true;
345 }
346
347 /**
348 * Drop every field for a session. This is what session-clear and the GDPR
349 * eraser want — the old code deleted mode/channel by hand and silently
350 * left owner and originating_page behind forever, which is a large part of
351 * why those two prefixes dominated the options table.
352 */
353 public static function delete_session($session_id) {
354 global $wpdb;
355
356 $session_id = self::sanitize($session_id);
357 if ($session_id === '' || !self::ensure_table()) {
358 return false;
359 }
360
361 $wpdb->delete(self::table(), array('session_id' => $session_id), array('%s'));
362 unset(self::$cache[$session_id]);
363
364 // Belt: an install part-way through migration may still hold the legacy
365 // rows for this session. Clearing a session must clear both homes.
366 foreach (self::$legacy_prefixes as $prefix) {
367 delete_option($prefix . $session_id);
368 }
369
370 // d0cae1: integrations holding per-session state outside WP purge on
371 // this. Fires on every ended session — the retention sweep in trim()
372 // fires it too with reason 'retention'; an integration that only
373 // listens to one path leaks state on the other.
374 do_action('mxchat_session_ended', $session_id, array('reason' => 'deleted'));
375
376 return true;
377 }
378
379 /**
380 * Reverse lookup: which session owns this Slack channel? The inbound
381 * Slack Events webhook resolves agent replies this way — before b64b77 it
382 * queried wp_options for mxchat_channel_ rows, which this store migrates
383 * away and deletes, so the webhook MUST read the table instead
384 * (plan 71e4b6). Newest-updated wins if a channel id was ever reused.
385 *
386 * @param string $channel_id
387 * @return string Session id, or '' when no session owns the channel.
388 */
389 public static function find_by_channel($channel_id) {
390 global $wpdb;
391
392 $channel_id = is_scalar($channel_id) ? trim((string) $channel_id) : '';
393 if ($channel_id === '' || !self::ensure_table()) {
394 return '';
395 }
396
397 $table = self::table();
398
399 $session_id = $wpdb->get_var(
400 $wpdb->prepare(
401 "SELECT session_id FROM `$table` WHERE channel = %s ORDER BY updated_at DESC LIMIT 1",
402 $channel_id
403 )
404 );
405
406 return is_string($session_id) ? $session_id : '';
407 }
408
409 /**
410 * Sessions whose stored visitor email matches (collation-insensitive, same
411 * as the option sweeps' strtolower compare). The lead-wipe and GDPR
412 * lingering sweeps use this as the table-side twin of their
413 * "option_name LIKE 'mxchat_email_%'" scans (5658f2).
414 *
415 * @param string $email
416 * @return string[] Session ids, possibly empty.
417 */
418 public static function find_by_email($email) {
419 global $wpdb;
420
421 $email = is_scalar($email) ? trim((string) $email) : '';
422 if ($email === '' || !self::ensure_table()) {
423 return array();
424 }
425
426 $table = self::table();
427
428 $sids = $wpdb->get_col(
429 $wpdb->prepare("SELECT session_id FROM `$table` WHERE visitor_email = %s", $email)
430 );
431
432 return is_array($sids) ? $sids : array();
433 }
434
435 /**
436 * Every session row holding a visitor email — the table-side source for
437 * the Leads tab's orphan bucket (pre-chat captures with no transcript
438 * rows). Bounded by lead count, not session count: identity columns are
439 * only ever set by the capture paths.
440 *
441 * @return array[] Rows of [session_id, visitor_email, visitor_name].
442 */
443 /**
444 * Sessions carrying visitor identity.
445 *
446 * @param bool $include_nameless_email Also return sessions that have a
447 * NAME but no email address. Off by default, so every existing
448 * caller sees exactly the rows it always has; the Leads tab opts in
449 * when the owner has made the email field optional (c0cfaf), where
450 * a name-only capture is a real lead rather than a half-filled one.
451 */
452 public static function identity_rows($include_nameless_email = false) {
453 global $wpdb;
454
455 if (!self::ensure_table()) {
456 return array();
457 }
458
459 $table = self::table();
460
461 $where = "visitor_email IS NOT NULL AND visitor_email != ''";
462 if ($include_nameless_email) {
463 $where = "($where) OR (visitor_name IS NOT NULL AND visitor_name != '')";
464 }
465
466 $rows = $wpdb->get_results(
467 "SELECT session_id, visitor_email, visitor_name FROM `$table`
468 WHERE $where",
469 ARRAY_A
470 );
471
472 return is_array($rows) ? $rows : array();
473 }
474
475 /**
476 * Record the lead-capture consent decision in one atomic upsert (b062c4).
477 *
478 * Three facts travel together — whether the box was ticked, when, and the
479 * exact label text the visitor saw — because a "yes" without "to what"
480 * proves nothing once the owner rewrites the label. One query rather than
481 * three set() calls so a crash can never store half a consent record.
482 *
483 * Rows written here always also carry visitor_email (the capture endpoint
484 * stores the email in the same request), so the retention sweep's identity
485 * guard keeps consent records exactly as long as it keeps the lead.
486 *
487 * @param string $session_id
488 * @param bool $given True when the box was ticked.
489 * @param string $label The sanitized label markup as rendered to the visitor.
490 * @return bool True when the write was attempted against a live table.
491 */
492 public static function record_consent($session_id, $given, $label) {
493 global $wpdb;
494
495 $session_id = self::sanitize($session_id);
496 if ($session_id === '' || !self::ensure_table()) {
497 return false;
498 }
499
500 $table = self::table();
501 $now = current_time('mysql');
502
503 $wpdb->query(
504 $wpdb->prepare(
505 "INSERT INTO `$table` (session_id, consent_given, consent_label, consent_recorded_at, created_at, updated_at)
506 VALUES (%s, %s, %s, %s, %s, %s)
507 ON DUPLICATE KEY UPDATE
508 consent_given = VALUES(consent_given),
509 consent_label = VALUES(consent_label),
510 consent_recorded_at = VALUES(consent_recorded_at),
511 updated_at = VALUES(updated_at)",
512 $session_id,
513 $given ? 'yes' : 'no',
514 (string) $label,
515 $now,
516 $now,
517 $now
518 )
519 );
520
521 unset(self::$cache[$session_id]);
522 self::maybe_trim();
523
524 return true;
525 }
526
527 /**
528 * Newest recorded consent for a lead email, across all their sessions.
529 * The Leads tab and CSV export read this — a lead is aggregated by email
530 * while consent is captured per session, so "the lead's consent state" is
531 * the most recent record. NULL result means NOT RECORDED, which the
532 * callers must render as exactly that ("not recorded" and "no consent"
533 * are different facts).
534 *
535 * @param string $email
536 * @return array|null ['given' => 'yes'|'no', 'label' => string, 'recorded_at' => string] or null.
537 */
538 public static function latest_consent($email) {
539 global $wpdb;
540
541 $email = is_scalar($email) ? trim((string) $email) : '';
542 if ($email === '' || !self::ensure_table()) {
543 return null;
544 }
545
546 $table = self::table();
547
548 $row = $wpdb->get_row(
549 $wpdb->prepare(
550 "SELECT consent_given, consent_label, consent_recorded_at
551 FROM `$table`
552 WHERE visitor_email = %s AND consent_given IS NOT NULL AND consent_given != ''
553 ORDER BY consent_recorded_at DESC LIMIT 1",
554 $email
555 ),
556 ARRAY_A
557 );
558
559 if (!is_array($row)) {
560 return null;
561 }
562
563 return array(
564 'given' => (string) $row['consent_given'],
565 'label' => is_string($row['consent_label']) ? $row['consent_label'] : '',
566 'recorded_at' => is_string($row['consent_recorded_at']) ? $row['consent_recorded_at'] : '',
567 );
568 }
569
570 private static function row($session_id) {
571 if (array_key_exists($session_id, self::$cache)) {
572 return self::$cache[$session_id];
573 }
574
575 if (!self::ensure_table()) {
576 return false;
577 }
578
579 global $wpdb;
580 $table = self::table();
581
582 $row = $wpdb->get_row(
583 $wpdb->prepare("SELECT * FROM `$table` WHERE session_id = %s", $session_id),
584 ARRAY_A
585 );
586
587 self::$cache[$session_id] = is_array($row) ? $row : false;
588
589 return self::$cache[$session_id];
590 }
591
592 /** Test seam — the request cache is otherwise invisible to callers. */
593 public static function flush_cache() {
594 self::$cache = array();
595 }
596
597 private static function sanitize($session_id) {
598 if (class_exists('MxChat_Utils')) {
599 return MxChat_Utils::sanitize_session_id($session_id);
600 }
601
602 $session_id = is_scalar($session_id) ? trim((string) $session_id) : '';
603
604 return preg_match('/\A[A-Za-z0-9_-]{1,128}\z/', $session_id) ? $session_id : '';
605 }
606
607 /* ---------------------------------------------------------------------
608 * Migration
609 * ------------------------------------------------------------------ */
610
611 public static function migration_state() {
612 $state = get_option(self::MIGRATION_OPTION, array());
613 if (!is_array($state)) {
614 $state = array();
615 }
616
617 $state = wp_parse_args(
618 $state,
619 array(
620 'done' => false,
621 'last_option_id' => 0,
622 'moved' => 0,
623 'sessions' => 0,
624 'version' => 1, // states written before versioning are b64b77's v1
625 )
626 );
627
628 // A version bump means $legacy_prefixes gained entries after this
629 // install finished (or started) migrating — reset the bookmark so the
630 // new prefixes get a full pass. Re-processing already-moved rows is a
631 // no-op (COALESCE upsert), so the re-scan is safe. The reset lives
632 // here rather than in migrate_batch() so is_migrated() flips false at
633 // the same moment, which re-arms the legacy_get() read-through for
634 // rows the re-scan has not reached yet.
635 if ((int) $state['version'] !== self::MIGRATION_VERSION) {
636 $state['version'] = self::MIGRATION_VERSION;
637 $state['done'] = false;
638 $state['last_option_id'] = 0;
639 }
640
641 return $state;
642 }
643
644 public static function is_migrated() {
645 $state = self::migration_state();
646 return !empty($state['done']);
647 }
648
649 /** One batch per call. Safe to call on any request; no-ops once drained. */
650 public static function maybe_migrate() {
651 if (self::is_migrated()) {
652 return 0;
653 }
654
655 return self::migrate_batch(self::MIGRATION_BATCH);
656 }
657
658 /**
659 * Move one batch of legacy option rows into the sessions table.
660 *
661 * Idempotent: the upsert makes a re-run of the same rows a no-op, and the
662 * option_id bookmark only moves forward. A crash mid-batch re-processes at
663 * worst one batch.
664 *
665 * @return int Number of option rows folded in this batch.
666 */
667 public static function migrate_batch($batch = self::MIGRATION_BATCH) {
668 global $wpdb;
669
670 if (!self::ensure_table()) {
671 return 0;
672 }
673
674 $state = self::migration_state();
675 if (!empty($state['done'])) {
676 return 0;
677 }
678
679 $batch = max(1, (int) $batch);
680
681 $likes = array();
682 $params = array((int) $state['last_option_id']);
683 foreach (self::$legacy_prefixes as $prefix) {
684 $likes[] = 'option_name LIKE %s';
685 $params[] = $wpdb->esc_like($prefix) . '%';
686 }
687 $params[] = $batch;
688
689 $rows = $wpdb->get_results(
690 $wpdb->prepare(
691 "SELECT option_id, option_name, option_value
692 FROM {$wpdb->options}
693 WHERE option_id > %d AND (" . implode(' OR ', $likes) . ")
694 ORDER BY option_id ASC
695 LIMIT %d",
696 $params
697 ),
698 ARRAY_A
699 );
700
701 if (empty($rows)) {
702 $state['done'] = true;
703 update_option(self::MIGRATION_OPTION, $state, 'no');
704 return 0;
705 }
706
707 $moved = 0;
708 $sessions = array();
709 $to_delete = array();
710
711 foreach ($rows as $row) {
712 $state['last_option_id'] = max((int) $state['last_option_id'], (int) $row['option_id']);
713
714 $field = null;
715 $sid = '';
716 foreach (self::$legacy_prefixes as $candidate => $prefix) {
717 if (strpos($row['option_name'], $prefix) === 0) {
718 $field = $candidate;
719 $sid = self::sanitize(substr($row['option_name'], strlen($prefix)));
720 break;
721 }
722 }
723
724 // Junk suffix (not a valid session id) — leave the row alone rather
725 // than guess. It is not ours to delete.
726 if ($field === null || $sid === '') {
727 continue;
728 }
729
730 $value = maybe_unserialize($row['option_value']);
731
732 if (in_array($field, self::$json_fields, true)) {
733 $stored = is_null($value) ? null : wp_json_encode($value);
734 } else {
735 $stored = is_scalar($value) ? (string) $value : wp_json_encode($value);
736 }
737
738 $sessions[$sid] = true;
739 $to_delete[] = $row['option_name'];
740
741 self::upsert_raw($sid, self::$columns[$field], $stored);
742 $moved++;
743 }
744
745 // delete_option() rather than a bulk DELETE: it clears the object cache
746 // and the notoptions cache, which a raw query would leave stale.
747 foreach ($to_delete as $option_name) {
748 delete_option($option_name);
749 }
750
751 $state['moved'] = (int) $state['moved'] + $moved;
752 $state['sessions'] = (int) $state['sessions'] + count($sessions);
753
754 if (count($rows) < $batch) {
755 $state['done'] = true;
756 }
757
758 update_option(self::MIGRATION_OPTION, $state, 'no');
759 self::flush_cache();
760
761 return $moved;
762 }
763
764 /**
765 * Upsert used by the migration. Preserves the legacy row's own timestamps
766 * as far as we can — options carry none, so migrated sessions start their
767 * retention clock now rather than being swept on the first pass.
768 *
769 * COALESCE, not plain VALUES(): migration only FILLS missing values and
770 * never overwrites one the live code wrote after the update. A plain
771 * upsert let a stale legacy row clobber newer state — e.g. re-arm
772 * mode='agent' on a session the agent had already ended (plan 71e4b6).
773 * The normal set() upsert stays last-write-wins; that is correct there.
774 */
775 private static function upsert_raw($session_id, $column, $value) {
776 global $wpdb;
777
778 $table = self::table();
779 $now = current_time('mysql');
780
781 $wpdb->query(
782 $wpdb->prepare(
783 "INSERT INTO `$table` (session_id, `$column`, created_at, updated_at)
784 VALUES (%s, %s, %s, %s)
785 ON DUPLICATE KEY UPDATE `$column` = COALESCE(`$column`, VALUES(`$column`)), updated_at = VALUES(updated_at)",
786 $session_id,
787 $value,
788 $now,
789 $now
790 )
791 );
792 }
793
794 /* ---------------------------------------------------------------------
795 * Retention
796 * ------------------------------------------------------------------ */
797
798 public static function retention_days() {
799 $days = (int) apply_filters('mxchat_session_retention_days', self::DEFAULT_RETENTION_DAYS);
800
801 // 0 or negative would sweep live sessions; refuse it.
802 return $days > 0 ? $days : self::DEFAULT_RETENTION_DAYS;
803 }
804
805 /**
806 * Time-gated sweep fired from set(). The spec calls for cleanup that
807 * survives WP-Cron never running, so the write path carries it: at most
808 * one capped delete per TRIM_INTERVAL regardless of traffic.
809 */
810 public static function maybe_trim() {
811 $last = (int) get_option(self::TRIM_STAMP_OPTION, 0);
812 $now = time();
813
814 if ($last && ($now - $last) < self::TRIM_INTERVAL) {
815 return 0;
816 }
817
818 // Stamp first: a slow delete must not let concurrent writes pile up
819 // duplicate sweeps.
820 update_option(self::TRIM_STAMP_OPTION, $now, 'yes');
821
822 return self::trim(self::TRIM_BATCH);
823 }
824
825 /**
826 * Delete up to $limit sessions untouched for longer than the retention
827 * window. Capped so it can never turn a chat request into a long delete.
828 *
829 * @return int Rows deleted.
830 */
831 public static function trim($limit = self::TRIM_BATCH) {
832 global $wpdb;
833
834 if (!self::ensure_table()) {
835 return 0;
836 }
837
838 $table = self::table();
839 $limit = max(1, (int) $limit);
840 $cutoff = gmdate('Y-m-d H:i:s', time() - (self::retention_days() * DAY_IN_SECONDS));
841
842 // current_time('mysql') writes site-local time, so compare against a
843 // site-local cutoff.
844 $cutoff = get_date_from_gmt($cutoff, 'Y-m-d H:i:s');
845
846 // Identity guard (5658f2): rows holding a visitor name/email are LEADS
847 // — the Leads tab lists them and the legacy option rows they replace
848 // were never expired. Retention only sweeps anonymous session state;
849 // identity rows leave via the eraser / lead-delete / wipe paths.
850 //
851 // d0cae1: when something listens for ended sessions the swept ids must
852 // be known, so the delete goes SELECT-then-DELETE-by-id. Unhooked, the
853 // original single-statement bulk delete runs unchanged.
854 if (has_action('mxchat_session_ended')) {
855 $swept_ids = $wpdb->get_col(
856 $wpdb->prepare(
857 "SELECT session_id FROM `$table` WHERE updated_at IS NOT NULL AND updated_at < %s
858 AND (visitor_email IS NULL OR visitor_email = '')
859 AND (visitor_name IS NULL OR visitor_name = '')
860 LIMIT %d",
861 $cutoff,
862 $limit
863 )
864 );
865
866 if (empty($swept_ids)) {
867 return 0;
868 }
869
870 $placeholders = implode(',', array_fill(0, count($swept_ids), '%s'));
871 $deleted = $wpdb->query(
872 $wpdb->prepare(
873 "DELETE FROM `$table` WHERE session_id IN ($placeholders)",
874 $swept_ids
875 )
876 );
877
878 if ($deleted) {
879 self::flush_cache();
880 }
881
882 foreach ($swept_ids as $swept_id) {
883 do_action('mxchat_session_ended', $swept_id, array('reason' => 'retention'));
884 }
885
886 return (int) $deleted;
887 }
888
889 $deleted = $wpdb->query(
890 $wpdb->prepare(
891 "DELETE FROM `$table` WHERE updated_at IS NOT NULL AND updated_at < %s
892 AND (visitor_email IS NULL OR visitor_email = '')
893 AND (visitor_name IS NULL OR visitor_name = '')
894 LIMIT %d",
895 $cutoff,
896 $limit
897 )
898 );
899
900 if ($deleted) {
901 self::flush_cache();
902 }
903
904 return (int) $deleted;
905 }
906
907 /** Daily cron: drain migration faster, then sweep with a larger cap. */
908 public static function run_maintenance() {
909 for ($i = 0; $i < 10; $i++) {
910 if (self::maybe_migrate() === 0) {
911 break;
912 }
913 }
914
915 self::trim(self::TRIM_BATCH * 10);
916 }
917
918 /* ---------------------------------------------------------------------
919 * Reporting — used by the admin tools + the verification harness.
920 * ------------------------------------------------------------------ */
921
922 public static function stats() {
923 global $wpdb;
924
925 $table = self::table();
926 $legacy_likes = array();
927 $params = array();
928 foreach (self::$legacy_prefixes as $prefix) {
929 $legacy_likes[] = 'option_name LIKE %s';
930 $params[] = $wpdb->esc_like($prefix) . '%';
931 }
932
933 $legacy_remaining = (int) $wpdb->get_var(
934 $wpdb->prepare(
935 "SELECT COUNT(*) FROM {$wpdb->options} WHERE " . implode(' OR ', $legacy_likes),
936 $params
937 )
938 );
939
940 return array(
941 'sessions' => (int) $wpdb->get_var("SELECT COUNT(*) FROM `$table`"),
942 'legacy_remaining' => $legacy_remaining,
943 'migration' => self::migration_state(),
944 'retention_days' => self::retention_days(),
945 );
946 }
947 }
948