at main 325 lines 11 kB view raw
1//! API functions for collaboration invites. 2 3use crate::fetch::Fetcher; 4use jacquard::IntoStatic; 5use jacquard::prelude::*; 6use jacquard::smol_str::format_smolstr; 7use jacquard::types::string::{AtUri, Cid, Datetime, Did, Nsid}; 8use jacquard::types::uri::Uri; 9use reqwest::Url; 10use std::collections::HashSet; 11use weaver_api::com_atproto::repo::list_records::ListRecords; 12use weaver_api::com_atproto::repo::strong_ref::StrongRef; 13use weaver_api::sh_weaver::collab::{accept::Accept, invite::Invite}; 14use weaver_common::WeaverError; 15use weaver_common::constellation::GetBacklinksQuery; 16 17const ACCEPT_NSID: &str = "sh.weaver.collab.accept"; 18const INVITE_NSID: &str = "sh.weaver.collab.invite"; 19const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; 20 21/// An invite sent by the current user. 22#[derive(Clone, Debug, PartialEq)] 23pub struct SentInvite { 24 pub uri: AtUri<'static>, 25 pub invitee: Did<'static>, 26 pub resource_uri: AtUri<'static>, 27 pub message: Option<String>, 28 pub created_at: Datetime, 29 pub accepted: bool, 30} 31 32/// An invite received by the current user. 33#[derive(Clone, Debug, PartialEq)] 34pub struct ReceivedInvite { 35 pub uri: AtUri<'static>, 36 pub cid: Cid<'static>, 37 pub inviter: Did<'static>, 38 pub resource_uri: AtUri<'static>, 39 pub resource_cid: Cid<'static>, 40 pub message: Option<String>, 41 pub created_at: Datetime, 42} 43 44/// An accepted invite (for listing collaborators). 45#[derive(Clone, Debug, PartialEq)] 46pub struct AcceptedInvite { 47 pub accept_uri: AtUri<'static>, 48 pub collaborator: Did<'static>, 49 pub resource_uri: AtUri<'static>, 50 pub accepted_at: Datetime, 51} 52 53/// Create an invite to collaborate on a resource. 54pub async fn create_invite( 55 fetcher: &Fetcher, 56 resource: StrongRef<'static>, 57 invitee: Did<'static>, 58 message: Option<String>, 59) -> Result<AtUri<'static>, WeaverError> { 60 let mut invite_builder = Invite::new() 61 .resource(resource) 62 .invitee(invitee) 63 .created_at(Datetime::now()); 64 65 if let Some(msg) = message { 66 invite_builder = invite_builder.message(Some(jacquard::CowStr::from(msg))); 67 } 68 69 let invite = invite_builder.build(); 70 71 let output = fetcher 72 .create_record(invite, None) 73 .await 74 .map_err(|e| WeaverError::InvalidNotebook(jacquard::smol_str::format_smolstr!("Failed to create invite: {}", e).into()))?; 75 76 Ok(output.uri.into_static()) 77} 78 79/// Accept a collaboration invite. 80pub async fn accept_invite( 81 fetcher: &Fetcher, 82 invite_ref: StrongRef<'static>, 83 resource_uri: AtUri<'static>, 84) -> Result<AtUri<'static>, WeaverError> { 85 let accept = Accept::new() 86 .invite(invite_ref) 87 .resource(resource_uri) 88 .created_at(Datetime::now()) 89 .build(); 90 91 let output = fetcher 92 .create_record(accept, None) 93 .await 94 .map_err(|e| WeaverError::InvalidNotebook(jacquard::smol_str::format_smolstr!("Failed to accept invite: {}", e).into()))?; 95 96 Ok(output.uri.into_static()) 97} 98 99/// Fetch invites sent by the current user. 100pub async fn fetch_sent_invites(fetcher: &Fetcher) -> Result<Vec<SentInvite>, WeaverError> { 101 let did = fetcher 102 .current_did() 103 .await 104 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 105 106 let request = ListRecords::new() 107 .repo(did) 108 .collection(Nsid::raw(INVITE_NSID)) 109 .limit(100) 110 .build(); 111 112 let response = fetcher 113 .send(request) 114 .await 115 .map_err(|e| WeaverError::InvalidNotebook(jacquard::smol_str::format_smolstr!("Failed to list invites: {}", e).into()))?; 116 117 let output = response.into_output().map_err(|e| { 118 WeaverError::InvalidNotebook(jacquard::smol_str::format_smolstr!("Failed to parse list response: {}", e).into()) 119 })?; 120 121 let mut invites = Vec::new(); 122 for record in output.records { 123 if let Ok(invite) = jacquard::from_data::<Invite>(&record.value) { 124 let uri = record.uri.into_static(); 125 let accepted = check_invite_accepted(fetcher, &uri).await; 126 127 invites.push(SentInvite { 128 uri, 129 invitee: invite.invitee.into_static(), 130 resource_uri: invite.resource.uri.into_static(), 131 message: invite.message.map(|s| s.to_string()), 132 created_at: invite.created_at.clone(), 133 accepted, 134 }); 135 } 136 } 137 138 Ok(invites) 139} 140 141/// Check if an invite has been accepted by querying for accept records. 142async fn check_invite_accepted(fetcher: &Fetcher, invite_uri: &AtUri<'_>) -> bool { 143 let Ok(constellation_url) = Url::parse(CONSTELLATION_URL) else { 144 return false; 145 }; 146 147 // Query for sh.weaver.collab.accept records that reference this invite via .invite.uri 148 let query = GetBacklinksQuery { 149 subject: Uri::At(invite_uri.clone().into_static()), 150 source: jacquard::smol_str::format_smolstr!("{}:invite.uri", ACCEPT_NSID).into(), 151 cursor: None, 152 did: vec![], 153 limit: 1, 154 }; 155 156 let Ok(response) = fetcher.client.xrpc(constellation_url).send(&query).await else { 157 return false; 158 }; 159 160 let Ok(output) = response.into_output() else { 161 return false; 162 }; 163 164 !output.records.is_empty() 165} 166 167/// Fetch invites received by the current user (via Constellation backlinks). 168/// 169/// This queries Constellation to find invite records where the current user 170/// is the invitee, then fetches each record from the inviter's PDS to get 171/// the full invite details. 172pub async fn fetch_received_invites(fetcher: &Fetcher) -> Result<Vec<ReceivedInvite>, WeaverError> { 173 let did = fetcher 174 .current_did() 175 .await 176 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 177 178 let constellation_url = Url::parse(CONSTELLATION_URL) 179 .map_err(|e| WeaverError::InvalidNotebook(jacquard::smol_str::format_smolstr!("Invalid constellation URL: {}", e).into()))?; 180 181 // Query for sh.weaver.collab.invite records where .invitee = current user's DID 182 let query = GetBacklinksQuery { 183 subject: Uri::Did(did.clone()), 184 source: jacquard::smol_str::format_smolstr!("{}:invitee", INVITE_NSID).into(), 185 cursor: None, 186 did: vec![], 187 limit: 100, 188 }; 189 190 let response = fetcher 191 .client 192 .xrpc(constellation_url) 193 .send(&query) 194 .await 195 .map_err(|e| WeaverError::InvalidNotebook(jacquard::smol_str::format_smolstr!("Constellation query failed: {}", e).into()))?; 196 197 let output = response.into_output().map_err(|e| { 198 WeaverError::InvalidNotebook(jacquard::smol_str::format_smolstr!("Failed to parse constellation response: {}", e).into()) 199 })?; 200 201 // For each RecordId, fetch the actual record from the inviter's PDS 202 let mut invites = Vec::new(); 203 204 for record_id in output.records { 205 let inviter_did = record_id.did.into_static(); 206 207 // Build the AT-URI for the invite record 208 let uri_string = jacquard::smol_str::format_smolstr!( 209 "at://{}/{}/{}", 210 inviter_did, 211 INVITE_NSID, 212 record_id.rkey.as_ref() 213 ); 214 let Ok(invite_uri) = AtUri::new(&uri_string) else { 215 continue; 216 }; 217 let invite_uri = invite_uri.into_static(); 218 219 // Fetch the invite record from the inviter's PDS 220 let Ok(response) = fetcher.get_record::<Invite>(&invite_uri).await else { 221 continue; 222 }; 223 224 let Ok(record) = response.into_output() else { 225 continue; 226 }; 227 228 let Some(cid) = record.cid else { 229 continue; 230 }; 231 232 // record.value is already the typed Invite from get_record::<Invite> 233 let invite = &record.value; 234 235 invites.push(ReceivedInvite { 236 uri: record.uri.into_static(), 237 cid: cid.into_static(), 238 inviter: inviter_did, 239 resource_uri: invite.resource.uri.clone().into_static(), 240 resource_cid: invite.resource.cid.clone().into_static(), 241 message: invite.message.as_ref().map(|s| s.to_string()), 242 created_at: invite.created_at.clone(), 243 }); 244 } 245 246 Ok(invites) 247} 248 249/// Find all participants (owner + collaborators) for a resource by its rkey. 250/// 251/// This works regardless of which copy of the entry you're viewing because it 252/// queries for invites by rkey pattern, then collects all involved DIDs. 253pub async fn find_all_participants( 254 fetcher: &Fetcher, 255 resource_uri: &AtUri<'_>, 256) -> Result<Vec<Did<'static>>, WeaverError> { 257 let Some(rkey) = resource_uri.rkey() else { 258 return Ok(vec![]); 259 }; 260 261 let constellation_url = Url::parse(CONSTELLATION_URL) 262 .map_err(|e| WeaverError::InvalidNotebook(jacquard::smol_str::format_smolstr!("Invalid constellation URL: {}", e).into()))?; 263 264 // Query for all invite records that reference entries with this rkey 265 // We search for invites where resource.uri contains the rkey 266 // The source pattern matches the JSON path in the invite record 267 let query = GetBacklinksQuery { 268 subject: Uri::At(resource_uri.clone().into_static()), 269 source: jacquard::smol_str::format_smolstr!("{}:resource.uri", INVITE_NSID).into(), 270 cursor: None, 271 did: vec![], 272 limit: 100, 273 }; 274 275 let mut participants: HashSet<Did<'static>> = HashSet::new(); 276 277 // First try with the exact URI 278 if let Ok(response) = fetcher.client.xrpc(constellation_url.clone()).send(&query).await { 279 if let Ok(output) = response.into_output() { 280 for record_id in &output.records { 281 // The inviter (owner) is the DID that created the invite 282 participants.insert(record_id.did.clone().into_static()); 283 284 // Now we need to fetch the invite to get the invitee 285 let uri_string = jacquard::smol_str::format_smolstr!( 286 "at://{}/{}/{}", 287 record_id.did, 288 INVITE_NSID, 289 record_id.rkey.as_ref() 290 ); 291 if let Ok(invite_uri) = AtUri::new(&uri_string) { 292 if let Ok(response) = fetcher.get_record::<Invite>(&invite_uri).await { 293 if let Ok(record) = response.into_output() { 294 let invite = &record.value; 295 // Check if this invite was accepted 296 if check_invite_accepted(fetcher, &invite_uri.into_static()).await { 297 participants.insert(invite.invitee.clone().into_static()); 298 } 299 } 300 } 301 } 302 } 303 } 304 } 305 306 // Also try querying with the owner's URI if we can determine it 307 // This handles the case where we're viewing from a collaborator's copy 308 let authority_did = match resource_uri.authority() { 309 jacquard::types::ident::AtIdentifier::Did(d) => Some(d.clone().into_static()), 310 _ => None, 311 }; 312 313 if let Some(ref did) = authority_did { 314 participants.insert(did.clone()); 315 } 316 317 // If no participants found via invites, return just the current entry's authority 318 if participants.is_empty() { 319 if let Some(did) = authority_did { 320 return Ok(vec![did]); 321 } 322 } 323 324 Ok(participants.into_iter().collect()) 325}