diff --git a/includes/class-handler.php b/includes/class-handler.php index c507402ba..da81f5714 100644 --- a/includes/class-handler.php +++ b/includes/class-handler.php @@ -49,6 +49,7 @@ public static function register_handlers() { */ public static function register_outbox_handlers() { Handler\Outbox\Announce::init(); + Handler\Outbox\Arrive::init(); Handler\Outbox\Create::init(); Handler\Outbox\Delete::init(); Handler\Outbox\Follow::init(); diff --git a/includes/handler/outbox/class-arrive.php b/includes/handler/outbox/class-arrive.php new file mode 100644 index 000000000..8f7b7ba4a --- /dev/null +++ b/includes/handler/outbox/class-arrive.php @@ -0,0 +1,170 @@ + array( + 'type' => 'Note', + 'name' => $title, + 'content' => $data['content'] ?? self::get_summary( $data ), + ), + 'to' => $data['to'] ?? array(), + 'cc' => $data['cc'] ?? array(), + ); + + $post = Posts::create( $activity, $user_id, $visibility ); + + if ( \is_wp_error( $post ) ) { + return $post; + } + + /** + * Fires after an outgoing Arrive activity has created a post. + * + * @param int $post_id The created post ID. + * @param array|null $location The location data from the activity. + * @param array $data The activity data. + * @param int $user_id The user ID. + */ + \do_action( 'activitypub_outbox_arrive_sent', $post->ID, $data['location'] ?? null, $data, $user_id ); + + return $post; + } + + /** + * Save location geodata on the created post. + * + * Hooked to `activitypub_outbox_arrive_sent`. Uses the standard + * `geo_*` meta keys that the Post transformer reads back when + * converting to ActivityPub Place objects. + * + * @since unreleased + * + * @param int $post_id The post ID. + * @param array|null $location The ActivityPub location data. + * @param array $data The activity data. + * @param int $user_id The user ID. + */ + public static function save_location( $post_id, $location, $data, $user_id ) { + if ( ! \is_array( $location ) ) { + return; + } + + if ( ! empty( $location['name'] ) ) { + \update_post_meta( $post_id, 'geo_address', \sanitize_text_field( $location['name'] ) ); + } + + if ( isset( $location['latitude'] ) && \is_numeric( $location['latitude'] ) ) { + \update_post_meta( $post_id, 'geo_latitude', (float) $location['latitude'] ); + } + + if ( isset( $location['longitude'] ) && \is_numeric( $location['longitude'] ) ) { + \update_post_meta( $post_id, 'geo_longitude', (float) $location['longitude'] ); + } + + if ( ! empty( $location['name'] ) || ( isset( $location['latitude'] ) && isset( $location['longitude'] ) ) ) { + \update_post_meta( $post_id, 'geo_public', '1' ); + } + } + + /** + * Extract the summary string from an Arrive activity. + * + * Supports both `summary` (string) and `summaryMap` (language map). + * + * @param array $data The activity data. + * + * @return string The summary text. + */ + private static function get_summary( $data ) { + if ( ! empty( $data['summary'] ) ) { + return $data['summary']; + } + + if ( ! empty( $data['summaryMap'] ) && \is_array( $data['summaryMap'] ) ) { + return \reset( $data['summaryMap'] ); + } + + return ''; + } + + /** + * Extract a human-readable name from an ActivityPub location. + * + * @param mixed $location The location data (array or string). + * + * @return string|null The location name or null. + */ + private static function get_location_name( $location ) { + if ( \is_array( $location ) && ! empty( $location['name'] ) ) { + return \sanitize_text_field( $location['name'] ); + } + + if ( \is_string( $location ) && ! empty( $location ) ) { + return \sanitize_text_field( $location ); + } + + return null; + } +} diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 917ba5b0c..68cc4c065 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -427,9 +427,18 @@ public function create_item( $request ) { $object_id = get_object_id( $result ); if ( $object_id ) { - // Handler returned a WP_Post or WP_Comment; look up its outbox entry. + /* + * Handler returned a WP_Post or WP_Comment; look up its outbox entry. + * Fall back to Create if the specific type isn't found, because the + * Post scheduler wraps new posts in Create activities regardless of + * the original C2S activity type (e.g. Arrive → Create). + */ $activity_type = \ucfirst( $data['type'] ?? 'Create' ); $outbox_item = Outbox::get_by_object_id( $object_id, $activity_type ); + + if ( ! $outbox_item && 'Create' !== $activity_type ) { + $outbox_item = Outbox::get_by_object_id( $object_id, 'Create' ); + } } elseif ( \is_int( $result ) && $result > 0 ) { // Handler returned an outbox post ID directly. $outbox_item = \get_post( $result ); diff --git a/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php b/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php index ada960366..bc9e0d48d 100644 --- a/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php @@ -790,14 +790,14 @@ public function test_c2s_create_article_has_no_post_format() { } /** - * Test C2S POST with an intransitive activity and object actor payload. + * Test C2S POST with an Arrive activity creates a WordPress post. * - * Ensures activities like Arrive do not trigger object-ID resolution errors - * when the actor is provided as an object and no explicit object is present. + * Ensures the Arrive handler creates a check-in post with location + * geodata and stores the activity in the outbox. * * @covers ::create_item */ - public function test_c2s_arrive_with_actor_object() { + public function test_c2s_arrive_creates_post_with_geodata() { $user = \Activitypub\Collection\Actors::get_by_id( self::$user_id ); $data = array( @@ -809,8 +809,11 @@ public function test_c2s_arrive_with_actor_object() { 'url' => $user->get_url(), ), 'location' => array( - 'id' => 'https://places.pub/relation/659839', - 'name' => 'Ettlingen', + 'type' => 'Place', + 'id' => 'https://places.pub/relation/659839', + 'name' => 'Ettlingen', + 'latitude' => 48.9408, + 'longitude' => 8.4075, ), 'content' => 'Arrived.', 'to' => 'https://www.w3.org/ns/activitystreams#Public', @@ -827,27 +830,68 @@ public function test_c2s_arrive_with_actor_object() { $this->assertEquals( 201, $response->get_status() ); $response_data = $response->get_data(); - $this->assertSame( 'Arrive', $response_data['type'] ); + $this->assertSame( 'Create', $response_data['type'] ); - $outbox_items = \get_posts( - array( - 'post_type' => Outbox::POST_TYPE, - 'post_status' => 'any', - 'author' => self::$user_id, - 'posts_per_page' => 1, - 'orderby' => 'ID', - 'order' => 'DESC', - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'meta_query' => array( - array( - 'key' => '_activitypub_activity_type', - 'value' => 'Arrive', - ), - ), - ) + // Find the created post from the response object ID. + $object = $response_data['object']; + $post_id = \url_to_postid( $object['id'] ); + $this->assertGreaterThan( 0, $post_id, 'Arrive should create a WordPress post.' ); + + $this->assertSame( 'status', \get_post_format( $post_id ), 'Arrive post should have status format.' ); + $this->assertStringContainsString( 'Ettlingen', \get_the_title( $post_id ), 'Post title should contain location name.' ); + + // Verify geodata meta. + $this->assertSame( 'Ettlingen', \get_post_meta( $post_id, 'geo_address', true ) ); + $this->assertEquals( 48.9408, (float) \get_post_meta( $post_id, 'geo_latitude', true ) ); + $this->assertEquals( 8.4075, (float) \get_post_meta( $post_id, 'geo_longitude', true ) ); + $this->assertSame( '1', \get_post_meta( $post_id, 'geo_public', true ) ); + } + + /** + * Test C2S POST with Arrive activity without coordinates. + * + * Ensures the handler works when the location only has a name + * but no latitude/longitude, as is common with checkin.swf.pub. + * + * @covers ::create_item + */ + public function test_c2s_arrive_with_name_only_location() { + $user = \Activitypub\Collection\Actors::get_by_id( self::$user_id ); + + $data = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Arrive', + 'actor' => $user->get_id(), + 'location' => array( + 'id' => 'https://places.pub/relation/659839', + 'name' => 'Ettlingen', + ), + 'content' => 'Hello!', + 'summaryMap' => array( + 'en' => 'Arrived at Ettlingen', + ), + 'to' => 'https://www.w3.org/ns/activitystreams#Public', + 'cc' => $user->get_followers(), ); - $this->assertNotEmpty( $outbox_items ); - $this->assertSame( $user->get_id(), \get_post_meta( $outbox_items[0]->ID, '_activitypub_object_id', true ) ); + $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . self::$user_id . '/outbox' ); + $request->set_header( 'Content-Type', 'application/activity+json' ); + $request->set_body( \wp_json_encode( $data ) ); + + \wp_set_current_user( self::$user_id ); + + $response = \rest_get_server()->dispatch( $request ); + $this->assertEquals( 201, $response->get_status() ); + + // Find the created post from the response object ID. + $response_data = $response->get_data(); + $post_id = \url_to_postid( $response_data['object']['id'] ); + $this->assertGreaterThan( 0, $post_id, 'Arrive should create a WordPress post.' ); + + // Verify geodata - address saved but no coordinates. + $this->assertSame( 'Ettlingen', \get_post_meta( $post_id, 'geo_address', true ) ); + $this->assertEmpty( \get_post_meta( $post_id, 'geo_latitude', true ), 'No latitude when not provided.' ); + $this->assertEmpty( \get_post_meta( $post_id, 'geo_longitude', true ), 'No longitude when not provided.' ); + $this->assertSame( '1', \get_post_meta( $post_id, 'geo_public', true ), 'geo_public set when name is present.' ); } }