+21
LICENSE
+21
LICENSE
···
1
+
MIT License
2
+
3
+
Copyright (c) 2025 Engielolz
4
+
5
+
Permission is hereby granted, free of charge, to any person obtaining a copy
6
+
of this software and associated documentation files (the "Software"), to deal
7
+
in the Software without restriction, including without limitation the rights
8
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+
copies of the Software, and to permit persons to whom the Software is
10
+
furnished to do so, subject to the following conditions:
11
+
12
+
The above copyright notice and this permission notice shall be included in all
13
+
copies or substantial portions of the Software.
14
+
15
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+
SOFTWARE.
+63
README.md
+63
README.md
···
1
+
# bash-atproto
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.
4
+
5
+
It supports the following operations (most API calls are done to the account's PDS):
6
+
7
+
* Resolving a handle to did:plc/did:web
8
+
9
+
* Resolving an account's PDS from the DID
10
+
11
+
* Creating and closing sessions on the PDS
12
+
13
+
* Saving and loading a secrets file (contains your access and refresh tokens)
14
+
15
+
* Extracting account and token information from the access token
16
+
17
+
* Refreshing tokens
18
+
19
+
* Creating basic Bluesky text post records
20
+
21
+
* Creating Bluesky repost records
22
+
23
+
* Preparing an image for Bluesky (including resizing and compressing)
24
+
25
+
* Uploading blobs
26
+
27
+
* Creating a post with a single embedded image with alt text
28
+
29
+
### Dependencies
30
+
31
+
bash-atproto requires cURL 7.76 or later and jq. Posting images (not used by 765coverbot) additionally requires `imagemagick`, `exiftool` and `uuidgen`.
32
+
33
+
## Basic usage
34
+
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:
36
+
37
+
1. `bap_didInit <did or handle>` which will resolve your handle to a DID
38
+
39
+
2. `bap_findPDS $savedDID` which will retrieve the account's PDS for use
40
+
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.
42
+
43
+
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
+
45
+
### Standalone usage
46
+
47
+
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
+
49
+
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`
60
+
61
+
## License
62
+
63
+
bash-atproto is licensed under the MIT License. Please note that bash-atproto is hobbyist-grade software and I take no responsibility if it is used to post Discourse™ to your account.
+73
bap-sprk.sh
+73
bap-sprk.sh
···
1
+
#!/bin/bash
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)
5
+
# The PDS is not capable of verifying Spark lexicon at the moment, be careful!
6
+
7
+
function bapSprk_err() {
8
+
>&2 echo "bash-atproto: spark: $*"
9
+
}
10
+
11
+
function bapSprk_echo() {
12
+
if [ ! "$bap_verbosity" -ge 1 ]; then return 0; fi
13
+
echo "bash-atproto: spark: $*"
14
+
}
15
+
16
+
function bapSprk_postVideo() {
17
+
# The parameters are different commpared to bap_postVideoToBluesky
18
+
# If you just ran bap_postBlobToPDS, you can use the variables on the right.
19
+
# 1 blob - $bap_postedImage
20
+
# 2 size - $bap_postedSize
21
+
# 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
25
+
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)
39
+
bapSprk_echo "Posted record at $uri"
40
+
}
41
+
42
+
43
+
function bapSprk_postImage() {
44
+
# Trying to mirror bap_postImageToBluesky
45
+
# Lexicon has alt text but not image dimensions?
46
+
# param:
47
+
# 1 - blob
48
+
# 2 - mimetype
49
+
# 3 - size
50
+
# 4 - width
51
+
# 5 - height
52
+
# 6 - alt text
53
+
# 7 - text
54
+
# image dimensions will be ignored, or specify ""
55
+
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)
72
+
bapSprk_echo "Posted record at $uri"
73
+
}
+393
bash-atproto.sh
+393
bash-atproto.sh
···
1
+
#!/bin/bash
2
+
# SPDX-License-Identifier: MIT
3
+
4
+
# you can change these
5
+
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"
8
+
bap_chmodSecrets=1
9
+
bap_verbosity=1
10
+
11
+
function baperr () {
12
+
>&2 echo "bash-atproto: $*"
13
+
}
14
+
15
+
function bapecho () {
16
+
if [ ! "$bap_verbosity" -ge 1 ]; then return 0; fi
17
+
echo "bash-atproto: $*"
18
+
}
19
+
20
+
function bapverbose () {
21
+
if [ ! "$bap_verbosity" -ge 2 ]; then return 0; fi
22
+
echo "bash-atproto: $*"
23
+
}
24
+
25
+
function bap_decodeJwt () {
26
+
bap_jwt="$(echo $1 | cut -d '.' -f 2 \
27
+
| sed 's/$/====/' | fold -w 4 | sed '$ d' | tr -d '\n' | tr '_-' '/+' \
28
+
| base64 -d | jq -re)" || { baperr "not a jwt"; return 1; }
29
+
# 1: fetch JWT payload 2: pad and convert to base64 3: decode
30
+
return 0
31
+
}
32
+
33
+
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)"
38
+
}
39
+
40
+
function bap_loadSecrets () {
41
+
if [[ -f $1 ]]; then while IFS= read -r line; do declare -g "$line"; done < "$1"
42
+
bap_decodeJwt "$savedAccess" || return 1
43
+
bapInternal_loadFromJwt
44
+
return 0
45
+
else return 1
46
+
fi
47
+
}
48
+
49
+
function bap_saveSecrets () {
50
+
bapecho 'Updating secrets'
51
+
echo 'savedAccess='$savedAccess > "$1"
52
+
echo 'savedRefresh='$savedRefresh >> "$1"
53
+
if [ "$bap_chmodSecrets" != "0" ]; then chmod 600 "$1"; fi
54
+
return 0
55
+
}
56
+
57
+
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
63
+
}
64
+
65
+
function bapInternal_errorCheck () {
66
+
case $1 in
67
+
0);;
68
+
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
72
+
baperr 'the token needs to be refreshed'
73
+
return 2;;
74
+
*)
75
+
if [ ! -z "$3" ]; then baperr "$3"; fi
76
+
baperr "cURL threw exception $1 in function $2"
77
+
return 1;;
78
+
esac
79
+
}
80
+
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
+
function bapInternal_validateDID () {
90
+
if ! [[ "$1" =~ ^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$ ]]; then baperr "fatal: input not a did"; return 1; fi
91
+
return 0
92
+
}
93
+
94
+
function bap_getKeys () { # 1: failure 2: user error
95
+
if [ -z "$2" ]; then baperr "No app password was passed"; return 2; fi
96
+
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")
98
+
bapInternal_errorCheck $? bap_getKeys "fatal: failed to authenticate" || return $?
99
+
bapecho secured the keys!
100
+
savedAccess=$(echo $bap_result | jq -r .accessJwt)
101
+
savedRefresh=$(echo $bap_result | jq -r .refreshJwt)
102
+
# 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
105
+
bapInternal_verifyStatus || return $?
106
+
return 0
107
+
}
108
+
109
+
function bap_refreshKeys () {
110
+
if [ -z "$savedRefresh" ]; then baperr "cannot refresh without a saved refresh token"; return 1; fi
111
+
bapecho 'Trying to refresh keys...'
112
+
bap_result=$(curl --fail-with-body -s -A "$bap_curlUserAgent" -X POST -H "Authorization: Bearer $savedRefresh" "$savedPDS/xrpc/com.atproto.server.refreshSession")
113
+
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
117
+
bapInternal_verifyStatus || return $?
118
+
return 0
119
+
}
120
+
121
+
function bap_closeSession () {
122
+
if [ -z "$savedRefresh" ]; then baperr "need refresh token to close session"; return 1; fi
123
+
bap_result=$(curl --fail-with-body -s -A "$bap_curlUserAgent" -X POST -H "Authorization: Bearer $savedRefresh" "$savedPDS/xrpc/com.atproto.server.deleteSession")
124
+
bapInternal_errorCheck $? bap_closeSession "error: failed to delete session" || return $?
125
+
savedAccess= savedRefresh=
126
+
bapecho "session closed successfully"
127
+
return 0
128
+
}
129
+
130
+
function bapCYOR_str () {
131
+
# for quotes
132
+
if [ -z "$1" ]; then baperr "nothing to add"; return 1; fi
133
+
if [ -z "$bap_cyorRecord" ]; then bap_cyorRecord="{}"; fi
134
+
bap_temp=$2
135
+
bap_cyorRecord=$(echo "$bap_cyorRecord" | jq -c "$3.[\"$1\"]=\"$bap_temp\"")
136
+
return $?
137
+
}
138
+
139
+
function bapCYOR_add () {
140
+
# for things that shouldn't be in quotes
141
+
if [ -z "$1" ]; then baperr "nothing to add"; return 1; fi
142
+
if [ -z "$bap_cyorRecord" ]; then bap_cyorRecord="{}"; fi
143
+
bap_temp=$2
144
+
bap_cyorRecord=$(echo "$bap_cyorRecord" | jq -c "$3.[\"$1\"]=$bap_temp")
145
+
return $?
146
+
}
147
+
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)")
152
+
return $?
153
+
}
154
+
155
+
function bapCYOR_bskypost () {
156
+
bap_cyorRecord="{}"
157
+
bapCYOR_str \$type app.bsky.feed.post
158
+
bapCYOR_str text ""
159
+
}
160
+
161
+
function bapInternal_finalizeRecord () {
162
+
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}"
164
+
if ! jq -e . >/dev/null <<<"$1"; then baperr "finalize: JSON parse error"; return 1; fi
165
+
return 0
166
+
}
167
+
168
+
function bap_postRecord () {
169
+
bapInternal_finalizeRecord "$1" || { baperr "not posting because finalize failed"; return 1; }
170
+
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
+
bapInternal_errorCheck $? bap_postRecord "failed to post record" || return $?
172
+
return 0
173
+
}
174
+
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 () {
203
+
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
207
+
}
208
+
209
+
function bapHelper_compressImageForBluesky () {
210
+
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
215
+
workfile=${workfile%.*}.jpg
216
+
}
217
+
218
+
function bap_prepareImageForBluesky () { # 1: error 2 missing dep
219
+
if [ -z "$1" ]; then baperr "fatal: no image specified to prepare"; return 1; fi
220
+
mkdir /tmp/bash-atproto 2>/dev/null
221
+
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
228
+
fi
229
+
if [[ $(stat -c %s /tmp/bash-atproto/$workfile) -gt 1000000 ]]; then
230
+
bapHelper_compressImageForBluesky
231
+
if ! [ "$?" = "0" ]; then return 1; fi
232
+
fi
233
+
bapecho "image preparation successful"
234
+
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)
239
+
return 0
240
+
}
241
+
242
+
function bap_postBlobToPDS () {
243
+
# okay, params are:
244
+
# $1 is the file name and path
245
+
# $2 is the mime type
246
+
if [ -z "$2" ]; then baperr "fatal: Required argument missing"; return 1; fi
247
+
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
+
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)
252
+
bapecho "Blob uploaded ($bap_postedBlob)"
253
+
return 0
254
+
}
255
+
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 () {
336
+
if [ -z "$1" ]; then baperr "fatal: no did specified"; return 1; fi
337
+
bapInternal_validateDID "$1" || return 1
338
+
case "$(echo $1 | cut -d ':' -f 2)" in
339
+
340
+
"plc")
341
+
bap_result=$(curl -s --fail-with-body -A "$bap_curlUserAgent" "$bap_plcDirectory/$1")
342
+
bapInternal_errorCheck $? bap_findPDS "fatal: did:plc lookup failed" || return $?
343
+
;;
344
+
345
+
"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 $?
348
+
;;
349
+
350
+
*)
351
+
baperr "fatal: unrecognized did type"
352
+
return 1
353
+
;;
354
+
esac
355
+
bap_resolve=$(echo $bap_result | jq -re .service)
356
+
if ! [ "$?" = "0" ]; then baperr "fatal: failed to parse DID document"; return 1; fi
357
+
iter=0
358
+
while read -r id; do
359
+
if ! [ "$id" = "#atproto_pds" ]; then
360
+
((iter+=1))
361
+
continue
362
+
fi
363
+
savedPDS=$(echo "$bap_resolve" | jq -r ".[$iter].serviceEndpoint")
364
+
break
365
+
done <<< "$(echo "$bap_resolve" | jq -r .[].id)"
366
+
if [ -z "$savedPDS" ]; then baperr "fatal: PDS not found in DID document"; return 1; fi
367
+
return 0
368
+
}
369
+
370
+
function bap_didInit () {
371
+
if [ -z "$1" ]; then baperr "specify identifier as first parameter"; return 1; fi
372
+
373
+
if bapInternal_validateDID $1 2> /dev/null; then
374
+
savedDID=$1
375
+
bapecho "Using user-specified DID: $savedDID"
376
+
return 0
377
+
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"
383
+
return 1
384
+
fi
385
+
bapecho "Using DID from API: $savedDID"
386
+
387
+
else
388
+
baperr "fatal: input not a handle or did"
389
+
return 1
390
+
391
+
fi
392
+
return 0
393
+
}