PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.25
MxChat – AI Chatbot & Content Generation for WordPress v3.2.25
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-live-agent-schedule.php

class-mxchat-live-agent-schedule.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.25, at includes/class-mxchat-live-agent-schedule.php

326 lines 12.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * File: includes/class-mxchat-live-agent-schedule.php
4 *
5 * Availability schedules for live-agent handoff (plan-mxchat-20260716-8ccaa2,
6 * split per-channel by plan-mxchat-20260718-99d7a4).
7 *
8 * Slack and Telegram each own an INDEPENDENT schedule, rendered under their own
9 * Integrations tab. They are separate, self-contained integrations in the admin
10 * nav; a site may staff them differently (e.g. Slack for the day team, Telegram
11 * for an overseas evening crew), and a Telegram-only owner must never have to
12 * open the Slack tab to find the hours control.
13 *
14 * Off-hours behaves as if that channel's handoff were never enabled: the
15 * channel's handover tool is withheld from the model in
16 * MxChat_Tool_Registry::enabled_tools() — each channel independently — so the
17 * bot never dangles a human it cannot reach, and the handover callbacks
18 * themselves re-check (defense in depth, for any path that invokes them
19 * directly).
20 *
21 * Stored in standalone options, deliberately NOT inside mxchat_options: the
22 * schedule is a nested structure, and mxchat_sanitize() REBUILDS mxchat_options
23 * on every autosave of any other field, stripping keys it has no isset-handler
24 * for (the 2c02ea bug class). Standalone options sidestep that entirely.
25 *
26 * MIGRATION: 3.2.14 pre-release builds briefly shipped ONE shared schedule in
27 * the legacy option (mxchat_live_agent_schedule). On first read, if a channel's
28 * own option is absent and the legacy one exists, BOTH channels are seeded from
29 * it — nobody who configured hours loses them. The legacy option is left in
30 * place as a fallback read (a later cleanup may remove it); it is never
31 * consulted once the per-channel options exist.
32 *
33 * DEFAULT IS OFF. An install that never touches this is byte-identical to
34 * before: enabled = false -> is_within_hours() always returns true, and
35 * availability stays governed solely by the manual status toggles.
36 *
37 * Times are evaluated in the SITE's timezone (wp_timezone()), never UTC and
38 * never the visitor's — the schedule describes when *staff* are at their desks.
39 */
40
41 if (!defined('ABSPATH')) {
42 exit;
43 }
44
45 class MxChat_Live_Agent_Schedule {
46
47 /**
48 * LEGACY shared-schedule option (pre-split 3.2.14 builds). Migration source
49 * and fallback only — never written to by current code, never read once the
50 * per-channel options exist. See the file docblock for why it isn't in
51 * mxchat_options.
52 */
53 const OPTION = 'mxchat_live_agent_schedule';
54
55 /** Per-channel standalone options (plan 99d7a4; webhook added by d88e22). */
56 const OPTION_SLACK = 'mxchat_live_agent_schedule_slack';
57 const OPTION_TELEGRAM = 'mxchat_live_agent_schedule_telegram';
58 const OPTION_WEBHOOK = 'mxchat_live_agent_schedule_webhook';
59
60 /* ------------------------------------------------------------------ *
61 * Channels
62 * ------------------------------------------------------------------ */
63
64 /** The channels that carry a schedule. Key = channel id used across the API. */
65 public static function channels() {
66 return array('slack', 'telegram', 'webhook');
67 }
68
69 /**
70 * Resolve a channel id to its option name. Unknown input maps to Slack —
71 * every real call site passes a literal, so this is a typo net, not a
72 * routing feature.
73 */
74 private static function option_name($channel) {
75 if ($channel === 'telegram') {
76 return self::OPTION_TELEGRAM;
77 }
78 if ($channel === 'webhook') {
79 return self::OPTION_WEBHOOK;
80 }
81 return self::OPTION_SLACK;
82 }
83
84 /**
85 * One-time seed of the per-channel options from the legacy shared option.
86 * Idempotent and cheap: writes only when a channel option is ABSENT and the
87 * legacy option holds an array. Runs lazily on first read rather than on an
88 * upgrade hook — upgrade hooks demonstrably don't fire on every install
89 * (DISABLE_WP_CRON sites, plan bc08a6), a read always does.
90 */
91 private static function maybe_migrate() {
92 static $done = false;
93 if ($done) {
94 return;
95 }
96 $done = true;
97
98 $legacy = get_option(self::OPTION, null);
99 if (!is_array($legacy)) {
100 return;
101 }
102 $seed = self::normalize($legacy);
103 // Webhook (d88e22) is deliberately NOT seeded: the legacy shared option
104 // predates the webhook channel, so it never expressed intent about it.
105 foreach (array(self::OPTION_SLACK, self::OPTION_TELEGRAM) as $opt) {
106 if (!is_array(get_option($opt, null))) {
107 update_option($opt, $seed);
108 }
109 }
110 // Legacy option intentionally NOT deleted — fallback for a downgrade,
111 // removable in a later cleanup release.
112 }
113
114 /* ------------------------------------------------------------------ *
115 * State
116 * ------------------------------------------------------------------ */
117
118 /**
119 * Shape of a never-configured schedule: disabled, with a sane Mon-Fri 9-5
120 * pre-fill so the editor opens on something meaningful rather than blank.
121 * The pre-fill is inert while `enabled` is false.
122 *
123 * Days are keyed 1 (Monday) .. 7 (Sunday) — PHP's ISO-8601 'N', so the key
124 * IS the value date('N') returns. No lookup table to drift.
125 */
126 public static function defaults() {
127 $days = array();
128 foreach (range(1, 7) as $n) {
129 $days[$n] = array(
130 'enabled' => ($n <= 5), // Mon-Fri pre-filled, weekend off
131 'start' => '09:00',
132 'end' => '17:00',
133 );
134 }
135 return array(
136 'enabled' => false,
137 'days' => $days,
138 );
139 }
140
141 /** One channel's saved schedule, normalized. Always safe to index. */
142 public static function get($channel) {
143 self::maybe_migrate();
144 $saved = get_option(self::option_name($channel), null);
145 if (!is_array($saved)) {
146 return self::defaults();
147 }
148 return self::normalize($saved);
149 }
150
151 /** Is the schedule feature switched on for this channel? */
152 public static function is_enabled($channel) {
153 $s = self::get($channel);
154 return !empty($s['enabled']);
155 }
156
157 /* ------------------------------------------------------------------ *
158 * Evaluation
159 * ------------------------------------------------------------------ */
160
161 /**
162 * Is this channel inside a configured on-hours window right now?
163 *
164 * Returns TRUE when the channel's schedule is disabled — "no schedule" must
165 * never mean "never available", or switching the feature off would strand
166 * every install that had it on.
167 *
168 * @param string $channel 'slack' or 'telegram'.
169 * @param int|null $now_ts Unix timestamp to evaluate (defaults to now).
170 * Injectable so the window logic is testable
171 * without waiting for Tuesday.
172 */
173 public static function is_within_hours($channel, $now_ts = null) {
174 $sched = self::get($channel);
175 if (empty($sched['enabled'])) {
176 return true;
177 }
178
179 $tz = function_exists('wp_timezone') ? wp_timezone() : new DateTimeZone('UTC');
180 $now = new DateTimeImmutable('@' . ($now_ts === null ? time() : (int) $now_ts));
181 $now = $now->setTimezone($tz);
182
183 $today = (int) $now->format('N'); // 1 (Mon) .. 7 (Sun)
184 $yesterday = ($today === 1) ? 7 : $today - 1;
185 $minutes = ((int) $now->format('G') * 60) + (int) $now->format('i');
186
187 // Today's own window, plus any window that STARTED yesterday and wrapped
188 // past midnight into today (an evening shift covers 00:30 the next day).
189 if (self::day_covers($sched['days'][$today], $minutes, false)) {
190 return true;
191 }
192 if (self::day_covers($sched['days'][$yesterday], $minutes, true)) {
193 return true;
194 }
195 return false;
196 }
197
198 /**
199 * Does one day's window cover $minutes past midnight?
200 *
201 * @param bool $from_yesterday Evaluating this day as YESTERDAY's entry, i.e.
202 * asking only "did its window wrap into today".
203 */
204 private static function day_covers($day, $minutes, $from_yesterday) {
205 if (empty($day) || empty($day['enabled'])) {
206 return false;
207 }
208 $start = self::to_minutes($day['start']);
209 $end = self::to_minutes($day['end']);
210 if ($start === null || $end === null) {
211 return false;
212 }
213
214 if ($start === $end) {
215 // Equal start/end = 24 hours (e.g. 00:00-00:00 on an always-staffed
216 // day). It covers its own day fully and cannot wrap into the next.
217 return !$from_yesterday;
218 }
219
220 if ($end > $start) {
221 // Normal same-day window. Cannot spill into the following day.
222 return !$from_yesterday && $minutes >= $start && $minutes < $end;
223 }
224
225 // Wraps past midnight (e.g. 22:00 -> 02:00): the tail belongs to the
226 // NEXT calendar day, which is why yesterday's entry is consulted at all.
227 return $from_yesterday ? ($minutes < $end) : ($minutes >= $start);
228 }
229
230 /** 'HH:MM' -> minutes past midnight, or null if unparseable. */
231 private static function to_minutes($hhmm) {
232 if (!is_string($hhmm) || !preg_match('/^(\d{1,2}):(\d{2})$/', trim($hhmm), $m)) {
233 return null;
234 }
235 $h = (int) $m[1];
236 $i = (int) $m[2];
237 if ($h > 23 || $i > 59) {
238 return null;
239 }
240 return ($h * 60) + $i;
241 }
242
243 /* ------------------------------------------------------------------ *
244 * Save path
245 * ------------------------------------------------------------------ */
246
247 /** Coerce any input into the canonical shape. Unknown/garbage -> defaults. */
248 public static function normalize($input) {
249 $defaults = self::defaults();
250 if (!is_array($input)) {
251 return $defaults;
252 }
253
254 $out = array(
255 'enabled' => !empty($input['enabled']),
256 'days' => array(),
257 );
258
259 $days = (isset($input['days']) && is_array($input['days'])) ? $input['days'] : array();
260 foreach (range(1, 7) as $n) {
261 $src = isset($days[$n]) && is_array($days[$n]) ? $days[$n] : array();
262 $start = isset($src['start']) ? self::clean_time($src['start']) : null;
263 $end = isset($src['end']) ? self::clean_time($src['end']) : null;
264 $out['days'][$n] = array(
265 'enabled' => !empty($src['enabled']),
266 // An unparseable time falls back to the default rather than
267 // disabling the day silently — a typo shouldn't drop coverage
268 // without saying so.
269 'start' => $start !== null ? $start : $defaults['days'][$n]['start'],
270 'end' => $end !== null ? $end : $defaults['days'][$n]['end'],
271 );
272 }
273 return $out;
274 }
275
276 /** Normalize a time string to zero-padded 'HH:MM', or null if invalid. */
277 private static function clean_time($hhmm) {
278 $mins = self::to_minutes($hhmm);
279 if ($mins === null) {
280 return null;
281 }
282 return sprintf('%02d:%02d', intdiv($mins, 60), $mins % 60);
283 }
284
285 /** Persist one channel's schedule. Returns the normalized array written. */
286 public static function save($channel, $input) {
287 $clean = self::normalize($input);
288 update_option(self::option_name($channel), $clean);
289 return $clean;
290 }
291
292 /* ------------------------------------------------------------------ *
293 * Presentation helpers
294 * ------------------------------------------------------------------ */
295
296 /** Day labels, keyed to match defaults() (1 = Monday). Locale-aware. */
297 public static function day_labels() {
298 global $wp_locale;
299 // $wp_locale->get_weekday() is 0-indexed on SUNDAY, ours is ISO (1=Mon).
300 $map = array(1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6, 7 => 0);
301 $out = array();
302 foreach ($map as $iso => $wp_index) {
303 $out[$iso] = ($wp_locale && method_exists($wp_locale, 'get_weekday'))
304 ? $wp_locale->get_weekday($wp_index)
305 : $iso;
306 }
307 return $out;
308 }
309
310 /**
311 * Human label for the site timezone, so the admin never has to guess which
312 * clock the schedule runs on (Maxwell's explicit ask on approval).
313 */
314 public static function timezone_label() {
315 $tz_string = get_option('timezone_string');
316 if (!empty($tz_string)) {
317 return $tz_string;
318 }
319 $offset = (float) get_option('gmt_offset', 0);
320 if ($offset == 0) {
321 return 'UTC';
322 }
323 return 'UTC' . ($offset > 0 ? '+' : '') . rtrim(rtrim(number_format($offset, 2, '.', ''), '0'), '.');
324 }
325 }
326