Files
movies-diary/crates/adapters/activitypub/src/event_handler.rs

534 lines
17 KiB
Rust

use async_trait::async_trait;
use chrono::Datelike;
use domain::ports::EventHandler;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{
GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository,
UserFederationSettingsQuery,
},
value_objects::{InstanceIdentity, MovieId, ReviewId, UserId},
};
use std::sync::Arc;
use k_ap::{ActivityPubService, ApVisibility};
use crate::objects::{ReviewApInput, goal_to_ap_object, review_to_ap_object};
use crate::urls::{actor_url, goal_url, review_url};
pub struct ActivityPubEventHandler {
ap_service: Arc<ActivityPubService>,
content_query: Arc<dyn LocalApContentQuery>,
review_repo: Arc<dyn ReviewRepository>,
movie_repo: Arc<dyn MovieQuery>,
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
instance: InstanceIdentity,
}
impl ActivityPubEventHandler {
#[allow(clippy::too_many_arguments)]
pub fn new(
ap_service: Arc<ActivityPubService>,
content_query: Arc<dyn LocalApContentQuery>,
review_repo: Arc<dyn ReviewRepository>,
movie_repo: Arc<dyn MovieQuery>,
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
instance: InstanceIdentity,
) -> Self {
Self {
ap_service,
content_query,
review_repo,
movie_repo,
goal_repo,
stats_repo,
federation_settings,
instance,
}
}
}
#[async_trait]
impl EventHandler for ActivityPubEventHandler {
fn name(&self) -> &'static str {
"activitypub"
}
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
match event {
DomainEvent::ReviewLogged {
review_id, user_id, ..
} => self
.on_review_logged(user_id, review_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::ReviewUpdated {
review_id, user_id, ..
} => self
.on_review_updated(user_id, review_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::ReviewDeleted { review_id, user_id } => self
.on_review_deleted(user_id, review_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::UserUpdated { user_id } => self
.ap_service
.broadcast_actor_update(user_id.value())
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::WatchlistEntryAdded {
user_id,
movie_id,
movie_title,
release_year,
external_metadata_id,
added_at,
} => self
.on_watchlist_added(
user_id,
movie_id,
movie_title,
*release_year,
external_metadata_id,
added_at,
)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => self
.on_watchlist_removed(user_id, movie_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::FederationDeliveryRequested {
inbox_url,
activity_json,
signing_actor_id,
} => {
let inbox: url::Url = inbox_url
.parse()
.map_err(|e| DomainError::InfrastructureError(format!("bad inbox URL: {e}")))?;
self.ap_service
.deliver_to_inbox(inbox, activity_json.clone(), *signing_actor_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
DomainEvent::PosterSynced { movie_id } => self
.on_poster_synced(movie_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::GoalCreated {
user_id,
year,
target_count,
..
} => self
.broadcast_goal(user_id, *year, *target_count, true)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::GoalUpdated {
user_id,
year,
target_count,
..
} => self
.broadcast_goal(user_id, *year, *target_count, false)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::GoalDeleted { user_id, year, .. } => self
.on_goal_deleted(user_id, *year)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::UserDeleted { user_id } => {
let ap_id = actor_url(&self.instance, user_id.value());
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
DomainEvent::UserAccountMoved {
user_id,
new_actor_url,
} => {
let target = new_actor_url.parse::<url::Url>().map_err(|e| {
DomainError::InfrastructureError(format!("invalid new_actor_url: {e}"))
})?;
self.ap_service
.broadcast_move(user_id.value(), target)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
_ => Ok(()),
}
}
}
impl ActivityPubEventHandler {
async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or_default();
if !flags.reviews {
return Ok(());
}
let review = match self.review_repo.get_review_by_id(review_id).await? {
Some(r) => r,
None => return Ok(()),
};
let ap_id = review_url(&self.instance, review_id);
let actor = actor_url(&self.instance, user_id.value());
let movie = self
.movie_repo
.get_movie_by_id(review.movie_id())
.await
.ok()
.flatten();
let movie_title = movie
.as_ref()
.map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let release_year = movie
.as_ref()
.map(|m| m.release_year().value())
.unwrap_or(0);
let obj = review_to_ap_object(
&review,
ReviewApInput {
ap_id: ap_id.clone(),
actor_url: actor,
movie_title,
release_year,
external_metadata_id: movie
.as_ref()
.and_then(|m| m.external_metadata_id())
.map(|id| id.value().to_string()),
poster_url: movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| self.instance.image_url_for(p.value())),
base_url: self.instance.base_url().to_string(),
},
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
let year = review.watched_at().year() as u16;
self.broadcast_goal_progress_update(user_id, year).await?;
Ok(())
}
async fn on_review_updated(
&self,
user_id: &UserId,
review_id: &ReviewId,
) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or_default();
if !flags.reviews {
return Ok(());
}
let review = match self.review_repo.get_review_by_id(review_id).await? {
Some(r) => r,
None => return Ok(()),
};
let ap_id = review_url(&self.instance, review_id);
let actor = actor_url(&self.instance, user_id.value());
let movie = self
.movie_repo
.get_movie_by_id(review.movie_id())
.await
.ok()
.flatten();
let movie_title = movie
.as_ref()
.map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let release_year = movie
.as_ref()
.map(|m| m.release_year().value())
.unwrap_or(0);
let obj = review_to_ap_object(
&review,
ReviewApInput {
ap_id,
actor_url: actor,
movie_title,
release_year,
external_metadata_id: movie
.as_ref()
.and_then(|m| m.external_metadata_id())
.map(|id| id.value().to_string()),
poster_url: movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| self.instance.image_url_for(p.value())),
base_url: self.instance.base_url().to_string(),
},
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
Ok(())
}
async fn on_review_deleted(
&self,
user_id: &UserId,
review_id: &ReviewId,
) -> anyhow::Result<()> {
let ap_id = review_url(&self.instance, review_id);
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;
Ok(())
}
async fn on_watchlist_added(
&self,
user_id: &UserId,
movie_id: &domain::value_objects::MovieId,
movie_title: &str,
release_year: u16,
external_metadata_id: &Option<String>,
added_at: &chrono::NaiveDateTime,
) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or_default();
if !flags.watchlist {
return Ok(());
}
use crate::urls::watchlist_entry_url;
let ap_id = watchlist_entry_url(&self.instance, user_id.value(), movie_id.value());
let actor = actor_url(&self.instance, user_id.value());
let poster_url = self
.movie_repo
.get_movie_by_id(movie_id)
.await
.ok()
.flatten()
.and_then(|m| {
m.poster_path()
.map(|p| self.instance.image_url_for(p.value()))
});
let added_at_utc =
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(*added_at, chrono::Utc);
let obj = crate::objects::watchlist_to_ap_object(crate::objects::WatchlistApInput {
ap_id: ap_id.clone(),
actor_url: actor,
movie_title: movie_title.to_string(),
release_year,
external_metadata_id: external_metadata_id.clone(),
poster_url,
added_at: added_at_utc,
base_url: self.instance.base_url().to_string(),
});
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
Ok(())
}
async fn on_watchlist_removed(
&self,
user_id: &UserId,
movie_id: &domain::value_objects::MovieId,
) -> anyhow::Result<()> {
use crate::urls::watchlist_entry_url;
let ap_id = watchlist_entry_url(&self.instance, user_id.value(), movie_id.value());
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;
Ok(())
}
async fn on_poster_synced(&self, movie_id: &MovieId) -> anyhow::Result<()> {
let entries = self
.content_query
.get_local_reviews_for_movie(movie_id)
.await?;
let movie = self.movie_repo.get_movie_by_id(movie_id).await?;
let movie = match movie {
Some(m) => m,
None => return Ok(()),
};
let external_metadata_id = movie
.external_metadata_id()
.map(|id| id.value().to_string());
let poster_url = movie
.poster_path()
.map(|p| self.instance.image_url_for(p.value()));
for entry in entries {
let review = entry.review();
let user_id = review.user_id();
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or_default();
if !flags.reviews {
continue;
}
let ap_id = review_url(&self.instance, review.id());
let actor = actor_url(&self.instance, user_id.value());
let obj = review_to_ap_object(
review,
ReviewApInput {
ap_id,
actor_url: actor,
movie_title: movie.title().value().to_string(),
release_year: movie.release_year().value(),
external_metadata_id: external_metadata_id.clone(),
poster_url: poster_url.clone(),
base_url: self.instance.base_url().to_string(),
},
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
}
Ok(())
}
async fn broadcast_goal_progress_update(
&self,
user_id: &UserId,
year: u16,
) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or_default();
if !flags.goals {
return Ok(());
}
let Some(goal) = self
.goal_repo
.find_by_user_and_year(user_id, year)
.await
.ok()
.flatten()
else {
return Ok(());
};
let current = self
.stats_repo
.count_reviews_in_year(user_id, year)
.await
.unwrap_or(0);
let ap_id = goal_url(&self.instance, user_id.value(), year);
let actor = actor_url(&self.instance, user_id.value());
let obj = goal_to_ap_object(
ap_id,
actor,
year,
goal.target_count(),
current,
self.instance.base_url(),
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
Ok(())
}
async fn broadcast_goal(
&self,
user_id: &UserId,
year: u16,
target_count: u32,
is_create: bool,
) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or_default();
if !flags.goals {
return Ok(());
}
let current = self
.stats_repo
.count_reviews_in_year(user_id, year)
.await
.unwrap_or(0);
let ap_id = goal_url(&self.instance, user_id.value(), year);
let actor = actor_url(&self.instance, user_id.value());
let obj = goal_to_ap_object(
ap_id,
actor,
year,
target_count,
current,
self.instance.base_url(),
);
let json = serde_json::to_value(obj)?;
if is_create {
self.ap_service
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
} else {
self.ap_service
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
}
Ok(())
}
async fn on_goal_deleted(&self, user_id: &UserId, year: u16) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or_default();
if !flags.goals {
return Ok(());
}
let ap_id = goal_url(&self.instance, user_id.value(), year);
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;
Ok(())
}
}