diff --git a/composer.json b/composer.json index f744fb1..09cd0ed 100644 --- a/composer.json +++ b/composer.json @@ -61,7 +61,7 @@ ], "test-debug": [ "@prepare-test", - "@test-general" + "@test-the-events-calendar" ], "test-vs-event-list": "phpunit --filter=vs_event_list", "test-the-events-calendar": "phpunit --filter=the_events_calendar", diff --git a/includes/activitypub/transmogrifier/class-base.php b/includes/activitypub/transmogrifier/class-base.php index 517ae89..7833135 100644 --- a/includes/activitypub/transmogrifier/class-base.php +++ b/includes/activitypub/transmogrifier/class-base.php @@ -56,7 +56,7 @@ abstract class Base { $post_id = $this->save_event(); if ( $post_id ) { - update_post_meta( $post_id, '_event_bridge_for_activitypub_is_cached', true ); + update_post_meta( $post_id, '_event_bridge_for_activitypub_is_remote_cached', true ); update_post_meta( $post_id, 'activitypub_content_visibility', constant( 'ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL' ) ?? '' ); } } diff --git a/includes/activitypub/transmogrifier/class-the-events-calendar.php b/includes/activitypub/transmogrifier/class-the-events-calendar.php index a0b1ac0..559dfb8 100644 --- a/includes/activitypub/transmogrifier/class-the-events-calendar.php +++ b/includes/activitypub/transmogrifier/class-the-events-calendar.php @@ -11,7 +11,7 @@ namespace Event_Bridge_For_ActivityPub\ActivityPub\Transmogrifier; -use DateTime; +use Tribe__Date_Utils; use function Activitypub\sanitize_url; @@ -27,25 +27,47 @@ defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore */ class The_Events_Calendar extends Base { /** - * Get a list of Post IDs of events that have ended. + * Map an ActivityStreams Place to the Events Calendar venue. * - * @param int $cache_retention_period Additional time buffer in seconds. - * @return ?array + * @param array $location An ActivityPub location as an associative array. + * @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-place + * @return array */ - public static function get_past_events( $cache_retention_period = 0 ): ?array { - unset( $cache_retention_period ); + private function get_venue_args( $location ) { + $args = array( + 'venue' => $location['name'], + 'status' => 'publish', + ); - $results = array(); + if ( is_array( $location['address'] ) && isset( $location['address']['type'] ) && 'PostalAddress' === $location['address']['type'] ) { + $mapping = array( + 'streetAddress' => 'address', + 'postalCode' => 'zip', + 'addressLocality' => 'city', + 'addressState' => 'state', + 'addressCountry' => 'country', + 'url' => 'website', + ); - return $results; + foreach ( $mapping as $postal_address_key => $venue_key ) { + if ( isset( $location['address'][ $postal_address_key ] ) ) { + $args[ $venue_key ] = $location['address'][ $postal_address_key ]; + } + } + } elseif ( is_string( $location['address'] ) ) { + // Use the address field for a solely text address. + $args['address'] = $location['address']; + } + + return $args; } /** * Add venue. * - * @param int $post_id The post ID. + * @return int|bool $post_id The venues post ID. */ - private function add_venue( $post_id ) { + private function add_venue() { $location = $this->activitypub_event->get_location(); if ( ! $location ) { @@ -60,6 +82,25 @@ class The_Events_Calendar extends Base { if ( 'online' === $location['name'] ) { return; } + + $post_ids = tribe_events()->search( $location['name'] )->all(); + + $post_id = false; + + if ( count( $post_ids ) ) { + $post_id = reset( $post_ids ); + } + + if ( $post_id && get_post_meta( $post_id, '_event_bridge_for_activitypub_is_remote_cached' ) ) { + tribe_venues()->where( 'id', $post_id )->set_args( $this->get_venue_args( $location ) )->save()[0]; + } else { + $post = tribe_venues()->set_args( $this->get_venue_args( $location ) )->create(); + if ( $post ) { + $post_id = $post->ID; + } + } + + return $post_id; } /** @@ -75,6 +116,8 @@ class The_Events_Calendar extends Base { $duration = $this->get_duration(); + $venue_id = $this->add_venue(); + $args = array( 'title' => sanitize_text_field( $this->activitypub_event->get_name() ), 'content' => wp_kses_post( $this->activitypub_event->get_content() ), @@ -84,6 +127,11 @@ class The_Events_Calendar extends Base { 'guid' => sanitize_url( $this->activitypub_event->get_id() ), ); + if ( $venue_id ) { + $args['venue'] = $venue_id; + $args['VenueID'] = $venue_id; + } + $tribe_event = new The_Events_Calendar_Event_Repository(); if ( $post_id ) { @@ -99,8 +147,6 @@ class The_Events_Calendar extends Base { return false; } - $this->add_venue( $post->ID ); - // Limit this as a safety measure. remove_filter( 'wp_revisions_to_keep', array( self::class, 'revisions_to_keep' ) ); diff --git a/includes/activitypub/transmogrifier/class-vs-event-list.php b/includes/activitypub/transmogrifier/class-vs-event-list.php new file mode 100644 index 0000000..31d33ac --- /dev/null +++ b/includes/activitypub/transmogrifier/class-vs-event-list.php @@ -0,0 +1,182 @@ + $location['name'], + 'status' => 'publish', + ); + + if ( is_array( $location['address'] ) && isset( $location['address']['type'] ) && 'PostalAddress' === $location['address']['type'] ) { + $mapping = array( + 'streetAddress' => 'address', + 'postalCode' => 'zip', + 'addressLocality' => 'city', + 'addressState' => 'state', + 'addressCountry' => 'country', + 'url' => 'website', + ); + + foreach ( $mapping as $postal_address_key => $venue_key ) { + if ( isset( $location['address'][ $postal_address_key ] ) ) { + $args[ $venue_key ] = $location['address'][ $postal_address_key ]; + } + } + } elseif ( is_string( $location['address'] ) ) { + // Use the address field for a solely text address. + $args['address'] = $location['address']; + } + + return $args; + } + + /** + * Add venue. + * + * @return int|bool $post_id The venues post ID. + */ + private function add_venue() { + $location = $this->activitypub_event->get_location(); + + if ( ! $location ) { + return; + } + + if ( ! isset( $location['name'] ) ) { + return; + } + + // Fallback for Gancio instances. + if ( 'online' === $location['name'] ) { + return; + } + + $post_ids = tribe_events()->search( $location['name'] )->all(); + + $post_id = false; + + if ( count( $post_ids ) ) { + $post_id = reset( $post_ids ); + } + + if ( $post_id && get_post_meta( $post_id, '_event_bridge_for_activitypub_is_remote_cached' ) ) { + tribe_venues()->where( 'id', $post_id )->set_args( $this->get_venue_args( $location ) )->save()[0]; + } else { + $post = tribe_venues()->set_args( $this->get_venue_args( $location ) )->create(); + if ( $post ) { + $post_id = $post->ID; + } + } + + return $post_id; + } + + /** + * Save the ActivityPub event object as GatherPress Event. + * + * @return false|int + */ + public function save_event() { + // Limit this as a safety measure. + add_filter( 'wp_revisions_to_keep', array( self::class, 'revisions_to_keep' ) ); + + $post_id = $this->get_post_id_from_activitypub_id(); + + $duration = $this->get_duration(); + + $venue_id = $this->add_venue(); + + $args = array( + 'title' => sanitize_text_field( $this->activitypub_event->get_name() ), + 'content' => wp_kses_post( $this->activitypub_event->get_content() ), + 'start_date' => gmdate( 'Y-m-d H:i:s', strtotime( $this->activitypub_event->get_start_time() ) ), + 'duration' => $duration, + 'status' => 'publish', + 'guid' => sanitize_url( $this->activitypub_event->get_id() ), + ); + + if ( $venue_id ) { + $args['venue'] = $venue_id; + $args['VenueID'] = $venue_id; + } + + $tribe_event = new The_Events_Calendar_Event_Repository(); + + if ( $post_id ) { + $args['post_title'] = $args['title']; + $args['post_content'] = $args['content']; + // Update existing GatherPress event post. + $post = \Tribe__Events__API::updateEvent( $post_id, $args ); + } else { + $post = $tribe_event->set_args( $args )->create(); + } + + if ( ! $post ) { + return false; + } + + // Limit this as a safety measure. + remove_filter( 'wp_revisions_to_keep', array( self::class, 'revisions_to_keep' ) ); + + return $post->ID; + } + + /** + * Get the events duration in seconds. + * + * @return int + */ + private function get_duration() { + $end_time = $this->activitypub_event->get_end_time(); + if ( ! $end_time ) { + return 2 * HOUR_IN_SECONDS; + } + return abs( strtotime( $end_time ) - strtotime( $this->activitypub_event->get_start_time() ) ); + } +} diff --git a/includes/class-event-sources.php b/includes/class-event-sources.php index fdb5b9f..06e5165 100644 --- a/includes/class-event-sources.php +++ b/includes/class-event-sources.php @@ -80,7 +80,7 @@ class Event_Sources { } \register_post_meta( $event_plugin_integration::get_post_type(), - '_event_bridge_for_activitypub_is_cached', + '_event_bridge_for_activitypub_is_remote_cached', array( 'type' => 'string', 'single' => false, @@ -89,6 +89,21 @@ class Event_Sources { }, ) ); + + $location_post_type = $event_plugin_integration::get_location_post_type(); + if ( $location_post_type ) { + \register_post_meta( + $location_post_type, + '_event_bridge_for_activitypub_is_remote_cached', + array( + 'type' => 'string', + 'single' => false, + 'sanitize_callback' => function ( $value ) { + return esc_sql( $value ); + }, + ) + ); + } } } @@ -168,7 +183,7 @@ class Event_Sources { * @return bool */ public static function is_cached_external_event_post( $post ): bool { - if ( get_post_meta( $post->ID, '_event_bridge_for_activitypub_is_cached', true ) ) { + if ( get_post_meta( $post->ID, '_event_bridge_for_activitypub_is_remote_cached', true ) ) { return true; } diff --git a/includes/integrations/class-event-plugin-integration.php b/includes/integrations/class-event-plugin-integration.php index d18d2ee..c436d47 100644 --- a/includes/integrations/class-event-plugin-integration.php +++ b/includes/integrations/class-event-plugin-integration.php @@ -55,6 +55,15 @@ abstract class Event_Plugin_Integration { */ abstract public static function get_activitypub_event_transformer( $post ): ActivityPub_Event_Transformer; + /** + * In case an event plugin used a custom post type for the locations/venues return it here. + * + * @return ?string + */ + public static function get_location_post_type() { + return null; + } + /** * Returns the IDs of the admin pages of the plugin. * diff --git a/includes/integrations/class-gatherpress.php b/includes/integrations/class-gatherpress.php index 87f5dae..3b07487 100644 --- a/includes/integrations/class-gatherpress.php +++ b/includes/integrations/class-gatherpress.php @@ -83,19 +83,19 @@ final class GatherPress extends Event_Plugin_Integration implements Feature_Even /** * Get a list of Post IDs of events that have ended. * - * @param int $ended_before_time Filter: only get events that ended before that datetime as unix-time. + * @param int $ends_before_time Filter: only get events that ended before that datetime as unix-time. * * @return array */ - public static function get_cached_remote_events( $ended_before_time ): array { + public static function get_cached_remote_events( $ends_before_time ): array { global $wpdb; - $ended_before_time_string = gmdate( 'Y-m-d H:i:s', $ended_before_time ); + $ends_before_time_string = gmdate( 'Y-m-d H:i:s', $ends_before_time ); $results = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->prefix}gatherpress_events WHERE datetime_end < %s", - $ended_before_time_string + $ends_before_time_string ) ); @@ -116,7 +116,7 @@ final class GatherPress extends Event_Plugin_Integration implements Feature_Even if ( $post && 'gatherpress_event' === $post->post_type ) { // Add your custom logic here to decide whether to force the link. // For example, force it only if a specific meta field exists. - if ( get_post_meta( $post->ID, '_event_bridge_for_activitypub_is_cached', true ) ) { + if ( get_post_meta( $post->ID, '_event_bridge_for_activitypub_is_remote_cached', true ) ) { return true; // Force the online event link. } } diff --git a/includes/integrations/class-the-events-calendar.php b/includes/integrations/class-the-events-calendar.php index d3337fe..23cbbef 100644 --- a/includes/integrations/class-the-events-calendar.php +++ b/includes/integrations/class-the-events-calendar.php @@ -64,6 +64,15 @@ final class The_Events_Calendar extends Event_plugin_Integration implements Feat return new The_Events_Calendar_Transformer( $post, self::get_event_category_taxonomy() ); } + /** + * Return the location/venue post type used by tribe. + * + * @return ?string + */ + public static function get_location_post_type() { + return class_exists( '\Tribe__Events__Venue' ) ? \Tribe__Events__Venue::POSTTYPE : 'tribe_venue'; + } + /** * Returns the IDs of the admin pages of the plugin. * @@ -88,11 +97,39 @@ final class The_Events_Calendar extends Event_plugin_Integration implements Feat /** * Get a list of Post IDs of events that have ended. * - * @param int $ended_before_time Filter: only get events that ended before that datetime as unix-time. + * @param int $ends_before_time Filter to only get events that ended before that datetime as unix-time. * - * @return array + * @return array */ - public static function get_cached_remote_events( $ended_before_time ): array { - return array(); + public static function get_cached_remote_events( $ends_before_time ): array { + add_filter( + 'tribe_repository_events_apply_modifier_schema_entry', + array( self::class, 'add_is_activitypub_remote_cached_to_query' ), + 10, + 1 + ); + + $events = tribe_events()->where( 'ends_before', $ends_before_time )->get_ids(); + + remove_filter( + 'tribe_repository_events_apply_modifier_schema_entry', + array( self::class, 'add_is_activitypub_remote_cached_to_query' ) + ); + + return $events; + } + + /** + * Only show remote cached ActivityPub events in Tribe query. + * + * @param array $schema_entry The current schema entry. + * @return array The modified schema entry. + */ + public static function add_is_activitypub_remote_cached_to_query( $schema_entry ) { + $schema_entry['meta_query']['is-remote-cached'] = array( + 'key' => '_event_bridge_for_activitypub_is_remote_cached', + 'compare' => 'EXISTS', + ); + return $schema_entry; } } diff --git a/includes/integrations/interface-feature-event-sources.php b/includes/integrations/interface-feature-event-sources.php index 4b43322..110acf2 100644 --- a/includes/integrations/interface-feature-event-sources.php +++ b/includes/integrations/interface-feature-event-sources.php @@ -36,9 +36,9 @@ interface Feature_Event_Sources { * * Filters the events to include only those that ended before the specified timestamp. * - * @param int $ended_before_time Unix timestamp. Only events ending before this time will be included. + * @param int $ends_before_time Unix timestamp. Only events ending before this time will be included. * * @return int[] List of post IDs for events that match the criteria. */ - public static function get_cached_remote_events( $ended_before_time ): array; + public static function get_cached_remote_events( $ends_before_time ): array; } diff --git a/tests/includes/activitypub/transformer/class-test-the-events-calendar.php b/tests/includes/activitypub/transformer/class-test-the-events-calendar.php index 7717826..6837b74 100644 --- a/tests/includes/activitypub/transformer/class-test-the-events-calendar.php +++ b/tests/includes/activitypub/transformer/class-test-the-events-calendar.php @@ -1,6 +1,6 @@ assertEquals( $json['object']['location']['address'], $venue->address ); - // $this->assertEquals( $json['object']['location']['name'], $venue->post_title ); + $this->assertEquals( $json['object']['location']['address'], $venue->address ); + $this->assertEquals( $json['object']['location']['name'], $venue->post_title ); + + \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); + } + + /** + * Test receiving event from followed actor. + */ + public function test_incoming_event_with_postal_address() { + \add_filter( 'activitypub_defer_signature_verification', '__return_true' ); + + $json = array( + 'id' => 'https://remote.example/@organizer/events/new-year-party#create', + 'type' => 'Create', + 'actor' => 'https://remote.example/@organizer', + 'object' => array( + 'id' => 'https://remote.example/@organizer/events/new-year-party', + 'type' => 'Event', + 'startTime' => \gmdate( 'Y-m-d\TH:i:s\Z', time() + WEEK_IN_SECONDS ), + 'endTime' => \gmdate( 'Y-m-d\TH:i:s\Z', time() + WEEK_IN_SECONDS + HOUR_IN_SECONDS ), + 'name' => 'Fediverse Party for The Events Calendar', + 'to' => 'https://www.w3.org/ns/activitystreams#Public', + 'published' => '2020-01-01T00:00:00Z', + 'location' => array( + 'type' => 'Place', + 'name' => 'Fediverse Concert Hall', + 'address' => array( + 'type' => 'PostalAddress', + 'streetAddress' => 'FediStreet 13', + 'postalCode' => '1337', + 'addressLocality' => 'Feditown', + 'addressState' => 'Fediverse State', + 'addressCountry' => 'Fediverse World', + 'url' => 'https://fedidevs.org/', + ), + ), + ), + ); + + $request = new WP_REST_Request( 'POST', '/activitypub/1.0/users/0/inbox' ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( \wp_json_encode( $json ) ); + + // Dispatch the request. + $response = \rest_do_request( $request ); + $this->assertEquals( 202, $response->get_status() ); + + // Check if post has been created. + $events = tribe_get_events(); + + $this->assertEquals( 1, count( $events ) ); + + // Initialize new GatherPress Event object. + $event = tribe_get_event( $events[0] ); + + $this->assertEquals( $json['object']['name'], $event->post_title ); + $this->assertEquals( $json['object']['startTime'], $event->dates->start->format( 'Y-m-d\TH:i:s\Z' ) ); + $this->assertEquals( $json['object']['endTime'], $event->dates->end->format( 'Y-m-d\TH:i:s\Z' ) ); + + $venues = $event->venues; + // Get first venue. We currently only support a single venue. + if ( $venues instanceof \Tribe\Events\Collections\Lazy_Post_Collection ) { + $venue = $venues->first(); + } elseif ( empty( $this->wp_object->venues ) || ! empty( $this->wp_object->venues[0] ) ) { + return null; + } else { + $venue = $venues[0]; + } + + $this->assertEquals( $json['object']['location']['name'], $venue->post_title ); + $this->assertEquals( $json['object']['location']['address']['streetAddress'], $venue->address ); + $this->assertEquals( $json['object']['location']['address']['postalCode'], $venue->zip ); + $this->assertEquals( $json['object']['location']['address']['addressLocality'], $venue->city ); + $this->assertEquals( $json['object']['location']['address']['addressState'], $venue->state ); + $this->assertEquals( $json['object']['location']['address']['addressCountry'], $venue->country ); + $this->assertEquals( $json['object']['location']['address']['url'], $venue->website ); \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); } diff --git a/tests/includes/class-test-event-sources.php b/tests/includes/class-test-event-sources.php index d6f523d..23d576d 100644 --- a/tests/includes/class-test-event-sources.php +++ b/tests/includes/class-test-event-sources.php @@ -209,7 +209,6 @@ class Test_Event_Sources extends \WP_UnitTestCase { \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); } - /** * Test receiving event from actor we do not follow. */ diff --git a/tests/includes/integrations/class-test-the-events-calendar.php b/tests/includes/integrations/class-test-the-events-calendar.php new file mode 100644 index 0000000..dcbe51e --- /dev/null +++ b/tests/includes/integrations/class-test-the-events-calendar.php @@ -0,0 +1,139 @@ + 'https://remote.example/@organizer', + 'type' => 'Person', + 'inbox' => 'https://remote.example/@organizer/inbox', + 'outbox' => 'https://remote.example/@organizer/outbox', + 'name' => 'The Organizer', + 'summary' => 'Just a random organizer of events in the Fediverse', + ); + + /** + * REST Server. + * + * @var WP_REST_Server + */ + protected $server; + + /** + * Set up the test. + */ + public function set_up() { + if ( ! class_exists( '\Tribe__Events__Main' ) ) { + self::markTestSkipped( 'The Events Calendar plugin is not active.' ); + } + + \add_option( 'permalink_structure', '/%postname%/' ); + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + + do_action( 'rest_api_init' ); + + \Activitypub\Rest\Server::add_hooks(); + + // Make sure that ActivityPub support is enabled for The Events Calendar. + $aec = \Event_Bridge_For_ActivityPub\Setup::get_instance(); + $aec->activate_activitypub_support_for_active_event_plugins(); + + // Add event source (ActivityPub follower). + _delete_all_posts(); + \Event_Bridge_For_ActivityPub\ActivityPub\Model\Event_Source::init_from_array( self::FOLLOWED_ACTOR )->save(); + + \update_option( 'event_bridge_for_activitypub_event_sources_active', true ); + \update_option( + 'event_bridge_for_activitypub_integration_used_for_event_sources_feature', + \Event_Bridge_For_ActivityPub\Integrations\The_Events_Calendar::class + ); + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_BLOG_MODE ); + } + + /** + * Tear down the test. + */ + public function tear_down() { + \delete_option( 'permalink_structure' ); + } + + /** + * Test receiving event from followed actor. + */ + public function test_getting_past_remote_events() { + \add_filter( 'activitypub_defer_signature_verification', '__return_true' ); + + // Receive an federated event. + $json = array( + 'id' => 'https://remote.example/@organizer/events/new-year-party#create', + 'type' => 'Create', + 'actor' => 'https://remote.example/@organizer', + 'object' => array( + 'id' => 'https://remote.example/@organizer/events/new-year-party', + 'type' => 'Event', + 'startTime' => \gmdate( 'Y-m-d\TH:i:s\Z', time() + WEEK_IN_SECONDS ), + 'endTime' => \gmdate( 'Y-m-d\TH:i:s\Z', time() + WEEK_IN_SECONDS + HOUR_IN_SECONDS ), + 'name' => 'Fediverse Party for The Events Calendar', + 'to' => 'https://www.w3.org/ns/activitystreams#Public', + 'published' => '2020-01-01T00:00:00Z', + 'location' => array( + 'type' => 'Place', + 'name' => 'Fediverse Concert Hall', + 'address' => 'Fedistreet 13, Feditown 1337', + ), + ), + ); + + $request = new WP_REST_Request( 'POST', '/activitypub/1.0/users/0/inbox' ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( \wp_json_encode( $json ) ); + + // Dispatch the request. + $response = \rest_do_request( $request ); + $this->assertEquals( 202, $response->get_status() ); + + // Check if post has been created. + $events = tribe_get_events(); + $this->assertEquals( 1, count( $events ) ); + + // Create a The Events Calendar Event without content. + $wp_object = tribe_events() + ->set_args( TRIBE_Transformer_Test::MOCKUP_EVENTS['minimal_event'] ) + ->create(); + + $this->assertNotEquals( false, $wp_object ); + // Check if we now have two tribe events. + $events = tribe_get_events(); + $this->assertEquals( 2, count( $events ) ); + + $events = The_Events_Calendar::get_cached_remote_events( time() + MONTH_IN_SECONDS ); + $this->assertEquals( 1, count( $events ) ); + $this->assertEquals( $json['object']['id'], get_post( $events[0] )->guid ); + + $events = The_Events_Calendar::get_cached_remote_events( time() - WEEK_IN_SECONDS ); + $this->assertEquals( 0, count( $events ) ); + + \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); + } +}