self::erase_user( (int) $user_id ) ); } /** * Add the exporter. * * @param array> $exporters Exporters. * @return array> */ public static function register_exporter( $exporters ): array { $exporters = (array) $exporters; $exporters[ self::ID ] = array( 'exporter_friendly_name' => __( 'Agent Adapt: connected AI agents and their activity', 'agent-adapt' ), 'callback' => array( __CLASS__, 'export' ), ); return $exporters; } /** * Add the eraser. * * @param array> $erasers Erasers. * @return array> */ public static function register_eraser( $erasers ): array { $erasers = (array) $erasers; $erasers[ self::ID ] = array( 'eraser_friendly_name' => __( 'Agent Adapt: connected AI agents and their activity', 'agent-adapt' ), 'callback' => array( __CLASS__, 'erase' ), ); return $erasers; } /** * Export one page. * * @param string $email Email address in the request. * @param int $page Page, from 1. * @return array{data: array>, done: bool} */ public static function export( $email, $page = 1 ): array { $user_id = self::user_id( (string) $email ); if ( $user_id < 1 ) { return array( 'data' => array(), 'done' => true ); } $page = max( 1, (int) $page ); $counts = self::counts( $user_id ); $pages = array_map( static fn( int $n ): int => (int) ceil( $n / self::PAGE ), $counts ); $left = $page; foreach ( $pages as $group => $n ) { if ( $left <= $n ) { return array( 'data' => self::items( $group, $user_id, ( $left - 1 ) * self::PAGE ), 'done' => $page >= array_sum( $pages ), ); } $left -= $n; } return array( 'data' => array(), 'done' => true ); } /** * Erase everything of the account's, as the file header describes. * * @param string $email Email address in the request. * @param int $page Page (one call does it all, see the file header). * @return array{items_removed: bool, items_retained: bool, messages: string[], done: bool} */ public static function erase( $email, $page = 1 ): array { unset( $page ); $out = array( 'items_removed' => false, 'items_retained' => false, 'messages' => array(), 'done' => true, ); $user_id = self::user_id( (string) $email ); if ( $user_id < 1 ) { return $out; } return self::erase_user( $user_id ); } /** * Erase everything of one account's: an erasure request, or the account being deleted. * * @param int $user_id WordPress user ID. * @return array{items_removed: bool, items_retained: bool, messages: string[], done: bool} */ public static function erase_user( int $user_id ): array { $out = array( 'items_removed' => false, 'items_retained' => false, 'messages' => array(), 'done' => true, ); if ( $user_id < 1 ) { return $out; } global $wpdb; $sessions_t = AgentAdapt_Schema::table( 'sessions' ); // phpcs:disable WordPress.DB.DirectDatabaseQuery $sessions = (array) $wpdb->get_results( $wpdb->prepare( 'SELECT session_id, wc_session_key FROM %i WHERE bound_user_id = %d OR user_id = %d', $sessions_t, $user_id, $user_id ), ARRAY_A ); // The public clients among the account's agents, before the rows that name them go. $hashes = (array) $wpdb->get_col( $wpdb->prepare( 'SELECT client_hash FROM %i WHERE user_id = %d UNION SELECT client_hash FROM %i WHERE bound_user_id = %d OR user_id = %d', AgentAdapt_Schema::table( 'grants' ), $user_id, $sessions_t, $user_id, $user_id ) ); $events = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i e WHERE e.user_id = %d OR ( e.session_id <> '' AND e.session_id IN ( SELECT s.session_id FROM %i s WHERE s.bound_user_id = %d OR s.user_id = %d ) )", AgentAdapt_Schema::table( 'events' ), $user_id, $sessions_t, $user_id, $user_id ) ); // phpcs:enable $public = array(); foreach ( array_unique( array_map( 'strval', $hashes ) ) as $hash ) { if ( AgentAdapt_Agents::is_public_hash( $hash ) ) { $public[] = self::agent_client_id( $hash ); } } $codes = AgentAdapt_Codes::delete_for_user( $user_id ); $grants = AgentAdapt_Grants::erase_user( $user_id ); AgentAdapt_Events::anonymise_user( $user_id ); $carts = AgentAdapt_WC_Session::delete_stored( array_column( $sessions, 'wc_session_key' ) ); AgentAdapt_Tokens::delete_for( array_column( $sessions, 'session_id' ), $user_id ); $ended = AgentAdapt_Sessions::erase_account( $user_id ); $subjects = AgentAdapt_Sessions::forget_account_subjects( $user_id, array_filter( $public ) ); $say = static function ( int $n, string $message ) use ( &$out ): void { if ( $n > 0 ) { $out['items_removed'] = true; $out['messages'][] = sprintf( $message, $n ); } }; /* translators: %d: number of AI agents */ $say( $grants['revoked'], _n( 'Disconnected %d AI agent from the account. Its access ended at once.', 'Disconnected %d AI agents from the account. Their access ended at once.', $grants['revoked'], 'agent-adapt' ) ); /* translators: %d: number of connection records */ $say( $grants['deleted'], _n( 'Removed %d record of an AI agent connected to the account.', 'Removed %d records of AI agents connected to the account.', $grants['deleted'], 'agent-adapt' ) ); /* translators: %d: number of sign-in approvals */ $say( $codes, _n( 'Removed %d sign-in approval given to an AI agent.', 'Removed %d sign-in approvals given to AI agents.', $codes, 'agent-adapt' ) ); /* translators: %d: number of agent Sessions */ $say( $ended, _n( 'Ended %d AI agent Session tied to the account and removed the account from it. It stays only as an anonymous count of a visit.', 'Ended %d AI agent Sessions tied to the account and removed the account from them. They stay only as anonymous counts of visits.', $ended, 'agent-adapt' ) ); /* translators: %d: number of carts */ $say( $carts, _n( 'Removed %d cart saved by an AI agent.', 'Removed %d carts saved by AI agents.', $carts, 'agent-adapt' ) ); /* translators: %d: number of events */ $say( $events, _n( 'Removed the account from %d record of AI agent activity. The record stays without it, so the store\'s agent statistics don\'t change.', 'Removed the account from %d records of AI agent activity. They stay without it, so the store\'s agent statistics don\'t change.', $events, 'agent-adapt' ) ); /* translators: %d: number of IDs */ $say( $subjects, _n( 'Replaced %d AI agent user ID that was derived from the account.', 'Replaced %d AI agent user IDs that were derived from the account.', $subjects, 'agent-adapt' ) ); return $out; } /** * Register the suggested privacy-policy text (Settings → Privacy). */ public static function add_policy_content(): void { if ( function_exists( 'wp_add_privacy_policy_content' ) ) { wp_add_privacy_policy_content( 'Agent Adapt', self::policy_text() ); } } /** * The suggested privacy-policy text, with this store's own periods. */ public static function policy_text(): string { $days = static fn( int $seconds ): int => max( 1, (int) round( $seconds / DAY_IN_SECONDS ) ); $access = $days( AgentAdapt_Grants::lifetime() ); $cart = max( $days( AgentAdapt_Sessions::IDLE_LIFETIME ), AgentAdapt_Cart_Continuity::retention_days() ); $offered = AgentAdapt_Cart_Continuity::retention_days(); $events = AgentAdapt_Events::retention_days(); $paragraphs = array( __( 'You can use a personal AI agent, an assistant that acts for you, to shop with us. When an agent visits, we record which agent it is, by the name and web address it publishes, and each of its visits (agent sessions). Until you connect an agent to your account, we know the person it acts for only by an ID the agent chooses, which we store only in a one-way (hashed) form.', 'agent-adapt' ), sprintf( /* translators: %d: number of days */ _n( 'If you connect an agent to your account, we record your approval: which agent, what you allowed it to do, when you connected it and when it was last used. Its access ends after %d day, or sooner if you disconnect it in My Account → Connected agents or change your password or email address. The keys the agent uses are stored only in a one-way (hashed) form that cannot be used to get into your account. We also record each agent session signed in to your account.', 'If you connect an agent to your account, we record your approval: which agent, what you allowed it to do, when you connected it and when it was last used. Its access ends after %d days, or sooner if you disconnect it in My Account → Connected agents or change your password or email address. The keys the agent uses are stored only in a one-way (hashed) form that cannot be used to get into your account. We also record each agent session signed in to your account.', $access, 'agent-adapt' ), $access ), $offered > 0 ? sprintf( /* translators: %d: number of days */ _n( 'The cart an agent builds is saved for %d day after it last changes, so the same agent can offer it to you again, and is then deleted.', 'The cart an agent builds is saved for %d days after it last changes, so the same agent can offer it to you again, and is then deleted.', $cart, 'agent-adapt' ), $cart ) : sprintf( /* translators: %d: number of days */ _n( 'The cart an agent builds is saved for %d day after it last changes, and is then deleted.', 'The cart an agent builds is saved for %d days after it last changes, and is then deleted.', $cart, 'agent-adapt' ), $cart ), $events > 0 ? sprintf( /* translators: %d: number of days */ _n( 'We record what agents do here (activity such as searches, products viewed, cart changes, links to checkout and orders) and any report an agent sends us about something it could not do for you, and delete those records after %d day.', 'We record what agents do here (activity such as searches, products viewed, cart changes, links to checkout and orders) and any report an agent sends us about something it could not do for you, and delete those records after %d days.', $events, 'agent-adapt' ), $events ) : __( 'We do not keep records of what agents do here, or the reports they send us.', 'agent-adapt' ), __( 'We keep the records of your approvals and of agent sessions signed in to your account until you ask us to erase your personal data. They are included when you ask us for a copy of your personal data, and erased when you ask us to erase it. To check an agent, our store fetches its public details from the agent\'s own web address, and sends nothing about you when it does.', 'agent-adapt' ), ); $html = '

' . esc_html__( 'Agent Adapt lets your customers\' personal AI agents use your store and, when a customer approves, their account. The suggested text below describes what it stores, with this store\'s current settings.', 'agent-adapt' ) . '

'; $html .= '

' . esc_html__( 'Suggested text:', 'agent-adapt' ) . ' ' . esc_html( array_shift( $paragraphs ) ) . '

'; foreach ( $paragraphs as $text ) { $html .= '

' . esc_html( $text ) . '

'; } return $html; } /** * Items in each group, in export order. * * @param int $user_id Account. * @return array */ private static function counts( int $user_id ): array { global $wpdb; $sessions = AgentAdapt_Schema::table( 'sessions' ); // phpcs:disable WordPress.DB.DirectDatabaseQuery return array( 'connections' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE user_id = %d', AgentAdapt_Schema::table( 'grants' ), $user_id ) ), 'approvals' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE user_id = %d', AgentAdapt_Schema::table( 'codes' ), $user_id ) ), 'sessions' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE bound_user_id = %d OR user_id = %d', $sessions, $user_id, $user_id ) ), 'activity' => (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i e WHERE e.user_id = %d OR ( e.session_id <> '' AND e.session_id IN ( SELECT s.session_id FROM %i s WHERE s.bound_user_id = %d OR s.user_id = %d ) )", AgentAdapt_Schema::table( 'events' ), $user_id, $sessions, $user_id, $user_id ) ), ); // phpcs:enable } /** * One page of one group's items. * * @param string $group Group (counts()). * @param int $user_id Account. * @param int $offset First row. * @return array> */ private static function items( string $group, int $user_id, int $offset ): array { global $wpdb; $agents = AgentAdapt_Schema::table( 'agents' ); $sessions = AgentAdapt_Schema::table( 'sessions' ); $out = array(); // phpcs:disable WordPress.DB.DirectDatabaseQuery if ( 'connections' === $group ) { $rows = (array) $wpdb->get_results( $wpdb->prepare( 'SELECT g.grant_id, g.scope, g.created, g.expires, g.last_used, g.revoked, g.revoked_at, g.revoked_reason, a.name, a.client_id FROM %i g LEFT JOIN %i a ON a.client_hash = g.client_hash WHERE g.user_id = %d ORDER BY g.created ASC, g.grant_id ASC LIMIT %d OFFSET %d', AgentAdapt_Schema::table( 'grants' ), $agents, $user_id, self::PAGE, $offset ), ARRAY_A ); foreach ( $rows as $row ) { $out[] = self::item( 'connections', (string) $row['grant_id'], self::connection( $row ) ); } } elseif ( 'approvals' === $group ) { $rows = (array) $wpdb->get_results( $wpdb->prepare( 'SELECT c.kind, c.status, c.granted_scope, c.created, c.expires, a.name, a.client_id FROM %i c LEFT JOIN %i a ON a.client_hash = c.client_hash WHERE c.user_id = %d ORDER BY c.created ASC, c.code_hash ASC LIMIT %d OFFSET %d', AgentAdapt_Schema::table( 'codes' ), $agents, $user_id, self::PAGE, $offset ), ARRAY_A ); foreach ( $rows as $i => $row ) { $out[] = self::item( 'approvals', (string) ( $offset + $i + 1 ), self::approval( $row ) ); } } elseif ( 'sessions' === $group ) { $rows = (array) $wpdb->get_results( $wpdb->prepare( 'SELECT s.user_id, s.wc_session_key, s.created, s.last_used, s.expires, s.ended, a.name, a.client_id FROM %i s LEFT JOIN %i a ON a.client_hash = s.client_hash WHERE s.bound_user_id = %d OR s.user_id = %d ORDER BY s.created ASC, s.session_id ASC LIMIT %d OFFSET %d', $sessions, $agents, $user_id, $user_id, self::PAGE, $offset ), ARRAY_A ); $carts = AgentAdapt_WC_Session::stored( array_column( $rows, 'wc_session_key' ) ); foreach ( $rows as $i => $row ) { $out[] = self::item( 'sessions', (string) ( $offset + $i + 1 ), self::session( $row, $user_id, (array) ( $carts[ (string) $row['wc_session_key'] ]['cart'] ?? array() ) ) ); } } elseif ( 'activity' === $group ) { $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT e.id, e.created, e.channel, e.event, e.object_id, e.outcome, e.meta, a.name, a.client_id FROM %i e LEFT JOIN %i a ON a.client_hash = e.client_hash WHERE e.user_id = %d OR ( e.session_id <> '' AND e.session_id IN ( SELECT s.session_id FROM %i s WHERE s.bound_user_id = %d OR s.user_id = %d ) ) ORDER BY e.created ASC, e.id ASC LIMIT %d OFFSET %d", AgentAdapt_Schema::table( 'events' ), $agents, $user_id, $sessions, $user_id, $user_id, self::PAGE, $offset ), ARRAY_A ); foreach ( $rows as $row ) { $out[] = self::item( 'activity', (string) (int) $row['id'], self::activity( $row ) ); } } // phpcs:enable return $out; } /** * One export item. * * @param string $group Group. * @param string $id Item ID within the group. * @param array $data Fields. * @return array */ private static function item( string $group, string $id, array $data ): array { $groups = array( 'connections' => array( __( 'Connected AI agents', 'agent-adapt' ), __( 'AI agents you connected to your account at this store: what each one was allowed to do, when it was connected and last used, and when its access ends or ended.', 'agent-adapt' ) ), 'approvals' => array( __( 'AI agent sign-in approvals', 'agent-adapt' ), __( 'Approvals you gave on this store\'s sign-in pages for an AI agent to connect to your account. Each is deleted a day after it expires.', 'agent-adapt' ) ), 'sessions' => array( __( 'AI agent sessions', 'agent-adapt' ), __( 'Visits by AI agents that were signed in to your account at the time or later, with the cart each one holds.', 'agent-adapt' ) ), 'activity' => array( __( 'AI agent activity', 'agent-adapt' ), __( 'What AI agents did at this store in the sessions above: each event, when it happened, the agent, how it reached the store and the outcome.', 'agent-adapt' ) ), ); return array( 'group_id' => self::ID . '-' . $group, 'group_label' => $groups[ $group ][0], 'group_description' => $groups[ $group ][1], 'item_id' => self::ID . '-' . $group . '-' . $id, 'data' => $data, ); } /** * A connection's fields. * * @param array $row Grant columns (selected for export). * @return array */ private static function connection( array $row ): array { $data = self::agent( $row ); self::add( $data, __( 'What it can do', 'agent-adapt' ), self::scopes( (string) $row['scope'] ) ); self::add( $data, __( 'Connected at', 'agent-adapt' ), self::when( (int) $row['created'] ) ); self::add( $data, __( 'Last used at', 'agent-adapt' ), self::when( (int) $row['last_used'] ) ); self::add( $data, __( 'Access until', 'agent-adapt' ), self::when( (int) $row['expires'] ) ); if ( (int) $row['revoked'] ) { self::add( $data, __( 'Status', 'agent-adapt' ), __( 'Disconnected', 'agent-adapt' ) ); self::add( $data, __( 'Disconnected at', 'agent-adapt' ), self::when( (int) $row['revoked_at'] ) ); self::add( $data, __( 'Why it was disconnected', 'agent-adapt' ), self::reason( (string) $row['revoked_reason'] ) ); } else { self::add( $data, __( 'Status', 'agent-adapt' ), (int) $row['expires'] < time() ? __( 'Expired', 'agent-adapt' ) : __( 'Connected', 'agent-adapt' ) ); } return $data; } /** * A sign-in approval's fields. * * @param array $row Code columns (selected for export). * @return array */ private static function approval( array $row ): array { $data = self::agent( $row ); $statuses = array( 'approved' => (int) $row['expires'] < time() ? __( 'Expired before the agent used it', 'agent-adapt' ) : __( 'Approved, not used yet', 'agent-adapt' ), 'used' => __( 'Used to connect the agent', 'agent-adapt' ), ); self::add( $data, __( 'How you approved it', 'agent-adapt' ), 'device' === $row['kind'] ? __( 'With a code the agent showed you', 'agent-adapt' ) : __( 'On this store\'s sign-in page', 'agent-adapt' ) ); self::add( $data, __( 'What you approved', 'agent-adapt' ), self::scopes( (string) $row['granted_scope'] ) ); self::add( $data, __( 'Status', 'agent-adapt' ), $statuses[ (string) $row['status'] ] ?? (string) $row['status'] ); self::add( $data, __( 'Started at', 'agent-adapt' ), self::when( (int) $row['created'] ) ); self::add( $data, __( 'Expires at', 'agent-adapt' ), self::when( (int) $row['expires'] ) ); return $data; } /** * A Session's fields. * * @param array $row Session columns (selected for export). * @param int $user_id The account. * @param array> $cart Its stored cart lines. * @return array */ private static function session( array $row, int $user_id, array $cart ): array { $data = self::agent( $row ); self::add( $data, __( 'Started at', 'agent-adapt' ), self::when( (int) $row['created'] ) ); self::add( $data, __( 'Last used at', 'agent-adapt' ), self::when( (int) $row['last_used'] ) ); if ( (int) $row['ended'] ) { $status = __( 'Ended', 'agent-adapt' ); } elseif ( (int) $row['expires'] < time() ) { $status = __( 'Expired', 'agent-adapt' ); } elseif ( (int) $row['user_id'] === $user_id ) { $status = __( 'Signed in to your account', 'agent-adapt' ); } else { $status = __( 'Signed out', 'agent-adapt' ); } self::add( $data, __( 'Status', 'agent-adapt' ), $status ); if ( ! (int) $row['ended'] ) { self::add( $data, __( 'Ends if not used by', 'agent-adapt' ), self::when( (int) $row['expires'] ) ); } $lines = array(); foreach ( $cart as $line ) { if ( is_array( $line ) && (int) ( $line['product_id'] ?? 0 ) > 0 && (int) ( $line['quantity'] ?? 0 ) > 0 ) { $id = (int) ( $line['variation_id'] ?? 0 ) ? (int) $line['variation_id'] : (int) $line['product_id']; $product = wc_get_product( $id ); /* translators: %d: product ID */ $name = $product ? wp_strip_all_tags( $product->get_name() ) : sprintf( __( 'Product %d', 'agent-adapt' ), $id ); $lines[] = (int) $line['quantity'] . ' × ' . $name; } } self::add( $data, __( 'Cart', 'agent-adapt' ), $lines ? implode( '; ', $lines ) : __( 'Empty', 'agent-adapt' ) ); return $data; } /** * An event's fields. * * @param array $row Event columns (selected for export). * @return array */ private static function activity( array $row ): array { $data = array(); self::add( $data, __( 'Event', 'agent-adapt' ), (string) $row['event'] ); self::add( $data, __( 'Time', 'agent-adapt' ), self::when( (int) $row['created'] ) ); $data = array_merge( $data, self::agent( $row ) ); self::add( $data, __( 'Channel', 'agent-adapt' ), (string) $row['channel'] ); self::add( $data, __( 'Outcome', 'agent-adapt' ), (string) $row['outcome'] ); if ( (int) $row['object_id'] > 0 ) { self::add( $data, __( 'Product or order ID', 'agent-adapt' ), (string) (int) $row['object_id'] ); } $meta = json_decode( (string) $row['meta'], true ); if ( is_array( $meta ) && is_string( $meta['terms'] ?? null ) && '' !== $meta['terms'] ) { self::add( $data, __( 'Search terms', 'agent-adapt' ), $meta['terms'] ); } return $data; } /** * The agent, as it names itself, and its client_id. * * @param array $row Row with the agent's name and client_id. * @return array */ private static function agent( array $row ): array { $client_id = (string) ( $row['client_id'] ?? '' ); $name = (string) ( $row['name'] ?? '' ); $data = array(); self::add( $data, __( 'Agent', 'agent-adapt' ), '' !== $name ? $name : (string) wp_parse_url( $client_id, PHP_URL_HOST ) ); self::add( $data, __( 'Agent client ID', 'agent-adapt' ), $client_id ); return $data; } /** * Append a field. * * @param array $data Fields. * @param string $name Name. * @param string $value Value. */ private static function add( array &$data, string $name, string $value ): void { $data[] = array( 'name' => $name, 'value' => $value, ); } /** * Scopes in the words the customer approved them in. * * @param string $scope Space-separated scopes. */ private static function scopes( string $scope ): string { $desc = AgentAdapt_Signin::descriptions(); return implode( '; ', array_map( static fn( $s ) => (string) ( $desc[ $s ] ?? $s ), array_values( array_filter( explode( ' ', $scope ) ) ) ) ); } /** * Why a connection ended, in words. * * @param string $reason Stored reason (AgentAdapt_Grants::revoke()). */ private static function reason( string $reason ): string { $words = array( 'customer_disconnected' => __( 'You disconnected it.', 'agent-adapt' ), 'agent_signed_out' => __( 'The agent signed out.', 'agent-adapt' ), 'password_reset' => __( 'Your password was reset.', 'agent-adapt' ), 'password_changed' => __( 'Your password was changed.', 'agent-adapt' ), 'email_changed' => __( 'Your email address was changed.', 'agent-adapt' ), 'no_longer_eligible' => __( 'Your account could no longer be connected to agents.', 'agent-adapt' ), 'code_reused' => __( 'Its sign-in code was presented twice, so it was disconnected as a precaution.', 'agent-adapt' ), 'user_deleted' => __( 'Your account was deleted.', 'agent-adapt' ), ); return $words[ $reason ] ?? $reason; } /** * An agent's client_id by hash. * * @param string $hash Agent hash. */ private static function agent_client_id( string $hash ): string { global $wpdb; return (string) $wpdb->get_var( $wpdb->prepare( 'SELECT client_id FROM %i WHERE client_hash = %s', AgentAdapt_Schema::table( 'agents' ), $hash ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * The account with this email address, or 0. * * @param string $email Email address. */ private static function user_id( string $email ): int { $user = '' !== $email ? get_user_by( 'email', $email ) : false; return $user instanceof WP_User ? (int) $user->ID : 0; } /** * A time for the export (UTC, as the other Agent Adapt exports write it). * * @param int $ts Unix time. */ private static function when( int $ts ): string { return $ts > 0 ? gmdate( 'Y-m-d\TH:i:s\Z', $ts ) : ''; } }