# 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 `OutboundFederationPort` in `crates/domain/src/ports.rs`** Find `OutboundFederationPort` (around line 417). Add after `broadcast_undo_announce`: ```rust /// 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 `SpyPort` in `crates/application/src/services/federation_event.rs`** Find `SpyPort` struct (around line 245). Add two fields: ```rust liked: Mutex>, undo_liked: Mutex>, ``` Find `impl OutboundFederationPort for SpyPort`. Add after `broadcast_undo_announce`: ```rust 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** ```bash 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** ```bash 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 `LikeType` and `LikeActivity` to `crates/adapters/activitypub-base/src/activities.rs`** Find where `AnnounceType` is defined (around line 13). Add right after: ```rust #[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: ```rust #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct LikeActivity { pub id: Url, #[serde(rename = "type")] pub kind: LikeType, pub actor: ObjectId, pub object: Url, } ``` ### Part B — ApObjectHandler trait (content.rs) - [ ] **Step 2: Add `on_like` and `on_announce_received` to `ApObjectHandler` in `crates/adapters/activitypub-base/src/content.rs`** Find the `ApObjectHandler` trait. Add after `on_actor_removed`: ```rust /// 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** ```bash 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** ```bash 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_inbox` private method to `impl ActivityPubService`** Add this private method inside `impl ActivityPubService` (not inside the port impl block): ```rust 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_inbox` private method** Add directly after `broadcast_like_to_inbox`: ```rust 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_like` and `broadcast_undo_like` in `impl domain::ports::OutboundFederationPort for ActivityPubService`** Find the existing `broadcast_undo_announce` impl. Add directly after it: ```rust 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 `Activity` for `LikeActivity` in `crates/adapters/activitypub-base/src/activities.rs`** Add after the `LikeActivity` struct definition: ```rust #[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) -> 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::receive` to call `on_announce_received`** Find `AnnounceActivity::receive`. After the `add_announce` call and before the `tracing::info!`, add: ```rust 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 `LikeActivity` in `InboxActivities` enum** Find the `InboxActivities` enum. Add: ```rust #[serde(rename = "Like")] Like(LikeActivity), ``` - [ ] **Step 7: Verify activitypub-base compiles** ```bash 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** ```bash 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` and `event_publisher: Option>`. These are all we need. Pattern for both methods: 1. Parse the thought UUID out of the object URL path (`/thoughts/{uuid}`) 2. Find the remote actor's local user ID via `ap_repo.find_remote_actor_id(actor_url)` 3. Publish the appropriate domain event — the notification service already handles `LikeAdded` and `BoostAdded` - [ ] **Step 1: Read `crates/adapters/activitypub/src/handler.rs` to understand the struct and existing impls** Look for `struct ThoughtsObjectHandler` and `impl ApObjectHandler for ThoughtsObjectHandler`. - [ ] **Step 2: Implement `on_like` in `impl ApObjectHandler for ThoughtsObjectHandler`** Add: ```rust 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`: ```rust 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** ```bash cd /mnt/drive/dev/thoughts && cargo build 2>&1 | grep "^error" | head -10 ``` Expected: no errors. - [ ] **Step 5: Run tests** ```bash cd /mnt/drive/dev/thoughts && cargo test -p domain -p application 2>&1 | tail -5 ``` - [ ] **Step 6: Commit** ```bash 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: ```rust #[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** ```bash 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 `BoostAdded` arm** Find the `DomainEvent::BoostAdded` match arm. Add a locality check at the top: ```rust 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 `LikeAdded` arm** Find the `_ => Ok(())` catch-all at the end of the `match event` block. Add before it: ```rust 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 `LikeRemoved` arm** Add directly after `LikeAdded`: ```rust 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** ```bash 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** ```bash 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** ```bash 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 `BoostAdded` locality guard (`u.local`) ensures remote boosts published by `on_announce_received` skip federation fan-out. Same guard applies to `LikeAdded`. - **Existing notification service**: `LikeAdded` and `BoostAdded` events published from inbound activity handlers are picked up by `NotificationEventService` unchanged — 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).