atproto blogging
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}