$ git clone http://ion.nu/git/relaybot
commit 5916bb749a44aab9524c4e28fed8d0a3a4bf7f2d
Author: Alicia <...>
Date:   Mon Mar 27 23:02:14 2017 +0200

    Initial commit

diff --git a/API.doc b/API.doc
new file mode 100644
index 0000000..c44fc84
--- /dev/null
+++ b/API.doc
@@ -0,0 +1,19 @@
+Variables:
+EXECDIR                    Home of relaybot.sh and all the modules + static data
+DATADIR                    Home of things like messages to relay
+
+Functions:
+addcmd <command> <function>
+delcmd <command> <function>
+addeventhandler <event> <function>
+deleventhandler <event> <function>
+handleevent <event> <data...>
+toggle_cammode <mode>
+
+Command functions are called as <function> <from> <parameters>
+
+Events commonly pass along the following parameters:
+chatmsg <msg> <from> (<color>, if applicable>)
+join <nickname>
+part <nickname>
+camup <nickname>
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b01846c
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,5 @@
+CFLAGS=$(shell pkg-config --cflags gdk-pixbuf-2.0 libavcodec libswscale libavutil)
+LIBS=$(shell pkg-config --libs gdk-pixbuf-2.0 libavcodec libswscale libavutil)
+
+camutil: camutil.c
+ $(CC) $(CFLAGS) $^ $(LIBS) -o $@
diff --git a/camutil.c b/camutil.c
new file mode 100644
index 0000000..0c905e7
--- /dev/null
+++ b/camutil.c
@@ -0,0 +1,165 @@
+/*
+    camutil, a utility to give relaybot a face (or something)
+    Copyright (C) 2015  alicia@ion.nu
+
+    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, version 3 of the License.
+
+    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 <http://www.gnu.org/licenses/>.
+*/
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <signal.h>
+#include <gdk-pixbuf/gdk-pixbuf.h>
+#include <libavcodec/avcodec.h>
+#include <sys/prctl.h>
+#include <libswscale/swscale.h>
+#if LIBAVUTIL_VERSION_MAJOR>50 || (LIBAVUTIL_VERSION_MAJOR==50 && LIBAVUTIL_VERSION_MINOR>37)
+  #include <libavutil/imgutils.h>
+#else
+  #include <libavcore/imgutils.h>
+#endif
+
+int running=1;
+
+void stoprunning(int x)
+{
+  running=0;
+}
+
+void fullwrite(int fd, void* buf, int len)
+{
+  while(len>0)
+  {
+    int wrote=write(fd, buf, len);
+    if(wrote<1){return;}
+    len-=wrote;
+    buf+=wrote;
+  }
+}
+
+void fixpixels(GdkPixbuf* img)
+{
+// printf("Channels: %i\n", gdk_pixbuf_get_n_channels(img));
+  if(gdk_pixbuf_get_n_channels(img)==4)
+  {
+    unsigned char* pixels=gdk_pixbuf_get_pixels(img);
+    unsigned int i;
+    unsigned int pixelcount=gdk_pixbuf_get_width(img)*gdk_pixbuf_get_height(img);
+    for(i=0; i<pixelcount; ++i)
+    {
+      memmove(&pixels[i*3], &pixels[i*4], 3);
+    }
+  }
+}
+
+int main(int argc, char** argv)
+{
+  signal(SIGUSR1, stoprunning);
+  avcodec_register_all();
+  AVCodec* vencoder=avcodec_find_encoder(AV_CODEC_ID_FLV1);
+  unsigned int delay=500000;
+  // Set up camera
+  AVCodecContext* ctx=avcodec_alloc_context3(vencoder);
+  unsigned int i;
+  GdkPixbuf* img;
+  GdkPixbufAnimation* anim;
+  void** images=0;
+  unsigned int imgcount=0;
+  for(i=1; i<argc; ++i)
+  {
+    anim=gdk_pixbuf_animation_new_from_file(argv[i], 0);
+    if(!anim){continue;}
+    if(gdk_pixbuf_animation_is_static_image(anim)) // Not sure if this is necessary or if it'd just be a single frame anyway
+    {
+      ++imgcount;
+      images=realloc(images, sizeof(void*)*imgcount);
+      img=gdk_pixbuf_animation_get_static_image(anim);
+      fixpixels(img);
+      images[imgcount-1]=gdk_pixbuf_get_pixels(img);
+    }else{
+      GTimeVal atime={0,0};
+      GdkPixbufAnimationIter* iter=gdk_pixbuf_animation_get_iter(anim, &atime);
+      GdkPixbuf* firstimg=gdk_pixbuf_animation_iter_get_pixbuf(iter);
+      ++imgcount;
+      images=realloc(images, sizeof(void*)*imgcount);
+      img=gdk_pixbuf_copy(firstimg);
+      fixpixels(img);
+      images[imgcount-1]=gdk_pixbuf_get_pixels(img);
+      while(1)
+      {
+        if(gdk_pixbuf_animation_iter_advance(iter, &atime))
+        {
+          img=gdk_pixbuf_animation_iter_get_pixbuf(iter);
+          if(img==firstimg){break;}
+          img=gdk_pixbuf_copy(img);
+          fixpixels(img);
+        }
+        ++imgcount;
+        images=realloc(images, sizeof(void*)*imgcount);
+        images[imgcount-1]=gdk_pixbuf_get_pixels(img);
+        g_time_val_add(&atime, 100000);
+      }
+    }
+  }
+  ctx->width=gdk_pixbuf_get_width(img);
+  ctx->height=gdk_pixbuf_get_height(img);
+  ctx->pix_fmt=PIX_FMT_YUV420P;
+  ctx->time_base.num=1;
+  ctx->time_base.den=10;
+  avcodec_open2(ctx, vencoder, 0);
+  AVFrame* frame=av_frame_alloc();
+  frame->format=PIX_FMT_RGB24;
+  frame->width=ctx->width;
+  frame->height=ctx->height;
+  av_image_alloc(frame->data, frame->linesize, ctx->width, ctx->height, frame->format, 1);
+  AVPacket packet;
+  packet.buf=0;
+  packet.data=0;
+  packet.size=0;
+  packet.dts=AV_NOPTS_VALUE;
+  packet.pts=AV_NOPTS_VALUE;
+
+  // Set up frame for conversion from the camera's format to a format the encoder can use
+  AVFrame* dstframe=av_frame_alloc();
+  dstframe->format=ctx->pix_fmt;
+  dstframe->width=ctx->width;
+  dstframe->height=ctx->height;
+  av_image_alloc(dstframe->data, dstframe->linesize, ctx->width, ctx->height, ctx->pix_fmt, 1);
+
+  struct SwsContext* swsctx=sws_getContext(frame->width, frame->height, PIX_FMT_RGB24, frame->width, frame->height, AV_PIX_FMT_YUV420P, 0, 0, 0, 0);
+
+  i=0;
+  while(running)
+  {
+    usleep(delay);
+    if(delay>100000){delay-=50000;}
+    ++i;
+    if(i>=imgcount){i=0;}
+    frame->data[0]=images[i];
+    int gotpacket;
+    sws_scale(swsctx, (const uint8_t*const*)frame->data, frame->linesize, 0, frame->height, dstframe->data, dstframe->linesize);
+    av_init_packet(&packet);
+    packet.data=0;
+    packet.size=0;
+    avcodec_encode_video2(ctx, &packet, dstframe, &gotpacket);
+    unsigned char frameinfo=0x22; // Note: differentiating between keyframes and non-keyframes seems to break stuff, so let's just go with all being interframes (1=keyframe, 2=interframe, 3=disposable interframe)
+    dprintf(1, "/video %i\n", packet.size+1);
+    fullwrite(1, &frameinfo, 1);
+    fullwrite(1, packet.data, packet.size);
+
+    av_free_packet(&packet);
+  }
+  sws_freeContext(swsctx);
+  return 1;
+}
diff --git a/modules/activitystats.sh b/modules/activitystats.sh
new file mode 100755
index 0000000..9b05de2
--- /dev/null
+++ b/modules/activitystats.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+if [ -z "$irclog" ]; then echo 'ERROR: the activitystats module requires $irclog to be defined' >&2; fi
+
+activitystats()
+{
+  days="`grep -c '^--- Day changed ' "$irclog"`"
+  hour="`date +%H`"
+  msgcount="`grep -c "^${hour}:[0-9][0-9] <" "$irclog"`"
+  perminute="`expr "${msgcount}000" / "$days" / 60`"
+  say "This hour of day gets `decimals "$perminute" 3` messages per minute on average"
+}
+
+addcmd '!activitystats' activitystats
diff --git a/modules/camface.sh b/modules/camface.sh
new file mode 100755
index 0000000..4fb12dc
--- /dev/null
+++ b/modules/camface.sh
@@ -0,0 +1,26 @@
+#!/bin/sh
+if [ "x${protocol}" != "xtc_client" ]; then echo 'WARNING: the camface module is unlikely to make sense on other protocols than tinychat/tc_client' >&2; fi
+camface()
+{
+  if toggle_cammode face; then
+    "${EXECDIR}/camutil" \
+"${EXECDIR}/relaybotface/relaybot1.png" \
+"${EXECDIR}/relaybotface/relaybot1.png" \
+"${EXECDIR}/relaybotface/relaybot2.png" \
+"${EXECDIR}/relaybotface/relaybot2.png" \
+"${EXECDIR}/relaybotface/relaybot3.png" \
+"${EXECDIR}/relaybotface/relaybot3.png" \
+"${EXECDIR}/relaybotface/relaybot4.png" \
+"${EXECDIR}/relaybotface/relaybot4.png" \
+"${EXECDIR}/relaybotface/relaybot4.png" \
+"${EXECDIR}/relaybotface/relaybot4.png" \
+"${EXECDIR}/relaybotface/relaybot3.png" \
+"${EXECDIR}/relaybotface/relaybot3.png" \
+"${EXECDIR}/relaybotface/relaybot2.png" \
+"${EXECDIR}/relaybotface/relaybot2.png" \
+"${EXECDIR}/relaybotface/relaybot1.png" \
+"${EXECDIR}/relaybotface/relaybot1.png" &
+  fi
+}
+
+addcmd '!cam' camface
diff --git a/modules/conversation.sh b/modules/conversation.sh
new file mode 100755
index 0000000..658e337
--- /dev/null
+++ b/modules/conversation.sh
@@ -0,0 +1,46 @@
+#!/bin/false
+getspecies()
+{
+  if echo "$species" | grep -q "^${1}="; then
+    echo "$species" | sed -n -e "s/^${1}=//p"
+  else
+    echo 'human'
+  fi
+}
+
+conversation()
+{
+  chatmsg="$1"
+  from="$2"
+
+  if echo "$chatmsg" | grep -i -q '\(thx\|tha*nks*\|thanks* *y*o*u\)[,.]* *\(relay\|bots*\|relaybots*\)[!,.]*$'; then
+    say "you're welcome `getspecies "$from"`"
+
+  elif echo "$chatmsg" | grep -q '^ *good  *\(relay\|\)bot *$'; then
+    say "good `getspecies "$from"`"
+
+  elif echo "$chatmsg" | grep -i -q '^\(hee*yy*a*\|hii*\|hee*ll*oo*\)\(\| there\),*\(\| fellow\) \(homos\|homosexuals\|everyone*\|y*o*u *all\|y*o*u *guys\|everybody\|bots\|relaybot\|all\|yall\|y.all\|lesbos\|ladies\|friends\|girls\|gays\)\([ \.!,].*\|\)[ ~]*$'; then
+    sayonce "Hey ${from}"
+
+  elif echo "$chatmsg" | grep -i -q '^\(good *night\|good *nite\|g.night\|g.nite\|nights*\|night night\|nite nite\),* \(everyone*\|y*o*u *all\|y*o*u *guys\|everybody\|bots\|relaybot\|all\|yall\|y.all\|lesbos\|ladies\)\([ \.!,].*\|\)$'; then
+    sayonce "Good night ${from}"
+
+  elif echo "$chatmsg" | grep -i -q '^\(good *morning*\|good *morn\|g.morning*\|g.morn\|morning\),* \(everyone*\|y*o*u *all\|y*o*u *guys\|everybody\|bots\|relaybot\|all\|yall\|y.all\|lesbos\|ladies\)\([ \.!,].*\|\)$'; then
+    sayonce "Good morning ${from}"
+
+  elif echo "$chatmsg" | grep -i -q '^\(<3\|&lt;3\|I love\|I love you\|I luv\|I luv u\),* \(everyone*\|y*o*u *all\|y*o*u *guys\|everybody\|bots\|relaybot\|yall\|y.all\)\([ \.!,].*\|\)$' || echo "$chatmsg" | grep -i -x -q ' *relaybot[:,]* *\(<3\|I love you\) *'; then
+    sayonce "<3 ${from}"
+
+  elif (echo "$chatmsg" | grep -i -q '\(relay\|\)bots*\b' && echo "$chatmsg" | grep -i -q '^\(.* \|\)how old ') || echo "$chatmsg" | grep -i -q "^\(\|.* \)how old \(is\|are\) \(everyone*\|y*o*u *all\|y*o*u *guys\|y'*all\)"; then
+    sec="`date +%s`"
+    sec="`expr "$sec" - 1429999200`"
+    weeks="`expr "$sec" / 60 / 60 / 24 / 7`"
+    say "${weeks} weeks" # TODO: Count years and months instead?
+
+  else
+    return 1
+  fi
+  return 0
+}
+
+addeventhandler chatmsg conversation
diff --git a/modules/conversion.sh b/modules/conversion.sh
new file mode 100755
index 0000000..75847f3
--- /dev/null
+++ b/modules/conversion.sh
@@ -0,0 +1,97 @@
+#!/bin/sh
+conversion_get_feet="s/'.*//;s/.*\"//"
+conversion_get_inch="s/\".*//;s/.*'//"
+imperial_to_metric()
+{
+  input="$2"
+  if ! echo "$input" | grep -q "['\"]"; then input="${input}\""; fi
+  feet="`echo "$input" | sed -e "$conversion_get_feet"`"
+  inches="`echo "$input" | sed -e "$conversion_get_inch"`"
+  if [ "x${feet}" = "x" ]; then feet=0; fi
+  if [ "x${inches}" = "x" ]; then inches=0; fi
+  decimals="`countdecimals "$inches"`"
+  inches="`echo "$inches" | sed -e 's/\.//'`"
+  feet="`tenpow "$feet" "$decimals"`"
+  inches="`expr "$feet" '*' 12 + "$inches"`"
+  decimals="`expr "$decimals" + 2`"
+  centimeters="`expr "$inches" '*' 254`"
+  centimeters="`decimals "$centimeters" "$decimals"`"
+  if [ "x${centimeters}" = "x" ]; then return; fi
+  say "${centimeters} cm"
+}
+
+metric_to_imperial()
+{
+  input="`echo "$2" | tr -d '[:alpha:]'`"
+  decimals="`countdecimals "$input"`"
+  input="`echo "$input" | sed -e 's/\.//'`"
+  if echo "$2" | grep -i -q '[0-9]km$'; then
+    input="${input}00000"
+  elif echo "$2" | grep -i -q '[0-9]m$'; then
+    input="${input}00"
+  fi
+  inch="`tenpow 254 "$decimals"`"
+  inches="`expr "$input" '*' 100000 / "$inch"`"
+  if [ "x${inches}" = "x" ]; then return; fi
+  feet="`expr "$inches" '/' 12000`"
+  inches="`expr "$inches" '%' 12000`"
+  inches="`decimals "$inches" 3`"
+  say "${feet}'${inches}\""
+}
+
+celsius_to_fahrenheit()
+{
+  c="`echo "$2" | sed -e 's|[/ .]||g'`"
+  decimals="`echo "$2" | sed -n -e 's/^.*\.//p' | tr -d '\r\n' | wc -c`"
+  dec='.'
+  for x in `seq "$decimals"`; do dec="${dec}."; done
+  zero="`echo "$dec" | tr . 0`"
+  expr "$c" '*' 90 / 5 + "32${zero}" | sed -e "s/${dec}$/\.&°F/" | sayx
+}
+
+fahrenheit_to_celsius()
+{
+  f="`echo "$2" | sed -e 's|[/ .]||g'`"
+  decimals="`echo "$2" | sed -n -e 's/^.*\.//p' | tr -d '\r\n' | wc -c`"
+  dec=''
+  for x in `seq "$decimals"`; do dec="${dec}."; done
+  zero="`echo "$dec" | tr . 0`"
+  expr '(' "$f" - "32${zero}" ')' '*' 50 / 9 | sed -e "s/${dec}.$/\\.&°C/" | sayx
+}
+
+gallons_to_liters()
+{
+  g="`echo "$2" | sed -e 's|[/ .]||g'`"
+  expr "$g" '*' 379 | sed -e 's/..$/\.& liters/' | sayx
+}
+
+lbs_to_kg()
+{
+  decimals="`countdecimals "$2"`"
+  lbs="`echo "$2" | sed -e 's|[/ .]||g'`"
+  decimals="`expr "$decimals" + 5`"
+  kg="`expr "$lbs" '*' 45359`"
+  kg="`decimals "$kg" "$decimals"`"
+  say "${kg} kg"
+}
+
+kg_to_lbs()
+{
+  decimals="`countdecimals "$2"`"
+  kg="`echo "$2" | sed -e 's|[/ .]||g'`"
+  decimals="`expr "$decimals" + 5`"
+  lbs="`expr "$kg" '*' 220462`"
+  lbs="`decimals "$lbs" "$decimals"`"
+  say "${lbs} lbs"
+}
+
+addcmd '!imperial' metric_to_imperial
+addcmd '!metric' imperial_to_metric
+addcmd '!f' celsius_to_fahrenheit
+addcmd '!F' celsius_to_fahrenheit
+addcmd '!c' fahrenheit_to_celsius
+addcmd '!C' fahrenheit_to_celsius
+addcmd '!l' gallons_to_liters
+addcmd '!L' gallons_to_liters
+addcmd '!kg' lbs_to_kg
+addcmd '!lbs' kg_to_lbs
diff --git a/modules/countdown.sh b/modules/countdown.sh
new file mode 100755
index 0000000..ffe5c73
--- /dev/null
+++ b/modules/countdown.sh
@@ -0,0 +1,63 @@
+#!/bin/sh
+countdown_cmd()
+{
+  now="`date +%s`"
+  target="`date -d "$countdownto" +%s`"
+  diff="`expr "$target" - "$now"`"
+  if [ "$diff" -lt 0 ]; then say "The target time has been reached"; return; fi
+  days="`expr "$diff" / 86400`"
+  diff="`expr "$diff" '%' 86400`"
+  hours="`expr "$diff" / 3600`"
+  diff="`expr "$diff" '%' 3600`"
+  minutes="`expr "$diff" / 60`"
+  output=''
+  if [ "$days" != "0" ]; then
+    output="${days} day"
+    if [ "$days" != "1" ]; then output="${output}s"; fi
+  fi
+  if [ "$hours" != "0" ]; then
+    if [ -n "$output" ]; then output="${output}, "; fi
+    output="${output}${hours} hour"
+    if [ "$hours" != "1" ]; then output="${output}s"; fi
+  fi
+  if [ "$minutes" != "0" ]; then
+    if [ -n "$output" ]; then output="${output}, "; fi
+    output="${output}${minutes} minute"
+    if [ "$minutes" != "1" ]; then output="${output}s"; fi
+  fi
+  say "$output"
+}
+
+# Just a copy of the above, except for the / in the output
+countdown_cmd_secret()
+{
+  now="`date +%s`"
+  target="`date -d "$countdownto" +%s`"
+  diff="`expr "$target" - "$now"`"
+  if [ "$diff" -lt 0 ]; then echo "/The target time has been reached"; return; fi
+  days="`expr "$diff" / 86400`"
+  diff="`expr "$diff" '%' 86400`"
+  hours="`expr "$diff" / 3600`"
+  diff="`expr "$diff" '%' 3600`"
+  minutes="`expr "$diff" / 60`"
+  output=''
+  if [ "$days" != "0" ]; then
+    output="${days} day"
+    if [ "$days" != "1" ]; then output="${output}s"; fi
+  fi
+  if [ "$hours" != "0" ]; then
+    if [ -n "$output" ]; then output="${output}, "; fi
+    output="${output}${hours} hour"
+    if [ "$hours" != "1" ]; then output="${output}s"; fi
+  fi
+  if [ "$minutes" != "0" ]; then
+    if [ -n "$output" ]; then output="${output}, "; fi
+    output="${output}${minutes} minute"
+    if [ "$minutes" != "1" ]; then output="${output}s"; fi
+  fi
+  echo "/ $output"
+}
+
+addcmd '!countdown' countdown_cmd
+addcmd '/!countdown' countdown_cmd_secret
+if [ -z "$countdownto" ]; then echo 'WARNING: the countdown module requires $countdownto to be set to a date' >&2; fi
diff --git a/modules/gitreport.sh b/modules/gitreport.sh
new file mode 100644
index 0000000..41baaca
--- /dev/null
+++ b/modules/gitreport.sh
@@ -0,0 +1,27 @@
+#!/bin/sh
+gitreport="`date +%s | xargs expr 30 +`"
+mkdir -p "${DATADIR}/gitreport"
+gitreport_check()
+{
+  if [ "`date +%s`" -gt "$gitreport" ]; then
+    gitreport="`date +%s | xargs expr 300 +`"
+    for repo in ${gitreport_repos}; do
+      fsrepo="`echo "$repo" | sed -e 's/[^-a-zA-Z0-9]/_/g'`"
+      commit="`curl -s "${repo}/refs/heads/master"`"
+      oldcommit="`cat "${DATADIR}/gitreport/${fsrepo}" 2> /dev/null`"
+      if [ -z "$oldcommit" ]; then echo "$commit" > "${DATADIR}/gitreport/${fsrepo}"; return; fi
+      if [ -z "$commit" ]; then continue; fi
+      if [ "x${commit}" = "x${oldcommit}" ]; then continue; fi
+      echo "$commit" > "${DATADIR}/gitreport/${fsrepo}"
+      object="`echo "$commit" | sed -e 's|^..|&/|'`"
+      msg="`(printf '\037\213\010\000\026\022\360\127'; curl -s "${repo}/objects/${object}") | gzip -d 2> /dev/null | sed -e '1,/^$/d'`"
+      if [ -z "$msg" ]; then continue; fi
+      say "New commit in `basename "$repo"`: ${msg}"
+    done
+  fi
+}
+addeventhandler chatmsg gitreport_check
+addeventhandler join gitreport_check
+addeventhandler part gitreport_check
+addeventhandler ping gitreport_check
+if [ -z "$gitreport_repos" ]; then echo 'WARNING: the gitreport module requires $gitreport_repos to be set to a list of git repository URLs' >&2; fi
diff --git a/modules/imgcam.sh b/modules/imgcam.sh
new file mode 100755
index 0000000..7a8d0da
--- /dev/null
+++ b/modules/imgcam.sh
@@ -0,0 +1,22 @@
+#!/bin/sh
+if [ "x${protocol}" != "xtc_client" ]; then echo 'WARNING: the imgcam module is unlikely to make sense on other protocols than tinychat/tc_client' >&2; fi
+imgcam()
+{
+  if toggle_cammode img; then
+    img="$2"
+    if ! wget -O img.img "$img"; then
+      cammode=''
+      echo '/camdown'
+      continue
+    fi
+    if echo "$img" | grep -q '\.gif$'; then
+      convert img.img -coalesce -resize 320x -alpha remove -alpha off img.gif
+      "${EXECDIR}/camutil" img.gif &
+    else
+      convert img.img -resize 320x -alpha remove -alpha off img.png
+      "${EXECDIR}/camutil" img.png &
+    fi
+  fi
+}
+
+addcmd '!img' imgcam
diff --git a/modules/magnifier.sh b/modules/magnifier.sh
new file mode 100755
index 0000000..07931c4
--- /dev/null
+++ b/modules/magnifier.sh
@@ -0,0 +1,22 @@
+#!/bin/sh
+if [ "x${protocol}" != "xtc_client" ]; then echo 'WARNING: the magnifier module is unlikely to make sense on other protocols than tinychat/tc_client' >&2; fi
+magnifier_toggle()
+{
+  deleventhandler chatmsg magnifier
+  if toggle_cammode magnifier; then
+    addeventhandler chatmsg magnifier
+    magnifier
+  fi
+}
+
+magnifier()
+{
+  chatmsg="$1"
+  from="$2"
+  if [ "x${cammode}" != 'xmagnifier' ]; then return; fi
+  convert -font /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf -size 320x240 -background '#ffffff' -fill "#000000" "caption:${from}: ${chatmsg}" /tmp/magnifier.png
+  pkill -x camutil
+  "${EXECDIR}/camutil" /tmp/magnifier.png &
+}
+
+addcmd '!magnifier' magnifier_toggle
diff --git a/modules/neverhaveiever.sh b/modules/neverhaveiever.sh
new file mode 100755
index 0000000..7d7a658
--- /dev/null
+++ b/modules/neverhaveiever.sh
@@ -0,0 +1,102 @@
+#!/bin/sh
+if [ "x${protocol}" != "xtc_client" ]; then echo 'WARNING: the neverhaveiever module is unlikely to make sense on other protocols than tinychat/tc_client' >&2; fi
+neverhaveiever_join()
+{
+  if [ "x${cammode}" != "xneverhaveiever" ]; then return; fi
+  from="$1"
+  if [ -n "$2" ]; then return; fi
+  if echo "$neverhaveiever" | grep -q "^${from}: "; then
+    say "${from}: you're already in the game"
+  elif [ "x${neverhaveiever}" != "x" ] && echo "$neverhaveiever" | grep -v -q ": ${neverhaveieverstartnum}\(\| (next)\)$"; then
+    say "${from}: the game has already started, join next round instead"
+  else
+    if [ "x${neverhaveiever}" = "x" ]; then
+      neverhaveiever="${from}: ${neverhaveieverstartnum} (next)"
+    else
+      neverhaveiever="${neverhaveiever}
+${from}: ${neverhaveieverstartnum}"
+    fi
+    convert -font /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf -size 320x240 -background '#ffffff' -fill "#000000" "label:${neverhaveiever}" /tmp/neverhaveiever.png
+    pkill -x camutil
+    "${EXECDIR}/camutil" /tmp/neverhaveiever.png &
+  fi
+}
+
+neverhaveiever_quit()
+{
+  if [ "x${cammode}" != "xneverhaveiever" ]; then return; fi
+  from="$1"
+  neverhaveiever="`echo "$neverhaveiever" | sed -e "/^${from}: /d"`"
+  if [ "x${neverhaveiever}" = "x" ]; then # No users left = stop
+    toggle_cammode neverhaveiever
+  elif [ "`echo "$neverhaveiever" | wc -l`" = "1" ]; then # Win by default
+    say "Winner: `echo "$neverhaveiever" | sed -e 's/: .*//'`"
+    toggle_cammode neverhaveiever
+  else
+    convert -font /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf -size 320x240 -background '#ffffff' -fill "#000000" "label:${neverhaveiever}" /tmp/neverhaveiever.png
+    pkill -x camutil
+    "${EXECDIR}/camutil" /tmp/neverhaveiever.png &
+  fi
+}
+
+neverhaveiever_losepoint()
+{
+  if [ "x${cammode}" != "xneverhaveiever" ]; then return; fi
+  from="$1"
+  if [ -n "$2" ]; then return; fi
+  points="`echo "$neverhaveiever" | sed -n -e "s/ (next)//; s/^${from}: //p"`"
+  if [ "x${points}" = "x" ]; then
+    say "${from}: you don't seem to have joined the game"
+  else
+    points="`expr "$points" - 1`"
+    neverhaveiever="`echo "$neverhaveiever" | sed -e "s/^${from}: [0-9]*/${from}: ${points}/"`"
+    convert -font /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf -size 320x240 -background '#ffffff' -fill "#000000" "label:${neverhaveiever}" /tmp/neverhaveiever.png
+    pkill -x camutil
+    "${EXECDIR}/camutil" /tmp/neverhaveiever.png &
+    neverhaveiever="`echo "$neverhaveiever" | sed -e "/^.*: 0/d"`"
+    if [ "`echo "$neverhaveiever" | wc -l`" = "1" ]; then
+      say "Winner: `echo "$neverhaveiever" | sed -e 's/: .*//'`"
+      toggle_cammode neverhaveiever
+    fi
+  fi
+}
+
+neverhaveiever_addnext='n;s/$/ (next)/'
+neverhaveiever_next()
+{
+  if [ "x${cammode}" != "xneverhaveiever" ]; then return; fi
+  from="$1"
+  if ! echo "$2" | grep -qi '^have I ever'; then return; fi
+  neverhaveiever="`echo "$neverhaveiever" | sed -e "s/ (next)//; /^${from}: /{${neverhaveiever_addnext};}"`"
+  convert -font /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf -size 320x240 -background '#ffffff' -fill "#000000" "label:${neverhaveiever}" /tmp/neverhaveiever.png
+  pkill -x camutil
+  "${EXECDIR}/camutil" /tmp/neverhaveiever.png &
+}
+
+neverhaveiever_toggle()
+{
+  delcmd '-1' neverhaveiever_losepoint
+  delcmd '!join' neverhaveiever_join
+  delcmd '!quit' neverhaveiever_quit
+  delcmd 'never' neverhaveiever_next
+  delcmd 'Never' neverhaveiever_next
+  delcmd 'NEVER' neverhaveiever_next
+  if toggle_cammode neverhaveiever; then
+    neverhaveieverstartnum="`echo "$2" | sed -e 's/ //g'`"
+    if [ -z "$neverhaveieverstartnum" ]; then neverhaveieverstartnum=5; fi
+    convert -font /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf -size 320x240 -background '#ffffff' -fill "#000000" "caption:Type !join to join never-have-I-ever, type '-1' when losing a point, !quit to quit" /tmp/neverhaveiever.png
+    "${EXECDIR}/camutil" /tmp/neverhaveiever.png &
+    neverhaveiever=''
+    addcmd '-1' neverhaveiever_losepoint
+    addcmd '!join' neverhaveiever_join
+    addcmd '!quit' neverhaveiever_quit
+    addcmd 'never' neverhaveiever_next
+    addcmd 'Never' neverhaveiever_next
+    addcmd 'NEVER' neverhaveiever_next
+  fi
+}
+
+addcmd '!neverhaveiever' neverhaveiever_toggle
+addcmd '!neverhaveIever' neverhaveiever_toggle
+addcmd '!NeverHaveIEver' neverhaveiever_toggle
+addcmd '!NEVERHAVEIEVER' neverhaveiever_toggle
diff --git a/modules/relay.sh b/modules/relay.sh
new file mode 100755
index 0000000..2a0f8c8
--- /dev/null
+++ b/modules/relay.sh
@@ -0,0 +1,80 @@
+#!/bin/sh
+active=''
+mkdir -p "${DATADIR}/relaymsgs"
+relay_msg()
+{
+  from="$2"
+  # New relay format, potentially supporting nick aliasing
+  first=true
+  for msg in `grep -rli "^ to: ${from}\$" "${DATADIR}/relaymsgs" | sort -n`; do
+    if "$first"; then
+      sed -n -e "s/^\\([^ :]*\\):/${from}: Message from \1:/p" "$msg" | sayx
+      first=false
+    else
+      sed -n -e "s/^\\([^ :]*\\):/From \1:/p" "$msg" | sayx
+    fi
+    rm -f "$msg"
+    sleep 0.8
+  done
+  active="`echo "$active" | grep -v "^${from}: "`
+${from}: `date +%s | xargs expr 300 +`"
+}
+
+relay_relay()
+{
+  from="$1"
+  tolistplus="`echo "$2" | sed -e 's/ .*//;s|+| |g'`" # Get recipient(s) and separate by +
+  actualtolist=''
+  for tolist in ${tolistplus}; do
+    tolist="`echo "$tolist" | sed -e 's|/| |g'`"
+    for to in ${tolist}; do
+      mintime="`echo "$active" | grep "^${to}: " | sed -e 's/.*: //'`"
+      echo "mintime: ${mintime}, now: `date +%s`" >&2
+      if [ "$mintime" -gt "`date +%s`" ] 2> /dev/null; then
+        say "${to} looks active to me, won't relay" priv "$from"
+        continue 2
+      fi
+      if [ "`grep -l "^ to: ${to}\$" "${DATADIR}/relaymsgs"/* | wc -l`" -gt 5 ]; then
+        say "${from}: ${to}'s inbox is full (restricting to 6 messages to prevent getting autobanned)"
+        continue 2
+      fi
+    done
+    if [ "x${actualtolist}" = "x" ]; then
+      actualtolist="`echo "$tolist" | tr ' ' '/'`"
+    else
+      actualtolist="${actualtolist}+`echo "$tolist" | tr ' ' '/'`"
+    fi
+    msg="`echo "$2" | sed -e "s/^[^ ]* //"`"
+    echo "Relaying message from '${from}' to '${tolist}': '${msg}'" >&2
+    id="`date +%s`"
+    while [ -e "${DATADIR}/relaymsgs/${id}" ]; do id="`expr "$id" + 1`"; done
+    for to in ${tolist}; do
+      echo " to: ${to}" >> "${DATADIR}/relaymsgs/${id}"
+    done
+    echo "${from}: ${msg}" >> "${DATADIR}/relaymsgs/${id}"
+  done
+  if [ "x${actualtolist}" != "x" ]; then
+    say "Will relay '${msg}' to ${actualtolist}" priv "$from"
+  fi
+}
+
+relay_relayed()
+{
+  from="$1"
+  to="$2"
+  if grep -l "^ to: ${to}\$" "${DATADIR}/relaymsgs"/* | xargs --no-run-if-empty grep "^${from}: " | grep -q .; then
+    say "There are messages from ${from} to ${to} which have not yet been delivered"
+  else
+    say "All messages from ${from} to ${to} have been delivered :)"
+  fi
+}
+
+relay_part()
+{
+  active="`echo "$active" | grep -v "^${1}: "`"
+}
+
+addeventhandler chatmsg relay_msg
+addeventhandler part relay_part
+addcmd '!relay' relay_relay
+addcmd '!relayed' relay_relayed
diff --git a/modules/reminder.sh b/modules/reminder.sh
new file mode 100755
index 0000000..e1446bf
--- /dev/null
+++ b/modules/reminder.sh
@@ -0,0 +1,81 @@
+#!/bin/false
+wordtonum='s/\ba\b/1/g;
+s/\ban\b/1/ig;
+s/\bone\b/1/ig;
+s/\btwo\b/2/ig;
+s/\bthree\b/3/ig;
+s/\bfour\b/4/ig;
+s/\bfive\b/5/ig;
+s/\bsix\b/6/ig;
+s/\bseven\b/7/ig;
+s/\beight\b/8/ig;
+s/\bnine\b/9/ig;
+s/\bten\b/10/ig;
+s/\beleven\b/11/ig;
+s/\bfifteen\b/15/ig;
+s/\btwenty\b/20/ig;
+s/\bthirty\b/30/ig;
+s/\bfourty\b/40/ig;
+s/\bfourtyfive\b/45/ig;
+s/\bfifty\b/50/ig;
+s/\band\b/+/ig;
+s/,/+/g;
+s/\btomorrow\b/+1day/ig;
+s/\bnext day\b/+1day/ig;
+s/\bnext week\b/+7days/ig;
+s/\bnext month\b/+1month/ig;
+'
+reversepronouns='s/\bmy\b/yourXX<tmp>XX/ig; s/\bme\b/youXX<tmp>XX/ig; s/\byour\b/my/ig; s/\byou\b/me/ig; s/XX<tmp>XX//g'
+
+reminder()
+{
+  chatmsg="$1"
+  from="$2"
+  if echo "$chatmsg" | grep -qi '^\(relaybot: *\|relaybot, *\|\)remind me to .* \(in\|at\|on\) '; then
+    inat="`echo "$chatmsg" | sed -e 's/^\(relaybot: *\|relaybot, *\|\)remind me to .* \(in\|at\|on\) .*/\2/i'`"
+    desttime="`echo "$chatmsg" | sed -e "s/^\(relaybot: *\|relaybot, *\|\)remind me to .* \(in\|at\|on\) //i; ${wordtonum}"`"
+    remindmsg="`echo "$chatmsg" | sed -e 's/^\(relaybot: *\|relaybot, *\|\)remind me to \(.*\) \(in\|at\|on\) .*/\2/i'`"
+  elif echo "$chatmsg" | grep -qi '^\(relaybot: *\|relaybot, *\|\)remind me \(in\|at\|on\) .* to .'; then
+    inat="`echo "$chatmsg" | sed -e 's/^\(relaybot: *\|relaybot, *\|\)remind me \(in\|at\|on\) .* to ./\2/i'`"
+    desttime="`echo "$chatmsg" | sed -e "s/^\(relaybot: *\|relaybot, *\|\)remind me \(in\|at\|on\) .* to .//i; ${wordtonum}"`"
+    remindmsg="`echo "$chatmsg" | sed -e 's/^\(relaybot: *\|relaybot, *\|\)remind me \(in\|at\|on\) .* to \(.*\)/\3/i'`"
+  else
+    return
+  fi
+  remindmsg="`echo "$remindmsg" | sed -e "$reversepronouns"`"
+  if [ "$inat" = "in" ]; then desttime="+${desttime}"; fi # TODO: what about "remind me in <month/year>?"
+  if date -d "$desttime" > /dev/null 2> /dev/null; then
+    desttime="`date -d "$desttime" +%s`"
+    diff="`date +%s`"
+    diff="`expr "$desttime" - "$diff"`"
+    if [ "$diff" -lt 1 ]; then return 1; fi
+    if [ "$diff" -gt 30 ]; then
+      say "ok ${from}, I will remind you"
+    fi
+    if [ "$diff" -lt 86400 ]; then # Less than 24 hours, volatile but precise reminder
+      (
+        sleep "$diff"
+        say "${from}: remember to ${remindmsg}"
+      ) &
+    else # 24 hours or more, less precise but persists across reloads/reconnects
+      mkdir -p "${DATADIR}/reminders"
+      id="`date +%s`"
+      while [ -e "${DATADIR}/reminders/${id}" ]; do id="`expr "$id" + 1`"; done
+      echo "Time: ${desttime}" > "${DATADIR}/reminders/${id}"
+      echo "For: ${from}" >> "${DATADIR}/reminders/${id}"
+      echo "Message: ${remindmsg}" >> "${DATADIR}/reminders/${id}"
+    fi
+    return 0
+  fi
+  for x in `grep -rl "^For: ${from}$" "${DATADIR}/reminders" 2> /dev/null`; do
+    desttime="`sed -n -e 's/^Time: //p' "$remindmsg"`"
+    if [ "$desttime" -gt "`date +%s`" ]; then continue; fi
+    msg="`sed -n -e 's/^Message: //p' "$x"`"
+    say "${from}: remember to ${msg}"
+    rm -f "$x"
+    break # Only one reminder per message
+  done
+  return 1
+}
+
+addeventhandler chatmsg reminder
diff --git a/modules/tc_versioncheck.sh b/modules/tc_versioncheck.sh
new file mode 100755
index 0000000..ef34b8b
--- /dev/null
+++ b/modules/tc_versioncheck.sh
@@ -0,0 +1,16 @@
+#!/bin/false
+versioncheck="`date +%s | xargs expr 3600 +`"
+versioncheck_check()
+{
+  if [ "`date +%s`" -gt "$versioncheck" ]; then
+    version="`curl -s 'https://tinychat.com/embed/chat.js' | sed -n -e '/swfName =/{s/.*swfName = "Tinychat-//;s/\.swf".*//;p;q;}'`"
+    if [ "$version" != "`cat "${DATADIR}/flashclient.version"`" ]; then
+      if [ -e "${DATADIR}/flashclient.version" ]; then # Don't announce on the first check
+        say "Flash client version changed to ${version}"
+      fi
+      echo "$version" > "${DATADIR}/flashclient.version"
+    fi
+    versioncheck="`date +%s | xargs expr 3600 +`"
+  fi
+}
+addeventhandler chatmsg versioncheck_check
diff --git a/modules/time.sh b/modules/time.sh
new file mode 100755
index 0000000..3474b17
--- /dev/null
+++ b/modules/time.sh
@@ -0,0 +1,25 @@
+#!/bin/sh
+gettimeat()
+{
+  timezone="`echo "$2" | sed -e 's/ /_/g'`"
+  timezone="`find /usr/share/zoneinfo -follow -type f -iname "$timezone" | head -n 1`"
+  if [ "x${timezone}" = "x" ]; then
+    say "Unknown timezone"
+  else
+    TZ="$timezone" date '+%H:%M %Z' | sayx
+  fi
+}
+
+gettimeatsecret()
+{
+  timezone="`echo "$2" | sed -e 's/ /_/g'`"
+  timezone="`find /usr/share/zoneinfo -follow -type f -iname "$timezone" | head -n 1`"
+  if [ "x${timezone}" = "x" ]; then
+    echo "/Unknown timezone"
+  else
+    TZ="$timezone" date '+%H:%M %Z' | sed -e 's|^|/|'
+  fi
+}
+
+addcmd '!time' gettimeat
+addcmd '/!time' gettimeatsecret
diff --git a/modules/topic.sh b/modules/topic.sh
new file mode 100755
index 0000000..1e548db
--- /dev/null
+++ b/modules/topic.sh
@@ -0,0 +1,32 @@
+#!/bin/sh
+topic='{UNSET}'
+
+topic_changed()
+{
+  if [ "$topic" = "{UNSET}" ]; then
+    echo "Topic (join): ${1}" >&2
+  else
+    echo "Topic (new): ${1}" >&2
+    say "Topic changed: ${1}"
+  fi
+  topic="$1"
+}
+
+topic_save()
+{
+  echo "$topic" > relaybot.reload.topic
+}
+topic_load()
+{
+  topic="`cat relaybot.reload.topic`"
+}
+
+topic_get()
+{
+  say "Topic: ${topic}"
+}
+
+addeventhandler topic topic_changed
+addeventhandler reload_before topic_save
+addeventhandler reload_after topic_load
+addcmd '!topic' topic_get
diff --git a/modules/videoutils.sh b/modules/videoutils.sh
new file mode 100755
index 0000000..67d5f42
--- /dev/null
+++ b/modules/videoutils.sh
@@ -0,0 +1,26 @@
+#!/bin/sh
+if [ "x${protocol}" != "xtc_client" ]; then echo 'WARNING: the videoutils module is unlikely to make sense on other protocols than tinychat/tc_client' >&2; fi
+videoutils_mbs()
+{
+  if ! echo "$2" | grep -q '^youTube '; then return; fi
+  vid="`echo "$2" | sed -e 's|^youTube ||; s/ .*//'`"
+  bans="`curl --connect-timeout 10 -s "http://polsy.org.uk/stuff/ytrestrict.cgi?ytid=${vid}" | sed -n -e 's/.*<td>.*<td>.. - //p'`"
+  if [ "`echo "$bans" | wc -l`" -gt 5 ]; then
+    bans="`echo "$bans" | wc -l` countries, http://polsy.org.uk/stuff/ytrestrict.cgi?ytid=${vid}"
+  else
+    bans="`echo "$bans" | sed -e 's/$/, /' | tr -d '\n' | sed -e 's/, $//; s/, \([^,]*\)$/ and \1/'`"
+  fi
+# echo "Bans for ${vid}: ${bans}" >&2
+  if [ "x${bans}" != "x" -a "x${bans}" != "xGermany" ]; then # Germany bans everything, so ignore it if it's just them
+    say "Note: this video is blocked in ${bans}"
+  fi
+  # If a name wasn't given, find and announce it
+  if echo "$2" | grep -q '^youTube [^ ]* 0$'; then
+    title="`youtube-dl --get-title -- "$vid"`"
+    if [ -n "$title" ]; then
+      echo "/video: ${title}"
+    fi
+  fi
+}
+
+addcmd '/mbs' videoutils_mbs
diff --git a/modules/whoismycrush.sh b/modules/whoismycrush.sh
new file mode 100755
index 0000000..cd4530f
--- /dev/null
+++ b/modules/whoismycrush.sh
@@ -0,0 +1,25 @@
+#!/bin/false
+guesscrush()
+{
+  nick="$1"
+  lines=50000
+  tail -n "$lines" "${DATADIR}/relaybot.chat" | sed -e 's/:.*//' | grep -B 1 -F -x -- "$nick" | grep -v -x -F -- "$nick" | grep -v -- -- | sort | uniq -c | sort -n | tail -n 1 | sed -e 's/.* //' | grep .
+}
+
+whoismycrush()
+{
+  from="$1"
+  x="`echo "$from" | sed -e 's/[0-9]*$//'`"
+  crush="`echo "$crushes" | sed -n -e "s/^${x}=//p"`"
+  if [ -z "${crush}" ]; then
+    crush="`guesscrush "$from" || echo "I don't know, sorry"`"
+  else
+    sleep 1 # Some artificial delay
+  fi
+  if echo "$saidlast" | grep -q '^[^ ]*: your crush is probably\.\.\. '; then
+    say "${from}: ${crush}"
+  else
+    say "${from}: your crush is probably... ${crush}"
+  fi
+}
+addcmd '!whoismycrush' whoismycrush
diff --git a/protocols/irc.sh b/protocols/irc.sh
new file mode 100644
index 0000000..a70960d
--- /dev/null
+++ b/protocols/irc.sh
@@ -0,0 +1,39 @@
+#!/bin/sh
+if [ -z "$port" ]; then port=6667; fi
+protocol_cmd="`which nc` '${host}' '${port}'"
+
+handleline()
+{
+  line="`echo "$line" | tr -d '\r\n'`"
+echo "Got: ${line}" >&2
+  name="`echo "$line" | sed -e 's/^://;s/!.*//'`"
+  if echo "$line" | grep -q '^:[^ ]* 332 '; then
+    handleevent topic "`echo "$line" | sed -e 's/^:[^:]*://'`"
+  elif echo "$line" | grep -q '^:[^ ]* PRIVMSG '; then
+    color="`echo "$line" | sed -n -e 's/.*\([0-9][0-9]\?\).*/\1/p'`"
+    msg="`echo "$line" | sed -e 's/^:[^:]*://; s/[0-9][0-9]\?//g'`"
+    handleevent chatmsg "$msg" "$name" "$color"
+  elif echo "$line" | grep -q '^:[^ ]* JOIN '; then
+    handleevent join "$name"
+  elif echo "$line" | grep -q '^:[^ ]* \(QUIT\|PART\) '; then
+    handleevent part "$name"
+  elif echo "$line" | grep -q '^:[^ ]* 001 '; then
+    echo "JOIN ${channel}"
+  elif echo "$line" | grep -q '^PING '; then
+    echo "$line" | sed -e 's/PING/PONG/'
+    handleevent ping
+  fi
+}
+
+say()
+{
+  saidlast="$1"
+  echo "PRIVMSG ${channel} :${1}"
+  echo "relaybot: $1" >> "${DATADIR}/relaybot.chat"
+}
+
+protocol_init()
+{
+  echo 'USER relaybot relaybot relaybot relaybot'
+  echo "NICK ${nickname}"
+}
diff --git a/protocols/stdio.sh b/protocols/stdio.sh
new file mode 100644
index 0000000..518672f
--- /dev/null
+++ b/protocols/stdio.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+protocol_cmd="run_with_PIPE_as_argv1"
+if [ -n "$cookiejar" ]; then protocol_cmd="${protocol_cmd} --cookies '${cookiejar}'"; fi
+if [ -n "$textcolor" ]; then protocol_cmd="${protocol_cmd} -c '${textcolor}'"; fi
+
+handleline()
+{
+  handleevent chatmsg "$1" "user"
+}
+
+say()
+{
+  saidlast="$1"
+  echo "$1"
+  echo "relaybot: $1" >> "${DATADIR}/relaybot.chat"
+}
+
+protocol_init()
+{
+  true # Nothing to initialize
+}
diff --git a/protocols/tc_client.sh b/protocols/tc_client.sh
new file mode 100755
index 0000000..22acca8
--- /dev/null
+++ b/protocols/tc_client.sh
@@ -0,0 +1,59 @@
+#!/bin/sh
+protocol_cmd="`which tc_client` --hexcolors ${channel} ${nickname} ${password}"
+if [ -n "$cookiejar" ]; then protocol_cmd="${protocol_cmd} --cookies '${cookiejar}'"; fi
+if [ -n "$textcolor" ]; then protocol_cmd="${protocol_cmd} -c '${textcolor}'"; fi
+
+handleline()
+{
+  line="$1"
+
+  if echo "$line" | grep -q '^\[[0-2][0-9]:[0-6][0-9]\] ([^)]*)[^:]*: '; then
+    from="`echo "$line" | sed -e "s/^[^ ]* ([^)]*)\([^:]*\):.*/\1/"`"
+    chatmsg="`echo "$line" | sed -e 's/^\[[0-2][0-9]:[0-6][0-9]\] ([^)]*)[^:]*: //'`"
+    color="`echo "$line" | sed -e "s/^[^ ]* (//; s/[,)].*//"`"
+    handleevent chatmsg "$chatmsg" "$from" "$color"
+  elif echo "$line" | grep -q '^Room topic: '; then
+    handleevent topic "`echo "$line" | sed -e 's/^Room topic: //'`"
+  elif echo "$line" | grep -q '^[^ ]* cammed up$'; then
+    handleevent camup "`echo "$line" | sed -e 's/ .*//'`"
+  elif echo "$line" | grep -q '^guest-[0-9]* is logged in as '; then
+    name="`echo "$line" | sed -e 's/ .*//'`"
+    acc="`echo "$line" | sed -e 's|^guest-[0-9]* is logged in as ||'`"
+    handleevent account "$name" "$acc"
+  elif echo "$line" | grep -q '^\[[0-2][0-9]:[0-6][0-9]\] [^ ]* entered the channel$'; then
+    name="`echo "$line" | sed -e 's/^\[[0-2][0-9]:[0-6][0-9]\] //;s/ .*//'`"
+    handleevent join "$name"
+    echo "/whois ${name}" # Used by multiple modules
+  elif echo "$line" | grep -q '^\[[0-2][0-9]:[0-6][0-9]\] [^:]* changed nickname to '; then
+    from="`echo "$line" | sed -e 's/^\[[0-2][0-9]:[0-6][0-9]\] //; s/ changed nickname to .*//'`"
+    nick="`echo "$line" | sed -e 's/^\[[0-2][0-9]:[0-6][0-9]\] [^ ]* changed nickname to //'`"
+    handleevent nick "$from" "$nick"
+  elif echo "$line" | grep -q '^\[[0-2][0-9]:[0-6][0-9]\] [^:]* left the channel'; then
+    nick="`echo "$line" | sed -e 's/^\[[0-2][0-9]:[0-6][0-9]\] //; s/ .*//'`"
+    handleevent part "$nick"
+  elif echo "$line" | grep -q '^[^ ]* is a moderator.'; then # TODO: expand into proper mod tracking?
+    handleevent mod "`echo "$line" | sed -e 's/ .*//'`"
+  elif [ "x${line}" = "xProbably cut off:" ]; then
+    handleevent probablycutoff
+  elif echo "$line" | grep '^Captcha:' >&2; then
+    read x < /dev/tty
+    echo done >&2
+    echo
+  fi
+}
+
+say()
+{
+  if [ "x${2}" = "xpriv" ]; then
+    echo "/priv ${3} ${1}"
+    return
+  fi
+  saidlast="$1"
+  echo "$1"
+  echo "relaybot: $1" >> "${DATADIR}/relaybot.chat"
+}
+
+protocol_init()
+{
+  true # Nothing to initialize
+}
diff --git a/relaybot.conf b/relaybot.conf
new file mode 100755
index 0000000..b9739df
--- /dev/null
+++ b/relaybot.conf
@@ -0,0 +1,30 @@
+#!/bin/sh
+# Sample configuration file
+
+irclog='/home/user/irclogs/tc_client_irchack/#relaybotchannel.log'
+protocol='tc_client'
+nickname='relaybot'
+channel='relaybotchannel'
+password='channelpassword'
+cookiejar='cookies' # For fewer captchas
+textcolor=12 # Purple
+DATADIR='/home/user/relaybot_data'
+countdownto='Apr 25 23:00:00 CEST 2025' # when relaybot turns 10
+gitreport_repos='https://ion.nu/git/relaybot.git'
+modules='
+tc_versioncheck
+conversation
+reminder
+conversion
+neverhaveiever
+time
+topic
+relay
+camface
+imgcam
+magnifier
+activitystats
+countdown
+gitreport
+videoutils
+whoismycrush'
diff --git a/relaybot.sh b/relaybot.sh
new file mode 100755
index 0000000..0bcffc7
--- /dev/null
+++ b/relaybot.sh
@@ -0,0 +1,143 @@
+#!/bin/sh
+# TODO: accept path to configuration file as $1, make PIPE an environment variable instead?
+# Or parse arguments normally, -r for reload, -b for the bot part, non-flag for configuration file
+EXECDIR="`realpath "$0" | xargs dirname`" # Home of this script and all the modules + static data
+DATADIR="." # Home of things like messages to relay
+. ./relaybot.conf
+. "${EXECDIR}/protocols/${protocol}.sh"
+if [ "x${1}" != "xPIPE" ]; then
+  (
+    echo '[Net'
+    echo "$protocol_cmd"
+    echo ']' '[Bot' "$0" PIPE ']' '{Net:1>Bot:0}' '{Bot:1>Net:0}'
+  ) | xargs pipexec -k
+fi
+echo '(Re-)loaded' >&2
+chatcommands=''
+addcmd()
+{
+  chatcommands="${chatcommands}
+${1}=${2}"
+}
+delcmd()
+{
+  chatcommands="`echo "$chatcommands" | sed -e "/^${1}=${2}\$/d"`"
+}
+eventhandlers=''
+addeventhandler()
+{
+# echo "Adding event handler '${2}' for '${1}'" >&2
+  eventhandlers="${eventhandlers}
+${1}=${2}"
+}
+deleventhandler()
+{
+  eventhandlers="`echo "$eventhandlers" | sed -e "/^${1}=${2}\$/d"`"
+}
+handleevent()
+{
+# echo "Handling event '${1}'" >&2
+  for x in `echo "$eventhandlers" | sed -n -e "s/^${1}=//p"`; do
+# echo "Handler: ${x}" >&2
+    "$x" "$2" "$3" "$4"
+  done
+}
+protocol_init
+for module in ${modules}; do . "${EXECDIR}/modules/${module}.sh"; done
+# TODO: move this into a module for cam features to depend on?
+cammode=''
+toggle_cammode()
+{
+  if [ "x$cammode" = "x${1}" ]; then
+    cammode=''
+    pkill -x camutil
+    echo '/camdown'
+    return 1
+  else
+    if [ "x$cammode" != "x" ]; then
+      pkill -x camutil
+    else
+      echo '/camup'
+    fi
+    cammode="$1"
+    return 0
+  fi
+}
+if [ "x${2}" = "xreload" ]; then
+  cammode="`cat relaybot.reload.cammode`"
+  handleevent reload_after
+  rm -f relaybot.reload.*
+fi
+saidlast=''
+sayx()
+{
+  read x
+  say "$x"
+}
+sayonce()
+{
+  if [ "x${saidlast}" != "x${1}" ]; then say "$1"; saidlast="$1"; fi
+}
+decimals()
+{
+  value="$1"
+  decimalcount="$2"
+  decimalpoints=''
+  for x in `seq "$decimalcount"`; do
+    value="0${value}"
+    decimalpoints="${decimalpoints}[0-9]"
+  done
+  echo "$value" | sed -e "s/${decimalpoints}\$/.&/;s/^0*//;s/0*\$//;s/\\.\$//"
+}
+countdecimals()
+{
+  echo -n "$1" | sed -n -e 's/.*\.//p' | wc -c
+}
+tenpow()
+{
+  echo -n "$1"
+  seq "$2" | sed -e 's/.*/0/' | tr -d '\n'
+}
+
+commandhandler()
+{
+  chatmsg="$1"
+  from="$2"
+  potentialcmd="`echo "$chatmsg" | sed -e 's/ .*//'`"
+  for commandbinding in ${chatcommands}; do
+    chatcommand="`echo "$commandbinding" | sed -e 's/=.*//'`"
+    if [ "x${potentialcmd}" = "x${chatcommand}" ]; then
+      commandcallback="`echo "$commandbinding" | sed -e 's/.*=//'`"
+      "$commandcallback" "$from" "`echo "$chatmsg" | sed -e 's/^[^ ]*//; s/^ //'`"
+    fi
+  done
+}
+addeventhandler chatmsg commandhandler
+
+do_reload()
+{
+  # TODO: cleaner implementation of restriction/authorization so this can be called as a command
+  # TODO: callbacks for modules to save and restore state cleanly (events done, need handlers)
+  if ! sh -n "$0"; then echo "/priv ${from} Syntax error, won't reload"; continue; fi
+  echo "$cammode" > relaybot.reload.cammode
+  handleevent reload_before
+  exec "$0" PIPE reload
+}
+
+logchat()
+{
+  msg="$1"
+  from="$2"
+  echo "${from}: ${msg}" >> "${DATADIR}/relaybot.chat"
+}
+addeventhandler chatmsg logchat
+
+while true; do
+  line=''
+  if ! read -r line; then
+    if [ ! -e relaybot.say ]; then break; fi
+  fi
+  if [ "x${line}" = "x" ]; then continue; fi
+  echo "Got line '${line}'" >> "${DATADIR}/relaybot.log" # TODO: clean up references to this obsolete thing (camspammers module)
+  handleline "$line"
+done
diff --git a/relaybotface/relaybot.xcf b/relaybotface/relaybot.xcf
new file mode 100644
index 0000000..f822850
Binary files /dev/null and b/relaybotface/relaybot.xcf differ
diff --git a/relaybotface/relaybot1.png b/relaybotface/relaybot1.png
new file mode 100644
index 0000000..9c4194e
Binary files /dev/null and b/relaybotface/relaybot1.png differ
diff --git a/relaybotface/relaybot2.png b/relaybotface/relaybot2.png
new file mode 100644
index 0000000..039d0f5
Binary files /dev/null and b/relaybotface/relaybot2.png differ
diff --git a/relaybotface/relaybot3.png b/relaybotface/relaybot3.png
new file mode 100644
index 0000000..9f98852
Binary files /dev/null and b/relaybotface/relaybot3.png differ
diff --git a/relaybotface/relaybot4.png b/relaybotface/relaybot4.png
new file mode 100644
index 0000000..b3acc41
Binary files /dev/null and b/relaybotface/relaybot4.png differ