From a5e30d9256a59c363140edccf9776bb2efb5e61c Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 25 Feb 2026 19:24:55 +0100 Subject: [PATCH] Add Arrive outbox handler for check-in activities Create WordPress posts from C2S Arrive activities (check-ins). The handler synthesizes a Note from the intransitive Arrive data, creates a post via Posts::create(), and saves location geodata (geo_address, geo_latitude, geo_longitude) via the activitypub_outbox_arrive_sent action hook. Also fixes the outbox controller to fall back to Create when looking up outbox entries, since the Post scheduler wraps new posts in Create activities regardless of the original C2S type. --- includes/class-handler.php | 1 + includes/handler/outbox/class-arrive.php | 170 ++++++++++++++++++ includes/rest/class-outbox-controller.php | 11 +- .../rest/class-test-outbox-controller.php | 94 +++++++--- 4 files changed, 250 insertions(+), 26 deletions(-) create mode 100644 includes/handler/outbox/class-arrive.php 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.' ); } }