+1
.gitignore
+1
.gitignore
···
1
+
.cask
+8
Cask
+8
Cask
+183
skeet.el
+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