atproto library for bash scripts
atproto bash-atproto

Compare changes

Choose any two refs to compare.

+47 -25
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://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 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 5 + ## Supported functions 12 6 13 - * Saving and loading a secrets file (contains your access and refresh tokens) 7 + Functions built into `bash-atproto.sh` 14 8 15 - * Extracting account and token information from the access token 9 + * Resolving a handle to did:plc/did:web - `bap_didInit` 10 + * Resolving an account's PDS from the DID - `bap_findPDS` 11 + * Creating and closing sessions on the PDS - `bap_getKeys` and `bap_closeSession` 12 + * Saving and loading a secrets file (contains your access and refresh tokens) - `bap_saveSecrets` and `bap_loadSecrets` 13 + * Extracting account and token information from the access token - (automatic) 14 + * Refreshing tokens - `bap_refreshKeys` 15 + * Prepare an image, stripping EXIF data and (if needed) resizing it for a set file size and dimensions - `bap_prepareImage` 16 + * Uploading blobs - `bap_uploadBlobToPDS` 17 + * Working with records: 18 + * Add string value - `bapCYOR_str` 19 + * Add value - `bapCYOR_add` 20 + * Add to array - `bapCYOR_arr` 21 + * Remove key - `bapCYOR_rem` 22 + * Post record - `bap_postRecord` 16 23 17 - * Refreshing tokens 24 + ### Bluesky functions 18 25 19 - * Creating basic Bluesky text post records 26 + With `bap-bsky.sh` 20 27 21 - * Creating Bluesky repost records 28 + * Creating basic Bluesky text post records - `bapBsky_createPost` 29 + * Creating Bluesky repost records - `bapBsky_createRepost` 30 + * Preparing an image for Bluesky (including resizing and compressing) - `bapBsky_prepareImage` 31 + * Creating a post with a single embedded image or video with alt text and embedded image dimensions - `bapBsky_postImage` and `bapBsky_postVideo` 32 + * When working on a post: 33 + * Start a post - `bapBsky_cyorInit` 34 + * Prepare an image for upload - `bapBsky_prepareImage` 35 + * Add an image - `bapBsky_cyorAddImage` 36 + * Add a video - `bapBsky_cyorAddVideo` 37 + * Set language - `bapBsky_cyorAddLangs` 38 + * Add tags - `bapBsky_cyorAddTags` 39 + * Add self-labels - `bapBsky_cyorAddSelfLabels` 40 + * Make reply - `bapBsky_cyorAddReply` 41 + * Make quote - `bapBsky_cyorAddQuote` 42 + * Submit post - `bapBsky_submitPost` 22 43 23 - * Preparing an image for Bluesky (including resizing and compressing) 44 + ### Spark functions 24 45 25 - * Uploading blobs 46 + With the **experimental** `bap-sprk.sh` 26 47 27 - * Creating a post with a single embedded image with alt text 48 + * Creating a post with a single embedded image with alt text or video - `bapSprk_postImage` and `bapSprk_postVideo` 49 + * When working on a post: 50 + * Start a post - `bapSprk_cyorInit` 51 + * Prepare an image for upload - `bapSprk_prepareImage` 52 + * Add an image - `bapSprk_cyorAddImage` 53 + * Add a video - `bapSprk_cyorAddVideo` 54 + * Submit post - `bapSprk_submitPost` 28 55 29 - ### Dependencies 56 + ## Dependencies 30 57 31 - bash-atproto requires cURL 7.76 or later and jq. Posting images (not used by 765coverbot) additionally requires `imagemagick`, `exiftool` and `uuidgen`. 58 + bash-atproto requires cURL 7.76 or later and jq 1.7 or later. Posting images and video additionally requires `imagemagick`, `exiftool`, `uuidgen` and `file`. 32 59 33 60 ## Basic usage 34 61 35 62 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 63 37 64 1. `bap_didInit <did or handle>` which will resolve your handle to a DID 38 - 39 65 2. `bap_findPDS $savedDID` which will retrieve the account's PDS for use 40 - 41 66 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. 67 + 68 + 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 69 43 70 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 71 ··· 47 74 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 75 49 76 1. `source bash-atproto.sh` 50 - 51 77 2. `bap_didInit <your did or handle>` 52 - 53 78 3. `bap_findPDS $savedDID` 54 - 55 79 4. `bap_getKeys $savedDID <app password>` 56 - 57 80 5. `bap_postToBluesky "Hello, World!" en` 58 - 59 81 6. `bap_closeSession` 60 82 61 83 ## License
+349
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" != "3" ] || ! [ "$bap_internalMinorVer" -ge "3" ]; then >&2 echo "Incorrect bash-atproto version"; return 1; fi 6 + 7 + bapBsky_internalVersion=1 8 + bapBsky_internalMinorVer=4 9 + bapBsky_allowLegacyPre2=1 10 + 11 + function bapBskyErr () { 12 + >&2 echo "bap-bsky: $*" 13 + } 14 + 15 + function bapBskyEcho () { 16 + if [ ! "$bap_verbosity" -ge 1 ]; then return 0; fi 17 + echo "bap-bsky: $*" 18 + } 19 + 20 + function bapBsky_cyorInit () { 21 + bap_cyorRecord="{}" 22 + bapCYOR_str \$type app.bsky.feed.post 23 + bapCYOR_str text "" 24 + } 25 + 26 + function bapBskyInternal_convertToRecordWithMedia () { 27 + cyorTemp=$(echo "$bap_cyorRecord $(echo $bap_cyorRecord | jq -r .embed | echo {\"embed\": {\"$1\": $(</dev/stdin)}})" | jq -s add) || return 1 28 + bap_cyorRecord=$cyorTemp 29 + bapCYOR_str \$type "app.bsky.embed.recordWithMedia" .embed 30 + bapCYOR_str \$type "app.bsky.embed.record" .embed.record 31 + } 32 + 33 + function bapBsky_cyorAddImage () { 34 + if [ -z "$6" ]; then bapBskyErr "error: Required argument missing"; return 1; fi 35 + if [ "$(echo $bap_cyorRecord | jq -r .embed.[\"\$type\"])" = "app.bsky.embed.record" ]; then bapBskyInternal_convertToRecordWithMedia record; fi 36 + if [ "$(echo $bap_cyorRecord | jq -r .embed.[\"\$type\"])" = "app.bsky.embed.recordWithMedia" ]; then local l=.media; fi 37 + # it's easy but just LOOK at all those commands 38 + bapCYOR_str \$type app.bsky.embed.images .embed$l 39 + if [ -n "$7" ]; then bapCYOR_str alt "$7" .embed$l.images.[$1]; fi 40 + bapCYOR_str \$type blob .embed$l.images.[$1].image 41 + bapCYOR_str \$link $2 .embed$l.images.[$1].image.ref 42 + bapCYOR_str mimeType "$3" .embed$l.images.[$1].image 43 + bapCYOR_add size $4 .embed$l.images.[$1].image 44 + bapCYOR_add width $5 .embed$l.images.[$1].aspectRatio 45 + bapCYOR_add height $6 .embed$l.images.[$1].aspectRatio 46 + } 47 + 48 + function bapBsky_cyorAddVideo () { 49 + # param: 50 + # 1 - blob 51 + # 2 - size 52 + # 3 - width 53 + # 4 - height 54 + # 5 - alt text 55 + if [ -z "$4" ]; then bapBskyErr "error: Required argument missing"; return 1; fi 56 + if [ "$(echo $bap_cyorRecord | jq -r .embed.[\"\$type\"])" = "app.bsky.embed.record" ]; then bapBskyInternal_convertToRecordWithMedia record; fi 57 + if [ "$(echo $bap_cyorRecord | jq -r .embed.[\"\$type\"])" = "app.bsky.embed.recordWithMedia" ]; then local l=.media; fi 58 + bapCYOR_str alt "$5" .embed$l 59 + bapCYOR_str \$type app.bsky.embed.video .embed$l 60 + bapCYOR_str \$type blob .embed$l.video 61 + bapCYOR_str \$link "$1" .embed$l.video.ref 62 + bapCYOR_str mimeType "video/mp4" .embed$l.video 63 + bapCYOR_add size $2 .embed$l.video 64 + bapCYOR_add width $3 .embed$l.aspectRatio 65 + bapCYOR_add height $4 .embed$l.aspectRatio 66 + } 67 + 68 + # (porn, sexual, nudity), graphic-media 69 + function bapBsky_cyorAddLabel () { 70 + if [ "$bapBsky_allowLegacyPre2" != "1" ]; then >&2 echo "${FUNCNAME[0]}: command not found"; return 127; fi 71 + bapBskyErr "warn: function ${FUNCNAME[0]} is deprecated. use bapBsky_cyorAddSelfLabels instead" 72 + if [ -z "$2" ]; then bapBskyErr "error: Required argument missing"; return 1; fi 73 + bapCYOR_str \$type com.atproto.label.defs#selfLabels .labels 74 + # can you use multiple labels like this? 75 + bapCYOR_str val $2 .labels.values.[$1] 76 + } 77 + 78 + function bapBsky_cyorGetReplyRoot () { 79 + if [ -z "$1" ]; then bapBskyErr "error: Required argument missing"; return 1; fi 80 + bapBsky_temp=$(bap_getRecord $1) || return $? 81 + if echo $bapBsky_temp | jq -re '.value.reply.root.uri' > /dev/null; then 82 + # copy values 83 + bapCYOR_str uri $(echo $bapBsky_temp | jq -re '.value.reply.root.uri') .reply.root 84 + bapCYOR_str cid $(echo $bapBsky_temp | jq -re '.value.reply.root.cid') .reply.root 85 + else 86 + # we just wasted time and bandwidth! 87 + bapCYOR_str uri $(echo $bapBsky_temp | jq -re '.uri') .reply.root 88 + bapCYOR_str cid $(echo $bapBsky_temp | jq -re '.cid') .reply.root 89 + fi 90 + return 0 91 + } 92 + 93 + function bapBsky_cyorAddReply () { 94 + if [ -z "$1" ]; then bapBskyErr "error: Required argument missing"; return 1; fi 95 + bapBsky_temp=$(bap_getRecord $1) || return $? 96 + bapCYOR_str uri $(echo $bapBsky_temp | jq -re '.uri') .reply.parent 97 + bapCYOR_str cid $(echo $bapBsky_temp | jq -re '.cid') .reply.parent 98 + if ! echo $bap_cyorRecord | jq -re '.reply.root' > /dev/null; then bapBsky_cyorGetReplyRoot $1 || return $?; fi 99 + return 0 100 + } 101 + 102 + function bapBsky_cyorAddLangs () { 103 + if [ -z "$1" ]; then bapBskyErr "error: Required arugment missing"; return 1; fi 104 + bapCYOR_rem langs 105 + local iter=0 106 + while [ -n "$1" ]; do 107 + bapCYOR_arr "$1" .langs.["$iter"] || return $? 108 + ((iter++)) 109 + # lexicon limit 3 110 + if [ "$iter" -gt "2" ]; then break; fi 111 + shift 112 + done 113 + return 0 114 + } 115 + 116 + function bapBsky_cyorAddTags () { 117 + if [ -z "$1" ]; then bapBskyErr "error: Required arugment missing"; return 1; fi 118 + bapCYOR_rem tags 119 + local iter=0 120 + while [ -n "$1" ]; do 121 + bapCYOR_arr "$1" .tags.["$iter"] || return $? 122 + ((iter++)) 123 + # lexicon limit 8 124 + if [ "$iter" -gt "7" ]; then break; fi 125 + shift 126 + done 127 + return 0 128 + } 129 + 130 + function bapBsky_cyorAddSelfLabels () { 131 + if [ -z "$1" ]; then bapBskyErr "error: Required argument missing"; return 1; fi 132 + bapCYOR_str \$type com.atproto.label.defs#selfLabels .labels 133 + bapCYOR_rem labels.values 134 + local iter=0 135 + while [ -n "$1" ]; do 136 + bapCYOR_str val "$1" .labels.values.["$iter"] 137 + ((iter++)) 138 + shift 139 + done 140 + return 0 141 + } 142 + 143 + function bapBsky_cyorAddQuote () { 144 + if [ -z "$1" ]; then bapBskyErr "error: Required argument missing"; return 1; fi 145 + local bapBsky_temp cyorTemp l 146 + bapBsky_temp=$(bap_getRecord $1) || return $? 147 + case "$(echo $bap_cyorRecord | jq -r .embed.[\"\$type\"])" in 148 + "null") 149 + bapCYOR_str \$type "app.bsky.embed.record" .embed;; 150 + "app.bsky.embed.record");; 151 + "app.bsky.embed.recordWithMedia") 152 + l=.record;; 153 + *) 154 + # convert to record with media 155 + bapBskyInternal_convertToRecordWithMedia media 156 + l=.record;; 157 + esac 158 + bapCYOR_str uri $(echo $bapBsky_temp | jq -re '.uri') .embed.record$l 159 + bapCYOR_str cid $(echo $bapBsky_temp | jq -re '.cid') .embed.record$l 160 + return 0 161 + } 162 + 163 + function bapBsky_cyorAddExternalEmbed () { 164 + if [ -z "$3" ] || [ -n "$4" ] && [ -z "$6" ]; then bapBskyErr "error: Required argument missing"; return 1; fi 165 + if [ "$(echo $bap_cyorRecord | jq -r .embed.[\"\$type\"])" = "app.bsky.embed.record" ]; then bapBskyInternal_convertToRecordWithMedia record; fi 166 + if [ "$(echo $bap_cyorRecord | jq -r .embed.[\"\$type\"])" = "app.bsky.embed.recordWithMedia" ]; then local l=.media; fi 167 + bapCYOR_str \$type app.bsky.embed.external .embed$l 168 + bapCYOR_str uri "$1" .embed$l.external 169 + bapCYOR_str title "$2" .embed$l.external 170 + bapCYOR_str description "$3" .embed$l.external 171 + if [ -n "$4" ]; then 172 + bapCYOR_str \$type blob .embed$l.external.thumb 173 + bapCYOR_str \$link "$4" .embed$l.external.thumb.ref 174 + bapCYOR_str mimeType "$5" .embed$l.external.thumb 175 + bapCYOR_add size $6 .embed$l.external.thumb 176 + fi 177 + } 178 + 179 + function bapBsky_submitPost () { 180 + bapCYOR_str createdAt $(date -u +%Y-%m-%dT%H:%M:%S.%3NZ) 181 + bap_postRecord "$bap_cyorRecord" || return $? 182 + bap_cyorRecord= 183 + uri=$(echo $bap_result | jq -r .uri) 184 + cid=$(echo $bap_result | jq -r .cid) 185 + return 0 186 + } 187 + 188 + function bapBsky_createPost () { #1: exception 2: refresh required 189 + if [ -z "$1" ]; then bapBskyErr "fatal: No argument given to post"; return 1; fi 190 + bapBsky_cyorInit 191 + bapCYOR_str text "$1" 192 + if [ -n "$2" ]; then bapBsky_cyorAddLangs $2; fi 193 + bapBsky_submitPost || return $? 194 + bapBskyEcho "Posted record at $uri" 195 + return 0 196 + } 197 + 198 + 199 + function bapBsky_createRepost () { # arguments 1 is uri, 2 is cid. error codes same as postToBluesky 200 + if [ -z "$2" ]; then bapBskyErr "fatal: Required argument missing"; return 1; fi 201 + bap_cyorRecord= 202 + bapCYOR_str \$type app.bsky.feed.repost 203 + bapCYOR_str cid $2 .subject 204 + bapCYOR_str uri $1 .subject 205 + bapBsky_submitPost || return $? 206 + bapBskyEcho "Repost record at $uri" 207 + return 0 208 + } 209 + 210 + function bapBsky_prepareImage () { 211 + bap_prepareImage "$1" 1000000 2000 2000 212 + return $? 213 + } 214 + 215 + function bapBsky_postImage () { #1: exception 2: refresh required 216 + # param: 217 + # 1 - blob 218 + # 2 - mimetype 219 + # 3 - size 220 + # 4 - width 221 + # 5 - height 222 + # 6 - alt text 223 + # 7 - text 224 + if [ -z "$5" ]; then bapBskyErr "fatal: more arguments required"; return 1; fi 225 + bapBsky_cyorInit 226 + bapBsky_cyorAddImage 0 $1 $2 $3 $4 $5 "$6" 227 + bapCYOR_str text "$7" 228 + bapBsky_submitPost || return $? 229 + bapBskyEcho "Posted record at $uri" 230 + return 0 231 + } 232 + 233 + function bapBsky_checkVideo () { 234 + if [ ! -f "$1" ]; then bapBskyErr "error: specify file to check"; return 1; fi 235 + if [[ $(stat -c %s "$1") -gt 100000000 ]]; then bapBskyErr 'fatal: video may not exceed 100 mb'; return 1; fi 236 + 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 237 + return 0 238 + } 239 + 240 + function bapBsky_prepareVideoIndirect () { 241 + bapBsky_prepareVideo "$@" 242 + return $? 243 + } 244 + 245 + function bapBsky_prepareVideo () { 246 + # stub, will actually talk to bluesky video service in the future 247 + # $1 is file 248 + # $2 is mime (like bap_postBlobToPDS) 249 + if [ -z "$2" ]; then bapBskyErr "fatal: Required argument missing"; return 1; fi 250 + if [ "$2" != "video/mp4" ]; then if [ "$bapBsky_allowLegacyPre2" != "1" ]; then bapBskyErr "videos must be mp4"; return 1; else bapBskyErr "warning: videos are only supposed to be mp4. this will be blocked in a future version"; fi; fi 251 + bapBsky_checkVideo "$1" || return $? 252 + local uuid=$(uuidgen) 253 + if [ "$bapBsky_allowLegacyPre2" != "1" ]; then 254 + exiftool -all= "$1" -o "/tmp/$uuid.mp4" || return $? 255 + else 256 + cp "$1" "/tmp/$uuid.mp4" || return $? 257 + fi 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 'video "posted"' 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:video.bsky.app "" app.bsky.video.getUploadLimits) || return $? 270 + bap_result=$(curl --fail-with-body -s -A "$bap_curlUserAgent" -H "Authorization: Bearer $bapBsky_temp" "https://video.bsky.app/xrpc/app.bsky.video.getUploadLimits") 271 + bapInternal_errorCheck $? bapBsky_videoLimits "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_postVideo () { 281 + # param: 282 + # 1-5 - see bapBsky_cyorAddVideo 283 + # 6 - text 284 + if [ -z "$4" ]; then bapBskyErr "fatal: more arguments required"; return 1; fi 285 + bapBsky_cyorInit 286 + bapCYOR_str text "$6" 287 + bapBsky_cyorAddVideo $1 $2 $3 $4 "$5" 288 + bapBsky_submitPost || return $? 289 + bapBskyEcho "Posted record at $uri" 290 + return 0 291 + } 292 + 293 + # Compatibility stubs 294 + # Legacy scripts need to load bap-bsky.sh to use them. 295 + 296 + function bapBsky_stubWarn () { 297 + if [ "${FUNCNAME[1]}" != "bap_postToBluesky" ] && [ "$bapBsky_allowLegacyPre2" != "1" ]; then >&2 echo "${FUNCNAME[1]}: command not found"; return 127; fi 298 + bapBskyErr "warn: function ${FUNCNAME[1]} was renamed. call $1 instead" 299 + if [ "${FUNCNAME[1]}" != "bap_postToBluesky" ]; then bapBskyErr "warn: this compatibility stub is deprecated and will be removed in a future version"; fi 300 + } 301 + 302 + function bapCYOR_bskypost () { 303 + bapBsky_stubWarn bapBsky_cyorInit || return $? 304 + bapBsky_cyorInit 305 + return $? 306 + } 307 + 308 + # This replaces the bash-atproto native function 309 + function bap_postToBluesky () { 310 + bapBsky_stubWarn bapBsky_createPost 311 + bapBsky_createPost "$@" 312 + return $? 313 + } 314 + 315 + function bap_repostToBluesky () { 316 + bapBsky_stubWarn bapBsky_createRepost || return $? 317 + bapBsky_createRepost $* 318 + return $? 319 + } 320 + 321 + function bap_prepareImageForBluesky () { 322 + bapBsky_stubWarn bapBsky_prepareImage || return $? 323 + bapBsky_prepareImage "$@" 324 + return $? 325 + } 326 + 327 + function bap_postImageToBluesky () { 328 + bapBsky_stubWarn bapBsky_postImage || return $? 329 + bapBsky_postImage "$@" 330 + return $? 331 + } 332 + 333 + function bap_checkVideoForBluesky () { 334 + bapBsky_stubWarn bapBsky_checkVideo || return $? 335 + bapBsky_checkVideo "$@" 336 + return $? 337 + } 338 + 339 + function bap_prepareVideoForBluesky () { 340 + bapBsky_stubWarn bapBsky_prepareVideo || return $? 341 + bapBsky_prepareVideo "$@" 342 + return $? 343 + } 344 + 345 + function bap_postVideoToBluesky () { 346 + bapBsky_stubWarn bapBsky_postVideo || return $? 347 + bapBsky_postVideo "$@" 348 + return $? 349 + }
+60
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" != "3" ] || ! [ "$bap_internalMinorVer" -ge "1" ]; then >&2 echo "Incorrect bash-atproto version"; return 1; fi 6 + 7 + bapExt_internalVersion=1 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_findPDS $(bap_resolveDID $ATFILE_USERNAME) || { bapExtErr "Couldn't resolve PDS!"; return 1; } 25 + bap_getKeys $ATFILE_USERNAME $ATFILE_PASSWORD || { bapExtErr "Couldn't log in with ATFile credentials!"; return 1; } 26 + bapInternal_loadFromJwt 27 + ATFILE_USERNAME= ATFILE_PASSWORD= 28 + bapExtEcho "Auth successful. Use bap_closeSession when done" 29 + return 0 30 + } 31 + 32 + function bapExt_fastLogin () { 33 + if [ "$2" = "" ]; then bapExtErr "Required argument missing"; return 1; fi 34 + bap_findPDS $(bap_resolveDID $1) || return $? 35 + bap_getKeys $(bap_resolveDID $1) $2 || return $? 36 + bapInternal_loadFromJwt 37 + return 0 38 + } 39 + 40 + function bapExt_checkDeps () { 41 + local problem=0 42 + if ! type file >/dev/null 2>&1; then problem=2; bapExtErr "missing file"; fi 43 + if ! type convert >/dev/null 2>&1; then problem=2; bapExtErr "missing convert"; fi 44 + if ! type exiftool >/dev/null 2>&1; then problem=2; bapExtErr "missing exiftool"; fi 45 + if ! type uuidgen >/dev/null 2>&1; then problem=2; bapExtErr "missing uuidgen"; fi 46 + if ! type jq >/dev/null 2>&1; then problem=1; bapExtErr "missing jq"; fi 47 + if ! type curl >/dev/null 2>&1; then problem=1; bapExtErr "missing curl"; fi 48 + # if you can load this function, you have bash 49 + 50 + case "$problem" in 51 + 1) 52 + bapExtEcho "A core dependency required by bash-atproto is missing." 53 + bapExtEcho "bash-atproto will not work.";; 54 + 2) 55 + bapExtEcho "A dependency used by the media subsystems of bash-atproto is missing." 56 + bapExtEcho "Processing images and video will not function, but other functionality will work.";; 57 + 0) 58 + bapExtEcho "There are no bash-atproto dependency issues on this system.";; 59 + esac 60 + }
+76 -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 + if [ -z "$bap_internalVersion" ]; then >&2 echo "bash-atproto not loaded?"; return 127; fi 7 + if [ "$bap_internalVersion" != "3" ] || ! [ "$bap_internalMinorVer" -ge "0" ]; then >&2 echo "Incorrect bash-atproto version"; return 1; fi 6 8 7 - function bapSprk_err() { 8 - >&2 echo "bash-atproto: spark: $*" 9 + 10 + bapSprk_internalVersion=2 11 + bapSprk_internalMinorVer=0 12 + 13 + function bapSprk_err () { 14 + >&2 echo "bap-sprk: $*" 9 15 } 10 16 11 - function bapSprk_echo() { 17 + function bapSprk_echo () { 12 18 if [ ! "$bap_verbosity" -ge 1 ]; then return 0; fi 13 - echo "bash-atproto: spark: $*" 19 + echo "bap-sprk: $*" 20 + } 21 + 22 + function bapSprk_cyorInit () { 23 + bap_cyorRecord="{}" 24 + bapCYOR_str \$type so.sprk.feed.post 25 + bapCYOR_str text "" 26 + } 27 + 28 + function bapSprk_cyorAddImage () { 29 + # Lexicon has alt text but no image dimensions 30 + # 1 image, 2 blob, 3 mime, 4 size, 5 alt 31 + if [ -z "$4" ]; then bapSprkErr "error: Required argument missing"; return 1; fi 32 + bapCYOR_str \$type so.sprk.embed.images .embed 33 + bapCYOR_str alt "$5" .embed.images.[$1] 34 + bapCYOR_str \$type so.sprk.embed.images#image .embed.images.[$1] 35 + bapCYOR_str \$type blob .embed.images.[$1].image 36 + bapCYOR_str \$link $2 .embed.images.[$1].image.ref 37 + bapCYOR_str mimeType "$3" .embed.images.[$1].image 38 + bapCYOR_add size $4 .embed.images.[$1].image 39 + #bapCYOR_add width $6 .embed.images.[$1].aspectRatio 40 + #bapCYOR_add height $7 .embed.images.[$1].aspectRatio 41 + } 42 + 43 + function bapSprk_cyorAddVideo () { 44 + if [ -z "$3" ]; then bapSprkErr "error: Required argument missing"; return 1; fi 45 + bapCYOR_str \$type so.sprk.embed.video .embed 46 + bapCYOR_str alt "$4" .embed 47 + bapCYOR_str \$type blob .embed.video 48 + bapCYOR_str \$link $1 .embed.video.ref 49 + bapCYOR_str mimeType "$3" .embed.video 50 + bapCYOR_add size $2 .embed.video 51 + } 52 + 53 + function bapSprk_submitPost () { 54 + bapCYOR_str createdAt $(date -u +%Y-%m-%dT%H:%M:%S.%3NZ) 55 + bap_postRecord "$bap_cyorRecord" || return $? 56 + bap_cyorRecord= 57 + uri=$(echo $bap_result | jq -r .uri) 58 + cid=$(echo $bap_result | jq -r .cid) 59 + return 0 60 + } 61 + 62 + function bapSprk_prepareImage () { 63 + # 5MB image limit: at://sprk.so/app.bsky.feed.post/3lipdqef2k22n 64 + # Get rid of dimensions liimt with really big numbers 65 + bap_prepareImage "$1" 5000000 2147483647 2147483647 66 + return $? 14 67 } 15 68 16 - function bapSprk_postVideo() { 69 + function bapSprk_postVideo () { 17 70 # The parameters are different commpared to bap_postVideoToBluesky 18 71 # If you just ran bap_postBlobToPDS, you can use the variables on the right. 19 72 # 1 blob - $bap_postedImage 20 73 # 2 size - $bap_postedSize 21 74 # 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 75 + # 4 alt text 76 + # 5 post text 25 77 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) 78 + bapSprk_cyorInit 79 + bapCYOR_str text "$5" 80 + bapSprk_cyorAddVideo $1 $2 $3 "$4" 81 + bapSprk_submitPost "$bap_cyorRecord" || return $? 39 82 bapSprk_echo "Posted record at $uri" 83 + return 0 40 84 } 41 85 42 86 43 - function bapSprk_postImage() { 44 - # Trying to mirror bap_postImageToBluesky 45 - # Lexicon has alt text but not image dimensions? 87 + function bapSprk_postImage () { 88 + # Trying to mirror bapBsky_postImage 46 89 # param: 47 90 # 1 - blob 48 91 # 2 - mimetype 49 92 # 3 - size 50 - # 4 - width 51 - # 5 - height 52 - # 6 - alt text 53 - # 7 - text 54 - # image dimensions will be ignored, or specify "" 93 + # 4 - alt text 94 + # 5 - text 55 95 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) 96 + bapSprk_cyorInit 97 + bapCYOR_str text "$5" 98 + bapSprk_cyorAddImage 0 $1 $2 $3 "$4" "$5" 99 + bapSprk_submitPost "$bap_cyorRecord" || return $? 72 100 bapSprk_echo "Posted record at $uri" 101 + return 0 73 102 }
+116 -145
bash-atproto.sh
··· 1 1 #!/bin/bash 2 2 # SPDX-License-Identifier: MIT 3 + bap_internalVersion=3 4 + bap_internalMinorVer=3 3 5 4 6 # you can change these 5 7 bap_plcDirectory=https://plc.directory 6 8 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) -c safe.directory=$(cd $(dirname $BASH_SOURCE); pwd) describe --always --dirty)" 8 10 bap_chmodSecrets=1 9 11 bap_verbosity=1 10 12 ··· 20 22 function bapverbose () { 21 23 if [ ! "$bap_verbosity" -ge 2 ]; then return 0; fi 22 24 echo "bash-atproto: $*" 25 + } 26 + 27 + function baperrverb () { 28 + if [ ! "$bap_verbosity" -ge 2 ]; then return 0; fi 29 + >&2 echo "bash-atproto: $*" 23 30 } 24 31 25 32 function bap_decodeJwt () { ··· 66 73 case $1 in 67 74 0);; 68 75 22) 69 - if [ ! -z "$3" ]; then baperr "$3"; fi 76 + if [ -n "$3" ]; then baperr "$3"; fi 77 + if ! jq -e . >/dev/null 2>&1 <<<"$bap_result"; then baperr "the server did not respond with valid JSON"; return 1; fi 70 78 APIErrorCode=$(echo $bap_result | jq -r .error) 71 79 if ! [ "$APIErrorCode" = "ExpiredToken" ]; then bapInternal_processAPIError $2 bap_result; return 1; fi 72 80 baperr 'the token needs to be refreshed' 73 81 return 2;; 74 82 *) 75 - if [ ! -z "$3" ]; then baperr "$3"; fi 83 + if [ -n "$3" ]; then baperr "$3"; fi 76 84 baperr "cURL threw exception $1 in function $2" 77 85 return 1;; 78 86 esac ··· 81 89 function bapInternal_verifyStatus () { 82 90 if [ "$(echo $bap_result | jq -r .active)" = "false" ]; then 83 91 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 92 + 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 85 93 return 115 86 94 fi 87 95 } ··· 101 109 savedRefresh=$(echo $bap_result | jq -r .refreshJwt) 102 110 # we don't care about the handle 103 111 bap_decodeJwt $savedAccess 104 - if [ "$(echo $bap_jwt | jq -r .scope)" != "com.atproto.appPass" ]; then baperr "warning: this is not an app password"; fi 112 + if [ "$(echo $bap_jwt | jq -r .scope)" = "com.atproto.access" ]; then baperr "warning: this is not an app password"; fi 105 113 bapInternal_verifyStatus || return $? 106 114 return 0 107 115 } ··· 131 139 # for quotes 132 140 if [ -z "$1" ]; then baperr "nothing to add"; return 1; fi 133 141 if [ -z "$bap_cyorRecord" ]; then bap_cyorRecord="{}"; fi 134 - bap_temp=$2 135 - bap_cyorRecord=$(echo "$bap_cyorRecord" | jq -c "$3.[\"$1\"]=\"$bap_temp\"") 142 + local bap_temp 143 + bap_temp=$(echo "$bap_cyorRecord" | jq -c "$3.[\"$1\"]=\"$2\"") || return $? 144 + bap_cyorRecord=$bap_temp 136 145 return $? 137 146 } 138 147 ··· 140 149 # for things that shouldn't be in quotes 141 150 if [ -z "$1" ]; then baperr "nothing to add"; return 1; fi 142 151 if [ -z "$bap_cyorRecord" ]; then bap_cyorRecord="{}"; fi 143 - bap_temp=$2 144 - bap_cyorRecord=$(echo "$bap_cyorRecord" | jq -c "$3.[\"$1\"]=$bap_temp") 152 + local bap_temp 153 + bap_temp=$(echo "$bap_cyorRecord" | jq -c "$3.[\"$1\"]=$2") || return $? 154 + bap_cyorRecord=$bap_temp 145 155 return $? 146 156 } 147 157 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)") 158 + function bapCYOR_arr () { 159 + # arrays 160 + if [ -z "$1" ]; then baperr "nothing to add"; return 1; fi 161 + if [ -z "$bap_cyorRecord" ]; then bap_cyorRecord="{}"; fi 162 + local bap_temp 163 + bap_temp=$(echo "$bap_cyorRecord" | jq -c "$2=\"$1\"") || return $? 164 + bap_cyorRecord=$bap_temp 152 165 return $? 153 166 } 154 167 155 - function bapCYOR_bskypost () { 156 - bap_cyorRecord="{}" 157 - bapCYOR_str \$type app.bsky.feed.post 158 - bapCYOR_str text "" 168 + function bapCYOR_rem () { 169 + # doesn't handle special names atm 170 + if [ -z "$1" ] || [ -z "$bap_cyorRecord" ]; then baperr "nothing to remove"; return 1; fi 171 + local bap_temp 172 + bap_temp=$(echo $bap_cyorRecord | jq -c "del(.$1)") || return $? 173 + bap_cyorRecord=$bap_temp 174 + return $? 159 175 } 160 176 161 177 function bapInternal_finalizeRecord () { ··· 174 190 175 191 function bap_postToBluesky () { #1: exception 2: refresh required 176 192 if [ -z "$1" ]; then baperr "fatal: No argument given to post"; return 1; fi 177 - bapCYOR_bskypost 193 + bap_cyorRecord='{"$type":"app.bsky.feed.post"}' 178 194 bapCYOR_str text "$1" 179 - if ! [ -z "2" ]; then bapCYOR_add langs "[\"$2\"]"; fi 195 + if [ -n "$2" ]; then bapCYOR_add langs "[\"$2\"]"; fi 180 196 bapCYOR_str createdAt $(date -u +%Y-%m-%dT%H:%M:%S.%3NZ) 181 197 bap_postRecord "$bap_cyorRecord" || return $? 182 198 uri=$(echo $bap_result | jq -r .uri) ··· 185 201 return 0 186 202 } 187 203 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 () { 204 + function bapInternal_resizeImage () { 203 205 bapecho "need to resize image" 204 - convert /tmp/bash-atproto/$workfile -resize 2000x2000 /tmp/bash-atproto/new-$workfile 206 + convert /tmp/bash-atproto/$workfile -resize $1x$2 /tmp/bash-atproto/new-$workfile 205 207 if ! [ "$?" = "0" ]; then baperr "fatal: convert failed!"; rm /tmp/bash-atproto/$workfile 2>/dev/null; return 1; fi 206 208 mv -f /tmp/bash-atproto/new-$workfile /tmp/bash-atproto/$workfile 207 209 } 208 210 209 - function bapHelper_compressImageForBluesky () { 211 + function bapInternal_compressImage () { 210 212 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 + convert /tmp/bash-atproto/$workfile -define jpeg:extent=$1 /tmp/bash-atproto/new-${workfile%.*}.jpg 214 + 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 213 215 rm /tmp/bash-atproto/$workfile 214 216 mv -f /tmp/bash-atproto/new-${workfile%.*}.jpg /tmp/bash-atproto/${workfile%.*}.jpg 215 217 workfile=${workfile%.*}.jpg 216 218 } 217 219 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 + function bap_prepareImage () { # 1: error 2 missing dep 221 + # args: 1 - image, 2 - max size, 3 - max width, 4 - max height 222 + if [ -z "$4" ]; then baperr "fatal: not enough parameters"; return 1; fi 223 + if [ ! -f "$1" ]; then baperr "fatal: image not found"; return 1; fi 220 224 mkdir /tmp/bash-atproto 2>/dev/null 221 225 workfile=$(uuidgen)."${1##*.}" 222 - cp $1 /tmp/bash-atproto/$workfile 226 + cp "$1" /tmp/bash-atproto/$workfile 223 227 exiftool -all= /tmp/bash-atproto/$workfile -overwrite_original 224 228 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 229 + if [[ $(identify -format '%w' /tmp/bash-atproto/$workfile) -gt $3 ]] || [[ $(identify -format '%h' /tmp/bash-atproto/$workfile) -gt $4 ]]; then 230 + bapInternal_resizeImage $3 $4 227 231 if ! [ "$?" = "0" ]; then return 1; fi 228 232 fi 229 - if [[ $(stat -c %s /tmp/bash-atproto/$workfile) -gt 1000000 ]]; then 230 - bapHelper_compressImageForBluesky 233 + if [[ $(stat -c %s /tmp/bash-atproto/$workfile) -gt $2 ]]; then 234 + bapInternal_compressImage $2 231 235 if ! [ "$?" = "0" ]; then return 1; fi 232 236 fi 233 237 bapecho "image preparation successful" ··· 253 257 return 0 254 258 } 255 259 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 () { 260 + function bap_resolvePDS () { 336 261 if [ -z "$1" ]; then baperr "fatal: no did specified"; return 1; fi 337 262 bapInternal_validateDID "$1" || return 1 338 263 case "$(echo $1 | cut -d ':' -f 2)" in 339 264 340 265 "plc") 341 266 bap_result=$(curl -s --fail-with-body -A "$bap_curlUserAgent" "$bap_plcDirectory/$1") 342 - bapInternal_errorCheck $? bap_findPDS "fatal: did:plc lookup failed" || return $? 267 + bapInternal_errorCheck $? bap_resolvePDS "fatal: did:plc lookup failed" || return $? 343 268 ;; 344 269 345 270 "web") 346 271 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 $? 272 + bapInternal_errorCheck $? bap_resolvePDS "fatal: did:web lookup failed" || return $? 348 273 ;; 349 274 350 275 *) ··· 360 285 ((iter+=1)) 361 286 continue 362 287 fi 363 - savedPDS=$(echo "$bap_resolve" | jq -r ".[$iter].serviceEndpoint") 288 + echo "$bap_resolve" | jq -r ".[$iter].serviceEndpoint" 364 289 break 365 290 done <<< "$(echo "$bap_resolve" | jq -r .[].id)" 366 - if [ -z "$savedPDS" ]; then baperr "fatal: PDS not found in DID document"; return 1; fi 367 291 return 0 368 292 } 369 293 370 - function bap_didInit () { 371 - if [ -z "$1" ]; then baperr "specify identifier as first parameter"; return 1; fi 294 + function bap_findPDS () { 295 + savedPDS=$(bap_resolvePDS $1) 296 + if [ -z "$savedPDS" ]; then baperr "fatal: PDS not found in DID document"; return 1; fi 297 + return 0 298 + } 372 299 373 - if bapInternal_validateDID $1 2> /dev/null; then 374 - savedDID=$1 375 - bapecho "Using user-specified DID: $savedDID" 300 + function bap_resolveDID () { 301 + if [ -z "$1" ]; then baperr "specify identifier as first parameter"; return 1; fi 302 + if bapInternal_validateDID $1 2> /dev/null; then 303 + echo $1 376 304 return 0 377 305 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" 306 + 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 307 + baperrverb "Looking up handle from $bap_handleResolveURL" 308 + bap_temp=$(curl -s -A "$bap_curlUserAgent" -G --data-urlencode "handle=$1" "$bap_handleResolveURL/xrpc/com.atproto.identity.resolveHandle" | jq -re .did) 309 + if [ "$?" != "0" ]; then 310 + baperr "Error obtaining DID from API" 311 + return 2 312 + fi 313 + echo $bap_temp 314 + return 0 315 + 316 + else 317 + baperr "fatal: input not a handle or did" 383 318 return 1 319 + 384 320 fi 385 - bapecho "Using DID from API: $savedDID" 321 + # should not get here 322 + return 1 323 + } 386 324 387 - else 388 - baperr "fatal: input not a handle or did" 389 - return 1 325 + function bap_didInit () { 326 + savedDID=$(bap_resolveDID "$1") || return $? 327 + bapecho "Using DID: $savedDID" 328 + return 0 329 + } 330 + 331 + function bap_getRecord () { 332 + # get did of user 333 + if [ "$bap_verbosity" -ge "2" ]; then >&2 echo -n "bash-atproto: fetching did..."; fi 334 + bap_temp[0]=$(bap_resolveDID $(echo $1 | cut -d '/' -f 3)) || { baperr "failed to fetch did of record creator"; return 1; } 335 + # get their pds 336 + if [ "$bap_verbosity" -ge "2" ]; then >&2 echo -n "pds..."; fi 337 + bap_temp[1]=$(bap_resolvePDS ${bap_temp[0]}) || { baperr "failed to fetch pds of record creator"; return 1; } 338 + # get the post 339 + if [ "$bap_verbosity" -ge "2" ]; then >&2 echo -n "post..."; fi 340 + bap_result=$(curl -s --fail-with-body -A "$bap_curlUserAgent" -G --data-urlencode "repo=${bap_temp[0]}" --data-urlencode "collection=$(echo $1 | cut -d '/' -f 4)" --data-urlencode "rkey=$(echo $1 | cut -d '/' -f 5)" ${bap_temp[1]}/xrpc/com.atproto.repo.getRecord) || { bapInternal_errorCheck $? bap_getRecord "failed to fetch record"; return 1; } 341 + echo $bap_result 342 + baperrverb "ok" 343 + bap_result= 344 + return 0 345 + } 390 346 391 - fi 392 - return 0 347 + function bap_getServiceAuth () { 348 + # Service, Lifetime, Lexicon 349 + if [ -z "$1" ]; then baperr "Required argument missing"; return 2; fi 350 + if ! bapInternal_validateDID $1 2> /dev/null; then baperr "input must be a DID"; return 1; fi 351 + bap_temp="aud=$1" 352 + if [ -n "$2" ]; then bap_temp="$bap_temp&exp=$(($2 + $(date +%s)))"; fi 353 + if [ -n "$3" ]; then bap_temp="$bap_temp&lxm=$3"; fi 354 + bap_result=$(curl -s --fail-with-body -A "$bap_curlUserAgent" -G -H "Authorization: Bearer $savedAccess" "$savedPDS/xrpc/com.atproto.server.getServiceAuth?$bap_temp") 355 + bapInternal_errorCheck $? bap_getServiceAuth "fatal: failed to mint service auth token" || return $? 356 + echo $bap_result | jq -r .token 357 + # PDS may change the expiry that bap requests 358 + if [ -n "$2" ]; then 359 + bap_decodeJwt "$(echo $bap_result | jq -r .token)" 360 + 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 361 + fi 362 + bap_result= 363 + return 0 393 364 }
+126
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_didInit <your handle or did>` 11 + 3. `bap_findPDS $savedDID` 12 + 4. `bap_getKeys $savedDID <app password>` 13 + 14 + 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. 15 + 16 + The rest of these commands assume you have already sourced bash-atproto and logged in. 17 + 18 + ### Refreshing 19 + 20 + 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. 21 + 22 + 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! 23 + 24 + ### Ending a session 25 + 26 + 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. 27 + 28 + ## Create a Bluesky post 29 + 30 + 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. 31 + 32 + To create a simple post in English: 33 + 34 + 1. `source bap-bsky.sh` 35 + 2. `bapBsky_cyorInit` 36 + 3. `bapCYOR_str text "It's my first post!"` 37 + 4. `bapBsky_cyorAddLangs en` 38 + 5. `bapBsky_submitPost` 39 + 40 + ### Adding languages 41 + 42 + `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: 43 + 44 + * `bapBsky_cyorAddLangs en ja pt` to mark a post as having English, Japanese and Portuguese. 45 + 46 + ### Adding an image 47 + 48 + Before submitting the post, run these commands: 49 + 50 + 1. `bapBsky_prepareImage <image file>` 51 + 2. `bap_postBlobToPDS $bap_preparedImage $bap_preparedMime` 52 + 1. These variables are prepared by the previous command. 53 + 3. `bapBsky_cyorAddImage # $bap_postedBlob $bap_postedMime $bap_postedSize $bap_imageWidth $bap_imageHeight "<alt text, optional>"` 54 + 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. 55 + 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. 56 + 57 + ### Adding a video 58 + 59 + You can embed a video in the post like this: 60 + 61 + 1. `bapBsky_prepareVideo <video file> <mime type>` 62 + 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`. 63 + 2. `bapBsky_cyorAddVideo $bap_postedBlob $bap_postedSize $bap_imageWidth $bap_imageHeight "<alt text, optional>"` 64 + 65 + ### Adding a self-label 66 + 67 + This can be done any time before submission, but ideally you'd do it after adding the embed. 68 + 69 + 1. `bapBsky_cyorAddSelfLabels <labels>` 70 + 1. You can specify multiple labels as separate parameters. 71 + 2. The official Bluesky app maps the self-labels with these names: 72 + * Suggestive - `sexual` 73 + * Nudity - `nudity` 74 + * Adult - `porn` 75 + * Graphic Media - `graphic-media` 76 + 77 + ### Replies 78 + 79 + You can set your post to be a reply to another with `bapBsky_cyorAddReply`. It takes a simple AT URI as input. 80 + 81 + * `bapBsky_cyorAddReply at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3l6ovsdood32z` 82 + 83 + This will also fetch the appropriate root post if it hasn't been added already. 84 + 85 + ### Quote posts 86 + 87 + 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. 88 + 89 + 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. 90 + 91 + ### Adding (hidden hash)tags 92 + 93 + 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`: 94 + 95 + * `bapBsky_cyorAddTags Bluesky ATProto` 96 + 97 + ### Other things 98 + 99 + 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. 100 + 101 + 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. 102 + 103 + ## Spark 104 + 105 + 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. 106 + 107 + 1. `source bap-sprk.sh` 108 + 2. `bapSprk_cyorInit` 109 + 3. `bapCYOR_str text "<post text, optional>"` 110 + 4. Add an embed (pick below) 111 + 5. `bapSprk_submitPost` 112 + 113 + ### Post an image 114 + 115 + 1. `bapSprk_prepareImage <image file>` 116 + 2. `bap_postBlobToPDS $bap_preparedImage $bap_preparedMime` 117 + 3. `bapSprk_cyorAddImage # $bap_postedBlob $bap_postedMime $bap_postedSize "<alt text, optional>"` 118 + 1. `#` is the index number of the image. Unlike Bluesky, you can add up to 12! (0-11) 119 + 120 + ### Post a video 121 + 122 + There's no Spark-dedicated prepare video command yet, but it's still pretty similar to Bluesky: 123 + 124 + 1. `bap_postBlobToPDS <video file> video/mp4` 125 + 1. Spark might support other video types, but it's better to just use mp4. You might want to scrub metadata before posting. 126 + 2. `bapSprk_cyorAddVideo $bap_postedBlob $bap_postedSize "<alt text, optional>"`
+14
docs/MISSING.md
··· 1 + Current list of missing features 2 + 3 + ## bap-bsky 4 + 5 + * Communication with Lumi (Bluesky video service) 6 + * Facets 7 + * Mentions 8 + * URLs 9 + * Hashtags 10 + * All functions unrelated to creating posts (low priority) 11 + 12 + ## bap-sprk 13 + 14 + To be determined...