installed plugin Event Bridge for ActivityPub version 1.1.0

This commit is contained in:
2025-04-29 21:32:06 +00:00
committed by Gitium
parent fc3d7ab181
commit cf022e2628
93 changed files with 14432 additions and 0 deletions

View File

@ -0,0 +1,35 @@
<?php
/**
* Class responsible for registering handlers for incoming activities to the ActivityPub plugin.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\ActivityPub\Handler\Accept;
use Event_Bridge_For_ActivityPub\ActivityPub\Handler\Update;
use Event_Bridge_For_ActivityPub\ActivityPub\Handler\Create;
use Event_Bridge_For_ActivityPub\ActivityPub\Handler\Delete;
use Event_Bridge_For_ActivityPub\ActivityPub\Handler\Undo;
/**
* Class responsible for registering handlers for incoming activities to the ActivityPub plugin.
*/
class Handler {
/**
* Register all ActivityPub handlers.
*/
public static function register_handlers() {
Accept::init();
Update::init();
Create::init();
Delete::init();
Undo::init();
}
}

View File

@ -0,0 +1,542 @@
<?php
/**
* ActivityPub Event Sources (=Followed Actors) Collection.
*
* The Event Sources are nothing else than follows in the ActivityPub world.
* However, this plugins currently only listens to Event object being created,
* updated or deleted by a follow.
*
* For the ActivityPub `Follow` the Blog-Actor from the ActivityPub plugin is used.
*
* This class is responsible for defining a custom post type in WordPress along
* with post-meta fields and methods to easily manage event sources. This includes
* handling side effects, like when an event source is added a follow request is sent
* or adding them to the `follow` collection o the blog-actor profile.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Collection;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Model\Blog;
use Event_Bridge_For_ActivityPub\ActivityPub\Model\Event_Source;
use WP_Error;
use WP_Query;
use function Activitypub\is_tombstone;
use function Activitypub\get_remote_metadata_by_actor;
/**
* ActivityPub Event Sources (=Followed Actors) Collection.
*
* The Event Sources are nothing else than follows in the ActivityPub world.
* However, this plugins currently only listens to Event object being created,
* updated or deleted by a follow.
*
* For the ActivityPub `Follow` the Blog-Actor from the ActivityPub plugin is used.
*
* This class is responsible for defining a custom post type in WordPress along
* with post-meta fields and methods to easily manage event sources. This includes
* handling side effects, like when an event source is added a follow request is sent
* or adding them to the `follow` collection o the blog-actor profile.
*/
class Event_Sources {
/**
* The custom post type.
*/
const POST_TYPE = 'ap_event_source';
/**
* Init.
*/
public static function init() {
self::register_post_type();
\add_action( 'event_bridge_for_activitypub_follow', array( self::class, 'activitypub_follow_actor' ), 10, 1 );
\add_action( 'event_bridge_for_activitypub_unfollow', array( self::class, 'activitypub_unfollow_actor' ), 10, 1 );
}
/**
* Register the post type used to store the external event sources (i.e., followed ActivityPub actors).
*/
public static function register_post_type() {
register_post_type(
self::POST_TYPE,
array(
'labels' => array(
'name' => _x( 'Event Sources', 'post_type plural name', 'event-bridge-for-activitypub' ),
'singular_name' => _x( 'Event Source', 'post_type single name', 'event-bridge-for-activitypub' ),
),
'public' => false,
'hierarchical' => false,
'rewrite' => false,
'query_var' => false,
'delete_with_user' => false,
'can_export' => true,
'supports' => array(),
)
);
\register_post_meta(
self::POST_TYPE,
'_activitypub_actor_id',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => 'sanitize_url',
)
);
\register_post_meta(
self::POST_TYPE,
'_activitypub_errors',
array(
'type' => 'string',
'single' => false,
'sanitize_callback' => function ( $value ) {
if ( ! is_string( $value ) ) {
throw new \Exception( 'Error message is no valid string' );
}
return esc_sql( $value );
},
)
);
\register_post_meta(
self::POST_TYPE,
'_activitypub_actor_json',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => function ( $value ) {
return sanitize_text_field( $value );
},
)
);
\register_post_meta(
self::POST_TYPE,
'_activitypub_inbox',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => 'sanitize_url',
)
);
\register_post_meta(
self::POST_TYPE,
'_event_bridge_for_activitypub_utilize_announces',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => function ( $value ) {
if ( 'same_origin' === $value ) {
return 'same_origin';
}
return '';
},
)
);
\register_post_meta(
self::POST_TYPE,
'_event_bridge_for_activitypub_accept_of_follow',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => 'sanitize_url',
)
);
\register_post_meta(
self::POST_TYPE,
'_event_bridge_for_activitypub_event_count',
array(
'type' => 'integer',
'single' => true,
'sanitize_callback' => 'absint',
'default' => '0',
)
);
}
/**
* Add new Event Source.
*
* @param string $actor The Actor URL/ID.
*
* @return Event_Source|WP_Error|null The Followed (WP_Post array) or an WP_Error.
*/
public static function add_event_source( $actor ) {
$meta = get_remote_metadata_by_actor( $actor );
if ( is_tombstone( $meta ) ) {
return $meta;
}
if ( \is_wp_error( $meta ) ) {
return $meta;
}
if ( empty( $meta ) ) {
return null;
}
$event_source = new Event_Source();
$event_source->from_array( $meta );
$post_id = $event_source->save();
if ( \is_wp_error( $post_id ) ) {
return $post_id;
}
self::delete_event_source_transients();
self::queue_follow_actor( $actor );
return $event_source;
}
/**
* Compose the ActivityPub ID of a follow request.
*
* @param string $follower_id The ActivityPub ID of the actor that followers the other one.
* @param string $followed_id The ActivityPub ID of the followed actor.
* @return string The `Follow` ID.
*/
public static function compose_follow_id( $follower_id, $followed_id ) {
return $follower_id . '#follow-' . \preg_replace( '~^https?://~', '', $followed_id );
}
/**
* Delete all transients related to the event sources.
*
* @return void
*/
public static function delete_event_source_transients(): void {
\delete_transient( 'event_bridge_for_activitypub_event_sources' );
\delete_transient( 'event_bridge_for_activitypub_event_sources_hosts' );
}
/**
* Check whether an attachment is set as a featured image of any post.
*
* @param string|int $attachment_id The numeric post ID of the attachment.
* @return bool
*/
public static function is_attachment_featured_image( $attachment_id ): bool {
if ( ! is_numeric( $attachment_id ) ) {
return false;
}
// Query posts with the given attachment ID as their featured image.
$args = array(
'post_type' => 'any',
'meta_query' => array(
array(
'key' => '_thumbnail_id',
'value' => $attachment_id,
'compare' => '=',
),
),
'fields' => 'ids', // Only retrieve post IDs for performance.
'numberposts' => 1, // We only need one match to confirm.
);
$posts = \get_posts( $args );
return ! empty( $posts );
}
/**
* Delete all posts of an event source.
*
* @param int $event_source_post_id The WordPress Post ID of the event source.
* @return void
*/
public static function delete_events_by_event_source( $event_source_post_id ): void {
global $wpdb;
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id FROM $wpdb->postmeta WHERE meta_key = %s AND meta_value = %s",
'_event_bridge_for_activitypub_event_source',
absint( $event_source_post_id )
)
);
// If no matching posts are found, return early.
if ( empty( $results ) ) {
return;
}
// Loop through the posts and delete them permanently.
foreach ( $results as $result ) {
// Check if the post has a thumbnail.
$thumbnail_id = \get_post_thumbnail_id( $result->post_id );
if ( $thumbnail_id ) {
// Remove the thumbnail from the post.
\delete_post_thumbnail( $result->post_id );
// Delete the attachment (and its files) from the media library.
if ( self::is_attachment_featured_image( $thumbnail_id ) ) {
\wp_delete_attachment( $thumbnail_id, true );
}
}
\wp_delete_post( $result->post_id, true );
}
// Clean up the query.
\wp_reset_postdata();
}
/**
* Remove an Event Source (=Followed ActivityPub actor).
*
* @param int|string $event_source_post_id The Events Sources Post ID or ActivityPub ID.
*
* @return void Post data on success, false or null on failure.
*/
public static function remove_event_source( $event_source_post_id ): void {
$event_source = Event_Source::get_by_id( $event_source_post_id );
if ( ! $event_source ) {
return;
}
self::delete_events_by_event_source( $event_source->get__id() );
self::delete_event_source_transients();
// Temporary hack.
$post = \get_post( $event_source->get__id() );
$post->post_status = 'draft';
if ( $post instanceof \WP_Post ) {
$post = \get_object_vars( $post );
$post = \wp_slash( $post );
$post = \wp_update_post( $post );
}
self::queue_unfollow_actor( $event_source->get_id() );
}
/**
* Get all Event-Sources.
*
* It returns associative arrays, where the keys are the WordPress post IDs and the values are the ActivityPub IDs.
* For example:
* array(
* 15 => 'https://remote.example/actors/event_organizer1',
* 19 => 'https://remote2.example/users/johnmastodon',
* )
*
* @return array List of `Event Sources`: <WP_Post-ID> => <ActivityPub-ID>
*/
public static function get_event_sources(): array {
$event_sources = get_transient( 'event_bridge_for_activitypub_event_sources' );
if ( $event_sources && is_array( $event_sources ) ) {
return $event_sources;
}
$event_sources = self::get_event_sources_with_count()['actors'];
set_transient( 'event_bridge_for_activitypub_event_sources', $event_sources );
return $event_sources;
}
/**
* Get the Event Sources along with a total count for pagination purposes.
*
* @param int $number Maximum number of results to return.
* @param int $page Page number.
* @param array $args The WP_Query arguments.
*
* @return array {
* Data about the event sources.
*
* @type array $actors List of `Event Sources`: <WP_Post-ID> => <ActivityPub-ID>
* @type int $total Total number of followers.
* }
*/
public static function get_event_sources_with_count( $number = -1, $page = null, $args = array() ): array {
$defaults = array(
'post_type' => self::POST_TYPE,
'posts_per_page' => $number,
'paged' => $page,
'orderby' => 'ID',
'order' => 'DESC',
'post_status' => array( 'publish', 'pending', 'draft', 'auto-draft', 'future', 'private', 'inherit' ),
);
$args = wp_parse_args( $args, $defaults );
$query = new WP_Query( $args );
$total = $query->found_posts;
$actors = array();
foreach ( $query->get_posts() as $post ) {
$actors[ $post->ID ] = get_post_meta( $post->ID, '_activitypub_actor_id', true );
}
$event_sources = compact( 'actors', 'total' );
return $event_sources;
}
/**
* Queue a hook to run async.
*
* @param string $hook The hook name.
* @param array $args The arguments to pass to the hook.
* @param string $unqueue_hook Optional a hook to unschedule before queuing.
* @return void|bool|WP_Error Whether the hook was queued.
*/
public static function queue( $hook, $args, $unqueue_hook = null ) {
if ( $unqueue_hook ) {
$hook_timestamp = \wp_next_scheduled( $unqueue_hook, $args );
if ( $hook_timestamp ) {
\wp_unschedule_event( $hook_timestamp, $unqueue_hook, $args );
}
}
if ( \wp_next_scheduled( $hook, $args ) ) {
return;
}
return \wp_schedule_single_event( \time(), $hook, $args );
}
/**
* Prepare to follow an ActivityPub actor via a scheduled event.
*
* @param string $actor The ActivityPub actor.
*
* @return bool Whether the event was queued.
*/
public static function queue_follow_actor( $actor ): bool {
$queued = self::queue(
'event_bridge_for_activitypub_follow',
array( $actor ),
'event_bridge_for_activitypub_unfollow'
);
if ( \is_wp_error( $queued ) ) {
return false;
}
// Following this actor has already been queued.
if ( null === $queued ) {
return true;
}
return $queued;
}
/**
* Follow an ActivityPub actor via the Blog user.
*
* @param string $actor_id The ID/URL of the Actor.
*/
public static function activitypub_follow_actor( $actor_id ) {
$actor = Event_Source::get_by_id( $actor_id );
if ( ! $actor ) {
return $actor;
}
$inbox = $actor->get_shared_inbox();
$to = $actor->get_id();
$from_actor = new Blog();
$activity = new \Activitypub\Activity\Activity();
$activity->set_type( 'Follow' );
$activity->set_to( null );
$activity->set_cc( null );
$activity->set_actor( $from_actor->get_id() );
$activity->set_object( $to );
$activity->set_id( self::compose_follow_id( $from_actor->get_id(), $to ) );
$activity = $activity->to_json();
\Activitypub\safe_remote_post( $inbox, $activity, \Activitypub\Collection\Actors::BLOG_USER_ID );
}
/**
* Prepare to unfollow an actor via a scheduled event.
*
* @param string $actor The ActivityPub actor ID.
*
* @return bool|void|WP_Error Whether the event was queued.
*/
public static function queue_unfollow_actor( $actor ) {
$queued = self::queue(
'event_bridge_for_activitypub_unfollow',
array( $actor ),
'event_bridge_for_activitypub_follow'
);
if ( \is_wp_error( $queued ) ) {
return false;
}
// Following this actor has already been queued.
if ( null === $queued ) {
return true;
}
return $queued;
}
/**
* Unfollow an ActivityPub actor.
*
* @param string $actor The ActivityPub ID of the actor to unfollow.
* @return void
*/
public static function activitypub_unfollow_actor( $actor ): void {
$actor = Event_Source::get_by_id( $actor );
if ( ! $actor ) {
return;
}
$inbox = $actor->get_shared_inbox();
$to = $actor->get_id();
$from_actor = new Blog();
if ( ! $inbox ) {
return;
}
$activity = new \Activitypub\Activity\Activity();
$activity->set_type( 'Undo' );
$activity->set_to( null );
$activity->set_cc( null );
$activity->set_actor( $from_actor->get_id() );
$activity->set_object(
array(
'type' => 'Follow',
'actor' => $actor,
'object' => $to,
'id' => self::compose_follow_id( $from_actor->get_id(), $to ),
)
);
$activity->set_id( $from_actor->get_id() . '#unfollow-' . \preg_replace( '~^https?://~', '', $to ) );
$activity = $activity->to_json();
\Activitypub\safe_remote_post( $inbox, $activity, \Activitypub\Collection\Actors::BLOG_USER_ID );
$actor->delete();
self::delete_event_source_transients();
}
}

View File

@ -0,0 +1,76 @@
<?php
/**
* Accept handler file.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later */
namespace Event_Bridge_For_ActivityPub\ActivityPub\Handler;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Collection\Actors;
use Activitypub\Model\Blog;
use Event_Bridge_For_ActivityPub\ActivityPub\Model\Event_Source;
use Event_Bridge_For_ActivityPub\ActivityPub\Collection\Event_Sources as Event_Sources_Collection;
use function Activitypub\object_to_uri;
/**
* Handle Accept requests.
*/
class Accept {
/**
* Initialize the class, registering the handler for incoming `Accept` activities to the ActivityPub plugin.
*/
public static function init(): void {
\add_action(
'activitypub_inbox_accept',
array( self::class, 'handle_accept' ),
15,
2
);
}
/**
* Handle incoming "Accept" activities.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
*/
public static function handle_accept( $activity, $user_id ): void {
// We only process activities that are target to the blog actor.
if ( Actors::BLOG_USER_ID !== $user_id ) {
return;
}
// Check that we are actually following/or have a pending follow request this actor.
$event_source_post_id = Event_Source::get_post_id_by_activitypub_id( $activity['actor'] );
if ( ! $event_source_post_id ) {
return;
}
// This is what the ID of the follow request would look like.
$application = new Blog();
$follow_id = Event_Sources_Collection::compose_follow_id( $application->get_id(), $activity['actor'] );
// Check if the object of the `Accept` is indeed the `Follow` request we sent to that actor.
if ( object_to_uri( $activity['object'] ) !== $follow_id ) {
return;
}
// Save the accept status of the follow request to the event source post.
\update_post_meta( $event_source_post_id, '_event_bridge_for_activitypub_accept_of_follow', $activity['id'] );
\wp_update_post(
array(
'ID' => $event_source_post_id,
'post_status' => 'publish',
)
);
// Trigger the backfilling of events from this actor.
\do_action( 'event_bridge_for_activitypub_backfill_events', $event_source_post_id );
}
}

View File

@ -0,0 +1,76 @@
<?php
/**
* Create handler file.
*
* @package Event_Bridge_For_ActivityPub
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Handler;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Collection\Actors;
use Event_Bridge_For_ActivityPub\ActivityPub\Model\Event_Source;
use Event_Bridge_For_ActivityPub\Event_Sources;
use Event_Bridge_For_ActivityPub\Setup;
use function Activitypub\is_activity_public;
/**
* Handle Create requests.
*/
class Create {
/**
* Initialize the class, registering the handler for incoming `Create` activities to the ActivityPub plugin.
*/
public static function init(): void {
\add_action(
'activitypub_inbox_create',
array( self::class, 'handle_create' ),
15,
2
);
}
/**
* Handle incoming "Create" activities.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
*/
public static function handle_create( $activity, $user_id ): void {
// We only process activities that are target to the blog actor.
if ( Actors::BLOG_USER_ID !== $user_id ) {
return;
}
// Check if Activity is public or not.
if ( ! is_activity_public( $activity ) ) {
return;
}
// Check if an object is set and it is an object of type `Event`.
if ( ! isset( $activity['object']['type'] ) || 'Event' !== $activity['object']['type'] ) {
return;
}
// Check that we are actually following/or have a pending follow request this actor.
$event_source_post_id = Event_Source::get_post_id_by_activitypub_id( $activity['actor'] );
if ( ! $event_source_post_id ) {
return;
}
if ( Event_Sources::is_time_passed( $activity['object']['startTime'] ) ) {
return;
}
$transmogrifier = Setup::get_transmogrifier();
if ( ! $transmogrifier ) {
return;
}
$transmogrifier::save( $activity['object'], $event_source_post_id );
}
}

View File

@ -0,0 +1,62 @@
<?php
/**
* Delete handler file.
*
* @package Event_Bridge_For_ActivityPub
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Handler;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Collection\Actors;
use Event_Bridge_For_ActivityPub\Event_Sources;
use Event_Bridge_For_ActivityPub\Setup;
use function Activitypub\object_to_uri;
/**
* Handle Delete requests.
*/
class Delete {
/**
* Initialize the class, registering the handler for incoming `Delete` activities to the ActivityPub plugin.
*/
public static function init() {
\add_action(
'activitypub_inbox_delete',
array( self::class, 'handle_delete' ),
15,
2
);
}
/**
* Handle "Follow" requests.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
*/
public static function handle_delete( $activity, $user_id ): void {
// We only process activities that are target to the application user.
if ( Actors::BLOG_USER_ID !== $user_id ) {
return;
}
// Check that we are actually following this actor.
if ( ! Event_Sources::actor_is_event_source( $activity['actor'] ) ) {
return;
}
$id = object_to_uri( $activity['object'] );
$transmogrifier = Setup::get_transmogrifier();
if ( ! $transmogrifier ) {
return;
}
$transmogrifier::delete( $id );
}
}

View File

@ -0,0 +1,159 @@
<?php
/**
* Join handler file.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Handler;
use Activitypub\Activity\Activity;
use Activitypub\Collection\Actors;
use Activitypub\Activity\Actor;
use Activitypub\Http;
use Activitypub\Transformer\Factory;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Event as Event_Transformer;
use function Activitypub\get_remote_metadata_by_actor;
use function Activitypub\object_to_uri;
/**
* Handle Join requests.
*/
class Join {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action(
'activitypub_register_handlers',
array( self::class, 'register_join_handler' )
);
\add_action(
'event_bridge_for_activitypub_ignore_join',
array( self::class, 'send_ignore_response' ),
10,
3
);
}
/**
* Register the join handler to the ActivityPub plugin.
*/
public static function register_join_handler() {
\add_action(
'activitypub_inbox_join',
array( self::class, 'handle_join' )
);
}
/**
* Handle ActivityPub "Join" requests.
*
* @param array $activity The activity object.
*/
public static function handle_join( $activity ) {
$actor = get_remote_metadata_by_actor( object_to_uri( $activity['actor'] ) );
// If we cannot fetch the actor, we cannot continue.
if ( \is_wp_error( $actor ) ) {
return;
}
// This should be already validated, but just to be sure.
if ( ! array_key_exists( 'object', $activity ) ) {
return;
}
// Get the WordPress Post ID, via the ActivityPub ID.
$post_id = self::get_post_id_by_activitypub_id( \sanitize_url( object_to_uri( $activity['object'] ) ) );
if ( ! $post_id ) {
// No post is found for this URL/ID.
return;
}
// Check whether the target object/post is an event post.
$transformer = Factory::get_transformer( get_post( $post_id ) );
if ( ! $transformer instanceof Event_Transformer ) {
return;
}
// Pass over to Event plugin specific handler if implemented here. Until then just send an ignore.
do_action(
'event_bridge_for_activitypub_ignore_join',
$transformer->get_actor_object()->get_id(), // Gets the WordPress user that "owns" the object by ActivityPub means.
$activity['id'],
Actor::init_from_array( $actor )
);
}
/**
* Send "Ignore" response.
*
* @param string $actor The actors ActivityPub ID which sends the response.
* @param string $ignored The ID of the Activity that gets ignored.
* @param Actor|mixed $to The target actor.
*/
public static function send_ignore_response( $actor, $ignored, $to ) {
// Get actor object that owns the object that was targeted by the ignored activity.
$actor = Actors::get_by_resource( $actor );
if ( \is_wp_error( $to ) || \is_wp_error( $actor ) ) {
return;
}
// Get inbox.
$inbox = $to->get_inbox();
if ( ! $inbox ) {
return;
}
// Send "Ignore" activity.
$activity = new Activity();
$activity->set_type( 'Ignore' );
$activity->set_object( \esc_url_raw( $ignored ) );
$activity->set_actor( $actor->get_id() );
$activity->set_to( $to->get_id() );
$activity->set_id( $actor->get_id() . '#ignore-' . \preg_replace( '~^https?://~', '', $ignored ) );
$activity->set_sensitive( null );
// @phpstan-ignore-next-line
Http::post( $inbox, $activity->to_json(), $actor->get__id() );
}
/**
* Get the WordPress Post ID by the ActivityPub ID.
*
* @param string $activitypub_id The ActivityPub objects ID.
* @return int The WordPress post ID.
*/
private static function get_post_id_by_activitypub_id( $activitypub_id ) {
// Parse the URL and extract its components.
$parsed_url = wp_parse_url( $activitypub_id );
$home_url = \trailingslashit( \home_url() );
// Ensure the base URL matches the home URL.
if ( \trailingslashit( "{$parsed_url['scheme']}://{$parsed_url['host']}" ) !== $home_url ) {
return 0;
}
// Check for a valid query string and parse it.
if ( isset( $parsed_url['query'] ) ) {
parse_str( $parsed_url['query'], $query_vars );
// Ensure the only parameter is 'p'.
if ( count( $query_vars ) === 1 && isset( $query_vars['p'] ) ) {
return intval( $query_vars['p'] ); // Return the post ID.
}
}
// Fallback: legacy ActivityPub plugin (before version 3.0.0) used pretty permalinks as `id`.
return \url_to_postid( $activitypub_id );
}
}

View File

@ -0,0 +1,73 @@
<?php
/**
* Undo handler file.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later */
namespace Event_Bridge_For_ActivityPub\ActivityPub\Handler;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Collection\Actors;
use Event_Bridge_For_ActivityPub\Event_Sources;
use function Activitypub\object_to_uri;
/**
* Handle Uno requests.
*/
class Undo {
/**
* Initialize the class, registering the handler for incoming `Uno` activities to the ActivityPub plugin.
*/
public static function init(): void {
\add_action(
'activitypub_inbox_undo',
array( self::class, 'handle_undo' ),
15,
2
);
}
/**
* Handle incoming "Undo" activities.
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
*/
public static function handle_undo( $activity, $user_id ): void {
// We only process activities that are target to the blog actor.
if ( Actors::BLOG_USER_ID !== $user_id ) {
return;
}
// Check that we are actually following/or have a pending follow request for this actor.
if ( ! Event_Sources::actor_is_event_source( $activity['actor'] ) ) {
return;
}
$accept_id = \sanitize_url( object_to_uri( $activity['object'] ) );
global $wpdb;
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id FROM $wpdb->postmeta WHERE meta_key = %s AND meta_value = %s",
'_event_bridge_for_activitypub_accept_of_follow',
$accept_id
)
);
// If no event source with that accept ID is found return.
if ( empty( $results ) ) {
return;
}
$post_id = reset( $results )->post_id;
\delete_post_meta( $post_id, '_event_bridge_for_activitypub_accept_of_follow' );
}
}

View File

@ -0,0 +1,76 @@
<?php
/**
* Update handler file.
*
* @package Event_Bridge_For_ActivityPub
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Handler;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Collection\Actors;
use Event_Bridge_For_ActivityPub\ActivityPub\Model\Event_Source;
use Event_Bridge_For_ActivityPub\Event_Sources;
use Event_Bridge_For_ActivityPub\Setup;
use function Activitypub\is_activity_public;
/**
* Handle Update requests.
*/
class Update {
/**
* Initialize the class, registering the handler for incoming `Update` activities to the ActivityPub plugin.
*/
public static function init(): void {
\add_action(
'activitypub_inbox_update',
array( self::class, 'handle_update' ),
15,
2
);
}
/**
* Handle incoming "Update" activities..
*
* @param array $activity The activity-object.
* @param int $user_id The id of the local blog-user.
*/
public static function handle_update( $activity, $user_id ): void {
// We only process activities that are target to the application user.
if ( Actors::BLOG_USER_ID !== $user_id ) {
return;
}
// Check if Activity is public or not.
if ( ! is_activity_public( $activity ) ) {
return;
}
// Check if an object is set and it is an object of type `Event`.
if ( ! isset( $activity['object']['type'] ) || 'Event' !== $activity['object']['type'] ) {
return;
}
// Check that we are actually following/or have a pending follow request this actor.
$event_source_post_id = Event_Source::get_post_id_by_activitypub_id( $activity['actor'] );
if ( ! $event_source_post_id ) {
return;
}
if ( Event_Sources::is_time_passed( $activity['object']['startTime'] ) ) {
return;
}
$transmogrifier = Setup::get_transmogrifier();
if ( ! $transmogrifier ) {
return;
}
$transmogrifier::save( $activity['object'], $event_source_post_id );
}
}

View File

@ -0,0 +1,371 @@
<?php
/**
* Event-Source (=ActivityPub Actor that is followed) model.
*
* This class holds methods needed for relating an ActivityPub actor
* that is followed with the custom post type structure how it is
* stored within WordPress.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Model;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Actor;
use Event_Bridge_For_ActivityPub\ActivityPub\Collection\Event_Sources;
use WP_Error;
use WP_Post;
/**
* Event-Source (=ActivityPub Actor that is followed) model.
*
* This class holds methods needed for relating an ActivityPub actor
* that is followed with the custom post type structure how it is
* stored within WordPress.
*
* @method ?string get_published()
* @method string get_id()
* @method ?string get_name()
* @method ?string get_updated()
* @method int get__id()
* @method ?string get_status()
* @method ?string get_summary()
* @method Event_Source set_published(string $published)
* @method Event_Source set_id(string $id)
* @method Event_Source set_name(string $name)
* @method Event_Source set_updated(string $updated)
* @method Event_Source set__id(int $id)
* @method Event_Source set_status(string $status)
* @method Event_Source set_summary(string $summary)
* @method ?string get_inbox()
* @method string|array get_icon()
* @method array get_endpoints()
*/
class Event_Source extends Actor {
const ACTIVITYPUB_USER_HANDLE_REGEXP = '(?:([A-Za-z0-9_.-]+)@((?:[A-Za-z0-9_-]+\.)+[A-Za-z]+))';
/**
* The WordPress Post ID which stores the event source.
*
* @var int
*/
protected $_id; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
/**
* The WordPress post status of the post which stores the event source.
*
* @var string
*/
protected $status; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
/**
* Get the Icon URL (Avatar).
*
* @return string The URL to the Avatar.
*/
public function get_icon_url(): string {
$icon = $this->get_icon();
if ( is_string( $icon ) ) {
return $icon;
}
if ( isset( $icon['url'] ) && is_string( $icon['url'] ) ) {
return $icon['url'];
}
return '';
}
/**
* Return the Post-IDs of all events cached by this event source.
*/
public static function get_cached_events(): array {
return array();
}
/**
* Getter for URL attribute.
*
* @return string The URL.
*/
public function get_url() {
if ( $this->url ) {
return $this->url;
}
return $this->id;
}
/**
* Get the outbox.
*
* @return ?string The outbox URL.
*/
public function get_outbox() {
if ( $this->outbox ) {
return $this->outbox;
}
$actor_json = \get_post_meta( $this->get__id(), '_activitypub_actor_json', true );
if ( ! $actor_json ) {
return null;
}
$actor = \json_decode( $actor_json, true );
if ( ! isset( $actor['outbox'] ) ) {
\do_action( 'event_bridge_for_activitypub_write_log', array( "[ACTIVITYPUB] Did not find outbox URL for actor {$actor}" ) );
return null;
}
return $actor['outbox'];
}
/**
* Get the Event Source Post ID by the ActivityPub ID.
*
* @param string $activitypub_actor_id The ActivityPub actor ID.
* @return int|false The Event Sources Post ID, if a WordPress Post representing it is found, false otherwise.
*/
public static function get_post_id_by_activitypub_id( $activitypub_actor_id ) {
$event_sources = Event_Sources::get_event_sources();
return array_search( $activitypub_actor_id, $event_sources, true );
}
/**
* Get the Event Source by the ActivityPub ID or WordPress Post ID.
*
* @param int|string $event_source_id The ActivityPub actor ID as string or the Post ID as int of the Event Source.
* @return ?Event_Source The Event Sources if it exists, false otherwise.
*/
public static function get_by_id( $event_source_id ): ?Event_Source {
$post_id = is_integer( $event_source_id ) ? $event_source_id : self::get_post_id_by_activitypub_id( $event_source_id );
if ( ! $post_id ) {
return null;
}
// Get Custom Post.
$event_source_post = \get_post( $post_id );
if ( ! $event_source_post ) {
return null;
}
// Init From Custom Post.
$event_source = self::init_from_cpt( $event_source_post );
if ( ! $event_source ) {
return null;
}
return $event_source;
}
/**
* Convert a Custom-Post-Type input to an \Event_Bridge_For_ActivityPub\ActivityPub\Model\Event_Source.
*
* @param \WP_Post $post The post object.
* @return ?Event_Source
*/
public static function init_from_cpt( $post ): ?Event_Source {
if ( Event_Sources::POST_TYPE !== $post->post_type ) {
return null;
}
$actor_json = \get_post_meta( $post->ID, '_activitypub_actor_json', true );
$object = static::init_from_json( $actor_json );
if ( \is_wp_error( $object ) ) {
return null;
}
$object->set__id( $post->ID );
$object->set_name( $post->post_title );
$object->set_summary( $post->post_excerpt );
$object->set_published( gmdate( 'Y-m-d H:i:s', strtotime( $post->post_date ) ) );
$object->set_updated( gmdate( 'Y-m-d H:i:s', strtotime( $post->post_modified ) ) );
$object->set_status( $post->post_status );
$thumbnail_id = \get_post_thumbnail_id( $post );
if ( $thumbnail_id ) {
$object->set_icon(
array(
'type' => 'Image',
'url' => \wp_get_attachment_image_url( $thumbnail_id, 'thumbnail', true ),
)
);
}
if ( ! $object instanceof Event_Source ) { // To make phpstan happy.
return null;
}
return $object;
}
/**
* Validate the current Event Source ActivityPub actor object.
*
* @return boolean True if the verification was successful.
*/
public function is_valid(): bool {
// The minimum required attributes.
$required_attributes = array(
'id',
'preferredUsername',
'inbox',
'publicKey',
'publicKeyPem',
);
foreach ( $required_attributes as $attribute ) {
if ( ! $this->get( $attribute ) ) {
return false;
}
}
return true;
}
/**
* Update the post meta.
*/
protected function get_post_meta_input() {
$meta_input = array();
$meta_input['_activitypub_inbox'] = \sanitize_url( $this->get_shared_inbox() );
$meta_input['_activitypub_actor_json'] = $this->to_json();
$meta_input['_activitypub_actor_id'] = $this->get_id();
return $meta_input;
}
/**
* Get the shared inbox, with a fallback to the inbox.
*
* @return string|null The URL to the shared inbox, the inbox or null.
*/
public function get_shared_inbox() {
if ( ! empty( $this->get_endpoints()['sharedInbox'] ) ) {
return $this->get_endpoints()['sharedInbox'];
} elseif ( ! empty( $this->get_inbox() ) ) {
return $this->get_inbox();
}
return null;
}
/**
* Save the current Event Source object to Database within custom post type.
*
* @return int|WP_Error The post ID or an WP_Error.
*/
public function save() {
Event_Sources::delete_event_source_transients();
if ( ! $this->is_valid() ) {
return new WP_Error( 'activitypub_invalid_follower', __( 'Invalid Follower', 'event-bridge-for-activitypub' ), array( 'status' => 400 ) );
}
if ( ! $this->get__id() ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT post_id FROM $wpdb->postmeta WHERE meta_key='_activitypub_actor_id' AND meta_value=%s",
esc_sql( $this->get_id() )
)
);
if ( $post_id ) {
$post = get_post( $post_id );
$this->set__id( $post->ID );
}
}
$post_id = $this->get__id();
$args = array(
'ID' => $post_id,
'post_title' => wp_strip_all_tags( sanitize_text_field( $this->get_name() ) ),
'post_author' => 0,
'post_type' => Event_Sources::POST_TYPE,
'post_name' => esc_url_raw( $this->get_id() ),
'post_excerpt' => sanitize_text_field( wp_kses( $this->get_summary(), 'user_description' ) ),
'post_status' => 'pending',
'meta_input' => $this->get_post_meta_input(),
);
if ( ! empty( $post_id ) ) {
// If this is an update, prevent the "added" date from being overwritten by the current date.
$post = get_post( $post_id );
$args['post_date'] = $post->post_date;
$args['post_date_gmt'] = $post->post_date_gmt;
}
$post_id = \wp_insert_post( $args );
$this->_id = $post_id;
// Abort if inserting or updating the post didn't work.
// @phpstan-ignore-next-line
if ( is_wp_error( $post_id ) || 0 === $post_id ) {
return $post_id;
}
// Delete old icon.
// Check if the post has a thumbnail.
$thumbnail_id = get_post_thumbnail_id( $post_id );
if ( $thumbnail_id ) {
// Remove the thumbnail from the post.
delete_post_thumbnail( $post_id );
// Delete the attachment (and its files) from the media library.
wp_delete_attachment( $thumbnail_id, true );
}
// Set new icon.
$icon = $this->get_icon();
if ( isset( $icon['url'] ) ) {
$image = media_sideload_image( sanitize_url( $icon['url'] ), $post_id, null, 'id' );
}
if ( isset( $image ) && ! is_wp_error( $image ) ) {
set_post_thumbnail( $post_id, $image );
}
return $post_id;
}
/**
* Delete an Event Source and it's profile image.
*/
public function delete() {
$post_id = $this->get__id();
if ( ! $post_id ) {
return false;
}
$thumbnail_id = get_post_thumbnail_id( $post_id );
if ( $thumbnail_id ) {
wp_delete_attachment( $thumbnail_id, true );
}
$result = wp_delete_post( $post_id, false ) ?? false;
if ( $result ) {
Event_Sources::delete_event_source_transients();
}
return $result;
}
}

View File

@ -0,0 +1,107 @@
<?php
/**
* Event Post scheduler class file.
*
* @package Event_Bridge_For_ActivityPub
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Scheduler;
use Event_Bridge_For_ActivityPub\Setup;
use function Activitypub\add_to_outbox;
/**
* Event Post scheduler class.
*/
class Event {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
\add_action( 'transition_post_status', array( self::class, 'maybe_schedule_event_post_activity' ), 50, 3 );
\add_action( 'event_bridge_for_activitypub_add_event_post_to_outbox', array( self::class, 'add_event_post_to_outbox' ), 10, 3 );
\add_filter( 'activitypub_is_post_disabled', array( self::class, 'is_post_disabled_for_the_activitypub_plugin' ), 50, 2 );
}
/**
* Prevent the ActivityPub plugin from dealing with event posts.
*
* @param bool $disabled Whether the post is already marked as disabled.
* @param \WP_Post $post The WordPress post.
* @return bool
*/
public static function is_post_disabled_for_the_activitypub_plugin( $disabled, $post ): bool {
if ( $disabled ) {
return true;
}
if ( Setup::get_instance()->is_event_post_type_of_active_event_plugin( $post->post_type ) ) {
return true;
}
return false;
}
/**
* Schedule Activities.
*
* @param string $new_status New post status.
* @param string $old_status Old post status.
* @param \WP_Post $post Post object.
*/
public static function maybe_schedule_event_post_activity( $new_status, $old_status, $post ): void {
if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
return;
}
if ( ! Setup::get_instance()->is_event_post_type_of_active_event_plugin( $post->post_type ) ) {
return;
}
if ( Setup::is_post_disabled( $post ) ) {
return;
}
switch ( $new_status ) {
case 'publish':
$type = ( 'publish' === $old_status ) ? 'Update' : 'Create';
break;
case 'draft':
$type = ( 'publish' === $old_status ) ? 'Update' : false;
break;
case 'trash':
$type = 'Delete';
break;
default:
$type = false;
}
// Do not send Activities if `$type` is not set or unknown.
if ( empty( $type ) ) {
return;
}
$hook = 'event_bridge_for_activitypub_add_event_post_to_outbox';
$args = array( $post, $type, $post->post_author );
if ( false === \wp_next_scheduled( $hook, $args ) ) {
\wp_schedule_single_event( \time() + 10, $hook, $args );
}
}
/**
* Add an event post to the outbox.
*
* @param \WP_Post $post The WP_Post object to add to the outbox.
* @param string $activity_type The type of the Activity.
* @param integer $user_id The User-ID.
*/
public static function add_event_post_to_outbox( $post, $activity_type, $user_id ): void {
$post = get_post( $post->ID );
add_to_outbox( $post, $activity_type, $user_id );
}
}

View File

@ -0,0 +1,78 @@
<?php
/**
* ActivityPub Transformer for the plugin Event Organiser.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Place;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Event as Base_Event_Transformer;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place\Event_Organiser as Event_Organiser_Location_Transformer;
use WP_Post;
/**
* ActivityPub Transformer for Event Organiser.
*
* @since 1.0.0
*/
final class Event_Organiser extends Base_Event_Transformer {
/**
* The events occurances.
*
* @var ?array
*/
protected $schedule;
/**
* Extended constructor.
*
* The item is overridden with a the item with filters. This object
* also contains attributes specific to the Event organiser plugin like the
* occurrence id.
*
* @param WP_Post $item The WordPress object.
* @param string $wp_taxonomy The taxonomy slug of the event post type.
*/
public function __construct( $item, $wp_taxonomy ) {
parent::__construct( $item, $wp_taxonomy );
$this->schedule = \eo_get_event_schedule( $item->ID );
}
/**
* Get the end time from the event object.
*/
public function get_end_time(): string {
return $this->schedule['end']->format( 'Y-m-d\TH:i:sP' );
}
/**
* Get the start time from the event object.
*/
public function get_start_time(): string {
return $this->schedule['start']->format( 'Y-m-d\TH:i:sP' );
}
/**
* Get location from the event object.
*/
public function get_location(): ?Place {
$venue = \get_the_terms( $this->item->ID, 'event-venue' );
if ( empty( $venue ) || is_wp_error( $venue ) ) {
return null;
}
$venue = array_pop( $venue );
$location_transformer = new Event_Organiser_Location_Transformer( $venue );
$location = $location_transformer->to_object();
return $location;
}
}

View File

@ -0,0 +1,609 @@
<?php
/**
* Replace the default ActivityPub Transformer
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Event as Event_Object;
use Activitypub\Activity\Extended_Object\Place;
use Activitypub\Shortcodes;
use Activitypub\Transformer\Post;
use DateTime;
use WP_Comment;
use WP_Post;
/**
* Base transformer for WordPress event post types to ActivityPub events.
*
* Everything that transforming several WordPress post types that represent events
* have in common, as well as sane defaults for events should be defined here.
*
* BeforeFirstRelease:
* [ ] remove link at the end of the content.
* [ ] add organizer.
* [ ] do add Cancelled reason in the content.
*/
abstract class Event extends Post {
/**
* The WordPress event taxonomy.
*
* @var ?string
*/
protected $wp_taxonomy;
/**
* Returns the ActivityStreams 2.0 Object-Type for an Event.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event
*
* @return string The Event Object-Type.
*/
protected function get_type(): string {
return 'Event';
}
/**
* Get a sane default for whether comments are enabled.
*/
protected function get_comments_enabled(): ?bool {
return \comments_open( $this->item );
}
/**
* Set a hardcoded template for the content.
*
* This actually disabled templates for the content.
* Maybe this independent templates for events will be added later.
*/
protected function get_post_content_template(): string {
return '[ap_content]';
}
/**
* Extend the construction of the Post Transformer to also set the according taxonomy of the event post type.
*
* @param \WP_Post $item The WordPress post object (event).
* @param string $wp_taxonomy The taxonomy slug of the event post type.
*/
public function __construct( $item, $wp_taxonomy = 'category' ) {
parent::__construct( $item );
$this->wp_taxonomy = $wp_taxonomy;
}
/**
* Extract the join mode.
*
* Currently we don't handle joins, we always mark events as external.
*
* @return string
*/
public function get_join_mode(): ?string {
return 'external';
}
/**
* Extract the external participation url.
*
* Currently we don't handle joins, we always mark events as external.
* We just link back to the events HTML representation on our WordPress site.
*
* @return ?string The external participation URL.
*/
public function get_external_participation_url(): ?string {
return 'external' === $this->get_join_mode() ? $this->get_url() : null;
}
/**
* Set the event category, via the mapping setting.
*
* @return ?string
*/
public function get_category(): ?string {
if ( is_null( $this->wp_taxonomy ) ) {
return null;
}
$current_category_mapping = \get_option( 'event_bridge_for_activitypub_event_category_mappings', array() );
$terms = \get_the_terms( $this->item, $this->wp_taxonomy );
// Check if the event has a category set and if that category has a specific mapping return that one.
if ( ! is_wp_error( $terms ) && $terms && array_key_exists( $terms[0]->slug, $current_category_mapping ) ) {
return sanitize_text_field( $current_category_mapping[ $terms[0]->slug ] );
} else {
// Return the default event category.
return sanitize_text_field( \get_option( 'event_bridge_for_activitypub_default_event_category', 'MEETING' ) );
}
}
/**
* Retrieves the excerpt text (may be HTML). Used for constructing the summary.
*
* @return ?string
*/
protected function retrieve_excerpt(): ?string {
if ( $this->item->post_excerpt ) {
return $this->item->post_excerpt;
} else {
return null;
}
}
/**
* Get the start time.
*
* This is mandatory and must be implemented in the final event transformer class.
*/
abstract public function get_start_time(): string;
/**
* Get the end time.
*
* This is not mandatory and therefore just return null by default.
*/
public function get_end_time(): ?string {
return null;
}
/**
* Get a default for the location.
*
* This should be overridden in the actual event transformer.
*
* @return array|Place|null
*/
public function get_location() {
return null;
}
/**
* Default value for the event status.
*/
public function get_status(): ?string {
return 'CONFIRMED';
}
/**
* Compose a human readable formatted start time.
*/
protected function format_start_time(): string {
return $this->format_time( $this->get_start_time() );
}
/**
* Compose a human readable formatted end time.
*/
protected function format_end_time(): string {
return $this->format_time( $this->get_end_time() );
}
/**
* Compose a human readable formatted time.
*
* @param ?string $time The time which needs to be formatted.
*/
protected static function format_time( $time ) {
if ( is_null( $time ) ) {
return '';
}
$start_datetime = new DateTime( $time );
$start_timestamp = $start_datetime->getTimestamp();
$datetime_format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
return \wp_date( $datetime_format, $start_timestamp );
}
/**
* Generates output for the 'ap_start_time' shortcode.
*
* @param ?array $atts The shortcode's attributes.
* @return string The formatted start date and time of the event.
*/
public function shortcode_start_time( $atts ) {
$start_timestamp = $this->get_start_time();
return $this->generate_time_output( $start_timestamp, $atts, '🗓️', __( 'Start', 'event-bridge-for-activitypub' ) );
}
/**
* Generates output for the 'ap_end_time' shortcode.
*
* @param ?array $atts The shortcode's attributes.
* @return string The formatted end date and time of the event.
*/
public function shortcode_end_time( $atts ) {
$end_timestamp = $this->get_end_time();
return $this->generate_time_output( $end_timestamp, $atts, '⏳', __( 'End', 'event-bridge-for-activitypub' ) );
}
/**
* Generates the formatted time output for a shortcode.
*
* @param string|null $timestamp The timestamp for the event time.
* @param array $atts The shortcode attributes.
* @param string $icon The icon to display.
* @param string $label The label to display (e.g., 'Start', 'End').
* @return string The formatted date and time, or an empty string if the timestamp is invalid.
*/
private function generate_time_output( $timestamp, $atts, $icon, $label ): string {
if ( ! $timestamp ) {
return '';
}
$args = shortcode_atts(
array(
'icon' => 'true',
'label' => 'true',
),
$atts
);
$args['icon'] = filter_var( $args['icon'], FILTER_VALIDATE_BOOLEAN );
$args['label'] = filter_var( $args['label'], FILTER_VALIDATE_BOOLEAN );
$output = array();
if ( $args['icon'] ) {
$output[] = $icon;
}
if ( $args['label'] ) {
$output[] = $label . ':';
}
$output[] = self::format_time( $timestamp );
return implode( ' ', $output );
}
/**
* Generates output for the 'ap_location' shortcode.
*
* @param ?array $atts The shortcode's attributes.
* @return string The formatted location/address of the event.
*/
public function shortcode_location( $atts ) {
$args = shortcode_atts(
array(
'icon' => 'true',
'label' => 'true',
'country' => 'true',
'zip' => 'true',
'city' => 'true',
'street' => 'true',
),
$atts,
'ap_location'
);
// Convert attributes to booleans.
$args = array_map(
function ( $value ) {
return filter_var( $value, FILTER_VALIDATE_BOOLEAN );
},
$args
);
$location = $this->get_location();
if ( ! $location ) {
return '';
}
$output = array();
if ( is_array( $location ) && isset( $location['type'] ) && 'VirtualLocation' === $location['type'] ) {
if ( $args['icon'] ) {
$output[] = '🔗';
}
if ( $args['label'] && isset( $location['name'] ) ) {
$output[] = $location['name'] . ':';
}
} else {
if ( $args['icon'] ) {
$output[] = '📍';
}
if ( $args['label'] ) {
$output[] = esc_html__( 'Location', 'event-bridge-for-activitypub' ) . ':';
}
}
$output[] = $this->get_formatted_address( true, $args );
// Join output array into a single string with spaces and return.
return implode( ' ', array_filter( $output ) );
}
/**
* Formats the address based on provided arguments.
*
* @param mixed $address The address data, either as a string or an array.
* @param array $args The arguments for which components to include.
* @return string The formatted address.
*/
protected static function format_address( $address, $args = array() ) {
if ( is_string( $address ) ) {
return esc_html( $address );
}
if ( empty( $args ) ) {
$args = array(
'icon' => 'true',
'title' => 'true',
'country' => 'true',
'zip' => 'true',
'city' => 'true',
'street' => 'true',
);
}
if ( is_array( $address ) ) {
$address_parts = array();
$components = array(
'street' => 'streetAddress',
'zip' => 'postalCode',
'city' => 'addressLocality',
'country' => 'addressCountry',
);
foreach ( $components as $arg_key => $address_key ) {
if ( $args[ $arg_key ] && ! empty( $address[ $address_key ] ) ) {
$address_parts[] = esc_html( $address[ $address_key ] );
}
}
return implode( ', ', $address_parts );
}
return '';
}
/**
* Format the category using the translation.
*/
protected function format_categories(): string {
if ( is_null( $this->wp_taxonomy ) ) {
return '';
}
$categories = array();
// Add the federated category string.
require_once EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_DIR . '/includes/event-categories.php';
$federated_category = $this->get_category();
if ( array_key_exists( $federated_category, EVENT_BRIDGE_FOR_ACTIVITYPUB_EVENT_CATEGORIES ) ) {
$categories[] = EVENT_BRIDGE_FOR_ACTIVITYPUB_EVENT_CATEGORIES[ $federated_category ];
}
// Add all category terms.
$terms = \get_the_terms( $this->item, $this->wp_taxonomy );
if ( $terms && ! is_wp_error( $terms ) ) {
foreach ( $terms as $term ) {
$categories[] = $term->name;
}
}
if ( ! empty( $categories ) ) {
return implode( ' · ', array_unique( $categories ) );
}
return '';
}
/**
* Register the shortcodes.
*/
public function register_shortcodes() {
Shortcodes::register();
foreach ( array( 'location', 'start_time', 'end_time' ) as $shortcode ) {
\add_shortcode( 'ap_' . $shortcode, array( $this, 'shortcode_' . $shortcode ) );
}
}
/**
* Register the shortcodes.
*/
public function unregister_shortcodes() {
Shortcodes::unregister();
foreach ( array( 'location', 'start_time', 'end_time' ) as $shortcode ) {
\remove_shortcode( 'ap_' . $shortcode );
}
}
/**
* Get the summary.
*/
public function get_summary(): ?string {
if ( 'preset' === get_option( 'event_bridge_for_activitypub_summary_type', 'preset' ) ) {
$summary = EVENT_BRIDGE_FOR_ACTIVITYPUB_SUMMARY_TEMPLATE;
} else {
$summary = $this->get_event_summary_template();
}
// It seems that shortcodes are only applied to published posts.
if ( is_preview() ) {
$this->item->post_status = 'publish';
}
// Register our shortcodes just in time.
$this->register_shortcodes();
// Fill in the shortcodes.
\setup_postdata( $this->item );
Shortcodes::register();
$summary = \do_shortcode( $summary );
\wp_reset_postdata();
$summary = \wpautop( $summary );
$summary = \preg_replace( '/[\n\r\t]/', '', $summary );
$summary = \trim( $summary );
$summary = \apply_filters( 'event_bridge_for_activitypub_the_summary', $summary, $this->item );
// Unregister the shortcodes.
$this->unregister_shortcodes();
if ( 'plain' === get_option( 'event_bridge_for_activitypub_summary_format', 'html' ) ) {
$summary = self::strip_html_preserve_linebreaks( $summary );
}
return $summary;
}
/**
* Strip all HTML but preverse some line breaks.
*
* @param mixed $content The HTML input.
* @return string
*/
private static function strip_html_preserve_linebreaks( $content ): string {
// Replace <br> with newlines.
$content = preg_replace( '/<br\s*\/?>/i', "\n", $content );
// Replace closing </p> followed by <p> with double newlines (preserve paragraph breaks).
$content = preg_replace( '/<\/p>\s*<p>/', "\n\n", $content );
// Preserve list structure.
$content = preg_replace( '/<\/ul>/i', "\n", $content );
$content = preg_replace( '/<li>/i', '- ', $content );
$content = preg_replace( '/<\/li>/i', "\n", $content );
// Remove all remaining HTML tags.
$content = wp_strip_all_tags( $content );
// Normalize excessive newlines (more than 2 in a row to just 2).
$content = preg_replace( "/\n{3,}/", "\n\n", $content );
// Trim trailing newlines.
return trim( $content );
}
/**
* Get the address as a string.
*
* @param bool $include_location_name Whether to include the locations name.
* @param array $args The arguments forwarded to format_address.
*
* @return string
*/
public function get_formatted_address( $include_location_name = false, $args = array() ) {
$location = $this->get_location();
if ( $location instanceof Place ) {
$location_name = $location->get_name();
$foramted_address = self::format_address( $location->get_address(), $args );
$loaction_parts = array();
if ( $location_name ) {
$location_parts[] = $location_name;
}
if ( $foramted_address ) {
$location_parts[] = $foramted_address;
}
if ( ! empty( $location_parts ) ) {
return implode( ', ', $location_parts );
}
} elseif ( is_array( $location ) && isset( $location['type'] ) && 'VirtualLocation' === $location['type'] ) {
if ( isset( $location['url'] ) ) {
return $location['url'];
}
}
return '';
}
/**
* Gets the template to use to generate the summary of the ActivityStreams representation of an event post.
*
* @return string The Template.
*/
protected function get_event_summary_template() {
$summary = \get_option( 'event_bridge_for_activitypub_custom_summary', EVENT_BRIDGE_FOR_ACTIVITYPUB_SUMMARY_TEMPLATE );
$template = $summary ?? EVENT_BRIDGE_FOR_ACTIVITYPUB_SUMMARY_TEMPLATE;
return apply_filters( 'event_bridge_for_activitypub_summary_template', $template, $this->item );
}
/**
* By default set the timezone of the WordPress site.
*
* This is likely to be overwritten by the actual transformer.
*
* @return string The timezone string of the site.
*/
public function get_timezone(): string {
return \wp_timezone_string();
}
/**
* Remove the permalink shortcode from a WordPress template.
*
* This used for the summary template, because the summary usually gets,
* used when converting a object, where the URL is usually appended anyway.
*
* @param string $template The template string.
* @param WP_Post|WP_Comment $item The item which was used to select the template.
*/
public static function remove_ap_permalink_from_template( $template, $item ) {
// we could override the template here, to get out custom template from an option.
if ( 'event' === $item->post_type ) {
$template = str_replace( '[ap_permalink]', '', $template );
$template = str_replace( '[ap_permalink type="html"]', '', $template );
}
return $template;
}
/**
* Generic function that converts an WP-Event object to an ActivityPub-Event object.
*
* @return Event_object|\WP_Error
*/
public function to_object() {
$activitypub_object = new Event_Object();
$activitypub_object = $this->transform_object_properties( $activitypub_object );
// @phpstan-ignore-next-line
$activitypub_object->set_comments_enabled( $this->get_comments_enabled() );
if ( \is_wp_error( $activitypub_object ) ) {
return $activitypub_object;
}
// maybe move the following logic (till end of the function) into getter functions.
$published = \strtotime( $this->item->post_date_gmt );
$activitypub_object->set_published( \gmdate( 'Y-m-d\TH:i:s\Z', $published ) );
$updated = \strtotime( $this->item->post_modified_gmt );
if ( $updated > $published ) {
$activitypub_object->set_updated( \gmdate( 'Y-m-d\TH:i:s\Z', $updated ) );
}
$activitypub_object->set_content_map(
array(
$this->get_locale() => $this->get_content(),
)
);
$activitypub_object->set_to(
array(
'https://www.w3.org/ns/activitystreams#Public',
$this->get_actor_object()->get_followers(),
)
);
// @phpstan-ignore-next-line
return $activitypub_object;
}
}

View File

@ -0,0 +1,166 @@
<?php
/**
* ActivityPub Transformer for Events managed with Eventin.
*
* @link https://support.themewinter.com/docs/plugins/docs-category/eventin/
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Place;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Event;
use Etn\Core\Event\Event_Model;
use function Activitypub\esc_hashtag;
/**
* ActivityPub Transformer for Events managed with Eventin.
*
* @since 1.0.0
*/
final class Eventin extends Event {
/**
* Holds the EM_Event object.
*
* @var Event_Model
*/
protected $event_model;
/**
* Extend the constructor, to also set the Event Model.
*
* This is a special class object form The Events Calendar which
* has a lot of useful functions, we make use of our getter functions.
*
* @param \WP_Post $item The WordPress object.
* @param string $wp_taxonomy The taxonomy slug of the event post type.
*/
public function __construct( $item, $wp_taxonomy ) {
parent::__construct( $item, $wp_taxonomy );
$this->event_model = new Event_Model( $this->item->ID );
}
/**
* Get the end time from the event object.
*/
public function get_start_time(): string {
return \gmdate( 'Y-m-d\TH:i:s\Z', strtotime( $this->event_model->get_start_datetime() ) );
}
/**
* Get the end time from the event object.
*/
public function get_end_time(): string {
return \gmdate( 'Y-m-d\TH:i:s\Z', strtotime( $this->event_model->get_end_datetime() ) );
}
/**
* Get the timezone of the event.
*/
public function get_timezone(): string {
return $this->event_model->get_timezone();
}
/**
* Get whether the event is online.
*
* @return bool
*/
public function get_is_online(): bool {
return 'online' === $this->event_model->__get( 'event_type' ) ? true : false;
}
/**
* Maybe add online link to attachments.
*
* @return array
*/
public function get_attachment(): array {
$attachment = parent::get_attachment();
$location = (array) $this->event_model->__get( 'location' );
// @phpstan-ignore-next-line
if ( array_key_exists( 'integration', $location ) && array_key_exists( $location['integration'], $location ) ) {
$online_link = array(
'type' => 'Link',
'mediaType' => 'text/html',
'name' => $location[ $location['integration'] ],
'href' => $location[ $location['integration'] ],
);
$attachment[] = $online_link;
}
return $attachment;
}
/**
* Compose the events tags.
*/
public function get_tag(): ?array {
// The parent tag function also fetches the mentions.
$tags = parent::get_tag();
$post_tags = \wp_get_post_terms( $this->item->ID, 'etn_tags' );
$post_categories = \wp_get_post_terms( $this->item->ID, 'etn_category' );
if ( ! is_wp_error( $post_tags ) && $post_tags ) {
foreach ( $post_tags as $term ) {
$tag = array(
'type' => 'Hashtag',
'href' => \esc_url( \get_tag_link( $term->term_id ) ),
'name' => esc_hashtag( $term->name ),
);
$tags[] = $tag;
}
}
if ( ! is_wp_error( $post_categories ) && $post_categories ) {
foreach ( $post_categories as $term ) {
$tag = array(
'type' => 'Hashtag',
'href' => \esc_url( \get_tag_link( $term->term_id ) ),
'name' => esc_hashtag( $term->name ),
);
$tags[] = $tag;
}
}
if ( empty( $tags ) ) {
return null;
}
return $tags;
}
/**
* Get the location.
*
* @return ?Place
*/
public function get_location(): ?Place {
$location = (array) $this->event_model->__get( 'location' );
// @phpstan-ignore-next-line
if ( ! array_key_exists( 'address', $location ) ) {
return null;
}
// @phpstan-ignore-next-line
$place = new Place();
$address = $location['address'];
$place->set_name( $address );
$place->set_address( $address );
$place->set_sensitive( null );
return $place;
}
}

View File

@ -0,0 +1,186 @@
<?php
/**
* Class file for the ActivityPub Transformer for events the WordPress plugin EventON Events Calendar.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Place;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Event as Event_Transformer;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place\EventOn as EventOn_Place_Transformer;
/**
* Class for the ActivityPub Transformer for events the WordPress plugin EventON Events Calendar.
*
* This transformer tries a different principle: The setters are chainable.
*
* @since 1.0.0
*/
final class EventOn extends Event_Transformer {
/**
* The location meta for all locations.
*
* @var array
*/
protected $tax_meta;
/**
* Extend the construction of the Post Transformer to also set the according taxonomy of the event post type.
*
* @param \WP_Post $item The WordPress post object (event).
* @param string $wp_taxonomy The taxonomy slug of the event post type.
*/
public function __construct( $item, $wp_taxonomy = 'category' ) {
parent::__construct( $item );
$this->wp_taxonomy = $wp_taxonomy;
$this->tax_meta = \get_option( 'evo_tax_meta' );
}
/**
* Get content.
*/
public function get_content(): string {
$subtitle = \get_post_meta( $this->item->ID, 'evcal_subtitle', true );
$content = $subtitle ? $subtitle . '<br>' : '';
$content = $content . parent::get_content();
return $content;
}
/**
* Get the event location(s).
*
* @return array|null The Place.
*/
public function get_location() {
$location = array();
$terms = \get_the_terms( $this->item->ID, 'event_location' );
// The terms may both contain virtual and physical Locations.
if ( ! empty( $terms ) ) {
foreach ( $terms as $term ) {
$location_transformer = new EventOn_Place_Transformer( $term );
$location[] = $location_transformer->to_object()->to_array( false );
}
}
// Virtual Locations can also be directly int the post meta, not in terms!
$virtual_url = \get_post_meta( $this->item->ID, '_vir_url', true );
$virtual_type = \get_post_meta( $this->item->ID, '_vir_type', true );
if ( $virtual_url ) {
$virtual_location = array(
'type' => 'VirtualLocation',
'url' => (string) $virtual_url,
);
if ( $virtual_type ) {
$virtual_location['name'] = (string) $virtual_type;
}
$location[] = $virtual_location;
}
// If we only have one location, send object directy, not in array.
if ( 1 === count( $location ) ) {
$location = reset( $location );
} elseif ( empty( $location ) ) {
return null;
}
return $location;
}
/**
* Get the end time from the events metadata.
*/
public function get_end_time(): ?string {
$end_time = \get_post_meta( $this->item->ID, '_unix_end_ev', true );
$timezone = \get_post_meta( $this->item->ID, '_evo_tz', true );
$timezone = $timezone ? new \DateTimeZone( $timezone ) : null;
if ( is_null( $end_time ) || empty( $end_time ) ) {
return null;
}
return \wp_date( 'Y-m-d\TH:i:sP', (int) $end_time, $timezone );
}
/**
* Get timezone
*
* @return string
*/
public function get_timezone(): string {
$timezone = \get_post_meta( $this->item->ID, '_evo_tz', true );
return $timezone ?? \wp_timezone_string();
}
/**
* Get the end time from the events metadata.
*/
public function get_start_time(): string {
$start_time = \get_post_meta( $this->item->ID, '_unix_start_ev', true );
$timezone = \get_post_meta( $this->item->ID, '_evo_tz', true );
$timezone = $timezone ? new \DateTimeZone( $timezone ) : null;
return \wp_date( 'Y-m-d\TH:i:sP', (int) $start_time, $timezone );
}
/**
* Get the event link from the events metadata.
*
* @return ?array Associated array of an ActivityStreams Link object with the events URL.
*/
private function get_event_link(): ?array {
$event_link = \get_post_meta( $this->item->ID, 'event-link', true );
$event_link_label = \get_post_meta( $this->item->ID, 'event-link-label', true ) ?? 'Event Link';
if ( $event_link ) {
return array(
'type' => 'Link',
'name' => $event_link_label,
'href' => \esc_url( $event_link ),
'mediaType' => 'text/html',
);
}
return null;
}
/**
* Overrides/extends the get_attachments function to also add the event Link.
*
* @return array
*/
protected function get_attachment(): array {
$attachments = parent::get_attachment();
if ( count( $attachments ) ) {
$attachments[0]['type'] = 'Document';
$attachments[0]['name'] = 'Banner';
}
$event_link = $this->get_event_link();
if ( $event_link ) {
$attachments[] = $event_link;
}
return $attachments;
}
/**
* Retrieves the excerpt text (may be HTML). Used for constructing the summary.
*
* @return ?string
*/
protected function retrieve_excerpt(): ?string {
if ( \get_post_meta( $this->item->ID, 'event-summary', true ) ) {
return \get_post_meta( $this->item->ID, 'event-summary', true );
} else {
return parent::retrieve_excerpt();
}
}
}

View File

@ -0,0 +1,70 @@
<?php
/**
* ActivityPub Transformer for the plugin EventPrime.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Place;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Event as Base_Event_Transformer;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place\EventPrime as EventPrime_Location_Transformer;
/**
* ActivityPub Transformer for VS Event
*
* @since 1.0.0
*/
final class EventPrime extends Base_Event_Transformer {
/**
* Get the end time from the event object.
*/
public function get_end_time(): ?string {
$timestamp = \get_post_meta( $this->wp_object->ID, 'em_end_date', true );
if ( $timestamp ) {
return \gmdate( 'Y-m-d\TH:i:s\Z', $timestamp );
} else {
return null;
}
}
/**
* Get the end time from the event object.
*/
public function get_start_time(): string {
$timestamp = \get_post_meta( $this->wp_object->ID, 'em_start_date', true );
if ( $timestamp ) {
return \gmdate( 'Y-m-d\TH:i:s\Z', $timestamp );
} else {
return '';
}
}
/**
* Get location from the event object.
*/
public function get_location(): ?Place {
$venue_term_id = \get_post_meta( $this->item->ID, 'em_venue', true );
if ( ! $venue_term_id ) {
return null;
}
$venue = \get_the_terms( $this->item->ID, 'em_venue' );
if ( empty( $venue ) || is_wp_error( $venue ) ) {
return null;
}
$venue = array_pop( $venue );
$location_transformer = new EventPrime_Location_Transformer( $venue );
$location = $location_transformer->to_object();
return $location;
}
}

View File

@ -0,0 +1,248 @@
<?php
/**
* ActivityPub Transformer for the plugin Very Simple Event List.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Place;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Event as Event_Transformer;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place\Events_Manager as Events_Manager_Place_Transformer;
use DateTime;
use DateTimeZone;
use EM_Event;
use function Activitypub\esc_hashtag;
/**
* ActivityPub Transformer for events from the WordPress plugin 'Events Manager'
*
* @see https://wordpress.org/plugins/events-manager/
*
* @since 1.0.0
*/
final class Events_Manager extends Event_Transformer {
/**
* Holds the EM_Event object.
*
* @var EM_Event
*/
protected $em_event;
/**
* Extend the constructor, to also set the Events Manager objects.
*
* This is a special class object form The Events Calendar which
* has a lot of useful functions, we make use of our getter functions.
*
* @param \WP_Post $item The WordPress object.
* @param string $wp_taxonomy The taxonomy slug of the event post type.
*/
public function __construct( $item, $wp_taxonomy ) {
parent::__construct( $item, $wp_taxonomy );
$this->em_event = new EM_Event( $this->item->ID, 'post_id' );
}
/**
* Returns whether the even is online
*
* @return bool
*/
protected function is_online(): bool {
return \EM_Event_Locations\Event_Locations::is_enabled( 'url' ) && 'url' === $this->em_event->event_location_type;
}
/**
* Get the event location.
*
* @return array|Place|null The Place.
*/
public function get_location() {
if ( $this->is_online() ) {
if ( property_exists( $this->em_event->event_location, 'data' ) ) {
$event_location = $this->em_event->event_location->data;
} else {
$event_location = array();
}
$event_link_url = isset( $event_location['url'] ) ? $event_location['url'] : null;
$event_link_text = isset( $event_location['text'] ) ? $event_location['text'] : esc_html__( 'Link', 'event-bridge-for-activitypub' );
if ( empty( $event_link_url ) ) {
return null;
}
return array(
'type' => 'VirtualLocation',
'url' => \esc_url( $event_link_url ),
'name' => \esc_html( $event_link_text ),
);
}
if ( ! \EM_Locations::is_enabled() ) {
return null;
}
$em_location = $this->em_event->get_location();
if ( ! isset( $em_location->post_id ) || ! $em_location->post_id ) {
return null;
}
$location_transformer = new Events_Manager_Place_Transformer( get_post( $em_location->post_id ) );
$full_location_object = false;
$location = $location_transformer->to_object( $full_location_object );
return $location;
}
/**
* Get the end time from the events metadata.
*/
public function get_end_time(): ?string {
return null;
}
/**
* Get the end time from the events metadata.
*/
public function get_start_time(): string {
$date_string = $this->em_event->event_start_date;
$time_string = $this->em_event->event_start_time;
$timezone_string = $this->em_event->event_timezone;
// Create a DateTime object with the given date, time, and timezone.
$datetime = new DateTime( $date_string . ' ' . $time_string, new DateTimeZone( $timezone_string ) );
// Set the timezone for proper formatting.
$datetime->setTimezone( new DateTimeZone( 'UTC' ) );
// Format the DateTime object as 'Y-m-d\TH:i:s\Z'.
$formatted_date = $datetime->format( 'Y-m-d\TH:i:s\Z' );
return $formatted_date;
}
/**
* Returns the maximum attendee capacity.
*
* @return ?int
*/
public function get_maximum_attendee_capacity() {
return $this->em_event->event_spaces;
}
/**
* Return the remaining attendee capacity
*
* @return ?int
*/
public function get_remaining_attendee_capacity(): ?int {
$em_bookings_count = $this->get_participant_count();
$max_bookings = $this->em_event->event_spaces;
if ( $max_bookings && $em_bookings_count ) {
return $this->em_event->event_spaces - $em_bookings_count;
}
return null;
}
/**
* Returns the current participant count.
*
* @return int
*/
public function get_participant_count(): int {
$em_bookings = $this->em_event->get_bookings()->get_bookings();
return count( $em_bookings->bookings );
}
/**
* Get the event link as an ActivityPub Link object, but as an associative array.
*
* @return array|null
*/
private function get_event_link_attachment(): ?array {
if ( $this->is_online() ) {
if ( property_exists( $this->em_event->event_location, 'data' ) ) {
$event_location = $this->em_event->event_location->data;
} else {
$event_location = array();
}
$event_link_url = isset( $event_location['url'] ) ? $event_location['url'] : null;
$event_link_text = isset( $event_location['text'] ) ? $event_location['text'] : __( 'Link', 'event-bridge-for-activitypub' );
if ( empty( $event_link_url ) ) {
return null;
}
return array(
'type' => 'Link',
'name' => \esc_html( $event_link_text ),
'href' => \esc_url( $event_link_url ),
'mediaType' => 'text/html',
);
}
return null;
}
/**
* Overrides/extends the get_attachments function to also add the event Link.
*/
protected function get_attachment(): array {
// Get attachments via parent function.
$attachments = parent::get_attachment();
// The first attachment is the featured image, make sure it is compatible with Mobilizon.
if ( count( $attachments ) ) {
$attachments[0]['type'] = 'Document';
$attachments[0]['name'] = 'Banner';
}
$event_link_attachment = $this->get_event_link_attachment();
if ( $event_link_attachment ) {
$attachments[] = $event_link_attachment;
}
return $attachments;
}
/**
* Compose the events tags.
*/
public function get_tag(): array {
// The parent tag function also fetches the mentions.
$tags = parent::get_tag();
$post_tags = \wp_get_post_terms( $this->item->ID, 'event-tags' );
if ( $post_tags ) {
foreach ( $post_tags as $post_tag ) {
$tag = array(
'type' => 'Hashtag',
'href' => \esc_url( \get_tag_link( $post_tag->term_id ) ),
'name' => esc_hashtag( $post_tag->name ),
);
$tags[] = $tag;
}
}
return $tags;
}
/**
* Get the events title/name.
*
* @return string
*/
protected function get_name(): string {
return $this->em_event->event_name;
}
}

View File

@ -0,0 +1,156 @@
<?php
/**
* ActivityPub Transformer for the GatherPress event plugin.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Event as Event_Object;
use Activitypub\Activity\Extended_Object\Place;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Event;
use GatherPress\Core\Event as GatherPress_Event;
/**
* ActivityPub Transformer for VS Event
*
* @since 1.0.0
*/
final class GatherPress extends Event {
/**
* The current GatherPress Event object.
*
* @var GatherPress_Event
*/
protected $gp_event;
/**
* The current GatherPress Venue.
*
* @var array
*/
protected $gp_venue;
/**
* Extend the constructor, to also set the GatherPress objects.
*
* This is a special class object form The Events Calendar which
* has a lot of useful functions, we make use of our getter functions.
*
* @param \WP_Post $item The WordPress object.
* @param string $wp_taxonomy The taxonomy slug of the event post type.
*/
public function __construct( $item, $wp_taxonomy ) {
parent::__construct( $item, $wp_taxonomy );
$this->gp_event = new GatherPress_Event( $item->ID );
$this->gp_venue = $this->gp_event->get_venue_information();
}
/**
* Get the event location.
*
* @return ?Place The place objector null if not place set.
*/
public function get_location(): ?Place {
$address = $this->gp_venue['full_address'];
if ( $address ) {
$place = new Place();
$place->set_type( 'Place' );
$place->set_name( $address );
$place->set_address( $address );
return $place;
} else {
return null;
}
}
/**
* Get the end time from the event object.
*/
public function get_end_time(): string {
return $this->gp_event->get_datetime_end( 'Y-m-d\TH:i:s\Z' );
}
/**
* Get the end time from the event object.
*/
public function get_start_time(): string {
return $this->gp_event->get_datetime_start( 'Y-m-d\TH:i:s\Z' );
}
/**
* Get the event link from the events metadata.
*/
private function get_event_link(): array {
$event_link = get_post_meta( $this->item->ID, 'event-link', true );
if ( $event_link ) {
return array(
'type' => 'Link',
'name' => 'Website',
'href' => \esc_url( $event_link ),
'mediaType' => 'text/html',
);
}
return array();
}
/**
* Overrides/extends the get_attachments function to also add the event Link.
*/
protected function get_attachment(): array {
$attachments = parent::get_attachment();
if ( count( $attachments ) ) {
$attachments[0]['type'] = 'Document';
$attachments[0]['name'] = 'Banner';
}
$event_link = $this->get_event_link();
if ( $event_link ) {
$attachments[] = $this->get_event_link();
}
return $attachments;
}
/**
* Prevents gatherpress blocks from being rendered for the content.
*
* @param mixed $block_content The blocks content.
* @param mixed $block The block.
*/
public static function filter_gatherpress_blocks( $block_content, $block ) {
// Check if the block name starts with 'gatherpress'.
if ( isset( $block['blockName'] ) && 0 === strpos( $block['blockName'], 'gatherpress/' ) ) {
return ''; // Skip rendering this block.
}
return $block_content; // Return the content for other blocks.
}
/**
* Apply the filter for preventing the rendering off gatherpress blocks just in time.
*
* @return Event_Object
*/
public function to_object(): Event_Object {
add_filter( 'render_block', array( self::class, 'filter_gatherpress_blocks' ), 10, 2 );
$activitypub_object = parent::to_object();
remove_filter( 'render_block', array( self::class, 'filter_gatherpress_blocks' ) );
return $activitypub_object;
}
/**
* Determine whether the event is online.
*
* @return bool
*/
public function get_is_online(): bool {
return $this->gp_event->maybe_get_online_event_link() ? true : false;
}
}

View File

@ -0,0 +1,127 @@
<?php
/**
* ActivityPub Tribe Transformer
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Place;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Event;
use MEC;
use MEC\Events\Event as MEC_Event;
use MEC_main;
/**
* ActivityPub Tribe Transformer
*
* @since 1.0.0
*/
final class Modern_Events_Calendar_Lite extends Event {
/**
* The MEC Event object.
*
* @var MEC_Event|null
*/
protected $mec_event;
/**
* The MEC main instance.
*
* @var MEC_main|null
*/
protected $mec_main;
/**
* Extend the constructor, to also set the tribe object.
*
* This is a special class object form The Events Calendar which
* has a lot of useful functions, we make use of our getter functions.
*
* @param \WP_Post $item The WordPress object.
* @param string $wp_taxonomy The taxonomy slug of the event post type.
*/
public function __construct( $item, $wp_taxonomy ) {
parent::__construct( $item, $wp_taxonomy );
$this->mec_main = MEC::getInstance( 'app.libraries.main' );
$this->mec_event = new MEC_Event( $item );
}
/**
* Retrieves the content without the plugins rendered shortcodes.
*/
public function get_content(): string {
$content = wpautop( $this->item->post_content );
return $content;
}
/**
* Get the end time from the event object.
*
* @return string
*/
public function get_start_time(): string {
return \gmdate( 'Y-m-d\TH:i:s\Z', $this->mec_event->get_datetime()['start']['timestamp'] );
}
/**
* Get the end time from the event object.
*
* @return string
*/
public function get_end_time(): string {
return \gmdate( 'Y-m-d\TH:i:s\Z', $this->mec_event->get_datetime()['end']['timestamp'] );
}
/**
* Get the location.
*
* @return ?Place
*/
public function get_location(): ?Place {
$location_id = $this->mec_main->get_master_location_id( $this->mec_event->ID );
if ( ! $location_id ) {
return null;
}
$data = $this->mec_main->get_location_data( $location_id );
$location = new Place();
$location->set_sensitive( null );
if ( ! empty( $data['address'] ) ) {
$location->set_address( $data['address'] );
}
if ( ! empty( $data['name'] ) ) {
$location->set_name( $data['name'] );
}
if ( ! empty( $data['longitude'] ) ) {
$location->set_longitude( $data['longitude'] );
}
if ( ! empty( $data['latitude'] ) ) {
$location->set_latitude( $data['latitude'] );
}
return $location;
}
/**
* Get the location.
*/
public function get_timezone(): string {
$timezone = get_post_meta( $this->item->ID, 'mec_timezone', true );
if ( 'global' === $timezone ) {
return parent::get_timezone();
}
return $timezone;
}
}

View File

@ -0,0 +1,203 @@
<?php
/**
* ActivityPub Tribe Transformer
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Event as Event_Object;
use Activitypub\Activity\Extended_Object\Place;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place\The_Events_Calendar as The_Events_Calendar_Location;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Event;
use WP_Post;
use function Activitypub\esc_hashtag;
/**
* ActivityPub Tribe Transformer
*
* @since 1.0.0
*/
final class The_Events_Calendar extends Event {
/**
* The Tribe Event object.
*
* @var WP_Post|null
*
* @property string $timezone
* @property string $event_status
* @property \Tribe\Events\Collections\Lazy_Post_Collection|WP_Post $venues
* @property string $start_date
*/
protected $tribe_event;
/**
* Extend the constructor, to also set the tribe object.
*
* This is a special class object form The Events Calendar which
* has a lot of useful functions, we make use of our getter functions.
*
* @param WP_Post $item The WordPress object.
* @param string $wp_taxonomy The taxonomy slug of the event post type.
*/
public function __construct( $item, $wp_taxonomy ) {
parent::__construct( $item, $wp_taxonomy );
$this->tribe_event = \tribe_get_event( $item );
}
/**
* Get the tags, including also the set categories from The Events Calendar.
*
* @return array The array if tags,
*/
public function get_tag(): array {
$tags = array();
$category_ids = tribe_get_event_cat_ids();
if ( $category_ids ) {
foreach ( $category_ids as $category_id ) {
$term = \get_term( $category_id );
$tag = array(
'type' => 'Hashtag',
'href' => \esc_url( \get_term_link( $term ) ),
'name' => esc_hashtag( $term->name ),
);
$tags[] = $tag;
}
}
$tags = array_merge( $tags, parent::get_tag() );
return $tags;
}
/**
* Get the end time from the event object.
*/
public function get_end_time(): string {
$utc_time = get_post_meta( $this->tribe_event->ID, '_EventEndDateUTC', true );
$timezone = new \DateTimeZone( $this->get_timezone() );
$time = new \DateTime( $utc_time );
$time->setTimezone( $timezone );
return $time->format( 'Y-m-d\TH:i:sP' );
}
/**
* Get the end time from the event object.
*/
public function get_start_time(): string {
$utc_time = get_post_meta( $this->tribe_event->ID, '_EventStartDateUTC', true );
$timezone = new \DateTimeZone( $this->get_timezone() );
$time = new \DateTime( $utc_time );
$time->setTimezone( $timezone );
return $time->format( 'Y-m-d\TH:i:sP' );
}
/**
* Get the timezone of the event.
*
* @return string The timezone string of the site.
*/
public function get_timezone(): string {
// @phpstan-ignore-next-line
$timezone = $this->tribe_event->timezone;
if ( ! $timezone || ! is_string( $timezone ) ) {
return parent::get_timezone();
}
return $timezone;
}
/**
* Get status of the tribe event
*
* @return string status of the event
*/
public function get_status(): string {
// @phpstan-ignore-next-line
$event_status = $this->tribe_event->event_status;
if ( 'canceled' === $event_status ) {
return 'CANCELLED';
}
if ( 'postponed' === $event_status ) {
return 'CANCELLED'; // This will be reflected in the cancelled reason.
}
return 'CONFIRMED';
}
/**
* Check if the comments are enabled for the current event.
*/
public function get_comments_enabled(): bool {
return ( 'open' === $this->tribe_event->comment_status ) ? true : false;
}
/**
* Check if the event is an online event.
*/
public function get_is_online(): bool {
return false;
}
/**
* Get the event location.
*
* @return ?Place The place/venue if one is set.
*/
public function get_location(): ?Place {
// Get short handle for the venues.
if ( ! \tribe_has_venue( $this->tribe_event->ID ) ) {
return null;
}
$venue_id = \tribe_get_venue_id( $this->tribe_event->ID );
$post = \get_post( $venue_id );
if ( ! $post ) {
return null;
}
$location_transformer = new The_Events_Calendar_Location( $post );
$full_location_object = false;
$location = $location_transformer->to_object( $full_location_object );
return $location;
}
/**
* Apply the filter for preventing the rendering off The Events Calendar blocks just in time.
*
* @return Event_Object
*/
public function to_object(): Event_Object {
add_filter( 'render_block', array( self::class, 'filter_tribe_blocks' ), 10, 2 );
$activitypub_object = parent::to_object();
remove_filter( 'render_block', array( self::class, 'filter_tribe_blocks' ) );
return $activitypub_object;
}
/**
* Prevents The Events Calendar blocks from being rendered for the content.
*
* @param mixed $block_content The blocks content.
* @param mixed $block The block.
*/
public static function filter_tribe_blocks( $block_content, $block ) {
// Check if the block name starts with 'tribe' and is not an exception.
if ( isset( $block['blockName'] ) && 0 === strpos( $block['blockName'], 'tribe/' ) ) {
return ''; // Skip rendering this block.
}
return $block_content; // Return the content for other blocks.
}
}

View File

@ -0,0 +1,115 @@
<?php
/**
* ActivityPub Transformer for the plugin Very Simple Event List.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Place;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Event as Event_Transformer;
/**
* ActivityPub Transformer for VS Event.
*
* This transformer tries a different principle: The setters are chainable.
*
* @since 1.0.0
*/
final class VS_Event_List extends Event_Transformer {
/**
* Get the event location.
*
* @return Place The Place.
*/
public function get_location(): ?Place {
$address = \get_post_meta( $this->item->ID, 'event-location', true );
if ( $address ) {
$place = new Place();
$place->set_type( 'Place' );
$place->set_name( $address );
$place->set_address( $address );
return $place;
} else {
return null;
}
}
/**
* Get the end time from the events metadata.
*/
public function get_end_time(): ?string {
if ( 'yes' === \get_post_meta( $this->item->ID, 'event-hide-end-time', true ) ) {
return null;
}
$end_time = \get_post_meta( $this->item->ID, 'event-date', true );
if ( is_null( $end_time ) || empty( $end_time ) || 'no' === $end_time ) {
return null;
}
return \gmdate( 'Y-m-d\TH:i:s\Z', (int) $end_time );
}
/**
* Get the end time from the events metadata.
*/
public function get_start_time(): string {
$start_time = \get_post_meta( $this->item->ID, 'event-start-date', true );
return \gmdate( 'Y-m-d\TH:i:s\Z', (int) $start_time );
}
/**
* Get the event link from the events metadata.
*
* @return ?array Associated array of an ActivityStreams Link object with the events URL.
*/
private function get_event_link(): ?array {
$event_link = \get_post_meta( $this->item->ID, 'event-link', true );
$event_link_label = \get_post_meta( $this->item->ID, 'event-link-label', true ) ?? 'Event Link';
if ( $event_link ) {
return array(
'type' => 'Link',
'name' => $event_link_label,
'href' => \esc_url( $event_link ),
'mediaType' => 'text/html',
);
}
return null;
}
/**
* Overrides/extends the get_attachments function to also add the event Link.
*
* @return array
*/
protected function get_attachment(): array {
$attachments = parent::get_attachment();
if ( count( $attachments ) ) {
$attachments[0]['type'] = 'Document';
$attachments[0]['name'] = 'Banner';
}
$event_link = $this->get_event_link();
if ( $event_link ) {
$attachments[] = $event_link;
}
return $attachments;
}
/**
* Retrieves the excerpt text (may be HTML). Used for constructing the summary.
*
* @return ?string
*/
protected function retrieve_excerpt(): ?string {
if ( \get_post_meta( $this->item->ID, 'event-summary', true ) ) {
return \get_post_meta( $this->item->ID, 'event-summary', true );
} else {
return parent::retrieve_excerpt();
}
}
}

View File

@ -0,0 +1,162 @@
<?php
/**
* ActivityPub Transformer for the plugin Very Simple Event List.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Place;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Event as Event_Transformer;
use DateTime;
use DateTimeZone;
/**
* ActivityPub Transformer for events from the WordPress plugin 'Events Manager'
*
* @see https://wordpress.org/plugins/events-manager/
*
* @since 1.0.0
*/
final class WP_Event_Manager extends Event_Transformer {
/**
* Returns whether the even is online
*
* @return bool
*/
protected function get_is_online(): bool {
$is_online_text = get_post_meta( $this->item->ID, '_event_online', true );
$is_online = false;
// Radio buttons.
if ( 'yes' === $is_online_text ) {
$is_online = true;
}
// Checkbox.
if ( '1' === $is_online_text ) {
$is_online = true;
}
return $is_online;
}
/**
* Get the event location.
*
* @return ?Place The Place.
*/
public function get_location(): ?Place {
$location_name = get_post_meta( $this->item->ID, '_event_location', true );
if ( $location_name ) {
$location = new Place();
$location->set_name( $location_name );
$location->set_sensitive( null );
$location->set_address( $location_name );
return $location;
}
return null;
}
/**
* Get the end time from the events metadata.
*
* @return ?string The events end-datetime if is set, null otherwise.
*/
public function get_end_time(): ?string {
$end_date = get_post_meta( $this->item->ID, '_event_end_date', true );
if ( ! $end_date ) {
return null;
}
$timezone = new DateTimeZone( $this->get_timezone() );
if ( is_numeric( $end_date ) ) {
$end_date = '@' . $end_date;
}
$end_datetime = new DateTime( $end_date, $timezone );
return $end_datetime->format( 'Y-m-d\TH:i:sP' );
}
/**
* Get timezone.
*
* @return string
*/
public function get_timezone(): string {
$time_zone = get_post_meta( $this->item->ID, '_event_timezone', true );
if ( $time_zone ) {
return $time_zone;
}
return parent::get_timezone();
}
/**
* Get the end time from the events metadata.
*/
public function get_start_time(): string {
$start_date = get_post_meta( $this->item->ID, '_event_start_date', true );
$timezone = new DateTimeZone( $this->get_timezone() );
if ( is_numeric( $start_date ) ) {
$start_date = '@' . $start_date;
}
$start_datetime = new DateTime( $start_date, $timezone );
return $start_datetime->format( 'Y-m-d\TH:i:sP' );
}
/**
* Get the event link as an ActivityPub Link object, but as an associative array.
*
* @return ?array
*/
private function get_event_link_attachment(): ?array {
$event_link_url = get_post_meta( $this->item->ID, '_event_video_url', true );
if ( str_starts_with( $event_link_url, 'http' ) ) {
return array(
'type' => 'Link',
'name' => \esc_html__( 'Video URL', 'event-bridge-for-activitypub' ),
'href' => \esc_url( $event_link_url ),
'mediaType' => 'text/html',
);
} else {
return null;
}
}
/**
* Overrides/extends the get_attachments function to also add the event Link.
*/
protected function get_attachment() {
// Get attachments via parent function.
$attachments = parent::get_attachment();
// The first attachment is the featured image, make sure it is compatible with Mobilizon.
if ( count( $attachments ) ) {
$attachments[0]['type'] = 'Document';
$attachments[0]['name'] = 'Banner';
}
if ( $this->get_event_link_attachment() ) {
$attachments[] = $this->get_event_link_attachment();
}
return $attachments;
}
/**
* Get the events title/name.
*
* @return string
*/
protected function get_name(): string {
return $this->item->post_title;
}
}

View File

@ -0,0 +1,146 @@
<?php
/**
* Class file for the ActivityPub transformer of the venues of The Events Calendar to `as:Place`.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Place as Place_Object;
use Activitypub\Transformer\Post;
/**
* Class for the ActivityPub transformer of the venues of The Events Calendar to `as:Place`.
*
* @method array|string get_address()
*
* @since 1.0.0
*/
abstract class Base_Post_Place extends Post {
/**
* Set the type of the object.
*
* @return string
*/
public function get_type(): string {
return 'Place';
}
/**
* Set the type of the object.
*
* @return ?array
*/
public function get_replies() {
return null;
}
/**
* Set the type of the object.
*
* @return ?string
*/
public function get_sensitive() {
return null;
}
/**
* Null content to prevent registering and unregistering ActivityPub shortcodes in parent function.
*
* @return ?string
*/
public function get_content() {
return null;
}
/**
* Completely remove attachments.
*
* @return ?array
*/
public function get_attachment() {
return null;
}
/**
* Completely remove summary.
*
* @return ?string
*/
public function get_summary() {
return null;
}
/**
* Completely remove tag.
*
* @return ?array
*/
public function get_tag() {
return null;
}
/**
* Completely media type.
*
* @return ?string
*/
public function get_media_type() {
return null;
}
/**
* Generic function that converts an WordPress location object to an ActivityPub-Place object.
*
* @param bool $full_object bool Return an object with all properties set, or a minimal one as used within an `as:Event`s location.
* @return Place_Object|\WP_Error
*/
public function to_object( $full_object = true ) {
$activitypub_object = new Place_Object();
$activitypub_object = $this->transform_object_properties( $activitypub_object );
if ( \is_wp_error( $activitypub_object ) ) {
return $activitypub_object;
}
if ( ! empty( $activitypub_object->get_content() ) ) {
$activitypub_object->set_content_map(
array(
$this->get_locale() => $this->get_content(),
)
);
}
$updated = \strtotime( $this->item->post_modified_gmt );
$activitypub_object->set_updated( \gmdate( 'Y-m-d\TH:i:s\Z', $updated ) );
if ( $full_object ) {
$published = \strtotime( $this->item->post_date_gmt );
$activitypub_object->set_published( \gmdate( 'Y-m-d\TH:i:s\Z', $published ) );
$activitypub_object->set_to(
array(
'https://www.w3.org/ns/activitystreams#Public',
$this->get_actor_object()->get_followers(),
)
);
}
$address = $this->get_address();
if ( $address ) {
$activitypub_object->set_address( $address );
}
// @phpstan-ignore-next-line
return $activitypub_object;
}
}

View File

@ -0,0 +1,165 @@
<?php
/**
* Class file a base `Place` transformer where the place/location/venue is stored in a WordPress term.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Place as Place_Object;
use Activitypub\Transformer\Base;
/**
* Class for a base `Place` transformer where the place/location/venue is stored in a WordPress term.
*
* @method array|string get_address()
*
* @since 1.0.0
*/
abstract class Base_Term_Place extends Base {
/**
* Set the type of the object.
*
* @return string
*/
public function get_type(): string {
return 'Place';
}
/**
* Get the WordPress term ID of the current Transformers item.
*
* @return int
*/
public function get__id() {
return $this->item->term_id;
}
/**
* Get the ActivityPub ID of the term.
*
* @return string
*/
public function get_url() {
return \get_term_link( $this->item );
}
/**
* Returns the most unique, resolvable "ID" there currently is for a WordPress term.
*
* @return string The "ID"
*/
public function get_id() {
/**
* The first approach was to use the normal query from WordPress, but it contains the slug, which might be edited.
*
* \add_query_arg( $this->item->taxonomy, $this->item->slug, \trailingslashit( \home_url() ) );
*
* As https://github.com/Automattic/wordpress-activitypub/pull/1272 got merged, now we can definy a real ID.
*/
return \add_query_arg( 'term_id', $this->item->term_id, \trailingslashit( \home_url() ) );
}
/**
* Use Term description as ActivityPub content.
*
* @return mixed|string
*/
public function get_content() {
return $this->item->description;
}
/**
* Returns the name for the ActivityPub Item which is the title of the term.
*
* @return string|null The title or null if the object type is `note`.
*/
protected function get_name() {
if ( isset( $this->item->name ) && $this->item instanceof \WP_Term ) {
return \wp_strip_all_tags(
\html_entity_decode(
$this->item->name
)
);
}
return null;
}
/**
* Generic function that converts an WordPress location object to an ActivityPub-Place object.
*
* @return Place_Object|\WP_Error
*/
public function to_object() {
$activitypub_object = new Place_Object();
$activitypub_object->set_type( $this->get_type() );
$activitypub_object->set_id( $this->get_id() );
$activitypub_object->set_name( $this->get_name() );
$activitypub_object->set_url( $this->get_url() );
$activitypub_object->set_content( $this->get_content() );
$activitypub_object->set_sensitive( $this->get_sensitive() );
$address = $this->get_address();
if ( $address ) {
$activitypub_object->set_address( $address );
}
return $activitypub_object;
}
/**
* Don't set a media type on Place per default.
*
* @return null
* @phpstan-ignore-next-line
*/
public function get_media_type() {
return null;
}
/**
* Don't set sensitive per default.
*
* @return null
*/
public function get_sensitive() {
return null;
}
/**
* Don't support replies for Place per default.
*
* @return null
*/
public function get_replies() {
return null;
}
/**
* Don't support tags for Place per default.
*
* @return null
* @phpstan-ignore-next-line
*/
protected function get_tag() {
return null;
}
/**
* Don't set attrbuted to per default.
*
* @return null The attributed to.
* @phpstan-ignore-next-line
*/
protected function get_attributed_to() {
return null;
}
}

View File

@ -0,0 +1,89 @@
<?php
/**
* Class file for the ActivityPub transformer of the venues of The Events Calendar to `as:Place`.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
/**
* Class for the ActivityPub transformer of the venues of The Events Calendar to `as:Place`.
*
* @since 1.0.0
*/
final class Event_Organiser extends Base_Term_Place {
/**
* Get the longitute.
*
* @return float|null
*/
public function get_longitude() {
$longitude = \eo_get_venue_lng( $this->item->ID );
return 0.0 !== $longitude ? $longitude : null;
}
/**
* Get the latitude.
*
* @return float|null
*/
public function get_latitude() {
$latitude = \eo_get_venue_lat( $this->item->ID );
return 0.0 !== $latitude ? $latitude : null;
}
/**
* Get the description of the venue as the ActivityPub content.
*
* @return string|null
*/
public function get_content() {
$description = \eo_get_venue_description( $this->item->term_id );
if ( empty( $description ) ) {
return null;
}
return $description;
}
/**
* Get the events address.
*
* @return ?array The place/venue if one is set, or null if no valid address data exists.
*/
public function get_address(): ?array {
$address = \eo_get_venue_address( $this->item->term_id );
// Map the values to a schema.org PostalAddress.
$postal_address = array(
'streetAddress' => isset( $address['address'] ) ? $address['address'] : null,
'postalCode' => isset( $address['address'] ) ? $address['postcode'] : null,
'addressRegion' => isset( $address['address'] ) ? $address['state'] : null,
'addressLocality' => isset( $address['address'] ) ? $address['city'] : null,
'addressCountry' => isset( $address['address'] ) ? $address['country'] : null,
);
// Filter out empty values.
foreach ( $postal_address as $key => $value ) {
if ( empty( $value ) ) {
unset( $postal_address[ $key ] );
}
}
// If no valid address data remains, return null.
if ( empty( $postal_address ) ) {
return null;
}
// Add the type.
$postal_address['type'] = 'PostalAddress';
return $postal_address;
}
}

View File

@ -0,0 +1,197 @@
<?php
/**
* Class file for the ActivityPub transformer of the venues of The Events Calendar to `as:Place`.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Place as Place_Object;
/**
* Class for the ActivityPub transformer of the venues of The Events Calendar to `as:Place`.
*
* @since 1.0.0
*/
final class EventOn extends Base_Term_Place {
/**
* The location meta for all locations.
*
* @var ?array
*/
protected $tax_meta = null;
/**
* Extend the construction to get the taxonomy meta for this term from options.
*
* @param \WP_Term $item The WordPress post object (event).
*/
public function __construct( $item ) {
parent::__construct( $item );
$evo_tax_meta = \get_option( 'evo_tax_meta' );
if ( isset( $evo_tax_meta['event_location'][ $item->term_id ] ) ) {
$this->tax_meta = $evo_tax_meta['event_location'][ $item->term_id ];
}
}
/**
* Generic function that converts an WordPress location object to an ActivityPub-Place object.
*
* @return Place_Object|\WP_Error
*/
public function to_object() {
$object = parent::to_object();
if ( \is_wp_error( $object ) ) {
return $object;
}
$object->set_longitude( $this->get_longitude() );
$object->set_latitude( $this->get_latitude() );
return $object;
}
/**
* Get the type, either Place or VirtualLocation, both is stored in the same taxonomy.
*
* @return string
*/
public function get_type(): string {
if ( $this->is_virtual_location() ) {
return 'VirtualLocation';
}
return 'Place';
}
/**
* Get the longitute.
*
* @return float|null
*/
public function get_longitude(): ?float {
$longitude = null;
if ( isset( $this->tax_meta['location_lon'] ) ) {
$longitude = $this->tax_meta['location_lon'];
}
return $longitude ? (float) $longitude : null;
}
/**
* Get the latitude.
*
* @return float|null
*/
public function get_latitude(): ?float {
$latitude = null;
if ( isset( $this->tax_meta['location_lat'] ) ) {
$latitude = $this->tax_meta['location_lat'];
}
return $latitude ? (float) $latitude : null;
}
/**
* Get the events address.
*
* @return ?array The place/venue if one is set, or null if no valid address data exists.
*/
public function get_address(): ?array {
if ( $this->is_virtual_location() ) {
return null;
}
// Map the values to a schema.org PostalAddress.
$postal_address = array(
'streetAddress' => isset( $this->tax_meta['location_address'] ) ? (string) $this->tax_meta['location_address'] : null,
'addressRegion' => isset( $this->tax_meta['location_state'] ) ? (string) $this->tax_meta['location_state'] : null,
'addressCountry' => isset( $this->tax_meta['location_country'] ) ? (string) $this->tax_meta['location_country'] : null,
);
if ( isset( $this->tax_meta['location_city'] ) ) {
$locality_and_postal_code = $this->parse_city_for_postal_code( $this->tax_meta['location_city'] );
$postal_address['addressLocality'] = (string) $locality_and_postal_code['addressLocality'];
$postal_address['postalCode'] = (string) $locality_and_postal_code['postalCode'];
}
// Filter out empty values.
foreach ( $postal_address as $key => $value ) {
if ( empty( $value ) ) {
unset( $postal_address[ $key ] );
}
}
// If no valid address data remains, return null.
if ( empty( $postal_address ) ) {
return null;
}
// Add the type.
$postal_address = array_merge(
array(
'type' => 'PostalAddress',
),
$postal_address
);
return $postal_address;
}
/**
* Check if this term represents a virtual location.
*
* @return bool
*/
private function is_virtual_location(): bool {
if ( isset( $this->tax_meta['location_type'] ) && 'virtual' === $this->tax_meta['location_type'] ) {
return true;
}
return false;
}
/**
* Parse a string whether it contains a postal code and seperates both.
*
* @param string $input The input string of the locality which might contain the postal code too.
* @return array{addressLocality: string, postalCode: string}
*/
private function parse_city_for_postal_code( $input ): array {
$input = trim( $input );
if ( empty( $input ) ) {
return array(
'addressLocality' => '',
'postalCode' => '',
);
}
$parts = explode( ' ', $input );
$postal_code = '';
$locality = array();
foreach ( $parts as $part ) {
if ( preg_match( '/^\d{4,5}$/', $part ) ) {
// Match postal codes (assuming 4-5 digit codes).
$postal_code = $part;
} else {
// Assume everything else is part of the name of the city.
$locality[] = $part;
}
}
return array(
'addressLocality' => implode( ' ', $locality ),
'postalCode' => $postal_code,
);
}
}

View File

@ -0,0 +1,59 @@
<?php
/**
* Class file for the ActivityPub transformer of the venues of The Events Calendar to `as:Place`.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
/**
* Class for the ActivityPub transformer of the venues of The Events Calendar to `as:Place`.
*
* @since 1.0.0
*/
final class EventPrime extends Base_Term_Place {
/**
* Get URL
*
* @return string|null
*/
public function get_url() {
$ep = new \Eventprime_Basic_Functions();
$url = $ep->ep_get_custom_page_url( 'venues_page', $this->item->term_id, 'venue', 'term' );
if ( \is_wp_error( $url ) ) {
return null;
}
return $url;
}
/**
* Get the best "ID" we currently have.
*
* @return string|null
*/
public function get_id() {
return $this->get_url();
}
/**
* Get the event location.
*
* @return array|string|null The place/venue if one is set.
*/
public function get_address() {
$address = \get_term_meta( $this->item->term_id, 'em_address', true );
$display_address = \get_term_meta( $this->item->term_id, 'em_display_address_on_frontend', true );
if ( $address && $display_address ) {
return $address;
}
return null;
}
}

View File

@ -0,0 +1,95 @@
<?php
/**
* Class file for the ActivityPub transformer of the venues of The Events Calendar to `as:Place`.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place\Base_Post_Place;
/**
* Class for the ActivityPub transformer of the venues of The Events Calendar to `as:Place`.
*
* @since 1.0.0
*/
final class Events_Manager extends Base_Post_Place {
/**
* The EM Location object.
*
* @var ?\EM_Location
*/
protected $em_location;
/**
* Set the EM Location object on construction.
*
* @param \WP_Post $post The WordPress post object of the EM Location.
*/
public function __construct( $post ) {
parent::__construct( $post );
// We check for WP_Post to indicate that this might change in the future, to also e.g. allow for locations stored in terms.
// @phpstan-ignore-next-line
if ( $post instanceof \WP_Post && EM_POST_TYPE_LOCATION === $post->post_type ) {
$this->em_location = em_get_location( $post );
}
}
/**
* Get the name of the location.
*
* @return ?string
*/
public function get_name(): ?string {
if ( isset( $this->em_location->location_name ) ) {
return \wp_strip_all_tags(
\html_entity_decode(
$this->em_location->location_name
)
);
}
return null;
}
/**
* Get the event location.
*
* @return ?array The place/venue if one is set.
*/
public function get_address(): ?array {
$postal_address = array();
if ( isset( $this->em_location->location_country ) && $this->em_location->location_country ) {
$postal_address['addressCountry'] = $this->em_location->location_country;
}
if ( isset( $this->em_location->location_town ) && $this->em_location->location_town ) {
$postal_address['addressLocality'] = $this->em_location->location_town;
}
if ( isset( $this->em_location->location_address ) && $this->em_location->location_address ) {
$postal_address['streetAddress'] = $this->em_location->location_address;
}
if ( isset( $this->em_location->location_state ) && $this->em_location->location_state ) {
$postal_address['addressRegion'] = $this->em_location->location_state;
}
if ( isset( $this->em_location->location_postcode ) && $this->em_location->location_postcode ) {
$postal_address['postalCode'] = $this->em_location->location_postcode;
}
if ( ! empty( $postal_address ) ) {
return array_merge( array( 'type' => 'PostalAddress' ), $postal_address );
}
return null;
}
}

View File

@ -0,0 +1,63 @@
<?php
/**
* Class file for the ActivityPub transformer of the venues of The Events Calendar to `as:Place`.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place\Base_Post_Place;
/**
* Class for the ActivityPub transformer of the venues of The Events Calendar to `as:Place`.
*
* @since 1.0.0
*/
final class The_Events_Calendar extends Base_Post_Place {
/**
* Get the event location.
*
* @return ?array The place/venue if one is set.
*/
public function get_address(): ?array {
$postal_address = array();
$country = \tribe_get_country( $this->item->ID );
if ( $country ) {
$postal_address['addressCountry'] = $country;
}
$city = \tribe_get_city( $this->item->ID );
if ( $city ) {
$postal_address['addressLocality'] = $city;
}
$province = \tribe_get_province( $this->item->ID );
if ( $province ) {
$postal_address['addressRegion'] = $province;
}
$zip = \tribe_get_zip( $this->item->ID );
if ( $zip ) {
$postal_address['postalCode'] = $zip;
}
$address = \tribe_get_address( $this->item->ID );
if ( $city ) {
$postal_address['streetAddress'] = $address;
}
if ( empty( $postal_address ) ) {
return null;
}
$postal_address = array_merge( array( 'type' => 'PostalAddress' ), $postal_address );
return $postal_address;
}
}

View File

@ -0,0 +1,378 @@
<?php
/**
* Base class with common functions for transforming an ActivityPub Event object to a WordPress object.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transmogrifier;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Event;
use Event_Bridge_For_ActivityPub\ActivityPub\Collection\Event_Sources;
use Event_Bridge_For_ActivityPub\ActivityPub\Transmogrifier\Helper\Sanitizer;
use WP_Error;
use WP_Post;
/**
* Base class with common functions for transforming an ActivityPub Event object to a WordPress object.
*
* @since 1.0.0
*/
abstract class Base {
/**
* Internal function to actually save the event.
*
* @param Event $activitypub_event The ActivityPub event as associative array.
* @param int $event_source_post_id The Post ID of the Event Source that owns the outbox.
*
* @return false|int Post-ID on success, false on failure.
*/
abstract protected static function save_event( $activitypub_event, $event_source_post_id );
/**
* Save the ActivityPub event object within WordPress.
*
* @param array $activitypub_event The ActivityPub event as associative array.
* @param int $event_source_post_id The Post ID of the Event Source that owns the outbox.
*/
public static function save( $activitypub_event, $event_source_post_id ): void {
// Sanitize the incoming event and set only the properties used by the transmogrifier classes.
$activitypub_event = Sanitizer::init_and_sanitize_event_object_from_array( $activitypub_event );
if ( is_wp_error( $activitypub_event ) ) {
return;
}
// Pass the saving to the actual Transmogrifier implementation.
$post_id = static::save_event( $activitypub_event, $event_source_post_id );
// Post processing: Logging and marking the imported event's origin.
$event_activitypub_id = $activitypub_event->get_id();
$event_source_activitypub_id = \get_the_guid( $event_source_post_id );
if ( $post_id ) {
\do_action(
'event_bridge_for_activitypub_write_log',
array( "[ACTIVITYPUB] Processed incoming event {$event_activitypub_id} from {$event_source_activitypub_id}" )
);
// Use post meta to remember who we received this event from.
\update_post_meta( $post_id, '_event_bridge_for_activitypub_event_source', absint( $event_source_post_id ) );
\update_post_meta( $post_id, 'activitypub_content_visibility', defined( 'ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL' ) ? constant( 'ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL' ) : '' );
} else {
\do_action(
'event_bridge_for_activitypub_write_log',
array( "[ACTIVITYPUB] Failed processing incoming event {$event_activitypub_id} from {$event_source_activitypub_id}" )
);
}
}
/**
* Delete a local event in WordPress that is a cached remote one.
*
* @param string $activitypub_event_id The ActivityPub events ID.
* @return bool|WP_Post|null|WP_Error
*/
public static function delete( $activitypub_event_id ) {
$post_id = static::get_post_id_from_activitypub_id( $activitypub_event_id );
if ( ! $post_id ) {
\do_action(
'event_bridge_for_activitypub_write_log',
array( "[ACTIVITYPUB] Received delete for event that is not cached locally {$activitypub_event_id}" )
);
return new WP_Error(
'event_bridge_for_activitypub_remote_event_not_found',
\__( 'Remote event not found in cache', 'event-bridge-for-activitypub' ),
array( 'status' => 404 )
);
}
$thumbnail_id = get_post_thumbnail_id( $post_id );
if ( $thumbnail_id && ! Event_Sources::is_attachment_featured_image( $thumbnail_id ) ) {
wp_delete_attachment( $thumbnail_id, true );
}
$result = wp_delete_post( $post_id, true );
if ( $result ) {
\do_action( 'event_bridge_for_activitypub_write_log', array( "[ACTIVITYPUB] Deleted cached event {$activitypub_event_id}" ) );
} else {
\do_action( 'event_bridge_for_activitypub_write_log', array( "[ACTIVITYPUB] Failed deleting cached event {$activitypub_event_id}" ) );
}
return $result;
}
/**
* Format an ActivityStreams xds:datetime to WordPress GMT format.
*
* @param string $time_string The ActivityStreams xds:datetime (may include offset).
* @return string The GMT string in format 'Y-m-d H:i:s'.
*/
protected static function format_time_string_to_wordpress_gmt( $time_string ): string {
$datetime = new \DateTime( $time_string );
$datetime->setTimezone( new \DateTimeZone( 'GMT' ) );
return $datetime->format( 'Y-m-d H:i:s' );
}
/**
* Get WordPress post by ActivityPub object ID using the guid.
*
* @param string $activitypub_id The ActivityPub object ID.
* @return int The WordPress Post ID, 0 if not post with that ActivityPub object ID (by guid) is found.
*/
protected static function get_post_id_from_activitypub_id( $activitypub_id ): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM $wpdb->posts WHERE guid=%s",
esc_sql( $activitypub_id ),
)
);
}
/**
* Get the image URL and alt-text of an ActivityPub object.
*
* @param mixed $data The ActivityPub object as ann associative array.
* @return array Array containing the images URL and alt-text.
*/
private static function extract_image_alt_and_url( $data ): array {
$image = array(
'url' => null,
'alt' => null,
);
// Check whether it is already simple.
if ( ! $data || is_string( $data ) ) {
$image['url'] = $data;
return $image;
}
if ( ! isset( $data['type'] ) ) {
return $image;
}
if ( ! in_array( $data['type'], array( 'Document', 'Image' ), true ) ) {
return $image;
}
if ( isset( $data['url'] ) ) {
$image['url'] = $data['url'];
} elseif ( isset( $data['id'] ) ) {
$image['id'] = $data['id'];
}
if ( isset( $data['name'] ) ) {
$image['alt'] = $data['name'];
}
return $image;
}
/**
* Returns the URL of the featured image.
*
* @param Event $event The ActivityPub event object.
*
* @return array
*/
protected static function get_featured_image( $event ): array {
// Search for the featured image in the image property.
$image = $event->get_image();
if ( $image ) {
return self::extract_image_alt_and_url( $image );
}
// Fallback attachment.
$attachment = $event->get_attachment();
// If attachment is an array get the first fitting one.
if ( is_array( $attachment ) && ! empty( $attachment ) ) {
$supported_types = array( 'Image', 'Document' );
$match = null;
foreach ( $attachment as $item ) {
if ( in_array( $item['type'], $supported_types, true ) ) {
$match = $item;
break;
}
}
$attachment = $match;
}
return self::extract_image_alt_and_url( $attachment );
}
/**
* Given an image URL return an attachment ID. Image will be side-loaded into the media library if it doesn't exist.
*
* Forked from https://gist.github.com/kingkool68/a66d2df7835a8869625282faa78b489a.
*
* @param int $post_id The post ID where the image will be set as featured image.
* @param string $url The image URL to maybe sideload.
* @uses media_sideload_image
* @return string|int|WP_Error
*/
protected static function maybe_sideload_image( $post_id, $url = '' ) {
global $wpdb;
// Include necessary WordPress file for media handling.
if ( ! function_exists( 'media_sideload_image' ) ) {
// @phpstan-ignore-next-line
require_once ABSPATH . 'wp-admin/includes/media.php';
// @phpstan-ignore-next-line
require_once ABSPATH . 'wp-admin/includes/file.php';
// @phpstan-ignore-next-line
require_once ABSPATH . 'wp-admin/includes/image.php';
}
// Check to see if the URL has already been fetched, if so return the attachment ID.
$attachment_id = $wpdb->get_var(
$wpdb->prepare( "SELECT `post_id` FROM {$wpdb->postmeta} WHERE `meta_key` = '_source_url' AND `meta_value` = %s", $url )
);
if ( ! empty( $attachment_id ) ) {
return $attachment_id;
}
$attachment_id = $wpdb->get_var(
$wpdb->prepare( "SELECT `ID` FROM {$wpdb->posts} WHERE guid=%s", $url )
);
if ( ! empty( $attachment_id ) ) {
return $attachment_id;
}
// If the URL doesn't exist, sideload it to the media library.
return media_sideload_image( $url, $post_id, $url, 'id' );
}
/**
* Sideload an image_url set it as featured image and add the alt-text.
*
* @param int $post_id The post ID where the image will be set as featured image.
* @param string $image_url The image URL.
* @param string $alt_text The alt-text of the image.
* @return int|WP_Error The attachment ID
*/
protected static function set_featured_image_with_alt( $post_id, $image_url, $alt_text = '' ) {
// Maybe sideload the image or get the Attachment ID of an existing one.
$image_id = self::maybe_sideload_image( $post_id, $image_url );
if ( \is_wp_error( $image_id ) ) {
// Handle the error.
return $image_id;
}
// Set the image as the featured image for the post.
\set_post_thumbnail( $post_id, $image_id );
// Update the alt text.
if ( ! empty( $alt_text ) ) {
\update_post_meta( $image_id, '_wp_attachment_image_alt', $alt_text );
}
return $image_id; // Return the attachment ID for further use if needed.
}
/**
* Convert a PostalAddress to a string.
*
* @link https://schema.org/PostalAddress
*
* @param array $postal_address The PostalAddress as an associative array.
* @return string
*/
private static function postal_address_to_string( $postal_address ): string {
if ( ! isset( $postal_address['type'] ) || 'PostalAddress' !== $postal_address['type'] ) {
_doing_it_wrong(
__METHOD__,
'The parameter postal_address must be an associate array like schema.org/PostalAddress.',
esc_html( EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_VERSION )
);
}
$address = array();
$known_attributes = array(
'streetAddress',
'postalCode',
'addressLocality',
'addressState',
'addressCountry',
);
foreach ( $known_attributes as $attribute ) {
if ( isset( $postal_address[ $attribute ] ) && is_string( $postal_address[ $attribute ] ) ) {
$address[] = $postal_address[ $attribute ];
}
}
$address_string = implode( ' ,', $address );
return $address_string;
}
/**
* Convert an address to a string.
*
* @param mixed $address The address as an object, string or associative array.
* @return string
*/
protected static function address_to_string( $address ): string {
if ( is_string( $address ) ) {
return $address;
}
if ( is_object( $address ) ) {
$address = (array) $address;
}
if ( ! is_array( $address ) || ! isset( $address['type'] ) ) {
return '';
}
if ( 'PostalAddress' === $address['type'] ) {
return self::postal_address_to_string( $address );
}
return '';
}
/**
* Return the number of revisions to keep.
*
* @return int The number of revisions to keep.
*/
public static function revisions_to_keep(): int {
return 5;
}
/**
* Returns the URL of the online event link.
*
* @param Event $event The ActivityPub event object.
*
* @return ?string
*/
protected static function get_online_event_link_from_attachments( $event ): ?string {
$attachments = $event->get_attachment();
if ( ! is_array( $attachments ) || empty( $attachments ) ) {
return null;
}
foreach ( $attachments as $attachment ) {
if ( array_key_exists( 'type', $attachment ) && 'Link' === $attachment['type'] && isset( $attachment['href'] ) ) {
return $attachment['href'];
}
}
return null;
}
}

View File

@ -0,0 +1,207 @@
<?php
/**
* ActivityPub Transmogrify for the GatherPress event plugin.
*
* Handles converting incoming external ActivityPub events to GatherPress Events.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transmogrifier;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Event;
use Activitypub\Activity\Extended_Object\Place;
use DateTime;
use Event_Bridge_For_ActivityPub\Integrations\GatherPress as IntegrationsGatherPress;
use GatherPress\Core\Event as GatherPress_Event;
/**
* ActivityPub Transmogrifier for the GatherPress event plugin.
*
* Handles converting incoming external ActivityPub events to GatherPress Events.
*
* @since 1.0.0
*/
class GatherPress extends Base {
/**
* Add tags to post.
*
* @param Event $event The ActivityPub event object.
* @param int $post_id The post ID.
*
* @return bool
*/
private static function add_tags_to_post( $event, $post_id ) {
$tags_array = $event->get_tag();
// Ensure the input is valid.
if ( empty( $tags_array ) || ! is_array( $tags_array ) || ! $post_id ) {
return false;
}
// Extract and process tag names.
$tag_names = array();
foreach ( $tags_array as $tag ) {
if ( isset( $tag['name'] ) && 'Hashtag' === $tag['type'] ) {
$tag_names[] = ltrim( $tag['name'], '#' ); // Remove the '#' from the name.
}
}
// Add the tags as terms to the post.
if ( ! empty( $tag_names ) ) {
\wp_set_object_terms( $post_id, $tag_names, IntegrationsGatherPress::get_event_category_taxonomy(), true );
}
return true;
}
/**
* Add venue.
*
* @param Event $activitypub_event The ActivityPub event object.
* @param int $post_id The post ID.
*/
private static function add_venue( $activitypub_event, $post_id ) {
$location = $activitypub_event->get_location();
if ( ! $location ) {
return;
}
if ( $location instanceof Place ) {
$location = $location->to_array();
}
if ( ! is_array( $location ) ) {
return;
}
if ( ! isset( $location['name'] ) ) {
return;
}
// Fallback for Gancio instances.
if ( 'online' === $location['name'] ) {
$online_event_link = self::get_online_event_link_from_attachments( $activitypub_event );
if ( ! $online_event_link ) {
return;
}
\update_post_meta( $post_id, 'gatherpress_online_event_link', \sanitize_url( $online_event_link ) );
\wp_set_object_terms( $post_id, 'online-event', '_gatherpress_venue', false );
return;
}
$venue_instance = \GatherPress\Core\Venue::get_instance();
$venue_name = \sanitize_title( $location['name'] );
$venue_slug = $venue_instance->get_venue_term_slug( $venue_name );
$venue_post = $venue_instance->get_venue_post_from_term_slug( $venue_slug );
if ( ! $venue_post ) {
$venue_id = \wp_insert_post(
array(
'post_title' => sanitize_text_field( $location['name'] ),
'post_type' => 'gatherpress_venue',
'post_status' => 'publish',
)
);
} else {
$venue_id = $venue_post->ID;
}
$venue_information = array();
$address_string = isset( $location['address'] ) ? self::address_to_string( $location['address'] ) : '';
$venue_information['fullAddress'] = $address_string;
$venue_information['phone_number'] = '';
$venue_information['website'] = '';
$venue_information['permalink'] = '';
$venue_json = \wp_json_encode( $venue_information );
\update_post_meta( $venue_id, 'gatherpress_venue_information', $venue_json );
\wp_set_object_terms( $post_id, $venue_slug, '_gatherpress_venue', false );
}
/**
* Save the ActivityPub event object as GatherPress Event.
*
* @param Event $activitypub_event The ActivityPub event object.
* @param int $event_source_post_id The Post ID of the Event Source that owns the outbox.
*
* @return false|int
*/
protected static function save_event( $activitypub_event, $event_source_post_id ) {
// Limit this as a safety measure.
\add_filter( 'wp_revisions_to_keep', array( self::class, 'revisions_to_keep' ) );
$post_id = self::get_post_id_from_activitypub_id( $activitypub_event->get_id() );
$args = array(
'post_title' => sanitize_text_field( $activitypub_event->get_name() ),
'post_type' => 'gatherpress_event',
'post_content' => wp_kses_post( $activitypub_event->get_content() ?? '' ) . '<!-- wp:gatherpress/venue /-->',
'post_excerpt' => wp_kses_post( $activitypub_event->get_summary() ?? '' ),
'post_status' => 'publish',
'guid' => sanitize_url( $activitypub_event->get_id() ),
);
if ( $activitypub_event->get_published() ) {
$post_date = self::format_time_string_to_wordpress_gmt( $activitypub_event->get_published() );
$args['post_date'] = $post_date;
$args['post_date_gmt'] = $post_date;
}
if ( $post_id ) {
// Update existing GatherPress event post.
$args['ID'] = $post_id;
\wp_update_post( $args );
} else {
// Insert new GatherPress event post.
$post_id = \wp_insert_post( $args );
}
// @phpstan-ignore-next-line
if ( ! $post_id || \is_wp_error( $post_id ) ) {
return false;
}
// Insert the dates.
$gatherpress_event = new GatherPress_Event( $post_id );
$start_time = $activitypub_event->get_start_time();
$end_time = $activitypub_event->get_end_time();
if ( ! $end_time ) {
$end_time = new DateTime( $start_time );
$end_time->modify( '+1 hour' );
$end_time = $end_time->format( 'Y-m-d H:i:s' );
}
$params = array(
'datetime_start' => $start_time,
'datetime_end' => $end_time,
'timezone' => $activitypub_event->get_timezone(),
);
// Sanitization of the params is done in the save_datetimes function just in time.
$gatherpress_event->save_datetimes( $params );
// Insert featured image.
$image = self::get_featured_image( $activitypub_event );
self::set_featured_image_with_alt( $post_id, $image['url'], $image['alt'] );
// Add hashtags.
self::add_tags_to_post( $activitypub_event, $post_id );
// Add venue.
self::add_venue( $activitypub_event, $post_id );
// Limit this as a safety measure.
\remove_filter( 'wp_revisions_to_keep', array( self::class, 'revisions_to_keep' ) );
return $post_id;
}
}

View File

@ -0,0 +1,371 @@
<?php
/**
* ActivityPub Transmogrify for the The Events Calendar event plugin.
*
* Handles converting incoming external ActivityPub events to The Events Calendar Events.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transmogrifier;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Event;
use Activitypub\Activity\Extended_Object\Place;
use Event_Bridge_For_ActivityPub\ActivityPub\Model\Event_Source;
use Event_Bridge_For_ActivityPub\ActivityPub\Transmogrifier\Helper\The_Events_Calendar_Event_Repository;
use Event_Bridge_For_ActivityPub\ActivityPub\Transmogrifier\Helper\The_Events_Calendar_Venue_Repository;
use function Activitypub\object_to_uri;
/**
* ActivityPub Transmogrifier for the GatherPress event plugin.
*
* Handles converting incoming external ActivityPub events to GatherPress Events.
*
* @since 1.0.0
*/
class The_Events_Calendar extends Base {
/**
* Save the ActivityPub event object as GatherPress Event.
*
* @param Event $activitypub_event The ActivityPub event as associative array.
* @param int $event_source_post_id The Post ID of the Event Source that owns the outbox.
*
* @return false|int
*/
protected static function save_event( $activitypub_event, $event_source_post_id ) {
// Limit the number of saved post revisions as a safety measure.
add_filter( 'wp_revisions_to_keep', array( self::class, 'revisions_to_keep' ) );
$post_id = self::get_post_id_from_activitypub_id( $activitypub_event->get_id() );
$duration = self::get_duration( $activitypub_event );
$venue_id = self::add_venue( $activitypub_event, $event_source_post_id );
$organizer_id = self::add_organizer( $activitypub_event );
$args = array(
'title' => $activitypub_event->get_name(),
'content' => $activitypub_event->get_content() ?? '',
'start_date' => gmdate( 'Y-m-d H:i:s', strtotime( $activitypub_event->get_start_time() ) ),
'duration' => $duration,
'status' => 'publish',
'guid' => $activitypub_event->get_id(),
);
if ( $venue_id ) {
$args['venue'] = $venue_id;
$args['VenueID'] = $venue_id;
}
if ( $organizer_id ) {
$args['organizer'] = $organizer_id;
$args['OrganizerID'] = $organizer_id;
}
if ( $activitypub_event->get_published() ) {
$post_date = self::format_time_string_to_wordpress_gmt( $activitypub_event->get_published() );
$args['post_date'] = $post_date;
$args['post_date_gmt'] = $post_date;
}
$tribe_event = new The_Events_Calendar_Event_Repository();
if ( $post_id ) {
$post = $tribe_event->where( 'id', $post_id )->set_args( $args )->save();
} else {
$post = $tribe_event->set_args( $args )->create();
}
if ( $post instanceof \WP_Post ) {
$post_id = $post->ID;
}
if ( ! $post_id ) {
return false;
}
// Insert featured image.
$image = self::get_featured_image( $activitypub_event );
if ( isset( $image['url'] ) ) {
self::set_featured_image_with_alt( $post_id, $image['url'], $image['alt'] );
}
// Add tags.
self::add_tags_to_post( $activitypub_event, $post_id );
// Remove revision limit.
remove_filter( 'wp_revisions_to_keep', array( self::class, 'revisions_to_keep' ) );
return $post_id;
}
/**
* Map an ActivityStreams Place to the Events Calendar venue.
*
* @param array|Place $location An ActivityPub location as an associative array or Place object.
* @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-place
* @return array
*/
private static function get_venue_args( $location ): array {
$args = array(
'venue' => $location['name'],
'status' => 'publish',
);
if ( $location instanceof Place ) {
$location = $location->to_array();
}
if ( ! isset( $location['address'] ) ) {
return $args;
}
if ( is_array( $location['address'] ) ) {
$mapping = array(
'streetAddress' => 'address',
'postalCode' => 'zip',
'addressLocality' => 'city',
'addressState' => 'state',
'addressCountry' => 'country',
'url' => 'website',
);
foreach ( $mapping as $postal_address_key => $venue_key ) {
if ( isset( $location['address'][ $postal_address_key ] ) ) {
$args[ $venue_key ] = $location['address'][ $postal_address_key ];
}
}
} elseif ( is_string( $location['address'] ) ) {
// Use the address field for a solely text address.
$args['address'] = $location['address'];
}
if ( isset( $location['id'] ) ) {
$args['guid'] = $location['id'];
}
return $args;
}
/**
* Add venue.
*
* @param Event $activitypub_event The ActivityPub event object.
* @param int $event_source_post_id The WordPress Post ID of the event source.
*
* @return ?int $post_id The venues post ID.
*/
private static function add_venue( $activitypub_event, $event_source_post_id ): ?int {
$location = $activitypub_event->get_location();
// Make sure we have a valid location in the right format.
if ( ! $location ) {
return null;
}
if ( $location instanceof Place ) {
$location = $location->to_array();
}
if ( ! is_array( $location ) ) {
return null;
}
if ( ! isset( $location['name'] ) ) {
return null;
}
// Fallback for Gancio instances.
if ( 'online' === $location['name'] ) {
return null;
}
$tribe_venue = new The_Events_Calendar_Venue_Repository();
// If the venue already exists try to find it's post id.
$post_id = null;
// Search if we already got this venue/place in our database.
if ( isset( $location['id'] ) ) {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s",
$location['id'],
\Tribe__Events__Venue::POSTTYPE
)
);
if ( $post_id ) {
$post_id = (int) $post_id;
}
}
if ( ! $post_id ) {
// Try to find a match by searching.
$results = $tribe_venue->search( $location['name'] )->all();
foreach ( $results as $potential_matching_post_id ) {
// @phpstan-ignore-next-line
if ( $potential_matching_post_id instanceof \WP_Post ) {
$potential_matching_post_id = $potential_matching_post_id->ID;
}
// Only accept a match for the venue/location if it was received by the same actor.
if ( \get_post_meta( $potential_matching_post_id, '_event_bridge_for_activitypub_event_source', true ) === $event_source_post_id ) {
$post_id = $potential_matching_post_id;
break;
}
}
}
if ( $post_id ) {
// Update if we found a match.
$result = $tribe_venue->where( 'id', $post_id )->set_args( self::get_venue_args( $location ) )->save();
if ( array_key_exists( $post_id, $result ) && $result[ $post_id ] ) {
return $post_id;
}
} else {
// Create a new venue.
$post = $tribe_venue->set_args( self::get_venue_args( $location ) )->create();
if ( $post ) {
$post_id = $post->ID;
update_post_meta( $post_id, '_event_bridge_for_activitypub_event_source', $event_source_post_id );
}
}
return $post_id;
}
/**
* Add organizer.
*
* @param Event $activitypub_event The ActivityPub event object.
*
* @return int|bool $post_id The organizers post ID.
*/
private static function add_organizer( $activitypub_event ) {
// This might likely change, because of FEP-8a8e.
$actor = $activitypub_event->get_attributed_to();
if ( is_null( $actor ) ) {
return false;
}
$actor_id = object_to_uri( $actor );
$event_source = Event_Source::get_by_id( $actor_id );
// As long as we do not support announces, we expect the attributedTo to be an existing event source.
if ( ! $event_source ) {
return false;
}
// Prepare arguments for inserting/updating the organizer post.
$args = array(
'organizer' => $event_source->get_name(),
'description' => $event_source->get_summary(),
'website' => $event_source->get_url(),
'excerpt' => $event_source->get_summary(),
'post_parent' => $event_source->get__id(), // Maybe just use post meta too here.
);
if ( $event_source->get_published() ) {
$post_date = self::format_time_string_to_wordpress_gmt( $event_source->get_published() );
$args['post_date'] = $post_date;
$args['post_date_gmt'] = $post_date;
}
// Get organizer if it is already present.
$children = \get_children(
array(
'post_parent' => $event_source->get__id(),
'post_type' => \Tribe__Events__Organizer::POSTTYPE,
),
);
if ( count( $children ) ) {
// Update organizer post.
$child = array_pop( $children );
$tribe_organizer_post_ids = \tribe_organizers()->where( 'id', $child->ID )->set_args( $args )->save();
// Fallback to delete duplicates.
foreach ( $children as $to_delete ) {
\wp_delete_post( $to_delete->ID, true );
}
// If updating failed return.
if ( 1 !== count( $tribe_organizer_post_ids ) || ! reset( $tribe_organizer_post_ids ) ) {
return false;
}
$tribe_organizer_post_id = array_key_first( $tribe_organizer_post_ids );
} else {
// Create new organizer post.
$tribe_organizer_post = \tribe_organizers()->set_args( $args )->create();
if ( ! $tribe_organizer_post ) {
return false;
}
$tribe_organizer_post_id = $tribe_organizer_post->ID;
// Make a relationship between the event source WP_Post and the organizer WP_Post.
\update_post_meta( $tribe_organizer_post_id, '_event_bridge_for_activitypub_event_source', true );
}
// Add the thumbnail of the event source to the organizer.
if ( \get_post_thumbnail_id( $event_source->get__id() ) ) {
\set_post_thumbnail( $tribe_organizer_post_id, \get_post_thumbnail_id( $event_source->get__id() ) );
}
return $tribe_organizer_post_id;
}
/**
* Add tags to post.
*
* @param Event $activitypub_event The ActivityPub event object.
* @param int $post_id The post ID.
*/
private static function add_tags_to_post( $activitypub_event, $post_id ): bool {
$tags_array = $activitypub_event->get_tag();
// Ensure the input is valid.
if ( empty( $tags_array ) || ! is_array( $tags_array ) || ! $post_id ) {
return false;
}
// Extract and process tag names.
$tag_names = array();
foreach ( $tags_array as $tag ) {
if ( isset( $tag['name'] ) && 'Hashtag' === $tag['type'] ) {
$tag_names[] = ltrim( $tag['name'], '#' ); // Remove the '#' from the name.
}
}
// Add the tags as terms to the post.
if ( ! empty( $tag_names ) ) {
\wp_set_object_terms( $post_id, $tag_names, 'post_tag', true );
}
return true;
}
/**
* Get the events duration in seconds.
*
* @param Event $activitypub_event The ActivityPub event object.
*
* @return int
*/
private static function get_duration( $activitypub_event ): int {
$end_time = $activitypub_event->get_end_time();
if ( ! $end_time ) {
return 2 * HOUR_IN_SECONDS;
}
return abs( strtotime( $end_time ) - strtotime( $activitypub_event->get_start_time() ) );
}
}

View File

@ -0,0 +1,175 @@
<?php
/**
* ActivityPub Transmogrifier for the VS Event List event plugin.
*
* Handles converting incoming external ActivityPub events to events of VS Event List.
*
* @link https://wordpress.org/plugins/very-simple-event-list/
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transmogrifier;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Event;
use Activitypub\Activity\Extended_Object\Place;
use Event_Bridge_For_ActivityPub\Integrations\VS_Event_List as IntegrationsVS_Event_List;
/**
* ActivityPub Transmogrifier for the VS Event List event plugin.
*
* Handles converting incoming external ActivityPub events to events of VS Event List.
*
* @link https://wordpress.org/plugins/very-simple-event-list/
* @since 1.0.0
*/
class VS_Event_List extends Base {
/**
* Extract location and address as string.
*
* @param mixed $location The ActivityStreams location.
* @return string The location and address formatted as a single string.
*/
private static function get_location_as_string( $location ): string {
$location_string = '';
if ( $location instanceof Place ) {
$location = $location->to_array();
}
// Return empty string when location is not an associative array.
if ( ! is_array( $location ) || 0 === count( $location ) ) {
return $location_string;
}
if ( ! isset( $location['type'] ) || 'Place' !== $location['type'] ) {
return $location_string;
}
// Add name of the location.
if ( isset( $location['name'] ) ) {
$location_string .= $location['name'];
}
// Add delimiter between name and address if both are set.
if ( isset( $location['name'] ) && isset( $location['address'] ) ) {
$location_string .= ' ';
}
// Add address.
if ( isset( $location['address'] ) ) {
$location_string .= self::address_to_string( $location['address'] );
}
return $location_string;
}
/**
* Add tags to post.
*
* @param Event $activitypub_event The ActivityPub event object.
* @param int $post_id The post ID.
*/
private static function add_tags_to_post( $activitypub_event, $post_id ) {
$tags_array = $activitypub_event->get_tag();
// Ensure the input is valid.
if ( empty( $tags_array ) || ! is_array( $tags_array ) || ! $post_id ) {
return false;
}
// Extract and process tag names.
$tag_names = array();
foreach ( $tags_array as $tag ) {
if ( isset( $tag['name'] ) && 'Hashtag' === $tag['type'] ) {
$tag_names[] = ltrim( $tag['name'], '#' ); // Remove the '#' from the name.
}
}
// Add the tags as terms to the post.
if ( ! empty( $tag_names ) ) {
\wp_set_object_terms( $post_id, $tag_names, IntegrationsVS_Event_List::get_event_category_taxonomy(), true );
}
return true;
}
/**
* Save the ActivityPub event object as VS Event List event.
*
* @param Event $activitypub_event The ActivityPub event object.
* @param int $event_source_post_id The Post ID of the Event Source that owns the outbox.
*
* @return false|int
*/
protected static function save_event( $activitypub_event, $event_source_post_id ) {
// Limit this as a safety measure.
\add_filter( 'wp_revisions_to_keep', array( self::class, 'revisions_to_keep' ) );
$post_id = self::get_post_id_from_activitypub_id( $activitypub_event->get_id() );
$args = array(
'post_title' => $activitypub_event->get_name(),
'post_type' => \Event_Bridge_For_ActivityPub\Integrations\VS_Event_List::get_post_type(),
'post_content' => $activitypub_event->get_content() ?? '',
'post_excerpt' => $activitypub_event->get_summary() ?? '',
'post_status' => 'publish',
'guid' => $activitypub_event->get_id(),
'meta_input' => array(
'event-start-date' => \strtotime( $activitypub_event->get_start_time() ),
'event-link' => $activitypub_event->get_url() ?? $activitypub_event->get_id(),
'event-link-label' => \sanitize_text_field( __( 'Original Website', 'event-bridge-for-activitypub' ) ),
'event-link-target' => 'yes', // Open in new window.
'event-link-title' => 'no', // Whether to redirect event title to original source.
'event-link-image' => 'no', // Whether to redirect events featured image to original source.
),
);
if ( $activitypub_event->get_published() ) {
$post_date = self::format_time_string_to_wordpress_gmt( $activitypub_event->get_published() );
$args['post_date'] = $post_date;
$args['post_date_gmt'] = $post_date;
}
// Add end time.
$end_time = $activitypub_event->get_end_time();
if ( $end_time ) {
$args['meta_input']['event-date'] = \strtotime( $end_time );
}
// Maybe add location.
$location = self::get_location_as_string( $activitypub_event->get_location() );
if ( $location ) {
$args['meta_input']['event-location'] = $location;
}
if ( $post_id ) {
// Update existing event post.
$args['ID'] = $post_id;
$post_id = \wp_update_post( $args );
} else {
// Insert new event post.
$post_id = \wp_insert_post( $args );
}
// @phpstan-ignore-next-line
if ( 0 === $post_id || \is_wp_error( $post_id ) ) {
return false;
}
// Insert featured image.
$image = self::get_featured_image( $activitypub_event );
self::set_featured_image_with_alt( $post_id, $image['url'], $image['alt'] );
// Add hashtags.
self::add_tags_to_post( $activitypub_event, $post_id );
// Limit this as a safety measure.
\remove_filter( 'wp_revisions_to_keep', array( self::class, 'revisions_to_keep' ) );
return $post_id;
}
}

View File

@ -0,0 +1,241 @@
<?php
/**
* Collection of functions that sanitize an incoming event.
*
* We do a lot of duck-typing. We just discard/ignore attributes/properties we do not know.
* Replacing this with defining a schema and using rest_sanitize_value_from_schema is a future goal.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transmogrifier\Helper;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Event;
use Activitypub\Activity\Extended_Object\Place;
use WP_Error;
use function Activitypub\object_to_uri;
/**
* Collection of functions that sanitize an incoming event.
*
* We do a lot of duck-typing. We just discard/ignore attributes/properties we do not know.
* Replacing this with defining a schema and using rest_sanitize_value_from_schema is a future goal.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
class Sanitizer {
/**
* Convert input array to an Event.
*
* @param mixed $data The object array.
*
* @return Event|WP_Error An Object built from the input array or WP_Error when it's not an array.
*/
public static function init_and_sanitize_event_object_from_array( $data ) {
if ( ! is_array( $data ) ) {
return new WP_Error( 'invalid_array', __( 'Invalid array', 'event-bridge-for-activitypub' ), array( 'status' => 404 ) );
}
$event = new Event();
// Straightforward sanitization of all attributes we possible make use of.
if ( isset( $data['content'] ) ) {
$event->set_content( \wp_kses_post( $data['content'] ) );
}
if ( isset( $data['summary'] ) ) {
$event->set_summary( \wp_kses_post( $data['summary'] ) );
}
if ( isset( $data['name'] ) ) {
$event->set_name( \sanitize_text_field( $data['name'] ) );
}
if ( isset( $data['startTime'] ) ) {
$event->set_start_time( \sanitize_text_field( $data['startTime'] ) );
}
if ( isset( $data['endTime'] ) ) {
$event->set_end_time( \sanitize_text_field( $data['endTime'] ) );
}
if ( isset( $data['published'] ) ) {
$event->set_published( \sanitize_text_field( $data['published'] ) );
}
if ( isset( $data['id'] ) ) {
$event->set_id( \sanitize_url( $data['id'] ) );
}
if ( isset( $data['url'] ) ) {
$event->set_url( \sanitize_url( $data['url'] ) );
}
if ( isset( $data['attributedTo'] ) ) {
$event->set_attributed_to( self::sanitize_attributed_to( $data['attributedTo'] ) );
}
if ( isset( $data['location'] ) ) {
$event->set_location( self::sanitize_place_object_from_array( $data['location'] ) );
}
if ( isset( $data['attachment'] ) ) {
$event->set_attachment( self::sanitize_attachment( $data['attachment'] ) );
}
if ( isset( $data['tag'] ) ) {
$event->set_tag( self::sanitize_attachment( $data['tag'] ) );
}
return $event;
}
/**
* Sanitize attributedTo.
*
* Currently only multiple attributedTo's are not supported.
*
* @param mixed $data The object array.
*
* @return string
*/
private static function sanitize_attributed_to( $data ): string {
if ( is_array( $data ) && self::array_is_list( $data ) ) {
$data = reset( $data );
}
return object_to_uri( $data );
}
/**
* Sanitize attachments.
*
* @param mixed $data The object array.
*
* @return ?array
*/
private static function sanitize_attachment( $data ): ?array {
if ( ! is_array( $data ) ) {
return null;
}
if ( ! self::array_is_list( $data ) ) {
$data = array( $data );
}
$attachment = array();
foreach ( $data as $item ) {
$sanitized_item = array();
// Straightforward sanitization of all attributes we possible make use of.
if ( isset( $item['name'] ) ) {
$sanitized_item['name'] = \sanitize_text_field( $item['name'] );
}
if ( isset( $item['url'] ) ) {
$sanitized_item['url'] = \sanitize_url( $item['url'] );
}
if ( isset( $item['id'] ) ) {
$sanitized_item['id'] = \sanitize_url( $item['id'] );
}
if ( isset( $item['type'] ) ) {
$sanitized_item['type'] = \sanitize_text_field( $item['type'] );
}
if ( isset( $item['href'] ) ) {
$sanitized_item['href'] = \sanitize_text_field( $item['href'] );
}
if ( isset( $sanitized_item['url'] ) || isset( $sanitized_item['href'] ) || isset( $sanitized_item['name'] ) ) {
$attachment[] = $sanitized_item;
}
}
return $attachment;
}
/**
* Fallback for PHP version prior to 8.1 for array_is_list.
*
* @param array $arr The array to check.
* @return bool
*/
private static function array_is_list( $arr ) {
if ( ! function_exists( 'array_is_list' ) ) {
if ( array() === $arr ) {
return true;
}
return array_keys( $arr ) === range( 0, count( $arr ) - 1 );
}
return array_is_list( $arr );
}
/**
* Convert input array to an Location.
*
* @param mixed $data The object array.
*
* @return ?Place An Object built from the input array or null.
*/
private static function sanitize_place_object_from_array( $data ): ?Place {
if ( ! is_array( $data ) ) {
return null;
}
// If the array is a list, work with the first item.
if ( array_key_exists( 0, $data ) ) {
$data = $data[0];
}
$place = new Place();
if ( isset( $data['name'] ) ) {
$place->set_name( \sanitize_text_field( $data['name'] ) );
}
if ( isset( $data['id'] ) ) {
$place->set_id( \sanitize_url( $data['id'] ) );
}
if ( isset( $data['url'] ) ) {
$place->set_url( \sanitize_url( $data['url'] ) );
}
if ( isset( $data['address'] ) ) {
if ( is_string( $data['address'] ) ) {
$place->set_address( \sanitize_text_field( $data['address'] ) );
}
if ( is_array( $data['address'] ) && isset( $data['address']['type'] ) && 'PostalAddress' === $data['address']['type'] ) {
$address = array();
if ( isset( $data['address']['streetAddress'] ) ) {
$address['streetAddress'] = \sanitize_text_field( $data['address']['streetAddress'] );
}
if ( isset( $data['address']['postalCode'] ) ) {
$address['postalCode'] = \sanitize_text_field( $data['address']['postalCode'] );
}
if ( isset( $data['address']['addressLocality'] ) ) {
$address['addressLocality'] = \sanitize_text_field( $data['address']['addressLocality'] );
}
if ( isset( $data['address']['addressState'] ) ) {
$address['addressState'] = \sanitize_text_field( $data['address']['addressState'] );
}
if ( isset( $data['address']['addressCountry'] ) ) {
$address['addressCountry'] = \sanitize_text_field( $data['address']['addressCountry'] );
}
if ( isset( $data['address']['url'] ) ) {
$address['url'] = \sanitize_url( $data['address']['url'] );
}
$place->set_address( $address );
}
}
return $place;
}
}

View File

@ -0,0 +1,43 @@
<?php
/**
* Extending the Tribe Events API to allow setting of the guid.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transmogrifier\Helper;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
/**
* Extending the Tribe Events API to allow setting of the guid.
*
* @since 1.0.0
*/
class The_Events_Calendar_Event_Repository extends \Tribe__Events__Repositories__Event {
/**
* Override diff: allow setting of guid.
*
* @var array An array of keys that cannot be updated on this repository.
*/
protected static $blocked_keys = array(
'ID',
'post_type',
'comment_count',
);
/**
* Whether the current key can be updated by this repository or not.
*
* @since 4.7.19
*
* @param string $key The key.
* @return bool
*/
protected function can_be_updated( $key ): bool {
return ! in_array( $key, self::$blocked_keys, true );
}
}

View File

@ -0,0 +1,43 @@
<?php
/**
* Extending the Organizer Venue API to allow setting of the guid.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transmogrifier\Helper;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
/**
* Extending the Organizer Venue API to allow setting of the guid.
*
* @since 1.0.0
*/
class The_Events_Calendar_Organizer_Repository extends \Tribe__Events__Repositories__Organizer {
/**
* Override diff: allow setting of guid.
*
* @var array An array of keys that cannot be updated on this repository.
*/
protected static $blocked_keys = array(
'ID',
'post_type',
'comment_count',
);
/**
* Whether the current key can be updated by this repository or not.
*
* @since 4.7.19
*
* @param string $key The key.
* @return bool
*/
protected function can_be_updated( $key ): bool {
return ! in_array( $key, self::$blocked_keys, true );
}
}

View File

@ -0,0 +1,43 @@
<?php
/**
* Extending the Tribe Venue API to allow setting of the guid.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\ActivityPub\Transmogrifier\Helper;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
/**
* Extending the Tribe Venue API to allow setting of the guid.
*
* @since 1.0.0
*/
class The_Events_Calendar_Venue_Repository extends \Tribe__Events__Repositories__Venue {
/**
* Override diff: allow setting of guid.
*
* @var array An array of keys that cannot be updated on this repository.
*/
protected static $blocked_keys = array(
'ID',
'post_type',
'comment_count',
);
/**
* Whether the current key can be updated by this repository or not.
*
* @since 4.7.19
*
* @param string $key The key.
* @return bool
*/
protected function can_be_updated( $key ): bool {
return ! in_array( $key, self::$blocked_keys, true );
}
}

View File

@ -0,0 +1,103 @@
<?php
/**
* Class responsible for Event Plugin related admin notices.
*
* Notices for guiding to proper configuration of ActivityPub with event plugins.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\Admin;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\Integrations\Event_Plugin_Integration;
/**
* Class responsible for Event Plugin related admin notices.
*
* Notices for guiding to proper configuration of ActivityPub with event plugins.
*
* @since 1.0.0
*/
class Event_Plugin_Admin_Notices {
/**
* Information about the event plugin.
*
* @var Event_Plugin_Integration
*/
protected $event_plugin;
/**
* Adds admin notices to an active supported event plugin.
*
* @param Event_Plugin_Integration $event_plugin Class that has implements functions to handle a certain supported activate event plugin.
*/
public function __construct( $event_plugin ) {
$this->event_plugin = $event_plugin;
if ( $this->event_post_type_is_not_activitypub_enabled() ) {
add_action( 'admin_notices', array( $this, 'admin_notice_activitypub_not_enabled_for_post_type' ), 10, 1 );
}
}
/**
* Check if ActivityPub is enabled for the custom post type of the event plugin.
*
* @return bool
*/
private function event_post_type_is_not_activitypub_enabled(): bool {
return ! in_array( $this->event_plugin::get_post_type(), get_option( 'activitypub_support_post_types', array() ), true );
}
/**
* Display the admin notices for the plugins.
*
* @return void
*/
public function admin_notice_activitypub_not_enabled_for_post_type(): void {
if ( $this->event_plugin::is_plugin_page() ) {
$this->do_admin_notice_post_type_not_activitypub_enabled();
}
}
/**
* Print admin notice that the current post type is not enabled in the ActivityPub plugin.
*
* @return void
*/
private function do_admin_notice_post_type_not_activitypub_enabled(): void {
$all_plugins = get_plugins();
$event_plugin_file = $this->event_plugin::get_relative_plugin_file();
if ( isset( $all_plugins[ $event_plugin_file ]['Name'] ) ) {
$event_plugin_name = $all_plugins[ $event_plugin_file ]['Name'];
} elseif ( isset( get_mu_plugins()[ $event_plugin_file ]['Name'] ) ) {
$event_plugin_name = get_mu_plugins()[ $event_plugin_file ]['Name'];
} else {
return;
}
$activitypub_plugin_data = get_plugin_data( ACTIVITYPUB_PLUGIN_FILE );
$notice = sprintf(
/* translators: 1: the name of the event plugin a admin notice is shown. 2: The name of the ActivityPub plugin. */
_x(
'You have installed the <i>%1$s</i> plugin, but the event post type of the plugin <i>%2$s</i> is <b>not enabled</b> in the <a href="%3$s">%1$s settings</a>.',
'admin notice',
'event-bridge-for-activitypub'
),
esc_html( $activitypub_plugin_data['Name'] ),
esc_html( $event_plugin_name ),
admin_url( 'options-general.php?page=activitypub&tab=settings' )
);
$allowed_html = array(
'a' => array(
'href' => true,
'title' => true,
),
'b' => array(),
'i' => array(),
);
echo '<div class="notice notice-warning is-dismissible"><p>' . \wp_kses( $notice, $allowed_html ) . '</p></div>';
}
}

View File

@ -0,0 +1,152 @@
<?php
/**
* Class responsible for general admin notices.
*
* Notices for guiding to proper configuration of this plugin.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\Admin;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
/**
* Class responsible for general admin notices.
*
* Notices for guiding to proper configuration of this plugin.
* - ActivityPub plugin not installed and activated
* - No supported Event Plugin installed and activated
*
* @since 1.0.0
*/
class General_Admin_Notices {
/**
* URL of the ActivityPub plugin. Needed when the ActivityPub plugin is not installed.
*/
const ACTIVITYPUB_PLUGIN_URL = 'https://wordpress.org/plugins/activitypub';
const EVENT_BRIDGE_FOR_ACTIVITYPUB_SUPPORTED_EVENT_PLUGINS_URL = 'https://wordpress.org/plugins/event-bridge-for-activitypub/#installation';
/**
* Allowed HTML for admin notices.
*
* @var array
*/
const ALLOWED_HTML = array(
'a' => array(
'href' => true,
'title' => true,
'target' => true,
),
'br',
'i',
);
/**
* Admin notice when the ActivityPub plugin is not enabled.
*
* @return string
*/
public static function get_admin_notice_activitypub_plugin_not_enabled(): string {
return sprintf(
/* translators: 1: An URL that points to the ActivityPub plugin. */
_x(
'For the Event Bridge for ActivityPub to work, you will need to install and activate the <a href="%1$s">ActivityPub</a> plugin.',
'admin notice',
'event-bridge-for-activitypub'
),
esc_html( self::ACTIVITYPUB_PLUGIN_URL )
);
}
/**
* Admin notice when the ActivityPub plugin version is too old.
*
* @return string
*/
public static function get_admin_notice_activitypub_plugin_version_too_old(): string {
return sprintf(
/* translators: 1: The name of the ActivityPub plugin. 2: The minimum required version number of the ActivityPub plugin. */
_x(
'Please upgrade your <a href="%1$s">ActivityPub</a> plugin. At least version %2$s is required for the Event Bridge for ActivityPub to work.',
'admin notice',
'event-bridge-for-activitypub'
),
esc_html( self::ACTIVITYPUB_PLUGIN_URL ),
esc_html( EVENT_BRIDGE_FOR_ACTIVITYPUB_ACTIVITYPUB_PLUGIN_MIN_VERSION )
);
}
/**
* Warning that no supported event plugin can be found.
*
* @return string
*/
public static function get_admin_notice_no_supported_event_plugin_active(): string {
return sprintf(
/* translators: 1: An URL to the list of supported event plugins. */
_x(
'The Plugin <i>Event Bridge for ActivityPub</i> is of no use, because you do not have installed and activated a supported Event Plugin.
<br> For a list of supported Event Plugins see <a href="%1$s" target="_blank">here</a>.',
'admin notice',
'event-bridge-for-activitypub'
),
esc_url( self::EVENT_BRIDGE_FOR_ACTIVITYPUB_SUPPORTED_EVENT_PLUGINS_URL )
);
}
/**
* Warning to fix status issues first.
*
* @return string
*/
public static function get_admin_notice_status_not_ok(): string {
return sprintf(
/* translators: 1: An URL to the list of supported event plugins. */
_x(
'The Plugin <i>Event Bridge for ActivityPub</i> is of no use, because you do not have installed and activated a supported Event Plugin.
<br> For a list of supported Event Plugins see <a href="%1$s">here</a>.',
'admin notice',
'event-bridge-for-activitypub'
),
esc_html( self::EVENT_BRIDGE_FOR_ACTIVITYPUB_SUPPORTED_EVENT_PLUGINS_URL )
);
}
/**
* Warning if the plugin is Active and the ActivityPub plugin is not.
*
* @return void
*/
public static function activitypub_plugin_not_enabled(): void {
$notice = self::get_admin_notice_activitypub_plugin_not_enabled();
// @phpstan-ignore-next-line
echo '<div class="notice notice-warning"><p>' . \wp_kses( $notice, self::ALLOWED_HTML ) . '</p></div>';
}
/**
* Warning if the plugin is Active and the ActivityPub plugins version is too old.
*
* @return void
*/
public static function activitypub_plugin_version_too_old(): void {
$notice = self::get_admin_notice_activitypub_plugin_version_too_old();
// @phpstan-ignore-next-line
echo '<div class="notice notice-warning"><p>' . \wp_kses( $notice, self::ALLOWED_HTML ) . '</p></div>';
}
/**
* Warning when no supported Even Plugin is installed and active.
*
* @return void
*/
public static function no_supported_event_plugin_active(): void {
$notice = self::get_admin_notice_no_supported_event_plugin_active();
// @phpstan-ignore-next-line
echo '<div class="notice notice-warning"><p>' . \wp_kses( $notice, self::ALLOWED_HTML ) . '</p></div>';
}
}

View File

@ -0,0 +1,211 @@
<?php
/**
* Health_Check class.
*
* @package Event_Bridge_For_ActivityPub
*/
namespace Event_Bridge_For_ActivityPub\Admin;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Transformer\Factory as Transformer_Factory;
use Event_Bridge_For_ActivityPub\Integrations\Event_Plugin_Integration;
use Event_Bridge_For_ActivityPub\Setup;
use WP_Post;
use WP_Query;
/**
* ActivityPub Health_Check Class.
*/
class Health_Check {
/**
* Initialize health checks.
*/
public static function init() {
\add_filter( 'site_status_tests', array( self::class, 'add_tests' ) );
\add_filter( 'debug_information', array( self::class, 'add_debug_information' ) );
}
/**
* Add tests to the Site Health Check.
*
* @param array $tests The test array.
*
* @return array The filtered test array.
*/
public static function add_tests( $tests ): array {
$tests['direct']['event_bridge_for_activitypub_test'] = array(
'label' => __( 'ActivityPub Event Transformer Test', 'event-bridge-for-activitypub' ),
'test' => array( self::class, 'test_event_transformation' ),
);
return $tests;
}
/**
* The the transformation of the most recent event posts.
*
* @return array
*/
public static function test_event_transformation(): array {
$result = array(
'label' => \__( 'Transformation of Events to a valid ActivityStreams representation.', 'event-bridge-for-activitypub' ),
'status' => 'good',
'badge' => array(
'label' => \__( 'Event Bridge for ActivityPub', 'event-bridge-for-activitypub' ),
'color' => 'green',
),
'description' => \sprintf(
'<p>%s</p>',
\__( 'The transformation to ActivityPub of your most recent events was successful.', 'event-bridge-for-activitypub' )
),
'actions' => '',
'test' => 'test_event_transformation',
);
$check = self::transform_most_recent_event_posts();
if ( true === $check ) {
return $result;
}
$result['status'] = 'critical';
$result['label'] = \__( 'One or more of your most recent events failed to transform to ActivityPub', 'event-bridge-for-activitypub' );
$result['badge']['color'] = 'red';
$result['description'] = \sprintf(
'<p>%s</p>',
\__( 'The transformation to ActivityPub of your most recent events was not successful.', 'event-bridge-for-activitypub' )
);
return $result;
}
/**
* Test if right transformer gets applied.
*
* @param Event_Plugin_Integration $event_plugin The event plugin definition.
*
* @return bool True if the check passed.
*/
public static function test_if_event_transformer_is_used( $event_plugin ): bool {
if ( ! Setup::get_instance()->is_activitypub_plugin_active() ) {
return false;
}
// Get a (random) event post.
$event_posts = self::get_most_recent_event_posts( $event_plugin->get_post_type(), 1 );
// If no post is found, we can not do this test.
if ( isset( $event_posts[0] ) || empty( $event_posts ) ) {
return true;
}
// Call the transformer Factory.
$transformer = Transformer_Factory::get_transformer( \get_post( $event_posts[0] ) );
// Check that we got the right transformer.
$desired_transformer_class = $event_plugin::get_activitypub_event_transformer( $event_posts[0] );
if ( $transformer instanceof $desired_transformer_class ) {
return true;
}
return false;
}
/**
* Retrieves the most recently published event posts of a certain event post type.
*
* @param ?string $event_post_type The post type of the events.
* @param ?int $number_of_posts The maximum number of events to return.
*
* @return \WP_Post[] Array of event posts, or false if none are found.
*/
public static function get_most_recent_event_posts( $event_post_type = null, $number_of_posts = 5 ): array {
if ( ! Setup::get_instance()->is_activitypub_plugin_active() ) {
return array();
}
if ( ! $event_post_type ) {
$active_event_plugins = Setup::get_instance()->get_active_event_plugins();
$active_event_plugin = reset( $active_event_plugins );
if ( ! $active_event_plugin ) {
return array();
}
$event_post_type = $active_event_plugin->get_post_type();
}
$args = array(
'numberposts' => $number_of_posts,
'category' => 0,
'orderby' => 'date',
'order' => 'DESC',
'include' => array(),
'exclude' => array(),
'meta_query' => array(
'relation' => 'OR',
array(
'key' => '_event_bridge_for_activitypub_event_source',
'compare' => 'NOT EXISTS',
),
array(
'key' => '_event_bridge_for_activitypub_event_source',
'value' => '',
'compare' => '=',
),
),
'post_type' => $event_post_type,
'suppress_filters' => true,
);
$query = new WP_Query();
return $query->query( $args );
}
/**
* Transform the most recent event posts.
*/
public static function transform_most_recent_event_posts(): bool {
return true;
}
/**
* Retrieves information like name and version from active event plugins.
*/
private static function get_info_about_active_event_plugins(): array {
$active_event_plugins = Setup::get_instance()->get_active_event_plugins();
$info = array();
foreach ( $active_event_plugins as $active_event_plugin ) {
$event_plugin_file = $active_event_plugin->get_relative_plugin_file();
$event_plugin_data = \get_plugin_data( $event_plugin_file );
$info[] = array(
'event_plugin_name' => $event_plugin_data['Name'],
'event_plugin_version' => $event_plugin_data['Version'],
'event_plugin_file' => $event_plugin_file,
);
}
return $info;
}
/**
* Static function for generating site debug data when required.
*
* @param array $info The debug information to be added to the core information page.
* @return array The extended information.
*/
public static function add_debug_information( $info ): array {
$info['event_bridge_for_activitypub'] = array(
'label' => __( 'Event Bridge for ActivityPub', 'event-bridge-for-activitypub' ),
'fields' => array(
'plugin_version' => array(
'label' => __( 'Plugin Version', 'event-bridge-for-activitypub' ),
'value' => EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_VERSION,
'private' => true,
),
'active_event_plugins' => self::get_info_about_active_event_plugins(),
),
);
return $info;
}
}

View File

@ -0,0 +1,280 @@
<?php
/**
* General settings class.
*
* This file contains the General class definition, which handles the "General" settings
* page for the Event Bridge for ActivityPub Plugin, providing options for configuring various general settings.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
*/
namespace Event_Bridge_For_ActivityPub\Admin;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Webfinger;
use Event_Bridge_For_ActivityPub\ActivityPub\Model\Event_Source;
use Event_Bridge_For_ActivityPub\Event_Sources;
use Event_Bridge_For_ActivityPub\ActivityPub\Collection\Event_Sources as Event_Source_Collection;
use Event_Bridge_For_ActivityPub\Integrations\Event_Plugin_Integration;
use Event_Bridge_For_ActivityPub\Integrations\Feature_Event_Sources;
use Event_Bridge_For_ActivityPub\Setup;
/**
* Class responsible for the Event Bridge for ActivityPub related Settings.
*
* Class which handles the "General" settings page for the Event Bridge for ActivityPub Plugin,
* providing options for configuring various general settings.
*
* @since 1.0.0
*/
class Settings_Page {
const STATIC = 'Event_Bridge_For_ActivityPub\Admin\Settings_Page';
const SETTINGS_SLUG = 'event-bridge-for-activitypub';
/**
* Init settings pages.
*
* @return void
*/
public static function init() {
\add_filter( 'activitypub_admin_settings_tabs', array( self::class, 'add_settings_tab' ) );
\add_action(
'admin_init',
array( self::class, 'maybe_add_event_source' ),
);
}
/**
* Adds a custom tab to the ActivityPub settings.
*
* @param array $tabs The existing tabs array.
* @return array The modified tabs array.
*/
public static function add_settings_tab( $tabs ): array {
$tabs['event-bridge-for-activitypub'] = array(
'label' => __( 'Event Bridge', 'event-bridge-for-activitypub' ),
'template' => EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_DIR . 'templates/settings/tab.php',
);
return $tabs;
}
/**
* Checks whether the current request wants to add an event source (ActivityPub follow) and passed on to actual handler.
*
* @return void
*/
public static function maybe_add_event_source() {
if ( ! isset( $_POST['event_bridge_for_activitypub_add_event_source'] ) ) {
return;
}
// Check and verify request and check capabilities.
if ( ! \wp_verify_nonce( sanitize_key( $_REQUEST['_wpnonce'] ), 'event-bridge-for-activitypub_add-event-source-options' ) ) {
return;
}
if ( ! \current_user_can( 'manage_options' ) ) {
return;
}
$event_source = \sanitize_text_field( $_POST['event_bridge_for_activitypub_add_event_source'] );
$actor_url = false;
$url = \wp_parse_url( $event_source );
$error_message = \esc_html__( 'Failed to add Event Source', 'event-bridge-for-activitypub' );
// Check if URL is a Collection or a single Actor.
$maybe_collection = \wp_safe_remote_get( $event_source );
if ( ! \is_wp_error( $maybe_collection ) ) {
$maybe_collection = \json_decode( \wp_remote_retrieve_body( $maybe_collection ), true );
}
$event_sources = array();
if ( isset( $maybe_collection['type'] ) && in_array( $maybe_collection['type'], array( 'Collection', 'OrderedCollection' ), true ) ) {
// Return only the IDs of the items in the collection.
$event_sources = \wp_list_pluck( $maybe_collection['items'], 'id' );
} else {
$event_sources[] = $event_source;
}
// Iterate over all event sources and add them to the collection.
foreach ( $event_sources as $event_source ) {
$url = \wp_parse_url( $event_source );
if ( isset( $url['path'], $url['host'], $url['scheme'] ) ) {
$actor_url = \sanitize_url( $event_source );
} elseif ( preg_match( '/^@?' . Event_Source::ACTIVITYPUB_USER_HANDLE_REGEXP . '$/i', $event_source ) ) {
$actor_url = Webfinger::resolve( $event_source );
if ( \is_wp_error( $actor_url ) ) {
\add_settings_error(
'event-bridge-for-activitypub_add-event-source',
'event_bridge_for_activitypub_cannot_follow_actor',
$error_message . ': ' . esc_html__( 'Cannot find an ActivityPub actor for this user handle via Webfinger.', 'event-bridge-for-activitypub' ),
'error'
);
continue;
}
} else {
if ( ! isset( $url['path'] ) && isset( $url['host'] ) ) {
$actor_url = Event_Sources::get_application_actor( $url['host'] );
} elseif ( self::is_domain( $event_source ) ) {
$actor_url = Event_Sources::get_application_actor( $event_source );
}
if ( ! $actor_url ) {
\add_settings_error(
'event-bridge-for-activitypub_add-event-source',
'event_bridge_for_activitypub_cannot_follow_actor',
$error_message . ': ' . \esc_html__( 'Unable to identify the ActivityPub relay actor to follow for this domain.', 'event-bridge-for-activitypub' ),
'error'
);
continue;
}
}
if ( ! $actor_url ) {
\add_settings_error(
'event-bridge-for-activitypub_add-event-source',
'event_bridge_for_activitypub_cannot_follow_actor',
$error_message . ': ' . \esc_html__( 'ActivityPub actor does not exist.', 'event-bridge-for-activitypub' ),
'error'
);
continue;
}
// Don't proceed if on the same host!
if ( \wp_parse_url( \home_url(), PHP_URL_HOST ) === \wp_parse_url( $actor_url, PHP_URL_HOST ) ) {
\add_settings_error(
'event-bridge-for-activitypub_add-event-source',
'event_bridge_for_activitypub_cannot_follow_actor',
$error_message . ': ' . \esc_html__( 'Cannot follow own actor on own domain.', 'event-bridge-for-activitypub' ),
'error'
);
continue;
}
Event_Source_Collection::add_event_source( $actor_url );
}
}
/**
* Check if a string is a valid domain name.
*
* @param string $domain The input string which might be a domain.
* @return bool
*/
private static function is_domain( $domain ): bool {
$pattern = '/^(?!\-)(?:(?:[a-zA-Z\d](?:[a-zA-Z\d\-]{0,61}[a-zA-Z\d])?)\.)+(?!\d+$)[a-zA-Z\d]{2,63}$/';
return 1 === preg_match( $pattern, $domain );
}
/**
* Adds Link to the settings page in the plugin page.
* It's called via apply_filter('plugin_action_links_' . PLUGIN_NAME).
*
* @param array $links Already added links.
*
* @return array Original links but with link to setting page added.
*/
public static function settings_link( $links ): array {
$links[] = \sprintf(
'<a href="%1s">%2s</a>',
\add_query_arg( 'tab', 'event-bridge-for-activitypub', \menu_page_url( 'activitypub', false ) ),
\__( 'Settings', 'event-bridge-for-activitypub' )
);
return $links;
}
/**
* Receive the event categories (terms) used by the event plugin.
*
* @param Event_Plugin_Integration $event_plugin Contains info about a certain event plugin.
*
* @return array An array of Terms.
*/
private static function get_event_terms( $event_plugin ): array {
$taxonomy = $event_plugin::get_event_category_taxonomy();
if ( $taxonomy ) {
$event_terms = get_terms(
array(
'taxonomy' => $taxonomy,
'hide_empty' => true,
)
);
return ! is_wp_error( $event_terms ) ? $event_terms : array();
} else {
return array();
}
}
/**
* Preparing the data and loading the template for the settings page.
*
* @return void
*/
public static function do_settings_page(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $_GET['subpage'] ) ) {
$tab = 'welcome';
} else {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$tab = \sanitize_key( $_GET['subpage'] );
}
// Fallback to always re-scan active event plugins, when user visits admin area of this plugin.
$plugin_setup = Setup::get_instance();
$plugin_setup->redetect_active_event_plugins();
$event_plugins = $plugin_setup->get_active_event_plugins();
switch ( $tab ) {
case 'settings':
$event_terms = array();
foreach ( $event_plugins as $event_plugin_integration ) {
$event_terms = array_merge( $event_terms, self::get_event_terms( $event_plugin_integration ) );
}
$args = array(
'slug' => self::SETTINGS_SLUG,
'event_terms' => $event_terms,
);
\load_template( EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_DIR . 'templates/settings/subpages/settings.php', true, $args );
break;
case 'event-sources':
$supports_event_sources = array();
foreach ( $event_plugins as $event_plugin_integration ) {
if ( is_a( $event_plugin_integration, Feature_Event_Sources::class ) ) {
$class_name = get_class( $event_plugin_integration );
$supports_event_sources[ $class_name ] = $event_plugin_integration::get_plugin_name();
}
}
$args = array(
'supports_event_sources' => $supports_event_sources,
);
\wp_enqueue_script( 'thickbox' );
\wp_enqueue_style( 'thickbox' );
\load_template( EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_DIR . 'templates/settings/subpages/event-sources.php', true, $args );
break;
case 'welcome':
default:
\wp_enqueue_script( 'plugin-install' );
\add_thickbox();
\wp_enqueue_script( 'updates' );
\load_template( EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_DIR . 'templates/settings/subpages/welcome.php', true );
break;
}
}
}

View File

@ -0,0 +1,100 @@
<?php
/**
* Class responsible for User Interface additions in the Admin UI.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\Admin;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\ActivityPub\Model\Event_Source;
use Event_Bridge_For_ActivityPub\ActivityPub\Collection\Event_Sources as Event_Sources_Collection;
use Event_Bridge_For_ActivityPub\Event_Sources;
/**
* Class responsible for Event Plugin related admin notices.
*
* Notices for guiding to proper configuration of ActivityPub with event plugins.
*
* @since 1.0.0
*/
class User_Interface {
/**
* Init.
*/
public static function init() {
\add_filter( 'page_row_actions', array( self::class, 'row_actions' ), 10, 2 );
\add_filter( 'post_row_actions', array( self::class, 'row_actions' ), 10, 2 );
\add_filter( 'map_meta_cap', array( self::class, 'disable_editing_for_external_events' ), 10, 4 );
}
/**
* Add an column that shows the origin of an external event.
*
* @param array $columns The current columns.
* @return array
*/
public static function add_origin_column( $columns ) {
// Add a new column after the title column.
$columns['activitypub_origin'] = __( 'ActivityPub origin', 'event-bridge-for-activitypub' );
return $columns;
}
/**
* Add a "⁂ Preview" link to the row actions.
*
* @param array $actions The existing actions.
* @param \WP_Post $post The post object.
*
* @return array The modified actions.
*/
public static function row_actions( $actions, $post ): array {
// check if the post is enabled for ActivityPub.
if ( ! Event_Sources::is_cached_external_post( $post ) ) {
return $actions;
}
$url = $post->guid;
$parent = get_post_parent();
if ( $parent && Event_Sources_Collection::POST_TYPE === $parent->post_type ) {
$url = \get_post_meta( $parent->ID, '_activitypub_actor_id', true );
}
$actions['view_origin'] = sprintf(
'<a href="%s" target="_blank">⁂ %s</a>',
\esc_url( $url ),
\esc_html__( 'Open original page', 'event-bridge-for-activitypub' )
);
return $actions;
}
/**
* Modify the user capabilities so that nobody can edit external events.
*
* @param array $caps Concerned user's capabilities.
* @param mixed $cap Required primitive capabilities for the requested capability.
* @param array $user_id The WordPress user ID.
* @param array $args Additional args.
*
* @return array
*/
public static function disable_editing_for_external_events( $caps, $cap, $user_id, $args ) {
if ( 'edit_post' === $cap && isset( $args[0] ) ) {
$post_id = $args[0];
$post = get_post( $post_id );
if ( $post && Event_Sources::is_cached_external_post( $post ) ) {
// Deny editing by returning 'do_not_allow'.
return array( 'do_not_allow' );
}
}
return $caps;
}
}

View File

@ -0,0 +1,67 @@
<?php
/**
* Class responsible for autoloading Event Bridge for ActivityPub class files.
*
* The Autoloader class is responsible for automatically loading class files as needed
* to ensure a clean and organized codebase. It maps class names to their corresponding
* file locations within the GatherPress plugin.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
/**
* Class Autoloader.
*
* This class is responsible for automatic loading of classes and namespaces.
*
* @since 1.0.0
*/
class Autoloader {
/**
* Register method for autoloader.
*
* @since 1.0.0
*
* @return void
*/
public static function register(): void {
spl_autoload_register(
function ( $full_class ) {
$base_dir = EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_DIR . '/includes/';
$base = 'Event_Bridge_For_ActivityPub\\';
if ( strncmp( $full_class, $base, strlen( $base ) ) === 0 ) {
$maybe_uppercase = str_replace( $base, '', $full_class );
$class = strtolower( $maybe_uppercase );
// All classes should be capitalized. If this is instead looking for a lowercase method, we ignore that.
if ( $maybe_uppercase === $class ) {
return;
}
if ( false !== strpos( $class, '\\' ) ) {
$parts = explode( '\\', $class );
$class = array_pop( $parts );
$sub_dir = strtr( implode( '/', $parts ), '_', '-' );
$base_dir = $base_dir . $sub_dir . '/';
}
$filename = 'class-' . strtr( $class, '_', '-' );
$file = $base_dir . $filename . '.php';
if ( file_exists( $file ) && is_readable( $file ) ) {
require_once $file;
} else {
\wp_die( sprintf( esc_html( 'Required class not found or not readable: %s' ), esc_html( $full_class ) ) );
}
}
}
);
}
}

View File

@ -0,0 +1,41 @@
<?php
/**
* Class file for Debug Class.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
/**
* Debug Class.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
class Debug {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
if ( defined( 'WP_DEBUG_LOG' ) && constant( 'WP_DEBUG_LOG' ) ) {
\add_action( 'event_bridge_for_activitypub_write_log', array( self::class, 'write_log' ), 10, 1 );
}
}
/**
* Write a log entry.
*
* @param mixed $log The log entry.
*/
public static function write_log( $log ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions
\error_log( \print_r( $log, true ) );
}
}

View File

@ -0,0 +1,514 @@
<?php
/**
* Class for handling and saving the ActivityPub event sources (i.e. follows).
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Model\Blog;
use DateTime;
use DateTimeZone;
use Event_Bridge_For_ActivityPub\ActivityPub\Collection\Event_Sources as Event_Sources_Collection;
use Event_Bridge_For_ActivityPub\ActivityPub\Handler;
use Event_Bridge_For_ActivityPub\Admin\User_Interface;
use Event_Bridge_For_ActivityPub\Integrations\Feature_Event_Sources;
use WP_Error;
use WP_Post;
use WP_REST_Request;
use function Activitypub\is_activitypub_request;
/**
* Class for handling and saving the ActivityPub event sources (i.e. follows).
*
* @package Event_Bridge_For_ActivityPub
*/
class Event_Sources {
/**
* Init.
*/
public static function init() {
// Register the Event Sources Collection which takes care of managing the event sources.
\add_action( 'init', array( Event_Sources_Collection::class, 'init' ) );
// Allow wp_safe_redirect to all followed event sources hosts.
\add_filter( 'allowed_redirect_hosts', array( self::class, 'add_event_sources_hosts_to_allowed_redirect_hosts' ) );
// Register handlers for incoming activities to the ActivityPub plugin, e.g. incoming `Event` objects.
\add_action( 'activitypub_register_handlers', array( Handler::class, 'register_handlers' ) );
// Add validation filter, so that only plausible activities reach the handlers above.
\add_filter(
'activitypub_validate_object',
array( self::class, 'validate_event_object' ),
12,
3
);
\add_filter(
'activitypub_validate_object',
array( self::class, 'validate_activity' ),
13,
3
);
// Apply modifications to the UI, e.g. disable editing of remote event posts.
\add_action( 'init', array( User_Interface::class, 'init' ) );
// Register post meta to the event plugins post types needed for easier handling of this feature.
\add_action( 'init', array( self::class, 'register_post_meta' ) );
// Register filters that prevent cached remote events from being federated again.
\add_filter( 'activitypub_is_post_disabled', array( self::class, 'is_post_disabled_for_activitypub' ), 99, 2 );
\add_filter( 'template_include', array( self::class, 'redirect_activitypub_requests_for_cached_external_events' ), 100 );
// Register daily schedule to cleanup cached remote events that have ended.
\add_action( 'event_bridge_for_activitypub_event_sources_clear_cache', array( self::class, 'clear_cache' ) );
if ( ! \wp_next_scheduled( 'event_bridge_for_activitypub_event_sources_clear_cache' ) ) {
\wp_schedule_event( time(), 'daily', 'event_bridge_for_activitypub_event_sources_clear_cache' );
}
// Add the actors followed by the event sources feature to the `follow` collection of the used ActivityPub actor.
\add_filter( 'activitypub_rest_following', array( self::class, 'add_event_sources_to_follow_collection' ), 10, 2 );
// Add action for backfilling the events.
Outbox_Parser::init();
}
/**
* Register post meta.
*
* @return void
*/
public static function register_post_meta() {
$setup = Setup::get_instance();
foreach ( $setup->get_active_event_plugins() as $event_plugin_integration ) {
if ( ! is_a( $event_plugin_integration, Feature_Event_Sources::class ) ) {
continue;
}
$post_type = $event_plugin_integration::get_post_type();
self::register_post_meta_event_bridge_for_activitypub_event_source( $post_type );
$post_type = $event_plugin_integration::get_place_post_type();
if ( $post_type ) {
self::register_post_meta_event_bridge_for_activitypub_event_source( $post_type );
}
$post_type = $event_plugin_integration::get_organizer_post_type();
if ( $post_type ) {
self::register_post_meta_event_bridge_for_activitypub_event_source( $post_type );
}
}
}
/**
* Register post meta _event_bridge_for_activitypub_event_source for a given post type.
*
* @param string $post_type The post type to register the meta for.
* @return void
*/
private static function register_post_meta_event_bridge_for_activitypub_event_source( $post_type ) {
\register_post_meta(
$post_type,
'_event_bridge_for_activitypub_event_source',
array(
'type' => 'integer',
'single' => true,
'sanitize_callback' => 'absint',
)
);
}
/**
* Get the Application actor via FEP-2677.
*
* @param string $domain The domain without scheme.
* @return bool|string The URL/ID of the application actor, false if not found.
*/
public static function get_application_actor( $domain ) {
$result = wp_remote_get( 'https://' . $domain . '/.well-known/nodeinfo' );
if ( is_wp_error( $result ) ) {
return false;
}
$body = wp_remote_retrieve_body( $result );
$nodeinfo = json_decode( $body, true );
// Check if 'links' exists and is an array.
if ( isset( $nodeinfo['links'] ) && is_array( $nodeinfo['links'] ) ) {
foreach ( $nodeinfo['links'] as $link ) {
// Check if this link matches the application actor rel.
if ( isset( $link['rel'] ) && 'https://www.w3.org/ns/activitystreams#Application' === $link['rel'] ) {
if ( is_string( $link['href'] ) ) {
return $link['href'];
}
break;
}
}
}
// Return false if no application actor is found.
return false;
}
/**
* Filter that cached external posts are not scheduled via the ActivityPub plugin.
*
* Posts that are actually just external events are treated as cache. They are displayed in
* the frontend HTML view and redirected via ActivityPub request, but we do not own them.
*
* @param bool $disabled If it is disabled already by others (the upstream ActivityPub plugin).
* @param WP_Post $post The WordPress post object.
* @return bool False if the post is not disabled for federation via ActivityPub.
*/
public static function is_post_disabled_for_activitypub( $disabled, $post = null ): bool {
if ( $disabled ) {
return $disabled;
}
return self::is_cached_external_post( $post );
}
/**
* Determine whether a WP post is a cached external event.
*
* @param WP_Post|int $post The WordPress post object or post ID.
* @return bool
*/
public static function is_cached_external_post( $post ): bool {
$post_id = $post instanceof WP_Post ? $post->ID : $post;
if ( \get_post_meta( $post_id, '_event_bridge_for_activitypub_event_source', true ) ) {
return true;
}
return false;
}
/**
* Add the ActivityPub template for EventPrime.
*
* @param string $template The path to the template object.
* @return string The new path to the JSON template.
*/
public static function redirect_activitypub_requests_for_cached_external_events( $template ) {
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
return $template;
}
if ( ! is_activitypub_request() ) {
return $template;
}
if ( ! \is_singular() ) {
return $template;
}
$post = \get_post( \get_queried_object_id() );
if ( self::is_cached_external_post( $post ) ) {
\wp_safe_redirect( $post->guid, 301 );
exit;
}
return $template;
}
/**
* Delete old cached events that took place in the past.
*/
public static function clear_cache() {
// Get the event plugin integration that is used.
$event_plugin_integration = Setup::get_event_plugin_integration_used_for_event_sources_feature();
if ( ! $event_plugin_integration ) {
return;
}
$cache_retention_period = get_option( 'event_bridge_for_activitypub_event_source_cache_retention', WEEK_IN_SECONDS );
$ended_before_time = gmdate( 'Y-m-d H:i:s', time() - $cache_retention_period );
$past_event_ids = $event_plugin_integration::get_cached_remote_events( $ended_before_time );
foreach ( $past_event_ids as $post_id ) {
if ( has_post_thumbnail( $post_id ) ) {
$attachment_id = get_post_thumbnail_id( $post_id );
wp_delete_attachment( $attachment_id, true );
}
wp_delete_post( $post_id, true );
}
}
/**
* Add the Blog Authors to the following list of the Blog Actor
* if Blog not in single mode.
*
* @param array $follow_list The array of following urls.
* @param mixed $user The user object, a subtype of \Activitypub\Model\User.
*
* @return array The array of following urls.
*/
public static function add_event_sources_to_follow_collection( $follow_list, $user ): array {
if ( ! $user instanceof Blog ) {
return $follow_list;
}
$event_sources_activitypub_ids = array_values( Event_Sources_Collection::get_event_sources() );
return array_merge( $follow_list, $event_sources_activitypub_ids );
}
/**
* Get an array will all unique hosts of all Event-Sources.
*
* @return array A list with all unique hosts of all Event Sources' ActivityPub IDs.
*/
public static function get_event_sources_hosts() {
$hosts = get_transient( 'event_bridge_for_activitypub_event_sources_hosts' );
if ( $hosts ) {
return $hosts;
}
$event_sources = Event_Sources_Collection::get_event_sources();
$hosts = array();
foreach ( $event_sources as $actor ) {
$url = wp_parse_url( $actor );
if ( isset( $url['host'] ) ) {
$hosts[] = $url['host'];
}
}
$hosts = array_unique( $hosts );
set_transient( 'event_bridge_for_activitypub_event_sources_hosts', $hosts );
return $hosts;
}
/**
* Add Event Sources hosts to allowed hosts used by safe redirect.
*
* @param array $hosts The hosts before the filter.
* @return array
*/
public static function add_event_sources_hosts_to_allowed_redirect_hosts( $hosts ) {
$event_sources_hosts = self::get_event_sources_hosts();
return array_merge( $hosts, $event_sources_hosts );
}
/**
* Mark incoming accept activities as valid.
*
* @param bool $valid The validation state.
* @param string $param The object parameter.
* @param WP_REST_Request $request The request object.
*
* @return bool|WP_Error The validation state: true if valid, false if not.
*/
public static function validate_activity( $valid, $param, $request ) {
if ( $valid ) {
return $valid;
}
$json_params = $request->get_json_params();
if ( isset( $json_params['object']['type'] ) && in_array( $json_params['object']['type'], array( 'Accept', 'Undo' ), true ) ) {
return true;
}
return $valid;
}
/**
* Validate the event object.
*
* @param bool $valid The validation state.
* @param string $param The object parameter.
* @param WP_REST_Request $request The request object.
*
* @return bool|WP_Error The validation state: true if valid, false if not.
*/
public static function validate_event_object( $valid, $param, $request ) {
$json_params = $request->get_json_params();
// Check if we should continue with the validation.
if ( isset( $json_params['object']['type'] ) && 'Event' === $json_params['object']['type'] ) {
$valid = true;
} else {
return $valid;
}
if ( empty( $json_params['type'] ) ) {
return false;
}
if ( empty( $json_params['actor'] ) ) {
return false;
}
if ( ! in_array( $json_params['type'], array( 'Create', 'Update', 'Delete', 'Announce' ), true ) ) {
return $valid;
}
if ( ! self::is_valid_activitypub_event_object( $json_params['object'] ) ) {
return false;
}
if ( ! self::same_host( $json_params['actor'], $json_params['id'], $json_params['object']['id'] ) ) {
return false;
}
return true;
}
/**
* Checks if all provided URLs belong to the same origin (host).
*
* @param string ...$urls List of URLs to compare.
* @return bool True if all URLs have the same host, false otherwise.
*/
public static function same_host( ...$urls ) {
if ( empty( $urls ) ) {
return false; // No URLs given, can't compare hosts.
}
$first = \wp_parse_url( array_shift( $urls ) );
if ( ! isset( $first['host'] ) ) {
return false;
}
$first_host = $first['host'];
foreach ( $urls as $url ) {
$result = \wp_parse_url( $url );
if ( ! isset( $result['host'] ) ) {
return false;
}
if ( $result['host'] !== $first_host ) {
return false;
}
}
return true;
}
/**
* Check if the object is a valid ActivityPub event.
*
* @param mixed $event_object The (event) object as an associative array.
* @return bool True if the object is an valid ActivityPub Event, false if not.
*/
public static function is_valid_activitypub_event_object( $event_object ): bool {
if ( ! is_array( $event_object ) ) {
return false;
}
$required = array(
'id',
'startTime',
'name',
);
if ( array_intersect( $required, array_keys( $event_object ) ) !== $required ) {
return false;
}
if ( ! self::is_valid_activitypub_time_string( $event_object['startTime'] ) ) {
return false;
}
if ( ! self::is_valid_activitypub_id( $event_object['id'] ) ) {
return false;
}
return true;
}
/**
* Validate an ActivityPub ID.
*
* @link https://www.w3.org/TR/activitypub/#obj-id
*
* @param string $id The ID to validate.
* @return bool
*/
public static function is_valid_activitypub_id( $id ) {
return \sanitize_url( $id ) ? true : false;
}
/**
* Validate a time string if it is according to the ActivityPub specification.
*
* @link https://www.w3.org/TR/activitystreams-core/#dates
*
* @param string $time_string The xsd:datetime string.
* @return bool
*/
public static function is_valid_activitypub_time_string( string $time_string ): bool {
// Regular expression based on AS2 rules.
return 1 === preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})$/', $time_string );
}
/**
* Check if a given DateTime is already passed.
*
* @param string|DateTime $time The ActivityPub like time string or DateTime object.
* @return bool
*/
public static function is_time_passed( $time ) {
if ( ! $time instanceof DateTime ) {
// Create a DateTime object from the ActivityPub time string.
$time = new DateTime( $time, new DateTimeZone( 'UTC' ) );
}
// Get the current time in UTC.
$current_time = new DateTime( 'now', new DateTimeZone( 'UTC' ) );
// Compare the event time with the current time.
return $time < $current_time;
}
/**
* Determine whether an Event is an ongoing or future event.
*
* @param array $event_object The ActivityPub Event as an associative array.
* @return bool
*/
public static function is_ongoing_or_future_event( $event_object ) {
if ( isset( $event_object['endTime'] ) ) {
$time = $event_object['endTime'];
} else {
$time = new DateTime( $event_object['startTime'], new DateTimeZone( 'UTC' ) );
$time->modify( '+3 hours' );
}
return ! self::is_time_passed( $time );
}
/**
* Check that an ActivityPub actor is an event source (i.e. it is followed by the ActivityPub blog actor).
*
* @param string $actor_id The actor ID.
* @return bool True if the ActivityPub actor ID is followed, false otherwise.
*/
public static function actor_is_event_source( $actor_id ) {
$event_sources = Event_Sources_Collection::get_event_sources();
if ( in_array( $actor_id, $event_sources, true ) ) {
return true;
}
return false;
}
}

View File

@ -0,0 +1,282 @@
<?php
/**
* Class file for parsing an ActivityPub outbox for Events.
*
* The main external entry function is `backfill_events`.
* The function `import_events_from_outbox` is used for delaying the parsing via schedules.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Http;
use Event_Bridge_For_ActivityPub\ActivityPub\Model\Event_Source;
use WP_Error;
use function Activitypub\object_to_uri;
/**
* Class for parsing an ActivityPub outbox for Events.
*
* The main external entry function is `backfill_events`.
* The function `import_events_from_outbox` is used for delaying the parsing via schedules.
*/
class Outbox_Parser {
/**
* Maximum number of events to backfill per actor.
*/
const MAX_EVENTS_TO_IMPORT = 20;
/**
* Init actions.
*/
public static function init(): void {
// Add action for backfilling the events.
\add_action( 'event_bridge_for_activitypub_backfill_events', array( self::class, 'backfill_events' ), 10, 1 );
\add_action( 'event_bridge_for_activitypub_import_events_from_outbox', array( self::class, 'import_events_from_outbox' ), 10, 2 );
}
/**
* Initialize the backfilling of events via the outbox of an ActivityPub actor.
*
* @param int $event_source_post_id The Post ID of Event Source we want to backfill the events for.
* @return void
*/
public static function backfill_events( $event_source_post_id ): void {
$event_source = Event_Source::get_by_id( $event_source_post_id );
if ( ! $event_source ) {
return;
}
$outbox_url = $event_source->get_outbox();
if ( ! $outbox_url ) {
return;
}
// Schedule the import of events via the outbox.
self::queue_importing_from_outbox( $outbox_url, $event_source->get__id(), 0 );
}
/**
* Import events from an outbox: OrderedCollection or OrderedCollectionPage.
*
* @param string $url The url of the current page or outbox.
* @param int $event_source_post_id The Post ID of the Event Source that owns the outbox.
* @return void
*/
public static function import_events_from_outbox( $url, $event_source_post_id ) {
$setup = Setup::get_instance();
if ( ! $setup->is_activitypub_plugin_active() ) {
return;
}
$outbox = self::fetch_outbox( $url );
if ( ! $outbox ) {
return;
}
$current_count = self::get_import_count( $event_source_post_id );
if ( $current_count >= self::MAX_EVENTS_TO_IMPORT ) {
return;
}
// Process orderedItems if they exist (non-paginated outbox).
if ( isset( $outbox['orderedItems'] ) && is_array( $outbox['orderedItems'] ) ) {
$current_count += self::import_events_from_items(
$outbox['orderedItems'],
$event_source_post_id,
self::MAX_EVENTS_TO_IMPORT - $current_count
);
}
self::update_import_count( $event_source_post_id, $current_count );
// If the count is already exceeded abort here.
if ( $current_count >= self::MAX_EVENTS_TO_IMPORT ) {
return;
}
// Get next page and if it exists schedule the import of next page.
$pagination_url = self::get_pagination_url( $outbox );
if ( $pagination_url ) {
self::queue_importing_from_outbox( $pagination_url, $event_source_post_id );
}
}
/**
* Check if an Activity is of type Update or Create.
*
* @param array $activity The Activity as associative array.
* @return bool
*/
private static function is_create_or_update_activity( $activity ) {
if ( ! isset( $activity['type'] ) ) {
return false;
}
if ( in_array( $activity['type'], array( 'Update', 'Create' ), true ) ) {
return true;
}
return false;
}
/**
* Parses items from an Collection, OrderedCollection, CollectionPage or OrderedCollectionPage.
*
* @param array $items The items as an associative array.
* @param int $max_items The maximum number of items to parse.
* @return array Parsed events from the collection.
*/
private static function parse_outbox_items_for_events( $items, $max_items ) {
$parsed_events = array();
foreach ( $items as $activity ) {
// Abort if we have exceeded the maximal events to return.
if ( $max_items > 0 && count( $parsed_events ) >= $max_items ) {
break;
}
// Check if it is a create or update Activity.
if ( ! self::is_create_or_update_activity( $activity ) ) {
continue;
}
// If no object is set we cannot process anything.
if ( ! isset( $activity['object'] ) ) {
continue;
}
// Check if the Event object meets the minimum requirements and is valid.
$is_valid_event_object = Event_Sources::is_valid_activitypub_event_object( $activity['object'] );
if ( ! $is_valid_event_object ) {
continue;
}
// Check if the event is in the future or ongoing.
if ( Event_Sources::is_ongoing_or_future_event( $activity['object'] ) ) {
$parsed_events[] = $activity['object'];
}
}
return $parsed_events;
}
/**
* Import events from the items of an outbox.
*
* @param array $items The items/orderedItems as an associative array.
* @param int $event_source_post_id The Post ID of the Event Source that owns the outbox.
* @param int $limit The limit of how many events to save locally.
* @return int The number of saved events (at least attempted).
*/
private static function import_events_from_items( $items, $event_source_post_id, $limit = -1 ): int {
$events = self::parse_outbox_items_for_events( $items, $limit );
$transmogrifier = Setup::get_transmogrifier();
if ( ! $transmogrifier ) {
return 0;
}
$imported_count = 0;
foreach ( $events as $event ) {
$transmogrifier::save( $event, $event_source_post_id );
++$imported_count;
if ( $limit > 0 && $imported_count >= $limit ) {
break;
}
}
return $imported_count;
}
/**
* Schedule the import of events from an outbox OrderedCollection or OrderedCollectionPage.
*
* @param string $url The url of the current page or outbox.
* @param int $event_source_post_id The Post ID of the Event Source that owns the outbox.
* @param int $delay The delay of the current time in seconds.
* @return bool
*/
private static function queue_importing_from_outbox( $url, $event_source_post_id, $delay = 10 ): bool {
$hook = 'event_bridge_for_activitypub_import_events_from_outbox';
$args = array( $url, $event_source_post_id );
if ( \wp_next_scheduled( $hook, $args ) ) {
return false;
}
return \wp_schedule_single_event( \time() + $delay, $hook, $args );
}
/**
* Get the current import count for the actor.
*
* @param int $event_source_post_id The Post ID of the Event Source that owns the outbox.
* @return int The current count of imported events.
*/
private static function get_import_count( $event_source_post_id ): int {
return (int) \get_post_meta( $event_source_post_id, '_event_bridge_for_activitypub_event_count', true );
}
/**
* Update the import count for an event source..
*
* @param int $event_source_post_id The Post ID of the Event Source that owns the outbox.
* @param int $count The new count of imported events.
* @return void
*/
private static function update_import_count( $event_source_post_id, $count ) {
\update_post_meta( $event_source_post_id, '_event_bridge_for_activitypub_event_count', $count );
}
/**
* Fetch the outbox from the given URL.
*
* @param string $url The URL of the outbox.
* @return array|null The decoded outbox data, or null if fetching fails.
*/
private static function fetch_outbox( $url ) {
$response = Http::get( $url );
if ( \is_wp_error( $response ) ) {
return null;
}
$outbox = \wp_remote_retrieve_body( $response );
$outbox = \json_decode( $outbox, true );
return ( is_array( $outbox ) && isset( $outbox['type'] ) && isset( $outbox['id'] ) ) ? $outbox : null;
}
/**
* Get the pagination URL from the outbox.
*
* @param array $outbox The outbox data.
* @return string|null The pagination URL, or null if not found.
*/
private static function get_pagination_url( $outbox ) {
// If we are on a collection page simply use the next key.
if ( 'OrderedCollectionPage' === $outbox['type'] && ! empty( $outbox['next'] ) && is_string( $outbox['next'] ) ) {
return $outbox['next'];
}
// If we still have the ordered collection itself.
if ( isset( $outbox['type'] ) && 'OrderedCollection' === $outbox['type'] && isset( $outbox['first'] ) ) {
return object_to_uri( $outbox['first'] );
}
return null;
}
}

View File

@ -0,0 +1,40 @@
<?php
/**
* Class file initializing the custom ActivityPub preview.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
* @since 1.0.0
*/
namespace Event_Bridge_For_ActivityPub;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
/**
* Class for initializing the custom ActivityPub preview(s).
*/
class Preview {
/**
* Init functions to hook into the ActivityPub plugin.
*/
public static function init() {
\add_filter( 'activitypub_preview_template', array( self::class, 'maybe_apply_event_preview_template' ), 10, 0 );
}
/**
* Maybe apply a custom preview template if the post type is an event post type of a supported event plugin.
*
* @return string The full path for the ActivityPub preview template to use.
*/
public static function maybe_apply_event_preview_template() {
$event_post_types = Setup::get_instance()->get_active_event_plugins_post_types();
if ( in_array( \get_post_type(), $event_post_types, true ) ) {
return EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_DIR . '/templates/event-preview.php';
}
return ACTIVITYPUB_PLUGIN_DIR . '/templates/post-preview.php';
}
}

View File

@ -0,0 +1,192 @@
<?php
/**
* Class file for Event Reminders.
*
* Automatic announcing or sending of reminders before the events start time.
*
* @package Event_Bridge_For_ActivityPub
* @license AGPL-3.0-or-later
* @since 1.0.0
*/
namespace Event_Bridge_For_ActivityPub;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Transformer\Factory as Transformer_Factory;
use Event_Bridge_For_ActivityPub\Setup;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Event as Event_Transformer;
use DateTime;
use WP_Post;
use function ActivityPub\add_to_outbox;
use function Activitypub\is_user_disabled;
/**
* Adds automatic announcing or sending of reminders before the events start time.
*/
class Reminder {
/**
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
// Post transitions.
\add_action( 'transition_post_status', array( self::class, 'maybe_schedule_event_reminder' ), 33, 3 );
\add_action( 'delete_post', array( self::class, 'unschedule_event_reminder' ), 33, 1 );
// Send an event reminder.
\add_action( 'event_bridge_for_activitypub_send_event_reminder', array( self::class, 'send_event_reminder' ), 10, 1 );
// Load the block which allows overriding the reminder time for an individual event in the post settings.
\add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) );
// Register the post-meta which stores per-event overrides of the side-wide default of the reminder time gap.
\add_action( 'init', array( self::class, 'register_postmeta' ), 11 );
}
/**
* Register post meta for controlling whether and when a reminder is scheduled for an individual event.
*/
public static function register_postmeta() {
$ap_post_types = \get_post_types_by_support( 'activitypub' );
foreach ( $ap_post_types as $post_type ) {
\register_post_meta(
$post_type,
'event_bridge_for_activitypub_reminder_time_gap',
array(
'show_in_rest' => true,
'single' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
)
);
}
}
/**
* Enqueue the block editor assets.
*/
public static function enqueue_editor_assets() {
// Check for our supported post types.
$current_screen = \get_current_screen();
$event_post_types = Setup::get_instance()->get_active_event_plugins_post_types();
if ( ! $current_screen || ! in_array( $current_screen->post_type, $event_post_types, true ) ) {
return;
}
$asset_data = include EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_DIR . 'build/reminder/plugin.asset.php';
$plugin_url = plugins_url( 'build/reminder/plugin.js', EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_FILE );
\wp_enqueue_script( 'event-bridge-for-activitypub-reminder', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
// Pass the the default site wide time gap option to the settings block on the events edit page.
\wp_localize_script(
'event-bridge-for-activitypub-reminder',
'activityPubEventBridge',
array(
'reminderTypeGap' => \get_option( 'event_bridge_for_activitypub_reminder_time_gap', 0 ),
)
);
}
/**
* Schedule Activities.
*
* @param string $new_status New post status.
* @param string $old_status Old post status.
* @param ?WP_Post $post Post object.
*/
public static function maybe_schedule_event_reminder( $new_status, $old_status, $post ): void {
if ( ! $post instanceof WP_Post ) {
return;
}
// At first always unschedule the reminder for this event, it will be added again, in case.
self::unschedule_event_reminder( $post->ID );
// Do not set reminders if post is password protected.
if ( \post_password_required( $post ) ) {
return;
}
// Only schedule an reminder for event post types.
if ( ! Setup::get_instance()->is_event_post_type_of_active_event_plugin( $post->post_type ) ) {
return;
}
// Do not schedule a reminder if the event is not published.
if ( 'publish' !== $new_status ) {
return;
}
// See if a reminder time gap is set for the event individually in the events post-meta.
$reminder_time_gap = (int) get_post_meta( $post->ID, 'event_bridge_for_activitypub_reminder_time_gap', true );
// If not fallback to the global reminder time gap.
if ( ! $reminder_time_gap ) {
$reminder_time_gap = \get_option( 'event_bridge_for_activitypub_reminder_time_gap', 0 );
}
// Any non positive integer means that this feature is not active for this event post.
if ( 0 === $reminder_time_gap || ! is_int( $reminder_time_gap ) ) {
return;
}
// Get start time of the event.
$event_transformer = Transformer_Factory::get_transformer( $post );
if ( \is_wp_error( $event_transformer ) || ! $event_transformer instanceof Event_Transformer ) {
return;
}
$start_time = $event_transformer->get_start_time();
$start_datetime = new DateTime( $start_time );
$start_timestamp = $start_datetime->getTimestamp();
// Get the time when the reminder of the event's start should be sent.
$schedule_time = $start_timestamp - $reminder_time_gap;
// If the reminder time has already passed "now" skip it.
if ( $schedule_time < \time() ) {
return;
}
// All checks passed: schedule a single event which will trigger the sending of the reminder for this event post.
\wp_schedule_single_event( $schedule_time, 'event_bridge_for_activitypub_send_event_reminder', array( $post->ID ) );
}
/**
* Unschedule the event reminder.
*
* @param int $post_id The WordPress post ID of the event post.
*/
public static function unschedule_event_reminder( $post_id ): void {
\wp_clear_scheduled_hook( 'event_bridge_for_activitypub_send_event_reminder', array( $post_id ) );
}
/**
* Send a reminder for an event post.
*
* This currently sends an Announce activity.
*
* @param int $post_id The WordPress post ID of the event post.
*/
public static function send_event_reminder( $post_id ) {
$post = \get_post( $post_id );
$transformer = Transformer_Factory::get_transformer( $post );
if ( \is_wp_error( $transformer ) || ! $transformer instanceof Event_Transformer ) {
return;
}
$actor = $transformer->get_actor_object();
$user_id = $actor->get__id();
if ( $user_id > 0 && is_user_disabled( $user_id ) ) {
return;
}
// Add announce of the event to outbox.
add_to_outbox( $post, 'Announce', $user_id );
}
}

View File

@ -0,0 +1,261 @@
<?php
/**
* General settings class.
*
* This file contains the General class definition, which handles the "General" settings
* page for the Event Bridge for ActivityPub Plugin, providing options for configuring various general settings.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
*/
namespace Event_Bridge_For_ActivityPub;
use Event_Bridge_For_ActivityPub\ActivityPub\Collection\Event_Sources;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\Integrations\Feature_Event_Sources;
/**
* Class responsible for the ActivityPui Event Extension related Settings.
*
* Class responsible for the ActivityPui Event Extension related Settings.
*
* @since 1.0.0
*/
class Settings {
const SETTINGS_SLUG = 'event-bridge-for-activitypub';
/**
* The default ActivityPub event category.
*
* @var string
*/
const DEFAULT_EVENT_CATEGORY = 'MEETING';
/**
* Register the settings for the Event Bridge for ActivityPub plugin.
*
* @return void
*/
public static function register_settings(): void {
\register_setting(
'event-bridge-for-activitypub',
'event_bridge_for_activitypub_default_event_category',
array(
'type' => 'string',
'description' => \__( 'Default standardized federated event category.', 'event-bridge-for-activitypub' ),
'show_in_rest' => true,
'default' => self::DEFAULT_EVENT_CATEGORY,
'sanitize_callback' => array( self::class, 'sanitize_mapped_event_category' ),
)
);
\register_setting(
'event-bridge-for-activitypub',
'event_bridge_for_activitypub_event_category_mappings',
array(
'type' => 'array',
'description' => \__( 'Define your own custom post template', 'event-bridge-for-activitypub' ),
'default' => array(),
'sanitize_callback' => array( self::class, 'sanitize_event_category_mappings' ),
)
);
\register_setting(
'event-bridge-for-activitypub',
'event_bridge_for_activitypub_reminder_time_gap',
array(
'type' => 'array',
'description' => \__( 'Time gap in seconds when a reminder is triggered that the event is about to start.', 'event-bridge-for-activitypub' ),
'default' => 0, // Zero leads to this feature being deactivated.
'sanitize_callback' => 'absint',
)
);
\register_setting(
'event-bridge-for-activitypub',
'event_bridge_for_activitypub_initially_activated',
array(
'type' => 'boolean',
'description' => \__( 'Whether the plugin just got activated for the first time.', 'event-bridge-for-activitypub' ),
'default' => 1,
)
);
\register_setting(
'event-bridge-for-activitypub',
'event_bridge_for_activitypub_summary_type',
array(
'type' => 'string',
'description' => \__( 'Summary type to use for ActivityStreams', 'event-bridge-for-activitypub' ),
'show_in_rest' => true,
'default' => 'preset',
)
);
\register_setting(
'event-bridge-for-activitypub',
'event_bridge_for_activitypub_summary_format',
array(
'type' => 'string',
'description' => \__( 'Summary format to use for ActivityStreams', 'event-bridge-for-activitypub' ),
'show_in_rest' => true,
'default' => 'html',
)
);
\register_setting(
'event-bridge-for-activitypub',
'event_bridge_for_activitypub_custom_summary',
array(
'type' => 'string',
'description' => \__( 'Define your own custom summary template for events', 'event-bridge-for-activitypub' ),
'show_in_rest' => true,
'default' => EVENT_BRIDGE_FOR_ACTIVITYPUB_SUMMARY_TEMPLATE,
)
);
\register_setting(
'event-bridge-for-activitypub_event-sources',
'event_bridge_for_activitypub_event_sources_active',
array(
'type' => 'boolean',
'show_in_rest' => true,
'description' => \__( 'Whether the event sources feature is activated.', 'event-bridge-for-activitypub' ),
'default' => 0,
'sanitize_callback' => array( self::class, 'sanitize_event_sources_feature_active' ),
)
);
\register_setting(
'event-bridge-for-activitypub_event-sources',
'event_bridge_for_activitypub_event_source_cache_retention',
array(
'type' => 'integer',
'show_in_rest' => true,
'description' => \__( 'The cache retention period for external event sources.', 'event-bridge-for-activitypub' ),
'default' => WEEK_IN_SECONDS,
'sanitize_callback' => 'absint',
)
);
\register_setting(
'event-bridge-for-activitypub_event-sources',
'event_bridge_for_activitypub_integration_used_for_event_sources_feature',
array(
'type' => 'string',
'description' => \__( 'Define which plugin/integration is used for the event sources feature', 'event-bridge-for-activitypub' ),
'default' => array(),
'sanitize_callback' => array( self::class, 'sanitize_event_plugin_integration_used_for_event_sources' ),
)
);
\register_setting(
'event-bridge-for-activitypub_add-event-source',
'event_bridge_for_activitypub_add_event_source',
array(
'type' => 'array',
'description' => \__( 'Dummy setting for adding event sources', 'event-bridge-for-activitypub' ),
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
)
);
}
/**
* Do not allow the event sources feature to get deactivated, when event sources are still followed.
*
* @param mixed $value The optios value.
*/
public static function sanitize_event_sources_feature_active( $value ) {
$count = count( Event_Sources::get_event_sources() );
$value = (bool) $value;
if ( 0 === $count ) {
return $value;
}
if ( ! $value ) {
\add_settings_error(
'event-bridge-for-activitypub_event-sources',
'event_bridge_for_activitypub_cannot_disable_event_sources',
__( 'It is not possible to disable the Event Sources feature while you are still having active followed Event Sources.', 'event-bridge-for-activitypub' ),
'error'
);
}
return true;
}
/**
* Sanitize the option which event plugin.
*
* @param mixed $event_plugin_integration The setting.
* @return string
*/
public static function sanitize_event_plugin_integration_used_for_event_sources( $event_plugin_integration ): string {
if ( ! is_string( $event_plugin_integration ) ) {
return '';
}
$setup = Setup::get_instance();
$active_event_plugins = $setup->get_active_event_plugins();
$valid_options = array();
foreach ( $active_event_plugins as $active_event_plugin ) {
if ( $active_event_plugin instanceof Feature_Event_Sources ) {
$valid_options[] = get_class( $active_event_plugin );
}
}
if ( in_array( $event_plugin_integration, $valid_options, true ) ) {
return $event_plugin_integration;
}
return Setup::get_default_integration_class_name_used_for_event_sources_feature();
}
/**
* Sanitize the target ActivityPub Event category.
*
* @param string $event_category The ActivityPUb event category.
*/
public static function sanitize_mapped_event_category( $event_category ): string {
return self::is_allowed_event_category( $event_category ) ? $event_category : self::DEFAULT_EVENT_CATEGORY;
}
/**
* Sanitization function for the event category mapping setting.
*
* Currently only the default event categories are allowed to be target of a mapping.
*
* @param array $event_category_mappings The settings value.
*
* @return array An array that contains only valid mapping pairs.
*/
public static function sanitize_event_category_mappings( $event_category_mappings ): array {
if ( empty( $event_category_mappings ) ) {
return array();
}
foreach ( $event_category_mappings as $taxonomy_slug => $event_category ) {
if ( ! self::is_allowed_event_category( $event_category ) ) {
unset( $event_category_mappings[ $taxonomy_slug ] );
}
}
return $event_category_mappings;
}
/**
* Checks if the given event category is allowed to be target of a mapping.
*
* @param string $event_category The event category to check.
*
* @return bool True if allowed, false otherwise.
*/
private static function is_allowed_event_category( $event_category ): bool {
require_once EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_DIR . '/includes/event-categories.php';
$allowed_event_categories = array_keys( EVENT_BRIDGE_FOR_ACTIVITYPUB_EVENT_CATEGORIES );
return in_array( $event_category, $allowed_event_categories, true );
}
}

View File

@ -0,0 +1,692 @@
<?php
/**
* Class responsible for initializing Event Bridge for ActivityPub.
*
* The setup class provides function for checking if this plugin should be activated.
* It detects supported event plugins and provides all setup hooks and filters.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\ActivityPub\Collection\Event_Sources as Event_Sources_Collection;
use Event_Bridge_For_ActivityPub\ActivityPub\Handler\Join as Join_Handler;
use Event_Bridge_For_ActivityPub\ActivityPub\Scheduler\Event as Event_Scheduler;
use Event_Bridge_For_ActivityPub\Admin\Event_Plugin_Admin_Notices;
use Event_Bridge_For_ActivityPub\Admin\General_Admin_Notices;
use Event_Bridge_For_ActivityPub\Admin\Health_Check;
use Event_Bridge_For_ActivityPub\Admin\Settings_Page;
use Event_Bridge_For_ActivityPub\Integrations\Event_Plugin_Integration;
use Event_Bridge_For_ActivityPub\Integrations\Feature_Event_Sources;
use Event_Bridge_For_ActivityPub\Reminder;
use function Activitypub\is_user_type_disabled;
// @phpstan-ignore-next-line
require_once ABSPATH . 'wp-admin/includes/plugin.php';
/**
* Class Setup.
* This class is responsible for initializing Event Bridge for ActivityPub.
*
* @since 1.0.0
*/
class Setup {
/**
* Keep the information whether the ActivityPub plugin is active.
*
* @var boolean
*/
protected $activitypub_plugin_is_active = false;
/**
* Keep the current version of the current ActivityPub plugin.
*
* @var string
*/
protected $activitypub_plugin_version = '';
/**
* Holds an array of the currently activated supported event plugins.
*
* @var Event_Plugin_Integration[]
*/
protected $active_event_plugins = array();
/**
* Constructor for the Setup class.
*
* Initializes and sets up various components of the plugin.
*
* @since 1.0.0
*/
protected function __construct() {
// Detect the presence/active-status and version of the ActivityPub plugin.
$this->activitypub_plugin_is_active = defined( 'ACTIVITYPUB_PLUGIN_VERSION' ) || \is_plugin_active( 'activitypub/activitypub.php' );
$this->activitypub_plugin_version = self::get_activitypub_plugin_version();
// Register main action that load the Event Bridge For ActivityPub.
\add_action( 'plugins_loaded', array( $this, 'setup_hooks' ) );
}
/**
* The single instance of the class.
*
* @since 1.0.0
* @var ?self The instance of the class.
*/
private static $instance = null;
/**
* Get the instance of the Singleton class.
*
* If an instance does not exist, it creates one; otherwise, it returns the existing instance.
*
* @since 1.0.0
*
* @return self The instance of the class.
*/
public static function get_instance(): self {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Getter function for whether the ActivityPub plugin is active.
*
* @return bool True when the ActivityPub plugin is active.
*/
public function is_activitypub_plugin_active(): bool {
return $this->activitypub_plugin_is_active;
}
/**
* Get the current version of the ActivityPub plugin.
*
* @return string The semantic Version.
*/
private static function get_activitypub_plugin_version(): string {
if ( defined( 'ACTIVITYPUB_PLUGIN_VERSION' ) ) {
return constant( 'ACTIVITYPUB_PLUGIN_VERSION' );
}
return '0.0.0';
}
/**
* Getter function for the active event plugins.
*
* @return Event_Plugin_Integration[]
*/
public function get_active_event_plugins(): array {
return $this->active_event_plugins;
}
/**
* Getter function for the active event plugins event post types.
*
* @return array List of event post types of the active event plugins.
*/
public function get_active_event_plugins_post_types(): array {
$post_types = array();
foreach ( $this->active_event_plugins as $event_plugin ) {
$post_types[] = $event_plugin->get_post_type();
}
return $post_types;
}
/**
* Function to check whether a post type is an event post type of an active event plugin.
*
* @param string $post_type The post type.
*
* @return bool True if it is an event post type.
*/
public function is_event_post_type_of_active_event_plugin( $post_type ): bool {
foreach ( $this->active_event_plugins as $event_plugin ) {
if ( $post_type === $event_plugin->get_post_type() ) {
return true;
}
}
return false;
}
/**
* Holds all the full class names for the supported event plugins.
*
* @var string[]
*/
private const EVENT_PLUGIN_INTEGRATIONS = array(
\Event_Bridge_For_ActivityPub\Integrations\Events_Manager::class,
\Event_Bridge_For_ActivityPub\Integrations\GatherPress::class,
\Event_Bridge_For_ActivityPub\Integrations\The_Events_Calendar::class,
\Event_Bridge_For_ActivityPub\Integrations\VS_Event_List::class,
\Event_Bridge_For_ActivityPub\Integrations\WP_Event_Manager::class,
\Event_Bridge_For_ActivityPub\Integrations\Eventin::class,
\Event_Bridge_For_ActivityPub\Integrations\Modern_Events_Calendar_Lite::class,
\Event_Bridge_For_ActivityPub\Integrations\Event_Organiser::class,
\Event_Bridge_For_ActivityPub\Integrations\EventPrime::class,
\Event_Bridge_For_ActivityPub\Integrations\EventOn::class,
);
/**
* Force the re-scan for active event plugins without using the cached transient.
*
* @return void
*/
public function redetect_active_event_plugins(): void {
if ( ! $this->activitypub_plugin_is_active ) {
return;
}
\delete_transient( 'event_bridge_for_activitypub_active_event_plugins' );
$this->detect_active_event_plugins();
}
/**
* Function that checks for supported activated event plugins.
*
* @return array List of supported event plugins as keys from the SUPPORTED_EVENT_PLUGINS const.
*/
public function detect_active_event_plugins(): array {
// Detection will fail in case the ActivityPub plugin is not active.
if ( ! $this->activitypub_plugin_is_active ) {
return array();
}
$active_event_plugins = \get_transient( 'event_bridge_for_activitypub_active_event_plugins' );
if ( $active_event_plugins ) {
$this->active_event_plugins = $active_event_plugins;
return $active_event_plugins;
}
if ( ! function_exists( 'get_plugins' ) ) {
// @phpstan-ignore-next-line
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$all_plugins = array_merge( \get_plugins(), \get_mu_plugins() );
$active_event_plugins = array();
foreach ( self::EVENT_PLUGIN_INTEGRATIONS as $event_plugin_integration ) {
// Get the filename of the main plugin file of the event plugin (relative to the plugin dir).
$event_plugin_file = $event_plugin_integration::get_relative_plugin_file();
// Check if plugin is present on disk and is activated.
if ( array_key_exists( $event_plugin_file, $all_plugins ) && \is_plugin_active( $event_plugin_file ) ) {
$active_event_plugins[ $event_plugin_file ] = new $event_plugin_integration();
}
}
\set_transient( 'event_bridge_for_activitypub_active_event_plugins', $active_event_plugins );
$this->active_event_plugins = $active_event_plugins;
return $active_event_plugins;
}
/**
* Function that checks which event plugins support the event sources feature.
*
* @return array List of supported event plugins as keys from the SUPPORTED_EVENT_PLUGINS const.
*/
public static function detect_event_plugins_supporting_event_sources(): array {
$plugins_supporting_event_sources = array();
foreach ( self::EVENT_PLUGIN_INTEGRATIONS as $event_plugin_integration ) {
if ( is_a( $event_plugin_integration, Feature_Event_Sources::class, true ) ) {
$plugins_supporting_event_sources[] = new $event_plugin_integration();
}
}
return $plugins_supporting_event_sources;
}
/**
* Main setup function of the plugin "Event Bridge For ActivityPub".
*
* This method adds hooks for different purposes as needed.
*
* @since 1.0.0
*
* @return void
*/
public function setup_hooks(): void {
// Detect active supported event plugins.
$this->detect_active_event_plugins();
// Register hook that runs when this plugin gets activated.
\register_activation_hook( EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_FILE, array( $this, 'activate' ) );
// Register listeners whenever any plugin gets activated or deactivated to maybe update the transient of active event plugins.
\add_action( 'activated_plugin', array( $this, 'redetect_active_event_plugins' ) );
\add_action( 'deactivated_plugin', array( $this, 'redetect_active_event_plugins' ) );
// Add hook that takes care of all notices in the Admin UI.
\add_action( 'admin_init', array( $this, 'do_admin_notices' ) );
// Add hook that registers all settings of this plugin to WordPress.
\add_action( 'admin_init', array( Settings::class, 'register_settings' ) );
// Add hook that loads CSS and JavaScript files for the Admin UI.
\add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_styles' ) );
// Register the settings page of this plugin as a Tab in the ActivityPub plugins settings.
Settings_Page::init();
// Add settings link in the Plugin overview Page.
\add_filter(
'plugin_action_links_' . EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_BASENAME,
array( Settings_Page::class, 'settings_link' )
);
// If we don't have any active event plugins, or the ActivityPub plugin is not enabled, abort here.
if ( empty( $this->active_event_plugins ) || ! $this->activitypub_plugin_is_active ) {
self::shut_down();
return;
}
// Register health checks and status reports to the WordPress status report site.
\add_action( 'init', array( Health_Check::class, 'init' ) );
// Check if the minimum required version of the ActivityPub plugin is installed, if not abort.
if ( ! version_compare( $this->activitypub_plugin_version, EVENT_BRIDGE_FOR_ACTIVITYPUB_ACTIVITYPUB_PLUGIN_MIN_VERSION, '>=' ) ) {
return;
}
// Register our own deplayed event scheduler because of upstream race-condition bug.
// See https://github.com/Automattic/wordpress-activitypub/issues/1269 for more information.
if ( version_compare( $this->activitypub_plugin_version, '7.0.0', '<' ) ) {
Event_Scheduler::init();
}
// Register the event reminders.
\add_action( 'init', array( Reminder::class, 'init' ) );
// Initialize the handling of "Join" activities.
Join_Handler::init();
// If the Event-Sources feature is enabled and all requirements are met, initialize it.
if ( ! is_user_type_disabled( 'blog' ) && \get_option( 'event_bridge_for_activitypub_event_sources_active' ) ) {
Event_Sources::init();
}
// Initialize writing of debug logs.
Debug::init();
$this->register_plugin_specific_hooks();
// Most importantly: register the ActivityPub transformers for events to the ActivityPub plugin.
\add_filter( 'activitypub_transformer', array( $this, 'register_activitypub_transformer' ), 10, 3 );
// Apply custom ActivityPub previews for events.
\add_action( 'init', array( Preview::class, 'init' ) );
$this->maybe_register_term_activitypub_ids();
}
/**
* Temporary hack to register custom actions and hooks, only needed by exceptional event plugins.
*
* @return void
*/
private function register_plugin_specific_hooks(): void {
if ( array_key_exists( \Event_Bridge_For_ActivityPub\Integrations\EventPrime::get_relative_plugin_file(), $this->active_event_plugins ) ) {
\Event_Bridge_For_ActivityPub\Integrations\EventPrime::init();
}
}
/**
* Shut down the plugin.
*
* @return void
*/
public static function shut_down(): void {
// Delete all transients.
Event_Sources_Collection::delete_event_source_transients();
\delete_transient( 'event_bridge_for_activitypub_active_event_plugins' );
// Unschedule all crons.
\wp_unschedule_hook( 'event_bridge_for_activitypub_event_sources_clear_cache' );
}
/**
* Add the CSS for the admin pages.
*
* @param string $hook_suffix The suffix of the hook.
*
* @return void
*/
public static function enqueue_styles( $hook_suffix ): void {
if ( 'settings_page_activitypub' !== $hook_suffix ) {
return;
}
// Check if we're on your custom tab.
$current_tab = isset( $_GET['tab'] ) ? \sanitize_key( $_GET['tab'] ) : 'welcome'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( 'event-bridge-for-activitypub' === $current_tab ) {
\wp_enqueue_style(
'event-bridge-for-activitypub-admin-styles',
plugins_url(
'assets/css/event-bridge-for-activitypub-admin.css',
EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_FILE
),
array(),
EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_VERSION
);
\wp_enqueue_script(
'event-bridge-for-activitypub-admin-script',
plugins_url(
'assets/js/event-bridge-for-activitypub-admin.js',
EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_FILE
),
array( 'jquery' ),
EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_VERSION,
false
);
}
}
/**
* Fires the initialization of admin notices.
*/
public function do_admin_notices(): void {
foreach ( $this->active_event_plugins as $event_plugin ) {
new Event_Plugin_Admin_Notices( $event_plugin );
}
// Check if any general admin notices are needed and add actions to insert the needed admin notices.
if ( ! $this->activitypub_plugin_is_active ) {
// The ActivityPub plugin is not active.
\add_action( 'admin_notices', array( General_Admin_Notices::class, 'activitypub_plugin_not_enabled' ), 10, 0 );
return;
}
if ( ! version_compare( $this->activitypub_plugin_version, EVENT_BRIDGE_FOR_ACTIVITYPUB_ACTIVITYPUB_PLUGIN_MIN_VERSION, '>=' ) ) {
// The ActivityPub plugin is too old.
\add_action( 'admin_notices', array( General_Admin_Notices::class, 'activitypub_plugin_version_too_old' ), 10, 0 );
return;
}
if ( empty( $this->active_event_plugins ) ) {
// No supported Event Plugin is active.
\add_action( 'admin_notices', array( General_Admin_Notices::class, 'no_supported_event_plugin_active' ), 10, 0 );
}
}
/**
* Add the custom transformers for the events and locations of several WordPress event plugins.
*
* @param \Activitypub\Transformer\Base $transformer The transformer to use.
* @param mixed $data The data to transform.
* @param string $object_class The class of the object to transform.
*
* @return \Activitypub\Transformer\Base|null|\WP_Error
*/
public function register_activitypub_transformer( $transformer, $data, $object_class ) {
// If the current WordPress object is not a post (e.g., a WP_Comment), don't change the transformer.
if ( 'WP_Post' === $object_class ) {
// Get the transformer for a specific event plugins event or location post type.
foreach ( $this->active_event_plugins as $event_plugin ) {
// Check if we have an event.
if ( $data->post_type === $event_plugin->get_post_type() ) {
if ( ! self::is_post_disabled( $data ) ) {
return $event_plugin::get_activitypub_event_transformer( $data );
} else {
return new \WP_Error( 'invalid_object', __( 'Invalid object', 'event-bridge-for-activitypub' ) );
}
}
// Check if we have a location.
if ( $data->post_type === $event_plugin->get_place_post_type() ) {
if ( ! self::is_post_disabled( $data ) ) {
return $event_plugin::get_activitypub_place_transformer( $data );
} else {
return new \WP_Error( 'invalid_object', __( 'Invalid object', 'event-bridge-for-activitypub' ) );
}
}
}
} elseif ( 'WP_Term' === $object_class ) {
foreach ( $this->active_event_plugins as $event_plugin ) {
if ( $data->taxonomy === $event_plugin->get_place_taxonomy() ) {
return $event_plugin::get_activitypub_place_transformer( $data );
}
}
}
// Return the default transformer.
return $transformer;
}
/**
* Check if a post of a post type that is managed by this plugin is disabled for ActivityPub.
*
* This function checks the visibility of the post and whether it is private or has a password.
*
* @param mixed $post The post object or ID.
*
* @return boolean True if the post is disabled, false otherwise.
*/
public static function is_post_disabled( $post ): bool {
$post = \get_post( $post );
$disabled = false;
if ( ! $post ) {
return true;
}
$visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true );
if (
ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL === $visibility ||
ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ||
'private' === $post->post_status ||
! empty( $post->post_password )
) {
$disabled = true;
}
return $disabled;
}
/**
* Activates ActivityPub support for all active event plugins event post-types.
*
* @since 1.0.0
*
* @return void
*/
public function activate_activitypub_support_for_active_event_plugins(): void {
// If someone installs this plugin, we simply enable ActivityPub support for all currently active event post types.
$activitypub_supported_post_types = get_option( 'activitypub_support_post_types', array() );
foreach ( $this->active_event_plugins as $event_plugin ) {
if ( ! in_array( $event_plugin->get_post_type(), $activitypub_supported_post_types, true ) ) {
$activitypub_supported_post_types[] = $event_plugin->get_post_type();
add_post_type_support( $event_plugin->get_post_type(), 'activitypub' );
}
}
\update_option( 'activitypub_support_post_types', $activitypub_supported_post_types );
}
/**
* Activates the Event Bridge for ActivityPub plugin.
*
* This method handles the activation of the Event Bridge for ActivityPub plugin.
*
* @since 1.0.0
* @see register_activation_hook()
* @return void
*/
public function activate(): void {
$this->redetect_active_event_plugins();
// Don't allow plugin activation, when the ActivityPub plugin is not activated yet.
if ( ! $this->activitypub_plugin_is_active ) {
\deactivate_plugins( plugin_basename( EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_FILE ) );
$notice = General_Admin_Notices::get_admin_notice_activitypub_plugin_not_enabled();
\wp_die(
// @phpstan-ignore-next-line
wp_kses( $notice, General_Admin_Notices::ALLOWED_HTML ),
'Plugin dependency check',
array( 'back_link' => true ),
);
}
if ( empty( $this->active_event_plugins ) ) {
\deactivate_plugins( plugin_basename( EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_FILE ) );
$notice = General_Admin_Notices::get_admin_notice_no_supported_event_plugin_active();
\wp_die(
// @phpstan-ignore-next-line
wp_kses( $notice, General_Admin_Notices::ALLOWED_HTML ),
'Plugin dependency check',
array( 'back_link' => true ),
);
}
self::activate_activitypub_support_for_active_event_plugins();
}
/**
* Maybe (depending on active event plugins) make it possible to querly event terms by `?term_id=<term_id>`.
*
* @return void
*/
private function maybe_register_term_activitypub_ids(): void {
$register_term_query_var = false;
foreach ( $this->active_event_plugins as $event_plugin ) {
if ( $event_plugin::get_place_taxonomy() ) {
$register_term_query_var = true;
break;
}
}
if ( $register_term_query_var ) {
\add_filter( 'query_vars', array( self::class, 'add_term_query_var' ) );
\add_filter( 'activitypub_queried_object', array( $this, 'maybe_detect_event_plugins_location_term' ) );
}
}
/**
* Add the 'activitypub' query for term variable so WordPress won't mangle it.
*
* @param array $vars The query variables.
*
* @return array The query variables.
*/
public static function add_term_query_var( $vars ) {
$vars[] = 'term_id';
return $vars;
}
/**
* Filters the queried object.
*
* @param \WP_Term|\WP_Post_Type|\WP_Post|\WP_User|\WP_Comment|null $queried_object The queried object.
*/
public function maybe_detect_event_plugins_location_term( $queried_object ) {
if ( $queried_object ) {
return $queried_object;
}
$term_id = \get_query_var( 'term_id' );
if ( $term_id ) {
$queried_object = \get_term( $term_id );
}
if ( $queried_object instanceof \WP_Term && $this->is_place_taxonomy_of_active_event_plugin( $queried_object->taxonomy ) ) {
return $queried_object;
}
return null;
}
/**
* Check whether a taxonomy is an active event plugins location taxonomy.
*
* @param string $taxonomy The taxonomy.
* @return boolean
*/
private function is_place_taxonomy_of_active_event_plugin( $taxonomy ): bool {
foreach ( $this->active_event_plugins as $event_plugin ) {
if ( $event_plugin::get_place_taxonomy() === $taxonomy ) {
return true;
}
}
return false;
}
/**
* Get the event plugin integration class name used for the event sources feature.
*
* @return ?string The class name of the event plugin integration class.
*/
public static function get_event_plugin_integration_used_for_event_sources_feature(): ?string {
// Get plugin option.
$event_plugin_integration = get_option(
'event_bridge_for_activitypub_integration_used_for_event_sources_feature',
self::get_default_integration_class_name_used_for_event_sources_feature()
);
// Exit if event sources are not active or no plugin is specified.
if ( empty( $event_plugin_integration ) ) {
return null;
}
// Validate if setting is actual existing class.
if ( ! class_exists( $event_plugin_integration ) ) {
return null;
}
return $event_plugin_integration;
}
/**
* Get the transmogrifier class.
*
* Retrieves the appropriate transmogrifier class based on the active event plugins and settings.
*
* @return ?string The transmogrifier class name or null if not available.
*/
public static function get_transmogrifier(): ?string {
$event_plugin_integration = self::get_event_plugin_integration_used_for_event_sources_feature();
if ( ! $event_plugin_integration ) {
return null;
}
// Validate if get_transformer method exists in event plugin integration.
if ( ! method_exists( $event_plugin_integration, 'get_transmogrifier' ) ) {
return null;
}
$transmogrifier = $event_plugin_integration::get_transmogrifier();
return $transmogrifier;
}
/**
* Get the full class name of the first event plugin integration that is active and supports the event source feature.
*
* @return string The full class name of the event plugin integration.
*/
public static function get_default_integration_class_name_used_for_event_sources_feature(): string {
$setup = self::get_instance();
$event_plugin_integrations = $setup->get_active_event_plugins();
foreach ( $event_plugin_integrations as $event_plugin_integration ) {
if ( $event_plugin_integration instanceof Feature_Event_Sources ) {
return get_class( $event_plugin_integration );
}
}
return '';
}
}

View File

@ -0,0 +1,50 @@
<?php
/**
* File responsible for defining the event category strings.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
define(
'EVENT_BRIDGE_FOR_ACTIVITYPUB_EVENT_CATEGORIES',
array(
'ARTS' => __( 'Arts', 'event-bridge-for-activitypub' ),
'BOOK_CLUBS' => __( 'Book clubs', 'event-bridge-for-activitypub' ),
'BUSINESS' => __( 'Business', 'event-bridge-for-activitypub' ),
'CAUSES' => __( 'Causes', 'event-bridge-for-activitypub' ),
'COMEDY' => __( 'Comedy', 'event-bridge-for-activitypub' ),
'CRAFTS' => __( 'Crafts', 'event-bridge-for-activitypub' ),
'FOOD_DRINK' => __( 'Food & Drink', 'event-bridge-for-activitypub' ),
'HEALTH' => __( 'Health', 'event-bridge-for-activitypub' ),
'MUSIC' => __( 'Music', 'event-bridge-for-activitypub' ),
'AUTO_BOAT_AIR' => __( 'Auto, boat and air', 'event-bridge-for-activitypub' ),
'COMMUNITY' => __( 'Community', 'event-bridge-for-activitypub' ),
'FAMILY_EDUCATION' => __( 'Family & Education', 'event-bridge-for-activitypub' ),
'FASHION_BEAUTY' => __( 'Fashion & Beauty', 'event-bridge-for-activitypub' ),
'FILM_MEDIA' => __( 'Film & Media', 'event-bridge-for-activitypub' ),
'GAMES' => __( 'Games', 'event-bridge-for-activitypub' ),
'LANGUAGE_CULTURE' => __( 'Language & Culture', 'event-bridge-for-activitypub' ),
'LEARNING' => __( 'Learning', 'event-bridge-for-activitypub' ),
'LGBTQ' => __( 'LGBTQ', 'event-bridge-for-activitypub' ),
'MOVEMENTS_POLITICS' => __( 'Movements and politics', 'event-bridge-for-activitypub' ),
'NETWORKING' => __( 'Networking', 'event-bridge-for-activitypub' ),
'PARTY' => __( 'Party', 'event-bridge-for-activitypub' ),
'PERFORMING_VISUAL_ARTS' => __( 'Performing & Visual Arts', 'event-bridge-for-activitypub' ),
'PETS' => __( 'Pets', 'event-bridge-for-activitypub' ),
'PHOTOGRAPHY' => __( 'Photography', 'event-bridge-for-activitypub' ),
'OUTDOORS_ADVENTURE' => __( 'Outdoors & Adventure', 'event-bridge-for-activitypub' ),
'SPIRITUALITY_RELIGION_BELIEFS' => __( 'Spirituality, Religion & Beliefs', 'event-bridge-for-activitypub' ),
'SCIENCE_TECH' => __( 'Science & Tech', 'event-bridge-for-activitypub' ),
'SPORTS' => __( 'Sports', 'event-bridge-for-activitypub' ),
'THEATRE' => __( 'Theatre', 'event-bridge-for-activitypub' ),
'MEETING' => __( 'Meeting', 'event-bridge-for-activitypub' ), // Default value in federation.
'DEFAULT' => __( 'Default', 'event-bridge-for-activitypub' ), // Internal default for overrides.
),
);

View File

@ -0,0 +1,109 @@
<?php
/**
* Event Organiser.
*
* Defines all the necessary meta information and methods for the integration
* of the WordPress "Event Organiser" plugin.
*
* @link https://wordpress.org/plugins/event-organiser/
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
*/
namespace Event_Bridge_For_ActivityPub\Integrations;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Query;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Event_Organiser as Event_Organiser_Transformer;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place\Event_Organiser as Event_Organiser_Place_Transformer;
/**
* Event Organiser.
*
* Defines all the necessary meta information and methods for the integration
* of the WordPress "Event Organiser" plugin.
*
* @since 1.0.0
*/
final class Event_Organiser extends Event_Plugin_Integration {
/**
* Returns the full plugin file.
*
* @return string
*/
public static function get_relative_plugin_file(): string {
return 'event-organiser/event-organiser.php';
}
/**
* Returns the event post type of the plugin.
*
* @return string
*/
public static function get_post_type(): string {
return 'event';
}
/**
* Returns the IDs of the admin pages of the plugin.
*
* @return array The settings page urls.
*/
public static function get_settings_pages(): array {
return array( 'event-organiser' );
}
/**
* Returns the taxonomy used for the plugin's event categories.
*
* @return string
*/
public static function get_event_category_taxonomy(): string {
return 'event-category';
}
/**
* In case an event plugin uses a custom taxonomy for storing locations/venues return it here.
*
* @return string
*/
public static function get_place_taxonomy() {
return 'event-venue';
}
/**
* Returns the ActivityPub transformer for a Event_Organiser event post.
*
* @param \WP_Post $post The WordPress post object of the Event.
* @return Event_Organiser_Transformer
*/
public static function get_activitypub_event_transformer( $post ): Event_Organiser_Transformer {
return new Event_Organiser_Transformer( $post, self::get_event_category_taxonomy() );
}
/**
* Returns the ActivityPub transformer for a Event_Organiser event venue which is stored in a taxonomy.
*
* @param \WP_Term $term The WordPress Term/Taxonomy of the venue.
* @return Event_Organiser_Place_Transformer
*/
public static function get_activitypub_place_transformer( $term ): Event_Organiser_Place_Transformer {
if ( Query::get_instance()->is_activitypub_request() && defined( 'EVENT_ORGANISER_DIR' ) ) {
$class_path = constant( EVENT_ORGANISER_DIR ) . 'includes/class-eo-theme-compatability.php';
if ( file_exists( $class_path ) ) {
require_once $class_path;
// Remove the theme filter which is not needed in ActivityStreams.
$eo = \EO_Theme_Compatabilty::get_instance();
if ( $eo instanceof \EO_Theme_Compatabilty ) {
$eo->remove_filter( 'template_include', PHP_INT_MAX - 1 );
}
}
}
return new Event_Organiser_Place_Transformer( $term );
}
}

View File

@ -0,0 +1,132 @@
<?php
/**
* Abstract base class for a basic integration of a WordPress event plugin.
*
* Basic information and methods that each supported event needs for this plugin to work.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\Integrations;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Event as ActivityPub_Event_Transformer;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place\Base_Post_Place;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place\Base_Term_Place;
use WP_Post;
require_once EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_DIR . 'includes/integrations/interface-feature-event-sources.php';
/**
* Abstract base class for a basic integration of a WordPress event plugin.
*
* Basic information and methods that each supported event needs for this plugin to work.
*
* @since 1.0.0
*/
abstract class Event_Plugin_Integration {
/**
* Returns the plugin file relative to the plugins dir.
*
* @return string
*/
abstract public static function get_relative_plugin_file(): string;
/**
* Returns the event post type of the plugin.
*
* @return string
*/
abstract public static function get_post_type(): string;
/**
* Returns the taxonomy used for the plugin's event categories.
*
* @return string
*/
abstract public static function get_event_category_taxonomy(): string;
/**
* Returns the Activitypub transformer for events of the event plugins event post type.
*
* @param WP_Post $post The WordPress post object of the Event.
* @return ActivityPub_Event_Transformer
*/
abstract public static function get_activitypub_event_transformer( $post ): ActivityPub_Event_Transformer;
/**
* In case an event plugin uses a custom post type for the locations/venues return it here.
*
* @return ?string
*/
public static function get_place_post_type() {
return null;
}
/**
* In case an event plugin uses a custom taxonomy for storing locations/venues return it here.
*
* @return ?string
*/
public static function get_place_taxonomy() {
return null;
}
/**
* Returns the Activitypub transformer for places of the event plugins location post type.
*
* @param WP_Post $post The WordPress post object of the Event.
* @return Base_Post_Place|Base_Term_Place|null
*/
public static function get_activitypub_place_transformer( $post ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
return null;
}
/**
* In case an event plugin used a custom post type for organizers return it here.
*
* @return ?string
*/
public static function get_organizer_post_type() {
return null;
}
/**
* Returns the IDs of the admin pages of the plugin.
*
* @return array The IDs of one or several admin/settings pages.
*/
public static function get_settings_pages(): array {
return array();
}
/**
* Get the plugins name from the main plugin-file's top-level-file-comment.
*/
public static function get_plugin_name(): string {
$all_plugins = array_merge( get_plugins(), get_mu_plugins() );
if ( isset( $all_plugins[ static::get_relative_plugin_file() ]['Name'] ) ) {
return $all_plugins[ static::get_relative_plugin_file() ]['Name'];
} else {
return '';
}
}
/**
* Detects whether the current screen is a admin page of the event plugin.
*/
public static function is_plugin_page(): bool {
// Get the current page.
$screen = get_current_screen();
// Check if we are on a edit page for the event, or on the settings page of the event plugin.
$is_event_plugins_edit_page = 'edit' === $screen->base && static::get_post_type() === $screen->post_type;
$is_event_plugins_settings_page = in_array( $screen->id, static::get_settings_pages(), true );
return $is_event_plugins_edit_page || $is_event_plugins_settings_page;
}
}

View File

@ -0,0 +1,74 @@
<?php
/**
* Eventin.
*
* Defines all the necessary meta information and methods for the integration of the
* WordPress plugin "Eventin".
*
* @link https://wordpress.org/plugins/eventin/
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
*/
namespace Event_Bridge_For_ActivityPub\Integrations;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Eventin as Eventin_Transformer;
/**
* Eventin.
*
* Defines all the necessary meta information and methods for the integration of the
* WordPress plugin "Eventin".
*
* @since 1.0.0
*/
final class Eventin extends Event_Plugin_Integration {
/**
* Returns the full plugin file.
*
* @return string
*/
public static function get_relative_plugin_file(): string {
return 'wp-event-solution/eventin.php';
}
/**
* Returns the event post type of the plugin.
*
* @return string
*/
public static function get_post_type(): string {
return 'etn';
}
/**
* Returns the IDs of the admin pages of the plugin.
*
* @return array The settings page url.
*/
public static function get_settings_pages(): array {
return array( 'eventin' ); // Base always is wp-admin/admin.php?page=eventin.
}
/**
* Returns the taxonomy used for the plugin's event categories.
*
* @return string
*/
public static function get_event_category_taxonomy(): string {
return 'etn_category';
}
/**
* Returns the ActivityPub transformer for a Eventin event post.
*
* @param \WP_Post $post The WordPress post object of the Event.
* @return Eventin_Transformer
*/
public static function get_activitypub_event_transformer( $post ): Eventin_Transformer {
return new Eventin_Transformer( $post, self::get_event_category_taxonomy() );
}
}

View File

@ -0,0 +1,94 @@
<?php
/**
* EventON Events Calendar
*
* Defines all the necessary meta information for the integration of the WordPress event plugin
* "EventON Events Calendar".
*
* @link https://wordpress.org/plugins/eventon-lite
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
*/
namespace Event_Bridge_For_ActivityPub\Integrations;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\EventOn as EventOn_Event_Transformer;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place\EventOn as EventOn_Location_Transformer;
/**
* EventON Events Calendar
*
* Defines all the necessary meta information for the integration of the WordPress event plugin
* "EventON Events Calendar".
*
* @since 1.0.0
*/
final class EventOn extends Event_Plugin_Integration {
/**
* Returns the full plugin file.
*
* @return string
*/
public static function get_relative_plugin_file(): string {
return 'eventon-lite/eventon.php';
}
/**
* Returns the event post type of the plugin.
*
* @return string
*/
public static function get_post_type(): string {
return 'ajde_events';
}
/**
* Returns the IDs of the admin pages of the plugin.
*
* @return array The settings page urls.
*/
public static function get_settings_pages(): array {
return array( 'admin.php?page=eventon' );
}
/**
* Returns the taxonomy used for the plugin's event categories.
*
* @return string
*/
public static function get_event_category_taxonomy(): string {
return '';
}
/**
* Returns the ActivityPub transformer for a VS_Event_List event post.
*
* @param \WP_Post $post The WordPress post object of the Event.
* @return EventOn_Event_Transformer
*/
public static function get_activitypub_event_transformer( $post ): EventOn_Event_Transformer {
return new EventOn_Event_Transformer( $post, self::get_event_category_taxonomy() );
}
/**
* In case an event plugin uses a custom taxonomy for storing locations/venues return it here.
*
* @return string
*/
public static function get_place_taxonomy() {
return 'event_location';
}
/**
* Returns the ActivityPub transformer for a Event_Organiser event venue which is stored in a taxonomy.
*
* @param \WP_Term $term The WordPress Term/Taxonomy of the venue.
* @return EventOn_Location_Transformer
*/
public static function get_activitypub_place_transformer( $term ): EventOn_Location_Transformer {
return new EventOn_Location_Transformer( $term );
}
}

View File

@ -0,0 +1,171 @@
<?php
/**
* EventPrime Events Calendar, Bookings and Tickets
*
* @link https://wordpress.org/plugins/eventprime-event-calendar-management/
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
*/
namespace Event_Bridge_For_ActivityPub\Integrations;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\EventPrime as EventPrime_Event_Transformer;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place\EventPrime as EventPrime_Place_Transformer;
use Eventprime_Basic_Functions;
use function Activitypub\is_activitypub_request;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
/**
* This class defines which information is necessary for the EventPrime event plugin.
*
* @since 1.0.0
*/
final class EventPrime extends Event_Plugin_Integration {
/**
* Add filter for the template inclusion.
*/
public static function init() {
// Forcefully enable 'activitypub' post type support for EventPrime, because it is not public and cannot be done in the admin UI.
\add_post_type_support( self::get_post_type(), 'activitypub' );
\add_filter( 'activitypub_transformer', array( self::class, 'register_activitypub_transformer' ), 10, 3 );
}
/**
* Returns the full plugin file.
*
* @return string
*/
public static function get_relative_plugin_file(): string {
return 'eventprime-event-calendar-management/event-prime.php';
}
/**
* Returns the event post type of the plugin.
*
* @return string
*/
public static function get_post_type(): string {
return 'em_event';
}
/**
* Returns the taxonomy used for storing venues.
*
* @return string
*/
public static function get_place_taxonomy(): string {
return 'em_venue';
}
/**
* Returns the IDs of the admin pages of the plugin.
*
* @return array The settings page urls.
*/
public static function get_settings_pages(): array {
return array( 'ep-settings' );
}
/**
* Returns the ActivityPub transformer.
*
* @param \WP_Post $post The WordPress post object of the Event.
* @return EventPrime_Event_Transformer
*/
public static function get_activitypub_event_transformer( $post ): EventPrime_Event_Transformer {
return new EventPrime_Event_Transformer( $post, self::get_event_category_taxonomy() );
}
/**
* Returns the taxonomy used for the plugin's event categories.
*
* @return string
*/
public static function get_event_category_taxonomy(): string {
return 'em_event_type';
}
/**
* Maybe use the custom transformer for the EventPrime.
*
* @param mixed $transformer The transformer to use.
* @param mixed $data The data to transform.
* @param string $object_class The class of the object to transform.
*
* @return mixed
*/
public static function register_activitypub_transformer( $transformer, $data, $object_class ) {
if ( 'WP_Post' !== $object_class ) {
return $transformer;
}
$object_type = self::post_contains_eventprime_object( $data );
if ( 'event' === $object_type ) {
$post = get_post( self::get_object_id( $object_type ) );
if ( $post && self::get_post_type() === $post->post_type ) {
return new EventPrime_Event_Transformer( $post );
}
}
if ( 'venue' === $object_type ) {
$term = get_term( self::get_object_id( $object_type ) );
if ( $term && self::get_place_taxonomy() === $term->taxonomy ) {
return new EventPrime_Place_Transformer( $term );
}
}
return $transformer;
}
/**
* Determine if the current post is actually just a shortcode Wrapper linking to an EventPrime event.
*
* @param \WP_Post $post The WordPress post object.
* @return string|bool
*/
private static function post_contains_eventprime_object( $post ) {
if ( 'page' !== $post->post_type ) {
return false;
}
if ( '[em_event]' === $post->post_content || '[em_events]' === $post->post_content ) {
return 'event';
}
if ( '[em_sites]' === $post->post_content ) {
return 'venue';
}
return false;
}
/**
* Extract the post id for events and term id for venues for an EventPrime event query.
*
* @param string $type 'event' or 'venue'.
* @return bool|int The post ID, or term ID if found, false otherwise.
*/
private static function get_object_id( $type = 'event' ) {
if ( ! in_array( $type, array( 'venue', 'event' ), true ) ) {
return false;
}
$event = get_query_var( $type );
if ( ! $event ) {
if ( ! empty( filter_input( INPUT_GET, $type, FILTER_SANITIZE_FULL_SPECIAL_CHARS ) ) ) {
$event = rtrim( filter_input( INPUT_GET, $type, FILTER_SANITIZE_FULL_SPECIAL_CHARS ), '/\\' );
}
}
if ( $event ) {
$ep_basic_functions = new Eventprime_Basic_Functions();
return $ep_basic_functions->ep_get_id_by_slug( $event, "em_{$type}" );
}
return false;
}
}

View File

@ -0,0 +1,95 @@
<?php
/**
* Events Manager.
*
* Defines all the necessary meta information and methods for the integration of the
* WordPress plugin "Events Manager".
*
* @link https://wordpress.org/plugins/events-manager/
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
*/
namespace Event_Bridge_For_ActivityPub\Integrations;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Events_Manager as Events_Manager_Event_Transformer;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place\Events_Manager as Events_Manager_Place_Transformer;
/**
* Events Manager.
*
* Defines all the necessary meta information and methods for the integration of the
* WordPress plugin "Events Manager".
*
* @since 1.0.0
*/
final class Events_Manager extends Event_Plugin_Integration {
/**
* Returns the full plugin file.
*
* @return string
*/
public static function get_relative_plugin_file(): string {
return 'events-manager/events-manager.php';
}
/**
* Returns the event post type of the plugin.
*
* @return string
*/
public static function get_post_type(): string {
return defined( 'EM_POST_TYPE_EVENT' ) ? constant( 'EM_POST_TYPE_EVENT' ) : 'event';
}
/**
* Returns the place post type of the plugin.
*
* @return string
*/
public static function get_place_post_type(): string {
return defined( 'EM_POST_TYPE_LOCATION' ) ? constant( 'EM_POST_TYPE_LOCATION' ) : 'location';
}
/**
* Returns the Activitypub transformer for places of the event plugins location post type.
*
* @param \WP_Post $post The WordPress post object of the Event.
* @return Events_Manager_Place_Transformer
*/
public static function get_activitypub_place_transformer( $post ): Events_Manager_Place_Transformer {
return new Events_Manager_Place_Transformer( $post );
}
/**
* Returns the IDs of the admin pages of the plugin.
*
* @return array The settings page urls.
*/
public static function get_settings_page(): array {
return array();
}
/**
* Returns the taxonomy used for the plugin's event categories.
*
* @return string
*/
public static function get_event_category_taxonomy(): string {
return defined( 'EM_TAXONOMY_CATEGORY' ) ? constant( 'EM_TAXONOMY_CATEGORY' ) : 'event-categories';
}
/**
* Returns the ActivityPub transformer for a Events_Manager event post.
*
* @param \WP_Post $post The WordPress post object of the Event.
* @return Events_Manager_Event_Transformer
*/
public static function get_activitypub_event_transformer( $post ): Events_Manager_Event_Transformer {
return new Events_Manager_Event_Transformer( $post, self::get_event_category_taxonomy() );
}
}

View File

@ -0,0 +1,143 @@
<?php
/**
* GatherPress.
*
* Defines all the necessary meta information and methods for the integration
* of the WordPress event plugin "GatherPress".
*
* @link https://wordpress.org/plugins/gatherpress/
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
*/
namespace Event_Bridge_For_ActivityPub\Integrations;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\GatherPress as GatherPress_Transformer;
use Event_Bridge_For_ActivityPub\ActivityPub\Transmogrifier\GatherPress as GatherPress_Transmogrifier;
/**
* GatherPress.
*
* Defines all the necessary meta information and methods for the integration
* of the WordPress event plugin "GatherPress".
*
* @since 1.0.0
*/
final class GatherPress extends Event_Plugin_Integration implements Feature_Event_Sources {
/**
* Returns the full plugin file.
*
* @return string
*/
public static function get_relative_plugin_file(): string {
return 'gatherpress/gatherpress.php';
}
/**
* Returns the event post type of the plugin.
*
* @return string
*/
public static function get_post_type(): string {
return class_exists( '\GatherPress\Core\Event' ) ? \GatherPress\Core\Event::POST_TYPE : 'gatherpress_event';
}
/**
* Returns the IDs of the admin pages of the plugin.
*
* @return array The settings page urls.
*/
public static function get_settings_pages(): array {
return array( class_exists( '\GatherPress\Core\Utility' ) ? \GatherPress\Core\Utility::prefix_key( 'general' ) : 'gatherpress_general' );
}
/**
* Returns the taxonomy used for the plugin's event categories.
*
* @return string
*/
public static function get_event_category_taxonomy(): string {
return class_exists( '\GatherPress\Core\Topic' ) ? \GatherPress\Core\Topic::TAXONOMY : 'gatherpress_topic';
}
/**
* Returns the ActivityPub transformer for a GatherPress event post.
*
* @param \WP_Post $post The WordPress post object of the Event.
* @return GatherPress_Transformer
*/
public static function get_activitypub_event_transformer( $post ): GatherPress_Transformer {
return new GatherPress_Transformer( $post, self::get_event_category_taxonomy() );
}
/**
* Returns the Transmogrifier for GatherPress.
*/
public static function get_transmogrifier(): string {
return GatherPress_Transmogrifier::class;
}
/**
* Get a list of Post IDs of events that have ended.
*
* @param int $ends_before_time Filter: only get events that ended before that datetime as unix-time.
*
* @return array
*/
public static function get_cached_remote_events( $ends_before_time ): array {
global $wpdb;
$ends_before_time_string = gmdate( 'Y-m-d H:i:s', $ends_before_time );
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT DISTINCT {$wpdb->prefix}posts.ID
FROM {$wpdb->prefix}posts
LEFT JOIN {$wpdb->prefix}gatherpress_events
ON {$wpdb->prefix}posts.ID = {$wpdb->prefix}gatherpress_events.post_id
LEFT JOIN {$wpdb->prefix}postmeta
ON {$wpdb->prefix}posts.ID = {$wpdb->prefix}postmeta.post_id
WHERE {$wpdb->prefix}posts.post_type = 'gatherpress_event'
AND {$wpdb->prefix}posts.post_status = 'publish'
AND {$wpdb->prefix}gatherpress_events.datetime_end_gmt <= %s
AND {$wpdb->prefix}postmeta.meta_key = '_event_bridge_for_activitypub_event_source'
",
$ends_before_time_string
),
ARRAY_N
);
$post_ids = array_column( $results, 0 );
return $post_ids;
}
/**
* Init function: force displaying online event link for federated events.
*/
public static function init(): void {
\add_filter(
'gatherpress_force_online_event_link',
function ( $force_online_event_link ) {
// Get the current post object.
$post = get_post();
// Check if we are in a valid context and the post type is 'gatherpress'.
if ( $post && 'gatherpress_event' === $post->post_type ) {
// Add your custom logic here to decide whether to force the link.
// For example, force it only if a specific meta field exists.
if ( get_post_meta( $post->ID, '_event_bridge_for_activitypub_event_source', true ) ) {
return true; // Force the online event link.
}
}
return $force_online_event_link; // Default behavior.
},
10,
1
);
}
}

View File

@ -0,0 +1,75 @@
<?php
/**
* Modern Events Calendar (Lite)
*
* Defines all the necessary meta information for the integration of the
* WordPress plugin "Modern Events Calendar (Lite)".
*
* @link https://webnus.net/modern-events-calendar/
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
*/
namespace Event_Bridge_For_ActivityPub\Integrations;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\Modern_Events_Calendar_Lite as Modern_Events_Calendar_Lite_Transformer;
/**
* Modern Events Calendar (Lite)
*
* Defines all the necessary meta information for the integration of the
* WordPress plugin "Modern Events Calendar (Lite)".
*
* @since 1.0.0
*/
final class Modern_Events_Calendar_Lite extends Event_Plugin_Integration {
/**
* Returns the full plugin file.
*
* @return string
*/
public static function get_relative_plugin_file(): string {
return 'modern-events-calendar-lite/modern-events-calendar-lite.php';
}
/**
* Returns the event post type of the plugin.
*
* @return string
*/
public static function get_post_type(): string {
// See MEC_feature_events->get_main_post_type().
return 'mec-events';
}
/**
* Returns the IDs of the admin pages of the plugin.
*
* @return array The settings page urls.
*/
public static function get_settings_pages(): array {
return array( 'MEC-settings', 'MEC-support', 'MEC-ix', 'MEC-wizard', 'MEC-addons', 'mec-intro' );
}
/**
* Returns the taxonomy used for the plugin's event categories.
*
* @return string
*/
public static function get_event_category_taxonomy(): string {
return 'mec_category';
}
/**
* Returns the ActivityPub transformer for a Modern_Events_Calendar_Lite event post.
*
* @param \WP_Post $post The WordPress post object of the Event.
* @return Modern_Events_Calendar_Lite_Transformer
*/
public static function get_activitypub_event_transformer( $post ): Modern_Events_Calendar_Lite_Transformer {
return new Modern_Events_Calendar_Lite_Transformer( $post, self::get_event_category_taxonomy() );
}
}

View File

@ -0,0 +1,155 @@
<?php
/**
* The Events Calendar.
*
* Defines all the necessary meta information for the integration of the
* WordPress plugin "The Events Calendar".
*
* @link https://wordpress.org/plugins/the-events-calendar/
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
*/
namespace Event_Bridge_For_ActivityPub\Integrations;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\The_Events_Calendar as The_Events_Calendar_Event_Transformer;
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Place\The_Events_Calendar as The_Events_Calendar_Place_Transformer;
use Event_Bridge_For_ActivityPub\ActivityPub\Transmogrifier\The_Events_Calendar as The_Events_Calendar_Transmogrifier;
/**
* The Events Calendar.
*
* Defines all the necessary meta information for the integration of the
* WordPress plugin "The Events Calendar".
*
* @since 1.0.0
*/
final class The_Events_Calendar extends Event_Plugin_Integration implements Feature_Event_Sources {
/**
* Returns the full plugin file.
*
* @return string
*/
public static function get_relative_plugin_file(): string {
return 'the-events-calendar/the-events-calendar.php';
}
/**
* Returns the event post type of the plugin.
*
* @return string
*/
public static function get_post_type(): string {
return class_exists( '\Tribe__Events__Main' ) ? \Tribe__Events__Main::POSTTYPE : 'tribe_event';
}
/**
* Returns the taxonomy used for the plugin's event categories.
*
* @return string
*/
public static function get_event_category_taxonomy(): string {
return class_exists( '\Tribe__Events__Main' ) ? \Tribe__Events__Main::TAXONOMY : 'tribe_events_cat';
}
/**
* Returns the ActivityPub transformer for a The_Events_Calendar event post.
*
* @param \WP_Post $post The WordPress post object of the Event.
* @return The_Events_Calendar_Event_Transformer
*/
public static function get_activitypub_event_transformer( $post ): The_Events_Calendar_Event_Transformer {
return new The_Events_Calendar_Event_Transformer( $post, self::get_event_category_taxonomy() );
}
/**
* Return the location/venue post type used by tribe.
*
* @return string
*/
public static function get_place_post_type(): string {
return class_exists( '\Tribe__Events__Venue' ) ? \Tribe__Events__Venue::POSTTYPE : 'tribe_venue';
}
/**
* Return the organizers post type used by tribe.
*
* @return string
*/
public static function get_organizer_post_type(): string {
return class_exists( '\Tribe__Events__Organizer' ) ? \Tribe__Events__Organizer::POSTTYPE : 'tribe_organizer';
}
/**
* Returns the ActivityPub transformer for a The_Events_Calendar venue post.
*
* @param \WP_Post $post The WordPress post object of the venue.
* @return The_Events_Calendar_Place_Transformer
*/
public static function get_activitypub_place_transformer( $post ): The_Events_Calendar_Place_Transformer {
return new The_Events_Calendar_Place_Transformer( $post );
}
/**
* Returns the IDs of the admin pages of the plugin.
*
* @return array The settings page urls.
*/
public static function get_settings_pages(): array {
if ( class_exists( '\Tribe\Events\Admin\Settings' ) ) {
$page = \Tribe\Events\Admin\Settings::$settings_page_id;
} else {
$page = 'tec-events-settings';
}
return array( $page );
}
/**
* Returns the Transmogrifier for The_Events_Calendar.
*/
public static function get_transmogrifier(): string {
return The_Events_Calendar_Transmogrifier::class;
}
/**
* Get a list of Post IDs of events that have ended.
*
* @param int $ends_before_time Filter to only get events that ended before that datetime as unix-time.
*
* @return array<int>
*/
public static function get_cached_remote_events( $ends_before_time ): array {
add_filter(
'tribe_repository_events_apply_modifier_schema_entry',
array( self::class, 'add_is_activitypub_remote_cached_to_query' ),
10,
1
);
$events = tribe_events()->where( 'ends_before', $ends_before_time )->get_ids();
remove_filter(
'tribe_repository_events_apply_modifier_schema_entry',
array( self::class, 'add_is_activitypub_remote_cached_to_query' )
);
return $events;
}
/**
* Only show remote cached ActivityPub events in Tribe query.
*
* @param array $schema_entry The current schema entry.
* @return array The modified schema entry.
*/
public static function add_is_activitypub_remote_cached_to_query( $schema_entry ) {
$schema_entry['meta_query']['is-remote-cached'] = array(
'key' => '_event_bridge_for_activitypub_event_source',
'compare' => 'EXISTS',
);
return $schema_entry;
}
}

View File

@ -0,0 +1,117 @@
<?php
/**
* VS Events LIst.
*
* Defines all the necessary meta information for the integration of the WordPress event plugin
* "Very Simple Events List".
*
* @link https://wordpress.org/plugins/very-simple-event-list/
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
*/
namespace Event_Bridge_For_ActivityPub\Integrations;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\VS_Event_List as VS_Event_List_Transformer;
use Event_Bridge_For_ActivityPub\ActivityPub\Transmogrifier\VS_Event_List as VS_Event_List_Transmogrifier;
use WP_Query;
/**
* VS Events LIst.
*
* Defines all the necessary meta information for the integration of the WordPress event plugin
* "Very Simple Events List".
*
* @since 1.0.0
*/
final class VS_Event_List extends Event_Plugin_Integration implements Feature_Event_Sources {
/**
* Returns the full plugin file.
*
* @return string
*/
public static function get_relative_plugin_file(): string {
return 'very-simple-event-list/vsel.php';
}
/**
* Returns the event post type of the plugin.
*
* @return string
*/
public static function get_post_type(): string {
return 'event';
}
/**
* Returns the IDs of the admin pages of the plugin.
*
* @return array The settings page urls.
*/
public static function get_settings_pages(): array {
return array( 'settings_page_vsel' );
}
/**
* Returns the taxonomy used for the plugin's event categories.
*
* @return string
*/
public static function get_event_category_taxonomy(): string {
return 'event_cat';
}
/**
* Returns the ActivityPub transformer for a VS_Event_List event post.
*
* @param \WP_Post $post The WordPress post object of the Event.
* @return VS_Event_List_Transformer
*/
public static function get_activitypub_event_transformer( $post ): VS_Event_List_Transformer {
return new VS_Event_List_Transformer( $post, self::get_event_category_taxonomy() );
}
/**
* Returns the Transmogrifier for The_Events_Calendar.
*/
public static function get_transmogrifier(): string {
return VS_Event_List_Transmogrifier::class;
}
/**
* Get a list of Post IDs of events that have ended.
*
* @param int $ends_before_time Filter to only get events that ended before that datetime as unix-time.
*
* @return array<int>
*/
public static function get_cached_remote_events( $ends_before_time ): array {
$args = array(
'post_type' => 'event',
'posts_per_page' => -1,
'fields' => 'ids',
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_event_bridge_for_activitypub_event_source',
'compare' => 'EXISTS',
),
array(
'key' => 'event-date',
'value' => $ends_before_time,
'type' => 'NUMERIC',
'compare' => '<',
),
),
);
$query = new WP_Query( $args );
$post_ids = $query->posts;
return $post_ids;
}
}

View File

@ -0,0 +1,73 @@
<?php
/**
* WP Event Manager.
*
* Defines all the necessary meta information for the Integration of the
* WordPress event plugin "WP Event Manager".
*
* @link https://de.wordpress.org/plugins/wp-event-manager
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
*/
namespace Event_Bridge_For_ActivityPub\Integrations;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Event_Bridge_For_ActivityPub\ActivityPub\Transformer\Event\WP_Event_Manager as WP_Event_Manager_Transformer;
/**
* Interface for a supported event plugin.
*
* This interface defines which information is necessary for a supported event plugin.
*
* @since 1.0.0
*/
final class WP_Event_Manager extends Event_Plugin_Integration {
/**
* Returns the full plugin file.
*
* @return string
*/
public static function get_relative_plugin_file(): string {
return 'wp-event-manager/wp-event-manager.php';
}
/**
* Returns the event post type of the plugin.
*
* @return string
*/
public static function get_post_type(): string {
return 'event_listing';
}
/**
* Returns the IDs of the admin pages of the plugin.
*
* @return array The settings page urls.
*/
public static function get_settings_pages(): array {
return array( 'event-manager-settings' );
}
/**
* Returns the taxonomy used for the plugin's event categories.
*
* @return string
*/
public static function get_event_category_taxonomy(): string {
return 'event_listing_category';
}
/**
* Returns the ActivityPub transformer for a WP_Event_Manager event post.
*
* @param \WP_Post $post The WordPress post object of the Event.
* @return WP_Event_Manager_Transformer
*/
public static function get_activitypub_event_transformer( $post ): WP_Event_Manager_Transformer {
return new WP_Event_Manager_Transformer( $post, self::get_event_category_taxonomy() );
}
}

View File

@ -0,0 +1,42 @@
<?php
/**
* Interface for defining Methods needed for the Event Sources feature.
*
* The Event Sources feature is about following other ActivityPub actors and
* importing their events. That means treating them as cache and listing them.
* Events should be deleted some time after they have ended.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\Integrations;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
/**
* Interface for an event plugin integration that supports the Event Sources feature.
*
* @since 1.0.0
*/
interface Feature_Event_Sources {
/**
* Returns the full class name of the transmogrifier.
*
* @return string
*/
public static function get_transmogrifier(): string;
/**
* Retrieves a list of post IDs for cached remote events that have ended.
*
* Filters the events to include only those that ended before the specified timestamp.
*
* @param int $ends_before_time Unix timestamp. Only events ending before this time will be included.
*
* @return int[] List of post IDs for events that match the criteria.
*/
public static function get_cached_remote_events( $ends_before_time ): array;
}

View File

@ -0,0 +1,243 @@
<?php
/**
* Event Sources Table-Class file.
*
* This table display the event sources (=followed ActivityPub actors) that are used for
* importing (caching and displaying) remote events to the WordPress site.
*
* @package Event_Bridge_For_ActivityPub
* @since 1.0.0
* @license AGPL-3.0-or-later
*/
namespace Event_Bridge_For_ActivityPub\Table;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use WP_List_Table;
use Event_Bridge_For_ActivityPub\ActivityPub\Collection\Event_Sources as Event_Sources_Collection;
use Event_Bridge_For_ActivityPub\ActivityPub\Model\Event_Source;
if ( ! \class_exists( '\WP_List_Table' ) ) {
// @phpstan-ignore-next-line
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
/**
* Event Sources Table-Class.
*/
class Event_Sources extends WP_List_Table {
/**
* Constructor.
*/
public function __construct() {
parent::__construct(
array(
'singular' => \__( 'Event Source', 'event-bridge-for-activitypub' ),
'plural' => \__( 'Event Sources', 'event-bridge-for-activitypub' ),
'ajax' => true,
)
);
}
/**
* Get columns.
*
* @return array
*/
public function get_columns(): array {
return array(
'cb' => '<input type="checkbox" />',
'icon' => \__( 'Icon', 'event-bridge-for-activitypub' ),
'name' => \__( 'Name', 'event-bridge-for-activitypub' ),
'accepted' => \__( 'Follow', 'event-bridge-for-activitypub' ),
'url' => \__( 'URL', 'event-bridge-for-activitypub' ),
'published' => \__( 'Followed', 'event-bridge-for-activitypub' ),
'modified' => \__( 'Last updated', 'event-bridge-for-activitypub' ),
);
}
/**
* Returns sortable columns.
*
* @return array
*/
public function get_sortable_columns(): array {
return array(
'name' => array( 'name', true ),
'modified' => array( 'modified', false ),
'published' => array( 'published', false ),
);
}
/**
* Prepare items.
*/
public function prepare_items(): void {
$columns = $this->get_columns();
$hidden = array();
$this->process_action();
$this->_column_headers = array( $columns, $hidden, $this->get_sortable_columns() );
$page_num = $this->get_pagenum();
$per_page = 20;
$args = array();
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['orderby'] ) ) {
$args['orderby'] = sanitize_text_field( wp_unslash( $_GET['orderby'] ) );
}
if ( isset( $_GET['order'] ) ) {
$args['order'] = sanitize_text_field( wp_unslash( $_GET['order'] ) );
}
if ( isset( $_GET['s'] ) && isset( $_REQUEST['_wpnonce'] ) ) {
$nonce = sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) );
if ( wp_verify_nonce( $nonce, 'bulk-' . $this->_args['plural'] ) ) {
$args['s'] = sanitize_text_field( wp_unslash( $_GET['s'] ) );
}
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$event_sources = Event_Sources_Collection::get_event_sources_with_count( $per_page, $page_num, $args );
$total_count = $event_sources['total'];
$this->items = array();
$this->set_pagination_args(
array(
'total_items' => $total_count,
'total_pages' => (int) ceil( $total_count / $per_page ),
'per_page' => $per_page,
)
);
foreach ( $event_sources['actors'] as $event_source_post_id => $event_source_activitypub_id ) {
$event_source = Event_Source::get_by_id( (int) $event_source_post_id );
if ( ! $event_source || ! in_array( $event_source->get_status(), array( 'publish', 'pending' ), true ) ) {
continue;
}
$item = array(
'icon' => esc_attr( $event_source->get_icon_url() ),
'name' => esc_attr( $event_source->get_name() ),
'url' => esc_attr( $event_source_activitypub_id ),
'accepted' => esc_attr( get_post_meta( $event_source->get__id(), '_event_bridge_for_activitypub_accept_of_follow', true ) ),
'identifier' => esc_attr( $event_source_post_id ),
'published' => esc_attr( $event_source->get_published() ),
'modified' => esc_attr( $event_source->get_updated() ),
);
$this->items[] = $item;
}
}
/**
* Returns bulk actions.
*
* @return array
*/
public function get_bulk_actions(): array {
return array(
'delete' => __( 'Delete', 'event-bridge-for-activitypub' ),
);
}
/**
* Column default.
*
* @param array $item Item.
* @param string $column_name Column name.
* @return string
*/
public function column_default( $item, $column_name ) {
if ( ! array_key_exists( $column_name, $item ) ) {
return __( 'None', 'event-bridge-for-activitypub' );
}
return $item[ $column_name ];
}
/**
* Column avatar.
*
* @param array $item Item.
* @return string
*/
public function column_icon( $item ): string {
return sprintf(
'<img src="%s" width="25px;" />',
$item['icon']
);
}
/**
* Column url.
*
* @param array $item Item.
* @return string
*/
public function column_url( $item ): string {
return sprintf(
'<a href="%s" target="_blank">%s</a>',
esc_url( $item['url'] ),
$item['url']
);
}
/**
* Column cb.
*
* @param array $item Item.
* @return string
*/
public function column_cb( $item ): string {
return sprintf( '<input type="checkbox" name="event_sources[]" value="%s" />', esc_attr( $item['identifier'] ) );
}
/**
* Column action.
*
* @param array $item Item.
* @return string
*/
public function column_accepted( $item ): string {
if ( $item['accepted'] ) {
return esc_html__( 'Accepted', 'event-bridge-for-activitypub' );
} else {
return esc_html__( 'Pending', 'event-bridge-for-activitypub' );
}
}
/**
* Process action.
*/
public function process_action(): void {
if ( ! isset( $_REQUEST['event_sources'] ) || ! isset( $_REQUEST['_wpnonce'] ) ) {
return;
}
$nonce = sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) );
if ( ! wp_verify_nonce( $nonce, 'bulk-' . $this->_args['plural'] ) ) {
return;
}
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$event_sources = $_REQUEST['event_sources']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
if ( ! is_array( $event_sources ) ) {
return;
}
if ( 'delete' === $this->current_action() ) {
foreach ( $event_sources as $event_source ) {
Event_Sources_Collection::remove_event_source( absint( $event_source ) );
}
}
}
}