birch.sh 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. #!/usr/bin/env bash
  2. #
  3. # birch - a simple irc client in bash
  4. clean() {
  5. # '\e[?7h': Re-enable line wrapping.
  6. # '\e[2J': Clear the screen.
  7. # '\e[;r': Reset the scroll area.
  8. # '\e[?1049l': Swap back to the primary screen.
  9. printf '\e[?7h\e[2J\e[;r\e[?1049l'
  10. rm -rf "$TMPDIR/birch-$$"
  11. # Kill the IRC client to also exit the child
  12. # listener which runs in the background.
  13. kill 0
  14. }
  15. refresh() {
  16. # The pure bash method of grabbing the terminal
  17. # size in cells in unreliable. This command always
  18. # works and doesn't add a tiny delay.
  19. shopt -s checkwinsize; (:;:)
  20. # '\e[?1049h': Swap to the alternate buffer.
  21. # '\e[?7l': Disable line wrapping.
  22. # '\e[2J': Clear the screen.
  23. # '\e[3;%sr': Set the scroll area.
  24. # '\e[999H': Move the cursor to the bottom.
  25. printf '\e[?1049h\e[?7l\e[2J\e[3;%sr\e[999H' "$((LINES-1))"
  26. }
  27. resize() {
  28. refresh
  29. status
  30. # '\e7': Save the cursor position.
  31. # '\e[?25l': Hide the cursor.
  32. # '\r': Move the cursor to column 0.
  33. # '\e[999B': Move the cursor to the bottom.
  34. # '\e[A': Move the cursor up a line.
  35. printf '\e7\e[?25l\r\e[999B\e[A'
  36. # Print the last N lines of the log file.
  37. {
  38. [[ -s .c ]] && read -r c < .c
  39. mapfile -tn 0 log 2>/dev/null < "${c:-$chan}"
  40. printf '%s\n' \
  41. "${log[@]: -(LINES > ${#log[@]} ? ${#log[@]} : LINES)}"
  42. }
  43. # '\e[999H': Move the cursor back to the bottom.
  44. # '\e[?25h': Unhide th cursor.
  45. printf '\e[999H\e[?25h'
  46. }
  47. status() {
  48. # Each channel or "buffer" is a file in the current
  49. # directory. A simple glob is used to populate the
  50. # list.
  51. #
  52. # The array is turned into a string with a space on
  53. # either end so that we can find/replace the current
  54. # buffer to add highlighting.
  55. cl=(*[^:]) cL=" ${cl[*]} "
  56. # '\e7': Save the cursor position.
  57. # '\e[H': Move the cursor to 0,0.
  58. # '\e[K': Clear the current line.
  59. # '\e8': Restore cursor position.
  60. printf '\e7\e[H\e[K%b\e8' \
  61. "${cL/" $chan "/ ${BIRCH_STATUS:=$'\e[7m'}"$chan"$'\e[m' }"
  62. }
  63. connect() {
  64. # Open an input/output network socket to the IRC server
  65. # using the file descriptor '9'.
  66. exec 9<>"/dev/tcp/${s:=irc.andrewyu.org}/${P:-6667}" ||
  67. exit 1
  68. printf 'NICK %s\nUSER %s - - :%s\nPASS %s\n' \
  69. "${U:-${nick:=${u:-$USER}}}" "$nick" "$nick" "${p-}" >&9
  70. # Join all passed channels as early as we can.
  71. printf 'JOIN %s\n' "${c:=#idc}" >&9
  72. chan=${c/,*}
  73. }
  74. prin() {
  75. # Strip escape sequences from the first word in the
  76. # full message so that we can calculate how much padding
  77. # to add for alignment.
  78. raw=${1%% *}
  79. raw=${raw//$'\e[1;3'?m}
  80. raw=${raw//$'\e[m'}
  81. # Generate a cursor right sequence based on the length
  82. # of the above "raw" word. The nick column is a fixed
  83. # width of '10' so it's simply '10 - word_len'.
  84. printf -v out '\e[%sC%s' \
  85. "$((${#raw}>10?0:11-${#raw}))" "$1"
  86. # Grab the current channel a second time to ensure it
  87. # didn't change during the printing process.
  88. [[ -s .c ]] && read -r chan < .c
  89. # Only display to the terminal if the message destination
  90. # matches the currently focused buffer.
  91. #
  92. # '\e[?25l': Hide the cursor.
  93. # '\e7': Save cursor position.
  94. # '\e[999B': Move the cursor to the bottom.
  95. # '\e[A': Move the cursor up a line.
  96. # '\r': Move the cursor to column 0.
  97. # '\e8': Restore cursor position.
  98. # '\e[?25h': Unhide the cursor.
  99. [[ $dest == "$chan" ]] &&
  100. printf '\e[?25l\e7\e[999B\e[A\r%s\n\r\e8\e[?25h' "$out"
  101. # Log the message to it's destination temporary file.
  102. # This is how history, resize and buffer swaps work.
  103. printf '\r%s\n' "$out" >> "$dest"
  104. }
  105. cmd() {
  106. # Unescape some pesky tab completion blunders.
  107. inp=${1//\\\#/\#} inp=${inp//\\@/@} inp=${inp//\\:/:}
  108. inp=${inp//\\\[/\[} inp=${inp//\\\]/\]} inp=${inp//\\!/!}
  109. inp=${inp//\\\(/\(} inp=${inp//\\\)/\)}
  110. set -- "$inp"
  111. # Save the sent input to readline's history so up/down
  112. # arrow work to scroll through sent history.
  113. history -s "$1"
  114. # Read the input into an array chopping off the /cmd.
  115. # This makes splitting everything easier below if it
  116. # is needed.
  117. read -r _ a args <<< "$inp"
  118. # This is a simple function to send the input to the
  119. # terminal and to the listener while saving space below.
  120. send() { parse "$1"; printf '%s\n' "$1" >&9; }
  121. case $1 in "") ;;
  122. '/join '*)
  123. chan=$a
  124. printf '%s\n' "$chan" > .c
  125. [[ -f $a ]] || printf ':%s JOIN %s\n' "$nick" "$a" >&9
  126. kill -28 0
  127. status
  128. ;;
  129. '/nick '*)
  130. printf 'NICK %s\n' "$a" >&9
  131. nick=$a
  132. ;;
  133. '/msg '*)
  134. send "PRIVMSG $a :$args"
  135. ;;
  136. '/raw '*)
  137. printf '%s\n' "$a $args" >&9
  138. ;;
  139. '/me '*)
  140. send "PRIVMSG $chan :"$'\001'"ACTION $a $args"$'\001'
  141. ;;
  142. '/part'*)
  143. printf '%s PART %s :bye bye\n' "$nick" "${a:=$chan}" >&9
  144. sleep 1
  145. rm -f "$a"
  146. ;;
  147. '/shrug'*)
  148. send "PRIVMSG $chan :¯\_(ツ)_/¯"
  149. ;;
  150. '/quit'*)
  151. send "QUIT :$a $args"
  152. clean
  153. ;;
  154. '/next'*)
  155. chan=${cl[z = z + 1 >= ${#cl[@]} ? 0 : z + 1]}
  156. ;;&
  157. '/prev'*)
  158. chan=${cl[z = z - 1 < 0 ? ${#cl[@]}-1 : z - 1]}
  159. ;;&
  160. '/'[0-9]*)
  161. chan="${cl[${1//[!0-9]/} >= ${#cl[@]} ? 0 : ${1//[!0-9]/}]}"
  162. ;;&
  163. '/next'*|'/prev'*|'/'[0-9]*)
  164. printf '%s\n' "$chan" > .c
  165. kill -28 0
  166. ;;
  167. '/names'*)
  168. send "NAMES $chan"
  169. ;;
  170. '/topic'*)
  171. send "TOPIC $chan"
  172. ;;
  173. '/away '*)
  174. send "AWAY :$a $args"
  175. ;;
  176. '/away'*)
  177. send "AWAY"
  178. ;;
  179. /*)
  180. send "NOTICE :${1/ *} not implemented yet"
  181. ;;
  182. *)
  183. send "PRIVMSG $chan :$1"
  184. ;;
  185. esac
  186. # Clear the input line once we're done.
  187. printf '\r\e[2K\r'
  188. }
  189. parse() {
  190. fields=() word='' from='' whom=''
  191. [[ -s .c ]] && read -r chan < .c
  192. # If the first "word" in the raw IRC message contains
  193. # ':', '@' or '!', split it and grab the sending user
  194. # nick.
  195. [[ "${1%% *}" == *[:@!]* ]] && {
  196. from=${1%% *}
  197. IFS='!@' read -r whom _ <<< "${from#:}"
  198. }
  199. # Read the rest of the message character by character
  200. # until we reach the first ':'. Once the first colon
  201. # is hit, break from the loop and assume that everything
  202. # after it is the message contents.
  203. #
  204. # Each word prior to ':' is appended to an array so that
  205. # we may use each portion.
  206. while IFS= read -d '' -rn 1 c; do case $c in
  207. ' ') [[ $word ]] && fields+=("$word") word= ;;
  208. :) break ;;
  209. *) word+=$c ;;
  210. esac; done <<< "${1/"$from"}"
  211. # Grab the message contents by stripping everything we've
  212. # found so far above. Then word wrap each line at 60
  213. # chars wide. TODO: Pure bash and unrestriced..
  214. mesg=${1/"${from:+$from }${fields[*]} "} mesg=${mesg#:}
  215. mesg=$(fold -sw "${BIRCH_COLUMNS:=60}" <<< "$mesg")
  216. mesg=${mesg//$'\n'/$'\n' }
  217. # If the field after the typical dest is a channel, use
  218. # it in place of the regular field. This correctly
  219. # catches MOTD and join messages.
  220. case ${fields[2]} in
  221. \#*|\*) fields[1]=${fields[2]} ;;
  222. =) fields[1]=${fields[3]} ;;
  223. esac
  224. whom=${whom:-$nick}
  225. dest=${fields[1]:-$chan}
  226. # If the message itself contains ACTION with surrounding
  227. # '\001', we're dealing with '/me'. Simply set the type
  228. # to 'ACTION' so we may specially deal with it below.
  229. [[ $mesg == *$'\001ACTION'*$'\001'* ]] &&
  230. fields[0]=ACTION mesg=${mesg/$'\001ACTION' }
  231. # Color the interesting parts based on their lengths.
  232. # This saves a lot of space below.
  233. nc=$'\e[1;3'$(((${#whom}%6)+1))m$whom$'\e[m'
  234. pu=$'\e[1;3'$(((${#whom}%6)+1))m${whom:0:10}$'\e[m'
  235. me=$'\e[1;3'$(((${#nick}%6)+1))m$nick$'\e[m'
  236. mc=$'\e[1;3'$(((${#mesg}%6)+1))m$mesg$'\e[m'
  237. dc=$'\e[1;3'$(((${#dest}%6)+1))m$dest$'\e[m'
  238. # The first element in the fields array points to the
  239. # type of message we're dealing with.
  240. case ${fields[0]} in
  241. PRIVMSG)
  242. prin "$pu ${mesg//$nick/$me}"
  243. [[ $dest == *$nick* || $mesg == *$nick* ]] &&
  244. type -p notify-send >/dev/null &&
  245. notify-send "birch: New mention" "$whom: $mesg"
  246. ;;
  247. ACTION)
  248. prin "* $nc ${mesg/$'\001'}"
  249. ;;
  250. NOTICE)
  251. prin "NOTE $mesg"
  252. ;;
  253. QUIT)
  254. rm -f "$whom:"
  255. [[ ${nl[chan]} == *" $whom "* ]] &&
  256. prin "<-- $nc has quit ${dc//$dest/$chan}"
  257. ;;
  258. PART)
  259. rm -f "$whom:"
  260. [[ $dest == "$chan" ]] &&
  261. prin "<-- $nc has left $dc"
  262. ;;
  263. JOIN)
  264. [[ $whom == "$nick" ]] && chan=$mesg
  265. : > "$whom:"
  266. dest=$mesg
  267. prin "--> $nc has joined $mc"
  268. ;;
  269. NICK)
  270. prin "--@ $nc is now known as $mc"
  271. ;;
  272. PING)
  273. printf 'PONG%s\n' "${1##PING}" >&9
  274. ;;
  275. AWAY)
  276. dest=$nick
  277. prin "-- Away status: $mesg"
  278. ;;
  279. 00?|2[56]?|37?)
  280. dest=\*
  281. ;;&
  282. 376)
  283. cmd "${x:-}"
  284. ;;&
  285. 353)
  286. [[ -f "$dest" ]] || return
  287. read -ra ns <<< "$mesg"
  288. nl[chan]=" $mesg "
  289. for nf in "${ns[@]/%/:}"; do
  290. : > "$nf"
  291. done
  292. ;;&
  293. *)
  294. prin "-- $mesg"
  295. ;;
  296. esac
  297. }
  298. args() {
  299. # Simple argument parsing. We use 'declare' to... declare
  300. # variables named after the argument they represent (-b == $b).
  301. while getopts :s:u:U:p:c:x:P:v opt; do case $opt in
  302. \?)
  303. printf 'birch <args>\n\n'
  304. printf -- '-s <host>\n'
  305. printf -- '-c <channel>\n'
  306. printf -- '-u <nick>\n'
  307. printf -- '-p <server_password>\n'
  308. printf -- '-U <server_username>\n'
  309. printf -- '-P <port>\n'
  310. printf -- '-x <cmd>\n\n'
  311. printf -- '-h (help)\n'
  312. printf -- '-v (version)\n'
  313. ;;
  314. v) printf 'birch 0.0.1\n' ;;
  315. :) printf 'Option -%s requires an argument\n' "$OPTARG" >&2 ;;
  316. *) declare -g "$opt=$OPTARG"
  317. esac; [[ $opt =~ \?|v|: ]] && exit; done
  318. }
  319. main() {
  320. args "$@"
  321. refresh
  322. connect
  323. # Enable loadable bash builtins if available.
  324. # YES! Bash has loadable builtins for a myriad of
  325. # external commands. This includes 'sleep'!
  326. enable -f /usr/lib/bash/mkdir mkdir 2>/dev/null
  327. enable -f /usr/lib/bash/sleep sleep 2>/dev/null
  328. # Setup the temporary directory and create any channel
  329. # files early. Change the PWD to this directory to
  330. # simplify file handling later on.
  331. mkdir -p "${TMPDIR:=/tmp}/birch-$$"
  332. cd "$_" || exit 1
  333. printf '%s\n' "$chan" > .c
  334. IFS=, read -ra channels <<< "$c"
  335. for f in "${channels[@]}"; do : >> "$f"; done
  336. # Declare an associative array to hold the nick list
  337. # of each channel. The key is the channel name and the
  338. # value is a string containing each nick.
  339. declare -A nl
  340. # Bind 'ctrl+n' to cycle through the buffer list. As
  341. # the prompt uses bash's builtin 'readline', we're
  342. # able to do whatever we like with it. Neat huh?
  343. bind -x '"\C-n":cmd "/next"' &>/dev/null
  344. bind -x '"\C-p":cmd "/prev"' &>/dev/null
  345. bind 'TAB:menu-complete' &>/dev/null
  346. bind 'set match-hidden-files off' &>/dev/null
  347. bind 'set horizontal-scroll-mode on' &>/dev/null
  348. # Set readline's history file so that we can manage
  349. # its history ourselves.
  350. export HISTFILE=$PWD/hist
  351. export HISTCONTROL=ignoreboth:erasedups
  352. export INPUTRC=$BIRCH_INPUTRC
  353. trap resize WINCH
  354. trap 'cmd /quit' INT
  355. # Start the listener loop in the background so that
  356. # we are able to additionally run an input loop below.
  357. while read -sru 9; do
  358. parse "${REPLY%%$'\r'*}"
  359. done &
  360. # Start the input loop which uses bash's builtin
  361. # readline. This gives us neato features like a full
  362. # set of keybindings, tab completion, etc, etc.
  363. while status && read -er; do
  364. cmd "$REPLY"
  365. done
  366. }
  367. main "$@"