Initial commit

Changed files
+192
+1
.gitignore
··· 1 + .cask
+8
Cask
··· 1 + (source gnu) 2 + (source melpa) 3 + 4 + (package "skeet" "0.1" "Write and publish posts from within Emacs.") 5 + (package-file "skeet.el") 6 + 7 + (development 8 + (depends-on "request"))
+183
skeet.el
··· 1 + ;;; skeet.el --- Post on Bluesky from Emacs. -*- lexical-binding:t -*- 2 + 3 + ;; Copyright (C) 2025 Amy 4 + 5 + ;; Author: Amy <amy+git@amogus.cloud> 6 + ;; Version: 1.0 7 + ;; Package-Requires: ((request "20250219.2213")) 8 + 9 + (require 'request) 10 + 11 + ;;; Code: 12 + 13 + (defvar skeet--session nil) 14 + (defvar skeet--last-used nil) 15 + ;;;###autoload 16 + (defgroup skeet nil 17 + "Configure the Skeet bluesky client.") 18 + (defcustom skeet-user-handle "" 19 + "Your ATProto/Bluesky handle" 20 + :type '(string) 21 + :group 'skeet) 22 + (defcustom skeet-app-password "" 23 + "Your app password. It could also be your regular password, although, it's not recommended." 24 + :type '(string) 25 + :group 'skeet) 26 + (defcustom skeet-quickdid-endpoint "https://quickdid.smokesignal.tools" 27 + "Instance of QuickDID used to resolve handles to their decentralized identifiers." 28 + :type '(string) 29 + :group 'skeet) 30 + (defcustom skeet-plc-directory "https://plc.directory" 31 + "Public ledger of credentials, used for PDS resolution." 32 + :type '(string) 33 + :group 'skeet) 34 + 35 + (defconst skeet--b32s-charset (mapcar 36 + 'char-to-string 37 + (string-to-list "234567abcdefghijklmnopqrstuvwxyz"))) 38 + 39 + 40 + (defun skeet--to-base32-sortable (int) 41 + (let ((s "") 42 + (i int)) 43 + (while (not (= i 0)) 44 + (let ((c (% i 32))) 45 + (setq i (floor (/ i 32))) 46 + (setq s (concat s (nth c skeet--b32s-charset))))) 47 + s)) 48 + 49 + (defun skeet--get-epoch () 50 + (floor (float-time))) 51 + 52 + (defun skeet--get-clock-id () 53 + (floor (random 1023))) 54 + 55 + (defun skeet--tid (ts clock-id) 56 + "Generate a new Timestamp Identifier (TID) 57 + Used for Record keys" 58 + (concat (string-pad (skeet--to-base32-sortable ts) 11 ?2) 59 + (string-pad (skeet--to-base32-sortable clock-id) 2 ?2))) 60 + 61 + (defun skeet--time-to-iso-8601 (ts) 62 + (format-time-string "%Y-%m-%dT%H:%M:%SZ" (seconds-to-time ts))) 63 + 64 + (defun skeet--resolve-did-by-handle (handle) 65 + "Resolve the user's DID by handle using QuickDID" 66 + (let ((did nil)) 67 + (request (concat skeet-quickdid-endpoint 68 + "/xrpc/com.atproto.identity.resolveHandle") 69 + :params `(("handle" . ,handle)) 70 + :parser #'skeet--util-json 71 + :sync t 72 + :success (cl-function 73 + (lambda (&key data &allow-other-keys) 74 + (setq did (gethash "did" data))))) 75 + did)) 76 + 77 + (defun skeet--util-json () 78 + "Parsing utility for request.el" 79 + (let ((json-object-type 'hash-table) 80 + (json-array-type 'list)) 81 + (json-read))) 82 + 83 + (defun skeet--to-pds-route (pds nsid) 84 + (concat 85 + (gethash "serviceEndpoint" pds) 86 + "/xrpc/" 87 + nsid)) 88 + 89 + (defun skeet--get-pds-from-document (document) 90 + "Utility function to extract the user's PersonalDataService for later use 91 + IE. Authentication, Posting" 92 + (defun find-pds-service (services) 93 + (let ((service (car services))) 94 + (cond 95 + ((null service) (error "No PDS declared in document")) 96 + ((equal "#atproto_pds" (gethash "id" service)) service) 97 + (t (find-pds-service (cdr services)))))) 98 + 99 + (let ((declared-services (gethash "service" document))) 100 + (find-pds-service declared-services))) 101 + 102 + (defun skeet--get-pds (did) 103 + "Get the user's PDS from their PLC document" 104 + (let ((pds-info nil)) 105 + (request (concat skeet-plc-directory "/" did) 106 + :parser #'skeet--util-json 107 + :sync t 108 + :success (cl-function 109 + (lambda (&key data &allow-other-keys) 110 + (setq pds-info (skeet--get-pds-from-document data))))) 111 + pds-info)) 112 + 113 + (defun skeet--create-session (pds id) 114 + (let ((session-information nil)) 115 + (request (skeet--to-pds-route pds "com.atproto.server.createSession") 116 + :type "POST" 117 + :data (json-encode `(("identifier" . ,id) 118 + ("password" . ,skeet-app-password))) 119 + :headers '(("Content-Type" . "application/json")) 120 + :parser #'skeet--util-json 121 + :sync t 122 + :success (cl-function 123 + (lambda (&key data &allow-other-keys) 124 + (setq session-information data))) 125 + :error (cl-function 126 + (lambda (&rest args &key error-thrown &key data &allow-other-keys) 127 + (message (format "Got Error: %S\n%s" error-thrown data))))) 128 + session-information)) 129 + 130 + (defun skeet--make-post-data (did content rkey) 131 + ;; https://docs.bsky.app/docs/advanced-guides/posts 132 + `((repo . ,did) 133 + (collection . "app.bsky.feed.post") 134 + (record . (($type . "app.bsky.feed.post") 135 + (text . ,content) 136 + (createdAt . ,(skeet--time-to-iso-8601 137 + (skeet--get-epoch))))) 138 + (rkey . ,rkey) 139 + (validate . t))) 140 + 141 + (defun skeet--validate-post-len (cnt) 142 + (if (< 300 (length cnt)) 143 + (error "Message is too long. Maximum length is 300 characters.") 144 + cnt)) 145 + 146 + (defun skeet--create-session-if-not-exists (did pds) 147 + "Returns either a cached session (initial + refresh JWT) or a brand new session." 148 + (if (null skeet--session) 149 + (setq skeet--session (let ((new-session (skeet--create-session pds did))) 150 + `((access-jwt . ,(gethash "accessJwt" new-session)) 151 + (refresh-jwt . ,(gethash "refreshJwt" new-session))))) 152 + (if (< (+ skeet--last-used 60) (skeet--get-epoch)) 153 + (message "TODO: Refresh token...") 154 + skeet--session))) 155 + 156 + ;;;###autoload 157 + (defun skeet () 158 + "Publish something to your bluesky account" 159 + (interactive) 160 + (let* ((post-content (read-string "Skeet: ")) 161 + (did (skeet--resolve-did-by-handle skeet-user-handle)) 162 + (pds (skeet--get-pds did)) 163 + (session (skeet--create-session-if-not-exists did pds))) 164 + (request (skeet--to-pds-route pds "com.atproto.repo.createRecord") 165 + :data (json-encode (skeet--make-post-data did 166 + post-content 167 + (skeet--tid 168 + (skeet--get-epoch) 169 + (skeet--get-clock-id)))) 170 + :headers `(("Content-Type" . "application/json") 171 + ("Authorization" . ,(concat "Bearer " (cdr (assoc 'access-jwt session))))) 172 + :type "POST" 173 + :sync t 174 + :success (cl-function 175 + (lambda () 176 + (message "Check bluesky i guess"))) 177 + :error (cl-function 178 + (lambda (&rest args &key error-thrown &key data &allow-other-keys) 179 + (message (format "Got Error: %S\n%s" error-thrown data))))))) 180 + 181 + (provide 'skeet) 182 + 183 + ;;; skeet.el ends here