Hosting a Telegram Bot on FreeBSD

2020-08-26

Let's host a Telegram bot. Initially, the endeavor looks quite challenging, but it's actually a lot simpler than I thought.

Environment

To start, I'm creating a new jail. This is not a necessary or required step, but I like to separate things that have no business being connected.
Like many FreeBSD users, I manage my jails using iocage:

$ sudo iocage create -r 12.1-RELEASE -n bot

In this case, I am using a bot that simply polls the Telegram servers, so no port redirects are necessary. Letting the jail connect to the internet is enough, which I am using my firewall (pf) for:

/etc/pf.conf (excerpt):
...
nat on $ext_if inet from $jail_if:network to any -> $ext_if
...

Now enter the new jail:

$ sudo iocage console -f bot

The bot is also getting its own user account. While it is in a jail, it's never a good idea to run things as root that don't have to. Additionally, I intend to use the same jail for more bots I will host in the future, with each bot running under a different user.

The Bot

Now, with the environment constructed, we can start setting up the bot. In this case, I am using a simple Markov Chain Bot that consists of a single Python script. After using the official "BotFather" to define a username and obtain an API token, as well as giving that token to the bot (in this case by defining a variable in the Python file), it already works in principle. We can manually execute the script (python3 ./bot.py), open Telegram and send messages — it should already work.

Control

Of course, we're not finished yet. Manually starting the bot every time the server reboots or the jail is restarted is way too cumbersome. It could be launched by a cronjob (e.g. @reboot python3 ./bot.py), however that's still a pretty dirty way to do it. For instance, to turn it off or restart it, we'd need to first use ps -aux to find its PID, then manually kill the process.

What we need instead is an rc.d script. This will let us start and stop the bot just like other services:

$ service bot start
$ service bot stop

Daemonizing

First of all, the bot needs to run as a daemon. That is, once started, it runs in the background instead of blocking the shell it was launched from. If you are hosting a bot that already does this on its own, you can skip this step.
Unfortunately, the bot I am using here does not daemonize itself. Using something like 'python3 ./bot.py > /dev/null &' to suppress the output and force it into the background would (mostly) work, but it's not a very good or clean solution.
Instead, we'll make use of the daemon(8) utility included in FreeBSD:

daemon -u markov -p ./bot.pid python3 ./markov_bot.py > /dev/null 2>> ./bot.log

rc.d

Having figured out how to daemonize the bot, the above command can be wrapped into an rc.d script:

#!/bin/sh

# PROVIDE: markov_bot 1
# REQUIRE: LOGIN

. /etc/rc.subr

name="markov_bot"

procname="/usr/local/bin/python3"2
pidfile="/var/run/${name}.pid"

sig_stop="INT"3

command="/usr/sbin/daemon"
command_args="-u markov -p $pidfile4 $procname /bots/markov/markov_bot.py >> /dev/null 2>> /bots/markov/bot.log"

markov_bot_chdir="/bots/markov"5

rcvar="${name}_enable"6

load_rc_config $name
run_rc_command "$1"
  1. rcorder(8) block:
    • 'PROVIDE: markov_bot' defines the name of the "condition" (or "service") this rc.d script provides
    • 'REQUIRE: LOGIN' defines the conditions it needs to run. In this case, it will be run at the very end of the init sequence
    In my testing, it did not work without the 'PROVIDE' line while 'REQUIRE' was optional.
  2. This variable tells the system what kind of process the pidfile points at. By default, it expects the process in $command (here: /usr/sbin/daemon) and will not use the pidfile if it points somewhere else.
  3. For some reason, this particular bot needs to be stopped by SIGINT, it doesn't save its state if terminated by the default SIGTERM.
  4. Because daemon(8) does not forward SIGINT, the lower case '-p' flag is used (instead of '-P') to create a pidfile for the child process. It will be stopped directly instead of going through the daemon(8) process. daemon(8) itself will stop once the child process exits.
  5. This bot expects a subdirectory "markov/" in its working directory. Setting markov_bot_chdir tells rc.subr to cd there. The name of this variable always needs to be '${name}_chdir'.
  6. The name of the variable that will be used to enable the daemon in /etc/rc.conf

Also have a look at the manpages, especially daemon(8) and rc.subr(8). The latter contains a list of variables that rc.d scripts can set.

Wrapping up

After writing the rc.d script, we can test it:

$ ./bot.sh onestart
    Starting markov_bot.
        
$ ./bot.sh onestatus
    markov_bot is running as pid 12345.
        
$ ./bot.sh onestop
    Stopping markov_bot.
    Waiting for PIDS: 12345.

Having confirmed that it works, the finished script can be moved to /usr/local/etc/rc.d and activated by setting markov_bot_enable="YES" in /etc/rc.conf:

$ mv ./bot.sh /usr/local/etc/rc.d/markov_bot
$ sysrc markov_bot_enable="YES"

It can now be controlled with the service(8) utility:

$ service markov_bot (start|stop|restart|status)

Further Reading