atproto libraries implementation in ocaml
1(** High-level API Agent for AT Protocol.
2
3 This module provides a user-friendly interface for common AT Protocol
4 operations like authentication, posting, following, and reading feeds. *)
5
6open Atproto_syntax
7open Atproto_xrpc
8
9type session = {
10 did : string;
11 handle : string;
12 access_jwt : string;
13 refresh_jwt : string option;
14 pds_endpoint : Uri.t;
15}
16
17type t = { client : Client.t; session : session option }
18
19type error =
20 | Not_authenticated
21 | Xrpc_error of Client.error
22 | Parse_error of string
23 | Invalid_response of string
24
25let error_to_string = function
26 | Not_authenticated -> "Not authenticated"
27 | Xrpc_error e -> Client.error_to_string e
28 | Parse_error msg -> Printf.sprintf "Parse error: %s" msg
29 | Invalid_response msg -> Printf.sprintf "Invalid response: %s" msg
30
31let create ~pds =
32 let client = Client.of_uri pds in
33 { client; session = None }
34
35let create_from_url ~url =
36 let client = Client.create ~base_url:url in
37 { client; session = None }
38
39let client t = t.client
40let is_authenticated t = Option.is_some t.session
41let session t = t.session
42let did t = Option.map (fun s -> s.did) t.session
43let handle t = Option.map (fun s -> s.handle) t.session
44
45let login t ~identifier ~password =
46 match Client.create_session t.client ~identifier ~password with
47 | Error e -> Error (Xrpc_error e)
48 | Ok json -> (
49 match Atproto_json.to_object_opt json with
50 | Some pairs -> (
51 match
52 ( Atproto_json.get_string_opt "did" pairs,
53 Atproto_json.get_string_opt "handle" pairs,
54 Atproto_json.get_string_opt "accessJwt" pairs )
55 with
56 | Some did, Some handle, Some access_jwt ->
57 let refresh_jwt =
58 Atproto_json.get_string_opt "refreshJwt" pairs
59 in
60 let session =
61 {
62 did;
63 handle;
64 access_jwt;
65 refresh_jwt;
66 pds_endpoint = Client.base_url t.client;
67 }
68 in
69 let client = Client.with_auth ~token:access_jwt t.client in
70 Ok { client; session = Some session }
71 | _ -> Error (Invalid_response "Missing required session fields"))
72 | None -> Error (Invalid_response "Expected object"))
73
74let logout t =
75 match t.session with
76 | None -> Ok { t with client = Client.without_auth t.client }
77 | Some _ ->
78 let _ = Client.delete_session t.client in
79 Ok { client = Client.without_auth t.client; session = None }
80
81let refresh_session t =
82 match t.session with
83 | None -> Error Not_authenticated
84 | Some session -> (
85 match session.refresh_jwt with
86 | None -> Error (Invalid_response "No refresh token available")
87 | Some refresh_token -> (
88 let refresh_client = Client.with_auth ~token:refresh_token t.client in
89 match Client.refresh_session refresh_client with
90 | Error e -> Error (Xrpc_error e)
91 | Ok json -> (
92 match Atproto_json.to_object_opt json with
93 | Some pairs -> (
94 match
95 ( Atproto_json.get_string_opt "did" pairs,
96 Atproto_json.get_string_opt "handle" pairs,
97 Atproto_json.get_string_opt "accessJwt" pairs )
98 with
99 | Some did, Some handle, Some access_jwt ->
100 let refresh_jwt =
101 Atproto_json.get_string_opt "refreshJwt" pairs
102 in
103 let new_session =
104 { session with did; handle; access_jwt; refresh_jwt }
105 in
106 let client =
107 Client.with_auth ~token:access_jwt t.client
108 in
109 Ok { client; session = Some new_session }
110 | _ ->
111 Error (Invalid_response "Missing required session fields")
112 )
113 | None -> Error (Invalid_response "Expected object"))))
114
115let get_session t =
116 match t.session with
117 | None -> Error Not_authenticated
118 | Some _ -> (
119 match Client.get_session t.client with
120 | Error e -> Error (Xrpc_error e)
121 | Ok json -> Ok json)
122
123type profile = {
124 did : string;
125 handle : string;
126 display_name : string option;
127 description : string option;
128 avatar : string option;
129 banner : string option;
130 followers_count : int;
131 follows_count : int;
132 posts_count : int;
133}
134
135let parse_profile json =
136 match Atproto_json.to_object_opt json with
137 | Some pairs -> (
138 match
139 ( Atproto_json.get_string_opt "did" pairs,
140 Atproto_json.get_string_opt "handle" pairs )
141 with
142 | Some did, Some handle ->
143 Ok
144 {
145 did;
146 handle;
147 display_name = Atproto_json.get_string_opt "displayName" pairs;
148 description = Atproto_json.get_string_opt "description" pairs;
149 avatar = Atproto_json.get_string_opt "avatar" pairs;
150 banner = Atproto_json.get_string_opt "banner" pairs;
151 followers_count =
152 (match Atproto_json.get_int_opt "followersCount" pairs with
153 | Some i -> i
154 | None -> 0);
155 follows_count =
156 (match Atproto_json.get_int_opt "followsCount" pairs with
157 | Some i -> i
158 | None -> 0);
159 posts_count =
160 (match Atproto_json.get_int_opt "postsCount" pairs with
161 | Some i -> i
162 | None -> 0);
163 }
164 | _ -> Error (Invalid_response "Missing did or handle"))
165 | None -> Error (Invalid_response "Expected object")
166
167let get_profile t ~actor =
168 match Nsid.of_string "app.bsky.actor.getProfile" with
169 | Error _ -> Error (Parse_error "invalid nsid")
170 | Ok nsid -> (
171 match Client.query t.client ~nsid ~params:[ ("actor", actor) ] () with
172 | Error e -> Error (Xrpc_error e)
173 | Ok json -> parse_profile json)
174
175type post_ref = { uri : string; cid : string }
176type reply_ref = { root : post_ref; parent : post_ref }
177
178let parse_post_ref json =
179 match Atproto_json.to_object_opt json with
180 | Some pairs -> (
181 match
182 ( Atproto_json.get_string_opt "uri" pairs,
183 Atproto_json.get_string_opt "cid" pairs )
184 with
185 | Some uri, Some cid -> Ok { uri; cid }
186 | _ -> Error (Invalid_response "Missing uri or cid"))
187 | None -> Error (Invalid_response "Expected object")
188
189let make_timestamp () =
190 let t = Unix.gettimeofday () in
191 let tm = Unix.gmtime t in
192 Printf.sprintf "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ" (tm.Unix.tm_year + 1900)
193 (tm.Unix.tm_mon + 1) tm.Unix.tm_mday tm.Unix.tm_hour tm.Unix.tm_min
194 tm.Unix.tm_sec
195 (int_of_float ((t -. floor t) *. 1000.))
196
197let create_post t ~text ?reply ?langs () =
198 match t.session with
199 | None -> Error Not_authenticated
200 | Some session -> (
201 match Nsid.of_string "com.atproto.repo.createRecord" with
202 | Error _ -> Error (Parse_error "invalid nsid")
203 | Ok nsid -> (
204 let now = make_timestamp () in
205 let record =
206 [
207 ("$type", Atproto_json.string "app.bsky.feed.post");
208 ("text", Atproto_json.string text);
209 ("createdAt", Atproto_json.string now);
210 ]
211 in
212 let record =
213 match reply with
214 | Some r ->
215 ( "reply",
216 Atproto_json.object_
217 [
218 ( "root",
219 Atproto_json.object_
220 [
221 ("uri", Atproto_json.string r.root.uri);
222 ("cid", Atproto_json.string r.root.cid);
223 ] );
224 ( "parent",
225 Atproto_json.object_
226 [
227 ("uri", Atproto_json.string r.parent.uri);
228 ("cid", Atproto_json.string r.parent.cid);
229 ] );
230 ] )
231 :: record
232 | None -> record
233 in
234 let record =
235 match langs with
236 | Some ls ->
237 ("langs", Atproto_json.array (List.map Atproto_json.string ls))
238 :: record
239 | None -> record
240 in
241 let input =
242 Atproto_json.object_
243 [
244 ("repo", Atproto_json.string session.did);
245 ("collection", Atproto_json.string "app.bsky.feed.post");
246 ("record", Atproto_json.object_ record);
247 ]
248 in
249 match Client.procedure t.client ~nsid ~input () with
250 | Error e -> Error (Xrpc_error e)
251 | Ok json -> parse_post_ref json))
252
253let create_post_richtext t ~richtext ?reply ?langs () =
254 match t.session with
255 | None -> Error Not_authenticated
256 | Some session -> (
257 match Nsid.of_string "com.atproto.repo.createRecord" with
258 | Error _ -> Error (Parse_error "invalid nsid")
259 | Ok nsid -> (
260 let now = make_timestamp () in
261 let rt_json = Richtext.to_json richtext in
262 let base_record =
263 match Atproto_json.to_object_opt rt_json with
264 | Some pairs -> pairs
265 | None -> []
266 in
267 let record =
268 ("$type", Atproto_json.string "app.bsky.feed.post")
269 :: ("createdAt", Atproto_json.string now)
270 :: base_record
271 in
272 let record =
273 match reply with
274 | Some r ->
275 ( "reply",
276 Atproto_json.object_
277 [
278 ( "root",
279 Atproto_json.object_
280 [
281 ("uri", Atproto_json.string r.root.uri);
282 ("cid", Atproto_json.string r.root.cid);
283 ] );
284 ( "parent",
285 Atproto_json.object_
286 [
287 ("uri", Atproto_json.string r.parent.uri);
288 ("cid", Atproto_json.string r.parent.cid);
289 ] );
290 ] )
291 :: record
292 | None -> record
293 in
294 let record =
295 match langs with
296 | Some ls ->
297 ("langs", Atproto_json.array (List.map Atproto_json.string ls))
298 :: record
299 | None -> record
300 in
301 let input =
302 Atproto_json.object_
303 [
304 ("repo", Atproto_json.string session.did);
305 ("collection", Atproto_json.string "app.bsky.feed.post");
306 ("record", Atproto_json.object_ record);
307 ]
308 in
309 match Client.procedure t.client ~nsid ~input () with
310 | Error e -> Error (Xrpc_error e)
311 | Ok json -> parse_post_ref json))
312
313let delete_post t ~uri =
314 match t.session with
315 | None -> Error Not_authenticated
316 | Some session -> (
317 match Nsid.of_string "com.atproto.repo.deleteRecord" with
318 | Error _ -> Error (Parse_error "invalid nsid")
319 | Ok nsid -> (
320 match At_uri.of_string uri with
321 | Error _ -> Error (Parse_error "invalid AT-URI")
322 | Ok at_uri -> (
323 let rkey =
324 match At_uri.rkey at_uri with Some r -> r | None -> ""
325 in
326 let input =
327 Atproto_json.object_
328 [
329 ("repo", Atproto_json.string session.did);
330 ("collection", Atproto_json.string "app.bsky.feed.post");
331 ("rkey", Atproto_json.string rkey);
332 ]
333 in
334 match Client.procedure t.client ~nsid ~input () with
335 | Error e -> Error (Xrpc_error e)
336 | Ok _ -> Ok ())))
337
338let like t ~uri ~cid =
339 match t.session with
340 | None -> Error Not_authenticated
341 | Some session -> (
342 match Nsid.of_string "com.atproto.repo.createRecord" with
343 | Error _ -> Error (Parse_error "invalid nsid")
344 | Ok nsid -> (
345 let now = make_timestamp () in
346 let input =
347 Atproto_json.object_
348 [
349 ("repo", Atproto_json.string session.did);
350 ("collection", Atproto_json.string "app.bsky.feed.like");
351 ( "record",
352 Atproto_json.object_
353 [
354 ("$type", Atproto_json.string "app.bsky.feed.like");
355 ( "subject",
356 Atproto_json.object_
357 [
358 ("uri", Atproto_json.string uri);
359 ("cid", Atproto_json.string cid);
360 ] );
361 ("createdAt", Atproto_json.string now);
362 ] );
363 ]
364 in
365 match Client.procedure t.client ~nsid ~input () with
366 | Error e -> Error (Xrpc_error e)
367 | Ok json -> parse_post_ref json))
368
369let unlike t ~uri =
370 match t.session with
371 | None -> Error Not_authenticated
372 | Some session -> (
373 match Nsid.of_string "com.atproto.repo.deleteRecord" with
374 | Error _ -> Error (Parse_error "invalid nsid")
375 | Ok nsid -> (
376 match At_uri.of_string uri with
377 | Error _ -> Error (Parse_error "invalid AT-URI")
378 | Ok at_uri -> (
379 let rkey =
380 match At_uri.rkey at_uri with Some r -> r | None -> ""
381 in
382 let input =
383 Atproto_json.object_
384 [
385 ("repo", Atproto_json.string session.did);
386 ("collection", Atproto_json.string "app.bsky.feed.like");
387 ("rkey", Atproto_json.string rkey);
388 ]
389 in
390 match Client.procedure t.client ~nsid ~input () with
391 | Error e -> Error (Xrpc_error e)
392 | Ok _ -> Ok ())))
393
394let follow t ~did =
395 match t.session with
396 | None -> Error Not_authenticated
397 | Some session -> (
398 match Nsid.of_string "com.atproto.repo.createRecord" with
399 | Error _ -> Error (Parse_error "invalid nsid")
400 | Ok nsid -> (
401 let now = make_timestamp () in
402 let input =
403 Atproto_json.object_
404 [
405 ("repo", Atproto_json.string session.did);
406 ("collection", Atproto_json.string "app.bsky.graph.follow");
407 ( "record",
408 Atproto_json.object_
409 [
410 ("$type", Atproto_json.string "app.bsky.graph.follow");
411 ("subject", Atproto_json.string did);
412 ("createdAt", Atproto_json.string now);
413 ] );
414 ]
415 in
416 match Client.procedure t.client ~nsid ~input () with
417 | Error e -> Error (Xrpc_error e)
418 | Ok json -> parse_post_ref json))
419
420let unfollow t ~uri =
421 match t.session with
422 | None -> Error Not_authenticated
423 | Some session -> (
424 match Nsid.of_string "com.atproto.repo.deleteRecord" with
425 | Error _ -> Error (Parse_error "invalid nsid")
426 | Ok nsid -> (
427 match At_uri.of_string uri with
428 | Error _ -> Error (Parse_error "invalid AT-URI")
429 | Ok at_uri -> (
430 let rkey =
431 match At_uri.rkey at_uri with Some r -> r | None -> ""
432 in
433 let input =
434 Atproto_json.object_
435 [
436 ("repo", Atproto_json.string session.did);
437 ("collection", Atproto_json.string "app.bsky.graph.follow");
438 ("rkey", Atproto_json.string rkey);
439 ]
440 in
441 match Client.procedure t.client ~nsid ~input () with
442 | Error e -> Error (Xrpc_error e)
443 | Ok _ -> Ok ())))
444
445let repost t ~uri ~cid =
446 match t.session with
447 | None -> Error Not_authenticated
448 | Some session -> (
449 match Nsid.of_string "com.atproto.repo.createRecord" with
450 | Error _ -> Error (Parse_error "invalid nsid")
451 | Ok nsid -> (
452 let now = make_timestamp () in
453 let input =
454 Atproto_json.object_
455 [
456 ("repo", Atproto_json.string session.did);
457 ("collection", Atproto_json.string "app.bsky.feed.repost");
458 ( "record",
459 Atproto_json.object_
460 [
461 ("$type", Atproto_json.string "app.bsky.feed.repost");
462 ( "subject",
463 Atproto_json.object_
464 [
465 ("uri", Atproto_json.string uri);
466 ("cid", Atproto_json.string cid);
467 ] );
468 ("createdAt", Atproto_json.string now);
469 ] );
470 ]
471 in
472 match Client.procedure t.client ~nsid ~input () with
473 | Error e -> Error (Xrpc_error e)
474 | Ok json -> parse_post_ref json))
475
476type feed_item = {
477 post_uri : string;
478 post_cid : string;
479 author_did : string;
480 author_handle : string;
481 text : string;
482 created_at : string;
483 reply_count : int;
484 repost_count : int;
485 like_count : int;
486}
487
488type feed = { items : feed_item list; cursor : string option }
489
490let parse_feed_item json =
491 match Atproto_json.to_object_opt json with
492 | Some pairs -> (
493 match Atproto_json.get_object_opt "post" pairs with
494 | Some post_pairs ->
495 let uri =
496 Option.value ~default:""
497 (Atproto_json.get_string_opt "uri" post_pairs)
498 in
499 let cid =
500 Option.value ~default:""
501 (Atproto_json.get_string_opt "cid" post_pairs)
502 in
503 let author =
504 match Atproto_json.get_object_opt "author" post_pairs with
505 | Some a -> a
506 | None -> []
507 in
508 let record =
509 match Atproto_json.get_object_opt "record" post_pairs with
510 | Some r -> r
511 | None -> []
512 in
513 Some
514 {
515 post_uri = uri;
516 post_cid = cid;
517 author_did =
518 Option.value ~default:""
519 (Atproto_json.get_string_opt "did" author);
520 author_handle =
521 Option.value ~default:""
522 (Atproto_json.get_string_opt "handle" author);
523 text =
524 Option.value ~default:""
525 (Atproto_json.get_string_opt "text" record);
526 created_at =
527 Option.value ~default:""
528 (Atproto_json.get_string_opt "createdAt" record);
529 reply_count =
530 Option.value ~default:0
531 (Atproto_json.get_int_opt "replyCount" post_pairs);
532 repost_count =
533 Option.value ~default:0
534 (Atproto_json.get_int_opt "repostCount" post_pairs);
535 like_count =
536 Option.value ~default:0
537 (Atproto_json.get_int_opt "likeCount" post_pairs);
538 }
539 | None -> None)
540 | None -> None
541
542let get_timeline t ?cursor ?limit () =
543 match t.session with
544 | None -> Error Not_authenticated
545 | Some _ -> (
546 match Nsid.of_string "app.bsky.feed.getTimeline" with
547 | Error _ -> Error (Parse_error "invalid nsid")
548 | Ok nsid -> (
549 let params = [] in
550 let params =
551 match cursor with
552 | Some c -> ("cursor", c) :: params
553 | None -> params
554 in
555 let params =
556 match limit with
557 | Some l -> ("limit", string_of_int l) :: params
558 | None -> params
559 in
560 match Client.query t.client ~nsid ~params () with
561 | Error e -> Error (Xrpc_error e)
562 | Ok json -> (
563 match Atproto_json.to_object_opt json with
564 | Some pairs ->
565 let items =
566 match Atproto_json.get_array_opt "feed" pairs with
567 | Some items -> List.filter_map parse_feed_item items
568 | None -> []
569 in
570 let cursor = Atproto_json.get_string_opt "cursor" pairs in
571 Ok { items; cursor }
572 | None -> Error (Invalid_response "Expected object"))))
573
574let get_author_feed t ~actor ?cursor ?limit () =
575 match Nsid.of_string "app.bsky.feed.getAuthorFeed" with
576 | Error _ -> Error (Parse_error "invalid nsid")
577 | Ok nsid -> (
578 let params = [ ("actor", actor) ] in
579 let params =
580 match cursor with Some c -> ("cursor", c) :: params | None -> params
581 in
582 let params =
583 match limit with
584 | Some l -> ("limit", string_of_int l) :: params
585 | None -> params
586 in
587 match Client.query t.client ~nsid ~params () with
588 | Error e -> Error (Xrpc_error e)
589 | Ok json -> (
590 match Atproto_json.to_object_opt json with
591 | Some pairs ->
592 let items =
593 match Atproto_json.get_array_opt "feed" pairs with
594 | Some items -> List.filter_map parse_feed_item items
595 | None -> []
596 in
597 let cursor = Atproto_json.get_string_opt "cursor" pairs in
598 Ok { items; cursor }
599 | None -> Error (Invalid_response "Expected object")))