Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions includes/class-handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
170 changes: 170 additions & 0 deletions includes/handler/outbox/class-arrive.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php
/**
* Outbox Arrive handler file.
*
* @package Activitypub
*/

namespace Activitypub\Handler\Outbox;

use Activitypub\Collection\Posts;

use function Activitypub\is_activity_public;

/**
* Handle outgoing Arrive activities.
*
* @since unreleased
*/
class Arrive {
/**
* Initialize the class, registering WordPress hooks.
*
* @since unreleased
*/
public static function init() {
\add_filter( 'activitypub_outbox_arrive', array( self::class, 'handle_arrive' ), 10, 3 );
\add_action( 'activitypub_outbox_arrive_sent', array( self::class, 'save_location' ), 10, 4 );
}

/**
* Handle outgoing "Arrive" activities from local actors.
*
* Arrive is an intransitive activity indicating that the actor
* has arrived at a location. Creates a WordPress post so the
* check-in appears on the blog. Location geodata is saved via
* the `activitypub_outbox_arrive_sent` action.
*
* @since unreleased
*
* @param array $data The activity data array.
* @param int $user_id The user ID.
* @param string|null $visibility Content visibility.
*
* @return \WP_Post|\WP_Error|false The created post, error, or false.
*/
public static function handle_arrive( $data, $user_id = null, $visibility = null ) {
if ( ! is_activity_public( $data ) ) {
return false;
}

$location_name = self::get_location_name( $data['location'] ?? null );

$title = $location_name
? sprintf(
/* translators: %s: location name */
\__( 'Checked in at %s', 'activitypub' ),
$location_name
)
: \__( 'Check-in', 'activitypub' );

/*
* Synthesize a Create-style activity for Posts::create().
* Arrive is intransitive (no object), so we build a Note
* from the activity-level content/summary.
*/
$activity = array(
'object' => 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;
}
}
11 changes: 10 additions & 1 deletion includes/rest/class-outbox-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
94 changes: 69 additions & 25 deletions tests/phpunit/tests/includes/rest/class-test-outbox-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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',
Expand All @@ -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.' );
}
}
Loading