123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454 |
- #!/usr/bin/env bash
- #
- # birch - a simple irc client in bash
- clean() {
- # '\e[?7h': Re-enable line wrapping.
- # '\e[2J': Clear the screen.
- # '\e[;r': Reset the scroll area.
- # '\e[?1049l': Swap back to the primary screen.
- printf '\e[?7h\e[2J\e[;r\e[?1049l'
- rm -rf "$TMPDIR/birch-$$"
- # Kill the IRC client to also exit the child
- # listener which runs in the background.
- kill 0
- }
- refresh() {
- # The pure bash method of grabbing the terminal
- # size in cells in unreliable. This command always
- # works and doesn't add a tiny delay.
- shopt -s checkwinsize; (:;:)
- # '\e[?1049h': Swap to the alternate buffer.
- # '\e[?7l': Disable line wrapping.
- # '\e[2J': Clear the screen.
- # '\e[3;%sr': Set the scroll area.
- # '\e[999H': Move the cursor to the bottom.
- printf '\e[?1049h\e[?7l\e[2J\e[3;%sr\e[999H' "$((LINES-1))"
- }
- resize() {
- refresh
- status
- # '\e7': Save the cursor position.
- # '\e[?25l': Hide the cursor.
- # '\r': Move the cursor to column 0.
- # '\e[999B': Move the cursor to the bottom.
- # '\e[A': Move the cursor up a line.
- printf '\e7\e[?25l\r\e[999B\e[A'
- # Print the last N lines of the log file.
- {
- [[ -s .c ]] && read -r c < .c
- mapfile -tn 0 log 2>/dev/null < "${c:-$chan}"
- printf '%s\n' \
- "${log[@]: -(LINES > ${#log[@]} ? ${#log[@]} : LINES)}"
- }
- # '\e[999H': Move the cursor back to the bottom.
- # '\e[?25h': Unhide th cursor.
- printf '\e[999H\e[?25h'
- }
- status() {
- # Each channel or "buffer" is a file in the current
- # directory. A simple glob is used to populate the
- # list.
- #
- # The array is turned into a string with a space on
- # either end so that we can find/replace the current
- # buffer to add highlighting.
- cl=(*[^:]) cL=" ${cl[*]} "
- # '\e7': Save the cursor position.
- # '\e[H': Move the cursor to 0,0.
- # '\e[K': Clear the current line.
- # '\e8': Restore cursor position.
- printf '\e7\e[H\e[K%b\e8' \
- "${cL/" $chan "/ ${BIRCH_STATUS:=$'\e[7m'}"$chan"$'\e[m' }"
- }
- connect() {
- # Open an input/output network socket to the IRC server
- # using the file descriptor '9'.
- exec 9<>"/dev/tcp/${s:=irc.andrewyu.org}/${P:-6667}" ||
- exit 1
- printf 'NICK %s\nUSER %s - - :%s\nPASS %s\n' \
- "${U:-${nick:=${u:-$USER}}}" "$nick" "$nick" "${p-}" >&9
- # Join all passed channels as early as we can.
- printf 'JOIN %s\n' "${c:=#idc}" >&9
- chan=${c/,*}
- }
- prin() {
- # Strip escape sequences from the first word in the
- # full message so that we can calculate how much padding
- # to add for alignment.
- raw=${1%% *}
- raw=${raw//$'\e[1;3'?m}
- raw=${raw//$'\e[m'}
- # Generate a cursor right sequence based on the length
- # of the above "raw" word. The nick column is a fixed
- # width of '10' so it's simply '10 - word_len'.
- printf -v out '\e[%sC%s' \
- "$((${#raw}>10?0:11-${#raw}))" "$1"
- # Grab the current channel a second time to ensure it
- # didn't change during the printing process.
- [[ -s .c ]] && read -r chan < .c
- # Only display to the terminal if the message destination
- # matches the currently focused buffer.
- #
- # '\e[?25l': Hide the cursor.
- # '\e7': Save cursor position.
- # '\e[999B': Move the cursor to the bottom.
- # '\e[A': Move the cursor up a line.
- # '\r': Move the cursor to column 0.
- # '\e8': Restore cursor position.
- # '\e[?25h': Unhide the cursor.
- [[ $dest == "$chan" ]] &&
- printf '\e[?25l\e7\e[999B\e[A\r%s\n\r\e8\e[?25h' "$out"
- # Log the message to it's destination temporary file.
- # This is how history, resize and buffer swaps work.
- printf '\r%s\n' "$out" >> "$dest"
- }
- cmd() {
- # Unescape some pesky tab completion blunders.
- inp=${1//\\\#/\#} inp=${inp//\\@/@} inp=${inp//\\:/:}
- inp=${inp//\\\[/\[} inp=${inp//\\\]/\]} inp=${inp//\\!/!}
- inp=${inp//\\\(/\(} inp=${inp//\\\)/\)}
- set -- "$inp"
- # Save the sent input to readline's history so up/down
- # arrow work to scroll through sent history.
- history -s "$1"
- # Read the input into an array chopping off the /cmd.
- # This makes splitting everything easier below if it
- # is needed.
- read -r _ a args <<< "$inp"
- # This is a simple function to send the input to the
- # terminal and to the listener while saving space below.
- send() { parse "$1"; printf '%s\n' "$1" >&9; }
- case $1 in "") ;;
- '/join '*)
- chan=$a
- printf '%s\n' "$chan" > .c
- [[ -f $a ]] || printf ':%s JOIN %s\n' "$nick" "$a" >&9
- kill -28 0
- status
- ;;
- '/nick '*)
- printf 'NICK %s\n' "$a" >&9
- nick=$a
- ;;
- '/msg '*)
- send "PRIVMSG $a :$args"
- ;;
- '/raw '*)
- printf '%s\n' "$a $args" >&9
- ;;
- '/me '*)
- send "PRIVMSG $chan :"$'\001'"ACTION $a $args"$'\001'
- ;;
- '/part'*)
- printf '%s PART %s :bye bye\n' "$nick" "${a:=$chan}" >&9
- sleep 1
- rm -f "$a"
- ;;
- '/shrug'*)
- send "PRIVMSG $chan :¯\_(ツ)_/¯"
- ;;
- '/quit'*)
- send "QUIT :$a $args"
- clean
- ;;
- '/next'*)
- chan=${cl[z = z + 1 >= ${#cl[@]} ? 0 : z + 1]}
- ;;&
- '/prev'*)
- chan=${cl[z = z - 1 < 0 ? ${#cl[@]}-1 : z - 1]}
- ;;&
- '/'[0-9]*)
- chan="${cl[${1//[!0-9]/} >= ${#cl[@]} ? 0 : ${1//[!0-9]/}]}"
- ;;&
- '/next'*|'/prev'*|'/'[0-9]*)
- printf '%s\n' "$chan" > .c
- kill -28 0
- ;;
- '/names'*)
- send "NAMES $chan"
- ;;
- '/topic'*)
- send "TOPIC $chan"
- ;;
- '/away '*)
- send "AWAY :$a $args"
- ;;
- '/away'*)
- send "AWAY"
- ;;
- /*)
- send "NOTICE :${1/ *} not implemented yet"
- ;;
- *)
- send "PRIVMSG $chan :$1"
- ;;
- esac
- # Clear the input line once we're done.
- printf '\r\e[2K\r'
- }
- parse() {
- fields=() word='' from='' whom=''
- [[ -s .c ]] && read -r chan < .c
- # If the first "word" in the raw IRC message contains
- # ':', '@' or '!', split it and grab the sending user
- # nick.
- [[ "${1%% *}" == *[:@!]* ]] && {
- from=${1%% *}
- IFS='!@' read -r whom _ <<< "${from#:}"
- }
- # Read the rest of the message character by character
- # until we reach the first ':'. Once the first colon
- # is hit, break from the loop and assume that everything
- # after it is the message contents.
- #
- # Each word prior to ':' is appended to an array so that
- # we may use each portion.
- while IFS= read -d '' -rn 1 c; do case $c in
- ' ') [[ $word ]] && fields+=("$word") word= ;;
- :) break ;;
- *) word+=$c ;;
- esac; done <<< "${1/"$from"}"
- # Grab the message contents by stripping everything we've
- # found so far above. Then word wrap each line at 60
- # chars wide. TODO: Pure bash and unrestriced..
- mesg=${1/"${from:+$from }${fields[*]} "} mesg=${mesg#:}
- mesg=$(fold -sw "${BIRCH_COLUMNS:=60}" <<< "$mesg")
- mesg=${mesg//$'\n'/$'\n' }
- # If the field after the typical dest is a channel, use
- # it in place of the regular field. This correctly
- # catches MOTD and join messages.
- case ${fields[2]} in
- \#*|\*) fields[1]=${fields[2]} ;;
- =) fields[1]=${fields[3]} ;;
- esac
- whom=${whom:-$nick}
- dest=${fields[1]:-$chan}
- # If the message itself contains ACTION with surrounding
- # '\001', we're dealing with '/me'. Simply set the type
- # to 'ACTION' so we may specially deal with it below.
- [[ $mesg == *$'\001ACTION'*$'\001'* ]] &&
- fields[0]=ACTION mesg=${mesg/$'\001ACTION' }
- # Color the interesting parts based on their lengths.
- # This saves a lot of space below.
- nc=$'\e[1;3'$(((${#whom}%6)+1))m$whom$'\e[m'
- pu=$'\e[1;3'$(((${#whom}%6)+1))m${whom:0:10}$'\e[m'
- me=$'\e[1;3'$(((${#nick}%6)+1))m$nick$'\e[m'
- mc=$'\e[1;3'$(((${#mesg}%6)+1))m$mesg$'\e[m'
- dc=$'\e[1;3'$(((${#dest}%6)+1))m$dest$'\e[m'
- # The first element in the fields array points to the
- # type of message we're dealing with.
- case ${fields[0]} in
- PRIVMSG)
- prin "$pu ${mesg//$nick/$me}"
- [[ $dest == *$nick* || $mesg == *$nick* ]] &&
- type -p notify-send >/dev/null &&
- notify-send "birch: New mention" "$whom: $mesg"
- ;;
- ACTION)
- prin "* $nc ${mesg/$'\001'}"
- ;;
- NOTICE)
- prin "NOTE $mesg"
- ;;
- QUIT)
- rm -f "$whom:"
- [[ ${nl[chan]} == *" $whom "* ]] &&
- prin "<-- $nc has quit ${dc//$dest/$chan}"
- ;;
- PART)
- rm -f "$whom:"
- [[ $dest == "$chan" ]] &&
- prin "<-- $nc has left $dc"
- ;;
- JOIN)
- [[ $whom == "$nick" ]] && chan=$mesg
- : > "$whom:"
- dest=$mesg
- prin "--> $nc has joined $mc"
- ;;
- NICK)
- prin "--@ $nc is now known as $mc"
- ;;
- PING)
- printf 'PONG%s\n' "${1##PING}" >&9
- ;;
- AWAY)
- dest=$nick
- prin "-- Away status: $mesg"
- ;;
- 00?|2[56]?|37?)
- dest=\*
- ;;&
- 376)
- cmd "${x:-}"
- ;;&
- 353)
- [[ -f "$dest" ]] || return
- read -ra ns <<< "$mesg"
- nl[chan]=" $mesg "
- for nf in "${ns[@]/%/:}"; do
- : > "$nf"
- done
- ;;&
- *)
- prin "-- $mesg"
- ;;
- esac
- }
- args() {
- # Simple argument parsing. We use 'declare' to... declare
- # variables named after the argument they represent (-b == $b).
- while getopts :s:u:U:p:c:x:P:v opt; do case $opt in
- \?)
- printf 'birch <args>\n\n'
- printf -- '-s <host>\n'
- printf -- '-c <channel>\n'
- printf -- '-u <nick>\n'
- printf -- '-p <server_password>\n'
- printf -- '-U <server_username>\n'
- printf -- '-P <port>\n'
- printf -- '-x <cmd>\n\n'
- printf -- '-h (help)\n'
- printf -- '-v (version)\n'
- ;;
- v) printf 'birch 0.0.1\n' ;;
- :) printf 'Option -%s requires an argument\n' "$OPTARG" >&2 ;;
- *) declare -g "$opt=$OPTARG"
- esac; [[ $opt =~ \?|v|: ]] && exit; done
- }
- main() {
- args "$@"
- refresh
- connect
- # Enable loadable bash builtins if available.
- # YES! Bash has loadable builtins for a myriad of
- # external commands. This includes 'sleep'!
- enable -f /usr/lib/bash/mkdir mkdir 2>/dev/null
- enable -f /usr/lib/bash/sleep sleep 2>/dev/null
- # Setup the temporary directory and create any channel
- # files early. Change the PWD to this directory to
- # simplify file handling later on.
- mkdir -p "${TMPDIR:=/tmp}/birch-$$"
- cd "$_" || exit 1
- printf '%s\n' "$chan" > .c
- IFS=, read -ra channels <<< "$c"
- for f in "${channels[@]}"; do : >> "$f"; done
- # Declare an associative array to hold the nick list
- # of each channel. The key is the channel name and the
- # value is a string containing each nick.
- declare -A nl
- # Bind 'ctrl+n' to cycle through the buffer list. As
- # the prompt uses bash's builtin 'readline', we're
- # able to do whatever we like with it. Neat huh?
- bind -x '"\C-n":cmd "/next"' &>/dev/null
- bind -x '"\C-p":cmd "/prev"' &>/dev/null
- bind 'TAB:menu-complete' &>/dev/null
- bind 'set match-hidden-files off' &>/dev/null
- bind 'set horizontal-scroll-mode on' &>/dev/null
- # Set readline's history file so that we can manage
- # its history ourselves.
- export HISTFILE=$PWD/hist
- export HISTCONTROL=ignoreboth:erasedups
- export INPUTRC=$BIRCH_INPUTRC
- trap resize WINCH
- trap 'cmd /quit' INT
- # Start the listener loop in the background so that
- # we are able to additionally run an input loop below.
- while read -sru 9; do
- parse "${REPLY%%$'\r'*}"
- done &
- # Start the input loop which uses bash's builtin
- # readline. This gives us neato features like a full
- # set of keybindings, tab completion, etc, etc.
- while status && read -er; do
- cmd "$REPLY"
- done
- }
- main "$@"
|