'Free Entry', 'slug' => 'free-entry', 'available' => 100, 'min' => 1, 'max' => 5, ]; /** * Constructor */ public function __construct() { parent::__construct( 'eventin-create-event', 'Create, add, or schedule a new Eventin event, conference, workshop, seminar, or gathering. ' . 'IMPORTANT: The event will NOT be published automatically — ' . 'it is always saved as a draft so the admin can review it before going live. ' . 'Required fields: title, timezone, start_date (YYYY-MM-DD), end_date (YYYY-MM-DD), ' . 'start_time (HH:MM:SS), end_time (HH:MM:SS), description. ' . 'Always write a full description — an opening paragraph about the event, a short ' . '"What you will get" list, and a "Who it is for" line. ' . 'Always write the faq argument too — 3 or 4 short questions and answers that match ' . 'the description you just wrote. ' . 'A banner image and a featured image are created automatically from the title, date and place. ' . 'The event is given the "Event Template One" layout and one free ticket so the admin ' . 'has a complete page to review. ' . 'If the event title is NOT in the Latin alphabet, also send banner_title with a short English version. ' . 'For offline and hybrid events, the location address is required. ' . 'For online and hybrid events, pass meeting_link when the admin has given one. ' . 'Before assigning categories or tags, call eventin-list-categories and ' . 'eventin-list-tags first to obtain their numeric IDs.' ); } /** * Define tool properties * * @return array */ protected function properties(): array { return [ new ToolProperty( name: 'title', type: PropertyType::STRING, description: 'The event title.', required: true ), new ToolProperty( name: 'timezone', type: PropertyType::STRING, description: 'Valid PHP timezone identifier, e.g. America/New_York, Europe/London, UTC.', required: true ), new ToolProperty( name: 'start_date', type: PropertyType::STRING, description: 'Event start date in YYYY-MM-DD format.', required: true ), new ToolProperty( name: 'end_date', type: PropertyType::STRING, description: 'Event end date in YYYY-MM-DD format.', required: true ), new ToolProperty( name: 'start_time', type: PropertyType::STRING, description: 'Event start time in HH:MM:SS format (24-hour), e.g. 09:00:00.', required: true ), new ToolProperty( name: 'end_time', type: PropertyType::STRING, description: 'Event end time in HH:MM:SS format (24-hour), e.g. 17:00:00.', required: true ), new ToolProperty( name: 'description', type: PropertyType::STRING, description: 'The event page body as HTML. Write a real page, not one line: ' . 'an opening paragraph about the event, a short "What you will get" list, ' . 'and a "Who it is for" line. At least 120 characters.', required: true ), new ToolProperty( name: 'event_type', type: PropertyType::STRING, description: 'Event type. Accepted values: offline, online, hybrid. ' . 'offline means a real place people travel to (Eventin shows it as "Venue"), ' . 'online means a virtual event, hybrid means both. ' . 'For offline and hybrid, location is required.', required: false ), new ToolProperty( name: 'location', type: PropertyType::STRING, description: 'Event location address as plain text, e.g. "24 Main Street, Boston, MA". ' . 'This is the Venue Name shown on the event page. ' . 'Required for offline and hybrid events.', required: false ), new ToolProperty( name: 'category_ids', type: PropertyType::STRING, description: 'Optional comma-separated list of category IDs to assign, e.g. "12,15". ' . 'Call eventin-list-categories first to get valid IDs.', required: false ), new ToolProperty( name: 'tag_ids', type: PropertyType::STRING, description: 'Optional comma-separated list of tag IDs to assign, e.g. "7,9". ' . 'Call eventin-list-tags first to get valid IDs.', required: false ), new ToolProperty( name: 'meeting_link', type: PropertyType::STRING, description: 'The online meeting URL for online and hybrid events, ' . 'e.g. https://meet.google.com/abc-defg-hij or a Zoom link. ' . 'Ignored for offline events. If the admin has not decided the link yet, ' . 'leave it out — the event is still created and the link can be added later.', required: false ), new ToolProperty( name: 'banner_title', type: PropertyType::STRING, description: 'Optional short English title used only on the generated banner image, ' . '40 characters or less. Send this when the event title is NOT in the Latin ' . 'alphabet — for example Bengali, Arabic, Chinese, Hindi or Thai — because ' . 'the banner font cannot draw those letters. The event title itself is never changed.', required: false ), new ToolProperty( name: 'faq', type: PropertyType::STRING, description: 'Frequently asked questions for the event page, as a JSON-encoded array ' . 'of objects with a "question" and an "answer" key, for example: ' . '[{"question":"Do I need a laptop?","answer":"Yes. Bring a laptop and a charger."}]. ' . 'Write 3 or 4 that match the description — things a real visitor would ask about ' . 'this event. At most 6 are kept. If you leave it out, three plain questions about ' . 'the date, the place and booking are added instead.', required: false ), ]; } /** * Human-readable label for the approval card header. * * @return string */ public function get_approval_label(): string { return 'Create Eventin Event'; } /** * One-line summary of what this invocation will do. * * @return string */ public function format_approval_summary(): string { $inputs = $this->getInputs(); $title = isset( $inputs['title'] ) ? "`{$inputs['title']}`" : '(untitled)'; $start_date = $inputs['start_date'] ?? ''; $end_date = $inputs['end_date'] ?? ''; $start_time = isset( $inputs['start_time'] ) ? substr( $inputs['start_time'], 0, 5 ) : ''; $end_time = isset( $inputs['end_time'] ) ? substr( $inputs['end_time'], 0, 5 ) : ''; $timezone = $inputs['timezone'] ?? 'UTC'; $date_part = $start_date; if ( $end_date && $end_date !== $start_date ) { $date_part .= " - {$end_date}"; } $time_part = $start_time ? "{$start_time}-{$end_time}" : ''; $summary = "Create event {$title}"; if ( $date_part ) { $summary .= " on {$date_part}"; } if ( $time_part ) { $summary .= " {$time_part}"; } if ( $timezone ) { $summary .= " ({$timezone})"; } $summary .= ', with a free ticket'; return $summary; } /** * Human-readable version of tool inputs for the approval card. * * Resolves category_ids and tag_ids to names so the admin can verify * what categories/tags are being assigned before approving. * * @return array */ public function get_approval_inputs(): array { $inputs = $this->getInputs(); if ( ! empty( $inputs['category_ids'] ) ) { $category_ids_raw = $inputs['category_ids']; unset( $inputs['category_ids'] ); $ids = array_filter( array_map( 'intval', explode( ',', (string) $category_ids_raw ) ) ); $names = []; foreach ( $ids as $id ) { $term = get_term( $id, 'etn_category' ); if ( $term && ! is_wp_error( $term ) ) { $names[] = $term->name . ' (ID: ' . $id . ')'; } else { $names[] = 'ID: ' . $id; } } if ( ! empty( $names ) ) { $inputs['categories'] = implode( ', ', $names ); } } if ( ! empty( $inputs['tag_ids'] ) ) { $tag_ids_raw = $inputs['tag_ids']; unset( $inputs['tag_ids'] ); $ids = array_filter( array_map( 'intval', explode( ',', (string) $tag_ids_raw ) ) ); $names = []; foreach ( $ids as $id ) { $term = get_term( $id, 'etn_tags' ); if ( $term && ! is_wp_error( $term ) ) { $names[] = $term->name . ' (ID: ' . $id . ')'; } else { $names[] = 'ID: ' . $id; } } if ( ! empty( $names ) ) { $inputs['tags'] = implode( ', ', $names ); } } // The raw JSON is noise on the card. Show how many questions the admin // is approving instead, and name the things we add without being asked. $faq_json = $inputs['faq'] ?? null; unset( $inputs['faq'] ); $faq_count = count( Event_Faq_Builder::build( is_string( $faq_json ) ? $faq_json : null, $inputs ) ); $inputs['faq'] = sprintf( '%d question(s)', $faq_count ); $inputs['template'] = 'Event Template One'; $inputs['free_ticket'] = sprintf( '%s, %d seats, free', self::DEFAULT_TICKET['name'], self::DEFAULT_TICKET['available'] ); return $inputs; } /** * Execute the tool * * @param string $title Event title. * @param string $timezone PHP timezone string. * @param string $start_date Start date (YYYY-MM-DD). * @param string $end_date End date (YYYY-MM-DD). * @param string $start_time Start time (HH:MM:SS). * @param string $end_time End time (HH:MM:SS). * @param string|null $description Event description. * @param string|null $event_type Event type. * @param string|null $location_type Ignored. Kept so a model call that still * sends the old argument does not fail. * @param string|null $location Location address text. * @param string|null $category_ids Comma-separated category IDs. * @param string|null $tag_ids Comma-separated tag IDs. * @param string|null $banner_title Short English title for the banner image. * @param string|null $meeting_link Online meeting URL for online/hybrid events. * @param string|null $faq JSON array of question/answer objects. * @return array */ public function __invoke( string $title, string $timezone, string $start_date, string $end_date, string $start_time, string $end_time, ?string $description = null, ?string $event_type = null, ?string $location_type = null, ?string $location = null, ?string $category_ids = null, ?string $tag_ids = null, ?string $banner_title = null, ?string $meeting_link = null, ?string $faq = null ): array { $check = $this->guard( 'etn_manage_event' ); if ( $check ) { return $check; } try { if ( empty( trim( $title ) ) ) { return [ 'error' => 'Event title is required.' ]; } if ( empty( trim( $timezone ) ) ) { return [ 'error' => 'Timezone is required.' ]; } if ( empty( trim( $start_date ) ) ) { return [ 'error' => 'start_date is required.' ]; } if ( empty( trim( $end_date ) ) ) { return [ 'error' => 'end_date is required.' ]; } if ( empty( trim( $start_time ) ) ) { return [ 'error' => 'start_time is required.' ]; } if ( empty( trim( $end_time ) ) ) { return [ 'error' => 'end_time is required.' ]; } $parsed_start_date = \DateTime::createFromFormat( 'Y-m-d', $start_date ); if ( ! $parsed_start_date || $parsed_start_date->format( 'Y-m-d' ) !== $start_date ) { return [ 'error' => 'start_date must be in YYYY-MM-DD format.' ]; } $parsed_end_date = \DateTime::createFromFormat( 'Y-m-d', $end_date ); if ( ! $parsed_end_date || $parsed_end_date->format( 'Y-m-d' ) !== $end_date ) { return [ 'error' => 'end_date must be in YYYY-MM-DD format.' ]; } $parsed_start_time = \DateTime::createFromFormat( 'H:i:s', $start_time ); if ( ! $parsed_start_time || $parsed_start_time->format( 'H:i:s' ) !== $start_time ) { return [ 'error' => 'start_time must be in HH:MM:SS format (24-hour).' ]; } $parsed_end_time = \DateTime::createFromFormat( 'H:i:s', $end_time ); if ( ! $parsed_end_time || $parsed_end_time->format( 'H:i:s' ) !== $end_time ) { return [ 'error' => 'end_time must be in HH:MM:SS format (24-hour).' ]; } $start_datetime = \DateTime::createFromFormat( 'Y-m-d H:i:s', "{$start_date} {$start_time}" ); $end_datetime = \DateTime::createFromFormat( 'Y-m-d H:i:s', "{$end_date} {$end_time}" ); if ( $end_datetime < $start_datetime ) { return [ 'error' => 'end_date/end_time must be on or after start_date/start_time.' ]; } try { new \DateTimeZone( $timezone ); } catch ( \Exception $tz_e ) { $msg = "Invalid timezone `{$timezone}`. Use a valid PHP timezone identifier like America/New_York."; return [ 'error' => $msg ]; } // An unknown word becomes null, which stores an empty event_type. // Eventin's Location control then falls back to Venue on its own. $event_type = self::normalize_event_type( $event_type ); $is_physical = in_array( $event_type, [ 'offline', 'hybrid' ], true ); if ( $is_physical && empty( trim( $location ?? '' ) ) ) { return [ 'error' => 'location address is required for offline and hybrid events.' ]; } $is_online = in_array( $event_type, [ 'online', 'hybrid' ], true ); $clean_link = trim( (string) $meeting_link ); if ( '' !== $clean_link && ! filter_var( $clean_link, FILTER_VALIDATE_URL ) ) { return [ 'error' => "Invalid meeting_link `{$clean_link}`. It must be a full URL, e.g. https://meet.google.com/abc-defg-hij." ]; } $event_fields = [ 'title' => $title, 'start_date' => $start_date, 'end_date' => $end_date, 'start_time' => $start_time, 'end_time' => $end_time, 'timezone' => $timezone, 'location' => $location ?? '', 'event_type' => $event_type ?? '', ]; $final_description = Event_Description_Builder::needs_fallback( $description ) ? Event_Description_Builder::build( $event_fields ) : wp_kses_post( (string) $description ); $faqs = Event_Faq_Builder::build( $faq, $event_fields ); $body = [ 'title' => sanitize_text_field( $title ), 'description' => $final_description, 'visibility_status' => 'draft', 'timezone' => sanitize_text_field( $timezone ), 'start_date' => $start_date, 'end_date' => $end_date, 'start_time' => $start_time, 'end_time' => $end_time, 'ticket_variations' => [ $this->default_ticket() ], 'faq' => $faqs, 'event_layout' => self::DEFAULT_LAYOUT, ]; // Always send the key. Eventin reads $prepared_event['event_type'] // without checking, so leaving it out raises "Undefined array key". $body['event_type'] = $event_type ?? ''; $event_location = []; // Never send location_type. Eventin's read endpoint has a legacy // branch: when the stored etn_event_location_type is 'new_location' // it loads the venue from etn_event_location_list, a meta key // nothing writes any more, so the event edit page came back with an // empty Venue Name box. Eventin's own event form does not send the // key either — it leaves the meta as '' and the address array in // etn_event_location is then read and rendered everywhere. if ( $is_physical && ! empty( trim( $location ?? '' ) ) ) { $event_location['address'] = sanitize_text_field( $location ); } // Eventin looks up a meeting platform by $location['integration'] for // every online and hybrid event, and throws "Unsupported platform:" // when it is missing. custom_url is the built-in platform that needs // no connected account, so a plain link always works. if ( $is_online ) { $event_location['integration'] = 'custom_url'; $event_location['custom_url'] = $clean_link; } if ( ! empty( $event_location ) ) { $body['location'] = $event_location; } if ( null !== $category_ids ) { $ids = array_filter( array_map( 'intval', explode( ',', $category_ids ) ) ); if ( ! empty( $ids ) ) { $body['categories'] = array_values( $ids ); } } if ( null !== $tag_ids ) { $ids = array_filter( array_map( 'intval', explode( ',', $tag_ids ) ) ); if ( ! empty( $ids ) ) { $body['tags'] = array_values( $ids ); } } $request = new \WP_REST_Request( 'POST' ); $request->set_header( 'content-type', 'application/json' ); $request->set_body( wp_json_encode( $body ) ); $controller = new \Eventin\Event\Api\EventController(); $response = $controller->create_item( $request ); if ( is_wp_error( $response ) ) { return [ 'error' => $response->get_error_message() ]; } $data = $response->get_data(); $event_id = (int) ( $data['id'] ?? 0 ); if ( ! $event_id ) { return [ 'error' => 'Failed to create event. Eventin did not return a valid event ID.' ]; } $images = $this->attach_images( $event_id, $title, $banner_title, $event_fields ); $faq_count = count( $faqs ); $extras = sprintf( 'It uses the Event Template One layout, has one free ticket (%1$s) and %2$d FAQ answer(s).', self::DEFAULT_TICKET['name'], $faq_count ); $message = $images['banner_set'] ? "Event created as draft (ID: {$event_id}) with a banner and a featured image. {$extras} " . 'Now ask the admin whether to publish it, and call eventin-publish-event only if they say yes.' : "Event created as draft (ID: {$event_id}). No banner image could be made on this server. {$extras} " . 'Now ask the admin whether to publish it, and call eventin-publish-event only if they say yes.'; return [ 'success' => true, 'event_id' => $event_id, 'status' => 'draft', 'edit_url' => get_edit_post_link( $event_id, 'raw' ), 'banner_set' => $images['banner_set'], 'featured_set' => $images['featured_set'], 'template' => self::DEFAULT_LAYOUT, 'free_ticket' => self::DEFAULT_TICKET['name'], 'faq_count' => $faq_count, 'message' => $message, ]; } catch ( \Exception $e ) { Log::error( 'Eventin_Create_Event_Tool error', [ 'error' => $e->getMessage() ] ); return [ 'error' => 'Failed to create event: ' . $e->getMessage() ]; } } /** * The free ticket every new event starts with. * * The key names are Eventin's own — `etn_avaiilable_tickets` really is * spelled with three i's in the stored meta. * * @return array */ private function default_ticket(): array { return [ 'etn_ticket_name' => self::DEFAULT_TICKET['name'], 'etn_ticket_slug' => self::DEFAULT_TICKET['slug'], 'etn_ticket_price' => 0.0, 'etn_avaiilable_tickets' => self::DEFAULT_TICKET['available'], 'etn_min_ticket' => self::DEFAULT_TICKET['min'], 'etn_max_ticket' => self::DEFAULT_TICKET['max'], 'etn_enable_ticket' => true, 'etn_sold_tickets' => 0, 'start_date' => '', 'end_date' => '', 'start_time' => '', 'end_time' => '', ]; } /** * Draw, upload and attach the banner and the card image. * * Every failure is logged and swallowed. The event has already been * created at this point, so nothing here may throw. * * @param int $event_id The new event ID. * @param string $title The event title. * @param string|null $banner_title Optional English title for the picture. * @param array $event_fields Fields used to build the date and place lines. * @return array [ 'banner_set' => bool, 'featured_set' => bool ] */ private function attach_images( int $event_id, string $title, ?string $banner_title, array $event_fields ): array { $result = [ 'banner_set' => false, 'featured_set' => false, ]; try { $context = [ 'title' => $title, 'banner_title' => $banner_title, 'date_line' => Event_Description_Builder::date_line( $event_fields ), 'place_line' => Event_Description_Builder::place_line( $event_fields ), 'temp_dir' => get_temp_dir(), ]; $banner = $this->make_image( $context, Text_Banner_Generator::BANNER_WIDTH, Text_Banner_Generator::BANNER_HEIGHT, $event_id, $title . ' Banner', $title ); if ( null === $banner ) { return $result; } update_post_meta( $event_id, 'event_banner', $banner['url'] ); update_post_meta( $event_id, 'event_banner_id', $banner['id'] ); $result['banner_set'] = true; $card = $this->make_image( $context, Text_Banner_Generator::CARD_WIDTH, Text_Banner_Generator::CARD_HEIGHT, $event_id, $title . ' Card', $title ); if ( null !== $card ) { set_post_thumbnail( $event_id, $card['id'] ); $result['featured_set'] = true; } } catch ( \Throwable $e ) { Log::warning( 'Eventin_Create_Event_Tool could not attach images', [ 'event_id' => $event_id, 'error' => $e->getMessage(), ] ); } return $result; } /** * Make one image at the given size and put it in the media library. * * The aisentic_event_banner_file filter lets another plugin supply its * own picture — for example an AI generated one. Returning a file path * from that filter skips the GD drawing completely. * * @param array $context Drawing context. * @param int $width Image width. * @param int $height Image height. * @param int $event_id Parent event ID. * @param string $name Attachment title. * @param string $alt_text Alt text. * @return array|null [ 'id' => int, 'url' => string ] or null. */ private function make_image( array $context, int $width, int $height, int $event_id, string $name, string $alt_text ): ?array { $context['width'] = $width; $context['height'] = $height; $file = apply_filters( 'aisentic_event_banner_file', null, $context ); if ( ! is_string( $file ) || '' === $file ) { $file = Text_Banner_Generator::generate( $context ); } if ( ! is_string( $file ) || ! file_exists( $file ) ) { Log::warning( 'Eventin_Create_Event_Tool banner skipped: no image file', [ 'event_id' => $event_id ] ); return null; } $uploaded = Media_Library::sideload( $file, $name, $event_id, $alt_text ); // media_handle_sideload moves the file on success. Clean up if it is // still there, so a failed upload leaves nothing behind. if ( file_exists( $file ) ) { wp_delete_file( $file ); } return $uploaded; } }