feat(ap): @mention notification from inbound remote Notes

This commit is contained in:
2026-05-15 05:44:10 +02:00
parent ca1ebc4b68
commit 6c83c193ed
6 changed files with 138 additions and 1 deletions

View File

@@ -55,6 +55,14 @@ pub trait ApObjectHandler: Send + Sync {
/// Called when a remote actor removes a Like from a local thought. /// 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<()>; 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. /// Total number of locally-authored posts across all users.
async fn count_local_posts(&self) -> anyhow::Result<u64>; async fn count_local_posts(&self) -> anyhow::Result<u64>;
} }

View File

@@ -147,7 +147,44 @@ impl ApObjectHandler for ThoughtsObjectHandler {
note.in_reply_to.as_ref(), note.in_reply_to.as_ref(),
) )
.await .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 &note.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( async fn on_update(
@@ -225,6 +262,46 @@ impl ApObjectHandler for ThoughtsObjectHandler {
Ok(()) 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<()> { async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> Result<()> {
let thought_uuid = object_url let thought_uuid = object_url
.path() .path()

View File

@@ -24,6 +24,8 @@ pub struct ThoughtNote {
pub sensitive: bool, pub sensitive: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>, pub summary: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub tag: Vec<serde_json::Value>,
} }
impl ThoughtNote { impl ThoughtNote {
@@ -50,6 +52,7 @@ impl ThoughtNote {
in_reply_to, in_reply_to,
sensitive, sensitive,
summary, summary,
tag: Vec::new(),
} }
} }
} }

View File

@@ -81,6 +81,11 @@ pub enum EventPayload {
connection_type: String, connection_type: String,
page: u32, page: u32,
}, },
MentionReceived {
thought_id: String,
mentioned_user_id: String,
author_user_id: String,
},
} }
impl EventPayload { impl EventPayload {
@@ -104,6 +109,7 @@ impl EventPayload {
Self::ProfileUpdated { .. } => "users.profile_updated", Self::ProfileUpdated { .. } => "users.profile_updated",
Self::FetchRemoteActorPosts { .. } => "federation.fetch_outbox", Self::FetchRemoteActorPosts { .. } => "federation.fetch_outbox",
Self::FetchActorConnections { .. } => "federation.fetch_connections", Self::FetchActorConnections { .. } => "federation.fetch_connections",
Self::MentionReceived { .. } => "mentions.received",
} }
} }
} }
@@ -234,6 +240,15 @@ impl From<&DomainEvent> for EventPayload {
connection_type: connection_type.clone(), connection_type: connection_type.clone(),
page: *page, 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, connection_type,
page, 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")?),
},
}) })
} }
} }

View File

@@ -112,6 +112,23 @@ impl NotificationEventService {
}) })
.await .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(()), _ => Ok(()),
} }
} }

View File

@@ -73,6 +73,11 @@ pub enum DomainEvent {
connection_type: String, connection_type: String,
page: u32, page: u32,
}, },
MentionReceived {
thought_id: ThoughtId,
mentioned_user_id: UserId,
author_user_id: UserId,
},
} }
pub struct EventEnvelope { pub struct EventEnvelope {