docs: AP likes and boost notifications implementation plan
This commit is contained in:
779
docs/superpowers/plans/2026-05-15-ap-likes-boosts.md
Normal file
779
docs/superpowers/plans/2026-05-15-ap-likes-boosts.md
Normal file
@@ -0,0 +1,779 @@
|
||||
# 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<Vec<String>>,
|
||||
undo_liked: Mutex<Vec<String>>,
|
||||
```
|
||||
|
||||
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<DbActor>,
|
||||
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<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::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<dyn ActivityPubRepository>` and `event_publisher: Option<Arc<dyn EventPublisher>>`. 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).
|
||||
Reference in New Issue
Block a user