'sessions', 'session.signed_in' => 'signins', 'cart.handoff' => 'handoffs', 'order.placed' => 'orders', ); /** * Feedback: the most groups shown, and the most descriptions in each. */ private const FEEDBACK_GROUPS = 25; private const FEEDBACK_DESCRIPTIONS = 10; /** * Hook registration. */ public static function init(): void { add_action( 'admin_menu', array( __CLASS__, 'menu' ), 60 ); add_action( 'admin_post_' . self::ACTION, array( __CLASS__, 'handle_status' ) ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'styles' ) ); } /** * Submenu under WooCommerce. */ public static function menu(): void { add_submenu_page( 'woocommerce', __( 'Personal agents', 'agent-adapt' ), __( 'Agents', 'agent-adapt' ), 'manage_woocommerce', self::PAGE, array( __CLASS__, 'render' ) ); } /** * Agents with their activity over the last 30 days. * * @return array> */ public static function rows(): array { global $wpdb; $a = AgentAdapt_Schema::table( 'agents' ); $s = AgentAdapt_Schema::table( 'sessions' ); $e = AgentAdapt_Schema::table( 'events' ); $since = time() - 30 * DAY_IN_SECONDS; // phpcs:disable WordPress.DB.DirectDatabaseQuery return (array) $wpdb->get_results( $wpdb->prepare( "SELECT a.client_id, a.client_hash, a.name, a.status, a.first_seen, a.last_seen, (SELECT COUNT(*) FROM %i s WHERE s.client_hash = a.client_hash AND s.created >= %d) AS sessions, (SELECT COUNT(*) FROM %i e WHERE e.client_hash = a.client_hash AND e.event = 'order.placed' AND e.created >= %d) AS orders FROM %i a ORDER BY a.last_seen DESC LIMIT 200", $s, $since, $e, $since, $a ), ARRAY_A ); // phpcs:enable } /** * What agents did between two moments, counted from the event stream: * Sessions started, customers signed in, carts handed to customers for * checkout, and orders placed after a handoff. * * @param int $since Start (inclusive), Unix time. * @param int $until End (exclusive), Unix time. * @return array{sessions: int, signins: int, handoffs: int, orders: int} */ public static function activity( int $since, int $until ): array { global $wpdb; $out = array_fill_keys( array_values( self::ACTIVITY ), 0 ); $rows = (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT event, COUNT(*) AS n FROM %i WHERE event IN ('session.started', 'session.signed_in', 'cart.handoff', 'order.placed') AND created >= %d AND created < %d GROUP BY event", AgentAdapt_Schema::table( 'events' ), $since, $until ), ARRAY_A ); foreach ( $rows as $r ) { $out[ self::ACTIVITY[ $r['event'] ] ] = (int) $r['n']; } return $out; } /** * Render the screen. */ public static function render(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You do not have permission to manage agents.', 'agent-adapt' ), 403 ); } $rows = self::rows(); $now = time(); echo '

' . esc_html__( 'Personal agents', 'agent-adapt' ) . '

'; echo '

' . esc_html__( 'Agents that have started a Session with your store, identified by the address they publish (their client ID). Blocking an agent ends its access at once and refuses new Sessions.', 'agent-adapt' ) . '

'; if ( isset( $_GET['agentadapt_updated'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only flag. echo '

' . esc_html__( 'Agent updated.', 'agent-adapt' ) . '

'; } $activity = self::activity( $now - 30 * DAY_IN_SECONDS, $now + 1 ); echo '

' . esc_html__( 'The last 30 days', 'agent-adapt' ) . '

'; echo '
'; $labels = array( 'sessions' => __( 'Agent Sessions', 'agent-adapt' ), 'signins' => __( 'Customer sign-ins', 'agent-adapt' ), 'handoffs' => __( 'Carts handed to customers', 'agent-adapt' ), 'orders' => __( 'Orders through agents', 'agent-adapt' ), ); foreach ( $labels as $k => $label ) { echo '
' . esc_html( $label ) . '
' . esc_html( number_format_i18n( $activity[ $k ] ) ) . '
'; } echo '
'; echo '

' . esc_html__( 'A cart is handed to the customer as a checkout link they open in their own browser; orders count when they check out through it. To list those orders, filter WooCommerce → Orders by "Placed via a personal agent".', 'agent-adapt' ) . '

'; /** * Below the 30-day activity summary. * * @param array{sessions: int, signins: int, handoffs: int, orders: int} $activity The counts shown. */ do_action( 'agentadapt_agents_activity', $activity ); self::render_feedback( $now ); echo '

' . esc_html__( 'Agents', 'agent-adapt' ) . '

'; echo ''; foreach ( array( __( 'Agent', 'agent-adapt' ), __( 'Client ID', 'agent-adapt' ), __( 'Sessions (30 days)', 'agent-adapt' ), __( 'Orders (30 days)', 'agent-adapt' ), __( 'Last seen', 'agent-adapt' ), __( 'Status', 'agent-adapt' ), '' ) as $h ) { echo ''; } echo ''; if ( ! $rows ) { echo ''; } foreach ( $rows as $r ) { $blocked = 'blocked' === $r['status']; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html( $h ) . '
' . esc_html__( 'No personal agent has visited yet.', 'agent-adapt' ) . '
' . esc_html( '' !== $r['name'] ? $r['name'] : __( '(unnamed)', 'agent-adapt' ) ) . '' . esc_html( $r['client_id'] ) . '' . esc_html( number_format_i18n( (int) $r['sessions'] ) ) . '' . esc_html( number_format_i18n( (int) $r['orders'] ) ) . '' . esc_html( $r['last_seen'] ? human_time_diff( (int) $r['last_seen'] ) : '—' ) . '' . esc_html( $blocked ? __( 'Blocked', 'agent-adapt' ) : __( 'Allowed', 'agent-adapt' ) ) . '
'; wp_nonce_field( self::ACTION ); echo ''; echo ''; echo ''; echo ''; echo '
'; /** * Below the agents table: premium policy controls attach here. * * @param array> $rows The agents listed. */ do_action( 'agentadapt_agents_screen', $rows ); echo '
'; } /** * "What agents couldn't do": feedback reports (spec §8) from the last 30 * days, grouped by what the customer wanted to do, compared ignoring case * and runs of whitespace. Every value on it was written by an agent, so * every value is escaped and the copy says reports can be wrong. Nothing * here, or anywhere, acts on a report or gives one to a model. * * @param int $now Unix time. */ public static function render_feedback( int $now ): void { $since = $now - 30 * DAY_IN_SECONDS; $groups = AgentAdapt_Feedback_Store::groups( $since, self::FEEDBACK_GROUPS ); echo '

' . esc_html__( 'What agents couldn\'t do', 'agent-adapt' ) . '

'; echo '

' . esc_html__( 'When a customer asks their personal agent to do something at your store and the agent finds no way to do it, the agent can report it here. Reports from the last 30 days are grouped by what the customer wanted to do, the most reported first.', 'agent-adapt' ) . '

'; echo '

' . esc_html__( 'Each report is written by the customer\'s agent, not by the customer and not by Agent Adapt. It can be mistaken, and any agent can send one, so treat it as a lead to check rather than an instruction. Agents are asked to leave out names and order details; if a report includes them anyway, handle it as you would any customer\'s personal data.', 'agent-adapt' ) . '

'; if ( ! $groups ) { echo '

' . esc_html__( 'No reports in the last 30 days.', 'agent-adapt' ) . '

'; return; } echo ''; foreach ( array( __( 'What the customer wanted to do', 'agent-adapt' ), __( 'Reports', 'agent-adapt' ), __( 'From', 'agent-adapt' ), __( 'Last reported', 'agent-adapt' ), __( 'What the agents said', 'agent-adapt' ) ) as $h ) { echo ''; } echo ''; foreach ( $groups as $g ) { $reports = AgentAdapt_Feedback_Store::in_group( $g['task_key'], $since, self::FEEDBACK_DESCRIPTIONS ); $agents = array_unique( array_map( array( __CLASS__, 'agent_label' ), AgentAdapt_Feedback_Store::agents_in_group( $g['task_key'], $since ) ) ); $shown = count( $reports ); $summary = $g['reports'] > $shown /* translators: 1: number of descriptions shown, 2: number of reports. */ ? sprintf( __( 'Show the latest %1$d of %2$d', 'agent-adapt' ), $shown, $g['reports'] ) /* translators: %d: number of descriptions. */ : sprintf( _n( 'Show %d description', 'Show %d descriptions', $shown, 'agent-adapt' ), $shown ); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html( $h ) . '
' . esc_html( (string) ( $reports[0]['task'] ?? '' ) ) . '' . esc_html( number_format_i18n( $g['reports'] ) ) . '' . esc_html( implode( ', ', $agents ) ) . '' . esc_html( self::ago( $g['last'], $now ) ) . '
' . esc_html( $summary ) . '
    '; foreach ( $reports as $r ) { $about = self::agent_label( $r ) . ' · ' . self::ago( (int) $r['created'], $now ); if ( ! in_array( (string) $r['kind'], AgentAdapt_Feedback::KINDS, true ) ) { /* translators: %s: the kind of report, as the agent sent it. */ $about .= ' · ' . sprintf( __( 'reported as "%s"', 'agent-adapt' ), (string) $r['kind'] ); } echo '
  • '; } echo '
'; } /** * An agent's name, or its client ID's host when it publishes none. * * @param array $row Row with agent_name and client_id. */ private static function agent_label( array $row ): string { $name = trim( (string) ( $row['agent_name'] ?? '' ) ); if ( '' !== $name ) { return $name; } $host = (string) wp_parse_url( (string) ( $row['client_id'] ?? '' ), PHP_URL_HOST ); return '' !== $host ? $host : __( '(unknown agent)', 'agent-adapt' ); } /** * "5 mins ago". * * @param int $then Unix time. * @param int $now Unix time. */ private static function ago( int $then, int $now ): string { /* translators: %s: a length of time, such as "5 mins". */ return sprintf( __( '%s ago', 'agent-adapt' ), human_time_diff( $then, $now ) ); } /** * The admin stylesheet, on this screen and on a user's profile (connected agents) only. * * @param string $hook The admin page's hook suffix. */ public static function styles( string $hook ): void { if ( 'woocommerce_page_' . self::PAGE === $hook || 'user-edit.php' === $hook || 'profile.php' === $hook ) { wp_enqueue_style( 'agentadapt-admin', AGENTADAPT_PLUGIN_URL . 'assets/css/admin.css', array(), AGENTADAPT_VERSION ); } } /** * admin-post: block or unblock. The capability and the nonce are checked * before any submitted value is read. */ public static function handle_status(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You do not have permission to manage agents.', 'agent-adapt' ), 403 ); } check_admin_referer( self::ACTION ); $hash = isset( $_POST['client_hash'] ) ? sanitize_key( wp_unslash( $_POST['client_hash'] ) ) : ''; self::set_status( $hash, ! empty( $_POST['block'] ) ); wp_safe_redirect( add_query_arg( array( 'page' => self::PAGE, 'agentadapt_updated' => 1 ), admin_url( 'admin.php' ) ) ); exit; } /** * Block or unblock by hash. * * @param string $hash Agent hash. * @param bool $blocked Block. */ public static function set_status( string $hash, bool $blocked ): bool { global $wpdb; $t = AgentAdapt_Schema::table( 'agents' ); $cid = $wpdb->get_var( $wpdb->prepare( "SELECT client_id FROM %i WHERE client_hash = %s", $t, $hash ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ( ! $cid ) { return false; } AgentAdapt_Agents::set_blocked( (string) $cid, $blocked ); return true; } }