[Из песочницы] Каркас для Telegram-бота на Erlang
Начало
Будем играть в «больших дяденек» — берем библиотеки от Nine Nines — cowboy в качестве веб-сервера, lager для логгинга. Логгинг здесь как таковой, не особо и нужен —, но надо было научиться использовать lager, поэтому он тут. Была мысль использовать и gun, но, поразмыслив, я все-таки отказался от него в пользу httpc.
Собственно, Erlybot представляет собой классическое OTP-application — супервизор и два gen_server-а в качестве воркеров. Почему именно два — объясню ниже.
Все крутилось на простеньком VPS без доменного имени. SSL-сертификат самоподписанный. Cowboy спрятан за nginx-ом, nginx слушает 443 порт, и проксирует запросы на localhost:7770, при помощи несложного location:
server {
listen 443 ssl;
server_name ;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_certificate /etc/nginx/ssl/mypub.pem;
ssl_certificate_key /etc/nginx/ssl/mypriv.key;
location / {
proxy_pass http://127.0.0.1:7770;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
}
location / {
return 404;
}
}
Соответственно, вебхук настроен на URL, содержащий IP-адрес, порт и токен бота (здесь для URL берем только часть токена после двоеточия, без цифр):
curl -F "url=https:///" https://api.telegram.org/bot/setWebhook
Управление сборкой, статический анализ — стандартные rebar3, dialyzer (опция warn_missing_spec включена).
Cowboy в проекте использован 2-й, для этого в rebar.config нужно явно прописать, из какого бранча брать библиотеку:
{cowboy, ".*",{git, "https://github.com/ninenines/cowboy.git", {branch, "master"}}}
Конвертор JSON — jsx.
Сразу скажу, полных листингов я здесь не привожу, в конце статьи — ссылка на GitHub.
Как всё работает
Erlybot_app запускает через ensure_all_started все зависимости, а затем супервизора erlybot_sup. Тот, в свою очередь, поднимает двух воркеров — erlybot_parser и erlybot_processor. Erlybot_processor делает следующее: сначала он инициализирует Cowboy — происходит компиляция ковбойского пути, он по понятным причинам всего один, затем заводится веб-сервер на localhost:7770. Далее создается именованная ETS-таблица с именем usertable — там мы будем хранить пользовательские сессии.
-spec init_cowboy() -> ok.
init_cowboy() ->
{ok, Token} = application:get_env(?APPLICATION, token),
{ok, IP} = application:get_env(?APPLICATION, ip),
{ok, Port} = application:get_env(?APPLICATION, port),
BotPath = binary:list_to_bin("/" ++ lists:last(string:tokens(Token, ":"))),
Dispatch = cowboy_router:compile([
{'_', [{BotPath, erlybot_cowboy_handler, #{}}]}]),
{ok, _} = cowboy:start_clear(main_bot_listener, 100,
[
{port, Port},
{ip, IP}
],
#{env => #{dispatch => Dispatch}}),
..
lager:info("Erlybot: Cowboy initialization complete."),
ok.
%% @doc creates new ETS for user states
-spec init_usertable() -> atom().
init_usertable() ->
ets:new(usertable, [named_table, public, set]).
Все это делается хитрым трюком, который называется «отложенная инициализация». Суть в том, что при запуске gen_server при помощи start_link, в итоге вызывается коллбэк init/1, а он блокирует родительский процесс, поэтому что-то тяжелое в init/1 лучше не запускать, а сделать вот так:
init(_Args) ->
lager:info("Erlybot: starting messages processor..."),
self() ! do_init,
{ok, []}.
То есть послали сами себе сообщеньку do_init, и спокойно вернули управление родителю. Сообщенька ловится в handle_info, где и происходит основной рок-н-ролл:
handle_info(do_init, _State) ->
init_cowboy(),
init_usertable(),
{noreply, []}.
Да, я отлично понимаю, что скомпилировать путь Cowboy, запустить сервер и создать таблицу — это совсем недолго и несложно, но мы же стараемся, чтобы все было как «у больших», да и трюк отличный.
С этой минуты Ковбой ожидает поступления HTTP-запросов, которые он передаст на обработку как настроено — в erlybot_cowboy_handler.
Хэндлер этот представляет собой обычный процесс. Он запускается, обрабатывает запрос единственным коллбэком init/2 и тихо умирает.
init(Req0, State) ->
{ok, Data, _} = cowboy_req:read_body(Req0),
Req = cowboy_req:reply(200, #{},"" , Req0),
erlybot_parser:parse_message(Data),
{ok, Req, State}.
Здесь мы передаем пришедшие данные асинхронно в процесс-парсер. Асинхронно потому, что нам надо поскорее ответить Телеграму 200 ОК, а то он еще чего подумает, что сообщение не получено, и начнет его повторять, а это нам не надо.
Парсер
Парсер, на самом деле, претерпел наибольшее количество изменений за всю историю проекта. Ключевых вопросов было в общем два — «Как сделать так, чтобы парсер не падал от отсутствия необходимых полей?» (в спецификации JSON Telegram практически все необходимые мне поля за исключением id, указаны как optional) и «Как добиться этого не используя лютые вложенные if и case, ибо это совсем не Erlang-way?»
Какое-то время мне казалось очень удачной идеей выделить парсер в отдельный процесс и просто let it crash. Я так и сделал, и стал спамить в бота стикерами с котиками. Парсер падал, перезапускался, потом достигался лимит на перезапуски и падало, соответственно, все приложение. Именно поэтому процесса два — это суровое наследие творческого поиска.
В итоге, после нескольких проб, ошибок и рефакторингов, мне удалось родить решение непадающего парсера — для этого пришлось написать небольшую оберточку над proplists: get_value, и анализировать получившийся кортеж на наличие undefined:
-spec get_value (term(), undefined) -> undefined;
(binary(), [term()]) -> term().
get_value(_, undefined) -> undefined;
get_value(Key, Data) -> proplists:get_value(Key, Data).
UpdateBody = jsx:decode(Msg),
Message = get_value(<<"message">>, UpdateBody),
UserId = get_value(<<"id">>, get_value(<<"from">>, Message)),
Username = get_value(<<"username">>, get_value(<<"from">>, Message)),
ChatId = get_value(<<"id">>, get_value(<<"chat">>, Message)),
MessageText = get_value(<<"text">>, Message),
Reply = {UserId, Username, ChatId, MessageText},
validate_message(lists:member(undefined, tuple_to_list(Reply)), Reply),
[… some code omitted…]
validate_message(false, {UserId, Username, ChatId, MessageText}) ->
erlybot_processor:process_message({UserId, binary:bin_to_list(Username), ChatId, binary:bin_to_list(MessageText)});
validate_message(true, _) ->
lager:info("Erlybot parser error!"), ok.
Таким образом, если proplists: get_value отдал undefined, крэша не произойдет, все значения так или иначе лягут в кортеж, который только при условии отсутствия в нем undefined будет отправлен функцией validate_message в erlybot_processor.
Процессор
Процессор заботится о нескольких вещах. Первое, это хранение состояния пользователя в ETS-таблице. Id отправителя сообщения проверяется по таблице, если его там нет, то он туда заносится со статусом unauthorized. Далее ему отправляется предложение ввести пароль, и пользователь переводится в статус challenge_sent. После успешного ответа с паролем пользователю выставляется статус authorized и его команды отныне могут поступать на хэндлеры команд, вплоть до команды /exit, которая разлогинит его из сессии с ботом:
Userstate = check_user_state(UserId),
case Userstate of
"unauthorized" ->
reply_to_unauthorized(UserId, Username, ChatId, normalize_command(MessageText));
"challenge_sent" ->
wait_for_password(UserId, Username, ChatId, normalize_command(MessageText));
"authorized" ->
handle_command(UserId, Username, ChatId, normalize_command(MessageText))
end,
Хэндлеры команд устроены просто — это обыкновенная функция, в clause которой происходит паттерн-матчинг конкретной команды:
handle_command(_UserId, _Username, ChatId, "/help") ->
send_reply(ChatId, "No real goals, just for fun.");
Таким образом, добавить нужный функционал боту очень просто — пишем обработчик нужной нам команды. Всё. Разумеется, в обработчике может быть всё, что угодно.
Что можно улучшить
Нет тестов, да. Совсем. Это плохо, но их нет. Возможно, использовать gen_server так, как это сделал я, тоже не вполне корректно, состояние-то хранится в ETS, а не в State. Да, разумеется, сессии умрут, если бота перезапустить. Наверное, можно вылечить при помощи DETS.
И да, обещанная ссылка на Github: github.com/Developer3971/Erlybot
Для тестирования токен бота необходимо добавить в erlybot.app.src.
На этом всё, спасибо за внимание. Буду рад адекватной критике.