The goal of this project is to provide a way to alive Telegram bots by scripting that even simpler than CGI scripts. All you need to write is a script (on any language) that is complying with extremely simple contract.
This bot engine has proven itself in alerting, system monitoring and managing tasks.
It also good for prototyping and fast proofing ideas.
The engine is not perfect. Some error messages could be more informative. Somewhere you can face a lug of documentation and the need to appeal to source code.
However, the engine has already proven itself in production and prototyping.
It served bots for huge conferences, meetings and events. It has helped customers and provided control functionality for crew.
The engine successfully drives several monitoring and alerting bots.
It seems, API of this bot engines is quite stable and won't change dramatically in the near future.
You impalement all your business logic in your scripts. You are totally free to use all Telegram API abilities.
cnbot
interact with scripts using (i) stdout
stream, (ii) arguments and (iii) environment variables.
The engine automatically recognize multimedia and images. It cares about concurrency and races.
It also provides simple API for asynchronous messaging from cron
s and such things.
It manages tasks (subprocesses), controls timeouts, sends signals and provides abilities to run long-running tasks like long image/video conversions and/or downloading.
One instance of engine is able to manage several different bots.
All you need is bot token (instructions).
docker build -t cnbot:latest https://raw.githubusercontent.com/michurin/cnbot/master/demo/Dockerfile
docker run -it --rm --name cnbot -e TB_TOKEN=4839574812:AAFD39kkdpWt3ywyRZergyOLMaJhac60qc cnbot:latest
First things first, you need to create bot and get it's token. It is free, just follow instructions.
You need Telegram API token, golang
and standard system commands echo
and true
.
go install github.com/michurin/cnbot/cmd/...@latest
tb_token='4839574812:AAFD39kkdpWt3ywyRZergyOLMaJhac60qc' tb_script=echo tb_long_running_script=true tb_ctrl_addr=:9999 cnbot
or without installation:
git clone https://github.com/michurin/cnbot
cd cnbot
tb_token='4839574812:AAFD39kkdpWt3ywyRZergyOLMaJhac60qc' tb_script=echo tb_long_running_script=true tb_ctrl_addr=:9999 go run ./cmd/...
You are free to keep your token in file and use syntax like this to refer to file: tb_token=@filename
Don't worry, we will use configuration file further. The engine is able to use both files and direct environment variables.
tb_YOURBOTNAME_token
is a token your are given:digits:long_string
tb_YOURBOTNAME_script
is a command to run. We use the standard system commandecho
. I can be located elsewhere in your system. Try to saywhereis echo
to fine ittb_YOURBOTNAME_long_running_script
let it be the same command. We consider it latertb_YOURBOTNAME_ctrl_addr
we consider it soon
Run this command with correct variables and try to say something to you bot. You will be echoed by it.
You may as well put your configuration into env-file. The format of file is literally the same as systemd
use.
So you are able to load it in systemd
files as well. For example:
# let's name it config.env
tb_token='TOKEN'
tb_script=/usr/bin/echo
tb_long_running_script=/usr/bin/echo
tb_ctrl_addr=:9999
Now just start bot like this:
cnbot config.env
Let's look at the script, that shows its arguments and environment variables:
#!/bin/sh
echo "Args: $@"
echo "Environment:"
env | grep tg_ | sort
Name it mybot.sh
and mention it in configuration variable tb_script=./mybot.sh
. Restart the bot and say to it Hello bot!
.
It will reply to you something like that:
╭─────────────────────────────────────────╮
│ Args: hello bot! │
│ Environment: │
│ tg_message_chat_first_name=Alexey │
│ tg_message_chat_id=153333328 │
│ tg_message_chat_last_name=Michurin │
│ tg_message_chat_type=private │
│ tg_message_chat_username=AlexeyMichurin │
│ tg_message_date=1717171717 │
│ tg_message_from_first_name=Alexey │
│ tg_message_from_id=153333328 │
│ tg_message_from_is_bot=false │
│ tg_message_from_language_code=en │
│ tg_message_from_last_name=Michurin │
│ tg_message_from_username=AlexeyMichurin │
│ tg_message_message_id=4554 │
│ tg_message_text=Hello bot! │
│ tg_update_id=513333387 │
│ tg_x_build=development (devel) │
│ tg_x_ctrl_addr=:9999 │
╰─────────────────────────────────────────╯
You can see that your message has been put to arguments in convenient normalized form, and you have a bunch of useful variables
with additional information. We will consider them further. At this point we just figure out then our user id is tg_message_from_id=153333328
.
We will use this information very soon.
You are free to send messages from anywhere: from cron jobs, from init scripts... Try it just from command line:
curl -qs http://localhost:9999/?to=153333328 -d 'OK!'
If you bot is running, you will obtain the message OK!
in you Telegram client.
╭──────────╮
│ OK! │
╰──────────╯
Do not forget to use your user id from previous section.
It makes sense what variable tb_ctrl_addr=:9999
is for. It defines a control interface for external interactions with bot engine.
You can call whatever method you want. Full list of methods can be found in the official Telegram bot API documentation.
For example, you can obtain information about your bot (using method getMe):
curl -qs http://localhost:9999/method/getMe | jq
The response will look like this:
{
"ok": true,
"result": {
"id": 223333386,
"is_bot": true,
"first_name": "Your Bot",
"username": "your_bot",
"can_join_groups": true,
"can_read_all_group_messages": false,
"supports_inline_queries": false,
"can_connect_to_business": false
}
}
It enables you to send extended messages. For example, you can send a message with buttons (method sendMessage):
curl -qs http://localhost:9999/sendMessage -F chat_id=153333328 -F text='Select search engine' -F reply_markup='{"inline_keyboard":[[{"text":"Google","url":"https://www.google.com/"}, {"text":"DuckDuckGo","url":"https://duckduckgo.com/"}]]}'
You will receive message with two clickable buttons:
╭───────────────────────────╮
│ Select search engine │
├─────────────┬─────────────┤
│ Google ↗│ DuckDuckGo ↗│
╰─────────────┴─────────────╯
Do not forget to change user_id
.
Note
You can use any prefixes in URLs.
URLs http://localhost:9999/sendMessage
and http://localhost:9999/ANITHING/sendMessage
are equal.
It allows you to put engine's API behind prefix.
Bot recognizes media type of input. It will send text:
echo 'Hello!' | curl -qs http://localhost:9999/?to=153333328 --data-binary '@-'
However, it will send you image:
curl -qs https://github.githubassets.com/favicons/favicon.png | curl -qs http://localhost:9999/?to=153333328 --data-binary '@-'
Important
Please use the --data-binary
option for binary data. Option -d
corrupts EOLs.
(echo '%!PRE'; echo 'Hello!') | curl -qs http://localhost:9999/?to=153333328 --data-binary '@-'
Let's extend our mybot.sh
like that (it is literally demo script you can run by docker compose):
#!/bin/bash
LOG=logs/log.log # /dev/null
FROM="$tg_message_from_id"
API() {
API_STDOUT "$@" >>"$LOG"
}
API_STDOUT() {
url="http://localhost$tg_x_ctrl_addr/$1"
shift
echo "====== curl $url $@" >>"$LOG"
curl -qs "$url" "$@" 2>>"$LOG"
echo >>"$LOG"
echo >>"$LOG"
}
(
echo '==================='
echo "Args: $@"
echo "Environment:"
env | grep tg_ | sort
echo '...................'
) >>"$LOG"
case "$1" in
debug)
echo '%!PRE'
echo "Args: $@"
echo "Environment:"
env | grep tg_ | sort
echo "FROM=$FROM"
echo "LOG=$LOG"
;;
about)
echo '%!PRE'
API_STDOUT getMe | jq
;;
two)
API "?to=$FROM" -d 'OK ONE!'
API "?to=$FROM" -d 'OK TWO!!'
echo 'OK NATIVE'
;;
buttons)
bGoogle='{"text":"Google","url":"https://www.google.com/"}'
bDuck='{"text":"DuckDuckGo","url":"https://duckduckgo.com/"}'
API sendMessage \
-F chat_id=$FROM \
-F text='Select search engine' \
-F reply_markup='{"inline_keyboard":[['"$bGoogle,$bDuck"']]}'
;;
image)
curl -qs https://github.com/fluidicon.png
;;
invert)
wm=0
fid=''
for x in $tg_message_photo # finding the biggest image but ignoring too big ones
do
v=${x}_file_size
s=${!v} # trick: getting variable name from variable; we need bash for it
if test $s -gt 102400; then continue; fi # skipping too big files
v=${x}_width
w=${!v}
v=${x}_file_id
f=${!v}
if test $w -gt $wm; then wm=$w; fid=$f; fi
done
if test -n "$fid"
then
API_STDOUT '' -G --data-urlencode "file_id=$fid" -o - | mogrify -flip -flop -format png -
else
echo "attache not found (maybe it was skipped due to enormous size)"
fi
;;
reaction)
API setMessageReaction \
-F chat_id=$FROM \
-F message_id=$tg_message_message_id \
-F reaction='[{"type":"emoji","emoji":"👾"}]'
echo 'Bot reacted to your message☝️'
;;
madrid)
API sendLocation \
-F chat_id="$FROM" \
-F latitude='40.423467' \
-F longitude='-3.712184'
;;
menu)
mShowEnv='{"text":"show environment","callback_data":"menu-debug"}'
mShowNotification='{"text":"show notification","callback_data":"menu-notification"}'
mShowAlert='{"text":"show alert","callback_data":"menu-alert"}'
mLikeIt='{"text":"like it","callback_data":"menu-like"}'
mUnlikeIt='{"text":"unlike it","callback_data":"menu-unlike"}'
mDelete='{"text":"delete this message","callback_data":"menu-delete"}'
mLayout="[[$mShowEnv],[$mShowAlert,$mShowNotification],[$mLikeIt,$mUnlikeIt],[$mDelete]]"
API sendMessage \
-F chat_id=$FROM \
-F text='Actions' \
-F reply_markup='{"inline_keyboard":'"$mLayout"'}'
;;
run)
API "?to=$FROM&a=reactions&a=$tg_message_message_id" -X RUN
echo "I'll show you long run"
;;
edit)
API "?to=$FROM&a=editing" -X RUN
;;
id)
echo '%!PRE'
id 2>&1
;;
caps)
echo '%!PRE'
getpcaps --verbose --iab $$
;;
hostname)
echo '%!PRE'
hostname 2>&1
;;
help)
API sendMessage -F chat_id=$FROM -F parse_mode=Markdown -F text='
Known commands:
- `debug` — show args, environment and vars
- `about` — reslut of getMe
- `two` — one request, two responses
- `buttons` — message with buttons
- `image` — show image
- `invert` (as capture to image) — returns flipped flopped image
- `reaction` — show reaction
- `madrid` — show location
- `menu` — scripted buttons
- `run` — long-run example (long sequence of reactions)
- `edit` — long-run example (editing)
- `id` — check user who script runs from
- `caps` — check current capabilities (`getpcaps $$`)
- `hostname` — check hostname where script runs
- `help` — show this message
- `privacy` — mandatory privacy information
- `start` — just very first greeting message
'
;;
start)
API sendMessage -F chat_id=$FROM -F parse_mode=Markdown -F text='
Hi there!👋
It is demo bot to show an example of usage [cnbot](https://github.com/michurin/cnbot) bot engine.
You can use `help` command to see all available commands.'
;;
privacy) # https://telegram.org/tos/bot-developers#4-privacy
echo "This bot does not collect or share any personal information."
;;
*)
if test -n "$tg_callback_query_data"
then
case "$1" in
menu-debug)
API answerCallbackQuery -F callback_query_id="$tg_callback_query_id"
echo '%!PRE'
echo "Environment:"
env | grep tg_ | sort
;;
menu-like)
API answerCallbackQuery -F callback_query_id="$tg_callback_query_id" -F "text=Like it"
API setMessageReaction -F chat_id=$tg_callback_query_message_chat_id \
-F message_id=$tg_callback_query_message_message_id \
-F reaction='[{"type":"emoji","emoji":"👾"}]'
;;
menu-unlike)
API answerCallbackQuery -F callback_query_id="$tg_callback_query_id" -F "text=Don't like it"
API setMessageReaction -F chat_id=$tg_callback_query_message_chat_id \
-F message_id=$tg_callback_query_message_message_id \
-F reaction='[]'
;;
menu-delete)
API answerCallbackQuery -F callback_query_id="$tg_callback_query_id"
API deleteMessage -F chat_id=$tg_callback_query_message_chat_id \
-F message_id=$tg_callback_query_message_message_id
;;
menu-notification)
API answerCallbackQuery -F callback_query_id="$tg_callback_query_id" -F text="Notification text (200 chars maximum)"
;;
menu-alert)
API answerCallbackQuery -F callback_query_id="$tg_callback_query_id" -F text="Notification text shown as alert" -F show_alert=true
;;
esac
else
API sendMessage -F chat_id=$FROM -F text='Invalid command. Say `help`.' -F parse_mode=Markdown
fi
;;
esac
Let's add script for long-running tasks mybot_long.sh
(it's demo script):
#!/bin/sh
LOG=logs/log_long.log # /dev/null
FROM="$tg_x_to"
API() {
API_STDOUT "$@" >>"$LOG"
}
API_STDOUT() {
url="http://localhost$tg_x_ctrl_addr/$1"
shift
echo "====== curl $url $@" >>"$LOG"
curl -qs "$url" "$@" 2>>"$LOG"
echo >>"$LOG"
echo >>"$LOG"
}
case "$1" in
reactions)
MESSAGE_ID="$2"
for e in "👾" "🤔" "😎"
do
API setMessageReaction -F chat_id=$FROM -F message_id=$MESSAGE_ID -F reaction='[{"type":"emoji","emoji":"'"$e"'"}]'
sleep 1
done
API setMessageReaction -F chat_id=$FROM -F message_id=$MESSAGE_ID -F reaction='[]'
;;
editing)
MESSAGE_ID="$(API_STDOUT sendMessage -F chat_id=$FROM -F text='Starting...' | jq .result.message_id)"
if test -n "$MESSAGE_ID"
then
for i in 2 4 6 8
do
sleep 1
API editMessageText -F chat_id=$FROM -F message_id="$MESSAGE_ID" -F text="Doing... ${i}0% complete..."
done
sleep 1
API editMessageText -F chat_id=$FROM -F message_id="$MESSAGE_ID" -F text='Done.'
else
echo "cannot obtain message id"
fi
;;
*)
echo 'invalid mode'
;;
esac
Restart bot with this configuration (mybot.env
):
tb_token = 'TOKEN'
tb_script = ./mybot.sh
tb_long_running_script = ./mybot_long.sh
tb_ctrl_addr = :9999
Like that:
# if you install it
cnbot mybot.env
# if you start it without installing, just from sources
go run ./cmd/cnbot/... mybot.env
Note
Please note when you are modifying script, all changes takes effect immediately. You don't need to restart the bot engine. You have to restart the bot engine if you want to change its environment variables only.
Try to talk to your bot. Now it recognizes commands and shows you many different possibilities.
Let me explain what is happening in this examples step by step.
You wouldn't be mistaken for thinking that this script is slightly awkward. It is written that way to be more splittable. We will consider better structure further.
Let's briefly touch on two helpers functions we are using in this scripts.
Both of them helps you to call bot engine API (not Telegram API, but bot engine).
API_STDOUT()
takes it's first argument as a tail of API URL and consider all the rest of arguments
as curl
's arguments. For example, API_STDOUT getMe
means literally
curl -qs "http://localhost$tg_x_ctrl_addr/getMe"
.
API_STDOUT()
throws it's output to stdout
, API()
doesn't though.
API "?to=$FROM" -d 'OK'
means curl -qs "http://localhost$tg_x_ctrl_addr/?to=$FROM -d 'OK'
Both of them logs their output to $LOG
file.
This script recognizes several commands. We already consider the following commands:
debug
— it's our first scriptabout
— just callgetMe
API method. You can also see how we useAPI_STDOUT
helpertwo
— shows how to send asynchronous message from script. We saw how to do it from command line before. You can also see how we useAPI
helperbuttons
— message with buttons as we saw beforeimage
— shows how to send image. Just throw it tostdout
and bot engine will recognize that it is image and send it in proper way
All the rest commands we will consider further.
You are already seeing the bot can be configured by configuration file and directory by environment variable.
Environment has higher priority.
All variables have the same structure: tb_{MEANING}
or tb_{BOTNAME}_{MEANING}
if you need to start several bots.
To configure bot x
and y
, you need to pass this variable to cnbot
:
tb_x_token='TOKEN_X'
tb_x_script=/usr/bin/echo
tb_x_long_running_script=/usr/bin/echo
tb_x_ctrl_addr=:9999
tb_y_token='TOKEN_Y'
tb_y_script=/usr/bin/echo
tb_y_long_running_script=/usr/bin/echo
tb_y_ctrl_addr=:9998
Bot engine runs your scripts with command line arguments. It can be useful for small bots.
Arguments prepared from messages, captions and callback's data. Strings are cast to lower-case, cleaned of control characters and split by white spaces.
For example the message $Hello world!
will be represented as two arguments hello
and world
.
Following characters will be removed from the arguments: !"#$&'()*+-./:;<=>?@[\]`|
.
Bot engine converts every JSON-update to flat set of environment variables this way:
{
"ok": true,
"result": [
{
"message": {
"caption": "Hi!",
"chat": {
"first_name": "Alexey",
"id": 150000000,
"last_name": "Michurin",
"type": "private",
"username": "AlexeyMichurin"
},
"date": 1600000000,
"from": {
"first_name": "Alexey",
"id": 150000000,
"is_bot": false,
"language_code": "en",
"last_name": "Michurin",
"username": "AlexeyMichurin"
},
"message_id": 2222,
"photo": [
{
"file_id": "aaa0",
"file_size": 2444,
"file_unique_id": "id0",
"height": 90,
"width": 90
},
{
"file_id": "aaa1",
"file_size": 4888,
"file_unique_id": "id1",
"height": 128,
"width": 128
}
]
},
"update_id": 500000000
}
]
}
turns to the following environment variables:
tg_message_caption=Hi!
tg_message_chat_first_name=Alexey
tg_message_chat_id=150000000
tg_message_chat_last_name=Michurin
tg_message_chat_type=private
tg_message_chat_username=AlexeyMichurin
tg_message_date=1600000000
tg_message_from_first_name=Alexey
tg_message_from_id=150000000
tg_message_from_is_bot=false
tg_message_from_language_code=en
tg_message_from_last_name=Michurin
tg_message_from_username=AlexeyMichurin
tg_message_message_id=2222
tg_message_photo=tg_message_photo_0 tg_message_photo_1
tg_message_photo_0_file_id=aaa0
tg_message_photo_0_file_size=2444
tg_message_photo_0_file_unique_id=id0
tg_message_photo_0_height=90
tg_message_photo_0_width=90
tg_message_photo_1_file_id=aaa1
tg_message_photo_1_file_size=4888
tg_message_photo_1_file_unique_id=id1
tg_message_photo_1_height=128
tg_message_photo_1_width=128
tg_update_id=500000000
Engine provides the following additional variables:
tg_x_build
tg_x_ctrl_addr
tg_x_to
(long-running scripts only)
Note
Beware. Bot engine does NOT convey its environment to child scripts.
Bot engine does not transfer environment to child scripts. It is conscious decision cause it helps to
make script's behavior more predictable and reproducible. Variables like $PATH
, $LANG
, $LS_ALL
can
change behavior of many commands and functions. It can lead to hard to debug behavior.
If you need to have some environment variables, just set them in you script explicitly.
Current working directory is directory, where the script is located in.
Bot engine generates all tasks of the same bot run strictly concurrently. It means you can use shared resources like files without any doubts. And your tasks have to finish in short time.
Bot engine will send SIGTERM
to task after 10 seconds, and SIGKILL
after next 10 seconds.
Long-running tasks can be executed simultaneously though.
They also have timeouts: 10 minutes.
To upload something (image, video, audio, etc) you can just throw it stdout of your script.
If you need to add capture or group multimedia files in one message, you need to call
Telegram API. As usual, you don't need to care about secrets etc just use cnbot
control handler as we did above.
To download attachments (file, video, audio, photos, etc) you have to use file_id
from message and
just perform GET
request to control handler with file_id=...
in query string. See action invert
in example above.
# --- global variables
...
# --- helper variables
...
# --- must have commands
case $1 in
start)
echo "Hello message"
exit
;;
privacy) # https://telegram.org/tos/bot-developers#4-privacy
echo "This bot does not collect or share any personal information."
exit
esac
# --- whitelist checks for user_id
# it is just example:
# - allows.list have contains strings line "_${ID}_" (it makes you able to write comments and things like that)
# - we consider messages and callbacks
if grep "_${tg_message_from_id}${tg_callback_query_from_id}_" allows.list 2>&1 >/dev/null
then
: # pass this user, you may want to log it
else
echo 'You are not allowd'
exit
fi
# --- process text messages
if [ -n "$tg_message_text" ]
then
case "$1" in
...
esac
exit
fi
# --- process images
if [ -n "$tg_message_photo" ]
then
case "$1" in
...
esac
exit
fi
# --- process voices (for instance)
if [ -n "$tg_message_voice_file_id" ]
then
...
exit # don't forget to exit
fi
# --- process callbacks
if [ -n "$tg_callback_query_data" ]
then
...
exit
fi
# process... whatever you want
if ...
...
exit
fi
Of course, it is good idea to split script, using source file.sh
instruction.
And you are still able to use other languages and approaches for sure.
Caution
Just don't forget to be careful, keep in mind that anybody in internet can send anything to your bot.
Keep reading. We will consider how to protect your bot.
To debug your scripts, you can use this wrapper. Tune $CMD
, and enjoy
full logging: arguments, environment, out and err streams, exit code.
#!/bin/sh
# put your command here
CMD=./mybot.py
# tune naming for your taste
base="logs/$(date +%s-)_${$}_"
ext='.log'
n=0
for a in "$@"
do
echo "$a" >"${base}arg_${n}${ext}"
n="$(($n+1))"
done
env | sort >"${base}env${ext}"
set -o pipefail
"$CMD" "$@" 2>"${base}err${ext}" | tee "${base}out${ext}"
code="$?"
echo "$code" >"${base}status${ext}"
exit "$code"
./build.sh
sudo install ./cnbot /usr/bin
The process itself does not try to be immortal. It dies on fatal issues that can not be solved by process itself. Like network problems.
It is believed that the process will be restart by systemd
or stuff like that according the proper way with timeouts, logging, notifications, alerting.
Systemd unit file example (/etc/systemd/system/cnbot.service
):
[Unit]
Description=Telegram bot (cnbot) service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Restart=always
RestartSec=1
User=nobody
ExecStart=/usr/bin/cnbot /etc/cnbot-config.env
[Install]
WantedBy=multi-user.target
- Some engine API methods are using both POST-body and query parameters. It's against standards. However I haven't invented something more convenient and standard yet.
- Engine API uses non-standard method
RUN
. It allows by standards, however it doesn't seem inevitable. - Engine uses
mime.ExtensionsByType()
to detect extensions for multimedia attachments. This function relies on the system configuration. It's highly recommended to install package likeshared-mime-info
. Pleas keep it in mind when you build production docker images and deploy the engine to remote servers. - Integration tests rely exclusively on
bash
rather than any other shell. Simplesh
won't work in most cases. - Tests also rely on
curl
. - Engine doesn't retry any requests to Telegram API. Looks like issue. However, Telegram API doesn't provide any idempotency keys, and engine doesn't save state between restarts. It seems you have to solve this issue somehow else.
- It hasn't been tested on MS Windows and FreeBSD.
- The engine doesn't support persistent storage. You have to save state if you need by yourself.
- Engine consider kill signals as errors. So it's final log message is error mostly. It is confusing.
- Right now code has a lot of public types, methods and functions. I want this code to be able to be embedded and integrated. However, public API needs to be reviewed.
- Contract must be simple and flexible
- New features of Telegram bot API has to be available instantly without changing of code of the bot
- Bot has to manage subprocesses: timeouts, etc
- Bot has to manage API call: rate limits, etc
- Configuration must be simple
- Code must be testable and has to be covered
- Functionality has to be observable and has to provide ability to add metrics and monitoring by adding middleware without code changing
- The engine tries to be case insensitive considering environment variables. It can lead to false warnings
Run proxy. For example mitmproxy:
mitmdump --flow-detail 4 -p 9001 --mode reverse:https://api.telegram.org
Instruct the bot to use proxy and run it:
export tb_api_origin=http://localhost:9001
./cnbot ... # run bot, it will deal with Telegram API through the proxy and you will see everything
(horrible ASCII art warning)
Telegram infrastructure
^ ............. crons
HTTP : HTTP : scripts
: v any other
.=BOT================================================. asynchronous
| API | HTTP server for |
|..........................| asynchronous messaging |
| polling for : sending | |
| updates : messages <-- send data from req |
`===================================================='
| ^ ^ send stdout |
| | `---------. | request params
| message | send | | as command line positional args
v data | stdout | v
........................ ......................
: run script for every : : long-running :
: message : : script :
:......................: :....................: