atproto library for bash scripts
atproto bash-atproto

Initial commit

Engielolz d5ea0e75

+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
··· 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
··· 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
··· 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 + }