From 52ab35d79d8aa2a2881133774c8632bd6b38af3b Mon Sep 17 00:00:00 2001 From: Nick Gerakines <12125+ngerakines@users.noreply.github.com> Date: Thu, 22 Jan 2026 21:25:52 -0500 Subject: [PATCH] feature: post RSVP going to feed --- src/config.rs | 2 +- src/http/errors/mod.rs | 2 + src/http/errors/share_bluesky_error.rs | 17 ++ src/http/errors/web_error.rs | 8 + src/http/handle_create_rsvp.rs | 5 + src/http/handle_share_rsvp_bluesky.rs | 165 ++++++++++++++++++ src/http/middleware_auth.rs | 2 +- src/http/mod.rs | 1 + src/http/server.rs | 2 + templates/en-us/create_rsvp.partial.html | 32 ++++ .../en-us/share_rsvp_bluesky.partial.html | 21 +++ 11 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 src/http/errors/share_bluesky_error.rs create mode 100644 src/http/handle_share_rsvp_bluesky.rs create mode 100644 templates/en-us/share_rsvp_bluesky.partial.html diff --git a/src/config.rs b/src/config.rs index aea3f52..5fb7899 100644 --- a/src/config.rs +++ b/src/config.rs @@ -204,7 +204,7 @@ impl Config { /// Returns the OAuth scope string with the external_base interpolated pub fn oauth_scope(&self) -> String { format!( - "atproto include:community.lexicon.calendar.authFull?aud=did:web:{}#smokesignal include:events.smokesignal.authFull blob:*/* account:email", + "atproto include:community.lexicon.calendar.authFull?aud=did:web:{}#smokesignal include:events.smokesignal.authFull include:app.bsky.authCreatePosts blob:*/* account:email", self.external_base ) } diff --git a/src/http/errors/mod.rs b/src/http/errors/mod.rs index a542c45..997b9ed 100644 --- a/src/http/errors/mod.rs +++ b/src/http/errors/mod.rs @@ -11,6 +11,7 @@ pub mod lfg_error; pub mod login_error; pub mod middleware_errors; pub mod profile_import_error; +pub mod share_bluesky_error; pub mod url_error; pub mod view_event_error; pub mod web_error; @@ -24,6 +25,7 @@ pub(crate) use lfg_error::LfgError; pub(crate) use login_error::LoginError; pub(crate) use middleware_errors::WebSessionError; pub(crate) use profile_import_error::ProfileImportError; +pub(crate) use share_bluesky_error::ShareBlueskyError; pub(crate) use url_error::UrlError; pub(crate) use view_event_error::ViewEventError; pub(crate) use web_error::WebError; diff --git a/src/http/errors/share_bluesky_error.rs b/src/http/errors/share_bluesky_error.rs new file mode 100644 index 0000000..9b27f97 --- /dev/null +++ b/src/http/errors/share_bluesky_error.rs @@ -0,0 +1,17 @@ +use thiserror::Error; + +/// Represents errors that can occur when sharing an RSVP to Bluesky. +#[derive(Debug, Error)] +pub(crate) enum ShareBlueskyError { + /// Error when the AT Protocol server returns an error response. + #[error("error-smokesignal-share-bluesky-1 Failed to create post: {message}")] + PostCreationFailed { message: String }, + + /// Error when the event is not found. + #[error("error-smokesignal-share-bluesky-2 Event not found: {aturi}")] + EventNotFound { aturi: String }, + + /// Error when the user does not have a "going" RSVP to the event. + #[error("error-smokesignal-share-bluesky-3 No going RSVP found for event")] + NoGoingRsvp, +} diff --git a/src/http/errors/web_error.rs b/src/http/errors/web_error.rs index b8e95ed..c724c0b 100644 --- a/src/http/errors/web_error.rs +++ b/src/http/errors/web_error.rs @@ -20,6 +20,7 @@ use super::import_error::ImportError; use super::lfg_error::LfgError; use super::login_error::LoginError; use super::middleware_errors::MiddlewareAuthError; +use super::share_bluesky_error::ShareBlueskyError; use super::url_error::UrlError; /// Represents all possible errors that can occur in the HTTP layer. @@ -158,6 +159,13 @@ pub(crate) enum WebError { /// such as creating, viewing, or deactivating LFG records. #[error(transparent)] LfgError(#[from] LfgError), + + /// Share to Bluesky errors. + /// + /// This error occurs when there are issues with sharing RSVP to Bluesky, + /// such as post creation failures or event not found. + #[error(transparent)] + ShareBlueskyError(#[from] ShareBlueskyError), } /// Implementation of Axum's `IntoResponse` trait for WebError. diff --git a/src/http/handle_create_rsvp.rs b/src/http/handle_create_rsvp.rs index 681befc..da915f7 100644 --- a/src/http/handle_create_rsvp.rs +++ b/src/http/handle_create_rsvp.rs @@ -446,6 +446,9 @@ pub(crate) async fn handle_create_rsvp( None }; + // Get event AT-URI for share button + let event_aturi = build_rsvp_form.subject_aturi.clone(); + return Ok(RenderHtml( &render_template, web_context.engine.clone(), @@ -453,6 +456,8 @@ pub(crate) async fn handle_create_rsvp( build_rsvp_form, event_url, redirect_url, + is_going_rsvp, + event_aturi, }}, ) .into_response()); diff --git a/src/http/handle_share_rsvp_bluesky.rs b/src/http/handle_share_rsvp_bluesky.rs new file mode 100644 index 0000000..bf94881 --- /dev/null +++ b/src/http/handle_share_rsvp_bluesky.rs @@ -0,0 +1,165 @@ +use std::collections::HashSet; + +use ammonia::Builder; +use axum::{extract::State, response::IntoResponse}; +use axum_extra::extract::{Cached, Form}; +use axum_htmx::HxRequest; +use axum_template::RenderHtml; +use chrono::Utc; +use minijinja::context as template_context; +use serde::Deserialize; +use serde_json::json; + +use crate::{ + atproto::auth::create_dpop_auth_from_session, + http::{ + context::WebContext, + errors::{CommonError, ShareBlueskyError, WebError}, + middleware_auth::Auth, + middleware_i18n::Language, + utils::{truncate_text, url_from_aturi}, + }, + storage::event::{event_get, extract_event_details, rsvp_get_by_event_and_did}, +}; +use atproto_client::com::atproto::repo::{CreateRecordRequest, CreateRecordResponse, create_record}; + +#[derive(Deserialize)] +pub(crate) struct ShareRsvpBlueskyRequest { + event_aturi: String, +} + +pub(crate) async fn handle_share_rsvp_bluesky( + State(web_context): State, + Language(language): Language, + Cached(auth): Cached, + HxRequest(_hx_request): HxRequest, + Form(request): Form, +) -> Result { + let current_handle = auth.require("/rsvp")?; + let session = auth.session().ok_or(CommonError::NotAuthorized)?; + + let render_template = format!("{}/share_rsvp_bluesky.partial.html", language); + + // Fetch event details + let event = event_get(&web_context.pool, &request.event_aturi) + .await + .map_err(|_| ShareBlueskyError::EventNotFound { + aturi: request.event_aturi.clone(), + })?; + + // Verify the user has a "going" RSVP to this event + let rsvp = rsvp_get_by_event_and_did(&web_context.pool, &request.event_aturi, ¤t_handle.did) + .await? + .ok_or(ShareBlueskyError::NoGoingRsvp)?; + + if rsvp.status != "going" { + return Err(ShareBlueskyError::NoGoingRsvp.into()); + } + + // Build event URL + let event_url = url_from_aturi(&web_context.config.external_base, &request.event_aturi)?; + + // Extract event details for description + let details = extract_event_details(&event); + let description_short = if details.description.is_empty() { + String::new() + } else { + let truncated = truncate_text(&details.description, 200, Some("...".to_string())); + Builder::new() + .tags(HashSet::new()) + .clean(&truncated) + .to_string() + }; + + // Create DPoP auth + let dpop_auth = create_dpop_auth_from_session(session)?; + + // Build the post text + let post_text = format!("I'm going to \"{}\"!", event.name); + + // Build the external embed + let external_embed = json!({ + "$type": "app.bsky.embed.external", + "external": { + "uri": event_url, + "title": event.name, + "description": description_short + } + }); + + // Build the post record + let post_record = json!({ + "$type": "app.bsky.feed.post", + "text": post_text, + "createdAt": Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(), + "embed": external_embed, + "langs": ["en"] + }); + + // Create the record + let create_request = CreateRecordRequest { + repo: current_handle.did.clone(), + collection: "app.bsky.feed.post".to_string(), + validate: true, + record_key: None, + record: post_record, + swap_commit: None, + }; + + let result = create_record( + &web_context.http_client, + &atproto_client::client::Auth::DPoP(dpop_auth), + ¤t_handle.pds, + create_request, + ) + .await; + + match result { + Ok(CreateRecordResponse::StrongRef { uri, .. }) => { + // Build Bluesky post URL + let post_url = build_bluesky_post_url(¤t_handle.handle, &uri); + + Ok(RenderHtml( + &render_template, + web_context.engine.clone(), + template_context! { + success => true, + post_url, + language => language.to_string(), + }, + ) + .into_response()) + } + Ok(CreateRecordResponse::Error(err)) => Ok(RenderHtml( + &render_template, + web_context.engine.clone(), + template_context! { + success => false, + error_message => err.error_message(), + language => language.to_string(), + }, + ) + .into_response()), + Err(err) => Ok(RenderHtml( + &render_template, + web_context.engine.clone(), + template_context! { + success => false, + error_message => err.to_string(), + language => language.to_string(), + }, + ) + .into_response()), + } +} + +/// Build a Bluesky post URL from handle and AT-URI. +/// AT-URI format: at://did:plc:xxx/app.bsky.feed.post/rkey +/// Result: https://bsky.app/profile/{handle}/post/{rkey} +fn build_bluesky_post_url(handle: &str, aturi: &str) -> String { + let rkey = aturi + .strip_prefix("at://") + .and_then(|s| s.split('/').last()) + .unwrap_or(""); + format!("https://bsky.app/profile/{}/post/{}", handle, rkey) +} diff --git a/src/http/middleware_auth.rs b/src/http/middleware_auth.rs index b774ee9..0257b68 100644 --- a/src/http/middleware_auth.rs +++ b/src/http/middleware_auth.rs @@ -16,7 +16,7 @@ use super::errors::middleware_errors::MiddlewareAuthError; /// Cookie name for session storage. /// Updated version to force re-authentication when cookie format changes. -pub(crate) const AUTH_COOKIE_NAME: &str = "session20260118"; +pub(crate) const AUTH_COOKIE_NAME: &str = "session20260122"; #[derive(Clone)] pub(crate) enum Auth { diff --git a/src/http/mod.rs b/src/http/mod.rs index c218d29..5d1968b 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -62,6 +62,7 @@ pub mod handle_quick_event; pub mod handle_search; pub mod handle_set_language; pub mod handle_settings; +pub mod handle_share_rsvp_bluesky; pub mod handle_unaccept_rsvp; pub mod handle_unsubscribe; pub mod handle_view_event; diff --git a/src/http/server.rs b/src/http/server.rs index 248c3ba..29bb739 100644 --- a/src/http/server.rs +++ b/src/http/server.rs @@ -107,6 +107,7 @@ use crate::http::{ handle_notification_preferences_update, handle_profile_update, handle_settings, handle_timezone_update, }, + handle_share_rsvp_bluesky::handle_share_rsvp_bluesky, handle_unaccept_rsvp::handle_unaccept_rsvp, handle_unsubscribe::handle_unsubscribe, handle_view_event::handle_view_event, @@ -347,6 +348,7 @@ pub fn build_router(web_context: WebContext) -> Router { .route("/accept_rsvp", post(handle_accept_rsvp)) .route("/bulk_accept_rsvps", post(handle_bulk_accept_rsvps)) .route("/unaccept_rsvp", post(handle_unaccept_rsvp)) + .route("/share_rsvp_bluesky", post(handle_share_rsvp_bluesky)) .route("/finalize_acceptance", post(handle_finalize_acceptance)) .route( "/event/upload-header", diff --git a/templates/en-us/create_rsvp.partial.html b/templates/en-us/create_rsvp.partial.html index cfd588a..3797ecc 100644 --- a/templates/en-us/create_rsvp.partial.html +++ b/templates/en-us/create_rsvp.partial.html @@ -15,6 +15,22 @@ {% else %}

The RSVP has been recorded!

{% endif %} + {% if is_going_rsvp %} +
+

Let others know you're going!

+ +
+ {% endif %} {% else %} @@ -36,6 +52,22 @@ View Event

+ {% if is_going_rsvp %} +
+

Let others know you're going!

+ +
+ {% endif %} {% endif %} diff --git a/templates/en-us/share_rsvp_bluesky.partial.html b/templates/en-us/share_rsvp_bluesky.partial.html new file mode 100644 index 0000000..ddf529d --- /dev/null +++ b/templates/en-us/share_rsvp_bluesky.partial.html @@ -0,0 +1,21 @@ +{% if success %} + + + Posted to Bluesky! + +

+ + + View Post + + +

+{% else %} + + + Failed to post to Bluesky + +{% if error_message %} +

{{ error_message }}

+{% endif %} +{% endif %} -- 2.51.2