using common lisp to poke at bluesky

i doubt i'm alone in the belief that the worst part of having a social media account is using the account. this has been my experience ever since the fall of forums and the rise of "posting". i'm an avid user of irc and other forms of tui communication, so for me its mostly a matter of "i like the simple of looking at text on a screen that is only text i do not like going to a website and clicking widgets or worse yet using my mouse."

for this reason a lot of my social media stuff, if i do not have a way to interact with it on my terms, tends to start to languish in some form or another. this has been the case with x the everything app formerly twitter and with bluesky the other everything app. the former is for the better probably but the latter is a bit of a shame because bluesky is actually pretty freeking cool imo. if there was an emacs plugin for it i'd be all over that. but i've yet to find one :(

i was content to let it languish, posting only when i am posting that i am posting (streaming) on the broadcasting application but managed to see prodzpod use the app to post by applying !post in a discord message. i asked about this and they linked me be atproto api that exposes this via typescript.

its kinda stupid easy. making a simple post boils down to this:

import { AtpAgent } from '@atproto/api'

const agent = new AtpAgent ({
    service: 'https://bsky.social'
    })

await agent.login({
    identifier: 'bigbookofbug.bsky.social',
    password: 'yeah this is my real password im very real'
    })

await agent.post({
    text: "hello test :3",
    createdAt: new Date().toISOString()
    })

there's also a python version but i didn't try it because i don't use python tbh.

running this, i was able to get an object containing the uri for the post. curling from that uri (and checking bluesky to be doubly sure), i get the following:

<div id="bsky_post_summary">
  <h3>Post</h3>
  <p id="bsky_display_name">(not &#39;emma)</p>
  <p id="bsky_handle">bigbookofbug.bsky.social</p>
  <p id="bsky_did">did:plc:bjytbyh3ukmcvbrkmzq3a27a</p>
  <p id="bsky_post_text">hello test :3</p>
  <p id="bsky_post_indexedat">2025-01-12T23:31:14.050Z</p>
</div>

cool as hell, but its typescript, which isn't bad but not my preferred. the question remained: what if it was lisp

authorization

the bluesky docs expose the CURL method to do a simple auth and text post, and based on my poking at twitch using common lisp, i knew that the drakma package would more or less allow me to rewrite it in lisp.

i decided to call the program /lispsky/, and got to work by first creating a package that would allow me to curl and manipulate the json:

(defpackage #:lispsky
  (:import-from :uiop #:strcat)
  (:use #:cl))

(in-package :lispsky)

;; this is my preferred JSON parser
;; but the name is unfortunate so i apply a nickname
(require :com.inuoe.jzon)
(uiop:add-package-local-nickname '#:jzon '#:com.inuoe.jzon)

;; not the newest or flashiest, but gets the job done
(require :drakma)

the first thing we have to do is get two auth token: accessJwt for request authentication, and refreshJwt for a method of updating the session with a new access token when the previous accessJwt expires. only the former is used really in the case of posting, with the latter only needed if longer operations are being performed. for this, three pieces of data are needed: our handle, our password, and our host domain. the handle and domain are public, and therefore can be safely stored as globals. i chose to wrap the password into a function that uses emacs to grab it from a password store.

we also need a method of getting the JSON so that we can rip out the access tokens:


(defvar *pdshost* "https://bsky.social")
(defvar *uname* "bigbookofbug.bsky.social")

;; i know the format string is ugly but hey i'll change it
(defun get-session-json ()
  (let ((passwd (get-secret "bigbookofbug.bsky.social"))
		(uri (strcat *pdshost* "/xrpc/com.atproto.server.createSession")))
	(drakma:http-request uri
		:method :post
		:content-type "application/json"
		:content (format nil "{"identifier": "~a", "password": "~a"}"
									*uname* passwd))))

;; not the best method of token, but works for testing
;; these variables aren't printed on error
(defvar *accessjwt* nil)
(defvar *refreshjwt* nil)

then comes, the moment of truth, the authentication. the check at then end is twofold in that it keeps the repl from straight up printing the tokens, and also keeping the output clean on the chance there's actually an error:


(defun auth-tuah ()
  (let ((raw-json (flexi-streams:octets-to-string (get-session-json))))
	(setf *accessjwt* (gethash "accessJwt" (jzon:parse raw-json)))
	(setf *refreshjwt* (gethash "refreshJwt" (jzon:parse raw-json)))
	(if (and *accessjwt* *refreshjwt*)
		(format t "Authorized!")
		(format t "Error in authorization: ~%~a~%" raw-json))))

posting

the moment of truth. bluesky requests require three fields to be specified in order to make a post: repo, text, and createdAt. while there are lisp libraries to print the current time, running date does as good a job as anything else out there:


;; uiop returns shell commands with a 
 and we don't want that
(defun trim-newline (str)
  (string-trim (string #\Newline) str))

(defun make-post (post)
  (let ((uri (strcat *pdshost* "/xrpc/com.atproto.repo.createRecord"))
	(date (trim-newline (uiop:run-program "date -u +%Y-%m-%dT%H:%M:%SZ" :output :string))))
	(drakma:http-request uri
		:method :post
		:content-type "application/json"
		:additional-headers `(("Authorization" . ,(strcat "Bearer " *accessjwt*)))
		:content (format nil "{"repo": "~a", "collection": "app.bsky.feed.post", "record": {"text": "~a", "createdAt": "~a"}}"
				*uname* post date))))

fingers crossed, i ran (make-post "hello yes hello world lisp test :3") in the repl and managed to get a uri back. curling the uri, we see the following:

<p lang="en">Learn more about Bluesky at <a href="https://bsky.social">bsky.social</a> and <a href="https://atproto.com">atproto.com</a>.
  <div id="bsky_post_summary">
    <h3>Post</h3>
    <p id="bsky_display_name">(not &#39;emma)</p>
    <p id="bsky_handle">bigbookofbug.bsky.social</p>
    <p id="bsky_did">did:plc:bjytbyh3ukmcvbrkmzq3a27a</p>
    <p id="bsky_post_text">hello yes hello world lisp test :3</p>
    <p id="bsky_post_indexedat">2025-01-13T00:58:07.151Z</p>
  </div>

it works !!!

further plans

there's a lot on my plate currently, but making lispsky into a fully-fledged alternative would be a rather fun task, or if not that at least a way to make more complex posts. currently, links and other embeds are not possible with a simple make-post, but could hypothetically work doing some reverse engineering following this "under the hood" rich text guide. i'd like to get lispsky this far.

another thing worth considering is the format of the content we are sending. it's rather messy in this method, could easily be improved upon using something like CLOS or the jzon reader macro (or a combination of the two). this would make it more usable by others, which is good because "making things for my friends and other cool people" is my main source of joy from most things, be it programming, baking, cooking, or birthday gifts.

writing this into the void right now, but bugsite is right around the corner, so this will not be the case in maybe like another week (and certainly isn't the case if you're reading this).