updated plugin ActivityPub version 8.3.0
This commit is contained in:
@ -7,17 +7,19 @@
|
||||
|
||||
namespace Activitypub\Integration;
|
||||
|
||||
use DateTime;
|
||||
use Activitypub\Webfinger as Webfinger_Util;
|
||||
use Activitypub\Activity\Actor;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Extra_Fields;
|
||||
use Activitypub\Collection\Followers;
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
use Activitypub\Http;
|
||||
use Activitypub\Mention;
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Collection\Followers;
|
||||
use Activitypub\Collection\Extra_Fields;
|
||||
use Activitypub\Transformer\Factory;
|
||||
use Activitypub\Webfinger as Webfinger_Util;
|
||||
use Enable_Mastodon_Apps\Entity\Account;
|
||||
use Enable_Mastodon_Apps\Entity\Status;
|
||||
use Enable_Mastodon_Apps\Entity\Media_Attachment;
|
||||
use Enable_Mastodon_Apps\Entity\Notification;
|
||||
use Enable_Mastodon_Apps\Entity\Status;
|
||||
|
||||
use function Activitypub\get_remote_metadata_by_actor;
|
||||
use function Activitypub\is_user_type_disabled;
|
||||
@ -30,10 +32,18 @@ use function Activitypub\is_user_type_disabled;
|
||||
* @see https://github.com/akirk/enable-mastodon-apps
|
||||
*/
|
||||
class Enable_Mastodon_Apps {
|
||||
/**
|
||||
* Default limit for notifications.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const DEFAULT_NOTIFICATION_LIMIT = 15;
|
||||
|
||||
/**
|
||||
* Initialize the class, registering WordPress hooks.
|
||||
*/
|
||||
public static function init() {
|
||||
\add_filter( 'mastodon_api_valid_user', array( self::class, 'is_ap_actor' ), 10, 2 );
|
||||
\add_filter( 'mastodon_api_account_followers', array( self::class, 'api_account_followers' ), 10, 2 );
|
||||
\add_filter( 'mastodon_api_account', array( self::class, 'api_account_external' ), 15, 2 );
|
||||
\add_filter( 'mastodon_api_account', array( self::class, 'api_account_internal' ), 9, 2 );
|
||||
@ -42,9 +52,12 @@ class Enable_Mastodon_Apps {
|
||||
\add_filter( 'mastodon_api_search', array( self::class, 'api_search_by_url' ), 40, 2 );
|
||||
\add_filter( 'mastodon_api_get_posts_query_args', array( self::class, 'api_get_posts_query_args' ) );
|
||||
\add_filter( 'mastodon_api_statuses', array( self::class, 'api_statuses_external' ), 10, 2 );
|
||||
\add_filter( 'mastodon_api_status_by_url', array( self::class, 'api_status_by_url' ), 10, 2 );
|
||||
\add_filter( 'mastodon_api_status_context', array( self::class, 'api_get_replies' ), 10, 3 );
|
||||
\add_filter( 'mastodon_api_update_credentials', array( self::class, 'api_update_credentials' ), 10, 2 );
|
||||
\add_filter( 'mastodon_api_submit_status_text', array( Mention::class, 'the_content' ) );
|
||||
\add_filter( 'mastodon_api_notifications_get', array( self::class, 'api_notifications_get' ), 10, 5 );
|
||||
\add_filter( 'mastodon_api_tag_timeline', array( self::class, 'api_tag_timeline_tags_pub' ), 20, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -175,6 +188,26 @@ class Enable_Mastodon_Apps {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate ap_actor post IDs as valid Mastodon API users.
|
||||
*
|
||||
* @param bool $is_valid Whether the user is valid.
|
||||
* @param string|int $user_id The user ID to check.
|
||||
*
|
||||
* @return bool True if the user ID is a valid ap_actor post.
|
||||
*/
|
||||
public static function is_ap_actor( $is_valid, $user_id ) {
|
||||
if ( $is_valid ) {
|
||||
return $is_valid;
|
||||
}
|
||||
|
||||
if ( \is_numeric( $user_id ) && Remote_Actors::POST_TYPE === \get_post_type( (int) $user_id ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $is_valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add followers to Mastodon API.
|
||||
*
|
||||
@ -185,45 +218,30 @@ class Enable_Mastodon_Apps {
|
||||
*/
|
||||
public static function api_account_followers( $followers, $user_id ) {
|
||||
$user_id = self::maybe_map_user_to_blog( $user_id );
|
||||
$activitypub_followers = Followers::get_followers( $user_id, 40 );
|
||||
$mastodon_followers = array_map(
|
||||
function ( $item ) {
|
||||
$acct = Webfinger_Util::uri_to_acct( $item->get_id() );
|
||||
$activitypub_followers = Followers::get_many( $user_id, 40 );
|
||||
$mastodon_followers = array();
|
||||
|
||||
if ( $acct && ! is_wp_error( $acct ) ) {
|
||||
$acct = \str_replace( 'acct:', '', $acct );
|
||||
} else {
|
||||
$acct = $item->get_id();
|
||||
}
|
||||
foreach ( $activitypub_followers as $follower ) {
|
||||
$actor = Remote_Actors::get_actor( $follower );
|
||||
if ( ! $actor || \is_wp_error( $actor ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$account = new Account();
|
||||
$account->id = \strval( $item->get__id() );
|
||||
$account->username = $item->get_preferred_username();
|
||||
$account->acct = $acct;
|
||||
$account->display_name = $item->get_name();
|
||||
$account->url = $item->get_url();
|
||||
$account->avatar = $item->get_icon_url();
|
||||
$account->avatar_static = $item->get_icon_url();
|
||||
$account->created_at = new DateTime( $item->get_published() );
|
||||
$account->last_status_at = new DateTime( $item->get_published() );
|
||||
$account->note = $item->get_summary();
|
||||
$account->header = $item->get_image_url();
|
||||
$account->header_static = $item->get_image_url();
|
||||
$account->followers_count = 0;
|
||||
$account->following_count = 0;
|
||||
$account->statuses_count = 0;
|
||||
$account->bot = false;
|
||||
$account->locked = false;
|
||||
$account->group = false;
|
||||
$account->discoverable = false;
|
||||
$account->noindex = false;
|
||||
$account->fields = array();
|
||||
$account->emojis = array();
|
||||
$account = self::actor_to_account( $actor, $follower->ID );
|
||||
|
||||
return $account;
|
||||
},
|
||||
$activitypub_followers
|
||||
);
|
||||
$account->followers_count = 0;
|
||||
$account->following_count = 0;
|
||||
$account->statuses_count = 0;
|
||||
$account->bot = false;
|
||||
$account->locked = false;
|
||||
$account->group = false;
|
||||
$account->discoverable = false;
|
||||
$account->noindex = false;
|
||||
$account->fields = array();
|
||||
$account->emojis = array();
|
||||
|
||||
$mastodon_followers[] = $account;
|
||||
}
|
||||
|
||||
return array_merge( $mastodon_followers, $followers );
|
||||
}
|
||||
@ -237,6 +255,13 @@ class Enable_Mastodon_Apps {
|
||||
* @return Account The filtered Account.
|
||||
*/
|
||||
public static function api_account_external( $user_data, $user_id ) {
|
||||
if ( ! $user_data && \is_numeric( $user_id ) && Remote_Actors::POST_TYPE === \get_post_type( (int) $user_id ) ) {
|
||||
$actor = Remote_Actors::get_actor( (int) $user_id );
|
||||
if ( $actor && ! \is_wp_error( $actor ) ) {
|
||||
return self::actor_to_account( $actor, (int) $user_id );
|
||||
}
|
||||
}
|
||||
|
||||
if ( $user_data || ( is_numeric( $user_id ) && $user_id ) ) {
|
||||
// Only augment.
|
||||
return $user_data;
|
||||
@ -299,7 +324,7 @@ class Enable_Mastodon_Apps {
|
||||
$account->header_static = $account->header;
|
||||
}
|
||||
|
||||
$account->created_at = new DateTime( $user->get_published() );
|
||||
$account->created_at = new \DateTime( $user->get_published() );
|
||||
|
||||
$post_types = \get_option( 'activitypub_support_post_types', array( 'post' ) );
|
||||
$query_args = array(
|
||||
@ -310,26 +335,26 @@ class Enable_Mastodon_Apps {
|
||||
$query_args['author'] = $user_id;
|
||||
}
|
||||
$posts = \get_posts( $query_args );
|
||||
$account->last_status_at = ! empty( $posts ) ? new DateTime( $posts[0]->post_date_gmt ) : $account->created_at;
|
||||
$account->last_status_at = ! empty( $posts ) ? new \DateTime( $posts[0]->post_date_gmt ) : $account->created_at;
|
||||
|
||||
$account->fields = self::get_extra_fields( $user_id_to_use );
|
||||
// Now do it in source['fields'] with stripped tags.
|
||||
$account->source['fields'] = \array_map(
|
||||
function ( $field ) {
|
||||
static function ( $field ) {
|
||||
$field['value'] = \wp_strip_all_tags( $field['value'], true );
|
||||
return $field;
|
||||
},
|
||||
$account->fields
|
||||
);
|
||||
|
||||
$account->followers_count = Followers::count_followers( $user->get__id() );
|
||||
$account->followers_count = Followers::count( $user_id );
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use our representation of posts to power each status item.
|
||||
* Includes proper referncing of 3rd party comments that arrived via federation.
|
||||
* Includes proper referencing of 3rd party comments that arrived via federation.
|
||||
*
|
||||
* @param null|Status $status The status, typically null to allow later filters their shot.
|
||||
* @param int $post_id The post ID.
|
||||
@ -368,59 +393,122 @@ class Enable_Mastodon_Apps {
|
||||
/**
|
||||
* Get account for actor.
|
||||
*
|
||||
* @param string $uri The URI.
|
||||
* @param string|Actor $actor_or_uri The Actor object or URI.
|
||||
*
|
||||
* @return Account|null The account.
|
||||
*/
|
||||
private static function get_account_for_actor( $uri ) {
|
||||
if ( ! is_string( $uri ) || empty( $uri ) ) {
|
||||
return null;
|
||||
private static function get_account_for_actor( $actor_or_uri ) {
|
||||
// If it's already an Actor object, use it directly.
|
||||
if ( $actor_or_uri instanceof Actor ) {
|
||||
return self::actor_to_account( $actor_or_uri );
|
||||
}
|
||||
$data = get_remote_metadata_by_actor( $uri );
|
||||
|
||||
if ( ! $data || is_wp_error( $data ) ) {
|
||||
if ( ! \is_string( $actor_or_uri ) || empty( $actor_or_uri ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch actor from cache or remote.
|
||||
$actor_post = Remote_Actors::fetch_by_uri( $actor_or_uri );
|
||||
if ( ! $actor_post || \is_wp_error( $actor_post ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$actor = Remote_Actors::get_actor( $actor_post );
|
||||
if ( ! $actor || \is_wp_error( $actor ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::actor_to_account( $actor, $actor_post->ID );
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an Actor object to an Account.
|
||||
*
|
||||
* @param Actor $actor The actor object.
|
||||
* @param int|null $post_id Optional WordPress post ID for the actor.
|
||||
*
|
||||
* @return Account The account.
|
||||
*/
|
||||
private static function actor_to_account( $actor, $post_id = null ) {
|
||||
$account = new Account();
|
||||
|
||||
$acct = Webfinger_Util::uri_to_acct( $uri );
|
||||
if ( ! $acct || is_wp_error( $acct ) ) {
|
||||
$actor_id = $post_id ? $post_id : $actor->get__id();
|
||||
if ( ! $actor_id ) {
|
||||
$actor_id = $actor->get_id();
|
||||
}
|
||||
|
||||
$account->id = \strval( $actor_id );
|
||||
$account->username = $actor->get_preferred_username();
|
||||
$account->acct = $actor->get_webfinger();
|
||||
$account->display_name = $actor->get_name();
|
||||
$account->url = $actor->get_url();
|
||||
$account->created_at = new \DateTime( 'now' );
|
||||
|
||||
$icon = $actor->get_icon();
|
||||
$avatar = null;
|
||||
if ( $icon ) {
|
||||
if ( \is_array( $icon ) && isset( $icon['url'] ) ) {
|
||||
$avatar = $icon['url'];
|
||||
} elseif ( \is_string( $icon ) ) {
|
||||
$avatar = $icon;
|
||||
}
|
||||
}
|
||||
if ( $avatar ) {
|
||||
$account->avatar = $avatar;
|
||||
$account->avatar_static = $avatar;
|
||||
}
|
||||
|
||||
$summary = $actor->get_summary();
|
||||
if ( $summary ) {
|
||||
$account->note = $summary;
|
||||
}
|
||||
|
||||
$image = $actor->get_image();
|
||||
$header = null;
|
||||
if ( $image ) {
|
||||
if ( \is_array( $image ) && isset( $image['url'] ) ) {
|
||||
$header = $image['url'];
|
||||
} elseif ( \is_string( $image ) ) {
|
||||
$header = $image;
|
||||
}
|
||||
}
|
||||
if ( $header ) {
|
||||
$account->header = $header;
|
||||
$account->header_static = $header;
|
||||
}
|
||||
|
||||
$published = $actor->get_published();
|
||||
if ( $published ) {
|
||||
$account->created_at = new \DateTime( $published );
|
||||
}
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a status by its remote URL.
|
||||
*
|
||||
* @param Status|null $status The current status.
|
||||
* @param string $url The remote URL of the status.
|
||||
*
|
||||
* @return Status|null The status, or null if it could not be fetched.
|
||||
*/
|
||||
public static function api_status_by_url( $status, $url ) {
|
||||
if ( $status ) {
|
||||
return $status;
|
||||
}
|
||||
|
||||
$object = Http::get_remote_object( $url, true );
|
||||
if ( \is_wp_error( $object ) || ! isset( $object['attributedTo'] ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( str_starts_with( $acct, 'acct:' ) ) {
|
||||
$acct = substr( $acct, 5 );
|
||||
$account = self::get_account_for_actor( $object['attributedTo'] );
|
||||
if ( ! $account ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$account->id = $acct;
|
||||
$account->username = $acct;
|
||||
$account->acct = $acct;
|
||||
$account->display_name = $data['name'];
|
||||
$account->url = $uri;
|
||||
|
||||
if ( ! empty( $data['summary'] ) ) {
|
||||
$account->note = $data['summary'];
|
||||
}
|
||||
|
||||
if (
|
||||
isset( $data['icon']['type'] ) &&
|
||||
isset( $data['icon']['url'] ) &&
|
||||
'Image' === $data['icon']['type']
|
||||
) {
|
||||
$account->avatar = $data['icon']['url'];
|
||||
$account->avatar_static = $data['icon']['url'];
|
||||
}
|
||||
|
||||
if ( isset( $data['image'] ) ) {
|
||||
$account->header = $data['image']['url'];
|
||||
$account->header_static = $data['image']['url'];
|
||||
}
|
||||
if ( ! isset( $data['published'] ) ) {
|
||||
$data['published'] = 'now';
|
||||
}
|
||||
$account->created_at = new DateTime( $data['published'] );
|
||||
|
||||
return $account;
|
||||
return self::activity_to_status( $object, $account );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -437,17 +525,7 @@ class Enable_Mastodon_Apps {
|
||||
return $search_data;
|
||||
}
|
||||
|
||||
$object = Http::get_remote_object( $request->get_param( 'q' ), true );
|
||||
if ( is_wp_error( $object ) || ! isset( $object['attributedTo'] ) ) {
|
||||
return $search_data;
|
||||
}
|
||||
|
||||
$account = self::get_account_for_actor( $object['attributedTo'] );
|
||||
if ( ! $account ) {
|
||||
return $search_data;
|
||||
}
|
||||
|
||||
$status = self::activity_to_status( $object, $account );
|
||||
$status = \apply_filters( 'mastodon_api_status_by_url', null, $request->get_param( 'q' ) );
|
||||
if ( $status ) {
|
||||
$search_data['statuses'][] = $status;
|
||||
}
|
||||
@ -475,34 +553,20 @@ class Enable_Mastodon_Apps {
|
||||
}
|
||||
$q = sanitize_text_field( wp_unslash( $q ) );
|
||||
|
||||
$followers = Followers::get_followers( $user_id, 40, null, array( 's' => $q ) );
|
||||
$followers = Followers::get_many( $user_id, 40, null, array( 's' => $q ) );
|
||||
if ( ! $followers ) {
|
||||
return $search_data;
|
||||
}
|
||||
|
||||
foreach ( $followers as $follower ) {
|
||||
$acct = Webfinger_Util::uri_to_acct( $follower->get_id() );
|
||||
|
||||
if ( $acct && ! is_wp_error( $acct ) ) {
|
||||
$acct = \str_replace( 'acct:', '', $acct );
|
||||
} else {
|
||||
$acct = $follower->get_url();
|
||||
$actor = Remote_Actors::get_actor( $follower );
|
||||
if ( ! $actor || \is_wp_error( $actor ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$account = new Account();
|
||||
$account->id = \strval( $follower->get__id() );
|
||||
$account->username = $follower->get_preferred_username();
|
||||
$account->acct = $acct;
|
||||
$account->display_name = $follower->get_name();
|
||||
$account->url = $follower->get_url();
|
||||
$account->uri = $follower->get_id();
|
||||
$account->avatar = $follower->get_icon_url();
|
||||
$account->avatar_static = $follower->get_icon_url();
|
||||
$account->created_at = new DateTime( $follower->get_published() );
|
||||
$account->last_status_at = new DateTime( $follower->get_published() );
|
||||
$account->note = $follower->get_summary();
|
||||
$account->header = $follower->get_image_url();
|
||||
$account->header_static = $follower->get_image_url();
|
||||
$account = self::actor_to_account( $actor, $follower->ID );
|
||||
|
||||
$account->uri = $actor->get_id();
|
||||
|
||||
$search_data['accounts'][] = $account;
|
||||
}
|
||||
@ -545,13 +609,13 @@ class Enable_Mastodon_Apps {
|
||||
$object = $item;
|
||||
}
|
||||
|
||||
if ( ! isset( $object['type'] ) || 'Note' !== $object['type'] || ! $account ) {
|
||||
if ( ! isset( $object['type'] ) || ! in_array( $object['type'], array( 'Article', 'Note' ), true ) || ! $account ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$status = new Status();
|
||||
$status->id = $post_id ?? $object['id'];
|
||||
$status->created_at = new DateTime( $object['published'] );
|
||||
$status->created_at = new \DateTime( $object['published'] );
|
||||
$status->content = $object['content'];
|
||||
$status->account = $account;
|
||||
|
||||
@ -571,7 +635,7 @@ class Enable_Mastodon_Apps {
|
||||
|
||||
if ( ! empty( $object['attachment'] ) ) {
|
||||
$status->media_attachments = array_map(
|
||||
function ( $attachment ) {
|
||||
static function ( $attachment ) {
|
||||
$default_attachment = array(
|
||||
'url' => null,
|
||||
'mediaType' => null,
|
||||
@ -659,7 +723,7 @@ class Enable_Mastodon_Apps {
|
||||
}
|
||||
|
||||
$new_statuses = array_map(
|
||||
function ( $item ) use ( $account, $args ) {
|
||||
static function ( $item ) use ( $account, $args ) {
|
||||
if ( $args['exclude_replies'] ) {
|
||||
if ( isset( $item['object']['inReplyTo'] ) && $item['object']['inReplyTo'] ) {
|
||||
return null;
|
||||
@ -680,6 +744,208 @@ class Enable_Mastodon_Apps {
|
||||
return array_slice( $activitypub_statuses, 0, $limit );
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum number of tags.pub items to resolve per request.
|
||||
*
|
||||
* Each outbox item requires multiple HTTP round-trips to resolve
|
||||
* (Announce activity → original post → author actor), so we ignore
|
||||
* the client-requested limit and use this small batch size instead.
|
||||
* Results are cached, so subsequent requests are fast.
|
||||
*/
|
||||
const TAGS_PUB_BATCH_SIZE = 5;
|
||||
|
||||
/**
|
||||
* Supplement tag timeline with posts from tags.pub outbox.
|
||||
*
|
||||
* Fetches the outbox of the corresponding tags.pub actor (e.g. @wordpress@tags.pub)
|
||||
* and resolves Announce activities to their original posts.
|
||||
*
|
||||
* @param \WP_REST_Response|null $statuses The current statuses (WP_REST_Response with Status[] data).
|
||||
* @param \WP_REST_Request $request The request object.
|
||||
*
|
||||
* @return \WP_REST_Response|null The statuses including remote ones.
|
||||
*/
|
||||
public static function api_tag_timeline_tags_pub( $statuses, $request ) {
|
||||
$hashtag = \strtolower( $request->get_param( 'hashtag' ) );
|
||||
if ( ! $hashtag ) {
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
$remote_statuses = self::fetch_tags_pub_outbox( $hashtag, self::TAGS_PUB_BATCH_SIZE );
|
||||
if ( empty( $remote_statuses ) ) {
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
if ( ! $statuses instanceof \WP_REST_Response ) {
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
$merged = \array_merge( $statuses->data, $remote_statuses );
|
||||
|
||||
// Deduplicate by status ID to prevent client crashes (e.g. Tusky).
|
||||
$seen = array();
|
||||
$merged = \array_values(
|
||||
\array_filter(
|
||||
$merged,
|
||||
function ( $status ) use ( &$seen ) {
|
||||
if ( isset( $seen[ $status->id ] ) ) {
|
||||
return false;
|
||||
}
|
||||
$seen[ $status->id ] = true;
|
||||
return true;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Sort by created_at descending.
|
||||
\usort(
|
||||
$merged,
|
||||
function ( $a, $b ) {
|
||||
$a_ts = isset( $a->created_at ) ? $a->created_at->getTimestamp() : 0;
|
||||
$b_ts = isset( $b->created_at ) ? $b->created_at->getTimestamp() : 0;
|
||||
return $b_ts - $a_ts;
|
||||
}
|
||||
);
|
||||
|
||||
$limit = $request->get_param( 'limit' ) ?: 20;
|
||||
$statuses->data = \array_slice( $merged, 0, $limit );
|
||||
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch posts from the tags.pub outbox for a given hashtag.
|
||||
*
|
||||
* Resolved ActivityPub objects are cached as plain arrays in a transient
|
||||
* to avoid storing PHP objects (which is brittle across deployments).
|
||||
* Status entities are built fresh from the cached data on each request.
|
||||
*
|
||||
* @param string $hashtag The hashtag name (without #).
|
||||
* @param int $limit Maximum number of posts to return.
|
||||
*
|
||||
* @return Status[] Array of Status entities.
|
||||
*/
|
||||
private static function fetch_tags_pub_outbox( $hashtag, $limit = 20 ) {
|
||||
$transient_key = 'activitypub_tags_pub_' . \md5( $hashtag );
|
||||
$cached = \get_transient( $transient_key );
|
||||
|
||||
if ( false === $cached ) {
|
||||
$cached = self::resolve_tags_pub_items( $hashtag, $limit );
|
||||
$ttl = empty( $cached ) ? 5 * \MINUTE_IN_SECONDS : 15 * \MINUTE_IN_SECONDS;
|
||||
\set_transient( $transient_key, $cached, $ttl );
|
||||
}
|
||||
|
||||
// Build Status entities from the cached ActivityPub data.
|
||||
$statuses = array();
|
||||
foreach ( $cached as $entry ) {
|
||||
$account = self::get_account_for_actor( $entry['actor_uri'] );
|
||||
if ( $account ) {
|
||||
$status = self::activity_to_status( $entry['object'], $account );
|
||||
if ( $status ) {
|
||||
$statuses[] = $status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and resolve tags.pub outbox items to cacheable arrays.
|
||||
*
|
||||
* Each item requires multiple HTTP round-trips (Announce → original
|
||||
* post → author actor), so results are cached by the caller.
|
||||
*
|
||||
* @param string $hashtag The hashtag name (without #).
|
||||
* @param int $limit Maximum number of items to resolve.
|
||||
*
|
||||
* @return array[] Array of arrays with 'object' and 'actor_uri' keys.
|
||||
*/
|
||||
private static function resolve_tags_pub_items( $hashtag, $limit ) {
|
||||
/**
|
||||
* Filters the tags.pub base URL for tag timeline lookups.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*
|
||||
* @param string $base_url The base URL. Default 'https://tags.pub'.
|
||||
*/
|
||||
$base_url = \apply_filters( 'activitypub_tags_pub_base_url', 'https://tags.pub' );
|
||||
$outbox_url = \trailingslashit( $base_url ) . 'user/' . \rawurlencode( $hashtag ) . '/outbox';
|
||||
$outbox = Http::get_remote_object( $outbox_url, true );
|
||||
|
||||
if ( \is_wp_error( $outbox ) || empty( $outbox['first'] ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$page = Http::get_remote_object( $outbox['first'], true );
|
||||
if ( \is_wp_error( $page ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$items = $page['orderedItems'] ?? $page['items'] ?? array();
|
||||
$results = array();
|
||||
|
||||
foreach ( $items as $item ) {
|
||||
if ( \count( $results ) >= $limit ) {
|
||||
break;
|
||||
}
|
||||
|
||||
$resolved = self::resolve_tags_pub_item( $item );
|
||||
if ( $resolved ) {
|
||||
$results[] = $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tags.pub outbox item (Announce activity) to cacheable data.
|
||||
*
|
||||
* @param string|array $item The outbox item (URI string or activity object).
|
||||
*
|
||||
* @return array|null Array with 'object' and 'actor_uri' keys, or null on failure.
|
||||
*/
|
||||
private static function resolve_tags_pub_item( $item ) {
|
||||
// Resolve item to an activity object.
|
||||
if ( \is_string( $item ) ) {
|
||||
$activity = Http::get_remote_object( $item, true );
|
||||
if ( \is_wp_error( $activity ) ) {
|
||||
return null;
|
||||
}
|
||||
} elseif ( \is_array( $item ) ) {
|
||||
$activity = $item;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = $activity['type'] ?? '';
|
||||
if ( 'Announce' !== $type ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the original post URL from the Announce.
|
||||
$object_url = \is_string( $activity['object'] ) ? $activity['object'] : ( $activity['object']['id'] ?? null );
|
||||
if ( ! $object_url ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$object = Http::get_remote_object( $object_url, true );
|
||||
if ( \is_wp_error( $object ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$actor_uri = $object['attributedTo'] ?? '';
|
||||
if ( ! $actor_uri ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'object' => $object,
|
||||
'actor_uri' => $actor_uri,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get replies for Mastodon API.
|
||||
*
|
||||
@ -718,7 +984,7 @@ class Enable_Mastodon_Apps {
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
$response = Http::get( $url, true );
|
||||
$response = Http::get( $url, array(), true );
|
||||
if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
|
||||
continue;
|
||||
}
|
||||
@ -737,4 +1003,244 @@ class Enable_Mastodon_Apps {
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add repost, like, and follow notifications from ActivityPub data.
|
||||
*
|
||||
* @param array $notifications The notifications array.
|
||||
* @param object $request The request object.
|
||||
* @param int|null $limit Max number of notifications per page.
|
||||
* @param string|null $before_date MySQL datetime; only return notifications before this date.
|
||||
* @param string|null $after_date MySQL datetime; only return notifications after this date.
|
||||
*
|
||||
* @return array The filtered notifications.
|
||||
*/
|
||||
public static function api_notifications_get( $notifications, $request, $limit = null, $before_date = null, $after_date = null ) {
|
||||
$types = $request->get_param( 'types' );
|
||||
$exclude_types = $request->get_param( 'exclude_types' );
|
||||
|
||||
$include_reblog = ( ! \is_array( $types ) || \in_array( 'reblog', $types, true ) ) &&
|
||||
( ! \is_array( $exclude_types ) || ! \in_array( 'reblog', $exclude_types, true ) );
|
||||
$include_favourite = ( ! \is_array( $types ) || \in_array( 'favourite', $types, true ) ) &&
|
||||
( ! \is_array( $exclude_types ) || ! \in_array( 'favourite', $exclude_types, true ) );
|
||||
$include_follow = ( ! \is_array( $types ) || \in_array( 'follow', $types, true ) ) &&
|
||||
( ! \is_array( $exclude_types ) || ! \in_array( 'follow', $exclude_types, true ) );
|
||||
|
||||
if ( ! $include_reblog && ! $include_favourite && ! $include_follow ) {
|
||||
return $notifications;
|
||||
}
|
||||
|
||||
$user_id = \get_current_user_id();
|
||||
if ( ! $user_id ) {
|
||||
return $notifications;
|
||||
}
|
||||
|
||||
if ( ! \class_exists( Notification::class ) ) {
|
||||
return $notifications;
|
||||
}
|
||||
|
||||
if ( null === $limit ) {
|
||||
$limit = $request->get_param( 'limit' ) ? $request->get_param( 'limit' ) : self::DEFAULT_NOTIFICATION_LIMIT;
|
||||
}
|
||||
|
||||
// Get reblog/favourite notifications from comments.
|
||||
if ( $include_reblog || $include_favourite ) {
|
||||
$comment_types = array();
|
||||
if ( $include_reblog ) {
|
||||
$comment_types[] = 'repost';
|
||||
}
|
||||
if ( $include_favourite ) {
|
||||
$comment_types[] = 'like';
|
||||
}
|
||||
|
||||
$post_types = \get_option( 'activitypub_support_post_types', array( 'post' ) );
|
||||
|
||||
$comment_args = array(
|
||||
'post_author' => $user_id,
|
||||
'post_type' => $post_types,
|
||||
'type__in' => $comment_types,
|
||||
'number' => $limit,
|
||||
'orderby' => 'comment_date',
|
||||
'order' => 'DESC',
|
||||
);
|
||||
|
||||
$date_query = self::build_date_query( $before_date, $after_date, 'comment_date' );
|
||||
if ( $date_query ) {
|
||||
$comment_args['date_query'] = $date_query;
|
||||
}
|
||||
|
||||
$comments = \get_comments( $comment_args );
|
||||
|
||||
foreach ( $comments as $comment ) {
|
||||
$type = 'repost' === $comment->comment_type ? 'reblog' : 'favourite';
|
||||
|
||||
$account = self::get_account_for_comment( $comment );
|
||||
if ( ! $account ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = self::api_post_status( $comment->comment_post_ID );
|
||||
if ( ! $status ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$notification = new Notification();
|
||||
$notification->id = \strval( $comment->comment_ID );
|
||||
$notification->type = $type;
|
||||
$notification->created_at = \mysql2date( 'Y-m-d\TH:i:s.000P', $comment->comment_date, false );
|
||||
$notification->account = $account;
|
||||
$notification->status = $status;
|
||||
|
||||
$notifications[] = $notification;
|
||||
}
|
||||
}
|
||||
|
||||
// Get follow notifications from followers.
|
||||
if ( $include_follow ) {
|
||||
$notifications = self::add_follow_notifications( $notifications, $user_id, $limit, $before_date, $after_date );
|
||||
}
|
||||
|
||||
return $notifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a WP_Query/WP_Comment_Query date_query array from optional date bounds.
|
||||
*
|
||||
* @param string|null $before_date MySQL datetime upper bound (exclusive).
|
||||
* @param string|null $after_date MySQL datetime lower bound (exclusive).
|
||||
* @param string|null $column Optional date column name (e.g. 'comment_date'). Omit for default.
|
||||
*
|
||||
* @return array|null date_query array, or null when no bounds are set.
|
||||
*/
|
||||
private static function build_date_query( $before_date, $after_date, $column = null ) {
|
||||
if ( ! $before_date && ! $after_date ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$date_query = array();
|
||||
if ( $before_date ) {
|
||||
$clause = array(
|
||||
'before' => $before_date,
|
||||
'inclusive' => false,
|
||||
);
|
||||
if ( $column ) {
|
||||
$clause['column'] = $column;
|
||||
}
|
||||
$date_query[] = $clause;
|
||||
}
|
||||
if ( $after_date ) {
|
||||
$clause = array(
|
||||
'after' => $after_date,
|
||||
'inclusive' => false,
|
||||
);
|
||||
if ( $column ) {
|
||||
$clause['column'] = $column;
|
||||
}
|
||||
$date_query[] = $clause;
|
||||
}
|
||||
|
||||
return $date_query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add follow notifications from ActivityPub followers.
|
||||
*
|
||||
* @param array $notifications The notifications array.
|
||||
* @param int $user_id The user ID.
|
||||
* @param int $limit Max number of followers to fetch.
|
||||
* @param string|null $before_date MySQL datetime; only return followers added before this date.
|
||||
* @param string|null $after_date MySQL datetime; only return followers added after this date.
|
||||
*
|
||||
* @return array The notifications array with follow notifications added.
|
||||
*/
|
||||
private static function add_follow_notifications( $notifications, $user_id, $limit = self::DEFAULT_NOTIFICATION_LIMIT, $before_date = null, $after_date = null ) {
|
||||
$user_id = self::maybe_map_user_to_blog( $user_id );
|
||||
|
||||
$follower_args = array(
|
||||
'orderby' => 'post_date',
|
||||
'order' => 'DESC',
|
||||
);
|
||||
|
||||
$date_query = self::build_date_query( $before_date, $after_date );
|
||||
if ( $date_query ) {
|
||||
$follower_args['date_query'] = $date_query;
|
||||
}
|
||||
|
||||
$followers = Followers::get_many(
|
||||
$user_id,
|
||||
$limit,
|
||||
null,
|
||||
$follower_args
|
||||
);
|
||||
|
||||
foreach ( $followers as $follower ) {
|
||||
$actor = Remote_Actors::get_actor( $follower );
|
||||
if ( ! $actor || \is_wp_error( $actor ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$account = self::actor_to_account( $actor, $follower->ID );
|
||||
if ( ! $account ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$notification = new Notification();
|
||||
$notification->id = \strval( $follower->ID );
|
||||
$notification->type = 'follow';
|
||||
$notification->created_at = \mysql2date( 'Y-m-d\TH:i:s.000P', $follower->post_date, false );
|
||||
$notification->account = $account;
|
||||
|
||||
$notifications[] = $notification;
|
||||
}
|
||||
|
||||
return $notifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account for a comment from cached data.
|
||||
*
|
||||
* @param object $comment The comment object.
|
||||
*
|
||||
* @return Account|null The account.
|
||||
*/
|
||||
private static function get_account_for_comment( $comment ) {
|
||||
$default_avatar = \get_avatar_url( $comment->comment_author_email ?: '', array( 'size' => 96 ) );
|
||||
|
||||
// Try to get cached remote actor data.
|
||||
$remote_actor_id = \get_comment_meta( $comment->comment_ID, '_activitypub_remote_actor_id', true );
|
||||
if ( $remote_actor_id ) {
|
||||
$actor = Remote_Actors::get_actor( $remote_actor_id );
|
||||
if ( $actor && ! \is_wp_error( $actor ) ) {
|
||||
$account = self::actor_to_account( $actor );
|
||||
|
||||
// Use remote actor post ID as account ID.
|
||||
$account->id = \strval( $remote_actor_id );
|
||||
|
||||
// Use default avatar if actor has none.
|
||||
if ( empty( $account->avatar ) ) {
|
||||
$account->avatar = $default_avatar;
|
||||
$account->avatar_static = $default_avatar;
|
||||
}
|
||||
|
||||
return $account;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to comment author data.
|
||||
if ( empty( $comment->comment_author_url ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$account = new Account();
|
||||
$account->id = $comment->comment_author_url;
|
||||
$account->username = $comment->comment_author;
|
||||
$account->acct = $comment->comment_author_email ?: $comment->comment_author;
|
||||
$account->display_name = $comment->comment_author;
|
||||
$account->url = $comment->comment_author_url;
|
||||
$account->avatar = $default_avatar;
|
||||
$account->avatar_static = $default_avatar;
|
||||
$account->created_at = new \DateTime( $comment->comment_date );
|
||||
|
||||
return $account;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user