Add replies collection (#876)

* typo in phpdoc

* add first draft for adding replies collections to posts and comments

* refactoring

* Fix php CodeSniffer violations

* fix typo in php comment

* add draft for testing replies

* replies: test with own comment

* fix basic test for replies collection

* Restrict 'type' parameter for replies to 'post' or 'comment' in REST API

* some cleanups

* prefer ID over URL

* rename to `reply_id` to make clear that it is not the WordPress comment_id

* modularize retrieving of comment link via comment meta

* fix phpcs

* I think we should be more precise with this

and maybe there are other fallbacks coming

---------

Co-authored-by: Matthias Pfefferle <pfefferle@users.noreply.github.com>
This commit is contained in:
André Menrath 2024-09-25 13:24:35 +02:00 committed by GitHub
parent d361a6954d
commit 7370e97b5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 446 additions and 32 deletions

View file

@ -39,7 +39,7 @@ require_once __DIR__ . '/includes/functions.php';
\defined( 'ACTIVITYPUB_AUTHORIZED_FETCH' ) || \define( 'ACTIVITYPUB_AUTHORIZED_FETCH', false );
\defined( 'ACTIVITYPUB_DISABLE_REWRITES' ) || \define( 'ACTIVITYPUB_DISABLE_REWRITES', false );
\defined( 'ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS', false );
// Disable reactions like `Like` and `Accounce` by default
// Disable reactions like `Like` and `Announce` by default
\defined( 'ACTIVITYPUB_DISABLE_REACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_REACTIONS', true );
\defined( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS', false );
\defined( 'ACTIVITYPUB_SHARED_INBOX_FEATURE' ) || \define( 'ACTIVITYPUB_SHARED_INBOX_FEATURE', false );

View file

@ -90,6 +90,21 @@ class Activity extends Base_Object {
*/
protected $result;
/**
* Identifies a Collection containing objects considered to be responses
* to this object.
* WordPress has a strong core system of approving replies. We only include
* approved replies here.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-replies
*
* @var array
* | ObjectType
* | Link
* | null
*/
protected $replies;
/**
* An indirect object of the activity from which the
* activity is directed.

View file

@ -340,6 +340,46 @@ class Comment {
return $classes;
}
/**
* Gets the public comment id via the WordPress comments meta.
*
* @param int $wp_comment_id The internal WordPress comment ID.
* @param bool $fallback Whether the code should fall back to `source_url` if `source_id` is not set.
*
* @return string|null The ActivityPub id/url of the comment.
*/
public static function get_source_id( $wp_comment_id, $fallback = true ) {
$comment_meta = \get_comment_meta( $wp_comment_id );
if ( ! empty( $comment_meta['source_id'][0] ) ) {
return $comment_meta['source_id'][0];
} elseif ( ! empty( $comment_meta['source_url'][0] && $fallback ) ) {
return $comment_meta['source_url'][0];
}
return null;
}
/**
* Gets the public comment url via the WordPress comments meta.
*
* @param int $wp_comment_id The internal WordPress comment ID.
* @param bool $fallback Whether the code should fall back to `source_id` if `source_url` is not set.
*
* @return string|null The ActivityPub id/url of the comment.
*/
public static function get_source_url( $wp_comment_id, $fallback = true ) {
$comment_meta = \get_comment_meta( $wp_comment_id );
if ( ! empty( $comment_meta['source_url'][0] ) ) {
return $comment_meta['source_url'][0];
} elseif ( ! empty( $comment_meta['source_id'][0] && $fallback ) ) {
return $comment_meta['source_id'][0];
}
return null;
}
/**
* Link remote comments to source url.
*
@ -353,15 +393,9 @@ class Comment {
return $comment_link;
}
$comment_meta = \get_comment_meta( $comment->comment_ID );
$public_comment_link = self::get_source_url( $comment->comment_ID );
if ( ! empty( $comment_meta['source_url'][0] ) ) {
return $comment_meta['source_url'][0];
} elseif ( ! empty( $comment_meta['source_id'][0] ) ) {
return $comment_meta['source_id'][0];
}
return $comment_link;
return $public_comment_link ?? $comment_link;
}
@ -374,13 +408,12 @@ class Comment {
*/
public static function generate_id( $comment ) {
$comment = \get_comment( $comment );
$comment_meta = \get_comment_meta( $comment->comment_ID );
// show external comment ID if it exists
if ( ! empty( $comment_meta['source_id'][0] ) ) {
return $comment_meta['source_id'][0];
} elseif ( ! empty( $comment_meta['source_url'][0] ) ) {
return $comment_meta['source_url'][0];
$public_comment_link = self::get_source_id( $comment->comment_ID );
if ( $public_comment_link ) {
return $public_comment_link;
}
// generate URI based on comment ID

View file

@ -111,7 +111,7 @@ class Followers {
}
/**
* Get a Follower by Actor indepenent from the User.
* Get a Follower by Actor independent from the User.
*
* @param string $actor The Actor URL.
*

View file

@ -0,0 +1,176 @@
<?php
namespace Activitypub\Collection;
use WP_Post;
use WP_Comment;
use WP_Error;
use Activitypub\Comment;
use function Activitypub\is_local_comment;
use function Activitypub\get_rest_url_by_path;
/**
* Class containing code for getting replies Collections and CollectionPages of posts and comments.
*/
class Replies {
/**
* Build base arguments for fetching the comments of either a WordPress post or comment.
*
* @param WP_Post|WP_Comment $wp_object
*/
private static function build_args( $wp_object ) {
$args = array(
'status' => 'approve',
'orderby' => 'comment_date_gmt',
'order' => 'ASC',
);
if ( $wp_object instanceof WP_Post ) {
$args['parent'] = 0; // TODO: maybe this is unnecessary.
$args['post_id'] = $wp_object->ID;
} elseif ( $wp_object instanceof WP_Comment ) {
$args['parent'] = $wp_object->comment_ID;
} else {
return new WP_Error();
}
return $args;
}
/**
* Adds pagination args comments query.
*
* @param array $args Query args built by self::build_args.
* @param int $page The current pagination page.
* @param int $comments_per_page The number of comments per page.
*/
private static function add_pagination_args( $args, $page, $comments_per_page ) {
$args['number'] = $comments_per_page;
$offset = intval( $page ) * $comments_per_page;
$args['offset'] = $offset;
return $args;
}
/**
* Get the replies collections ID.
*
* @param WP_Post|WP_Comment $wp_object
*
* @return string The rest URL of the replies collection.
*/
private static function get_id( $wp_object ) {
if ( $wp_object instanceof WP_Post ) {
return get_rest_url_by_path( sprintf( 'posts/%d/replies', $wp_object->ID ) );
} elseif ( $wp_object instanceof WP_Comment ) {
return get_rest_url_by_path( sprintf( 'comments/%d/replies', $wp_object->comment_ID ) );
} else {
return new WP_Error();
}
}
/**
* Get the replies collection.
*
* @param WP_Post|WP_Comment $wp_object
* @param int $page
*
* @return array An associative array containing the replies collection without JSON-LD context.
*/
public static function get_collection( $wp_object ) {
$id = self::get_id( $wp_object );
if ( ! $id ) {
return null;
}
$replies = array(
'id' => $id,
'type' => 'Collection',
);
$replies['first'] = self::get_collection_page( $wp_object, 0, $replies['id'] );
return $replies;
}
/**
* Get the ActivityPub ID's from a list of comments.
*
* It takes only federated/non-local comments into account, others also do not have an
* ActivityPub ID available.
*
* @param WP_Comment[] $comments The comments to retrieve the ActivityPub ids from.
*
* @return string[] A list of the ActivityPub ID's.
*/
private static function get_reply_ids( $comments ) {
$comment_ids = array();
// Only add external comments from the fediverse.
// Maybe use the Comment class more and the function is_local_comment etc.
foreach ( $comments as $comment ) {
if ( is_local_comment( $comment ) ) {
continue;
}
$public_comment_id = Comment::get_source_id( $comment->comment_ID );
if ( $public_comment_id ) {
$comment_ids[] = $public_comment_id;
}
}
return $comment_ids;
}
/**
* Returns a replies collection page as an associative array.
*
* @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage
*
* @param WP_Post|WP_Comment $wp_object The post of comment the replies are for.
* @param int $page The current pagination page.
* @param string $part_of The collection id/url the returned CollectionPage belongs to.
*
* @return array A CollectionPage as an associative array.
*/
public static function get_collection_page( $wp_object, $page, $part_of = null ) {
// Build initial arguments for fetching approved comments.
$args = self::build_args( $wp_object );
// Retrieve the partOf if not already given.
$part_of = $part_of ?? self::get_id( $wp_object );
// If the collection page does not exist.
if ( is_wp_error( $args ) || is_wp_error( $part_of ) ) {
return null;
}
// Get to total replies count.
$total_replies = \get_comments( array_merge( $args, array( 'count' => true ) ) );
// Modify query args to retrieve paginated results.
$comments_per_page = \get_option( 'comments_per_page' );
// Fetch internal and external comments for current page.
$comments = get_comments( self::add_pagination_args( $args, $page, $comments_per_page ) );
// Get the ActivityPub ID's of the comments, without out local-only comments.
$comment_ids = self::get_reply_ids( $comments );
// Build the associative CollectionPage array.
$collection_page = array(
'id' => \add_query_arg( 'page', $page, $part_of ),
'type' => 'CollectionPage',
'partOf' => $part_of,
'items' => $comment_ids,
);
if ( $total_replies / $comments_per_page > $page + 1 ) {
$collection_page['next'] = \add_query_arg( 'page', $page + 1, $part_of );
}
return $collection_page;
}
}

View file

@ -6,7 +6,10 @@ use WP_REST_Response;
use Activitypub\Activity\Actor;
use Activitypub\Activity\Base_Object;
use Activitypub\Collection\Users as User_Collection;
use Activitypub\Collection\Replies;
use Activitypub\Transformer\Factory;
use WP_Error;
use function Activitypub\esc_hashtag;
use function Activitypub\is_single_user;
@ -69,6 +72,73 @@ class Collection {
),
)
);
\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/(?P<type>[\w\-\.]+)s/(?P<id>[\w\-\.]+)/replies',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'replies_get' ),
'args' => self::request_parameters_for_replies(),
'permission_callback' => '__return_true',
),
)
);
}
/**
* The endpoint for replies collections
*
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response The response object.
*/
public static function replies_get( $request ) {
$type = $request->get_param( 'type' );
// Get the WordPress object of that "owns" the requested replies.
switch ( $type ) {
case 'comment':
$wp_object = \get_comment( $request->get_param( 'id' ) );
break;
case 'post':
default:
$wp_object = \get_post( $request->get_param( 'id' ) );
break;
}
if ( ! isset( $wp_object ) || is_wp_error( $wp_object ) ) {
return new WP_Error(
'activitypub_replies_collection_does_not_exist',
\sprintf(
// translators: %s: The type (post, comment, etc.) for which no replies collection exists.
\__( 'No reply collection exists for the type %s.', 'activitypub' ),
$type
)
);
}
$page = intval( $request->get_param( 'page' ) );
// If the request parameter page is present get the CollectionPage otherwise the replies collection.
if ( isset( $page ) ) {
$response = Replies::get_collection_page( $wp_object, $page );
} else {
$response = Replies::get_collection( $wp_object );
}
if ( is_wp_error( $response ) ) {
return $response;
}
// Add ActivityPub Context.
$response = array_merge(
array( '@context' => Base_Object::JSON_LD_CONTEXT ),
$response
);
return new WP_REST_Response( $response, 200 );
}
/**
@ -225,4 +295,26 @@ class Collection {
return $params;
}
/**
* The supported parameters
*
* @return array list of parameters
*/
public static function request_parameters_for_replies() {
$params = array();
$params['type'] = array(
'required' => true,
'type' => 'string',
'enum' => array( 'post', 'comment' ),
);
$params['id'] = array(
'required' => true,
'type' => 'string',
);
return $params;
}
}

View file

@ -75,13 +75,9 @@ class Comment {
return $template;
}
$comment_meta = \get_comment_meta( $comment_id );
$resource = Comment_Utils::get_source_id( $comment_id );
if ( ! empty( $comment_meta['source_id'][0] ) ) {
$resource = $comment_meta['source_id'][0];
} elseif ( ! empty( $comment_meta['source_url'][0] ) ) {
$resource = $comment_meta['source_url'][0];
} else {
if ( ! $resource ) {
$resource = Comment_Utils::generate_id( $comment );
}

View file

@ -1,12 +1,13 @@
<?php
namespace Activitypub\Transformer;
use WP_Error;
use WP_Post;
use WP_Comment;
use Activitypub\Activity\Activity;
use Activitypub\Activity\Base_Object;
use Activitypub\Collection\Replies;
/**
* WordPress Base Transformer
@ -108,6 +109,16 @@ abstract class Base {
return $activity;
}
abstract protected function get_id();
/**
* Get the replies Collection.
*/
public function get_replies() {
$replies = Replies::get_collection( $this->wp_object, $this->get_id() );
return $replies;
}
/**
* Returns the ID of the WordPress Object.
*

View file

@ -134,13 +134,8 @@ class Comment extends Base {
}
if ( $parent_comment ) {
$comment_meta = \get_comment_meta( $parent_comment->comment_ID );
if ( ! empty( $comment_meta['source_id'][0] ) ) {
$in_reply_to = $comment_meta['source_id'][0];
} elseif ( ! empty( $comment_meta['source_url'][0] ) ) {
$in_reply_to = $comment_meta['source_url'][0];
} elseif ( ! empty( $parent_comment->user_id ) ) {
$in_reply_to = Comment_Utils::get_source_id( $parent_comment->comment_ID );
if ( ! $in_reply_to && ! empty( $parent_comment->user_id ) ) {
$in_reply_to = Comment_Utils::generate_id( $parent_comment );
}
} else {

View file

@ -124,7 +124,7 @@ class Post extends Base {
*
* @return string The Posts ID.
*/
public function get_id() {
protected function get_id() {
return $this->get_url();
}

View file

@ -1,5 +1,62 @@
<?php
class Test_Activitypub_Comment extends WP_UnitTestCase {
public function test_get_source_id_or_url() {
$comment_id = wp_insert_comment(
array(
'comment_type' => 'comment id',
'comment_content' => 'This is a comment id test',
'comment_author_url' => 'https://example.com',
'comment_author_email' => '',
'comment_meta' => array(
'protocol' => 'activitypub',
'source_id' => 'https://example.com/id',
),
)
);
$this->assertEquals( 'https://example.com/id', \Activitypub\Comment::get_source_url( $comment_id ) );
$this->assertEquals( 'https://example.com/id', \Activitypub\Comment::get_source_id( $comment_id ) );
$this->assertEquals( 'https://example.com/id', \Activitypub\Comment::get_source_id( $comment_id, false ) );
$this->assertEquals( null, \Activitypub\Comment::get_source_url( $comment_id, false ) );
$comment_id = wp_insert_comment(
array(
'comment_type' => 'comment url',
'comment_content' => 'This is a comment url test',
'comment_author_url' => 'https://example.com',
'comment_author_email' => '',
'comment_meta' => array(
'protocol' => 'activitypub',
'source_url' => 'https://example.com/url',
),
)
);
$this->assertEquals( 'https://example.com/url', \Activitypub\Comment::get_source_id( $comment_id ) );
$this->assertEquals( 'https://example.com/url', \Activitypub\Comment::get_source_url( $comment_id ) );
$this->assertEquals( 'https://example.com/url', \Activitypub\Comment::get_source_url( $comment_id, false ) );
$this->assertEquals( null, \Activitypub\Comment::get_source_id( $comment_id, false ) );
$comment_id = wp_insert_comment(
array(
'comment_type' => 'comment url and id',
'comment_content' => 'This is a comment url and id test',
'comment_author_url' => 'https://example.com',
'comment_author_email' => '',
'comment_meta' => array(
'protocol' => 'activitypub',
'source_url' => 'https://example.com/url',
'source_id' => 'https://example.com/id',
),
)
);
$this->assertEquals( 'https://example.com/id', \Activitypub\Comment::get_source_id( $comment_id ) );
$this->assertEquals( 'https://example.com/id', \Activitypub\Comment::get_source_id( $comment_id, false ) );
$this->assertEquals( 'https://example.com/url', \Activitypub\Comment::get_source_url( $comment_id ) );
$this->assertEquals( 'https://example.com/url', \Activitypub\Comment::get_source_url( $comment_id, false ) );
}
/**
* @dataProvider ability_to_federate_comment
*/

View file

@ -0,0 +1,39 @@
<?php
class Test_Activitypub_Replies extends WP_UnitTestCase {
public function test_replies_collection_of_post_with_federated_comments() {
$post_id = \wp_insert_post(
array(
'post_author' => 1,
'post_content' => 'test',
)
);
$source_id = 'https://example.instance/notes/123';
$comment = array(
'user_id' => 1,
'comment_type' => 'comment',
'comment_content' => 'This is a comment.',
'comment_author_url' => 'https://example.com',
'comment_author_email' => '',
'comment_meta' => array(
'protocol' => 'activitypub',
'source_id' => $source_id,
),
'comment_post_ID' => $post_id,
);
$comment_id = wp_insert_comment( $comment );
wp_set_comment_status( $comment_id, 'hold' );
$replies = Activitypub\Collection\Replies::get_collection( get_post( $post_id ) );
$this->assertEquals( $replies['id'], sprintf( 'http://example.org/index.php?rest_route=/activitypub/1.0/posts/%d/replies', $post_id ) );
$this->assertCount( 0, $replies['first']['items'] );
wp_set_comment_status( $comment_id, 'approve' );
$replies = Activitypub\Collection\Replies::get_collection( get_post( $post_id ) );
$this->assertCount( 1, $replies['first']['items'] );
$this->assertEquals( $replies['first']['items'][0], $source_id );
}
}