Skip to content

Commit 73fd87d

Browse files
committed
Add ActivityPub plugin adapter
1 parent 688a155 commit 73fd87d

2 files changed

Lines changed: 395 additions & 0 deletions

File tree

Lines changed: 389 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,389 @@
1+
<?php
2+
/**
3+
* ActivityPub Plugin Adapter for Microsub.
4+
*
5+
* @package Microsub
6+
*/
7+
8+
namespace Microsub\Adapters;
9+
10+
use Microsub\Adapter;
11+
12+
/**
13+
* ActivityPub Adapter
14+
*
15+
* Provides Microsub functionality using the ActivityPub plugin as backend.
16+
*
17+
* @see https://github.com/Automattic/wordpress-activitypub
18+
*/
19+
class ActivityPub extends Adapter {
20+
21+
/**
22+
* Adapter identifier.
23+
*
24+
* @var string
25+
*/
26+
protected $id = 'activitypub';
27+
28+
/**
29+
* Adapter name.
30+
*
31+
* @var string
32+
*/
33+
protected $name = 'ActivityPub';
34+
35+
/**
36+
* Check if the ActivityPub plugin is available.
37+
*
38+
* @return bool
39+
*/
40+
public static function is_available() {
41+
return \defined( 'ACTIVITYPUB_PLUGIN_VERSION' );
42+
}
43+
44+
/**
45+
* Check if this adapter can handle following the given URL.
46+
*
47+
* ActivityPub can handle URLs that resolve to ActivityPub actors.
48+
*
49+
* @param string $url The URL to check.
50+
* @return bool True if this adapter can handle the URL.
51+
*/
52+
public function can_handle_url( $url ) {
53+
if ( ! self::is_available() ) {
54+
return false;
55+
}
56+
57+
// Check if URL resolves to an ActivityPub actor.
58+
$actor = \Activitypub\get_remote_metadata_by_actor( $url );
59+
60+
return ! \is_wp_error( $actor ) && ! empty( $actor );
61+
}
62+
63+
/**
64+
* Check if this adapter owns/manages the given feed URL.
65+
*
66+
* @param string $url The feed URL to check.
67+
* @return bool True if this adapter owns the feed.
68+
*/
69+
public function owns_feed( $url ) {
70+
if ( ! self::is_available() ) {
71+
return false;
72+
}
73+
74+
$actor = $this->get_actor_by_url( $url );
75+
return ! empty( $actor );
76+
}
77+
78+
/**
79+
* Get an actor post by URL.
80+
*
81+
* @param string $url The actor URL.
82+
* @return \WP_Post|null The actor post or null.
83+
*/
84+
protected function get_actor_by_url( $url ) {
85+
$actors = \get_posts(
86+
array(
87+
'post_type' => \Activitypub\Collection\Remote_Actors::POST_TYPE,
88+
'post_status' => 'publish',
89+
'posts_per_page' => 1,
90+
'meta_query' => array(
91+
'relation' => 'OR',
92+
array(
93+
'key' => '_activitypub_actor_id',
94+
'value' => $url,
95+
),
96+
array(
97+
'key' => '_activitypub_canonical_url',
98+
'value' => $url,
99+
),
100+
),
101+
)
102+
);
103+
104+
return ! empty( $actors ) ? $actors[0] : null;
105+
}
106+
107+
/**
108+
* Get list of channels.
109+
*
110+
* @param array $channels Current channels array from other adapters.
111+
* @param int $user_id The user ID.
112+
* @return array Array of channels with 'uid' and 'name'.
113+
*/
114+
public function get_channels( $channels, $user_id ) {
115+
if ( ! self::is_available() ) {
116+
return $channels;
117+
}
118+
119+
// Add ActivityPub channel.
120+
$channels[] = array(
121+
'uid' => 'activitypub',
122+
'name' => \__( 'ActivityPub', 'microsub' ),
123+
);
124+
125+
return $channels;
126+
}
127+
128+
/**
129+
* Get timeline entries for a channel.
130+
*
131+
* @param array $result Current result with 'items' from other adapters.
132+
* @param string $channel Channel UID.
133+
* @param array $args Query arguments (after, before, limit).
134+
* @return array Timeline data with 'items' and optional 'paging'.
135+
*/
136+
public function get_timeline( $result, $channel, $args ) {
137+
if ( ! self::is_available() ) {
138+
return $result;
139+
}
140+
141+
// Only handle 'activitypub' channel.
142+
if ( 'activitypub' !== $channel ) {
143+
return $result;
144+
}
145+
146+
$limit = isset( $args['limit'] ) ? \absint( $args['limit'] ) : 20;
147+
148+
// Get posts from inbox.
149+
$query_args = array(
150+
'post_type' => \Activitypub\Collection\Inbox::POST_TYPE,
151+
'post_status' => 'publish',
152+
'posts_per_page' => $limit,
153+
'orderby' => 'date',
154+
'order' => 'DESC',
155+
);
156+
157+
// Handle cursor-based pagination.
158+
if ( ! empty( $args['after'] ) ) {
159+
$query_args['date_query'] = array(
160+
array(
161+
'before' => $args['after'],
162+
),
163+
);
164+
}
165+
166+
if ( ! empty( $args['before'] ) ) {
167+
$query_args['date_query'] = array(
168+
array(
169+
'after' => $args['before'],
170+
),
171+
);
172+
$query_args['order'] = 'ASC';
173+
}
174+
175+
$query = new \WP_Query( $query_args );
176+
177+
foreach ( $query->posts as $post ) {
178+
$item = $this->post_to_jf2( $post );
179+
if ( $item ) {
180+
$result['items'][] = $item;
181+
}
182+
}
183+
184+
return $result;
185+
}
186+
187+
/**
188+
* Get list of followed feeds in a channel.
189+
*
190+
* @param array $result Current result from other adapters.
191+
* @param string $channel Channel UID.
192+
* @param int $user_id The user ID.
193+
* @return array Array of feed objects with 'type' and 'url'.
194+
*/
195+
public function get_following( $result, $channel, $user_id ) {
196+
if ( ! self::is_available() ) {
197+
return $result;
198+
}
199+
200+
// Only handle 'activitypub' channel.
201+
if ( 'activitypub' !== $channel ) {
202+
return $result;
203+
}
204+
205+
// Get followed actors.
206+
$actors = \get_posts(
207+
array(
208+
'post_type' => \Activitypub\Collection\Remote_Actors::POST_TYPE,
209+
'post_status' => 'publish',
210+
'posts_per_page' => -1,
211+
'meta_query' => array(
212+
array(
213+
'key' => \Activitypub\Collection\Following::FOLLOWING_META_KEY,
214+
'value' => $user_id,
215+
'compare' => '=',
216+
),
217+
),
218+
)
219+
);
220+
221+
foreach ( $actors as $actor ) {
222+
$actor_id = \get_post_meta( $actor->ID, '_activitypub_actor_id', true );
223+
224+
$feed = array(
225+
'type' => 'feed',
226+
'url' => $actor_id ?: $actor->guid,
227+
'_id' => \md5( $actor_id ?: $actor->guid ),
228+
);
229+
230+
if ( $actor->post_title ) {
231+
$feed['name'] = $actor->post_title;
232+
}
233+
234+
$icon = \get_post_meta( $actor->ID, '_activitypub_icon', true );
235+
if ( $icon ) {
236+
$feed['photo'] = \is_array( $icon ) ? ( $icon['url'] ?? '' ) : $icon;
237+
}
238+
239+
$result[] = $feed;
240+
}
241+
242+
return $result;
243+
}
244+
245+
/**
246+
* Follow a URL.
247+
*
248+
* @param array|null $result Current result or null.
249+
* @param string $channel Channel UID.
250+
* @param string $url URL to follow.
251+
* @param int $user_id The user ID.
252+
* @return array|null Feed data on success, null to pass to next adapter.
253+
*/
254+
public function follow( $result, $channel, $url, $user_id ) {
255+
if ( ! self::is_available() ) {
256+
return $result;
257+
}
258+
259+
// Check if we can handle this URL.
260+
if ( ! $this->can_handle_url( $url ) ) {
261+
return $result; // Pass to next adapter.
262+
}
263+
264+
// Use ActivityPub follow function.
265+
if ( \function_exists( '\Activitypub\follow' ) ) {
266+
$follow_result = \Activitypub\follow( $url, $user_id );
267+
268+
if ( ! \is_wp_error( $follow_result ) ) {
269+
return array(
270+
'type' => 'feed',
271+
'url' => $url,
272+
);
273+
}
274+
}
275+
276+
return $result;
277+
}
278+
279+
/**
280+
* Unfollow a URL.
281+
*
282+
* @param bool|null $result Current result or null.
283+
* @param string $channel Channel UID.
284+
* @param string $url URL to unfollow.
285+
* @param int $user_id The user ID.
286+
* @return bool|null True on success, false on failure, null to pass to next adapter.
287+
*/
288+
public function unfollow( $result, $channel, $url, $user_id ) {
289+
if ( ! self::is_available() ) {
290+
return $result;
291+
}
292+
293+
// Check if we own this feed.
294+
$actor = $this->get_actor_by_url( $url );
295+
296+
if ( ! $actor ) {
297+
return $result; // Pass to next adapter.
298+
}
299+
300+
// Use ActivityPub unfollow function.
301+
if ( \function_exists( '\Activitypub\unfollow' ) ) {
302+
$unfollow_result = \Activitypub\unfollow( $actor->ID, $user_id );
303+
304+
if ( ! \is_wp_error( $unfollow_result ) ) {
305+
return true;
306+
}
307+
}
308+
309+
return false;
310+
}
311+
312+
/**
313+
* Convert an inbox post to jf2 format.
314+
*
315+
* @param \WP_Post $post The post object.
316+
* @return array|null jf2 formatted entry or null.
317+
*/
318+
protected function post_to_jf2( $post ) {
319+
$activity = \get_post_meta( $post->ID, '_activitypub_activity', true );
320+
321+
if ( empty( $activity ) ) {
322+
return null;
323+
}
324+
325+
$object = $activity['object'] ?? $activity;
326+
327+
$jf2 = array(
328+
'type' => 'entry',
329+
'_id' => (string) $post->ID,
330+
'published' => \get_the_date( 'c', $post ),
331+
);
332+
333+
// URL.
334+
if ( ! empty( $object['url'] ) ) {
335+
$jf2['url'] = \is_array( $object['url'] ) ? $object['url'][0] : $object['url'];
336+
} elseif ( ! empty( $object['id'] ) ) {
337+
$jf2['url'] = $object['id'];
338+
}
339+
340+
// Name/Title.
341+
if ( ! empty( $object['name'] ) ) {
342+
$jf2['name'] = $object['name'];
343+
}
344+
345+
// Content.
346+
if ( ! empty( $object['content'] ) ) {
347+
$jf2['content'] = array(
348+
'html' => $object['content'],
349+
'text' => \wp_strip_all_tags( $object['content'] ),
350+
);
351+
} elseif ( ! empty( $object['summary'] ) ) {
352+
$jf2['content'] = array(
353+
'text' => $object['summary'],
354+
);
355+
}
356+
357+
// Author.
358+
$actor = $object['attributedTo'] ?? ( $activity['actor'] ?? null );
359+
if ( $actor ) {
360+
$actor_data = \is_string( $actor )
361+
? \Activitypub\get_remote_metadata_by_actor( $actor )
362+
: $actor;
363+
364+
if ( ! \is_wp_error( $actor_data ) && ! empty( $actor_data ) ) {
365+
$jf2['author'] = array(
366+
'type' => 'card',
367+
'name' => $actor_data['name'] ?? ( $actor_data['preferredUsername'] ?? '' ),
368+
'url' => $actor_data['url'] ?? ( $actor_data['id'] ?? '' ),
369+
);
370+
371+
if ( ! empty( $actor_data['icon'] ) ) {
372+
$icon = $actor_data['icon'];
373+
$jf2['author']['photo'] = \is_array( $icon ) ? ( $icon['url'] ?? '' ) : $icon;
374+
}
375+
}
376+
}
377+
378+
// Images.
379+
if ( ! empty( $object['attachment'] ) ) {
380+
foreach ( $object['attachment'] as $attachment ) {
381+
if ( isset( $attachment['mediaType'] ) && \str_starts_with( $attachment['mediaType'], 'image/' ) ) {
382+
$jf2['photo'][] = $attachment['url'] ?? '';
383+
}
384+
}
385+
}
386+
387+
return $jf2;
388+
}
389+
}

0 commit comments

Comments
 (0)