*/ private array $frontend_results_by_signature = []; /** * Constructor * * @param Settings|null $settings Optional; falls back to a fresh Settings. */ public function __construct( ?Settings $settings = null ) { $this->settings = $settings ?? new Settings(); parent::__construct( 'eventin-event-search', 'Search published events on this website by keyword, category, date range, or price. Returns event summaries (title, date, place, price range, seats left). Call this whenever a visitor asks about events. Pass an EMPTY query to list what is coming up.' ); } /** * Define tool properties * * @return array */ protected function properties(): array { return [ new ToolProperty( name: 'query', type: PropertyType::STRING, description: 'Search keywords from the event title or description. Omit or pass an empty string to list upcoming events.', required: false ), new ToolProperty( name: 'category', type: PropertyType::STRING, description: 'Event category slug to filter by.', required: false ), new ToolProperty( name: 'from_date', type: PropertyType::STRING, description: 'Only events still running on or after this date, format YYYY-MM-DD. Defaults to today.', required: false ), new ToolProperty( name: 'to_date', type: PropertyType::STRING, description: 'Latest event start date, format YYYY-MM-DD.', required: false ), new ToolProperty( name: 'max_price', type: PropertyType::NUMBER, description: 'Only return events with at least one ticket at or below this price.', required: false ), new ToolProperty( name: 'per_page', type: PropertyType::NUMBER, description: 'Maximum number of events to return. The site caps this to its configured limit.', required: false ), ]; } /** * Execute the tool * * Every parameter is nullable: NeuronAI passes null (not the default) when * the model omits an optional property, and a non-nullable type would * TypeError at the call site, outside this try/catch. * * @param string|null $query Search keywords. * @param string|null $category Category slug. * @param string|null $from_date Events that end on or after this date, YYYY-MM-DD. * @param string|null $to_date Latest start date, YYYY-MM-DD. * @param float|null $max_price Highest acceptable ticket price. * @param int|null $per_page Maximum results. * @return array */ public function __invoke( ?string $query = '', ?string $category = null, ?string $from_date = null, ?string $to_date = null, ?float $max_price = null, ?int $per_page = null ): array { if ( ! class_exists( 'Wpeventin' ) ) { return [ 'error' => 'Eventin is not active on this website.' ]; } // \Throwable, not \Exception: a bad meta shape can raise a PHP Error that // would otherwise crash the whole agent turn. try { $search = sanitize_text_field( (string) ( $query ?? '' ) ); // Merchant's configured limit is the ceiling; the hard cap guards a // misconfigured value. $configured = (int) ( new Public_Agent_Settings( $this->settings ) ) ->integration( 'eventin', 'recommendedLimit', 3 ); $configured = min( max( 1, $configured ), self::MAX_RESULTS ); $limit = min( max( 1, $per_page ?? $configured ), $configured ); // Tell the model when its count was capped, so a short list is not read // as "the site only has N events". $clamp_note = ( null !== $per_page && $per_page > $limit ) ? sprintf( 'Requested %d events but the site limit is %d.', $per_page, $limit ) : ''; // Slugify to match term slugs, and reject an unknown category loudly // rather than silently returning nothing. $category_slug = ''; if ( ! empty( $category ) ) { $category_slug = sanitize_title( $category ); if ( ! term_exists( $category_slug, 'etn_category' ) ) { return [ 'error' => 'Category "' . $category_slug . '" was not found on this website. Try a different category, or search without one.' ]; } } $from = $this->normalize_date( $from_date ) ?? gmdate( 'Y-m-d' ); $to = $this->normalize_date( $to_date ); if ( null !== $to && $to < $from ) { return [ 'error' => 'The date range is backwards: from_date (' . $from . ') is after to_date (' . $to . '). Swap them or drop one.' ]; } $signature = md5( strtolower( $search ) . '|' . $limit . '|' . $category_slug . '|' . $from . '|' . ( $to ?? '' ) . '|' . ( $max_price ?? '' ) ); $cache_key = 'aisentic_etn_search_' . $signature; $cached = get_transient( $cache_key ); if ( false !== $cached ) { if ( '' !== $clamp_note ) { $cached['note'] = $clamp_note; } return $this->finalize_result( $cached ); } $events = $this->query_events( $search, $category_slug, $from, $to, $limit, $max_price ); $result = [ 'events' => $events, 'total' => count( $events ), 'query' => $search, // Hint for which rich UI to render. 'ui_hint' => 'event_carousel', // Keys the rich payload so two searches in one turn never cross. 'signature' => $signature, ]; $configured_ttl = (int) ( new Public_Agent_Settings( $this->settings ) ) ->integration( 'eventin', 'searchCacheTtlSeconds', 300 ); // Floor at 60s: absint() would turn 0 or a negative into 0, and // set_transient( ..., 0 ) means NO expiration at all. $ttl = max( 60, absint( apply_filters( 'aisentic_etn_search_cache_ttl', $configured_ttl ) ) ); // Never cache an empty result — it would poison every retry inside the // TTL with a stale "nothing found". if ( ! empty( $events ) ) { set_transient( $cache_key, $result, $ttl ); } // Applied after caching so the note does not stick to an entry shared // across different requested counts. if ( '' !== $clamp_note ) { $result['note'] = $clamp_note; } return $this->finalize_result( $result ); } catch ( \Throwable $e ) { // Log the real cause for the operator; the model gets a generic // non-retry message. Log::error( 'Eventin event search failed: ' . $e->getMessage() ); return [ 'error' => 'Event search is temporarily unavailable. Do not retry — tell the visitor you could not look that up right now.' ]; } } /** * Find events that have not finished yet, and shape each one. * * Events that start on or after $from come first. Events that started * earlier but are still running (a multi-day conference, a course) only * fill the places that are left, so an old long event never pushes a new * one out of the list. * * @param string $search Keywords. * @param string $category_slug Category slug or ''. * @param string $from Events that end on or after this date. * @param string|null $to Latest start date. * @param int $limit Maximum results. * @param float|null $max_price Price ceiling. * @return array */ private function query_events( string $search, string $category_slug, string $from, ?string $to, int $limit, ?float $max_price ): array { $events = $this->find_events( $search, '', $category_slug, $from, $to, $limit, $max_price ); // WordPress search reads only the title and the text, but Eventin keeps // the address in post meta. So "Dhaka" missed an event held in Dhaka. // Address matches fill the places the text matches left. if ( '' !== $search && count( $events ) < $limit ) { foreach ( $this->find_events( '', $search, $category_slug, $from, $to, $limit, $max_price ) as $id => $event ) { $events[ $id ] = $events[ $id ] ?? $event; } } return array_slice( $events, 0, $limit ); } /** * Find events for one search: upcoming ones first, then running ones. * * @param string $search Keywords for the title and text, or ''. * @param string $location Text the address must contain, or ''. * @param string $category_slug Category slug or ''. * @param string $from Events that end on or after this date. * @param string|null $to Latest start date. * @param int $limit Maximum results. * @param float|null $max_price Price ceiling. * @return array */ private function find_events( string $search, string $location, string $category_slug, string $from, ?string $to, int $limit, ?float $max_price ): array { $upcoming = [ [ 'key' => 'etn_start_date', 'value' => $from, 'compare' => '>=', 'type' => 'DATE', ], ]; if ( null !== $to ) { $upcoming[] = [ 'key' => 'etn_start_date', 'value' => $to, 'compare' => '<=', 'type' => 'DATE', ]; } $events = $this->run_query( $this->query_args( $search, $location, $category_slug, $limit, $upcoming ), $max_price ); if ( count( $events ) >= $limit ) { return array_slice( $events, 0, $limit, true ); } // Started before $from, so it is also before $to: no upper bound needed. $running = [ [ 'key' => 'etn_start_date', 'value' => $from, 'compare' => '<', 'type' => 'DATE', ], [ 'key' => 'etn_end_date', 'value' => $from, 'compare' => '>=', 'type' => 'DATE', ], ]; foreach ( $this->run_query( $this->query_args( $search, $location, $category_slug, $limit, $running ), $max_price ) as $id => $event ) { $events[ $id ] = $events[ $id ] ?? $event; } return array_slice( $events, 0, $limit, true ); } /** * Build the WP_Query args for one date filter. * * @param string $search Keywords. * @param string $location Text the address must contain, or ''. * @param string $category_slug Category slug or ''. * @param int $limit Maximum results. * @param array $meta_query Date conditions. * @return array */ private function query_args( string $search, string $location, string $category_slug, int $limit, array $meta_query ): array { if ( '' !== $location ) { // The address is a serialized array, so LIKE finds the text inside it. $meta_query[] = [ 'key' => 'etn_event_location', 'value' => $location, 'compare' => 'LIKE', ]; } $args = [ 'post_type' => 'etn', // publish only. Never drafts, private, or password-protected events. 'post_status' => 'publish', // Over-fetch: run_query() drops recurring parents and events that // ended earlier today, which would otherwise leave the list short. 'posts_per_page' => $limit * 3, 's' => $search, // Eventin stores the event date in post meta, so ordering and date // filtering have no alternative here. The result set is capped at 12 // and the whole call is cached for at least 60 seconds. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_key' => 'etn_start_date', 'orderby' => 'meta_value', 'order' => 'ASC', 'has_password' => false, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => $meta_query, ]; if ( '' !== $category_slug ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query $args['tax_query'] = [ [ 'taxonomy' => 'etn_category', 'field' => 'slug', 'terms' => $category_slug, ], ]; } return $args; } /** * Run one query and shape each event, keyed by event id. * * @param array $args WP_Query args. * @param float|null $max_price Price ceiling. * @return array */ private function run_query( array $args, ?float $max_price ): array { $events = []; $loop = new \WP_Query( $args ); while ( $loop->have_posts() ) { $loop->the_post(); $post_id = (int) get_the_ID(); // A recurring parent spans the whole series. Its children are the real // dates, so the parent would show the first (past) date as upcoming. if ( self::is_recurring_parent( $post_id ) || self::has_ended( $post_id ) ) { continue; } $event = $this->format_event( $post_id, (string) get_the_title() ); if ( null === $event ) { continue; } // A price filter drops any event whose cheapest ticket is over budget. if ( null !== $max_price && null !== $event['price_min'] && $event['price_min'] > $max_price ) { continue; } $events[ $event['id'] ] = $event; } wp_reset_postdata(); return $events; } /** * Whether the event is the parent of a recurring series. * * @param int $post_id Event post id. * @return bool */ public static function is_recurring_parent( int $post_id ): bool { return 0 === (int) wp_get_post_parent_id( $post_id ) && 'yes' === get_post_meta( $post_id, 'recurring_enabled', true ); } /** * Whether the event end time has already passed. * * The date query only compares dates, so an event that ended at 5 PM today * still matches at 7 PM. This checks the end time in the event's own * timezone. An event with no readable end stays in the list. * * @param int $post_id Event post id. * @return bool */ public static function has_ended( int $post_id ): bool { $end = self::event_timestamp( $post_id, 'etn_end_date', 'etn_end_time', '11:59 PM' ); return null !== $end && $end < time(); } /** * Whether the event start time has already passed. * * Together with has_ended() this tells a long event that is on now (a * course, a fair) apart from one that starts later. Its start date can be * months ago, which reads as a past event when shown alone. * * @param int $post_id Event post id. * @return bool */ public static function has_started( int $post_id ): bool { $start = self::event_timestamp( $post_id, 'etn_start_date', 'etn_start_time', '12:00 AM' ); return null !== $start && $start <= time(); } /** * One stored event date and time as a timestamp, in the event's own zone. * * @param int $post_id Event post id. * @param string $date_key Meta key of the date. * @param string $time_key Meta key of the time. * @param string $default_time Time to use when none is stored. * @return int|null Null when the date is missing or unreadable. */ private static function event_timestamp( int $post_id, string $date_key, string $time_key, string $default_time ): ?int { $date = (string) get_post_meta( $post_id, $date_key, true ); if ( '' === $date ) { return null; } $time = (string) get_post_meta( $post_id, $time_key, true ); $timezone = (string) get_post_meta( $post_id, 'event_timezone', true ); try { $zone = '' !== $timezone ? new \DateTimeZone( $timezone ) : wp_timezone(); } catch ( \Exception $e ) { $zone = wp_timezone(); } try { $moment = new \DateTimeImmutable( trim( $date . ' ' . ( '' !== $time ? $time : $default_time ) ), $zone ); } catch ( \Exception $e ) { return null; } return $moment->getTimestamp(); } /** * Build one event summary from its post id. * * @param int $post_id Event post id. * @param string $title Event title. * @return array|null Null when the event has no sellable ticket at all. */ private function format_event( int $post_id, string $title ): ?array { $variations = get_post_meta( $post_id, 'etn_ticket_variations', true ); $variations = is_array( $variations ) ? $variations : []; $location_meta = get_post_meta( $post_id, 'etn_event_location', true ); $location = is_array( $location_meta ) ? (string) ( $location_meta['address'] ?? '' ) : ''; $tickets = []; $prices = []; $seats_left = 0; $unlimited = false; $timezone = (string) get_post_meta( $post_id, 'event_timezone', true ); foreach ( $variations as $variation ) { if ( ! is_array( $variation ) ) { continue; } if ( false === ( $variation['etn_enable_ticket'] ?? true ) ) { continue; } // Only tickets on sale right now. A ticket whose sale has ended or not // started would be offered here and then refused by the ticket check. if ( 'open' !== Eventin_Ticket_Action_Tool::sale_window_state( $variation, $timezone )['state'] ) { continue; } $price = (float) ( $variation['etn_ticket_price'] ?? 0 ); $prices[] = $price; $available = $this->seats_for_ticket( $variation ); if ( null === $available ) { $unlimited = true; } else { $seats_left += $available; } $tickets[] = [ 'slug' => (string) ( $variation['etn_ticket_slug'] ?? '' ), 'name' => self::decode_text( (string) ( $variation['etn_ticket_name'] ?? '' ) ), 'price' => $price, 'is_free' => 0.0 === $price, 'seats_left' => $available, 'min' => isset( $variation['etn_min_ticket'] ) ? (int) $variation['etn_min_ticket'] : null, 'max' => isset( $variation['etn_max_ticket'] ) ? (int) $variation['etn_max_ticket'] : null, ]; } if ( empty( $tickets ) ) { return null; } $categories = wp_get_post_terms( $post_id, 'etn_category', [ 'fields' => 'names' ] ); return [ 'id' => $post_id, 'title' => self::decode_text( $title ), 'permalink' => (string) get_permalink( $post_id ), 'image_url' => (string) get_the_post_thumbnail_url( $post_id, 'medium' ), 'start_datetime' => trim( (string) get_post_meta( $post_id, 'etn_start_date', true ) . ' ' . (string) get_post_meta( $post_id, 'etn_start_time', true ) ), 'end_datetime' => trim( (string) get_post_meta( $post_id, 'etn_end_date', true ) . ' ' . (string) get_post_meta( $post_id, 'etn_end_time', true ) ), // On now: started already (ended events never reach here). The card // then shows "On now, until " instead of an old start date. 'is_running' => self::has_started( $post_id ), 'timezone' => $timezone, 'location' => self::decode_text( $location ), 'categories' => is_array( $categories ) ? array_map( [ self::class, 'decode_text' ], $categories ) : [], 'price_min' => empty( $prices ) ? null : min( $prices ), 'price_max' => empty( $prices ) ? null : max( $prices ), 'currency' => function_exists( 'etn_currency' ) ? etn_currency() : 'USD', 'currency_symbol' => self::currency_symbol(), 'is_free' => ! empty( $prices ) && 0.0 === max( $prices ), // null means "no limit set", which is NOT the same as 0 seats left. 'seats_left' => $unlimited ? null : $seats_left, 'sale_open' => true, 'has_seat_plan' => (bool) get_post_meta( $post_id, 'enable_seatmap', true ), 'tickets' => $tickets, ]; } /** * Decode HTML entities in text that the chat renders literally. * * Two sources produce encoded text: WordPress returns post titles with * entities (an en dash comes back as "–"), and Eventin stores currency * symbols encoded in utils/currency.php ("$" for USD). React renders card * text as literal text, so anything left encoded is shown raw to the visitor. * * @param string $text Possibly encoded text. * @return string */ public static function decode_text( string $text ): string { return html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } /** * Collapse an eventin-event-search result payload into one compact line per * event: "#id title — date tz — price". Returns null when the payload has no * usable event list so the caller falls back to truncation. * * Chat_Controller stores this in place of the full result, so the model on * the next turn still sees each event without the full payload. * * @param string $json JSON-encoded event-search result. * @return string|null */ public static function summarize_for_history( string $json ): ?string { $data = json_decode( $json, true ); if ( ! is_array( $data ) || empty( $data['events'] ) || ! is_array( $data['events'] ) ) { return null; } $rows = []; foreach ( $data['events'] as $event ) { if ( ! is_array( $event ) ) { continue; } $id = isset( $event['id'] ) ? (int) $event['id'] : 0; $title = isset( $event['title'] ) ? trim( wp_strip_all_tags( (string) $event['title'] ) ) : ''; $when = trim( (string) ( $event['start_datetime'] ?? '' ) . ' ' . (string) ( $event['timezone'] ?? '' ) ); // A long event that started months ago is on now. Say so, or the model // on the next turn reads the old start date as a past event. if ( ! empty( $event['is_running'] ) && ! empty( $event['end_datetime'] ) ) { $when = trim( 'on now, until ' . (string) $event['end_datetime'] . ' ' . (string) ( $event['timezone'] ?? '' ) ); } if ( ! empty( $event['is_free'] ) ) { $price = 'free'; } else { $symbol = (string) ( $event['currency_symbol'] ?? '' ); $min = $event['price_min'] ?? null; $max = $event['price_max'] ?? null; $price = null === $min ? '' : $symbol . $min . ( ( null !== $max && $max !== $min ) ? '–' . $symbol . $max : '' ); } // The real ticket names. Without them the model guessed names like // "free" on the next turn, and the ticket check refused them. $names = []; foreach ( (array) ( $event['tickets'] ?? [] ) as $ticket ) { $name = is_array( $ticket ) ? trim( (string) ( $ticket['name'] ?? '' ) ) : ''; if ( '' !== $name ) { $names[] = $name; } } $rows[] = '#' . $id . ' ' . $title . ( '' !== $when ? ' — ' . $when : '' ) . ( '' !== $price ? ' — ' . $price : '' ) . ( ! empty( $names ) ? ' — tickets: ' . implode( ', ', $names ) : '' ); } if ( empty( $rows ) ) { return null; } $summary = count( $rows ) . ' event(s): ' . implode( '; ', $rows ) . '. Pass these event ids straight to eventin-ticket-action without searching again.'; // Bound the worst case without losing the per-event structure. if ( mb_strlen( $summary ) > 2000 ) { $summary = mb_substr( $summary, 0, 2000 ) . '…(more events omitted)'; } return $summary; } /** * Currency symbol as a plain character. * * @return string */ public static function currency_symbol(): string { $symbol = function_exists( 'etn_currency_symbol' ) ? (string) etn_currency_symbol() : '$'; return self::decode_text( $symbol ); } /** * Seats left for one ticket type, or null when it is unlimited. * * Mirrors validateAvailableQuantity() in Eventin's ticket-selector. * `etn_avaiilable_tickets` is Eventin's spelling — two i's. Correcting it * silently breaks every seat count. * * @param array $variation One ticket variation. * @return int|null */ private function seats_for_ticket( array $variation ): ?int { $total = $variation['etn_avaiilable_tickets'] ?? null; if ( true === ( $variation['etn_unlimited_tickets'] ?? false ) || null === $total || '' === $total || -1 === (int) $total ) { return null; } $sold = (int) ( $variation['etn_sold_tickets'] ?? 0 ); $pending = (int) ( $variation['pending'] ?? 0 ); return max( 0, (int) $total - ( $sold + $pending ) ); } /** * Accept only a YYYY-MM-DD date. Anything else becomes null. * * @param string|null $raw Raw date string. * @return string|null */ private function normalize_date( ?string $raw ): ?string { if ( empty( $raw ) ) { return null; } $raw = trim( $raw ); return preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw ) ? $raw : null; } /** * Stash the rich result for the SSE layer; cut `tickets` down to name and * price in the agent-facing copy to keep input tokens down. The names stay, * so the model passes a real ticket name instead of guessing one ("free"). * * @param array $result The full (rich) search result. * @return array The lean result to return to the agent. */ private function finalize_result( array $result ): array { $this->last_frontend_result = $result; if ( ! empty( $result['signature'] ) ) { $this->frontend_results_by_signature[ (string) $result['signature'] ] = $result; } if ( ! empty( $result['events'] ) && is_array( $result['events'] ) ) { $result['events'] = array_map( static function ( $event ) { if ( is_array( $event ) && isset( $event['tickets'] ) && is_array( $event['tickets'] ) ) { $event['tickets'] = array_map( static function ( $ticket ) { return [ 'name' => $ticket['name'] ?? '', 'price' => $ticket['price'] ?? 0, ]; }, $event['tickets'] ); } return $event; }, $result['events'] ); } return $result; } /** * Rich result of the last search, or null if none has run. * * @return array|null */ public function get_last_frontend_result(): ?array { return $this->last_frontend_result; } /** * Rich result for a specific search, matched by its input signature (the * query string alone is ambiguous when several ran in one turn). * * @param string $signature Signature from the tool result's `signature` field. * @return array|null */ public function get_frontend_result( string $signature ): ?array { return $this->frontend_results_by_signature[ $signature ] ?? null; } }