> $schedules */ public function schedules(array $schedules): array { $schedules[self::SCHEDULE] = ['display' => __('Every five minutes (Sarcio)', 'sarcio'), 'interval' => 300]; return $schedules; } public static function unschedule(): void { $next = wp_next_scheduled(self::HOOK); if ($next) { wp_unschedule_event($next, self::HOOK); } } /** The administrator the site runs an ask as: one with a stored Sarcio sign-in. */ public static function runner(): int { $preferred = (int) Options::get('bulk_user'); if ($preferred > 0 && Account::has($preferred) && user_can($preferred, 'manage_options')) { return $preferred; } foreach (get_users(['role' => 'administrator', 'fields' => 'ID', 'number' => 50]) as $id) { if (Account::has((int) $id)) { return (int) $id; } } return 0; } /** WP-Cron: one task per poll. */ public static function poll(): void { if (!self::enabled()) { return; } $task = self::fetch(); if ($task === null) { return; } $runner = self::runner(); if ($runner === 0) { self::report((string) $task['id'], ['error' => __('No administrator on this site has connected their Sarcio account, so nothing can run here.', 'sarcio'), 'status' => 'FAILED', 'summary' => []]); return; } $previous = get_current_user_id(); wp_set_current_user($runner); Remote::begin(); try { $result = ($task['kind'] ?? 'ASK') === 'UNDO' ? self::undo((array) ($task['changeIds'] ?? [])) : self::run((string) ($task['text'] ?? '')); } catch (\Throwable $e) { $result = ['error' => sanitize_text_field($e->getMessage()), 'status' => 'FAILED', 'summary' => []]; } finally { Remote::end(); wp_set_current_user($previous); } self::report((string) $task['id'], $result); } /** * The assistant loop without a browser, preparing only: reads from the * allowed list run, allowed changes are laid over a preview, and the * preview becomes a request for an administrator. * * @return array{status:string,summary:array,error?:string,adminUrl?:string,requestId?:string} */ public static function run(string $text): array { if (!Remote::active()) { return ['error' => __('Changes written for several sites are only prepared by the scheduled check.', 'sarcio'), 'status' => 'FAILED', 'summary' => []]; } Catalog::reset(); $stage = Stage::sync('', []); if (!$stage['ok']) { return ['error' => (string) ($stage['error'] ?? ''), 'status' => 'FAILED', 'summary' => []]; } $messages = [['role' => 'user', 'text' => $text]]; $summaries = []; $previews = []; $calls = 0; $lastText = ''; for ($turn = 0; $turn < self::MAX_TURNS; $turn++) { $reply = Client::turn(get_current_user_id(), $messages, '', Remote::catalog()); if (isset($reply['error']) || isset($reply['reconnect'])) { Stage::clear((string) $stage['token']); return ['error' => (string) ($reply['error'] ?? __('The site’s Sarcio sign-in needs renewing.', 'sarcio')), 'status' => 'FAILED', 'summary' => array_values($summaries)]; } $lastText = (string) ($reply['text'] ?? ''); $messages[] = ['calls' => $reply['calls'], 'role' => 'assistant', 'text' => $lastText]; if ($reply['calls'] === []) { break; } $results = []; foreach ($reply['calls'] as $call) { $id = (string) $call['id']; $args = is_array($call['args']) ? $call['args'] : []; if (++$calls > self::MAX_CALLS) { $results[] = ['id' => $id, 'ok' => false, 'skipped' => true, 'error' => __('Too many steps for one ask.', 'sarcio')]; continue; } $ability = Remote::allows((string) $call['ability']) ? Catalog::get((string) $call['ability']) : null; if ($ability === null) { $results[] = ['id' => $id, 'ok' => false, 'error' => __('Not something this site can do.', 'sarcio')]; continue; } if ($ability->kind === Ability::READ) { $one = Catalog::run($ability->name, $args); $results[] = ['id' => $id, 'ok' => (bool) $one['ok'], 'output' => $one['ok'] ? Transcript::capOutput($one['output'] ?? []) : null] + ($one['ok'] ? [] : ['error' => (string) ($one['error'] ?? '')]); continue; } if ($ability->stage !== null) { $staged = Stage::add($id, $ability->name, $args); if ($staged['ok']) { $summaries[$id] = (string) ($staged['summary'] ?? Catalog::label($ability->name)); $previews[$id] = ['after' => (string) ($staged['after'] ?? ''), 'before' => (string) ($staged['before'] ?? ''), 'summary' => $summaries[$id]]; } $results[] = ['id' => $id, 'ok' => (bool) $staged['ok'], 'output' => $staged['ok'] ? ['staged' => true, 'summary' => $summaries[$id]] : null] + ($staged['ok'] ? [] : ['error' => (string) ($staged['error'] ?? '')]); continue; } $results[] = ['id' => $id, 'ok' => false, 'skipped' => true, 'error' => __('This cannot be previewed, so it was left out. Someone on the site can ask for it in the assistant.', 'sarcio')]; } $messages[] = ['results' => $results, 'role' => 'result']; } $lines = array_values(array_map(static fn (string $line): string => mb_substr($line, 0, 300), $summaries)); if (Stage::changes() === []) { Stage::clear((string) $stage['token']); return ['error' => $lastText !== '' ? mb_substr($lastText, 0, 1000) : __('The assistant found nothing to change.', 'sarcio'), 'status' => 'FAILED', 'summary' => $lines]; } $request = Requests::fromStage(mb_substr($text, 0, 200), $previews, Requests::ORIGIN_AGENCY); if (!$request['ok']) { Stage::clear((string) $stage['token']); return ['error' => (string) ($request['error'] ?? ''), 'status' => 'FAILED', 'summary' => $lines]; } return ['adminUrl' => RequestsPage::url((string) $request['request']['id']), 'requestId' => (string) $request['request']['id'], 'status' => 'STAGED', 'summary' => $lines]; } /** @param array $changeIds */ public static function undo(array $changeIds): array { if (!Remote::active()) { return ['error' => __('Changes written for several sites are only prepared by the scheduled check.', 'sarcio'), 'status' => 'FAILED', 'summary' => []]; } $request = Requests::fromAgencyUndo(array_values(array_filter(array_map('intval', $changeIds)))); if (!$request['ok']) { return ['error' => (string) ($request['error'] ?? ''), 'status' => 'FAILED', 'summary' => []]; } $lines = array_values(array_map(static fn (array $change): string => (string) $change['summary'], $request['request']['changes'] ?? [])); return ['adminUrl' => RequestsPage::url((string) $request['request']['id']), 'requestId' => (string) $request['request']['id'], 'status' => 'STAGED', 'summary' => $lines]; } /** @return array|null */ private static function fetch(): ?array { $siteKey = (string) Options::get('site_key'); $secret = (string) Options::get('sidecar_key'); if ($siteKey === '' || $secret === '') { return null; } $response = wp_remote_get( sprintf('%s/api/sites/%s/bulk-asks/pending', Options::apiUrl(), rawurlencode($siteKey)), ['headers' => ['Authorization' => 'Bearer ' . $secret], 'redirection' => 0, 'timeout' => 8], ); if (is_wp_error($response) || (int) wp_remote_retrieve_response_code($response) !== 200) { return null; } $body = json_decode((string) wp_remote_retrieve_body($response), true); $task = is_array($body) && is_array($body['task'] ?? null) ? $body['task'] : null; return $task !== null && is_string($task['id'] ?? null) && preg_match('~^[A-Za-z0-9-]{1,64}$~', $task['id']) ? $task : null; } /** @param array $result */ private static function report(string $taskId, array $result): void { $siteKey = (string) Options::get('site_key'); $secret = (string) Options::get('sidecar_key'); if ($siteKey === '' || $secret === '') { return; } wp_remote_post( sprintf('%s/api/sites/%s/bulk-asks/%s/result', Options::apiUrl(), rawurlencode($siteKey), rawurlencode($taskId)), [ 'body' => (string) wp_json_encode(array_filter([ 'adminUrl' => $result['adminUrl'] ?? null, 'changeIds' => $result['changeIds'] ?? null, 'error' => isset($result['error']) && $result['error'] !== '' ? mb_substr((string) $result['error'], 0, 1000) : null, 'requestId' => $result['requestId'] ?? null, 'status' => (string) $result['status'], 'summary' => array_slice((array) ($result['summary'] ?? []), 0, 100), ], static fn ($value): bool => $value !== null)), 'headers' => ['Authorization' => 'Bearer ' . $secret, 'Content-Type' => 'application/json'], 'redirection' => 0, 'timeout' => 8, ], ); } }