25 KiB
ActivityPub Likes & Boost Notifications Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Wire local likes/unlikes to outbound Like/Undo(Like) AP activities, and handle inbound Like and Announce activities so Mastodon interactions create notifications.
Architecture: Four layers of change — domain port extension, ActivityPubService implementation, application-layer federation event routing, and inbox activity handler registration. Inbound likes/boosts publish domain events (LikeAdded/BoostAdded) so the existing notification service picks them up without duplication. A locality guard in federation_event.rs prevents re-broadcasting remote boosts.
Tech Stack: Rust, activitypub_federation crate, async-trait, serde, domain ports.
Files
| Action | File | Purpose |
|---|---|---|
| Modify | crates/domain/src/ports.rs |
Add broadcast_like, broadcast_undo_like to OutboundFederationPort |
| Modify | crates/application/src/services/federation_event.rs |
Add liked/undo_liked to SpyPort; add LikeAdded/LikeRemoved arms; add locality guard to BoostAdded |
| Modify | crates/adapters/activitypub-base/src/activities.rs |
Add LikeActivity struct; LikeActivity::receive; update AnnounceActivity::receive; register in InboxActivities |
| Modify | crates/adapters/activitypub-base/src/content.rs |
Add on_like, on_announce_received to ApObjectHandler trait |
| Modify | crates/adapters/activitypub-base/src/service.rs |
Add broadcast_like_to_inbox, broadcast_undo_like_to_inbox; implement port methods |
| Modify | crates/adapters/activitypub/src/handler.rs |
Implement on_like, on_announce_received in ThoughtsObjectHandler |
Task 1: Extend OutboundFederationPort + SpyPort
Files:
-
Modify:
crates/domain/src/ports.rs -
Modify:
crates/application/src/services/federation_event.rs -
Step 1: Add two methods to
OutboundFederationPortincrates/domain/src/ports.rs
Find OutboundFederationPort (around line 417). Add after broadcast_undo_announce:
/// Send a Like activity to a remote thought author's inbox.
/// Only called when a LOCAL user likes a REMOTE thought (one with an ap_id).
async fn broadcast_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError>;
/// Send Undo(Like) to a remote thought author's inbox.
async fn broadcast_undo_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError>;
- Step 2: Add stubs to
SpyPortincrates/application/src/services/federation_event.rs
Find SpyPort struct (around line 245). Add two fields:
liked: Mutex<Vec<String>>,
undo_liked: Mutex<Vec<String>>,
Find impl OutboundFederationPort for SpyPort. Add after broadcast_undo_announce:
async fn broadcast_like(
&self,
_: &UserId,
ap_id: &str,
_: &str,
) -> Result<(), DomainError> {
self.liked.lock().unwrap().push(ap_id.to_string());
Ok(())
}
async fn broadcast_undo_like(
&self,
_: &UserId,
ap_id: &str,
_: &str,
) -> Result<(), DomainError> {
self.undo_liked.lock().unwrap().push(ap_id.to_string());
Ok(())
}
- Step 3: Verify compilation
cd /mnt/drive/dev/thoughts && cargo build -p domain -p application 2>&1 | grep "^error" | head -10
Expected: no errors (activitypub-base will fail until Task 3 — that's fine, build only those two crates).
- Step 4: Commit
git add crates/domain/src/ports.rs crates/application/src/services/federation_event.rs
git commit -m "feat(domain): add broadcast_like/broadcast_undo_like to OutboundFederationPort"
Task 2: LikeActivity struct + ApObjectHandler trait methods
Files:
- Modify:
crates/adapters/activitypub-base/src/activities.rs - Modify:
crates/adapters/activitypub-base/src/content.rs
Part A — LikeActivity struct (activities.rs)
- Step 1: Add
LikeTypeandLikeActivitytocrates/adapters/activitypub-base/src/activities.rs
Find where AnnounceType is defined (around line 13). Add right after:
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename = "Like")]
pub struct LikeType;
impl Default for LikeType {
fn default() -> Self {
Self
}
}
Find where AnnounceActivity struct is defined (around line 461). Add a LikeActivity struct after it:
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LikeActivity {
pub id: Url,
#[serde(rename = "type")]
pub kind: LikeType,
pub actor: ObjectId<DbActor>,
pub object: Url,
}
Part B — ApObjectHandler trait (content.rs)
- Step 2: Add
on_likeandon_announce_receivedtoApObjectHandlerincrates/adapters/activitypub-base/src/content.rs
Find the ApObjectHandler trait. Add after on_actor_removed:
/// Called when a remote actor likes a local thought.
/// `object_url` is the AP URL of the liked note (e.g. `{base}/thoughts/{uuid}`).
/// `actor_url` is the AP URL of the remote actor who sent the Like.
async fn on_like(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
/// Called when a remote actor boosts (Announce) a local thought.
/// `object_url` is the AP URL of the announced note.
/// `actor_url` is the AP URL of the remote actor who sent the Announce.
async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
- Step 3: Verify compilation of activitypub-base
cd /mnt/drive/dev/thoughts && cargo build -p activitypub-base 2>&1 | grep "^error" | head -10
Expected: errors that ThoughtsObjectHandler in activitypub doesn't implement the new methods — that's fine. activitypub-base itself should compile.
- Step 4: Commit
git add crates/adapters/activitypub-base/src/activities.rs \
crates/adapters/activitypub-base/src/content.rs
git commit -m "feat(activitypub-base): LikeActivity struct + on_like/on_announce_received trait methods"
Task 3: Implement broadcast_like + LikeActivity::receive + AnnounceActivity update
Files:
- Modify:
crates/adapters/activitypub-base/src/service.rs - Modify:
crates/adapters/activitypub-base/src/activities.rs
Part A — ActivityPubService implementation (service.rs)
- Step 1: Add
broadcast_like_to_inboxprivate method toimpl ActivityPubService
Add this private method inside impl ActivityPubService (not inside the port impl block):
pub async fn broadcast_like_to_inbox(
&self,
liker_user_id: uuid::Uuid,
object_ap_id: url::Url,
author_inbox_url: url::Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(liker_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
// Deterministic ID so Undo(Like) can reference the same activity.
let like_id = url::Url::parse(&format!(
"{}/activities/like/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", liker_user_id, object_ap_id).as_bytes(),
)
))?;
let like = crate::activities::LikeActivity {
id: like_id,
kind: Default::default(),
actor: activitypub_federation::fetch::object_id::ObjectId::from(
local_actor.ap_id.clone(),
),
object: object_ap_id,
};
let sends = activitypub_federation::activity_sending::SendActivityTask::prepare(
&activitypub_federation::protocol::context::WithContext::new_default(like),
&local_actor,
vec![author_inbox_url],
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some Like deliveries failed permanently");
}
Ok(())
}
- Step 2: Add
broadcast_undo_like_to_inboxprivate method
Add directly after broadcast_like_to_inbox:
pub async fn broadcast_undo_like_to_inbox(
&self,
liker_user_id: uuid::Uuid,
object_ap_id: url::Url,
author_inbox_url: url::Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(liker_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
// Reconstruct the same deterministic like ID used when the like was sent.
let like_id = url::Url::parse(&format!(
"{}/activities/like/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", liker_user_id, object_ap_id).as_bytes(),
)
))?;
let undo_id =
crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let undo = crate::activities::UndoActivity {
id: undo_id,
kind: Default::default(),
actor: activitypub_federation::fetch::object_id::ObjectId::from(
local_actor.ap_id.clone(),
),
object: serde_json::json!({
"type": "Like",
"id": like_id.to_string(),
"actor": local_actor.ap_id.to_string(),
"object": object_ap_id.to_string(),
}),
};
let sends = activitypub_federation::activity_sending::SendActivityTask::prepare(
&activitypub_federation::protocol::context::WithContext::new_default(undo),
&local_actor,
vec![author_inbox_url],
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some Undo(Like) deliveries failed permanently");
}
Ok(())
}
- Step 3: Implement
broadcast_likeandbroadcast_undo_likeinimpl domain::ports::OutboundFederationPort for ActivityPubService
Find the existing broadcast_undo_announce impl. Add directly after it:
async fn broadcast_like(
&self,
liker_user_id: &domain::value_objects::UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), domain::errors::DomainError> {
let object = url::Url::parse(object_ap_id)
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
let inbox = url::Url::parse(author_inbox_url)
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
self.broadcast_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
.await
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))
}
async fn broadcast_undo_like(
&self,
liker_user_id: &domain::value_objects::UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), domain::errors::DomainError> {
let object = url::Url::parse(object_ap_id)
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
let inbox = url::Url::parse(author_inbox_url)
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
self.broadcast_undo_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
.await
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))
}
Part B — LikeActivity::receive + AnnounceActivity update (activities.rs)
- Step 4: Implement
ActivityforLikeActivityincrates/adapters/activitypub-base/src/activities.rs
Add after the LikeActivity struct definition:
#[async_trait]
impl Activity for LikeActivity {
type DataType = FederationData;
type Error = crate::error::Error;
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring Like from blocked domain");
return Ok(());
}
// Only process if the liked object is on our instance.
if self.object.host_str().unwrap_or("") != data.domain {
return Ok(());
}
data.object_handler
.on_like(&self.object, self.actor.inner())
.await
.map_err(|e| crate::error::Error::Other(e.to_string()))?;
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received like");
Ok(())
}
}
- Step 5: Update
AnnounceActivity::receiveto callon_announce_received
Find AnnounceActivity::receive. After the add_announce call and before the tracing::info!, add:
data.object_handler
.on_announce_received(&self.object, self.actor.inner())
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to process announce notification");
});
- Step 6: Register
LikeActivityinInboxActivitiesenum
Find the InboxActivities enum. Add:
#[serde(rename = "Like")]
Like(LikeActivity),
- Step 7: Verify activitypub-base compiles
cd /mnt/drive/dev/thoughts && cargo build -p activitypub-base 2>&1 | grep "^error" | head -10
Expected: no errors from activitypub-base. (activitypub crate will fail until Task 4.)
- Step 8: Commit
git add crates/adapters/activitypub-base/src/service.rs \
crates/adapters/activitypub-base/src/activities.rs
git commit -m "feat(activitypub-base): broadcast_like/undo_like + LikeActivity inbox handler"
Task 4: Implement on_like and on_announce_received in ThoughtsObjectHandler
Files:
- Modify:
crates/adapters/activitypub/src/handler.rs
ThoughtsObjectHandler has ap_repo: Arc<dyn ActivityPubRepository> and event_publisher: Option<Arc<dyn EventPublisher>>. These are all we need.
Pattern for both methods:
- Parse the thought UUID out of the object URL path (
/thoughts/{uuid}) - Find the remote actor's local user ID via
ap_repo.find_remote_actor_id(actor_url) - Publish the appropriate domain event — the notification service already handles
LikeAddedandBoostAdded
- Step 1: Read
crates/adapters/activitypub/src/handler.rsto understand the struct and existing impls
Look for struct ThoughtsObjectHandler and impl ApObjectHandler for ThoughtsObjectHandler.
- Step 2: Implement
on_likeinimpl ApObjectHandler for ThoughtsObjectHandler
Add:
async fn on_like(&self, object_url: &url::Url, actor_url: &url::Url) -> anyhow::Result<()> {
// Parse thought UUID from path like /thoughts/{uuid}
let thought_uuid = object_url
.path()
.strip_prefix("/thoughts/")
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let thought_uuid = match thought_uuid {
Some(u) => u,
None => {
tracing::debug!(object = %object_url, "on_like: not a local thought URL, skipping");
return Ok(());
}
};
// Resolve the remote actor to a local user ID.
let actor_user_id = self
.ap_repo
.find_remote_actor_id(actor_url)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let actor_user_id = match actor_user_id {
Some(id) => id,
None => {
tracing::debug!(actor = %actor_url, "on_like: remote actor not interned, skipping notification");
return Ok(());
}
};
if let Some(ep) = &self.event_publisher {
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
let like_id = domain::value_objects::LikeId::new();
ep.publish(&domain::events::DomainEvent::LikeAdded {
like_id,
user_id: actor_user_id,
thought_id,
})
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok(())
}
- Step 3: Implement
on_announce_received
Add directly after on_like:
async fn on_announce_received(
&self,
object_url: &url::Url,
actor_url: &url::Url,
) -> anyhow::Result<()> {
// Parse thought UUID from path like /thoughts/{uuid}
let thought_uuid = object_url
.path()
.strip_prefix("/thoughts/")
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let thought_uuid = match thought_uuid {
Some(u) => u,
None => return Ok(()),
};
let actor_user_id = self
.ap_repo
.find_remote_actor_id(actor_url)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let actor_user_id = match actor_user_id {
Some(id) => id,
None => return Ok(()),
};
if let Some(ep) = &self.event_publisher {
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
let boost_id = domain::value_objects::BoostId::new();
ep.publish(&domain::events::DomainEvent::BoostAdded {
boost_id,
user_id: actor_user_id,
thought_id,
})
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
Ok(())
}
- Step 4: Verify full workspace build
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error" | head -10
Expected: no errors.
- Step 5: Run tests
cd /mnt/drive/dev/thoughts && cargo test -p domain -p application 2>&1 | tail -5
- Step 6: Commit
git add crates/adapters/activitypub/src/handler.rs
git commit -m "feat(activitypub): implement on_like and on_announce_received in ThoughtsObjectHandler"
Task 5: federation_event.rs — LikeAdded/LikeRemoved arms + BoostAdded locality guard
Files:
- Modify:
crates/application/src/services/federation_event.rs
The federation service must:
-
BoostAdded: add a locality check so remote boosts (published by Task 4) don't get re-broadcast
-
LikeAdded: fan-out only when a LOCAL user likes a REMOTE thought (has ap_id)
-
LikeRemoved: Undo(Like) when a LOCAL user unlikes a REMOTE thought
-
Step 1: Write tests for the new arms
Find the #[cfg(test)] block in crates/application/src/services/federation_event.rs. Add:
#[tokio::test]
async fn like_added_local_user_remote_thought_broadcasts_like() {
let store = TestStore::default();
let spy = Arc::new(SpyPort::default());
// Set up a remote thought with ap_id
let author = {
let mut u = test_user("remote_author");
u.local = false;
u.inbox_url = Some("https://mastodon.social/users/author/inbox".into());
u
};
let thought = {
let mut t = test_thought(author.id.clone());
t.ap_id = Some("https://mastodon.social/posts/123".into());
t.in_reply_to_url = None;
t
};
let liker = test_user("alice"); // local user
store.users.lock().unwrap().push(author);
store.users.lock().unwrap().push(liker.clone());
store.thoughts.lock().unwrap().push(thought.clone());
let svc = test_service(store, spy.clone());
svc.process(&DomainEvent::LikeAdded {
like_id: LikeId::new(),
user_id: liker.id,
thought_id: thought.id,
})
.await
.unwrap();
assert_eq!(spy.liked.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn like_added_remote_user_skips_broadcast() {
let store = TestStore::default();
let spy = Arc::new(SpyPort::default());
let author = test_user("alice");
let thought = test_thought(author.id.clone()); // local thought, no ap_id
let remote_liker = {
let mut u = test_user("bob");
u.local = false;
u
};
store.users.lock().unwrap().push(author);
store.users.lock().unwrap().push(remote_liker.clone());
store.thoughts.lock().unwrap().push(thought.clone());
let svc = test_service(store, spy.clone());
svc.process(&DomainEvent::LikeAdded {
like_id: LikeId::new(),
user_id: remote_liker.id,
thought_id: thought.id,
})
.await
.unwrap();
assert!(spy.liked.lock().unwrap().is_empty());
}
#[tokio::test]
async fn boost_added_remote_user_skips_broadcast() {
let store = TestStore::default();
let spy = Arc::new(SpyPort::default());
let author = test_user("alice");
let thought = test_thought(author.id.clone());
let remote_booster = {
let mut u = test_user("bob");
u.local = false;
u
};
store.users.lock().unwrap().push(author);
store.users.lock().unwrap().push(remote_booster.clone());
store.thoughts.lock().unwrap().push(thought.clone());
let svc = test_service(store, spy.clone());
svc.process(&DomainEvent::BoostAdded {
boost_id: BoostId::new(),
user_id: remote_booster.id,
thought_id: thought.id,
})
.await
.unwrap();
assert!(spy.announced.lock().unwrap().is_empty());
}
Note: these tests use test_user, test_thought, test_service helpers — read the existing tests in the same file to find these helpers and use the same pattern. If User.local field setters don't exist, set the field directly (it's pub).
- Step 2: Run tests to confirm they fail
cd /mnt/drive/dev/thoughts && cargo test -p application federation_event 2>&1 | grep "FAILED\|error" | head -10
Expected: tests fail (LikeAdded arm not handled, BoostAdded has no locality guard).
- Step 3: Add locality guard to existing
BoostAddedarm
Find the DomainEvent::BoostAdded match arm. Add a locality check at the top:
DomainEvent::BoostAdded {
boost_id: _,
user_id,
thought_id,
} => {
// Only fan-out if the booster is a local user. Remote boosts (from inbound
// Announce activities) must not be re-broadcast to avoid loops.
let booster = match self.users.find_by_id(user_id).await? {
Some(u) if u.local => u,
_ => return Ok(()),
};
let _ = booster; // suppress unused warning — kept for the locality check
let thought = match self.thoughts.find_by_id(thought_id).await? {
Some(t) => t,
None => return Ok(()),
};
let object_ap_id = self.object_ap_id(&thought, thought_id);
self.ap.broadcast_announce(user_id, &object_ap_id).await
}
- Step 4: Add
LikeAddedarm
Find the _ => Ok(()) catch-all at the end of the match event block. Add before it:
DomainEvent::LikeAdded {
like_id: _,
user_id,
thought_id,
} => {
// Only federate: local liker + remote thought (has ap_id + author has inbox).
let liker = match self.users.find_by_id(user_id).await? {
Some(u) if u.local => u,
_ => return Ok(()),
};
let _ = liker;
let thought = match self.thoughts.find_by_id(thought_id).await? {
Some(t) if t.ap_id.is_some() => t,
_ => return Ok(()),
};
let author = match self.users.find_by_id(&thought.user_id).await? {
Some(u) if u.inbox_url.is_some() => u,
_ => return Ok(()),
};
let object_ap_id = thought.ap_id.unwrap();
let inbox_url = author.inbox_url.unwrap();
self.ap
.broadcast_like(user_id, &object_ap_id, &inbox_url)
.await
}
- Step 5: Add
LikeRemovedarm
Add directly after LikeAdded:
DomainEvent::LikeRemoved {
user_id,
thought_id,
} => {
let liker = match self.users.find_by_id(user_id).await? {
Some(u) if u.local => u,
_ => return Ok(()),
};
let _ = liker;
let thought = match self.thoughts.find_by_id(thought_id).await? {
Some(t) if t.ap_id.is_some() => t,
_ => return Ok(()),
};
let author = match self.users.find_by_id(&thought.user_id).await? {
Some(u) if u.inbox_url.is_some() => u,
_ => return Ok(()),
};
let object_ap_id = thought.ap_id.unwrap();
let inbox_url = author.inbox_url.unwrap();
self.ap
.broadcast_undo_like(user_id, &object_ap_id, &inbox_url)
.await
}
- Step 6: Run tests — all should pass
cd /mnt/drive/dev/thoughts && cargo test -p application federation_event 2>&1 | tail -5
Expected: all pass.
- Step 7: Full build + all unit tests
cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error" | head -5
cd /mnt/drive/dev/thoughts && cargo test -p domain -p application 2>&1 | tail -5
- Step 8: Commit
git add crates/application/src/services/federation_event.rs
git commit -m "feat(application): federate local likes + locality guard prevents remote boost re-broadcast"
Notes
- No loop risk: The
BoostAddedlocality guard (u.local) ensures remote boosts published byon_announce_receivedskip federation fan-out. Same guard applies toLikeAdded. - Existing notification service:
LikeAddedandBoostAddedevents published from inbound activity handlers are picked up byNotificationEventServiceunchanged — it already creates notifications for these events. - Deterministic activity IDs: Like and Undo(Like) use
Uuid::new_v5(NAMESPACE_URL, "{user}/{object}")so the Undo can reference the original Like ID without DB storage. - Only remote thoughts get likes federated: Local thoughts liked by local users generate no outbound activity (the like is already recorded locally).