} */ public static function config( Settings $settings ) { $integrations = (array) $settings->get( 'integrations', array() ); return array( 'enabled' => ! empty( $integrations['sheets_enabled'] ), 'spreadsheet' => isset( $integrations['sheets_spreadsheet'] ) ? (string) $integrations['sheets_spreadsheet'] : '', 'events' => isset( $integrations['sheets_events'] ) ? array_values( array_map( 'strval', (array) $integrations['sheets_events'] ) ) : array(), ); } /** * Whether the connection could deliver at all: switched on, a spreadsheet * named, and the Google connection's key still stored — all three, because * the credential is borrowed and can leave on its own (a Google disconnect * must stand this service down with it, not leave it erroring). * * @param Settings $settings Plugin settings. * @return bool */ public static function connected( Settings $settings ) { $config = self::config( $settings ); return $config['enabled'] && '' !== $config['spreadsheet'] && self::has_key(); } /** * Whether the owner ticked this event's box. * * @param Settings $settings Plugin settings. * @param string $event Event name. * @return bool */ public static function wants( Settings $settings, $event ) { if ( ! self::connected( $settings ) ) { return false; } $config = self::config( $settings ); return in_array( (string) $event, $config['events'], true ); } /** * No per-payload gate: a spreadsheet is a HISTORY, so everything the * checkbox subscribed to belongs in it. * * @param Settings $settings Plugin settings. * @param string $event Event name. * @param array $data Contract-shaped payload. * @return bool */ public static function accepts( Settings $settings, $event, array $data ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- the grammar's full signature. return true; } /* -- The borrowed key ----------------------------------------------------- */ /** * Whether the Google connection holds a service-account key. Presence of * the ciphertext only — nothing is decrypted to answer a yes/no that runs * on every boot. * * @return bool */ public static function has_key() { return '' !== (string) ( new GoogleSettings() )->get( 'sa_json', '' ); } /** * The service account's EMAIL — what the owner shares the sheet with, so * the connect help and the no-access error can say it out loud. Plaintext * in the Google store by design (granting it access IS the setup step). * * @return string */ public static function sa_email() { return (string) ( new GoogleSettings() )->get( 'sa_email', '' ); } /** * A spreadsheet ID as Google will take it: the long /d/… code — pasted * bare, or extracted from the sheet's whole URL (the kinder paste). Anything * else normalizes to '' — stored garbage would fail on every delivery. * * @param string $raw Raw input: an ID, or the spreadsheet's URL. * @return string The bare spreadsheet ID, or ''. */ public static function normalize_spreadsheet_id( $raw ) { $raw = trim( (string) $raw ); if ( preg_match( '#/d/([A-Za-z0-9_-]{15,})#', $raw, $m ) ) { return $m[1]; } return preg_match( '/^[A-Za-z0-9_-]{15,}$/', $raw ) ? $raw : ''; } /* -- Connect-time proof --------------------------------------------------- */ /** * Prove the road end to end: one append carrying the header row and a test * row. A success means the ID names a real spreadsheet, the sheet is shared * with the service account, and rows actually land — the whole promise, in * one call. Failures keep their two distinct sentences ({@see map_error}). * * @param string $spreadsheet The normalized spreadsheet ID. * @return true|\WP_Error True, or an error whose code names the bad thing * (agentimus_sheets_id / agentimus_sheets_access / …). */ public static function verify( $spreadsheet ) { $token = self::token(); if ( is_wp_error( $token ) ) { return $token; } return self::append( $spreadsheet, $token, array( self::HEADER, array( self::when( time() ), 'connected', '', __( 'Agentimus test row — your site can reach this sheet. The Integrations screen says whether the connection was saved. New rows append below as events happen.', 'agentimus' ), home_url( '/' ), ), ) ); } /* -- Delivery ------------------------------------------------------------- */ /** * Deliver one envelope as one appended row. One append, one verdict — * retry policy belongs to the queue, not here. * * @param string $event Event name. * @param array $envelope The versioned envelope to format. * @return true|\WP_Error True on success; a WP_Error naming what went wrong. */ public static function deliver( $event, array $envelope ) { $config = self::config( new Settings() ); if ( '' === $config['spreadsheet'] || ! self::has_key() ) { return new \WP_Error( 'agentimus_sheets_unconfigured', __( 'Google Sheets is not configured.', 'agentimus' ) ); } $token = self::token(); if ( is_wp_error( $token ) ) { return $token; } return self::append( $config['spreadsheet'], $token, array( self::row( (string) $event, $envelope ) ) ); } /** * The formatter: one envelope, one row of five flat columns in the stable * order the header names — When · Event · Level · What happened · Link. * Pure — the tests hold each event's exact shape. * * @param string $event Event name. * @param array $envelope The versioned envelope ({ event, site, data, at, … }). * @return array */ public static function row( $event, array $envelope ) { $data = isset( $envelope['data'] ) && is_array( $envelope['data'] ) ? $envelope['data'] : array(); return array( self::when( isset( $envelope['at'] ) ? (int) $envelope['at'] : time() ), (string) $event, self::level( (string) $event, $data ), self::summary( (string) $event, $data ), self::link( (string) $event, $data, isset( $envelope['site'] ) ? (string) $envelope['site'] : '' ), ); } /* -- Delivery state (this service's slice of the shared honesty line) ---- */ /** * This connection's delivery state, always in its full shape. * * @return array{lastDeliveredAt:int,lastError:string,lastErrorAt:int} */ public static function state() { return DeliveryState::read( self::ID ); } /** A delivery landed (a success also clears the standing error). */ public static function record_success() { DeliveryState::success( self::ID ); } /** * A delivery failed; the freshest error wins. * * @param string $message Plain-words failure reason. */ public static function record_failure( $message ) { DeliveryState::failure( self::ID, $message ); } /** Drop the state — the disconnect path. */ public static function forget_state() { DeliveryState::forget( self::ID ); } /* -- Internals ------------------------------------------------------------ */ /** * A Sheets-scope token from the borrowed key — the same JWT flow Search * Console rides, a scope of its own (a Search Console token must never * carry write rights to spreadsheets, and vice versa). * * @return string|\WP_Error The bearer token, or the failure in Google's words. */ private static function token() { $minted = Auth::token( ( new GoogleSettings() )->sa_json(), Auth::SCOPE_SHEETS ); if ( isset( $minted['error'] ) || empty( $minted['token'] ) ) { return new \WP_Error( 'agentimus_sheets_auth', sprintf( /* translators: %s: what went wrong minting the Google token. */ __( 'Google didn’t accept the stored service-account key: %s', 'agentimus' ), isset( $minted['error'] ) ? (string) $minted['error'] : __( 'no token was issued.', 'agentimus' ) ) ); } return (string) $minted['token']; } /** * One append call: rows onto the end of the first sheet's table. * * valueInputOption=RAW on purpose — every value lands as the literal * string, so site data beginning with "=" (or "+", "@") can never become a * formula the spreadsheet executes. * * @param string $spreadsheet Spreadsheet ID. * @param string $token Bearer token. * @param array $rows Rows of column strings. * @return true|\WP_Error */ private static function append( $spreadsheet, $token, array $rows ) { $response = wp_remote_post( self::API . rawurlencode( (string) $spreadsheet ) . '/values/A1:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS', array( 'timeout' => self::TIMEOUT, 'headers' => array( 'Authorization' => 'Bearer ' . (string) $token, 'Content-Type' => 'application/json; charset=utf-8', ), 'body' => wp_json_encode( array( 'values' => $rows ) ), ) ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code >= 200 && $code < 300 ) { return true; } return self::map_error( $code, (string) wp_remote_retrieve_body( $response ) ); } /** * Google's status codes, translated into the sentence the owner can act * on. The two that matter get their own words: 404 is the ID (no such * spreadsheet), 403 is the sharing (the sheet exists, the service account * was never let in — and the email to share with is said OUT LOUD). * * @param int $code HTTP status. * @param string $body Google's response body. * @return \WP_Error */ /** * Google's machine-readable reason for a refusal — `error.details[].reason`, * the field that tells SERVICE_DISABLED from a sheet that was never shared. * '' when Google gave none, which is itself the answer: no reason offered. * * ⭐ The `details` array is read first because it is the authoritative field; * `error.status` is a coarser bucket (a disabled API also reports itself as * PERMISSION_DENIED there, which is exactly how one problem got mistaken for * another). * * @param string $body Google's response body. * @return string The reason token, or ''. */ private static function google_reason( $body ) { $json = json_decode( (string) $body, true ); if ( ! is_array( $json ) || ! isset( $json['error'] ) || ! is_array( $json['error'] ) ) { return ''; } $details = isset( $json['error']['details'] ) && is_array( $json['error']['details'] ) ? $json['error']['details'] : array(); foreach ( $details as $detail ) { if ( is_array( $detail ) && ! empty( $detail['reason'] ) ) { return (string) $detail['reason']; } } return isset( $json['error']['status'] ) ? (string) $json['error']['status'] : ''; } /** * Google's own sentence, clipped — quoted verbatim wherever we cannot better * it. Its SERVICE_DISABLED message carries the exact enable link for the * owner's project, which is worth more than anything we could write. * * @param string $body Google's response body. * @return string */ private static function google_message( $body ) { $json = json_decode( (string) $body, true ); $msg = is_array( $json ) && isset( $json['error']['message'] ) ? trim( (string) $json['error']['message'] ) : ''; return '' !== $msg ? substr( $msg, 0, 300 ) : __( '(no message)', 'agentimus' ); } private static function map_error( $code, $body ) { if ( 404 === $code ) { return new \WP_Error( 'agentimus_sheets_id', __( 'Google can’t find a spreadsheet with that ID. Check the long code in the sheet’s URL — it sits between /d/ and /edit.', 'agentimus' ) ); } if ( 403 === $code ) { // ⛔⛔ GOOGLE SAYS 403 FOR AT LEAST THREE DIFFERENT PROBLEMS, and they // need three different fixes. This branch used to assert the sharing // one and never look at $body — so on 2026-08-17 a site whose Sheets // API was simply switched off was told, for ten minutes, to share a // sheet it had already shared with the right address as an Editor. // A wrong diagnosis is worse than none: it sends the owner to fix // something that is not broken. Google names the real reason in the // body; read it, and never claim a cause it did not give. $reason = self::google_reason( $body ); if ( 'SERVICE_DISABLED' === $reason ) { return new \WP_Error( 'agentimus_sheets_api_off', sprintf( /* translators: %s: Google's own message, which carries the exact enable link for this project. */ __( 'The Google Sheets API is switched off for this Google Cloud project. Sharing is not the problem — turn the API on, then connect again. Google says: %s', 'agentimus' ), self::google_message( $body ) ) ); } if ( 'ACCESS_TOKEN_SCOPE_INSUFFICIENT' === $reason ) { return new \WP_Error( 'agentimus_sheets_scope', __( 'The Google key this site holds is not allowed to write to Sheets. Sharing is not the problem — reconnect Google under Data Sources, which mints the key again with the right permission.', 'agentimus' ) ); } // A reason we do not have words for is REPEATED, never guessed at — // the same rule the fallback below follows for every other status. if ( '' !== $reason && 'PERMISSION_DENIED' !== $reason && 'IAM_PERMISSION_DENIED' !== $reason ) { return new \WP_Error( 'agentimus_sheets_status', sprintf( /* translators: 1: Google's machine-readable reason, 2: Google's own message. */ __( 'Google Sheets refused this (%1$s): %2$s', 'agentimus' ), $reason, self::google_message( $body ) ) ); } // No reason given, or a plain permission denial: the sheet really was // never shared. This is the common case, and now the only one that // claims to be it. $email = self::sa_email(); return new \WP_Error( 'agentimus_sheets_access', '' !== $email ? sprintf( /* translators: %s: the service account's email address. */ __( 'The spreadsheet exists, but it isn’t shared with the service account. In Google Sheets, hit Share and add %s as an Editor — like any collaborator.', 'agentimus' ), $email ) : __( 'The spreadsheet exists, but it isn’t shared with the service account. In Google Sheets, hit Share and add the service account’s email (shown on the Integrations card) as an Editor.', 'agentimus' ) ); } $json = json_decode( $body, true ); $message = is_array( $json ) && isset( $json['error']['message'] ) ? substr( trim( (string) $json['error']['message'] ), 0, 140 ) : ''; return new \WP_Error( 'agentimus_sheets_status', '' !== $message ? sprintf( /* translators: 1: the HTTP status Google answered with, 2: Google's own error message. */ __( 'Google Sheets answered %1$d: %2$s', 'agentimus' ), $code, $message ) : sprintf( /* translators: %d: the HTTP status Google answered with. */ __( 'Google Sheets answered %d.', 'agentimus' ), $code ) ); } /** * The When column: site-local, sortable, one fixed shape — a history is * for sorting and filtering, so the format never follows display settings. * * @param int $at Unix timestamp. * @return string */ private static function when( $at ) { return wp_date( 'Y-m-d H:i:s', (int) $at ); } /** * The Level column: the finding's tier in words; every other event leaves * it empty — the tier is a findings word, and inventing one would be a lie. * * @param string $event Event name. * @param array $data Contract-shaped payload. * @return string */ private static function level( $event, array $data ) { if ( Events::FINDING_OPENED !== $event ) { return ''; } return self::tier_words( isset( $data['tier'] ) ? (string) $data['tier'] : '' ); } /** * The What-happened column: one plain line per event. Each service holds * its own copy of this grammar — services never import each other. * * @param string $event Event name. * @param array $data Contract-shaped payload. * @return string */ private static function summary( $event, array $data ) { switch ( $event ) { case Events::FINDING_OPENED: return isset( $data['title'] ) ? (string) $data['title'] : ''; case Events::DIGEST_SENT: $label = isset( $data['period']['label'] ) ? (string) $data['period']['label'] : ''; $message = sprintf( /* translators: 1: agent visits, 2: AI referrals, 3: impostors. */ __( '%1$d agent visits, %2$d AI referrals, %3$d impostors caught.', 'agentimus' ), isset( $data['agents'] ) ? (int) $data['agents'] : 0, isset( $data['referrals'] ) ? (int) $data['referrals'] : 0, isset( $data['impostors'] ) ? (int) $data['impostors'] : 0 ); if ( '' !== $label ) { $message = $label . ' — ' . $message; } $now = isset( $data['score']['now'] ) ? $data['score']['now'] : null; $prev = isset( $data['score']['prev'] ) ? $data['score']['prev'] : null; if ( null !== $now ) { $message .= ' ' . sprintf( /* translators: %d: the current readiness score. */ __( 'Score %d', 'agentimus' ), (int) $now ); if ( null !== $prev && (int) $prev !== (int) $now ) { $message .= sprintf( /* translators: %d: the previous score. */ __( ' (was %d)', 'agentimus' ), (int) $prev ); } $message .= '.'; } return $message; case Events::IMPOSTOR_FLAGGED: return sprintf( /* translators: %s: the client name that failed its check. */ __( 'A client calling itself “%s” failed its operator’s check.', 'agentimus' ), isset( $data['client'] ) ? (string) $data['client'] : '' ); case Events::ROBOTS_CHANGED: $added = isset( $data['added'] ) ? array_filter( array_map( 'strval', (array) $data['added'] ), 'strlen' ) : array(); $removed = isset( $data['removed'] ) ? array_filter( array_map( 'strval', (array) $data['removed'] ), 'strlen' ) : array(); $parts = array(); if ( array() !== $added ) { /* translators: %s: the added robots.txt lines, joined. */ $parts[] = sprintf( __( 'Added: %s.', 'agentimus' ), implode( '; ', $added ) ); } if ( array() !== $removed ) { /* translators: %s: the removed robots.txt lines, joined. */ $parts[] = sprintf( __( 'Removed: %s.', 'agentimus' ), implode( '; ', $removed ) ); } return array() !== $parts ? implode( ' ', $parts ) : __( 'robots.txt policy changed.', 'agentimus' ); case Events::CITATION_RUN_FINISHED: $checks = isset( $data['checks'] ) ? (int) $data['checks'] : 0; $message = sprintf( /* translators: %d: how many checks the run made. */ _n( '%d check run.', '%d checks run.', $checks, 'agentimus' ), $checks ); if ( ! empty( $data['capped'] ) ) { $message .= ' ' . __( 'The run stopped at its cap.', 'agentimus' ); } return $message; case Events::AGENT_WROTE_CONTENT: $message = sprintf( /* translators: 1: the post title, 2: created/updated/trashed/restored. */ __( '“%1$s” %2$s by an AI assistant.', 'agentimus' ), isset( $data['title'] ) ? (string) $data['title'] : '', Events::wrote_words( isset( $data['action'] ) ? (string) $data['action'] : '' )['verb'] ); if ( ! empty( $data['ability'] ) ) { $message .= ' ' . sprintf( /* translators: %s: the tool (ability) that made the write. */ __( 'Via %s.', 'agentimus' ), (string) $data['ability'] ); } return $message; } // A filter-added event this formatter never learned: the Event column // already names it, so the summary stays honestly empty. return ''; } /** * The Link column: where acting on the row starts. Only the events with a * real destination get one — an invented link would be decoration. * * @param string $event Event name. * @param array $data Contract-shaped payload. * @param string $site The envelope's site URL (ends in /). * @return string */ private static function link( $event, array $data, $site ) { if ( Events::FINDING_OPENED === $event ) { return isset( $data['url'] ) ? (string) $data['url'] : ''; } if ( Events::ROBOTS_CHANGED === $event && '' !== $site ) { return rtrim( $site, '/' ) . '/robots.txt'; } return ''; } /** * The finding tier in words (each service holds its own copy — services * never import each other). * * @param string $tier Findings tier id. * @return string */ private static function tier_words( $tier ) { if ( Findings::URGENT === $tier ) { return __( 'urgent', 'agentimus' ); } if ( Findings::WORTH === $tier ) { return __( 'worth knowing', 'agentimus' ); } if ( Findings::LATER === $tier ) { return __( 'for later', 'agentimus' ); } return $tier; } }