#!/bin/bash # pdt: post to FSF social media via command line # Copyright (C) 2021 Ian Kelling # SPDX-License-Identifier: AGPL-3.0-or-later # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # This is meant to be sourced. PATH="$HOME/.local/bin:$PATH" _pdtsh_file="$(readlink -f -- "${BASH_SOURCE[0]}")" _pdtsh_dir="${_pdtsh_file%/*}" _pdtsh_tweet="$_pdtsh_dir/t.py" _pdtsh_blocks=██████████████████████████████████████████████████████████████ # usage: tweet [--PROFILE_NAME] [POST...] # Uses variables as input: # $media for image file path. # $alt_text for image alt text. # # --PROFILE_NAME Either dbd or fsf. Defaults to whatever profile was used # last in any pdt command. # # Note retweeting cannot be done with the gratis api level. I suggest # posting a message like: 'Retweet: # https://nitter.net/fsf/status/1726382360826958325' tweet() { local keys_file profile verbose_arg ret out line num_regex account_real_name local md5_text local -a out_lines ret=0 if $verbose; then verbose_arg=-v fi keys_file=$_pdtsh_dir/twitter_keys.py if [[ $1 == --* ]]; then profile=${1#--} rm -f $keys_file ln -s $keys_file-$profile $keys_file shift fi if [[ $profile == dbd ]]; then account_real_name=endDRM elif [[ $profile ]]; then account_real_name=$profile else account_real_name=USERNAME_HERE fi # shellcheck disable=SC1090 # not relevant to this script source ~/src/tweepy/venv/bin/activate out=$( { printf "%s\n" "$*" if [[ $media ]]; then printf "%s\n" "$media" if [[ $alt_text ]]; then printf "%s\n" "$alt_text" fi fi } | $_pdtsh_tweet $verbose_arg 2>&1 ) || ret=1 if [[ $media ]]; then md5_text="$(md5sum $media | awk '{print $1}') " fi post_info="$md5_text $*" if (( ret == 1 )); then if printf "%s\n" "$out" \ | grep -Fx 'You are not allowed to create a Tweet with duplicate content.' &>/dev/null; then # We dont output duplicate error when not in verbose mode, because it is # is pretty easy to trigger. if [[ -s ~/pdt/tweets.log ]] && grep -qFx -- "$post_info" ~/pdt/tweets.log; then if $verbose; then echo "pdt: error: post failed. twitter and our log says this tweet is duplicate." >&2 fi else printf "%s\n" "$post_info" >> ~/pdt/tweets.log if $verbose; then printf "%s\n" "$out" >&2 fi fi else printf "%s\n" "$out" >&2 fi deactivate return 1 fi ## handle case of ret == 0 mapfile -t out_lines <<<"$out" num_regex='$[0-9][0-9]*$' for line in "${out_lines[@]}"; do if [[ $line =~ $num_regex ]]; then echo "https://nitter.net/$account_real_name/status/$line" fi done if [[ $line ]]; then if [[ -s ~/pdt/tweets.log ]] && grep -qFx -- "$post_info" ~/pdt/tweets.log; then echo "pdt: this tweet was a duplicate according to our log but unexpectedly was allowed." else printf "%s\n" "$post_info" >> ~/pdt/tweets.log fi fi deactivate } _pdtsh-tweet-option() { local keys_file option option=$1 shift keys_file=$_pdtsh_dir/twitter_keys.py if [[ $1 == --* ]]; then profile=${1#--} rm -f $keys_file ln -s $keys_file-$profile $keys_file shift fi # shellcheck disable=SC1090 # not relevant to this script source ~/src/tweepy/venv/bin/activate $_pdtsh_tweet $option $1 || { deactivate; return 1; } deactivate } # usage: tweetrm [--PROFILE_NAME] POST_ID # # Delete twitter post. Post id is the number at end of a url like: # https://nitter.net/user/status/1725640623149969527 # # --PROFILE_NAME see see documentation for tweet() tweetrm() { _pdtsh-tweet-option --delete "$@" } # usage: pin-tweet [--PROFILE_NAME] POST_ID # # Pin twitter post. Post id is the number at end of a url like: # https://nitter.net/user/status/1725640623149969527 # # --PROFILE_NAME see see documentation for tweet() # pin-tweet() { _pdtsh-tweet-option --pin "$@" } # usage: unpin-tweet [--PROFILE_NAME] POST_ID # # Unpin twitter post. Post id is the number at end of a url like: # https://nitter.net/user/status/1725640623149969527 # # --PROFILE_NAME see see documentation for tweet() # unpin-tweet() { _pdtsh-tweet-option --unpin "$@" } # usage: twitter-banner [--PROFILE_NAME] IMAGE_PATH # # IMAGE_PATH Path to jpg or png, 1500 px by 500px. # # --PROFILE_NAME see see documentation for tweet() # twitter-banner() { _pdtsh-tweet-option --banner "$@" } # usage: toot [--PROFILE_NAME] [TOOT_ARGS] # # --PROFILE_NAME Either dbd or fsf. Defaults to whatever profile was used # last in any pdt command. toot() { local mast_profile # shellcheck disable=SC1090 # not relevant to this script source ~/src/toot/venv/bin/activate if [[ $1 == --* ]]; then mast_profile=${1#--} shift command toot activate $toot_quiet_arg $mast_profile || { deactivate; return 1; } fi command toot "$@" || { deactivate; return 1; } deactivate } # post to mastodon and/org twitter. # # usage: # pdt [-s mastodon|twitter|gnusocial] [--dbd] [-m IMAGE_FILE] [-a ALT_TEXT] [POST] # # -s mastodon|twitter|gnusocial Post to a single social network. # # --dbd Use dbd account instead of fsf. # # -m IMAGE_FILE Uploads IMAGE_FILE. if MEDIA_FILE.txt exists, the last # line of that file will be used as ALT_TEXT unless -a # has been used. # # -a ALT_TEXT Alt-text of IMAGE_FILE. # # Without POST, you will be prompted for it. POST can have have some # special markup for twitter so you can have longer than 280 char posts # to mastodon, then split them or truncate them for twitter. # # * /tnt/ short for "twitter next tweet". /tnt/ will be removed, and the # tweet will be split into 2 at that point. # # * /teof/ short for "twitter end of file." It will be removed and text # after it won't be posted to twitter. An example of how this is # useful: You write a post which is 284 characters. Instead of # splitting it in 2 for twitter, you could put the least important # hash tag and the end and omit it on twitter. # # Twitter considers all urls to be 23 characters. # # To search a file of posts for any posts with longer than 280 # characters which haven't been split with /tnt/, run the following # (replacing FILE with a path like /home/common/campaigns/pdt/pdt.txt). # # sed -r 's,/teof/.*,,;s/ *-(m|a|-dbd) [^ ]*//;s,https?://[^ ]*,https://xxxxxxxxxxxxxxx,g' FILE | awk '$0 !~ /\/tnt\// && length > 280 {print length, $0}' # # That will print the length of the post as twitter sees it (with 23 # characters per url). # # broken usage: # pdt [-v VIDEO_PATH] [POST] # # Outdated usage: # # Gnu Social posting code exists, but we aren't using it. You would need # to run pdt-gnusocial-setup beforehand. Alt text does not work on # gnusocial. # pdt() { local video media twitter_account gs_account mastodon_account video gs_arg network local do_mastodon do_twitter do_gnusocial verbose local -a toot_args if [[ $pdttest ]]; then twitter_account=iank gs_account=fsfes mastodon_account=fsftest@hostux.social else twitter_account=fsf gs_account=fsf mastodon_account=fsf@hostux.social fi video=false do_mastodon=true do_twitter=true do_gnusocial=false alt_text= while [[ $1 == -* ]]; do case $1 in -s) network="$2" do_mastodon=false do_twitter=false do_gnusocial=false case $network in mastodon) do_mastodon=true ;; twitter) do_twitter=true ;; gnusocial) do_gnusocial=true ;; *) echo "pdt: error: expected -s mastodon|twitter|gnusocial" return 1 ;; esac shift 2 ;; -a) alt_text="$2" shift 2 ;; -m) media="$2" shift 2 toot_args+=(--media "$media" ) gs_arg="-F media=@$media" ;; --dbd) twitter_account=dbd gs_account=dbd mastodon_account=endDRM@hostux.social shift ;; -v) video=true media="$2" shift 2 ;; esac done if [[ $media ]]; then if [[ ! -e $media ]]; then echo "error: file not found $media" return 1 fi if [[ $media == *\ * ]]; then echo "error: file path contains a space. move it to non-space path" return 1 fi if [[ ! $alt_text && -r $media.txt && -s $media.txt ]]; then alt_text=$(tail -n1 $media.txt) fi if [[ $alt_text ]]; then toot_args+=(--description "$alt_text" ) fi fi # if we have no argument if (( ! $# )); then read -r -p "input PDT text: " input set -- "$input" fi verbose=false toot_quiet_arg=--quiet if [[ $- == *i* ]]; then verbose=true toot_quiet_arg= echo "About to PDT the following line. Press enter to confirm or ctrl-c to quit:" echo "$*" read -r fi if $video; then local oath oath=$HOME/.rainbow_oauth rm -f $oath ln -s ${oath}-$twitter_account $oath python3 ~/src/video-tweet/async-upload.py "$media" "$*" return fi fails=() if $do_twitter; then if ! tweet --$twitter_account "${*%/teof/*}"; then fails+=(tweet) fi fi # remove /tnt/ for non-twitter social media. text="$*" text=${text// \/tnt\/ / } text="${text//\/tnt\/}" text="${text//\/teof\/}" if $do_mastodon; then if ! toot --$mastodon_account post $toot_quiet_arg "$text" "${toot_args[@]}"; then fails+=(toot) fi fi if $do_gnusocial; then # https://gnusocial.net/doc/twitterapi if ! curl -o /dev/null -sS -u "$gs_account:$(cat ~/.gnusocial_login-$gs_account)" \ $gs_arg -F "status=$text" https://status.fsf.org/api/statuses/update.xml; then fails+=(gnu-social) fi fi if (( ${#fails[@]} )); then printf "%s\n" "$(tput setaf 5 2>/dev/null ||:)${_pdtsh_blocks:0:${COLUMNS:-60}}$(tput sgr0 2>/dev/null||:)" echo "FSF ERROR: ${fails[*]} might not have posted. post text: $text" >&2 fi } # Usage: toot-setup # # Only run manually for testing. # # this expects pdt-pip-setup has been run already, and it doesn't setup # errhandle. # # note: auth info is stored at ~/.config/toot/config.json pdt-toot-setup() { local -a mastodon_accounts if [[ $pdttest ]]; then mastodon_accounts=(fsftest) else mastodon_accounts=(fsf endDRM) fi rm -rf ~/src/toot mkdir -p ~/src/toot # subshell for cd ( cd ~/src/toot # on t11, got myself into a situation where when doing pip install virtualenv, # it gave an error that /usr/bin/pip didn't exist, so i did # sudo ln -s /home/iank/.local/bin/pip /usr/bin # python3 -m virtualenv -p python3 venv # shellcheck disable=SC1091 # not relevant to this script source venv/bin/activate # pip freeze after a pip install, as of 2022-11-28 cat >requirements.txt <<'EOF' beautifulsoup4==4.11.1 certifi==2022.9.24 charset-normalizer==2.1.1 idna==2.8 requests==2.22.0 soupsieve==2.3.2.post1 toot==0.29.0 urllib3==1.25.8 urwid==2.1.2 wcwidth==0.2.5 EOF # new 2022-11 packages # beautifulsoup4==4.11.1 # certifi==2022.9.24 # charset-normalizer==2.1.1 # idna==3.4 # requests==2.28.1 # soupsieve==2.3.2.post1 # toot==0.29.0 # urllib3==1.26.13 # urwid==2.1.2 # wcwidth==0.2.5 # old 2019 packages # beautifulsoup4==4.8.2 # certifi==2019.11.28 # chardet==3.0.4 # idna==2.8 # requests==2.22.0 # soupsieve==1.9.5 # toot==0.25.2 # urllib3==1.25.8 # urwid==2.1.0 # wcwidth==0.1.8 # i mixed in some old packages to get newer toot working on t9. python3 -m pip install -r requirements.txt # not needed due to subshell # deactivate ) for account in ${mastodon_accounts[@]}; do if ! toot activate $account@hostux.social &>/dev/null; then printf "%s\n" "$(tput setaf 5 2>/dev/null ||:)${_pdtsh_blocks:0:${COLUMNS:-60}}$(tput sgr0 2>/dev/null||:)" echo "Please login to the account named \"$account\" on https://hostux.social in your main browser then press enter." echo "WARNING: if you log into an account other than \"$account\", this won't work" read -r toot login -i hostux.social fi done } # Usage: pdt-pip-setup pdt-pip-setup() { if [[ ! -e ~/.local/bin/pip ]]; then tmp=$(mktemp) pyver=$(python3 --version | sed -r 's/.*(3\.[0-9]+).*/\1/') echo fyi: detected pyver: $pyver. this should look something like 3.6 if dpkg --compare-versions 3.6 ge $pyver; then # The bootstrap script at https://bootstrap.pypa.io/get-pip.py required 3.7+ # when i wrote this. wget -O$tmp https://bootstrap.pypa.io/pip/$pyver/get-pip.py else wget -O$tmp https://bootstrap.pypa.io/get-pip.py fi python3 $tmp --user hash -r fi python3 -m pip install --user -U virtualenv } # generally only meant to be called internally from pdt-setup pdt-twitter-setup() { ( cd ~/src/tweepy python3 -m virtualenv -p python3 venv # shellcheck disable=SC1091 # not relevant to this script source venv/bin/activate python3 -m pip install . ) } pdt-gnusocial-setup() { for account in dbd fsf; do if [[ ! -s ~/.gnusocial_login-$account ]]; then printf "%s\n" "$(tput setaf 5 2>/dev/null ||:)${_pdtsh_blocks:0:${COLUMNS:-60}}$(tput sgr0 2>/dev/null||:)" read -r -p "please enter the password for $account@status.fsf.org > " pass touch ~/.gnusocial_login-$account chmod 600 ~/.gnusocial_login-$account printf "%s\n" "$pass" > ~/.gnusocial_login-$account fi done } # Usage: pdt-setup # # # Twitter requires separate installation of 2 files alongside this file, # twitter_keys.py-fsf, # twitter_keys.py-dbd. They look like this: # # access_token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # access_token_secret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # consumer_key = 'xxxxxxxxxxxxxxxxxxxxxxxxx' # consumer_secret = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' # # To get them, as of 2023-11-17: # * Go to https://developer.twitter.com, # * You will need to allow some nonfree javascript scripts to # run. Generally, just the ones that come from twitter.come, not # things like google analytics. This is a one time sacrifice of software # freedom which will enable posting to twitter in freedom indefinitely # until some major change at twitter happens, for example, they shut # down the api. That could be many years from now. # * Click developer portal # * A login page appears, enter your twitter credentials. # * If you've never gotten an api key before, click sign up for a free account # * If you had a developer account from before 2023, you will need to # create a project and an app or add an old app to the project. # # * If this is a new developer account, you have to click setup user # authentication settings. Select read/write permissions, automated # app or bot, and give it any random url in app info. Ignore the # OAuth 2.0 keys. # # * Click on the app, click keys and tokens, # # * Under consumer keys, sub-item "api key and secret", click # regenerate. copy to vars consumer_key and consumer_secret # # * Under authentication tokens, sub-item Access Token and Secret, # click generate. copy to vars access_token and access_token_secret. # pdt-setup() { mkdir -p ~/src for repo in errhandle video-tweet; do if [[ -e ~/src/$repo/.git ]]; then if git -C ~/src/$repo remote -v | grep -E "^origin[[:space:]]+[^[:space:]]*vcs.fsf.org[^[:space:]]+$repo.git" &>/dev/null; then git -C ~/src/$repo fetch git -C ~/src/$repo reset --hard origin/master git -C ~/src/$repo clean -xfffd else rm -rf ~/src/$repo git clone https://vcs.fsf.org/git/$repo.git ~/src/$repo fi else git clone https://vcs.fsf.org/git/$repo.git ~/src/$repo fi done # shellcheck disable=SC1090 # tested separately source ~/src/errhandle/bash-bear pdt-pip-setup pdt-toot-setup pdt-twitter-setup # not using gnusocial by default. # pdt-gnusocial-setup err-allow echo "pdt-setup complete" }