feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready #1
@@ -55,6 +55,14 @@ pub trait ApObjectHandler: Send + Sync {
|
||||
/// Called when a remote actor removes a Like from a local thought.
|
||||
async fn on_unlike(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
|
||||
|
||||
/// Called when an inbound Note tags a local user with a Mention.
|
||||
async fn on_mention(
|
||||
&self,
|
||||
thought_ap_id: &Url,
|
||||
mentioned_user_uuid: uuid::Uuid,
|
||||
actor_url: &Url,
|
||||
) -> anyhow::Result<()>;
|
||||
|
||||
/// Total number of locally-authored posts across all users.
|
||||
async fn count_local_posts(&self) -> anyhow::Result<u64>;
|
||||
}
|
||||
|
||||
@@ -147,7 +147,44 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
note.in_reply_to.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
|
||||
// Fire mention notifications for local @mentions in the note's tag array.
|
||||
let base_url = url::Url::parse(&self.urls.base_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|h| h.to_string()))
|
||||
.unwrap_or_default();
|
||||
|
||||
for tag in ¬e.tag {
|
||||
if tag.get("type").and_then(|t| t.as_str()) != Some("Mention") {
|
||||
continue;
|
||||
}
|
||||
let href = match tag.get("href").and_then(|h| h.as_str()) {
|
||||
Some(h) => h,
|
||||
None => continue,
|
||||
};
|
||||
let href_url = match url::Url::parse(href) {
|
||||
Ok(u) => u,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if href_url.host_str().unwrap_or("") != base_url {
|
||||
continue;
|
||||
}
|
||||
let user_uuid = href_url
|
||||
.path()
|
||||
.strip_prefix("/users/")
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
if let Some(uuid) = user_uuid {
|
||||
self.on_mention(ap_id, uuid, actor_url)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "failed to process mention notification");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_update(
|
||||
@@ -225,6 +262,46 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_mention(
|
||||
&self,
|
||||
thought_ap_id: &url::Url,
|
||||
mentioned_user_uuid: uuid::Uuid,
|
||||
actor_url: &url::Url,
|
||||
) -> anyhow::Result<()> {
|
||||
let author_user_id = match self
|
||||
.repo
|
||||
.find_remote_actor_id(actor_url)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?
|
||||
{
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let thought_uuid = thought_ap_id
|
||||
.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(()),
|
||||
};
|
||||
|
||||
if let Some(ep) = &self.event_publisher {
|
||||
ep.publish(&domain::events::DomainEvent::MentionReceived {
|
||||
thought_id: domain::value_objects::ThoughtId::from_uuid(thought_uuid),
|
||||
mentioned_user_id: domain::value_objects::UserId::from_uuid(mentioned_user_uuid),
|
||||
author_user_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> Result<()> {
|
||||
let thought_uuid = object_url
|
||||
.path()
|
||||
|
||||
@@ -24,6 +24,8 @@ pub struct ThoughtNote {
|
||||
pub sensitive: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub tag: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ThoughtNote {
|
||||
@@ -50,6 +52,7 @@ impl ThoughtNote {
|
||||
in_reply_to,
|
||||
sensitive,
|
||||
summary,
|
||||
tag: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,11 @@ pub enum EventPayload {
|
||||
connection_type: String,
|
||||
page: u32,
|
||||
},
|
||||
MentionReceived {
|
||||
thought_id: String,
|
||||
mentioned_user_id: String,
|
||||
author_user_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventPayload {
|
||||
@@ -104,6 +109,7 @@ impl EventPayload {
|
||||
Self::ProfileUpdated { .. } => "users.profile_updated",
|
||||
Self::FetchRemoteActorPosts { .. } => "federation.fetch_outbox",
|
||||
Self::FetchActorConnections { .. } => "federation.fetch_connections",
|
||||
Self::MentionReceived { .. } => "mentions.received",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,6 +240,15 @@ impl From<&DomainEvent> for EventPayload {
|
||||
connection_type: connection_type.clone(),
|
||||
page: *page,
|
||||
},
|
||||
DomainEvent::MentionReceived {
|
||||
thought_id,
|
||||
mentioned_user_id,
|
||||
author_user_id,
|
||||
} => Self::MentionReceived {
|
||||
thought_id: thought_id.to_string(),
|
||||
mentioned_user_id: mentioned_user_id.to_string(),
|
||||
author_user_id: author_user_id.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,6 +388,18 @@ impl TryFrom<EventPayload> for DomainEvent {
|
||||
connection_type,
|
||||
page,
|
||||
},
|
||||
EventPayload::MentionReceived {
|
||||
thought_id,
|
||||
mentioned_user_id,
|
||||
author_user_id,
|
||||
} => DomainEvent::MentionReceived {
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
mentioned_user_id: UserId::from_uuid(parse_uuid(
|
||||
&mentioned_user_id,
|
||||
"mentioned_user_id",
|
||||
)?),
|
||||
author_user_id: UserId::from_uuid(parse_uuid(&author_user_id, "author_user_id")?),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,23 @@ impl NotificationEventService {
|
||||
})
|
||||
.await
|
||||
}
|
||||
DomainEvent::MentionReceived {
|
||||
thought_id,
|
||||
mentioned_user_id,
|
||||
author_user_id,
|
||||
} => {
|
||||
self.notifications
|
||||
.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: mentioned_user_id.clone(),
|
||||
notification_type: NotificationType::Mention,
|
||||
from_user_id: Some(author_user_id.clone()),
|
||||
thought_id: Some(thought_id.clone()),
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,11 @@ pub enum DomainEvent {
|
||||
connection_type: String,
|
||||
page: u32,
|
||||
},
|
||||
MentionReceived {
|
||||
thought_id: ThoughtId,
|
||||
mentioned_user_id: UserId,
|
||||
author_user_id: UserId,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct EventEnvelope {
|
||||
|
||||
Reference in New Issue
Block a user