+58
-32
README.md
+58
-32
README.md
···
1
1
# bash-atproto
2
2
3
-
bash-atproto is a bare-bones atproto client that I wrote for [imasimgbot](https://github.com/Engielolz/imasimgbot) and the now-defunct [765coverbot](https://tangled.sh/@did:plc:s2cyuhd7je7eegffpnurnpud/765coverbot). It is a Bash script that uses cURL to authenticate, create records and upload blobs.
3
+
bash-atproto is a bare-bones atproto client/library that I wrote for [imasimgbot](https://tangled.org/did:plc:s2cyuhd7je7eegffpnurnpud/imasimgbot) and the now-defunct [765coverbot](https://tangled.org/did:plc:s2cyuhd7je7eegffpnurnpud/765coverbot). It is a Bash script that uses cURL to authenticate, create records and upload blobs.
4
4
5
-
It supports the following operations (most API calls are done to the account's PDS):
5
+
## Supported functions
6
6
7
-
* Resolving a handle to did:plc/did:web
7
+
Functions built into `bash-atproto.sh`
8
8
9
-
* Resolving an account's PDS from the DID
10
-
11
-
* Creating and closing sessions on the PDS
9
+
* Fetch a record from URI - `bap_getRecord`
10
+
* Resolving a handle to did:plc/did:web - `bap_getDID`
11
+
* Resolving the DID doc for a DID - `bap_resolveDID`
12
+
* Resolving a handle from a DID - `bap_resolveHandle`
13
+
* Resolving an account's PDS from the DID - `bap_resolvePDS`
14
+
* Creating and closing sessions on the PDS - `bap_getKeys` and `bap_closeSession`
15
+
* Saving and loading a secrets file (contains your access and refresh tokens) - `bap_saveSecrets` and `bap_loadSecrets`
16
+
* Extracting account and token information from the access token - (automatic)
17
+
* Refreshing tokens - `bap_refreshKeys`
18
+
* Minting service auth tokens - `bap_getServiceAuth`
19
+
* Prepare an image, stripping EXIF data and (if needed) resizing it for a set file size and dimensions - `bap_prepareImage`
20
+
* Uploading blobs - `bap_uploadBlobToPDS`
21
+
* Working with records:
22
+
* Add string value - `bapCYOR_str`
23
+
* Add value - `bapCYOR_add`
24
+
* Add to array - `bapCYOR_arr`
25
+
* Remove key - `bapCYOR_rem`
26
+
* Post record - `bap_postRecord`
12
27
13
-
* Saving and loading a secrets file (contains your access and refresh tokens)
28
+
### Bluesky functions
14
29
15
-
* Extracting account and token information from the access token
16
-
17
-
* Refreshing tokens
18
-
19
-
* Creating basic Bluesky text post records
30
+
With `bap-bsky.sh`
20
31
21
-
* Creating Bluesky repost records
32
+
* Creating basic Bluesky text post records - `bapBsky_createPost`
33
+
* Creating Bluesky repost records - `bapBsky_createRepost`
34
+
* Preparing an image for Bluesky (including resizing and compressing) - `bapBsky_prepareImage`
35
+
* Creating a post with a single embedded image or video with alt text and embedded image dimensions - `bapBsky_postImage` and `bapBsky_postVideo`
36
+
* When working on a post:
37
+
* Start a post - `bapBsky_cyorInit`
38
+
* Prepare an image for upload - `bapBsky_prepareImage`
39
+
* Add an image - `bapBsky_cyorAddImage`
40
+
* Add a video - `bapBsky_cyorAddVideo`
41
+
* Add an embed - `bapBsky_cyorAddExternalEmbed`
42
+
* Set language - `bapBsky_cyorAddLangs`
43
+
* Add tags - `bapBsky_cyorAddTags`
44
+
* Add self-labels - `bapBsky_cyorAddSelfLabels`
45
+
* Make reply - `bapBsky_cyorAddReply`
46
+
* Make quote - `bapBsky_cyorAddQuote`
47
+
* Submit post - `bapBsky_submitPost`
22
48
23
-
* Preparing an image for Bluesky (including resizing and compressing)
49
+
### Spark functions
24
50
25
-
* Uploading blobs
51
+
With the **experimental** `bap-sprk.sh`
26
52
27
-
* Creating a post with a single embedded image with alt text
53
+
* Creating a post with a single embedded image with alt text or video - `bapSprk_postImage` and `bapSprk_postVideo`
54
+
* When working on a post:
55
+
* Start a post - `bapSprk_cyorInit`
56
+
* Prepare an image for upload - `bapSprk_prepareImage`
57
+
* Add an image - `bapSprk_cyorAddImage`
58
+
* Add a video - `bapSprk_cyorAddVideo`
59
+
* Submit post - `bapSprk_submitPost`
28
60
29
-
### Dependencies
61
+
## Dependencies
30
62
31
-
bash-atproto requires cURL 7.76 or later and jq. Posting images (not used by 765coverbot) additionally requires `imagemagick`, `exiftool` and `uuidgen`.
63
+
bash-atproto requires `curl` 7.76 or later, `jq` 1.7 or later and `dig`. Posting images and video additionally requires `imagemagick`, `exiftool`, `uuidgen` and `file`.
32
64
33
65
## Basic usage
34
66
35
-
bash-atproto is loaded with `source bash-atproto.sh`. From there, most operations will require you to sign-in to an atproto account, which can be done in three functions:
67
+
bash-atproto is loaded with `source bash-atproto.sh`. From there, most operations will require you to sign-in to an atproto account, which can be done in one function:
36
68
37
-
1. `bap_didInit <did or handle>` which will resolve your handle to a DID
69
+
* `bap_getKeys <did or handle> <password>`
38
70
39
-
2. `bap_findPDS $savedDID` which will retrieve the account's PDS for use
71
+
The access and refresh tokens are saved in memory and can be written to disk with `bap_saveSecrets <file>`. It is recommended you use an App Password to log in rather than a normal password.
40
72
41
-
3. `bap_getKeys $savedDID <password>` to log in. The access and refresh tokens are saved in memory and can be written to disk with `bap_saveSecrets <file>`. It is recommended you use an App Password to log in rather than a normal password.
73
+
You may need to load additional scripts to handle other tasks. For example, to post to Bluesky, you will need to `source bap-bsky.sh`.
42
74
43
75
If bash-atproto is being used with a bot or other service that runs periodically, the calling application should implement a timer to refresh tokens. bash-atproto provides a function to perform a refresh (`bap_refreshKeys`) and a timestamp to detect when a token refresh should be performed (`$bap_savedAccessExpiry`), but it does not do so itself.
44
76
···
47
79
bash-atproto is mainly meant for use as a component for bash scripts that want to use the AT Protocol, but it by itself can be used as a simple atproto client for the command line. The following commands will log in to your PDS, create a Bluesky post and then log out:
48
80
49
81
1. `source bash-atproto.sh`
50
-
51
-
2. `bap_didInit <your did or handle>`
52
-
53
-
3. `bap_findPDS $savedDID`
54
-
55
-
4. `bap_getKeys $savedDID <app password>`
56
-
57
-
5. `bap_postToBluesky "Hello, World!" en`
58
-
59
-
6. `bap_closeSession`
82
+
2. `source bap-bsky.sh`
83
+
3. `bap_getKeys <your did or handle> <app password>`
84
+
4. `bapBsky_createPost "Hello, World!" en`
85
+
5. `bap_closeSession`
60
86
61
87
## License
62
88
+397
bap-bsky.sh
+397
bap-bsky.sh
···
1
+
#!/bin/bash
2
+
# SPDX-License-Identifier: MIT
3
+
# bap-bsky.sh: the bash-atproto functions pertaining to Bluesky Social.
4
+
if [ -z "$bap_internalVersion" ]; then >&2 echo "bash-atproto not loaded?"; return 127; fi
5
+
if [ "$bap_internalVersion" != "4" ] || ! [ "$bap_internalMinorVer" -ge "-1" ]; then >&2 echo "Incorrect bash-atproto version"; return 1; fi
6
+
7
+
bapBsky_internalVersion=2
8
+
bapBsky_internalMinorVer=0
9
+
bapBsky_bskyAppViewDID=did:web:api.bsky.app#bsky_appview
10
+
bapBsky_lumiURL=video.bsky.app
11
+
12
+
function bapBskyErr () {
13
+
>&2 echo "bap-bsky: $*"
14
+
}
15
+
16
+
function bapBskyEcho () {
17
+
if [ ! "$bap_verbosity" -ge 1 ]; then return 0; fi
18
+
echo "bap-bsky: $*"
19
+
}
20
+
21
+
function bapBskyErrVerb () {
22
+
if [ ! "$bap_verbosity" -ge 2 ]; then return 0; fi
23
+
>&2 echo "bap-bsky: $*"
24
+
}
25
+
26
+
function bapBsky_cyorInit () {
27
+
bap_cyorRecord="{}"
28
+
bapCYOR_str \$type app.bsky.feed.post
29
+
bapCYOR_str text ""
30
+
}
31
+
32
+
function bapBskyInternal_convertToRecordWithMedia () {
33
+
cyorTemp=$(echo "$bap_cyorRecord $(echo "$bap_cyorRecord" | jq -r .embed | echo "{\"embed\": {\"$1\": $(</dev/stdin)}}")" | jq -s add) || return 1
34
+
bap_cyorRecord=$cyorTemp
35
+
bapCYOR_str \$type "app.bsky.embed.recordWithMedia" .embed
36
+
bapCYOR_str \$type "app.bsky.embed.record" .embed.record
37
+
}
38
+
39
+
function bapBsky_cyorAddImage () {
40
+
if [ -z "$6" ]; then bapBskyErr "error: Required argument missing"; return 1; fi
41
+
if [ "$(echo "$bap_cyorRecord" | jq -r ".embed.[\"\$type\"]")" = "app.bsky.embed.record" ]; then bapBskyInternal_convertToRecordWithMedia record; fi
42
+
if [ "$(echo "$bap_cyorRecord" | jq -r ".embed.[\"\$type\"]")" = "app.bsky.embed.recordWithMedia" ]; then local l=.media; fi
43
+
# it's easy but just LOOK at all those commands
44
+
bapCYOR_str \$type app.bsky.embed.images ".embed$l"
45
+
if [ -n "$7" ]; then bapCYOR_str alt "$7" ".embed$l.images.[$1]"; fi
46
+
bapCYOR_str \$type blob ".embed$l.images.[$1].image"
47
+
bapCYOR_str \$link "$2" ".embed$l.images.[$1].image.ref"
48
+
bapCYOR_str mimeType "$3" ".embed$l.images.[$1].image"
49
+
bapCYOR_add size "$4" ".embed$l.images.[$1].image"
50
+
bapCYOR_add width "$5" ".embed$l.images.[$1].aspectRatio"
51
+
bapCYOR_add height "$6" ".embed$l.images.[$1].aspectRatio"
52
+
}
53
+
54
+
function bapBsky_cyorAddVideo () {
55
+
# param:
56
+
# 1 - blob
57
+
# 2 - size
58
+
# 3 - width
59
+
# 4 - height
60
+
# 5 - alt text
61
+
if [ -z "$4" ]; then bapBskyErr "error: Required argument missing"; return 1; fi
62
+
if [ "$(echo "$bap_cyorRecord" | jq -r ".embed.[\"\$type\"]")" = "app.bsky.embed.record" ]; then bapBskyInternal_convertToRecordWithMedia record; fi
63
+
if [ "$(echo "$bap_cyorRecord" | jq -r ".embed.[\"\$type\"]")" = "app.bsky.embed.recordWithMedia" ]; then local l=.media; fi
64
+
bapCYOR_str alt "$5" ".embed$l"
65
+
bapCYOR_str \$type app.bsky.embed.video ".embed$l"
66
+
bapCYOR_str \$type blob ".embed$l.video"
67
+
bapCYOR_str \$link "$1" ".embed$l.video.ref"
68
+
bapCYOR_str mimeType "video/mp4" ".embed$l.video"
69
+
bapCYOR_add size "$2" ".embed$l.video"
70
+
bapCYOR_add width "$3" ".embed$l.aspectRatio"
71
+
bapCYOR_add height "$4" ".embed$l.aspectRatio"
72
+
}
73
+
74
+
function bapBskyInternal_cyorGetReplyRoot () {
75
+
if [ -z "$1" ]; then bapBskyErr "error: Required argument missing"; return 1; fi
76
+
bapBsky_temp=$(bapBsky_getPost "$1" -e) || return $?
77
+
if echo "$bapBsky_temp" | jq -re '.record.reply.root.uri' > /dev/null; then
78
+
# copy values
79
+
bapCYOR_str uri "$(echo "$bapBsky_temp" | jq -re '.record.reply.root.uri')" .reply.root
80
+
bapCYOR_str cid "$(echo "$bapBsky_temp" | jq -re '.record.reply.root.cid')" .reply.root
81
+
elif [ -z "$bapBsky_temp" ]; then
82
+
bapBskyErr "failed to resolve post"
83
+
return 1
84
+
else
85
+
# we just wasted time and bandwidth!
86
+
bapCYOR_str uri "$(echo "$bapBsky_temp" | jq -re '.uri')" .reply.root
87
+
bapCYOR_str cid "$(echo "$bapBsky_temp" | jq -re '.cid')" .reply.root
88
+
fi
89
+
return 0
90
+
}
91
+
92
+
function bapBsky_cyorAddReply () {
93
+
if [ -z "$1" ]; then bapBskyErr "error: Required argument missing"; return 1; fi
94
+
local bapBsky_temp bapBsky_tempPost=$bap_cyorRecord
95
+
bapBsky_temp=$(bapBsky_getPost "$1" -e) || return $?
96
+
if [ "$(echo "$bapBsky_temp" | jq -r .viewer.replyDisabled)" = "true" ]; then bapBskyErr "specified post disallows replies"; return 2; fi
97
+
bapCYOR_str uri "$(echo "$bapBsky_temp" | jq -re '.uri')" .reply.parent
98
+
bapCYOR_str cid "$(echo "$bapBsky_temp" | jq -re '.cid')" .reply.parent
99
+
bapBskyInternal_cyorGetReplyRoot "$1" || { bap_cyorRecord=$bapBsky_tempPost; return 1; }
100
+
return 0
101
+
}
102
+
103
+
function bapBsky_cyorAddLangs () {
104
+
if [ -z "$1" ]; then bapBskyErr "error: Required arugment missing"; return 1; fi
105
+
bapCYOR_rem langs
106
+
local iter=0
107
+
while [ -n "$1" ]; do
108
+
bapCYOR_arr "$1" .langs.["$iter"] || return $?
109
+
((iter++))
110
+
# lexicon limit 3
111
+
if [ "$iter" -gt "2" ]; then break; fi
112
+
shift
113
+
done
114
+
return 0
115
+
}
116
+
117
+
function bapBsky_cyorAddTags () {
118
+
if [ -z "$1" ]; then bapBskyErr "error: Required arugment missing"; return 1; fi
119
+
bapCYOR_rem tags
120
+
local iter=0
121
+
while [ -n "$1" ]; do
122
+
bapCYOR_arr "$1" .tags.["$iter"] || return $?
123
+
((iter++))
124
+
# lexicon limit 8
125
+
if [ "$iter" -gt "7" ]; then break; fi
126
+
shift
127
+
done
128
+
return 0
129
+
}
130
+
131
+
# (porn, sexual, nudity), graphic-media
132
+
function bapBsky_cyorAddSelfLabels () {
133
+
if [ -z "$1" ]; then bapBskyErr "error: Required argument missing"; return 1; fi
134
+
bapCYOR_str \$type com.atproto.label.defs#selfLabels .labels
135
+
bapCYOR_rem labels.values
136
+
local iter=0
137
+
while [ -n "$1" ]; do
138
+
bapCYOR_str val "$1" .labels.values.["$iter"]
139
+
((iter++))
140
+
shift
141
+
done
142
+
return 0
143
+
}
144
+
145
+
function bapBsky_cyorAddQuote () {
146
+
if [ -z "$1" ]; then bapBskyErr "error: Required argument missing"; return 1; fi
147
+
local bapBsky_temp cyorTemp l
148
+
bapBsky_temp=$(bapBsky_getPost "$1" -e) || return $?
149
+
if [ "$(echo "$bapBsky_temp" | jq -r .viewer.embeddingDisabled)" = "true" ]; then bapBskyErr "specified post disallows quotes"; return 2; fi
150
+
case "$(echo "$bap_cyorRecord" | jq -r ".record.embed.[\"\$type\"]")" in
151
+
"null")
152
+
bapCYOR_str \$type "app.bsky.embed.record" .embed;;
153
+
"app.bsky.embed.record");;
154
+
"app.bsky.embed.recordWithMedia")
155
+
l=.record;;
156
+
*)
157
+
# convert to record with media
158
+
bapBskyInternal_convertToRecordWithMedia media
159
+
l=.record;;
160
+
esac
161
+
bapCYOR_str uri "$(echo "$bapBsky_temp" | jq -re '.uri')" .embed.record$l
162
+
bapCYOR_str cid "$(echo "$bapBsky_temp" | jq -re '.cid')" .embed.record$l
163
+
return 0
164
+
}
165
+
166
+
function bapBsky_cyorAddExternalEmbed () {
167
+
if [ -z "$3" ] || [ -n "$4" ] && [ -z "$6" ]; then bapBskyErr "error: Required argument missing"; return 1; fi
168
+
if [ "$(echo "$bap_cyorRecord" | jq -r ".embed.[\"\$type\"]")" = "app.bsky.embed.record" ]; then bapBskyInternal_convertToRecordWithMedia record; fi
169
+
if [ "$(echo "$bap_cyorRecord" | jq -r ".embed.[\"\$type\"]")" = "app.bsky.embed.recordWithMedia" ]; then local l=.media; fi
170
+
bapCYOR_str \$type app.bsky.embed.external ".embed$l"
171
+
bapCYOR_str uri "$1" ".embed$l.external"
172
+
bapCYOR_str title "$2" ".embed$l.external"
173
+
bapCYOR_str description "$3" ".embed$l.external"
174
+
if [ -n "$4" ]; then
175
+
bapCYOR_str \$type blob ".embed$l.external.thumb"
176
+
bapCYOR_str \$link "$4" ".embed$l.external.thumb.ref"
177
+
bapCYOR_str mimeType "$5" ".embed$l.external.thumb"
178
+
bapCYOR_add size "$6" ".embed$l.external.thumb"
179
+
fi
180
+
}
181
+
182
+
#shellcheck disable=SC2120
183
+
function bapBsky_submitPost () {
184
+
if ! echo "$bap_cyorRecord" | jq -re .createdAt > /dev/null; then bapCYOR_str createdAt "$(bap_generateDatetime)"; fi
185
+
bapBskyErrVerb "submitting record..."
186
+
bap_postRecord "$bap_cyorRecord" "$1" "$2" || return $?
187
+
bap_cyorRecord=
188
+
uri=$(echo "$bap_result" | jq -r .uri)
189
+
cid=$(echo "$bap_result" | jq -r .cid)
190
+
return 0
191
+
}
192
+
193
+
function bapBsky_createPost () { #1: exception 2: refresh required
194
+
if [ -z "$1" ]; then bapBskyErr "fatal: No argument given to post"; return 1; fi
195
+
bapBsky_cyorInit
196
+
bapCYOR_str text "$1"
197
+
if [ -n "$2" ]; then bapBsky_cyorAddLangs "$2"; fi
198
+
bapBsky_submitPost || return $?
199
+
bapBskyEcho "Posted record at $uri"
200
+
return 0
201
+
}
202
+
203
+
function bapBsky_createRepost () { # arguments 1 is uri, 2 is cid. error codes same as postToBluesky
204
+
if [ -z "$2" ]; then bapBskyErr "fatal: Required argument missing"; return 1; fi
205
+
if [ "$bapBsky_skipRepostChecks" != "1" ]; then
206
+
bapBsky_temp=$(bapBsky_getPost "$1" -e) || return $?
207
+
if [ "$(echo "$bapBsky_temp" | jq -r .viewer.repost)" != "null" ]; then bapBskyErr "you already reposted this post!"; return 0; fi
208
+
fi
209
+
bap_cyorRecord=
210
+
bapCYOR_str \$type app.bsky.feed.repost
211
+
bapCYOR_str cid "$2" .subject
212
+
bapCYOR_str uri "$1" .subject
213
+
if [ "$3" = "--no-create" ]; then declare -g bap_cyorRecord=$bap_cyorRecord; return 0; fi
214
+
bapBsky_submitPost || return $?
215
+
bapBskyEcho "Repost record at $uri"
216
+
return 0
217
+
}
218
+
219
+
function bapBsky_prepareImage () {
220
+
bap_prepareImage "$1" 1000000 2000 2000
221
+
return $?
222
+
}
223
+
224
+
function bapBsky_postImage () { #1: exception 2: refresh required
225
+
# param:
226
+
# 1 - blob
227
+
# 2 - mimetype
228
+
# 3 - size
229
+
# 4 - width
230
+
# 5 - height
231
+
# 6 - alt text
232
+
# 7 - text
233
+
if [ -z "$5" ]; then bapBskyErr "fatal: more arguments required"; return 1; fi
234
+
bapBsky_cyorInit
235
+
bapBsky_cyorAddImage 0 "$1" "$2" "$3" "$4" "$5" "$6"
236
+
bapCYOR_str text "$7"
237
+
bapBsky_submitPost || return $?
238
+
bapBskyEcho "Posted record at $uri"
239
+
return 0
240
+
}
241
+
242
+
function bapBsky_checkVideo () {
243
+
if [ ! -f "$1" ]; then bapBskyErr "error: specify file to check"; return 1; fi
244
+
if [[ $(stat -c %s "$1") -gt 100000000 ]]; then bapBskyErr 'fatal: video may not exceed 100 mb'; return 1; fi
245
+
if [ "$(exiftool -duration# -s3 "$1" | awk '{print int($1+0.5)}')" -gt "180" ]; then bapBskyErr "error: video length must be 3 minutes or less"; return 1; fi
246
+
return 0
247
+
}
248
+
249
+
function bapBsky_prepareVideoIndirect () {
250
+
# $1 is file
251
+
# $2 is mime (like bap_postBlobToPDS)
252
+
if [ -z "$2" ]; then bapBskyErr "fatal: Required argument missing"; return 1; fi
253
+
if [ "$2" != "video/mp4" ]; then bapBskyErr "videos must be mp4"; fi
254
+
bapBsky_checkVideo "$1" || return $?
255
+
# bap_prepareImage "$1" 100000000 2147483647 2147483647
256
+
local uuid=$(uuidgen)
257
+
exiftool -all= "$1" -o "/tmp/$uuid.mp4" || return $?
258
+
bap_postBlobToPDS "/tmp/$uuid.mp4" "$2" || { bapBskyErr "warning: video upload failed"; rm "/tmp/$uuid.mp4"; return 1; }
259
+
rm "/tmp/$uuid.mp4"
260
+
bap_imageWidth=$(exiftool -ImageWidth -s3 "$1")
261
+
bap_imageHeight=$(exiftool -ImageHeight -s3 "$1")
262
+
bapBskyEcho 'uploaded video'
263
+
return 0
264
+
}
265
+
266
+
function bapBsky_getVideoLimits () {
267
+
# TODO: figure out atproto-proxy for Lumi
268
+
local bap_result bapBsky_temp
269
+
bapBsky_temp=$(bap_getServiceAuth "did:web:$bapBsky_lumiURL" "" app.bsky.video.getUploadLimits) || return $?
270
+
bap_result=$(curl --fail-with-body -s -A "$bap_curlUserAgent" -H "Authorization: Bearer $bapBsky_temp" "https://$bapBsky_lumiURL/xrpc/app.bsky.video.getUploadLimits")
271
+
bapInternal_errorCheck $? bapBsky_getvideoLimits "fatal: failed to get video limits" || return $?
272
+
if [ "$1" = "--raw" ]; then echo "$bap_result"; else
273
+
if [ "$(echo "$bap_result" | jq -r .canUpload)" != "true" ]; then echo "$bap_result" | jq -r .message; return 1; fi
274
+
echo "$bap_result" | jq -r .message
275
+
echo "Can upload $(echo "$bap_result" | jq -r .remainingDailyVideos) more videos, $(echo "$bap_result" | jq -r .remainingDailyBytes) more bytes."
276
+
return 0
277
+
fi
278
+
}
279
+
280
+
# function bapBsky_videoUpload () {
281
+
# local bap_resultcode
282
+
# bap_result=$(curl --fail-with-body -s -A "$bap_curlUserAgent" -X POST -H "Authorization: Bearer $1" -H "Content-Type: $3" --data-binary @"$2" "https://$bapBsky_lumiURL/xrpc/app.bsky.video.uploadVideo")
283
+
# bap_resultcode=$?
284
+
# # BskyVideo can throw HTTP 409 if it's already done, check for that
285
+
# if [ "$(echo $bap_result | jq -r .jobStatus.error)" = "already_exists" ]; then bap_resultcode=0; fi
286
+
# bapInternal_errorCheck $bap_resultcode bapBsky_videoUpload "fatal: failed to upload video" || return $?
287
+
# # give job id stuff
288
+
# bapBsky_jobId=$(echo "$bap_result" | jq -r .jobStatus.jobId)
289
+
# return 0
290
+
# }
291
+
#
292
+
# function bapBsky_videoStatus () {
293
+
# # no auth needed?
294
+
# curl --fail-with-body -s -A "$bap_curlUserAgent" "https://$bapBsky_lumiURL/xrpc/app.bsky.video.getJobStatus?jobId=$1"
295
+
# }
296
+
297
+
function bapBsky_prepareVideo () {
298
+
# if [ -z "$2" ]; then bapBskyErr "fatal: Required argument missing"; return 1; fi
299
+
# bapBsky_checkVideo "$1" || return $?
300
+
# local bapBsky_temp
301
+
# #bapBsky_temp=$(bap_getServiceAuth did:web:video.bsky.app 900 com.atproto.server.uploadBlob) || return $?
302
+
# bapBsky_temp=$(bap_getServiceAuth did:web:$(echo $savedPDS | sed 's|https://||g') 900 com.atproto.server.uploadBlob) || return $?
303
+
# bapBsky_videoUpload "$bapBsky_temp" "$1" "$2" || return $?
304
+
# while :; do
305
+
# bapBsky_temp=$(bapBsky_videoStatus "$bapBsky_jobId" | jq -r '.jobStatus.state') || { bapBskyErr "unexpected response from videoStatus"; return 1; }
306
+
# if [ "$bapBsky_temp" = "JOB_STATUS_COMPLETED" ]; then break; fi
307
+
# if [ "$bapBsky_temp" = "JOB_STATUS_FAILED" ]; then bapBskyErr "the video failed to process"; return 1; fi
308
+
# sleep 1
309
+
# done
310
+
# bap_postedBlob=$(echo $bapBsky_temp | jq -r .jobStatus.blob)
311
+
# bap_postedMime="video/mp4"
312
+
# bap_imageWidth=$(exiftool -ImageWidth -s3 $1)
313
+
# bap_imageHeight=$(exiftool -ImageHeight -s3 $1)
314
+
# return 0
315
+
bapBskyErr "function not implemented. try using bapBsky_prepareVideoIndirect"
316
+
return 1
317
+
}
318
+
319
+
function bapBsky_postVideo () {
320
+
# param:
321
+
# 1-5 - see bapBsky_cyorAddVideo
322
+
# 6 - text
323
+
if [ -z "$4" ]; then bapBskyErr "fatal: more arguments required"; return 1; fi
324
+
bapBsky_cyorInit
325
+
bapCYOR_str text "$6"
326
+
bapBsky_cyorAddVideo "$1" "$2" "$3" "$4" "$5"
327
+
bapBsky_submitPost || return $?
328
+
bapBskyEcho "Posted record at $uri"
329
+
return 0
330
+
}
331
+
332
+
function bapBsky_verifyActor () {
333
+
if [ -z "$1" ]; then bapBskyErr "error: Required argument missing"; return 1; fi
334
+
local bap_cyorRecord bapBsky_temp bapBsky_temp2
335
+
bapBsky_temp2=$(bap_getDID "$1") || return $?
336
+
bapBsky_temp=$(bap_getRecord "at://$bapBsky_temp2/app.bsky.actor.profile/self") || { bapBskyErr "failed to resolve profile for verification"; return 1; }
337
+
bap_cyorRecord=
338
+
bapCYOR_str \$type "app.bsky.graph.verification"
339
+
bapCYOR_str handle "$(bap_resolveHandle "$bapBsky_temp2")"
340
+
bapCYOR_str subject "$bapBsky_temp2"
341
+
bapCYOR_str displayName "$(echo "$bapBsky_temp" | jq -r .value.displayName)"
342
+
if [ "$2" = "--no-create" ]; then declare -g bap_cyorRecord=$bap_cyorRecord; return 0; fi
343
+
bapBsky_submitPost || return $?
344
+
bapBskyEcho "verified $(echo "$bapBsky_temp" | jq -r .value.displayName)"
345
+
return 0
346
+
}
347
+
348
+
function bapBsky_getProfile () {
349
+
if [ -z "$savedAccess" ]; then bapBskyErr "needs auth!"; return 1; fi
350
+
if ! bapInternal_validateDID "$1" 2>/dev/null && ! bapInternal_validateHandle "$1" 2>/dev/null; then bapBskyErr "bad identifier"; return 1; fi
351
+
local bap_result
352
+
bap_result=$(curl --fail-with-body -s -A "$bap_curlUserAgent" -H "Authorization: Bearer $savedAccess" -H "atproto-proxy: $bapBsky_bskyAppViewDID" "$savedPDS/xrpc/app.bsky.actor.getProfile?actor=$1")
353
+
bapInternal_errorCheck $? bapBsky_getProfile "failed to get profile for $1" || return $?
354
+
echo "$bap_result"
355
+
return 0
356
+
}
357
+
358
+
function bapBsky_followActor () {
359
+
if [ -z "$1" ]; then bapBskyErr "error: Required argument missing"; return 1; fi
360
+
local bap_cyorRecord='' bapBsky_temp
361
+
bapBsky_temp=$(bapBsky_getProfile "$1") || return $?
362
+
if [ "$(echo "$bapBsky_temp" | jq -r .viewer.blockedBy)" = "true" ]; then bapBskyErr "can't follow: $(echo "$bapBsky_temp" | jq -r .displayName) is blocking you!"; return 2; fi
363
+
if [ "$(echo "$bapBsky_temp" | jq -r .viewer.blockingByList)" != "null" ]; then bapBskyErr "can't follow: you are blocking $(echo "$bapBsky_temp" | jq -r .displayName) by list $(echo "$bapBsky_temp" | jq -r .viewer.blockingByList.name)!"; return 3; fi
364
+
if [ "$(echo "$bapBsky_temp" | jq -r .viewer.blocking)" != "null" ]; then bapBskyErr "can't follow: you are blocking $(echo "$bapBsky_temp" | jq -r .displayName)!"; return 3; fi
365
+
if [ "$(echo "$bapBsky_temp" | jq -r .viewer.following)" != "null" ]; then bapBskyErr "already following $(echo "$bapBsky_temp" | jq -r .displayName)"; return 0; fi
366
+
if [ "$(echo "$bapBsky_temp" | jq -r .did)" = "$savedDID" ]; then bapBskyErr "you can't follow yourself!"; return 1; fi
367
+
bapCYOR_str \$type "app.bsky.graph.follow"
368
+
bapCYOR_str subject "$(bap_getDID "$1" || return 1)"
369
+
if [ "$2" = "--no-create" ]; then declare -g bap_cyorRecord=$bap_cyorRecord; return 0; fi
370
+
bapBsky_submitPost || return $?
371
+
bapBskyEcho "now following $(echo "$bapBsky_temp" | jq -r .displayName)"
372
+
return 0
373
+
}
374
+
375
+
function bapBsky_getPost () {
376
+
if [ -z "$savedAccess" ]; then bapBskyErr "needs auth!"; return 1; fi
377
+
if [ "$(echo "$1" | cut -d '/' -f 4)" != "app.bsky.feed.post" ]; then bapBskyErr "this is not a post!"; return 1; fi
378
+
local bap_result
379
+
bapBskyErrVerb "looking up post..."
380
+
bap_result=$(curl --fail-with-body -s -A "$bap_curlUserAgent" -H "Authorization: Bearer $savedAccess" -H "atproto-proxy: $bapBsky_bskyAppViewDID" "$savedPDS/xrpc/app.bsky.feed.getPosts?uris=$(echo -n "$1" | jq -s -R -r @uri)")
381
+
bapInternal_errorCheck $? bapBsky_getPost "failed to get post with uri $1" || return $?
382
+
echo "$bap_result" | jq -r .posts.[0]
383
+
if [ "$bap_result" = "null" ] && [ "$2" = "-e" ]; then bapBskyErr "failed to resolve post. it may not exist or there may be a blocking relationship."; return 2; fi
384
+
return 0
385
+
}
386
+
387
+
function bapBsky_likePost () {
388
+
if [ -z "$1" ]; then bapBskyErr "error: Required argument missing"; return 1; fi
389
+
local bap_cyorRecord='' bapBsky_temp
390
+
bapBsky_temp=$(bapBsky_getPost "$1" -e) || return $?
391
+
if [ "$(echo "$bapBsky_temp" | jq -r .viewer.like)" != "null" ]; then bapBskyErr "you already liked this post!"; return 0; fi
392
+
bapCYOR_str \$type "app.bsky.feed.like"
393
+
bapCYOR_str cid "$(echo "$bapBsky_temp" | jq -r .cid)" .subject
394
+
bapCYOR_str uri "$(echo "$bapBsky_temp" | jq -r .uri)" .subject
395
+
if [ "$2" = "--no-create" ]; then declare -g bap_cyorRecord=$bap_cyorRecord; return 0; fi
396
+
bapBsky_submitPost || return $?
397
+
}
+68
bap-extra.sh
+68
bap-extra.sh
···
1
+
#!/bin/bash
2
+
# SPDX-License-Identifier: MIT
3
+
# bap-extra.sh: Other functions that might be useful
4
+
if [ -z "$bap_internalVersion" ]; then >&2 echo "bash-atproto not loaded?"; return 127; fi
5
+
if [ "$bap_internalVersion" != "4" ] || ! [ "$bap_internalMinorVer" -ge "-1" ]; then >&2 echo "Incorrect bash-atproto version"; return 1; fi
6
+
7
+
bapExt_internalVersion=2
8
+
bapExt_internalMinorVer=1
9
+
10
+
function bapExtErr () {
11
+
>&2 echo "bap-extra: $*"
12
+
}
13
+
14
+
function bapExtEcho () {
15
+
if [ ! "$bap_verbosity" -ge 1 ]; then return 0; fi
16
+
echo "bap-extra: $*"
17
+
}
18
+
19
+
function bapExt_authWithATFileCreds () {
20
+
# Auth with atfile credentials for your convenience
21
+
if [ -f "$HOME/.config/atfile.env" ]; then while IFS= read -r line; do declare -g "$line"; done < "$HOME/.config/atfile.env"
22
+
else bapExtErr "no ATFile credentials file to load!"; return 1; fi
23
+
if [ -z "$ATFILE_USERNAME" ] || [ -z "$ATFILE_PASSWORD" ]; then bapExtErr "ATFile credentials not found!"; fi
24
+
bap_getKeys "$ATFILE_USERNAME" "$ATFILE_PASSWORD" || { bapExtErr "Couldn't log in with ATFile credentials!"; return 1; }
25
+
ATFILE_USERNAME='' ATFILE_PASSWORD=''
26
+
bapExtEcho "Auth successful. Use bap_closeSession when done"
27
+
return 0
28
+
}
29
+
30
+
function bapExt_checkDeps () {
31
+
local problem=0
32
+
if ! type file >/dev/null 2>&1; then problem=2; bapExtErr "missing file"; fi
33
+
if ! type convert >/dev/null 2>&1; then problem=2; bapExtErr "missing convert"; fi
34
+
if ! type exiftool >/dev/null 2>&1; then problem=2; bapExtErr "missing exiftool"; fi
35
+
if ! type uuidgen >/dev/null 2>&1; then problem=2; bapExtErr "missing uuidgen"; fi
36
+
if ! type jq >/dev/null 2>&1; then problem=1; bapExtErr "missing jq"; fi
37
+
if ! type curl >/dev/null 2>&1; then problem=1; bapExtErr "missing curl"; fi
38
+
if ! type dig >/dev/null 2>&1; then problem=1; bapExtErr "missing dig"; fi
39
+
# if you can load this function, you have bash
40
+
41
+
case "$problem" in
42
+
1)
43
+
bapExtEcho "A core dependency required by bash-atproto is missing."
44
+
bapExtEcho "bash-atproto will not work.";;
45
+
2)
46
+
bapExtEcho "A dependency used by the media subsystems of bash-atproto is missing."
47
+
bapExtEcho "Processing images and video will not function, but other functionality will work.";;
48
+
0)
49
+
bapExtEcho "There are no bash-atproto dependency issues on this system.";;
50
+
esac
51
+
}
52
+
53
+
function bapExt_bskyCancelActor () {
54
+
if [ -z "$1" ]; then bapExtErr "error: Required argument missing"; return 1; fi
55
+
local bap_cyorRecord bapExt_temp bapExt_temp2
56
+
bapExt_temp2=$(bap_getDID "$1") || return $?
57
+
bapExt_temp=$(bap_getRecord "at://$bapExt_temp2/app.bsky.actor.profile/self") || { bapExtErr "failed to resolve profile for cancelation"; return 1; }
58
+
bap_cyorRecord=
59
+
bapCYOR_str \$type "app.bsky.graph.cancellation"
60
+
bapCYOR_str handle "$(bap_resolveHandle "$bapExt_temp2")"
61
+
bapCYOR_str subject "$bapExt_temp2"
62
+
bapCYOR_str displayName "$(echo "$bapExt_temp" | jq -r .value.displayName)"
63
+
bapCYOR_str createdAt "$(bap_generateDatetime)"
64
+
bap_postRecord "$bap_cyorRecord" || return $?
65
+
uri=$(echo "$bap_result" | jq -r .uri)
66
+
cid=$(echo "$bap_result" | jq -r .cid)
67
+
bapExtEcho "canceled $(echo "$bapExt_temp" | jq -r .value.displayName)"
68
+
}
+101
-47
bap-sprk.sh
+101
-47
bap-sprk.sh
···
1
1
#!/bin/bash
2
2
# SPDX-License-Identifier: MIT
3
-
# bap-sprk.sh: Provides helper functions to post images and video to Spark Social.
4
-
# Requires bash-atproto (https://github.com/Engielolz/imasimgbot/blob/master/bash-atproto.sh)
3
+
# bap-sprk.sh: Spark the revolution...from the command line!
5
4
# The PDS is not capable of verifying Spark lexicon at the moment, be careful!
5
+
# This code has not been thoroughly tested. Use at your own risk!
6
+
# TODO Replies still use .text not .caption.text
7
+
if [ -z "$bap_internalVersion" ]; then >&2 echo "bash-atproto not loaded?"; return 127; fi
8
+
if [ "$bap_internalVersion" != "4" ] || ! [ "$bap_internalMinorVer" -ge "-1" ]; then >&2 echo "Incorrect bash-atproto version"; return 1; fi
6
9
7
-
function bapSprk_err() {
8
-
>&2 echo "bash-atproto: spark: $*"
10
+
11
+
bapSprk_internalVersion=2
12
+
bapSprk_internalMinorVer=1
13
+
14
+
function bapSprk_err () {
15
+
>&2 echo "bap-sprk: $*"
9
16
}
10
17
11
-
function bapSprk_echo() {
18
+
function bapSprk_echo () {
12
19
if [ ! "$bap_verbosity" -ge 1 ]; then return 0; fi
13
-
echo "bash-atproto: spark: $*"
20
+
echo "bap-sprk: $*"
21
+
}
22
+
23
+
function bapSprk_cyorInit () {
24
+
bap_cyorRecord="{}"
25
+
bapCYOR_str \$type so.sprk.feed.post
26
+
bapCYOR_str text "" .caption
14
27
}
15
28
16
-
function bapSprk_postVideo() {
29
+
function bapSprk_cyorAddImage () {
30
+
# Lexicon has alt text but no image dimensions
31
+
# 1 image, 2 blob, 3 mime, 4 size, 5 alt
32
+
if [ -z "$4" ]; then bapSprk_err "error: Required argument missing"; return 1; fi
33
+
bapCYOR_str \$type so.sprk.media.images .media
34
+
bapCYOR_str alt "$5" ".media.images.[$1]"
35
+
bapCYOR_str \$type so.sprk.embed.images#image ".media.images.[$1]"
36
+
bapCYOR_str \$type blob ".media.images.[$1].image"
37
+
bapCYOR_str \$link "$2" ".media.images.[$1].image.ref"
38
+
bapCYOR_str mimeType "$3" ".media.images.[$1].image"
39
+
bapCYOR_add size "$4" ".media.images.[$1].image"
40
+
#bapCYOR_add width $6 ".media.images.[$1].aspectRatio"
41
+
#bapCYOR_add height $7 ".media.images.[$1].aspectRatio"
42
+
}
43
+
44
+
function bapSprk_cyorAddVideo () {
45
+
if [ -z "$3" ]; then bapSprk_err "error: Required argument missing"; return 1; fi
46
+
bapCYOR_str \$type so.sprk.media.video .media
47
+
bapCYOR_str alt "$4" .media
48
+
bapCYOR_str \$type blob .media.video
49
+
bapCYOR_str \$link "$1" .media.video.ref
50
+
bapCYOR_str mimeType "$3" .media.video
51
+
bapCYOR_add size "$2" .media.video
52
+
}
53
+
54
+
function bapSprk_submitPost () {
55
+
bapCYOR_str createdAt "$(bap_generateDatetime)"
56
+
bap_postRecord "$bap_cyorRecord" "$1" "$2" || return $?
57
+
bap_cyorRecord=
58
+
uri=$(echo "$bap_result" | jq -r .uri)
59
+
cid=$(echo "$bap_result" | jq -r .cid)
60
+
return 0
61
+
}
62
+
63
+
function bapSprk_prepareImage () {
64
+
# 5MB image limit: at://sprk.so/app.bsky.feed.post/3lipdqef2k22n
65
+
# Get rid of dimensions limit with really big numbers
66
+
bap_prepareImage "$1" 5000000 2147483647 2147483647
67
+
return $?
68
+
}
69
+
70
+
function bapSprk_postVideo () {
17
71
# The parameters are different commpared to bap_postVideoToBluesky
18
72
# If you just ran bap_postBlobToPDS, you can use the variables on the right.
19
73
# 1 blob - $bap_postedImage
20
74
# 2 size - $bap_postedSize
21
75
# 3 mime - $bap_postedMime
22
-
# 4 text - excluding may or may not be bad in the lexicon
23
-
# The lexicon I've seen don't have alt text or video dimensions...
24
-
# Also no multiple video, that must be coming later
76
+
# 4 alt text
77
+
# 5 post text
25
78
if [ -z "$3" ]; then bapSprk_err "fatal: more arguments required"; return 1; fi
26
-
bap_cyorRecord="{}"
27
-
bapCYOR_str text "$4"
28
-
bapCYOR_str \$type so.sprk.feed.post
29
-
bapCYOR_str \$type so.sprk.embed.video .embed
30
-
#bapCYOR_str alt "" .embed.video
31
-
bapCYOR_str \$type blob .embed.video
32
-
bapCYOR_str \$link $1 .embed.video.ref
33
-
bapCYOR_str mimeType "$bap_postedMime" .embed.video
34
-
bapCYOR_add size $bap_postedSize .embed.video
35
-
bapCYOR_str createdAt $(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)
36
-
bap_postRecord "$bap_cyorRecord" || return $?
37
-
uri=$(echo $bap_result | jq -r .uri)
38
-
cid=$(echo $bap_result | jq -r .cid)
79
+
bapSprk_cyorInit
80
+
bapCYOR_str text "$5" .caption
81
+
bapSprk_cyorAddVideo "$1" "$2" "$3" "$4"
82
+
bapSprk_submitPost "$bap_cyorRecord" || return $?
39
83
bapSprk_echo "Posted record at $uri"
84
+
return 0
40
85
}
41
86
42
87
43
-
function bapSprk_postImage() {
44
-
# Trying to mirror bap_postImageToBluesky
45
-
# Lexicon has alt text but not image dimensions?
88
+
function bapSprk_postImage () {
89
+
# Trying to mirror bapBsky_postImage
46
90
# param:
47
91
# 1 - blob
48
92
# 2 - mimetype
49
93
# 3 - size
50
-
# 4 - width
51
-
# 5 - height
52
-
# 6 - alt text
53
-
# 7 - text
54
-
# image dimensions will be ignored, or specify ""
94
+
# 4 - alt text
95
+
# 5 - text
55
96
if [ -z "$3" ]; then bapSprk_err "fatal: more arguments required"; return 1; fi
56
-
bap_cyorRecord="{}"
57
-
bapCYOR_str text "$7"
58
-
bapCYOR_str \$type so.sprk.feed.post
59
-
bapCYOR_str \$type so.sprk.embed.images .embed
60
-
bapCYOR_str alt "$6" .embed.images.[0]
61
-
bapCYOR_str \$type so.sprk.embed.images#image .embed.images.[0]
62
-
bapCYOR_str \$type blob .embed.images.[0].image
63
-
bapCYOR_str \$link $1 .embed.images.[0].image.ref
64
-
bapCYOR_str mimeType "$2" .embed.images.[0].image
65
-
bapCYOR_add size $3 .embed.images.[0].image
66
-
#bapCYOR_add width $4 .embed.images.[0].aspectRatio
67
-
#bapCYOR_add height $5 .embed.images.[0].aspectRatio
68
-
bapCYOR_str createdAt $(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)
69
-
bap_postRecord "$bap_cyorRecord" || return $?
70
-
uri=$(echo $bap_result | jq -r .uri)
71
-
cid=$(echo $bap_result | jq -r .cid)
97
+
bapSprk_cyorInit
98
+
bapCYOR_str text "$5" .caption
99
+
bapSprk_cyorAddImage 0 "$1" "$2" "$3" "$4" "$5"
100
+
bapSprk_submitPost "$bap_cyorRecord" || return $?
72
101
bapSprk_echo "Posted record at $uri"
102
+
return 0
103
+
}
104
+
105
+
function bapSprkInternal_cyorGetReplyRoot () {
106
+
if [ -z "$1" ]; then bapSprk_err "error: Required argument missing"; return 1; fi
107
+
bapSprk_temp=$(bap_getRecord "$1") || return $?
108
+
if echo "$bapSprk_temp" | jq -re '.value.reply.root.uri' > /dev/null; then
109
+
# copy values
110
+
bapCYOR_str uri "$(echo "$bapSprk_temp" | jq -re '.value.reply.root.uri')" .reply.root
111
+
bapCYOR_str cid "$(echo "$bapSprk_temp" | jq -re '.value.reply.root.cid')" .reply.root
112
+
else
113
+
# we just wasted time and bandwidth!
114
+
bapCYOR_str uri "$(echo "$bapSprk_temp" | jq -re '.uri')" .reply.root
115
+
bapCYOR_str cid "$(echo "$bapSprk_temp" | jq -re '.cid')" .reply.root
116
+
fi
117
+
return 0
118
+
}
119
+
120
+
function bapSprk_cyorAddReply () {
121
+
if [ -z "$1" ]; then bapSprk_err "error: Required argument missing"; return 1; fi
122
+
bapSprk_temp=$(bap_getRecord "$1") || return $?
123
+
bapCYOR_str uri "$(echo "$bapSprk_temp" | jq -re '.uri')" .reply.parent
124
+
bapCYOR_str cid "$(echo "$bapSprk_temp" | jq -re '.cid')" .reply.parent
125
+
bapSprkInternal_cyorGetReplyRoot "$1"
126
+
return 0
73
127
}
+208
-208
bash-atproto.sh
+208
-208
bash-atproto.sh
···
1
1
#!/bin/bash
2
2
# SPDX-License-Identifier: MIT
3
+
# shellcheck disable=SC2034
4
+
bap_internalVersion=4
5
+
bap_internalMinorVer=-1
3
6
4
7
# you can change these
5
8
bap_plcDirectory=https://plc.directory
6
-
bap_handleResolveURL=https://public.api.bsky.app
7
-
bap_curlUserAgent="curl/$(curl -V | awk 'NR==1{print $2}') bash-atproto/2-$(git -C $(dirname $BASH_SOURCE) -c safe.directory=$(dirname $BASH_SOURCE) describe --always --dirty)"
9
+
bap_curlUserAgent="curl/$(curl -V | awk 'NR==1{print $2}') bash-atproto/$bap_internalVersion.$bap_internalMinorVer-$(git -C "$(dirname "${BASH_SOURCE[0]}")" -c safe.directory="$(cd "$(dirname "${BASH_SOURCE[0]}")"; pwd)" describe --always --dirty 2>/dev/null)"
8
10
bap_chmodSecrets=1
9
11
bap_verbosity=1
12
+
bap_disableOptionalChecks=0
13
+
bap_dryRun=0
10
14
11
15
function baperr () {
12
16
>&2 echo "bash-atproto: $*"
···
20
24
function bapverbose () {
21
25
if [ ! "$bap_verbosity" -ge 2 ]; then return 0; fi
22
26
echo "bash-atproto: $*"
27
+
}
28
+
29
+
function baperrverb () {
30
+
if [ ! "$bap_verbosity" -ge 2 ]; then return 0; fi
31
+
>&2 echo "bash-atproto: $*"
23
32
}
24
33
25
34
function bap_decodeJwt () {
26
-
bap_jwt="$(echo $1 | cut -d '.' -f 2 \
35
+
bap_jwt="$(echo "$1" | cut -d '.' -f 2 \
27
36
| sed 's/$/====/' | fold -w 4 | sed '$ d' | tr -d '\n' | tr '_-' '/+' \
28
37
| base64 -d | jq -re)" || { baperr "not a jwt"; return 1; }
29
38
# 1: fetch JWT payload 2: pad and convert to base64 3: decode
···
31
40
}
32
41
33
42
function bapInternal_loadFromJwt () {
34
-
savedDID="$(echo $bap_jwt | jq -r .sub)"
35
-
savedPDS="https://$(echo $bap_jwt | jq -r .aud | sed 's/did:web://g')"
36
-
savedAccessTimestamp="$(echo $bap_jwt | jq -r .iat)" #deprecated
37
-
savedAccessExpiry="$(echo $bap_jwt | jq -r .exp)"
43
+
savedDID="$(echo "$bap_jwt" | jq -r .sub)"
44
+
savedPDS="https://$(echo "$bap_jwt" | jq -r .aud | sed 's/did:web://g')"
45
+
savedAccessTimestamp="$(echo "$bap_jwt" | jq -r .iat)" #deprecated
46
+
savedAccessExpiry="$(echo "$bap_jwt" | jq -r .exp)"
47
+
}
48
+
49
+
function bapInternal_verifyStatus () {
50
+
if [ "$bap_disableOptionalChecks" = "1" ]; then return 0; fi
51
+
if [ "$(echo "$bap_result" | jq -r .active)" = "false" ]; then
52
+
baperr "warning: account is inactive"
53
+
if [ -n "$(echo "$bap_result" | jq -r .status)" ]; then baperr "pds said: $(echo "$bap_result" | jq -r .status)"; else baperr "no reason was given for the account not being active"; fi
54
+
return 115
55
+
fi
38
56
}
39
57
40
58
function bap_loadSecrets () {
···
48
66
49
67
function bap_saveSecrets () {
50
68
bapecho 'Updating secrets'
51
-
echo 'savedAccess='$savedAccess > "$1"
52
-
echo 'savedRefresh='$savedRefresh >> "$1"
69
+
echo "savedAccess=$savedAccess" > "$1"
70
+
echo "savedRefresh=$savedRefresh" >> "$1"
53
71
if [ "$bap_chmodSecrets" != "0" ]; then chmod 600 "$1"; fi
54
72
return 0
55
73
}
56
74
57
75
function bapInternal_processAPIError () {
58
-
baperr 'Function' $1 'encountered an API error'
59
-
APIErrorCode=$(echo ${!2} | jq -r .error)
60
-
APIErrorMessage=$(echo ${!2} | jq -r .message)
61
-
baperr 'Error code:' $APIErrorCode
62
-
baperr 'Message:' $APIErrorMessage
76
+
baperr "Function $1 encountered an API error"
77
+
APIErrorCode=$(echo "${!2}" | jq -r .error)
78
+
APIErrorMessage=$(echo "${!2}" | jq -r .message)
79
+
baperr "Error code: $APIErrorCode"
80
+
baperr "Message: $APIErrorMessage"
63
81
}
64
82
65
83
function bapInternal_errorCheck () {
66
84
case $1 in
67
85
0);;
68
86
22)
69
-
if [ ! -z "$3" ]; then baperr "$3"; fi
70
-
APIErrorCode=$(echo $bap_result | jq -r .error)
71
-
if ! [ "$APIErrorCode" = "ExpiredToken" ]; then bapInternal_processAPIError $2 bap_result; return 1; fi
87
+
if [ -n "$3" ]; then baperr "$3"; fi
88
+
if ! jq -e . >/dev/null 2>&1 <<<"$bap_result"; then baperr "the server did not respond with valid JSON"; return 1; fi
89
+
APIErrorCode=$(echo "$bap_result" | jq -r .error)
90
+
if ! [ "$APIErrorCode" = "ExpiredToken" ]; then bapInternal_processAPIError "$2" bap_result; return 1; fi
72
91
baperr 'the token needs to be refreshed'
73
92
return 2;;
74
93
*)
75
-
if [ ! -z "$3" ]; then baperr "$3"; fi
94
+
if [ -n "$3" ]; then baperr "$3"; fi
76
95
baperr "cURL threw exception $1 in function $2"
77
96
return 1;;
78
97
esac
79
98
}
80
99
81
-
function bapInternal_verifyStatus () {
82
-
if [ "$(echo $bap_result | jq -r .active)" = "false" ]; then
83
-
baperr "warning: account is inactive"
84
-
if [ ! -z "$(echo $bap_result | jq -r .status)" ]; then baperr "pds said: $(echo $bap_result | jq -r .status)"; else baperr "no reason was given for the account not being active"; fi
85
-
return 115
86
-
fi
87
-
}
88
-
89
100
function bapInternal_validateDID () {
90
101
if ! [[ "$1" =~ ^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$ ]]; then baperr "fatal: input not a did"; return 1; fi
91
102
return 0
92
103
}
93
104
105
+
function bapInternal_validateHandle () {
106
+
if ! [[ "$1" =~ ^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$ ]]; then baperr "fatal: input not a handle"; return 1; fi
107
+
return 0
108
+
}
109
+
94
110
function bap_getKeys () { # 1: failure 2: user error
95
111
if [ -z "$2" ]; then baperr "No app password was passed"; return 2; fi
112
+
local lSavedPDS=$3
113
+
if [ -z "$lSavedPDS" ]; then lSavedPDS=$(bap_resolvePDS "$(bap_getDID "$1")") || return $?; fi
96
114
bapecho 'fetching keys'
97
-
bap_result=$(curl --fail-with-body -s -A "$bap_curlUserAgent" -X POST -H 'Content-Type: application/json' -d "{ \"identifier\": \"$1\", \"password\": \"$2\" }" "$savedPDS/xrpc/com.atproto.server.createSession")
115
+
bap_result=$(curl --fail-with-body -s -A "$bap_curlUserAgent" -X POST -H 'Content-Type: application/json' -d "{\"identifier\":\"$1\",\"password\":\"$2\"}" "$lSavedPDS/xrpc/com.atproto.server.createSession")
98
116
bapInternal_errorCheck $? bap_getKeys "fatal: failed to authenticate" || return $?
99
117
bapecho secured the keys!
100
-
savedAccess=$(echo $bap_result | jq -r .accessJwt)
101
-
savedRefresh=$(echo $bap_result | jq -r .refreshJwt)
118
+
savedAccess=$(echo "$bap_result" | jq -r .accessJwt)
119
+
savedRefresh=$(echo "$bap_result" | jq -r .refreshJwt)
102
120
# we don't care about the handle
103
-
bap_decodeJwt $savedAccess
104
-
if [ "$(echo $bap_jwt | jq -r .scope)" != "com.atproto.appPass" ]; then baperr "warning: this is not an app password"; fi
121
+
bap_decodeJwt "$savedAccess"
122
+
if [ "$(echo "$bap_jwt" | jq -r .scope)" = "com.atproto.access" ]; then baperr "warning: this is not an app password"; fi
123
+
bapInternal_loadFromJwt
105
124
bapInternal_verifyStatus || return $?
106
125
return 0
107
126
}
···
111
130
bapecho 'Trying to refresh keys...'
112
131
bap_result=$(curl --fail-with-body -s -A "$bap_curlUserAgent" -X POST -H "Authorization: Bearer $savedRefresh" "$savedPDS/xrpc/com.atproto.server.refreshSession")
113
132
bapInternal_errorCheck $? bap_refreshKeys "fatal: failed to refresh keys!" || return $?
114
-
savedAccess=$(echo $bap_result | jq -r .accessJwt)
115
-
savedRefresh=$(echo $bap_result | jq -r .refreshJwt)
116
-
bap_decodeJwt $savedAccess
133
+
savedAccess=$(echo "$bap_result" | jq -r .accessJwt)
134
+
savedRefresh=$(echo "$bap_result" | jq -r .refreshJwt)
135
+
bap_decodeJwt "$savedAccess"
136
+
bapInternal_loadFromJwt
117
137
bapInternal_verifyStatus || return $?
118
138
return 0
119
139
}
···
122
142
if [ -z "$savedRefresh" ]; then baperr "need refresh token to close session"; return 1; fi
123
143
bap_result=$(curl --fail-with-body -s -A "$bap_curlUserAgent" -X POST -H "Authorization: Bearer $savedRefresh" "$savedPDS/xrpc/com.atproto.server.deleteSession")
124
144
bapInternal_errorCheck $? bap_closeSession "error: failed to delete session" || return $?
125
-
savedAccess= savedRefresh=
145
+
savedAccess='' savedRefresh='' savedAccessTimestamp='' savedAccessExpiry=''
126
146
bapecho "session closed successfully"
127
147
return 0
128
148
}
···
131
151
# for quotes
132
152
if [ -z "$1" ]; then baperr "nothing to add"; return 1; fi
133
153
if [ -z "$bap_cyorRecord" ]; then bap_cyorRecord="{}"; fi
134
-
bap_temp=$2
135
-
bap_cyorRecord=$(echo "$bap_cyorRecord" | jq -c "$3.[\"$1\"]=\"$bap_temp\"")
154
+
local bap_temp
155
+
bap_temp=$(echo "$bap_cyorRecord" | jq -c "$3.[\"$1\"]=\"$2\"") || return $?
156
+
bap_cyorRecord=$bap_temp
136
157
return $?
137
158
}
138
159
···
140
161
# for things that shouldn't be in quotes
141
162
if [ -z "$1" ]; then baperr "nothing to add"; return 1; fi
142
163
if [ -z "$bap_cyorRecord" ]; then bap_cyorRecord="{}"; fi
143
-
bap_temp=$2
144
-
bap_cyorRecord=$(echo "$bap_cyorRecord" | jq -c "$3.[\"$1\"]=$bap_temp")
164
+
local bap_temp
165
+
bap_temp=$(echo "$bap_cyorRecord" | jq -c "$3.[\"$1\"]=$2") || return $?
166
+
bap_cyorRecord=$bap_temp
145
167
return $?
146
168
}
147
169
148
-
function bapCYOR_rem () {
149
-
# doesn't handle special names atm
150
-
if [ -z "$1" ] || [ -z "bap_cyorRecord" ]; then baperr "nothing to remove"; return 1; fi
151
-
bap_cyorRecord=$(echo $bap_cyorRecord | jq -c "del(.$1)")
170
+
function bapCYOR_arr () {
171
+
# arrays
172
+
if [ -z "$1" ]; then baperr "nothing to add"; return 1; fi
173
+
if [ -z "$bap_cyorRecord" ]; then bap_cyorRecord="{}"; fi
174
+
local bap_temp
175
+
bap_temp=$(echo "$bap_cyorRecord" | jq -c "$2=\"$1\"") || return $?
176
+
bap_cyorRecord=$bap_temp
152
177
return $?
153
178
}
154
179
155
-
function bapCYOR_bskypost () {
156
-
bap_cyorRecord="{}"
157
-
bapCYOR_str \$type app.bsky.feed.post
158
-
bapCYOR_str text ""
180
+
function bapCYOR_rem () {
181
+
# doesn't handle special names atm
182
+
if [ -z "$1" ] || [ -z "$bap_cyorRecord" ]; then baperr "nothing to remove"; return 1; fi
183
+
local bap_temp
184
+
bap_temp=$(echo "$bap_cyorRecord" | jq -c "del(.$1)") || return $?
185
+
bap_cyorRecord=$bap_temp
186
+
return $?
159
187
}
160
188
161
189
function bapInternal_finalizeRecord () {
162
190
if ! jq -e . >/dev/null <<<"$1"; then baperr "can't finalize: JSON parse error"; return 1; fi
163
-
bap_finalRecord="{\"collection\": $(echo $1 | jq -c '.["$type"]'), \"repo\": \"$savedDID\", \"record\": $1}"
191
+
local bap_temp
192
+
if [ "$2" = "true" ]; then bap_temp=", \"validate\": true"; fi
193
+
if [ "$2" = "false" ]; then bap_temp=", \"validate\": false"; fi
194
+
if [ -n "$3" ]; then bap_temp="$bap_temp, \"rkey\": \"$3\""; fi
195
+
bap_finalRecord="{\"collection\": $(echo "$1" | jq -c '.["$type"]'), \"repo\": \"$savedDID\", \"record\": $1$bap_temp}"
164
196
if ! jq -e . >/dev/null <<<"$1"; then baperr "finalize: JSON parse error"; return 1; fi
165
197
return 0
166
198
}
167
199
168
200
function bap_postRecord () {
169
-
bapInternal_finalizeRecord "$1" || { baperr "not posting because finalize failed"; return 1; }
201
+
bapInternal_finalizeRecord "$1" "$2" "$3"|| { baperr "not posting because finalize failed"; return 1; }
202
+
if [ "$bap_dryRun" = "1" ]; then
203
+
bapecho "The following $(echo "$bap_finalRecord" | jq -r '.record.["$type"]') record would be sent to the PDS:"
204
+
echo "$bap_finalRecord" | jq
205
+
return 1
206
+
fi
170
207
bap_result=$(curl --fail-with-body -s -A "$bap_curlUserAgent" -X POST -H "Authorization: Bearer $savedAccess" -H 'Content-Type: application/json' -d "$bap_finalRecord" "$savedPDS/xrpc/com.atproto.repo.createRecord")
171
208
bapInternal_errorCheck $? bap_postRecord "failed to post record" || return $?
172
209
return 0
173
210
}
174
211
175
-
function bap_postToBluesky () { #1: exception 2: refresh required
176
-
if [ -z "$1" ]; then baperr "fatal: No argument given to post"; return 1; fi
177
-
bapCYOR_bskypost
178
-
bapCYOR_str text "$1"
179
-
if ! [ -z "2" ]; then bapCYOR_add langs "[\"$2\"]"; fi
180
-
bapCYOR_str createdAt $(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)
181
-
bap_postRecord "$bap_cyorRecord" || return $?
182
-
uri=$(echo $bap_result | jq -r .uri)
183
-
cid=$(echo $bap_result | jq -r .cid)
184
-
bapecho "Posted record at $uri"
185
-
return 0
186
-
}
187
-
188
-
function bap_repostToBluesky () { # arguments 1 is uri, 2 is cid. error codes same as postToBluesky
189
-
if [ -z "$2" ]; then baperr "fatal: Required argument missing"; return 1; fi
190
-
bap_cyorRecord=
191
-
bapCYOR_str \$type app.bsky.feed.repost
192
-
bapCYOR_str cid $2 .subject
193
-
bapCYOR_str uri $1 .subject
194
-
bapCYOR_str createdAt $(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)
195
-
bap_postRecord "$bap_cyorRecord" || return $?
196
-
uri=$(echo $bap_result | jq -r .uri)
197
-
cid=$(echo $bap_result | jq -r .cid)
198
-
bapecho "Repost record at $uri"
199
-
return 0
200
-
}
201
-
202
-
function bapHelper_resizeImageForBluesky () {
212
+
function bapInternal_resizeImage () {
213
+
if ! [[ "$mimetemp" =~ ^image ]]; then baperr "can't resize non-pictures"; return 2; fi
203
214
bapecho "need to resize image"
204
-
convert /tmp/bash-atproto/$workfile -resize 2000x2000 /tmp/bash-atproto/new-$workfile
205
-
if ! [ "$?" = "0" ]; then baperr "fatal: convert failed!"; rm /tmp/bash-atproto/$workfile 2>/dev/null; return 1; fi
206
-
mv -f /tmp/bash-atproto/new-$workfile /tmp/bash-atproto/$workfile
215
+
convert "/tmp/bash-atproto/$workfile" -resize "$1"x"$2" "/tmp/bash-atproto/new-$workfile" || { baperr "fatal: convert failed!"; rm "/tmp/bash-atproto/$workfile" 2>/dev/null; return 1; }
216
+
mv -f "/tmp/bash-atproto/new-$workfile" "/tmp/bash-atproto/$workfile"
207
217
}
208
218
209
-
function bapHelper_compressImageForBluesky () {
219
+
function bapInternal_compressImage () {
220
+
if ! [[ "$mimetemp" =~ ^image ]]; then baperr "can't compress non-pictures"; return 2; fi
210
221
bapecho "image is too big, trying to compress"
211
-
convert /tmp/bash-atproto/$workfile -define jpeg:extent=1000kb /tmp/bash-atproto/new-${workfile%.*}.jpg
212
-
if [[ ! "$?" = "0" ]] || [[ $(stat -c %s /tmp/bash-atproto/new-${workfile%.*}.jpg) -gt 1000000 ]]; then baperr "fatal: error compressing image or image too big to fit in skeet"; rm /tmp/bash-atproto/$workfile /tmp/bash-atproto/new-${workfile%.*}.jpg; return 1; fi
213
-
rm /tmp/bash-atproto/$workfile
214
-
mv -f /tmp/bash-atproto/new-${workfile%.*}.jpg /tmp/bash-atproto/${workfile%.*}.jpg
222
+
convert "/tmp/bash-atproto/$workfile" -define jpeg:extent="$1" "/tmp/bash-atproto/new-${workfile%.*}.jpg"
223
+
if [[ ! "$?" = "0" ]] || [[ $(stat -c %s "/tmp/bash-atproto/new-${workfile%.*}.jpg") -gt $1 ]]; then baperr "fatal: error compressing image"; rm "/tmp/bash-atproto/$workfile" "/tmp/bash-atproto/new-${workfile%.*}.jpg"; return 1; fi
224
+
rm "/tmp/bash-atproto/$workfile"
225
+
mv -f "/tmp/bash-atproto/new-${workfile%.*}.jpg" "/tmp/bash-atproto/${workfile%.*}.jpg"
215
226
workfile=${workfile%.*}.jpg
216
227
}
217
228
218
-
function bap_prepareImageForBluesky () { # 1: error 2 missing dep
219
-
if [ -z "$1" ]; then baperr "fatal: no image specified to prepare"; return 1; fi
229
+
function bap_prepareImage () { # 1: error 2 missing dep
230
+
# args: 1 - image, 2 - max size, 3 - max width, 4 - max height
231
+
if [ -z "$4" ]; then baperr "fatal: not enough parameters"; return 1; fi
232
+
if [ ! -f "$1" ]; then baperr "fatal: image not found"; return 1; fi
220
233
mkdir /tmp/bash-atproto 2>/dev/null
234
+
local workfile mimetemp
221
235
workfile=$(uuidgen)."${1##*.}"
222
-
cp $1 /tmp/bash-atproto/$workfile
223
-
exiftool -all= /tmp/bash-atproto/$workfile -overwrite_original
224
-
if ! [ "$?" = "0" ]; then baperr "fatal: exiftool failed!"; rm /tmp/bash-atproto/$workfile 2>/dev/null; return 1; fi
225
-
if [[ $(identify -format '%w' /tmp/bash-atproto/$workfile) -gt 2000 ]] || [[ $(identify -format '%h' /tmp/bash-atproto/$workfile) -gt 2000 ]]; then
226
-
bapHelper_resizeImageForBluesky
227
-
if ! [ "$?" = "0" ]; then return 1; fi
236
+
mimetemp=$(file --mime-type -b "$bap_preparedImage") || return 1
237
+
cp "$1" "/tmp/bash-atproto/$workfile"
238
+
exiftool -all= "/tmp/bash-atproto/$workfile" -overwrite_original || { baperr "fatal: exiftool failed!"; rm "/tmp/bash-atproto/$workfile" 2>/dev/null; return 1; }
239
+
if [[ $(exiftool -ImageWidth -s3 "/tmp/bash-atproto/$workfile") -gt $3 ]] || [[ $(exiftool -ImageHeight -s3 "/tmp/bash-atproto/$workfile") -gt $4 ]]; then
240
+
bapInternal_resizeImage "$3" "$4" || return 1
228
241
fi
229
-
if [[ $(stat -c %s /tmp/bash-atproto/$workfile) -gt 1000000 ]]; then
230
-
bapHelper_compressImageForBluesky
231
-
if ! [ "$?" = "0" ]; then return 1; fi
242
+
if [[ $(stat -c %s "/tmp/bash-atproto/$workfile") -gt $2 ]]; then
243
+
bapInternal_compressImage "$2" || return 1
232
244
fi
233
245
bapecho "image preparation successful"
234
246
bap_preparedImage=/tmp/bash-atproto/$workfile
235
-
bap_preparedMime=$(file --mime-type -b $bap_preparedImage)
236
-
bap_preparedSize=$(stat -c %s $bap_preparedImage)
237
-
bap_imageWidth=$(identify -format '%w' $bap_preparedImage)
238
-
bap_imageHeight=$(identify -format '%h' $bap_preparedImage)
247
+
bap_preparedMime=$(file --mime-type -b "$bap_preparedImage")
248
+
bap_preparedSize=$(stat -c %s "$bap_preparedImage")
249
+
bap_imageWidth=$(exiftool -ImageWidth -s3 "$bap_preparedImage")
250
+
bap_imageHeight=$(exiftool -ImageHeight -s3 "$bap_preparedImage")
239
251
return 0
240
252
}
241
253
···
246
258
if [ -z "$2" ]; then baperr "fatal: Required argument missing"; return 1; fi
247
259
bap_result=$(curl --fail-with-body -s -A "$bap_curlUserAgent" -X POST -H "Authorization: Bearer $savedAccess" -H "Content-Type: $2" --data-binary @"$1" "$savedPDS/xrpc/com.atproto.repo.uploadBlob")
248
260
bapInternal_errorCheck $? bap_postBlobToPDS "error: blob upload failed" || return $?
249
-
bap_postedBlob=$(echo $bap_result | jq -r .blob.ref.'"$link"')
250
-
bap_postedMime=$(echo $bap_result | jq -r .blob.mimeType)
251
-
bap_postedSize=$(echo $bap_result | jq -r .blob.size)
261
+
bap_postedBlob=$(echo "$bap_result" | jq -r .blob.ref.'"$link"')
262
+
bap_postedMime=$(echo "$bap_result" | jq -r .blob.mimeType)
263
+
bap_postedSize=$(echo "$bap_result" | jq -r .blob.size)
252
264
bapecho "Blob uploaded ($bap_postedBlob)"
253
265
return 0
254
266
}
255
267
256
-
function bap_postImageToBluesky () { #1: exception 2: refresh required
257
-
# param:
258
-
# 1 - blob
259
-
# 2 - mimetype
260
-
# 3 - size
261
-
# 4 - width
262
-
# 5 - height
263
-
# 6 - alt text
264
-
# 7 - text
265
-
if [ -z "$5" ]; then baperr "fatal: more arguments required"; return 1; fi
266
-
# it's easy but just LOOK at all those commands
267
-
bapCYOR_bskypost
268
-
bapCYOR_str text "$7"
269
-
bapCYOR_str \$type app.bsky.embed.images .embed
270
-
bapCYOR_str alt "$6" .embed.images.[0]
271
-
bapCYOR_str \$type blob .embed.images.[0].image
272
-
bapCYOR_str \$link $1 .embed.images.[0].image.ref
273
-
bapCYOR_str mimeType "$2" .embed.images.[0].image
274
-
bapCYOR_add size $3 .embed.images.[0].image
275
-
bapCYOR_add width $4 .embed.images.[0].aspectRatio
276
-
bapCYOR_add height $5 .embed.images.[0].aspectRatio
277
-
bapCYOR_str createdAt $(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)
278
-
bap_postRecord "$bap_cyorRecord" || return $?
279
-
uri=$(echo $bap_result | jq -r .uri)
280
-
cid=$(echo $bap_result | jq -r .cid)
281
-
bapecho "Posted record at $uri"
282
-
return 0
283
-
}
284
-
285
-
function bap_checkVideoForBluesky () {
286
-
if [ ! -f $1 ]; then baperr "error: specify file to check"; return 1; fi
287
-
if [[ $(stat -c %s $1) -gt 100000000 ]]; then baperr 'fatal: video may not exceed 100 mb'; return 1; fi
288
-
if [ "$(exiftool -duration# -s3 $1 | awk '{print int($1+0.5)}')" -gt "180" ]; then baperr "error: video length must be 3 minutes or less"; return 1; fi
289
-
return 0
290
-
}
291
-
292
-
function bap_prepareVideoForBluesky () {
293
-
# stub, will actually talk to bluesky video service in the future
294
-
# $1 is file
295
-
# $2 is mime (like bap_postBlobToPDS)
296
-
if [ -z "$2" ]; then baperr "fatal: Required argument missing"; return 1; fi
297
-
bap_checkVideoForBluesky "$1" || return $?
298
-
bap_postBlobToPDS $1 $2
299
-
if [ "$?" != "0" ]; then baperr "warning: video upload failed"; return 1; fi
300
-
bap_imageWidth=$(exiftool -ImageWidth -s3 $1)
301
-
bap_imageHeight=$(exiftool -ImageHeight -s3 $1)
302
-
bapecho 'video "posted"'
303
-
return 0
304
-
}
305
-
306
-
function bap_postVideoToBluesky () {
307
-
# param:
308
-
# 1 - blob
309
-
# 2 - size
310
-
# 3 - width
311
-
# 4 - height
312
-
# 5 - alt text
313
-
# 6 - text
314
-
# assuming video/mp4 is always the mimetype might be a bad assumption
315
-
if [ -z "$4" ]; then baperr "fatal: more arguments required"; return 1; fi
316
-
bapCYOR_bskypost
317
-
bapCYOR_str text "$6"
318
-
bapCYOR_str alt "$5" .embed
319
-
bapCYOR_str \$type app.bsky.embed.video .embed
320
-
bapCYOR_str \$type blob .embed.video
321
-
bapCYOR_str \$link "$1" .embed.video.ref
322
-
bapCYOR_str mimeType "video/mp4" .embed.video
323
-
bapCYOR_add size $2 .embed.video
324
-
bapCYOR_add width $3 .embed.aspectRatio
325
-
bapCYOR_add height $4 .embed.aspectRatio
326
-
bapCYOR_str createdAt $(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)
327
-
bap_postRecord "$bap_cyorRecord"
328
-
bapInternal_errorCheck $? bap_postVideoToBluesky "error: post failed" || return $?
329
-
uri=$(echo $bap_result | jq -r .uri)
330
-
cid=$(echo $bap_result | jq -r .cid)
331
-
bapecho "Posted record at $uri"
332
-
return 0
333
-
}
334
-
335
-
function bap_findPDS () {
268
+
function bap_resolveDID () {
336
269
if [ -z "$1" ]; then baperr "fatal: no did specified"; return 1; fi
337
270
bapInternal_validateDID "$1" || return 1
338
-
case "$(echo $1 | cut -d ':' -f 2)" in
271
+
case "$(echo "$1" | cut -d ':' -f 2)" in
339
272
340
273
"plc")
341
274
bap_result=$(curl -s --fail-with-body -A "$bap_curlUserAgent" "$bap_plcDirectory/$1")
342
-
bapInternal_errorCheck $? bap_findPDS "fatal: did:plc lookup failed" || return $?
275
+
bapInternal_errorCheck $? bap_resolvePDS "fatal: did:plc lookup failed" || return $?
343
276
;;
344
277
345
278
"web")
346
-
bap_result=$(curl -s --fail-with-body -A "$bap_curlUserAgent" "$(echo https://$1 | sed 's/did:web://g')/.well-known/did.json")
347
-
bapInternal_errorCheck $? bap_findPDS "fatal: did:web lookup failed" || return $?
279
+
bap_result=$(curl -s --fail-with-body -A "$bap_curlUserAgent" "$(echo "https://$1" | sed 's/did:web://g')/.well-known/did.json")
280
+
bapInternal_errorCheck $? bap_resolvePDS "fatal: did:web lookup failed" || return $?
348
281
;;
349
282
350
283
*)
351
-
baperr "fatal: unrecognized did type"
284
+
baperr "fatal: unknown did method $(echo "$1" | cut -d ':' -f 2)"
352
285
return 1
353
286
;;
354
287
esac
355
-
bap_resolve=$(echo $bap_result | jq -re .service)
356
-
if ! [ "$?" = "0" ]; then baperr "fatal: failed to parse DID document"; return 1; fi
288
+
echo "$bap_result"
289
+
return 0
290
+
}
291
+
292
+
function bap_resolvePDS () {
293
+
bap_resolve=$(bap_resolveDID "$1" | jq -re .service) || { baperr "fatal: failed to parse DID document"; return 1; }
357
294
iter=0
358
295
while read -r id; do
359
296
if ! [ "$id" = "#atproto_pds" ]; then
360
297
((iter+=1))
361
298
continue
362
299
fi
363
-
savedPDS=$(echo "$bap_resolve" | jq -r ".[$iter].serviceEndpoint")
300
+
echo "$bap_resolve" | jq -r ".[$iter].serviceEndpoint"
364
301
break
365
302
done <<< "$(echo "$bap_resolve" | jq -r .[].id)"
366
-
if [ -z "$savedPDS" ]; then baperr "fatal: PDS not found in DID document"; return 1; fi
367
303
return 0
368
304
}
369
305
370
-
function bap_didInit () {
371
-
if [ -z "$1" ]; then baperr "specify identifier as first parameter"; return 1; fi
306
+
function bap_resolveHandle () {
307
+
if ! bapInternal_validateDID "$1" 2> /dev/null; then baperr "fatal: specify did as first parameter"; return 1; fi
308
+
local bap_temp bap_temp2
309
+
bap_temp=$(bap_resolveDID "$1") || return $?
310
+
bap_temp2=$(echo "$bap_temp" | jq -re ".alsoKnownAs.[0]" | cut -d '/' -f 3)
311
+
if ! bapInternal_validateHandle "$bap_temp2" 2> /dev/null; then baperr "fatal: did doc returned invalid handle: $bap_temp2"; return 1; fi
312
+
echo "$bap_temp2"
313
+
return 0
314
+
}
372
315
373
-
if bapInternal_validateDID $1 2> /dev/null; then
374
-
savedDID=$1
375
-
bapecho "Using user-specified DID: $savedDID"
316
+
function bap_getDID () {
317
+
if [ -z "$1" ]; then baperr "specify identifier as first parameter"; return 1; fi
318
+
if bapInternal_validateDID "$1" 2> /dev/null; then
319
+
if [ "$bap_disableOptionalChecks" != "1" ]; then bap_resolveDID "$1" > /dev/null || return $?; fi
320
+
echo "$1"
376
321
return 0
377
322
378
-
elif [[ "$1" =~ ^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$ ]]; then
379
-
bapecho "Looking up handle from $bap_handleResolveURL"
380
-
savedDID=$(curl -s -A "$bap_curlUserAgent" -G --data-urlencode "handle=$1" "$bap_handleResolveURL/xrpc/com.atproto.identity.resolveHandle" | jq -re .did)
381
-
if [ "$?" != "0" ]; then
382
-
baperr "Error obtaining DID from API"
323
+
elif bapInternal_validateHandle "$1" 2> /dev/null; then
324
+
baperrverb "Looking up handle from DNS"
325
+
local bap_temp
326
+
bap_temp=$(dig -t txt _atproto."$1" +short | cut -d '=' -f 2 | tr -d '"')
327
+
if ! bapInternal_validateDID "$bap_temp" 2>/dev/null; then
328
+
baperrverb "Looking up handle from website"
329
+
bap_temp=$(curl -s -A "$bap_curlUserAgent" "https://$1/.well-known/atproto-did")
330
+
if [ "$?" != "0" ] || ! bapInternal_validateDID "$bap_temp" 2>/dev/null; then
331
+
baperr "Error resolving handle"
332
+
return 2
333
+
fi
334
+
fi
335
+
echo "$bap_temp"
336
+
return 0
337
+
else
338
+
baperr "fatal: input not a handle or did"
383
339
return 1
384
340
fi
385
-
bapecho "Using DID from API: $savedDID"
341
+
}
386
342
387
-
else
388
-
baperr "fatal: input not a handle or did"
389
-
return 1
343
+
function bap_didInit () {
344
+
savedDID=$(bap_getDID "$1") || return $?
345
+
bapecho "Using DID: $savedDID"
346
+
return 0
347
+
}
390
348
391
-
fi
392
-
return 0
349
+
function bap_getRecord () {
350
+
local bap_result bap_temp bap_temp2
351
+
# get did of user
352
+
if [ "$bap_verbosity" -ge "2" ]; then >&2 echo -n "bash-atproto: fetching did..."; fi
353
+
bap_temp=$(bap_getDID "$(echo "$1" | cut -d '/' -f 3)") || { baperr "failed to fetch did of record creator"; return 1; }
354
+
# get their pds
355
+
if [ "$bap_verbosity" -ge "2" ]; then >&2 echo -n "pds..."; fi
356
+
bap_temp2=$(bap_resolvePDS "$bap_temp") || { baperr "failed to fetch pds of record creator"; return 1; }
357
+
# get the post
358
+
if [ "$bap_verbosity" -ge "2" ]; then >&2 echo -n "record..."; fi
359
+
bap_result=$(curl -s --fail-with-body -A "$bap_curlUserAgent" -G --data-urlencode "repo=$bap_temp" --data-urlencode "collection=$(echo "$1" | cut -d '/' -f 4)" --data-urlencode "rkey=$(echo "$1" | cut -d '/' -f 5)" "$bap_temp2/xrpc/com.atproto.repo.getRecord") || { bapInternal_errorCheck $? bap_getRecord "failed to fetch record"; return 1; }
360
+
if [ "$bap_verbosity" -ge "2" ]; then >&2 echo "ok"; fi
361
+
echo "$bap_result"
362
+
return 0
363
+
}
364
+
365
+
function bap_getServiceAuth () {
366
+
# Service, Lifetime, Lexicon
367
+
if [ -z "$1" ]; then baperr "Required argument missing"; return 2; fi
368
+
if ! bapInternal_validateDID "$1" 2> /dev/null; then baperr "input must be a DID"; return 1; fi
369
+
bap_temp="aud=$1"
370
+
if [ -n "$2" ]; then bap_temp="$bap_temp&exp=$(($2 + $(date +%s)))"; fi
371
+
if [ -n "$3" ]; then bap_temp="$bap_temp&lxm=$3"; fi
372
+
bap_result=$(curl -s --fail-with-body -A "$bap_curlUserAgent" -G -H "Authorization: Bearer $savedAccess" "$savedPDS/xrpc/com.atproto.server.getServiceAuth?$bap_temp")
373
+
bapInternal_errorCheck $? bap_getServiceAuth "fatal: failed to mint service auth token" || return $?
374
+
echo "$bap_result" | jq -r .token
375
+
# PDS may change the expiry that bap requests
376
+
if [ -n "$2" ]; then
377
+
# local bap_jwt ?
378
+
bap_decodeJwt "$(echo "$bap_result" | jq -r .token)"
379
+
if [ "$(($2 + $(date +%s)))" != "$(echo "$bap_jwt" | jq -r .exp)" ]; then baperr "warn: expiry time mismatch: got $(echo "$bap_jwt" | jq -r .exp), expected $(($2 + $(date +%s)))"; fi
380
+
fi
381
+
bap_result=
382
+
return 0
383
+
}
384
+
385
+
function bap_generateDatetime () {
386
+
local bap_temp
387
+
bap_temp=$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)
388
+
# if date doesn't support sub-second precision, just fake it lol
389
+
if ! [[ "$(echo "$bap_temp" | cut -d '.' -f 2 | cut -c -3)" =~ ^[0-9]+$ ]]; then bap_temp=$(date -u +%Y-%m-%dT%H:%M:%S."$(echo 00$((RANDOM % 999)) | grep -o '...$')"Z); fi
390
+
if ! [[ $bap_temp =~ ^[0-9]{4}-[01][0-9]-[0-3][0-9]T[0-2][0-9]:[0-6][0-9]:[0-6][0-9](.[0-9]{1,20})?(Z|([+-][0-2][0-9]:[0-5][0-9]))$ ]]; then baperr "generated datetime is invalid"; return 1; fi
391
+
echo "$bap_temp"
392
+
return 0
393
393
}
+134
docs/HOWTO.md
+134
docs/HOWTO.md
···
1
+
# bash-atproto How To
2
+
3
+
This is a guide on using bash-atproto and its core addons bap-bsky and bap-sprk.
4
+
5
+
As a reminder, bash-atproto is meant for use as a library to be used by other scripts rather than a standalone client for interactive use. You can still use it like that, but writing another script to do the work for you is the better idea long term.
6
+
7
+
## Log in
8
+
9
+
1. `source bash-atproto.sh`
10
+
2. `bap_getKeys <did or handle> <app password> <your PDS, optional>`
11
+
12
+
The access and refresh tokens are stored in memory. You can write them to disk with `bap_saveSecrets <file name>` and load them with `bap_loadSecrets`. After obtaining the tokens, information about the account is fetched from the access token and loading from the tokens won't require any further account information.
13
+
14
+
The rest of these commands assume you have already sourced bash-atproto and logged in.
15
+
16
+
### Refreshing
17
+
18
+
The access token you get after logging in will expire shortly after being minted. The duration is PDS specific, but the default for the Bluesky PDS is 2 hours. Fortunately, the refresh token (which takes much longer to expire) can be used to mint another.
19
+
20
+
You can refresh the tokens at any time with `bap_refreshKeys`. If you previously wrote the tokens to disk, be sure to do so again, as the old refresh token it contains will no longer work!
21
+
22
+
### Ending a session
23
+
24
+
You can close the session with `bap_closeSession`. After this, the tokens are cleared from memory and the refresh token will no longer work. Tokens saved to disk will remain; it's good practice to clean them up.
25
+
26
+
## Create a Bluesky post
27
+
28
+
This is the manual way to create a Bluesky post with bap-bsky. You can use `bapBsky_createPost`, `bapBsky_postImage` and `bapBsky_postVideo` for basic posts, but manually writing the JSON with the CYOR commands allows for much more flexibility.
29
+
30
+
To create a simple post in English:
31
+
32
+
1. `source bap-bsky.sh`
33
+
2. `bapBsky_cyorInit`
34
+
3. `bapCYOR_str text "It's my first post!"`
35
+
4. `bapBsky_cyorAddLangs en`
36
+
5. `bapBsky_submitPost`
37
+
38
+
### Adding languages
39
+
40
+
`bapBsky_cyorAddLangs` can add language tags to your post. You can specify up to 3 (per the lexicon rules), separating additional tags as separate parameters:
41
+
42
+
* `bapBsky_cyorAddLangs en ja pt` to mark a post as having English, Japanese and Portuguese.
43
+
44
+
### Adding an image
45
+
46
+
Before submitting the post, run these commands:
47
+
48
+
1. `bapBsky_prepareImage <image file>`
49
+
2. `bap_postBlobToPDS $bap_preparedImage $bap_preparedMime`
50
+
1. These variables are prepared by the previous command.
51
+
3. `bapBsky_cyorAddImage # $bap_postedBlob $bap_postedMime $bap_postedSize $bap_imageWidth $bap_imageHeight "<alt text, optional>"`
52
+
1. `#` is the index number of the image. Counting starts at 0 and you can add up to 4, so 0-3 are acceptable values.
53
+
2. Like before, all the `$bap_*` variables are prepared for you by the previous commands. You may optionally specify alt text to go with the image.
54
+
55
+
### Adding a video
56
+
57
+
You can embed a video in the post like this:
58
+
59
+
1. `bapBsky_prepareVideo <video file> <mime type>`
60
+
1. The mime type for MP4 video is `video/mp4`. Other video types should not be used as `bapBsky_postVideo` and the Bluesky embed lexicon hardcode `video/mp4`.
61
+
2. `bapBsky_cyorAddVideo $bap_postedBlob $bap_postedSize $bap_imageWidth $bap_imageHeight "<alt text, optional>"`
62
+
63
+
### Adding a self-label
64
+
65
+
This can be done any time before submission, but ideally you'd do it after adding the embed.
66
+
67
+
1. `bapBsky_cyorAddSelfLabels <labels>`
68
+
1. You can specify multiple labels as separate parameters.
69
+
2. The official Bluesky app maps the self-labels with these names:
70
+
* Suggestive - `sexual`
71
+
* Nudity - `nudity`
72
+
* Adult - `porn`
73
+
* Graphic Media - `graphic-media`
74
+
75
+
### Replies
76
+
77
+
You can set your post to be a reply to another with `bapBsky_cyorAddReply`. It takes a simple AT URI as input.
78
+
79
+
* `bapBsky_cyorAddReply at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3l6ovsdood32z`
80
+
81
+
This will also fetch the appropriate root post if it hasn't been added already.
82
+
83
+
### Quote posts
84
+
85
+
You can also set the post in progress to be a quote post with `bapBsky_cyorAddQuote`. It takes an AT URI as input just like replies.
86
+
87
+
Note: Quote posts with media have a different structure for the media in question. The native `bapBsky_cyorAddImage` and `bapBsky_cyorAddVideo` functions handle this transparently, but you'll need to keep that in mind if you manually edit the JSON.
88
+
89
+
### Embeds
90
+
91
+
Link embeds are great, and you can add one with `bapBsky_cyorAddExternalEmbed`. There are two ways to use this function, depending on whether you want a thumbnail or not:
92
+
93
+
1. Link and text only: `bapBsky_cyorAddExternalEmbed <URL> <title> <description>`
94
+
95
+
2. With a thumbnail: `bapBsky_cyorAddExternalEmbed <URL> <title> <description> $bap_postedBlob $bap_postedMime $bap_postedSize`
96
+
97
+
Note that if you're using a thumbnail, you need to mind the dimensions that the client app expects. For example, most link thumbnails should be 2000x1050, and YouTube embeds are 2000x1500, have black bars on the top and bottom, and are cropped by the client.
98
+
99
+
### Adding (hidden hash)tags
100
+
101
+
A little known feature of the Bluesky post lexicon is the ability to add hidden hashtags. These don't appear in the post body, but they still work for discovery and search just like normal hashtags that are added with a facet. You can add up to 8 (again, lexicon limit) tags to your post with `bapBsky_cyorAddTags`:
102
+
103
+
* `bapBsky_cyorAddTags Bluesky ATProto`
104
+
105
+
### Other things
106
+
107
+
Because of how records work, it's possible to add your own JSON keys, more formally called [unspecced fields](https://www.pfrazee.com/blog/lexicon-guidance#going-off-schema). You can use the `bapCYOR_str` and `bapCYOR_add` functions to do this. As the names imply, the former is for strings and the latter for integers.
108
+
109
+
For example, if you wanted to secretly embed your pizza preferences in a post, you might run a command like `bapCYOR_str pizzaPreference "with-pineapple"`. This won't be visible in-app, but anyone that views that post's JSON (with [PDSls](https://pdsls.dev), for example) will be able to see your preference for pineapple on pizza.
110
+
111
+
## Spark
112
+
113
+
Creating Spark posts is a lot like creating Bluesky posts, even the lexicon is similar! Though as you might expect from an image and video platform, you'll need to add an appropriate embed if you want your post to work.
114
+
115
+
1. `source bap-sprk.sh`
116
+
2. `bapSprk_cyorInit`
117
+
3. `bapCYOR_str text "<post text, optional>"`
118
+
4. Add an embed (pick below)
119
+
5. `bapSprk_submitPost`
120
+
121
+
### Post an image
122
+
123
+
1. `bapSprk_prepareImage <image file>`
124
+
2. `bap_postBlobToPDS $bap_preparedImage $bap_preparedMime`
125
+
3. `bapSprk_cyorAddImage # $bap_postedBlob $bap_postedMime $bap_postedSize "<alt text, optional>"`
126
+
1. `#` is the index number of the image. Unlike Bluesky, you can add up to 12! (0-11)
127
+
128
+
### Post a video
129
+
130
+
There's no Spark-dedicated prepare video command yet, but it's still pretty similar to Bluesky:
131
+
132
+
1. `bap_postBlobToPDS <video file> video/mp4`
133
+
1. Spark might support other video types, but it's better to just use mp4. You might want to scrub metadata before posting.
134
+
2. `bapSprk_cyorAddVideo $bap_postedBlob $bap_postedSize "<alt text, optional>"`
+14
docs/MISSING.md
+14
docs/MISSING.md