atproto library for bash scripts
atproto bash-atproto

Compare changes

Choose any two refs to compare.

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