| 1 |
<?php |
| 2 |
/** |
| 3 |
* MxChat Licence Check — asks mxchat.ai whether this site's domain is still |
| 4 |
* switched on for its licence key, and honours an explicit "off" from the |
| 5 |
* licence holder's My Account after a three-day grace period. |
| 6 |
* |
| 7 |
* Plan: plan-mxchat-20261007-c93ede (Support Ticket 467). An Agency licence |
| 8 |
* holder who resells to clients could see the domains activated on a key but |
| 9 |
* had no way to switch one off, and even a server-side unlink left the old |
| 10 |
* install running Pro: `mxchat_license_status` is a local option written at |
| 11 |
* activation and never re-checked. |
| 12 |
* |
| 13 |
* The rules, from Maxwell's approval note (they override the original spec): |
| 14 |
* |
| 15 |
* 1. Turning back on is immediate. A site that is off (or in its grace |
| 16 |
* period) re-checks when an admin opens any MxChat screen, at most once a |
| 17 |
* minute; on the "Check license now" button; and hourly in the background. |
| 18 |
* Activating from the site's own Activate button stays instant as before. |
| 19 |
* 2. A site that is on checks once a day, at a random time per site. |
| 20 |
* 3. ONLY AN EXPLICIT "OFF" COUNTS. A timeout, a 5xx, a non-JSON body, a DNS |
| 21 |
* failure or an mxchat.ai outage never switches a site off. Today's |
| 22 |
* production endpoint does not know this route yet and answers |
| 23 |
* {"success":false,"data":"Invalid action"} — that is an error here too, |
| 24 |
* so a core shipped ahead of the website change keeps every site on. |
| 25 |
* 4. A site move is not "off". A valid key on a domain mxchat.ai does not |
| 26 |
* recognise ("unknown") stays on. |
| 27 |
* 5. Grace period: after the first explicit off the admin sees a notice and |
| 28 |
* Pro is switched off only when an off answer arrives three days after the |
| 29 |
* first one with no on/unknown in between. Nothing else is touched: |
| 30 |
* settings, knowledge, the saved key and email, and the free features all |
| 31 |
* stay exactly as they are, so switching back on restores Pro in place. |
| 32 |
* |
| 33 |
* What is sent to mxchat.ai: the licence key, the account email, the site's |
| 34 |
* host name and the plugin version (the same three identifiers the existing |
| 35 |
* activate / deactivate / check-domain calls already send, plus the version). |
| 36 |
* The readme's "External services" section documents all four calls. |
| 37 |
*/ |
| 38 |
|
| 39 |
if (!defined('ABSPATH')) { |
| 40 |
exit; |
| 41 |
} |
| 42 |
|
| 43 |
class MxChat_License_Check { |
| 44 |
|
| 45 |
const ENDPOINT = 'https://mxchat.ai/mxchat-api/license-status'; |
| 46 |
const OPTION = 'mxchat_license_check'; |
| 47 |
const CRON_HOOK = 'mxchat_license_check'; |
| 48 |
const NONCE_CHECK = 'mxchat_license_check_now'; |
| 49 |
const NONCE_DISMISS = 'mxchat_license_notice_dismiss'; |
| 50 |
|
| 51 |
const GRACE_SECONDS = 3 * DAY_IN_SECONDS; |
| 52 |
const DAILY_SECONDS = DAY_IN_SECONDS; |
| 53 |
const HOURLY_SECONDS = HOUR_IN_SECONDS; |
| 54 |
const RETRY_SECONDS = 6 * HOUR_IN_SECONDS; // after an unanswered check on a site that is on |
| 55 |
const ADMIN_SECONDS = MINUTE_IN_SECONDS; // admin-screen re-check while off / in grace |
| 56 |
const BUTTON_SECONDS = 10; // "Check license now" rate limit |
| 57 |
const DISMISS_SECONDS = DAY_IN_SECONDS; // a dismissed notice comes back after a day |
| 58 |
const TIMEOUT_CRON = 15; |
| 59 |
const TIMEOUT_ADMIN = 8; |
| 60 |
|
| 61 |
/** |
| 62 |
* The refusals mxchat.ai's activation gate answers with, in the WooCommerce |
| 63 |
* Software add-on's own error shape (plan c93ede rework): each one is the |
| 64 |
* licence owner's (or an administrator's) explicit decision, never a slot |
| 65 |
* count or a transport problem. |
| 66 |
*/ |
| 67 |
public static function owner_reasons() { |
| 68 |
return array( |
| 69 |
'106' => 'switched_off', |
| 70 |
'107' => 'new_sites_blocked', |
| 71 |
'108' => 'revoked', |
| 72 |
); |
| 73 |
} |
| 74 |
|
| 75 |
public static function is_owner_reason($reason) { |
| 76 |
return in_array((string) $reason, array('switched_off', 'new_sites_blocked', 'revoked'), true); |
| 77 |
} |
| 78 |
|
| 79 |
public static function init() { |
| 80 |
add_action(self::CRON_HOOK, array(__CLASS__, 'cron_check')); |
| 81 |
|
| 82 |
if (is_admin()) { |
| 83 |
add_action('admin_init', array(__CLASS__, 'admin_screen_check'), 5); |
| 84 |
add_action('admin_notices', array(__CLASS__, 'render_notice')); |
| 85 |
add_action('admin_enqueue_scripts', array(__CLASS__, 'enqueue_assets')); |
| 86 |
add_action('wp_ajax_mxchat_license_check_now', array(__CLASS__, 'ajax_check_now')); |
| 87 |
add_action('wp_ajax_mxchat_license_notice_dismiss', array(__CLASS__, 'ajax_dismiss_notice')); |
| 88 |
} |
| 89 |
|
| 90 |
self::ensure_scheduled(); |
| 91 |
} |
| 92 |
|
| 93 |
/* --------------------------------------------------------------------- |
| 94 |
* State |
| 95 |
* ------------------------------------------------------------------ */ |
| 96 |
|
| 97 |
public static function defaults() { |
| 98 |
return array( |
| 99 |
'last_check' => 0, // unix time of the last answer of any kind |
| 100 |
'last_result' => '', // on | off | unknown | error | '' |
| 101 |
'last_reason' => '', // the server's reason, or the transport / http / malformed code |
| 102 |
'last_source' => '', // cron | admin | button |
| 103 |
'off_since' => 0, // first explicit off of the current streak (0 = not off) |
| 104 |
'off_count' => 0, |
| 105 |
'revoked_at' => 0, // when this class set mxchat_license_status to inactive (0 = it did not) |
| 106 |
'restored_at' => 0, // set when Pro was switched back on by a check; cleared once the notice has shown |
| 107 |
'next_check' => 0, // unix time the next background check is due |
| 108 |
'dismissed_at' => 0, // the grace / off notice was dismissed at this time |
| 109 |
'refusal' => '', // the sentence mxchat.ai answered the last refused Activate click with |
| 110 |
); |
| 111 |
} |
| 112 |
|
| 113 |
public static function state() { |
| 114 |
$saved = get_option(self::OPTION, array()); |
| 115 |
return is_array($saved) ? array_merge(self::defaults(), $saved) : self::defaults(); |
| 116 |
} |
| 117 |
|
| 118 |
private static function save_state(array $state) { |
| 119 |
update_option(self::OPTION, $state, false); |
| 120 |
} |
| 121 |
|
| 122 |
/** The saved key, email and this site's host name; null when the site has no activation to check. */ |
| 123 |
public static function credentials() { |
| 124 |
$key = trim((string) get_option('mxchat_activation_key', '')); |
| 125 |
$email = trim((string) get_option('mxchat_pro_email', '')); |
| 126 |
if ($key === '' || $email === '') { |
| 127 |
return null; |
| 128 |
} |
| 129 |
return array('key' => $key, 'email' => $email, 'domain' => self::domain()); |
| 130 |
} |
| 131 |
|
| 132 |
/** Exactly what activation sent as the domain, so the server-side match is the same string. */ |
| 133 |
public static function domain() { |
| 134 |
$host = parse_url(home_url(), PHP_URL_HOST); |
| 135 |
return is_string($host) ? $host : ''; |
| 136 |
} |
| 137 |
|
| 138 |
public static function is_revoked() { |
| 139 |
$state = self::state(); |
| 140 |
return !empty($state['revoked_at']); |
| 141 |
} |
| 142 |
|
| 143 |
public static function in_grace() { |
| 144 |
$state = self::state(); |
| 145 |
return !empty($state['off_since']) && empty($state['revoked_at']); |
| 146 |
} |
| 147 |
|
| 148 |
/** |
| 149 |
* Clear the state. Called when the site activates (fresh start, first daily |
| 150 |
* check at a random time) or deactivates its licence (nothing left to check). |
| 151 |
*/ |
| 152 |
public static function reset($reschedule) { |
| 153 |
delete_option(self::OPTION); |
| 154 |
wp_clear_scheduled_hook(self::CRON_HOOK); |
| 155 |
if ($reschedule) { |
| 156 |
self::ensure_scheduled(); |
| 157 |
} |
| 158 |
} |
| 159 |
|
| 160 |
/* --------------------------------------------------------------------- |
| 161 |
* Scheduling |
| 162 |
* ------------------------------------------------------------------ */ |
| 163 |
|
| 164 |
/** |
| 165 |
* Make sure a background check is on the calendar. The first one lands at |
| 166 |
* a random moment within the next day and every later one is set from the |
| 167 |
* previous answer, so 850 sites spread their requests across the day. |
| 168 |
*/ |
| 169 |
public static function ensure_scheduled() { |
| 170 |
if (!self::credentials()) { |
| 171 |
return; |
| 172 |
} |
| 173 |
$state = self::state(); |
| 174 |
$now = time(); |
| 175 |
if (empty($state['next_check'])) { |
| 176 |
$state['next_check'] = $now + wp_rand(MINUTE_IN_SECONDS, DAY_IN_SECONDS); |
| 177 |
self::save_state($state); |
| 178 |
} |
| 179 |
if (false === wp_next_scheduled(self::CRON_HOOK)) { |
| 180 |
wp_schedule_single_event(max($now + MINUTE_IN_SECONDS, (int) $state['next_check']), self::CRON_HOOK); |
| 181 |
} |
| 182 |
} |
| 183 |
|
| 184 |
private static function reschedule($timestamp) { |
| 185 |
wp_clear_scheduled_hook(self::CRON_HOOK); |
| 186 |
wp_schedule_single_event(max(time() + MINUTE_IN_SECONDS, (int) $timestamp), self::CRON_HOOK); |
| 187 |
} |
| 188 |
|
| 189 |
/** |
| 190 |
* Is a check due? $context is 'cron' (background) or 'admin' (an admin |
| 191 |
* opened a MxChat screen). While the domain is off or in its grace period |
| 192 |
* the gap is an hour in the background and a minute on an admin screen; |
| 193 |
* otherwise the daily timetable applies. |
| 194 |
*/ |
| 195 |
public static function is_due($now, $context = 'cron') { |
| 196 |
if (!self::credentials()) { |
| 197 |
return false; |
| 198 |
} |
| 199 |
$state = self::state(); |
| 200 |
$status = get_option('mxchat_license_status', 'inactive'); |
| 201 |
if ($status !== 'active' && empty($state['revoked_at'])) { |
| 202 |
return false; // never activated, or deactivated by hand: nothing to check |
| 203 |
} |
| 204 |
if (!empty($state['off_since']) || !empty($state['revoked_at'])) { |
| 205 |
$gap = ($context === 'admin') ? self::ADMIN_SECONDS : self::HOURLY_SECONDS; |
| 206 |
return ($now - (int) $state['last_check']) >= $gap; |
| 207 |
} |
| 208 |
return !empty($state['next_check']) && $now >= (int) $state['next_check']; |
| 209 |
} |
| 210 |
|
| 211 |
public static function cron_check() { |
| 212 |
if (self::is_due(time(), 'cron')) { |
| 213 |
self::run_check('cron'); |
| 214 |
return; |
| 215 |
} |
| 216 |
$state = self::state(); |
| 217 |
if (!empty($state['next_check'])) { |
| 218 |
self::reschedule((int) $state['next_check']); |
| 219 |
} |
| 220 |
} |
| 221 |
|
| 222 |
public static function admin_screen_check() { |
| 223 |
if (wp_doing_ajax() || wp_doing_cron() || !current_user_can('manage_options')) { |
| 224 |
return; |
| 225 |
} |
| 226 |
$page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; |
| 227 |
if ($page === '' || strpos($page, 'mxchat') !== 0) { |
| 228 |
return; |
| 229 |
} |
| 230 |
if (self::is_due(time(), 'admin')) { |
| 231 |
self::run_check('admin'); |
| 232 |
} |
| 233 |
} |
| 234 |
|
| 235 |
/* --------------------------------------------------------------------- |
| 236 |
* The check |
| 237 |
* ------------------------------------------------------------------ */ |
| 238 |
|
| 239 |
/** |
| 240 |
* One request to mxchat.ai and the state change it implies. Returns the |
| 241 |
* interpreted answer plus the resulting Pro status, or null when the site |
| 242 |
* has nothing to check. |
| 243 |
*/ |
| 244 |
public static function run_check($source = 'cron') { |
| 245 |
$cred = self::credentials(); |
| 246 |
if (!$cred) { |
| 247 |
return null; |
| 248 |
} |
| 249 |
$response = wp_remote_post(self::ENDPOINT, array( |
| 250 |
'timeout' => ($source === 'cron') ? self::TIMEOUT_CRON : self::TIMEOUT_ADMIN, |
| 251 |
'sslverify' => true, |
| 252 |
'body' => array( |
| 253 |
'license_key' => $cred['key'], |
| 254 |
'email' => $cred['email'], |
| 255 |
'domain' => $cred['domain'], |
| 256 |
'plugin_version' => defined('MXCHAT_BASE_VERSION') ? MXCHAT_BASE_VERSION : '', |
| 257 |
), |
| 258 |
)); |
| 259 |
return self::apply(self::interpret($response), $source, time()); |
| 260 |
} |
| 261 |
|
| 262 |
/** |
| 263 |
* Turn an HTTP response into one of four words. Anything that is not a |
| 264 |
* well-formed answer is 'error' and changes nothing (rule 3). |
| 265 |
* |
| 266 |
* Expected body: {"success":true,"data":{"state":"on|off|unknown","reason":"…"}} |
| 267 |
*/ |
| 268 |
public static function interpret($response) { |
| 269 |
if (is_wp_error($response)) { |
| 270 |
return array('state' => 'error', 'reason' => 'transport_' . sanitize_key($response->get_error_code())); |
| 271 |
} |
| 272 |
$code = (int) wp_remote_retrieve_response_code($response); |
| 273 |
if ($code !== 200) { |
| 274 |
return array('state' => 'error', 'reason' => 'http_' . $code); |
| 275 |
} |
| 276 |
$body = json_decode((string) wp_remote_retrieve_body($response), true); |
| 277 |
if (!is_array($body) || empty($body['success']) || !isset($body['data']) || !is_array($body['data']) || !isset($body['data']['state'])) { |
| 278 |
return array('state' => 'error', 'reason' => 'malformed'); |
| 279 |
} |
| 280 |
$state = strtolower(trim((string) $body['data']['state'])); |
| 281 |
$reason = isset($body['data']['reason']) ? sanitize_key((string) $body['data']['reason']) : ''; |
| 282 |
if ($state === 'on' || $state === 'off') { |
| 283 |
return array('state' => $state, 'reason' => $reason); |
| 284 |
} |
| 285 |
return array('state' => 'unknown', 'reason' => ($reason !== '') ? $reason : 'state_' . sanitize_key($state)); |
| 286 |
} |
| 287 |
|
| 288 |
/** |
| 289 |
* Apply an interpreted answer at time $now. Public so the verification rig |
| 290 |
* can drive the state machine without a network; the plugin only calls it |
| 291 |
* from run_check(). |
| 292 |
*/ |
| 293 |
public static function apply(array $result, $source, $now) { |
| 294 |
$state = self::state(); |
| 295 |
$status = get_option('mxchat_license_status', 'inactive'); |
| 296 |
|
| 297 |
$state['last_check'] = (int) $now; |
| 298 |
$state['last_result'] = $result['state']; |
| 299 |
$state['last_reason'] = isset($result['reason']) ? (string) $result['reason'] : ''; |
| 300 |
$state['last_source'] = (string) $source; |
| 301 |
|
| 302 |
switch ($result['state']) { |
| 303 |
case 'off': |
| 304 |
if (empty($state['off_since'])) { |
| 305 |
$state['off_since'] = (int) $now; |
| 306 |
$state['off_count'] = 1; |
| 307 |
$state['dismissed_at'] = 0; // a new streak brings the notice back |
| 308 |
} else { |
| 309 |
$state['off_count'] = (int) $state['off_count'] + 1; |
| 310 |
} |
| 311 |
if (empty($state['revoked_at']) |
| 312 |
&& ($now - (int) $state['off_since']) >= self::GRACE_SECONDS |
| 313 |
&& $status === 'active') { |
| 314 |
// Three days of off and still off: pause Pro. Only this one option moves. |
| 315 |
update_option('mxchat_license_status', 'inactive'); |
| 316 |
$state['revoked_at'] = (int) $now; |
| 317 |
$state['dismissed_at'] = 0; |
| 318 |
} |
| 319 |
$state['next_check'] = $now + self::HOURLY_SECONDS; |
| 320 |
break; |
| 321 |
|
| 322 |
case 'on': |
| 323 |
case 'unknown': |
| 324 |
if (!empty($state['revoked_at'])) { |
| 325 |
update_option('mxchat_license_status', 'active'); |
| 326 |
$state['restored_at'] = (int) $now; |
| 327 |
} |
| 328 |
$state['off_since'] = 0; |
| 329 |
$state['off_count'] = 0; |
| 330 |
$state['revoked_at'] = 0; |
| 331 |
$state['dismissed_at'] = 0; |
| 332 |
$state['next_check'] = $now + self::DAILY_SECONDS - 1800 + wp_rand(0, 3600); |
| 333 |
break; |
| 334 |
|
| 335 |
default: // error: fail open, keep whatever streak there is |
| 336 |
$state['next_check'] = (!empty($state['off_since']) || !empty($state['revoked_at'])) |
| 337 |
? $now + self::HOURLY_SECONDS |
| 338 |
: $now + self::RETRY_SECONDS; |
| 339 |
break; |
| 340 |
} |
| 341 |
|
| 342 |
self::save_state($state); |
| 343 |
self::reschedule((int) $state['next_check']); |
| 344 |
|
| 345 |
$result['pro'] = get_option('mxchat_license_status', 'inactive'); |
| 346 |
return $result; |
| 347 |
} |
| 348 |
|
| 349 |
/** |
| 350 |
* The Activate button was refused by mxchat.ai's gate (plan c93ede rework): |
| 351 |
* the licence owner switched this domain off, blocked new sites on the key, |
| 352 |
* or the key was revoked. The activation handler has already set Pro |
| 353 |
* inactive. When Pro was on until this click, treat the refusal as the |
| 354 |
* owner's explicit off (so the hourly check keeps running and the owner's |
| 355 |
* switch-on restores Pro); when the site was never on, just remember the |
| 356 |
* sentence for the Pro page. The add-on's own errors (invalid key, no |
| 357 |
* slots, incomplete order) are not the owner's doing and change nothing here. |
| 358 |
*/ |
| 359 |
public static function note_activation_refusal($code, $message, $was_active) { |
| 360 |
$reasons = self::owner_reasons(); |
| 361 |
$code = (string) $code; |
| 362 |
if (!isset($reasons[$code])) { |
| 363 |
return null; |
| 364 |
} |
| 365 |
$now = time(); |
| 366 |
$state = self::state(); |
| 367 |
$state['last_check'] = $now; |
| 368 |
$state['last_result'] = 'off'; |
| 369 |
$state['last_reason'] = $reasons[$code]; |
| 370 |
$state['last_source'] = 'activate'; |
| 371 |
$state['refusal'] = sanitize_text_field((string) $message); |
| 372 |
if ($was_active) { |
| 373 |
if (empty($state['off_since'])) { |
| 374 |
$state['off_since'] = $now; |
| 375 |
$state['off_count'] = 1; |
| 376 |
} else { |
| 377 |
$state['off_count'] = (int) $state['off_count'] + 1; |
| 378 |
} |
| 379 |
$state['revoked_at'] = $now; |
| 380 |
$state['dismissed_at'] = 0; |
| 381 |
$state['next_check'] = $now + self::HOURLY_SECONDS; |
| 382 |
self::save_state($state); |
| 383 |
self::reschedule((int) $state['next_check']); |
| 384 |
return $reasons[$code]; |
| 385 |
} |
| 386 |
self::save_state($state); |
| 387 |
return $reasons[$code]; |
| 388 |
} |
| 389 |
|
| 390 |
/** |
| 391 |
* The owner's refusal to show under the activation form while Pro is off and |
| 392 |
* no check is running (a site that was never on, or was deactivated by hand). |
| 393 |
*/ |
| 394 |
public static function last_refusal() { |
| 395 |
$state = self::state(); |
| 396 |
if ($state['last_result'] !== 'off' || !self::is_owner_reason($state['last_reason']) || $state['refusal'] === '') { |
| 397 |
return ''; |
| 398 |
} |
| 399 |
return (string) $state['refusal']; |
| 400 |
} |
| 401 |
|
| 402 |
/* --------------------------------------------------------------------- |
| 403 |
* AJAX |
| 404 |
* ------------------------------------------------------------------ */ |
| 405 |
|
| 406 |
public static function ajax_check_now() { |
| 407 |
if (!current_user_can('manage_options') || !check_ajax_referer(self::NONCE_CHECK, 'nonce', false)) { |
| 408 |
wp_send_json_error(array('message' => esc_html__('Unauthorized', 'mxchat')), 403); |
| 409 |
} |
| 410 |
if (!self::credentials()) { |
| 411 |
wp_send_json_error(array('message' => esc_html__('No licence key is saved on this site.', 'mxchat'))); |
| 412 |
} |
| 413 |
$before = get_option('mxchat_license_status', 'inactive'); |
| 414 |
$state = self::state(); |
| 415 |
$throttled = false; |
| 416 |
if ($state['last_result'] !== '' && (time() - (int) $state['last_check']) < self::BUTTON_SECONDS) { |
| 417 |
$throttled = true; |
| 418 |
$result = array('state' => $state['last_result'], 'reason' => $state['last_reason']); |
| 419 |
} else { |
| 420 |
$result = self::run_check('button'); |
| 421 |
} |
| 422 |
$after = get_option('mxchat_license_status', 'inactive'); |
| 423 |
$view = self::view_model(); |
| 424 |
wp_send_json_success(array( |
| 425 |
'state' => $result['state'], |
| 426 |
'reason' => $result['reason'], |
| 427 |
'pro' => $after, |
| 428 |
'reload' => ($before !== $after), |
| 429 |
'throttled' => $throttled, |
| 430 |
'pill' => $view['pill_text'], |
| 431 |
'pillClass' => $view['pill_class'], |
| 432 |
'message' => $view['sentence'], |
| 433 |
)); |
| 434 |
} |
| 435 |
|
| 436 |
public static function ajax_dismiss_notice() { |
| 437 |
if (!current_user_can('manage_options') || !check_ajax_referer(self::NONCE_DISMISS, 'nonce', false)) { |
| 438 |
wp_send_json_error(); |
| 439 |
} |
| 440 |
$state = self::state(); |
| 441 |
$state['dismissed_at'] = time(); |
| 442 |
self::save_state($state); |
| 443 |
wp_send_json_success(); |
| 444 |
} |
| 445 |
|
| 446 |
/* --------------------------------------------------------------------- |
| 447 |
* Presentation |
| 448 |
* ------------------------------------------------------------------ */ |
| 449 |
|
| 450 |
/** The pill and the sentence the Pro page and the AJAX reply both show. */ |
| 451 |
public static function view_model() { |
| 452 |
$state = self::state(); |
| 453 |
$status = get_option('mxchat_license_status', 'inactive'); |
| 454 |
$now = time(); |
| 455 |
|
| 456 |
if (!empty($state['revoked_at'])) { |
| 457 |
$since = wp_date(get_option('date_format'), (int) $state['revoked_at']); |
| 458 |
if ($state['last_reason'] === 'revoked') { |
| 459 |
$sentence = sprintf( |
| 460 |
/* translators: %s: a date */ |
| 461 |
__('Managed by the licence owner. This licence key has been revoked, and Pro features have been paused since %s; your settings and knowledge are untouched. Contact MxChat support if you believe this is a mistake.', 'mxchat'), |
| 462 |
$since |
| 463 |
); |
| 464 |
} elseif ($state['last_reason'] === 'new_sites_blocked') { |
| 465 |
$sentence = sprintf( |
| 466 |
/* translators: 1: this site's domain, 2: a date */ |
| 467 |
__('Managed by the licence owner. New sites are blocked on this licence, so %1$s could not activate; Pro features have been paused since %2$s. Ask the licence holder to allow new sites in their mxchat.ai account, then click Check license now.', 'mxchat'), |
| 468 |
self::domain(), |
| 469 |
$since |
| 470 |
); |
| 471 |
} else { |
| 472 |
$sentence = sprintf( |
| 473 |
/* translators: 1: this site's domain, 2: a date */ |
| 474 |
__('Managed by the licence owner. %1$s was switched off in the licence holder\'s MxChat account, and only they can switch it back on; this site cannot. Pro features have been paused since %2$s; your settings and knowledge are untouched. Once the domain is switched back on at mxchat.ai they return within the hour, or now with Check license now.', 'mxchat'), |
| 475 |
self::domain(), |
| 476 |
$since |
| 477 |
); |
| 478 |
} |
| 479 |
return array( |
| 480 |
'pill_class' => 'mxch-status-pill-disabled', |
| 481 |
'pill_text' => ($state['last_reason'] === 'revoked') ? __('Revoked', 'mxchat') : __('Switched off', 'mxchat'), |
| 482 |
'sentence' => $sentence, |
| 483 |
); |
| 484 |
} |
| 485 |
|
| 486 |
if (!empty($state['off_since'])) { |
| 487 |
return array( |
| 488 |
'pill_class' => 'mxch-status-pill-warning', |
| 489 |
'pill_text' => __('Switching off', 'mxchat'), |
| 490 |
'sentence' => sprintf( |
| 491 |
/* translators: 1: this site's domain, 2: a date, 3: a date */ |
| 492 |
__('Managed by the licence owner. %1$s was switched off in the licence holder\'s MxChat account on %2$s, and only they can switch it back on. Pro features stay on until %3$s; if the domain is switched back on before then, nothing changes.', 'mxchat'), |
| 493 |
self::domain(), |
| 494 |
wp_date(get_option('date_format'), (int) $state['off_since']), |
| 495 |
wp_date(get_option('date_format'), (int) $state['off_since'] + self::GRACE_SECONDS) |
| 496 |
), |
| 497 |
); |
| 498 |
} |
| 499 |
|
| 500 |
if (empty($state['last_check'])) { |
| 501 |
return array( |
| 502 |
'pill_class' => ($status === 'active') ? 'mxch-status-pill-active' : 'mxch-status-pill-disabled', |
| 503 |
'pill_text' => __('Not checked yet', 'mxchat'), |
| 504 |
'sentence' => __('The first check with mxchat.ai runs within a day.', 'mxchat'), |
| 505 |
); |
| 506 |
} |
| 507 |
|
| 508 |
$ago = human_time_diff((int) $state['last_check'], $now); |
| 509 |
if ($state['last_result'] === 'error') { |
| 510 |
return array( |
| 511 |
'pill_class' => ($status === 'active') ? 'mxch-status-pill-active' : 'mxch-status-pill-disabled', |
| 512 |
'pill_text' => ($status === 'active') ? __('Active', 'mxchat') : __('Inactive', 'mxchat'), |
| 513 |
'sentence' => sprintf( |
| 514 |
/* translators: 1: how long ago, 2: the error code */ |
| 515 |
__('mxchat.ai could not be reached %1$s ago (%2$s). Nothing changed; the check is retried later.', 'mxchat'), |
| 516 |
$ago, |
| 517 |
$state['last_reason'] |
| 518 |
), |
| 519 |
); |
| 520 |
} |
| 521 |
if ($state['last_result'] === 'unknown') { |
| 522 |
return array( |
| 523 |
'pill_class' => 'mxch-status-pill-active', |
| 524 |
'pill_text' => __('Active', 'mxchat'), |
| 525 |
'sentence' => sprintf( |
| 526 |
/* translators: %s: how long ago */ |
| 527 |
__('Checked %s ago. mxchat.ai does not list this domain for the licence, which is normal after a site move; Pro stays on.', 'mxchat'), |
| 528 |
$ago |
| 529 |
), |
| 530 |
); |
| 531 |
} |
| 532 |
return array( |
| 533 |
'pill_class' => 'mxch-status-pill-active', |
| 534 |
'pill_text' => __('Active', 'mxchat'), |
| 535 |
'sentence' => sprintf( |
| 536 |
/* translators: %s: how long ago */ |
| 537 |
__('Confirmed on by mxchat.ai %s ago. Checked once a day.', 'mxchat'), |
| 538 |
$ago |
| 539 |
), |
| 540 |
); |
| 541 |
} |
| 542 |
|
| 543 |
/** True when the grace / off / restored notice has something to say and has not been dismissed today. */ |
| 544 |
public static function notice_visible() { |
| 545 |
$state = self::state(); |
| 546 |
if (!empty($state['restored_at'])) { |
| 547 |
return true; |
| 548 |
} |
| 549 |
if (empty($state['off_since']) && empty($state['revoked_at'])) { |
| 550 |
return false; |
| 551 |
} |
| 552 |
return empty($state['dismissed_at']) || (time() - (int) $state['dismissed_at']) >= self::DISMISS_SECONDS; |
| 553 |
} |
| 554 |
|
| 555 |
/** Pro & Extensions always; any other admin screen only while there is a notice to act on. */ |
| 556 |
public static function enqueue_assets($hook_suffix = '') { |
| 557 |
if (!current_user_can('manage_options')) { |
| 558 |
return; |
| 559 |
} |
| 560 |
$page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; |
| 561 |
if ($page !== 'mxchat-activation' && !self::notice_visible()) { |
| 562 |
return; |
| 563 |
} |
| 564 |
$plugin_url = plugin_dir_url(dirname(__FILE__)); |
| 565 |
$version = defined('MXCHAT_VERSION') ? MXCHAT_VERSION : '1.0'; |
| 566 |
wp_enqueue_script('mxchat-license-check-js', $plugin_url . 'js/license-check.js', array('jquery'), $version, true); |
| 567 |
wp_localize_script('mxchat-license-check-js', 'MxChatLicenseCheck', array( |
| 568 |
'ajaxUrl' => admin_url('admin-ajax.php'), |
| 569 |
'nonce' => wp_create_nonce(self::NONCE_CHECK), |
| 570 |
'dismissNonce' => wp_create_nonce(self::NONCE_DISMISS), |
| 571 |
'checking' => __('Checking with mxchat.ai…', 'mxchat'), |
| 572 |
'failed' => __('The check could not run. Try again in a moment.', 'mxchat'), |
| 573 |
'reloading' => __('Pro status changed. Reloading…', 'mxchat'), |
| 574 |
)); |
| 575 |
} |
| 576 |
|
| 577 |
/** |
| 578 |
* The WordPress admin notice, on every admin screen except Pro & Extensions |
| 579 |
* (which renders the same thing inside its own card). Three shapes: grace |
| 580 |
* period, switched off, and a one-time "back on". |
| 581 |
*/ |
| 582 |
public static function render_notice() { |
| 583 |
if (!current_user_can('manage_options')) { |
| 584 |
return; |
| 585 |
} |
| 586 |
$page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; |
| 587 |
if ($page === 'mxchat-activation') { |
| 588 |
return; |
| 589 |
} |
| 590 |
$state = self::state(); |
| 591 |
|
| 592 |
if (!empty($state['restored_at'])) { |
| 593 |
$state['restored_at'] = 0; |
| 594 |
self::save_state($state); |
| 595 |
?> |
| 596 |
<div class="notice notice-success is-dismissible mxchat-license-check-notice"> |
| 597 |
<p><strong><?php esc_html_e('MxChat Pro is back on.', 'mxchat'); ?></strong> |
| 598 |
<?php echo esc_html(sprintf( |
| 599 |
/* translators: %s: this site's domain */ |
| 600 |
__('%s was switched back on in the licence holder\'s MxChat account, so Pro features are available again.', 'mxchat'), |
| 601 |
self::domain() |
| 602 |
)); ?></p> |
| 603 |
</div> |
| 604 |
<?php |
| 605 |
return; |
| 606 |
} |
| 607 |
|
| 608 |
if (!self::notice_visible()) { |
| 609 |
return; |
| 610 |
} |
| 611 |
$view = self::view_model(); |
| 612 |
$revoked = !empty($state['revoked_at']); |
| 613 |
$pro_url = admin_url('admin.php?page=mxchat-activation'); |
| 614 |
?> |
| 615 |
<div class="notice <?php echo $revoked ? 'notice-error' : 'notice-warning'; ?> is-dismissible mxchat-license-check-notice" data-mxchat-license-notice="1"> |
| 616 |
<p><strong><?php echo $revoked |
| 617 |
? esc_html__('MxChat Pro is paused on this site.', 'mxchat') |
| 618 |
: esc_html__('MxChat Pro: this site has been switched off in the licence holder\'s account.', 'mxchat'); ?></strong></p> |
| 619 |
<p><?php echo esc_html($view['sentence']); ?></p> |
| 620 |
<p> |
| 621 |
<button type="button" class="button mxchat-license-check-now"><?php esc_html_e('Check license now', 'mxchat'); ?></button> |
| 622 |
<a class="button button-link" href="<?php echo esc_url($pro_url); ?>"><?php esc_html_e('Open Pro & Extensions', 'mxchat'); ?></a> |
| 623 |
<a class="button button-link" href="https://mxchat.ai/my-account/licenses/" target="_blank" rel="noopener"><?php esc_html_e('Open your licenses at mxchat.ai', 'mxchat'); ?></a> |
| 624 |
<span class="mxchat-license-check-msg" role="status"></span> |
| 625 |
</p> |
| 626 |
</div> |
| 627 |
<?php |
| 628 |
} |
| 629 |
} |
| 630 |
|